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/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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
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
|
-
|
|
347
|
+
tmp = copy_to_sibling(str(src), str(final))
|
|
272
348
|
os.replace(tmp, final)
|
|
273
349
|
except OSError as exc:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/scripts/caption.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
423
|
+
for piece, weight in zip(pieces, weights):
|
|
416
424
|
seg = (end - start) * weight / total_w
|
|
417
|
-
staged.append((t, min(end, t + seg),
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)")
|