ffmpeg-skill 3.2.1 → 3.2.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.
@@ -51,6 +51,7 @@ ROLES = {
51
51
  # Facts that are not derivable from the argparse parsers. Capability names:
52
52
  # ffmpeg / ffprobe the binaries on PATH
53
53
  # encoder:<name> `ffmpeg -encoders`
54
+ # decoder:<name> `ffmpeg -decoders`
54
55
  # filter:<name> `ffmpeg -filters`
55
56
  # bsf:<name> `ffmpeg -bsfs`
56
57
  # external:whisper a local whisper engine (whisper.cpp / faster-whisper / openai-whisper)
@@ -78,7 +79,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
78
79
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
79
80
  video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
80
81
  "cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family, or audio extracted when -o has an audio extension)"],
81
- required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"},
82
+ required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance; --smart (the partial GOPs at the cut points)"},
83
+ {"capability": "bsf:filter_units", "when": "--smart"}, {"capability": "bsf:h264_mp4toannexb", "when": "--smart"},
82
84
  dict(HDR_X265, when=HDR_X265["when"] + "; on an SDR HEVC source (the default since 3.0.0; --no-keep-hevc uses x264)"), {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
83
85
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
84
86
  "fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
@@ -136,7 +138,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
136
138
  "insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
137
139
  required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
138
140
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
139
- "background": dict(role="execution", inputs=[], outputs=["generated solid-colour or gradient video artifact"],
141
+ "background": dict(role="execution", inputs=[], outputs=["generated solid-colour or gradient video artifact", "transparent canvas in an alpha line (--transparent)"],
140
142
  required=FF + [X264], optional=[{"capability": "filter:gradients", "when": "--gradient"}],
141
143
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
142
144
  "reverse": dict(role="execution", inputs=["video asset"], outputs=["reversed video artifact"],
@@ -185,7 +187,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
185
187
  video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
186
188
  "color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
187
189
  required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
188
- {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": "bsf:h264_metadata", "when": "--retag on H.264 (without it the video is re-encoded)"},
190
+ {"capability": "filter:lut3d", "when": "--lut"}, {"capability": "filter:lutyuv", "when": "--legalize"}, {"capability": "filter:signalstats", "when": "--legalize (the measurement of the written file)"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": "bsf:h264_metadata", "when": "--retag on H.264 (without it the video is re-encoded)"},
189
191
  {"capability": "bsf:hevc_metadata", "when": "--retag on HEVC (without it the video is re-encoded)"},
190
192
  {"capability": "bsf:mpeg2_metadata", "when": "--retag on MPEG-2 (without it the video is re-encoded)"}, {"capability": "bsf:av1_metadata", "when": "--retag on AV1 (without it the video is re-encoded)"},
191
193
  {"capability": "bsf:vp9_metadata", "when": "--retag on VP9 (without it the video is re-encoded)"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
@@ -206,12 +208,13 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
206
208
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
207
209
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default); the audio row's volumedetect silence test when loudness is skipped"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"},
208
210
  {"capability": "filter:blackdetect", "when": "--content (black row)"}, {"capability": "filter:freezedetect", "when": "--content (frozen row)"},
209
- {"capability": "filter:silencedetect", "when": "--content (silence row)"}],
211
+ {"capability": "filter:silencedetect", "when": "--content (silence row)"},
212
+ {"capability": "filter:signalstats", "when": "--levels (legal levels row)"}, {"capability": "filter:lutyuv", "when": "--levels (legal levels row)"}],
210
213
  video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
211
- "scenes": dict(role="analysis", inputs=["video asset"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
214
+ "scenes": dict(role="analysis", inputs=["video asset", "audio asset (after 3.2.0: one scene; --beats, --audio-peaks, --speech, --highlights; --sheet and --shots need a picture)"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
212
215
  required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"},
213
216
  {"capability": "ffmpeg", "when": "--beats (one audio decode for the onset pass)"}],
214
- video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
217
+ video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
215
218
  "look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
216
219
  required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; without drawtext the sheet is drawn without them, fallback look_no_drawtext)"},
217
220
  {"capability": "filter:drawbox", "when": "--safe NAME (the platform's occluded zones)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
@@ -260,7 +263,7 @@ DRY_RUN_NOTES = {
260
263
  # of the original audio codec never happens on those tools.
261
264
  REENCODE_META: Dict[str, Dict[str, str]] = {
262
265
  "probe": dict(video="never", audio="never", note="analysis only, no artifact"),
263
- "cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, --codec, a VFR source (--vfr-guard), a keyframe snap past --tolerance, or a --segments join the parts cannot stream-copy (see cut.py --json: mode, reencode_reason, keyframe_snapped)"),
266
+ "cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; --smart copies the whole GOPs inside a cut and re-encodes the partial ones at its ends; re-encodes on --accurate, --codec, a VFR source (--vfr-guard), a keyframe snap past --tolerance, or a --segments join the parts cannot stream-copy (see cut.py --json: mode, reencode_reason, keyframe_snapped)"),
264
267
  "fit": dict(video="always", audio="always", note="always re-encodes to AAC when audio is present, even if only --fps or --aspect was asked for"),
265
268
  "crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
266
269
  "deinterlace": dict(video="always", audio="always", note="the yadif filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
@@ -437,6 +440,7 @@ ERROR_REASONS = {
437
440
  "plan_unknown_tool": "render.py PLAN: the plan names no tool render.py can run; details: {plan, plan_sha256, checked_at, tool, audit_log}",
438
441
  "plan_cwd_missing": "render.py PLAN: the directory the plan was made in is gone; details: {plan, plan_sha256, checked_at, cwd, audit_log}",
439
442
  "plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
443
+ "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}",
440
444
  "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)",
441
445
  "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: {}",
442
446
  "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}",
@@ -513,8 +517,10 @@ DIAGNOSTIC_CODES = {
513
517
  "output_audio_silent": ("warnings", "export.py: the written audio is silent (-inf LUFS), so no gain meets the platform's loudness; {platform}"),
514
518
  "flash_pass_failed": ("warnings", "check.py --content: the flash pass failed, so the flashes row is not measured (WARN, could not measure); the other rows stand"),
515
519
  "repair_incomplete": ("warnings", "export.py --repair: decode errors remain after the remux (a damaged frame needs a re-encode); {timestamp_warnings, decode_errors}"),
520
+ "legalize_residual": ("warnings", "color.py --legalize: samples of the encoded file are still outside the legal range after the clamp (the encoder's error around clamped areas); the shares are in legalize.output"),
516
521
  "size_over_limit": ("warnings", "export.py --max-mb: the written file is over the size asked for (the encoder overshot its bitrate); {size, limit}"),
517
522
  "loudness_outside_spec": ("warnings", "export.py: the written file's loudness is outside the platform's spec (normalized: after --normalize too); {platform, lufs, tp, normalized}"),
523
+ "smart_fallback": ("fallbacks", "cut.py --smart could not make or verify the smart cut, so the whole cut was re-encoded as --accurate would (smart.reason says why); {reason_code}"),
518
524
  "camera_not_started": ("fallbacks", "multicam.py: a range asked for a camera that had not started; camera 0 played it ({start, end, requested_camera, camera, starts_at}); `cuts` names the camera rendered"),
519
525
  "loudness_not_measured": ("warnings", "export.py: the written file's loudness could not be read, so the platform's loudness was not verified (loudness.ok null, a failing loudness verification step)"),
520
526
  "loudness_no_audio": ("skipped", "export.py: a platform preset's loudness check, on a source with no audio"),
@@ -533,9 +539,39 @@ DIAGNOSTIC_CODES = {
533
539
  "batch_cache_reset": ("warnings", "batch.py: .ffskill_cache.json did not parse and was started empty (also an audit line)"),
534
540
  "batch_cache_output_changed": ("warnings", "batch.py: a cached item's output was replaced or edited since the batch wrote it; the item runs again"),
535
541
  "batch_cache_entry_unverifiable": ("warnings", "batch.py: a cache entry from before output identities; the item runs again"),
542
+ "alpha_dropped": ("warnings", "color.py / graphics.py / overlay.py: the source's alpha channel is used and the output does not keep it (no --keep-alpha); the `alpha` verification step fails with reason alpha_dropped, and --drop-alpha says an opaque output is wanted"),
536
543
  }
537
544
 
538
545
 
546
+ # The alpha path beyond export.py (_common.alpha): --keep-alpha on the tools that grade or
547
+ # composite, --transparent on background.py. One description, so the four cannot drift.
548
+ ALPHA_LINE_TOOLS = ("color", "graphics", "overlay", "background")
549
+ ALPHA_LINE_OPTIONAL = [{"capability": "encoder:prores_ks", "when": "--keep-alpha / --transparent with --alpha-codec prores4444 (the default)"},
550
+ {"capability": "encoder:qtrle", "when": "--alpha-codec qtrle"}, {"capability": "encoder:png", "when": "--alpha-codec png"},
551
+ {"capability": "encoder:libvpx-vp9", "when": "--alpha-codec vp9"},
552
+ {"capability": "encoder:libopus", "when": "--alpha-codec vp9 on an input with audio (color, graphics, overlay)"},
553
+ {"capability": "decoder:libvpx-vp9", "when": "an alpha source or overlay --video layer stored as VP9 with alpha (refused without it, error.reason decoder_missing)"},
554
+ {"capability": "filter:alphaextract", "when": "an alpha source or --keep-alpha / --transparent (the alpha is read back)"}]
555
+
556
+
557
+ def alpha_result_schema(name: str) -> Dict[str, Any]:
558
+ flag = "--transparent" if name == "background" else "--keep-alpha"
559
+ out: Dict[str, Any] = {
560
+ "alpha_codec": {"enum": ["png", "prores4444", "qtrle", "vp9"], "description": f"{flag} only: the alpha line written (--alpha-codec, default prores4444); the source's colour tags are kept and no matrix is converted, as --codec prores does"},
561
+ "alpha_mode": {"enum": ["straight", "premultiplied"], "description": f"{flag} only: how the colour was written beside the alpha (--alpha-mode, default straight)"},
562
+ }
563
+ if name == "background":
564
+ out["alpha_codec"]["description"] += ". The `alpha` verification step reads every written frame back and expects alpha 0 (provenance specification)"
565
+ return out
566
+ out["alpha_dropped"] = {"type": "boolean", "description": "true when the source's alpha is used (a frame below opaque, or a stored alpha plane that could not be read) and the output does not keep it (color.py --retag too, when its copy fell back to an H.264 re-encode): without --keep-alpha the `alpha` verification step fails (reason alpha_dropped, attribution artifact, with an alpha_dropped warning); with --drop-alpha there is no such step and a note says the alpha was dropped on purpose. Absent on a source without alpha"}
567
+ if name == "color":
568
+ out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back unchanged; the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
569
+ else:
570
+ out["alpha_codec"]["description"] += ". Drawn on the input's alpha at 4:4:4 (layers composited with their alpha, text drawn into it); the `alpha` step (property alpha_at_least_source) passes when the written stream stores an alpha plane, every frame's alpha is at or above the source's (tolerance 2 of 255) and the output did not turn opaque in every frame the source was transparent in (reason: opaque_everywhere, alpha_below_source or no_alpha_plane when it fails)"
571
+ out["alpha_codec"]["description"] += ". A source that stores an alpha plane whose alpha could not be read is never taken as opaque: its step fails with reason source_alpha_unmeasured"
572
+ return out
573
+
574
+
539
575
  # `font_used` on the tools that draw text: which font the text was drawn with, so a run that
540
576
  # drew with a substitute says so (with a font_substituted fallback)
541
577
  FONT_USED_SCHEMA = {
@@ -565,7 +601,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
565
601
  "video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]},
566
602
  "timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
567
603
  "chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
568
- "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"}},
604
+ "subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title}]"},
605
+ "keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}},
569
606
  "additionalProperties": True}
570
607
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
571
608
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
@@ -582,8 +619,9 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
582
619
  if name == "check":
583
620
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
584
621
  "input_identity": {"type": "object", "description": "2.24: {size, mtime_ns} of the file graded, the same shape as a writing tool's output_identity"},
622
+ "levels": {"type": "object", "description": "--levels only: {standard, reference, bit_depth, legal {y, c}: inclusive legal code ranges at the file's depth, window_seconds, frames, out_of_range_pct {y, u, v, chroma}: share of samples outside them (chroma: Cb and Cr samples together), brng_pct (signalstats BRNG: pixels with any component outside the fixed BT.601/709 nominal range), worst_frame {time, y_pct, chroma_pct}, min {y, u, v}, max {y, u, v}, windows: the same per --levels-window seconds ({start, end, ...})}"},
585
623
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named (youtube was assumed and judgement rows are WARN), or when --compare paired a different number of frames than the reference has"},
586
- "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
624
+ "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; --compare adds similarity; --levels adds legal levels; --expect-identity adds identity; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
587
625
  elif name == "caption":
588
626
  extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
589
627
  "caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input. --ass: ass_malformed -- Dialogue lines that could not be read and are not counted -- and with --offset ass_shifted / ass_unshifted -- lines moved, and lines whose timestamps could not be read, copied as they were (each count above 0 is also a diagnostics.warnings entry)"},
@@ -604,7 +642,10 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
604
642
  extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"},
605
643
  "shots": {"type": "array", "description": "--shots (1.18): [{start, end, label, flow_magnitude}]"},
606
644
  "audio_peaks_db": {"type": "array", "description": "--audio-peaks (1.18): [{time, level}] measured dBFS, distinct from audio_peaks above"},
607
- "speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"}}
645
+ "speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"},
646
+ "audio_only": {"type": "boolean", "description": "After 3.2.0: present (true) when the input has no video stream: no scene detection ran and the file is one scene, 0 to its duration"},
647
+ "beats": {"type": "array", "description": "--beats (1.17): the regular beat grid, seconds"},
648
+ "beat_grid": {"type": "object", "description": "--beats (1.17): {supported_beats, tempo_bpm, interval, confidence, phase, onsets, supported, unsupported, method, step_s (the envelope's real step: 220/22050 s for --beat-step 0.01, after 3.2.0), range_bpm, usable, onset_refinement (after 3.2.0: pcm-window-ratio when each onset was placed on the decoded samples, else null)}"}}
608
649
  elif name == "silence":
609
650
  extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
610
651
  "removed_seconds_total": {"type": "number", "description": "--filler: seconds removed in all, silence plus filler words (removed_seconds stays the silence alone)"},
@@ -671,7 +712,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
671
712
  "sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
672
713
  "notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr: which path was taken and why; --retag: why the video was re-encoded (the stream copy failed, or ffprobe still read the old tags on it)"},
673
714
  "reencoded": {"type": "boolean", "description": "--retag: true when the stream copy could not carry the tags and the video was re-encoded with them on its frames"},
674
- "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
715
+ "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"},
716
+ "legalize": {"type": "object", "description": "--legalize only: {standard, reference, legal {y, c}, output}: output is check.py --levels' levels document for the written file (window 0); its verification step legal_levels fails when one frame keeps more than 1 % of its luma or chroma samples out of range after the encode"}}
675
717
  elif name == "loop":
