ffmpeg-skill 3.8.16 → 3.8.18
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 +3 -3
- package/mcp/server.py +143 -21
- package/package.json +1 -1
- package/references/scripts.md +8 -3
- package/scripts/_common/alpha.py +1 -0
- package/scripts/_common/color.py +30 -1
- package/scripts/_common/decision.py +42 -1
- package/scripts/_common/emit.py +55 -6
- package/scripts/_common/fonts.py +31 -0
- package/scripts/_common/probe.py +28 -1
- package/scripts/_common/runner.py +452 -80
- package/scripts/_common/wrap.py +24 -8
- package/scripts/_contract.py +18 -2
- package/scripts/batch.py +7 -1
- package/scripts/broll.py +13 -2
- package/scripts/caption.py +13 -5
- package/scripts/graphics.py +4 -1
- package/scripts/grid.py +14 -5
- package/scripts/metadata.py +5 -2
- package/scripts/overlay.py +10 -1
- package/scripts/render.py +11 -11
- package/scripts/report.py +26 -3
- package/scripts/silence.py +2 -2
- package/scripts/verify.py +32 -2
package/scripts/_common/wrap.py
CHANGED
|
@@ -282,16 +282,32 @@ def _slice_atom(atom: str, max_em: float) -> "List[str]":
|
|
|
282
282
|
No dictionary, no hyphenation library: an existing hyphen is preferred as the cut (the same
|
|
283
283
|
break R1 already allows), then whatever is left is cut again at exactly the widest prefix
|
|
284
284
|
that still measures within `max_em`, one character at a time. The characters themselves are
|
|
285
|
-
never rewritten -- every piece concatenates back to the original atom exactly.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
285
|
+
never rewritten -- every piece concatenates back to the original atom exactly.
|
|
286
|
+
|
|
287
|
+
Iterative, and each piece measures only the prefixes up to the first that is too wide (a
|
|
288
|
+
prefix is never narrower than a shorter one): the recursive form measured every prefix of the
|
|
289
|
+
remaining atom for each piece it cut, so a 1 KB hyphenated cue took seconds and 4 KB minutes
|
|
290
|
+
(A11, #465), in caption.py's own process, which --timeout (a limit on each ffmpeg call) does
|
|
291
|
+
not bound. The pieces are the ones the recursive form cut."""
|
|
292
292
|
pieces: "List[str]" = []
|
|
293
|
+
rest = atom
|
|
294
|
+
while True:
|
|
295
|
+
# latest hyphen whose left side still fits -- keeps the left half as large as possible;
|
|
296
|
+
# a rest whose every prefix fits, itself included, is the last piece
|
|
297
|
+
cut = 0
|
|
298
|
+
for end in range(1, len(rest) + 1):
|
|
299
|
+
if text_width_em(rest[:end]) > max_em:
|
|
300
|
+
break
|
|
301
|
+
if 1 <= end - 1 <= len(rest) - 2 and rest[end - 1] in _HYPHENS:
|
|
302
|
+
cut = end
|
|
303
|
+
else:
|
|
304
|
+
return pieces + [rest]
|
|
305
|
+
if not cut:
|
|
306
|
+
break
|
|
307
|
+
pieces.append(rest[:cut])
|
|
308
|
+
rest = rest[cut:]
|
|
293
309
|
cur = ""
|
|
294
|
-
for ch in
|
|
310
|
+
for ch in rest:
|
|
295
311
|
candidate = cur + ch
|
|
296
312
|
if cur and text_width_em(candidate) > max_em:
|
|
297
313
|
pieces.append(cur)
|
package/scripts/_contract.py
CHANGED
|
@@ -436,6 +436,7 @@ ERROR_REASONS = {
|
|
|
436
436
|
"output_is_directory": "the output path is a directory; details: {output}",
|
|
437
437
|
"media_path_is_playlist": "an input is a playlist that ffmpeg would follow to the files or URLs it names: HLS .m3u8/.m3u or an ffconcat list (by name or by its first bytes), a DASH manifest or an IMF composition playlist (by name or by the content ffmpeg's own probe looks for, whatever the file is called); the skill takes media files only; details: {path}",
|
|
438
438
|
"media_path_has_line_break": "a path written into a concat list (loop.py, join.py --copy, sequence.py, multi-part cuts) contains a line break, which the concat format cannot hold: each line is a directive, so the name would add entries to the list; details: {path}",
|
|
439
|
+
"still_unreadable": "an input read by an image demuxer (a .png, .jpg, ... name) has a picture stream with no frame size: the file is not an image ffmpeg can decode (a text file named .png); details: {path, format, codec}",
|
|
439
440
|
"media_path_not_a_file": "an input exists but is not a regular file (a FIFO or a device), which ffmpeg would block on or read as a stream; details: {path}",
|
|
440
441
|
"join_copy_mismatch": "join.py --copy: a clip's streams do not match the first clip's, it hides pre-roll frames with an edit list, or its picture and sound end more than 50 ms apart; details: {clips: [{index?, path, differs}]}",
|
|
441
442
|
"output_path_taken": "two files one run writes (a side file and the output, or two side files) resolve to the same path, so the second would replace the first; details: {output, other}",
|
|
@@ -446,7 +447,7 @@ ERROR_REASONS = {
|
|
|
446
447
|
"output_dir_not_writable": "the output's directory is not writable; details: {output, directory}",
|
|
447
448
|
"output_container_codecs": "a .webm output whose command encodes video or sound WebM cannot hold (H.264/HEVC, AAC; WebM takes VP8/VP9/AV1 and Opus/Vorbis), refused before ffmpeg runs; details: {output, container, encoders}",
|
|
448
449
|
"timemap_path_taken": "silence.py --timemap names the same file as -o, --edl or --plan; details: {timemap, flag, path}",
|
|
449
|
-
"output_locked": "another run holds the output's lock; details: {lock, output, pid, pid_alive, deadline, why}",
|
|
450
|
+
"output_locked": "another run holds the output's lock, or is taking a stale one over (why: takeover_in_progress), or a stale lock cannot be taken over safely because the filesystem gives no OS file lock (why: takeover_unavailable: delete it by hand); details: {lock, output, pid, pid_alive, deadline, why}",
|
|
450
451
|
"plan_inputs_changed": "render.py PLAN: a fingerprinted input is missing or changed; details: {plan, plan_sha256, checked_at, "
|
|
451
452
|
"inputs: [{path, status: unchanged|changed|missing, expected: {size, sha256_head_tail}, actual: {size, "
|
|
452
453
|
"sha256_head_tail} | \"missing\"}], unbound_inputs, audit_log}",
|
|
@@ -1943,6 +1944,11 @@ MCP_PROMPTS: List[Dict[str, Any]] = [
|
|
|
1943
1944
|
"to keep HDR and only drop the Dolby Vision layer, when strip_dovi is asked for). A flat, washed-out "
|
|
1944
1945
|
"look after a re-encode usually means HDR metadata was lost upstream; `probe` shows `hdr: true` "
|
|
1945
1946
|
"only for a real PQ/HLG/Dolby Vision signal, and `bt2020_or_hdr: true` also for BT.2020 SDR."),
|
|
1947
|
+
# A23 (#465): strip_dovi was taken and changed nothing; true now asks for the other recipe
|
|
1948
|
+
"flags": {"strip_dovi": (
|
|
1949
|
+
"Keep {input} HDR and drop only its Dolby Vision layer: `color.py {input} --strip-dovi`, a stream copy "
|
|
1950
|
+
"(HEVC only) that removes the Dolby Vision RPU so players use the HLG/HDR10 base layer. A source with no "
|
|
1951
|
+
"Dolby Vision metadata gets a `dovi_not_detected` warning.")},
|
|
1946
1952
|
},
|
|
1947
1953
|
]
|
|
1948
1954
|
|
|
@@ -1968,7 +1974,17 @@ def mcp_prompt_get(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
1968
1974
|
fields: Dict[str, str] = {a["name"]: str((arguments or {}).get(a["name"]) or a.get("default", "")) for a in prompt["arguments"]}
|
|
1969
1975
|
for opt_name, clause in prompt["optional"].items():
|
|
1970
1976
|
fields[opt_name] = clause.format(**fields) if fields.get(opt_name) else ""
|
|
1971
|
-
|
|
1977
|
+
template = prompt["template"]
|
|
1978
|
+
for flag, flag_template in (prompt.get("flags") or {}).items():
|
|
1979
|
+
# a yes/no argument picks the recipe; every prompt argument arrives as a string, so
|
|
1980
|
+
# "false" is no, and a value that is neither is a caller mistake (ValueError, as above)
|
|
1981
|
+
value = (arguments or {}).get(flag)
|
|
1982
|
+
word = str(value).strip().lower() if value is not None else ""
|
|
1983
|
+
if word in ("true", "1", "yes"):
|
|
1984
|
+
template = flag_template
|
|
1985
|
+
elif word not in ("", "false", "0", "no"):
|
|
1986
|
+
raise ValueError(f"prompt {name!r}: {flag} must be true or false, got {value!r}")
|
|
1987
|
+
text = template.format(**fields)
|
|
1972
1988
|
return {"description": prompt["description"], "messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
|
|
1973
1989
|
|
|
1974
1990
|
|
package/scripts/batch.py
CHANGED
|
@@ -699,10 +699,15 @@ def main() -> int:
|
|
|
699
699
|
created = [d for d in (outdir, work) if not d.exists()]
|
|
700
700
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
701
701
|
work.mkdir(parents=True, exist_ok=True)
|
|
702
|
-
|
|
702
|
+
# a real run that is refused before any item starts (--jobs, an output collision, a final path
|
|
703
|
+
# that is an input) leaves nothing behind either (A187, #465: it left out/ and out/.work)
|
|
704
|
+
item_started = {"item": False}
|
|
705
|
+
if created:
|
|
703
706
|
import atexit
|
|
704
707
|
|
|
705
708
|
def _remove_empty_dirs() -> None:
|
|
709
|
+
if not STATE.dry_run and item_started["item"]:
|
|
710
|
+
return
|
|
706
711
|
for d in sorted(created, key=lambda p: len(str(p)), reverse=True):
|
|
707
712
|
try:
|
|
708
713
|
if not any(d.iterdir()):
|
|
@@ -850,6 +855,7 @@ def main() -> int:
|
|
|
850
855
|
def run_item(i: int, src: Path) -> Dict[str, Any]:
|
|
851
856
|
"""process() in the item's own work directory, which goes when it ends, however it ends."""
|
|
852
857
|
import shutil
|
|
858
|
+
item_started["item"] = True
|
|
853
859
|
iw = item_work(i, src)
|
|
854
860
|
try:
|
|
855
861
|
return process(src, recipe, outdir, iw, deadline)
|
package/scripts/broll.py
CHANGED
|
@@ -26,7 +26,7 @@ from _common.decision import (av_trim, conform_output_t, conform_to_length, fram
|
|
|
26
26
|
grid_at_or_after, silence_filter, stream_starts, window_expr, windowed_overlay_x)
|
|
27
27
|
from _common import STATE, add_common, aac_args, apply_common, cfr_args, cfr_at_input, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, overlay_format, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
28
28
|
from _common.emit import note, record_fallback
|
|
29
|
-
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
29
|
+
from _common.probe import display_size, is_still_input, square_pixels, square_pixels_note, still_loop_args
|
|
30
30
|
from _common.emit import input_length
|
|
31
31
|
|
|
32
32
|
|
|
@@ -109,7 +109,9 @@ def main() -> int:
|
|
|
109
109
|
die(f"cutaway {i + 1}: --at {at:g}s is past the end of the A-roll ({dur_a:.3f}s)")
|
|
110
110
|
if dur_a and at + length > dur_a + 0.01:
|
|
111
111
|
die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
|
|
112
|
-
|
|
112
|
+
# a still has no material to run out of: it is looped for the window. image2 gives some (a
|
|
113
|
+
# TGA) a duration of one frame, which this refused as "only -0.460s from 0.5s" (review of #532)
|
|
114
|
+
dur_b = None if is_still_input(meta_b) else input_length(meta_b)
|
|
113
115
|
if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
|
|
114
116
|
die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
|
|
115
117
|
# the cutaway in whole frames: `first` is B's first frame at or after --from, `on` the
|
|
@@ -139,6 +141,15 @@ def main() -> int:
|
|
|
139
141
|
output = args.output or default_output(args.input, "broll")
|
|
140
142
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
141
143
|
for c in cutaways:
|
|
144
|
+
# a still B-roll is one frame to a plain -i: it showed for one frame of the cutaway and A
|
|
145
|
+
# came back under it (#510). Looped, it lasts the window it is trimmed to. The bound is past
|
|
146
|
+
# the trim's end, which runs from B's first frame at or after --from on the loop's own
|
|
147
|
+
# grid for the window's whole frames: --from + --duration cut that frame off a short window
|
|
148
|
+
# (--from 0.041 --duration 0.033 needs the frame at 0.08, review of #532). A frame of B
|
|
149
|
+
# past it costs nothing: the trim drops it.
|
|
150
|
+
if is_still_input(c["meta"]):
|
|
151
|
+
frame_b = 1 / c["rate"] if c["rate"] else 0.04
|
|
152
|
+
cmd += still_loop_args(c["path"], max(c["from"], c["first"]) + max(c["length"], c["span"] or 0) + 2 * frame_b)
|
|
142
153
|
cmd += ["-i", c["path"]]
|
|
143
154
|
|
|
144
155
|
# Picture: each B window is trimmed, normalised to A's frame and fps, shifted to start at its
|
package/scripts/caption.py
CHANGED
|
@@ -55,8 +55,8 @@ from _common import asr as _asr
|
|
|
55
55
|
from _common.asr import ENGINE_CHOICES
|
|
56
56
|
from _common.emit import warn
|
|
57
57
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
58
|
-
from _common.runner import carried_stream_maps, subtitle_codec_for, subtitle_container_holds
|
|
59
|
-
from _common.fonts import font_choice, record_font_choice
|
|
58
|
+
from _common.runner import carried_stream_maps, refuse_output_is_input, same_file, subtitle_codec_for, subtitle_container_holds
|
|
59
|
+
from _common.fonts import font_choice, record_font_choice, require_font_file
|
|
60
60
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
61
61
|
write_srt)
|
|
62
62
|
from _common.asr import srt_blocks
|
|
@@ -1327,6 +1327,11 @@ def main() -> int:
|
|
|
1327
1327
|
if args.brand and bcap.get("karaoke") and not args.karaoke:
|
|
1328
1328
|
args.karaoke = True
|
|
1329
1329
|
if args.brand and brand.get("font_file") and not args.fonts_dir:
|
|
1330
|
+
# the brand's face is found in its directory by libass, so a file there that is no font
|
|
1331
|
+
# left libass to another face without a word (#510); a soft track (--mode mux) draws nothing,
|
|
1332
|
+
# and nor does a run with no input video, which only writes the SRT (review of #532)
|
|
1333
|
+
if args.mode != "mux" and args.input:
|
|
1334
|
+
require_font_file(brand["font_file"], "the --brand file's font_file")
|
|
1330
1335
|
args.fonts_dir = str(Path(brand["font_file"]).parent)
|
|
1331
1336
|
if not (args.srt or args.ass or args.text or args.transcribe):
|
|
1332
1337
|
die("give one of --srt, --ass, --text or --transcribe")
|
|
@@ -1586,9 +1591,12 @@ def main() -> int:
|
|
|
1586
1591
|
up_front.append((args.write_ass or os.path.splitext(planned_out)[0] + ".ass", "ASS"))
|
|
1587
1592
|
if args.ass and args.offset and os.path.exists(args.ass):
|
|
1588
1593
|
up_front.append((os.path.splitext(planned_out)[0] + "_offset.ass", "offset ASS"))
|
|
1589
|
-
# -o naming the input
|
|
1590
|
-
|
|
1591
|
-
|
|
1594
|
+
# -o naming the input (the same name, a hard link, another case on a case-insensitive
|
|
1595
|
+
# volume) is refused here, before any sidecar or transcript is written: run()'s own guard
|
|
1596
|
+
# came after the .srt was already beside it (review of #530)
|
|
1597
|
+
if same_file(planned_out, args.input):
|
|
1598
|
+
refuse_output_is_input(planned_out, args.input, why="the captioned video would replace the source")
|
|
1599
|
+
up_front.append((planned_out, "video"))
|
|
1592
1600
|
claim(up_front)
|
|
1593
1601
|
if args.transcribe:
|
|
1594
1602
|
# the sidecar goes next to the output like the --text one, not into the source folder
|
package/scripts/graphics.py
CHANGED
|
@@ -47,7 +47,7 @@ from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_
|
|
|
47
47
|
from _common.emit import note, warn
|
|
48
48
|
from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
|
|
49
49
|
report as alpha_report, source_alpha, source_format, tag_args, video_line)
|
|
50
|
-
from _common.fonts import font_choice, record_font_choice
|
|
50
|
+
from _common.fonts import font_choice, record_font_choice, require_font_file
|
|
51
51
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
52
52
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
53
53
|
from _common import drawtext_fontfile # noqa: E402
|
|
@@ -175,6 +175,9 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
175
175
|
if args.brand and style.get("color") and not args.text_color:
|
|
176
176
|
text_c = color_hex(style["color"])
|
|
177
177
|
args.lang = args.lang or (brand.get("lang") if args.brand else None)
|
|
178
|
+
# checked where text is drawn with it: the progress template draws a bar only (review of #532)
|
|
179
|
+
if args.template != "progress" and (args.font_file or brand.get("font_file")):
|
|
180
|
+
require_font_file(args.font_file or brand["font_file"], "--font-file" if args.font_file else "the --brand file's font_file")
|
|
178
181
|
# a font that covers the text before drawtext renders boxes instead of glyphs (1.12)
|
|
179
182
|
_script, script_file, _family = script_font_for_text(
|
|
180
183
|
" ".join(t for t in (args.name, args.title, args.subtitle, args.text, args.top, args.bottom) if t),
|
package/scripts/grid.py
CHANGED
|
@@ -32,7 +32,7 @@ from _common import drawtext_fontfile # noqa: E402
|
|
|
32
32
|
from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
|
|
33
33
|
from _common.emit import note, record_fallback # noqa: E402
|
|
34
34
|
from _common.fonts import script_font_for_text # noqa: E402
|
|
35
|
-
from _common.probe import square_pixels # noqa: E402
|
|
35
|
+
from _common.probe import is_still_input, square_pixels, still_loop_args # noqa: E402
|
|
36
36
|
from _common.wrap import text_width_em # noqa: E402
|
|
37
37
|
from _common.emit import input_length # noqa: E402
|
|
38
38
|
from _common.decision import conform_output_t, conform_to_length # noqa: E402
|
|
@@ -116,8 +116,15 @@ def main() -> int:
|
|
|
116
116
|
if args.audio_from is not None and not metas[args.audio_from].get("audio"):
|
|
117
117
|
die(f"--audio-from {args.audio_from}: {args.inputs[args.audio_from]} has no audio stream")
|
|
118
118
|
|
|
119
|
-
|
|
119
|
+
# a still picture has no length of its own: it lasts as long as the grid (#510). Counted as a
|
|
120
|
+
# clip of 0 s, it made the grid 0 s long, and the run ended in a traceback on the empty file.
|
|
121
|
+
stills = [is_still_input(m) for m in metas]
|
|
122
|
+
if all(stills):
|
|
123
|
+
die("every cell is a still picture, so the grid has no length to take from a clip", kind="input",
|
|
124
|
+
hint="give at least one video cell, or make a clip of a still first (insert.py STILL --duration T)")
|
|
125
|
+
durations = [input_length(m) for m, still in zip(metas, stills) if not still]
|
|
120
126
|
target_duration = max(durations) if args.pad else min(durations)
|
|
127
|
+
durations = [target_duration if still else input_length(m) for m, still in zip(metas, stills)]
|
|
121
128
|
|
|
122
129
|
# each label is drawn with a font that covers its own script, as every other text-drawing tool
|
|
123
130
|
# picks one (through 3.1.3 a Japanese or Thai filename came out as tofu, #411); `font_used` is
|
|
@@ -139,11 +146,13 @@ def main() -> int:
|
|
|
139
146
|
if args.label == "auto" and cut:
|
|
140
147
|
note(f"{len(cut)} cell label(s) wider than the {args.cell_width} px cell at --font-size "
|
|
141
148
|
f"{args.font_size} were shortened from the end with an ellipsis")
|
|
142
|
-
|
|
149
|
+
# named after the first cell; a still there would give its own extension (white_grid.png), and
|
|
150
|
+
# the image muxer cannot write the grid's frames to one file name (review of #532)
|
|
151
|
+
output = args.output or default_output(args.inputs[0], "grid", "mp4" if stills[0] else None)
|
|
143
152
|
|
|
144
153
|
cmd = ffmpeg_base()
|
|
145
|
-
for p in args.inputs:
|
|
146
|
-
cmd += ["-i", p]
|
|
154
|
+
for p, still in zip(args.inputs, stills):
|
|
155
|
+
cmd += (still_loop_args(p, target_duration) if still else []) + ["-i", p]
|
|
147
156
|
|
|
148
157
|
# each cell converted by its own tags to the BT.709 the grid is tagged with (a BT.601 or an RGB
|
|
149
158
|
# input): without it 5.1/6.1 relabelled them, and 7.1+ converted cells to one another's matrix.
|
package/scripts/metadata.py
CHANGED
|
@@ -219,8 +219,11 @@ def main() -> int:
|
|
|
219
219
|
args = ap.parse_args()
|
|
220
220
|
apply_common(args)
|
|
221
221
|
if args.auto_chapters: # the only mode that writes them
|
|
222
|
-
|
|
223
|
-
|
|
222
|
+
# against the video this run writes, the default name too (A6, #465: --chapters-out at
|
|
223
|
+
# input_meta.mp4 wrote the chapter text over the video it had just written)
|
|
224
|
+
video_out = args.output or default_output(args.input, "meta", Path(args.input).suffix.lstrip(".") or "mp4")
|
|
225
|
+
guard_sidecar(args.chapters_out, args.input, siblings=(args.description_out, video_out))
|
|
226
|
+
guard_sidecar(args.description_out, args.input, siblings=(video_out,))
|
|
224
227
|
|
|
225
228
|
if args.chapters and args.clear_chapters:
|
|
226
229
|
die("--chapters and --clear-chapters exclude each other")
|
package/scripts/overlay.py
CHANGED
|
@@ -46,7 +46,7 @@ from _common.probe import display_size, refuse_not_a_still, square_pixels, squar
|
|
|
46
46
|
from _common.drawtext import shaping_refusal
|
|
47
47
|
from _common.wrap import wrap_text
|
|
48
48
|
from _common import text_width_em
|
|
49
|
-
from _common.fonts import font_choice, record_font_choice
|
|
49
|
+
from _common.fonts import font_choice, record_font_choice, require_font_file
|
|
50
50
|
from _common import drawtext_fontfile # noqa: E402
|
|
51
51
|
|
|
52
52
|
# Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
|
|
@@ -361,6 +361,7 @@ def main() -> int:
|
|
|
361
361
|
# at --font-size), so a project that asked for 200 px text completed with the 42 px default.
|
|
362
362
|
if args.text and not (args.image or args.video) and (args.scale is not None or args.scale_percent is not None):
|
|
363
363
|
die("--scale and --scale-percent size an image, logo or video layer; text size is --font-size", kind="input")
|
|
364
|
+
font_file_from = "--font-file" # what the refusal of a font file that is not one names
|
|
364
365
|
if args.brand:
|
|
365
366
|
if args.margin == ap.get_default("margin"):
|
|
366
367
|
args.margin = int(brand.get("safe_margin", args.margin))
|
|
@@ -368,6 +369,7 @@ def main() -> int:
|
|
|
368
369
|
args.font = brand.get("font", args.font)
|
|
369
370
|
if not args.font_file and brand.get("font_file"):
|
|
370
371
|
args.font_file = brand["font_file"]
|
|
372
|
+
font_file_from = "the --brand file's font_file"
|
|
371
373
|
if args.text:
|
|
372
374
|
# 1.15: drawtext never reorders or re-clusters (no harfbuzz), so Devanagari matras and
|
|
373
375
|
# Thai/Lao mark stacking come out wrong on EVERY build. A wrong frame is not a delivery:
|
|
@@ -393,6 +395,8 @@ def main() -> int:
|
|
|
393
395
|
"a font without the glyph draws an empty box (monochrome at best with one). --emoji none "
|
|
394
396
|
"leaves them out; caption.py/graphics.py composite colour PNGs with --emoji-assets",
|
|
395
397
|
"emoji_monochrome")
|
|
398
|
+
if args.text and args.font_file:
|
|
399
|
+
require_font_file(args.font_file, font_file_from)
|
|
396
400
|
script_file = script_family = None
|
|
397
401
|
if args.text and not args.font_file:
|
|
398
402
|
# 1.12: non-Latin overlay text picks a font by script, so a title in Japanese, Korean,
|
|
@@ -533,6 +537,11 @@ def main() -> int:
|
|
|
533
537
|
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
534
538
|
# picture's frames; overlay's enable is evaluated on the layer's frames too
|
|
535
539
|
ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
540
|
+
if not meta.get("duration"):
|
|
541
|
+
# nothing to bound by -t: the overlay ends with the main picture. -shortest alone never
|
|
542
|
+
# ended a still main (one picture, no duration): overlay kept repeating it under the
|
|
543
|
+
# looped layer and the run went on until its timeout (#510)
|
|
544
|
+
ov += ":shortest=1"
|
|
536
545
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
537
546
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
|
|
538
547
|
# the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
|
package/scripts/render.py
CHANGED
|
@@ -76,7 +76,7 @@ from export import PRESETS, PLATFORM_OF
|
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
77
|
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit as _emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
78
78
|
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, owned_identity, read_json, remember_written
|
|
79
|
-
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
79
|
+
from _common.emit import absorb_stage, audit, is_output_shape_flag, record_fallback, record_skip, refuse, stderr_records, warn
|
|
80
80
|
from _common.decision import rebase_project_paths
|
|
81
81
|
import subprocess
|
|
82
82
|
from _contract import CONTRACT_VERSION
|
|
@@ -405,9 +405,7 @@ def render_pack(names: List[str], args) -> int:
|
|
|
405
405
|
doc = {}
|
|
406
406
|
# the child ran with --json, so its planned commands come back in the document: the pack
|
|
407
407
|
# is the one path that writes seven files, and --dry-run has to show all of them
|
|
408
|
-
for
|
|
409
|
-
if line.startswith("$ ") or line.startswith("[dry-run]"):
|
|
410
|
-
STATE.commands.append(line[2:] if line.startswith("$ ") else line)
|
|
408
|
+
STATE.commands.extend(text for is_command, text in stderr_records(proc.stderr) if is_command)
|
|
411
409
|
STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
|
|
412
410
|
absorb_stage(name, doc)
|
|
413
411
|
out = doc.get("output") or dest_out
|
|
@@ -806,9 +804,9 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
806
804
|
cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + own + child_args() + ["--json"]
|
|
807
805
|
info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
|
|
808
806
|
proc = run_tool(cmd)
|
|
809
|
-
for line in proc.stderr
|
|
810
|
-
if
|
|
811
|
-
STATE.commands.append(line
|
|
807
|
+
for is_command, line in stderr_records(proc.stderr):
|
|
808
|
+
if is_command:
|
|
809
|
+
STATE.commands.append(line)
|
|
812
810
|
elif line.strip():
|
|
813
811
|
info(" " + line)
|
|
814
812
|
try:
|
|
@@ -1212,7 +1210,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1212
1210
|
os.chdir(plan["cwd"])
|
|
1213
1211
|
if not isinstance(plan.get("argv"), list):
|
|
1214
1212
|
_reject_plan(path, digest, "plan_argv_invalid", f"{path}: argv must be a list", argv_type=type(plan.get("argv")).__name__)
|
|
1215
|
-
|
|
1213
|
+
# a plan an earlier version recorded with --json-brief (A181): the tool is run for its full
|
|
1214
|
+
# document, as every plan written now asks for
|
|
1215
|
+
argv = [str(a) for a in plan["argv"] if not is_output_shape_flag(str(a))]
|
|
1216
1216
|
if STATE.dry_run:
|
|
1217
1217
|
for c in plan.get("commands") or []:
|
|
1218
1218
|
STATE.commands.append(c)
|
|
@@ -1226,9 +1226,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1226
1226
|
return 0
|
|
1227
1227
|
info(f"executing plan {path}: {tool} " + " ".join(argv))
|
|
1228
1228
|
proc = run_tool([str(script)] + argv + child_args() + ["--json"])
|
|
1229
|
-
for line in proc.stderr
|
|
1230
|
-
if line.startswith("
|
|
1231
|
-
STATE.commands.append(line
|
|
1229
|
+
for is_command, line in stderr_records(proc.stderr):
|
|
1230
|
+
if is_command and not line.startswith("[dry-run]"):
|
|
1231
|
+
STATE.commands.append(line)
|
|
1232
1232
|
elif line.strip():
|
|
1233
1233
|
info(" " + line)
|
|
1234
1234
|
try:
|
package/scripts/report.py
CHANGED
|
@@ -14,6 +14,7 @@ import base64
|
|
|
14
14
|
import html
|
|
15
15
|
import json
|
|
16
16
|
import os
|
|
17
|
+
import re
|
|
17
18
|
import sys
|
|
18
19
|
import tempfile
|
|
19
20
|
from pathlib import Path
|
|
@@ -145,6 +146,10 @@ def md_cells(line: str) -> List[str]:
|
|
|
145
146
|
return cells
|
|
146
147
|
|
|
147
148
|
|
|
149
|
+
# the columns of render.py's render_pack() table, in its order
|
|
150
|
+
PACK_HEADER = ["platform", "file", "size", "duration", "check"]
|
|
151
|
+
|
|
152
|
+
|
|
148
153
|
def pack_report(args) -> int:
|
|
149
154
|
"""render.py --template all writes <stem>_pack.md: one row per destination. This renders the
|
|
150
155
|
same rows as HTML, so a pack can be handed over the way a single delivery report is -- no
|
|
@@ -152,19 +157,37 @@ def pack_report(args) -> int:
|
|
|
152
157
|
text = read_text_or_die(args.pack, "--pack")
|
|
153
158
|
rows: List[List[str]] = []
|
|
154
159
|
header: List[str] = []
|
|
160
|
+
delimiter: Optional[List[str]] = None
|
|
155
161
|
for line in text.split("\n"):
|
|
156
162
|
line = line.strip()
|
|
157
163
|
if not line.startswith("|"):
|
|
164
|
+
if header:
|
|
165
|
+
break # the table ended: a later table in the file is not the pack's
|
|
158
166
|
continue
|
|
159
167
|
cells = md_cells(line)
|
|
160
|
-
if all(set(c) <= set("-: ") for c in cells):
|
|
161
|
-
continue
|
|
162
168
|
if not header:
|
|
163
169
|
header = cells
|
|
170
|
+
elif delimiter is None:
|
|
171
|
+
delimiter = cells # the line under the header, whatever it is: checked below
|
|
164
172
|
else:
|
|
173
|
+
# every later line is a row, a separator-like one too: `| | |` after a row was skipped
|
|
174
|
+
# as a delimiter and never counted (review of #532)
|
|
165
175
|
rows.append(cells)
|
|
176
|
+
# the table render.py --template all writes, and only that one: any Markdown table was taken
|
|
177
|
+
# and handed over as a pack report with `verified: true` (#510)
|
|
178
|
+
if header and header != PACK_HEADER:
|
|
179
|
+
die(f"{args.pack}: its table's columns are {' | '.join(header)}, not the pack table's "
|
|
180
|
+
f"({' | '.join(PACK_HEADER)}) that render.py --template all writes", kind="input")
|
|
181
|
+
# a header with a row under it and no delimiter line is no Markdown table (review of #532)
|
|
182
|
+
if header and delimiter is not None and not (
|
|
183
|
+
len(delimiter) == len(PACK_HEADER) and all(re.fullmatch(r":?-+:?", c) for c in delimiter)):
|
|
184
|
+
die(f"{args.pack}: the line under the pack table's header is not its delimiter row "
|
|
185
|
+
f"(|---|---|---|---|---|), so it is no Markdown table", kind="input")
|
|
186
|
+
bad = next((i for i, r in enumerate(rows) if len(r) != len(PACK_HEADER)), None)
|
|
187
|
+
if bad is not None:
|
|
188
|
+
die(f"{args.pack}: row {bad + 1} of the pack table has {len(rows[bad])} cells, not {len(PACK_HEADER)}", kind="input")
|
|
166
189
|
if not rows:
|
|
167
|
-
die(f"{args.pack}: no pack table found (expected the Markdown table render.py --template all writes)")
|
|
190
|
+
die(f"{args.pack}: no pack table found (expected the Markdown table render.py --template all writes)", kind="input")
|
|
168
191
|
title = args.title or f"Social pack — {Path(args.pack).stem.replace('_pack', '')}"
|
|
169
192
|
output = args.output or str(Path(args.pack).with_suffix(".html"))
|
|
170
193
|
guard_sidecar(output, args.pack)
|
package/scripts/silence.py
CHANGED
|
@@ -29,7 +29,7 @@ from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOU
|
|
|
29
29
|
from _common import asr as _asr
|
|
30
30
|
from _common.emit import warn
|
|
31
31
|
from _common.asr import _dicts
|
|
32
|
-
from _common.runner import parse_json, replace_checked
|
|
32
|
+
from _common.runner import parse_json, replace_checked, same_file
|
|
33
33
|
from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
|
|
34
34
|
from _common.emit import refuse
|
|
35
35
|
from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
|
|
@@ -672,7 +672,7 @@ def main() -> int:
|
|
|
672
672
|
# --overwrite allows replacing an old map, never the source, the cut or the EDL
|
|
673
673
|
refuse_output_is_input(args.timemap, *[p for p in (args.input, args.words, args.filler_words) if p])
|
|
674
674
|
for flag, other in (("-o", args.output), ("--edl", args.edl), ("--plan", getattr(args, "plan", None))):
|
|
675
|
-
if other and
|
|
675
|
+
if other and same_file(other, args.timemap):
|
|
676
676
|
refuse("input", "timemap_path_taken",
|
|
677
677
|
f"--timemap {args.timemap} is the same file as {flag}; give the map its own path",
|
|
678
678
|
timemap=args.timemap, flag=flag, path=other)
|
package/scripts/verify.py
CHANGED
|
@@ -26,6 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
from typing import Any, Dict, List, Optional, Tuple
|
|
27
27
|
|
|
28
28
|
from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe, refuse_output_is_input, MEDIA_EXT as _MEDIA_EXT
|
|
29
|
+
from _common.runner import _stop_child, _unwatch, _watch
|
|
29
30
|
|
|
30
31
|
HERE = Path(__file__).resolve().parent
|
|
31
32
|
# _common's one list less the stills, as batch.py walks a folder: verify kept a list of its own,
|
|
@@ -140,13 +141,24 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
|
140
141
|
if not ok:
|
|
141
142
|
r.update(kind="verification", reason="hdr_lost", message=r["error"])
|
|
142
143
|
return r
|
|
144
|
+
child = subprocess.Popen([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE,
|
|
145
|
+
stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
|
|
146
|
+
# watched like run_tool()'s child (review of #530): a SIGTERM or Ctrl-C to verify.py stops the
|
|
147
|
+
# step through stop_children() -- its own handler removes its partial output and lock --
|
|
148
|
+
# instead of leaving it running and writing after verify.py has exited
|
|
149
|
+
_watch(child, [])
|
|
143
150
|
try:
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
out, err = child.communicate(timeout=timeout or None)
|
|
152
|
+
proc = subprocess.CompletedProcess(child.args, child.returncode, out, err)
|
|
146
153
|
except subprocess.TimeoutExpired:
|
|
154
|
+
# stopped as run_tool() stops a sibling script past its ceiling (#465 A183's sweep): the
|
|
155
|
+
# SIGKILL subprocess.run() sent left the step's partial output and output lock in --out
|
|
156
|
+
_stop_child(child)
|
|
147
157
|
msg = f"timeout after {timeout:.0f}s"
|
|
148
158
|
return {"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": msg,
|
|
149
159
|
"kind": "timeout", "reason": "step_timeout", "message": msg}
|
|
160
|
+
finally:
|
|
161
|
+
_unwatch(child)
|
|
150
162
|
try:
|
|
151
163
|
doc = json.loads(proc.stdout)
|
|
152
164
|
except ValueError:
|
|
@@ -198,10 +210,27 @@ def main() -> int:
|
|
|
198
210
|
files = collect(args.paths)
|
|
199
211
|
FORWARD.update(overwrite=STATE.overwrite, fast=STATE.fast)
|
|
200
212
|
tmp = None
|
|
213
|
+
started = {"step": False}
|
|
201
214
|
if args.out:
|
|
202
215
|
# made first, so a --report inside a new --out folder passes the guard below (#413 review)
|
|
203
216
|
outdir = Path(args.out)
|
|
217
|
+
made = [d for d in (outdir, *outdir.parents) if not d.exists()]
|
|
204
218
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
if made:
|
|
220
|
+
import atexit
|
|
221
|
+
|
|
222
|
+
def _remove_unused_dirs() -> None:
|
|
223
|
+
# a run refused before its first step (a --report guard) leaves no empty --out
|
|
224
|
+
# folder behind, as batch.py leaves no out/ (#465 A187's sibling); once a step has
|
|
225
|
+
# run the folder is the caller's, empty or not
|
|
226
|
+
if started["step"]:
|
|
227
|
+
return
|
|
228
|
+
for d in made: # deepest first
|
|
229
|
+
try:
|
|
230
|
+
d.rmdir()
|
|
231
|
+
except OSError:
|
|
232
|
+
pass
|
|
233
|
+
atexit.register(_remove_unused_dirs)
|
|
205
234
|
if args.report:
|
|
206
235
|
# a file this run writes itself (#408): not one of the media it verifies (a folder's
|
|
207
236
|
# files included), nor an existing file without --overwrite
|
|
@@ -301,6 +330,7 @@ def main() -> int:
|
|
|
301
330
|
refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
|
|
302
331
|
|
|
303
332
|
results = []
|
|
333
|
+
started["step"] = True
|
|
304
334
|
for f, entry, plan, cues in jobs:
|
|
305
335
|
info(f"=== {f}")
|
|
306
336
|
if cues:
|