ffmpeg-skill 3.8.16 → 3.8.17

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.
@@ -282,16 +282,32 @@ def _slice_atom(atom: str, max_em: float) -> "List[str]":
282
282
  No dictionary, no hyphenation library: an existing hyphen is preferred as the cut (the same
283
283
  break R1 already allows), then whatever is left is cut again at exactly the widest prefix
284
284
  that still measures within `max_em`, one character at a time. The characters themselves are
285
- never rewritten -- every piece concatenates back to the original atom exactly."""
286
- if text_width_em(atom) <= max_em:
287
- return [atom]
288
- for i in range(len(atom) - 2, 0, -1):
289
- # latest hyphen whose left side still fits -- keeps the left half as large as possible
290
- if atom[i] in _HYPHENS and text_width_em(atom[:i + 1]) <= max_em:
291
- return [atom[:i + 1]] + _slice_atom(atom[i + 1:], max_em)
285
+ never rewritten -- every piece concatenates back to the original atom exactly.
286
+
287
+ Iterative, and each piece measures only the prefixes up to the first that is too wide (a
288
+ prefix is never narrower than a shorter one): the recursive form measured every prefix of the
289
+ remaining atom for each piece it cut, so a 1 KB hyphenated cue took seconds and 4 KB minutes
290
+ (A11, #465), in caption.py's own process, which --timeout (a limit on each ffmpeg call) does
291
+ not bound. The pieces are the ones the recursive form cut."""
292
292
  pieces: "List[str]" = []
293
+ rest = atom
294
+ while True:
295
+ # latest hyphen whose left side still fits -- keeps the left half as large as possible;
296
+ # a rest whose every prefix fits, itself included, is the last piece
297
+ cut = 0
298
+ for end in range(1, len(rest) + 1):
299
+ if text_width_em(rest[:end]) > max_em:
300
+ break
301
+ if 1 <= end - 1 <= len(rest) - 2 and rest[end - 1] in _HYPHENS:
302
+ cut = end
303
+ else:
304
+ return pieces + [rest]
305
+ if not cut:
306
+ break
307
+ pieces.append(rest[:cut])
308
+ rest = rest[cut:]
293
309
  cur = ""
294
- for ch in atom:
310
+ for ch in rest:
295
311
  candidate = cur + ch
296
312
  if cur and text_width_em(candidate) > max_em:
297
313
  pieces.append(cur)
@@ -436,6 +436,7 @@ ERROR_REASONS = {
436
436
  "output_is_directory": "the output path is a directory; details: {output}",
437
437
  "media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
438
438
  "media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
439
+ "still_unreadable": "an input read by an image demuxer (a .png, .jpg, ... name) has a picture stream with no frame size: the file is not an image ffmpeg can decode (a text file named .png); details: {path, format, codec}",
439
440
  "media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
440
441
  "join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
441
442
  "output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
@@ -446,7 +447,7 @@ ERROR_REASONS = {
446
447
  "output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
447
448
  "output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
448
449
  "timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
449
- "output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
450
+ "output_locked": "another run holds the output's lock, or is taking a stale one over (why: takeover_in_progress), or a stale lock cannot be taken over safely because the filesystem gives no OS file lock (why: takeover_unavailable: delete it by hand); details: {lock, output, pid, pid_alive, deadline, why}",
450
451
  "plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
451
452
  "inputs: [{path, status: unchanged|changed|missing, expected: {size, sha256_head_tail}, actual: {size, "
452
453
  "sha256_head_tail} | \"missing\"}], unbound_inputs, audit_log}",
@@ -1943,6 +1944,11 @@ MCP_PROMPTS: List[Dict[str, Any]] = [
1943
1944
  "to keep HDR and only drop the Dolby Vision layer, when strip_dovi is asked for). A flat, washed-out "
1944
1945
  "look after a re-encode usually means HDR metadata was lost upstream; `probe` shows `hdr: true` "
1945
1946
  "only for a real PQ/HLG/Dolby Vision signal, and `bt2020_or_hdr: true` also for BT.2020 SDR."),
1947
+ # A23 (#465): strip_dovi was taken and changed nothing; true now asks for the other recipe
1948
+ "flags": {"strip_dovi": (
1949
+ "Keep {input} HDR and drop only its Dolby Vision layer: `color.py {input} --strip-dovi`, a stream copy "
1950
+ "(HEVC only) that removes the Dolby Vision RPU so players use the HLG/HDR10 base layer. A source with no "
1951
+ "Dolby Vision metadata gets a `dovi_not_detected` warning.")},
1946
1952
  },
1947
1953
  ]
