ffmpeg-skill 3.6.1 → 3.6.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.
@@ -178,8 +178,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
178
178
  video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
179
179
  "silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
180
180
  required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"},
181
- {"capability": "external:whisper", "when": "--filler --transcribe"},
182
- {"capability": "external:parakeet", "when": "--filler --transcribe (English; either engine family satisfies it)"}] + AUDIO_OUT,
181
+ {"capability": "external:whisper", "when": "--transcribe (with --filler or a sentence flag)"},
182
+ {"capability": "external:parakeet", "when": "--transcribe (with --filler or a sentence flag; English; either engine family satisfies it)"}] + AUDIO_OUT,
183
183
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
184
184
  "join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
185
185
  required=FF + ["filter:xfade", "filter:acrossfade"],
@@ -289,7 +289,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
289
289
  "caption": dict(video="conditional", audio="conditional", note="--mode burn (default) always re-encodes both streams to render pixels; --mode mux copies video and audio untouched and only adds a subtitle stream"),
290
290
  "overlay": dict(video="always", audio="always"),
291
291
  "graphics": dict(video="always", audio="always"),
292
- "sync": dict(video="conditional", audio="conditional", note="video is -c:v copy only for --trim-second when the second file started earlier (offset<0) and the copy succeeds; it is re-encoded whenever --replace-audio's stream copy fails, or in --trim-second when the second file started later (offset>=0, the common case) or --fix-drift is used"),
292
+ "sync": dict(video="conditional", audio="conditional", note="video is -c:v copy only for --replace-audio (the reference picture) while that copy succeeds; --trim-second always re-encodes a video second file, since a copy would start at the keyframe before the trim point; a sound-only second file that started earlier is stream-copied"),
293
293
  "multicam": dict(video="always", audio="always"),
294
294
  "audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
295
295
  "loudness": dict(video="never", audio="always"),
@@ -421,6 +421,7 @@ ERROR_REASONS = {
421
421
  "input_incomplete": "kind ffmpeg: the input has no index (\"moov atom not found\"): a recording cut off or a copy not finished; --repair cannot rebuild it; details: {}",
422
422
  "input_unreadable": "kind ffmpeg: ffmpeg could not read the input as media (\"Invalid data found when processing input\"); details: {}",
423
423
  "ffmpeg_unsupported": "kind missing_tool: the ffmpeg/ffprobe on PATH reports a version older than 6.1, which 3.0.0 no longer supports (5.x was deprecated in 2.14.0); details: {version, required}",
424
+ "read_only_plan_refused": "--plan in a process run with FFMPEG_SKILL_READ_ONLY=1 (mcp/server.py sets it for the tools annotated readOnlyHint: probe, check, cropdetect), which must write no file; details: {plan}",
424
425
  "output_exists": "an output already exists and --overwrite was not given; details.outputs: [{path, resolved, size, mtime_ns}]",
425
426
  "output_is_input": "the output resolves to the same file as an input; details: {output, input, resolved}",
426
427
  "output_is_directory": "the output path is a directory; details: {output}",
@@ -444,9 +445,9 @@ ERROR_REASONS = {
444
445
  "plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
445
446
  "decoder_missing": "color.py / graphics.py / overlay.py: a VP9 source or --video layer stored with an alpha layer, on a build with no libvpx-vp9 decoder (FFmpeg's own VP9 decoder drops the alpha), kind missing_tool; details: {name}",
446
447
  "encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
447
- "asr_engine_missing": "kind missing_tool: --transcribe (caption.py, silence.py --filler) found no local speech-to-text engine; the message lists the install lines; details: {}",
448
+ "asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
448
449
  "encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
449
- "no_speech": "kind input: --transcribe (caption.py, silence.py --filler) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
450
+ "no_speech": "kind input: --transcribe (caption.py; silence.py with --filler or a sentence flag) ran a local speech engine that heard no speech in the input; details: {engine}; also top-level reason and engine",
450
451
  "engine_failed": "kind input: --transcribe: an installed speech engine ran and failed (a named --engine, or every engine --engine auto found); details: {engine, detail | engines: [{engine, reason, detail}]}; also top-level reason, engine and detail/engines",
451
452
  "english_only": "kind input: --transcribe under --engine auto found only Parakeet engines, which it runs for English speech only, and the language is not English; details: {engine, engines}; also top-level reason, engine and engines",
452
453
  }
@@ -618,7 +619,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
618
619
  "verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
619
620
  "verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
620
621
  "unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
621
- "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode. Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
622
+ "source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode (kept only when the written start timecode is the label of the frame the output starts on: the source's own, or moved there by a tool that moved the picture's start; a join of several ranges writes none). Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
622
623
  "diagnostics": DIAGNOSTICS_SCHEMA}
