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.
@@ -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) -> subprocess.CompletedProcess:
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
- first = run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False)
1900
+ attempt that failed for another reason and happened to succeed the second time.
1901
+
1902
+ `relabel_timecode`: the command writes its own start timecode (timeline.output_timecode_args),
1903
+ so a source's tmcd data track is not copied -- its label, at the source's rate, would win over
1904
+ the new one; the MOV/MP4 muxer builds the track again from the label."""
1905
+ skip_tmcd = ["-map", "-0:d:m:timecode"] if relabel_timecode else []
1906
+ first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + ["-c:s", "copy", "-c:d", "copy", output], check=False)
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
 
@@ -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
- NO_SPACE_SCRIPTS = ("ja", "zh", "ko")
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
- """Did a space stand at the break between these two wrapped lines? Only spaced scripts put one
308
- there -- a CJK/Thai break sits between two characters that were written with nothing between
309
- them, and re-joining them with a space would insert a character the cue never had."""
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 _fix_orphans(lines: "List[str]", max_em: float) -> "List[str]":
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], _break_spaced(lines[-2], lines[-1]))
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) -> "List[str]":
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, _spaced = prev[-1]
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], _break_spaced(lines[i - 1], 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) -> "List[str]":
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], _break_spaced(first, second))
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]") -> "Tuple[List[str], int]":
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], _break_spaced(first, second))
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) -> "List[str]":
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
- for atom, spaced in _atoms(raw):
622
- if slice_overlong and not current and text_width_em(atom) > max_em:
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
- chunk.extend(pieces[:-1])
627
- current = pieces[-1]
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 current and text_width_em(candidate) > max_em:
631
- chunk.append(current)
632
- if slice_overlong and text_width_em(atom) > max_em:
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
- chunk.append(current)
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]") -> "List[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
- rebalanced, _moved = _rebalance_phrase(fixed, max_em, lang)
658
- rebalanced = _fix_weak_lines(rebalanced, max_em)
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
- chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced)
685
- lines.extend(_balance(chunk, max_em, mode, lang) if balance else chunk)
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
- chunk = _greedy_chunks(raw, max_em, slice_overlong=slice_overlong, sliced=sliced)
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" else _balance(list(chunk), max_em, "measured", None))
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])