ffmpeg-skill 1.13.0 → 1.15.0
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 +72 -29
- package/SKILL.md +46 -41
- package/bin/install.js +1 -1
- package/docs/contract.md +72 -10
- package/package.json +4 -2
- package/references/gotchas.md +85 -1
- package/references/scripts.md +146 -15
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common.py +596 -37
- package/scripts/_contract.py +27 -9
- package/scripts/_platforms.py +251 -0
- package/scripts/caption.py +343 -93
- package/scripts/check.py +13 -16
- package/scripts/export.py +87 -11
- package/scripts/fit.py +21 -2
- package/scripts/graphics.py +381 -20
- package/scripts/look.py +35 -0
- package/scripts/overlay.py +72 -15
- package/scripts/render.py +313 -29
- package/scripts/report.py +73 -1
- package/templates/facebook.json +47 -0
- package/templates/linkedin.json +47 -0
- package/templates/podcast.json +22 -0
- package/templates/reels.json +47 -0
- package/templates/shorts.json +47 -0
- package/templates/tiktok.json +47 -0
- package/templates/x.json +47 -0
- package/templates/youtube-shorts.json +47 -0
- package/templates/youtube.json +47 -0
package/scripts/caption.py
CHANGED
|
@@ -38,9 +38,11 @@ import re
|
|
|
38
38
|
import sys
|
|
39
39
|
import unicodedata
|
|
40
40
|
from pathlib import Path
|
|
41
|
-
from typing import List, Optional, Tuple
|
|
41
|
+
from typing import Dict, List, Optional, Tuple
|
|
42
42
|
|
|
43
|
-
from
|
|
43
|
+
from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units, resolve as resolve_platform
|
|
44
|
+
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
45
|
+
from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, LEADING_VOWELS, NO_SPACE_SCRIPTS, _char_em, _is_mark, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, char_script, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, 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
|
|
44
46
|
|
|
45
47
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
46
48
|
|
|
@@ -297,89 +299,41 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
|
|
|
297
299
|
return out
|
|
298
300
|
|
|
299
301
|
|
|
300
|
-
# --------------------------------------------------------------------------- readable cues (1.12)
|
|
301
|
-
# Average advance width per character, in em (a fraction of the font size). Proportional Latin text
|
|
302
|
-
# averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
|
|
303
|
-
# Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
|
|
304
|
-
# real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
|
|
305
|
-
# shaping, while a cue wrapped from an average is right to within a character on every line.
|
|
306
|
-
# (Latin is measured per character from LATIN_EM below, not from this average.)
|
|
307
|
-
ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
|
|
308
|
-
"ru": 0.55, "el": 0.55, "latin": 0.55}
|
|
309
302
|
# How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
|
|
310
303
|
# 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
|
|
311
304
|
SAFE_WIDTH_FRACTION = 0.9
|
|
312
|
-
#
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
# enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
|
|
316
|
-
# against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
|
|
317
|
-
# the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
|
|
318
|
-
# past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
|
|
319
|
-
# un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
|
|
320
|
-
# 0.57 lowercase and anything else Latin-ish).
|
|
321
|
-
LATIN_EM = {
|
|
322
|
-
' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
|
|
323
|
-
'(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
|
|
324
|
-
'1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
|
|
325
|
-
'9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
|
|
326
|
-
'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
|
|
327
|
-
'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
|
|
328
|
-
'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
|
|
329
|
-
'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
|
|
330
|
-
'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
|
|
331
|
-
'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
|
|
332
|
-
'r': 0.42, 's': 0.53, 't': 0.4, 'u': 0.64, 'v': 0.6, 'w': 0.82, 'x': 0.6, 'y': 0.6, 'z': 0.53,
|
|
333
|
-
'{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
|
|
334
|
-
}
|
|
335
|
-
# Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
|
|
336
|
-
# between them and the base that follows.
|
|
337
|
-
LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
def _is_mark(ch: str) -> bool:
|
|
341
|
-
"""A character that hangs off the one before it: a combining mark (any script) or one of the
|
|
342
|
-
Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
|
|
343
|
-
return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
def _char_em(ch: str) -> float:
|
|
347
|
-
# CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
|
|
348
|
-
# full-width grid as the ideographs they sit between, even though they are not "Han" to a
|
|
349
|
-
# script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
|
|
350
|
-
cp = ord(ch)
|
|
351
|
-
# A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
|
|
352
|
-
# charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
|
|
353
|
-
if unicodedata.combining(ch) != 0 or unicodedata.category(ch) == "Mn":
|
|
354
|
-
return 0.0
|
|
355
|
-
if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
|
|
356
|
-
return 1.0
|
|
357
|
-
script = char_script(ch)
|
|
358
|
-
if script == "latin":
|
|
359
|
-
if ch in LATIN_EM:
|
|
360
|
-
return LATIN_EM[ch]
|
|
361
|
-
if ch.isupper() or ch.isdigit():
|
|
362
|
-
return 0.7
|
|
363
|
-
return 0.57
|
|
364
|
-
return ADVANCE_EM.get(script, 0.55)
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
def text_width_em(text: str) -> float:
|
|
368
|
-
"""Width of `text` in em, from the per-script average advance table."""
|
|
369
|
-
return sum(_char_em(ch) for ch in text)
|
|
305
|
+
# ORPHAN_MIN_EM: one full-width CJK/Thai character plus a hair. A last line narrower than this is a
|
|
306
|
+
# single stranded character -- eval 14's th1 (a lone 'ล') and dl3 (a lone '行').
|
|
307
|
+
ORPHAN_MIN_EM = 1.1
|
|
370
308
|
|
|
371
309
|
|
|
372
310
|
def _atoms(line: str) -> List[Tuple[str, bool]]:
|
|
373
311
|
"""Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
|
|
374
|
-
character, one per whitespace-delimited word otherwise -- each with
|
|
375
|
-
before it in the original. The flag is what puts the text back together
|
|
376
|
-
"Hello 世界" keeps its space, "世界です" gains none."""
|
|
312
|
+
character, one per emoji cluster, one per whitespace-delimited word otherwise -- each with
|
|
313
|
+
whether a space stood before it in the original. The flag is what puts the text back together
|
|
314
|
+
exactly as written: "Hello 世界" keeps its space, "世界です" gains none."""
|
|
377
315
|
out: List[Tuple[str, bool]] = []
|
|
378
316
|
word = ""
|
|
379
317
|
spaced = False # a space stands before the atom being built
|
|
380
318
|
pending = False # a space stands before the NEXT atom
|
|
381
319
|
attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
|
|
382
|
-
|
|
320
|
+
# An emoji cluster is one atom: a wrap must never land inside a ZWJ sequence, a flag pair or
|
|
321
|
+
# between a base and its skin-tone modifier (the same rule combining marks already follow).
|
|
322
|
+
clusters = {i: len(cl) for i, cl in emoji_clusters(line)}
|
|
323
|
+
i = 0
|
|
324
|
+
while i < len(line):
|
|
325
|
+
ch = line[i]
|
|
326
|
+
if i in clusters:
|
|
327
|
+
cluster = line[i:i + clusters[i]]
|
|
328
|
+
if word:
|
|
329
|
+
out.append((word, spaced))
|
|
330
|
+
word = ""
|
|
331
|
+
out.append((cluster, pending))
|
|
332
|
+
pending = False
|
|
333
|
+
attach_next = False
|
|
334
|
+
i += clusters[i]
|
|
335
|
+
continue
|
|
336
|
+
i += 1
|
|
383
337
|
if char_script(ch) in NO_SPACE_SCRIPTS:
|
|
384
338
|
if word:
|
|
385
339
|
out.append((word, spaced))
|
|
@@ -413,29 +367,240 @@ def _join(left: str, atom: str, spaced: bool) -> str:
|
|
|
413
367
|
return left + (" " if spaced else "") + atom
|
|
414
368
|
|
|
415
369
|
|
|
416
|
-
def
|
|
370
|
+
def _break_spaced(first: str, second: str) -> bool:
|
|
371
|
+
"""Did a space stand at the break between these two wrapped lines? Only spaced scripts put one
|
|
372
|
+
there -- a CJK/Thai break sits between two characters that were written with nothing between
|
|
373
|
+
them, and re-joining them with a space would insert a character the cue never had."""
|
|
374
|
+
if not first or not second:
|
|
375
|
+
return False
|
|
376
|
+
return char_script(first[-1]) not in NO_SPACE_SCRIPTS and char_script(second[0]) not in NO_SPACE_SCRIPTS \
|
|
377
|
+
and char_script(first[-1]) != "emoji" and char_script(second[0]) != "emoji"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _fix_orphans(lines: List[str], max_em: float) -> List[str]:
|
|
381
|
+
"""No last line that is a single stranded atom.
|
|
382
|
+
|
|
383
|
+
Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
|
|
384
|
+
produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
|
|
385
|
+
last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
|
|
386
|
+
onto it -- but only while the result still fits and the line above does not become an orphan
|
|
387
|
+
itself, so a two-word cue is never made worse."""
|
|
388
|
+
lines = list(lines)
|
|
389
|
+
for _ in range(len(lines)):
|
|
390
|
+
if len(lines) < 2:
|
|
391
|
+
break
|
|
392
|
+
tail = _atoms(lines[-1])
|
|
393
|
+
if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
|
|
394
|
+
break
|
|
395
|
+
prev = _atoms(lines[-2])
|
|
396
|
+
if len(prev) < 2:
|
|
397
|
+
break
|
|
398
|
+
moved, spaced = prev[-1]
|
|
399
|
+
new_prev = ""
|
|
400
|
+
for atom, sp in prev[:-1]:
|
|
401
|
+
new_prev = _join(new_prev, atom, sp)
|
|
402
|
+
new_last = _join(moved, tail[0][0], _break_spaced(lines[-2], lines[-1]))
|
|
403
|
+
if text_width_em(new_last) > max_em or text_width_em(new_prev) < ORPHAN_MIN_EM:
|
|
404
|
+
break
|
|
405
|
+
lines[-2], lines[-1] = new_prev, new_last
|
|
406
|
+
return lines
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _rebalance(lines: List[str], max_em: float) -> List[str]:
|
|
410
|
+
"""Move each break to the one that minimises the widest line of the pair, without changing the
|
|
411
|
+
line count.
|
|
412
|
+
|
|
413
|
+
Greedy wrapping fills line 1 to the brim and leaves line 2 short, which is what split eval 14's
|
|
414
|
+
`"A third line the tool times for me"` mid-phrase. Only spaced scripts are rebalanced: a
|
|
415
|
+
non-spaced script has no phrase structure in its atom list, so moving the break there only
|
|
416
|
+
moves the ragged edge. A break is never placed before a punctuation-only atom."""
|
|
417
|
+
if len(lines) < 2:
|
|
418
|
+
return lines
|
|
419
|
+
out = list(lines)
|
|
420
|
+
for i in range(len(out) - 1):
|
|
421
|
+
first, second = out[i], out[i + 1]
|
|
422
|
+
tail_atoms = _atoms(second)
|
|
423
|
+
if tail_atoms:
|
|
424
|
+
tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
|
|
425
|
+
atoms = _atoms(first) + tail_atoms
|
|
426
|
+
if not atoms or any(char_script(ch) in NO_SPACE_SCRIPTS for ch in first + second):
|
|
427
|
+
continue
|
|
428
|
+
best = None
|
|
429
|
+
for cut in range(1, len(atoms)):
|
|
430
|
+
if not atoms[cut][1]:
|
|
431
|
+
continue # only break where a space stood
|
|
432
|
+
if all(not ch.isalnum() for ch in atoms[cut][0]):
|
|
433
|
+
continue # never strand punctuation at the start of a line
|
|
434
|
+
a = b = ""
|
|
435
|
+
for atom, sp in atoms[:cut]:
|
|
436
|
+
a = _join(a, atom, sp)
|
|
437
|
+
for atom, sp in atoms[cut:]:
|
|
438
|
+
b = _join(b, atom, sp)
|
|
439
|
+
wa, wb = text_width_em(a), text_width_em(b)
|
|
440
|
+
if max(wa, wb) > max_em:
|
|
441
|
+
continue
|
|
442
|
+
key = (max(wa, wb), abs(wa - wb))
|
|
443
|
+
if best is None or key < best[0]:
|
|
444
|
+
best = (key, a, b)
|
|
445
|
+
if best is not None:
|
|
446
|
+
out[i], out[i + 1] = best[1], best[2]
|
|
447
|
+
return out
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def wrap_text(text: str, max_em: float, *, balance: bool = True) -> List[str]:
|
|
417
451
|
"""Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
|
|
418
452
|
|
|
419
453
|
An atom wider than the whole line (one very long word) is left alone on its line rather than
|
|
420
|
-
cut mid-word: an over-long line is readable, a chopped word is not.
|
|
454
|
+
cut mid-word: an over-long line is readable, a chopped word is not. Two post-passes then make
|
|
455
|
+
the result readable rather than merely legal (1.15): no one-character orphan line, and for
|
|
456
|
+
spaced scripts a break chosen to minimise the widest line instead of greedily.
|
|
421
457
|
"""
|
|
422
458
|
lines: List[str] = []
|
|
423
459
|
for raw in text.split("\n"):
|
|
424
460
|
if not raw.strip():
|
|
425
461
|
continue
|
|
426
462
|
current = ""
|
|
463
|
+
chunk: List[str] = []
|
|
427
464
|
for atom, spaced in _atoms(raw):
|
|
428
465
|
candidate = _join(current, atom, spaced)
|
|
429
466
|
if current and text_width_em(candidate) > max_em:
|
|
430
|
-
|
|
467
|
+
chunk.append(current)
|
|
431
468
|
current = atom
|
|
432
469
|
else:
|
|
433
470
|
current = candidate
|
|
434
471
|
if current:
|
|
435
|
-
|
|
472
|
+
chunk.append(current)
|
|
473
|
+
if balance and len(chunk) > 1:
|
|
474
|
+
fixed = _fix_orphans(chunk, max_em)
|
|
475
|
+
rebalanced = _rebalance(fixed, max_em)
|
|
476
|
+
if len(rebalanced) == len(chunk):
|
|
477
|
+
chunk = rebalanced
|
|
478
|
+
else:
|
|
479
|
+
chunk = fixed
|
|
480
|
+
lines.extend(chunk)
|
|
436
481
|
return lines or [text]
|
|
437
482
|
|
|
438
483
|
|
|
484
|
+
# --------------------------------------------------------------------------- emoji (1.15)
|
|
485
|
+
def _cue_lines(text: str) -> List[str]:
|
|
486
|
+
return [l for l in text.split("\n")]
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def plan_emoji(cues, args, play_w, play_h, brand=None):
|
|
490
|
+
"""Decide how this run draws the emoji in `cues`, and where each PNG goes.
|
|
491
|
+
|
|
492
|
+
Returns (cues, plan) where `cues` may have had its emoji replaced by EMOJI_SENTINEL (the PNG
|
|
493
|
+
route) or stripped (`--emoji none`), and `plan` is the `emoji` result key plus the overlay
|
|
494
|
+
entries the filter graph needs. `None` plan means "nothing to do": no emoji in the text.
|
|
495
|
+
"""
|
|
496
|
+
clusters_all = [cl for _s, _e, t in cues for _i, cl in emoji_clusters(t)]
|
|
497
|
+
if not clusters_all:
|
|
498
|
+
return cues, None
|
|
499
|
+
assets = resolve_emoji_assets(getattr(args, "emoji_assets", None), None, brand)
|
|
500
|
+
want = getattr(args, "emoji", "auto")
|
|
501
|
+
support = emoji_support(assets, probe=True)
|
|
502
|
+
mode = support["mode"] if want == "auto" else want
|
|
503
|
+
if want == "color" and not support["libass_color"]:
|
|
504
|
+
die("--emoji color: this ffmpeg renders emoji monochrome through libass "
|
|
505
|
+
f"({support['detail']}) -- pass --emoji-assets DIR for colour, or --emoji mono",
|
|
506
|
+
kind="input")
|
|
507
|
+
if want == "png" and not assets:
|
|
508
|
+
die("--emoji png: no emoji assets directory resolved -- " + EMOJI_ASSET_HINT, kind="input")
|
|
509
|
+
plan = {"mode": mode, "count": len(clusters_all),
|
|
510
|
+
"clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
|
|
511
|
+
"assets": assets, "missing": [], "overlays": []}
|
|
512
|
+
if mode == "none":
|
|
513
|
+
out = []
|
|
514
|
+
for start, end, text in cues:
|
|
515
|
+
for cl in {cl for _i, cl in emoji_clusters(text)}:
|
|
516
|
+
text = text.replace(cl, "")
|
|
517
|
+
out.append((start, end, re.sub(r"[ \t]{2,}", " ", text).strip()))
|
|
518
|
+
info("emoji: stripped from the drawn text (--emoji none)")
|
|
519
|
+
return out, plan
|
|
520
|
+
if mode in ("color", "mono"):
|
|
521
|
+
if mode == "mono":
|
|
522
|
+
info("warning: emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
523
|
+
"--emoji-assets DIR for colour). " + support["detail"])
|
|
524
|
+
return cues, plan
|
|
525
|
+
# --- the PNG overlay route -------------------------------------------------------------
|
|
526
|
+
if not play_w or not play_h:
|
|
527
|
+
return cues, plan
|
|
528
|
+
scale = float(getattr(args, "emoji_scale", 1.0) or 1.0)
|
|
529
|
+
# --animate moves the TEXT (\fad/\fscx in the ASS); the PNG has to move with it, or the emoji
|
|
530
|
+
# pops in against a line that is still fading up. These match the \fad values below.
|
|
531
|
+
fade_in, fade_out = {"fade": (0.2, 0.2), "pop": (0.08, 0.12),
|
|
532
|
+
"slide": (0.15, 0.15)}.get(getattr(args, "animate", None) or "none", (0.0, 0.0))
|
|
533
|
+
size_px = args.size * play_h / 288.0
|
|
534
|
+
margin_px = args.margin * play_h / 288.0
|
|
535
|
+
line_h = size_px * 1.2
|
|
536
|
+
box_px = size_px * scale
|
|
537
|
+
align = ALIGN[args.position]
|
|
538
|
+
out_cues = []
|
|
539
|
+
for start, end, text in cues:
|
|
540
|
+
lines = _cue_lines(text)
|
|
541
|
+
n = len(lines)
|
|
542
|
+
new_lines = []
|
|
543
|
+
for i, line in enumerate(lines):
|
|
544
|
+
if align in (7, 8, 9):
|
|
545
|
+
y_top = margin_px + i * line_h
|
|
546
|
+
elif align in (4, 5, 6):
|
|
547
|
+
y_top = play_h / 2.0 - (n * line_h) / 2.0 + i * line_h
|
|
548
|
+
else:
|
|
549
|
+
y_top = play_h - margin_px - (n - i) * line_h
|
|
550
|
+
line_w = text_width_em(line, scale) * size_px
|
|
551
|
+
if align in (1, 4, 7):
|
|
552
|
+
x0 = margin_px
|
|
553
|
+
elif align in (3, 6, 9):
|
|
554
|
+
x0 = play_w - margin_px - line_w
|
|
555
|
+
else:
|
|
556
|
+
x0 = (play_w - line_w) / 2.0
|
|
557
|
+
# libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
|
|
558
|
+
# occupies the RIGHT end of the rendered line. Measuring the prefix from the left
|
|
559
|
+
# edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
|
|
560
|
+
rtl = detect_script(line) in BIDI_SCRIPTS
|
|
561
|
+
rebuilt = ""
|
|
562
|
+
cursor = 0
|
|
563
|
+
for idx, cluster in emoji_clusters(line):
|
|
564
|
+
prefix = line[:idx]
|
|
565
|
+
asset = emoji_asset_for(cluster, assets)
|
|
566
|
+
name = emoji_codepoint_name(cluster)
|
|
567
|
+
if not asset:
|
|
568
|
+
if name not in plan["missing"]:
|
|
569
|
+
plan["missing"].append(name)
|
|
570
|
+
rebuilt += line[cursor:idx + len(cluster)]
|
|
571
|
+
cursor = idx + len(cluster)
|
|
572
|
+
continue
|
|
573
|
+
if rtl:
|
|
574
|
+
x = x0 + line_w - text_width_em(prefix + cluster, scale) * size_px
|
|
575
|
+
else:
|
|
576
|
+
x = x0 + text_width_em(prefix, scale) * size_px
|
|
577
|
+
y = y_top + (line_h - box_px) / 2.0
|
|
578
|
+
plan["overlays"].append({
|
|
579
|
+
"asset": asset, "cluster": name,
|
|
580
|
+
"x": int(round(max(0.0, min(x, play_w - box_px)))),
|
|
581
|
+
"y": int(round(max(0.0, min(y, play_h - box_px)))),
|
|
582
|
+
"start": round(start, 3), "end": round(end, 3), "box": int(round(box_px)),
|
|
583
|
+
"fade_in": round(min(fade_in, max(0.0, (end - start) / 2.0)), 3),
|
|
584
|
+
"fade_out": round(min(fade_out, max(0.0, (end - start) / 2.0)), 3)})
|
|
585
|
+
rebuilt += line[cursor:idx] + EMOJI_SENTINEL
|
|
586
|
+
cursor = idx + len(cluster)
|
|
587
|
+
rebuilt += line[cursor:]
|
|
588
|
+
new_lines.append(rebuilt)
|
|
589
|
+
out_cues.append((start, end, "\n".join(new_lines)))
|
|
590
|
+
# `or 60` would swallow the one value that means "no overlays at all".
|
|
591
|
+
_max = getattr(args, "emoji_max", None)
|
|
592
|
+
limit = 60 if _max is None else int(_max)
|
|
593
|
+
if len(plan["overlays"]) > limit:
|
|
594
|
+
die(f"{len(plan['overlays'])} emoji overlays would be built for this job (limit {limit}, "
|
|
595
|
+
"--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
|
|
596
|
+
"linearly -- split the job, or use --emoji none", kind="input")
|
|
597
|
+
if plan["missing"]:
|
|
598
|
+
info("warning: no PNG in the assets directory for " + ", ".join(plan["missing"]) +
|
|
599
|
+
" -- those clusters are drawn by the text font instead")
|
|
600
|
+
plan["box_px"] = int(round(box_px))
|
|
601
|
+
return out_cues, plan
|
|
602
|
+
|
|
603
|
+
|
|
439
604
|
def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
|
|
440
605
|
min_duration: float, offset: float) -> Tuple[List[Tuple[float, float, str]], dict]:
|
|
441
606
|
"""Shift, wrap, split and lengthen cues so they can actually be read.
|
|
@@ -446,7 +611,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
446
611
|
proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
|
|
447
612
|
cue's start. Returns the new cues and a count of what changed.
|
|
448
613
|
"""
|
|
449
|
-
stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0}
|
|
614
|
+
stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0, "rebalanced": 0}
|
|
450
615
|
staged: List[Tuple[float, float, str]] = []
|
|
451
616
|
for start, end, text in cues:
|
|
452
617
|
if offset:
|
|
@@ -460,6 +625,8 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
460
625
|
lines = wrap_text(text, max_em)
|
|
461
626
|
if lines != [l for l in text.split("\n") if l.strip()]:
|
|
462
627
|
stats["wrapped"] += 1
|
|
628
|
+
if lines != wrap_text(text, max_em, balance=False):
|
|
629
|
+
stats["rebalanced"] += 1
|
|
463
630
|
if len(lines) > max_lines:
|
|
464
631
|
chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
|
|
465
632
|
weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
|
|
@@ -487,7 +654,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
487
654
|
|
|
488
655
|
def report_layout(stats: dict) -> None:
|
|
489
656
|
"""One info line, only when a cue actually changed."""
|
|
490
|
-
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "split", "extended", "dropped") if stats.get(k)]
|
|
657
|
+
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "split", "extended", "dropped") if stats.get(k)]
|
|
491
658
|
if parts:
|
|
492
659
|
info("cues: " + ", ".join(parts))
|
|
493
660
|
|
|
@@ -627,15 +794,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
627
794
|
]
|
|
628
795
|
lines = []
|
|
629
796
|
for start, end, text in cues:
|
|
630
|
-
text = text.replace("\n", "\\N")
|
|
631
797
|
# ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
|
|
632
798
|
# commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
|
|
633
799
|
# all effectively user-controlled) that happens to contain braces would otherwise be
|
|
634
800
|
# interpreted as those commands (\pos, \t, \fscx, ...), letting caption content reposition,
|
|
635
|
-
# rescale, or recolor itself or later text instead of just being read out.
|
|
636
|
-
#
|
|
637
|
-
#
|
|
638
|
-
|
|
801
|
+
# rescale, or recolor itself or later text instead of just being read out. libass has real
|
|
802
|
+
# escapes for the braces, so 1.15 escapes them (ass_escape) rather than deleting them:
|
|
803
|
+
# a cue that says "use {curly} braces" is read out with its braces, and still cannot open
|
|
804
|
+
# an override block. Newlines become \N in the same pass.
|
|
805
|
+
text = ass_escape(text)
|
|
639
806
|
fx = ""
|
|
640
807
|
if args.animate == "fade":
|
|
641
808
|
fx = "{\\fad(200,200)}"
|
|
@@ -648,7 +815,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
648
815
|
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
649
816
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
650
817
|
segments = body.split("\\N")
|
|
651
|
-
words = [w for seg in segments for w in seg.split(" ") if w]
|
|
818
|
+
words = [w for seg in segments for w in seg.split(" ") if w and w.strip(EMOJI_SENTINEL)]
|
|
652
819
|
# real word timings from the transcript beat both the energy estimate and the even
|
|
653
820
|
# split -- they are what the speaker actually did, not a proxy for it
|
|
654
821
|
durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, len(words))
|
|
@@ -662,8 +829,14 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
662
829
|
out_segments = []
|
|
663
830
|
for seg in segments:
|
|
664
831
|
ws = [w for w in seg.split(" ") if w]
|
|
665
|
-
|
|
832
|
+
# An emoji placeholder is its own ZERO-duration \kf segment: the highlight sweeps
|
|
833
|
+
# past the reserved gap without spending cue time on a glyph nobody sees (rendered
|
|
834
|
+
# and confirmed -- libass keeps the full gap inside a karaoke run).
|
|
835
|
+
out_segments.append(" ".join(
|
|
836
|
+
("{\\kf0}" + w) if not w.strip(EMOJI_SENTINEL) else f"{{\\kf{next(it)}}}{w}" for w in ws))
|
|
666
837
|
body = "\\N".join(out_segments)
|
|
838
|
+
if EMOJI_SENTINEL in body:
|
|
839
|
+
body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
|
|
667
840
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
|
|
668
841
|
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
669
842
|
fh.write("\n".join(header + lines) + "\n")
|
|
@@ -759,8 +932,25 @@ def main() -> int:
|
|
|
759
932
|
sty.add_argument("--shadow", type=float, default=0.0, help="shadow depth (default 0)")
|
|
760
933
|
sty.add_argument("--bold", action="store_true")
|
|
761
934
|
sty.add_argument("--position", choices=sorted(ALIGN), default=None, help="on-screen placement (default bottom)")
|
|
762
|
-
sty.add_argument("--margin", type=int, default=
|
|
935
|
+
sty.add_argument("--margin", type=int, default=None, help="vertical margin from the edge in ASS units (default 30, or the --platform safe zone)")
|
|
936
|
+
sty.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
|
|
937
|
+
help="keep the captions out of this destination's UI: the margin becomes the platform's safe "
|
|
938
|
+
"zone (TikTok's description bar, the Reels/Shorts chrome). An explicit --margin/--position wins")
|
|
763
939
|
sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
|
|
940
|
+
emo = ap.add_argument_group("emoji (1.15)")
|
|
941
|
+
emo.add_argument("--emoji", choices=["auto", "color", "png", "mono", "none"], default="auto",
|
|
942
|
+
help="how emoji in the cues are drawn: 'auto' picks the best this machine can do "
|
|
943
|
+
"(doctor --json .fonts.emoji), 'color' insists on a colour-capable libass, "
|
|
944
|
+
"'png' composites the --emoji-assets PNGs, 'mono' draws whatever glyph the text "
|
|
945
|
+
"font has, 'none' strips them")
|
|
946
|
+
emo.add_argument("--emoji-assets", metavar="DIR",
|
|
947
|
+
help="directory of emoji PNGs named by code point (1f389.png, 1f1ef-1f1f5.png) -- "
|
|
948
|
+
"Twemoji's assets/72x72 or Noto Emoji's png/128. Nothing is ever downloaded; "
|
|
949
|
+
"also read from brand.json styles.caption.emoji_assets and FFMPEG_SKILL_EMOJI_ASSETS")
|
|
950
|
+
emo.add_argument("--emoji-scale", type=float, default=1.0,
|
|
951
|
+
help="emoji box as a multiple of the line's font size (default 1.0)")
|
|
952
|
+
emo.add_argument("--emoji-max", type=int, default=60,
|
|
953
|
+
help="most emoji overlays one run may build (default 60)")
|
|
764
954
|
sty.add_argument("--max-lines", type=int, default=2, help="most lines one cue may occupy; a longer cue is split into consecutive cues (default 2)")
|
|
765
955
|
sty.add_argument("--min-duration", type=float, default=1.0, help="shortest time a cue stays on screen in seconds, never past the next cue (default 1.0)")
|
|
766
956
|
anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
|
|
@@ -792,6 +982,18 @@ def main() -> int:
|
|
|
792
982
|
args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
|
|
793
983
|
args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
|
|
794
984
|
args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
|
|
985
|
+
# --platform: the margin is the fraction of the frame that platform's own UI covers
|
|
986
|
+
# (scripts/_platforms.py). An explicit --margin is the more specific statement and wins;
|
|
987
|
+
# without either, the historical default 30 is unchanged.
|
|
988
|
+
# every tool resolves the spellings people write ('youtube-shorts' is 'shorts') in one place
|
|
989
|
+
args.platform = resolve_platform(args.platform)
|
|
990
|
+
if args.margin is None and args.platform and PLATFORMS[args.platform].get("frame"):
|
|
991
|
+
edge = PLATFORMS[args.platform]["safe"]["top" if args.position.startswith("top") else "bottom"]
|
|
992
|
+
args.margin = ass_units(edge)
|
|
993
|
+
info(f"--platform {args.platform}: caption margin {args.margin} ASS units ({edge * 100:.0f}% of the frame height, "
|
|
994
|
+
f"clear of the app's own UI)")
|
|
995
|
+
if args.margin is None:
|
|
996
|
+
args.margin = 30
|
|
795
997
|
# a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
|
|
796
998
|
# to be applied anyway and then refused as "animation is burn only" -- ignore it there
|
|
797
999
|
args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")
|
|
@@ -977,11 +1179,34 @@ def main() -> int:
|
|
|
977
1179
|
if not args.fonts_dir:
|
|
978
1180
|
args.fonts_dir = os.path.dirname(font_file)
|
|
979
1181
|
|
|
980
|
-
|
|
1182
|
+
# Emoji (1.15). Decided once, on the cues the burn will actually use: libass cannot place a
|
|
1183
|
+
# PNG, so the text keeps its place in the ASS (with the gap reserved) and each emoji becomes an
|
|
1184
|
+
# overlay composited after the ass= filter. A cue file that has no emoji costs nothing here.
|
|
1185
|
+
emoji_plan = None
|
|
1186
|
+
emoji_cues = None
|
|
1187
|
+
if not args.ass:
|
|
1188
|
+
if args.text or args.transcribe:
|
|
1189
|
+
src_cues = cues
|
|
1190
|
+
elif planned_cues is not None:
|
|
1191
|
+
src_cues = planned_cues
|
|
1192
|
+
elif os.path.exists(srt_path or ""):
|
|
1193
|
+
src_cues = parse_srt(srt_path)
|
|
1194
|
+
else:
|
|
1195
|
+
src_cues = []
|
|
1196
|
+
if src_cues and has_emoji("\n".join(t for _s, _e, t in src_cues)):
|
|
1197
|
+
emoji_cues, emoji_plan = plan_emoji(src_cues, args, play_w, play_h,
|
|
1198
|
+
brand if args.brand else None)
|
|
1199
|
+
# the PNG route and --emoji none both change the drawn text, so they need the generated ASS
|
|
1200
|
+
force_ass = bool(emoji_plan and (emoji_plan.get("overlays") or emoji_plan.get("mode") == "none"))
|
|
1201
|
+
if emoji_plan and emoji_plan.get("box_px"):
|
|
1202
|
+
args._emoji_box_px = emoji_plan["box_px"]
|
|
1203
|
+
|
|
1204
|
+
if (args.animate != "none" or args.karaoke or force_ass) and not args.ass:
|
|
981
1205
|
# both sources are already laid out: `cues` above, and srt_path was rewritten in place of
|
|
982
1206
|
# the caller's file when --offset/--max-lines/--min-duration changed anything
|
|
983
|
-
cues_for_ass =
|
|
984
|
-
|
|
1207
|
+
cues_for_ass = emoji_cues if emoji_cues is not None else (
|
|
1208
|
+
cues if (args.text or args.transcribe) else (
|
|
1209
|
+
planned_cues if planned_cues is not None else parse_srt(srt_path)))
|
|
985
1210
|
if args.karaoke and not getattr(args, "_word_timings", None):
|
|
986
1211
|
args._word_timings = whisper_word_timings(srt_path)
|
|
987
1212
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
@@ -1024,15 +1249,40 @@ def main() -> int:
|
|
|
1024
1249
|
if args.fonts_dir:
|
|
1025
1250
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
1026
1251
|
|
|
1027
|
-
cmd = ffmpeg_base() + ["-i", args.input
|
|
1252
|
+
cmd = ffmpeg_base() + ["-i", args.input]
|
|
1253
|
+
chains, emoji_inputs = emoji_filter_chain(emoji_plan or {}, "vsub", "vout") if emoji_plan else ([], [])
|
|
1254
|
+
if chains:
|
|
1255
|
+
for spec in emoji_inputs:
|
|
1256
|
+
cmd += spec
|
|
1257
|
+
asset = spec[-1]
|
|
1258
|
+
if asset not in STATE.plan_inputs:
|
|
1259
|
+
STATE.plan_inputs.append(asset)
|
|
1260
|
+
graph = ";".join([f"[0:v]{vf}[vsub]"] + chains)
|
|
1261
|
+
cmd += ["-filter_complex", graph, "-map", "[vout]"]
|
|
1262
|
+
else:
|
|
1263
|
+
cmd += ["-map", "0:v:0", "-vf", vf]
|
|
1028
1264
|
if meta.get("audio"):
|
|
1029
1265
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
1030
|
-
cmd +=
|
|
1266
|
+
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
1031
1267
|
cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
|
|
1032
1268
|
run(cmd)
|
|
1033
1269
|
result = probe(output, role="output")
|
|
1034
1270
|
info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
|
|
1035
|
-
|
|
1271
|
+
extra = {"notes": side_notes} if side_notes else {}
|
|
1272
|
+
if emoji_plan:
|
|
1273
|
+
notes = list(extra.get("notes") or [])
|
|
1274
|
+
if emoji_plan["mode"] == "mono":
|
|
1275
|
+
notes.append("emoji rendered monochrome (no colour path on this ffmpeg; "
|
|
1276
|
+
"--emoji-assets DIR for colour)")
|
|
1277
|
+
if emoji_plan["mode"] == "none":
|
|
1278
|
+
notes.append("emoji stripped from the drawn text (--emoji none)")
|
|
1279
|
+
if emoji_plan["missing"]:
|
|
1280
|
+
notes.append("no PNG asset for " + ", ".join(emoji_plan["missing"]))
|
|
1281
|
+
if notes:
|
|
1282
|
+
extra["notes"] = notes
|
|
1283
|
+
extra["emoji"] = {k: v for k, v in emoji_plan.items() if k not in ("overlays", "box_px")}
|
|
1284
|
+
extra["emoji"]["overlays"] = len(emoji_plan.get("overlays") or [])
|
|
1285
|
+
emit(output, **extra)
|
|
1036
1286
|
return 0
|
|
1037
1287
|
|
|
1038
1288
|
|
package/scripts/check.py
CHANGED
|
@@ -10,7 +10,8 @@ row's `fix` is the command that resolves it; a few of the less obvious FAILs
|
|
|
10
10
|
not a restatement of the spec value -- for a caller reporting this to someone
|
|
11
11
|
who doesn't already know why the spec says what it says.
|
|
12
12
|
|
|
13
|
-
Platforms: youtube, shorts, reels, tiktok, x, linkedin, broadcast (EBU R128),
|
|
13
|
+
Platforms: youtube, shorts, reels, tiktok, x, linkedin, facebook, broadcast (EBU R128),
|
|
14
|
+
podcast, custom -- one table, shared with export.py and the render.py templates
|
|
14
15
|
|
|
15
16
|
Examples:
|
|
16
17
|
python3 check.py final.mp4 --platform youtube
|
|
@@ -25,19 +26,15 @@ import sys
|
|
|
25
26
|
from fractions import Fraction
|
|
26
27
|
from typing import Any, Dict, List
|
|
27
28
|
|
|
29
|
+
from _platforms import PLATFORMS, PLATFORM_CHOICES, spec_of, resolve as resolve_platform
|
|
28
30
|
from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
"linkedin": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
|
|
37
|
-
"broadcast": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60, "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None, "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
|
|
38
|
-
"podcast": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
|
|
39
|
-
"custom": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
|
|
40
|
-
}
|
|
32
|
+
# The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
|
|
33
|
+
# render.py templates all read it, so a platform's loudness spec is stated once. Only the
|
|
34
|
+
# destinations that are compliance targets appear here; youtube-hdr / youtube-av1 are export
|
|
35
|
+
# presets of the youtube target, not separate specs.
|
|
36
|
+
SPECS: Dict[str, Dict[str, Any]] = {name: spec_of(name) for name in sorted(PLATFORMS)
|
|
37
|
+
if PLATFORMS[name]["check"] == name}
|
|
41
38
|
|
|
42
39
|
|
|
43
40
|
def measure_loudness(path: str) -> Dict[str, float]:
|
|
@@ -66,7 +63,7 @@ def aspect_name(w: int, h: int) -> str:
|
|
|
66
63
|
def main() -> int:
|
|
67
64
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
68
65
|
ap.add_argument("input")
|
|
69
|
-
ap.add_argument("--platform", choices=
|
|
66
|
+
ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
|
|
70
67
|
ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
|
|
71
68
|
ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
|
|
72
69
|
ap.add_argument("--lufs", type=float, help="override loudness target")
|
|
@@ -81,7 +78,7 @@ def main() -> int:
|
|
|
81
78
|
# spent a paragraph explaining why they left them alone. Without a named platform the
|
|
82
79
|
# judgement rows are advisory: WARN, not FAIL, and not counted as failed.
|
|
83
80
|
named = args.platform is not None
|
|
84
|
-
args.platform = args.platform or "youtube"
|
|
81
|
+
args.platform = resolve_platform(args.platform) or "youtube"
|
|
85
82
|
spec = dict(SPECS[args.platform])
|
|
86
83
|
if args.max_duration is not None:
|
|
87
84
|
spec["max_duration"] = args.max_duration
|
|
@@ -134,10 +131,10 @@ def main() -> int:
|
|
|
134
131
|
row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
|
|
135
132
|
row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
|
|
136
133
|
if spec["codecs"]:
|
|
137
|
-
row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.
|
|
134
|
+
row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + (PLATFORMS[args.platform].get("preset") or "youtube"),
|
|
138
135
|
reason="the platform's player may refuse to decode this codec at all, not just look worse")
|
|
139
136
|
pf = v.get("pix_fmt") or ""
|
|
140
|
-
if args.platform in ("reels", "tiktok", "x", "linkedin"):
|
|
137
|
+
if args.platform in ("reels", "tiktok", "x", "linkedin", "facebook"):
|
|
141
138
|
row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p",
|
|
142
139
|
reason="QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0")
|
|
143
140
|
if spec["sdr_only"] and v.get("hdr"):
|