623
624
  extra: Dict[str, Any] = {}
624
625
  if name == "check":
@@ -666,7 +667,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
666
667
  "hint": {"type": "string", "description": "when nothing sat below --threshold: the track's measured level and the --threshold that would find its pauses"},
667
668
  "speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"},
668
669
  "filler": {"type": "object", "description": "--filler: {lang, source (whisper-json:FILE | whisper:ENGINE | parakeet:ENGINE), engine, transcription (with --transcribe: {engine, model, language, routing}), removed, removed_count, matched_untimed (listed words in the transcript with no usable timing, not removed), matched_too_long (listed words longer than a filler, kept), ...}"},
669
- "notes": {"type": "array", "items": {"type": "string"}, "description": "--filler --transcribe under --engine auto: present when the English-only Parakeet model ran on English assumed (no --filler-lang, no language detected, no Whisper engine installed or none that succeeded); the transcript is wrong if the speech is not English"}}
670
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--transcribe (with --filler or a sentence flag) under --engine auto: present when the English-only Parakeet model ran on English assumed (no --filler-lang, no language detected, no Whisper engine installed or none that succeeded); the transcript is wrong if the speech is not English"}}
670
671
  elif name == "sync":
671
672
  extra = {"reference": {"type": "string"}, "second": {"type": "string"}, "offset_seconds": {"type": "number"}, "confidence": {"type": "number"}, "meaning": {"type": "string"}, "drift": {"type": "object"},
672
673
  "sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given; with --fix-drift each also carries drift_skipped_reason"},
@@ -694,7 +695,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
694
695
  "files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
695
696
  "failed": {"type": "integer"}, "total": {"type": "integer"}}
696
697
  elif name == "batch":
697
- extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
698
+ extra = {"results": {"type": "array", "description": "one row per input, every input (an interrupted batch included): ok (every step completed), verified (every step also verified what it wrote; false under --dry-run) and unverified ([{step, tool, failed}] naming the steps that did not); a failed row's error {step, tool, kind, message, code, reason, details, hint, exit_code} from the failing step's own document; diagnostics from its steps (tagged with step); cache_miss {reason: output_missing|output_changed|entry_unverifiable, ...} when a cache entry was not served; skipped \"timeout\" or \"interrupted\" for an item that never started and interrupted true for one stopped while running; under --watch, skipped \"settling\" (ok false, not cached) for a source that changed since the previous poll, or, first seen, was modified within one poll interval: it is processed on a later pass. A row whose recipe ran cut.py carries cut_reencoded (each cut.py call's reencoded) and cut_reencode_reasons (each reason those calls named, once per call)"},
698
699
  "processed": {"type": "integer"}, "total": {"type": "integer"},
699
700
  "cut_stream_copy": {"type": ["object", "null"], "description": "cut.py calls across the folder: {calls, stream_copy, reencoded, stream_copy_rate}; null when no cut.py step ran"},
700
701
  "cut_reencode_reasons": {"type": ["object", "null"], "description": "how many cut.py calls named each reencode_reason ({} when none re-encoded); a re-encoded call whose row came from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as unknown; null when no cut.py step ran"}}
@@ -769,6 +770,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
769
770
  elif name == "join":
770
771
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
771
772
  "expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
773
+ "frame_rate": {"type": "string", "description": "video mode only: the join's frame rate as an exact fraction (\"30000/1001\"), the rate every clip length and xfade offset is counted in"},
774
+ "clip_frames": {"type": "array", "items": {"type": "integer"}, "description": "video mode only: per clip, the whole frames of frame_rate it holds in the join (its picture's, or the frames that cover a sound running more than a frame past it); its sound is cut to the samples those frames last. A pending --dry-run input counts 0"},
775
+ "transition_frames": {"type": "integer", "description": "video mode only: the frames each xfade/acrossfade overlaps (--duration rounded to whole frames, at least 1; 0 for --transition none)"},
772
776
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
773
777
  "video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"},
774
778
  "skipped": {"type": "array", "description": "inputs --on-missing skip left out: [{index, path, reason}] ([] when none were)"},
