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/scripts/batch.py CHANGED
@@ -33,8 +33,10 @@ from pathlib import Path
33
33
  from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, MEDIA_EXT as _MEDIA_EXT
36
- from _common.runner import HW_FORCED_ENV, _OutputLock, add_hw_orchestrator_args, env_hw, stop_children
36
+ from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw,
37
+ owned_identity, stop_children)
37
38
  from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
39
+ from _common.decision import rebase_project_paths
38
40
 
39
41
  HERE = Path(__file__).resolve().parent
40
42
  MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
@@ -117,6 +119,16 @@ def gpu_key() -> str:
117
119
  JOBS_CAP = 8 # beyond this, concurrent encodes contend for the same cores and memory
118
120
 
119
121
  _LOG = threading.local()
122
+ # source -> {real path -> owned_identity()} of every file this process delivered for that item (a
123
+ # --watch loop's earlier passes): its final output and every side file its steps were allowed to
124
+ # write (an .srt beside the video). An existing path that is still exactly that file is the item's
125
+ # own, not someone else's, so a later pass of the SAME item replaces it (design-decisions: "A path
126
+ # this same run wrote ... is never someone else's file"); the permission is per item and per path,
127
+ # and is checked again where the write happens -- by deliver() under the output lock, by a step at
128
+ # its own output check (runner.OWNED_ENV). A file edited or replaced since, one an earlier run
129
+ # left, or one another item delivered (two items naming one side file) is still refused.
130
+ _OWN_OUTPUTS: Dict[str, Dict[str, Dict[str, int]]] = {}
131
+ _OWN_LOCK = threading.Lock()
120
132
  # set by batch.py's signal handler: a --jobs worker between two steps must not start the next one
121
133
  _STOP = threading.Event()
122
134
 
