fix: use ffmpeg -progress pipe for reliable apply progress
This commit is contained in:
1 parent
ed25460af4
commit
8b06eb2e42
2 files changed
+140
-42
No files matched your search
+25
-1
@@ -136,6 +136,8 @@ def main():
|
|||||||
ap.add_argument("--b", default=None, help="Video file B (delayed audio source)")
|
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("--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("--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()
|
args = ap.parse_args()
|
||||||
|
|
||||||
file_a = args.a
|
file_a = args.a
|
||||||
@@ -176,7 +178,29 @@ def main():
|
|||||||
print(f"Output: {out_path}")
|
print(f"Output: {out_path}")
|
||||||
|
|
||||||
cmd = build_ffmpeg_cmd(file_a, file_b, args.lag, out_path)
|
cmd = build_ffmpeg_cmd(file_a, file_b, args.lag, out_path)
|
||||||
print(f"\nRunning: {' '.join(cmd)}")
|
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)
|
r = subprocess.run(cmd)
|
||||||
if r.returncode != 0:
|
if r.returncode != 0:
|
||||||
print(f"ERROR: ffmpeg failed with code {r.returncode}", file=sys.stderr)
|
print(f"ERROR: ffmpeg failed with code {r.returncode}", file=sys.stderr)
|
||||||
|
|||||||
+115
-41
@@ -58,6 +58,69 @@ class ApplySyncWorker(QtCore.QThread):
|
|||||||
def abort(self):
|
def abort(self):
|
||||||
self._abort = True
|
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):
|
def run(self):
|
||||||
import subprocess
|
import subprocess
|
||||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
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,
|
cmd = [sys.executable, apply_script, "--config", self.config,
|
||||||
"--a", self.video_a, "--b", self.video_b,
|
"--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...")
|
self.status.emit("starting apply_sync...")
|
||||||
|
|
||||||
# Probe total frames from file A for progress
|
total_frames, _total_ms = self.probe_video_total(self.video_a)
|
||||||
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
|
|
||||||
|
|
||||||
|
# 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(
|
proc = subprocess.Popen(
|
||||||
cmd, cwd=script_dir,
|
cmd, cwd=script_dir,
|
||||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||||
universal_newlines=True
|
universal_newlines=True, bufsize=1
|
||||||
)
|
)
|
||||||
|
|
||||||
frames_done = 0
|
frames_done = 0
|
||||||
for line in proc.stderr:
|
last_line = ""
|
||||||
|
assert proc.stdout is not None
|
||||||
|
for line in proc.stdout:
|
||||||
if self._abort:
|
if self._abort:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
self.finished.emit(False)
|
self.finished.emit(False)
|
||||||
return
|
return
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
# ffmpeg outputs "frame= 123 fps=..." on stderr
|
if not line:
|
||||||
|
continue
|
||||||
|
last_line = line
|
||||||
if line.startswith("frame="):
|
if line.startswith("frame="):
|
||||||
try:
|
try:
|
||||||
parts = line.split()
|
frames_done = int(line.split("=", 1)[1].strip())
|
||||||
for p in parts:
|
except (ValueError, IndexError):
|
||||||
if p.isdigit():
|
|
||||||
frames_done = int(p)
|
|
||||||
break
|
|
||||||
except Exception:
|
|
||||||
pass
|
pass
|
||||||
self.progress.emit(frames_done, total_frames)
|
self.progress.emit(frames_done, total_frames)
|
||||||
self.status.emit(f"encoding frame {frames_done}/{total_frames or '?'}")
|
elif line.startswith("progress=end"):
|
||||||
elif "error" in line.lower() or "failed" in line.lower():
|
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)
|
self.status.emit(line)
|
||||||
|
|
||||||
rc = proc.wait()
|
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)
|
self.finished.emit(rc == 0)
|
||||||
|
|
||||||
|
|
||||||
@@ -328,18 +386,6 @@ class SyncWindow(QtWidgets.QMainWindow):
|
|||||||
self.btn_apply = btn_apply
|
self.btn_apply = btn_apply
|
||||||
ctr.addWidget(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
|
# volume + playback
|
||||||
ctr2 = QtWidgets.QHBoxLayout()
|
ctr2 = QtWidgets.QHBoxLayout()
|
||||||
lay.addLayout(ctr2)
|
lay.addLayout(ctr2)
|
||||||
@@ -381,6 +427,24 @@ class SyncWindow(QtWidgets.QMainWindow):
|
|||||||
self.ffmpeg_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
|
self.ffmpeg_label.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextSelectableByMouse)
|
||||||
lay.addWidget(self.ffmpeg_label)
|
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
|
# signals
|
||||||
self.slider.valueChanged.connect(self.on_slider)
|
self.slider.valueChanged.connect(self.on_slider)
|
||||||
self.spin_sec.valueChanged.connect(self.on_spin_sec)
|
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):
|
def _on_apply_progress(self, frames_done: int, frames_total: int):
|
||||||
if frames_total > 0:
|
if frames_total > 0:
|
||||||
|
done = min(frames_done, frames_total)
|
||||||
self.apply_progress.setRange(0, frames_total)
|
self.apply_progress.setRange(0, frames_total)
|
||||||
self.apply_progress.setValue(frames_done)
|
self.apply_progress.setValue(done)
|
||||||
pct = frames_done * 100 // frames_total
|
pct = done * 100 // frames_total
|
||||||
self.apply_status.setText(f"encoding frame {frames_done}/{frames_total} ({pct}%)")
|
self.apply_progress.setFormat(f"{pct}% — frame {done}/{frames_total}")
|
||||||
|
self.apply_status.setText(f"encoding frame {done}/{frames_total} ({pct}%)")
|
||||||
else:
|
else:
|
||||||
self.apply_progress.setRange(0, 0) # indeterminate
|
self.apply_progress.setRange(0, 0) # indeterminate
|
||||||
|
self.apply_progress.setFormat(f"frame {frames_done}...")
|
||||||
self.apply_status.setText(f"encoding frame {frames_done}...")
|
self.apply_status.setText(f"encoding frame {frames_done}...")
|
||||||
|
|
||||||
def _on_apply_done(self, success: bool):
|
def _on_apply_done(self, success: bool):
|
||||||
@@ -633,6 +700,13 @@ class SyncWindow(QtWidgets.QMainWindow):
|
|||||||
if HAS_SD:
|
if HAS_SD:
|
||||||
sd.stop()
|
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():
|
def main():
|
||||||
ap = argparse.ArgumentParser()
|
ap = argparse.ArgumentParser()
|
||||||
|
|||||||
Reference in new issue
Block a user