@@ -1759,6 +1763,17 @@ MCP_JSON_EXEMPT = ("look", "probe")
1759
1763
  # them out of every inputSchema (1.10.0-1.x kept them unless FFMPEG_SKILL_MCP_LEAN=1). A client
1760
1764
  # that still sends them is not refused -- build_argv() maps them like any other key.
1761
1765
  MCP_TRANSPORT_FLAGS = ("json", "progress")
1766
+ # what a read-only tool's (mcp_read_only: probe, check, cropdetect) MCP inputSchema says of the two
1767
+ # keys that would write a file: `plan` is refused over MCP and `overwrite` only governed that plan.
1768
+ # The names stay (3.x never removes an inputSchema property); the CLI keeps both working (every
1769
+ # tool gets --plan, docs/design-decisions.md).
1770
+ MCP_READ_ONLY_REFUSED = {
1771
+ "plan": "refused over MCP (read-only tool; error.reason read_only_plan_refused): this tool writes no file here; run it from the CLI to write a plan",
1772
+ "overwrite": "no effect over MCP: this read-only tool writes no file (it governed --plan, which is refused here)",
1773
+ }
1774
+ # set by mcp/server.py in the environment of a read-only tool's process: --plan is refused
1775
+ # (kind input, error.reason read_only_plan_refused), however the raw argv spells it
1776
+ READ_ONLY_ENV = "FFMPEG_SKILL_READ_ONLY"
1762
1777
  # default MCP tools/list surface (roadmap P1-7, shipped 1.18.3): the 42-tool schema dump costs
1763
1778
  # every MCP session context whether it needs it or not. These 12 are the tools eval iterations
1764
1779
  # 17-20's ground-truth `expect` lists actually name most often across the 118 agent prompts in
@@ -1905,6 +1920,13 @@ def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
1905
1920
  structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
1906
1921
  for dest in MCP_TRANSPORT_FLAGS:
1907
1922
  props.pop(dest, None)
1923
+ if mcp_read_only(spec):
1924
+ # a tool that writes no artifact is annotated readOnlyHint; --plan is the one file it could
1925
+ # still write (and --overwrite only governs that file). mcp/server.py runs the tool with
1926
+ # FFMPEG_SKILL_READ_ONLY=1, which refuses --plan however it is spelled; the descriptions say so
1927
+ for dest, text in MCP_READ_ONLY_REFUSED.items():
1928
+ if dest in props:
1929
+ props[dest]["description"] = text
1908
1930
  if structured.get("required"):
1909
1931
  structured["required"] = [d for d in structured["required"] if d not in MCP_TRANSPORT_FLAGS]
1910
1932
  if not structured["required"]:
@@ -1925,14 +1947,23 @@ def mcp_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
1925
1947
  "annotations": mcp_annotations(spec)}
1926
1948
 
1927
1949
 
1950
+ def mcp_read_only(spec: Dict[str, Any]) -> bool:
1951
+ """A tool that writes no artifact and does not touch its input: over MCP it writes nothing at
1952
+ all (mcp/server.py refuses its `plan`, see mcp_input_schema), so it is annotated readOnlyHint."""
1953
+ return not (bool(spec.get("produces_artifact")) or bool(spec.get("mutates_input")))
1954
+
1955
+
1928
1956
  def mcp_annotations(spec: Dict[str, Any]) -> Dict[str, Any]:
