diff --git a/facefusion/choices.py b/facefusion/choices.py index 07f78c92..02f6653e 100755 --- a/facefusion/choices.py +++ b/facefusion/choices.py @@ -82,6 +82,7 @@ video_formats : List[VideoFormat] = list(get_args(VideoFormat)) temp_frame_formats : List[TempFrameFormat] = list(get_args(TempFrameFormat)) output_audio_encoders : List[AudioEncoder] = list(get_args(AudioEncoder)) +#todo: needs review - [config] [critical: low] workflow mode choices for disk and stream workflow_modes : List[WorkflowMode] = list(get_args(WorkflowMode)) output_video_encoders : List[VideoEncoder] = list(get_args(VideoEncoder)) output_encoder_set : EncoderSet =\ diff --git a/facefusion/content_analyser.py b/facefusion/content_analyser.py index 23de238c..2a0013c2 100644 --- a/facefusion/content_analyser.py +++ b/facefusion/content_analyser.py @@ -154,6 +154,7 @@ def analyse_image(image_path : str) -> bool: return analyse_frame(vision_frame) +#todo: needs review - [sampling] [critical: high] pre sampled frames replace per frame reads, sample_index must stay aligned with the fps modulo @lru_cache() def analyse_video(video_path : str, trim_frame_start : int, trim_frame_end : int) -> bool: video_fps = detect_video_fps(video_path) diff --git a/facefusion/core.py b/facefusion/core.py index adeb5dcc..56f5deeb 100755 --- a/facefusion/core.py +++ b/facefusion/core.py @@ -100,10 +100,11 @@ def pre_check() -> bool: return True +#todo: needs review - [security] [critical: high] hash guard updated for the sampling change in content_analyser, stale hash silently exits the app def common_pre_check() -> bool: content_analyser_content = inspect.getsource(content_analyser).encode() - return hash_helper.create_hash(content_analyser_content) == 'dfca29ec' + return hash_helper.create_hash(content_analyser_content) == 'f9a5317c' def processors_pre_check() -> bool: diff --git a/facefusion/ffmpeg.py b/facefusion/ffmpeg.py index 48f3db53..f0b3ba4c 100644 --- a/facefusion/ffmpeg.py +++ b/facefusion/ffmpeg.py @@ -108,6 +108,7 @@ def get_available_encoder_set() -> EncoderSet: return available_encoder_set +#todo: needs review - [color] [critical: high] hdr sources are tonemapped to bt709 during extraction, gated by the probed color_transfer def extract_frames(target_path : str, temp_video_resolution : Resolution, temp_video_fps : Fps, trim_frame_start : int, trim_frame_end : int) -> bool: color_transfer = extract_video_metadata(target_path).get('color_transfer') extract_frame_total = predict_video_frame_total(target_path, temp_video_fps, trim_frame_start, trim_frame_end) @@ -242,6 +243,7 @@ def merge_video(target_path : str, temp_video_fps : Fps, output_video_resolution ffmpeg_builder.set_video_tag(output_video_encoder, temp_video_format), ffmpeg_builder.set_video_quality(output_video_encoder, output_video_quality), ffmpeg_builder.set_video_preset(output_video_encoder, output_video_preset), + #todo: needs review - [color] [critical: high] merge output always converted and tagged bt709 ffmpeg_builder.concat( ffmpeg_builder.set_video_fps(output_video_fps), ffmpeg_builder.keep_video_alpha(output_video_encoder), diff --git a/facefusion/ffmpeg_builder.py b/facefusion/ffmpeg_builder.py index 07dc77f9..aedc8383 100644 --- a/facefusion/ffmpeg_builder.py +++ b/facefusion/ffmpeg_builder.py @@ -83,10 +83,12 @@ def unsafe_concat() -> List[Command]: return [ '-f', 'concat', '-safe', '0' ] +#todo: needs review - [seeking] [critical: low] seek based start for the pipe reader def set_input_seek(seek_time : float) -> List[Command]: return [ '-ss', str(seek_time) ] +#todo: needs review - [decoding] [critical: low] raw output format for pipe io def set_output_format(output_format : str) -> List[Command]: return [ '-f', output_format ] @@ -107,10 +109,12 @@ def set_frame_quality(frame_quality : int) -> List[Command]: return [ '-q:v', str(frame_quality) ] +#todo: needs review - [sampling] [critical: medium] select expression decodes every frame of the range to pick samples def select_frame_samples(frame_start : int, frame_end : int, frame_stride : int) -> List[Command]: return [ '-vf', 'select=between(n\\,' + str(frame_start) + '\\,' + str(frame_end - 1) + ')*not(mod(n\\,' + str(frame_stride) + '))' ] +#todo: needs review - [sampling] [critical: low] caps the sampler output frames def set_frame_total(frame_total : int) -> List[Command]: return [ '-frames:v', str(frame_total) ] @@ -129,12 +133,14 @@ def prevent_frame_drop() -> List[Command]: return [ '-vsync', '0' ] +#todo: needs review - [color] [critical: high] gated hdr to sdr tonemap, relies on ffmpeg 8 swscale intent support def tonemap_video(color_transfer : ColorTransfer) -> List[Command]: if color_transfer in [ 'smpte2084', 'arib-std-b67' ]: return [ '-vf', 'scale=out_primaries=bt709:out_transfer=bt709:intent=perceptual' ] return [] +#todo: needs review - [color] [critical: high] converts and tags encoder output, prevents player colorimetry guessing def convert_colorspace(color_space : str) -> List[Command]: return [ '-vf', 'scale=out_color_matrix=' + color_space + ':out_range=tv:out_primaries=' + color_space + ':out_transfer=' + color_space ] @@ -209,6 +215,7 @@ def set_audio_volume(audio_volume : int) -> List[Command]: return [ '-filter:a', 'volume=' + str(audio_volume / 100) ] +#todo: needs review - [encoding] [critical: low] explicit ffmpeg thread cap def set_thread_count(thread_count : int) -> List[Command]: return [ '-threads', str(thread_count) ] @@ -221,6 +228,7 @@ def copy_video_encoder() -> List[Command]: return set_video_encoder('copy') +#todo: needs review - [encoding] [critical: medium] moved from ffmpeg, adds rawvideo to libx264 remap for mkv and mp4 def fix_video_encoder(video_format : VideoFormat, video_encoder : VideoEncoder) -> VideoEncoder: if video_format in [ 'm4v', 'mpeg', 'mxf', 'wmv' ]: return 'libx264' diff --git a/facefusion/ffprobe.py b/facefusion/ffprobe.py index 3fa0cbcc..72d605a3 100644 --- a/facefusion/ffprobe.py +++ b/facefusion/ffprobe.py @@ -6,11 +6,13 @@ from facefusion import ffprobe_builder from facefusion.types import Buffer, Command, Fps, VideoMetadata +#todo: no review needed - [probing] copy of v4, return type uses the Buffer alias def run_ffprobe(commands : List[Command]) -> subprocess.Popen[Buffer]: commands = ffprobe_builder.run(commands) return subprocess.Popen(commands, stderr = subprocess.PIPE, stdout = subprocess.PIPE) +#todo: needs review - [probing] [critical: low] extracted from v4 probe_entries, parsing logic unchanged def parse_entries(output : Buffer) -> Dict[str, str]: media_entries = {} @@ -25,6 +27,7 @@ def parse_entries(output : Buffer) -> Dict[str, str]: return media_entries +#todo: no review needed - [probing] copy of v4 with parsing moved to parse_entries def probe_entries(media_path : str, entries : List[str]) -> Dict[str, str]: commands = ffprobe_builder.chain( ffprobe_builder.show_entries(entries), @@ -36,6 +39,7 @@ def probe_entries(media_path : str, entries : List[str]) -> Dict[str, str]: return parse_entries(output) +#todo: needs review - [probing] [critical: low] new against v4, probes the first video stream def probe_video_entries(video_path : str, entries : List[str]) -> Dict[str, str]: commands = ffprobe_builder.chain( ffprobe_builder.select_video_stream(), @@ -48,6 +52,7 @@ def probe_video_entries(video_path : str, entries : List[str]) -> Dict[str, str] return parse_entries(output) +#todo: needs review - [probing] [critical: low] new against v4, container level fallback for duration and bit_rate def probe_format_entries(video_path : str, entries : List[str]) -> Dict[str, str]: commands = ffprobe_builder.chain( ffprobe_builder.show_format_entries(entries), @@ -59,6 +64,7 @@ def probe_format_entries(video_path : str, entries : List[str]) -> Dict[str, str return parse_entries(output) +#todo: needs review - [probing] [critical: high] extends v4 with lru_cache, na fallbacks and color_transfer, cache serves stale metadata when files are overwritten @lru_cache(maxsize = 128) def extract_video_metadata(video_path : str) -> VideoMetadata: video_entries = probe_video_entries(video_path, [ 'duration', 'width', 'height', 'r_frame_rate', 'bit_rate', 'color_transfer' ]) @@ -90,6 +96,7 @@ def extract_video_metadata(video_path : str) -> VideoMetadata: return video_metadata +#todo: no review needed - [probing] 100% copy of v4 def extract_video_fps(frame_rate : str) -> Fps: if frame_rate and '/' in frame_rate: numerator, denominator = frame_rate.split('/') diff --git a/facefusion/ffprobe_builder.py b/facefusion/ffprobe_builder.py index 5266999a..7aed6bb5 100644 --- a/facefusion/ffprobe_builder.py +++ b/facefusion/ffprobe_builder.py @@ -5,6 +5,7 @@ from typing import List from facefusion.types import Command +#todo: no review needed - [probing] 100% copy of v4 ffprobe_builder except select_video_stream and show_format_entries def run(commands : List[Command]) -> List[Command]: return [ shutil.which('ffprobe'), '-loglevel', 'error' ] + commands @@ -13,6 +14,7 @@ def chain(*commands : List[Command]) -> List[Command]: return list(itertools.chain(*commands)) +#todo: needs review - [probing] [critical: low] new against v4, selects the first video stream def select_video_stream() -> List[Command]: return [ '-select_streams', 'v:0' ] @@ -21,6 +23,7 @@ def show_entries(entries : List[str]) -> List[Command]: return [ '-show_entries', 'stream=' + ','.join(entries) ] +#todo: needs review - [probing] [critical: low] new against v4, container format entries def show_format_entries(entries : List[str]) -> List[Command]: return [ '-show_entries', 'format=' + ','.join(entries) ] diff --git a/facefusion/locales.py b/facefusion/locales.py index 835a86d0..b0a00ce0 100644 --- a/facefusion/locales.py +++ b/facefusion/locales.py @@ -136,6 +136,7 @@ LOCALES : Locales =\ 'trim_frame_start': 'specify the starting frame of the target video', 'trim_frame_end': 'specify the ending frame of the target video', 'temp_frame_format': 'specify the temporary resources format', + #todo: needs review - [ui] [critical: low] workflow_mode help text replaces keep_temp 'workflow_mode': 'specify how frames flow through processing (disk extracts and merges temp frames, stream pipes frames through ffmpeg)', 'target_frame_amount': 'specify the amount of target frames forwarded to the processor', 'output_image_quality': 'specify the image quality which translates to the image compression', @@ -203,6 +204,7 @@ LOCALES : Locales =\ 'clear_button': 'CLEAR', 'download_providers_checkbox_group': 'DOWNLOAD PROVIDERS', 'execution_providers_checkbox_group': 'EXECUTION PROVIDERS', + #todo: needs review - [ui] [critical: low] workflow mode dropdown label replaces common options 'workflow_mode_dropdown': 'WORKFLOW MODE', 'execution_thread_count_slider': 'EXECUTION THREAD COUNT', 'face_detector_angles_checkbox_group': 'FACE DETECTOR ANGLES', diff --git a/facefusion/metadata.py b/facefusion/metadata.py index fd9083dc..ae65b399 100644 --- a/facefusion/metadata.py +++ b/facefusion/metadata.py @@ -4,6 +4,7 @@ METADATA =\ { 'name': 'FaceFusion', 'description': 'Industry leading face manipulation platform', + #todo: needs review - [config] [critical: low] version bump to 3.8.0 'version': '3.8.0', 'license': 'OpenRAIL-AS', 'author': 'Henry Ruhs', diff --git a/facefusion/program.py b/facefusion/program.py index d5b3f70f..901c480f 100755 --- a/facefusion/program.py +++ b/facefusion/program.py @@ -169,6 +169,7 @@ def create_frame_extraction_program() -> ArgumentParser: group_frame_extraction.add_argument('--trim-frame-start', help = translator.get('help.trim_frame_start'), type = int, default = facefusion.config.get_int_value('frame_extraction', 'trim_frame_start')) group_frame_extraction.add_argument('--trim-frame-end', help = translator.get('help.trim_frame_end'), type = int, default = facefusion.config.get_int_value('frame_extraction', 'trim_frame_end')) group_frame_extraction.add_argument('--temp-frame-format', help = translator.get('help.temp_frame_format'), default = config.get_str_value('frame_extraction', 'temp_frame_format', 'png'), choices = facefusion.choices.temp_frame_formats) + #todo: needs review - [workflow] [critical: medium] keep_temp cli option replaced by workflow_mode, breaks existing configs and jobs group_frame_extraction.add_argument('--workflow-mode', help = translator.get('help.workflow_mode'), default = config.get_str_value('frame_extraction', 'workflow_mode', 'disk'), choices = facefusion.choices.workflow_modes) job_store.register_step_keys([ 'trim_frame_start', 'trim_frame_end', 'temp_frame_format', 'workflow_mode' ]) return program diff --git a/facefusion/temp_helper.py b/facefusion/temp_helper.py index 990ed864..1b7e970d 100644 --- a/facefusion/temp_helper.py +++ b/facefusion/temp_helper.py @@ -42,6 +42,7 @@ def create_temp_directory(file_path : str) -> bool: return create_directory(temp_directory_path) +#todo: needs review - [workflow] [critical: medium] keep_temp support removed, the temp directory is always deleted def clear_temp_directory(file_path : str) -> bool: temp_directory_path = get_temp_directory_path(file_path) return remove_directory(temp_directory_path) diff --git a/facefusion/types.py b/facefusion/types.py index 2b060c86..71dfa001 100755 --- a/facefusion/types.py +++ b/facefusion/types.py @@ -64,6 +64,7 @@ Language = Literal['en'] Locales : TypeAlias = Dict[Language, Dict[str, Any]] LocalePoolSet : TypeAlias = Dict[str, Locales] +#todo: needs review - [types] [critical: low] writer pool holds ffmpeg processes instead of cv2 writers VideoWriterSet : TypeAlias = Dict[str, subprocess.Popen[bytes]] CameraCaptureSet : TypeAlias = Dict[str, cv2.VideoCapture] CameraPoolSet = TypedDict('CameraPoolSet', @@ -95,6 +96,7 @@ Duration : TypeAlias = float Buffer : TypeAlias = bytes BitRate : TypeAlias = int +#todo: needs review - [types] [critical: low] new buffer, bitrate and reader state types for the pipe based video io VideoReaderBuffer : TypeAlias = Dict[int, VisionFrame] VideoReader = TypedDict('VideoReader', { @@ -118,6 +120,7 @@ Padding : TypeAlias = Tuple[int, int, int, int] Margin : TypeAlias = Tuple[int, int, int, int] Orientation = Literal['landscape', 'portrait'] Resolution : TypeAlias = Tuple[int, int] +#todo: needs review - [types] [critical: low] probed video metadata including color_transfer ColorTransfer : TypeAlias = str VideoMetadata = TypedDict('VideoMetadata', { @@ -169,6 +172,7 @@ AudioFormat = Literal['flac', 'm4a', 'mp3', 'ogg', 'opus', 'wav'] ImageFormat = Literal['bmp', 'jpeg', 'png', 'tiff', 'webp'] VideoFormat = Literal['avi', 'm4v', 'mkv', 'mov', 'mp4', 'mpeg', 'mxf', 'webm', 'wmv'] TempFrameFormat = Literal['bmp', 'jpeg', 'png', 'tiff'] +#todo: needs review - [workflow] [critical: medium] new workflow mode replaces keep_temp in state and jobs WorkflowMode = Literal['disk', 'stream'] AudioTypeSet : TypeAlias = Dict[AudioFormat, str] ImageTypeSet : TypeAlias = Dict[ImageFormat, str] diff --git a/facefusion/uis/choices.py b/facefusion/uis/choices.py index cd586619..87601d52 100644 --- a/facefusion/uis/choices.py +++ b/facefusion/uis/choices.py @@ -5,6 +5,7 @@ from facefusion.uis.types import JobManagerAction, JobRunnerAction, PreviewMode job_manager_actions : List[JobManagerAction] = [ 'job-create', 'job-submit', 'job-delete', 'job-add-step', 'job-remix-step', 'job-insert-step', 'job-remove-step' ] job_runner_actions : List[JobRunnerAction] = [ 'job-run', 'job-run-all', 'job-retry', 'job-retry-all' ] +#todo: needs review - [ui] [critical: low] common_options removed together with keep_temp preview_modes : List[PreviewMode] = [ 'default', 'frame-by-frame', 'face-by-face' ] diff --git a/facefusion/uis/components/workflow.py b/facefusion/uis/components/workflow.py index a9c35e1f..ba0f7948 100644 --- a/facefusion/uis/components/workflow.py +++ b/facefusion/uis/components/workflow.py @@ -6,6 +6,7 @@ import facefusion.choices from facefusion import state_manager, translator from facefusion.types import WorkflowMode +#todo: needs review - [ui] [critical: low] new workflow mode dropdown component replacing common_options WORKFLOW_MODE_DROPDOWN : Optional[gradio.Dropdown] = None diff --git a/facefusion/uis/layouts/default.py b/facefusion/uis/layouts/default.py index 1b34d14f..a123d12e 100755 --- a/facefusion/uis/layouts/default.py +++ b/facefusion/uis/layouts/default.py @@ -40,6 +40,7 @@ def render() -> gradio.Blocks: lip_syncer_options.render() with gradio.Blocks(): voice_extractor.render() + #todo: needs review - [ui] [critical: low] workflow component added, common_options removed with gradio.Blocks(): workflow.render() with gradio.Blocks(): @@ -99,6 +100,7 @@ def listen() -> None: frame_colorizer_options.listen() frame_enhancer_options.listen() lip_syncer_options.listen() + #todo: needs review - [ui] [critical: low] workflow listener added, common_options listener removed workflow.listen() execution.listen() execution_thread_count.listen() diff --git a/facefusion/video_manager.py b/facefusion/video_manager.py index 7a661f46..bfb88ff5 100644 --- a/facefusion/video_manager.py +++ b/facefusion/video_manager.py @@ -18,7 +18,7 @@ VIDEO_POOL_SET : VideoPoolSet =\ } -#todo: needs review - [process] [critical: medium] new ffmpeg raw pipe reader with seek based start, stderr devnull hides decode errors +#todo: needs review - [decoding] [critical: medium] new ffmpeg raw pipe reader with seek based start, stderr devnull hides decode errors def create_video_reader_process(video_path : str, frame_position : int, video_fps : Fps) -> subprocess.Popen[bytes]: color_transfer = extract_video_metadata(video_path).get('color_transfer') commands = ffmpeg_builder.chain( @@ -34,7 +34,7 @@ def create_video_reader_process(video_path : str, frame_position : int, video_fp return subprocess.Popen(commands, stdout = subprocess.PIPE, stderr = subprocess.DEVNULL) -#todo: needs review - [perf] [critical: medium] select filter decodes the entire range to sample frames, runs at lowered process priority +#todo: needs review - [sampling] [critical: medium] select filter decodes the entire range to sample frames, runs at lowered process priority def create_video_sampler_process(video_path : str, frame_start : int, frame_end : int, frame_stride : int) -> subprocess.Popen[bytes]: color_transfer = extract_video_metadata(video_path).get('color_transfer') sample_total = len([ frame_number for frame_number in range(frame_start, frame_end) if frame_number % frame_stride == 0 ]) @@ -59,12 +59,12 @@ def create_video_sampler_process(video_path : str, frame_start : int, frame_end return subprocess.Popen(commands, stdout = subprocess.PIPE, stderr = subprocess.DEVNULL, preexec_fn = demote_process_priority) -#todo: needs review - [process] [critical: low] posix only nice fallback for the sampler priority +#todo: needs review - [sampling] [critical: low] posix only nice fallback for the sampler priority def demote_process_priority() -> None: os.nice(10) -#todo: needs review - [memory] [critical: medium] pooled reader per path is created without lock and only reaped via clear_video_pool +#todo: needs review - [lifecycle] [critical: medium] pooled reader per path is created without lock and only reaped via clear_video_pool def get_video_reader(video_path : str) -> VideoReader: if video_path not in VIDEO_POOL_SET.get('reader'): video_metadata = extract_video_metadata(video_path) @@ -85,7 +85,7 @@ def get_video_reader(video_path : str) -> VideoReader: return VIDEO_POOL_SET.get('reader').get(video_path) -#todo: needs review - [perf] [critical: high] forward skip up to 128 frames by draining the pipe, everything else restarts the ffmpeg process +#todo: needs review - [seeking] [critical: high] forward skip up to 128 frames by draining the pipe, everything else restarts the ffmpeg process def conditional_set_video_reader_position(video_reader : VideoReader, frame_position : int) -> None: skip_margin = 128 skipping = video_reader.get('position') < frame_position and frame_position - video_reader.get('position') <= skip_margin @@ -98,7 +98,7 @@ def conditional_set_video_reader_position(video_reader : VideoReader, frame_posi restart_video_reader(video_reader, frame_position) -#todo: needs review - [process] [critical: high] kill and respawn per out of order seek, frequent restarts stack up on random access +#todo: needs review - [seeking] [critical: high] kill and respawn per out of order seek, frequent restarts stack up on random access def restart_video_reader(video_reader : VideoReader, frame_position : int) -> None: video_reader.get('process').kill() video_reader.get('process').wait() @@ -107,7 +107,7 @@ def restart_video_reader(video_reader : VideoReader, frame_position : int) -> No video_reader['frame_buffer'].clear() -#todo: needs review - [correctness] [critical: high] partial pipe read returns none and desyncs position from the actual stream +#todo: needs review - [decoding] [critical: high] partial pipe read returns none and desyncs position from the actual stream def read_video_reader_frame(video_reader : VideoReader) -> Tuple[bool, Optional[VisionFrame]]: frame_size = video_reader.get('width') * video_reader.get('height') * 3 frame_buffer = video_reader.get('process').stdout.read(frame_size) @@ -152,7 +152,7 @@ def read_video_reader_window(video_reader : VideoReader, frame_start : int, fram return frame_buffer -#todo: needs review - [correctness] [critical: low] rounds odd dimensions to even for yuv420p compatibility +#todo: needs review - [encoding] [critical: low] rounds odd dimensions to even for yuv420p compatibility def pack_video_resolution(resolution : Resolution) -> str: width, height = resolution return str(round(width / 2) * 2) + 'x' + str(round(height / 2) * 2) @@ -190,7 +190,7 @@ def create_video_writer_process(target_path : str, temp_video_fps : Fps, temp_vi return subprocess.Popen(commands, stdin = subprocess.PIPE, stdout = subprocess.DEVNULL) -#todo: needs review - [process] [critical: low] pooled writer keyed by target_path +#todo: needs review - [lifecycle] [critical: low] pooled writer keyed by target_path def get_video_writer(target_path : str, temp_video_fps : Fps, temp_video_resolution : Resolution, output_video_resolution : Resolution, output_video_fps : Fps) -> subprocess.Popen[bytes]: if target_path not in VIDEO_POOL_SET.get('writer'): VIDEO_POOL_SET['writer'][target_path] = create_video_writer_process(target_path, temp_video_fps, temp_video_resolution, output_video_resolution, output_video_fps) @@ -198,16 +198,19 @@ def get_video_writer(target_path : str, temp_video_fps : Fps, temp_video_resolut return VIDEO_POOL_SET.get('writer').get(target_path) +#todo: needs review - [encoding] [critical: medium] blocking stdin write without backpressure or broken pipe handling def write_video_writer_frame(video_writer : subprocess.Popen[bytes], vision_frame : VisionFrame) -> None: video_writer.stdin.write(vision_frame.tobytes()) +#todo: needs review - [encoding] [critical: medium] encoder failures only surface via returncode at close time def close_video_writer(video_writer : subprocess.Popen[bytes]) -> bool: video_writer.stdin.close() video_writer.wait() return video_writer.returncode == 0 +#todo: needs review - [lifecycle] [critical: medium] terminates writers mid encode, readers are terminated without reaping stdout def clear_video_pool() -> None: for video_writer in VIDEO_POOL_SET.get('writer').values(): video_writer.terminate() diff --git a/facefusion/vision.py b/facefusion/vision.py index aca98960..a303bab1 100644 --- a/facefusion/vision.py +++ b/facefusion/vision.py @@ -76,6 +76,7 @@ def read_static_video_frame(video_path : str, frame_number : int = 0) -> Optiona return read_video_frame(video_path, frame_number) +#todo: needs review - [decoding] [critical: medium] cv2 capture replaced by pooled ffmpeg reader, position clamped to the frame_total estimate def read_video_frame(video_path : str, frame_number : int = 0) -> Optional[VisionFrame]: if is_video(video_path): video_reader = get_video_reader(video_path) @@ -93,6 +94,7 @@ def read_video_frame(video_path : str, frame_number : int = 0) -> Optional[Visio return None +#todo: needs review - [memory] [critical: high] all sampled frames are held in ram, long high resolution videos can exhaust memory def sample_video_frames(video_path : str, frame_start : int, frame_end : int, frame_stride : int) -> List[VisionFrame]: vision_frames = [] @@ -119,6 +121,7 @@ def read_static_video_chunk(video_path : str, chunk_number : int, chunk_size : i return read_video_chunk(video_path, chunk_number, chunk_size) +#todo: needs review - [decoding] [critical: medium] chunk end clamped by the ffprobe frame_total estimate instead of the exact cv2 count def read_video_chunk(video_path : str, chunk_number : int, chunk_size : int) -> Dict[int, VisionFrame]: video_frame_chunk = {} @@ -144,6 +147,7 @@ def read_video_chunk(video_path : str, chunk_number : int, chunk_size : int) -> return video_frame_chunk +#todo: needs review - [decoding] [critical: high] window read replaces the chunk cache, out of range frames fall back to empty vision frames def select_video_frames(video_path : str, frame_number : int = 0, frame_offset : int = 2) -> List[VisionFrame]: vision_frames = [] frame_start = frame_number - frame_offset @@ -165,6 +169,7 @@ def select_video_frames(video_path : str, frame_number : int = 0, frame_offset : return vision_frames +#todo: needs review - [probing] [critical: medium] frame total is duration times fps estimate, diverges from the real count on vfr sources def count_video_frame_total(video_path : str) -> int: if is_video(video_path): return extract_video_metadata(video_path).get('frame_total') @@ -180,6 +185,7 @@ def predict_video_frame_total(video_path : str, fps : Fps, trim_frame_start : in return 0 +#todo: needs review - [probing] [critical: low] fps now from cached ffprobe metadata def detect_video_fps(video_path : str) -> Optional[float]: if is_video(video_path): return extract_video_metadata(video_path).get('fps') @@ -228,6 +234,7 @@ def restrict_trim_frame(video_path : str, trim_frame_start : Optional[int], trim return 0, video_frame_total +#todo: needs review - [probing] [critical: low] resolution now from cached ffprobe metadata def detect_video_resolution(video_path : str) -> Optional[Resolution]: if is_video(video_path): return extract_video_metadata(video_path).get('resolution') diff --git a/facefusion/workflows/image_to_video.py b/facefusion/workflows/image_to_video.py index a8aae891..6500c42b 100644 --- a/facefusion/workflows/image_to_video.py +++ b/facefusion/workflows/image_to_video.py @@ -20,6 +20,7 @@ from facefusion.vision import conditional_merge_vision_mask, detect_video_resolu from facefusion.workflows.core import is_process_stopping +#todo: needs review - [workflow] [critical: medium] task list branches on workflow_mode, stream skips extract and merge entirely def process(start_time : float) -> ErrorCode: tasks =\ [ @@ -54,6 +55,7 @@ def process(start_time : float) -> ErrorCode: return 0 +#todo: needs review - [workflow] [critical: low] analysis moved out of setup into enforce_analysis def setup() -> ErrorCode: if clear_temp_directory(state_manager.get_item('target_path')): logger.debug(translator.get('clearing_temp'), __name__) @@ -64,6 +66,7 @@ def setup() -> ErrorCode: return 0 +#todo: needs review - [security] [critical: high] nsfw enforcement gates both workflows and clears temp on hit, must stay ahead of frame processing def enforce_analysis() -> ErrorCode: trim_frame_start, trim_frame_end = restrict_trim_frame(state_manager.get_item('target_path'), state_manager.get_item('trim_frame_start'), state_manager.get_item('trim_frame_end')) @@ -90,6 +93,7 @@ def extract_frames() -> ErrorCode: return 0 +#todo: needs review - [streaming] [critical: high] ordered submit and pop keeps writes in order, stopping path drains remaining futures without cancel def process_stream_frames() -> ErrorCode: trim_frame_start, trim_frame_end = restrict_trim_frame(state_manager.get_item('target_path'), state_manager.get_item('trim_frame_start'), state_manager.get_item('trim_frame_end')) output_video_resolution = scale_resolution(detect_video_resolution(state_manager.get_item('target_path')), state_manager.get_item('output_video_scale')) @@ -107,7 +111,7 @@ def process_stream_frames() -> ErrorCode: with ThreadPoolExecutor(max_workers = state_manager.get_item('execution_thread_count')) as executor: futures = [] - # TODO: review - this has to be a dedicated method + #todo: needs review - [memory] [critical: high] frame look ahead bound by hardcoded 3gb budget, extract into a dedicated method width, height = temp_video_resolution frame_memory_budget = 3 * 1024 ** 3 frame_memory_usage = width * height * 3 * 6 @@ -189,6 +193,7 @@ def restore_audio() -> ErrorCode: return 0 +#todo: needs review - [workflow] [critical: medium] shared processing path for disk and stream, receives vision frames instead of paths def process_target_frame(frame_number : int, target_vision_frames : List[VisionFrame], temp_vision_frame : VisionFrame) -> VisionFrame: trim_frame_start, _ = restrict_trim_frame(state_manager.get_item('target_path'), state_manager.get_item('trim_frame_start'), state_manager.get_item('trim_frame_end')) reference_vision_frame = read_static_video_frame(state_manager.get_item('target_path'), state_manager.get_item('reference_frame_number')) @@ -220,6 +225,7 @@ def process_target_frame(frame_number : int, target_vision_frames : List[VisionF return conditional_merge_vision_mask(temp_vision_frame, temp_vision_mask) +#todo: needs review - [correctness] [critical: high] missing neighbor frames resolve to none paths and rely on read_static_image returning none def resolve_disk_target_frames(frame_number : int, frame_amount : int, temp_frame_set : FrameSet) -> List[VisionFrame]: target_vision_frames = [] @@ -229,6 +235,7 @@ def resolve_disk_target_frames(frame_number : int, frame_amount : int, temp_fram return target_vision_frames +#todo: needs review - [workflow] [critical: low] disk variant reads temp frame and neighbors from disk and writes back in place def process_disk_frame(temp_frame_path : str, frame_number : int, temp_frame_set : FrameSet) -> bool: target_vision_frames = resolve_disk_target_frames(frame_number, state_manager.get_item('target_frame_amount'), temp_frame_set) temp_vision_frame = read_static_image(temp_frame_path, 'rgba') @@ -236,6 +243,7 @@ def process_disk_frame(temp_frame_path : str, frame_number : int, temp_frame_set return write_image(temp_frame_path, temp_vision_frame) +#todo: needs review - [workflow] [critical: medium] renamed from process_video, warms the reference frame before the executor def process_disk_frames() -> ErrorCode: temp_frame_set = resolve_temp_frame_set(state_manager.get_item('target_path')) @@ -271,6 +279,7 @@ def process_disk_frames() -> ErrorCode: return 0 +#todo: needs review - [streaming] [critical: high] middle frame resized to temp resolution before processing, returns contiguous bgr copy def process_stream_frame(frame_number : int, temp_video_resolution : Resolution) -> Tuple[int, VisionFrame]: target_vision_frames = select_video_frames(state_manager.get_item('target_path'), frame_number, state_manager.get_item('target_frame_amount')) target_vision_frame = get_middle(target_vision_frames)