@@ -155,13 +167,15 @@ def step_error(index: Optional[int], tool: Optional[str], doc: Any) -> Dict[str,
155
167
  return out
156
168
 
157
169
 
158
- def run_step(argv: List[str], per_call: "Optional[float]" = None) -> "tuple[bool, Optional[dict]]":
170
+ def run_step(argv: List[str], per_call: "Optional[float]" = None,
171
+ owned: "Optional[Path]" = None) -> "tuple[bool, Optional[dict]]":
159
172
  """Returns (ok, doc): doc is the step's own --json result document; a failed step that
160
173
  printed none that parses gets one in the failure shape (error.reason step_output_unparsed,
161
174
  the last stderr line as the message), so its row still says why. {in}/{out} are already resolved by the caller before argv reaches here, so forcing
162
175
  --json on every step (harmless: no step reads another step's stdout, only its output path)
163
176
  costs nothing and lets a caller like process() read back tool-specific fields, e.g. cut.py's
164
- `reencoded`, without adding a bespoke flag per field it might someday want."""
177
+ `reencoded`, without adding a bespoke flag per field it might someday want. `owned`: the
178
+ owned_config() file the step reads under runner.OWNED_ENV."""
165
179
  script = argv[0]
166
180
  if script not in ALLOWED_STEP_SCRIPTS:
167
181
  # main() refuses such a recipe before any item runs; this stays as the last line of defence
@@ -173,7 +187,8 @@ def run_step(argv: List[str], per_call: "Optional[float]" = None) -> "tuple[bool
173
187
  "error": {"kind": "interrupted", "message": f"interrupted before {script} started"}}
174
188
  cmd = [str(HERE / script)] + argv[1:] + child_args() + (["--json"] if "--json" not in argv[1:] else [])
175
189
  log(" → " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd)))
176
- proc = run_tool(cmd, per_call=per_call)
190
+ env = dict(os.environ, **{OWNED_ENV: str(owned)}) if owned else None
191
+ proc = run_tool(cmd, per_call=per_call, env=env)
177
192
  try:
178
193
  doc = json.loads(proc.stdout)
179
194
  except ValueError:
@@ -240,9 +255,69 @@ def row_output(src: Path, recipe: Dict[str, Any], outdir: Path) -> Dict[str, Any
240
255
  return fields
241
256
 
242
257
 
243
- def deliver(src: str, final: Path) -> None:
258
+ def own_expected(src: Path, path: "Path | str") -> "Optional[Dict[str, int]]":
259
+ """The identity this process left `path` with, if it delivered a file there for `src`."""
260
+ with _OWN_LOCK:
261
+ return _OWN_OUTPUTS.get(str(src), {}).get(os.path.realpath(str(path)))
262
+
263
+
264
+ def is_own_output(src: Path, final: Path) -> bool:
265
+ """Whether `final` is still the file this process delivered there for `src` (_OWN_OUTPUTS)."""
266
+ expected = own_expected(src, final)
267
+ return expected is not None and owned_identity(str(final)) == expected
268
+
269
+
270
+ def record_own(src: Path, committed: "Dict[str, Dict[str, int]]", work: Path) -> None:
271
+ """Remember the files this item delivered, each with the identity it had when it was written
272
+ (a step's report_committed(), deliver() for the final path), never as it is now: a sidecar
273
+ edited while a later step ran no longer matches, so the item's next pass refuses it. Paths in
274
+ the item's work directory go with it and are skipped. Replaces what an earlier pass of the item
275
+ recorded."""
276
+ base = os.path.realpath(str(work))
277
+ mine: Dict[str, Dict[str, int]] = {}
278
+ for p, ident in committed.items():
279
+ real = os.path.realpath(p)
280
+ if real == base or real.startswith(base + os.sep) or not isinstance(ident, dict):
281
+ continue
282
+ mine[real] = ident
283
+ with _OWN_LOCK:
284
+ _OWN_OUTPUTS[str(src)] = mine
285
+
286
+
287
+ def owned_config(src: Path, work: Path, i: int) -> Path:
288
+ """The file a step reads under runner.OWNED_ENV: a snapshot of what this process delivered for
289
+ `src`, taken right before the step starts (one file per step, since a step reads it once), and
290
+ the directory where each of its processes reports the paths it passed the output check for."""
291
+ report = work / "owned-report"
292
+ report.mkdir(exist_ok=True)
293
+ with _OWN_LOCK:
294
+ replaceable = dict(_OWN_OUTPUTS.get(str(src), {}))
295
+ cfg = work / f"owned{i}.json"
296
+ cfg.write_text(json.dumps({"replaceable": replaceable, "report": str(report)}), encoding="utf-8")
297
+ return cfg
298
+
299
+
300
+ def owned_reports(work: Path) -> "Dict[str, Dict[str, int]]":
301
+ """{path: identity when written} for every file the item's steps reported committing
302
+ (owned_config's report directory). A path a step was allowed to write but never reported
303
+ writing is not in it."""
304
+ found: Dict[str, Dict[str, int]] = {}
305
+ for f in sorted((work / "owned-report").glob("*.json")) if (work / "owned-report").is_dir() else []:
306
+ try:
307
+ doc = json.loads(f.read_text(encoding="utf-8"))
308
+ except (OSError, ValueError):
309
+ continue
310
+ committed = doc.get("committed") if isinstance(doc, dict) else None
311
+ if isinstance(committed, dict):
312
+ found.update({p: i for p, i in committed.items() if isinstance(p, str) and isinstance(i, dict)})
313
+ return found
314
+
315
+
316
+ def deliver(src: str, final: Path, expected: "Optional[Dict[str, int]]" = None) -> "Optional[Dict[str, int]]":
244
317
  """Move an item's last written intermediate to its final path, under the output guard every
245
- tool applies: an existing file is replaced only with --overwrite. A dry run writes nothing."""
318
+ tool applies: an existing file is replaced only with --overwrite, or when it is still exactly
319
+ `expected` -- the file this process delivered there on an earlier pass -- checked under the
320
+ output lock, so a file another process put there since is refused. A dry run writes nothing."""
246
321
  if STATE.dry_run:
247
322
  # the refusal the real run would meet, as a step writing {out} plans it (#428 review)
248
323
  if final.exists() and not STATE.overwrite:
@@ -257,7 +332,7 @@ def deliver(src: str, final: Path) -> None:
257
332
  # under the output lock every tool's write holds, so the existence check and the move are one
258
333
  # step for another run delivering or writing the same path
259
334
  with _OutputLock(str(final), on_locked=locked):
260
- if final.exists() and not STATE.overwrite:
335
+ if final.exists() and not STATE.overwrite and not (expected is not None and owned_identity(str(final)) == expected):
261
336
  raise ItemError({"step": None, "tool": None, "kind": "input", "reason": "output_exists",
262
337
  "message": f"refusing to overwrite existing output {str(final)!r}: pass --overwrite to replace it"})
263
338
  try:
@@ -265,25 +340,39 @@ def deliver(src: str, final: Path) -> None:
265
340
  except OSError:
266
341
  # another filesystem: copy beside the final path and rename, so a copy that fails part
267
342
  # way never leaves a truncated file where the caller's was
268
- import shutil
269
- tmp = final.with_name(f".{final.stem}.ffskill-{os.getpid()}-{threading.get_ident()}{final.suffix}")
343
+ # created exclusively and written through the descriptor that created it: never through
344
+ # an entry planted at the name, and two threads delivering side by side get two names
345
+ tmp = None
270
346
  try:
271
- shutil.copyfile(src, tmp)
347
+ tmp = copy_to_sibling(str(src), str(final))
272
348
  os.replace(tmp, final)
273
349
  except OSError as exc:
274
- try:
275
- os.remove(tmp)
276
- except OSError:
277
- pass
350
+ if tmp:
351
+ try:
352
+ os.remove(tmp)
353
+ except OSError:
354
+ pass
278
355
  raise ItemError({"step": None, "tool": None, "kind": "output",
279
356
  "message": f"could not deliver {src} to {final}: {exc}"})
357
+ # the delivered file as it was put there, taken under the lock: what a later pass may replace
358
+ ident = owned_identity(str(final))
280
359
  log(f" delivered the last written file to {final}")
360
+ return ident
281
361
 
282
362
 
283
363
  def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
284
364
  deadline: "Optional[float]" = None) -> Dict[str, Any]:
285
365
  final = final_path(src, recipe, outdir)
286
366
  t0 = time.time()
367
+ # replacing this run's own earlier output is decided per path and at the write, never here:
368
+ # each step gets a snapshot of what this process delivered (runner.OWNED_ENV) and checks every
369
+ # existing file it would write against it; deliver() checks the final path under its lock
370
+ track = not STATE.dry_run and not STATE.overwrite
371
+
372
+ delivered: Dict[str, Dict[str, int]] = {}
373
+
374
+ def owned(i: int) -> "Optional[Path]":
375
+ return owned_config(src, work, i) if track else None
287
376
 
288
377
  def budget() -> "Optional[float]":
289
378
  """What is left of the BATCH's time limit -- not a fresh one per item. A --timeout is a
@@ -304,11 +393,15 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
304
393
  proj.setdefault("clips", [{}])
305
394
  while len(proj["clips"]) <= idx:
306
395
  proj["clips"].append({})
396
+ # the copy is written in the item's work directory, and render.py reads a project's
397
+ # relative paths against the project file's own directory: they are made absolute
398
+ # against the recipe's project, or "bed.wav" beside it was looked for in .work/<n>-xxx/
399
+ proj = rebase_project_paths(proj, Path(str(recipe["project"])).resolve().parent)
307
400
  proj["clips"][idx]["src"] = str(src.resolve())
308
401
  proj["output"] = str(final.resolve())
309
402
  pj = work / "project.json"
310
403
  pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
311
- ok, _doc = run_step(["render.py", str(pj)], budget())
404
+ ok, _doc = run_step(["render.py", str(pj)], budget(), owned=owned(0))
312
405
  step_docs = [("render", _doc)]
313
406
  failure = None if ok else step_error(0, "render.py", _doc)
314
407
  cut_reencoded: List[bool] = []
@@ -332,7 +425,9 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
332
425
  # what this one read, and when it is the last, the last file written is delivered (#415)
333
426
  writes = step_writes(step)
334
427
  argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
335
- step_ok, doc = run_step(argv, budget())
428
+ if last and writes and track and is_own_output(src, final):
429
+ log(f" {final.name} is this run's own earlier output, unchanged since: it may be replaced")
430
+ step_ok, doc = run_step(argv, budget(), owned=owned(i))
336
431
  step_docs.append((Path(str(argv[0])).stem if argv else "step", doc))
337
432
  # only cut.py's own doc carries `reencoded` -- true if ANY range this call cut (or its
338
433
  # --segments join) re-encoded, for the reasons in its `reencode_reason` (distinct per
@@ -351,9 +446,15 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
351
446
  # the last step wrote nothing: the item's deliverable is the last file a step wrote,
352
447
  # which is in this item's work directory and would be removed with it (#415)
353
448
  try:
354
- deliver(cur, final)
449
+ ident = deliver(cur, final, expected=own_expected(src, final))
450
+ if ident:
451
+ delivered[str(final)] = ident
355
452
  except ItemError as exc:
356
453
  ok, failure = False, exc.error
454
+ if ok and track:
455
+ # what this item delivered, each file as it was when written: the paths its steps reported
456
+ # committing (side files included) and the final path deliver() moved into place
457
+ record_own(src, {**owned_reports(work), **delivered}, work)
357
458
  result: Dict[str, Any] = {"file": str(src), **row_output(src, recipe, outdir), "ok": ok, "seconds": round(time.time() - t0, 1)}
358
459
  if failure:
359
460
  result["error"] = failure
@@ -432,6 +533,8 @@ def main() -> int:
432
533
  apply_common(args)
433
534
 
434
535
  started = time.time()
536
+ with _OWN_LOCK:
537
+ _OWN_OUTPUTS.clear()
435
538
  folder = Path(args.folder).resolve() # relative 'bdir' used to become bdir/bdir/out once joined with the default outdir
436
539
  if not folder.is_dir():
437
540
  die(f"not a folder: {folder}")
@@ -551,6 +654,35 @@ def main() -> int:
551
654
  except (ValueError, OSError):
552
655
  pass
553
656
 
657
+ # --watch: each source's (size, mtime_ns) at the previous poll. A folder files are copied,
658
+ # downloaded or recorded into is read while they grow: a file processed mid-copy delivered a
659
+ # fraction of itself as ok (verified, for an MKV or TS), and once it settled every later pass
660
+ # met that output as someone else's and refused it. A source is processed once it is
661
+ # unchanged across one poll interval -- the same size and mtime as at the previous poll, or,
662
+ # first seen, last modified at least one interval ago; until then its row is
663
+ # skipped: "settling", not cached, and the next pass looks again.
664
+ last_seen: Dict[str, "tuple[int, int]"] = {}
665
+
666
+ def settle_poll(files: List[Path]) -> "set":
667
+ if not args.watch:
668
+ return set()
669
+ unsettled = set()
670
+ now = time.time()
671
+ current: Dict[str, "tuple[int, int]"] = {}
672
+ for src in files:
673
+ try:
674
+ st = src.stat()
675
+ except OSError:
676
+ continue # gone since the scan: file_key() reports it as before
677
+ sig = (st.st_size, st.st_mtime_ns)
678
+ current[str(src)] = sig
679
+ before = last_seen.get(str(src))
680
+ if (before != sig) if before is not None else (now - st.st_mtime < args.watch):
681
+ unsettled.add(str(src))
682
+ last_seen.clear()
683
+ last_seen.update(current)
684
+ return unsettled
685
+
554
686
  def one_pass() -> List[Dict[str, Any]]:
555
687
  files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
556
688
  # Two different sources can compute the same final path (most often a fixed recipe "ext"
@@ -630,7 +762,13 @@ def main() -> int:
630
762
  pending: List[tuple] = []
631
763
  cache_miss: Dict[int, Dict[str, Any]] = {}
632
764
  pass_key = rkey + side_inputs_key(recipe)
765
+ polled = settle_poll(files)
633
766
  for i, src in enumerate(files):
767
+ if str(src) in polled:
768
+ info(f"skip (still being written) {src.name}: it changed since the last poll")
769
+ slots[i] = {"file": str(src), **row_output(src, recipe, outdir), "ok": False, "verified": False,
770
+ "seconds": 0.0, "skipped": "settling"}
771
+ continue
634
772
  key = f"{file_key(src)}:{pass_key}"
635
773
  hit = cache.get(key)
636
774
  if isinstance(hit, dict) and not args.force:
@@ -840,7 +978,9 @@ def main() -> int:
840
978
  timed_out=True)
841
979
  if done != len(results):
842
980
  failed_files = [r["file"] for r in results if not r["ok"]]
843
- die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else ""),
981
+ settling = sum(1 for r in results if r.get("skipped") == "settling")
982
+ die(f"{len(results) - done} of {len(results)} items failed: {', '.join(failed_files[:5])}" + (" ..." if len(failed_files) > 5 else "")
983
+ + (f" ({settling} of them still being written when the watch stopped, skipped: settling, not processed)" if settling else ""),
844
984
  kind="verification", output=None, dry_run=STATE.dry_run, results=results, processed=done, total=len(results))
845
985
  # cut.py's own `reencoded`, rolled up across every cut.py step this batch ran: how much of the
846
986
  # folder landed on the fast lossless path (mode "copy") vs re-encoded on at least one range.
package/scripts/broll.py CHANGED
@@ -22,7 +22,7 @@ import sys
22
22
  from typing import Any, Dict, List
23
23
 
24
24
  from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
25
- from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
25
+ from _common import STATE, add_common, aac_args, apply_common, cfr_args, 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
26
26
  from _common.emit import note, record_fallback
27
27
  from _common.probe import display_size, square_pixels, square_pixels_note
28
28
 
@@ -136,7 +136,7 @@ def main() -> int:
136
136
  b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
137
137
  parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
138
138
  f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
139
- parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
139
+ parts.append(f"{cur}[b{i}]overlay=0:0{overlay_format(meta_a)}:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
140
140
  cur = f"[v{i}]"
141
141
  vout = cur
142
142
 
@@ -43,9 +43,10 @@ from typing import Any, Dict, List, Optional, Tuple
43
43
 
44
44
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
45
  resolve as resolve_platform)
46
- from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
46
+ from _ass_overlay import (EMOJI_SENTINEL, emoji_placeholder, ass_escape, ass_escape_markup,
47
+ strip_srt_markup, reinsert_srt_markup, carry_srt_markup, srt_alignment)
47
48
  from _common.emit import refuse # noqa: E402
48
- from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
49
+ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, overlay_format, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
49
50
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
50
51
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
51
52
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -277,12 +278,12 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
277
278
  margin_px = args.margin * play_h / 288.0
278
279
  line_h = size_px * 1.2
279
280
  box_px = size_px * scale
280
- align = ALIGN[args.position]
281
281
  out_cues = []
282
282
  for start, end, text in cues:
283
283
  lines = _cue_lines(text)
284
284
  n = len(lines)
285
285
  new_lines = []
286
+ align = srt_alignment(text) or ALIGN[args.position] # a cue's {\anN} moves its emoji too
286
287
  for i, line in enumerate(lines):
287
288
  if align in (7, 8, 9):
288
289
  y_top = margin_px + i * line_h
@@ -290,7 +291,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
290
291
  y_top = play_h / 2.0 - (n * line_h) / 2.0 + i * line_h
291
292
  else:
292
293
  y_top = play_h - margin_px - (n - i) * line_h
293
- line_w = text_width_em(line, scale) * size_px
294
+ line_w = text_width_em(strip_srt_markup(line), scale) * size_px
294
295
  if align in (1, 4, 7):
295
296
  x0 = margin_px
296
297
  elif align in (3, 6, 9):
@@ -300,7 +301,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
300
301
  # libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
301
302
  # occupies the RIGHT end of the rendered line. Measuring the prefix from the left
302
303
  # edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
303
- rtl = detect_script(line) in BIDI_SCRIPTS
304
+ rtl = detect_script(strip_srt_markup(line)) in BIDI_SCRIPTS
304
305
  rebuilt = ""
305
306
  cursor = 0
306
307
  for idx, cluster in emoji_clusters(line):
@@ -313,6 +314,7 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
313
314
  rebuilt += line[cursor:idx + len(cluster)]
314
315
  cursor = idx + len(cluster)
315
316
  continue
317
+ prefix = strip_srt_markup(prefix) # a tag before the emoji takes no room
316
318
  if rtl:
317
319
  x = x0 + line_w - text_width_em(prefix + cluster, scale) * size_px
318
320
  else:
@@ -390,9 +392,12 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
390
392
  # greedy wrap would have given (`rebalanced`) and what 1.15's wrap would have
391
393
  # given (`phrase_breaks`). Three wrap_text() calls re-ran the atomiser each time.
392
394
  sliced_atoms: List[int] = []
393
- lines, greedy, measured = wrap_variants(text, own_em, mode=wrap, lang=lang,
395
+ # SRT markup (<i>, <font color>, {\an8}) is not text: the wrap measures and breaks
396
+ # the words a viewer reads, and the tags go back next to the characters they styled
397
+ plain = strip_srt_markup(text)
398
+ lines, greedy, measured = wrap_variants(plain, own_em, mode=wrap, lang=lang,
394
399
  slice_overlong=True, sliced=sliced_atoms)
395
- if lines != [l for l in text.split("\n") if l.strip()]:
400
+ if lines != [l for l in plain.split("\n") if l.strip()]:
396
401
  stats["wrapped"] += 1
397
402
  if sliced_atoms:
398
403
  # the escape hatch fired: a word or run with no break point the wrapper may use
@@ -407,19 +412,22 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
407
412
  stats["rebalanced"] += 1
408
413
  if wrap != "measured" and lines != measured:
409
414
  stats["phrase_breaks"] += 1
415
+ marked = reinsert_srt_markup(text, lines) if plain != text else lines
410
416
  if len(lines) > max_lines:
411
417
  chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
412
418
  weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
413
419
  total_w = sum(weights)
420
+ pieces = carry_srt_markup(["\n".join(marked[i:i + max_lines])
421
+ for i in range(0, len(marked), max_lines)])
414
422
  t = start
415
- for chunk, weight in zip(chunks, weights):
423
+ for piece, weight in zip(pieces, weights):
416
424
  seg = (end - start) * weight / total_w
417
- staged.append((t, min(end, t + seg), "\n".join(chunk)))
425
+ staged.append((t, min(end, t + seg), piece))
418
426
  staged_sizes.append(own_size)
419
427
  t += seg
420
428
  stats["split"] += len(chunks) - 1
421
429
  continue
422
- text = "\n".join(lines)
430
+ text = "\n".join(marked)
423
431
  staged.append((start, end, text))
424
432
  staged_sizes.append(own_size)
425
433
  out: List[Tuple[float, float, str]] = []
@@ -572,7 +580,7 @@ def parse_ass_cues(path: str, stats: Optional[dict] = None) -> List[Tuple[float,
572
580
 
573
581
 
574
582
  def cue_text_visible(text: str) -> bool:
575
- return bool((text or "").strip())
583
+ return bool(strip_srt_markup(text or "").strip()) # `<i></i>` draws nothing
576
584
 
577
585
 
578
586
  def count_visible_cues(cues, duration) -> Tuple[int, int]:
@@ -664,6 +672,17 @@ def _karaoke_word_durations(args, video: Optional[str], start: float, end: float
664
672
  return durs
665
673
 
666
674
 
675
+ # an override block this file wrote (a converted SRT tag), not the escaped `\{` a user typed
676
+ _OVERRIDE_BLOCK_RE = re.compile(r"(?<!\\)\{\\[^{}]*\}")
677
+
678
+
679
+ def _karaoke_word(token: str) -> bool:
680
+ """Does this space-separated Dialogue token hold something a viewer reads? An emoji
681
+ placeholder does not, and neither does a lone converted tag (`{\\i1}` from `<i> Hello`):
682
+ neither gets a share of the cue's time or a highlight of its own."""
683
+ return bool(_OVERRIDE_BLOCK_RE.sub("", token).strip(EMOJI_SENTINEL))
684
+
685
+
667
686
  def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int, video: str = None) -> None:
668
687
  """Write a styled ASS file with optional animation and word-by-word highlight."""
669
688
  def t(sec: float) -> str:
@@ -687,6 +706,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
687
706
  word_active = ass_color(args.highlight_color)
688
707
  word_upcoming = ass_color(args.upcoming_color)
689
708
  word_bord = f"{args.outline * scale * (max(1.0, args.karaoke_scale) / 100.0):.2f}"
709
+ style_bord = f"{args.outline * scale:.2f}"
690
710
  word_scale = int(round(args.karaoke_scale))
691
711
  # --karaoke-style word emits one Dialogue per word carrying the WHOLE cue with explicit \N
692
712
  # breaks (the layout's own fixed line list -- see max_line_em's docstring). WrapStyle: 2 tells
@@ -713,7 +733,9 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
713
733
  # escapes for the braces, so 1.15 escapes them (ass_escape) rather than deleting them:
714
734
  # a cue that says "use {curly} braces" is read out with its braces, and still cannot open
715
735
  # an override block. Newlines become \N in the same pass.
716
- text = ass_escape(text)
736
+ # SRT markup is the exception (#X2): <i>/<b>/<u>/<s>, <font color> and {\anN} become the
737
+ # overrides ffmpeg's SRT reader gives them, so this route draws what the plain one draws
738
+ text = ass_escape_markup(text, colour=not args.karaoke)
717
739
  fx = ""
718
740
  if args.animate == "fade":
719
741
  fx = "{\\fad(200,200)}"
@@ -740,7 +762,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
740
762
  # the active word's larger \fscx/\fscy, which is the jump the issue is filed against.
741
763
  line_words = [[w for w in seg.split(" ") if w] for seg in body.split("\\N")]
742
764
  flat = [w for lw in line_words for w in lw]
743
- real_idx = [i for i, w in enumerate(flat) if w.strip(EMOJI_SENTINEL)]
765
+ real_idx = [i for i, w in enumerate(flat) if _karaoke_word(w)]
744
766
  if not real_idx:
745
767
  # nothing to highlight (an all-emoji or empty cue) -- one plain event, same as
746
768
  # today's karaoke-off rendering, rather than zero Dialogue lines for this cue
@@ -764,15 +786,18 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
764
786
  for lw in line_words:
765
787
  styled_line = []
766
788
  for w in lw:
767
- if not w.strip(EMOJI_SENTINEL):
789
+ if not _karaoke_word(w):
768
790
  styled_line.append(w)
769
791
  else:
770
792
  rank = real_idx.index(flat_pos)
771
793
  if rank < j:
772
794
  styled_line.append(f"{{\\c{word_past}}}{w}")
773
795
  elif rank == j:
796
+ # put back what the active word changed, not {\r}: a reset would
797
+ # also drop the cue's own <i>/<b>/colour and a --fit-size \fs
774
798
  styled_line.append(
775
- f"{{\\c{word_active}\\fscx{word_scale}\\fscy{word_scale}\\bord{word_bord}}}{w}{{\\r}}")
799
+ f"{{\\c{word_active}\\fscx{word_scale}\\fscy{word_scale}\\bord{word_bord}}}{w}"
800
+ f"{{\\fscx100\\fscy100\\bord{style_bord}}}")
776
801
  else:
