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.
@@ -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 `abc‍def`
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 _is_emoji_base(ch):
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 _is_emoji_base(text[j + 1]):
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
@@ -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
- _PLAYLIST_SUFFIXES = (".m3u8", ".m3u", ".ffconcat")
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, an ffconcat list) rather
790
- than as media. A playlist names other files by path, so a media file handed to the skill could
791
- pull in any file the user can read. The skill's own concat lists pass `-f concat` and are not
792
- checked here."""
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 (HLS or ffconcat), which ffmpeg would follow to the files it names; "
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.split(':', 1)[0]}:), not as a local file; "
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.split(":", 1)[0].lower())
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 = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
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) -> subprocess.CompletedProcess:
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
- first = run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False)
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
- shutil.copyfile(src, tmp)
2116
+ tmp = copy_to_sibling(src, dst)
1860
2117
  os.replace(tmp, dst)
1861
2118
  except OSError as e:
1862
- try:
1863
- os.remove(tmp)
1864
- except OSError:
1865
- pass
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