676
718
  extra = {"boomerang": {"type": "boolean", "description": "--boomerang: frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice"},
677
719
  "crossfade": {"type": "number", "description": "--crossfade only: the fade used, in seconds, rounded to whole frames; one repeat is the clip minus this"},
@@ -680,7 +722,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
680
722
  "notes": {"type": "array", "items": {"type": "string"}, "description": "--boomerang on a source with audio: the audio was dropped"}}
681
723
  elif name == "cut":
682
724
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
683
- "mode": {"enum": ["copy", "hybrid", "accurate"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced; hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why)"},
725
+ "mode": {"enum": ["copy", "hybrid", "accurate", "smart"], "description": "copy: nothing re-encoded at any stage, the join included; accurate: --accurate was asked for or forced (or --smart fell back, reason in smart.reason); hybrid: asked for lossless but a segment or the join re-encoded anyway (reencode_reason says why); smart: --smart re-encoded only the partial GOPs at the cut points and copied the rest, verified"},
726
+ "smart": {"type": "object", "description": "--smart only: {applied: true, parts [{segment, kind: reencode|copy, start, end, frames}], reencoded_frames, copied_frames, matched (the source parameters the re-encoded parts reproduce: codec_name, profile, level, pix_fmt, width, height, sample_aspect_ratio, has_b_frames, colour tags), encoder (the libx264 arguments), checks {frames, grid_worst_seconds, copied_packets_equal, frames_decoded_equal, time_base}, time_base}; or {applied: false, reason, reason_code} when the cut could not be made or did not verify and the whole cut was re-encoded instead (reason_code: no_video, pending_input, vfr, codec, hold, params_unreadable, container, pix_fmt, profile, interlaced, reorder_depth, frame_rate, hdr, packets, no_frames, segments, av_offset, open_gop, seek, encode, copy_count, encode_count, params_mismatch, frames, decode, time_base)"},
684
727
  "requested_start": {"type": ["number", "null"], "description": "one segment: the requested start, clamped to the media's duration (null for --segments)"},
685
728
  "requested_end": {"type": ["number", "null"], "description": "one segment: the requested end, clamped to the media's duration (null for --segments)"},
686
729
  "requested_segments": {"type": ["array", "null"], "description": "--segments: [[start, end], ...] requested, clamped to the media's duration (null for one segment)"},
@@ -698,8 +741,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
698
741
  "keyframe_snapped": {"type": ["boolean", "null"], "description": "since 3.0.0 equal to start_snapped: a copied start really moved (2.x: precision was packet, the run a stream copy end to end)"},
699
742
  "start_snapped": {"type": ["boolean", "null"], "description": "measured: true when a copied segment's picture starts more than one frame from its requested start (the keyframe the copy's seek lands on, measured with the seek ffmpeg makes); false when every start is where asked (an MP4 edit list hides the pre-roll, the start is a keyframe, an audio-only copy, or everything re-encoded); null when a copied start was not measured (--dry-run)"},
700
743
  "reencoded": {"type": "boolean"},
701
- "reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback"]},
702
- "description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), stream_lost (the copy wrote no video or no audio, as an MPEG-TS copy with no keyframe in its window does), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
744
+ "reencode_reason": {"type": "array", "items": {"enum": ["requested", "codec", "vfr", "vfr_inconclusive", "pcm_container", "copy_failed", "stream_lost", "tolerance", "concat_fallback", "smart", "smart_fallback"]},
745
+ "description": "why anything was re-encoded, distinct values in first-seen order ([] when nothing was): smart (--smart re-encoded the partial GOPs at the cut points), smart_fallback (--smart could not be made or verified: the whole cut was re-encoded, smart.reason says why), requested (--accurate), codec (--codec), vfr (a variable-frame-rate source forced --accurate), vfr_inconclusive (its frame timing could not be measured), pcm_container (compressed audio into a .wav), copy_failed (the stream copy errored), stream_lost (the copy wrote no video or no audio, as an MPEG-TS copy with no keyframe in its window does), tolerance (a keyframe snap past --tolerance), concat_fallback (--segments parts could not be joined by stream copy)"},
703
746
  "segment_precision": {"type": ["array", "null"], "items": {"enum": ["packet", "sample", "codec_frame", "frame"]},
704
747
  "description": "--segments only: each segment's precision in order; least_exact_precision is the least exact of them (null for a single segment)"},
