Skip to content

Commit a70d112

Browse files
committed
ingest video
1 parent 16c3e72 commit a70d112

File tree

3 files changed

+851
-309
lines changed

3 files changed

+851
-309
lines changed
Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,359 @@
1+
import os
2+
import sys
3+
import subprocess
4+
import time
5+
import cv2
6+
import platform
7+
from collections import Counter
8+
import json
9+
import re
10+
11+
def get_video_color_info(video_file):
12+
"""
13+
Extract color-related metadata using ffprobe in JSON format:
14+
- color_range
15+
- color_primaries
16+
- color_transfer (transfer characteristics)
17+
- color_space (matrix coefficients)
18+
- mastering_display_metadata (if present)
19+
"""
20+
cmd = [
21+
"ffprobe", "-v", "error", "-show_streams", "-of", "json", video_file
22+
]
23+
try:
24+
output = subprocess.check_output(
25+
cmd,
26+
stderr=subprocess.STDOUT,
27+
text=True,
28+
encoding='utf-8',
29+
errors='replace'
30+
)
31+
except subprocess.CalledProcessError:
32+
return None, None, None, None, None
33+
34+
data = json.loads(output)
35+
streams = data.get("streams", [])
36+
video_stream = None
37+
for s in streams:
38+
if s.get("codec_type") == "video":
39+
video_stream = s
40+
break
41+
42+
if not video_stream:
43+
return None, None, None, None, None
44+
45+
color_range = video_stream.get("color_range")
46+
color_primaries = video_stream.get("color_primaries")
47+
color_transfer = video_stream.get("color_transfer")
48+
color_space = video_stream.get("color_space")
49+
50+
mastering_display_metadata = None
51+
if "side_data_list" in video_stream:
52+
for side_data in video_stream["side_data_list"]:
53+
if side_data.get("side_data_type") == "Mastering display metadata":
54+
mastering_display_metadata = side_data.get("display_primaries", side_data)
55+
break
56+
57+
return color_range, color_primaries, color_transfer, color_space, mastering_display_metadata
58+
59+
def run_ffprobe_for_audio_streams(video_file):
60+
cmd = [
61+
"ffprobe", "-v", "error", "-select_streams", "a",
62+
"-show_entries", "stream=index,codec_name:stream_tags=language",
63+
"-of", "json", video_file
64+
]
65+
try:
66+
output = subprocess.check_output(
67+
cmd,
68+
stderr=subprocess.STDOUT,
69+
text=True,
70+
encoding='utf-8',
71+
errors='replace'
72+
)
73+
except subprocess.CalledProcessError:
74+
return []
75+
76+
data = json.loads(output)
77+
streams = data.get("streams", [])
78+
audio_info = []
79+
for s in streams:
80+
idx = s.get("index")
81+
codec = s.get("codec_name")
82+
language = s.get("tags", {}).get("language", None)
83+
audio_info.append({"index": idx, "codec": codec, "language": language})
84+
return audio_info
85+
86+
def get_user_input(prompt, default=None):
87+
user_input = input(prompt)
88+
return user_input if user_input else default
89+
90+
def get_video_resolution(video_file):
91+
cap = cv2.VideoCapture(video_file)
92+
if not cap.isOpened():
93+
print(f"Unable to open video file: {video_file}")
94+
return None, None
95+
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
96+
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
97+
cap.release()
98+
return height, width
99+
100+
def get_video_duration(video_file):
101+
cap = cv2.VideoCapture(video_file)
102+
if not cap.isOpened():
103+
print(f"Unable to open video file: {video_file}")
104+
return None
105+
fps = cap.get(cv2.CAP_PROP_FPS)
106+
frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
107+
duration = frame_count / fps if fps else None
108+
cap.release()
109+
return duration
110+
def get_crop_parameters(video_file, input_width, input_height):
111+
print("Detecting optimal crop parameters throughout the video...")
112+
duration = get_video_duration(video_file)
113+
if duration is None or duration < 1:
114+
print("Unable to determine video duration or video is too short.")
115+
return None, None, None, None
116+
117+
default_limit = "48" # Updated default cropdetect limit value
118+
limit_value = get_user_input(f"Enter cropdetect limit value (higher detects more black areas) [{default_limit}]: ", default_limit)
119+
default_round = "4"
120+
round_value = get_user_input(f"Enter cropdetect round value (controls precision) [{default_round}]: ", default_round)
121+
122+
sample_interval = 300 # 5 minutes in seconds
123+
num_samples = max(12, min(72, int(duration / sample_interval)))
124+
if num_samples < 12:
125+
num_samples = 12
126+
127+
start_offset = min(300, duration * 0.05)
128+
interval = (duration - start_offset) / num_samples if duration > start_offset else duration / num_samples
129+
130+
crop_values = []
131+
for i in range(num_samples):
132+
start_time = start_offset + i * interval if duration > start_offset else i * interval
133+
if start_time >= duration:
134+
start_time = duration - 1
135+
print(f"Analyzing frame at {int(start_time)}s ({i+1}/{num_samples})...")
136+
command = [
137+
"ffmpeg",
138+
"-ss", str(start_time),
139+
"-i", video_file,
140+
"-vframes", "3",
141+
"-vf", f"cropdetect={limit_value}:{round_value}:0",
142+
"-f", "null",
143+
"-",
144+
"-hide_banner",
145+
"-loglevel", "verbose"
146+
]
147+
try:
148+
process = subprocess.Popen(
149+
command,
150+
stderr=subprocess.PIPE,
151+
stdout=subprocess.PIPE,
152+
text=True,
153+
encoding='utf-8',
154+
errors='replace'
155+
)
156+
stdout, stderr = process.communicate()
157+
ffmpeg_output = (stdout or '') + (stderr or '')
158+
for line in ffmpeg_output.split('\n'):
159+
if 'crop=' in line:
160+
idx = line.index('crop=')
161+
crop_str = line[idx+5:].strip()
162+
crop_values.append(crop_str)
163+
w, h, x, y = [int(v) for v in crop_str.split(':')]
164+
print(f"Detected crop at {int(start_time)}s: width={w}, height={h}, x={x}, y={y}")
165+
except Exception as e:
166+
print(f"Error while running cropdetect at {int(start_time)}s: {e}")
167+
continue
168+
169+
if crop_values:
170+
crop_counter = Counter(crop_values)
171+
most_common_crop = crop_counter.most_common(1)[0][0]
172+
w, h, x, y = [int(v) for v in most_common_crop.split(':')]
173+
print(f"\nDetected optimal crop parameters: width={w}, height={h}, x={x}, y={y}")
174+
else:
175+
print("No crop parameters found.")
176+
w, h, x, y = input_width, input_height, 0, 0 # Default to full frame if none detected
177+
178+
print(f"Detected crop parameters: width={w}, height={h}, x={x}, y={y}")
179+
redo_crop = get_user_input("Are you satisfied with the detected crop parameters? [Y/N] (default is Y): ", "Y").lower()
180+
if redo_crop in ["n", "0"]:
181+
print("Redoing crop detection...")
182+
return get_crop_parameters(video_file, input_width, input_height)
183+
184+
return w, h, x, y
185+
def collect_user_settings(video_file, input_width, input_height):
186+
"""
187+
Collect settings from the user for the first file and return them as a dictionary.
188+
"""
189+
settings = {}
190+
191+
# Select decoding mode
192+
decode_choice = get_user_input(
193+
"Select decoding mode:\n[1] Hardware decoding (default)\n[2] Software decoding\nEnter choice (1 or 2) [1]: ",
194+
"1"
195+
)
196+
settings['decode_flag'] = "--avhw" if decode_choice == "1" else "--avsw"
197+
198+
# Crop detection
199+
crop_w, crop_h, crop_x, crop_y = get_crop_parameters(video_file, input_width, input_height)
200+
settings['crop_params'] = f"{crop_x},{crop_y},{input_width - crop_w - crop_x},{input_height - crop_h - crop_y}" if crop_w else ""
201+
202+
# Print color-related metadata before HDR query
203+
color_range, color_primaries, color_transfer, color_space, mastering_display_metadata = get_video_color_info(video_file)
204+
print(f"Detected color_range: {color_range}")
205+
print(f"Detected color_primaries: {color_primaries}")
206+
print(f"Detected color_transfer (transfer characteristics): {color_transfer}")
207+
print(f"Detected color_space (matrix coefficients): {color_space}")
208+
if mastering_display_metadata:
209+
print("Detected mastering display metadata:")
210+
print(mastering_display_metadata)
211+
else:
212+
print("No mastering display metadata found.")
213+
214+
# HDR Conversion
215+
settings['hdr_enable'] = get_user_input("Enable HDR Conversion? [Y/N/1/0] (default is N): ", "N").lower()
216+
217+
# Resize to 4K
218+
settings['resize_enable'] = get_user_input("Enable Resize to 4K? [Y/N/1/0] (default is N): ", "N").lower()
219+
220+
# FRUC (Frame Rate Up Conversion)
221+
settings['fruc_enable'] = get_user_input("Enable FRUC (fps=60)? [Y/N/1/0] (default is N): ", "N").lower()
222+
223+
# Denoise
224+
settings['denoise_enable'] = get_user_input("Enable Denoise? [Y/N/1/0] (default is N): ", "N").lower()
225+
226+
# Artifact Reduction
227+
settings['artifact_enable'] = get_user_input("Enable Artifact Reduction? [Y/N/1/0] (default is N): ", "N").lower()
228+
229+
# Audio Handling
230+
audio_streams = run_ffprobe_for_audio_streams(video_file)
231+
settings['audio_tracks'] = []
232+
if audio_streams:
233+
track_selection = get_user_input("Enter the audio track numbers you want to process (comma separated): ")
234+
if track_selection:
235+
settings['audio_tracks'] = [int(x.strip()) for x in track_selection.split(",") if x.strip().isdigit()]
236+
else:
237+
settings['audio_tracks'] = [s['index'] for s in audio_streams] # Default to all tracks
238+
239+
if all(s['codec'] == 'ac3' for s in audio_streams if s['index'] in settings['audio_tracks']):
240+
audio_default = "1"
241+
print("All selected tracks are AC3. Defaulting to copy audio.")
242+
else:
243+
audio_default = "2"
244+
print("Not all selected tracks are AC3. Defaulting to convert to AC3.")
245+
246+
settings['audio_choice'] = get_user_input(
247+
"Do you want to copy the audio or convert it to AC3?\n[1] Copy Audio\n[2] Convert to AC3\n"
248+
f"Enter choice (1 or 2) [{audio_default}]: ",
249+
audio_default
250+
)
251+
else:
252+
print("No audio tracks found. Proceeding with no audio.")
253+
settings['audio_choice'] = "1" # Default to no conversion
254+
255+
# QVBR and GOP
256+
settings['qvbr'] = get_user_input("Enter target QVBR [20]: ", "20")
257+
settings['gop_len'] = get_user_input("Enter GOP length [6]: ", "6")
258+
259+
return settings
260+
def process_video(file_path, settings):
261+
"""
262+
Process a single video file using NVEncC with the provided settings.
263+
"""
264+
input_dir = os.path.dirname(file_path)
265+
file_name = os.path.basename(file_path)
266+
output_subdir = os.path.join(input_dir, "processed_videos")
267+
os.makedirs(output_subdir, exist_ok=True) # Ensure the output subfolder exists
268+
output_file = os.path.join(output_subdir, os.path.splitext(file_name)[0] + "_AV1.mkv") # Updated suffix
269+
270+
# Construct the command
271+
command = [
272+
"NVEncC64",
273+
settings['decode_flag'], # Use hardware or software decoding based on user input
274+
"--codec", "av1",
275+
"--qvbr", settings['qvbr'],
276+
"--preset", "p4",
277+
"--output-depth", "10",
278+
"--gop-len", settings['gop_len'],
279+
"--metadata", "copy",
280+
"--audio-copy", # Copy all audio streams
281+
"--sub-copy", # Copy all subtitle streams
282+
"--chapter-copy", # Copy chapters
283+
"--data-copy", # Copy data streams
284+
"-i", file_path,
285+
"-o", output_file
286+
]
287+
288+
if settings['crop_params']:
289+
command.extend(["--crop", settings['crop_params']])
290+
291+
if settings['resize_enable'] in ["y", "1"]:
292+
command.extend(["--vpp-resize", "algo=nvvfx-superres,superres-mode=0", "--output-res", "3840x2160"])
293+
294+
if settings['fruc_enable'] in ["y", "1"]:
295+
command.extend(["--vpp-fruc", "fps=60"])
296+
297+
if settings['artifact_enable'] in ["y", "1"]:
298+
command.extend(["--vpp-nvvfx-artifact-reduction", "mode=0"])
299+
300+
if settings['denoise_enable'] in ["y", "1"]:
301+
command.extend(["--vpp-nvvfx-denoise"])
302+
303+
if settings['hdr_enable'] in ["y", "1"]:
304+
command.extend(["--vpp-ngx-truehdr"])
305+
306+
if settings['audio_tracks']:
307+
for track in settings['audio_tracks']:
308+
command.extend(["--audio-stream", str(track)])
309+
if settings['audio_choice'] == "2":
310+
command.extend(["--audio-codec", "ac3", "--audio-bitrate", "640"])
311+
312+
print(f"Processing: {file_path}")
313+
try:
314+
subprocess.run(command, check=True)
315+
print(f"Success: Processed {file_path} -> {output_file}")
316+
except subprocess.CalledProcessError as e:
317+
print(f"Error: Failed to process {file_path}")
318+
print(e)
319+
320+
def process_batch(video_files):
321+
"""
322+
Process a batch of video files, reusing settings from the first file.
323+
"""
324+
settings = None
325+
326+
for index, file_path in enumerate(video_files):
327+
if index == 0:
328+
input_height, input_width = get_video_resolution(file_path)
329+
if input_height is None or input_width is None:
330+
print(f"Error: Could not retrieve resolution for {file_path}. Skipping.")
331+
continue
332+
settings = collect_user_settings(file_path, input_width, input_height)
333+
else:
334+
print(f"Reusing settings for: {file_path}")
335+
336+
process_video(file_path, settings)
337+
338+
def wait_for_any_key():
339+
if platform.system() == "Windows":
340+
import msvcrt
341+
print("Processing complete. Press any key to exit...")
342+
msvcrt.getch()
343+
else:
344+
print("Processing complete. Press any key to exit...")
345+
os.system("stty -echo -icanon")
346+
try:
347+
os.read(0, 1)
348+
finally:
349+
os.system("stty sane")
350+
351+
if __name__ == "__main__":
352+
if len(sys.argv) < 2:
353+
print("No video file specified. Please drag and drop a video file onto the script.")
354+
input("Press any key to exit...")
355+
sys.exit()
356+
357+
video_files = sys.argv[1:]
358+
process_batch(video_files)
359+
wait_for_any_key()

0 commit comments

Comments
 (0)