777
802
  styled_line.append(f"{{\\c{word_upcoming}}}{w}")
778
803
  flat_pos += 1
@@ -789,7 +814,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
789
814
  if args.karaoke:
790
815
  # split each line into words and give every word an equal share of the cue (\k is in centiseconds)
791
816
  segments = body.split("\\N")
792
- words = [w for seg in segments for w in seg.split(" ") if w and w.strip(EMOJI_SENTINEL)]
817
+ words = [w for seg in segments for w in seg.split(" ") if w and _karaoke_word(w)]
793
818
  durs = _karaoke_word_durations(args, video, start, end, len(words))
794
819
  it = iter(durs)
795
820
  out_segments = []
@@ -799,7 +824,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
799
824
  # past the reserved gap without spending cue time on a glyph nobody sees (rendered
800
825
  # and confirmed -- libass keeps the full gap inside a karaoke run).
801
826
  out_segments.append(" ".join(
802
- ("{\\kf0}" + w) if not w.strip(EMOJI_SENTINEL) else f"{{\\kf{next(it)}}}{w}" for w in ws))
827
+ ("{\\kf0}" + w) if not _karaoke_word(w) else f"{{\\kf{next(it)}}}{w}" for w in ws))
803
828
  body = "\\N".join(out_segments)
804
829
  if EMOJI_SENTINEL in body:
805
830
  body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
@@ -926,8 +951,8 @@ def mux_subtitle_codec(output: str) -> str:
926
951
 
927
952
  def ass_color(hex_rgb: str, alpha: int = 0) -> str:
928
953
  h = hex_rgb.lstrip("#")
929
- if len(h) != 6:
930
- die(f"colour must be RRGGBB hex, got '{hex_rgb}'")
954
+ if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
955
+ die(f"colour must be six hex digits RRGGBB, got '{hex_rgb}'")
931
956
  r, g, b = h[0:2], h[2:4], h[4:6]
932
957
  return f"&H{alpha:02X}{b}{g}{r}".upper()
933
958
 
@@ -1223,7 +1248,7 @@ def main() -> int:
1223
1248
  # viewer sees and would silently change the SRT this run writes, so the mux path is
1224
1249
  # left exactly as 1.16 wrote it.
1225
1250
  return
1226
- fit = fit_size(cue_list, **fit_params())
1251
+ fit = fit_size([(s, e, strip_srt_markup(t)) for s, e, t in cue_list], **fit_params())
1227
1252
  fit_stats["size_used"] = fit["size"]
1228
1253
  fit_stats["size_source"] = "platform-frame" if planned_frame else "input"
1229
1254
  fit_stats["shrunk"] = fit["shrunk"]