1948
1954
 
@@ -1968,7 +1974,17 @@ def mcp_prompt_get(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
1968
1974
  fields: Dict[str, str] = {a["name"]: str((arguments or {}).get(a["name"]) or a.get("default", "")) for a in prompt["arguments"]}
1969
1975
  for opt_name, clause in prompt["optional"].items():
1970
1976
  fields[opt_name] = clause.format(**fields) if fields.get(opt_name) else ""
1971
- text = prompt["template"].format(**fields)
1977
+ template = prompt["template"]
1978
+ for flag, flag_template in (prompt.get("flags") or {}).items():
1979
+ # a yes/no argument picks the recipe; every prompt argument arrives as a string, so
1980
+ # "false" is no, and a value that is neither is a caller mistake (ValueError, as above)
1981
+ value = (arguments or {}).get(flag)
1982
+ word = str(value).strip().lower() if value is not None else ""
1983
+ if word in ("true", "1", "yes"):
1984
+ template = flag_template
1985
+ elif word not in ("", "false", "0", "no"):
1986
+ raise ValueError(f"prompt {name!r}: {flag} must be true or false, got {value!r}")
1987
+ text = template.format(**fields)
1972
1988
  return {"description": prompt["description"], "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
1973
1989
 
1974
1990
 
package/scripts/batch.py CHANGED
@@ -699,10 +699,15 @@ def main() -> int:
699
699
  created = [d for d in (outdir, work) if not d.exists()]
700
700
  outdir.mkdir(parents=True, exist_ok=True)
701
701
  work.mkdir(parents=True, exist_ok=True)
702
- if STATE.dry_run and created:
702
+ # a real run that is refused before any item starts (--jobs, an output collision, a final path
703
+ # that is an input) leaves nothing behind either (A187, #465: it left out/ and out/.work)
704
+ item_started = {"item": False}
705
+ if created:
703
706
  import atexit
704
707
 
705
708
  def _remove_empty_dirs() -> None:
709
+ if not STATE.dry_run and item_started["item"]:
710
+ return
706
711
  for d in sorted(created, key=lambda p: len(str(p)), reverse=True):
707
712
  try:
708
713
  if not any(d.iterdir()):
@@ -850,6 +855,7 @@ def main() -> int:
850
855
  def run_item(i: int, src: Path) -> Dict[str, Any]:
851
856
  """process() in the item's own work directory, which goes when it ends, however it ends."""
852
857
  import shutil
858
+ item_started["item"] = True
853
859
  iw = item_work(i, src)
854
860
  try:
855
861
  return process(src, recipe, outdir, iw, deadline)
@@ -55,7 +55,7 @@ from _common import asr as _asr
55
55
  from _common.asr import ENGINE_CHOICES
56
56
  from _common.emit import warn
57
57
  from _common.probe import display_size, square_pixels, square_pixels_note
58
- from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
58
+ from _common.runner import carried_stream_maps, refuse_output_is_input, same_file, subtitle_codec_for, subtitle_container_holds
59
59
  from _common.fonts import font_choice, record_font_choice
60
60
  from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
61
61
  write_srt)
@@ -1586,9 +1586,12 @@ def main() -> int:
1586
1586
  up_front.append((args.write_ass or os.path.splitext(planned_out)[0] + ".ass", "ASS"))
1587
1587
  if args.ass and args.offset and os.path.exists(args.ass):
1588
1588
  up_front.append((os.path.splitext(planned_out)[0] + "_offset.ass", "offset ASS"))
1589
- # -o naming the input itself keeps its own, more specific refusal (_check_no_overwrite_input)
1590
- if os.path.realpath(planned_out) != os.path.realpath(args.input):
1591
- up_front.append((planned_out, "video"))
1589
+ # -o naming the input (the same name, a hard link, another case on a case-insensitive
1590
+ # volume) is refused here, before any sidecar or transcript is written: run()'s own guard
1591
+ # came after the .srt was already beside it (review of #530)
1592
+ if same_file(planned_out, args.input):
1593
+ refuse_output_is_input(planned_out, args.input, why="the captioned video would replace the source")
1594
+ up_front.append((planned_out, "video"))
1592
1595
  claim(up_front)
1593
1596
  if args.transcribe:
1594
1597
  # the sidecar goes next to the output like the --text one, not into the source folder
@@ -219,8 +219,11 @@ def main() -> int:
219
219
  args = ap.parse_args()
220
220
  apply_common(args)
221
221
  if args.auto_chapters: # the only mode that writes them
222
- guard_sidecar(args.chapters_out, args.input, siblings=(args.description_out, args.output))
223
- guard_sidecar(args.description_out, args.input, siblings=(args.output,))
222
+ # against the video this run writes, the default name too (A6, #465: --chapters-out at
223
+ # input_meta.mp4 wrote the chapter text over the video it had just written)
224
+ video_out = args.output or default_output(args.input, "meta", Path(args.input).suffix.lstrip(".") or "mp4")
225
+ guard_sidecar(args.chapters_out, args.input, siblings=(args.description_out, video_out))
226
+ guard_sidecar(args.description_out, args.input, siblings=(video_out,))
224
227
 
225
228
  if args.chapters and args.clear_chapters:
226
229
  die("--chapters and --clear-chapters exclude each other")
package/scripts/render.py CHANGED
@@ -76,7 +76,7 @@ from export import PRESETS, PLATFORM_OF
76
76
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
77
77
  from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit as _emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
78
78
  from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, owned_identity, read_json, remember_written
79
- from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
79
+ from _common.emit import absorb_stage, audit, is_output_shape_flag, record_fallback, record_skip, refuse, stderr_records, warn
80
80
  from _common.decision import rebase_project_paths
81
81
  import subprocess
82
82
  from _contract import CONTRACT_VERSION
@@ -405,9 +405,7 @@ def render_pack(names: List[str], args) -> int:
405
405
  doc = {}
406
406
  # the child ran with --json, so its planned commands come back in the document: the pack
407
407
  # is the one path that writes seven files, and --dry-run has to show all of them
408
- for line in proc.stderr.splitlines():
409
- if line.startswith("$ ") or line.startswith("[dry-run]"):
410
- STATE.commands.append(line[2:] if line.startswith("$ ") else line)
408
+ STATE.commands.extend(text for is_command, text in stderr_records(proc.stderr) if is_command)
411
409
  STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
412
410
  absorb_stage(name, doc)
413
411
  out = doc.get("output") or dest_out
@@ -806,9 +804,9 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
806
804
  cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + own + child_args() + ["--json"]
807
805
  info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
808
806
  proc = run_tool(cmd)
809
- for line in proc.stderr.splitlines():
810
- if line.startswith("$ ") or line.startswith("[dry-run]"):
811
- STATE.commands.append(line[2:] if line.startswith("$ ") else line)
807
+ for is_command, line in stderr_records(proc.stderr):
808
+ if is_command:
809
+ STATE.commands.append(line)
812
810
  elif line.strip():
813
811
  info(" " + line)
814
812
  try:
@@ -1212,7 +1210,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1212
1210
  os.chdir(plan["cwd"])
1213
1211
  if not isinstance(plan.get("argv"), list):
1214
1212
  _reject_plan(path, digest, "plan_argv_invalid", f"{path}: argv must be a list", argv_type=type(plan.get("argv")).__name__)
1215
- argv = [str(a) for a in plan["argv"]]
1213
+ # a plan an earlier version recorded with --json-brief (A181): the tool is run for its full
1214
+ # document, as every plan written now asks for
1215
+ argv = [str(a) for a in plan["argv"] if not is_output_shape_flag(str(a))]
1216
1216
  if STATE.dry_run:
1217
1217
  for c in plan.get("commands") or []:
1218
1218
  STATE.commands.append(c)
@@ -1226,9 +1226,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
1226
1226
  return 0
1227
1227
  info(f"executing plan {path}: {tool} " + " ".join(argv))
1228
1228
  proc = run_tool([str(script)] + argv + child_args() + ["--json"])
1229
- for line in proc.stderr.splitlines():
1230
- if line.startswith("$ "):
1231
- STATE.commands.append(line[2:])
1229
+ for is_command, line in stderr_records(proc.stderr):
1230
+ if is_command and not line.startswith("[dry-run]"):
1231
+ STATE.commands.append(line)
1232
1232
  elif line.strip():
1233
1233
  info(" " + line)
1234
1234
  try:
@@ -29,7 +29,7 @@ from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOU
29
29
  from _common import asr as _asr
30
30
  from _common.emit import warn
31
31
  from _common.asr import _dicts
32
- from _common.runner import parse_json, replace_checked
32
+ from _common.runner import parse_json, replace_checked, same_file
33
33
  from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
34
34
  from _common.emit import refuse
35
35
  from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
@@ -672,7 +672,7 @@ def main() -> int:
672
672
  # --overwrite allows replacing an old map, never the source, the cut or the EDL
673
673
  refuse_output_is_input(args.timemap, *[p for p in (args.input, args.words, args.filler_words) if p])
674
674
  for flag, other in (("-o", args.output), ("--edl", args.edl), ("--plan", getattr(args, "plan", None))):
675
- if other and os.path.realpath(other) == os.path.realpath(args.timemap):
675
+ if other and same_file(other, args.timemap):
676
676
  refuse("input", "timemap_path_taken",
677
677
  f"--timemap {args.timemap} is the same file as {flag}; give the map its own path",
678
678
  timemap=args.timemap, flag=flag, path=other)
package/scripts/verify.py CHANGED
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
  from typing import Any, Dict, List, Optional, Tuple
27
27
 
28
28
  from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe, refuse_output_is_input, MEDIA_EXT as _MEDIA_EXT
29
+ from _common.runner import _stop_child, _unwatch, _watch
29
30
 
30
31
  HERE = Path(__file__).resolve().parent
31
32
  # _common's one list less the stills, as batch.py walks a folder: verify kept a list of its own,
@@ -140,13 +141,24 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
140
141
  if not ok:
141
142
  r.update(kind="verification", reason="hdr_lost", message=r["error"])
142
143
  return r
144
+ child = subprocess.Popen([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE,
145
+ stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
146
+ # watched like run_tool()'s child (review of #530): a SIGTERM or Ctrl-C to verify.py stops the
147
+ # step through stop_children() -- its own handler removes its partial output and lock --
148
+ # instead of leaving it running and writing after verify.py has exited
149
+ _watch(child, [])
143
150
  try:
144
- proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
145
- text=True, encoding="utf-8", errors="replace", timeout=timeout or None)
151
+ out, err = child.communicate(timeout=timeout or None)
152
+ proc = subprocess.CompletedProcess(child.args, child.returncode, out, err)
146
153
  except subprocess.TimeoutExpired:
154
+ # stopped as run_tool() stops a sibling script past its ceiling (#465 A183's sweep): the
155
+ # SIGKILL subprocess.run() sent left the step's partial output and output lock in --out
156
+ _stop_child(child)
147
157
  msg = f"timeout after {timeout:.0f}s"
148
158
  return {"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": msg,
149
159
  "kind": "timeout", "reason": "step_timeout", "message": msg}
160
+ finally:
161
+ _unwatch(child)
150
162
  try:
151
163
  doc = json.loads(proc.stdout)
152
164
  except ValueError:
@@ -198,10 +210,27 @@ def main() -> int:
198
210
  files = collect(args.paths)
199
211
  FORWARD.update(overwrite=STATE.overwrite, fast=STATE.fast)
200
212
  tmp = None
213
+ started = {"step": False}
201
214
  if args.out:
202
215
  # made first, so a --report inside a new --out folder passes the guard below (#413 review)
203
216
  outdir = Path(args.out)
217
+ made = [d for d in (outdir, *outdir.parents) if not d.exists()]
204
218
  outdir.mkdir(parents=True, exist_ok=True)
219
+ if made:
220
+ import atexit
221
+
222
+ def _remove_unused_dirs() -> None:
223
+ # a run refused before its first step (a --report guard) leaves no empty --out
224
+ # folder behind, as batch.py leaves no out/ (#465 A187's sibling); once a step has
225
+ # run the folder is the caller's, empty or not
226
+ if started["step"]:
227
+ return
228
+ for d in made: # deepest first
229
+ try:
230
+ d.rmdir()
231
+ except OSError:
232
+ pass
233
+ atexit.register(_remove_unused_dirs)
205
234
  if args.report:
206
235
  # a file this run writes itself (#408): not one of the media it verifies (a folder's
207
236
  # files included), nor an existing file without --overwrite
@@ -301,6 +330,7 @@ def main() -> int:
301
330
  refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
302
331
 
303
332
  results = []
333
+ started["step"] = True
304
334
  for f, entry, plan, cues in jobs:
305
335
  info(f"=== {f}")
306
336
  if cues: