@softure-ai/marketing-kit 0.0.0-stage → 0.1.6
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/LICENSE +21 -0
- package/README.md +434 -2
- package/dist/cli/failure.d.ts +10 -0
- package/dist/cli/failure.d.ts.map +1 -0
- package/dist/cli/failure.js +16 -0
- package/dist/cli/failure.js.map +1 -0
- package/dist/cli/films.d.ts +13 -0
- package/dist/cli/films.d.ts.map +1 -0
- package/dist/cli/films.js +31 -0
- package/dist/cli/films.js.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +241 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/og.d.ts +4 -0
- package/dist/cli/og.d.ts.map +1 -0
- package/dist/cli/og.js +24 -0
- package/dist/cli/og.js.map +1 -0
- package/dist/cli/options.d.ts +58 -0
- package/dist/cli/options.d.ts.map +1 -0
- package/dist/cli/options.js +123 -0
- package/dist/cli/options.js.map +1 -0
- package/dist/cli/server.d.ts +12 -0
- package/dist/cli/server.d.ts.map +1 -0
- package/dist/cli/server.js +70 -0
- package/dist/cli/server.js.map +1 -0
- package/dist/cli/voice.d.ts +14 -0
- package/dist/cli/voice.d.ts.map +1 -0
- package/dist/cli/voice.js +47 -0
- package/dist/cli/voice.js.map +1 -0
- package/dist/compose/compose.d.ts +82 -0
- package/dist/compose/compose.d.ts.map +1 -0
- package/dist/compose/compose.js +364 -0
- package/dist/compose/compose.js.map +1 -0
- package/dist/compose/timeline.d.ts +202 -0
- package/dist/compose/timeline.d.ts.map +1 -0
- package/dist/compose/timeline.js +260 -0
- package/dist/compose/timeline.js.map +1 -0
- package/dist/config/actions-schema.d.ts +1166 -0
- package/dist/config/actions-schema.d.ts.map +1 -0
- package/dist/config/actions-schema.js +230 -0
- package/dist/config/actions-schema.js.map +1 -0
- package/dist/config/brand.d.ts +20 -0
- package/dist/config/brand.d.ts.map +1 -0
- package/dist/config/brand.js +72 -0
- package/dist/config/brand.js.map +1 -0
- package/dist/config/colors.d.ts +23 -0
- package/dist/config/colors.d.ts.map +1 -0
- package/dist/config/colors.js +39 -0
- package/dist/config/colors.js.map +1 -0
- package/dist/config/config.d.ts +126 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +173 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/css-colors.d.ts +22 -0
- package/dist/config/css-colors.d.ts.map +1 -0
- package/dist/config/css-colors.js +119 -0
- package/dist/config/css-colors.js.map +1 -0
- package/dist/config/day.d.ts +5 -0
- package/dist/config/day.d.ts.map +1 -0
- package/dist/config/day.js +10 -0
- package/dist/config/day.js.map +1 -0
- package/dist/config/design-json.d.ts +3 -0
- package/dist/config/design-json.d.ts.map +1 -0
- package/dist/config/design-json.js +36 -0
- package/dist/config/design-json.js.map +1 -0
- package/dist/config/issues.d.ts +23 -0
- package/dist/config/issues.d.ts.map +1 -0
- package/dist/config/issues.js +41 -0
- package/dist/config/issues.js.map +1 -0
- package/dist/config/schema.d.ts +1353 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +567 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/config/screenshot-names.d.ts +22 -0
- package/dist/config/screenshot-names.d.ts.map +1 -0
- package/dist/config/screenshot-names.js +15 -0
- package/dist/config/screenshot-names.js.map +1 -0
- package/dist/film.d.ts +149 -0
- package/dist/film.d.ts.map +1 -0
- package/dist/film.js +18 -0
- package/dist/film.js.map +1 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/messages/en.d.ts +23 -0
- package/dist/messages/en.d.ts.map +1 -0
- package/dist/messages/en.js +23 -0
- package/dist/messages/en.js.map +1 -0
- package/dist/messages/index.d.ts +49 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +18 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/pl.d.ts +3 -0
- package/dist/messages/pl.d.ts.map +1 -0
- package/dist/messages/pl.js +21 -0
- package/dist/messages/pl.js.map +1 -0
- package/dist/og/character-map.d.ts +15 -0
- package/dist/og/character-map.d.ts.map +1 -0
- package/dist/og/character-map.js +156 -0
- package/dist/og/character-map.js.map +1 -0
- package/dist/og/element.d.ts +19 -0
- package/dist/og/element.d.ts.map +1 -0
- package/dist/og/element.js +18 -0
- package/dist/og/element.js.map +1 -0
- package/dist/og/fonts.d.ts +50 -0
- package/dist/og/fonts.d.ts.map +1 -0
- package/dist/og/fonts.js +99 -0
- package/dist/og/fonts.js.map +1 -0
- package/dist/og/glyphs.d.ts +23 -0
- package/dist/og/glyphs.d.ts.map +1 -0
- package/dist/og/glyphs.js +165 -0
- package/dist/og/glyphs.js.map +1 -0
- package/dist/og/index.d.ts +12 -0
- package/dist/og/index.d.ts.map +1 -0
- package/dist/og/index.js +12 -0
- package/dist/og/index.js.map +1 -0
- package/dist/og/palette.d.ts +25 -0
- package/dist/og/palette.d.ts.map +1 -0
- package/dist/og/palette.js +25 -0
- package/dist/og/palette.js.map +1 -0
- package/dist/og/render.d.ts +47 -0
- package/dist/og/render.d.ts.map +1 -0
- package/dist/og/render.js +141 -0
- package/dist/og/render.js.map +1 -0
- package/dist/og/result.d.ts +11 -0
- package/dist/og/result.d.ts.map +1 -0
- package/dist/og/result.js +3 -0
- package/dist/og/result.js.map +1 -0
- package/dist/og/templates/context.d.ts +25 -0
- package/dist/og/templates/context.d.ts.map +1 -0
- package/dist/og/templates/context.js +2 -0
- package/dist/og/templates/context.js.map +1 -0
- package/dist/og/templates/frame.d.ts +23 -0
- package/dist/og/templates/frame.d.ts.map +1 -0
- package/dist/og/templates/frame.js +62 -0
- package/dist/og/templates/frame.js.map +1 -0
- package/dist/og/templates/headline-chart.d.ts +5 -0
- package/dist/og/templates/headline-chart.d.ts.map +1 -0
- package/dist/og/templates/headline-chart.js +28 -0
- package/dist/og/templates/headline-chart.js.map +1 -0
- package/dist/og/templates/headline-cta.d.ts +5 -0
- package/dist/og/templates/headline-cta.d.ts.map +1 -0
- package/dist/og/templates/headline-cta.js +25 -0
- package/dist/og/templates/headline-cta.js.map +1 -0
- package/dist/og/templates/index.d.ts +17 -0
- package/dist/og/templates/index.d.ts.map +1 -0
- package/dist/og/templates/index.js +16 -0
- package/dist/og/templates/index.js.map +1 -0
- package/dist/og/templates/schemas.d.ts +37 -0
- package/dist/og/templates/schemas.d.ts.map +1 -0
- package/dist/og/templates/schemas.js +50 -0
- package/dist/og/templates/schemas.js.map +1 -0
- package/dist/platforms.d.ts +22 -0
- package/dist/platforms.d.ts.map +1 -0
- package/dist/platforms.js +32 -0
- package/dist/platforms.js.map +1 -0
- package/dist/posts/posts.d.ts +30 -0
- package/dist/posts/posts.d.ts.map +1 -0
- package/dist/posts/posts.js +31 -0
- package/dist/posts/posts.js.map +1 -0
- package/dist/record/actions.d.ts +17 -0
- package/dist/record/actions.d.ts.map +1 -0
- package/dist/record/actions.js +91 -0
- package/dist/record/actions.js.map +1 -0
- package/dist/record/record.d.ts +84 -0
- package/dist/record/record.d.ts.map +1 -0
- package/dist/record/record.js +294 -0
- package/dist/record/record.js.map +1 -0
- package/dist/render/hyperframes.d.ts +19 -0
- package/dist/render/hyperframes.d.ts.map +1 -0
- package/dist/render/hyperframes.js +25 -0
- package/dist/render/hyperframes.js.map +1 -0
- package/dist/render/preflight.d.ts +9 -0
- package/dist/render/preflight.d.ts.map +1 -0
- package/dist/render/preflight.js +27 -0
- package/dist/render/preflight.js.map +1 -0
- package/dist/render/render.d.ts +36 -0
- package/dist/render/render.d.ts.map +1 -0
- package/dist/render/render.js +114 -0
- package/dist/render/render.js.map +1 -0
- package/dist/screenshot/gates.d.ts +14 -0
- package/dist/screenshot/gates.d.ts.map +1 -0
- package/dist/screenshot/gates.js +23 -0
- package/dist/screenshot/gates.js.map +1 -0
- package/dist/screenshot/screenshot.d.ts +50 -0
- package/dist/screenshot/screenshot.d.ts.map +1 -0
- package/dist/screenshot/screenshot.js +143 -0
- package/dist/screenshot/screenshot.js.map +1 -0
- package/dist/voice/cache.d.ts +28 -0
- package/dist/voice/cache.d.ts.map +1 -0
- package/dist/voice/cache.js +66 -0
- package/dist/voice/cache.js.map +1 -0
- package/dist/voice/elevenlabs.d.ts +12 -0
- package/dist/voice/elevenlabs.d.ts.map +1 -0
- package/dist/voice/elevenlabs.js +58 -0
- package/dist/voice/elevenlabs.js.map +1 -0
- package/dist/voice/fake.d.ts +17 -0
- package/dist/voice/fake.d.ts.map +1 -0
- package/dist/voice/fake.js +27 -0
- package/dist/voice/fake.js.map +1 -0
- package/dist/voice/produce.d.ts +33 -0
- package/dist/voice/produce.d.ts.map +1 -0
- package/dist/voice/produce.js +43 -0
- package/dist/voice/produce.js.map +1 -0
- package/dist/voice/provider.d.ts +41 -0
- package/dist/voice/provider.d.ts.map +1 -0
- package/dist/voice/provider.js +3 -0
- package/dist/voice/provider.js.map +1 -0
- package/dist/voice/providers.d.ts +11 -0
- package/dist/voice/providers.d.ts.map +1 -0
- package/dist/voice/providers.js +9 -0
- package/dist/voice/providers.js.map +1 -0
- package/dist/voice/voiceover.d.ts +65 -0
- package/dist/voice/voiceover.d.ts.map +1 -0
- package/dist/voice/voiceover.js +133 -0
- package/dist/voice/voiceover.js.map +1 -0
- package/package.json +57 -4
- package/schema/marketing.schema.json +3494 -0
- package/src/cli/failure.ts +17 -0
- package/src/cli/films.ts +35 -0
- package/src/cli/main.ts +237 -0
- package/src/cli/og.ts +24 -0
- package/src/cli/options.ts +154 -0
- package/src/cli/server.ts +74 -0
- package/src/cli/voice.ts +53 -0
- package/src/compose/compose.ts +425 -0
- package/src/compose/timeline.ts +384 -0
- package/src/config/actions-schema.ts +250 -0
- package/src/config/brand.ts +83 -0
- package/src/config/colors.ts +53 -0
- package/src/config/config.ts +262 -0
- package/src/config/css-colors.ts +118 -0
- package/src/config/day.ts +9 -0
- package/src/config/design-json.ts +37 -0
- package/src/config/issues.ts +56 -0
- package/src/config/schema.ts +644 -0
- package/src/config/screenshot-names.ts +31 -0
- package/src/film.ts +156 -0
- package/src/index.ts +171 -0
- package/src/messages/en.ts +22 -0
- package/src/messages/index.ts +24 -0
- package/src/messages/pl.ts +22 -0
- package/src/og/character-map.ts +158 -0
- package/src/og/element.ts +27 -0
- package/src/og/fonts.ts +136 -0
- package/src/og/glyphs.ts +175 -0
- package/src/og/index.ts +22 -0
- package/src/og/palette.ts +46 -0
- package/src/og/render.ts +178 -0
- package/src/og/result.ts +5 -0
- package/src/og/templates/context.ts +22 -0
- package/src/og/templates/frame.ts +91 -0
- package/src/og/templates/headline-chart.ts +42 -0
- package/src/og/templates/headline-cta.ts +35 -0
- package/src/og/templates/index.ts +31 -0
- package/src/og/templates/schemas.ts +65 -0
- package/src/platforms.ts +38 -0
- package/src/posts/posts.ts +62 -0
- package/src/record/actions.ts +113 -0
- package/src/record/record.ts +380 -0
- package/src/render/hyperframes.ts +30 -0
- package/src/render/preflight.ts +28 -0
- package/src/render/render.ts +146 -0
- package/src/screenshot/gates.ts +25 -0
- package/src/screenshot/screenshot.ts +200 -0
- package/src/voice/cache.ts +83 -0
- package/src/voice/elevenlabs.ts +66 -0
- package/src/voice/fake.ts +43 -0
- package/src/voice/produce.ts +59 -0
- package/src/voice/provider.ts +40 -0
- package/src/voice/providers.ts +19 -0
- package/src/voice/voiceover.ts +178 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SOFTURE
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,435 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @softure-ai/marketing-kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A CLI and a library that turn a project's **real app** into a vertical film (1080×1920, one file for
|
|
4
|
+
Instagram Reels, TikTok and Facebook Reels) plus ready post copy for each platform.
|
|
5
|
+
|
|
6
|
+
The film is not an animation that imitates the app. Playwright walks through the real page on a phone
|
|
7
|
+
screen (or a desktop browser, framed as a browser window in 16:9) frame by frame while a scene types and taps; the camera follows the thumb, captions follow the
|
|
8
|
+
voiceover word by word, and hyperframes renders the HTML composition to MP4.
|
|
9
|
+
|
|
10
|
+
Ported from FIRE_TRACKER's `video/` pipeline (roadmap item MK-1). Everything product-specific comes
|
|
11
|
+
from one `marketing.json` (MK-2), the scene included as declarative actions (MK-3); the 1:1 and 16:9 formats (MK-6),
|
|
12
|
+
TTS providers (MK-7), screenshots (MK-4) and OG images (MK-5) build on it. Background:
|
|
13
|
+
[docs/03-marketing-kit.md](../../docs/03-marketing-kit.md).
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
softure-marketing all <video> # voiceover from the cache -> recording -> render -> post copy
|
|
19
|
+
softure-marketing voice <video> [--commit] # voiceover; without --commit it only prints the cost estimate
|
|
20
|
+
softure-marketing record <video> [--today=YYYY-MM-DD] [--url=...]
|
|
21
|
+
softure-marketing render <video> [--quality=draft|standard|high]
|
|
22
|
+
softure-marketing preview <video> # the composition in the hyperframes preview
|
|
23
|
+
softure-marketing posts <video> # post copy only
|
|
24
|
+
softure-marketing og [image] # OG images (PNG) of every ogImages entry, or of one
|
|
25
|
+
softure-marketing shots [<id>] [--url=...] # the screenshots entries (or one), each behind its gates
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Every command takes `--config=<path>` (default `./marketing.json`). Exit codes: `0` done, `1` failed, `2`
|
|
29
|
+
the screen guard refused the recording (the screen did not show what the voiceover says).
|
|
30
|
+
|
|
31
|
+
- **`--commit`** is the only way to spend money: `voice` prints the cost estimate, then calls the
|
|
32
|
+
configured TTS provider (ElevenLabs, `ELEVENLABS_API_KEY` from the environment) and writes
|
|
33
|
+
`<video>/<key>.mp3` + `<video>/<key>.json` into `voice.cacheDir`. Commit them: the next render of the same text costs
|
|
34
|
+
nothing. `all` never pays; on a cache miss it prints the estimate and stops. See
|
|
35
|
+
[Voiceover providers and cost](#voiceover-providers-and-cost).
|
|
36
|
+
- **`--today`** records the app as of another day for one run; it overrides the video's `today`. Once a
|
|
37
|
+
voiceover is paid for, pin its day in the video's `today` instead, so a plain `all` reproduces the film in any
|
|
38
|
+
later month (the voiceover says numbers that depend on the day).
|
|
39
|
+
- **Server:** when the configured app does not answer, `record` starts `app.startCommand` in the config
|
|
40
|
+
folder on `app.port` and stops it afterwards (log in `<output.buildDir>/server.log`).
|
|
41
|
+
- Every command checks that the video's scene module exists (when it has one); `render`, `preview` and `all` also check
|
|
42
|
+
the brand files (logo, fonts, sound effects). A missing one is reported by its JSON path.
|
|
43
|
+
|
|
44
|
+
Output: `<output.dir>/<video>/<video>.mp4` and `posts.md`, OG images in `<output.dir>/og/<image>.png`;
|
|
45
|
+
recordings and compositions in `<output.buildDir>/<video>/`. None of it belongs in git.
|
|
46
|
+
|
|
47
|
+
### Screenshots
|
|
48
|
+
|
|
49
|
+
`shots` captures every `screenshots[]` entry, or the one named, into `<output.dir>/screenshots/<id>.png`.
|
|
50
|
+
Each entry gets a fresh browser at `width`×`height` CSS px with `app.colorScheme`, `brand.locale`,
|
|
51
|
+
`brand.timezone`, `app.hideSelectors` hidden and its own `motion` preference (`reduce` by default).
|
|
52
|
+
`scale` sets the device pixels per CSS pixel (1-4, default 1): at `2` an 800×600 entry is a 1600×1200 PNG,
|
|
53
|
+
sharp on a retina screen or a store listing. `colorSchemes` (e.g. `["light", "dark"]`) captures the entry
|
|
54
|
+
once per scheme, in its own browser, into `<id>-light.png` and `<id>-dark.png`; without it, one `<id>.png`
|
|
55
|
+
in `app.colorScheme`. `shots <id>` takes the entry's id and writes all of its files. Since an entry may
|
|
56
|
+
write any of those three names, an id that is another entry's `<id>-light` or `<id>-dark` is refused.
|
|
57
|
+
`full: true` first scrolls the page one screen at a time to the bottom, so lazy images and sections
|
|
58
|
+
load, then captures the whole page. A screenshot is kept only when it passes every gate:
|
|
59
|
+
|
|
60
|
+
| Gate | Refused when |
|
|
61
|
+
| --- | --- |
|
|
62
|
+
| `status` | the page answers with HTTP 400 or above, or not at all (`load`: it did not load within 30 s) |
|
|
63
|
+
| `phrase` | the page does not show `expect` within 5 s of loading (hidden elements do not count) |
|
|
64
|
+
| `size` | the file is smaller than `minBytes` (40 kB by default: a blank or broken page); the file is deleted |
|
|
65
|
+
|
|
66
|
+
Each file of an entry passes the gates on its own, so a page that shows its phrase only in the dark scheme
|
|
67
|
+
keeps `<id>-dark.png` and refuses `<id>-light.png`. `minBytes` applies to every file as written, whatever the
|
|
68
|
+
`scale`: a larger scale only makes the file bigger, so the default floor stays safe.
|
|
69
|
+
|
|
70
|
+
A failed file is not left behind, nor is an older file of its entry (any of `<id>.png`, `<id>-light.png`,
|
|
71
|
+
`<id>-dark.png`), and the others still run; any failure ends with
|
|
72
|
+
exit code `1`. `--url` points at another address of the app; without it, `shots` uses `app.baseUrl` or
|
|
73
|
+
starts `app.startCommand` as `record` does. A plain page can be smaller than 40 kB: set `minBytes` for it
|
|
74
|
+
(the fixture's calculator, a dark page with one form, is about 16 kB and sets 5000).
|
|
75
|
+
|
|
76
|
+
## `marketing.json`
|
|
77
|
+
|
|
78
|
+
The contract is one zod schema (`src/config/schema.ts`), published as
|
|
79
|
+
[`schema/marketing.schema.json`](schema/marketing.schema.json) (also `@softure-ai/marketing-kit/marketing.schema.json`).
|
|
80
|
+
Point `$schema` at it for editor completion: every key carries a description (what it does, and its default when
|
|
81
|
+
the schema cannot state one), so an editor or an agent sees the reference below while typing. A broken file is refused with every problem at once, each
|
|
82
|
+
on its JSON path (`videos[0].beats[2].id: "scene" appears twice`). Every path resolves against the
|
|
83
|
+
folder of `marketing.json`. A complete example: [examples/fixture/marketing.json](examples/fixture/marketing.json).
|
|
84
|
+
|
|
85
|
+
```jsonc
|
|
86
|
+
{
|
|
87
|
+
"$schema": "https://unpkg.com/@softure-ai/marketing-kit/schema/marketing.schema.json",
|
|
88
|
+
"brand": {
|
|
89
|
+
"name": "Acme Plan",
|
|
90
|
+
"locale": "en-US",
|
|
91
|
+
"timezone": "Europe/London",
|
|
92
|
+
"logo": { "svg": "brand/mark.svg" },
|
|
93
|
+
"tokensFrom": { "css": "../src/app/globals.css", "theme": "dark", "roles": { "cta": "success" } },
|
|
94
|
+
"colors": { "captionBackground": "#ecf1f7", "captionHighlight": "#047857" },
|
|
95
|
+
"fonts": {
|
|
96
|
+
"body": { "family": "Inter", "files": [{ "path": "fonts/inter.woff2", "weight": "100 900" }] },
|
|
97
|
+
"heading": { "family": "Lora", "fallback": "serif", "files": [{ "path": "fonts/lora-600.ttf", "weight": 600 }] }
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"app": {
|
|
101
|
+
"baseUrl": "http://localhost:3000",
|
|
102
|
+
"port": 3100,
|
|
103
|
+
"startCommand": ["npx", "next", "dev", "-p", "{port}"],
|
|
104
|
+
"colorScheme": "dark",
|
|
105
|
+
"hideSelectors": ["nextjs-portal"],
|
|
106
|
+
"screenGuardSelector": "main",
|
|
107
|
+
"device": { "viewport": [390, 844], "scale": 3 }
|
|
108
|
+
},
|
|
109
|
+
"voice": { "voiceId": "<ElevenLabs voice id>", "language": "en", "tempo": 1.1 },
|
|
110
|
+
"videos": [{
|
|
111
|
+
"id": "calculator-tour", "title": "Anna counts her date", "path": "/calculator",
|
|
112
|
+
"persona": { "name": "Anna", "age": 36, "tagline": "works out when she can stop working" },
|
|
113
|
+
"beats": [
|
|
114
|
+
{ "id": "hook", "text": "Forty-nine years. That is when Anna stops working." },
|
|
115
|
+
{ "id": "age", "text": "She types her age and taps next.", "actions": [
|
|
116
|
+
{ "do": "fill", "input": "age", "value": "36" },
|
|
117
|
+
{ "do": "until", "word": "taps" },
|
|
118
|
+
{ "do": "tap", "target": { "role": "button", "name": { "regex": "^Next$" } }, "after": 0.3 },
|
|
119
|
+
{ "do": "mark", "name": "exit-age", "target": { "testId": "exit-age" } },
|
|
120
|
+
{ "do": "focus", "target": [{ "text": "Exit age", "exact": true }, { "testId": "exit-age" }], "scale": 1.4 },
|
|
121
|
+
{ "do": "checkScreen" },
|
|
122
|
+
{ "do": "still", "name": "result" } ] },
|
|
123
|
+
{ "id": "cta", "text": "Count your own date.", "pad": 1.2, "actions": [{ "do": "wide" }] }
|
|
124
|
+
],
|
|
125
|
+
"hook": { "still": "result", "shots": [{ "mark": "exit-age", "scale": 1.6 }] },
|
|
126
|
+
"screenGuard": ["49 years"],
|
|
127
|
+
"endCard": { "headline": "Count your date", "url": "example.com/calculator", "note": "Free, no account" }
|
|
128
|
+
}],
|
|
129
|
+
"social": {
|
|
130
|
+
"linkTemplate": "https://example.com/calculator?ref={code}",
|
|
131
|
+
"platforms": { "instagram": { "code": "ig-01" }, "facebook": { "code": "fb-01" } },
|
|
132
|
+
"posts": [{ "video": "calculator-tour", "caption": "Anna is 36 and can stop at 49.", "hashtags": ["money"] }]
|
|
133
|
+
},
|
|
134
|
+
"sfx": { "tap": "sfx/click.mp3", "key": "sfx/key.mp3", "whoosh": "sfx/whoosh.mp3", "sparkle": "sfx/sparkle.mp3", "pop": "sfx/pop.mp3" },
|
|
135
|
+
"output": { "dir": "marketing/out", "buildDir": "marketing/build", "quality": "standard" }
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
| Section | Keys (default) | Meaning |
|
|
140
|
+
| --- | --- | --- |
|
|
141
|
+
| `brand` | `name`, `locale`, `timezone` | the end card's name; BCP 47 locale of the recording browser, `<html lang>` and the copy (its language needs a dictionary in `src/messages/`: `en`, `pl`); IANA zone of the recording browser |
|
|
142
|
+
| | `logo.svg` | the end card's logo next to the name (none: the name alone) |
|
|
143
|
+
| | `colors`, `tokensFrom` | the nine colour roles, see below |
|
|
144
|
+
| | `fonts.body`, `fonts.heading` | `family`, `fallback` (`sans-serif`), `files`: `path`, `weight` (`400` or `"100 900"`), `style` (`normal`), `unicodeRange`; none: the system's sans-serif. The heading font is the end card's and the avatar's; without one, the body font |
|
|
145
|
+
| `app` | `baseUrl`, `port`, `startCommand` | the running app, or the one the CLI starts (`startCommand` as arguments, no shell, `{port}` replaced) |
|
|
146
|
+
| | `colorScheme` (`light`), `hideSelectors` (`[]`), `screenGuardSelector` (`body`) | what the recording browser prefers; elements hidden while recording; the element whose text the screen guard reads |
|
|
147
|
+
| | `device` | the recording device: `kind` (`phone`, or `desktop` for a browser window in a 16:9 film), `viewport` `[width, height]` in CSS px (a desktop's at least 1024 wide, not taller than wide), `scale` (device pixels per CSS pixel), `mobile` (a phone's, default `true`; not allowed on a desktop); a video can override it |
|
|
148
|
+
| `voice` | `provider` (`elevenlabs`), `voiceId`, `model` (`eleven_multilingual_v2`), `language`, `tempo` (`1`, 0.8-1.3), `cacheDir` (`marketing/voiceover`) | the voiceover; text, voice, model and language make the cache key, the tempo is applied at build time |
|
|
149
|
+
| `videos[]` | `id`, `title`, `path`, `format` (`9:16`, or `1:1`, `16:9`), `device`, `voice` (`voiceId`, `model`, `tempo`) | a film and its overrides |
|
|
150
|
+
| | `persona`, `beats`, `hook`, `screenGuard`, `endCard` | the script, see [A film](#a-film) |
|
|
151
|
+
| | `hook.transition` (`fade`, or `rewind`, `cut`) | how the opening frame hands over to the scene |
|
|
152
|
+
| | `today` (`YYYY-MM-DD`) | the day the app is recorded as of; none: the day of the run; `--today` wins |
|
|
153
|
+
| | `beats[].actions`, `beats[].pad` | the scene as data, see [Scene actions](#scene-actions) |
|
|
154
|
+
| | `sceneModule` | instead of actions: the TS module exporting `scene` |
|
|
155
|
+
| `social` | `linkTemplate` | the link every post carries, `{code}` replaced by the platform's channel code |
|
|
156
|
+
| | `platforms` | `instagram`, `facebook`, `tiktok`, `youtube`, `linkedin`, `x`: `code`, `linkInBio` (true for Instagram, TikTok, YouTube) |
|
|
157
|
+
| | `posts[]` | `video`, `caption`, `hashtags`, `codes` (this video's own codes); a video without one gets no `posts.md` |
|
|
158
|
+
| `screenshots[]` | `id`, `path`, `width`, `height`, `full` (`false`), `expect`, `motion` (`reduce`), `minBytes` (`40000`), `scale` (`1`), `colorSchemes` | for `softure-marketing shots`, see [Screenshots](#screenshots) |
|
|
159
|
+
| `ogImages[]` | `id`, `template` (`headline-cta`, `headline-chart`), `size` (`[1200, 630]`), `data` | for `softure-marketing og`, see [OG images](#og-images) |
|
|
160
|
+
| `layout` | per layout (`9:16`, `1:1`, `16:9` for phone films; `desktop` for desktop films): `caption` (`top`, `left`, `right`, `fontSize`), `persona` (`top`, `left`, `right`), `endCard` (`top`, `left`, `right`, `headlineSize`, `phone.scale`, `phone.center`) | overrides of the layout's geometry table for every film of that layout, in frame px (`endCard.phone` is the browser window's pose in `desktop`); a missing key keeps the table's value. Values must fit the frame and each box's margins must leave at least 200 px for its text. The frame, the screen box and the camera target are fixed |
|
|
161
|
+
| `sfx` | `tap`, `key`, `whoosh`, `sparkle`, `pop` | sound effects; a missing one is silent |
|
|
162
|
+
| `output` | `dir` (`marketing/out`), `buildDir` (`marketing/build`), `quality` (`standard`) | where films go; `--quality` wins |
|
|
163
|
+
|
|
164
|
+
Channel codes follow the rule of `@softure-ai/analytics`, which counts them on the receiving app:
|
|
165
|
+
lowercase words joined by single dashes or underscores, at most 32 characters.
|
|
166
|
+
|
|
167
|
+
### Brand colours
|
|
168
|
+
|
|
169
|
+
| Role | Paints |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| `background` | the frame behind the phone and the vignette (`#rrggbb`) |
|
|
172
|
+
| `foreground`, `muted` | the end card and the persona card's text |
|
|
173
|
+
| `accent` | the touch ring, the end of the avatar's gradient |
|
|
174
|
+
| `cta`, `onCta` | the end card's link pill and its text; the avatar |
|
|
175
|
+
| `captionBackground`, `captionText`, `captionHighlight` | the caption pill, its words, the word being spoken |
|
|
176
|
+
|
|
177
|
+
A colour in `brand.colors` wins. Otherwise the role reads a token from `brand.tokensFrom`: the app's
|
|
178
|
+
stylesheet (`css`, custom properties in `[data-theme="<theme>"]`, then `:root`, `var()` resolved; a
|
|
179
|
+
stylesheet with other themes but not this one is an error) or an
|
|
180
|
+
Impeccable `design.json` (`designJson`, schemaVersion 2, `themes.<theme>.roles`). A role reads the token
|
|
181
|
+
of its own kebab name (`onCta` reads `on-cta`) unless `tokensFrom.roles` names another one, e.g.
|
|
182
|
+
`"roles": { "cta": "accessible", "onCta": "background" }`. A role with no colour is an error, never a
|
|
183
|
+
default. Values must be hex literals (`#rgb`, `#rrggbb`, `#rrggbbaa`).
|
|
184
|
+
|
|
185
|
+
### From FIRE_TRACKER's constants
|
|
186
|
+
|
|
187
|
+
| What MK-1 still had in code | Where it is now |
|
|
188
|
+
| --- | --- |
|
|
189
|
+
| `marketing.config.json` (`locale`, `brand.name`, `app`, `siteCss`, `posts.site`, `paths`) | `marketing.json`: `brand`, `app`, `brand.tokensFrom.css`, `social.linkTemplate`, `voice.cacheDir`, `output`, `sfx`, `brand.fonts` |
|
|
190
|
+
| film modules with data and scene | the data in `videos[]`, the scene in `beats[].actions` (or `sceneModule`, `export const scene: Scene`) |
|
|
191
|
+
| phone 390×844 @3, mobile | `app.device` |
|
|
192
|
+
| `pl-PL`, `Europe/Warsaw`, dark scheme | `brand.locale`, `brand.timezone`, `app.colorScheme` |
|
|
193
|
+
| hidden `nextjs-portal` and the mailing-list pill | `app.hideSelectors` |
|
|
194
|
+
| the screen guard reading `main` | `app.screenGuardSelector` |
|
|
195
|
+
| Geist and Newsreader files | `brand.fonts` |
|
|
196
|
+
| the arc logo, `#059669` and the light caption pill | `brand.logo`, `brand.colors.captionHighlight`, `captionBackground` |
|
|
197
|
+
| `--accessible` for the link pill and the avatar | `brand.colors.cta` (or `tokensFrom.roles.cta: "accessible"`) |
|
|
198
|
+
| FIRE's narrator voice, Polish | `voice.voiceId`, `voice.language: "pl"` (the cache keys of FIRE's paid recordings stay the same) |
|
|
199
|
+
| `?z=` and three fixed platforms | `social.linkTemplate`, `social.platforms` |
|
|
200
|
+
| five fixed sound file names | `sfx` |
|
|
201
|
+
|
|
202
|
+
## A film
|
|
203
|
+
|
|
204
|
+
A film is a `videos[]` entry: the script and the scene, either as beat `actions` or as a scene module.
|
|
205
|
+
The fixture has the same film both ways in [examples/fixture/marketing.json](examples/fixture/marketing.json):
|
|
206
|
+
`fixture-tour-actions` with actions, `fixture-tour` with the module
|
|
207
|
+
[examples/fixture/films/fixture-tour.ts](examples/fixture/films/fixture-tour.ts). Both record the same log.
|
|
208
|
+
|
|
209
|
+
- **`beats`**: the voiceover sentences. The first plays over the opening (a frame of the result), the last
|
|
210
|
+
ends on the end card. Changing the text means a new, paid recording.
|
|
211
|
+
- **`hook.transition`**: how the opening frame hands over to the scene's first frame. `fade` (the default) is a
|
|
212
|
+
0.8 s cross-fade; `rewind` runs 0.8 s back through the scene in five key frames joined by dissolves; `cut`
|
|
213
|
+
starts the scene at once. Before 0.1.6 every film had a rewind of 24 blended frames that flickered; a film
|
|
214
|
+
rendered again now opens with a fade unless it asks for `rewind`.
|
|
215
|
+
- **`today`**: the day the app is recorded as of. Pin it to the day the voiceover's numbers were true, so the
|
|
216
|
+
screen guard keeps passing after the calendar moves.
|
|
217
|
+
- **`actions`** on every beat after the first: what happens on screen during that sentence (below).
|
|
218
|
+
- **`sceneModule`**, the escape hatch for a scene that needs logic: a TS module exporting `scene`
|
|
219
|
+
(typed `Scene`) that drives the Director itself: `d.beat(id, …, { pad })`, then the same methods as
|
|
220
|
+
the actions (`d.fill(name, value)`, `d.tap(locator)`, `d.until(word)`, …). A video uses one or the other.
|
|
221
|
+
- **`screenGuard`**: every number the voiceover says, as the screen writes it. If the screen does not
|
|
222
|
+
show one, the recording stops with code 2 and no film is made.
|
|
223
|
+
|
|
224
|
+
### Scene actions
|
|
225
|
+
|
|
226
|
+
Each action is `{ "do": "<name>", …arguments }` and calls the Director method of the same name.
|
|
227
|
+
Optional arguments left out keep the Director's defaults.
|
|
228
|
+
|
|
229
|
+
| `do` | Arguments (default) | What it does |
|
|
230
|
+
| --- | --- | --- |
|
|
231
|
+
| `wide` | `scale` (`1`), `whoosh` (`false`) | camera on the whole screen |
|
|
232
|
+
| `tap` | `target`, `after` (`0.35` s) | scrolls the element into view if needed and taps its centre (clicks it on a desktop) |
|
|
233
|
+
| `type` | `text`, `perChar` (`0.13` s) | types into the focused element, one key at a time |
|
|
234
|
+
| `fill` | `input`, `value` | taps `input[name=<input>]`, moves the camera onto it (1.55×; on a desktop at most what still fits the frame) and types the value |
|
|
235
|
+
| `blur` | | takes the focus off the active element |
|
|
236
|
+
| `focus` | `target` (one or many), `scale` (fits the element), `height` | camera on the element, or on the rectangle around several |
|
|
237
|
+
| `bring` | `target`, `top` (`140` px), `seconds` (`0.5`) | scrolls so the element's top edge stands `top` px from the top |
|
|
238
|
+
| `mark` | `name`, `target` (one or many) | remembers the rectangle, e.g. for an opening shot |
|
|
239
|
+
| `still` | `name` | remembers the current frame as the opening frame |
|
|
240
|
+
| `cue` | `name` (`sparkle`, `persona-out`) | an event on the film's timeline |
|
|
241
|
+
| `hold` | `seconds` (0-30) | lets the screen run |
|
|
242
|
+
| `until` | `word` | waits until the voiceover says this word of the sentence |
|
|
243
|
+
| `checkScreen` | | the screen guard, now |
|
|
244
|
+
|
|
245
|
+
A beat's `pad` (`0.35` s) is how long the screen holds after the voiceover ends the sentence.
|
|
246
|
+
|
|
247
|
+
A **target** is a locator descriptor with exactly one of these keys, plus `nth` (0 = the first match):
|
|
248
|
+
|
|
249
|
+
| Descriptor | Playwright | Options |
|
|
250
|
+
| --- | --- | --- |
|
|
251
|
+
| `{ "role": "button", "name": "Next" }` | `getByRole` | `name` (the accessible name), `exact` |
|
|
252
|
+
| `{ "text": "Your wealth today" }` | `getByText` | `exact` |
|
|
253
|
+
| `{ "label": "Age" }` | `getByLabel` | `exact` |
|
|
254
|
+
| `{ "testId": "exit-age" }` | `getByTestId` | |
|
|
255
|
+
| `{ "css": "label", "hasText": "I want to know" }` | `locator` | `hasText` |
|
|
256
|
+
|
|
257
|
+
`name`, `text`, `label` and `hasText` take a string (a case-insensitive substring; with `exact: true` the
|
|
258
|
+
whole text, case-sensitive) or a regex: `{ "regex": "^Next$", "flags": "i" }` (flags from `imsu`).
|
|
259
|
+
Without `nth`, a target that matches several elements fails while recording: add `nth`, or narrow it.
|
|
260
|
+
|
|
261
|
+
Actions cover what FIRE's film uses, not all of Playwright: no chained or filtered locators, no
|
|
262
|
+
`getByPlaceholder`, `getByAltText` or `getByTitle`, no role options beyond `name` and `exact`, no regex
|
|
263
|
+
`testId`; numbers stay in ranges that catch unit slips (`scale` 0.5-4, `after`, `perChar` and `seconds`
|
|
264
|
+
up to 10 s, `hold` up to 30 s, whole pixels for `top` and `height`). A scene that needs more is a
|
|
265
|
+
`sceneModule`.
|
|
266
|
+
|
|
267
|
+
What can be checked without a browser is checked when the config loads, by JSON path: an `until` word
|
|
268
|
+
the sentence does not say, a `hook.still` or `hook.shots[].mark` no action saves, an opening shot after
|
|
269
|
+
the first without the `word` it starts on, a scene without `checkScreen`, actions on the opening sentence,
|
|
270
|
+
actions next to a `sceneModule`. The checks that span
|
|
271
|
+
sentences run once the rest of the config is valid, so fixing one round of errors can reveal the next.
|
|
272
|
+
An action that fails while recording names its path and the config file:
|
|
273
|
+
|
|
274
|
+
```text
|
|
275
|
+
✗ videos[0].beats[1].actions[2] (tap): sentence "age": getByRole('button', { name: /^Next$/ }) did not appear within 5 s. …
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Voiceover providers and cost
|
|
279
|
+
|
|
280
|
+
The voiceover goes through a `TtsProvider` (`src/voice/provider.ts`): `estimate(input)` is free and
|
|
281
|
+
needs no key, `synthesize(input)` returns the audio and the time of every word, or an error value.
|
|
282
|
+
`input` is `{ text, voiceId, model, language }`, and those four make the cache key
|
|
283
|
+
(`sha256({text, voice, model, lang})`, 16 hex characters). The tempo is applied at build time, so
|
|
284
|
+
changing it costs nothing.
|
|
285
|
+
|
|
286
|
+
| Run | What happens |
|
|
287
|
+
| --- | --- |
|
|
288
|
+
| the cache has `<video>/<key>.mp3` and `.json` (or, from 0.1.5 and earlier, `<key>.mp3` and `.json` in the cache root) | `voiceover: from the cache <key>, nothing spent.` |
|
|
289
|
+
| no cache, no `--commit` | `voiceover estimate (elevenlabs): 412 characters, at most 412 ElevenLabs credits.` and a dry-run line; no request is sent |
|
|
290
|
+
| no cache, `--commit` | the same estimate line **first**, then one paid request, then the files are written |
|
|
291
|
+
|
|
292
|
+
ElevenLabs bills per input character, at most one credit each; API plans may discount it, so the
|
|
293
|
+
estimate is an upper bound. Providers available to `voice.provider`: `elevenlabs`. For tests, the
|
|
294
|
+
package exports `createFakeTtsProvider()` (deterministic audio, evenly spaced words, records its calls)
|
|
295
|
+
and `produceVoiceover({ cacheDir, input, provider, isCommit, log })`, so a project can test its films
|
|
296
|
+
without the network. A second real provider must add its id to the cache key, so that its recordings
|
|
297
|
+
never collide with ElevenLabs's under the same voice and model names.
|
|
298
|
+
|
|
299
|
+
### The cache layout
|
|
300
|
+
|
|
301
|
+
Each video keeps its recordings in its own folder, `<voice.cacheDir>/<video id>/<key>.mp3` and `<key>.json`, so a
|
|
302
|
+
reader sees which film a paid file belongs to. The key is unchanged from earlier versions, so nothing is recorded
|
|
303
|
+
again:
|
|
304
|
+
|
|
305
|
+
- A recording saved by 0.1.5 or earlier sits flat in `voice.cacheDir`. It is still found (the video's folder first,
|
|
306
|
+
then the flat file), and `voice`/`all` print where to move it; `git mv` both files into the video's folder.
|
|
307
|
+
- When the script changes, the new recording lands next to the old one, and `voice`/`all` list the files in the
|
|
308
|
+
folder the current script no longer uses. Delete them in git once no film needs them.
|
|
309
|
+
|
|
310
|
+
### Migrating FIRE_TRACKER's voiceover cache
|
|
311
|
+
|
|
312
|
+
FIRE's key hashed the same object with the language fixed to `pl`, and its words files have the same
|
|
313
|
+
format, so its paid recordings are reused as they are, with no re-keying and no new paid call:
|
|
314
|
+
|
|
315
|
+
1. Copy FIRE's voiceover folder (`<key>.mp3` + `<key>.json` pairs) into `voice.cacheDir`, ideally into the folder of
|
|
316
|
+
the video each pair belongs to (`<voice.cacheDir>/<video id>/`); flat files are found too.
|
|
317
|
+
2. Set `voice.language` to `"pl"`, and `voice.voiceId` and `voice.model` (or a video's `voice`
|
|
318
|
+
override) to the values FIRE used.
|
|
319
|
+
3. Run `softure-marketing voice <video>` **without** `--commit` for every video. Each must print
|
|
320
|
+
`from the cache`; an estimate line means the text, voice or model differs from FIRE's, and nothing
|
|
321
|
+
was spent.
|
|
322
|
+
|
|
323
|
+
## OG images
|
|
324
|
+
|
|
325
|
+
`softure-marketing og` renders each `ogImages` entry with [Satori](https://github.com/vercel/satori)
|
|
326
|
+
and resvg to `<output.dir>/og/<id>.png`, at `size` (1200×630 by default), outside Next. The brand
|
|
327
|
+
supplies everything around the copy: the background and text colours, the logo and name in the top
|
|
328
|
+
corner, and the fonts. `data` is the template's input, checked by its own schema (each template's
|
|
329
|
+
fields are in the JSON Schema):
|
|
330
|
+
|
|
331
|
+
| Template | `data` |
|
|
332
|
+
| --- | --- |
|
|
333
|
+
| `headline-cta` | `headline` (≤ 90 characters), `eyebrow` (≤ 40), `cta` (≤ 32, a pill in `cta`/`onCta` colours), `tiles` (≤ 4 of `label` ≤ 24, `value` ≤ 16) |
|
|
334
|
+
| `headline-chart` | `headline`, `eyebrow`, `tiles` (≤ 3), `chart`: `viewBox` `[width, height]` and `paths` (1-8) of `d` (SVG path data), `tone` (`accent`, `cta`, `foreground`, `muted`), `fill` (a tint instead of a line), `strokeWidth` (view box units) |
|
|
335
|
+
|
|
336
|
+
Values the app computes, such as a chart or a projected date, are computed by the app and arrive in
|
|
337
|
+
`data` as numbers, text or SVG paths; the package only draws them.
|
|
338
|
+
|
|
339
|
+
**Fonts.** Satori reads static `.ttf`, `.otf` and `.woff` files only, so OG images refuse a `.woff2`
|
|
340
|
+
file or a variable range (`"100 900"`) in `brand.fonts`, by its JSON path; add a static file for OG
|
|
341
|
+
next to it. Templates ask for a weight (the headline for 700, the copy for 400 and 600) and get the
|
|
342
|
+
nearest one the brand loads, so a card never names a weight that is not loaded (Satori would draw
|
|
343
|
+
another one silently). Satori draws nothing, or an empty box, for a character no font maps, so a
|
|
344
|
+
card is checked before layout: a character of the copy (or of `brand.name`) that none of the fonts
|
|
345
|
+
Satori would try has is refused with the image id, the JSON path and the characters, e.g.
|
|
346
|
+
`ogImages[0].data.headline: "…" (U+0105)` for a Polish letter with a `latin` subset file.
|
|
347
|
+
Subset files work: list `latin` and `latin-ext` (or more) for each weight, as Fontsource ships them,
|
|
348
|
+
and a text tries them in the order listed, then the other family. Satori uses one file per family,
|
|
349
|
+
weight and style, so the second file of a weight and style is registered as the family `<family> #2`
|
|
350
|
+
(the third as `#3`) and templates write `font-family: <family>, <family> #2`; `unicodeRange` does
|
|
351
|
+
not steer this, the first listed file that has the character draws it. A subset file must exist at
|
|
352
|
+
every weight the copy uses: a letter only a `latin-ext` file of another weight has would come out
|
|
353
|
+
lighter or heavier than its line, so it is refused like a missing one. Whitespace, format characters
|
|
354
|
+
and variation selectors are not checked; emoji are, and need a font that has them.
|
|
355
|
+
|
|
356
|
+
**A thin Next route.** The `@softure-ai/marketing-kit/og` entry does not load Playwright, so a route
|
|
357
|
+
can render the same card per request, with live values in place of the configured `data`:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
// app/calculator/opengraph-image.ts
|
|
361
|
+
import { join } from "node:path";
|
|
362
|
+
import { loadMarketingConfig, renderConfiguredOgImage } from "@softure-ai/marketing-kit/og";
|
|
363
|
+
|
|
364
|
+
export const runtime = "nodejs"; // resvg is a native module
|
|
365
|
+
export const size = { width: 1200, height: 630 };
|
|
366
|
+
export const contentType = "image/png";
|
|
367
|
+
|
|
368
|
+
export default async function Image(): Promise<Response> {
|
|
369
|
+
const loaded = loadMarketingConfig(join(process.cwd(), "marketing.json"));
|
|
370
|
+
if (!loaded.ok) throw new Error(loaded.error);
|
|
371
|
+
const png = await renderConfiguredOgImage({
|
|
372
|
+
config: loaded.config,
|
|
373
|
+
id: "calculator",
|
|
374
|
+
data: { headline: "Stop working at 49", cta: "Count your date" }, // optional: per-request values
|
|
375
|
+
});
|
|
376
|
+
if (!png.ok) throw new Error(png.error);
|
|
377
|
+
return new Response(new Uint8Array(png.value), { headers: { "content-type": contentType } });
|
|
378
|
+
}
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
`@resvg/resvg-js` is a native module: if the bundler tries to bundle it, list it in
|
|
382
|
+
`serverExternalPackages` in `next.config.ts`. `renderOgImage({ template, data, size, brand, fonts })`
|
|
383
|
+
renders without a `marketing.json` at all.
|
|
384
|
+
|
|
385
|
+
## Requirements
|
|
386
|
+
|
|
387
|
+
- Node 22, **ffmpeg** in PATH.
|
|
388
|
+
- A Chromium for the recording and the screenshots: Playwright's own, or `PLAYWRIGHT_CHROMIUM_PATH=<path>`.
|
|
389
|
+
- A Chrome for hyperframes: downloaded on the first render (into `~/.cache/puppeteer`), or
|
|
390
|
+
`HYPERFRAMES_BROWSER_PATH=<path>` (a Chromium headless shell works).
|
|
391
|
+
- The CLI runs hyperframes with `HYPERFRAMES_NO_TELEMETRY=1` unless you set it yourself.
|
|
392
|
+
|
|
393
|
+
## Licences
|
|
394
|
+
|
|
395
|
+
| Asset | Licence | How the package handles it |
|
|
396
|
+
| --- | --- | --- |
|
|
397
|
+
| hyperframes `0.8.85` | Apache-2.0 | npm dependency, pinned, run from `node_modules` |
|
|
398
|
+
| GSAP | GreenSock's standard "no charge" licence | npm dependency `gsap`; `gsap.min.js` is copied into the project's build folder at render time, never shipped in this package |
|
|
399
|
+
| Fonts | the project's | not bundled; `brand.fonts` names the files, copied next to the composition at render time |
|
|
400
|
+
| Sound effects | the project's | not bundled; `sfx` names the files |
|
|
401
|
+
| ElevenLabs | paid API | key from `ELEVENLABS_API_KEY`, only with `--commit` |
|
|
402
|
+
| satori, @resvg/resvg-js | MPL-2.0 | npm dependencies, unmodified; resvg ships a prebuilt native binary per platform |
|
|
403
|
+
|
|
404
|
+
## Limitations
|
|
405
|
+
|
|
406
|
+
- Actions have no conditions or loops; a scene that needs them stays a `sceneModule`.
|
|
407
|
+
- Three formats: `9:16` (1080×1920), `1:1` (1080×1080) and `16:9` (1920×1080). A phone film is a framed phone laid
|
|
408
|
+
out by the geometry table in `src/compose/timeline.ts` (in 16:9 the phone stands left, the copy right);
|
|
409
|
+
one phone recording renders in every format; `layout` in `marketing.json` moves the copy and the end card, not the
|
|
410
|
+
phone. A desktop film (`device.kind: "desktop"`) is 16:9 only: the recorder opens a desktop browser (no touch,
|
|
411
|
+
mouse clicks) and the film frames it as a browser window whose address bar shows the end card's URL. The same
|
|
412
|
+
scene can record both when the app is responsive. Camera scales are relative to the screen, so an element as wide
|
|
413
|
+
as a desktop page needs a lower scale (about 1.2) than on a phone, or the zoom crops it.
|
|
414
|
+
- ElevenLabs is the only real voice provider; the estimate is an upper bound in credits, not money.
|
|
415
|
+
- Two OG templates.
|
|
416
|
+
|
|
417
|
+
## Development
|
|
418
|
+
|
|
419
|
+
```bash
|
|
420
|
+
npm test # unit tests (FIRE's, ported), the contract, the architecture test
|
|
421
|
+
npm run schema -w @softure-ai/marketing-kit # regenerate schema/marketing.schema.json after changing the schema
|
|
422
|
+
MARKETING_KIT_RENDER=1 \
|
|
423
|
+
PLAYWRIGHT_CHROMIUM_PATH=... HYPERFRAMES_BROWSER_PATH=... \
|
|
424
|
+
npx vitest run tools/marketing-kit/tests/render.test.ts # the fixture film end to end (~1.5 min)
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The render test copies [examples/fixture/](examples/fixture/) into a temporary folder, generates a tone
|
|
428
|
+
as its voiceover and tones as its sound effects with ffmpeg, runs `softure-marketing all` and checks the
|
|
429
|
+
MP4 with ffprobe. `MARKETING_KIT_KEEP=1` keeps the folder. CI runs it on every push in the `render` job of
|
|
430
|
+
[ci.yml](../../.github/workflows/ci.yml), with hyperframes on its own chrome-headless-shell.
|
|
431
|
+
|
|
432
|
+
The screenshot tests (`tests/screenshot.test.ts`, `tests/shots-cli.test.ts`) drive a browser against
|
|
433
|
+
static pages and the fixture app. They run whenever a Chromium is available (`PLAYWRIGHT_CHROMIUM_PATH`
|
|
434
|
+
or Playwright's own) and fail if `PLAYWRIGHT_CHROMIUM_PATH` names a missing file; CI points it at the
|
|
435
|
+
runner's Chrome.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An expected failure of a command: printed as one line without a stack, with its exit code. Any
|
|
3
|
+
* other error is a bug and is printed with its stack.
|
|
4
|
+
*/
|
|
5
|
+
export declare class CliFailure extends Error {
|
|
6
|
+
readonly exitCode: number;
|
|
7
|
+
constructor(message: string, exitCode?: number);
|
|
8
|
+
}
|
|
9
|
+
export declare function fail(message: string, exitCode?: number): never;
|
|
10
|
+
//# sourceMappingURL=failure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"failure.d.ts","sourceRoot":"","sources":["../../src/cli/failure.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,qBAAa,UAAW,SAAQ,KAAK;IAGjC,QAAQ,CAAC,QAAQ;gBADjB,OAAO,EAAE,MAAM,EACN,QAAQ,SAAI;CAKxB;AAED,wBAAgB,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,SAAI,GAAG,KAAK,CAEzD"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An expected failure of a command: printed as one line without a stack, with its exit code. Any
|
|
3
|
+
* other error is a bug and is printed with its stack.
|
|
4
|
+
*/
|
|
5
|
+
export class CliFailure extends Error {
|
|
6
|
+
exitCode;
|
|
7
|
+
constructor(message, exitCode = 1) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.exitCode = exitCode;
|
|
10
|
+
this.name = "CliFailure";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function fail(message, exitCode = 1) {
|
|
14
|
+
throw new CliFailure(message, exitCode);
|
|
15
|
+
}
|
|
16
|
+
//# sourceMappingURL=failure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"failure.js","sourceRoot":"","sources":["../../src/cli/failure.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,OAAO,UAAW,SAAQ,KAAK;IAGxB;IAFX,YACE,OAAe,EACN,WAAW,CAAC;QAErB,KAAK,CAAC,OAAO,CAAC,CAAC;QAFN,aAAQ,GAAR,QAAQ,CAAI;QAGrB,IAAI,CAAC,IAAI,GAAG,YAAY,CAAC;IAC3B,CAAC;CACF;AAED,MAAM,UAAU,IAAI,CAAC,OAAe,EAAE,QAAQ,GAAG,CAAC;IAChD,MAAM,IAAI,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;AAC1C,CAAC"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type MarketingConfig, type VideoConfig } from "../config/config.js";
|
|
2
|
+
import type { Film } from "../film.js";
|
|
3
|
+
/** A video from `marketing.json` with its scene ready to record. */
|
|
4
|
+
export type LoadedFilm = VideoConfig & Film & {
|
|
5
|
+
scenePath: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function listFilms(config: MarketingConfig): string[];
|
|
8
|
+
/**
|
|
9
|
+
* The video's entry and its scene: the beats' `actions`, or `sceneModule` (TypeScript through tsx).
|
|
10
|
+
* `scenePath` is the file to fix when the scene fails: the module, or the config for actions.
|
|
11
|
+
*/
|
|
12
|
+
export declare function loadFilm(config: MarketingConfig, id: string): Promise<LoadedFilm>;
|
|
13
|
+
//# sourceMappingURL=films.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"films.d.ts","sourceRoot":"","sources":["../../src/cli/films.ts"],"names":[],"mappings":"AAIA,OAAO,EAAmD,KAAK,eAAe,EAAE,KAAK,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAC9H,OAAO,KAAK,EAAE,IAAI,EAAS,MAAM,YAAY,CAAC;AAI9C,oEAAoE;AACpE,MAAM,MAAM,UAAU,GAAG,WAAW,GAAG,IAAI,GAAG;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpE,wBAAgB,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,MAAM,EAAE,CAE3D;AAMD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,MAAM,EAAE,eAAe,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAUvF"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { pathToFileURL } from "node:url";
|
|
2
|
+
import { tsImport } from "tsx/esm/api";
|
|
3
|
+
import { findMissingFiles, findVideo, formatConfigIssues } from "../config/config.js";
|
|
4
|
+
import { createActionScene } from "../record/actions.js";
|
|
5
|
+
import { fail } from "./failure.js";
|
|
6
|
+
export function listFilms(config) {
|
|
7
|
+
return config.videos.map((video) => video.id).sort();
|
|
8
|
+
}
|
|
9
|
+
function isScene(value) {
|
|
10
|
+
return typeof value === "function";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The video's entry and its scene: the beats' `actions`, or `sceneModule` (TypeScript through tsx).
|
|
14
|
+
* `scenePath` is the file to fix when the scene fails: the module, or the config for actions.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadFilm(config, id) {
|
|
17
|
+
const video = findVideo(config, id);
|
|
18
|
+
if (video === null)
|
|
19
|
+
fail(`no video "${id}" in ${config.file}. Available: ${listFilms(config).join(", ") || "none"}.`);
|
|
20
|
+
const missing = findMissingFiles(config, video, false);
|
|
21
|
+
if (missing.length > 0)
|
|
22
|
+
fail(formatConfigIssues(config, missing));
|
|
23
|
+
const source = video.sceneSource;
|
|
24
|
+
if (source.kind === "actions")
|
|
25
|
+
return { ...video, scene: createActionScene(source.beats, video.index), scenePath: config.file };
|
|
26
|
+
const imported = (await tsImport(pathToFileURL(source.path).href, import.meta.url));
|
|
27
|
+
if (!isScene(imported.scene))
|
|
28
|
+
fail(`videos[${video.index}].sceneModule: ${source.path} does not export a "scene" function.`);
|
|
29
|
+
return { ...video, scene: imported.scene, scenePath: source.path };
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=films.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"films.js","sourceRoot":"","sources":["../../src/cli/films.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAEvC,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,kBAAkB,EAA0C,MAAM,qBAAqB,CAAC;AAE9H,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EAAE,IAAI,EAAE,MAAM,cAAc,CAAC;AAKpC,MAAM,UAAU,SAAS,CAAC,MAAuB;IAC/C,OAAO,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;AACvD,CAAC;AAED,SAAS,OAAO,CAAC,KAAc;IAC7B,OAAO,OAAO,KAAK,KAAK,UAAU,CAAC;AACrC,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,MAAuB,EAAE,EAAU;IAChE,MAAM,KAAK,GAAG,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IACpC,IAAI,KAAK,KAAK,IAAI;QAAE,IAAI,CAAC,aAAa,EAAE,QAAQ,MAAM,CAAC,IAAI,gBAAgB,SAAS,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,MAAM,GAAG,CAAC,CAAC;IACtH,MAAM,OAAO,GAAG,gBAAgB,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;IACvD,IAAI,OAAO,CAAC,MAAM,GAAG,CAAC;QAAE,IAAI,CAAC,kBAAkB,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;IAClE,MAAM,MAAM,GAAG,KAAK,CAAC,WAAW,CAAC;IACjC,IAAI,MAAM,CAAC,IAAI,KAAK,SAAS;QAAE,OAAO,EAAE,GAAG,KAAK,EAAE,KAAK,EAAE,iBAAiB,CAAC,MAAM,CAAC,KAAK,EAAE,KAAK,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC;IAChI,MAAM,QAAQ,GAAG,CAAC,MAAM,QAAQ,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAwB,CAAC;IAC3G,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,IAAI,CAAC,UAAU,KAAK,CAAC,KAAK,kBAAkB,MAAM,CAAC,IAAI,sCAAsC,CAAC,CAAC;IAC7H,OAAO,EAAE,GAAG,KAAK,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,EAAE,SAAS,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC;AACrE,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../../src/cli/main.ts"],"names":[],"mappings":""}
|