@@ -1774,7 +1799,7 @@ def main() -> int:
1774
1799
  side_notes.append(square_pixels_note(meta["video"]))
1775
1800
  cmd = ffmpeg_base() + ["-i", args.input]
1776
1801
  chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
1777
- base_chain=layer_base_chain(meta))
1802
+ base_chain=layer_base_chain(meta), overlay_opts=overlay_format(meta))
1778
1803
  if emoji_plan else ([], []))
1779
1804
  if chains:
1780
1805
  for spec in emoji_inputs:
package/scripts/check.py CHANGED
@@ -525,7 +525,7 @@ def main() -> int:
525
525
  # comes back at a level nobody checked.
526
526
  ch = a.get("channels") or 0
527
527
  row("channels", "PASS" if ch in (1, 2) else "WARN", f"{ch}ch", "1 (mono) or 2 (stereo)",
528
- "audio.py --downmix (5.1/7.1 to stereo with the standard weights) or audio.py --mono",
528
+ "audio.py --downmix (5.1/7.1 to stereo with the ITU-R BS.775 weights) or audio.py --mono",
529
529
  reason="podcast players downmix 5.1 unpredictably")
530
530
  if args.platform == "podcast":
531
531
  chapters = meta.get("chapters") or []
package/scripts/color.py CHANGED
@@ -38,6 +38,7 @@ from _common.emit import note, warn
38
38
  from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
