ffmpeg-skill 3.1.3 → 3.1.5

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/README.md CHANGED
@@ -1,14 +1,7 @@
1
1
  <p align="center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: dark)" srcset="brand/app-icon-dark.png">
4
- <img src="brand/app-icon.png" width="128" alt="ffmpeg-skill app icon">
5
- </picture>
6
- </p>
7
-
8
- <p align="center">
9
- <picture>
10
- <source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-wordmark-white.svg">
11
- <img src="brand/logo/ffmpeg-skill-wordmark-black.svg" height="48" alt="ffmpeg-skill">
3
+ <source media="(prefers-color-scheme: dark)" srcset="brand/logo/ffmpeg-skill-horizontal-on-dark.svg">
4
+ <img src="brand/logo/ffmpeg-skill-horizontal.svg" height="72" alt="ffmpeg-skill">
12
5
  </picture>
13
6
  </p>
14
7
 
@@ -17,6 +10,7 @@ Claude Code, Cursor, Codex or any MCP client edits your footage with local FFmpe
17
10
  No cloud · No API keys · Python standard library</p>
18
11
 
19
12
  <p align="center">
13
+ <a href="https://kajisho5.github.io/ffmpeg-skill/"><b>Website</b></a> ·
20
14
  <a href="#quick-start"><b>Quick start</b></a> ·
21
15
  <a href="docs/demos.md">Demos</a> ·
22
16
  <a href="#tools">Tools</a> ·
@@ -32,6 +26,10 @@ No cloud · No API keys · Python standard library</p>
32
26
  <a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
33
27
  </p>
34
28
 
29
+ <p align="center">
30
+ <a href="https://kajisho5.github.io/ffmpeg-skill/"><img src="brand/og.png" width="720" alt="ffmpeg-skill v3: give your coding agent a video editor. An agent runs probe, cut, fit, export and check, and the final file is measured: 59.98 s, 1080x1920, 30 fps."></a>
31
+ </p>
32
+
35
33
  ```bash
36
34
  npx ffmpeg-skill
37
35
  ```
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.1.3`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.1.5`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.1.3", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.1.5", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.1.3",
3
+ "version": "3.1.5",
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",
@@ -59,7 +59,7 @@ from _common.runner import (
59
59
  ffmpeg_bsfs, ffmpeg_filters, ffmpeg_list_error, require_filter, filter_missing_hint, ffmpeg_release, ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
60
60
  _odd_dimension_retry, _on_signal, _OutputLock, _pid_dead, place_output, PROBE_TIMEOUT, _progress_line,
61
61
  read_text_or_die, refuse_output_is_input, _remember_output, require_tool, run, run_analysis, _run_captured,
62
- run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, _SIGNALS_INSTALLED, _stage_existing_output,
62
+ run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, still_input_args, _SIGNALS_INSTALLED, _stage_existing_output,
63
63
  STATE, _timed_out, _unwatch, _watch, X264_PRESETS
64
64
  )
65
65
  from _common.emit import (
@@ -212,7 +212,7 @@ __all__ = [
212
212
  "decode_gray_frames", "frame_flow", "label_shot_flow", "MOTION_GRID", "MOTION_SEARCH", "MOTION_STATIC_PX", "MOTION_PAN_SPREAD",
213
213
  "_run_captured", "run_keeping_subtitles", "run_tool", "_run_with_progress", "_SCRIPT_FONT_CACHE",
214
214
  "_script_font_entry", "script_font_for_text", "script_font_status", "_script_font_uncached", "_SCRIPT_RANGES",
215
- "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp",
215
+ "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp", "still_input_args",
216
216
  "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
217
217
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
218
218
  "source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
@@ -127,12 +127,18 @@ class MissingFpsError(ValueError):
127
127
  silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
128
128
 
129
129
 
130
+ def _on_windows() -> bool:
131
+ """Whether a backslash in a path is a separator (Windows) or a character of the name."""
132
+ return os.name == "nt"
133
+
134
+
130
135
  def concat_list_line(path: str) -> str:
131
136
  """One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
132
137
  inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
133
138
  with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
134
139
  name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
135
- escaped = str(path).replace("\\", "/").replace("'", "'\\''")
140
+ # only on Windows: on POSIX the quoted form takes a backslash literally, and it is part of the name (#408)
141
+ escaped = (str(path).replace("\\", "/") if _on_windows() else str(path)).replace("'", "'\\''")
136
142
  return f"file '{escaped}'"
137
143
 
138
144
 
@@ -252,8 +258,8 @@ def escape_filter_path(path: str, *, bind: bool = True) -> str:
252
258
  must survive both passes needs two levels of escaping, so a Windows drive letter `D:/x.srt` is
253
259
  written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
254
260
  ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
255
- Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
256
- never has to be escaped itself; `,`, `;`, `[` and `]` are graph-level characters and survive with
261
+ On Windows backslashes are turned into forward slashes first (ffmpeg accepts them there); on
262
+ POSIX a backslash is part of the name and is escaped through both passes (#408); `,`, `;`, `[` and `]` are graph-level characters and survive with
257
263
  one backslash. `'` is special: the graph parser also treats a quote as the start of a quoted
258
264
  token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
259
265
  filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
@@ -265,11 +271,23 @@ def escape_filter_path(path: str, *, bind: bool = True) -> str:
265
271
  if bind and os.path.isfile(path) and path not in STATE.plan_inputs:
266
272
  STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
267
273
  p = str(Path(path))
268
- p = p.replace("\\", "/")
274
+ if _on_windows():
275
+ p = p.replace("\\", "/")
276
+ else:
277
+ # on POSIX a backslash is a character of the name (#408): turning it into `/` opened a
278
+ # different path, so it is escaped through both passes instead
279
+ p = p.replace("\\", "\\\\\\\\")
269
280
  p = p.replace(":", "\\\\:")
270
281
  p = p.replace("'", "\\\\\\'")
271
282
  for ch in (",", ";", "[", "]"):
272
283
  p = p.replace(ch, "\\" + ch)
284
+ # the graph parser trims unescaped whitespace at either end of a value (#408: a relative
285
+ # " lead.srt" opened "lead.srt"); both passes keep an escaped one
286
+ lead = len(p) - len(p.lstrip())
287
+ trail = len(p) - len(p.rstrip())
288
+ if lead or trail:
289
+ body = p[lead:len(p) - trail] if trail else p[lead:]
290
+ p = "".join("\\\\" + c for c in p[:lead]) + body + "".join("\\\\" + c for c in p[len(p) - trail:] if trail)
273
291
  return p
274
292
 
275
293
 
@@ -474,7 +492,7 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
474
492
  return _x264_raw(crf, preset, keep_bt709)
475
493
  if codec == "hevc":
476
494
  if hdr:
477
- x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
495
+ x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
478
496
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(_clamped_crf("libx265", crf + 2, 51, crf)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
479
497
  "-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
480
498
  params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
@@ -485,7 +503,8 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
485
503
  if "libsvtav1" in ffmpeg_encoders():
486
504
  args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(_clamped_crf("libsvtav1", crf, 63, crf)), "-pix_fmt", pix]
487
505
  if hdr:
488
- args += hdr_tags
506
+ svt = hdr10_svtav1_params(v)
507
+ args += (["-svtav1-params", svt] if svt else []) + hdr_tags
489
508
  elif keep_bt709:
490
509
  params, extra = _sdr_bt709("libsvtav1")
491
510
  args += (["-svtav1-params", params] if params else []) + extra
@@ -508,6 +527,42 @@ def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str
508
527
  return []
509
528
 
510
529
 
530
+ def hdr10_x265_params(v: Dict[str, Any]) -> str:
531
+ """The source's HDR10 static metadata as x265 options (":master-display=...:max-cll=..."), or "".
532
+ FFmpeg before 7.0 does not pass the decoded frames' mastering-display / content-light side data
533
+ to libx265, so without these a 6.1 re-encode wrote PQ video with neither (#401). Given on 7+ too,
534
+ where they restate what FFmpeg would pass. x265 units: chromaticity 0.00002, luminance 0.0001 cd/m2."""
535
+ md, cl = _hdr10_parts(v)
536
+ out = ""
537
+ if md:
538
+ c = lambda k: round(md[k] * 50000)
539
+ out += (f":master-display=G({c('green_x')},{c('green_y')})B({c('blue_x')},{c('blue_y')})R({c('red_x')},{c('red_y')})"
540
+ f"WP({c('white_point_x')},{c('white_point_y')})L({round(md['max_luminance'] * 10000)},{round(md['min_luminance'] * 10000)})")
541
+ if cl:
542
+ out += f":max-cll={cl['max_content']},{cl['max_average']}"
543
+ return out
544
+
545
+
546
+ def hdr10_svtav1_params(v: Dict[str, Any]) -> str:
547
+ """The same for SVT-AV1 (-svtav1-params), which takes chromaticities and cd/m2 as decimals."""
548
+ md, cl = _hdr10_parts(v)
549
+ parts = []
550
+ if md:
551
+ f = lambda k: f"{md[k]:.5f}".rstrip("0").rstrip(".")
552
+ parts.append(f"mastering-display=G({f('green_x')},{f('green_y')})B({f('blue_x')},{f('blue_y')})R({f('red_x')},{f('red_y')})"
553
+ f"WP({f('white_point_x')},{f('white_point_y')})L({f('max_luminance')},{f('min_luminance')})")
554
+ if cl:
555
+ parts.append(f"content-light={cl['max_content']},{cl['max_average']}")
556
+ return ":".join(parts)
557
+
558
+
559
+ def _hdr10_parts(v: Dict[str, Any]) -> "Tuple[Optional[Dict[str, float]], Optional[Dict[str, int]]]":
560
+ st = v.get("hdr10_static") or {}
561
+ if v.get("color_transfer") != "smpte2084":
562
+ return None, None
563
+ return st.get("mastering_display"), st.get("content_light")
564
+
565
+
511
566
  def _clamped_crf(encoder: str, value: int, top: int, requested: int) -> int:
512
567
  """`value` capped at the encoder's maximum CRF, the cap recorded (`crf_clamped`) when it
513
568
  bites: the HEVC HDR line asks x265 for CRF+2, so --quality 50 or 51 there is encoded at 51."""
@@ -667,7 +722,7 @@ def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
667
722
  cs = v.get("color_space") or "bt2020nc"
668
723
  prim = v.get("color_primaries") or "bt2020"
669
724
  trc = v.get("color_transfer") or "arib-std-b67"
670
- x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
725
+ x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1{hdr10_x265_params(v)}" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
671
726
  return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
672
727
  "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
673
728
 
@@ -137,6 +137,30 @@ def _report_dropped_audio_tracks(meta: Dict[str, Any], ctx: "Context") -> None:
137
137
  ctx.notes.append(note_line)
138
138
 
139
139
 
140
+ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None:
141
+ """A PQ source's mastering-display / content-light metadata that a PQ output came out without
142
+ (#401): measured on the written file, so whatever encoder or FFmpeg version dropped it is said.
143
+ An output that is no longer PQ (color.py --to-sdr, an HLG conversion) was converted on purpose."""
144
+ first = getattr(STATE, "first_input", None)
145
+ src = first[2] if first and len(first) > 2 else None
146
+ v = (meta or {}).get("video") or {}
147
+ if not src or v.get("color_transfer") != "smpte2084":
148
+ return
149
+ if len(getattr(STATE, "video_inputs", None) or ()) > 1:
150
+ return # several pictures: the first input's metadata need not be the output's source
151
+ have = v.get("hdr10_static") or {}
152
+ lost = [k for k in ("mastering_display", "content_light") if src.get(k) and not have.get(k)]
153
+ if not lost:
154
+ return
155
+ names = {"mastering_display": "mastering display", "content_light": "content light level"}
156
+ warn("the source's HDR10 " + " and ".join(names[k] for k in lost) + " metadata did not come through to this output "
157
+ "(the encoder or this FFmpeg build did not write it); players may tone-map it differently",
158
+ "hdr10_metadata_dropped", ctx=ctx, dropped=lost, codec=v.get("codec"))
159
+ note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
160
+ if note_line and note_line not in ctx.notes:
161
+ ctx.notes.append(note_line)
162
+
163
+
140
164
  def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
141
165
  ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
142
166
  """die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
@@ -178,8 +202,9 @@ def record_skip(msg: str, code: str, *, ctx: "Optional[Context]" = None, **data:
178
202
 
179
203
 
180
204
  def note(msg: str, ctx: "Optional[Context]" = None) -> None:
181
- """A plain `notes` line that also goes to stderr."""
205
+ """A plain `notes` line that also goes to stderr, made inert as messages are (#400)."""
182
206
  ctx = ctx or STATE
207
+ msg = neutralize(msg)
183
208
  if msg not in ctx.notes:
184
209
  ctx.notes.append(msg)
185
210
  sys.stderr.write(f"{msg}\n")
@@ -682,6 +707,7 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
682
707
  if output and not ctx.dry_run:
683
708
  meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
684
709
  _report_dropped_audio_tracks(meta, ctx)
710
+ _report_dropped_hdr10_metadata(meta, ctx)
685
711
  if ctx.json:
686
712
  doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
687
713
  if meta:
@@ -889,13 +915,22 @@ def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Op
889
915
  platform = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}.get(cleaned[cleaned.index("--preset") + 1])
890
916
  if platform and output and tool != "check":
891
917
  verify.append({"tool": "check", "platform": platform})
918
+ plan_inputs = _plan_inputs(ctx.commands, cleaned, ctx)
919
+ real = os.path.realpath(path)
920
+ # every file the plan names (#413 review): its inputs, side inputs included (an SRT, a still),
921
+ # and the output it promises, a default name too, which apply_common could not know
922
+ for named in plan_inputs + ([output] if output else []):
923
+ if os.path.realpath(named) == real:
924
+ refuse("input", "output_is_input" if named != output else "output_path_taken",
925
+ f"--plan {path!r} is also {'the output' if named == output else 'an input'} of this run "
926
+ f"({named!r}); give the plan its own path", output=path, other=named)
892
927
  doc = {
893
928
  "plan_version": PLAN_VERSION,
894
929
  "created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
895
930
  "tool": tool,
896
931
  "argv": cleaned,
897
932
  "cwd": os.getcwd(),
898
- "inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
933
+ "inputs": [fingerprint(p) for p in plan_inputs],
899
934
  # inputs that did not exist yet, so carry no fingerprint (render.py reports them on execution)
900
935
  "unbound_inputs": _plan_unbound_inputs(ctx.commands, ctx),
901
936
  "commands": list(ctx.commands),
@@ -13,7 +13,7 @@ import statistics
13
13
  from fractions import Fraction
14
14
  from typing import Any, Dict, List, Optional, Sequence, Tuple
15
15
  from _common.emit import die, warn
16
- from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis
16
+ from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
17
17
 
18
18
 
19
19
  def fingerprint(path: str) -> Dict[str, Any]:
@@ -187,7 +187,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
187
187
  die(f"input not found: {path}")
188
188
  ffprobe = require_tool("ffprobe")
189
189
  proc = run(
190
- [ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
190
+ [ffprobe, "-v", "error", *still_input_args(path),
191
+ "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
191
192
  quiet=True,
192
193
  check=False,
193
194
  )
@@ -317,6 +318,10 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
317
318
  "hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
318
319
  "HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
319
320
  "dolby_vision": dovi,
321
+ # HDR10 static metadata (mastering display, content light level) as the encoders take it:
322
+ # FFmpeg before 7 does not hand it from the decoded frames to libx265 / libsvtav1, so the
323
+ # tools pass it explicitly (decision.hdr10_x265_params). None when the source has none.
324
+ "hdr10_static": _hdr10_static(ffprobe, path, video) if trc == "smpte2084" else None,
320
325
  "color_space": video.get("color_space"),
321
326
  "color_primaries": video.get("color_primaries"),
322
327
  "color_transfer": video.get("color_transfer"),
@@ -355,13 +360,76 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
355
360
  "language": (a.get("tags") or {}).get("language"),
356
361
  "title": (a.get("tags") or {}).get("title"),
357
362
  } for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
363
+ if role == "input" and out.get("video") and out["video"].get("codec") \
364
+ and os.path.splitext(path)[1].lower() not in _STILL_EXT:
365
+ # a still (an overlay's logo, an insert card) is not a second picture source (#412 review)
366
+ STATE.video_inputs.add(os.path.realpath(path))
358
367
  if role == "input" and out.get("audio_streams"):
359
368
  STATE.audio_inputs.add(os.path.realpath(path))
360
369
  if role == "input" and getattr(STATE, "first_input", None) is None:
361
- STATE.first_input = (path, len(out.get("audio_streams") or []))
370
+ STATE.first_input = (path, len(out.get("audio_streams") or []),
371
+ (out.get("video") or {}).get("hdr10_static"))
362
372
  return out
363
373
 
364
374
 
375
+ def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
376
+ """A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
377
+ a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
378
+ frame is decoded when either part is missing or incomplete in the stream. Each part is taken
379
+ from wherever it is complete first, the stream before the frame. Chromaticities are CIE 1931
380
+ x/y, luminances cd/m2. None when it has neither."""
381
+ out = _hdr10_parts(stream.get("side_data_list") or [])
382
+ if len(out) < 2:
383
+ # V:0, not v:0: an attached cover picture before the motion video has no HDR side data
384
+ proc = run([ffprobe, "-v", "error", "-select_streams", "V:0", "-read_intervals", "%+#1", "-show_frames",
385
+ "-show_entries", "frame=side_data_list", "-of", "json", path], quiet=True, check=False)
386
+ frames: List[Dict[str, Any]] = []
387
+ if proc.returncode == 0:
388
+ try:
389
+ frames = json.loads(proc.stdout or "{}").get("frames") or []
390
+ except ValueError:
391
+ pass
392
+ for k, v in _hdr10_parts((frames[0].get("side_data_list") or []) if frames else []).items():
393
+ out.setdefault(k, v)
394
+ return out or None
395
+
396
+
397
+ def _hdr10_parts(sides: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
398
+ """The complete parts in one side-data list: a mastering display with every field, a content
399
+ light level with both values; an incomplete record counts as absent."""
400
+ out: Dict[str, Any] = {}
401
+ for sd in sides:
402
+ kind = _side_kind(sd)
403
+ if kind == "mastering" and "mastering_display" not in out:
404
+ vals = {k: _ratio(sd.get(k)) for k in ("red_x", "red_y", "green_x", "green_y", "blue_x", "blue_y",
405
+ "white_point_x", "white_point_y", "min_luminance", "max_luminance")}
406
+ if all(v is not None for v in vals.values()):
407
+ out["mastering_display"] = {k: float(v) for k, v in vals.items()}
408
+ elif kind == "light" and "content_light" not in out:
409
+ mc, ma = _to_int(sd.get("max_content")), _to_int(sd.get("max_average"))
410
+ if mc is not None and ma is not None:
411
+ out["content_light"] = {"max_content": mc, "max_average": ma}
412
+ return out
413
+
414
+
415
+ _STILL_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".gif", ".avif", ".heic")
416
+
417
+
418
+ def _ratio(v: Any) -> "Optional[Fraction]":
419
+ """A side-data value such as "34000/50000" or "0/10000". Unlike _fraction() a zero is a value
420
+ (a mastering display's minimum luminance may be 0 cd/m2); only a missing or malformed one is None."""
421
+ try:
422
+ f = Fraction(str(v))
423
+ except (ValueError, ZeroDivisionError, TypeError):
424
+ return None
425
+ return f if f >= 0 else None
426
+
427
+
428
+ def _side_kind(sd: Dict[str, Any]) -> "Optional[str]":
429
+ t = str(sd.get("side_data_type") or "")
430
+ return "mastering" if t == "Mastering display metadata" else "light" if t == "Content light level metadata" else None
431
+
432
+
365
433
  def _bit_depth(pix_fmt: Optional[str]) -> int:
366
434
  """Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
367
435
  chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
@@ -324,7 +324,7 @@ class Context:
324
324
 
325
325
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
326
326
  "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
327
- "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs")
327
+ "notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs")
328
328
 
329
329
  def __init__(self) -> None:
330
330
  self.reset()
@@ -333,6 +333,7 @@ class Context:
333
333
  self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
334
334
  self.json = False # emit() prints a JSON document instead of the output path
335
335
  self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
336
+ self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
336
337
  self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
337
338
  self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
338
339
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
@@ -440,6 +441,13 @@ def apply_common(args: "argparse.Namespace") -> None:
440
441
  STATE.progress = bool(getattr(args, "progress", False))
441
442
  STATE.fast = bool(getattr(args, "fast", False))
442
443
  STATE.overwrite = bool(getattr(args, "overwrite", False))
444
+ if STATE.plan:
445
+ # the plan is a file this run writes itself: the side-file guards apply (#408), so it
446
+ # never replaces an input, the output or an existing file without --overwrite
447
+ named = [getattr(args, k, None) for k in ("input", "inputs", "reference", "compare")]
448
+ inputs = [p for v in named for p in (v if isinstance(v, (list, tuple)) else [v]) if isinstance(p, str)]
449
+ out = getattr(args, "output", None)
450
+ guard_sidecar(STATE.plan, *inputs, siblings=(out,) if isinstance(out, str) else ())
443
451
  if getattr(args, "timeout", None) is not None:
444
452
  STATE.timeout = max(0.0, float(args.timeout))
445
453
  else:
@@ -682,11 +690,15 @@ def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
682
690
  # while every guard here reads it as a path. Two or more letters, so a Windows
683
691
  # drive letter (`C:\x.mp4`) is a path; `pipe:` is the skill's own stdout/stdin and stays allowed.
684
692
  _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
693
+ # Off Windows there are no drive letters, and ffmpeg reads a one-letter `x:y.mp4` as protocol `x`
694
+ # too (#408: it then failed as "Protocol not found", with no hint that ./x:y.mp4 works)
695
+ _URL_SCHEME_POSIX = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
685
696
 
686
697
 
687
698
  def looks_like_url(value: str) -> bool:
688
699
  """True for a value ffmpeg would open through a protocol rather than as a local file."""
689
- return bool(_URL_SCHEME.match(value)) and not value.startswith("pipe:")
700
+ scheme = _URL_SCHEME if os.name == "nt" else _URL_SCHEME_POSIX
701
+ return bool(scheme.match(value)) and not value.startswith("pipe:")
690
702
 
691
703
 
692
704
  _PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat")
@@ -1704,13 +1716,34 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
1704
1716
  _remember_output(cmd)
1705
1717
 
1706
1718
 
1719
+ # extensions ffmpeg opens through the image2 demuxer, the only one that expands `%d` in a name
1720
+ # and the only one that has a `pattern_type` option (gif, avif, mp4... reject it: "Option
1721
+ # pattern_type not found")
1722
+ _IMAGE2_EXT = (".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff", ".dpx", ".exr", ".tga",
1723
+ ".ppm", ".pgm", ".pbm", ".pam", ".sgi", ".jp2", ".j2k", ".xbm", ".xpm", ".pcx", ".qoi")
1724
+
1725
+
1726
+ def still_input_args(path: str) -> List[str]:
1727
+ """`-pattern_type none` for a still read through image2 whose path has a `%` in it (#408):
1728
+ image2 otherwise reads `logo%d.png` as a numbered sequence and opens `logo1.png`, a file the
1729
+ run's guards and fingerprints never looked at. Empty for every other path, a video or a GIF
1730
+ with a `%` in its name included: their demuxers never expand it and reject the option."""
1731
+ p = str(path)
1732
+ return ["-pattern_type", "none"] if "%" in p and os.path.splitext(p)[1].lower() in _IMAGE2_EXT else []
1733
+
1734
+
1707
1735
  def sibling_temp(dst: str, tag: str) -> str:
1708
1736
  """A hidden path next to `dst`, with its extension (the muxer is chosen by it), for a file
1709
1737
  place_output(..., move=True) renames into place: same directory, so the rename never
1710
1738
  crosses a filesystem."""
1711
1739
  d, base = os.path.split(dst)
1712
1740
  stem, ext = os.path.splitext(base)
1713
- return os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
1741
+ path = os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}{ext}")
1742
+ n = 1
1743
+ while os.path.lexists(path): # a leftover of an earlier run with this pid: not ours to replace
1744
+ n += 1
1745
+ path = os.path.join(d, f".{stem}.ffskill-{tag}-{os.getpid()}-{n}{ext}")
1746
+ return path
1714
1747
 
1715
1748
 
1716
1749
  _DRAWTEXT_TMPDIR: "Optional[str]" = None
@@ -464,7 +464,8 @@ DIAGNOSTIC_CODES = {
464
464
  "crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
465
465
  # fonts and text
466
466
  "emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
467
- "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams); {source, kept, dropped}"),
467
+ "hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
468
+ "audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
468
469
  "grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
469
470
  "scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
470
471
  "look_no_drawtext": ("fallbacks", "look.py on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
package/scripts/batch.py CHANGED
@@ -34,7 +34,7 @@ from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
36
36
  from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw, stop_children
37
- from _common.emit import absorb_stage_hw, audit, note, steps_encoder_report, warn
37
+ from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
38
38
 
39
39
  HERE = Path(__file__).resolve().parent
40
40
  MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
@@ -722,7 +722,7 @@ def main() -> int:
722
722
  why = (" (reencode_reason: " + ", ".join(f"{k} x{v}" for k, v in reasons.items()) + ")") if reasons else ""
723
723
  info(f"cut.py: {copied}/{len(all_cuts)} call(s) stayed fully lossless stream-copy "
724
724
  f"({cut_summary['stream_copy_rate']:.0%}), {len(all_cuts) - copied} re-encoded a range or its --segments join{why}")
725
- unverified = [os.path.basename(r["file"]) for r in results if r.get("ok") and not r.get("verified")]
725
+ unverified = [shown(os.path.basename(r["file"])) for r in results if r.get("ok") and not r.get("verified")]
726
726
  if unverified and not STATE.dry_run:
727
727
  note(f"{len(unverified)} item(s) completed but did not verify everything they wrote (see each row's unverified): "
728
728
  + ", ".join(unverified[:5]) + (" ..." if len(unverified) > 5 else ""))
@@ -44,7 +44,8 @@ from typing import Any, Dict, List, Optional, Tuple
44
44
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
45
  resolve as resolve_platform)
46
46
  from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
47
- from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
47
+ from _common.emit import refuse # noqa: E402
48
+ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
48
49
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
49
50
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
50
51
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -1276,28 +1277,52 @@ def main() -> int:
1276
1277
  # next to the output was replaced on a re-run, even one ffmpeg's check then refused.
1277
1278
  # Side files whose existence depends on the cues (an _adjusted.srt, an emoji-forced ASS) are
1278
1279
  # checked again where they are written, still before that write.
1279
- claimed: set = set()
1280
-
1281
- def claim(paths) -> None:
1282
- todo = [p for p in paths if p and os.path.realpath(p) not in claimed]
1280
+ claimed: Dict[str, str] = {} # real path -> which artifact of this run holds it
1281
+
1282
+ def claim(items) -> None:
1283
+ """Reserve (path, artifact) pairs: the same artifact claiming its path again is a no-op,
1284
+ another artifact of this run claiming it is refused (#413 review: --write-srt and
1285
+ --write-ass naming one file, the SRT then replaced by the ASS)."""
1286
+ todo = []
1287
+ for path, owner in items:
1288
+ if not path:
1289
+ continue
1290
+ real = os.path.realpath(path)
1291
+ held = claimed.get(real)
1292
+ if held is None and os.path.exists(real):
1293
+ # a second name for the same file (a hard link) is the same path (#413 review)
1294
+ held = next((o for r, o in claimed.items()
1295
+ if os.path.exists(r) and os.path.samefile(r, real)), None)
1296
+ if held is None:
1297
+ claimed[real] = owner
1298
+ todo.append(path)
1299
+ elif held != owner:
1300
+ refuse("input", "output_path_taken",
1301
+ f"{path!r} is named for both the {held} and the {owner} this run writes; give each its own path",
1302
+ output=path, other=held)
1283
1303
  refuse_existing_outputs(todo)
1284
- claimed.update(os.path.realpath(p) for p in todo)
1304
+ # and the rest of the side-file guards (#408): never an input, the video, a missing
1305
+ # directory or an option-shaped name, even under --overwrite
1306
+ side_inputs = [q for q in (args.input, getattr(args, "text", None), getattr(args, "ass", None),
1307
+ getattr(args, "brand", None), *(t[0] for t in srt_tracks)) if isinstance(q, str)]
1308
+ for path in todo:
1309
+ guard_sidecar(path, *side_inputs)
1285
1310
 
1286
1311
  planned_out = args.output or (default_output(args.input, "captioned") if args.input else None)
1287
- up_front: List[str] = []
1312
+ up_front: List[Tuple[str, str]] = []
1288
1313
  if args.transcribe:
1289
- up_front.append(args.write_srt or os.path.splitext(planned_out)[0] + ".srt")
1314
+ up_front.append((args.write_srt or os.path.splitext(planned_out)[0] + ".srt", "SRT"))
1290
1315
  elif args.text:
1291
- up_front.append(args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
1292
- else os.path.splitext(args.text)[0] + ".srt"))
1316
+ up_front.append((args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
1317
+ else os.path.splitext(args.text)[0] + ".srt"), "SRT"))
1293
1318
  if args.input:
1294
1319
  if (args.animate != "none" or args.karaoke) and not args.ass:
1295
- up_front.append(args.write_ass or os.path.splitext(planned_out)[0] + ".ass")
1320
+ up_front.append((args.write_ass or os.path.splitext(planned_out)[0] + ".ass", "ASS"))
1296
1321
  if args.ass and args.offset and os.path.exists(args.ass):
1297
- up_front.append(os.path.splitext(planned_out)[0] + "_offset.ass")
1322
+ up_front.append((os.path.splitext(planned_out)[0] + "_offset.ass", "offset ASS"))
1298
1323
  # -o naming the input itself keeps its own, more specific refusal (_check_no_overwrite_input)
1299
1324
  if os.path.realpath(planned_out) != os.path.realpath(args.input):
1300
- up_front.append(planned_out)
1325
+ up_front.append((planned_out, "video"))
1301
1326
  claim(up_front)
1302
1327
  if args.transcribe:
1303
1328
  # the sidecar goes next to the output like the --text one, not into the source folder
@@ -1358,7 +1383,7 @@ def main() -> int:
1358
1383
  adjusted, changed = lay_out(parse_srt(srt_path))
1359
1384
  if changed:
1360
1385
  new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
1361
- claim([new_srt])
1386
+ claim([(new_srt, "adjusted SRT")])
1362
1387
  if STATE.dry_run:
1363
1388
  info(f"[dry-run] would write {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
1364
1389
  else:
@@ -1563,7 +1588,7 @@ def main() -> int:
1563
1588
  if args.karaoke and not getattr(args, "_word_timings", None):
1564
1589
  args._word_timings = whisper_word_timings(srt_path)
1565
1590
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
1566
- claim([ass_path])
1591
+ claim([(ass_path, "ASS")])
1567
1592
  w, h = meta["video"]["width"], meta["video"]["height"]
1568
1593
  if meta["video"].get("rotation") in (90, -90, 270, -270):
1569
1594
  w, h = h, w
@@ -32,7 +32,7 @@ import sys
32
32
  from typing import List, Optional, Tuple
33
33
 
34
34
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
35
- from _common import (require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
35
+ from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
36
36
  color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
37
37
  ffmpeg_base, info, label_chain, load_brand, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
38
38
  drawtext_boxborderw, drawtext_center_align, X264_PRESETS, time_arg, fmt_secs, STATE, drawtext_text_opts,
@@ -648,6 +648,10 @@ def main(force_route: Optional[str] = None) -> int:
648
648
  "overlays": len(emoji_overlays)}
649
649
  ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
650
650
  require_filter("ass", "drawing this text through libass") # before the sidecar is written
651
+ # a file this run writes itself: never the input or the video, a missing directory or an
652
+ # existing file without --overwrite is refused before anything is written (#408)
653
+ guard_sidecar(ass_path, *[q for q in (args.input, getattr(args, "brand", None), getattr(args, "font_file", None))
654
+ if isinstance(q, str)], siblings=(output,))
651
655
  if STATE.dry_run:
652
656
  info(f"[dry-run] would write {ass_path} ({len(elements)} text elements)")
653
657
  else:
package/scripts/insert.py CHANGED
@@ -27,6 +27,7 @@ import argparse
27
27
  import math
28
28
  import sys
29
29
 
30
+ from _common import still_input_args # noqa: E402
30
31
  from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
31
32
 
32
33
 
@@ -115,7 +116,7 @@ def main() -> int:
115
116
  ]
116
117
 
117
118
  output = args.output or default_output(args.input, "insert", "mp4")
118
- cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
119
+ cmd = ffmpeg_base() + ["-loop", "1", *still_input_args(args.input), "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
119
120
  # the SDR line, whatever the image is; its RGB (PNG) or BT.601 (JPEG) pixels are converted to
120
121
  # the BT.709 that line is tagged with
121
122
  cmd += video_args(None, args.crf, args.preset, source=meta)
package/scripts/look.py CHANGED
@@ -17,6 +17,7 @@ from pathlib import Path
17
17
  from typing import List, Optional
18
18
 
19
19
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
20
+ from _common import still_input_args # noqa: E402
20
21
  from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
21
22
  from _common import drawtext_fontfile # noqa: E402
22
23
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
@@ -239,8 +240,8 @@ def main() -> int:
239
240
  tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
240
241
  fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
241
242
  f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
242
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
243
- "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", out]
243
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-ss", f"{sec:.3f}", *still_input_args(args.compare), "-i", args.compare,
244
+ "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", "-update", "1", out]
244
245
  run(cmd)
245
246
  outputs.append(out)
246
247
  elif args.at:
@@ -254,7 +255,7 @@ def main() -> int:
254
255
  # several frames, or -o given as a stem/prefix without an image extension
255
256
  out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
256
257
  stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
257
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
258
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", *still_input_args(args.input), "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", "-update", "1", out]
258
259
  run(cmd)
259
260
  outputs.append(out)
260
261
  else:
@@ -281,7 +282,7 @@ def main() -> int:
281
282
  tc_sheet = tc.replace(timecode_filter(font_prefix), timecode_filter(font_prefix, seek)) if seek else tc
282
283
  vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc_sheet},"
283
284
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
284
- cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
285
+ cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", *still_input_args(args.input), "-i", args.input, "-vf", vf, "-frames:v", "1", "-update", "1", out]
285
286
  run(cmd)
286
287
  outputs.append(out)
287
288
  info(f"contact sheet: {n} frames every {step:.2f}s")
@@ -30,6 +30,7 @@ import sys
30
30
  from typing import List, Optional
31
31
 
32
32
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
33
+ from _common import still_input_args # noqa: E402
33
34
  from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
34
35
  from _common.emit import warn
35
36
  from _common.fonts import font_choice, record_font_choice
@@ -372,7 +373,7 @@ def main() -> int:
372
373
  if enable:
373
374
  ov += f":enable='{enable}'"
374
375
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
375
- cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
376
+ cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
376
377
  # the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
377
378
  # RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
378
379
  # 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
package/scripts/render.py CHANGED
@@ -342,6 +342,11 @@ def render_pack(names: List[str], args) -> int:
342
342
  and platform check), so the pack reports per-platform results rather than one verdict."""
343
343
  stem = Path(args.input).with_suffix("").name
344
344
  outdir = Path(args.output).parent if args.output else Path(args.input).resolve().parent
345
+ # the table is a file this run writes itself (#408): an existing one is refused without
346
+ # --overwrite, before the first destination renders
347
+ if outdir.is_dir(): # a missing folder is the destinations' own refusal (and a dry run's plan)
348
+ side = [v for k, v in vars(args).items() if k != "output" and isinstance(v, str) and os.path.isfile(v)]
349
+ guard_sidecar(str(outdir / f"{stem}_pack.md"), args.input, *side)
345
350
  dests = {name: str((load_template(name).get("check") or {}).get("platform") or name) for name in names}
346
351
  rows: List[Dict[str, Any]] = []
347
352
  outputs: List[str] = []
@@ -399,6 +404,9 @@ def render_pack(names: List[str], args) -> int:
399
404
  lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
400
405
  "Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
401
406
  if not STATE.dry_run:
407
+ if not outdir.is_dir():
408
+ die(f"pack: the output folder {outdir} does not exist, so the table was not written", kind="input",
409
+ pack=rows, outputs=outputs)
402
410
  Path(pack).write_text("\n".join(lines) + "\n", encoding="utf-8")
403
411
  info(("[dry-run] would write " if STATE.dry_run else "wrote ") + pack)
404
412
  if failed:
package/scripts/scenes.py CHANGED
@@ -302,7 +302,7 @@ def main() -> int:
302
302
  stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
303
303
  vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
304
304
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
305
- run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
305
+ run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
306
306
  info(f"wrote {args.sheet}")
307
307
  result["sheet"] = args.sheet
308
308
 
package/scripts/verify.py CHANGED
@@ -26,7 +26,7 @@ import time
26
26
  from pathlib import Path
27
27
  from typing import Any, Dict, List, Optional, Tuple
28
28
 
29
- from _common import STATE, add_common, apply_common, die, emit, info, probe
29
+ from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe
30
30
 
31
31
  HERE = Path(__file__).resolve().parent
32
32
  MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
@@ -160,9 +160,14 @@ def main() -> int:
160
160
  files = collect(args.paths)
161
161
  tmp = None
162
162
  if args.out:
163
+ # made first, so a --report inside a new --out folder passes the guard below (#413 review)
163
164
  outdir = Path(args.out)
164
165
  outdir.mkdir(parents=True, exist_ok=True)
165
- else:
166
+ if args.report:
167
+ # a file this run writes itself (#408): not one of the media it verifies (a folder's
168
+ # files included), nor an existing file without --overwrite
169
+ guard_sidecar(args.report, *args.paths, *map(str, files))
170
+ if not args.out:
166
171
  tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
167
172
  outdir = Path(tmp.name)
168
173
 
@@ -36,9 +36,10 @@ import sys
36
36
 
37
37
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
38
  from _common.probe import SILENT_PEAK_DBFS
39
+ from _common import still_input_args # noqa: E402
39
40
  from _common import (add_common, apply_common, aac_args, default_output, die, draws_chroma, emit, ffmpeg_base,
40
41
  info, load_brand, pad_filters, probe, rgb_chain_to_bt709, run, STATE, validate_color, video_args, X264_PRESETS,
41
- fmt_secs, child_args, run_tool, measured_level_dbfs)
42
+ fmt_secs, child_args, run_tool, measured_level_dbfs, sibling_temp)
42
43
  from _common.emit import absorb_stage_hw, warn
43
44
 
44
45
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
@@ -207,14 +208,15 @@ def main() -> int:
207
208
  if args.title or args.srt or args.text:
208
209
  # the visualisation is rendered first and the label/captions are drawn on it by the tools
209
210
  # that own those code paths, so neither is re-implemented here
210
- stem, ext = os.path.splitext(output)
211
- render = stem + "_vis" + ext
211
+ # hidden, pid-named siblings (#408): a fixed `<out>_vis` name was removed afterwards
212
+ # whoever had written it, the input or another file of the caller's included
213
+ render = sibling_temp(output, "vis")
212
214
  cmd = ffmpeg_base() + ["-i", args.input]
213
215
  if args.image:
214
216
  # -framerate before -loop: a looped still defaults to 25 fps, and overlay takes its rate
215
217
  # from the FIRST input -- so without this the file came out 25 fps however loud --fps or
216
218
  # --platform said otherwise (the colour-plate path never had the bug: `color=` carries r=).
217
- cmd += ["-framerate", f"{args.fps:g}", "-loop", "1", "-i", args.image]
219
+ cmd += ["-framerate", f"{args.fps:g}", "-loop", "1", *still_input_args(args.image), "-i", args.image]
218
220
  cmd += ["-filter_complex", vf]
219
221
  if args.image:
220
222
  cmd += ["-map", "[v]"]
@@ -227,15 +229,20 @@ def main() -> int:
227
229
  if meta.get("duration"):
228
230
  cmd += ["-t", f"{float(meta['duration']):.3f}"]
229
231
  cmd += ["-shortest", render]
230
- stem, ext = os.path.splitext(output)
231
- intermediates = [p for p in (render, stem + "_titled" + ext) if p != output]
232
+ titled_tmp = sibling_temp(output, "titled")
233
+ intermediates = [p for p in (render, titled_tmp) if p != output]
232
234
  try:
233
235
  run(cmd)
234
236
 
235
237
  stages = ["waveform"]
236
238
  current = render
237
239
  if args.title:
238
- titled = stem + "_titled" + ext if (args.srt or args.text) else output
240
+ titled = titled_tmp if (args.srt or args.text) else output
241
+ if titled == titled_tmp:
242
+ # graphics.py's libass route writes <its output stem>_gfx.ass beside it (#413 review)
243
+ titled_ass = os.path.splitext(titled)[0] + "_gfx.ass"
244
+ if not os.path.exists(titled_ass):
245
+ intermediates.append(titled_ass)
239
246
  # a dry run rendered no _vis file for graphics.py to probe, so its default --end (the
240
247
  # clip's end) came out 0 and the plan failed: give it the source's duration, which
241
248
  # is what -t caps the render at. A real run is unchanged.