ffmpeg-skill 1.19.2 → 1.20.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/docs/contract.md +13 -13
- package/package.json +1 -1
- package/scripts/graphics.py +12 -1
- package/scripts/look.py +133 -5
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`1.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.20.0`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
|
|
|
88
88
|
|
|
89
89
|
| What 2.0 removes | Since | Replacement | To be ready today |
|
|
90
90
|
|---|---|---|---|
|
|
91
|
-
| The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.
|
|
92
|
-
| `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.
|
|
93
|
-
| `json` and `progress` in the MCP `inputSchema` | 1.
|
|
94
|
-
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.
|
|
95
|
-
| Overwriting an existing output with only a warning | 1.
|
|
91
|
+
| The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.20.0 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
|
|
92
|
+
| `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.20.0 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
|
|
93
|
+
| `json` and `progress` in the MCP `inputSchema` | 1.20.0 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
|
|
94
|
+
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.20.0 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
|
|
95
|
+
| Overwriting an existing output with only a warning | 1.20.0 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
|
|
96
96
|
|
|
97
97
|
## Skill
|
|
98
98
|
|
|
99
99
|
```json
|
|
100
100
|
{
|
|
101
101
|
"contract_version": "1.0",
|
|
102
|
-
"deprecated": [{"what": "...", "since": "1.
|
|
103
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.
|
|
102
|
+
"deprecated": [{"what": "...", "since": "1.20.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
103
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.20.0", "execution_mode": "local", "kind": "execution",
|
|
104
104
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
105
105
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
106
106
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
@@ -128,7 +128,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
128
128
|
| `output_schema` | what `--json` prints on stdout |
|
|
129
129
|
| `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
|
|
130
130
|
| `supports_json` | whether `--json` exists |
|
|
131
|
-
| `supports_json_brief` | whether `--json-brief` exists (1.
|
|
131
|
+
| `supports_json_brief` | whether `--json-brief` exists (1.20.0): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
|
|
132
132
|
| `mutates_input` | always `false`: no tool overwrites its input |
|
|
133
133
|
| `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
|
|
134
134
|
| `verification` | `{required, tools}`: which tools to run on the output afterwards |
|
|
@@ -434,7 +434,7 @@ given a different type):
|
|
|
434
434
|
|
|
435
435
|
| key | tool | what it holds |
|
|
436
436
|
|---|---|---|
|
|
437
|
-
| `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not. `broken_inside_word` (1.
|
|
437
|
+
| `caption` | `caption.py` | the cue-layout counts the run only printed before (`shifted`, `wrapped`, `rebalanced`, `split`, `extended`, `dropped`) plus `wrap` (`"phrase"` or `"measured"`) and `phrase_breaks`, the number of cues a phrase rule broke somewhere the 1.15 width rule would not. `broken_inside_word` (1.20.0) counts atoms hard-sliced at the live column's edge because they did not fit alone even at the size floor — never a rewrite, the sliced pieces are the exact original characters. `overlong` is now residual: it fires only when even a single character is wider than the column |
|
|
438
438
|
| `tracks`, `subtitle_tracks` | `caption.py --mode mux` | one entry per subtitle stream in the output — `{index, file, language, title, codec, default, cues, kept_from_input}`; a stream the input already carried has `file: null` and `kept_from_input: true`. `subtitle_tracks` is the total. Every field describes the file as written, not as asked for: an MPEG-4 output reports `title: null` (the muxer stores none) and `default: true` on its first track (the muxer always enables it), each with a `notes` line |
|
|
439
439
|
| `auto_chapters` | `metadata.py --auto-chapters` | `{source, min_chapter, max_chapters, proposed, kept, titles, chapters, description_block, files}`. `titles` is always `"placeholder"`: the machine-readable form of "the skill did not name these". Each chapter carries its `evidence` (`start`, `silence`, `scene`, or `silence+scene` with the span, its length and the cut time) |
|
|
440
440
|
| `audiogram` | `waveform.py` (every run) | `{style, background, image, position, vis_height, platform, captions, title, stages, verified}`. `background` is `"image"` or `"color"`; `verified` is true when the render probes at the asked-for frame size, frame rate and within 0.05 s of the source audio, and is `false` under `--dry-run`, where nothing was rendered to verify |
|
|
@@ -450,7 +450,7 @@ Per-tool keys added in 1.17, all additive:
|
|
|
450
450
|
| `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
|
|
451
451
|
| `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
|
|
452
452
|
|
|
453
|
-
Per-tool keys added in 1.
|
|
453
|
+
Per-tool keys added in 1.20.0, all additive:
|
|
454
454
|
|
|
455
455
|
| key | tool | what it holds |
|
|
456
456
|
|---|---|---|
|
|
@@ -458,7 +458,7 @@ Per-tool keys added in 1.19.2, all additive:
|
|
|
458
458
|
| `text_unchanged` | `caption.py` | a sibling inside the `caption` block, **burn mode only** (`--mode mux` never touches the text and omits the key): `true` when the drawn text equals the cues that were handed in — nothing transcribed, no cue dropped, no cue **split** across two consecutive cues and no glyph stripped (`--emoji none`). Wrapping, line breaks and timing do not count: the words are the same. This tool never rewrites, shortens or translates a cue, so the key is a statement of what happened, not a judgement of the text |
|
|
459
459
|
|
|
460
460
|
|
|
461
|
-
Per-tool keys added in 1.
|
|
461
|
+
Per-tool keys added in 1.20.0, all additive:
|
|
462
462
|
|
|
463
463
|
| key | tool | what it holds |
|
|
464
464
|
|---|---|---|
|
|
@@ -588,7 +588,7 @@ most often across the corpus in `evals/agent_prompts*.json`. Every tool -- inclu
|
|
|
588
588
|
-- is still callable by name through `tools/call` regardless of what `tools/list` advertised; the
|
|
589
589
|
contract (`ffmpeg-skill contract --json`) still describes all 42 unconditionally. Set
|
|
590
590
|
`FFMPEG_SKILL_MCP_FULL=1` (anything but "" or `0`) in the server's environment to make `tools/list`
|
|
591
|
-
return all 42, as every version before 1.
|
|
591
|
+
return all 42, as every version before 1.20.0 did.
|
|
592
592
|
|
|
593
593
|
## Consuming the contract from an agent
|
|
594
594
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/scripts/graphics.py
CHANGED
|
@@ -260,6 +260,8 @@ def main() -> int:
|
|
|
260
260
|
usable = W - left - right - 2 * pad
|
|
261
261
|
return max(0.1, min(SAFE_WIDTH_FRACTION, usable / float(W))) if W else SAFE_WIDTH_FRACTION
|
|
262
262
|
|
|
263
|
+
sliced_atoms: List[int] = []
|
|
264
|
+
|
|
263
265
|
def wrapped(text, size_px, frac=SAFE_WIDTH_FRACTION):
|
|
264
266
|
"""A label broken to the frame's safe width (1.16).
|
|
265
267
|
|
|
@@ -268,6 +270,12 @@ def main() -> int:
|
|
|
268
270
|
the breaks in, at the same measured width and by the same four phrase rules caption.py
|
|
269
271
|
uses. A label that already fits comes back unchanged, which is why this is additive: the
|
|
270
272
|
only text it touches is text that used to run off the edge of the frame.
|
|
273
|
+
|
|
274
|
+
`slice_overlong=True` matches caption.py's burn path (1.18.4): a single atom that is
|
|
275
|
+
still wider than the column even alone -- a long hashtag/URL/name with no break point --
|
|
276
|
+
is hard-sliced at the column's edge (preferring an existing hyphen) rather than left to
|
|
277
|
+
render past the frame. An atom that already fits is never touched, so a fitting Thai
|
|
278
|
+
phrase or katakana run comes through exactly as before.
|
|
271
279
|
"""
|
|
272
280
|
text = str(text or "")
|
|
273
281
|
if not text or not size_px:
|
|
@@ -277,7 +285,8 @@ def main() -> int:
|
|
|
277
285
|
return text
|
|
278
286
|
out = []
|
|
279
287
|
for para in text.split("\n"):
|
|
280
|
-
out.extend(wrap_text(para, max_em, mode=args.wrap, lang=args.lang
|
|
288
|
+
out.extend(wrap_text(para, max_em, mode=args.wrap, lang=args.lang,
|
|
289
|
+
slice_overlong=True, sliced=sliced_atoms) if para.strip() else [para])
|
|
281
290
|
return "\n".join(out)
|
|
282
291
|
|
|
283
292
|
def add_text(text, drawtext, *, target=None, **el):
|
|
@@ -587,6 +596,8 @@ def main() -> int:
|
|
|
587
596
|
extra["ass"] = ass_path
|
|
588
597
|
if emoji_result:
|
|
589
598
|
extra["emoji"] = emoji_result
|
|
599
|
+
if sliced_atoms:
|
|
600
|
+
extra["broken_inside_word"] = len(sliced_atoms)
|
|
590
601
|
emit(output, **extra)
|
|
591
602
|
return 0
|
|
592
603
|
|
package/scripts/look.py
CHANGED
|
@@ -14,10 +14,10 @@ import argparse
|
|
|
14
14
|
import os
|
|
15
15
|
import sys
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import List
|
|
17
|
+
from typing import List, Optional
|
|
18
18
|
|
|
19
19
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
20
|
-
from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
|
|
20
|
+
from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
|
|
21
21
|
|
|
22
22
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
23
23
|
|
|
@@ -32,6 +32,103 @@ def timecode_filter(font_prefix: str) -> str:
|
|
|
32
32
|
return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
|
|
33
33
|
|
|
34
34
|
|
|
35
|
+
def _png_size(png_path: str) -> "Optional[tuple]":
|
|
36
|
+
# run_analysis(): the same wall-clock ceiling and #234 UTF-8 decoding as every other
|
|
37
|
+
# ffprobe/ffmpeg measurement in this codebase, check=False since a probe failure here is
|
|
38
|
+
# an unknown-metric result (has_ink: None), not a reason to die -- a stall still does,
|
|
39
|
+
# exactly like every other measurement's timeout.
|
|
40
|
+
proc = run_analysis(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
|
41
|
+
"stream=width,height", "-of", "csv=p=0", png_path], check=False)
|
|
42
|
+
parts = proc.stdout.strip().split(",")
|
|
43
|
+
if len(parts) != 2:
|
|
44
|
+
return None
|
|
45
|
+
try:
|
|
46
|
+
return int(parts[0]), int(parts[1])
|
|
47
|
+
except ValueError:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _png_pixels(png_path: str, w: int, h: int) -> "Optional[bytes]":
|
|
52
|
+
proc = run_analysis(["ffmpeg", "-v", "error", "-i", png_path, "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
|
53
|
+
check=False, text=False)
|
|
54
|
+
data = proc.stdout
|
|
55
|
+
return data if len(data) >= w * h * 3 else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def measure_ink(png_path: str, *, box=None, margin_frac: float = 0.03, threshold: int = 28) -> dict:
|
|
59
|
+
"""Non-background ink in `png_path` (or the `box` = (x0, y0, w, h) sub-region of it): a pixel-only
|
|
60
|
+
measurement, never a judgement of legibility. The background is estimated as the median RGB of a
|
|
61
|
+
thin strip around the region's own edge (so it works on any frame, not just a black synthetic one);
|
|
62
|
+
a pixel is "ink" when any channel differs from that estimate by more than `threshold`. Returns
|
|
63
|
+
{has_ink, bbox (x0,y0,x1,y1 within the region, or None), ink_fraction, row_bands} -- row_bands are
|
|
64
|
+
gap-merged bands of rows carrying ink, the same convention tests/_fixtures.py's _ass_ink_rows uses,
|
|
65
|
+
so a caption wrapped onto two lines shows as two bands without measuring any font."""
|
|
66
|
+
size = _png_size(png_path)
|
|
67
|
+
if not size:
|
|
68
|
+
return {"has_ink": None, "bbox": None, "ink_fraction": None, "row_bands": []}
|
|
69
|
+
full_w, full_h = size
|
|
70
|
+
data = _png_pixels(png_path, full_w, full_h)
|
|
71
|
+
if data is None:
|
|
72
|
+
return {"has_ink": None, "bbox": None, "ink_fraction": None, "row_bands": []}
|
|
73
|
+
if box:
|
|
74
|
+
bx, by, bw, bh = box
|
|
75
|
+
bx, by = max(0, bx), max(0, by)
|
|
76
|
+
bw, bh = min(bw, full_w - bx), min(bh, full_h - by)
|
|
77
|
+
else:
|
|
78
|
+
bx, by, bw, bh = 0, 0, full_w, full_h
|
|
79
|
+
if bw <= 0 or bh <= 0:
|
|
80
|
+
return {"has_ink": None, "bbox": None, "ink_fraction": None, "row_bands": []}
|
|
81
|
+
|
|
82
|
+
def px(x, y):
|
|
83
|
+
off = ((by + y) * full_w + (bx + x)) * 3
|
|
84
|
+
return data[off], data[off + 1], data[off + 2]
|
|
85
|
+
|
|
86
|
+
margin = max(1, int(min(bw, bh) * margin_frac))
|
|
87
|
+
border = []
|
|
88
|
+
for x in range(0, bw, max(1, bw // 40 or 1)):
|
|
89
|
+
border.append(px(x, 0))
|
|
90
|
+
border.append(px(x, bh - 1))
|
|
91
|
+
for y in range(0, bh, max(1, bh // 40 or 1)):
|
|
92
|
+
border.append(px(0, y))
|
|
93
|
+
border.append(px(bw - 1, y))
|
|
94
|
+
if not border:
|
|
95
|
+
border = [px(0, 0)]
|
|
96
|
+
bg = tuple(sorted(c[i] for c in border)[len(border) // 2] for i in range(3))
|
|
97
|
+
|
|
98
|
+
lit_rows = []
|
|
99
|
+
min_x = min_y = None
|
|
100
|
+
max_x = max_y = None
|
|
101
|
+
ink_count = 0
|
|
102
|
+
for y in range(bh):
|
|
103
|
+
row_lit = False
|
|
104
|
+
for x in range(bw):
|
|
105
|
+
r, g, b = px(x, y)
|
|
106
|
+
if abs(r - bg[0]) > threshold or abs(g - bg[1]) > threshold or abs(b - bg[2]) > threshold:
|
|
107
|
+
row_lit = True
|
|
108
|
+
ink_count += 1
|
|
109
|
+
min_x = x if min_x is None else min(min_x, x)
|
|
110
|
+
max_x = x if max_x is None else max(max_x, x)
|
|
111
|
+
min_y = y if min_y is None else min(min_y, y)
|
|
112
|
+
max_y = y if max_y is None else max(max_y, y)
|
|
113
|
+
if row_lit:
|
|
114
|
+
lit_rows.append(y)
|
|
115
|
+
|
|
116
|
+
bands = []
|
|
117
|
+
for y in lit_rows:
|
|
118
|
+
if bands and y - bands[-1][1] <= margin:
|
|
119
|
+
bands[-1][1] = y
|
|
120
|
+
else:
|
|
121
|
+
bands.append([y, y])
|
|
122
|
+
|
|
123
|
+
has_ink = min_x is not None
|
|
124
|
+
return {
|
|
125
|
+
"has_ink": has_ink,
|
|
126
|
+
"bbox": [min_x, min_y, max_x, max_y] if has_ink else None,
|
|
127
|
+
"ink_fraction": round(ink_count / (bw * bh), 4) if bw * bh else 0.0,
|
|
128
|
+
"row_bands": [list(b) for b in bands],
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
35
132
|
def main() -> int:
|
|
36
133
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
37
134
|
ap.add_argument("input")
|
|
@@ -43,6 +140,9 @@ def main() -> int:
|
|
|
43
140
|
ap.add_argument("--no-timecode", action="store_true")
|
|
44
141
|
ap.add_argument("--safe", choices=PLATFORM_CHOICES, help="shade the zones this platform's UI covers (its description bar, "
|
|
45
142
|
"like column, status bar) so you can see whether anything readable is under them")
|
|
143
|
+
ap.add_argument("--ink", action="store_true", help="measure non-background pixels in each written PNG (bbox, "
|
|
144
|
+
"ink_fraction, row_bands per tile for a contact sheet) instead of "
|
|
145
|
+
"just eyeballing it -- a pixel-only signal, not a legibility judgement")
|
|
46
146
|
add_common(ap)
|
|
47
147
|
args = ap.parse_args()
|
|
48
148
|
apply_common(args)
|
|
@@ -103,9 +203,15 @@ def main() -> int:
|
|
|
103
203
|
if not args.at:
|
|
104
204
|
die("--compare needs --at TIME")
|
|
105
205
|
probe(args.compare)
|
|
106
|
-
for t in args.at:
|
|
206
|
+
for idx, t in enumerate(args.at):
|
|
107
207
|
sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
|
|
108
|
-
|
|
208
|
+
if args.output and len(args.at) == 1:
|
|
209
|
+
out = args.output # one frame, one named image file: the caller's -o is the contract
|
|
210
|
+
else:
|
|
211
|
+
# several frames, or -o given as a stem/prefix: an occurrence index keeps every
|
|
212
|
+
# requested --at its own file even when two round to the same millisecond
|
|
213
|
+
stem_part = (args.output and Path(args.output).stem) or stem
|
|
214
|
+
out = os.path.join(outdir, f"{stem_part}_vs_{Path(args.compare).stem}_{idx}_{sec:.3f}s.png")
|
|
109
215
|
half = args.width // 2
|
|
110
216
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
111
217
|
tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
|
|
@@ -159,7 +265,29 @@ def main() -> int:
|
|
|
159
265
|
for o in outputs:
|
|
160
266
|
if STATE.dry_run or os.path.exists(o):
|
|
161
267
|
info(f"wrote {o}")
|
|
162
|
-
|
|
268
|
+
|
|
269
|
+
ink = None
|
|
270
|
+
if args.ink and not STATE.dry_run:
|
|
271
|
+
if outputs and args.at is None and not args.compare:
|
|
272
|
+
# contact sheet: one PNG holding a cols x rows grid (tile_w:padding=2:margin=2, as built above)
|
|
273
|
+
# -- split it back into per-tile boxes so each tile is measured on its own, not the whole sheet.
|
|
274
|
+
sheet = outputs[0]
|
|
275
|
+
size = _png_size(sheet)
|
|
276
|
+
if size:
|
|
277
|
+
full_w, full_h = size
|
|
278
|
+
margin, padding = 2, 2
|
|
279
|
+
th = (full_h - 2 * margin - (rows - 1) * padding) // rows if rows else full_h
|
|
280
|
+
tiles = []
|
|
281
|
+
for r in range(rows):
|
|
282
|
+
for c in range(cols):
|
|
283
|
+
bx = margin + c * (tile_w + padding)
|
|
284
|
+
by = margin + r * (th + padding)
|
|
285
|
+
tiles.append(measure_ink(sheet, box=(bx, by, tile_w, th)))
|
|
286
|
+
ink = {"tiles": tiles}
|
|
287
|
+
else:
|
|
288
|
+
ink = {"frames": [measure_ink(o) for o in outputs]}
|
|
289
|
+
|
|
290
|
+
emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs, **({"ink": ink} if ink is not None else {}))
|
|
163
291
|
if len(outputs) > 1 and not args.json:
|
|
164
292
|
for o in outputs:
|
|
165
293
|
print(o)
|