39
39
  report as alpha_report, source_alpha, tag_args, video_line)
40
40
  from _common.decision import keeps_source_tags
41
+ from _common.color import bt2020_sdr_to_bt709_chain, hdr_to_sdr_chain, is_bt2020_sdr # noqa: E402,F401
41
42
 
42
43
  # the curves of FFmpeg's CPU `tonemap` filter (every version from 5.1 to 9). bt2390 was listed
43
44
  # until 2.24 but exists only in tonemap_opencl/libplacebo, so choosing it always failed.
@@ -198,47 +199,6 @@ def lut_legs(meta=None, out_fmt: str = "yuv420p") -> "tuple":
198
199
  f"scale=out_color_matrix={matrix}:out_range=tv,format={out_fmt}")
199
200
 
200
201
 
201
- def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
202
- v = meta["video"]
203
- trc = v.get("color_transfer") or "smpte2084"
204
- prim = v.get("color_primaries") or "bt2020"
205
- space = v.get("color_space") or "bt2020nc"
206
- # zscale needs explicit input tags when the file lacks them
207
- chain: List[str] = [
208
- f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear:npl={peak:g}",
209
- "format=gbrpf32le",
210
- "zscale=p=bt709",
211
- f"tonemap=tonemap={tonemap}:desat={desat:g}",
212
- "zscale=t=bt709:m=bt709:r=tv",
213
- "format=yuv420p",
214
- ]
215
- return ",".join(chain)
216
-
217
-
218
- def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
219
- """Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
220
- true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
221
- its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
222
- white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
223
- as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
224
- v = meta["video"]
225
- trc = v.get("color_transfer") or "bt709"
226
- space = v.get("color_space") or "bt2020nc"
227
- return ",".join([
228
- f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
229
- "format=gbrpf32le",
230
- "zscale=p=bt709",
231
- "zscale=t=bt709:m=bt709:r=tv",
232
- "format=yuv420p",
233
- ])
234
-
235
-
236
- def is_bt2020_sdr(v: dict) -> bool:
237
- """BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
238
- return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
239
- and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
240
-
241
-
242
202
  # --retag targets: ffprobe's names (colorspace, primaries, transfer) and the ITU-T H.273 code points
243
203
  # h264_metadata/hevc_metadata write into the VUI (matrix_coefficients, colour_primaries,
244
204
  # transfer_characteristics).
@@ -436,7 +396,7 @@ def main() -> int:
436
396
  "(luma 14-241, chroma 5-251) legal range after the 8-bit 4:2:0 conversion, then measure the written "
437
397
  "file's out-of-range share (JSON legalize). --correct with no correction values clamps only")
438
398
  ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
439
- ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
399
+ ap.add_argument("--peak", type=float, default=1000.0, help="the PQ source's peak brightness in nits, the level the tone map takes to SDR white (default 1000; an HLG source peaks at 1000 by definition, so it does not apply there)")
440
400
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
441
401
  ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
442
402
  ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is tagged neither HDR nor BT.2020 (tone-mapped as PQ; a BT.2020 SDR file never needs it)")