ffmpeg-skill 3.8.8 → 3.8.9
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 +1 -1
- package/docs/contract.md +13 -3
- package/package.json +1 -1
- package/references/gotchas.md +5 -3
- package/references/scripts.md +11 -6
- package/scripts/_common/asr.py +12 -1
- package/scripts/_common/drawtext.py +11 -0
- package/scripts/_common/emit.py +3 -1
- package/scripts/_common/emoji.py +28 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +133 -5
- package/scripts/_contract.py +14 -9
- package/scripts/caption.py +39 -3
- package/scripts/color.py +36 -0
- package/scripts/cropdetect.py +24 -6
- package/scripts/cut.py +13 -5
- package/scripts/export.py +3 -2
- package/scripts/graphics.py +31 -12
- package/scripts/join.py +20 -2
- package/scripts/loudness.py +2 -1
- package/scripts/metadata.py +11 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +21 -3
- package/scripts/silence.py +27 -3
- package/scripts/stabilize.py +6 -2
- package/scripts/sync.py +8 -3
- package/scripts/waveform.py +13 -6
package/scripts/_contract.py
CHANGED
|
@@ -105,7 +105,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
105
105
|
optional=[HDR_X265, {"capability": "filter:delogo", "when": "--mode delogo"}],
|
|
106
106
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
107
107
|
"waveform": dict(role="execution", inputs=["audio or video asset (audio track only)"], outputs=["generated waveform or spectrum visualization video artifact"],
|
|
108
|
-
required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"}
|
|
108
|
+
required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"},
|
|
109
|
+
{"capability": "filter:drawtext", "when": "--title (graphics.py draws it)"},
|
|
110
|
+
{"capability": "filter:subtitles", "when": "--srt (caption.py burns it)"}],
|
|
109
111
|
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
110
112
|
"sphere": dict(role="execution", inputs=["360/spherical video asset"], outputs=["flat rectilinear video artifact of the chosen viewport"],
|
|
111
113
|
required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
|
|
@@ -133,7 +135,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
133
135
|
{"capability": "filter:scdet", "when": "--auto-chapters --from scenes|both"}],
|
|
134
136
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
135
137
|
"loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration (--boomerang: played forward then backward, silent)"],
|
|
136
|
-
required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}, {"capability": "filter:xfade", "when": "--crossfade"}, {"capability": "encoder:ffv1", "when": "--crossfade"}, {"capability": "encoder:flac", "when": "--crossfade"}],
|
|
138
|
+
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}, {"capability": "filter:xfade", "when": "--crossfade"}, {"capability": "encoder:ffv1", "when": "--crossfade"}, {"capability": "encoder:flac", "when": "--crossfade"}],
|
|
137
139
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
138
140
|
"insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
|
|
139
141
|
required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
|
|
@@ -158,7 +160,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
158
160
|
required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, {"capability": "filter:chromakey", "when": "--chromakey"}, HDR_X265],
|
|
159
161
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
160
162
|
"graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
|
|
161
|
-
required=FF + [X264, AAC, "filter:drawtext"],
|
|
163
|
+
required=FF + [X264, AAC, "filter:drawtext"],
|
|
164
|
+
optional=[HDR_X265, {"capability": "filter:ass", "when": "--text-render ass, a script drawtext cannot shape, or emoji drawn through libass or as PNGs"}],
|
|
162
165
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
163
166
|
"sync": dict(role="analysis_and_execution", inputs=["reference recording (video or audio)", "second recording (video or audio)"], outputs=["offset / drift JSON on stdout", "aligned artifact with --replace-audio / --trim-second / --fix-drift -o"],
|
|
164
167
|
required=FF, optional=[{"capability": AAC, "when": "writing a video container"}] + AUDIO_OUT,
|
|
@@ -201,7 +204,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
201
204
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
202
205
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
203
206
|
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores and dnxhr-* use pcm_s16le, copy stream-copies, gif has no audio)"},
|
|
204
|
-
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"}, {"capability": "encoder:dnxhd", "when": "preset dnxhr-lb / dnxhr-sq / dnxhr-hq / dnxhr-hqx / dnxhr-444"}, {"capability": "encoder:prores_ks", "when": "preset prores4444 / prores4444xq / key-fill"}, {"capability": "encoder:qtrle", "when": "preset qtrle"}, {"capability": "encoder:libvpx-vp9", "when": "preset vp9-alpha"}, {"capability": "encoder:hap", "when": "preset hap-alpha (an ffmpeg built with snappy)"}, {"capability": "filter:alphaextract", "when": "an alpha preset (the alpha is read back)"},
|
|
207
|
+
{"capability": X265, "when": "preset h265 / youtube-hdr"}, {"capability": "encoder:libsvtav1", "when": "preset youtube-av1 (libaom-av1 is the fallback)"}, {"capability": "encoder:prores_ks", "when": "preset prores"}, {"capability": "encoder:dnxhd", "when": "preset dnxhr-lb / dnxhr-sq / dnxhr-hq / dnxhr-hqx / dnxhr-444"}, {"capability": "encoder:prores_ks", "when": "preset prores4444 / prores4444xq / key-fill"}, {"capability": "encoder:qtrle", "when": "preset qtrle"}, {"capability": "encoder:libvpx-vp9", "when": "preset vp9-alpha"}, {"capability": "encoder:hap", "when": "preset hap-alpha (an ffmpeg built with snappy)"}, {"capability": "filter:alphaextract", "when": "an alpha preset (the alpha is read back)"},
|
|
205
208
|
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
|
|
206
209
|
{"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"},
|
|
207
210
|
{"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
|
|
@@ -423,7 +426,7 @@ DIAGNOSTICS_SCHEMA = {
|
|
|
423
426
|
# failure it is). Every refuse() call in scripts/ names one of these (a test reads the sources).
|
|
424
427
|
ERROR_REASONS = {
|
|
425
428
|
"disk_full": "kind ffmpeg: ffmpeg reported \"No space left on device\" while writing; the hint says to free space or write elsewhere; details: {}",
|
|
426
|
-
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command
|
|
429
|
+
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command names: for a command run() is given (not a run_analysis() measurement pass), refused before ffmpeg runs, dry runs included, when the build's filter list was read and lacks it, else ffmpeg's own \"No such filter\"; details: {name}; the hint points at doctor",
|
|
427
430
|
"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: {}",
|
|
428
431
|
"input_unreadable": "kind ffmpeg: ffmpeg could not read the input as media (\"Invalid data found when processing input\"); details: {}",
|
|
429
432
|
"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}",
|
|
@@ -453,7 +456,7 @@ ERROR_REASONS = {
|
|
|
453
456
|
"plan_field_invalid": "render.py PLAN: inputs, unbound_inputs, verify (objects), commands (strings) or cwd has the wrong JSON type; "
|
|
454
457
|
"details: {plan, plan_sha256, checked_at, field, field_type, audit_log}",
|
|
455
458
|
"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}",
|
|
456
|
-
"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
|
|
459
|
+
"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 a command run() is given names an encoder the build lacks for a stream the output keeps (an HDR source kept as HEVC, --codec hevc, a preset's encoder; not -c:a for a source with no sound, or with -an): refused before ffmpeg runs, dry runs included, when the build's encoder list was read and lacks it, else ffmpeg's own \"Unknown encoder\" (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
457
460
|
"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: {}",
|
|
458
461
|
"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}",
|
|
459
462
|
"audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
|
|
@@ -483,7 +486,8 @@ DIAGNOSTIC_CODES = {
|
|
|
483
486
|
"av1_encoder_libaom": ("fallbacks", "--codec av1 on a build without libsvtav1: libaom-av1 encoded instead; {wanted, used}"),
|
|
484
487
|
"crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
|
|
485
488
|
# fonts and text
|
|
486
|
-
"emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
|
|
489
|
+
"emoji_not_drawn": ("warnings", "caption.py / graphics.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; also graphics.py --text-render drawtext, which has no font fallback; {count, reason}"),
|
|
490
|
+
"emoji_mono_drawn_in_colour": ("warnings", "caption.py / graphics.py --emoji mono on a build whose libass draws the emoji face in colour: nothing makes the glyph monochrome, so the result's emoji.mode is color (graphics.py --text-render drawtext draws none: mode none, emoji_not_drawn)"),
|
|
487
491
|
"hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
|
|
488
492
|
"source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture or surround channels the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
|
|
489
493
|
"audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
|
|
@@ -497,9 +501,10 @@ DIAGNOSTIC_CODES = {
|
|
|
497
501
|
"font_path_not_ascii": ("warnings", "a --font-file under a non-ASCII path could not be given an ASCII form on Windows; drawtext may not open it; {font}"),
|
|
498
502
|
"fonts_dir_lacks_script": ("fallbacks", "no face in --fonts-dir covers the text's script, so one was picked by script; {fonts_dir, script}"),
|
|
499
503
|
"font_variant_missing": ("fallbacks", "--lang named Traditional Chinese but no Traditional face is installed, so a Simplified one draws it (some glyph forms differ); {lang, file}"),
|
|
500
|
-
"emoji_monochrome": ("warnings", "emoji drawn
|
|
504
|
+
"emoji_monochrome": ("warnings", "caption.py / graphics.py: emoji drawn monochrome through libass (no colour path here). overlay.py --text: emoji left to the one font drawtext loads, which has no fallback, so a font without the glyph draws an empty box"),
|
|
505
|
+
"words_outside_input": ("warnings", "silence.py --filler --words: word timings that start at or after the end of the input (a transcript of another file, or times in milliseconds); {count, words, duration}"),
|
|
501
506
|
"emoji_stripped": ("warnings", "overlay.py --emoji none: emoji removed from the drawn text; {mode}"),
|
|
502
|
-
"emoji_assets_missing": ("warnings", "--emoji-assets has no PNG for these clusters; the text
|
|
507
|
+
"emoji_assets_missing": ("warnings", "--emoji-assets has no PNG for these clusters; they stay in the text for libass, and the message says whether libass draws an emoji glyph on this build; {missing}"),
|
|
503
508
|
"drawtext_newline_glyph": ("warnings", "graphics.py --text-render drawtext on FFmpeg 8.0-8.0.1/8.1.0: a box is drawn at each line break of a multi-line label; {text_render}"),
|
|
504
509
|
"timeline_parse_failed": ("warnings", "render.py --export-timeline: the written file does not read back as its format (the parse step is ok: false); {path, format, error}"),
|
|
505
510
|
# captions
|
package/scripts/caption.py
CHANGED
|
@@ -46,6 +46,7 @@ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_unit
|
|
|
46
46
|
from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
|
|
47
47
|
strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
|
|
48
48
|
from _common.emit import refuse # noqa: E402
|
|
49
|
+
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
49
50
|
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
50
51
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
51
52
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
@@ -103,6 +104,32 @@ def cue_times_match(line: str) -> "Optional[re.Match[str]]":
|
|
|
103
104
|
# an SRT timing line: the times alone, the cue text on the lines after it
|
|
104
105
|
DROP_FRAME_RE = re.compile(r"^\s*\d+:\d{2}:\d{2};\d+")
|
|
105
106
|
SRT_TIMING_RE = re.compile(r"^\s*\d+:\d{2}:\d{2}[,.]\d+\s*-->\s*\d+:\d{2}:\d{2}[,.]\d+\s*$")
|
|
107
|
+
# the same line as WebVTT may write it, without the hours: `00:01.000 --> 00:02.000`
|
|
108
|
+
TIMING_ONLY_RE = re.compile(r"^\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*-->\s*(?:\d+:)?\d{1,2}:\d{2}[,.]\d+\s*$")
|
|
109
|
+
ASS_SECTIONS = ("[script info]", "[v4+ styles]", "[v4 styles]", "[events]")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def refuse_not_a_cue_file(path: str, lines: List[str]) -> None:
|
|
113
|
+
"""--text given a file in another format (#404): read as a cue file, a WebVTT header, an ASS
|
|
114
|
+
section or a JSON document was burned in line by line, auto-timed 3 s apart. Named by what the
|
|
115
|
+
file starts with; the SRT timing-line check below catches a file with no such header."""
|
|
116
|
+
head = next((ln.strip() for ln in lines if ln.strip()), "")
|
|
117
|
+
cue_shape = "A cue file has one cue per line: `START-END text` (or text alone, timed --auto-seconds apart)"
|
|
118
|
+
if head.upper().startswith("WEBVTT"):
|
|
119
|
+
die(f"--text: {path} is a WebVTT subtitle file (it starts with {head[:20]!r}); pass it as --srt, which reads "
|
|
120
|
+
f"WebVTT (render.py: --srt, not --cues). {cue_shape}", kind="input")
|
|
121
|
+
if head.lower() in ASS_SECTIONS or head.startswith("Dialogue:"):
|
|
122
|
+
die(f"--text: {path} is an ASS/SSA subtitle file (it starts with {head[:20]!r}); pass it as --ass, which burns "
|
|
123
|
+
f"it with the styles inside it (in a render.py project, the captions stage's \"ass\" key). {cue_shape}",
|
|
124
|
+
kind="input")
|
|
125
|
+
if head[:1] in "{[":
|
|
126
|
+
try:
|
|
127
|
+
doc = json.loads("\n".join(lines))
|
|
128
|
+
except ValueError:
|
|
129
|
+
doc = None
|
|
130
|
+
if isinstance(doc, dict) or (isinstance(doc, list) and doc and isinstance(doc[0], (dict, list))):
|
|
131
|
+
die(f"--text: {path} is a JSON document, not a cue file. {cue_shape}. A whisper JSON is a transcript: "
|
|
132
|
+
"caption.py --transcribe writes the cues from the audio", kind="input")
|
|
106
133
|
|
|
107
134
|
|
|
108
135
|
def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
|
|
@@ -111,7 +138,12 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
|
|
|
111
138
|
# split at "\n" only: splitlines() also breaks a cue at U+2028, U+0085, \v and \f, which
|
|
112
139
|
# made the rest of the line an auto-timed cue of its own
|
|
113
140
|
lines = read_text_or_die(path, "--text").split("\n")
|
|
141
|
+
refuse_not_a_cue_file(path, lines)
|
|
114
142
|
for n, raw in enumerate(lines, 1):
|
|
143
|
+
if TIMING_ONLY_RE.match(raw) and not SRT_TIMING_RE.match(raw):
|
|
144
|
+
# WebVTT's short form (no hours), in a file without the WEBVTT header
|
|
145
|
+
die(f"--text: {path} is a subtitle file, not a cue file (line {n} is a timing line with no text: "
|
|
146
|
+
f"'{raw.strip()}'); pass it as --srt (render.py: --srt, not --cues)", kind="input")
|
|
115
147
|
if SRT_TIMING_RE.match(raw):
|
|
116
148
|
# a cue file puts the text on the line with its times; this line has the times alone, so
|
|
117
149
|
# the file is an SRT. Read as a cue file, its numbers and timing lines would be burned in
|
|
@@ -257,6 +289,9 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
257
289
|
die(f"--emoji mono: this ffmpeg draws no emoji glyph through libass ({support['detail']}), so "
|
|
258
290
|
f"{len(clusters_all)} emoji would come out as empty boxes -- pass --emoji-assets DIR for colour, "
|
|
259
291
|
"or --emoji none to leave them out of the drawn text", kind="input")
|
|
292
|
+
if want == "mono" and support.get("libass_color"):
|
|
293
|
+
mode = "color"
|
|
294
|
+
warn(emoji_mono_on_colour_note(support), "emoji_mono_drawn_in_colour")
|
|
260
295
|
plan = {"mode": mode, "count": len(clusters_all),
|
|
261
296
|
"clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
|
|
262
297
|
"assets": assets, "missing": [], "overlays": []}
|
|
@@ -354,8 +389,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
|
354
389
|
"--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
|
|
355
390
|
"linearly -- split the job, or use --emoji none", kind="input")
|
|
356
391
|
if plan["missing"]:
|
|
357
|
-
warn("
|
|
358
|
-
" -- those clusters are drawn by the text font instead", "emoji_assets_missing", missing=list(plan["missing"]))
|
|
392
|
+
warn(emoji_missing_asset_note(plan["missing"], support), "emoji_assets_missing", missing=list(plan["missing"]))
|
|
359
393
|
plan["box_px"] = int(round(box_px))
|
|
360
394
|
return out_cues, plan
|
|
361
395
|
|
|
@@ -1934,7 +1968,9 @@ def main() -> int:
|
|
|
1934
1968
|
notes.append("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
1935
1969
|
"--emoji-assets DIR for colour)")
|
|
1936
1970
|
if emoji_plan["mode"] == "none":
|
|
1937
|
-
|
|
1971
|
+
# --emoji none was asked for only when it was: an auto run that can draw none says that
|
|
1972
|
+
notes.append("emoji stripped from the drawn text (--emoji none)" if getattr(args, "emoji", "auto") == "none" else
|
|
1973
|
+
"emoji left out of the drawn text: nothing here draws them (--emoji auto; the emoji_not_drawn warning says why)")
|
|
1938
1974
|
if emoji_plan["missing"]:
|
|
1939
1975
|
notes.append("no PNG asset for " + ", ".join(emoji_plan["missing"]))
|
|
1940
1976
|
if notes:
|
package/scripts/color.py
CHANGED
|
@@ -420,6 +420,41 @@ def legalize_report(standard: str, output: str) -> "tuple":
|
|
|
420
420
|
"worst_frame": worst, "tolerance_pct": LEGALIZE_TOLERANCE_PCT}])
|
|
421
421
|
|
|
422
422
|
|
|
423
|
+
def refuse_bad_cube(path: str) -> None:
|
|
424
|
+
"""--lut given a .cube that holds no complete 3D LUT (#404): lut3d failed on it with "Invalid
|
|
425
|
+
data found when processing input", which reads as an unreadable video and got that hint. Only
|
|
426
|
+
.cube is checked (lut3d's other formats are left to it), by what lut3d itself needs: a
|
|
427
|
+
LUT_3D_SIZE line and size^3 rows that start with three numbers."""
|
|
428
|
+
if os.path.splitext(path)[1].lower() != ".cube":
|
|
429
|
+
return
|
|
430
|
+
size = rows = 0
|
|
431
|
+
one_d = False
|
|
432
|
+
try:
|
|
433
|
+
with open(path, "r", encoding="utf-8", errors="replace") as fh:
|
|
434
|
+
for line in fh:
|
|
435
|
+
words = line.split()
|
|
436
|
+
if not words or words[0].startswith("#"):
|
|
437
|
+
continue
|
|
438
|
+
if words[0] == "LUT_3D_SIZE" and len(words) > 1 and words[1].isdigit():
|
|
439
|
+
size = int(words[1])
|
|
440
|
+
elif words[0] == "LUT_1D_SIZE":
|
|
441
|
+
one_d = True
|
|
442
|
+
elif len(words) >= 3:
|
|
443
|
+
try:
|
|
444
|
+
[float(w) for w in words[:3]]
|
|
445
|
+
rows += 1
|
|
446
|
+
except ValueError:
|
|
447
|
+
pass
|
|
448
|
+
except OSError as e:
|
|
449
|
+
die(f"--lut: cannot read {path}: {e.strerror}", kind="input")
|
|
450
|
+
if size < 2 or rows < size ** 3:
|
|
451
|
+
what = ("it is a 1D LUT (LUT_1D_SIZE), and --lut applies a 3D one" if one_d and size < 2
|
|
452
|
+
else "it has no LUT_3D_SIZE line" if size < 2
|
|
453
|
+
else f"it has {rows} rows of three numbers, and a LUT_3D_SIZE {size} table has {size ** 3}")
|
|
454
|
+
die(f"--lut {path} is not a complete 3D .cube LUT: {what}", kind="input",
|
|
455
|
+
hint="a .cube file is plain text with a LUT_3D_SIZE line and size^3 rows of three numbers; export it again from the grading tool")
|
|
456
|
+
|
|
457
|
+
|
|
423
458
|
def main() -> int:
|
|
424
459
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
425
460
|
ap.add_argument("input")
|
|
@@ -565,6 +600,7 @@ def main() -> int:
|
|
|
565
600
|
f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
|
|
566
601
|
if not os.path.exists(args.lut):
|
|
567
602
|
die(f"LUT not found: {args.lut}")
|
|
603
|
+
refuse_bad_cube(args.lut)
|
|
568
604
|
if not (0.0 <= args.lut_strength <= 1.0):
|
|
569
605
|
die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
|
|
570
606
|
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|
package/scripts/cropdetect.py
CHANGED
|
@@ -43,12 +43,30 @@ MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
|
|
|
43
43
|
CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
|
|
44
44
|
|
|
45
45
|
|
|
46
|
+
def round_rect(rect: Tuple[int, int, int, int], round_to: int, full: Tuple[int, int]) -> Tuple[int, int, int, int]:
|
|
47
|
+
"""One measured rectangle (w, h, x, y) as it is reported: the whole frame when the picture
|
|
48
|
+
reaches every edge (cropdetect's own round=2 reading of an odd side is one pixel short of it),
|
|
49
|
+
else rounded down to a multiple of `round_to` and re-centred the way cropdetect's round option
|
|
50
|
+
does it. Rounding inside the filter made a bar-free 1920x1080 read 1920x1072 at the default 16
|
|
51
|
+
(#403): 1080 is not a multiple of 16, and the rounded size was then compared with the source's."""
|
|
52
|
+
w, h, x, y = rect
|
|
53
|
+
fw, fh = full
|
|
54
|
+
if fw and fh and x == 0 and y == 0 and w >= fw - fw % 2 and h >= fh - fh % 2:
|
|
55
|
+
return fw, fh, 0, 0
|
|
56
|
+
# the filter's own reading of its round option: 1 is 16, an odd value is doubled
|
|
57
|
+
round_to = 16 if round_to <= 1 else round_to * 2 if round_to % 2 else round_to
|
|
58
|
+
shrink_w, shrink_h = w % round_to, h % round_to
|
|
59
|
+
return w - shrink_w, h - shrink_h, x + ((shrink_w // 2 + 1) & ~1), y + ((shrink_h // 2 + 1) & ~1)
|
|
60
|
+
|
|
61
|
+
|
|
46
62
|
def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
|
|
47
|
-
failed: "Optional[List[Dict]]" = None, square: str = "") -> List[Tuple[int, int, int, int]]:
|
|
63
|
+
failed: "Optional[List[Dict]]" = None, square: str = "", full: Tuple[int, int] = (0, 0)) -> List[Tuple[int, int, int, int]]:
|
|
48
64
|
"""Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
|
|
49
65
|
is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
|
|
50
66
|
so the result can say how many of the readings it stands on. `square` is the source's
|
|
51
|
-
square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411).
|
|
67
|
+
square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411).
|
|
68
|
+
`full` is that frame's size: the filter measures at round=2 and round_rect() rounds each
|
|
69
|
+
reading, so a picture that fills the frame is reported as the frame."""
|
|
52
70
|
ffmpeg = require_tool("ffmpeg")
|
|
53
71
|
per_window = max(0.5, seconds / max(1, samples))
|
|
54
72
|
rects: List[Tuple[int, int, int, int]] = []
|
|
@@ -57,7 +75,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
57
75
|
start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
|
|
58
76
|
start = max(0.0, start)
|
|
59
77
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
60
|
-
"-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round=
|
|
78
|
+
"-map", f"0:{PROBED_VIDEO}", "-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round=2:reset=1", "-f", "null", "-"]
|
|
61
79
|
proc = run_analysis(cmd, check=False)
|
|
62
80
|
if proc.returncode != 0:
|
|
63
81
|
# One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
|
|
@@ -67,7 +85,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
67
85
|
failed.append({"start": round(start, 3), "seconds": round(per_window, 3), "error": failures[-1][0][:300]})
|
|
68
86
|
continue
|
|
69
87
|
for m in CROP_RE.finditer(proc.stderr):
|
|
70
|
-
rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
|
|
88
|
+
rects.append(round_rect((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))), round_to, full))
|
|
71
89
|
if failures and len(failures) == samples:
|
|
72
90
|
die(f"cropdetect could not decode any of the {samples} sampled windows: {failures[-1][0][:300]}", kind="ffmpeg")
|
|
73
91
|
return rects
|
|
@@ -125,7 +143,7 @@ def main() -> int:
|
|
|
125
143
|
ap.add_argument("--seconds", type=float, default=10.0, help="total seconds of footage to sample across the file (default 10)")
|
|
126
144
|
ap.add_argument("--samples", type=int, default=5, help="number of windows spread across the file (default 5)")
|
|
127
145
|
ap.add_argument("--limit", type=float, default=0.0941176, help="black-pixel threshold, 0..1 (default ~0.094, cropdetect's own default)")
|
|
128
|
-
ap.add_argument("--round", type=int, default=16, dest="round_to", help="
|
|
146
|
+
ap.add_argument("--round", type=int, default=16, dest="round_to", help="a detected crop's width/height are rounded down to a multiple of this (default 16); a picture that reaches every edge is reported as the whole frame, unrounded")
|
|
129
147
|
ap.add_argument("--motion-centre", action="store_true",
|
|
130
148
|
help="report the motion centroid per second, sampled the same way as the crop "
|
|
131
149
|
"detection above (report only -- this tool never picks a reframe, it hands "
|
|
@@ -162,7 +180,7 @@ def main() -> int:
|
|
|
162
180
|
# as the top code of the source's bit depth (255 for 8 bits, 1023 for 10).
|
|
163
181
|
limit = args.limit if args.limit < 1 else float((1 << int(meta["video"].get("bit_depth") or 8)) - 1)
|
|
164
182
|
rects = detect(args.input, args.seconds, args.samples, limit, args.round_to, duration, failed,
|
|
165
|
-
square_pixels(meta["video"]))
|
|
183
|
+
square_pixels(meta["video"]), (sw, sh))
|
|
166
184
|
# windows_failed: a window ffmpeg could not decode was skipped without a word, so a
|
|
167
185
|
# crop measured on one window of five read as well-founded as one measured on all five
|
|
168
186
|
result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,
|
package/scripts/cut.py
CHANGED
|
@@ -58,7 +58,7 @@ import tempfile
|
|
|
58
58
|
import zlib
|
|
59
59
|
from typing import List, Tuple
|
|
60
60
|
|
|
61
|
-
from _common.runner import JsonTooDeep, fail_if_classified, read_json
|
|
61
|
+
from _common.runner import ffmpeg_failure_extra, JsonTooDeep, fail_if_classified, read_json
|
|
62
62
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
63
63
|
from _common import require_tool
|
|
64
64
|
from _common.probe import PROBED_AUDIO, PROBED_VIDEO, TS_EXTS, probed_maps, several_av_streams, signatures_match, stream_signature
|
|
@@ -950,7 +950,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
950
950
|
fail_if_classified(proc) # a full disk fails the re-encode too: its error, not a retry (#415)
|
|
951
951
|
info("stream copy failed, falling back to re-encode")
|
|
952
952
|
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta, reasons + ["copy_failed"], edit_list_ok)
|
|
953
|
-
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
|
|
953
|
+
die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
|
|
954
954
|
out_meta = probe(dst) if not reencode and not STATE.dry_run and copy_t is None else None
|
|
955
955
|
if out_meta is not None:
|
|
956
956
|
# #367: a copy from MPEG-TS whose window holds no video keyframe writes no video at all
|
|
@@ -1222,7 +1222,7 @@ def join_from_source(src: str, segments: List[Tuple[float, float]], dst: str, me
|
|
|
1222
1222
|
BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
|
|
1223
1223
|
|
|
1224
1224
|
|
|
1225
|
-
def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
1225
|
+
def _grid_from_source(path: str, min_confidence: float, named: bool = False) -> "dict":
|
|
1226
1226
|
"""The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
|
|
1227
1227
|
media file to measure. Reading a document is how a caller avoids a second decode."""
|
|
1228
1228
|
try:
|
|
@@ -1260,7 +1260,15 @@ def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
|
1260
1260
|
if isinstance(doc, dict):
|
|
1261
1261
|
die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
|
|
1262
1262
|
"`scenes.py MUSIC --beats --json`", kind="input")
|
|
1263
|
+
if doc is not None:
|
|
1264
|
+
die(f"--snap-source {path}: this JSON is not a scenes.py document (it is a {type(doc).__name__}, "
|
|
1265
|
+
"not an object with beat_grid) -- produce one with `scenes.py MUSIC --beats --json`", kind="input")
|
|
1263
1266
|
samples = decode_pcm_mono(path, BEAT_RATE, check=False)
|
|
1267
|
+
if not samples and named:
|
|
1268
|
+
# not "no reliable beat grid in this audio": nothing was decoded, so nothing was measured (#404)
|
|
1269
|
+
die(f"--snap-source {path}: no audio could be decoded from it, and it is not a scenes.py --beats --json "
|
|
1270
|
+
"document, so no beat grid was measured", kind="input",
|
|
1271
|
+
hint="pass the music bed (a media file with sound) or the JSON `scenes.py MUSIC --beats --json` prints")
|
|
1264
1272
|
block = max(1, int(round(BEAT_RATE * 0.01)))
|
|
1265
1273
|
env = rms_envelope(samples, block)
|
|
1266
1274
|
# the envelope's real step is block / rate (220 / 22050 = 9.977 ms), not the 10 ms asked for:
|
|
@@ -1295,7 +1303,7 @@ def snap_segments(args, segments, meta, total):
|
|
|
1295
1303
|
die("--min-confidence must be greater than 0: at 0 every grid is 'reliable', including "
|
|
1296
1304
|
"one measured from noise, which is exactly what --snap beats must not cut to. Use "
|
|
1297
1305
|
"--snap none if you do not want the points moved at all.", kind="input")
|
|
1298
|
-
grid = _grid_from_source(source, args.min_confidence)
|
|
1306
|
+
grid = _grid_from_source(source, args.min_confidence, named=bool(args.snap_source))
|
|
1299
1307
|
confidence = float(grid.get("confidence") or 0.0)
|
|
1300
1308
|
tempo = grid.get("tempo_bpm")
|
|
1301
1309
|
if confidence < args.min_confidence or not grid.get("beats"):
|
|
@@ -1880,7 +1888,7 @@ def main() -> int:
|
|
|
1880
1888
|
requested_start=round(requested[0][0], 6) if len(requested) == 1 else None,
|
|
1881
1889
|
requested_end=round(requested[0][1], 6) if len(requested) == 1 else None,
|
|
1882
1890
|
requested_segments=[[round(s, 6), round(e, 6)] for s, e in requested] if len(requested) > 1 else None,
|
|
1883
|
-
requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
|
|
1891
|
+
requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None and not STATE.dry_run else None,
|
|
1884
1892
|
duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
|
|
1885
1893
|
mode=mode, keyframe_snapped=report["keyframe_snapped"], start_snapped=report["start_snapped"],
|
|
1886
1894
|
reencode_reason=reencode_reason, least_exact_precision=report["least_exact_precision"],
|
package/scripts/export.py
CHANGED
|
@@ -256,8 +256,9 @@ def _key_fill(args, p: Dict, meta: Dict, output: str, cmd: List[str], vf: List[s
|
|
|
256
256
|
# than the picture, and the key has none
|
|
257
257
|
pair["duration"] = [round(v.get("duration") or v.get("tagged_duration") or 0, 3) for v in (fv, kv)]
|
|
258
258
|
same = all(a == b and a not in (None, 0) for a, b in pair.values())
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
# the two written files against each other: no expected value, so no provenance (#403: it said
|
|
260
|
+
# "output_measurement", which is not one of the contract's values)
|
|
261
|
+
steps = [{"step": "key_fill_pair", "property": "frames_size_rate_length", "ok": same, "measured": pair}]
|
|
261
262
|
key_level = _luma_stats(key)
|
|
262
263
|
level = _alpha_compare(key_level, source_alpha)
|
|
263
264
|
steps.append({"step": "key_level", "property": "key_mean_vs_source_alpha", "ok": level["ok"], "comparison": level,
|
package/scripts/graphics.py
CHANGED
|
@@ -34,6 +34,8 @@ import sys
|
|
|
34
34
|
from typing import List, Optional, Tuple
|
|
35
35
|
|
|
36
36
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
|
+
from _common.emoji import emoji_missing_asset_note, emoji_mono_on_colour_note
|
|
38
|
+
from _common.drawtext import shaping_refusal
|
|
37
39
|
from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args,
|
|
38
40
|
color_hex, default_output, die, draws_chroma, drawn_chain, emit, escape_drawtext, escape_filter_path,
|
|
39
41
|
ffmpeg_base, info, layer_base_chain, load_brand, overlay_format, parse_time, probe, rgb_chain_to_bt709, run, run_keeping_subtitles, video_args,
|
|
@@ -288,8 +290,7 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
288
290
|
f"({support['detail']}), so they would come out as empty boxes -- pass --emoji-assets DIR "
|
|
289
291
|
"for colour, or --emoji none", kind="input")
|
|
290
292
|
if args.text_render == "drawtext" and shaping:
|
|
291
|
-
die(f"{LANGUAGE_NAMES.get(_script, _script)} text
|
|
292
|
-
"build (the marks are reordered by harfbuzz, which drawtext does not use): drop "
|
|
293
|
+
die(f"{LANGUAGE_NAMES.get(_script, _script)} text {shaping_refusal(_script)}: drop "
|
|
293
294
|
"--text-render drawtext to render it through libass, or draw it with caption.py",
|
|
294
295
|
kind="input")
|
|
295
296
|
route = "ass" if (args.text_render == "ass" or
|
|
@@ -301,14 +302,26 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
301
302
|
# mode "mono" from the drawtext route is a claim the frame does not keep. libass DOES have a
|
|
302
303
|
# fallback chain, so an auto run routes there instead; a run that pinned --text-render
|
|
303
304
|
# drawtext degrades to "none" (strip) and says so rather than drawing tofu.
|
|
304
|
-
|
|
305
|
+
# "color" as much as "mono" (#403): an auto run on a build whose libass draws colour reported
|
|
306
|
+
# mode color from the drawtext route, which drew the empty box.
|
|
307
|
+
emoji_count = len(emoji_clusters(all_text))
|
|
308
|
+
emoji_names = sorted({emoji_codepoint_name(cl) for _i, cl in emoji_clusters(all_text)})
|
|
309
|
+
not_drawn = None
|
|
310
|
+
mono_in_colour = emoji_mode == "mono" and args.emoji == "mono" and bool(support.get("libass_color"))
|
|
311
|
+
if mono_in_colour:
|
|
312
|
+
emoji_mode = "color"
|
|
313
|
+
if emoji_mode in ("mono", "color") and route == "drawtext":
|
|
305
314
|
if args.text_render == "auto":
|
|
306
315
|
route = "ass"
|
|
307
316
|
else:
|
|
308
317
|
emoji_mode = "none"
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
318
|
+
not_drawn = "--text-render drawtext has no font fallback chain, so each would be an empty box (--text-render auto draws them through libass)"
|
|
319
|
+
elif emoji_mode == "none" and args.emoji == "auto":
|
|
320
|
+
not_drawn = ("libass draws no emoji glyph on this build (an empty box at best)"
|
|
321
|
+
if support.get("libass_glyph") is False else "no face here draws them")
|
|
322
|
+
if mono_in_colour and emoji_mode == "color":
|
|
323
|
+
# said once the route is final: a pinned drawtext route strips them instead (mode none)
|
|
324
|
+
warn(emoji_mono_on_colour_note(support), "emoji_mono_drawn_in_colour")
|
|
312
325
|
if emoji_mode == "mono":
|
|
313
326
|
warn("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
314
327
|
"--emoji-assets DIR for colour). " + support["detail"], "emoji_monochrome")
|
|
@@ -329,7 +342,13 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
329
342
|
setattr(args, _attr, _strip_emoji(getattr(args, _attr, None)))
|
|
330
343
|
all_text = " ".join(t for t in (args.name, args.title, args.subtitle, args.text,
|
|
331
344
|
args.top, args.bottom) if t)
|
|
332
|
-
|
|
345
|
+
if not_drawn:
|
|
346
|
+
# an auto or rerouted run did not get --emoji none: it says what it left out and why,
|
|
347
|
+
# as caption.py does (emoji_not_drawn), and counts what it left out
|
|
348
|
+
warn(f"emoji: {emoji_count} left out of the drawn text: {not_drawn} -- pass --emoji-assets DIR to draw them as PNGs",
|
|
349
|
+
"emoji_not_drawn", count=emoji_count, reason=support.get("detail"))
|
|
350
|
+
else:
|
|
351
|
+
info("emoji: stripped from the drawn text (--emoji none)")
|
|
333
352
|
elements: List[dict] = []
|
|
334
353
|
|
|
335
354
|
def ass_font_family() -> "Optional[str]":
|
|
@@ -712,10 +731,10 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
712
731
|
f"(limit {_max}, --emoji-max raises it); ffmpeg's filter graph and the "
|
|
713
732
|
"per-frame cost both grow linearly -- split the job, or use --emoji none", kind="input")
|
|
714
733
|
if missing:
|
|
715
|
-
warn(
|
|
734
|
+
warn(emoji_missing_asset_note(list(missing), support), "emoji_assets_missing", missing=list(missing))
|
|
716
735
|
if emoji_mode:
|
|
717
|
-
emoji_result = {"mode": emoji_mode, "count":
|
|
718
|
-
"clusters": sorted(seen) or
|
|
736
|
+
emoji_result = {"mode": emoji_mode, "count": emoji_count,
|
|
737
|
+
"clusters": sorted(seen) or emoji_names,
|
|
719
738
|
"assets": emoji_assets, "missing": missing,
|
|
720
739
|
"overlays": len(emoji_overlays)}
|
|
721
740
|
ass_path = args.write_ass or os.path.splitext(output)[0] + "_gfx.ass"
|
|
@@ -730,8 +749,8 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
730
749
|
text_overlay_ass(elements, play_w=W, play_h=H, path=ass_path, fonts_dir=ass_fonts_dir())
|
|
731
750
|
info(f"wrote {ass_path} ({len(elements)} text elements, rendered through libass)")
|
|
732
751
|
elif emoji_mode:
|
|
733
|
-
emoji_result = {"mode": emoji_mode, "count":
|
|
734
|
-
"clusters":
|
|
752
|
+
emoji_result = {"mode": emoji_mode, "count": emoji_count,
|
|
753
|
+
"clusters": emoji_names,
|
|
735
754
|
"assets": emoji_assets, "missing": [], "overlays": 0}
|
|
736
755
|
|
|
737
756
|
# The boxes with chroma (layers) go under the text, and the picture they and a converted colour
|
package/scripts/join.py
CHANGED
|
@@ -278,10 +278,17 @@ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
|
|
|
278
278
|
return 0
|
|
279
279
|
|
|
280
280
|
|
|
281
|
+
# ffmpeg concat-demuxer directives other than `file`: they change what a `file` line plays
|
|
282
|
+
CONCAT_DIRECTIVES = frozenset({"inpoint", "outpoint", "duration", "stream", "exact_stream_id", "file_packet_metadata",
|
|
283
|
+
"file_packet_meta", "option", "stream_meta", "stream_codec", "stream_extradata", "chapter"})
|
|
284
|
+
|
|
285
|
+
|
|
281
286
|
def read_list(path: str) -> List[str]:
|
|
282
287
|
"""--list FILE: one segment per line, in order, relative to the list file's own folder.
|
|
283
288
|
Blank lines and # comments are ignored, and ffmpeg's concat-demuxer spelling
|
|
284
|
-
(`file 'part 01.wav'`) is accepted, so a list written for
|
|
289
|
+
(`file 'part 01.wav'`, the `ffconcat version` header) is accepted, so a list written for
|
|
290
|
+
`ffmpeg -f concat` works as is unless it trims: `inpoint`, `outpoint`, `duration` and the other
|
|
291
|
+
directives are refused by name, since nothing here applies them."""
|
|
285
292
|
# the shared reader: a BOM, CRLF and a file that is not UTF-8 are read or refused as every
|
|
286
293
|
# other text input is, and a line ends at "\n" only
|
|
287
294
|
text = read_text_or_die(path, "--list")
|
|
@@ -295,6 +302,17 @@ def read_list(path: str) -> List[str]:
|
|
|
295
302
|
line = line[5:].strip()
|
|
296
303
|
if len(line) >= 2 and line[0] == line[-1] and line[0] in "'\"":
|
|
297
304
|
line = line[1:-1].replace("'\\''", "'")
|
|
305
|
+
elif line.split()[0] == "ffconcat" and line.split()[1:2] == ["version"]:
|
|
306
|
+
continue # the concat demuxer's header line names no file
|
|
307
|
+
elif line.split()[0] in CONCAT_DIRECTIVES and not os.path.exists(os.path.join(base, line)):
|
|
308
|
+
# only `file` lines are read: a directive was taken for a path, reported as a missing
|
|
309
|
+
# (or, under --dry-run, pending) input, and with --on-missing skip its trim was lost.
|
|
310
|
+
# `stream` takes no argument (FFmpeg 6.1's concat demuxer); a line naming a file that is
|
|
311
|
+
# there is a path, whatever its first word
|
|
312
|
+
die(f"--list {path}: `{line}` is an ffmpeg concat directive, which join.py does not apply: the list is "
|
|
313
|
+
"read for its `file` lines only", kind="input",
|
|
314
|
+
hint="trim a clip with cut.py first and list the cut file (inpoint/outpoint/duration), "
|
|
315
|
+
"or remove the line")
|
|
298
316
|
entries.append(line if os.path.isabs(line) else os.path.join(base, line))
|
|
299
317
|
if not entries:
|
|
300
318
|
# the failure this exists for: an upstream step produced nothing, and the join must
|
|
@@ -585,7 +603,7 @@ def main() -> int:
|
|
|
585
603
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
586
604
|
ap.add_argument("inputs", nargs="*", help="two or more clips in order (or --list FILE)")
|
|
587
605
|
ap.add_argument("--list", metavar="FILE", help="read the clips from FILE, one per line in order (paths relative to FILE; "
|
|
588
|
-
"ffmpeg concat lines `file 'x.wav'` also accepted); an empty list is refused")
|
|
606
|
+
"ffmpeg concat lines `file 'x.wav'` also accepted, other concat directives refused); an empty list is refused")
|
|
589
607
|
ap.add_argument("--on-missing", choices=["fail", "skip"], default="fail",
|
|
590
608
|
help="a missing, empty or unreadable input: fail (default; every problem named at once) or skip "
|
|
591
609
|
"(join the rest; each skipped input is reported under `skipped`)")
|
package/scripts/loudness.py
CHANGED
|
@@ -25,6 +25,7 @@ AUDIO_LENGTH_TOLERANCE = 0.03 # seconds: one AAC frame at 48 kHz is 0.021 s
|
|
|
25
25
|
LUFS_TOLERANCE = 1.0 # LU: a normalised file within this of the requested -I is on target
|
|
26
26
|
from _common.probe import PROBED_AUDIO, probed_maps, shorter_than_a_loudness_block
|
|
27
27
|
from _common.emit import refuse
|
|
28
|
+
from _common.runner import ffmpeg_failure_extra
|
|
28
29
|
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, run_keeping_subtitles, dry_run_input_pending
|
|
29
30
|
|
|
30
31
|
|
|
@@ -77,7 +78,7 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
|
77
78
|
proc = run_analysis(cmd, check=False, record=True)
|
|
78
79
|
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
79
80
|
if proc.returncode != 0 or not m:
|
|
80
|
-
die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
|
|
81
|
+
die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
|
|
81
82
|
data = json.loads(m.group(0))
|
|
82
83
|
data["too_short"] = False
|
|
83
84
|
for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
|
package/scripts/metadata.py
CHANGED
|
@@ -24,6 +24,7 @@ chapters.txt:
|
|
|
24
24
|
1:03:00 Outro
|
|
25
25
|
"""
|
|
26
26
|
import argparse
|
|
27
|
+
import re
|
|
27
28
|
import os
|
|
28
29
|
import sys
|
|
29
30
|
import tempfile
|
|
@@ -49,9 +50,18 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
|
|
|
49
50
|
if not line or line.startswith("#"):
|
|
50
51
|
continue
|
|
51
52
|
parts = line.split(None, 1)
|
|
53
|
+
vtt_header = not entries and line.upper().startswith("WEBVTT")
|
|
54
|
+
if vtt_header or (len(parts) > 1 and parts[1].startswith("-->")):
|
|
55
|
+
# a WebVTT header or an SRT/WebVTT timing line: read as chapters, its cue numbers and
|
|
56
|
+
# times became chapter starts and the run failed on their order, never naming the file (#404)
|
|
57
|
+
what = "a WebVTT header" if vtt_header else "a subtitle timing line"
|
|
58
|
+
die(f"{path}:{n}: `{line[:60]}` is {what}, so this is an SRT/WebVTT subtitle file, "
|
|
59
|
+
"not a chapter list (one chapter per line: `0:00 Intro`)", kind="input")
|
|
52
60
|
# chapter files carry no fps, so hh:mm:ss:ff needs its @fps suffix; time_arg() says so
|
|
53
61
|
start = time_arg(parts[0], f"{path}:{n}")
|
|
54
|
-
|
|
62
|
+
# `0:00 - Intro` (the form YouTube descriptions use): the separator is not part of the title
|
|
63
|
+
title = re.sub(r"^[-\u2013\u2014|:]\s+", "", parts[1].strip()) if len(parts) > 1 else ""
|
|
64
|
+
title = title or f"Chapter {len(entries) + 1}"
|
|
55
65
|
if "\x00" in title:
|
|
56
66
|
# FFmpeg ends a metadata value at NUL: "A\x00B" was written as "A", verified (the
|
|
57
67
|
# sibling of A260 in render.py's inline chapters)
|
package/scripts/overlay.py
CHANGED
|
@@ -41,7 +41,8 @@ from _common.color import is_rgb
|
|
|
41
41
|
from _common.decision import length_output_t
|
|
42
42
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
43
43
|
has_alpha_plane, report as alpha_report, source_alpha, source_format, tag_args, video_line)
|
|
44
|
-
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
44
|
+
from _common.probe import display_size, refuse_not_a_still, square_pixels, square_pixels_note
|
|
45
|
+
from _common.drawtext import shaping_refusal
|
|
45
46
|
from _common.wrap import wrap_text
|
|
46
47
|
from _common import text_width_em
|
|
47
48
|
from _common.fonts import font_choice, record_font_choice
|
|
@@ -323,8 +324,7 @@ def main() -> int:
|
|
|
323
324
|
# refuse and name the two tools that render the script correctly through libass.
|
|
324
325
|
_sc = detect_script(args.text, getattr(args, "lang", None))
|
|
325
326
|
if needs_shaping(_sc):
|
|
326
|
-
die(f"{LANGUAGE_NAMES.get(_sc, _sc)} text
|
|
327
|
-
"(the marks are reordered by harfbuzz, which drawtext does not use): draw it with "
|
|
327
|
+
die(f"{LANGUAGE_NAMES.get(_sc, _sc)} text {shaping_refusal(_sc)}: draw it with "
|
|
328
328
|
"caption.py (--text cues, burned through libass) or graphics.py (--template with "
|
|
329
329
|
"--text-render ass) instead", kind="input")
|
|
330
330
|
if has_emoji(args.text):
|
|
@@ -337,8 +337,12 @@ def main() -> int:
|
|
|
337
337
|
args.text = args.text[:_i] + args.text[_i + len(_cl):]
|
|
338
338
|
warn("emoji: stripped from the drawn text (--emoji none)", "emoji_stripped", mode="none")
|
|
339
339
|
else:
|
|
340
|
-
|
|
341
|
-
|
|
340
|
+
# not "drawn by the text font": drawtext loads that one font and has no fallback, so
|
|
341
|
+
# a font without the glyph (DejaVu Sans, every script font) draws an empty box (#403)
|
|
342
|
+
warn("emoji are left to the text font here: drawtext loads that one font and has no fallback, so "
|
|
343
|
+
"a font without the glyph draws an empty box (monochrome at best with one). --emoji none "
|
|
344
|
+
"leaves them out; caption.py/graphics.py composite colour PNGs with --emoji-assets",
|
|
345
|
+
"emoji_monochrome")
|
|
342
346
|
script_file = script_family = None
|
|
343
347
|
if args.text and not args.font_file:
|
|
344
348
|
# 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
|
|
@@ -440,7 +444,8 @@ def main() -> int:
|
|
|
440
444
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
441
445
|
|
|
442
446
|
if args.image:
|
|
443
|
-
probe(args.image)
|
|
447
|
+
refuse_not_a_still(args.image, probe(args.image), "--image",
|
|
448
|
+
"--video composites a moving layer; `look.py FILE --at T` writes one frame of it as a PNG")
|
|
444
449
|
chain: List[str] = ["format=rgba"]
|
|
445
450
|
if args.scale_percent:
|
|
446
451
|
chain.append(f"scale={int(vw * args.scale_percent / 100)}:-1")
|