1929
1957
  """MCP ToolAnnotations (spec 2025-06-18) derived from the ToolSpec flags. Hints only: clients
1930
- must not treat them as a security boundary. Every tool runs locally (no open world); none edits
1931
- its input in place; a tool that writes nothing is read-only."""
1932
- writes = bool(spec.get("produces_artifact")) or bool(spec.get("mutates_input"))
1958
+ must not treat them as a security boundary. Every tool runs locally (no open world). A tool
1959
+ that writes nothing is read-only (mcp_read_only: the server runs it with
1960
+ FFMPEG_SKILL_READ_ONLY=1, which refuses `--plan` however it is spelled). Every other tool is
1961
+ destructive: none edits its input in place, but with `overwrite` it replaces an existing file at its output or plan path, which is not an
1962
+ additive update."""
1963
+ writes = not mcp_read_only(spec)
1933
1964
  return {"title": spec["name"],
1934
1965
  "readOnlyHint": not writes,
1935
- "destructiveHint": bool(spec.get("mutates_input")),
1966
+ "destructiveHint": writes,
1936
1967
  "idempotentHint": spec.get("idempotency_hint") in ("bit_exact", "content_equivalent", "cached"),
1937
1968
  "openWorldHint": False}
1938
1969
 
@@ -1980,7 +2011,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1980
2011
  # first time it runs; faster-whisper is a library, so its fetch is made from the skill's own
1981
2012
  # process. A caller that sandboxes the network must know that before a first --transcribe.
1982
2013
  "model_downloads": {
1983
- "when": "first use of a speech engine (--transcribe; silence.py --filler --transcribe runs it under --dry-run too, caption.py --transcribe does not) whose model is not cached yet; media is never uploaded",
2014
+ "when": "first use of a speech engine (--transcribe; silence.py --transcribe, with --filler or a sentence flag, runs it under --dry-run too, caption.py --transcribe does not) whose model is not cached yet; media is never uploaded",
1984
2015
  "in_process": {"faster-whisper": "Hugging Face (Systran/faster-whisper-<model>)"},
1985
2016
  "child_process": {"parakeet-mlx": "Hugging Face (mlx-community/parakeet-*)",
1986
2017
  "openai-whisper": "OpenAI (its model checkpoint)"},
@@ -1994,8 +2025,8 @@ def build(detect: bool = True) -> Dict[str, Any]:
1994
2025
  "canonical": True,
1995
2026
  "transports": ["cli", "mcp"],
1996
2027
  "argument_mapping": {
1997
- "positional": "listed in input_schema.positional, in order; array values expand to several arguments",
1998
- "options": "key -> --key with '_' replaced by '-'; booleans are flags; arrays repeat the flag; input_schema.properties[key].cli lists the accepted spellings",
2028
+ "positional": "listed in input_schema.positional, in order; array values expand to several arguments; when any of them starts with '-', all of them follow '--' after every option, so none is read as an option",
2029
+ "options": "key -> --key with '_' replaced by '-'; booleans are flags; arrays repeat the flag; input_schema.properties[key].cli lists the accepted spellings; a value starting with '-' is one token, --long-flag=value (\"-50%\" is text, not an option)",
1999
2030
  "exceptions": "per tool in mcp.argument_exceptions (key -> flag), e.g. output -> -o, loudness.lufs -> -I, graphics.count_from -> --from",
2000
2031
  "json": "--json is appended for every tool except look and probe (probe prints JSON by default)",
2001
2032
  },
package/scripts/audio.py CHANGED
@@ -13,7 +13,7 @@ Examples:
13
13
  python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
14
14
  python3 audio.py talk.mp4 --music bed.mp3 --duck --duck-threshold -30 --duck-release 250 # ducks earlier and recovers faster
15
15
  python3 audio.py band.wav --stereo-widen 0.5 -o wide.wav # wider stereo image (a real stereo source; mono is refused)
16
- python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
16
+ python3 audio.py surround.mov --downmix # 5.1 -> stereo, ITU-R BS.775 weights, scaled not to clip
17
17
  python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
18
18
  python3 audio.py interview.mp4 -o interview.wav # extract the audio (no video in the output)
19
19
  python3 audio.py multi.mkv --audio-stream 1 --voice -o lav.m4a # pick the second audio track, clean it, write M4A
@@ -29,6 +29,8 @@ from _common.probe import SILENT_PEAK_DBFS
29
29
  from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, measured_level_dbfs, probe, run, run_keeping_subtitles, fmt_secs
30
30
  from _common.emit import warn
31
31
 
32
+ # --downmix: ITU-R BS.775 fold-down, renormalised by pan's '<' (see main()).
33
+ DOWNMIX_PAN = "pan=stereo|FL<FL+0.707*FC+0.707*BL+0.707*SL|FR<FR+0.707*FC+0.707*BR+0.707*SR"
32
34
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
33
35
 
34
36
  # --voice [light|medium|strong] (1.13). "medium" is the chain --voice has always produced, so a
@@ -191,7 +193,7 @@ def main() -> int:
191
193
  help="widen the stereo image, 0..1 (0 = untouched, 1 = maximum); needs a real stereo source: a mono input is "
192
194
  "refused (duplicating it leaves both channels identical, so there is nothing to widen) and more than two "
193
195
  "channels are refused unless --downmix folds them to stereo first")
194
- fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
196
+ fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo with the ITU-R BS.775 weights (centre and surrounds -3 dB, LFE dropped), scaled so the fold-down cannot clip")
195
197
  fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
196
198
  dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
197
199
  dyn.add_argument("--compress", action="store_true", help="compressor (acompressor); order: gate -> compressor -> limiter")
@@ -301,7 +303,11 @@ def main() -> int:
301
303
 
302
304
  fx: List[str] = []
303
305
  if args.downmix:
304
- fx.append("pan=stereo|FL=0.707*FC+FL+0.5*BL+0.5*SL+0.5*LFE|FR=0.707*FC+FR+0.5*BR+0.5*SR+0.5*LFE")
306
+ # ITU-R BS.775: centre and surrounds at -3 dB (0.707), LFE dropped. pan's '<' scales each
307
+ # output so its gains sum to 1 over the channels the input has (1/2.414 on 5.1, 1/3.121 on
308
+ # 7.1), so in-phase channels at full scale cannot clip; '=' summed 2.7x and clipped a loud
309
+ # 5.1 while the run said verified.
310
+ fx.append(DOWNMIX_PAN)
305
311
  if args.voice:
306
312
  fx.append(VOICE_LEVELS[args.voice])
307
313
  elif args.denoise:
package/scripts/batch.py CHANGED
@@ -33,8 +33,10 @@ from pathlib import Path
33
33
  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
- from _common.runner import HW_FORCED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw, stop_children
36
+ from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw,
37
+ owned_identity, stop_children)
37
38
  from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
39
+ from _common.decision import rebase_project_paths
38
40
 
39
41
  HERE = Path(__file__).resolve().parent
40
42
  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
@@ -117,6 +119,16 @@ def gpu_key() -> str:
117
119
  JOBS_CAP = 8 # beyond this, concurrent encodes contend for the same cores and memory
118
120
 
119
121
  _LOG = threading.local()
122
+ # source -> {real path -> owned_identity()} of every file this process delivered for that item (a
123
+ # --watch loop's earlier passes): its final output and every side file its steps were allowed to
124
+ # write (an .srt beside the video). An existing path that is still exactly that file is the item's
125
+ # own, not someone else's, so a later pass of the SAME item replaces it (design-decisions: "A path
126
+ # this same run wrote ... is never someone else's file"); the permission is per item and per path,
127
+ # and is checked again where the write happens -- by deliver() under the output lock, by a step at
128
+ # its own output check (runner.OWNED_ENV). A file edited or replaced since, one an earlier run
129
+ # left, or one another item delivered (two items naming one side file) is still refused.
130
+ _OWN_OUTPUTS: Dict[str, Dict[str, Dict[str, int]]] = {}
131
+ _OWN_LOCK = threading.Lock()
120
132
  # set by batch.py's signal handler: a --jobs worker between two steps must not start the next one
121
133
  _STOP = threading.Event()
122
134
 
@@ -155,13 +167,15 @@ def step_error(index: Optional[int], tool: Optional[str], doc: Any) -> Dict[str,
155
167
  return out
156
168
 
157
169
 
158
- def run_step(argv: List[str], per_call: "Optional[float]" = None) -> "tuple[bool, Optional[dict]]":
170
+ def run_step(argv: List[str], per_call: "Optional[float]" = None,
171
+ owned: "Optional[Path]" = None) -> "tuple[bool, Optional[dict]]":
159
172
  """Returns (ok, doc): doc is the step's own --json result document; a failed step that
