ffmpeg-skill 3.6.0 → 3.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -9
- package/mcp/server.py +28 -13
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +40 -14
- package/scripts/_ass_overlay.py +235 -2
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +65 -3
- package/scripts/_common/decision.py +89 -1
- package/scripts/_common/emit.py +31 -1
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +280 -22
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +45 -16
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +158 -18
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +46 -21
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/export.py +2 -2
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/look.py +31 -12
- package/scripts/multicam.py +6 -1
- package/scripts/overlay.py +20 -7
- package/scripts/redact.py +2 -2
- package/scripts/render.py +101 -25
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
package/scripts/_common/emoji.py
CHANGED
|
@@ -18,9 +18,9 @@ from _common.runner import safe_which as _safe_which
|
|
|
18
18
|
# separately from detect_script() so a cue's font resolution is still decided by its letters.
|
|
19
19
|
EMOJI_RANGES = (
|
|
20
20
|
(0x1F300, 0x1FAFF), # symbols & pictographs, supplemental, extended-A
|
|
21
|
-
(0x1F000, 0x1F0FF), # mahjong/domino/playing cards
|
|
22
|
-
(0x2600, 0x27BF), # misc symbols + dingbats
|
|
23
|
-
(0x2B00, 0x2BFF), # misc symbols and arrows
|
|
21
|
+
(0x1F000, 0x1F0FF), # mahjong/domino/playing cards (only 🀄 and 🃏 are emoji)
|
|
22
|
+
(0x2600, 0x27BF), # misc symbols + dingbats (Emoji_Presentation only: _is_emoji_char)
|
|
23
|
+
(0x2B00, 0x2BFF), # misc symbols and arrows (likewise)
|
|
24
24
|
(0xFE0F, 0xFE0F), # VS16 (emoji presentation selector)
|
|
25
25
|
(0x1F1E6, 0x1F1FF), # regional indicators (flags)
|
|
26
26
|
(0x20E3, 0x20E3), # combining enclosing keycap
|
|
@@ -52,19 +52,80 @@ _KEYCAP = 0x20E3
|
|
|
52
52
|
_KEYCAP_BASES = frozenset("0123456789#*")
|
|
53
53
|
|
|
54
54
|
|
|
55
|
+
def _cps(*spans) -> "frozenset":
|
|
56
|
+
out = set()
|
|
57
|
+
for span in spans:
|
|
58
|
+
lo, hi = span if isinstance(span, tuple) else (span, span)
|
|
59
|
+
out.update(range(lo, hi + 1))
|
|
60
|
+
return frozenset(out)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# The text-symbol blocks in EMOJI_RANGES (U+2600-27BF, U+2B00-2BFF, U+1F000-1F0FF) are mostly
|
|
64
|
+
# *text*: ♪ U+266A, ✓ U+2713 and ★ U+2605 are not emoji at all in Unicode's emoji-data.txt, and
|
|
65
|
+
# ♥ U+2665, ✔ U+2714, ☺ U+263A are emoji only when VS16 asks for the picture -- their default
|
|
66
|
+
# presentation is the character, which the caption font draws. Counting the whole block made
|
|
67
|
+
# SDH music notes and check marks "emoji", and `--emoji none` (what macOS and a Linux box
|
|
68
|
+
# without a colour face get under auto) cut them out of the burned text. In those blocks only
|
|
69
|
+
# Emoji_Presentation=Yes characters start a cluster on their own; an Emoji=Yes one does when
|
|
70
|
+
# VS16 or a skin-tone modifier follows it. Pinned from emoji-data.txt (Unicode 15.1); the
|
|
71
|
+
# pictograph planes keep their block rule.
|
|
72
|
+
_EMOJI_PRESENTATION_SYMBOLS = _cps(
|
|
73
|
+
(0x2614, 0x2615), (0x2648, 0x2653), 0x267F, 0x2693, 0x26A1, (0x26AA, 0x26AB),
|
|
74
|
+
(0x26BD, 0x26BE), (0x26C4, 0x26C5), 0x26CE, 0x26D4, 0x26EA, (0x26F2, 0x26F3), 0x26F5,
|
|
75
|
+
0x26FA, 0x26FD, 0x2705, (0x270A, 0x270B), 0x2728, 0x274C, 0x274E, (0x2753, 0x2755), 0x2757,
|
|
76
|
+
(0x2795, 0x2797), 0x27B0, 0x27BF, (0x2B1B, 0x2B1C), 0x2B50, 0x2B55, 0x1F004, 0x1F0CF)
|
|
77
|
+
_EMOJI_TEXT_DEFAULT_SYMBOLS = _cps(
|
|
78
|
+
(0x2600, 0x2604), 0x260E, 0x2611, 0x2618, 0x261D, 0x2620, (0x2622, 0x2623), 0x2626, 0x262A,
|
|
79
|
+
(0x262E, 0x262F), (0x2638, 0x263A), 0x2640, 0x2642, (0x265F, 0x2660), 0x2663,
|
|
80
|
+
(0x2665, 0x2666), 0x2668, 0x267B, 0x267E, (0x2692, 0x2697), 0x2699, (0x269B, 0x269C),
|
|
81
|
+
0x26A0, 0x26A7, (0x26B0, 0x26B1), 0x26C8, 0x26CF, 0x26D1, 0x26D3, 0x26E9, (0x26F0, 0x26F1),
|
|
82
|
+
0x26F4, (0x26F7, 0x26F9), 0x2702, (0x2708, 0x2709), (0x270C, 0x270D), 0x270F, 0x2712, 0x2714,
|
|
83
|
+
0x2716, 0x271D, 0x2721, (0x2733, 0x2734), 0x2744, 0x2747, (0x2763, 0x2764), 0x27A1,
|
|
84
|
+
(0x2B05, 0x2B07))
|
|
85
|
+
_SYMBOL_BLOCKS = ((0x2600, 0x27BF), (0x2B00, 0x2BFF), (0x1F000, 0x1F0FF))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _in_symbol_block(cp: int) -> bool:
|
|
89
|
+
return any(lo <= cp <= hi for lo, hi in _SYMBOL_BLOCKS)
|
|
90
|
+
|
|
91
|
+
|
|
55
92
|
def _is_emoji_char(ch: str) -> bool:
|
|
93
|
+
"""Is this character drawn as an emoji with nothing after it? In the text-symbol blocks only
|
|
94
|
+
the Emoji_Presentation characters are; ♪ ✓ ★ ♥ are text (see _EMOJI_PRESENTATION_SYMBOLS)."""
|
|
56
95
|
cp = ord(ch)
|
|
96
|
+
if _in_symbol_block(cp):
|
|
97
|
+
return cp in _EMOJI_PRESENTATION_SYMBOLS
|
|
57
98
|
return any(lo <= cp <= hi for lo, hi in EMOJI_RANGES)
|
|
58
99
|
|
|
59
100
|
|
|
101
|
+
def _can_be_emoji(ch: str) -> bool:
|
|
102
|
+
"""Emoji=Yes: an emoji by default, or a text-default symbol VS16 can turn into one (♥ ✔)."""
|
|
103
|
+
return _is_emoji_char(ch) or ord(ch) in _EMOJI_TEXT_DEFAULT_SYMBOLS
|
|
104
|
+
|
|
105
|
+
|
|
60
106
|
def _is_emoji_base(ch: str) -> bool:
|
|
61
107
|
"""Can this character START an emoji cluster? Pictographs and regional indicators can;
|
|
62
108
|
the joiners and modifiers (ZWJ, VS16, keycap, skin tone) never can -- they only bind to an
|
|
63
109
|
emoji base that already stands before them. Without this, a ZWJ or a VS16 sitting after an
|
|
64
|
-
ordinary letter turned that letter into "an emoji" and the PNG route replaced it with a gap.
|
|
110
|
+
ordinary letter turned that letter into "an emoji" and the PNG route replaced it with a gap.
|
|
111
|
+
A text-default symbol (♥ ✔ ☝) is a base only with the VS16 or modifier that follows it:
|
|
112
|
+
emoji_clusters() checks that, this function answers for the character alone."""
|
|
65
113
|
return ord(ch) not in _EMOJI_TAIL and _is_emoji_char(ch)
|
|
66
114
|
|
|
67
115
|
|
|
116
|
+
def _starts_emoji(text: str, i: int) -> bool:
|
|
117
|
+
"""Does an emoji cluster start at text[i]? A default-emoji base does; a text-default symbol
|
|
118
|
+
does only when VS16 or a skin-tone modifier follows it (♥️, ☝🏽), the sequences Unicode
|
|
119
|
+
defines as emoji; ♥ alone, and ♪ ✓ ★ always, are characters the font draws."""
|
|
120
|
+
ch = text[i]
|
|
121
|
+
if _is_emoji_base(ch):
|
|
122
|
+
return True
|
|
123
|
+
if ord(ch) in _EMOJI_TEXT_DEFAULT_SYMBOLS and i + 1 < len(text):
|
|
124
|
+
nxt = ord(text[i + 1])
|
|
125
|
+
return nxt == _VS16 or 0x1F3FB <= nxt <= 0x1F3FF
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
68
129
|
def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
|
|
69
130
|
"""(index in `text`, cluster) for every emoji in it, ZWJ sequences, VS16, keycaps, flag pairs
|
|
70
131
|
and skin-tone modifiers kept together -- 👩💻 is one cluster, not three, and 1️⃣ starts at the
|
|
@@ -74,7 +135,9 @@ def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
|
|
|
74
135
|
base (`0-9 # *`) that is actually followed by U+20E3. A ZWJ is glue *inside* a cluster, never
|
|
75
136
|
a starter and never a tail on its own: `क्ष` (Hindi ka + virama + ZWJ + ssa) and `abcdef`
|
|
76
137
|
contain no emoji. A base explicitly marked with U+FE0E (VS15, text presentation) is likewise
|
|
77
|
-
not an emoji -- the author asked for the character, not the picture.
|
|
138
|
+
not an emoji -- the author asked for the character, not the picture. A text-default symbol
|
|
139
|
+
(♥ ✔ ☝) starts one only with the VS16 or skin-tone modifier after it, and a symbol that is
|
|
140
|
+
not emoji at all (♪ ✓ ★) never does: the caption font draws those.
|
|
78
141
|
"""
|
|
79
142
|
out: "List[Tuple[int, str]]" = []
|
|
80
143
|
i = 0
|
|
@@ -82,7 +145,7 @@ def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
|
|
|
82
145
|
while i < n:
|
|
83
146
|
ch = text[i]
|
|
84
147
|
start = i
|
|
85
|
-
if
|
|
148
|
+
if _starts_emoji(text, i):
|
|
86
149
|
j = i + 1
|
|
87
150
|
if j < n and ord(text[j]) == _VS15: # text presentation requested: not an emoji
|
|
88
151
|
i = j + 1
|
|
@@ -104,7 +167,9 @@ def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
|
|
|
104
167
|
if cp in (_VS16, _KEYCAP) or 0x1F3FB <= cp <= 0x1F3FF:
|
|
105
168
|
j += 1
|
|
106
169
|
continue
|
|
107
|
-
if cp == _ZWJ and j + 1 < n and
|
|
170
|
+
if cp == _ZWJ and j + 1 < n and ord(text[j + 1]) not in _EMOJI_TAIL \
|
|
171
|
+
and _can_be_emoji(text[j + 1]):
|
|
172
|
+
# inside a ZWJ sequence a text-default symbol is emoji glue too: 🏃♀ ends in ♀
|
|
108
173
|
j += 2
|
|
109
174
|
continue
|
|
110
175
|
if (j == start + 1 and ord(ch) in _EMOJI_REGIONAL and cp in _EMOJI_REGIONAL):
|
|
@@ -345,7 +410,7 @@ def resolve_emoji_assets(flag: "Optional[str]" = None, project: "Optional[str]"
|
|
|
345
410
|
|
|
346
411
|
|
|
347
412
|
|
|
348
|
-
def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", base_chain=""):
|
|
413
|
+
def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", base_chain="", overlay_opts=""):
|
|
349
414
|
"""(chains, inputs) that composite the planned PNGs on top of `base_label`.
|
|
350
415
|
|
|
351
416
|
`inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
|
|
@@ -355,6 +420,8 @@ def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", ba
|
|
|
355
420
|
converted to the BT.709 the output is tagged with instead of by overlay's BT.601 default;
|
|
356
421
|
`base_chain` (label_chain(meta, meta): an untagged source's BT.709 label) goes in front of
|
|
357
422
|
the first overlay, so 7.1+ does not convert the two pictures to one another.
|
|
423
|
+
`overlay_opts` ends each overlay's options: the caller's overlay_format(meta), so a 10-bit
|
|
424
|
+
picture is not composited at overlay's 8-bit default.
|
|
358
425
|
"""
|
|
359
426
|
overlays = plan.get("overlays") or []
|
|
360
427
|
if not overlays:
|
|
@@ -413,6 +480,6 @@ def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", ba
|
|
|
413
480
|
# exactly what shipped first). eof_action=repeat (the default) holds the still for the
|
|
414
481
|
# whole timeline; enable= is what confines it to the cue's window.
|
|
415
482
|
chains.append(f"[{cur}][{label}]overlay=x={x}:y={o['y']}:"
|
|
416
|
-
f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'[{nxt}]")
|
|
483
|
+
f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'{overlay_opts}[{nxt}]")
|
|
417
484
|
cur = nxt
|
|
418
485
|
return chains, inputs
|
package/scripts/_common/probe.py
CHANGED
|
@@ -412,6 +412,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
412
412
|
"creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
|
|
413
413
|
"audio_language": 1 if lang and lang.lower() != "und" else 0,
|
|
414
414
|
"timecode": 1 if out["timecode"].get("value") else 0,
|
|
415
|
+
"timecode_value": out["timecode"].get("value"),
|
|
415
416
|
"audio_channels": (_to_int(audio.get("channels")) or 0) if audio else 0,
|
|
416
417
|
"has_video": bool(video), "has_audio": bool(audio),
|
|
417
418
|
}
|
|
@@ -1281,7 +1282,7 @@ def signatures_match(sigs: list, ext: str) -> bool:
|
|
|
1281
1282
|
return False
|
|
1282
1283
|
else:
|
|
1283
1284
|
codec = str(a.get("codec_name") or "")
|
|
1284
|
-
if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
|
|
1285
|
+
if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext.lower() in TS_EXTS):
|
|
1285
1286
|
return False
|
|
1286
1287
|
return True
|
|
1287
1288
|
|
|
@@ -332,7 +332,7 @@ class Context:
|
|
|
332
332
|
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec", "alpha_line",
|
|
333
333
|
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
|
|
334
334
|
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
|
|
335
|
-
"source_timecode", "smpte_hint", "delivered")
|
|
335
|
+
"source_timecode", "smpte_hint", "delivered", "expected_timecode")
|
|
336
336
|
|
|
337
337
|
def __init__(self) -> None:
|
|
338
338
|
self.reset()
|
|
@@ -343,6 +343,7 @@ class Context:
|
|
|
343
343
|
self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
|
|
344
344
|
self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
|
|
345
345
|
self.source_timecode = None # (start timecode, duration) of the first input probed that carries one (time_arg's SMPTE hint)
|
|
346
|
+
self.expected_timecode = None # the start timecode a tool that moved the picture's start wrote (timeline.output_timecode_args; "" for none): source_carry compares it
|
|
346
347
|
self.smpte_hint = None # what die() says when an hh:mm:ss:ff flag read past the end looks like a source timecode (#411)
|
|
347
348
|
self.delivered: List[str] = [] # deliverables written before a later step (export --normalize, render's chapters/check): a stop then keeps them (#415)
|
|
348
349
|
self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
|
|
@@ -481,6 +482,14 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
481
482
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
482
483
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
483
484
|
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
485
|
+
if STATE.plan and os.environ.get("FFMPEG_SKILL_READ_ONLY", "") not in ("", "0"):
|
|
486
|
+
# mcp/server.py runs the tools it annotates readOnlyHint (probe, check, cropdetect) with
|
|
487
|
+
# this set: a plan is the one file they could write, so it is refused here, after argparse,
|
|
488
|
+
# whatever spelling of --plan a raw argv used. Refused before the exit hook can write it.
|
|
489
|
+
plan_path, STATE.plan = STATE.plan, None
|
|
490
|
+
refuse("input", "read_only_plan_refused",
|
|
491
|
+
f"--plan {plan_path!r}: this tool runs read-only here (FFMPEG_SKILL_READ_ONLY) and writes no file; "
|
|
492
|
+
"run it from the command line to write a plan", plan=plan_path)
|
|
484
493
|
if STATE.plan:
|
|
485
494
|
# the plan is a file this run writes itself: the side-file guards apply (#408), so it
|
|
486
495
|
# never replaces an input, the output or an existing file without --overwrite
|
|
@@ -775,21 +784,69 @@ _URL_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]+:")
|
|
|
775
784
|
_URL_SCHEME_POSIX = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
776
785
|
|
|
777
786
|
|
|
787
|
+
def _is_subfile_url(value: str) -> bool:
|
|
788
|
+
"""ffmpeg's url_find_protocol() also opens `subfile,<options>:<url>` through the subfile
|
|
789
|
+
protocol: the scheme stops at the comma, so the regexes above never see it."""
|
|
790
|
+
return value.startswith("subfile,") and ":" in value[len("subfile,"):]
|
|
791
|
+
|
|
792
|
+
|
|
778
793
|
def looks_like_url(value: str) -> bool:
|
|
779
794
|
"""True for a value ffmpeg would open through a protocol rather than as a local file."""
|
|
795
|
+
if _is_subfile_url(value):
|
|
796
|
+
return True
|
|
780
797
|
scheme = _URL_SCHEME if os.name == "nt" else _URL_SCHEME_POSIX
|
|
781
798
|
return bool(scheme.match(value)) and not value.startswith("pipe:")
|
|
782
799
|
|
|
783
800
|
|
|
784
|
-
|
|
801
|
+
def url_scheme(value: str) -> str:
|
|
802
|
+
"""The protocol name ffmpeg would open `value` through (for a looks_like_url() value)."""
|
|
803
|
+
return "subfile" if _is_subfile_url(value) else value.split(":", 1)[0].lower()
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
_PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat", ".mpd")
|
|
785
807
|
_PLAYLIST_MAGIC = (b"#EXTM3U", b"ffconcat")
|
|
808
|
+
# ffmpeg probes a text format over the whole probe buffer (up to -probesize, 5,000,000 bytes), not
|
|
809
|
+
# over a fixed prefix, and reads that buffer as a C string, so the scan below stops at the first
|
|
810
|
+
# NUL byte: a real media file has one within its first few bytes, a manifest has none.
|
|
811
|
+
_MANIFEST_SCAN_LIMIT = 5_000_000
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _manifest_kind(path: str) -> Optional[str]:
|
|
815
|
+
""""DASH" or "IMF" when ffmpeg's own probe would read `path` as that manifest, whatever its
|
|
816
|
+
name: dash_probe() looks for `<MPD` and `dash:profile` or `3GPP:PSS:profile:DASH1`
|
|
817
|
+
(case-insensitively), imf_probe() for
|
|
818
|
+
`<CompositionPlaylist` and `ContentTitle>`. Either one names other files, local or remote, that
|
|
819
|
+
ffmpeg then opens (a DASH BaseURL is followed over HTTP even under -protocol_whitelist file)."""
|
|
820
|
+
buf = bytearray()
|
|
821
|
+
try:
|
|
822
|
+
with open(path, "rb") as fh:
|
|
823
|
+
while len(buf) < _MANIFEST_SCAN_LIMIT:
|
|
824
|
+
chunk = fh.read(65536)
|
|
825
|
+
if not chunk:
|
|
826
|
+
break
|
|
827
|
+
nul = chunk.find(b"\0")
|
|
828
|
+
if nul >= 0:
|
|
829
|
+
buf += chunk[:nul]
|
|
830
|
+
break
|
|
831
|
+
buf += chunk
|
|
832
|
+
except OSError:
|
|
833
|
+
return None
|
|
834
|
+
text = bytes(buf[:_MANIFEST_SCAN_LIMIT])
|
|
835
|
+
low = text.lower()
|
|
836
|
+
if b"<mpd" in low and (b"dash:profile" in low or b"3gpp:pss:profile:dash1" in low):
|
|
837
|
+
return "DASH"
|
|
838
|
+
if b"<CompositionPlaylist" in text and b"ContentTitle>" in text:
|
|
839
|
+
return "IMF"
|
|
840
|
+
return None
|
|
786
841
|
|
|
787
842
|
|
|
788
843
|
def _refuse_playlist(value: str) -> None:
|
|
789
|
-
"""Refuse an input that ffmpeg would read as a playlist (HLS .m3u8,
|
|
790
|
-
than as media. A playlist names other files by
|
|
791
|
-
|
|
792
|
-
|
|
844
|
+
"""Refuse an input that ffmpeg would read as a playlist (HLS .m3u8, a DASH .mpd, an IMF
|
|
845
|
+
composition playlist, an ffconcat list) rather than as media. A playlist names other files by
|
|
846
|
+
path or URL, so a media file handed to the skill could pull in any file the user can read, or
|
|
847
|
+
make ffmpeg fetch a URL. HLS and ffconcat are told by name or by their first bytes; DASH and IMF
|
|
848
|
+
by name or by the content ffmpeg's own probe looks for, anywhere before the first NUL byte. The
|
|
849
|
+
skill's own concat lists pass `-f concat` and are not checked here."""
|
|
793
850
|
if os.path.exists(value) and not os.path.isfile(value) and not os.path.isdir(value):
|
|
794
851
|
# a FIFO or a device: ffmpeg would block on it or read whatever it streams
|
|
795
852
|
refuse("input", "media_path_not_a_file",
|
|
@@ -801,9 +858,14 @@ def _refuse_playlist(value: str) -> None:
|
|
|
801
858
|
head = fh.read(16).lstrip(b"\xef\xbb\xbf \t\r\n")
|
|
802
859
|
except OSError:
|
|
803
860
|
pass
|
|
861
|
+
kind = None
|
|
804
862
|
if value.lower().endswith(_PLAYLIST_SUFFIXES) or head.startswith(_PLAYLIST_MAGIC):
|
|
863
|
+
kind = "HLS, DASH or ffconcat"
|
|
864
|
+
elif os.path.isfile(value):
|
|
865
|
+
kind = _manifest_kind(value)
|
|
866
|
+
if kind:
|
|
805
867
|
refuse("input", "media_path_is_playlist",
|
|
806
|
-
f"{value!r} is a playlist (
|
|
868
|
+
f"{value!r} is a playlist ({kind}), which ffmpeg would follow to the files or URLs it names; "
|
|
807
869
|
"this skill takes media files only. Pass the media file itself", path=value)
|
|
808
870
|
|
|
809
871
|
|
|
@@ -836,9 +898,9 @@ def _refuse_url_media(cmd: Sequence[str]) -> None:
|
|
|
836
898
|
for value in values:
|
|
837
899
|
if looks_like_url(value):
|
|
838
900
|
refuse("input", "media_path_is_url",
|
|
839
|
-
f"{value!r} would be opened by ffmpeg as a URL ({value
|
|
901
|
+
f"{value!r} would be opened by ffmpeg as a URL ({url_scheme(value)}:), not as a local file; "
|
|
840
902
|
"this skill reads and writes local files only. Pass a plain path (a local name containing "
|
|
841
|
-
"a colon works as ./name)", path=value, scheme=value
|
|
903
|
+
"a colon works as ./name)", path=value, scheme=url_scheme(value))
|
|
842
904
|
|
|
843
905
|
|
|
844
906
|
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
@@ -1176,7 +1238,13 @@ def refuse_existing_outputs(paths: Sequence[str]) -> None:
|
|
|
1176
1238
|
if output not in existing:
|
|
1177
1239
|
existing.append(output)
|
|
1178
1240
|
facts.append({"path": output, "resolved": real, "size": size, "mtime_ns": mtime_ns})
|
|
1241
|
+
# a file batch.py delivered on an earlier --watch pass, still exactly as it left it, is that
|
|
1242
|
+
# run's own: replaced without --overwrite, per path (anything else existing is still refused)
|
|
1243
|
+
if existing and not STATE.overwrite:
|
|
1244
|
+
keep = [i for i, p in enumerate(existing) if not owned_replaceable(p)]
|
|
1245
|
+
existing, facts = [existing[i] for i in keep], [facts[i] for i in keep]
|
|
1179
1246
|
if not existing or STATE.overwrite:
|
|
1247
|
+
report_owned_targets(paths)
|
|
1180
1248
|
return
|
|
1181
1249
|
hint = "pass --overwrite, or choose another -o path"
|
|
1182
1250
|
if len(existing) == 1:
|
|
@@ -1195,6 +1263,111 @@ def _remember_output(cmd: Sequence[str]) -> None:
|
|
|
1195
1263
|
STATE.written.add(os.path.realpath(output))
|
|
1196
1264
|
except OSError:
|
|
1197
1265
|
pass
|
|
1266
|
+
report_committed(output)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
# set only by batch.py for the steps it runs: the path of a JSON file {"replaceable": {real path:
|
|
1270
|
+
# owned_identity()}, "report": directory}. "replaceable" is every file that batch process
|
|
1271
|
+
# delivered (a --watch loop's earlier passes) as it left it; a step replaces one of those without
|
|
1272
|
+
# --overwrite while it is still that file, and nothing else. Each process that passes the output
|
|
1273
|
+
# check writes <report>/<pid>.json: {"targets": the paths it was allowed to write, "committed":
|
|
1274
|
+
# {real path: owned_identity()} taken when each of them was written (report_committed), so batch
|
|
1275
|
+
# learns the side files (an .srt beside the video) it delivered as they were when written, not as
|
|
1276
|
+
# they are when the item ends -- an edit made while a later step ran is not adopted.
|
|
1277
|
+
OWNED_ENV = "_FFMPEG_SKILL_OWNED"
|
|
1278
|
+
_OWNED_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
1279
|
+
_OWNED_TARGETS: Dict[str, List[str]] = {}
|
|
1280
|
+
_OWNED_COMMITTED: Dict[str, Dict[str, Dict[str, int]]] = {}
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def owned_identity(path: str) -> "Optional[Dict[str, int]]":
|
|
1284
|
+
"""{size, mtime_ns, ino, dev} of a regular file: what tells the file batch.py delivered from
|
|
1285
|
+
one put at the same path since (a replacement with the same size and mtime is a new inode).
|
|
1286
|
+
None when the path is not a regular file."""
|
|
1287
|
+
try:
|
|
1288
|
+
st = os.stat(path)
|
|
1289
|
+
except OSError:
|
|
1290
|
+
return None
|
|
1291
|
+
if not os.path.isfile(path):
|
|
1292
|
+
return None
|
|
1293
|
+
return {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "ino": st.st_ino, "dev": st.st_dev}
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _owned_config(config: Optional[str] = None) -> Dict[str, Any]:
|
|
1297
|
+
config = config if config is not None else os.environ.get(OWNED_ENV, "")
|
|
1298
|
+
if not config:
|
|
1299
|
+
return {}
|
|
1300
|
+
if config not in _OWNED_CACHE:
|
|
1301
|
+
try:
|
|
1302
|
+
with open(config, encoding="utf-8") as fh:
|
|
1303
|
+
doc = json.load(fh)
|
|
1304
|
+
except (OSError, ValueError):
|
|
1305
|
+
doc = {}
|
|
1306
|
+
_OWNED_CACHE[config] = doc if isinstance(doc, dict) else {}
|
|
1307
|
+
return _OWNED_CACHE[config]
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def owned_replaceable(path: str, config: Optional[str] = None) -> bool:
|
|
1311
|
+
"""Whether `path` is a file the batch.py process running this step delivered, still with the
|
|
1312
|
+
identity it had then (checked now, at the output check, not when the step was planned)."""
|
|
1313
|
+
replaceable = _owned_config(config).get("replaceable")
|
|
1314
|
+
if not isinstance(replaceable, dict):
|
|
1315
|
+
return False
|
|
1316
|
+
try:
|
|
1317
|
+
real = os.path.realpath(path)
|
|
1318
|
+
except OSError:
|
|
1319
|
+
return False
|
|
1320
|
+
expected = replaceable.get(real)
|
|
1321
|
+
return isinstance(expected, dict) and owned_identity(real) == expected
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def report_owned_targets(paths: Sequence[Optional[str]], config: Optional[str] = None) -> None:
|
|
1325
|
+
"""Under batch.py: record paths this process passed the output check for, in the report
|
|
1326
|
+
directory its config names. Nothing outside batch.py; a report that cannot be written only
|
|
1327
|
+
means the next pass refuses that file as someone else's."""
|
|
1328
|
+
config = config if config is not None else os.environ.get(OWNED_ENV, "")
|
|
1329
|
+
report = _owned_config(config).get("report")
|
|
1330
|
+
if not isinstance(report, str) or not report:
|
|
1331
|
+
return
|
|
1332
|
+
targets = _OWNED_TARGETS.setdefault(config, [])
|
|
1333
|
+
for p in paths:
|
|
1334
|
+
if p:
|
|
1335
|
+
try:
|
|
1336
|
+
real = os.path.realpath(p)
|
|
1337
|
+
except OSError:
|
|
1338
|
+
continue
|
|
1339
|
+
if real not in targets:
|
|
1340
|
+
targets.append(real)
|
|
1341
|
+
_write_owned_report(config, report)
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _write_owned_report(config: str, report: str) -> None:
|
|
1345
|
+
doc = {"targets": _OWNED_TARGETS.get(config, []), "committed": _OWNED_COMMITTED.get(config, {})}
|
|
1346
|
+
try:
|
|
1347
|
+
with open(os.path.join(report, f"{os.getpid()}.json"), "w", encoding="utf-8") as fh:
|
|
1348
|
+
json.dump(doc, fh)
|
|
1349
|
+
except OSError:
|
|
1350
|
+
pass
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def report_committed(path: Optional[str], config: Optional[str] = None) -> None:
|
|
1354
|
+
"""Under batch.py: record the identity `path` has right now, just after this process wrote it,
|
|
1355
|
+
if it passed this process's output check. What batch later lets a step replace is that file
|
|
1356
|
+
as it was written; one edited afterwards no longer matches and is refused."""
|
|
1357
|
+
config = config if config is not None else os.environ.get(OWNED_ENV, "")
|
|
1358
|
+
report = _owned_config(config).get("report")
|
|
1359
|
+
if not path or not isinstance(report, str) or not report:
|
|
1360
|
+
return
|
|
1361
|
+
try:
|
|
1362
|
+
real = os.path.realpath(path)
|
|
1363
|
+
except OSError:
|
|
1364
|
+
return
|
|
1365
|
+
if real not in _OWNED_TARGETS.get(config, []):
|
|
1366
|
+
return
|
|
1367
|
+
ident = owned_identity(real)
|
|
1368
|
+
if ident:
|
|
1369
|
+
_OWNED_COMMITTED.setdefault(config, {})[real] = ident
|
|
1370
|
+
_write_owned_report(config, report)
|
|
1198
1371
|
|
|
1199
1372
|
|
|
1200
1373
|
def remember_written(*paths: Optional[str]) -> None:
|
|
@@ -1207,6 +1380,7 @@ def remember_written(*paths: Optional[str]) -> None:
|
|
|
1207
1380
|
STATE.written.add(os.path.realpath(path))
|
|
1208
1381
|
except OSError:
|
|
1209
1382
|
pass
|
|
1383
|
+
report_committed(path)
|
|
1210
1384
|
|
|
1211
1385
|
|
|
1212
1386
|
def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
|
|
@@ -1243,10 +1417,73 @@ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str]
|
|
|
1243
1417
|
return list(cmd), None, None
|
|
1244
1418
|
d, base = os.path.split(output)
|
|
1245
1419
|
stem, ext = os.path.splitext(base)
|
|
1246
|
-
tmp =
|
|
1420
|
+
tmp = reserve_sibling(d, stem, ext)
|
|
1247
1421
|
return list(cmd[:-1]) + [tmp], output, tmp
|
|
1248
1422
|
|
|
1249
1423
|
|
|
1424
|
+
def _reserve(d: str, stem: str, ext: str, tag: str) -> "Tuple[str, Optional[int]]":
|
|
1425
|
+
"""(path, open descriptor) of a hidden sibling created exclusively; (path, None) when the
|
|
1426
|
+
directory refuses the create (the write that follows then fails there)."""
|
|
1427
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0)
|
|
1428
|
+
n = 1
|
|
1429
|
+
while True:
|
|
1430
|
+
path = os.path.join(d, f".{stem}.ffskill-" + (f"{tag}-" if tag else "") + f"{os.getpid()}"
|
|
1431
|
+
+ (f"-{n}" if n > 1 else "") + ext)
|
|
1432
|
+
try:
|
|
1433
|
+
return path, os.open(path, flags, 0o666)
|
|
1434
|
+
except FileExistsError:
|
|
1435
|
+
n += 1
|
|
1436
|
+
except OSError:
|
|
1437
|
+
return path, None
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def reserve_sibling(d: str, stem: str, ext: str, tag: str = "") -> str:
|
|
1441
|
+
"""Create the hidden sibling `.<stem>.ffskill-<pid><ext>` (or `...-<pid>-<n><ext>` when that
|
|
1442
|
+
name is taken) exclusively, and return its path, for ffmpeg to write with -y. O_EXCL never
|
|
1443
|
+
opens an existing entry, a symlink included, so a name planted in the output directory
|
|
1444
|
+
beforehand is never written through. What it cannot stop is someone who may delete files in
|
|
1445
|
+
that directory replacing this one between the create and ffmpeg's open: not possible in a
|
|
1446
|
+
sticky shared directory (/tmp), nor in one only the caller can write. A copy this process makes
|
|
1447
|
+
itself goes through copy_to_sibling(), which writes the descriptor it created."""
|
|
1448
|
+
path, fd = _reserve(d, stem, ext, tag)
|
|
1449
|
+
if fd is None:
|
|
1450
|
+
# fail closed: a name that was not created exclusively is never handed to ffmpeg
|
|
1451
|
+
refuse("input", "output_dir_not_writable", f"cannot create a temporary file in {d or '.'!r}",
|
|
1452
|
+
output=os.path.join(d, stem + ext), directory=d or ".")
|
|
1453
|
+
os.close(fd)
|
|
1454
|
+
return path
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def copy_to_sibling(src: str, dst: str) -> str:
|
|
1458
|
+
"""Copy `src` into a hidden sibling of `dst` created exclusively, through the descriptor that
|
|
1459
|
+
created it (never by reopening the name), and return the sibling's path for os.replace()."""
|
|
1460
|
+
import shutil
|
|
1461
|
+
d, base = os.path.split(dst)
|
|
1462
|
+
stem, ext = os.path.splitext(base)
|
|
1463
|
+
path, fd = _reserve(d, stem, ext, "")
|
|
1464
|
+
if fd is None:
|
|
1465
|
+
raise OSError(f"cannot create a temporary file beside {dst}")
|
|
1466
|
+
try:
|
|
1467
|
+
fout = os.fdopen(fd, "wb")
|
|
1468
|
+
except BaseException:
|
|
1469
|
+
os.close(fd)
|
|
1470
|
+
try:
|
|
1471
|
+
os.remove(path)
|
|
1472
|
+
except OSError:
|
|
1473
|
+
pass
|
|
1474
|
+
raise
|
|
1475
|
+
try:
|
|
1476
|
+
with fout, open(src, "rb") as fin:
|
|
1477
|
+
shutil.copyfileobj(fin, fout, 1 << 20)
|
|
1478
|
+
except BaseException:
|
|
1479
|
+
try:
|
|
1480
|
+
os.remove(path)
|
|
1481
|
+
except OSError:
|
|
1482
|
+
pass
|
|
1483
|
+
raise
|
|
1484
|
+
return path
|
|
1485
|
+
|
|
1486
|
+
|
|
1250
1487
|
HW_ENV = "FFMPEG_SKILL_HW"
|
|
1251
1488
|
# set only by render.py/batch.py --hw/--no-hw for the stages they run: "the outer command chose
|
|
1252
1489
|
# explicitly", so a child resolves $FFMPEG_SKILL_HW as a flag (export.py included), not a default
|
|
@@ -1533,13 +1770,14 @@ def child_limit(per_call: Optional[float] = None) -> Optional[float]:
|
|
|
1533
1770
|
return (limit * 4 + 60) if limit else None
|
|
1534
1771
|
|
|
1535
1772
|
|
|
1536
|
-
def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None
|
|
1773
|
+
def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None,
|
|
1774
|
+
env: Optional[Dict[str, str]] = None) -> subprocess.CompletedProcess:
|
|
1537
1775
|
"""Run a sibling script (`argv[0]` is the script path) under child_limit(). On overrun the
|
|
1538
1776
|
child is killed and a CompletedProcess is returned whose stdout is this skill's own failure
|
|
1539
1777
|
document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
|
|
1540
|
-
exactly as they would from the child itself."""
|
|
1778
|
+
exactly as they would from the child itself. `env` replaces the inherited environment."""
|
|
1541
1779
|
limit = child_limit(per_call)
|
|
1542
|
-
child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
|
|
1780
|
+
child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", env=env)
|
|
1543
1781
|
_watch(child, []) # a sibling script removes its own partial output; there is none of ours to clean
|
|
1544
1782
|
try:
|
|
1545
1783
|
out, err = child.communicate(timeout=limit)
|
|
@@ -1558,6 +1796,20 @@ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subpro
|
|
|
1558
1796
|
return subprocess.CompletedProcess(list(argv), 124, json.dumps(doc), partial + f"\nerror: {msg}\n")
|
|
1559
1797
|
|
|
1560
1798
|
|
|
1799
|
+
def option_args(flag: str, value: Any) -> List[str]:
|
|
1800
|
+
"""One option and its value for a sibling tool's argparse, as [flag, value] -- or as the single
|
|
1801
|
+
token `flag=value` when the value starts with '-'. argparse reads a separate token that starts
|
|
1802
|
+
with '-' (and is not a negative number) as the next option, so overlay text "-50%" was refused
|
|
1803
|
+
as "expected one argument" and a value like "--plan=x" became an option of its own. The joined
|
|
1804
|
+
form is read as the value whatever it holds. `flag` must be a long option: `-o=x` is not."""
|
|
1805
|
+
text = str(value)
|
|
1806
|
+
if text.startswith("-"):
|
|
1807
|
+
if not flag.startswith("--"):
|
|
1808
|
+
raise ValueError(f"option_args: {flag!r} is not a long option")
|
|
1809
|
+
return [f"{flag}={text}"]
|
|
1810
|
+
return [flag, text]
|
|
1811
|
+
|
|
1812
|
+
|
|
1561
1813
|
def child_args() -> List[str]:
|
|
1562
1814
|
"""The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
|
|
1563
1815
|
so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
|
|
@@ -1633,7 +1885,7 @@ def subtitle_container_holds(codec: str, out_ext: str) -> bool:
|
|
|
1633
1885
|
return True
|
|
1634
1886
|
|
|
1635
1887
|
|
|
1636
|
-
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
1888
|
+
def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool = False) -> bool:
|
|
1637
1889
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
1638
1890
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
1639
1891
|
there are none). A source whose subtitle codec cannot be copied into the target container
|
|
@@ -1645,8 +1897,13 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
|
1645
1897
|
The retry is recorded with the first attempt's error (`diagnostics.fallbacks`,
|
|
1646
1898
|
`non_av_streams_dropped`): the caller's `dropped_non_av_streams` says that streams went, the
|
|
1647
1899
|
record says why, which is the only way to tell a container that cannot hold them from a first
|
|
1648
|
-
attempt that failed for another reason and happened to succeed the second time.
|
|
1649
|
-
|
|
1900
|
+
attempt that failed for another reason and happened to succeed the second time.
|
|
1901
|
+
|
|
1902
|
+
`relabel_timecode`: the command writes its own start timecode (timeline.output_timecode_args),
|
|
1903
|
+
so a source's tmcd data track is not copied -- its label, at the source's rate, would win over
|
|
1904
|
+
the new one; the MOV/MP4 muxer builds the track again from the label."""
|
|
1905
|
+
skip_tmcd = ["-map", "-0:d:m:timecode"] if relabel_timecode else []
|
|
1906
|
+
first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + ["-c:s", "copy", "-c:d", "copy", output], check=False)
|
|
1650
1907
|
if first.returncode == 0:
|
|
1651
1908
|
return False
|
|
1652
1909
|
if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
|
|
@@ -1846,7 +2103,6 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
1846
2103
|
_check_existing_output(cmd)
|
|
1847
2104
|
d, base = os.path.split(dst)
|
|
1848
2105
|
stem, ext = os.path.splitext(base)
|
|
1849
|
-
tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
|
|
1850
2106
|
with _OutputLock(dst):
|
|
1851
2107
|
if move:
|
|
1852
2108
|
try:
|
|
@@ -1855,14 +2111,16 @@ def place_output(src: str, dst: str, move: bool = False) -> None:
|
|
|
1855
2111
|
return
|
|
1856
2112
|
except OSError:
|
|
1857
2113
|
pass
|
|
2114
|
+
tmp = None
|
|
1858
2115
|
try:
|
|
1859
|
-
|
|
2116
|
+
tmp = copy_to_sibling(src, dst)
|
|
1860
2117
|
os.replace(tmp, dst)
|
|
1861
2118
|
except OSError as e:
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
2119
|
+
if tmp:
|
|
2120
|
+
try:
|
|
2121
|
+
os.remove(tmp)
|
|
2122
|
+
except OSError:
|
|
2123
|
+
pass
|
|
1866
2124
|
die(f"could not place {dst}: {e}", kind="output")
|
|
1867
2125
|
_remember_output(cmd)
|
|
1868
2126
|
|
|
@@ -153,6 +153,45 @@ def _tc_label(frames: int, rate: Fraction, drop: bool) -> str:
|
|
|
153
153
|
return _tc(frames, nominal)[:-3] + ";" + _tc(frames, nominal)[-2:]
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
def output_timecode_args(meta: Dict[str, Any], first_frame: Optional[float], rate: Optional[float] = None) -> List[str]:
|
|
157
|
+
"""The arguments that give an output the start timecode of the source frame it starts on:
|
|
158
|
+
`first_frame` seconds after the source picture's first frame (negative: a head the tool
|
|
159
|
+
added), or None to write no start timecode at all (a join, whose later frames no single start
|
|
160
|
+
label describes). A tool that moves the start of the picture used to write the source's own
|
|
161
|
+
start timecode unchanged: FFmpeg copies the video stream's `timecode` tag, and the MOV/MP4
|
|
162
|
+
muxer rebuilds a tmcd track from it, so a cut from 1 s was stamped 01:00:00:00 for frame 30.
|
|
163
|
+
Both the stream and the container tag are set (Matroska keeps a stale stream tag otherwise;
|
|
164
|
+
MXF reads the container's). Nothing for a source without a start timecode. The label also
|
|
165
|
+
goes on STATE.expected_timecode, which source_carry compares the written file with: an
|
|
166
|
+
MXF, which always writes one, reads 00:00:00:00 when none is wanted and is listed as dropped.
|
|
167
|
+
`rate` is the frame rate the output is delivered at when the tool changes it (fit --fps, a
|
|
168
|
+
VFR source conformed to a constant rate), None for the source's own: the source's label is
|
|
169
|
+
read at the source's rate and the output's is counted at the output's, so 01:00:00:12 at 25
|
|
170
|
+
fps (0.48 s past the hour) is 01:00:00:24 at 50."""
|
|
171
|
+
from _common.runner import STATE
|
|
172
|
+
tc = (meta or {}).get("timecode") or {}
|
|
173
|
+
fps = ((meta or {}).get("video") or {}).get("fps")
|
|
174
|
+
if not tc.get("value") or not fps:
|
|
175
|
+
return []
|
|
176
|
+
label = ""
|
|
177
|
+
if first_frame is not None:
|
|
178
|
+
src_rate = exact_rate(float(fps))
|
|
179
|
+
out_rate = exact_rate(float(rate)) if rate else src_rate
|
|
180
|
+
drop = _drop_frame(tc.get("drop_frame"), out_rate)
|
|
181
|
+
nominal = round(float(out_rate))
|
|
182
|
+
if nominal >= 1:
|
|
183
|
+
day = (nominal * 600 - (nominal // 15) * 9) * 144 if drop else nominal * 86400
|
|
184
|
+
# the source label and the offset to the first frame, both in output frames, rounded
|
|
185
|
+
# once: 00:00:00:02 at 25 fps plus 0.08 s is 4.8 frames at 30, so frame 5, not 2 + 2
|
|
186
|
+
pos = (Fraction(start_tc_frames(tc, src_rate)) / src_rate
|
|
187
|
+
+ Fraction(first_frame).limit_denominator(1000000)) * out_rate
|
|
188
|
+
label = _tc_label(round(pos) % day, out_rate, drop)
|
|
189
|
+
# below 1 fps no frame count can name a frame: the label is removed, as when the first
|
|
190
|
+
# frame is unknown, rather than divide by a zero-length day
|
|
191
|
+
STATE.expected_timecode = label
|
|
192
|
+
return ["-metadata:s:v:0", f"timecode={label}", "-metadata", f"timecode={label}"]
|
|
193
|
+
|
|
194
|
+
|
|
156
195
|
def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
|
|
157
196
|
"""The editor-neutral timeline: every time in frames at `rate`.
|
|
158
197
|
|