ffmpeg-skill 3.1.5 → 3.1.7
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/docs/contract.md +11 -7
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +12 -6
- package/scripts/_common/__init__.py +7 -5
- package/scripts/_common/asr.py +49 -10
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +177 -10
- package/scripts/_common/emit.py +2 -0
- package/scripts/_common/fonts.py +26 -0
- package/scripts/_common/probe.py +115 -5
- package/scripts/_common/runner.py +38 -1
- package/scripts/_common/timeline.py +123 -27
- package/scripts/_contract.py +34 -3
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +105 -19
- package/scripts/color.py +37 -8
- package/scripts/crop.py +8 -3
- package/scripts/cropdetect.py +13 -9
- package/scripts/cut.py +4 -4
- package/scripts/export.py +16 -15
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +104 -67
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +9 -1
- package/scripts/join.py +29 -19
- package/scripts/look.py +41 -12
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +68 -14
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +91 -35
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/waveform.py +8 -3
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.1.7`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.1.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.1.7", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -786,8 +786,10 @@ Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
|
|
|
786
786
|
| `matched_untimed`, `matched_too_long` | `silence.py --filler` (inside `filler`) | listed words that were not cut: no usable timing in the transcript (also a `filler_words_untimed` warning), or longer than a filler (`FILLER_MAX_WORD`, a held vowel) |
|
|
787
787
|
|
|
788
788
|
`caption.py --transcribe` and `silence.py --filler --transcribe`: when a local speech engine ran
|
|
789
|
-
and produced no cue
|
|
790
|
-
|
|
789
|
+
and produced no cue (for `--filler`: a Whisper transcript with no text at all; text without word
|
|
790
|
+
timings is a build without them, refused as such), the refusal is `"<engine> found no speech in
|
|
791
|
+
<input>"` with `kind: input`, `reason: "no_speech"` and `engine` in the failure document (the
|
|
792
|
+
three ASR reasons below are in `error.reason` too, with `error.details`) — distinct from the "no local
|
|
791
793
|
speech-to-text engine found" refusal, which now only means no engine was found. When `--engine
|
|
792
794
|
auto` found engines and none transcribed, the refusal is `kind: input` with `reason:
|
|
793
795
|
"engine_failed"` (one ran and failed) or `"english_only"` (the only engines found were Parakeet
|
|
@@ -865,8 +867,9 @@ A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
|
865
867
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
866
868
|
`media_path_is_url`, `media_path_is_playlist` (an HLS or ffconcat playlist given as media),
|
|
867
869
|
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
868
|
-
`render.py`'s `plan_*` reasons,
|
|
869
|
-
`encoder_list_unreadable
|
|
870
|
+
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
871
|
+
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
|
872
|
+
and `english_only`), and `error.details`, the facts the message is written from, so a
|
|
870
873
|
caller never parses the prose:
|
|
871
874
|
|
|
872
875
|
```
|
|
@@ -886,7 +889,8 @@ present on success and failure documents alike, only when one list is not empty,
|
|
|
886
889
|
`render.py` stage reported carries `stage`. `contract --json` → `json_output.diagnostic_codes`
|
|
887
890
|
lists every code with its bucket and meaning (a test reads every `warn()`/`record_fallback()`/
|
|
888
891
|
`record_skip()` call against it). Among them: the runner's `odd_dimensions_scaled_even` (an encode
|
|
889
|
-
re-run with an even scale
|
|
892
|
+
re-run with an even scale, or broll.py's odd-sized A-roll scaled even up front; the frame loses a
|
|
893
|
+
row or column), `non_av_streams_dropped` (with the
|
|
890
894
|
first attempt's error beside `dropped_non_av_streams`), `measurement_input_pending` (skipped: a
|
|
891
895
|
dry-run measurement of an earlier stage's output), `ffmpeg_list_unreadable` (`ffmpeg -encoders` or
|
|
892
896
|
`-bsfs` could not be read; what needed it names the read failure, not a missing encoder) and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.7",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
|
@@ -178,8 +178,9 @@ these had ever shown up before.
|
|
|
178
178
|
pads audio should do the same.
|
|
179
179
|
- **FFmpeg 8.0 and 8.0.1's `aselect` passes every frame through** (its `activate` callback was lost
|
|
180
180
|
in the filter conversion; fixed in 8.0.2/8.1). Nothing fails: the picture is cut and the sound is
|
|
181
|
-
not.
|
|
182
|
-
`
|
|
181
|
+
not. Do not use `aselect` for removal: cut each range with `atrim` (silence.py re-cuts its ranges
|
|
182
|
+
through cut.py's `--segments --accurate` path; through 3.1.3 it tagged the kept frames with
|
|
183
|
+
`ametadata=mode=add` and kept them with `ametadata=mode=select`).
|
|
183
184
|
- **FFmpeg 8.0, 8.0.1 and 8.1.0 draw the font's missing-glyph box at each line break of a multi-line
|
|
184
185
|
`drawtext`** (each line is shaped with its newline; fixed in 8.0.2/8.1.1). `ffmpeg_version()` cannot
|
|
185
186
|
tell 8.1.0 from 8.1.1; `drawtext_draws_newline_glyph()` reads the point release. graphics.py routes
|
package/references/gotchas.md
CHANGED
|
@@ -319,7 +319,11 @@ the stored width and height plus that rotation. A tool that places or measures
|
|
|
319
319
|
a rectangle (`fit.py`, `crop.py`, `cropdetect.py`, `broll.py`, `overlay.py
|
|
320
320
|
--scale-percent` / `--platform`, `look.py --safe`) uses the displayed size,
|
|
321
321
|
the two swapped for 90° and 270°, because ffmpeg applies the tag before the
|
|
322
|
-
filter graph.
|
|
322
|
+
filter graph. A JPEG's EXIF Orientation counts as that rotation too. Footage that
|
|
323
|
+
stores non-square pixels (DV, HDV/XDCAM 1440x1080, DVD) is reported with its
|
|
324
|
+
`sample_aspect_ratio`; every tool that sizes, places or draws on the picture
|
|
325
|
+
first scales it to square pixels at its display aspect (its height kept, a
|
|
326
|
+
`notes` line says so), so rectangles and margins are in that displayed frame.
|
|
323
327
|
|
|
324
328
|
## Planning
|
|
325
329
|
|
package/references/scripts.md
CHANGED
|
@@ -513,7 +513,8 @@ freeze.py INPUT --hold T [--at T] [--mode insert|extend] [-o OUT]
|
|
|
513
513
|
how long the freeze lasts. `--mode insert` (default) inserts the hold at
|
|
514
514
|
`--at`, pushing everything after it later by `--hold` seconds. `--mode
|
|
515
515
|
extend` only works with `--at` at (or past) the clip's end and just makes
|
|
516
|
-
the last frame last `--hold` seconds longer, with nothing pushed
|
|
516
|
+
the last frame last `--hold` seconds longer, with nothing pushed; `--mode insert` at
|
|
517
|
+
the clip's end (no `--at`) does the same. Audio is
|
|
517
518
|
silent during the held frame in `--mode insert` (there is no source audio
|
|
518
519
|
for a frozen moment that didn't exist before). `--mode insert` drops a
|
|
519
520
|
subtitle/data track rather than copy it with timestamps that no longer match
|
|
@@ -650,15 +651,16 @@ silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|ko|z
|
|
|
650
651
|
[--model MODEL] [--filler-list] [--max-cuts 400]
|
|
651
652
|
```
|
|
652
653
|
Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
|
|
653
|
-
gaps shorter than `--min-silence`, and re-encodes
|
|
654
|
-
|
|
655
|
-
|
|
654
|
+
gaps shorter than `--min-silence`, and re-encodes the kept ranges the way `cut.py --segments
|
|
655
|
+
--accurate` does: both streams cut on the same frame times, so the sound never drifts from the
|
|
656
|
+
picture at the cuts, and a sound that starts late keeps its offset. `--list` prints silences, kept ranges and seconds removed
|
|
656
657
|
without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
|
|
657
658
|
so the user can edit the list by hand. Quiet rooms need `--threshold -40`
|
|
658
659
|
to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
659
660
|
|
|
660
661
|
**Breaths and weak word endings.** `--margin` is the padding kept on each side of every cut
|
|
661
|
-
(default 0.2 s since 2.22, 0.15 s before; 0.3 s for a speaker who trails off).
|
|
662
|
+
(default 0.2 s since 2.22, 0.15 s before; 0.3 s for a speaker who trails off). It is kept around
|
|
663
|
+
silences only: a filler word's cut is the word plus `--filler-pad` (since 3.1.6). `--speech-aware` keeps the short
|
|
662
664
|
breaths inside a sentence and cuts only at sentence-boundary pauses. A `--threshold` set too high
|
|
663
665
|
(-25, say) counts a weak word ending as silence: the result then warns.
|
|
664
666
|
|
|
@@ -851,7 +853,11 @@ OTIO's adapters). Clips keep their in/out and speed (an M2 line, a `timeMap`, a
|
|
|
851
853
|
`LinearTimeWarp`); `transition` becomes a cross dissolve centred on each cut, trimmed so the
|
|
852
854
|
timeline is exactly as long as `join.py`'s crossfade would render it; `audio.music` is its own
|
|
853
855
|
track; inline `chapters` are markers. Times take the render's own grammar (`"0:05"`,
|
|
854
|
-
`hh:mm:ss:ff` at the source's fps
|
|
856
|
+
`hh:mm:ss:ff` at the source's fps; an `out` past a source's end ends at it, as the render
|
|
857
|
+
cuts it, with a note; a chapter metadata.py would refuse is refused). Source positions count
|
|
858
|
+
from each source's start timecode at its own frame rate (EDL source timecodes, drop-frame
|
|
859
|
+
where the source's is; the FCPXML asset's `start`; OTIO's `available_range`), so a conform
|
|
860
|
+
finds the frames (3.1.6). The sequence is the project's `frame` sized by
|
|
855
861
|
fit.py's rule (`{aspect 16:9, width 1920}` is 1920x1080; an aspect alone takes the export
|
|
856
862
|
preset's size when the preset has that aspect; an aspect fit.py refuses, such as `16/9`, is
|
|
857
863
|
refused unless the project's own `fit.aspect` replaces it in the render). A clip of another
|
|
@@ -70,7 +70,8 @@ from _common.emit import (
|
|
|
70
70
|
from _common.probe import (
|
|
71
71
|
analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
|
|
72
72
|
_fraction, keyframes_near, classify_frame_timing, measure_frame_timing, SCORE_RE, SIL_RE, decode_gray_frames,
|
|
73
|
-
measured_level_dbfs, AUDIO_MEDIA_EXT, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
|
|
73
|
+
measured_level_dbfs, AUDIO_MEDIA_EXT, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output,
|
|
74
|
+
display_size, pixel_aspect, square_pixels, square_pixels_note
|
|
74
75
|
)
|
|
75
76
|
from _common.decision import (
|
|
76
77
|
aac_args, add_pad_fill_args, aspect_ratio, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
|
|
@@ -84,7 +85,7 @@ from _common.decision import (
|
|
|
84
85
|
_onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
|
|
85
86
|
brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
86
87
|
fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
|
|
87
|
-
signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, matrix_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
88
|
+
signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
88
89
|
)
|
|
89
90
|
from _common.asr import (
|
|
90
91
|
ASR_ENGINES, ASR_INSTALL_HINT, _asr_run, die_no_engine, parse_srt, transcribe, _transcribe_in,
|
|
@@ -93,7 +94,7 @@ from _common.asr import (
|
|
|
93
94
|
)
|
|
94
95
|
from _common.color import (
|
|
95
96
|
bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, color_hex, _COLOR_TOKEN_RE, drawn_stream_format, fold_bt709_matrix,
|
|
96
|
-
is_neutral_color, is_rgb, NON_BT709_MATRICES, rgb_island, rgb_to_bt709_filter, _sdr_bt709, validate_color
|
|
97
|
+
as_limited, is_full_range, is_neutral_color, is_rgb, limited_range_filter, NON_BT709_MATRICES, rgb_island, rgb_to_bt709_filter, _sdr_bt709, validate_color
|
|
97
98
|
)
|
|
98
99
|
from _common.text import (
|
|
99
100
|
ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align, drawtext_fontfile,
|
|
@@ -215,7 +216,7 @@ __all__ = [
|
|
|
215
216
|
"SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp", "still_input_args",
|
|
216
217
|
"_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
|
|
217
218
|
"time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
|
|
218
|
-
"source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
219
|
+
"source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "layer_base_chain", "matrix_chain", "range_chain", "as_limited", "is_full_range", "limited_range_filter", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
219
220
|
"_x264_raw", "_ZWJ",
|
|
220
221
|
"absorb_stage", "audit", "note", "_plan_unbound_inputs", "record_fallback", "record_skip", "refuse", "warn",
|
|
221
222
|
"ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
|
|
@@ -229,5 +230,6 @@ __all__ = [
|
|
|
229
230
|
"PENALTY_IDEOGRAPHS", "PENALTY_NEUTRAL", "PENALTY_OKURIGANA", "PENALTY_PARTICLE", "PENALTY_SENTENCE_END",
|
|
230
231
|
"_rebalance", "_rebalance_phrase", "SAFE_WIDTH_FRACTION", "_split_hyphens", "_slice_atom", "_particle_ends",
|
|
231
232
|
"_particle_starts", "wrap_text", "wrap_variants", "WRAP_MODES",
|
|
232
|
-
"fit_size", "line_em_for_size", "MIN_CAPTION_FRACTION", "ass_units_local"
|
|
233
|
+
"fit_size", "line_em_for_size", "MIN_CAPTION_FRACTION", "ass_units_local",
|
|
234
|
+
"display_size", "pixel_aspect", "square_pixels", "square_pixels_note"
|
|
233
235
|
]
|
package/scripts/_common/asr.py
CHANGED
|
@@ -115,7 +115,9 @@ def die_no_speech(engine: str, video: str) -> None:
|
|
|
115
115
|
"""An engine ran and heard nothing: kind input, `reason: "no_speech"`, naming the engine and
|
|
116
116
|
the input -- not the "no engine found" refusal (one was found) and not "no cues found in"
|
|
117
117
|
the engine's own temporary SRT (a path the caller never gave and that is already deleted)."""
|
|
118
|
+
# the reason is error.reason as every refusal's is (#414), and stays top-level where 2.x put it
|
|
118
119
|
die(f"{engine} found no speech in {video}", kind="input", reason=NO_SPEECH_REASON, engine=engine,
|
|
120
|
+
error_extra={"reason": NO_SPEECH_REASON, "details": {"engine": engine}},
|
|
119
121
|
hint="check that the input (and --audio-stream) carries the speech, or write the cues by hand with --text")
|
|
120
122
|
|
|
121
123
|
|
|
@@ -135,6 +137,7 @@ def die_engine_failed(engine: str, video: str, tried: "List[Dict[str, str]]", in
|
|
|
135
137
|
detail = next((t["detail"] for t in tried if t["engine"] == engine), "failed")
|
|
136
138
|
die(f"--engine {engine} could not transcribe {video}: {engine} {detail}", kind="input",
|
|
137
139
|
reason=ENGINE_FAILED_REASON, engine=engine, detail=detail,
|
|
140
|
+
error_extra={"reason": ENGINE_FAILED_REASON, "details": {"engine": engine, "detail": detail}},
|
|
138
141
|
hint="fix the engine (its own message is in detail), or use --engine auto to fall back to another installed engine")
|
|
139
142
|
|
|
140
143
|
|
|
@@ -152,7 +155,9 @@ def die_engines_failed(tried: "List[Dict[str, str]]", video: str, flag: str, alt
|
|
|
152
155
|
die(f"no installed speech-to-text engine could transcribe {video} for {flag}:\n"
|
|
153
156
|
+ "\n".join(f" {t['engine']} {t['detail']}" for t in tried) + "\n" + alternative,
|
|
154
157
|
kind="input", reason=ENGINE_FAILED_REASON if failed else ENGLISH_ONLY_REASON,
|
|
155
|
-
engine=(failed or tried)[0]["engine"], engines=[dict(t) for t in tried], hint=hint
|
|
158
|
+
engine=(failed or tried)[0]["engine"], engines=[dict(t) for t in tried], hint=hint,
|
|
159
|
+
error_extra={"reason": ENGINE_FAILED_REASON if failed else ENGLISH_ONLY_REASON,
|
|
160
|
+
"details": {"engine": (failed or tried)[0]["engine"], "engines": [dict(t) for t in tried]}})
|
|
156
161
|
|
|
157
162
|
|
|
158
163
|
def _failed(engine: str, detail: str) -> "Dict[str, str]":
|
|
@@ -317,15 +322,11 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
|
|
|
317
322
|
parakeet_model: Optional[str] = None) -> Transcription:
|
|
318
323
|
"""`model` is what the Whisper engines run; the Parakeet engines are offered `parakeet_model`
|
|
319
324
|
(default `model`), which differs only for a Parakeet model from $FFMPEG_SKILL_ASR_MODEL."""
|
|
320
|
-
from _common import
|
|
325
|
+
from _common import STATE
|
|
321
326
|
pmodel = model if parakeet_model is None else parakeet_model
|
|
322
327
|
language = engine_language(language)
|
|
323
328
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
324
|
-
|
|
325
|
-
# is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
|
|
326
|
-
# reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
|
|
327
|
-
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
328
|
-
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
329
|
+
engine_wav(ffmpeg, video, audio_stream, wav)
|
|
329
330
|
# every engine found that did not transcribe, and why: the refusal names them rather than
|
|
330
331
|
# telling the caller to install what is installed
|
|
331
332
|
tried: "List[Dict[str, str]]" = []
|
|
@@ -980,6 +981,37 @@ def _heard_nothing(text: str, key: str) -> bool:
|
|
|
980
981
|
return isinstance(doc, dict) and doc.get(key) == []
|
|
981
982
|
|
|
982
983
|
|
|
984
|
+
def _whisper_heard_nothing(path: str, key: str) -> bool:
|
|
985
|
+
"""True when a Whisper engine's JSON is well formed and none of its segments (`key`:
|
|
986
|
+
whisper.cpp's "transcription", openai-whisper's "segments") holds any text: the engine heard
|
|
987
|
+
no speech (#414). A document with text and no word timings is a build without them, not
|
|
988
|
+
silence, and stays the "no word-level timings" refusal."""
|
|
989
|
+
try:
|
|
990
|
+
doc = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
991
|
+
except (OSError, ValueError):
|
|
992
|
+
return False
|
|
993
|
+
if not isinstance(doc, dict) or not isinstance(doc.get(key), list):
|
|
994
|
+
return False
|
|
995
|
+
# every segment a dict with a text field, all of them blank: a segment with no `text` at all is
|
|
996
|
+
# output this skill cannot read, not silence (as _heard_nothing() reads Parakeet's)
|
|
997
|
+
return all(isinstance(seg, dict) and isinstance(seg.get("text"), str) and not seg["text"].strip()
|
|
998
|
+
for seg in doc[key])
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def engine_wav(ffmpeg: str, video: str, audio_stream: int, wav: str) -> None:
|
|
1002
|
+
"""The 16 kHz mono WAV an engine transcribes, on the file's clock: a sound that starts after
|
|
1003
|
+
the file's start is padded with silence to its place (aresample first_pts=0, as
|
|
1004
|
+
decode_pcm_mono() does since #409), so every cue and word time counts from the file's start,
|
|
1005
|
+
not from the sound's first sample (#414: a sound 0.276 s late gave cues 0.276 s early).
|
|
1006
|
+
A measurement input in our own temp dir, not a deliverable, so not a run() call (no
|
|
1007
|
+
--dry-run gate, not recorded); it keeps the time limit and reports an unreadable input as
|
|
1008
|
+
kind ffmpeg instead of a CalledProcessError traceback."""
|
|
1009
|
+
from _common import run_analysis
|
|
1010
|
+
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
1011
|
+
"-map", f"0:a:{audio_stream}", "-vn", "-af", "aresample=async=1:first_pts=0",
|
|
1012
|
+
"-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
1013
|
+
|
|
1014
|
+
|
|
983
1015
|
def transcribe_words(video: str, language: "Optional[str]" = None, model: "Optional[str]" = None,
|
|
984
1016
|
audio_stream: int = 0, engine: "Optional[str]" = None) -> "Tuple[List[Dict[str, Any]], Optional[str]]":
|
|
985
1017
|
"""transcribe_words_result()'s (words, engine) alone: the call shape this function has had
|
|
@@ -1021,9 +1053,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1021
1053
|
tmpdir = _tempfile.mkdtemp(prefix="ffskill_asrw_")
|
|
1022
1054
|
try:
|
|
1023
1055
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
1024
|
-
|
|
1025
|
-
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000",
|
|
1026
|
-
"-c:a", "pcm_s16le", wav])
|
|
1056
|
+
engine_wav(ffmpeg, video, audio_stream, wav)
|
|
1027
1057
|
|
|
1028
1058
|
tried: "List[Dict[str, str]]" = [] # engines found that gave no words, and why
|
|
1029
1059
|
|
|
@@ -1065,6 +1095,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1065
1095
|
proc = _asr_run(cmd, _subprocess, "whisper.cpp")
|
|
1066
1096
|
if proc.returncode == 0 and os.path.exists(base + ".json"):
|
|
1067
1097
|
words = _words_from_whisper_cpp_json(base + ".json")
|
|
1098
|
+
if not words and _whisper_heard_nothing(base + ".json", "transcription"):
|
|
1099
|
+
die_no_speech("whisper.cpp", video)
|
|
1068
1100
|
info(f"word timings from whisper.cpp ({len(words)} words)")
|
|
1069
1101
|
return Transcription(words=words, engine="whisper.cpp",
|
|
1070
1102
|
facts=dict(facts, engine="whisper.cpp", model=model_path,
|
|
@@ -1083,6 +1115,7 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1083
1115
|
collected: list = []
|
|
1084
1116
|
crashed: list = []
|
|
1085
1117
|
heard: list = []
|
|
1118
|
+
spoke: list = [] # a segment with text: speech was heard, word timings or not
|
|
1086
1119
|
|
|
1087
1120
|
def work() -> None:
|
|
1088
1121
|
try:
|
|
@@ -1091,6 +1124,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1091
1124
|
**({"initial_prompt": prompt} if prompt else {}))
|
|
1092
1125
|
heard.append(getattr(decoded, "language", None))
|
|
1093
1126
|
for seg in segments:
|
|
1127
|
+
if str(getattr(seg, "text", "") or "").strip():
|
|
1128
|
+
spoke.append(True)
|
|
1094
1129
|
for w in (getattr(seg, "words", None) or []):
|
|
1095
1130
|
collected.append({"word": str(w.word).strip(),
|
|
1096
1131
|
"start": float(w.start), "end": float(w.end)})
|
|
@@ -1109,6 +1144,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1109
1144
|
tried.append(_failed("faster-whisper", "found but failed: " + line))
|
|
1110
1145
|
failed = failed or "faster-whisper"
|
|
1111
1146
|
else:
|
|
1147
|
+
if not collected and not spoke:
|
|
1148
|
+
die_no_speech("faster-whisper", video)
|
|
1112
1149
|
info(f"word timings from faster-whisper ({len(collected)} words)")
|
|
1113
1150
|
return Transcription(words=[w for w in collected if w["word"]], engine="faster-whisper",
|
|
1114
1151
|
facts=dict(facts, engine="faster-whisper", model=model,
|
|
@@ -1129,6 +1166,8 @@ def transcribe_words_result(video: str, language: "Optional[str]" = None, model:
|
|
|
1129
1166
|
doc = os.path.join(tmpdir, "audio.json")
|
|
1130
1167
|
if proc.returncode == 0 and os.path.exists(doc):
|
|
1131
1168
|
words = _words_from_openai_whisper_json(doc)
|
|
1169
|
+
if not words and _whisper_heard_nothing(doc, "segments"):
|
|
1170
|
+
die_no_speech("openai-whisper", video)
|
|
1132
1171
|
info(f"word timings from openai-whisper ({len(words)} words)")
|
|
1133
1172
|
return Transcription(words=words, engine="openai-whisper",
|
|
1134
1173
|
facts=dict(facts, engine="openai-whisper", model=model,
|
package/scripts/_common/color.py
CHANGED
|
@@ -153,6 +153,45 @@ def is_neutral_color(value: Any) -> bool:
|
|
|
153
153
|
return False
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
def is_full_range(meta: Optional[Dict[str, Any]]) -> bool:
|
|
157
|
+
"""True when the probed YUV picture is full range: a `yuvj` format, or any other YUV format
|
|
158
|
+
tagged `pc` (VP9 and AV1 full range decode as plain `yuv420p` tagged pc, 10-bit full range as
|
|
159
|
+
`yuv420p10le` tagged pc). An RGB picture is not: bt709_matrix_filter() makes it limited YUV."""
|
|
160
|
+
v = (meta or {}).get("video") or {}
|
|
161
|
+
if is_rgb(meta) or not v.get("pix_fmt"):
|
|
162
|
+
return False
|
|
163
|
+
return str(v["pix_fmt"]).startswith("yuvj") or v.get("color_range") == "pc"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def limited_range_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
167
|
+
"""The filter that takes a full-range SDR YUV picture (is_full_range) to limited range, or None
|
|
168
|
+
for any other picture (limited, RGB, HDR/BT.2020, no probe).
|
|
169
|
+
|
|
170
|
+
A tool that puts several pictures in one frame or one stream (concat, xfade, xstack, overlay)
|
|
171
|
+
passes their samples through as they are, so a full-range clip beside a limited one was shown
|
|
172
|
+
with the other's range: C03028 came out C9251B (#410). The input range is left to `scale`,
|
|
173
|
+
which reads it from each frame (FFmpeg 6.1 already converts a full-range non-`yuvj` frame to
|
|
174
|
+
limited at any resize, so a stated `in_range=pc` would convert some frames twice); only the
|
|
175
|
+
output range is stated. A `yuvj` format is full range by name, so it is also given its limited
|
|
176
|
+
name (`format=`), or swscale would write full range under the limited tag."""
|
|
177
|
+
v = (meta or {}).get("video") or {}
|
|
178
|
+
if v.get("bt2020_or_hdr") or not is_full_range(meta):
|
|
179
|
+
return None
|
|
180
|
+
pf = str(v["pix_fmt"])
|
|
181
|
+
return "scale=out_range=tv" + (f",format=yuv{pf[4:]}" if pf.startswith("yuvj") else "")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def as_limited(meta: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
185
|
+
"""`meta` as it reads after limited_range_filter(): the same probe with a limited-range format and
|
|
186
|
+
tag, for the code that plans what is drawn on the converted picture (drawn_stream_format)."""
|
|
187
|
+
if not limited_range_filter(meta):
|
|
188
|
+
return meta
|
|
189
|
+
v = dict((meta or {}).get("video") or {})
|
|
190
|
+
pf = str(v["pix_fmt"])
|
|
191
|
+
v.update(pix_fmt="yuv" + pf[4:] if pf.startswith("yuvj") else pf, color_range="tv")
|
|
192
|
+
return {**(meta or {}), "video": v}
|
|
193
|
+
|
|
194
|
+
|
|
156
195
|
def drawn_stream_format(meta: Optional[Dict[str, Any]]) -> "Tuple[str, bool]":
|
|
157
196
|
"""(pix_fmt, full range) of the picture a tool draws on, for rgb_island(): the probed source's own
|
|
158
197
|
8-bit planar YUV format (an RGB source has become yuv444p, BT.709 limited, through
|
|
@@ -161,7 +200,7 @@ def drawn_stream_format(meta: Optional[Dict[str, Any]]) -> "Tuple[str, bool]":
|
|
|
161
200
|
if is_rgb(meta):
|
|
162
201
|
return "yuv444p", False
|
|
163
202
|
pf = str(v.get("pix_fmt") or "yuv420p")
|
|
164
|
-
full =
|
|
203
|
+
full = is_full_range(meta)
|
|
165
204
|
if pf not in ("yuv420p", "yuv422p", "yuv444p", "yuvj420p", "yuvj422p", "yuvj444p"):
|
|
166
205
|
pf = "yuv420p"
|
|
167
206
|
return pf, full
|
|
@@ -14,8 +14,8 @@ import argparse
|
|
|
14
14
|
from fractions import Fraction
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
17
|
-
from _common.color import (bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
|
|
18
|
-
is_neutral_color, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
|
|
17
|
+
from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
|
|
18
|
+
is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
|
|
19
19
|
from _common.emit import die, record_fallback, refuse, warn
|
|
20
20
|
from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
|
|
21
21
|
|
|
@@ -199,17 +199,60 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
199
199
|
return total
|
|
200
200
|
|
|
201
201
|
|
|
202
|
-
def time_arg(value: str, flag: str, fps: Optional[float] = None
|
|
202
|
+
def time_arg(value: str, flag: str, fps: Optional[float] = None,
|
|
203
|
+
source: Optional[Dict[str, Any]] = None) -> float:
|
|
203
204
|
"""parse_time() for a command-line flag: SMPTE hh:mm:ss:ff resolves with the input's fps when
|
|
204
205
|
the caller has one, and every parse failure is a `kind: input` refusal naming the flag (so
|
|
205
|
-
`--json` callers get a failure document, never a traceback).
|
|
206
|
+
`--json` callers get a failure document, never a traceback). `source` is the probe of the
|
|
207
|
+
input the time is in when that is not the first one (a B-roll's --from, an overlay layer's
|
|
208
|
+
--place range): the SMPTE hint then names that input's start timecode and length."""
|
|
206
209
|
try:
|
|
207
|
-
|
|
210
|
+
seconds = parse_time(value, fps)
|
|
208
211
|
except MissingFpsError as e:
|
|
209
212
|
die(f"{flag} {value!r}: {e}")
|
|
210
213
|
except ValueError as e:
|
|
211
214
|
die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at the source's fps or with an explicit @fps suffix)")
|
|
212
|
-
|
|
215
|
+
_remember_smpte_hint(value, flag, fps, seconds, source)
|
|
216
|
+
return seconds
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _remember_smpte_hint(value: str, flag: str, fps: Optional[float], seconds: float,
|
|
220
|
+
source: Optional[Dict[str, Any]] = None) -> None:
|
|
221
|
+
"""An hh:mm:ss:ff time counts from the file's first frame (00:00:00:00), not from a camera's
|
|
222
|
+
start timecode (docs/design-decisions.md, time grammar). When one reads past the end of a
|
|
223
|
+
source that carries a start timecode it was almost certainly typed off the source's own
|
|
224
|
+
burned-in or NLE timecode: keep a hint naming the frame it would be from the source's start,
|
|
225
|
+
which die() adds to the refusal a tool then raises for the out-of-range time (#411). The
|
|
226
|
+
grammar itself, and what a valid time means, are unchanged. A time in another input than the
|
|
227
|
+
first is judged against that input's own timecode and length (#416 review)."""
|
|
228
|
+
if source is not None:
|
|
229
|
+
own = (source.get("timecode") or {}).get("value")
|
|
230
|
+
tc = (own, source.get("duration")) if own else None
|
|
231
|
+
else:
|
|
232
|
+
tc = getattr(STATE, "source_timecode", None)
|
|
233
|
+
text = value.strip().rpartition("@")[0] if "@" in value else value.strip()
|
|
234
|
+
if not tc or len(text.split(":")) != 4:
|
|
235
|
+
return
|
|
236
|
+
start_tc, duration = tc
|
|
237
|
+
rate = fps
|
|
238
|
+
if "@" in value:
|
|
239
|
+
try:
|
|
240
|
+
rate = float(value.rpartition("@")[2])
|
|
241
|
+
except ValueError:
|
|
242
|
+
return
|
|
243
|
+
try:
|
|
244
|
+
start = parse_time(re.sub(r"[;.](\d+)$", r":\1", start_tc), rate)
|
|
245
|
+
except ValueError:
|
|
246
|
+
return
|
|
247
|
+
if not duration or seconds <= duration or seconds < start:
|
|
248
|
+
return
|
|
249
|
+
within = seconds - start
|
|
250
|
+
where = (f"{flag} {text} is {fmt_smpte_time(within, rate)} from the file's start"
|
|
251
|
+
if within <= duration else f"{flag} {text} is past the end of this {duration:.3f} s file even so")
|
|
252
|
+
prefix = (f"hh:mm:ss:ff times count from the file's first frame (00:00:00:00), not from the source's "
|
|
253
|
+
f"start timecode {start_tc}: ")
|
|
254
|
+
before = STATE.smpte_hint[len(prefix):] + "; " if STATE.smpte_hint and STATE.smpte_hint.startswith(prefix) else ""
|
|
255
|
+
STATE.smpte_hint = prefix + before + where
|
|
213
256
|
|
|
214
257
|
|
|
215
258
|
def signed_time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
|
|
@@ -291,6 +334,97 @@ def escape_filter_path(path: str, *, bind: bool = True) -> str:
|
|
|
291
334
|
return p
|
|
292
335
|
|
|
293
336
|
|
|
337
|
+
# ------------------------------------------------------- both streams on one clock (#409)
|
|
338
|
+
#
|
|
339
|
+
# ffmpeg shifts every stream of an input by one constant (the file's start_time), so a filter graph
|
|
340
|
+
# sees a sound that starts 0.3 s after its picture at 0.3. A per-stream `PTS-STARTPTS` moved that
|
|
341
|
+
# sound 0.3 s early. cut.py's re-cut is the reference: both streams are cut on the same bounds,
|
|
342
|
+
# shifted by the same constant, and the audio is padded to the origin
|
|
343
|
+
# (docs/design-decisions.md, "Both streams of a segment shift by one constant").
|
|
344
|
+
|
|
345
|
+
def stream_starts(meta: Optional[Dict[str, Any]]) -> Tuple[float, float]:
|
|
346
|
+
"""Where the video and the audio start on the clock a filter graph reads the input on (each
|
|
347
|
+
stream's start_time minus the file's); 0.0 for a stream whose start is unknown."""
|
|
348
|
+
from _common.emit import file_origin
|
|
349
|
+
meta = meta or {}
|
|
350
|
+
origin = file_origin(meta)
|
|
351
|
+
out = []
|
|
352
|
+
for kind in ("video", "audio"):
|
|
353
|
+
start = (meta.get(kind) or {}).get("start_time")
|
|
354
|
+
out.append(float(start) - origin if isinstance(start, (int, float)) else 0.0)
|
|
355
|
+
return out[0], out[1]
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def frame_rate(meta: Optional[Dict[str, Any]]) -> float:
|
|
359
|
+
"""The video's frame rate as cut.py's grid takes it: r_frame_rate, else fps; 0.0 when unknown."""
|
|
360
|
+
v = (meta or {}).get("video") or {}
|
|
361
|
+
try:
|
|
362
|
+
num, _, den = str(v.get("r_frame_rate") or "").partition("/")
|
|
363
|
+
rate = float(num) / float(den or 1)
|
|
364
|
+
except (ValueError, ZeroDivisionError):
|
|
365
|
+
rate = 0.0
|
|
366
|
+
if not 0 < rate <= 240: # a time base (90000/1), not a rate
|
|
367
|
+
rate = float(v.get("fps") or 0.0)
|
|
368
|
+
return rate
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def frame_durations(meta: Optional[Dict[str, Any]], rate: float) -> str:
|
|
372
|
+
"""An `fps` filter at the video's own rate (r_frame_rate as a fraction when it is one), the
|
|
373
|
+
last filter of a graph whose frames sit on that rate's grid: it moves none of them, and gives
|
|
374
|
+
each frame its duration back. From FFmpeg 7.0 setpts sets every frame's duration to 0 and
|
|
375
|
+
leaves the frame rate unknown, so the last frame of a graph that ends after a setpts (av_trim)
|
|
376
|
+
was written with no duration and the picture read a frame short (3.967 s for 120 frames)."""
|
|
377
|
+
v = (meta or {}).get("video") or {}
|
|
378
|
+
try:
|
|
379
|
+
q = Fraction(str(v.get("r_frame_rate") or ""))
|
|
380
|
+
except (ValueError, ZeroDivisionError):
|
|
381
|
+
q = None
|
|
382
|
+
if q is not None and abs(float(q) - rate) < 1e-6:
|
|
383
|
+
return f"fps={q.numerator}/{q.denominator}"
|
|
384
|
+
return f"fps={rate:.6f}"
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def grid_at_or_after(t: float, v0: float, rate: float) -> float:
|
|
388
|
+
"""The time of the first frame at or after `t` on a constant-rate grid starting at `v0`."""
|
|
389
|
+
if not rate:
|
|
390
|
+
return t
|
|
391
|
+
return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def av_trim(start: float, end: Optional[float], rate: float = 0.0, fill_gaps: bool = False) -> Tuple[str, str]:
|
|
395
|
+
"""The video and the audio filters that cut one segment [start, end) of the graph clock out of
|
|
396
|
+
both streams and shift both by the same constant, so an offset between them survives; `end`
|
|
397
|
+
None runs to the stream's end. The picture's trim sits half a frame before each bound (the
|
|
398
|
+
bounds are frame times: grid_at_or_after), so no FFmpeg version rounds a boundary frame in or
|
|
399
|
+
out; the audio is padded to the segment's origin when it starts later. `fill_gaps` also fills
|
|
400
|
+
every later gap in the sound with silence, however short (aresample's default only fills one
|
|
401
|
+
longer than 0.1 s and stretches the sound by a sample a second for the rest)."""
|
|
402
|
+
half = 0.5 / rate if rate else 0.0
|
|
403
|
+
pad = "aresample=async=1:min_hard_comp=0.001:first_pts=0" if fill_gaps else "aresample=async=1:first_pts=0"
|
|
404
|
+
vend = f":end={end - half:.6f}" if end is not None else ""
|
|
405
|
+
aend = f":end={end:.6f}" if end is not None else ""
|
|
406
|
+
return (f"trim=start={start - half:.6f}{vend},setpts=PTS-{start:.6f}/TB",
|
|
407
|
+
f"atrim=start={start:.6f}{aend},asetpts=PTS-{start:.6f}/TB,{pad}")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def av_clip_length(meta: Optional[Dict[str, Any]], fps: float) -> float:
|
|
411
|
+
"""The one length a clip holds once both streams start at its first frame (av_trim from the
|
|
412
|
+
video's start): the video's, or where the sound ends when it runs more than a frame past the
|
|
413
|
+
picture (narration is never cut; an AAC tail adds no frame). The container duration is only the
|
|
414
|
+
fallback when the video's length is unknown (a pending input)."""
|
|
415
|
+
meta = meta or {}
|
|
416
|
+
v = (meta.get("video") or {}).get("duration")
|
|
417
|
+
a = (meta.get("audio") or {}).get("duration")
|
|
418
|
+
if not v:
|
|
419
|
+
return meta.get("duration") or 0.0
|
|
420
|
+
if a:
|
|
421
|
+
v0, a0 = stream_starts(meta)
|
|
422
|
+
a = a + a0 - v0 # where the sound ends, counted from the first frame
|
|
423
|
+
if a and fps and a > v + 1.0 / fps:
|
|
424
|
+
return a
|
|
425
|
+
return v
|
|
426
|
+
|
|
427
|
+
|
|
294
428
|
def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> List[str]:
|
|
295
429
|
"""Force a constant frame rate on output when the source looks VFR (or fps is given).
|
|
296
430
|
|
|
@@ -661,6 +795,28 @@ def label_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, An
|
|
|
661
795
|
return f + "," if f else ""
|
|
662
796
|
|
|
663
797
|
|
|
798
|
+
def range_chain(meta: Optional[Dict[str, Any]], line_meta: Optional[Dict[str, Any]]) -> str:
|
|
799
|
+
"""For a picture that a mixing filter (concat, xfade, xstack, overlay) puts beside or under
|
|
800
|
+
another, encoded through video_args(line_meta): the conversion to limited range its chain
|
|
801
|
+
takes after its matrix_chain()/label_chain() ("<filter>," -- limited_range_filter), or "" (a
|
|
802
|
+
limited, RGB or HDR picture, or a line that does not tag BT.709). Every SDR picture such a
|
|
803
|
+
tool mixes is then limited range, whatever each one was: the filters mix samples, not colours."""
|
|
804
|
+
if STATE.codec == "prores" or ((line_meta or {}).get("video") or {}).get("bt2020_or_hdr"):
|
|
805
|
+
return ""
|
|
806
|
+
f = limited_range_filter(meta)
|
|
807
|
+
return f + "," if f else ""
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def layer_base_chain(meta: Optional[Dict[str, Any]]) -> str:
|
|
811
|
+
"""The chain in front of the picture `meta` probes where converted RGB layers (rgb_chain_to_bt709:
|
|
812
|
+
a still, an emoji PNG, a box of chroma) are overlaid on it: an untagged picture's BT.709 label
|
|
813
|
+
(label_chain) and, for a full-range one, its conversion to the limited range the layers are
|
|
814
|
+
made in (range_chain). "" when the line converts no layer (HDR, prores)."""
|
|
815
|
+
if not rgb_chain_to_bt709(meta):
|
|
816
|
+
return ""
|
|
817
|
+
return label_chain(meta, meta) + range_chain(meta, meta)
|
|
818
|
+
|
|
819
|
+
|
|
664
820
|
def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva420p") -> str:
|
|
665
821
|
"""For a picture the tool's own graph has made RGB(A) -- a still's `format=rgba` chain
|
|
666
822
|
(overlay.py --image, an emoji PNG) or an RGBA composite (waveform.py's --image plate) -- that
|
|
@@ -695,14 +851,25 @@ def drawn_chain(colour_chain: str, meta: Optional[Dict[str, Any]], colours: Sequ
|
|
|
695
851
|
|
|
696
852
|
Unchanged: a chain whose colours are all greys (the same YUV under every matrix: the default
|
|
697
853
|
black pads and white text), a line that does not tag BT.709 (HDR, prores). `labelled`: the
|
|
698
|
-
caller's graph already labels this picture (label_chain()) earlier in the same chain.
|
|
854
|
+
caller's graph already labels this picture (label_chain()) earlier in the same chain.
|
|
855
|
+
|
|
856
|
+
Before 7.1 `ff_draw` also takes the range from the pixel format alone: full for `yuvj`, limited
|
|
857
|
+
for every other, so on a full-range picture in a plain format (VP9/AV1 full range, 10-bit full
|
|
858
|
+
range) even black and white were drawn limited (pad black read 101010, #410), and 6.1's scale
|
|
859
|
+
has often made such a picture limited before the drawing anyway. There the picture is first
|
|
860
|
+
taken to limited range (range_chain(), which reads the range from the frames), and the
|
|
861
|
+
drawing, the colours and rgb_island() all work in limited range. 7.1+ draws in the link's
|
|
862
|
+
range and is left as it is."""
|
|
699
863
|
line = meta if line_meta == "same" else line_meta
|
|
864
|
+
pre = ""
|
|
865
|
+
if ffmpeg_version() < (7, 1) and not str(((meta or {}).get("video") or {}).get("pix_fmt") or "").startswith("yuvj"):
|
|
866
|
+
pre = range_chain(meta, line)
|
|
700
867
|
if not draws_chroma(colours, line):
|
|
701
|
-
return colour_chain
|
|
868
|
+
return pre + colour_chain
|
|
702
869
|
if ffmpeg_version() >= (7, 1):
|
|
703
870
|
return ("" if labelled else label_chain(meta, line)) + colour_chain
|
|
704
|
-
pix_fmt, full = drawn_stream_format(meta)
|
|
705
|
-
return rgb_island(colour_chain, neutral_chain, pix_fmt, full, tag)
|
|
871
|
+
pix_fmt, full = drawn_stream_format(as_limited(meta) if pre else meta)
|
|
872
|
+
return pre + rgb_island(colour_chain, neutral_chain, pix_fmt, full, tag)
|
|
706
873
|
|
|
707
874
|
|
|
708
875
|
def source_codec_video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium",
|
package/scripts/_common/emit.py
CHANGED
|
@@ -79,6 +79,8 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
|
|
|
79
79
|
|
|
80
80
|
`error_extra` goes inside `error` (refuse() passes `reason` and `details` there); the
|
|
81
81
|
collector's records (warn() and friends) are merged into the document as emit() merges them."""
|
|
82
|
+
if extra.get("hint") is None and kind == "input" and getattr(ctx or STATE, "smpte_hint", None):
|
|
83
|
+
extra["hint"] = (ctx or STATE).smpte_hint # an hh:mm:ss:ff flag read past a timecoded source's end (#411)
|
|
82
84
|
hint = neutralize(extra.pop("hint", None))
|
|
83
85
|
msg = neutralize(msg)
|
|
84
86
|
ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
|