160
173
  printed none that parses gets one in the failure shape (error.reason step_output_unparsed,
161
174
  the last stderr line as the message), so its row still says why. {in}/{out} are already resolved by the caller before argv reaches here, so forcing
162
175
  --json on every step (harmless: no step reads another step's stdout, only its output path)
163
176
  costs nothing and lets a caller like process() read back tool-specific fields, e.g. cut.py's
164
- `reencoded`, without adding a bespoke flag per field it might someday want."""
177
+ `reencoded`, without adding a bespoke flag per field it might someday want. `owned`: the
178
+ owned_config() file the step reads under runner.OWNED_ENV."""
165
179
  script = argv[0]
166
180
  if script not in ALLOWED_STEP_SCRIPTS:
167
181
  # main() refuses such a recipe before any item runs; this stays as the last line of defence
@@ -173,7 +187,8 @@ def run_step(argv: List[str], per_call: "Optional[float]" = None) -> "tuple[bool
173
187
  "error": {"kind": "interrupted", "message": f"interrupted before {script} started"}}
174
188
  cmd = [str(HERE / script)] + argv[1:] + child_args() + (["--json"] if "--json" not in argv[1:] else [])
175
189
  log(" → " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd)))
176
- proc = run_tool(cmd, per_call=per_call)
190
+ env = dict(os.environ, **{OWNED_ENV: str(owned)}) if owned else None
191
+ proc = run_tool(cmd, per_call=per_call, env=env)
177
192
  try:
178
193
  doc = json.loads(proc.stdout)
179
194
  except ValueError:
@@ -240,9 +255,69 @@ def row_output(src: Path, recipe: Dict[str, Any], outdir: Path) -> Dict[str, Any
240
255
  return fields
241
256
 
242
257
 
243
- def deliver(src: str, final: Path) -> None:
258
+ def own_expected(src: Path, path: "Path | str") -> "Optional[Dict[str, int]]":
259
+ """The identity this process left `path` with, if it delivered a file there for `src`."""
260
+ with _OWN_LOCK:
261
+ return _OWN_OUTPUTS.get(str(src), {}).get(os.path.realpath(str(path)))
262
+
263
+
264
+ def is_own_output(src: Path, final: Path) -> bool:
265
+ """Whether `final` is still the file this process delivered there for `src` (_OWN_OUTPUTS)."""
266
+ expected = own_expected(src, final)
267
+ return expected is not None and owned_identity(str(final)) == expected
268
+
269
+
270
+ def record_own(src: Path, committed: "Dict[str, Dict[str, int]]", work: Path) -> None:
271
+ """Remember the files this item delivered, each with the identity it had when it was written
272
+ (a step's report_committed(), deliver() for the final path), never as it is now: a sidecar
273
+ edited while a later step ran no longer matches, so the item's next pass refuses it. Paths in
274
+ the item's work directory go with it and are skipped. Replaces what an earlier pass of the item
275
+ recorded."""
276
+ base = os.path.realpath(str(work))
277
+ mine: Dict[str, Dict[str, int]] = {}
278
+ for p, ident in committed.items():
279
+ real = os.path.realpath(p)
280
+ if real == base or real.startswith(base + os.sep) or not isinstance(ident, dict):
281
+ continue
282
+ mine[real] = ident
283
+ with _OWN_LOCK:
284
+ _OWN_OUTPUTS[str(src)] = mine
285
+
286
+
287
+ def owned_config(src: Path, work: Path, i: int) -> Path:
288
+ """The file a step reads under runner.OWNED_ENV: a snapshot of what this process delivered for
289
+ `src`, taken right before the step starts (one file per step, since a step reads it once), and
290
+ the directory where each of its processes reports the paths it passed the output check for."""
291
+ report = work / "owned-report"
292
+ report.mkdir(exist_ok=True)
293
+ with _OWN_LOCK:
294
+ replaceable = dict(_OWN_OUTPUTS.get(str(src), {}))
295
+ cfg = work / f"owned{i}.json"
296
+ cfg.write_text(json.dumps({"replaceable": replaceable, "report": str(report)}), encoding="utf-8")
297
+ return cfg
298
+
299
+
300
+ def owned_reports(work: Path) -> "Dict[str, Dict[str, int]]":
301
+ """{path: identity when written} for every file the item's steps reported committing
302
+ (owned_config's report directory). A path a step was allowed to write but never reported
303
+ writing is not in it."""
304
+ found: Dict[str, Dict[str, int]] = {}
305
+ for f in sorted((work / "owned-report").glob("*.json")) if (work / "owned-report").is_dir() else []:
306
+ try:
307
+ doc = json.loads(f.read_text(encoding="utf-8"))
308
+ except (OSError, ValueError):
309
+ continue
310
+ committed = doc.get("committed") if isinstance(doc, dict) else None
311
+ if isinstance(committed, dict):
312
+ found.update({p: i for p, i in committed.items() if isinstance(p, str) and isinstance(i, dict)})
313
+ return found
314
+
315
+
316
+ def deliver(src: str, final: Path, expected: "Optional[Dict[str, int]]" = None) -> "Optional[Dict[str, int]]":
244
317
  """Move an item's last written intermediate to its final path, under the output guard every
245
- tool applies: an existing file is replaced only with --overwrite. A dry run writes nothing."""
318
+ tool applies: an existing file is replaced only with --overwrite, or when it is still exactly
319
+ `expected` -- the file this process delivered there on an earlier pass -- checked under the
320
+ output lock, so a file another process put there since is refused. A dry run writes nothing."""
246
321
  if STATE.dry_run:
247
322
  # the refusal the real run would meet, as a step writing {out} plans it (#428 review)
248
323
  if final.exists() and not STATE.overwrite:
@@ -257,7 +332,7 @@ def deliver(src: str, final: Path) -> None:
257
332
  # under the output lock every tool's write holds, so the existence check and the move are one
258
333
  # step for another run delivering or writing the same path
259
334
  with _OutputLock(str(final), on_locked=locked):
260
- if final.exists() and not STATE.overwrite:
335
+ if final.exists() and not STATE.overwrite and not (expected is not None and owned_identity(str(final)) == expected):
261
336
  raise ItemError({"step": None, "tool": None, "kind": "input", "reason": "output_exists",
262
337
  "message": f"refusing to overwrite existing output {str(final)!r}: pass --overwrite to replace it"})
263
338
  try:
@@ -279,13 +354,25 @@ def deliver(src: str, final: Path) -> None:
279
354
  pass
280
355
  raise ItemError({"step": None, "tool": None, "kind": "output",
281
356
  "message": f"could not deliver {src} to {final}: {exc}"})
357
+ # the delivered file as it was put there, taken under the lock: what a later pass may replace
358
+ ident = owned_identity(str(final))
282
359
  log(f" delivered the last written file to {final}")
360
+ return ident
283
361
 
284
362
 
285
363
  def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
286
364
  deadline: "Optional[float]" = None) -> Dict[str, Any]:
287
365
  final = final_path(src, recipe, outdir)
288
366
  t0 = time.time()
367
+ # replacing this run's own earlier output is decided per path and at the write, never here:
368
+ # each step gets a snapshot of what this process delivered (runner.OWNED_ENV) and checks every
369
+ # existing file it would write against it; deliver() checks the final path under its lock
370
+ track = not STATE.dry_run and not STATE.overwrite
371
+
372
+ delivered: Dict[str, Dict[str, int]] = {}
373
+
374
+ def owned(i: int) -> "Optional[Path]":
375
+ return owned_config(src, work, i) if track else None
289
376
 
290
377
  def budget() -> "Optional[float]":
291
378
  """What is left of the BATCH's time limit -- not a fresh one per item. A --timeout is a
@@ -306,11 +393,15 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
306
393
  proj.setdefault("clips", [{}])
307
394
  while len(proj["clips"]) <= idx:
308
395
  proj["clips"].append({})
396
+ # the copy is written in the item's work directory, and render.py reads a project's
397
+ # relative paths against the project file's own directory: they are made absolute
398
+ # against the recipe's project, or "bed.wav" beside it was looked for in .work/<n>-xxx/
399
+ proj = rebase_project_paths(proj, Path(str(recipe["project"])).resolve().parent)
309
400
  proj["clips"][idx]["src"] = str(src.resolve())
310
401
  proj["output"] = str(final.resolve())
311
402
  pj = work / "project.json"
312
403
  pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
313
- ok, _doc = run_step(["render.py", str(pj)], budget())
404
+ ok, _doc = run_step(["render.py", str(pj)], budget(), owned=owned(0))
314
405
  step_docs = [("render", _doc)]
315
406
  failure = None if ok else step_error(0, "render.py", _doc)
316
407
  cut_reencoded: List[bool] = []
@@ -334,7 +425,9 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
334
425
  # what this one read, and when it is the last, the last file written is delivered (#415)
335
426
  writes = step_writes(step)
336
427
  argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
337
- step_ok, doc = run_step(argv, budget())
428
+ if last and writes and track and is_own_output(src, final):
429
+ log(f" {final.name} is this run's own earlier output, unchanged since: it may be replaced")
430
+ step_ok, doc = run_step(argv, budget(), owned=owned(i))
338
431
  step_docs.append((Path(str(argv[0])).stem if argv else "step", doc))
339
432
  # only cut.py's own doc carries `reencoded` -- true if ANY range this call cut (or its
340
433
  # --segments join) re-encoded, for the reasons in its `reencode_reason` (distinct per
@@ -353,9 +446,15 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
353
446
  # the last step wrote nothing: the item's deliverable is the last file a step wrote,
354
447
  # which is in this item's work directory and would be removed with it (#415)
355
448
  try:
356
- deliver(cur, final)
449
+ ident = deliver(cur, final, expected=own_expected(src, final))
450
+ if ident:
451
+ delivered[str(final)] = ident
357
452
  except ItemError as exc:
358
453
  ok, failure = False, exc.error
454
+ if ok and track:
455
+ # what this item delivered, each file as it was when written: the paths its steps reported
456
+ # committing (side files included) and the final path deliver() moved into place
457
+ record_own(src, {**owned_reports(work), **delivered}, work)
359
458
  result: Dict[str, Any] = {"file": str(src), **row_output(src, recipe, outdir), "ok": ok, "seconds": round(time.time() - t0, 1)}
360
459
  if failure:
361
460
  result["error"] = failure
@@ -434,6 +533,8 @@ def main() -> int:
434
533
  apply_common(args)
435
534
 
436
535
  started = time.time()
536
+ with _OWN_LOCK:
537
+ _OWN_OUTPUTS.clear()
437
538
  folder = Path(args.folder).resolve() # relative 'bdir' used to become bdir/bdir/out once joined with the default outdir
438
539
  if not folder.is_dir():
439
540
  die(f"not a folder: {folder}")
@@ -553,6 +654,35 @@ def main() -> int:
553
654
  except (ValueError, OSError):
554
655
  pass
555
656
 
657
+ # --watch: each source's (size, mtime_ns) at the previous poll. A folder files are copied,
658
+ # downloaded or recorded into is read while they grow: a file processed mid-copy delivered a
659
+ # fraction of itself as ok (verified, for an MKV or TS), and once it settled every later pass
660
+ # met that output as someone else's and refused it. A source is processed once it is
661
+ # unchanged across one poll interval -- the same size and mtime as at the previous poll, or,
662
+ # first seen, last modified at least one interval ago; until then its row is
663
+ # skipped: "settling", not cached, and the next pass looks again.
664
+ last_seen: Dict[str, "tuple[int, int]"] = {}
665
+
666
+ def settle_poll(files: List[Path]) -> "set":
667
+ if not args.watch:
668
+ return set()
669
+ unsettled = set()
670
+ now = time.time()
671
+ current: Dict[str, "tuple[int, int]"] = {}
672
+ for src in files:
673
+ try:
674
+ st = src.stat()
675
+ except OSError:
676
+ continue # gone since the scan: file_key() reports it as before
677
+ sig = (st.st_size, st.st_mtime_ns)
678
+ current[str(src)] = sig
679
+ before = last_seen.get(str(src))
680
+ if (before != sig) if before is not None else (now - st.st_mtime < args.watch):
681
+ unsettled.add(str(src))
682
+ last_seen.clear()
683
+ last_seen.update(current)
684
+ return unsettled
685
+
556
686
  def one_pass() -> List[Dict[str, Any]]:
557
687
  files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
558
688
  # Two different sources can compute the same final path (most often a fixed recipe "ext"
@@ -632,7 +762,13 @@ def main() -> int:
632
762
  pending: List[tuple] = []
633
763
  cache_miss: Dict[int, Dict[str, Any]] = {}
634
764
  pass_key = rkey + side_inputs_key(recipe)
765
+ polled = settle_poll(files)
635
766
  for i, src in enumerate(files):
767
+ if str(src) in polled:
768
+ info(f"skip (still being written) {src.name}: it changed since the last poll")
769
+ slots[i] = {"file": str(src), **row_output(src, recipe, outdir), "ok": False, "verified": False,
770
+ "seconds": 0.0, "skipped": "settling"}
771
+ continue
636
772
  key = f"{file_key(src)}:{pass_key}"
637
773
  hit = cache.get(key)
638
774
  if isinstance(hit, dict) and not args.force:
@@ -842,7 +978,9 @@ def main() -> int:
842
978
  timed_out=True)
843
979
  if done != len(results):
844
980
  failed_files = [r["file"] for r in results if not r["ok"]]
845
- die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
981
+ settling = sum(1 for r in results if r.get("skipped") == "settling")
982
+ die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else "")
983
+ + (f" ({settling} of them still being written when the watch stopped, skipped: settling, not processed)" if settling else ""),
846
984
  kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
847
985
  # cut.py's own `reencoded`, rolled up across every cut.py step this batch ran: how much of the
848
986
  # folder landed on the fast lossless path (mode "copy") vs re-encoded on at least one range.
package/scripts/broll.py CHANGED
@@ -22,7 +22,7 @@ import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
24
  from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
25
- from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
25
+ from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
26
26
  from _common.emit import note, record_fallback
27
27
  from _common.probe import display_size, square_pixels, square_pixels_note
28
28
 
@@ -136,7 +136,7 @@ def main() -> int:
136
136
  b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
137
137
  parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
138
138
  f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
139
- parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
139
+ parts.append(f"{cur}[b{i}]overlay=0:0{overlay_format(meta_a)}:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
140
140
  cur = f"[v{i}]"
141
141
  vout = cur
142
142