ffmpeg-skill 1.4.2 → 1.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.2",
3
+ "version": "1.4.3",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -190,3 +190,40 @@ that merge -- no tests, no CodeQL, no release -- and the release only happened w
190
190
  merged. Describe the marker in words in PR bodies and commit messages ("the skip-CI marker"),
191
191
  or wrap it so it does not match, and after any merge that touches CI check that the push
192
192
  actually triggered the expected runs.
193
+
194
+ ### Two merges minutes apart: the first release run bumps on a stale main and its push is rejected
195
+
196
+ Found on 1.4.2 (2026-09-11). #167 (fix) merged, then #171 (docs) a minute later while the
197
+ release run for #167 was still bumping. The concurrency group serialises the runs, but a run
198
+ checks out the SHA that triggered it, so the first run's bump commit sat behind #171's merge
199
+ and `git push origin HEAD:main` was rejected as non-fast-forward. The second run (for #171)
200
+ then found both PRs unreleased and published 1.4.2 correctly, so nothing was lost -- one red
201
+ run and a confusing timeline. release.yml now checks out `ref: main` and rebases the bump on
202
+ main right before pushing. Lesson: a workflow that pushes to the branch that triggered it must
203
+ start from the branch tip, not from the triggering commit.
204
+
205
+ ### "Clean up the partial output on failure" deleted the user's file
206
+
207
+ Found by the 1.4.2 review (2026-09-12), present since #78 (2026-09-07). The cleanup that removes a
208
+ 0-byte stray after a failed encode keyed on "output path exists after failure", which is also
209
+ true of a deliverable that was there before the run and that ffmpeg never opened (a bad filter
210
+ argument fails at graph init, before the muxer touches the output -- on 6.1+). The --overwrite
211
+ consent added in #163 guards the success path only; the failure path had its own delete. The
212
+ first fix (snapshot size/mtime, leave an unchanged file alone) passed on 6.1 and failed in the
213
+ 5.1.1 CI job: FFmpeg 5.x opens (truncates) the output during option parsing, before any filter
214
+ initialises, so ffmpeg itself had already destroyed the file. The fix that holds on every
215
+ version is to never let ffmpeg write to an existing path: run against a hidden sibling temp
216
+ file and os.replace() it over the original on success only. Lessons: a destructive step must
217
+ know whether it created the thing it is about to destroy, "exists" is not that knowledge; and
218
+ "the tool fails before touching the file" is a version-specific fact, never a guarantee. And the second review found what the first one -- which had
219
+ just written the overwrite guard next to this code -- did not: a reviewer who wrote the fix
220
+ reads the file they fixed, not the one beside it.
221
+
222
+ ### --timeout only worked when ffmpeg was talking
223
+
224
+ Same review. The --progress runner iterated the progress pipe and compared the clock per
225
+ line, so the one case the timeout exists for (a deadlocked ffmpeg, which prints nothing)
226
+ never reached the comparison. The non-progress path used subprocess.run(timeout=) and was
227
+ fine, and the test only exercised that path. Lesson: a deadline belongs on a clock the loop
228
+ wakes up to check, never on the arrival of the thing you are waiting for; and a test for
229
+ "hang" must use a shim that actually hangs silently, not one that fails fast.
@@ -199,8 +199,8 @@ class Context:
199
199
  it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
200
200
  """
201
201
 
202
- __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
203
- _KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written")
202
+ __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
203
+ _KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
204
204
 
205
205
  def __init__(self) -> None:
206
206
  self.reset()
@@ -215,6 +215,7 @@ class Context:
215
215
  self.timeout: float = _env_timeout() # seconds per ffmpeg invocation, 0 = none
216
216
  self.overwrite = False # --overwrite: an existing output may be replaced
217
217
  self.written: set = set() # output paths this process has written itself
218
+ self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
218
219
 
219
220
  # mapping-style access kept for backwards compatibility
220
221
  def __getitem__(self, key: str) -> Any:
@@ -300,8 +301,19 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
300
301
  if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
301
302
  return
302
303
  try:
303
- if os.path.exists(output):
304
- os.remove(output)
304
+ if not os.path.exists(output):
305
+ return
306
+ # A file that was already there before this command ran is someone's deliverable, not
307
+ # our partial. If ffmpeg died before opening it (bad filter argument, unreadable input:
308
+ # the common case) it is byte-for-byte what it was, so leave it alone. Only when ffmpeg
309
+ # did open and truncate it (size or mtime changed) is what remains a partial of ours,
310
+ # and the original is already gone either way; then removing it is still right.
311
+ before = STATE.preexisting.get(os.path.realpath(output))
312
+ if before is not None:
313
+ st = os.stat(output)
314
+ if (st.st_size, st.st_mtime_ns) == before:
315
+ return
316
+ os.remove(output)
305
317
  except OSError:
306
318
  pass
307
319
 
@@ -348,7 +360,7 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
348
360
  2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
349
361
  process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
350
362
  output = cmd[-1]
351
- if STATE.overwrite or output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
363
+ if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
352
364
  return
353
365
  try:
354
366
  exists = os.path.isfile(output)
@@ -357,6 +369,13 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
357
369
  return
358
370
  if not exists or real in STATE.written:
359
371
  return
372
+ try:
373
+ st = os.stat(output)
374
+ STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
375
+ except OSError:
376
+ pass
377
+ if STATE.overwrite:
378
+ return
360
379
  if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
361
380
  die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
362
381
  info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
@@ -380,12 +399,40 @@ def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
380
399
  code=124, kind="timeout")
381
400
 
382
401
 
402
+ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str], Optional[str]]:
403
+ """When the output path already holds someone's file, run ffmpeg against a hidden sibling
404
+ temp path and move it over the original only on success.
405
+
406
+ ffmpeg's -y truncates the output the moment it opens it, and *when* it opens it depends on
407
+ the version: 6.1+ initialises the filter graph first (a bad LUT fails before the file is
408
+ touched), 5.x opens the output during option parsing, before any filter runs, so the same
409
+ bad LUT leaves a 0-byte file where the deliverable was. No amount of post-failure cleanup
410
+ can undo that; the only way to keep an existing file safe across a failed run is for ffmpeg
411
+ never to write to it. Same directory, same extension (the muxer is chosen by it), hidden
412
+ name, so nothing else changes for the encoder. Returns (command to execute, final path,
413
+ temp path); (cmd, None, None) when no staging is needed."""
414
+ output = cmd[-1]
415
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
416
+ return list(cmd), None, None
417
+ try:
418
+ if not os.path.isfile(output) or os.path.realpath(output) in STATE.written:
419
+ return list(cmd), None, None
420
+ except OSError:
421
+ return list(cmd), None, None
422
+ d, base = os.path.split(output)
423
+ stem, ext = os.path.splitext(base)
424
+ tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
425
+ return list(cmd[:-1]) + [tmp], output, tmp
426
+
427
+
383
428
  def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
384
429
  """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
385
430
 
386
431
  ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
387
432
  (a fake successful CompletedProcess is returned so scripts can keep planning), and run
388
- with a progress readout under --progress. ffprobe and other tools always run.
433
+ with a progress readout under --progress. ffprobe and other tools always run. An output
434
+ path that already exists is written through a temp file and replaced only on success
435
+ (see _stage_existing_output), so a failed run never costs the caller the file that was there.
389
436
  """
390
437
  is_ffmpeg = _is_ffmpeg(cmd)
391
438
  if is_ffmpeg:
@@ -396,9 +443,22 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
396
443
  info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
397
444
  if STATE.dry_run and is_ffmpeg:
398
445
  return subprocess.CompletedProcess(list(cmd), 0, "", "")
399
- if STATE.progress and is_ffmpeg and cmd[-1] != "-":
400
- return _run_with_progress(list(cmd), check)
401
- return _run_captured(list(cmd), check)
446
+ exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
447
+ if STATE.progress and is_ffmpeg and exec_cmd[-1] != "-":
448
+ proc = _run_with_progress(exec_cmd, check)
449
+ else:
450
+ proc = _run_captured(exec_cmd, check)
451
+ if final and tmp:
452
+ if proc.returncode == 0:
453
+ try:
454
+ os.replace(tmp, final)
455
+ except OSError as e:
456
+ _cleanup_partial_output(exec_cmd)
457
+ die(f"could not replace {final} with the new output: {e}", kind="output")
458
+ _remember_output(cmd)
459
+ else:
460
+ _cleanup_partial_output(exec_cmd)
461
+ return proc
402
462
 
403
463
 
404
464
  def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
@@ -455,22 +515,57 @@ def _progress_line(done: float, total: float, elapsed: float) -> str:
455
515
 
456
516
 
457
517
  def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
458
- """Run ffmpeg with -progress on a pipe and print percent/ETA to stderr."""
518
+ """Run ffmpeg with -progress on a pipe and print percent/ETA to stderr.
519
+
520
+ The time limit is checked on a clock, not per progress line: a deadlocked ffmpeg (the very
521
+ case --timeout exists for) prints nothing, so a loop that only looked at the deadline when a
522
+ line arrived waited on it forever. Reader threads drain both pipes; the main loop wakes at
523
+ least twice a second to compare the clock against the limit."""
524
+ import queue
525
+ import threading
459
526
  import time
460
527
  total = STATE.duration_hint or 0.0
461
528
  full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
462
529
  t0 = time.time()
463
530
  limit = _limit_for(cmd)
464
531
  proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
532
+ assert proc.stdout is not None and proc.stderr is not None
533
+ lines: "queue.Queue[Optional[str]]" = queue.Queue()
534
+ err_chunks: List[str] = []
535
+
536
+ def pump_out() -> None:
537
+ for line in proc.stdout: # type: ignore[union-attr]
538
+ lines.put(line)
539
+ lines.put(None)
540
+
541
+ def pump_err() -> None:
542
+ err_chunks.append(proc.stderr.read()) # type: ignore[union-attr]
543
+
544
+ threading.Thread(target=pump_out, daemon=True).start()
545
+ err_thread = threading.Thread(target=pump_err, daemon=True)
546
+ err_thread.start()
465
547
  last = ""
466
- assert proc.stdout is not None
467
- for line in proc.stdout:
468
- if limit and time.time() - t0 > limit:
469
- proc.kill()
470
- proc.communicate()
471
- if last:
472
- sys.stderr.write("\r" + " " * len(last) + "\r")
473
- _timed_out(cmd, limit)
548
+
549
+ def clear_line() -> None:
550
+ if last:
551
+ sys.stderr.write("\r" + " " * len(last) + "\r")
552
+
553
+ def timed_out() -> None:
554
+ proc.kill()
555
+ proc.wait()
556
+ clear_line()
557
+ _timed_out(cmd, limit or 0)
558
+
559
+ while True:
560
+ remaining = (limit - (time.time() - t0)) if limit else None
561
+ if remaining is not None and remaining <= 0:
562
+ timed_out()
563
+ try:
564
+ line = lines.get(timeout=min(0.5, remaining) if remaining is not None else 0.5)
565
+ except queue.Empty:
566
+ continue
567
+ if line is None:
568
+ break
474
569
  if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
475
570
  try:
476
571
  done = int(line.split("=")[1]) / 1_000_000
@@ -482,13 +577,12 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
482
577
  sys.stderr.flush()
483
578
  last = msg
484
579
  try:
485
- _, err = proc.communicate(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
580
+ proc.wait(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
486
581
  except subprocess.TimeoutExpired:
487
- proc.kill()
488
- proc.communicate()
489
- _timed_out(cmd, limit or 0)
490
- if last:
491
- sys.stderr.write("\r" + " " * len(last) + "\r")
582
+ timed_out()
583
+ err_thread.join()
584
+ err = "".join(err_chunks)
585
+ clear_line()
492
586
  if proc.returncode == 0:
493
587
  _remember_output(cmd)
494
588
  if proc.returncode != 0: