dsh-spell-check 0.0.0-stage → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ # dsh-spell-check bundle patch: mount the host half as a NEW loader row.
2
+ #
3
+ # This must be an `insert:` list, not a top-level `- id:` entry. A top-level
4
+ # entry with an `id` targets an EXISTING row to override it, so dsh aborts with
5
+ # `patch: entry "spell-check" not found` before the plugin ever loads.
6
+ #
7
+ # `name` MUST equal the package name: the loader imports that specifier, and
8
+ # `id` is the plugin's storage/prompt/effect key (keep it stable once released).
9
+ - insert:
10
+ - id: spell-check
11
+ name: 'dsh-spell-check'
12
+ config:
13
+ enabled: true
@@ -0,0 +1,9 @@
1
+ {
2
+ "id": "spell-check",
3
+ "version": "0.2.2",
4
+ "main": "src/index.js",
5
+ "description": "Chrome-style local spell check for the dsh composer",
6
+ "engines": {
7
+ "dsh": ">=0.2.1-rc.3"
8
+ }
9
+ }
package/package.json CHANGED
@@ -1,6 +1,90 @@
1
1
  {
2
2
  "name": "dsh-spell-check",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.2",
4
+ "description": "Chrome-style spell check for the dsh composer: the misspelled word under the caret gets a suggestion strip, and Tab accepts it. Local only — no network, no LLM, no telemetry.",
5
+ "icon": "assets/icon.png",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./engine": "./src/engine.js",
11
+ "./client": "./src/client.js",
12
+ "./cordis.patch.yml": "./cordis.patch.yml",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "files": [
16
+ "src/index.js",
17
+ "src/engine.js",
18
+ "src/client.js",
19
+ "assets/words.txt",
20
+ "assets/banner-light.png",
21
+ "assets/strip.gif",
22
+ "assets/strip.png",
23
+ "assets/menu.png",
24
+ "assets/banner-dark.png",
25
+ "assets/icon.png",
26
+ "cordis.patch.yml",
27
+ "dsh.plugin.json",
28
+ "scripts/test-engine.mjs",
29
+ "scripts/test-host.mjs",
30
+ "scripts/test-client.mjs",
31
+ "scripts/build-client.mjs",
32
+ "scripts/check-styles.mjs",
33
+ "scripts/make-banner.py",
34
+ "scripts/make-media.py",
35
+ "README.md",
36
+ "LICENSE",
37
+ "screenshots.json"
38
+ ],
39
+ "scripts": {
40
+ "build": "node scripts/build-client.mjs",
41
+ "test": "node scripts/check-styles.mjs && node scripts/test-engine.mjs && node scripts/test-host.mjs && node scripts/test-client.mjs",
42
+ "test:engine": "node scripts/test-engine.mjs",
43
+ "test:host": "node scripts/test-host.mjs",
44
+ "test:client": "node scripts/test-client.mjs",
45
+ "test:styles": "node scripts/check-styles.mjs"
46
+ },
47
+ "dsh": {
48
+ "bundle": {
49
+ "patch": "./cordis.patch.yml"
50
+ },
51
+ "client": {
52
+ "platform": "web",
53
+ "main": "./src/client.js"
54
+ }
55
+ },
56
+ "engines": {
57
+ "node": ">=22"
58
+ },
59
+ "keywords": [
60
+ "dsh-plugin",
61
+ "dsh",
62
+ "deepseek-harness",
63
+ "spellcheck",
64
+ "spell-check",
65
+ "autocorrect",
66
+ "typo",
67
+ "offline"
68
+ ],
69
+ "repository": {
70
+ "type": "git",
71
+ "url": "git+https://github.com/adithyanraj03/dsh-spell-check.git"
72
+ },
73
+ "homepage": "https://adithyanraj03.github.io/dsh-spell-check/",
74
+ "bugs": {
75
+ "url": "https://github.com/adithyanraj03/dsh-spell-check/issues"
76
+ },
77
+ "author": "Adithya N Raj (adithyanraj03)",
78
+ "license": "MIT",
79
+ "peerDependencies": {
80
+ "@deepseek-ai/dsh-typert-protocol": ">=0.1.0-rc.6"
81
+ },
82
+ "peerDependenciesMeta": {
83
+ "@deepseek-ai/dsh-typert-protocol": {
84
+ "optional": true
85
+ }
86
+ },
87
+ "devDependencies": {
88
+ "@deepseek-ai/dsh-typert-protocol": "0.2.2-rc.1"
89
+ }
90
+ }
@@ -0,0 +1,4 @@
1
+ [
2
+ "assets/strip.png",
3
+ "assets/menu.png"
4
+ ]
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Generate the browser half's embedded engine + dictionary.
3
+ *
4
+ * WHY THIS EXISTS
5
+ *
6
+ * The client half runs in the browser and cannot read files, so to correct
7
+ * without a round trip it needs the dictionary INSIDE its bundle. Embedding it
8
+ * by hand would mean a second copy of the rules that drifts from src/engine.js
9
+ * — the exact failure this avoids.
10
+ *
11
+ * So src/engine.js stays the single source of truth: this script strips its
12
+ * `export` keywords into a plain script block, prepends the word list, and
13
+ * splices both into the marked region of src/client.js. The host half imports
14
+ * src/engine.js directly, so both halves run the SAME code.
15
+ *
16
+ * Run with `node scripts/build-client.mjs` (also `npm run build`).
17
+ */
18
+
19
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
20
+ import { dirname, join } from 'node:path'
21
+ import { fileURLToPath } from 'node:url'
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url))
24
+ const root = join(here, '..')
25
+
26
+ const BEGIN = '/* >>> GENERATED ENGINE BLOCK'
27
+ const END = '/* <<< GENERATED ENGINE BLOCK <<< */'
28
+
29
+ const words = readFileSync(join(root, 'assets', 'words.txt'), 'utf8')
30
+ .split(/\r?\n/)
31
+ .map((line) => line.trim())
32
+ .filter(Boolean)
33
+
34
+ /**
35
+ * An OPTIONAL second word list, for swapping in a different dictionary.
36
+ *
37
+ * SCOWL, a Hunspell `en_US.dic`, or a wordfreq export all reduce to "one word
38
+ * per line", so dropping a file here is enough to change the dictionary without
39
+ * touching this plugin. It goes AFTER the shipped list, which keeps the common
40
+ * words ahead of it in the ranking.
41
+ */
42
+ const EXTRA = join(root, 'assets', 'words-extra.txt')
43
+ let extra = []
44
+ if (existsSync(EXTRA)) {
45
+ extra = readFileSync(EXTRA, 'utf8')
46
+ .split(/\r?\n/)
47
+ .map((line) => line.trim().toLowerCase())
48
+ // Hunspell dictionaries carry an affix marker after a slash (walk/ed) and
49
+ // SCOWL uses a similar suffix; only the plain headword is a word here.
50
+ .map((line) => line.split(/[/\s]/)[0])
51
+ .filter((line) => /^[a-z][a-z'-]{1,24}$/.test(line))
52
+ console.log(`build: extra dictionary ${extra.length} words from assets/words-extra.txt`)
53
+ }
54
+
55
+ const all = [...new Set([...words, ...extra])]
56
+
57
+ if (words.length === 0) {
58
+ console.error('build: assets/words.txt is empty — refusing to write a bundle with no dictionary')
59
+ process.exit(1)
60
+ }
61
+
62
+ /**
63
+ * src/engine.js as a plain script fragment.
64
+ *
65
+ * Only three export shapes are used in that file, and all three are line-initial
66
+ * by convention, so this stays a formatting rule rather than a parser.
67
+ */
68
+ const engineSource = readFileSync(join(root, 'src', 'engine.js'), 'utf8')
69
+ .split(/\r?\n/)
70
+ .filter((line) => !line.startsWith('export default'))
71
+ .map((line) => (line.startsWith('export ') ? line.slice('export '.length) : line))
72
+ .join('\n')
73
+
74
+ const block = [
75
+ BEGIN + ' — run `npm run build`. Do not edit between the markers. >>> */',
76
+ '',
77
+ ' /* The ranked word list, most frequent first. Index is the frequency rank. */',
78
+ ' const WORD_LIST = ' + JSON.stringify(all.join('\n')) + ';',
79
+ '',
80
+ engineSource,
81
+ '',
82
+ ' /* The one engine instance this page uses. */',
83
+ ' const ENGINE = createEngine(WORD_LIST.split("\\n").filter(Boolean), TYPO_MAP);',
84
+ '',
85
+ ' ' + END,
86
+ ].join('\n')
87
+
88
+ const clientPath = join(root, 'src', 'client.js')
89
+ const client = readFileSync(clientPath, 'utf8')
90
+
91
+ const start = client.indexOf(BEGIN)
92
+ const finish = client.indexOf(END)
93
+ if (start === -1 || finish === -1 || finish < start) {
94
+ console.error('build: generated markers not found in src/client.js')
95
+ process.exit(1)
96
+ }
97
+
98
+ const next = client.slice(0, start) + block + client.slice(finish + END.length)
99
+ writeFileSync(clientPath, next, 'utf8')
100
+
101
+ const bytes = Buffer.byteLength(next, 'utf8')
102
+ console.log(`build: embedded ${all.length} words into src/client.js (${(bytes / 1024).toFixed(1)} KB)`)
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Stylesheet sanity check: `node scripts/check-styles.mjs`.
3
+ *
4
+ * The client half builds one CSS string from an array of literals. A rule whose
5
+ * selector line is removed leaves its declarations behind, and those orphans
6
+ * break the parse of every rule after them — which is exactly how the
7
+ * right-click menu lost its positioning and rendered as raw text at the bottom
8
+ * of the page. This asserts the string is structurally composed of real rules.
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs'
12
+ import { dirname, join } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const here = dirname(fileURLToPath(import.meta.url))
16
+ const source = readFileSync(join(here, '..', 'src', 'client.js'), 'utf8')
17
+
18
+ const start = source.indexOf('function styles()')
19
+ const end = source.indexOf('].join', start)
20
+ if (start === -1 || end === -1) {
21
+ console.error('check-styles: could not locate styles()')
22
+ process.exit(1)
23
+ }
24
+
25
+ const block = source.slice(start, end)
26
+ const css = block
27
+ .split('\n')
28
+ .map((line) => line.trim())
29
+ .filter((line) => line.startsWith('"'))
30
+ .map((line) => line.replace(/^"/, '').replace(/",?$/, ''))
31
+ .join('')
32
+
33
+ let passed = 0
34
+ const failures = []
35
+ const check = (name, ok, detail) => {
36
+ if (ok) passed += 1
37
+ else failures.push(name + (detail === undefined ? '' : ` — ${detail}`))
38
+ }
39
+
40
+ const open = (css.match(/\{/g) ?? []).length
41
+ const close = (css.match(/\}/g) ?? []).length
42
+ check('braces balance', open === close, `${open} open vs ${close} close`)
43
+
44
+ // Every top-level statement must start with a selector, an at-rule, or a
45
+ // comment. A bare declaration at the start of a statement is the orphan shape.
46
+ const statements = css.split('}')
47
+ for (const statement of statements) {
48
+ const trimmed = statement.trim()
49
+ if (trimmed === '') continue
50
+ check(
51
+ 'statement starts with a selector',
52
+ /^(\.|@|:root|html|body|\/\*)/.test(trimmed),
53
+ trimmed.slice(0, 60),
54
+ )
55
+ }
56
+
57
+ // The rules that carry the two visible surfaces must survive.
58
+ check('strip rule present', css.includes('.sp-strip{'))
59
+ check('menu rule present', css.includes('.sp-menu{'))
60
+ check('menu open state present', css.includes('.sp-menu.sp-open{'))
61
+ check('menu positions itself', /\.sp-menu\{[^}]*position:fixed/.test(css))
62
+
63
+ // The chip was removed from the UI; nothing should reference it any more.
64
+ check('no chip rules left', css.includes('.sp-chip') === false)
65
+
66
+ console.log(`${passed} passed, ${failures.length} failed`)
67
+ if (failures.length > 0) {
68
+ for (const failure of failures) console.log(' FAIL ' + failure)
69
+ process.exitCode = 1
70
+ }
@@ -0,0 +1,191 @@
1
+ """
2
+ Generate the README banners.
3
+
4
+ Deliberately scripted rather than image-generated: a banner is a logo, a
5
+ wordmark, a tagline and the real screenshots, and a diffusion model renders text
6
+ and UI chrome badly. Pillow gives crisp type and exact colours.
7
+
8
+ python scripts/make-banner.py # the default variant, both themes
9
+ python scripts/make-banner.py --variant A # one variant, both themes
10
+ python scripts/make-banner.py --variant B --theme dark --suffix=-B
11
+
12
+ The variants differ only in how the screenshots are arranged:
13
+
14
+ A stacked — two cards, one above the other, with a clear gap
15
+ B hero — one large card, the whole right half
16
+ C layered — two cards offset and overlapping, like the sibling repos
17
+
18
+ Nothing is rotated: a tilted screenshot reads as a mock-up, and the point of
19
+ putting a screenshot in the banner is that it is the real thing.
20
+ """
21
+
22
+ import argparse
23
+ from pathlib import Path
24
+
25
+ from PIL import Image, ImageDraw, ImageFilter, ImageFont
26
+
27
+ ROOT = Path(__file__).resolve().parent.parent
28
+ ICON = ROOT / "assets" / "icon.png"
29
+ MENU = ROOT / "assets" / "menu.png"
30
+ STRIP = ROOT / "assets" / "strip.png"
31
+ OUT = ROOT / "assets"
32
+
33
+ W, H = 1400, 460
34
+
35
+ LIGHT = {
36
+ "bg": (247, 246, 250),
37
+ "glow": (232, 229, 242),
38
+ "title": (26, 22, 36),
39
+ "tag": (92, 86, 108),
40
+ "accent": (232, 72, 77),
41
+ "edge": (0, 0, 0, 30),
42
+ "shadow": (24, 20, 36, 64),
43
+ }
44
+ DARK = {
45
+ "bg": (18, 18, 22),
46
+ "glow": (32, 30, 42),
47
+ "title": (245, 244, 248),
48
+ "tag": (152, 148, 168),
49
+ "accent": (255, 106, 110),
50
+ "edge": (255, 255, 255, 32),
51
+ "shadow": (0, 0, 0, 140),
52
+ }
53
+
54
+ FONT_CANDIDATES = [
55
+ r"C:\Windows\Fonts\segoeuib.ttf",
56
+ r"C:\Windows\Fonts\SegoeUI.ttf",
57
+ r"C:\Windows\Fonts\arialbd.ttf",
58
+ ]
59
+ MONO_CANDIDATES = [
60
+ r"C:\Windows\Fonts\consola.ttf",
61
+ r"C:\Windows\Fonts\cour.ttf",
62
+ ]
63
+
64
+ # Tighter than the raw stills: the interesting part of each capture is the popup
65
+ # plus the line it belongs to, and cropping to that makes a card twice as tall at
66
+ # the same width — which is the whole point of the banner.
67
+ CROPS = {
68
+ "menu": (400, 0, 1350, 314), # 950 x 314
69
+ "strip": (150, 0, 1150, 260), # 1000 x 260
70
+ }
71
+
72
+ LAYOUTS = {
73
+ # name: (width, x, y). Top-left of each card; every right edge stays inside
74
+ # 1400 with a margin, and no card is ever clipped by the canvas.
75
+ "A": {"menu": (690, 690, 10), "strip": (700, 690, 248)},
76
+ "B": {"strip": (900, 470, 126)},
77
+ "C": {"menu": (700, 676, 16), "strip": (700, 700, 234)},
78
+ }
79
+
80
+ DEFAULT_VARIANT = "A"
81
+
82
+
83
+ def font(candidates, size):
84
+ for candidate in candidates:
85
+ if Path(candidate).exists():
86
+ return ImageFont.truetype(candidate, size)
87
+ return ImageFont.load_default()
88
+
89
+
90
+ def rounded_glow(size, colour):
91
+ glow = Image.new("RGBA", (size, size), (0, 0, 0, 0))
92
+ draw = ImageDraw.Draw(glow)
93
+ for step in range(size // 2, 0, -2):
94
+ alpha = int(26 * (1 - step / (size / 2)))
95
+ draw.ellipse(
96
+ (size // 2 - step, size // 2 - step, size // 2 + step, size // 2 + step),
97
+ fill=colour + (alpha,),
98
+ )
99
+ return glow
100
+
101
+
102
+ def rounded_mask(size, radius):
103
+ mask = Image.new("L", size, 0)
104
+ ImageDraw.Draw(mask).rounded_rectangle((0, 0, size[0] - 1, size[1] - 1), radius=radius, fill=255)
105
+ return mask
106
+
107
+
108
+ def pad_top(image, amount):
109
+ """
110
+ Give a crop headroom above its content.
111
+
112
+ The strip still was captured tight to the popup's rounded top, so pasting it
113
+ straight into a card clipped those corners and the popup read as flush with
114
+ the card edge rather than floating over the composer. The filler is the
115
+ crop's own corner colour, which is the page background behind the composer.
116
+ """
117
+ if amount <= 0:
118
+ return image
119
+ filler = image.getpixel((0, 0))
120
+ padded = Image.new("RGBA", (image.width, image.height + amount), filler)
121
+ padded.paste(image, (0, amount))
122
+ return padded
123
+
124
+
125
+ def card(path, crop, width, theme, radius=18, headroom=0):
126
+ """A screenshot as a rounded card with its own hairline edge."""
127
+ source = Image.open(path).convert("RGBA").crop(crop)
128
+ source = pad_top(source, headroom)
129
+ height = round(source.height * width / source.width)
130
+ image = source.resize((width, height), Image.LANCZOS)
131
+
132
+ mask = rounded_mask((width, height), radius)
133
+ image.putalpha(mask)
134
+ ImageDraw.Draw(image).rounded_rectangle(
135
+ (0, 0, width - 1, height - 1), radius=radius, outline=theme["edge"], width=1
136
+ )
137
+ return image
138
+
139
+
140
+ def place(canvas, art, x, y, theme):
141
+ """Composite a card with a soft shadow behind it."""
142
+ silhouette = Image.new("RGBA", art.size, (0, 0, 0, 0))
143
+ silhouette.paste(Image.new("RGBA", art.size, theme["shadow"]), (0, 0), art.split()[3])
144
+ silhouette = silhouette.filter(ImageFilter.GaussianBlur(18))
145
+ canvas.alpha_composite(silhouette, (x + 2, y + 12))
146
+ canvas.alpha_composite(art, (x, y))
147
+
148
+
149
+ def build(theme, name, variant):
150
+ layout = LAYOUTS[variant]
151
+ canvas = Image.new("RGB", (W, H), theme["bg"]).convert("RGBA")
152
+ canvas.alpha_composite(rounded_glow(460, theme["glow"]), (40, -30))
153
+
154
+ # Screenshots before the wordmark, so no card can ever cover the text.
155
+ for key, path in (("menu", MENU), ("strip", STRIP)):
156
+ if key not in layout:
157
+ continue # a single-card layout simply omits the other
158
+ width, x, y = layout[key]
159
+ headroom = 26 if key == "strip" else 0
160
+ place(canvas, card(path, CROPS[key], width, theme, headroom=headroom), x, y, theme)
161
+
162
+ icon = Image.open(ICON).convert("RGBA")
163
+ icon.thumbnail((168, 168), Image.LANCZOS)
164
+ canvas.alpha_composite(icon, (46, (H - icon.height) // 2 - 4))
165
+
166
+ draw = ImageDraw.Draw(canvas)
167
+
168
+ title_font = font(FONT_CANDIDATES, 74)
169
+ draw.text((232, 156), "spell check", font=title_font, fill=theme["title"])
170
+ caret_x = 232 + draw.textlength("spell check", font=title_font) + 10
171
+ draw.rectangle((caret_x, 168, caret_x + 6, 232), fill=theme["accent"])
172
+
173
+ tag_font = font(MONO_CANDIDATES, 21)
174
+ for index, line in enumerate(["Tab accepts the fix.", "Right-click for every option.", "100% local."]):
175
+ draw.text((236, 258 + index * 29), line, font=tag_font, fill=theme["tag"])
176
+
177
+ path = OUT / name
178
+ canvas.convert("RGB").save(path, "PNG", optimize=True)
179
+ print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size // 1024} KB)")
180
+
181
+
182
+ parser = argparse.ArgumentParser()
183
+ parser.add_argument("--variant", default=DEFAULT_VARIANT, choices=sorted(LAYOUTS))
184
+ parser.add_argument("--theme", default="both", choices=["both", "light", "dark"])
185
+ parser.add_argument("--suffix", default="", help="appended to the filename, for comparisons")
186
+ args = parser.parse_args()
187
+
188
+ if args.theme in ("both", "dark"):
189
+ build(DARK, f"banner-dark{args.suffix}.png", args.variant)
190
+ if args.theme in ("both", "light"):
191
+ build(LIGHT, f"banner-light{args.suffix}.png", args.variant)
@@ -0,0 +1,120 @@
1
+ """
2
+ Clean the recorded demo and build the README media.
3
+
4
+ The recording is made by the harness's own computer-use driver, which paints a
5
+ small blue cursor pill labelled "DSH" wherever it clicks. That pill lands on the
6
+ composer, exactly where the demo needs to be legible, so each frame is patched:
7
+ the pill is found by colour, and the rectangle is filled with the background
8
+ sampled just beside it. It sits on flat paint, so the repair is invisible.
9
+
10
+ python scripts/make-media.py
11
+
12
+ Inputs media/demo2.mp4 the raw recording
13
+ Outputs assets/strip.gif typing -> suggestion strip -> Tab accepts
14
+ assets/strip.png still, for the README
15
+ assets/menu.png still, the right-click menu
16
+ """
17
+
18
+ from pathlib import Path
19
+ import shutil
20
+ import subprocess
21
+
22
+ from PIL import Image
23
+
24
+ ROOT = Path(__file__).resolve().parent.parent
25
+ MEDIA = ROOT / "media"
26
+ ASSETS = ROOT / "assets"
27
+ FRAMES = MEDIA / "frames"
28
+
29
+ FFMPEG = shutil.which("ffmpeg") or (
30
+ r"C:\Users\adith\AppData\Local\Microsoft\WinGet\Packages"
31
+ r"\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\ffmpeg-6.1-full_build\bin\ffmpeg.exe"
32
+ )
33
+
34
+ # Crop of the full recording: composer + the strip above it.
35
+ CROP = "crop=900:130:340:840"
36
+ START, DURATION, FPS = 9.6, 10.5, 10
37
+
38
+
39
+ def run(*args):
40
+ subprocess.run([FFMPEG, "-hide_banner", "-loglevel", "error", "-y", *args], check=True)
41
+
42
+
43
+ def patch_pill(path):
44
+ """Erase the blue cursor pill from one frame.
45
+
46
+ The search window matters: the composer also contains a blue send button,
47
+ and a whole-frame sweep merged the two into one enormous rectangle that
48
+ painted over the entire row. The pill lives just right of the composer text
49
+ and just above the control row, so the search is confined to that band.
50
+ """
51
+ image = Image.open(path).convert("RGB")
52
+ pixels = image.load()
53
+ width, height = image.size
54
+
55
+ xs, ys = [], []
56
+ for y in range(40, min(height, 100)):
57
+ for x in range(240, min(width, 640)):
58
+ r, g, b = pixels[x, y]
59
+ if b > 140 and b - r > 60 and b - g > 40:
60
+ xs.append(x)
61
+ ys.append(y)
62
+ if not xs:
63
+ image.save(path)
64
+ return
65
+
66
+ left, right = min(xs) - 2, max(xs) + 2
67
+ top, bottom = min(ys) - 2, max(ys) + 2
68
+ # A cursor pill is small; anything larger is not the pill and is left alone.
69
+ if right - left > 70 or bottom - top > 40:
70
+ image.save(path)
71
+ return
72
+
73
+ for y in range(max(0, top), min(height, bottom + 1)):
74
+ # Per-row sample of the flat composer paint, taken clear of the pill.
75
+ fill = pixels[max(0, left - 10), y]
76
+ for x in range(max(0, left), min(width, right + 1)):
77
+ pixels[x, y] = fill
78
+ image.save(path)
79
+
80
+
81
+ def main():
82
+ if FRAMES.exists():
83
+ shutil.rmtree(FRAMES)
84
+ FRAMES.mkdir(parents=True)
85
+
86
+ ASSETS.mkdir(parents=True, exist_ok=True)
87
+
88
+ # 1. Frames, cropped to the composer.
89
+ run(
90
+ "-ss", str(START), "-t", str(DURATION), "-i", str(MEDIA / "demo2.mp4"),
91
+ "-vf", f"{CROP},fps={FPS}",
92
+ str(FRAMES / "f%03d.png"),
93
+ )
94
+
95
+ # 2. Repair every frame.
96
+ frames = sorted(FRAMES.glob("f*.png"))
97
+ for frame in frames:
98
+ patch_pill(frame)
99
+ print(f"patched {len(frames)} frames")
100
+
101
+ # 3. Reassemble as an animated GIF with one shared palette.
102
+ run(
103
+ # The input framerate MUST be declared: without it ffmpeg assumes 25 fps
104
+ # for an image sequence and a 10 fps capture comes back playing 2.5x fast.
105
+ "-framerate", str(FPS), "-i", str(FRAMES / "f%03d.png"),
106
+ "-vf", "split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=sierra2_4a",
107
+ "-loop", "0",
108
+ str(ASSETS / "strip.gif"),
109
+ )
110
+
111
+ # 4. A still for the README, taken mid-strip.
112
+ still = Image.open(frames[min(len(frames) - 1, int(FPS * 3.4))]).convert("RGB")
113
+ still = still.resize((still.width * 2, still.height * 2), Image.LANCZOS)
114
+ still.save(ASSETS / "strip.png")
115
+
116
+ size = (ASSETS / "strip.gif").stat().st_size // 1024
117
+ print(f"wrote assets/strip.gif ({size} KB) and assets/strip.png")
118
+
119
+
120
+ main()