ffmpeg-skill 3.6.1 → 3.6.3
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 -8
- 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 +234 -1
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +55 -0
- package/scripts/_common/decision.py +80 -0
- package/scripts/_common/emit.py +8 -0
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +148 -7
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +46 -15
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +149 -11
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +44 -19
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/join.py +135 -14
- 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 +30 -23
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
|
@@ -332,7 +332,7 @@ class Context:
|
|
|
332
332
|
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec", "alpha_line",
|
|
333
333
|
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto",
|
|
334
334
|
"notes", "warnings", "fallbacks", "skipped", "audit_log", "stage_verification", "first_input", "audio_inputs", "video_inputs",
|
|
335
|
-
"source_timecode", "smpte_hint", "delivered")
|
|
335
|
+
"source_timecode", "smpte_hint", "delivered", "expected_timecode")
|
|
336
336
|
|
|
337
337
|
def __init__(self) -> None:
|
|
338
338
|
self.reset()
|
|
@@ -343,6 +343,7 @@ class Context:
|
|
|
343
343
|
self.first_input = None # (path, audio track count) of the first input probed: emit() reports tracks the output left out
|
|
344
344
|
self.video_inputs = set() # real paths of the inputs probed that have a picture: with two or more, which one the output shows is the tool's choice (sync --trim-second, a join)
|
|
345
345
|
self.source_timecode = None # (start timecode, duration) of the first input probed that carries one (time_arg's SMPTE hint)
|
|
346
|
+
self.expected_timecode = None # the start timecode a tool that moved the picture's start wrote (timeline.output_timecode_args; "" for none): source_carry compares it
|
|
346
347
|
self.smpte_hint = None # what die() says when an hh:mm:ss:ff flag read past the end looks like a source timecode (#411)
|
|
347
348
|
self.delivered: List[str] = [] # deliverables written before a later step (export --normalize, render's chapters/check): a stop then keeps them (#415)
|
|
348
349
|
self.audio_inputs = set() # real paths of the inputs probed that have sound: with two or more, whose tracks an output should keep is the tool's choice (grid --audio-from, a mix)
|
|
@@ -481,6 +482,14 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
481
482
|
STATE.progress = bool(getattr(args, "progress", False))
|
|
482
483
|
STATE.fast = bool(getattr(args, "fast", False))
|
|
483
484
|
STATE.overwrite = bool(getattr(args, "overwrite", False))
|
|
485
|
+
if STATE.plan and os.environ.get("FFMPEG_SKILL_READ_ONLY", "") not in ("", "0"):
|
|
486
|
+
# mcp/server.py runs the tools it annotates readOnlyHint (probe, check, cropdetect) with
|
|
487
|
+
# this set: a plan is the one file they could write, so it is refused here, after argparse,
|
|
488
|
+
# whatever spelling of --plan a raw argv used. Refused before the exit hook can write it.
|
|
489
|
+
plan_path, STATE.plan = STATE.plan, None
|
|
490
|
+
refuse("input", "read_only_plan_refused",
|
|
491
|
+
f"--plan {plan_path!r}: this tool runs read-only here (FFMPEG_SKILL_READ_ONLY) and writes no file; "
|
|
492
|
+
"run it from the command line to write a plan", plan=plan_path)
|
|
484
493
|
if STATE.plan:
|
|
485
494
|
# the plan is a file this run writes itself: the side-file guards apply (#408), so it
|
|
486
495
|
# never replaces an input, the output or an existing file without --overwrite
|
|
@@ -1229,7 +1238,13 @@ def refuse_existing_outputs(paths: Sequence[str]) -> None:
|
|
|
1229
1238
|
if output not in existing:
|
|
1230
1239
|
existing.append(output)
|
|
1231
1240
|
facts.append({"path": output, "resolved": real, "size": size, "mtime_ns": mtime_ns})
|
|
1241
|
+
# a file batch.py delivered on an earlier --watch pass, still exactly as it left it, is that
|
|
1242
|
+
# run's own: replaced without --overwrite, per path (anything else existing is still refused)
|
|
1243
|
+
if existing and not STATE.overwrite:
|
|
1244
|
+
keep = [i for i, p in enumerate(existing) if not owned_replaceable(p)]
|
|
1245
|
+
existing, facts = [existing[i] for i in keep], [facts[i] for i in keep]
|
|
1232
1246
|
if not existing or STATE.overwrite:
|
|
1247
|
+
report_owned_targets(paths)
|
|
1233
1248
|
return
|
|
1234
1249
|
hint = "pass --overwrite, or choose another -o path"
|
|
1235
1250
|
if len(existing) == 1:
|
|
@@ -1248,6 +1263,111 @@ def _remember_output(cmd: Sequence[str]) -> None:
|
|
|
1248
1263
|
STATE.written.add(os.path.realpath(output))
|
|
1249
1264
|
except OSError:
|
|
1250
1265
|
pass
|
|
1266
|
+
report_committed(output)
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
# set only by batch.py for the steps it runs: the path of a JSON file {"replaceable": {real path:
|
|
1270
|
+
# owned_identity()}, "report": directory}. "replaceable" is every file that batch process
|
|
1271
|
+
# delivered (a --watch loop's earlier passes) as it left it; a step replaces one of those without
|
|
1272
|
+
# --overwrite while it is still that file, and nothing else. Each process that passes the output
|
|
1273
|
+
# check writes <report>/<pid>.json: {"targets": the paths it was allowed to write, "committed":
|
|
1274
|
+
# {real path: owned_identity()} taken when each of them was written (report_committed), so batch
|
|
1275
|
+
# learns the side files (an .srt beside the video) it delivered as they were when written, not as
|
|
1276
|
+
# they are when the item ends -- an edit made while a later step ran is not adopted.
|
|
1277
|
+
OWNED_ENV = "_FFMPEG_SKILL_OWNED"
|
|
1278
|
+
_OWNED_CACHE: Dict[str, Dict[str, Any]] = {}
|
|
1279
|
+
_OWNED_TARGETS: Dict[str, List[str]] = {}
|
|
1280
|
+
_OWNED_COMMITTED: Dict[str, Dict[str, Dict[str, int]]] = {}
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def owned_identity(path: str) -> "Optional[Dict[str, int]]":
|
|
1284
|
+
"""{size, mtime_ns, ino, dev} of a regular file: what tells the file batch.py delivered from
|
|
1285
|
+
one put at the same path since (a replacement with the same size and mtime is a new inode).
|
|
1286
|
+
None when the path is not a regular file."""
|
|
1287
|
+
try:
|
|
1288
|
+
st = os.stat(path)
|
|
1289
|
+
except OSError:
|
|
1290
|
+
return None
|
|
1291
|
+
if not os.path.isfile(path):
|
|
1292
|
+
return None
|
|
1293
|
+
return {"size": st.st_size, "mtime_ns": st.st_mtime_ns, "ino": st.st_ino, "dev": st.st_dev}
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _owned_config(config: Optional[str] = None) -> Dict[str, Any]:
|
|
1297
|
+
config = config if config is not None else os.environ.get(OWNED_ENV, "")
|
|
1298
|
+
if not config:
|
|
1299
|
+
return {}
|
|
1300
|
+
if config not in _OWNED_CACHE:
|
|
1301
|
+
try:
|
|
1302
|
+
with open(config, encoding="utf-8") as fh:
|
|
1303
|
+
doc = json.load(fh)
|
|
1304
|
+
except (OSError, ValueError):
|
|
1305
|
+
doc = {}
|
|
1306
|
+
_OWNED_CACHE[config] = doc if isinstance(doc, dict) else {}
|
|
1307
|
+
return _OWNED_CACHE[config]
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def owned_replaceable(path: str, config: Optional[str] = None) -> bool:
|
|
1311
|
+
"""Whether `path` is a file the batch.py process running this step delivered, still with the
|
|
1312
|
+
identity it had then (checked now, at the output check, not when the step was planned)."""
|
|
1313
|
+
replaceable = _owned_config(config).get("replaceable")
|
|
1314
|
+
if not isinstance(replaceable, dict):
|
|
1315
|
+
return False
|
|
1316
|
+
try:
|
|
1317
|
+
real = os.path.realpath(path)
|
|
1318
|
+
except OSError:
|
|
1319
|
+
return False
|
|
1320
|
+
expected = replaceable.get(real)
|
|
1321
|
+
return isinstance(expected, dict) and owned_identity(real) == expected
|
|
1322
|
+
|
|
1323
|
+
|
|
1324
|
+
def report_owned_targets(paths: Sequence[Optional[str]], config: Optional[str] = None) -> None:
|
|
1325
|
+
"""Under batch.py: record paths this process passed the output check for, in the report
|
|
1326
|
+
directory its config names. Nothing outside batch.py; a report that cannot be written only
|
|
1327
|
+
means the next pass refuses that file as someone else's."""
|
|
1328
|
+
config = config if config is not None else os.environ.get(OWNED_ENV, "")
|
|
1329
|
+
report = _owned_config(config).get("report")
|
|
1330
|
+
if not isinstance(report, str) or not report:
|
|
1331
|
+
return
|
|
1332
|
+
targets = _OWNED_TARGETS.setdefault(config, [])
|
|
1333
|
+
for p in paths:
|
|
1334
|
+
if p:
|
|
1335
|
+
try:
|
|
1336
|
+
real = os.path.realpath(p)
|
|
1337
|
+
except OSError:
|
|
1338
|
+
continue
|
|
1339
|
+
if real not in targets:
|
|
1340
|
+
targets.append(real)
|
|
1341
|
+
_write_owned_report(config, report)
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _write_owned_report(config: str, report: str) -> None:
|
|
1345
|
+
doc = {"targets": _OWNED_TARGETS.get(config, []), "committed": _OWNED_COMMITTED.get(config, {})}
|
|
1346
|
+
try:
|
|
1347
|
+
with open(os.path.join(report, f"{os.getpid()}.json"), "w", encoding="utf-8") as fh:
|
|
1348
|
+
json.dump(doc, fh)
|
|
1349
|
+
except OSError:
|
|
1350
|
+
pass
|
|
1351
|
+
|
|
1352
|
+
|
|
1353
|
+
def report_committed(path: Optional[str], config: Optional[str] = None) -> None:
|
|
1354
|
+
"""Under batch.py: record the identity `path` has right now, just after this process wrote it,
|
|
1355
|
+
if it passed this process's output check. What batch later lets a step replace is that file
|
|
1356
|
+
as it was written; one edited afterwards no longer matches and is refused."""
|
|
1357
|
+
config = config if config is not None else os.environ.get(OWNED_ENV, "")
|
|
1358
|
+
report = _owned_config(config).get("report")
|
|
1359
|
+
if not path or not isinstance(report, str) or not report:
|
|
1360
|
+
return
|
|
1361
|
+
try:
|
|
1362
|
+
real = os.path.realpath(path)
|
|
1363
|
+
except OSError:
|
|
1364
|
+
return
|
|
1365
|
+
if real not in _OWNED_TARGETS.get(config, []):
|
|
1366
|
+
return
|
|
1367
|
+
ident = owned_identity(real)
|
|
1368
|
+
if ident:
|
|
1369
|
+
_OWNED_COMMITTED.setdefault(config, {})[real] = ident
|
|
1370
|
+
_write_owned_report(config, report)
|
|
1251
1371
|
|
|
1252
1372
|
|
|
1253
1373
|
def remember_written(*paths: Optional[str]) -> None:
|
|
@@ -1260,6 +1380,7 @@ def remember_written(*paths: Optional[str]) -> None:
|
|
|
1260
1380
|
STATE.written.add(os.path.realpath(path))
|
|
1261
1381
|
except OSError:
|
|
1262
1382
|
pass
|
|
1383
|
+
report_committed(path)
|
|
1263
1384
|
|
|
1264
1385
|
|
|
1265
1386
|
def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
|
|
@@ -1649,13 +1770,14 @@ def child_limit(per_call: Optional[float] = None) -> Optional[float]:
|
|
|
1649
1770
|
return (limit * 4 + 60) if limit else None
|
|
1650
1771
|
|
|
1651
1772
|
|
|
1652
|
-
def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None
|
|
1773
|
+
def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None,
|
|
1774
|
+
env: Optional[Dict[str, str]] = None) -> subprocess.CompletedProcess:
|
|
1653
1775
|
"""Run a sibling script (`argv[0]` is the script path) under child_limit(). On overrun the
|
|
1654
1776
|
child is killed and a CompletedProcess is returned whose stdout is this skill's own failure
|
|
1655
1777
|
document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
|
|
1656
|
-
exactly as they would from the child itself."""
|
|
1778
|
+
exactly as they would from the child itself. `env` replaces the inherited environment."""
|
|
1657
1779
|
limit = child_limit(per_call)
|
|
1658
|
-
child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
|
|
1780
|
+
child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", env=env)
|
|
1659
1781
|
_watch(child, []) # a sibling script removes its own partial output; there is none of ours to clean
|
|
1660
1782
|
try:
|
|
1661
1783
|
out, err = child.communicate(timeout=limit)
|
|
@@ -1674,6 +1796,20 @@ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subpro
|
|
|
1674
1796
|
return subprocess.CompletedProcess(list(argv), 124, json.dumps(doc), partial + f"\nerror: {msg}\n")
|
|
1675
1797
|
|
|
1676
1798
|
|
|
1799
|
+
def option_args(flag: str, value: Any) -> List[str]:
|
|
1800
|
+
"""One option and its value for a sibling tool's argparse, as [flag, value] -- or as the single
|
|
1801
|
+
token `flag=value` when the value starts with '-'. argparse reads a separate token that starts
|
|
1802
|
+
with '-' (and is not a negative number) as the next option, so overlay text "-50%" was refused
|
|
1803
|
+
as "expected one argument" and a value like "--plan=x" became an option of its own. The joined
|
|
1804
|
+
form is read as the value whatever it holds. `flag` must be a long option: `-o=x` is not."""
|
|
1805
|
+
text = str(value)
|
|
1806
|
+
if text.startswith("-"):
|
|
1807
|
+
if not flag.startswith("--"):
|
|
1808
|
+
raise ValueError(f"option_args: {flag!r} is not a long option")
|
|
1809
|
+
return [f"{flag}={text}"]
|
|
1810
|
+
return [flag, text]
|
|
1811
|
+
|
|
1812
|
+
|
|
1677
1813
|
def child_args() -> List[str]:
|
|
1678
1814
|
"""The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
|
|
1679
1815
|
so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
|
|
@@ -1749,7 +1885,7 @@ def subtitle_container_holds(codec: str, out_ext: str) -> bool:
|
|
|
1749
1885
|
return True
|
|
1750
1886
|
|
|
1751
1887
|
|
|
1752
|
-
def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
1888
|
+
def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool = False) -> bool:
|
|
1753
1889
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
1754
1890
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
1755
1891
|
there are none). A source whose subtitle codec cannot be copied into the target container
|
|
@@ -1761,8 +1897,13 @@ def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
|
|
|
1761
1897
|
The retry is recorded with the first attempt's error (`diagnostics.fallbacks`,
|
|
1762
1898
|
`non_av_streams_dropped`): the caller's `dropped_non_av_streams` says that streams went, the
|
|
1763
1899
|
record says why, which is the only way to tell a container that cannot hold them from a first
|
|
1764
|
-
attempt that failed for another reason and happened to succeed the second time.
|
|
1765
|
-
|
|
1900
|
+
attempt that failed for another reason and happened to succeed the second time.
|
|
1901
|
+
|
|
1902
|
+
`relabel_timecode`: the command writes its own start timecode (timeline.output_timecode_args),
|
|
1903
|
+
so a source's tmcd data track is not copied -- its label, at the source's rate, would win over
|
|
1904
|
+
the new one; the MOV/MP4 muxer builds the track again from the label."""
|
|
1905
|
+
skip_tmcd = ["-map", "-0:d:m:timecode"] if relabel_timecode else []
|
|
1906
|
+
first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + ["-c:s", "copy", "-c:d", "copy", output], check=False)
|
|
1766
1907
|
if first.returncode == 0:
|
|
1767
1908
|
return False
|
|
1768
1909
|
if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):
|
|
@@ -153,6 +153,45 @@ def _tc_label(frames: int, rate: Fraction, drop: bool) -> str:
|
|
|
153
153
|
return _tc(frames, nominal)[:-3] + ";" + _tc(frames, nominal)[-2:]
|
|
154
154
|
|
|
155
155
|
|
|
156
|
+
def output_timecode_args(meta: Dict[str, Any], first_frame: Optional[float], rate: Optional[float] = None) -> List[str]:
|
|
157
|
+
"""The arguments that give an output the start timecode of the source frame it starts on:
|
|
158
|
+
`first_frame` seconds after the source picture's first frame (negative: a head the tool
|
|
159
|
+
added), or None to write no start timecode at all (a join, whose later frames no single start
|
|
160
|
+
label describes). A tool that moves the start of the picture used to write the source's own
|
|
161
|
+
start timecode unchanged: FFmpeg copies the video stream's `timecode` tag, and the MOV/MP4
|
|
162
|
+
muxer rebuilds a tmcd track from it, so a cut from 1 s was stamped 01:00:00:00 for frame 30.
|
|
163
|
+
Both the stream and the container tag are set (Matroska keeps a stale stream tag otherwise;
|
|
164
|
+
MXF reads the container's). Nothing for a source without a start timecode. The label also
|
|
165
|
+
goes on STATE.expected_timecode, which source_carry compares the written file with: an
|
|
166
|
+
MXF, which always writes one, reads 00:00:00:00 when none is wanted and is listed as dropped.
|
|
167
|
+
`rate` is the frame rate the output is delivered at when the tool changes it (fit --fps, a
|
|
168
|
+
VFR source conformed to a constant rate), None for the source's own: the source's label is
|
|
169
|
+
read at the source's rate and the output's is counted at the output's, so 01:00:00:12 at 25
|
|
170
|
+
fps (0.48 s past the hour) is 01:00:00:24 at 50."""
|
|
171
|
+
from _common.runner import STATE
|
|
172
|
+
tc = (meta or {}).get("timecode") or {}
|
|
173
|
+
fps = ((meta or {}).get("video") or {}).get("fps")
|
|
174
|
+
if not tc.get("value") or not fps:
|
|
175
|
+
return []
|
|
176
|
+
label = ""
|
|
177
|
+
if first_frame is not None:
|
|
178
|
+
src_rate = exact_rate(float(fps))
|
|
179
|
+
out_rate = exact_rate(float(rate)) if rate else src_rate
|
|
180
|
+
drop = _drop_frame(tc.get("drop_frame"), out_rate)
|
|
181
|
+
nominal = round(float(out_rate))
|
|
182
|
+
if nominal >= 1:
|
|
183
|
+
day = (nominal * 600 - (nominal // 15) * 9) * 144 if drop else nominal * 86400
|
|
184
|
+
# the source label and the offset to the first frame, both in output frames, rounded
|
|
185
|
+
# once: 00:00:00:02 at 25 fps plus 0.08 s is 4.8 frames at 30, so frame 5, not 2 + 2
|
|
186
|
+
pos = (Fraction(start_tc_frames(tc, src_rate)) / src_rate
|
|
187
|
+
+ Fraction(first_frame).limit_denominator(1000000)) * out_rate
|
|
188
|
+
label = _tc_label(round(pos) % day, out_rate, drop)
|
|
189
|
+
# below 1 fps no frame count can name a frame: the label is removed, as when the first
|
|
190
|
+
# frame is unknown, rather than divide by a zero-length day
|
|
191
|
+
STATE.expected_timecode = label
|
|
192
|
+
return ["-metadata:s:v:0", f"timecode={label}", "-metadata", f"timecode={label}"]
|
|
193
|
+
|
|
194
|
+
|
|
156
195
|
def build(proj: Dict[str, Any], probes: Dict[str, Dict[str, Any]], rel) -> Dict[str, Any]:
|
|
157
196
|
"""The editor-neutral timeline: every time in frames at `rate`.
|
|
158
197
|
|
package/scripts/_common/wrap.py
CHANGED
|
@@ -29,7 +29,11 @@ ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6,
|
|
|
29
29
|
# a word. A Thai run is therefore one atom, broken only at the spaces (or the manual `|`) the
|
|
30
30
|
# writer put there; an over-long run stays long on its own line, the rule long Latin words
|
|
31
31
|
# already follow.
|
|
32
|
-
|
|
32
|
+
# Korean is NOT here: it is written with spaces between words (eojeol), so a Hangul run is a word
|
|
33
|
+
# like a Latin one -- broken at its spaces, and cut inside only by _slice_atom's escape hatch.
|
|
34
|
+
# Per-character atoms broke `시작|합니다` and, once a pass moved the break, joined the halves
|
|
35
|
+
# with no space (`늦지마세요`) while the result said text_unchanged.
|
|
36
|
+
NO_SPACE_SCRIPTS = ("ja", "zh")
|
|
33
37
|
NO_BOUNDARY_SCRIPTS = ("th",) # per-character breaking would chop words: keep the run whole
|
|
34
38
|
|
|
35
39
|
|
|
@@ -304,9 +308,11 @@ def _join(left: str, atom: str, spaced: bool) -> str:
|
|
|
304
308
|
|
|
305
309
|
|
|
306
310
|
def _break_spaced(first: str, second: str) -> bool:
|
|
307
|
-
"""
|
|
308
|
-
|
|
309
|
-
|
|
311
|
+
"""A guess at whether a space stood at the break between these two wrapped lines, from the
|
|
312
|
+
scripts on each side. The wrapper's own passes do not use it: the greedy fill records the real
|
|
313
|
+
answer per break (`gaps`) and every pass carries it along, because a script cannot tell -- a
|
|
314
|
+
Japanese cue written with spaces (`はい そうです`) lost one whenever a pass moved a break that
|
|
315
|
+
stood at a space. Kept as the fallback for a caller that has only the two strings."""
|
|
310
316
|
if not first or not second:
|
|
311
317
|
return False
|
|
312
318
|
return char_script(first[-1]) not in NO_SPACE_SCRIPTS and char_script(second[0]) not in NO_SPACE_SCRIPTS \
|
|
@@ -477,7 +483,18 @@ def best_break(atoms: "Sequence[Tuple[str, bool]]", max_em: float,
|
|
|
477
483
|
return None if best is None else best[1]
|
|
478
484
|
|
|
479
485
|
|
|
480
|
-
def
|
|
486
|
+
def _gaps(lines: "List[str]", gaps: "Optional[List[bool]]") -> "List[bool]":
|
|
487
|
+
"""The per-break space flags a pass reads and updates: the caller's list (updated in place, so
|
|
488
|
+
the next pass sees the moved breaks), or the _break_spaced() guess when the caller has none."""
|
|
489
|
+
if gaps is None:
|
|
490
|
+
return [_break_spaced(a, b) for a, b in zip(lines, lines[1:])]
|
|
491
|
+
if len(gaps) != max(len(lines) - 1, 0):
|
|
492
|
+
raise ValueError("gaps must hold one flag per break")
|
|
493
|
+
return gaps
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _fix_orphans(lines: "List[str]", max_em: float,
|
|
497
|
+
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
481
498
|
"""No last line that is a single stranded atom.
|
|
482
499
|
|
|
483
500
|
Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
|
|
@@ -486,6 +503,7 @@ def _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
|
|
|
486
503
|
onto it -- but only while the result still fits and the line above does not become an orphan
|
|
487
504
|
itself, so a two-word cue is never made worse."""
|
|
488
505
|
lines = list(lines)
|
|
506
|
+
gaps = _gaps(lines, gaps)
|
|
489
507
|
for _ in range(len(lines)):
|
|
490
508
|
if len(lines) < 2:
|
|
491
509
|
break
|
|
@@ -499,14 +517,16 @@ def _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
|
|
|
499
517
|
new_prev = ""
|
|
500
518
|
for atom, sp in prev[:-1]:
|
|
501
519
|
new_prev = _join(new_prev, atom, sp)
|
|
502
|
-
new_last = _join(moved, tail[0][0],
|
|
520
|
+
new_last = _join(moved, tail[0][0], gaps[-1])
|
|
503
521
|
if text_width_em(new_last) > max_em or text_width_em(new_prev) < ORPHAN_MIN_EM:
|
|
504
522
|
break
|
|
505
523
|
lines[-2], lines[-1] = new_prev, new_last
|
|
524
|
+
gaps[-1] = spaced
|
|
506
525
|
return lines
|
|
507
526
|
|
|
508
527
|
|
|
509
|
-
def _fix_weak_lines(lines: "List[str]", max_em: float
|
|
528
|
+
def _fix_weak_lines(lines: "List[str]", max_em: float,
|
|
529
|
+
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
510
530
|
"""R2, generalised: _fix_orphans run at *every* boundary, against _is_weak_line.
|
|
511
531
|
|
|
512
532
|
1.15 only ever looked at the last line, so a stranded digit or kana in the middle of a
|
|
@@ -514,6 +534,7 @@ def _fix_weak_lines(lines: "List[str]", max_em: float) -> "List[str]":
|
|
|
514
534
|
of the line above moves down onto it -- with 1.15's two guards intact (the result must still
|
|
515
535
|
fit, and the line above must not itself become weak), so the line count never changes."""
|
|
516
536
|
lines = list(lines)
|
|
537
|
+
gaps = _gaps(lines, gaps)
|
|
517
538
|
for i in range(len(lines) - 1, 0, -1):
|
|
518
539
|
for _ in range(len(lines)):
|
|
519
540
|
if not _is_weak_line(lines[i]):
|
|
@@ -521,19 +542,21 @@ def _fix_weak_lines(lines: "List[str]", max_em: float) -> "List[str]":
|
|
|
521
542
|
prev = _atoms(lines[i - 1])
|
|
522
543
|
if len(prev) < 2:
|
|
523
544
|
break
|
|
524
|
-
moved,
|
|
545
|
+
moved, spaced = prev[-1]
|
|
525
546
|
new_prev = ""
|
|
526
547
|
for atom, sp in prev[:-1]:
|
|
527
548
|
new_prev = _join(new_prev, atom, sp)
|
|
528
|
-
new_last = _join(moved, lines[i],
|
|
549
|
+
new_last = _join(moved, lines[i], gaps[i - 1])
|
|
529
550
|
if text_width_em(new_last) > max_em or _is_weak_line(new_prev) \
|
|
530
551
|
or text_width_em(new_prev) < ORPHAN_MIN_EM:
|
|
531
552
|
break
|
|
532
553
|
lines[i - 1], lines[i] = new_prev, new_last
|
|
554
|
+
gaps[i - 1] = spaced
|
|
533
555
|
return lines
|
|
534
556
|
|
|
535
557
|
|
|
536
|
-
def _rebalance(lines: "List[str]", max_em: float
|
|
558
|
+
def _rebalance(lines: "List[str]", max_em: float,
|
|
559
|
+
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
537
560
|
"""Move each break to the one that minimises the widest line of the pair, without changing the
|
|
538
561
|
line count.
|
|
539
562
|
|
|
@@ -544,11 +567,12 @@ def _rebalance(lines: "List[str]", max_em: float) -> "List[str]":
|
|
|
544
567
|
if len(lines) < 2:
|
|
545
568
|
return lines
|
|
546
569
|
out = list(lines)
|
|
570
|
+
gaps = _gaps(out, gaps)
|
|
547
571
|
for i in range(len(out) - 1):
|
|
548
572
|
first, second = out[i], out[i + 1]
|
|
549
573
|
tail_atoms = _atoms(second)
|
|
550
574
|
if tail_atoms:
|
|
551
|
-
tail_atoms[0] = (tail_atoms[0][0],
|
|
575
|
+
tail_atoms[0] = (tail_atoms[0][0], gaps[i])
|
|
552
576
|
atoms = _atoms(first) + tail_atoms
|
|
553
577
|
if not atoms or any(char_script(ch) in NO_SPACE_SCRIPTS for ch in first + second):
|
|
554
578
|
continue
|
|
@@ -568,13 +592,15 @@ def _rebalance(lines: "List[str]", max_em: float) -> "List[str]":
|
|
|
568
592
|
continue
|
|
569
593
|
key = (max(wa, wb), abs(wa - wb))
|
|
570
594
|
if best is None or key < best[0]:
|
|
571
|
-
best = (key, a, b)
|
|
595
|
+
best = (key, a, b, atoms[cut][1])
|
|
572
596
|
if best is not None:
|
|
573
597
|
out[i], out[i + 1] = best[1], best[2]
|
|
598
|
+
gaps[i] = best[3]
|
|
574
599
|
return out
|
|
575
600
|
|
|
576
601
|
|
|
577
|
-
def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]"
|
|
602
|
+
def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]",
|
|
603
|
+
gaps: "Optional[List[bool]]" = None) -> "Tuple[List[str], int]":
|
|
578
604
|
"""_rebalance with R1-R4 deciding, for every script rather than spaced ones only.
|
|
579
605
|
|
|
580
606
|
Returns the new lines and how many breaks a phrase rule moved away from the position 1.15's
|
|
@@ -582,12 +608,13 @@ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]")
|
|
|
582
608
|
if len(lines) < 2:
|
|
583
609
|
return list(lines), 0
|
|
584
610
|
out = list(lines)
|
|
611
|
+
gaps = _gaps(out, gaps)
|
|
585
612
|
moved = 0
|
|
586
613
|
for i in range(len(out) - 1):
|
|
587
614
|
first, second = out[i], out[i + 1]
|
|
588
615
|
tail_atoms = _atoms(second)
|
|
589
616
|
if tail_atoms:
|
|
590
|
-
tail_atoms[0] = (tail_atoms[0][0],
|
|
617
|
+
tail_atoms[0] = (tail_atoms[0][0], gaps[i])
|
|
591
618
|
atoms = _split_hyphens(_atoms(first) + tail_atoms)
|
|
592
619
|
if len(atoms) < 2:
|
|
593
620
|
continue
|
|
@@ -602,11 +629,13 @@ def _rebalance_phrase(lines: "List[str]", max_em: float, lang: "Optional[str]")
|
|
|
602
629
|
if (a, b) != (first, second):
|
|
603
630
|
moved += 1
|
|
604
631
|
out[i], out[i + 1] = a, b
|
|
632
|
+
gaps[i] = atoms[cut][1]
|
|
605
633
|
return out, moved
|
|
606
634
|
|
|
607
635
|
|
|
608
636
|
def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
|
|
609
|
-
sliced: "Optional[List[int]]" = None
|
|
637
|
+
sliced: "Optional[List[int]]" = None,
|
|
638
|
+
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
610
639
|
"""The greedy fill on its own: the line count every mode must keep.
|
|
611
640
|
|
|
612
641
|
`slice_overlong` (off by default -- only caption.py's final burn-in pass turns it on, never
|
|
@@ -615,47 +644,65 @@ def _greedy_chunks(raw: str, max_em: float, *, slice_overlong: bool = False,
|
|
|
615
644
|
katakana run that already fits is completely untouched -- is hard-sliced by _slice_atom()
|
|
616
645
|
instead of kept whole. `sliced`, when given, gets one entry (the number of extra lines that
|
|
617
646
|
one atom produced) per atom actually sliced, which is how the caller counts
|
|
618
|
-
`broken_inside_word` without re-deriving it from the output lines.
|
|
647
|
+
`broken_inside_word` without re-deriving it from the output lines.
|
|
648
|
+
|
|
649
|
+
`gaps`, when given, is filled with one flag per break: did a space stand there in `raw`? The
|
|
650
|
+
passes in _balance() carry it, so a break they move keeps (or drops) exactly that space."""
|
|
619
651
|
current = ""
|
|
652
|
+
current_gap = False # did a space stand before `current`'s first atom
|
|
620
653
|
chunk: "List[str]" = []
|
|
621
|
-
|
|
622
|
-
|
|
654
|
+
breaks: "List[bool]" = []
|
|
655
|
+
|
|
656
|
+
def emit(line: str, gap_before: bool) -> None:
|
|
657
|
+
if chunk:
|
|
658
|
+
breaks.append(gap_before)
|
|
659
|
+
chunk.append(line)
|
|
660
|
+
|
|
661
|
+
def start_line(atom: str, spaced: bool) -> "Tuple[str, bool]":
|
|
662
|
+
"""The new current line opening with `atom`, sliced first when it is too wide alone."""
|
|
663
|
+
if slice_overlong and text_width_em(atom) > max_em:
|
|
623
664
|
pieces = _slice_atom(atom, max_em)
|
|
624
665
|
if sliced is not None and len(pieces) > 1:
|
|
625
666
|
sliced.append(len(pieces) - 1)
|
|
626
|
-
|
|
627
|
-
|
|
667
|
+
if len(pieces) > 1:
|
|
668
|
+
emit(pieces[0], spaced)
|
|
669
|
+
for piece in pieces[1:-1]:
|
|
670
|
+
emit(piece, False)
|
|
671
|
+
return pieces[-1], False
|
|
672
|
+
return atom, spaced
|
|
673
|
+
|
|
674
|
+
for atom, spaced in _atoms(raw):
|
|
675
|
+
if not current:
|
|
676
|
+
current, current_gap = start_line(atom, spaced)
|
|
628
677
|
continue
|
|
629
678
|
candidate = _join(current, atom, spaced)
|
|
630
|
-
if
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
pieces = _slice_atom(atom, max_em)
|
|
634
|
-
if sliced is not None and len(pieces) > 1:
|
|
635
|
-
sliced.append(len(pieces) - 1)
|
|
636
|
-
chunk.extend(pieces[:-1])
|
|
637
|
-
current = pieces[-1]
|
|
638
|
-
else:
|
|
639
|
-
current = atom
|
|
679
|
+
if text_width_em(candidate) > max_em:
|
|
680
|
+
emit(current, current_gap)
|
|
681
|
+
current, current_gap = start_line(atom, spaced)
|
|
640
682
|
else:
|
|
641
683
|
current = candidate
|
|
642
684
|
if current:
|
|
643
|
-
|
|
685
|
+
emit(current, current_gap)
|
|
686
|
+
if gaps is not None:
|
|
687
|
+
gaps[:] = breaks
|
|
644
688
|
return chunk
|
|
645
689
|
|
|
646
690
|
|
|
647
|
-
def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]"
|
|
691
|
+
def _balance(chunk: "List[str]", max_em: float, mode: str, lang: "Optional[str]",
|
|
692
|
+
gaps: "Optional[List[bool]]" = None) -> "List[str]":
|
|
648
693
|
"""The post-passes for one greedy chunk, in the mode's own order. Never changes the count:
|
|
649
694
|
a pass that would is discarded, exactly as 1.15 did."""
|
|
650
695
|
if len(chunk) < 2:
|
|
651
696
|
return chunk
|
|
697
|
+
gaps = list(gaps) if gaps is not None else None
|
|
652
698
|
if mode == "measured":
|
|
653
|
-
fixed = _fix_orphans(chunk, max_em)
|
|
654
|
-
rebalanced = _rebalance(fixed, max_em)
|
|
699
|
+
fixed = _fix_orphans(chunk, max_em, gaps)
|
|
700
|
+
rebalanced = _rebalance(fixed, max_em, list(gaps) if gaps is not None else None)
|
|
655
701
|
else:
|
|
656
|
-
fixed = _fix_weak_lines(_fix_orphans(chunk, max_em), max_em)
|
|
657
|
-
|
|
658
|
-
rebalanced =
|
|
702
|
+
fixed = _fix_weak_lines(_fix_orphans(chunk, max_em, gaps), max_em, gaps)
|
|
703
|
+
later = list(gaps) if gaps is not None else None
|
|
704
|
+
rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang, later)
|
|
705
|
+
rebalanced = _fix_weak_lines(rebalanced, max_em, later)
|
|
659
706
|
if len(rebalanced) == len(chunk):
|
|
660
707
|
return rebalanced
|
|
661
708
|
return fixed if len(fixed) == len(chunk) else chunk
|
|
@@ -681,8 +728,9 @@ def wrap_text(text: str, max_em: float, *, balance: bool = True, mode: str = "ph
|
|
|
681
728
|
for raw in text.split("\n"):
|
|
682
729
|
if not raw.strip():
|
|
683
730
|
continue
|
|
684
|
-
|
|
685
|
-
|
|
731
|
+
gaps: "List[bool]" = []
|
|
732
|
+
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps)
|
|
733
|
+
lines.extend(_balance(chunk, max_em, mode, lang, gaps) if balance else chunk)
|
|
686
734
|
return lines or [text]
|
|
687
735
|
def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
|
|
688
736
|
lang: "Optional[str]" = None, slice_overlong: bool = False,
|
|
@@ -701,10 +749,12 @@ def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
|
|
|
701
749
|
for raw in text.split("\n"):
|
|
702
750
|
if not raw.strip():
|
|
703
751
|
continue
|
|
704
|
-
|
|
752
|
+
gaps: "List[bool]" = []
|
|
753
|
+
chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced, gaps=gaps)
|
|
705
754
|
greedy.extend(chunk)
|
|
706
|
-
wrapped.extend(_balance(list(chunk), max_em, mode, lang))
|
|
707
|
-
measured.extend(chunk if mode == "measured"
|
|
755
|
+
wrapped.extend(_balance(list(chunk), max_em, mode, lang, gaps))
|
|
756
|
+
measured.extend(chunk if mode == "measured"
|
|
757
|
+
else _balance(list(chunk), max_em, "measured", None, gaps))
|
|
708
758
|
if not greedy:
|
|
709
759
|
greedy = [text]
|
|
710
760
|
return (wrapped or [text], greedy, measured or [text])
|