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.
- package/README.md +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -8
- package/mcp/server.py +28 -13
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +40 -14
- package/scripts/_ass_overlay.py +234 -1
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +55 -0
- package/scripts/_common/decision.py +80 -0
- package/scripts/_common/emit.py +8 -0
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +148 -7
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +46 -15
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +149 -11
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +44 -19
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/join.py +135 -14
- package/scripts/look.py +31 -12
- package/scripts/multicam.py +6 -1
- package/scripts/overlay.py +20 -7
- package/scripts/redact.py +2 -2
- package/scripts/render.py +30 -23
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
182
|
-
{"capability": "external:parakeet", "when": "--
|
|
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 --
|
|
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
|
|
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
|
|
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": "--
|
|
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)
|
|
1931
|
-
|
|
1932
|
-
|
|
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":
|
|
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 --
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|