705
748
  "edit_list": {"type": "boolean", "description": "a single MP4/MOV stream copy kept the edit list it wrote instead of -avoid_negative_ts make_zero, so the keyframe's pre-roll is stored but hidden and the picture and the sound start at the requested time (the default since 3.0.0; false under --no-edit-list and for every other path)"},
@@ -734,6 +777,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
734
777
  "audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
735
778
  if name in ("overlay", "graphics", "caption", "grid"):
736
779
  extra["font_used"] = FONT_USED_SCHEMA
780
+ if name in ALPHA_LINE_TOOLS:
781
+ extra.update(alpha_result_schema(name))
737
782
  if name in NON_AV_STREAM_TOOLS and "dropped_non_av_streams" not in extra:
738
783
  extra["dropped_non_av_streams"] = {"type": "boolean", "description": "true when the source's subtitle or data streams are not in the output: either the run leaves them out by design (a path whose output they would no longer match, such as speedramp.py's, join.py's or freeze.py's), or they could not be copied and the encode ran again without them (then a non_av_streams_dropped fallback says why); export.py's non_av_streams lists them"}
739
784
  if name == "cropdetect":
@@ -847,7 +892,7 @@ def _parse_ff_list(flag: str, text: str) -> List[str]:
847
892
  m = _FILTER_ROW.match(line)
848
893
  if m:
849
894
  names.append(m.group(1))
850
- elif flag == "-encoders":
895
+ elif flag in ("-encoders", "-decoders"):
851
896
  lines = text.splitlines()
852
897
  sep = next((i for i, l in enumerate(lines) if _LIST_SEPARATOR.match(l)), None)
853
898
  rows = lines[sep + 1:] if sep is not None else lines
@@ -1230,6 +1275,7 @@ def required_capabilities() -> Dict[str, List[str]]:
1230
1275
  req.update(meta["required"])
1231
1276
  opt.update(o["capability"] for o in meta["optional"] if o["capability"] != "delegated")
1232
1277
  opt.update(o["capability"] for o in CODEC_CAPS)
1278
+ opt.update(o["capability"] for o in ALPHA_LINE_OPTIONAL)
1233
1279
  opt -= req
1234
1280
  return {"required": sorted(req), "optional": sorted(opt)}
1235
1281
 
@@ -1275,6 +1321,7 @@ def doctor() -> Dict[str, Any]:
1275
1321
  "encoders": _ff_listing("ffmpeg", "-encoders"),
1276
1322
  "filters": _ff_listing("ffmpeg", "-filters"),
1277
1323
  "bsfs": _ff_listing("ffmpeg", "-bsfs"),
1324
+ "decoders": _ff_listing("ffmpeg", "-decoders"),
1278
1325
  }
1279
1326
  sets = {k: set(v["names"]) for k, v in listings.items()}
1280
1327
  state: Dict[str, str] = {} # capability -> available | missing | unknown
@@ -1298,6 +1345,8 @@ def doctor() -> Dict[str, Any]:
1298
1345
  state[cap] = "available" if _exe("ffprobe") else "missing"
1299
1346
  elif cap.startswith("encoder:"):
1300
1347
  state[cap] = _from("encoders", cap[8:])
1348
+ elif cap.startswith("decoder:"):
1349
+ state[cap] = _from("decoders", cap[8:])
1301
1350
  elif cap == "filter:drawtext":
1302
1351
  listing_state = _from("filters", "drawtext")
1303
1352
  if listing_state == "available":
@@ -1458,6 +1507,8 @@ def _capability_fix_hint(cap: str, drawtext_crashed: bool = False) -> str:
1458
1507
  full_hint = "install/build ffmpeg with it enabled"
1459
1508
  if cap.startswith("encoder:"):
1460
1509
  return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
1510
+ if cap.startswith("decoder:"):
1511
+ return f"this ffmpeg build has no {cap[8:]} decoder; {full_hint}"
1461
1512
  if cap == "filter:drawtext" and not drawtext_crashed:
1462
1513
  return f"this ffmpeg build has no drawtext filter (no freetype); {full_hint}"
1463
1514
  if cap == "filter:drawtext":
@@ -1623,7 +1674,8 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1623
1674
  # 1.20.0: SKILL.md's own request-table rows for this tool, machine-readable
1624
1675
  "examples": skill_examples().get(name, []),
1625
1676
  "role": meta["role"],
1626
- "capabilities": {"required": list(meta["required"]), "optional": _merge_optional(meta["optional"], CODEC_CAPS if "codec" in schema["properties"] else [])},
1677
+ "capabilities": {"required": list(meta["required"]), "optional": _merge_optional(meta["optional"], (CODEC_CAPS if "codec" in schema["properties"] else [])
1678
+ + (ALPHA_LINE_OPTIONAL if name in ALPHA_LINE_TOOLS else []))},
1627
1679
  "inputs": list(meta["inputs"]),
1628
1680
  "outputs": list(meta["outputs"]),
1629
1681
  "input_schema": schema,
@@ -1884,7 +1936,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1884
1936
  version = skill_version()
1885
1937
  tools = [tool_spec(n, version) for n in public_tools()]
1886
1938
  wanted = required_capabilities()
1887
- caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper | external:parakeet"}
1939
+ caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | decoder:<name> | filter:<name> | bsf:<name> | external:whisper | external:parakeet"}
1888
1940
  if detect:
1889
1941
  d = doctor()
1890
1942
  caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],