Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 123 additions & 47 deletions app/processors/video_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ def __init__(self, main_window: "MainWindow", num_threads=2):

# --- Subprocesses ---
self.virtcam: pyvirtualcam.Camera | None = None
self._virtcam_error_latch: bool = False
self.encoder = FFmpegEncoder()
self.ffplay_sound_sp: subprocess.Popen | None = (
None # ffplay process for live audio
Expand Down Expand Up @@ -818,9 +819,12 @@ def stop_flag_check():
if not ret:
if self.ffmpeg_input_sp:
# All frame numbers are in output frame space.
remaining_frames = self.max_frame_number - self.current_frame_number
remaining_frames = (
self.max_frame_number - self.current_frame_number
)
eof_like = (
self.current_frame_number >= self.max_frame_number - TAIL_TOLERANCE
self.current_frame_number
>= self.max_frame_number - TAIL_TOLERANCE
or remaining_frames <= self.max_consecutive_errors
)
if eof_like:
Expand Down Expand Up @@ -890,14 +894,14 @@ def stop_flag_check():
# instead of an error stop to avoid marking outputs as incomplete when
# the read failures are simply due to end-of-file conditions.
try:
near_eof = (
fn >= self.max_frame_number - TAIL_TOLERANCE
)
near_eof = fn >= self.max_frame_number - TAIL_TOLERANCE
except Exception:
near_eof = False

if near_eof:
print("[INFO] Feeder: Consecutive read errors occurred near EOF; treating as EOF.")
print(
"[INFO] Feeder: Consecutive read errors occurred near EOF; treating as EOF."
)
else:
self.stopped_by_error_limit = True

Expand Down Expand Up @@ -1384,27 +1388,34 @@ def send_frame_to_virtualcam(self, frame: numpy.ndarray):
Removed sleep_until_next_frame() to prevent blocking the Main GUI Thread.
The UI metronome (QTimer) already handles perfect timing and synchronization.
"""
if self.main_window.control["SendVirtCamFramesEnableToggle"] and self.virtcam:
height, width, _ = frame.shape
if self.virtcam.height != height or self.virtcam.width != width:
# Resolution changed (e.g. source swap / restorer output differs).
# Avoid hammering OBS with rapid close/reopen cycles — schedule a
# single deferred restart so the driver gets adequate settling time.
# We skip this frame rather than sending one with the wrong size.
print(
f"[INFO] VirtCam resolution changed ({self.virtcam.width}x{self.virtcam.height} -> {width}x{height}). Restarting virtual camera."
)
if self.main_window.control.get("SendVirtCamFramesEnableToggle", False):
# JIT Initialization: Ensure VirtCam is spun up if toggle is active but uninitialized.
# We use a Circuit Breaker (_virtcam_error_latch) to prevent infinite loops if initialization completely fails.
if not self.virtcam and not getattr(self, "_virtcam_error_latch", False):
self.enable_virtualcam()
return # Frame already consumed; next tick will send at the new size.

# Need to check again if virtcam was successfully re-enabled
# Need to check again if virtcam was successfully enabled
if self.virtcam:
try:
self.virtcam.send(frame)
# REMOVED: self.virtcam.sleep_until_next_frame()
# It forces the UI thread to freeze and fights the metronome.
except Exception as e:
print(f"[WARN] Failed sending frame to virtualcam: {e}")
height, width, _ = frame.shape
if self.virtcam.height != height or self.virtcam.width != width:
# Resolution changed (e.g. source swap / restorer output differs).
# Avoid hammering OBS with rapid close/reopen cycles
print(
f"[INFO] VirtCam resolution changed ({self.virtcam.width}x{self.virtcam.height} -> {width}x{height}). Restarting virtual camera."
)
self.enable_virtualcam()
return # Frame already consumed; next tick will send at the new size.

if self.virtcam:
try:
self.virtcam.send(frame)
except Exception as e:
print(
f"[WARN] Catastrophic failure sending frame to virtualcam: {e}"
)
# If the driver crashes midway, trip the circuit breaker and disable to prevent spam.
self._virtcam_error_latch = True
self.disable_virtualcam()

def set_number_of_threads(self, value):
"""Updates the thread count for the *next* worker pool."""
Expand Down Expand Up @@ -1627,7 +1638,9 @@ def process_video(self):
self.stop_processing()
return

print("[INFO] Sync: Reading first frame from FFmpeg recording input stream...")
print(
"[INFO] Sync: Reading first frame from FFmpeg recording input stream..."
)
ret, frame_bgr = self._read_frame_from_ffmpeg_input_stream()
print(f"[INFO] Sync: Initial FFmpeg stream read complete (Result: {ret}).")

Expand Down Expand Up @@ -2660,7 +2673,9 @@ def _start_recording_ffmpeg_input_stream(
self._used_ffmpeg_cap = False
return False

def _read_frame_from_ffmpeg_input_stream(self) -> tuple[bool, Optional[numpy.ndarray]]:
def _read_frame_from_ffmpeg_input_stream(
self,
) -> tuple[bool, Optional[numpy.ndarray]]:
"""Read one BGR frame from FFmpeg rawvideo stdout."""
if self.ffmpeg_input_prefetched_frame is not None:
frame = self.ffmpeg_input_prefetched_frame
Expand Down Expand Up @@ -2738,7 +2753,12 @@ def _restore_source_frame_state_after_capture_reopen(self) -> None:
except Exception:
pass

def source_to_output_frame(self, source_frame: int, src_fps: float | None = None, out_fps: float | None = None) -> int:
def source_to_output_frame(
self,
source_frame: int,
src_fps: float | None = None,
out_fps: float | None = None,
) -> int:
"""Map a source-frame index to output-frame index using fps values.

Falls back to `self.recording_source_fps` and `self.fps` when fps args
Expand All @@ -2748,13 +2768,22 @@ def source_to_output_frame(self, source_frame: int, src_fps: float | None = None
sf = int(source_frame)
except Exception:
return 0
src = float(src_fps) if src_fps is not None else float(self.recording_source_fps or 0)
src = (
float(src_fps)
if src_fps is not None
else float(self.recording_source_fps or 0)
)
out = float(out_fps) if out_fps is not None else float(self.fps or 0)
if src <= 0 or out <= 0:
return sf
return max(0, round(float(sf) * out / src))

def output_to_source_frame(self, output_frame: int, src_fps: float | None = None, out_fps: float | None = None) -> int:
def output_to_source_frame(
self,
output_frame: int,
src_fps: float | None = None,
out_fps: float | None = None,
) -> int:
"""Map an output-frame index back to source-frame index using fps values.

Falls back to `self.recording_source_fps` and `self.fps` when fps args
Expand All @@ -2764,7 +2793,11 @@ def output_to_source_frame(self, output_frame: int, src_fps: float | None = None
of = int(output_frame)
except Exception:
return 0
src = float(src_fps) if src_fps is not None else float(self.recording_source_fps or 0)
src = (
float(src_fps)
if src_fps is not None
else float(self.recording_source_fps or 0)
)
out = float(out_fps) if out_fps is not None else float(self.fps or 0)
if src <= 0 or out <= 0:
return of
Expand Down Expand Up @@ -2817,7 +2850,10 @@ def _handle_tail_drain_wait(self, frame_number_to_display: int) -> bool:
self.tail_pending_stall_start_sec = now_sec
return True

if now_sec - self.tail_pending_stall_start_sec >= TAIL_PENDING_STALL_TIMEOUT_SEC:
if (
now_sec - self.tail_pending_stall_start_sec
>= TAIL_PENDING_STALL_TIMEOUT_SEC
):
# Stall exceeded timeout: force finalize to avoid hang.
# Do not mark read-error/incomplete here; this path is a queue-drain
# safeguard and can happen even when encoded output is otherwise valid.
Expand Down Expand Up @@ -3979,7 +4015,10 @@ def _finalize_default_style_recording(self):
source_end_frame = self.output_to_source_frame(end_frame_for_calc)
source_span_duration = max(
0.0,
float((source_end_frame - processing_start_src) / float(self.recording_source_fps)),
float(
(source_end_frame - processing_start_src)
/ float(self.recording_source_fps)
),
)
else:
source_span_duration = (
Expand All @@ -3996,7 +4035,9 @@ def _finalize_default_style_recording(self):
else 0.0
)
encoded_duration = (
float(actual_frames_processed / float(self.fps)) if self.fps > 0 else 0.0
float(actual_frames_processed / float(self.fps))
if self.fps > 0
else 0.0
)
print(
f"[INFO] Calculated recording end time: {self.play_end_time:.3f}s "
Expand Down Expand Up @@ -4252,7 +4293,11 @@ def _finalize_default_style_recording(self):
if self.file_type == "video" and self.media_path:
last_processed = self.next_frame_to_display - 1
start_frame = getattr(self, "processing_start_frame", 0)
if self._used_ffmpeg_cap and self.fps > 0 and self.recording_source_fps > 0:
if (
self._used_ffmpeg_cap
and self.fps > 0
and self.recording_source_fps > 0
):
last_processed = self.output_to_source_frame(last_processed)
reset_frame = max(start_frame, last_processed)
# Slider stays in source frame space (approach 2).
Expand Down Expand Up @@ -4314,6 +4359,9 @@ def _finalize_default_style_recording(self):
def enable_virtualcam(self, backend=False):
"""Starts the pyvirtualcam device."""

# Reset the circuit breaker latch when an explicit start is requested
self._virtcam_error_latch = False

# Guard: Only run if the user has actually enabled the virtual cam
if not self.main_window.control.get("SendVirtCamFramesEnableToggle", False):
# Ensure it's also disabled if the toggle is off
Expand Down Expand Up @@ -4378,15 +4426,22 @@ def enable_virtualcam(self, backend=False):
except Exception as e:
if attempt == 0:
# First attempt failed (driver may still be releasing the handle).
# Wait longer and try once more before giving up.
print(f"[WARN] Virtual camera open failed (attempt 1): {e}. Retrying in 500 ms")
print(
f"[WARN] Virtual camera open failed (attempt 1): {e}. Retrying in 500 ms"
)
time.sleep(0.5)
else:
# Second attempt failed. Trip the circuit breaker to prevent infinite loop.
print(f"[ERROR] Failed to enable virtual camera: {e}")
self.virtcam = None
self._virtcam_error_latch = True

def disable_virtualcam(self):
"""Stops the pyvirtualcam device."""
# Also reset the error latch when explicitly turning it off,
# allowing a fresh try next time the user turns it on.
self._virtcam_error_latch = False

if self.virtcam:
print(f"[INFO] Disabling virtual camera '{self.virtcam.device}'.")
try:
Expand Down Expand Up @@ -4779,8 +4834,7 @@ def finalize_segment_concatenation(self):

# Add suffix only for real read-error stops.
has_real_read_errors = (
int(self.read_error_skip_count) > 0
or int(self.consecutive_read_errors) > 0
int(self.read_error_skip_count) > 0 or int(self.consecutive_read_errors) > 0
)
if self.stopped_by_error_limit and has_real_read_errors:
path_obj = Path(final_file_path)
Expand Down Expand Up @@ -5144,16 +5198,38 @@ def process_webcam(self):
f"[INFO] Init Webcam: Device={webcam_index}, Backend={backend_name}, Target={target_width}x{target_height} @ {target_fps}fps"
)

# 2. Initialize VideoCapture with the selected Backend
if self.media_capture:
misc_helpers.release_capture(self.media_capture)
self.media_capture = None
# 2. Initialize VideoCapture with the selected Backend (Prevent Race Condition)
reinitialize_needed = True

try:
self.media_capture = cv2.VideoCapture(webcam_index, backend_id)
except Exception as e:
print(f"[ERROR] Failed to init webcam with backend {backend_name}: {e}")
self.media_capture = cv2.VideoCapture(webcam_index)
# Determine if we can safely reuse the existing capture
if self.media_capture and self.media_capture.isOpened():
selected_btn = getattr(self.main_window, "selected_video_button", None)
from app.ui.widgets import widget_components

if isinstance(
selected_btn, widget_components.TargetMediaCardButton
) and getattr(selected_btn, "is_webcam", False):
if (
selected_btn.webcam_index == webcam_index
and selected_btn.webcam_backend == backend_id
):
reinitialize_needed = False
print(
"[INFO] Reusing existing webcam capture to prevent hardware lock issues."
)

if reinitialize_needed:
if self.media_capture:
misc_helpers.release_capture(self.media_capture)
self.media_capture = None
# CRITICAL: Wait for OS driver hardware lock to fully release
time.sleep(0.5)

try:
self.media_capture = cv2.VideoCapture(webcam_index, backend_id)
except Exception as e:
print(f"[ERROR] Failed to init webcam with backend {backend_name}: {e}")
self.media_capture = cv2.VideoCapture(webcam_index)

if not (self.media_capture and self.media_capture.isOpened()):
print("[ERROR] Unable to open webcam source.")
Expand Down
4 changes: 3 additions & 1 deletion app/processors/video_utils/video_encoding.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,9 @@ def extract_audio_segments(
audio_files = []
for idx, (start_frame, end_frame) in enumerate(segments):
if fps > 0:
start_time = time_offset_sec + max(0.0, (start_frame - frame_origin) / fps)
start_time = time_offset_sec + max(
0.0, (start_frame - frame_origin) / fps
)
end_time = time_offset_sec + max(
0.0, ((end_frame + 1) - frame_origin) / fps
)
Expand Down
16 changes: 10 additions & 6 deletions app/processors/workers/frame_worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,19 +490,23 @@ def process_and_emit_task(self):
traceback.print_exc()
# Emit the original (unprocessed) frame as a fallback so the recording
# metronome is never blocked waiting for a frame that will never arrive.
# Only do this for pool/video mode — single-frame and webcam have their
# own error recovery paths.
# Only do this for pool/video mode AND webcam mode — single-frame has its
# own error recovery path.
if (
not self.stop_event.is_set()
and not self.is_single_frame
and self.video_processor.file_type != "webcam"
and isinstance(_fallback_frame_rgb, np.ndarray)
):
try:
fallback_bgr = np.ascontiguousarray(_fallback_frame_rgb[..., ::-1])
self.video_processor.frame_processed_signal.emit(
self.frame_number, fallback_bgr
)
if self.video_processor.file_type == "webcam":
self.video_processor.webcam_frame_processed_signal.emit(
fallback_bgr
)
else:
self.video_processor.frame_processed_signal.emit(
self.frame_number, fallback_bgr
)
except Exception as fb_err:
print(
f"[WARN] Fallback emit also failed for frame "
Expand Down
4 changes: 3 additions & 1 deletion app/ui/widgets/actions/video_control_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ def update_video_time_line_edit(
else:
fps_to_use = fps

total_seconds = max(0.0, float(current_frame_number) / fps_to_use) if fps_to_use > 0 else 0.0
total_seconds = (
max(0.0, float(current_frame_number) / fps_to_use) if fps_to_use > 0 else 0.0
)
minutes = int(total_seconds // 60)
seconds = int(total_seconds % 60)
video_time_line_edit.setText(f"{minutes:02d}:{seconds:02d}")
Expand Down