@olegkoval/agent-skills 1.60.0 → 1.61.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/.github/copilot-instructions.md +2 -0
- package/.github/prompts/carousel-maker.prompt.md +102 -0
- package/.github/prompts/shorts-animator.prompt.md +103 -0
- package/.kiro/steering/carousel-maker.md +101 -0
- package/.kiro/steering/shorts-animator.md +102 -0
- package/.windsurf/rules/carousel-maker.md +100 -0
- package/.windsurf/rules/shorts-animator.md +101 -0
- package/README.md +4 -2
- package/adapters/claude/olko-creative/skills/carousel-maker/SKILL.md +112 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
- package/adapters/claude/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/SKILL.md +113 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/references/kit.md +59 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/references/looks.md +32 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/references/writing.md +9 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
- package/adapters/claude/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
- package/adapters/codex/olko-creative/README.md +2 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/SKILL.md +112 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
- package/adapters/cursor/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/SKILL.md +113 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/references/kit.md +59 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/references/looks.md +32 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/references/writing.md +9 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
- package/adapters/cursor/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/SKILL.md +112 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
- package/adapters/grok/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/SKILL.md +113 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/references/kit.md +59 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/references/looks.md +32 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/references/writing.md +9 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
- package/adapters/grok/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
- package/adapters/hermes/olko-creative/README.md +2 -0
- package/catalog/skills.json +46 -0
- package/package.json +1 -1
- package/plugins/olko-apple-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-creative/skills/carousel-maker/SKILL.md +111 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/cm.ts +220 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/domain/check.ts +68 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/domain/layout.ts +75 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/domain/schema.ts +54 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/domain/types.ts +33 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/edge/io.ts +87 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/package.json +4 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/test/domain.test.ts +99 -0
- package/plugins/olko-creative/skills/carousel-maker/scripts/tsconfig.json +16 -0
- package/plugins/olko-creative/skills/shorts-animator/SKILL.md +112 -0
- package/plugins/olko-creative/skills/shorts-animator/references/kit.md +59 -0
- package/plugins/olko-creative/skills/shorts-animator/references/looks.md +32 -0
- package/plugins/olko-creative/skills/shorts-animator/references/writing.md +9 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/compose.ts +290 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/dsp.ts +193 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/instruments.ts +185 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/master.ts +55 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/audio/soundtrack.ts +119 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/captions.ts +35 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/check.ts +96 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/look.ts +26 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/patch.ts +38 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/schema.ts +81 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/timeline.ts +75 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/domain/types.ts +91 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/chrome.ts +169 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/doctor.ts +52 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/media.ts +105 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/page.ts +53 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/probe.ts +75 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/sound.ts +46 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/edge/voice.ts +183 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/gouache.json +7 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/ink.json +6 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/oil.json +7 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/pastel.json +7 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/looks/watercolor.json +6 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/package.json +4 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/globals.d.ts +199 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/kit.ts +856 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/paint.ts +268 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/stage.ts +181 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/runtime/tsconfig.json +12 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/sa.ts +262 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/test/domain.test.ts +107 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/tsconfig.json +16 -0
- package/plugins/olko-creative/skills/shorts-animator/scripts/tts.py +20 -0
- package/plugins/olko-garmin-kit/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-git-tools/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-github-pr/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-obsidian/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-product/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-reflection/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-release/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-skill-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/olko-web-ops/.claude-plugin/plugin.json +1 -1
- package/scripts/lib/catalog.mjs +2 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: carousel-maker
|
|
3
|
+
description: Use when someone wants Instagram or LinkedIn carousel slides from a topic, a brief or pasted copy. A fixed brand template sets the text on every slide, and an optional library of hand-drawn illustrations, drawn once in code per brand and reused across decks, supplies the pictures. No image models or stock.
|
|
4
|
+
license: MIT
|
|
5
|
+
allowed-tools: Bash, Read, Write, Edit
|
|
6
|
+
compatibility: Codex, Claude Code, Hermes Agent, Cursor, GitHub Copilot, Windsurf, Kiro, and other Agent Skills compatible tools. Needs Node 22.18+ and Chrome; ffmpeg for the contact sheet. The illustration library needs the shorts-animator skill installed alongside.
|
|
7
|
+
metadata:
|
|
8
|
+
targets: [_source-only]
|
|
9
|
+
author: Oleg Koval
|
|
10
|
+
tags:
|
|
11
|
+
- instagram
|
|
12
|
+
- carousel
|
|
13
|
+
- social
|
|
14
|
+
- design
|
|
15
|
+
- slides
|
|
16
|
+
---
|
|
17
|
+
<!-- Generated by scripts/build-adapters.sh. Do not edit directly. -->
|
|
18
|
+
|
|
19
|
+
# Carousel Maker
|
|
20
|
+
|
|
21
|
+
A brand and a few lines of copy in, 1080x1350 carousel slides out. The template places the text, so it
|
|
22
|
+
is never garbled and never off-brand. Pictures come from the brand's library: drawings written once as
|
|
23
|
+
canvas code, painted by shorts-animator, and reused by every deck after that. Text-only decks need no
|
|
24
|
+
library at all.
|
|
25
|
+
|
|
26
|
+
`CM` = `node <this skill's folder>/scripts/cm.ts`. Write the full command each time. One folder per
|
|
27
|
+
brand: `BRAND=./<slug>-carousels`. Decks live in `$BRAND/decks/<deck>.json`.
|
|
28
|
+
|
|
29
|
+
## 1. Decide the copy
|
|
30
|
+
|
|
31
|
+
Decide the slides yourself. Ask one question only if the subject or the brand is unclear. A pasted
|
|
32
|
+
script is used word for word.
|
|
33
|
+
|
|
34
|
+
- 5 to 8 slides: a `cover`, then `point` slides, then one `cta`.
|
|
35
|
+
- The cover is a promise in 8 words or fewer. It is the only slide most people see.
|
|
36
|
+
- One idea per point slide: a title of 7 words or fewer, a body of 22 words or fewer.
|
|
37
|
+
- The cta asks for one thing (save, follow, comment) and carries a `follow` line.
|
|
38
|
+
- `caption` is the post text: two short lines and one question for the comments.
|
|
39
|
+
|
|
40
|
+
## 2. Set up the brand
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
CM new "$BRAND" --name "Slow Hours" --handle @slowhours --look ink # brand.json, decks/, art/, library/
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`brand.json`: `name`, `handle`, `colors` (`paper`, `ink`, `accent`, `muted`, all `#rrggbb`), `fonts`
|
|
47
|
+
(`head`, `body`, CSS font stacks). Set them from what they told you; the defaults are cream, near-black
|
|
48
|
+
and orange. `--look` picks the paint for the library: `ink`, `gouache`, `watercolor`, `pastel`, `oil`.
|
|
49
|
+
|
|
50
|
+
A deck:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{ "title": "mornings", "caption": "...", "slides": [
|
|
54
|
+
{ "kind": "cover", "title": "Slow mornings, in 4 steps", "body": "No 5am club.", "art": "window" },
|
|
55
|
+
{ "kind": "point", "title": "Coffee, outside", "body": "Five minutes on the step.", "art": "coffee" },
|
|
56
|
+
{ "kind": "point", "title": "Their own photo", "art": "photos/desk.jpg" },
|
|
57
|
+
{ "kind": "cta", "title": "Save this for tomorrow", "follow": "Follow @slowhours" } ] }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
`art` is a library drawing (a bare name), a file (a path, relative to `$BRAND`), or left out for a
|
|
61
|
+
text-only slide, which gets larger type.
|
|
62
|
+
|
|
63
|
+
## 3. Draw the library (once per brand)
|
|
64
|
+
|
|
65
|
+
Skip this for text-only decks. Every bare `art` name needs a function in `$BRAND/art/scenes.js`:
|
|
66
|
+
`window.SCENES = { "window": (k) => { k.sky({...}); ... } }`. Read `../shorts-animator/references/kit.md`
|
|
67
|
+
before writing scenes. Draw objects and places, square, one subject, big simple shapes, strong value
|
|
68
|
+
contrast; the paint pass adds texture. No people's faces close up.
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
CM art "$BRAND" # every drawing the decks use -> library/<name>.jpg
|
|
72
|
+
CM art "$BRAND" coffee # only these
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Look at the sheet it reports once. Redraw only the drawings that are muddy, empty or look alike. The
|
|
76
|
+
library is the brand's asset: the next deck reuses it and only new names cost a drawing.
|
|
77
|
+
|
|
78
|
+
## 4. Check, render, look (budgeted)
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
CM check "$BRAND" [deck] # no browser: unknown kinds, missing drawings or files, deck shape, word counts
|
|
82
|
+
CM render "$BRAND" [deck] # out/<deck>/slide-N.png, sheet.jpg, caption.txt; measures every slide
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Fix every `ERROR` from `check` before rendering. `render` also fails a slide whose text spills out of
|
|
86
|
+
its frame or whose picture did not load. Then read `out/<deck>/sheet.jpg` once. **Budget: at most two
|
|
87
|
+
looks per deck** (the library sheet and the deck sheet). Images are the expensive part of this loop; do
|
|
88
|
+
not reopen a sheet you have already judged.
|
|
89
|
+
|
|
90
|
+
Tell them in two lines what it is (slides, look) and: "Tell me what to change, one sentence is enough."
|
|
91
|
+
|
|
92
|
+
## 5. One-sentence changes
|
|
93
|
+
|
|
94
|
+
Smallest change, `CM check`, render, show.
|
|
95
|
+
|
|
96
|
+
| they say | change |
|
|
97
|
+
|---|---|
|
|
98
|
+
| shorter, punchier | the slide's `title` or `body` |
|
|
99
|
+
| different colours, our brand | `brand.json` `colors` |
|
|
100
|
+
| serif, sans, our font | `brand.json` `fonts` |
|
|
101
|
+
| a different picture on slide 3 | that slide's `art`, or one new scene plus `CM art "$BRAND" <name>` |
|
|
102
|
+
| no pictures | drop `art` from the slides |
|
|
103
|
+
| another post on the same brand | a new `decks/<deck>.json`; reuse library names |
|
|
104
|
+
|
|
105
|
+
## When something goes wrong
|
|
106
|
+
|
|
107
|
+
| symptom | fix |
|
|
108
|
+
|---|---|
|
|
109
|
+
| no Chrome | install Chrome, or set `CHROME` to a Chrome or headless-shell binary |
|
|
110
|
+
| `CM art` cannot find shorts-animator | install it alongside, or set `SHORTS_ANIMATOR` to its `scripts/sa.ts` |
|
|
111
|
+
| "text spills out of the frame" | cut words; `check` warns before this happens |
|
|
112
|
+
| no sheet.jpg | ffmpeg is missing; the slides are still in `out/<deck>/` |
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* Carousel Maker: a brand and a few lines of copy in, Instagram carousel slides out.
|
|
4
|
+
*
|
|
5
|
+
* CM new <dir> [--name "Brand"] [--handle @x] [--look ink] brand.json, decks/example.json, art/
|
|
6
|
+
* CM check <dir> [deck] what a render would get wrong, no browser
|
|
7
|
+
* CM art <dir> [scene...] draws the library: art/scenes.js -> library/<scene>.jpg
|
|
8
|
+
* CM render <dir> [deck] slides -> out/<deck>/slide-N.png, sheet.jpg, caption.txt
|
|
9
|
+
*
|
|
10
|
+
* CM = node <skill-dir>/scripts/cm.ts
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { check, fitFindings, report, type Finding, type Fit } from "./domain/check.ts";
|
|
16
|
+
import { contexts, H, MEASURE, page, section } from "./domain/layout.ts";
|
|
17
|
+
import { isMap, parseBrand, parseDeck } from "./domain/schema.ts";
|
|
18
|
+
import type { Art, Brand, Deck, Json, Result } from "./domain/types.ts";
|
|
19
|
+
import { dumpDom, findChrome, mkdir, readJson, screenshot, sheet, url, writeJson, writeNew } from "./edge/io.ts";
|
|
20
|
+
|
|
21
|
+
type Args = { readonly dir: string | undefined; readonly rest: readonly string[] };
|
|
22
|
+
type Command = (a: Args) => Promise<number>;
|
|
23
|
+
|
|
24
|
+
const SA = process.env.SHORTS_ANIMATOR ?? path.join(import.meta.dirname, "../../shorts-animator/scripts/sa.ts");
|
|
25
|
+
const ART_LOOKS = ["ink", "gouache", "watercolor", "pastel", "oil"];
|
|
26
|
+
|
|
27
|
+
const unwrap = <T>(r: Result<T>): T => {
|
|
28
|
+
if (!r.ok) throw new Error(r.errors.join("\n "));
|
|
29
|
+
return r.value;
|
|
30
|
+
};
|
|
31
|
+
const need = (dir: string | undefined, usage: string): string => {
|
|
32
|
+
if (!dir) throw new Error(`usage: ${usage}`);
|
|
33
|
+
return dir;
|
|
34
|
+
};
|
|
35
|
+
const opt = (rest: readonly string[], k: string, dflt: string): string => {
|
|
36
|
+
const i = rest.indexOf(`--${k}`);
|
|
37
|
+
return (i >= 0 ? rest[i + 1] : undefined) ?? dflt;
|
|
38
|
+
};
|
|
39
|
+
const positional = (rest: readonly string[]): readonly string[] =>
|
|
40
|
+
rest.filter((a, i) => !a.startsWith("--") && !(rest[i - 1] ?? "").startsWith("--"));
|
|
41
|
+
|
|
42
|
+
const brandOf = (d: string): Brand => unwrap(parseBrand(readJson(path.join(d, "brand.json"))));
|
|
43
|
+
const deckFiles = (d: string, only?: string): readonly string[] => {
|
|
44
|
+
const ddir = path.join(d, "decks");
|
|
45
|
+
const all = fs.existsSync(ddir) ? fs.readdirSync(ddir).filter((f) => f.endsWith(".json")).toSorted() : [];
|
|
46
|
+
const picked = only ? all.filter((f) => f === `${only.replace(/\.json$/, "")}.json`) : all;
|
|
47
|
+
if (picked.length === 0) throw new Error(only ? `no deck decks/${only}.json` : `no decks in ${ddir}`);
|
|
48
|
+
return picked.map((f) => path.join(ddir, f));
|
|
49
|
+
};
|
|
50
|
+
const deckOf = (f: string): Deck => {
|
|
51
|
+
const r = parseDeck(readJson(f));
|
|
52
|
+
if (!r.ok) throw new Error(`${path.basename(f)}:\n ${r.errors.join("\n ")}`);
|
|
53
|
+
return r.value;
|
|
54
|
+
};
|
|
55
|
+
const library = (d: string): readonly string[] => {
|
|
56
|
+
const ldir = path.join(d, "library");
|
|
57
|
+
return fs.existsSync(ldir) ? fs.readdirSync(ldir).filter((f) => f.endsWith(".jpg")).map((f) => f.slice(0, -4)) : [];
|
|
58
|
+
};
|
|
59
|
+
const artPath = (d: string, a: Art): string | undefined =>
|
|
60
|
+
a.kind === "library" ? path.join(d, "library", `${a.name}.jpg`) : a.kind === "file" ? path.resolve(d, a.file) : undefined;
|
|
61
|
+
|
|
62
|
+
const deckFindings = (d: string, deck: Deck): readonly Finding[] =>
|
|
63
|
+
check({
|
|
64
|
+
deck, library: library(d),
|
|
65
|
+
files: deck.slides.flatMap((s) => (s.art.kind === "file" && fs.existsSync(path.resolve(d, s.art.file)) ? [s.art.file] : [])),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const sa = (args: readonly string[]): number => {
|
|
69
|
+
if (!fs.existsSync(SA)) throw new Error(`shorts-animator not found at ${SA}; install it next to this skill or set SHORTS_ANIMATOR`);
|
|
70
|
+
return spawnSync(process.execPath, [SA, ...args], { stdio: "inherit" }).status ?? 1;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const EXAMPLE_DECK = {
|
|
74
|
+
title: "Example",
|
|
75
|
+
caption: "The post text under the carousel. Two short lines and one question for the comments.",
|
|
76
|
+
slides: [
|
|
77
|
+
{ kind: "cover", title: "Five-word promise goes here", body: "One line on who it is for.", art: "cover" },
|
|
78
|
+
{ kind: "point", title: "First step, short", body: "One or two plain sentences that make it concrete." },
|
|
79
|
+
{ kind: "point", title: "Second step, short", body: "One or two plain sentences that make it concrete." },
|
|
80
|
+
{ kind: "cta", title: "Save this for later", body: "Try one step today.", follow: "Follow for more." },
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
const EXAMPLE_SCENES = `// One function per library drawing, named the way decks refer to it ("art": "cover").
|
|
84
|
+
// k is the shorts-animator kit (../shorts-animator/references/kit.md). One big subject, 1:1, brand colours.
|
|
85
|
+
window.SCENES = {
|
|
86
|
+
cover: (k) => {
|
|
87
|
+
k.sky({ stops: ["#f4efe3", "#e8dcc4"] });
|
|
88
|
+
k.sun({ x: 0.5, y: 0.45, r: 0.18, color: "#e0783a" });
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const create: Command = async ({ dir, rest }) => {
|
|
94
|
+
const d = need(dir, "CM new <dir>");
|
|
95
|
+
mkdir(d, "decks");
|
|
96
|
+
mkdir(d, "library");
|
|
97
|
+
const name = opt(rest, "name", "Untitled brand");
|
|
98
|
+
const look = opt(rest, "look", "ink");
|
|
99
|
+
if (!ART_LOOKS.includes(look)) throw new Error(`--look must be one of ${ART_LOOKS.join(", ")}`);
|
|
100
|
+
writeNew(path.join(d, "brand.json"), JSON.stringify({ name, handle: opt(rest, "handle", ""), look,
|
|
101
|
+
colors: { paper: "#f4efe3", ink: "#1b1712", accent: "#e0783a", muted: "#6b6257" },
|
|
102
|
+
fonts: { head: "Georgia, serif", body: '"Helvetica Neue", Helvetica, Arial, sans-serif' } }, null, 2) + "\n");
|
|
103
|
+
writeNew(path.join(d, "decks", "example.json"), JSON.stringify(EXAMPLE_DECK, null, 2) + "\n");
|
|
104
|
+
const art = path.join(d, "art");
|
|
105
|
+
if (!fs.existsSync(path.join(art, "short.json")) && fs.existsSync(SA)) {
|
|
106
|
+
sa(["new", art, "--look", look, "--format", "1:1"]);
|
|
107
|
+
fs.writeFileSync(path.join(art, "scenes.js"), EXAMPLE_SCENES);
|
|
108
|
+
}
|
|
109
|
+
console.log(`✓ ${d}\n brand.json name, handle, colours, fonts, art look\n decks/ one JSON per carousel\n art/ the drawings (scenes.js), drawn once per brand\n library/ the drawn pictures decks pick from`);
|
|
110
|
+
return 0;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const checkCmd: Command = async ({ dir, rest }) => {
|
|
114
|
+
const d = need(dir, "CM check <dir> [deck]");
|
|
115
|
+
brandOf(d);
|
|
116
|
+
const results = deckFiles(d, positional(rest)[0]).map((f) => {
|
|
117
|
+
const found = deckFindings(d, deckOf(f));
|
|
118
|
+
console.log(`${path.basename(f, ".json")}: ${report(found)}`);
|
|
119
|
+
return found;
|
|
120
|
+
});
|
|
121
|
+
return results.flat().some((f) => f.level === "error") ? 1 : 0;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Every library name any deck asks for, in first-use order. */
|
|
125
|
+
const wanted = (d: string): readonly string[] => [
|
|
126
|
+
...new Set(deckFiles(d).flatMap((f) => deckOf(f).slides.flatMap((s) => (s.art.kind === "library" ? [s.art.name] : [])))),
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const art: Command = async ({ dir, rest }) => {
|
|
130
|
+
const d = need(dir, "CM art <dir> [scene...]");
|
|
131
|
+
const names = positional(rest).length > 0 ? positional(rest) : wanted(d);
|
|
132
|
+
if (names.length === 0) throw new Error("no deck asks for a library drawing; add \"art\": \"<scene>\" to a slide or name scenes");
|
|
133
|
+
const adir = path.join(d, "art");
|
|
134
|
+
const short = fs.existsSync(path.join(adir, "short.json")) ? readJson(path.join(adir, "short.json")) : {};
|
|
135
|
+
const base: { readonly [k: string]: Json } = isMap(short) ? short : {};
|
|
136
|
+
// One still line per drawing, so frame N is drawing N; no voice, music or captions to pay for.
|
|
137
|
+
writeJson(path.join(mkdir(adir), "short.json"), {
|
|
138
|
+
...base, title: `${brandOf(d).name} art`, format: "1:1", voice: "none", music: "none", captions: { style: "none" },
|
|
139
|
+
lines: names.map((n) => ({ say: `the ${n} drawing for a carousel slide`, scene: n, move: "still" })),
|
|
140
|
+
});
|
|
141
|
+
if (sa(["check", adir]) !== 0) return 1;
|
|
142
|
+
if (sa(["frames", adir]) !== 0) return 1;
|
|
143
|
+
const ldir = mkdir(d, "library");
|
|
144
|
+
names.forEach((n, i) => fs.copyFileSync(path.join(adir, "frames", `s${String(i).padStart(3, "0")}.jpg`), path.join(ldir, `${n}.jpg`)));
|
|
145
|
+
console.log(`✓ ${names.length} drawings -> ${ldir}\n look once at ${path.join(adir, "frames", "sheet.jpg")}`);
|
|
146
|
+
return 0;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const fitOf = (dom: string): readonly Fit[] => {
|
|
150
|
+
const m = /<pre id="fit">([^<]*)<\/pre>/.exec(dom);
|
|
151
|
+
const raw: unknown = m?.[1] ? JSON.parse(m[1].replace(/"/g, '"').replace(/&/g, "&")) : [];
|
|
152
|
+
return Array.isArray(raw)
|
|
153
|
+
? raw.flatMap((x: unknown) => (isMap(x) && typeof x["slide"] === "number"
|
|
154
|
+
? [{ slide: x["slide"], overflow: x["overflow"] === true, missingArt: x["missingArt"] === true }] : []))
|
|
155
|
+
: [];
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const renderDeck = (d: string, brand: Brand, f: string): readonly Finding[] => {
|
|
159
|
+
const deck = deckOf(f);
|
|
160
|
+
const before = deckFindings(d, deck);
|
|
161
|
+
if (before.some((x) => x.level === "error")) return before;
|
|
162
|
+
const chrome = findChrome();
|
|
163
|
+
if (!chrome) throw new Error("no Chrome found; install Chrome or set CHROME=/path/to/chrome");
|
|
164
|
+
const out = mkdir(d, "out", path.basename(f, ".json"));
|
|
165
|
+
const srcs = deck.slides.map((s) => {
|
|
166
|
+
const p = artPath(d, s.art);
|
|
167
|
+
return p ? url(p) : undefined;
|
|
168
|
+
});
|
|
169
|
+
const ctxs = contexts(brand, deck.slides, srcs);
|
|
170
|
+
const sections = deck.slides.flatMap((s, i) => {
|
|
171
|
+
const c = ctxs[i];
|
|
172
|
+
return c ? [section(s, c)] : [];
|
|
173
|
+
});
|
|
174
|
+
const deckHtml = path.join(out, "deck.html");
|
|
175
|
+
fs.writeFileSync(deckHtml, page(brand, sections.join(""), MEASURE));
|
|
176
|
+
const fit = fitFindings(fitOf(dumpDom(chrome, deckHtml, H)));
|
|
177
|
+
sections.forEach((s, i) => {
|
|
178
|
+
const html = path.join(out, `slide-${i + 1}.html`);
|
|
179
|
+
fs.writeFileSync(html, page(brand, s));
|
|
180
|
+
screenshot(chrome, html, path.join(out, `slide-${i + 1}.png`));
|
|
181
|
+
});
|
|
182
|
+
fs.writeFileSync(path.join(out, "caption.txt"), (deck.caption ?? "") + "\n");
|
|
183
|
+
const sheetFile = path.join(out, "sheet.jpg");
|
|
184
|
+
const made = sheet(path.join(out, "slide-%d.png"), deck.slides.length, sheetFile);
|
|
185
|
+
console.log(`✓ ${deck.slides.length} slides -> ${out}${made ? `\n look once at ${sheetFile}` : "\n (no ffmpeg: no contact sheet)"}`);
|
|
186
|
+
return [...before, ...fit];
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const render: Command = async ({ dir, rest }) => {
|
|
190
|
+
const d = need(dir, "CM render <dir> [deck]");
|
|
191
|
+
const brand = brandOf(d);
|
|
192
|
+
const found = deckFiles(d, positional(rest)[0]).flatMap((f) => {
|
|
193
|
+
const r = renderDeck(d, brand, f);
|
|
194
|
+
if (r.length > 0) console.log(`${path.basename(f, ".json")}: ${report(r)}`);
|
|
195
|
+
return r;
|
|
196
|
+
});
|
|
197
|
+
return found.some((f) => f.level === "error") ? 1 : 0;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const COMMANDS: Readonly<Record<string, Command>> = { new: create, check: checkCmd, art, render };
|
|
201
|
+
|
|
202
|
+
const usage = (): string => fs.readFileSync(import.meta.filename, "utf8").split("\n").slice(2, 11).join("\n").replace(/^ \* ?/gm, "");
|
|
203
|
+
|
|
204
|
+
process.removeAllListeners("warning");
|
|
205
|
+
process.on("warning", (w) => {
|
|
206
|
+
if (!(w.name === "ExperimentalWarning" && /stripTypeScriptTypes/.test(w.message))) console.error(`${w.name}: ${w.message}`);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const [cmd = "", dirArg, ...rest] = process.argv.slice(2);
|
|
210
|
+
const command = COMMANDS[cmd];
|
|
211
|
+
if (!command) {
|
|
212
|
+
console.log(usage());
|
|
213
|
+
process.exit(cmd ? 1 : 0);
|
|
214
|
+
}
|
|
215
|
+
command({ dir: dirArg ? path.resolve(dirArg) : undefined, rest })
|
|
216
|
+
.then((code) => process.exit(code))
|
|
217
|
+
.catch((e: unknown) => {
|
|
218
|
+
console.error("✗ " + (e instanceof Error ? e.message : String(e)));
|
|
219
|
+
process.exit(1);
|
|
220
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// `CM check`: catch what a render would get wrong, before paying for one. Each rule is a pure
|
|
2
|
+
// function over plain facts; the edge gathers the facts (the deck, the library, which files exist).
|
|
3
|
+
import { KINDS } from "./layout.ts";
|
|
4
|
+
import type { Deck, Slide } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export type Facts = { readonly deck: Deck; readonly library: readonly string[]; readonly files: readonly string[] };
|
|
7
|
+
export type Fit = { readonly slide: number; readonly overflow: boolean; readonly missingArt: boolean };
|
|
8
|
+
export type Finding = { readonly level: "error" | "warn"; readonly where: string; readonly msg: string };
|
|
9
|
+
|
|
10
|
+
const SLIDES = { min: 3, max: 10 };
|
|
11
|
+
|
|
12
|
+
const err = (where: string, msg: string): Finding => ({ level: "error", where, msg });
|
|
13
|
+
const warn = (where: string, msg: string): Finding => ({ level: "warn", where, msg });
|
|
14
|
+
const at = (i: number): string => `slide ${i + 1}`;
|
|
15
|
+
const words = (s: string | undefined): number => (s ? s.trim().split(/\s+/).length : 0);
|
|
16
|
+
const each = (deck: Deck, rule: (s: Slide, i: number) => readonly Finding[]): readonly Finding[] => deck.slides.flatMap(rule);
|
|
17
|
+
|
|
18
|
+
const unknownKinds = ({ deck }: Facts): readonly Finding[] =>
|
|
19
|
+
each(deck, (s, i) => (KINDS[s.kind] ? [] : [err(at(i), `kind "${s.kind}" is not one of ${Object.keys(KINDS).join(", ")}`)]));
|
|
20
|
+
|
|
21
|
+
const deckSize = ({ deck: { slides } }: Facts): readonly Finding[] =>
|
|
22
|
+
slides.length < SLIDES.min || slides.length > SLIDES.max ? [warn("deck", `${slides.length} slides; ${SLIDES.min} to ${SLIDES.max} read best`)] : [];
|
|
23
|
+
|
|
24
|
+
const shape = ({ deck: { slides } }: Facts): readonly Finding[] => [
|
|
25
|
+
...(slides[0]?.kind === "cover" ? [] : [warn(at(0), "the first slide should be a cover: it is the only one most people see")]),
|
|
26
|
+
...(slides.length < 2 || slides.at(-1)?.kind === "cta" ? [] : [warn(at(slides.length - 1), "the last slide should be a cta (save, follow, comment)")]),
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const lengths = ({ deck }: Facts): readonly Finding[] =>
|
|
30
|
+
each(deck, (s, i) => {
|
|
31
|
+
const k = KINDS[s.kind];
|
|
32
|
+
if (!k) return [];
|
|
33
|
+
return [
|
|
34
|
+
...(words(s.title) > k.titleWords ? [warn(at(i), `title is ${words(s.title)} words; a ${s.kind} title fits ${k.titleWords}`)] : []),
|
|
35
|
+
...(words(s.body) > k.bodyWords ? [warn(at(i), `body is ${words(s.body)} words; a ${s.kind} body fits ${k.bodyWords}`)] : []),
|
|
36
|
+
];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const missingArt = ({ deck, library, files }: Facts): readonly Finding[] => {
|
|
40
|
+
const lib = new Set(library);
|
|
41
|
+
const have = new Set(files);
|
|
42
|
+
return each(deck, (s, i) =>
|
|
43
|
+
s.art.kind === "library" && !lib.has(s.art.name)
|
|
44
|
+
? [err(at(i), `no drawing "${s.art.name}" in library/; add it to art/scenes.js and run CM art`)]
|
|
45
|
+
: s.art.kind === "file" && !have.has(s.art.file)
|
|
46
|
+
? [err(at(i), `no file ${s.art.file}`)]
|
|
47
|
+
: [],
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const repeatedArt = ({ deck }: Facts): readonly Finding[] => {
|
|
52
|
+
const names = deck.slides.map((s) => (s.art.kind === "library" ? s.art.name : s.art.kind === "file" ? s.art.file : ""));
|
|
53
|
+
return names.flatMap((n, i) => (n && names.indexOf(n) < i ? [warn(at(i), `repeats the picture of slide ${names.indexOf(n) + 1}`)] : []));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const RULES = [unknownKinds, missingArt, deckSize, shape, lengths, repeatedArt];
|
|
57
|
+
|
|
58
|
+
export const check = (facts: Facts): readonly Finding[] => RULES.flatMap((rule) => rule(facts));
|
|
59
|
+
|
|
60
|
+
/** What the browser measured after layout, as findings. */
|
|
61
|
+
export const fitFindings = (fit: readonly Fit[]): readonly Finding[] =>
|
|
62
|
+
fit.flatMap((f) => [
|
|
63
|
+
...(f.overflow ? [err(`slide ${f.slide}`, "text spills out of the frame; shorten the title or body")] : []),
|
|
64
|
+
...(f.missingArt ? [err(`slide ${f.slide}`, "its picture did not load")] : []),
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
export const report = (fs: readonly Finding[]): string =>
|
|
68
|
+
fs.length === 0 ? "check: clean" : fs.map((f) => `${f.level === "error" ? "ERROR" : "warn "} ${f.where}: ${f.msg}`).join("\n");
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// The brand template: one fixed 1080x1350 layout per slide kind, filled from the deck. Pure strings;
|
|
2
|
+
// the edge decides where the HTML goes and resolves each picture to a URL first.
|
|
3
|
+
import type { Brand, Slide } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export const W = 1080;
|
|
6
|
+
export const H = 1350;
|
|
7
|
+
|
|
8
|
+
export type Ctx = { readonly brand: Brand; readonly n: number; readonly total: number; readonly point: number; readonly src?: string };
|
|
9
|
+
type Kind = { readonly titleWords: number; readonly bodyWords: number; readonly render: (s: Slide, c: Ctx) => string };
|
|
10
|
+
|
|
11
|
+
export const esc = (s: string): string => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12
|
+
const img = (c: Ctx): string => (c.src ? `<img class="art" src="${esc(c.src)}">` : "");
|
|
13
|
+
const para = (text: string | undefined, cls = "body"): string => (text ? `<p class="${cls}">${esc(text)}</p>` : "");
|
|
14
|
+
const BOOKMARK = (b: Brand): string =>
|
|
15
|
+
`<svg class="mark" width="150" height="190" viewBox="0 0 150 190"><path d="M14 10h122v170l-61-44-61 44z" fill="${b.colors.accent}" stroke="${b.colors.ink}" stroke-width="8" stroke-linejoin="round"/></svg>`;
|
|
16
|
+
|
|
17
|
+
export const KINDS: Readonly<Record<string, Kind>> = {
|
|
18
|
+
cover: { titleWords: 8, bodyWords: 14, render: (s, c) => `${img(c)}<h1>${esc(s.title)}</h1>${para(s.body)}` },
|
|
19
|
+
point: {
|
|
20
|
+
titleWords: 7, bodyWords: 22,
|
|
21
|
+
render: (s, c) => `<div class="n">${String(c.point).padStart(2, "0")}</div><h1>${esc(s.title)}</h1>${para(s.body)}${img(c)}`,
|
|
22
|
+
},
|
|
23
|
+
cta: { titleWords: 6, bodyWords: 14, render: (s, c) => `${c.src ? img(c) : BOOKMARK(c.brand)}<h1>${esc(s.title)}</h1>${para(s.body)}${para(s.follow, "body follow")}` },
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export const css = ({ colors: k, fonts: f }: Brand): string => `
|
|
27
|
+
*{margin:0;box-sizing:border-box}
|
|
28
|
+
body{background:${k.paper}}
|
|
29
|
+
.slide{width:${W}px;height:${H}px;background:${k.paper};color:${k.ink};font-family:${f.body};
|
|
30
|
+
padding:80px 84px 64px;display:flex;flex-direction:column;overflow:hidden;position:relative}
|
|
31
|
+
h1{font-family:${f.head};font-weight:700;letter-spacing:-1.5px;line-height:1.02;text-wrap:balance;overflow-wrap:normal}
|
|
32
|
+
.art{display:block;width:100%;aspect-ratio:1;object-fit:cover;border-radius:28px;border:4px solid ${k.ink}}
|
|
33
|
+
.n{font-family:${f.head};font-weight:700;font-size:56px;color:${k.accent};margin-bottom:12px}
|
|
34
|
+
.body{font-size:34px;line-height:1.35;color:${k.muted};margin-top:18px;text-wrap:pretty}
|
|
35
|
+
.foot{margin-top:auto;display:flex;justify-content:space-between;font-size:26px;font-weight:600;color:${k.muted};padding-top:28px}
|
|
36
|
+
.swipe{color:${k.accent}}
|
|
37
|
+
.cover h1{font-size:92px;margin-top:44px}
|
|
38
|
+
.cover .art{width:760px;margin:0 auto}
|
|
39
|
+
.point h1{font-size:76px}
|
|
40
|
+
.point .art{width:800px;margin:44px auto 0}
|
|
41
|
+
.cta{justify-content:center;text-align:center}
|
|
42
|
+
.cta .mark{margin:auto auto 0}
|
|
43
|
+
.cta .art{width:520px;margin:auto auto 0}
|
|
44
|
+
.cta h1{font-size:104px;margin-top:56px}
|
|
45
|
+
.cta .follow{color:${k.ink};font-weight:600;margin-bottom:auto}
|
|
46
|
+
.noart.cover h1{font-size:128px;margin-top:auto}
|
|
47
|
+
.noart.point h1{font-size:104px;margin-top:40px}
|
|
48
|
+
.noart.point .body{font-size:44px;margin-top:36px}
|
|
49
|
+
`;
|
|
50
|
+
|
|
51
|
+
const foot = (c: Ctx): string =>
|
|
52
|
+
`<div class="foot"><span>${esc(c.brand.handle)}</span><span>${c.n === 1 ? '<span class="swipe">swipe →</span>' : `${c.n}/${c.total}`}</span></div>`;
|
|
53
|
+
|
|
54
|
+
/** One slide's section; an unknown kind renders as a point so a typo never blanks a slide. */
|
|
55
|
+
export const section = (s: Slide, c: Ctx): string => {
|
|
56
|
+
const kind = KINDS[s.kind] ? s.kind : "point";
|
|
57
|
+
const body = (KINDS[kind] ?? KINDS["point"])?.render(s, c) ?? "";
|
|
58
|
+
return `<section class="slide ${kind}${c.src ? "" : " noart"}">${body}${foot(c)}</section>`;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The context of every slide: its place in the deck, its point number, its picture. */
|
|
62
|
+
export const contexts = (brand: Brand, slides: readonly Slide[], srcs: readonly (string | undefined)[]): readonly Ctx[] =>
|
|
63
|
+
slides.map((s, i) => ({
|
|
64
|
+
brand, n: i + 1, total: slides.length, src: srcs[i],
|
|
65
|
+
point: slides.slice(0, i + 1).filter((x) => x.kind === "point" || !KINDS[x.kind]).length,
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
export const page = (brand: Brand, body: string, script = ""): string =>
|
|
69
|
+
`<!doctype html><meta charset="utf-8"><style>${css(brand)}</style><body>${body}${script}</body>`;
|
|
70
|
+
|
|
71
|
+
/** Runs in the browser after layout: which slides spill out of their frame or lost their picture. */
|
|
72
|
+
export const MEASURE = `<script>addEventListener("load",()=>{const r=[...document.querySelectorAll(".slide")].map((s,i)=>({slide:i+1,
|
|
73
|
+
overflow:s.scrollHeight>s.clientHeight+1||s.scrollWidth>s.clientWidth+1||[...s.querySelectorAll("h1,p")].some(e=>e.scrollWidth>e.clientWidth+1),
|
|
74
|
+
missingArt:[...s.querySelectorAll("img")].some(i=>!i.complete||i.naturalWidth===0)}));
|
|
75
|
+
const p=document.createElement("pre");p.id="fit";p.textContent=JSON.stringify(r);document.body.append(p)})</script>`;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// brand.json and decks/*.json arrive as unknown JSON; this is the one place they are narrowed.
|
|
2
|
+
import type { Art, Brand, Deck, Json, JsonMap, Result, Slide } from "./types.ts";
|
|
3
|
+
import { fail, ok } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export const isMap = (x: unknown): x is JsonMap => typeof x === "object" && x !== null && !Array.isArray(x);
|
|
6
|
+
|
|
7
|
+
const str = (o: JsonMap, k: string): string | undefined => {
|
|
8
|
+
const v = o[k];
|
|
9
|
+
return typeof v === "string" && v.trim() !== "" ? v : undefined;
|
|
10
|
+
};
|
|
11
|
+
const map = (o: JsonMap, k: string): JsonMap => {
|
|
12
|
+
const v = o[k];
|
|
13
|
+
return isMap(v) ? v : {};
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const HEX = /^#[0-9a-f]{6}$/i;
|
|
17
|
+
export const DEFAULT_COLORS = { paper: "#f4efe3", ink: "#1b1712", accent: "#e0783a", muted: "#6b6257" };
|
|
18
|
+
export const DEFAULT_FONTS = { head: "Georgia, serif", body: '"Helvetica Neue", Helvetica, Arial, sans-serif' };
|
|
19
|
+
|
|
20
|
+
export function parseBrand(raw: unknown): Result<Brand> {
|
|
21
|
+
if (!isMap(raw)) return fail("brand.json is not an object");
|
|
22
|
+
const c = map(raw, "colors");
|
|
23
|
+
const f = map(raw, "fonts");
|
|
24
|
+
const colors = {
|
|
25
|
+
paper: str(c, "paper") ?? DEFAULT_COLORS.paper, ink: str(c, "ink") ?? DEFAULT_COLORS.ink,
|
|
26
|
+
accent: str(c, "accent") ?? DEFAULT_COLORS.accent, muted: str(c, "muted") ?? DEFAULT_COLORS.muted,
|
|
27
|
+
};
|
|
28
|
+
const bad = Object.entries(colors).filter(([, v]) => !HEX.test(v)).map(([k, v]) => `colors.${k} "${v}" is not a #rrggbb colour`);
|
|
29
|
+
const name = str(raw, "name");
|
|
30
|
+
const errors = [...(name ? [] : ["brand.json needs a name"]), ...bad];
|
|
31
|
+
if (errors.length > 0 || !name) return fail(...errors);
|
|
32
|
+
return ok({ name, handle: str(raw, "handle") ?? "", colors, fonts: { head: str(f, "head") ?? DEFAULT_FONTS.head, body: str(f, "body") ?? DEFAULT_FONTS.body } });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A bare word is a library drawing; anything with a slash or an extension is a file. */
|
|
36
|
+
export const artOf = (x: Json | undefined): Art =>
|
|
37
|
+
typeof x !== "string" || x.trim() === "" ? { kind: "none" } : /[/.]/.test(x) ? { kind: "file", file: x } : { kind: "library", name: x };
|
|
38
|
+
|
|
39
|
+
const slideOf = (x: Json, i: number): Result<Slide> => {
|
|
40
|
+
if (!isMap(x)) return fail(`slides[${i}] is not an object`);
|
|
41
|
+
const title = str(x, "title");
|
|
42
|
+
if (!title) return fail(`slides[${i}] needs a title`);
|
|
43
|
+
return ok({ kind: str(x, "kind") ?? "point", title, body: str(x, "body"), follow: str(x, "follow"), art: artOf(x["art"]) });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function parseDeck(raw: unknown): Result<Deck> {
|
|
47
|
+
if (!isMap(raw)) return fail("the deck is not an object");
|
|
48
|
+
const list = raw["slides"];
|
|
49
|
+
if (!Array.isArray(list) || list.length === 0) return fail("the deck needs a non-empty slides list");
|
|
50
|
+
const parsed = list.map(slideOf);
|
|
51
|
+
const errors = parsed.flatMap((r) => (r.ok ? [] : r.errors));
|
|
52
|
+
const slides = parsed.flatMap((r) => (r.ok ? [r.value] : []));
|
|
53
|
+
return errors.length > 0 ? fail(...errors) : ok({ title: str(raw, "title") ?? "carousel", caption: str(raw, "caption"), slides });
|
|
54
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// The data a carousel is made of. Maps stay open: unknown keys are ignored, never rejected.
|
|
2
|
+
|
|
3
|
+
export type Json = null | boolean | number | string | readonly Json[] | { readonly [k: string]: Json };
|
|
4
|
+
export type JsonMap = { readonly [k: string]: Json };
|
|
5
|
+
|
|
6
|
+
export type Brand = {
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly handle: string;
|
|
9
|
+
readonly colors: { readonly paper: string; readonly ink: string; readonly accent: string; readonly muted: string };
|
|
10
|
+
readonly fonts: { readonly head: string; readonly body: string };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Where a slide's picture comes from: a drawing in the brand library, a file, or nothing. */
|
|
14
|
+
export type Art = { readonly kind: "none" } | { readonly kind: "library"; readonly name: string } | { readonly kind: "file"; readonly file: string };
|
|
15
|
+
|
|
16
|
+
export type Slide = {
|
|
17
|
+
readonly kind: string;
|
|
18
|
+
readonly title: string;
|
|
19
|
+
readonly body?: string;
|
|
20
|
+
readonly follow?: string;
|
|
21
|
+
readonly art: Art;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type Deck = {
|
|
25
|
+
readonly title: string;
|
|
26
|
+
readonly caption?: string;
|
|
27
|
+
readonly slides: readonly Slide[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type Result<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly errors: readonly string[] };
|
|
31
|
+
|
|
32
|
+
export const ok = <T>(value: T): Result<T> => ({ ok: true, value });
|
|
33
|
+
export const fail = <T>(...errors: readonly string[]): Result<T> => ({ ok: false, errors });
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Every process, browser and disk write the tool makes, kept out of the domain.
|
|
2
|
+
import { spawnSync, type SpawnSyncOptions } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { H, W } from "../domain/layout.ts";
|
|
8
|
+
import type { Json } from "../domain/types.ts";
|
|
9
|
+
|
|
10
|
+
export function run(cmd: string, args: readonly string[], opts: SpawnSyncOptions = {}): string {
|
|
11
|
+
const r = spawnSync(cmd, args, { encoding: "utf8", maxBuffer: 1 << 26, timeout: 120_000, ...opts });
|
|
12
|
+
if (r.status !== 0) {
|
|
13
|
+
const out = String(r.stderr || r.stdout || r.error?.message || "");
|
|
14
|
+
throw new Error(`${path.basename(cmd)} ${args.slice(0, 2).join(" ")}... failed:\n${out.slice(-1500)}`);
|
|
15
|
+
}
|
|
16
|
+
return String(r.stdout ?? "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const has = (cmd: string): boolean => spawnSync(cmd, ["-version"], { stdio: "ignore" }).status === 0;
|
|
20
|
+
export const readJson = (f: string): unknown => JSON.parse(fs.readFileSync(f, "utf8"));
|
|
21
|
+
export const writeJson = (f: string, v: Json | object): void => fs.writeFileSync(f, JSON.stringify(v, null, 2) + "\n");
|
|
22
|
+
export const mkdir = (...parts: string[]): string => {
|
|
23
|
+
const d = path.join(...parts);
|
|
24
|
+
fs.mkdirSync(d, { recursive: true });
|
|
25
|
+
return d;
|
|
26
|
+
};
|
|
27
|
+
export const writeNew = (f: string, text: string): boolean => {
|
|
28
|
+
if (fs.existsSync(f)) return false;
|
|
29
|
+
fs.writeFileSync(f, text);
|
|
30
|
+
return true;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type Chrome = { readonly bin: string; readonly shell: boolean };
|
|
34
|
+
|
|
35
|
+
const SHELL = process.platform === "win32" ? "chrome-headless-shell.exe" : "chrome-headless-shell";
|
|
36
|
+
const SYSTEM: Readonly<Record<string, readonly string[]>> = {
|
|
37
|
+
darwin: ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
|
38
|
+
linux: ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable", "/usr/bin/chromium", "/usr/bin/chromium-browser"],
|
|
39
|
+
win32: [
|
|
40
|
+
`${process.env["PROGRAMFILES"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
41
|
+
`${process.env["PROGRAMFILES(X86)"]}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
42
|
+
`${process.env["PROGRAMFILES(X86)"]}\\Microsoft\\Edge\\Application\\msedge.exe`,
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const dirs = (d: string): readonly string[] =>
|
|
47
|
+
fs.existsSync(d) ? fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => path.join(d, e.name)) : [];
|
|
48
|
+
|
|
49
|
+
/** The headless shell shorts-animator's doctor (via HyperFrames) downloads, newest first. */
|
|
50
|
+
const cachedShell = (): string | undefined =>
|
|
51
|
+
dirs(path.join(os.homedir(), ".cache/hyperframes/chrome/chrome-headless-shell"))
|
|
52
|
+
.toSorted()
|
|
53
|
+
.reverse()
|
|
54
|
+
.flatMap((v) => dirs(v).map((sub) => path.join(sub, SHELL)))
|
|
55
|
+
.find((f) => fs.existsSync(f));
|
|
56
|
+
|
|
57
|
+
export function findChrome(): Chrome | undefined {
|
|
58
|
+
const env = process.env.CHROME;
|
|
59
|
+
if (env && fs.existsSync(env)) return { bin: env, shell: /headless-shell/.test(env) };
|
|
60
|
+
const sys = (SYSTEM[process.platform] ?? []).find((c) => fs.existsSync(c));
|
|
61
|
+
if (sys) return { bin: sys, shell: false };
|
|
62
|
+
const shell = cachedShell();
|
|
63
|
+
return shell ? { bin: shell, shell: true } : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One short-lived browser per call: no DevTools socket to manage, and a crash costs one slide.
|
|
67
|
+
const flags = (c: Chrome, height: number): readonly string[] => [
|
|
68
|
+
...(c.shell ? [] : ["--headless"]), "--disable-gpu", "--hide-scrollbars", "--allow-file-access-from-files",
|
|
69
|
+
"--force-device-scale-factor=1", "--no-first-run", "--no-default-browser-check", `--window-size=${W},${height}`,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const url = (file: string): string => pathToFileURL(path.resolve(file)).href;
|
|
73
|
+
|
|
74
|
+
export const screenshot = (c: Chrome, html: string, png: string): void =>
|
|
75
|
+
void run(c.bin, [...flags(c, H), `--screenshot=${png}`, url(html)], { stdio: ["ignore", "ignore", "pipe"] });
|
|
76
|
+
|
|
77
|
+
/** The DOM after load, images and fonts included (the virtual time budget waits for them). */
|
|
78
|
+
export const dumpDom = (c: Chrome, html: string, height: number): string =>
|
|
79
|
+
run(c.bin, [...flags(c, height), "--virtual-time-budget=5000", "--dump-dom", url(html)]);
|
|
80
|
+
|
|
81
|
+
export const sheet = (pattern: string, count: number, out: string): boolean => {
|
|
82
|
+
if (!has("ffmpeg")) return false;
|
|
83
|
+
const cols = Math.min(count, 5);
|
|
84
|
+
run("ffmpeg", ["-v", "error", "-y", "-start_number", "1", "-framerate", "1", "-i", pattern,
|
|
85
|
+
"-vf", `scale=360:-2,tile=${cols}x${Math.ceil(count / cols)}:padding=8:color=white`, "-frames:v", "1", out]);
|
|
86
|
+
return true;
|
|
87
|
+
};
|