From f5af9c534a96fbf1c0ee4a6070741de896c5b607 Mon Sep 17 00:00:00 2001 From: Honney OC Date: Thu, 17 Sep 2026 18:14:07 +0000 Subject: [PATCH] fix: use ffmpeg -progress pipe for reliable apply progress --- apply_sync.py | 34 ++++++++-- sync_audio_test.py | 156 +++++++++++++++++++++++++++++++++------------ 2 files changed, 144 insertions(+), 46 deletions(-) diff --git a/apply_sync.py b/apply_sync.py index 8ff6337..22f292e 100644 --- a/apply_sync.py +++ b/apply_sync.py @@ -136,6 +136,8 @@ def main(): ap.add_argument("--b", default=None, help="Video file B (delayed audio source)") ap.add_argument("--lag", type=float, required=True, help="Lag in seconds (B relative to A, positive = B delayed)") ap.add_argument("--out", default=None, help="Output file path (overrides config)") + ap.add_argument("--progress", action="store_true", + help="Stream ffmpeg -progress key=value lines to stdout for GUI parsing") args = ap.parse_args() file_a = args.a @@ -176,11 +178,33 @@ def main(): print(f"Output: {out_path}") cmd = build_ffmpeg_cmd(file_a, file_b, args.lag, out_path) - print(f"\nRunning: {' '.join(cmd)}") - r = subprocess.run(cmd) - if r.returncode != 0: - print(f"ERROR: ffmpeg failed with code {r.returncode}", file=sys.stderr) - sys.exit(r.returncode) + print(f"\nRunning: {' '.join(cmd)}", file=sys.stderr, flush=True) + if args.progress: + # Machine-readable progress on stdout: frame=, out_time_ms=, progress=end. + # -nostats suppresses the \r human stats that GUIs cannot parse line-wise. + # Output options must precede the output path (last element). + cmd = cmd[:-1] + ["-progress", "pipe:1", "-nostats", cmd[-1]] + # stderr stays attached to the terminal: ffmpeg's final summary line + # (video:/audio: sizes) must remain visible, only stats are silenced. + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, + universal_newlines=True, bufsize=1) + assert proc.stdout is not None + for line in proc.stdout: + line = line.strip() + if not line: + continue + # Forward only progress protocol lines; keep stdout parseable. + if "=" in line: + print(line, flush=True) + rc = proc.wait() + if rc != 0: + print(f"ERROR: ffmpeg failed with code {rc}", file=sys.stderr) + sys.exit(rc) + else: + r = subprocess.run(cmd) + if r.returncode != 0: + print(f"ERROR: ffmpeg failed with code {r.returncode}", file=sys.stderr) + sys.exit(r.returncode) print(f"\nSynced output: {out_path}") diff --git a/sync_audio_test.py b/sync_audio_test.py index 59e2f6f..6783c4c 100644 --- a/sync_audio_test.py +++ b/sync_audio_test.py @@ -58,6 +58,69 @@ class ApplySyncWorker(QtCore.QThread): def abort(self): self._abort = True + @staticmethod + def _parse_rate(s: str) -> float: + s = (s or "").strip() + if not s or s in ("0/0", "0"): + return 0.0 + if "/" in s: + try: + n, d = s.split("/") + return float(n) / float(d) if float(d) != 0 else 0.0 + except Exception: + return 0.0 + try: + return float(s) + except ValueError: + return 0.0 + + @classmethod + def probe_video_total(cls, path: str) -> tuple[int, int]: + """Fast total frames + duration ms for file A (video is stream-copied). + + Uses duration x fps metadata — no slow -count_frames decode. + Returns (total_frames, total_ms), 0s on failure. + """ + import json as _json + import subprocess as _sp + try: + r = _sp.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=avg_frame_rate,r_frame_rate,nb_frames,duration", + "-show_entries", "format=duration", + "-of", "json", path], + capture_output=True, text=True, timeout=10 + ) + if r.returncode != 0: + return 0, 0 + data = _json.loads(r.stdout) + streams = data.get("streams", [{}]) + s = streams[0] if streams else {} + fmt = data.get("format", {}) + fps = cls._parse_rate(str(s.get("avg_frame_rate", ""))) + if fps <= 0: + fps = cls._parse_rate(str(s.get("r_frame_rate", ""))) + dur = 0.0 + for cand in (s.get("duration"), fmt.get("duration")): + try: + dur = float(cand) + if dur > 0: + break + except (TypeError, ValueError): + continue + total_ms = int(round(dur * 1000)) if dur > 0 else 0 + total_frames = 0 + if dur > 0 and fps > 0: + total_frames = int(round(dur * fps)) + if total_frames <= 0: + try: + total_frames = int(s.get("nb_frames") or 0) + except (TypeError, ValueError): + total_frames = 0 + return max(0, total_frames), max(0, total_ms) + except Exception: + return 0, 0 + def run(self): import subprocess script_dir = os.path.dirname(os.path.abspath(__file__)) @@ -65,52 +128,47 @@ class ApplySyncWorker(QtCore.QThread): cmd = [sys.executable, apply_script, "--config", self.config, "--a", self.video_a, "--b", self.video_b, - "--lag", f"{self.lag_sec:.6f}"] + "--lag", f"{self.lag_sec:.6f}", "--progress"] self.status.emit("starting apply_sync...") - # Probe total frames from file A for progress - total_frames = 0 - try: - r = subprocess.run( - ["ffprobe", "-v", "error", "-count_frames", - "-select_streams", "v:0", "-show_entries", "stream=nb_read_frames", - "-of", "default=noprint_wrappers=1:nokey=1", self.video_a], - capture_output=True, text=True, timeout=15 - ) - if r.returncode == 0: - total_frames = int(r.stdout.strip()) - except Exception: - pass + total_frames, _total_ms = self.probe_video_total(self.video_a) + # Single merged pipe: apply_sync forwards ffmpeg -progress key=value + # lines on stdout; human text has no "=" and is skipped for progress. proc = subprocess.Popen( cmd, cwd=script_dir, - stdout=subprocess.PIPE, stderr=subprocess.PIPE, - universal_newlines=True + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + universal_newlines=True, bufsize=1 ) frames_done = 0 - for line in proc.stderr: + last_line = "" + assert proc.stdout is not None + for line in proc.stdout: if self._abort: proc.kill() self.finished.emit(False) return line = line.strip() - # ffmpeg outputs "frame= 123 fps=..." on stderr + if not line: + continue + last_line = line if line.startswith("frame="): try: - parts = line.split() - for p in parts: - if p.isdigit(): - frames_done = int(p) - break - except Exception: + frames_done = int(line.split("=", 1)[1].strip()) + except (ValueError, IndexError): pass self.progress.emit(frames_done, total_frames) - self.status.emit(f"encoding frame {frames_done}/{total_frames or '?'}") - elif "error" in line.lower() or "failed" in line.lower(): + elif line.startswith("progress=end"): + self.progress.emit(total_frames or frames_done, + total_frames or frames_done) + elif "=" not in line and ("error" in line.lower() or "failed" in line.lower()): self.status.emit(line) rc = proc.wait() + # Keep ffmpeg's final summary line visible in the launching terminal. + if last_line: + print(f"[apply_sync] {last_line}", flush=True) self.finished.emit(rc == 0) @@ -328,18 +386,6 @@ class SyncWindow(QtWidgets.QMainWindow): self.btn_apply = btn_apply ctr.addWidget(btn_apply) - self.apply_progress = QtWidgets.QProgressBar() - self.apply_progress.setRange(0, 100) - self.apply_progress.setValue(0) - self.apply_progress.setMaximumWidth(150) - self.apply_progress.setVisible(False) - ctr.addWidget(self.apply_progress) - - self.apply_status = QtWidgets.QLabel("") - self.apply_status.setStyleSheet("color: #aaa; font-size: 11px;") - self.apply_status.setMaximumWidth(300) - ctr.addWidget(self.apply_status) - # volume + playback ctr2 = QtWidgets.QHBoxLayout() lay.addLayout(ctr2) @@ -381,6 +427,24 @@ class SyncWindow(QtWidgets.QMainWindow): self.ffmpeg_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse) lay.addWidget(self.ffmpeg_label) + # ── Apply-sync progress (full-width bottom row) ── + apply_row = QtWidgets.QHBoxLayout() + lay.addLayout(apply_row) + self.apply_progress = QtWidgets.QProgressBar() + self.apply_progress.setRange(0, 100) + self.apply_progress.setValue(0) + self.apply_progress.setTextVisible(True) + self.apply_progress.setVisible(False) + self.apply_progress.setSizePolicy( + QtWidgets.QSizePolicy.Policy.Expanding, + QtWidgets.QSizePolicy.Policy.Fixed) + apply_row.addWidget(self.apply_progress, stretch=1) + + self.apply_status = QtWidgets.QLabel("") + self.apply_status.setStyleSheet("color: #aaa; font-size: 11px;") + self.apply_status.setWordWrap(True) + apply_row.addWidget(self.apply_status, stretch=0) + # signals self.slider.valueChanged.connect(self.on_slider) self.spin_sec.valueChanged.connect(self.on_spin_sec) @@ -552,12 +616,15 @@ class SyncWindow(QtWidgets.QMainWindow): def _on_apply_progress(self, frames_done: int, frames_total: int): if frames_total > 0: + done = min(frames_done, frames_total) self.apply_progress.setRange(0, frames_total) - self.apply_progress.setValue(frames_done) - pct = frames_done * 100 // frames_total - self.apply_status.setText(f"encoding frame {frames_done}/{frames_total} ({pct}%)") + self.apply_progress.setValue(done) + pct = done * 100 // frames_total + self.apply_progress.setFormat(f"{pct}% — frame {done}/{frames_total}") + self.apply_status.setText(f"encoding frame {done}/{frames_total} ({pct}%)") else: self.apply_progress.setRange(0, 0) # indeterminate + self.apply_progress.setFormat(f"frame {frames_done}...") self.apply_status.setText(f"encoding frame {frames_done}...") def _on_apply_done(self, success: bool): @@ -633,6 +700,13 @@ class SyncWindow(QtWidgets.QMainWindow): if HAS_SD: sd.stop() + def closeEvent(self, event): + worker = getattr(self, "_apply_worker", None) + if worker is not None and worker.isRunning(): + worker.abort() + worker.wait(3000) + super().closeEvent(event) + def main(): ap = argparse.ArgumentParser()