storyshot 0.4.2 → 0.6.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/README.md CHANGED
@@ -152,7 +152,8 @@ Rendering is free and runs locally. Only the voiceover can cost money:
152
152
  | voice | cost | notes |
153
153
  |---|---|---|
154
154
  | none | free | captions only |
155
- | `kokoro` | free | local, English only; downloads a ~90 MB model on first use |
155
+ | `kokoro` | free | local, English and Mandarin Chinese (`zf_001`); downloads a ~90 MB model on first use (Chinese: ~130 MB, and needs Python 3.10–3.12) |
156
+ | `qwen` | free | local, Apple Silicon Macs only; the most natural free Chinese voice (`dylan`, `serena`); downloads a ~2.3 GB model and needs Python 3.10–3.12 |
156
157
  | ElevenLabs | your credits | the most natural voice; many languages including Chinese. Put `ELEVENLABS_API_KEY` in `.env`. |
157
158
  | OpenAI | your credits | `OPENAI_API_KEY` |
158
159
 
@@ -247,7 +248,7 @@ viewport: { width: 1600, height: 900 } # CSS px the site is laid out at
247
248
  output: { width: 1920, height: 1080, fps: 30 } # same aspect ratio; use 1080x1920 with a 900x1600 viewport for vertical
248
249
  theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter }
249
250
  music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or false
250
- sfx: true # click / whoosh / riser sounds
251
+ sfx: true # click / whoosh / riser sounds; clicks = no whooshes on cards and page changes (tutorial and course default); false = none
251
252
  voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
252
253
  storageState: auth.json # optional Playwright login state
253
254
  ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
@@ -393,10 +394,36 @@ mix is normalized to -14 LUFS.
393
394
 
394
395
  | provider | key (env or `.env`) | notes |
395
396
  |---|---|---|
396
- | `kokoro` (default) | none | Local and free; English voices only (`af_heart`, `am_michael`, `bf_emma`, …). Needs `npm i kokoro-js`; the first run downloads an ~90 MB model. |
397
+ | `kokoro` (default) | none | Local and free. English voices (`af_heart`, `am_michael`, `bf_emma`, …) and Mandarin Chinese ones (`zf_001`, see below). Needs `npm i kokoro-js`; the first run downloads an ~90 MB model. |
398
+ | `qwen` | none | Local and free, Apple Silicon Macs only: Qwen3-TTS, the most natural free Chinese voice here (`dylan`, `serena`, see below). |
397
399
  | `elevenlabs` | `ELEVENLABS_API_KEY` | Most natural, many languages including Chinese. `voice` is a voice ID; `model` defaults to `eleven_multilingual_v2`. The key needs Text to Speech access, plus Voices → Read to list voices. |
398
400
  | `openai` | `OPENAI_API_KEY` | `voice: alloy`, `model: gpt-4o-mini-tts`; optional `instructions` for tone. |
399
- | `say` | none | macOS built-in voices, robotic; fine for drafts. |
401
+ | `say` | none | macOS built-in voices, robotic; fine for drafts. For Chinese, use `qwen` (Apple Silicon) or `kokoro` with `zf_001` rather than `Tingting`. |
402
+
403
+ **Chinese narration.** `voice: { provider: kokoro, voice: zf_001 }` speaks Mandarin with Kokoro
404
+ v1.1-zh, free and local, and is what `kokoro` uses when the `say:` lines are Chinese and no voice is
405
+ named. It sounds far more natural than macOS `say -v Tingting`. Other voices: `zf_002` (female),
406
+ `zm_010`, `zm_011` (male), and others between `zf_001`…`zf_099` and `zm_009`…`zm_100` (not every
407
+ number exists). Its text-to-phoneme step (misaki) needs Python 3.10–3.12: on first use Storyshot
408
+ creates its own venv in `~/.cache/storyshot/zh-venv` (about 250 MB, under a minute; nothing is installed globally;
409
+ `STORYSHOT_PYTHON` picks the interpreter) and downloads a ~130 MB model. `storyshot doctor` says
410
+ whether it's ready. English words and letters in a line (`GeoGebra`, `y 等于 m x 加 b`) are spoken in
411
+ English, a 2 before a measure word is read 两 (`2 个单位`), and `=`, `+`, `×`, `÷`, `²` are spoken;
412
+ subtitles keep the line as written. Known issue: with every Kokoro voice, 逗号 can sound like 度号.
413
+
414
+ **Chinese on an Apple Silicon Mac: Qwen3-TTS.** `voice: { provider: qwen }` speaks with
415
+ [Qwen3-TTS 0.6B CustomVoice](https://huggingface.co/Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice) (Apache-2.0)
416
+ through [mlx-audio](https://github.com/Blaizzy/mlx-audio). It reads English words, letters and numbers
417
+ in a Chinese line more reliably than `zf_001`. Voices: `dylan` (male, Beijing accent; the default
418
+ for Chinese tutorials, lessons and docs), `serena` (female, warm; the default for Chinese promos),
419
+ `vivian` (female, bright), `uncle_fu` (male, mellow), `eric` (male, Sichuan accent), and `ryan`, `aiden` (English), `ono_anna` (Japanese), `sohee` (Korean). On
420
+ first use Storyshot creates a venv in `~/.cache/storyshot/qwen-venv` (Python 3.10–3.12, about 400 MB)
421
+ and downloads the ~2.3 GB model into the Hugging Face cache. It speaks at about 2x real time on an M4 Pro
422
+ and peaks at about 8 GB of memory. The model samples its speech, so a line now and then runs on into
423
+ invented words or stops short. Storyshot regenerates such a take (it judges by length) and warns if
424
+ one still looks off. A scientific number like `2e-105` is spelled out in Chinese, because it sets the
425
+ model babbling. On other machines the same storyboard renders with `kokoro` (`zf_001`, or `draftVoice`),
426
+ with a note.
400
427
 
401
428
  Other fields: `speed` (default 1), `volume` (default 1), and `pronounce`, a
402
429
  map of word → spoken form used only for TTS, e.g.
@@ -430,6 +457,41 @@ usually shouldn't.
430
457
  | `cta` | Dark call-to-action pill, e.g. `Start free at acme.com`. |
431
458
  | `duration` | Seconds. Default: fits the `say` line, else 1.0 (end card) or 2.2 (intro). |
432
459
  | `say` | Voiceover line played over the end card. |
460
+ | `emblem` | With `hyperframes`: one to three short monospace lines over the title (an alignment, a formula, a line of code); `false` for none. |
461
+
462
+ ### HyperFrames cards (optional)
463
+
464
+ `hyperframes: true` renders the intro, section and end cards with
465
+ [HyperFrames](https://hyperframes.heygen.com) (HeyGen's open-source HTML-to-video
466
+ renderer, Apache-2.0) for more motion. Two templates, picked by `purpose`:
467
+
468
+ - **lesson** (tutorials, courses, docs): the title lands word by word over a
469
+ slowly drifting, blurred view of the page the section leads into; section
470
+ cards show a progress bar of the parts; the intro's steps and the end card's
471
+ recap points light up as the narration names them.
472
+ - **launch** (promos): hand-drawn accents (a box round the logo, an underlined
473
+ highlight, an arrow to the call to action). The intro's paper turns into the
474
+ browser window and its logo lands on the app's own logo in the first frame;
475
+ the outro zooms from the last frame into the page and draws the call to
476
+ action over it.
477
+
478
+ ```yaml
479
+ hyperframes:
480
+ template: lesson # or launch (default: launch for pitch, lesson otherwise)
481
+ band: MVHLTPEEKSAVTALWGKV… # optional: text drifting behind the titles (a sequence, a formula)
482
+ bandColors: aminoacid # aminoacid | nucleotide | accent (default)
483
+ emblem: [LHCDKLHVDPENFRLL, "||||||||||||+|||", LHCDKLHVDPENFKLL] # optional, over the titles
484
+ hand: true # launch: hand lettering (Virgil) for the subtitle and call to action
485
+ backdrop: false # lesson: plain background instead of the blurred page
486
+ ```
487
+
488
+ It needs Node 22 or newer. On first use storyshot installs the pinned
489
+ HyperFrames version into `~/.cache/storyshot/hyperframes/` (a local install
490
+ of the `hyperframes` package wins); its telemetry is switched off. Cards are
491
+ cached, so `compose` only re-renders the ones that changed (about 10–25 s
492
+ each). Any card that can't be rendered falls back to the built-in one with a
493
+ warning. The Virgil font is © Ellinor Rapp, SIL Open Font License 1.1
494
+ (`src/fonts/Virgil-LICENSE.md`).
433
495
 
434
496
  ## License
435
497
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storyshot",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Turn a short YAML storyboard into a polished product demo video of your web app — smooth cursor, auto zoom, captions, music.",
5
5
  "author": "boheling <boheling@gmail.com>",
6
6
  "repository": {
@@ -12,6 +12,9 @@
12
12
  "bin": {
13
13
  "storyshot": "bin/storyshot.mjs"
14
14
  },
15
+ "scripts": {
16
+ "test": "node --test"
17
+ },
15
18
  "files": [
16
19
  "bin",
17
20
  "src",
@@ -36,6 +39,8 @@
36
39
  "zod": "^4.6.5"
37
40
  },
38
41
  "optionalDependencies": {
39
- "kokoro-js": "^1.2.1"
42
+ "gsap": "^3.14.2",
43
+ "kokoro-js": "^1.2.1",
44
+ "roughjs": "^4.6.6"
40
45
  }
41
46
  }
@@ -65,8 +65,8 @@ frames or stock footage presented as a recording of their app.
65
65
  narrated how-to; course = narrated lesson; docs = silent step-by-step GIFs)
66
66
  2. Length: about 20 s, 6 shots (it follows from the flow below)
67
67
  3. Flow: hero → Examples → Try It → AI result → pricing → end card
68
- 4. Voice: none (other options: free local English voice; ElevenLabs, best
69
- quality and Chinese support, key found in .env)
68
+ 4. Voice: none (other options: free local voice, English or Chinese;
69
+ ElevenLabs, best quality, key found in .env)
70
70
  5. Music: upbeat · Captions: English · Subtitle file (.srt): no
71
71
  6. Format: 16:9, 1080p (other option: 9:16 vertical)
72
72
  7. Login: not needed (or: Examples needs a login, so I'll open a browser
@@ -85,7 +85,16 @@ frames or stock footage presented as a recording of their app.
85
85
  | `docs` | one clip per step, each just long enough to see its result | none | none | step numbers, spotlight, cropped GIFs + PNGs, README.md article |
86
86
 
87
87
  Captions match the site's language. `kokoro` (free, local) speaks English
88
- only; for other languages, use ElevenLabs.
88
+ and Mandarin Chinese: for Chinese lines use `voice: { provider: kokoro,
89
+ voice: zf_001 }` (the default for Chinese `say:` lines), not macOS `say -v
90
+ Tingting`, which sounds robotic. It needs Python 3.10–3.12 and sets up its
91
+ own venv on first use (`doctor` reports it). On an Apple Silicon Mac, the
92
+ best free Chinese voice is `voice: { provider: qwen }` (Qwen3-TTS: `dylan`,
93
+ a male teacher's voice, for tutorials and lessons; `serena`, female, for
94
+ promos; those are the defaults): clearer than
95
+ zf_001 with English words and letters, about 2x real time, a ~2.3 GB model
96
+ on first use. Elsewhere a `qwen` storyboard renders with kokoro. For other
97
+ languages, use ElevenLabs.
89
98
  3. **Login.** If the flow needs a logged-in page:
90
99
  - Tell the user a browser window will open and that they should log in
91
100
  there, preferably with a demo account (the video shows whatever is on
@@ -175,6 +184,14 @@ small controls are readable. Your job is the structure:
175
184
  `note` the field the reader must not miss. Set the article's heading and
176
185
  first paragraph with `article: { title: …, intro: … }`. Downloads show a
177
186
  "Downloaded" bubble; `paste` and copy-to-clipboard work.
187
+ - **More motion on the cards** (when the user asks for livelier titles, or for
188
+ a launch video): `hyperframes: true` renders the intro, section and end
189
+ cards with HyperFrames (needs Node 22+; installs itself on first use). For a
190
+ subject with its own material, give the titles a backdrop from it:
191
+ `hyperframes: { band: <a sequence or formula>, emblem: [<1–3 short lines>] }`.
192
+ Promos get hand-drawn accents; `hand: true` uses hand lettering. Write the
193
+ intro's `bullets` and the end card's recap with the same words the `say`
194
+ line uses: each point lights up when the narration names it.
178
195
  - **End with a recap**: `end_card` with `bullets` listing the steps or rules
179
196
  to remember, and a `say` that repeats them.
180
197
  - **Narrating real results.** You can't know the numbers (scores, counts,
package/src/audio.mjs CHANGED
@@ -101,7 +101,7 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
101
101
  return press + release;
102
102
  }, 0.55);
103
103
  }
104
- for (const c of events.whooshes) {
104
+ for (const c of cfg.sfx.transitions ? events.whooshes : []) {
105
105
  const filt = svf(), len = 0.45;
106
106
  add(sfx, c - len * 0.55, len, (t) => {
107
107
  const p = t / len;
@@ -193,7 +193,7 @@ export async function soundtrack(cfg, events, silentVideo, { log = console.log }
193
193
  // so re-encoding by video sites doesn't clip
194
194
  execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', silentVideo, '-i', wavFile,
195
195
  '-map', '0:v', '-map', '1:a', '-c:v', 'copy', '-af', `loudnorm=I=-14:TP=-1.5:LRA=11,aresample=${SR},alimiter=limit=0.7:level=false:attack=1:release=50`, '-ar', `${SR}`, '-c:a', 'aac', '-b:a', '192k', '-shortest', '-movflags', '+faststart', cfg.out]);
196
- log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${events.whooshes.length} whooshes` +
196
+ log(`soundtrack: ${cfg.music ? (cfg.music.file ? 'music file' : `synth ${cfg.music.bpm} bpm`) : 'no music'}, ${events.clicks.length} clicks, ${cfg.sfx?.transitions ? events.whooshes.length : 0} whooshes` +
197
197
  (cfg.voice ? `, ${events.voice.length} narration lines (${cfg.voice.provider}/${cfg.voice.voice})` : ''));
198
198
  return { out: cfg.out, warnings, narration };
199
199
  }
package/src/check.mjs CHANGED
@@ -3,12 +3,15 @@ import fs from 'node:fs';
3
3
  import YAML from 'yaml';
4
4
  import { loadConfig } from './config.mjs';
5
5
  import { gitIgnored } from './auth.mjs';
6
+ import { isZhVoice, zhVoiceStatus } from './kokoro-zh.mjs';
7
+ import { qwenStatus } from './qwen-tts.mjs';
6
8
 
7
9
  const TOP = ['name', 'url', 'out', 'purpose', 'viewport', 'output', 'theme', 'music', 'voice', 'sfx', 'storageState', 'ignoreHTTPSErrors', 'intro', 'frame', 'pace', 'follow', 'article', 'cards', 'lang', 'colorScheme',
8
- 'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card'];
10
+ 'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes'];
9
11
  const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc'];
10
12
  const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
11
- const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think'];
13
+ const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think', 'emblem'];
14
+ const HYPERFRAMES = ['template', 'band', 'bandColors', 'emblem', 'hand', 'backdrop'];
12
15
  const TARGET = ['role', 'name', 'text', 'label', 'placeholder', 'alt', 'testId', 'css', 'exact', 'last', 'nth'];
13
16
  const OPTS = ['ms', 'offset', 'timeout', 'highlight', 'color', 'box'];
14
17
  const ACTIONS = {
@@ -84,6 +87,7 @@ export function check(file, { draft = false } = {}) {
84
87
  }
85
88
  if (raw.end_card) unknown(raw.end_card, END, 'end_card');
86
89
  if (raw.intro) unknown(raw.intro, END, 'intro');
90
+ if (raw.hyperframes && typeof raw.hyperframes === 'object') unknown(raw.hyperframes, HYPERFRAMES, 'hyperframes');
87
91
  if (raw.article) unknown(raw.article, ['title', 'intro'], 'article');
88
92
  if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style', 'window', 'title', 'icon'], 'frame');
89
93
  const todo = (fs.readFileSync(file, 'utf8').split('\n').map((l, i) => [l.replace(/#.*$/, ''), i + 1]).filter(([l]) => /\bTODO\b/.test(l)));
@@ -154,6 +158,17 @@ export function check(file, { draft = false } = {}) {
154
158
  if (need > room * 1.4 && need - room > 1) warnings.push(`step ${i + 1}: the say: line takes about ${need.toFixed(1)}s to speak but has ${room.toFixed(1)}s before ${nextK < 0 ? 'the video ends' : 'the next line'} — shorten it or raise duration (the lines after it would play late)`);
155
159
  }
156
160
  }
161
+ // the Chinese Kokoro voice needs a Python for its G2P: say so now, not after the recording
162
+ const v = draft && cfg.voice ? loadConfig(file, { draft }).voice : cfg.voice;
163
+ if (v?.provider === 'kokoro' && isZhVoice(v.voice)) {
164
+ const zh = zhVoiceStatus();
165
+ if (zh.problem) errors.push(zh.problem);
166
+ }
167
+ if (v?.provider === 'qwen') {
168
+ const q = qwenStatus();
169
+ if (q.problem) errors.push(q.problem);
170
+ }
171
+ if (v?.fallback) warnings.push(v.fallback);
157
172
  if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
158
173
  const env = cfg.voice.provider === 'elevenlabs' ? 'ELEVENLABS_API_KEY' : 'OPENAI_API_KEY';
159
174
  if (!process.env[env]) errors.push(`voice provider ${cfg.voice.provider} needs ${env} (environment or a .env file next to the storyboard) — or render with draft mode to use the free local voice`);
package/src/compose.mjs CHANGED
@@ -4,6 +4,7 @@ import { chromium } from 'playwright';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
+ import { renderHyperframesCards } from './hyperframes.mjs';
7
8
 
8
9
  const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
9
10
  const md = (s, accent) => esc(s).replace(/\*\*(.+?)\*\*/g, `<b style="color:${accent};font-weight:700">$1</b>`);
@@ -27,7 +28,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
27
28
  const END = !ec0 ? 0 : ec0.duration === 'auto' ? Math.max(2.5, (voiceLens.endCard ?? 0) + 0.6, readTime(ec0), ctaTime(ec0)) : ec0.duration;
28
29
  if (ec0 && END < 2.5) warn(`the end card shows for only ${END.toFixed(1)}s: too short to read its call to action — use duration: auto, or at least 2.5`);
29
30
  const in0 = cfg.intro;
30
- const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
31
+ const INTRO = !in0 ? 0 : in0.duration === 'auto' ? Math.max(cfg.hyperframes?.template === 'launch' ? 2.8 : 1.8, (voiceLens.intro ?? 0) + 0.8, readTime(in0)) : in0.duration;
31
32
  if (INTRO > 8) warn(`the intro card plays ${INTRO.toFixed(1)}s before anything happens: viewers leave during long intros — shorten its say or bullets (aim for 6–8s)`);
32
33
  const cards = []; // section cards: { i, t0, t1, card }
33
34
 
@@ -74,7 +75,11 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
74
75
  if (s.card) {
75
76
  // a section card plays over the step's first frame, before the step
76
77
  const C = cardLen(s.card, voiceLens.cards?.[i], 2.4);
77
- pieces.push({ raw: [r.start, r.start], o0: acc, o1: acc + C });
78
+ // while the card fades in, the page under it is still the last one shown (else the new step's
79
+ // page flashes through the fade); it changes once the card covers the screen
80
+ const prev = pieces.at(-1)?.raw[1];
81
+ if (prev != null) pieces.push({ raw: [prev, prev], o0: acc, o1: acc + 0.25 });
82
+ pieces.push({ raw: [r.start, r.start], o0: acc + (prev != null ? 0.25 : 0), o1: acc + C });
78
83
  cards.push({ i, t0: acc, t1: acc + C, card: s.card, thinkAt: C - (s.card.think ?? 0) - 0.3 });
79
84
  acc += C;
80
85
  }
@@ -419,9 +424,6 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
419
424
  }
420
425
  };
421
426
  const ec = cfg.endCard;
422
- if (ec) await animate(card(ec, ec.logoFile ?? path.join(cfg.work, 'logo.png')), END + 0.5, path.join(cfg.work, 'endcard'));
423
- for (const [k, c] of cards.entries()) await animate(card(c.card, c.card.logoFile ?? '', { thinkAt: c.thinkAt }), c.t1 - c.t0, path.join(cfg.work, `card${k}`));
424
- if (in0) await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
425
427
  if (cfg.frame) {
426
428
  // three layers: the backdrop (a picture, or the page itself blurred), the window chrome (shadow and
427
429
  // title bar), and the page with rounded bottom corners
@@ -467,6 +469,74 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
467
469
  await pg.screenshot({ path: path.join(cfg.work, 'frame-mask.png') });
468
470
  await pg.setViewportSize({ width: W, height: H });
469
471
  }
472
+
473
+ // what each HyperFrames card is about: its text, when its narration plays (card seconds), and
474
+ // pictures from the recording (the page it leads into, blurred, as a backdrop; for a launch intro, the
475
+ // first page sharp, and where the app shows the intro's logo, so the logo can land there)
476
+ function hfJobs() {
477
+ const launch = cfg.hyperframes.template === 'launch';
478
+ const seqAt = (t) => path.join(SEQ, `${String(Math.min(nFrames - 1, Math.max(0, Math.round(t * FPS)))).padStart(5, '0')}.jpg`);
479
+ const blurred = (t, name) => {
480
+ if (cfg.hyperframes.backdrop === false || !nFrames) return null;
481
+ const f = path.join(cfg.work, `${name}-backdrop.jpg`);
482
+ execFileSync('ffmpeg', ['-y', '-loglevel', 'error', '-i', seqAt(t), '-vf', `scale=${even(W / 4)}:-2,gblur=sigma=6`, '-q:v', '4', f]);
483
+ return f;
484
+ };
485
+ const sizeOf = (f) => {
486
+ try {
487
+ const [w, h] = execFileSync('ffprobe', ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', f]).toString().trim().split(',').map(Number);
488
+ return w && h ? { w, h } : null;
489
+ } catch { return null; }
490
+ };
491
+ const voice = (text, start, len) => (text && len ? { text, start, len } : null);
492
+ const jobs = [];
493
+ if (in0) {
494
+ const logo = [in0.logoFile, path.join(cfg.work, 'intro-logo.png')].find((f) => f && fs.existsSync(f)) ?? null;
495
+ const target = launch && logo && tl.introLogo ? onScreen(tl.introLogo, INTRO) : null;
496
+ jobs.push({ name: 'intro', kind: 'intro', seconds: INTRO, card: in0, voice: voice(in0.say, 0.15, voiceLens.intro), image: in0.imageFile,
497
+ logo, logoSize: target ? { w: target.w, h: target.h } : logo && sizeOf(logo), logoTarget: target,
498
+ page: launch && nFrames ? seqAt(INTRO) : null, backdrop: launch ? null : blurred(INTRO + 0.05, 'intro') });
499
+ }
500
+ for (const [k, c] of cards.entries()) {
501
+ jobs.push({ name: `card${k}`, kind: 'section', seconds: c.t1 - c.t0, card: c.card, voice: voice(c.card.say, 0.15, voiceLens.cards?.[c.i]),
502
+ index: k, of: cards.length, thinkAt: c.thinkAt, image: c.card.imageFile, backdrop: blurred(c.t1 + 0.05, `card${k}`) });
503
+ }
504
+ if (ec) {
505
+ const logo = [ec.logoFile, path.join(cfg.work, 'logo.png')].find((f) => f && fs.existsSync(f)) ?? null;
506
+ // the end card starts as the video ends (launch: over it, at VIDEO; lesson: a beat before)
507
+ jobs.push({ name: 'endcard', kind: 'end', seconds: launch ? END + 0.05 : END + 0.5, card: ec, voice: voice(ec.say, launch ? 0.05 : 0.3, voiceLens.endCard),
508
+ image: ec.imageFile, logo, logoSize: logo && sizeOf(logo), backdrop: launch ? null : blurred(VIDEO - 0.3, 'endcard') });
509
+ }
510
+ return jobs;
511
+ }
512
+
513
+ // the cards: HyperFrames compositions when asked for (`hyperframes:`), else (or for any card that
514
+ // doesn't render) the built-in ones. A launch outro is drawn with transparency over the last frame,
515
+ // which the ffmpeg pass below zooms into the page.
516
+ const hf = cfg.hyperframes ? await renderHyperframesCards(hfJobs(), {
517
+ W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work,
518
+ frame: { FX, FY, FW, FH, ...Object.fromEntries([['frameBg', 'frame-bg.png'], ['chrome', 'frame-chrome.png'], ['url', 'frame-url0.png']]
519
+ .map(([k, f]) => [k, cfg.frame && fs.existsSync(path.join(cfg.work, f)) ? path.join(cfg.work, f) : null])) },
520
+ }, { log, warn }) : {};
521
+ const place = (name, seconds) => {
522
+ const dir = path.join(cfg.work, name), src = fs.readdirSync(hf[name].dir).filter((f) => f.endsWith(`.${hf[name].ext}`)).sort();
523
+ fs.rmSync(dir, { recursive: true, force: true });
524
+ fs.mkdirSync(dir, { recursive: true });
525
+ for (let k = 0, n = Math.ceil(seconds * FPS) + 1; k < n; k++) fs.copyFileSync(path.join(hf[name].dir, src[Math.min(k, src.length - 1)]), path.join(dir, `${String(k).padStart(4, '0')}.${hf[name].ext}`));
526
+ };
527
+ const ecAlpha = !!hf.endcard?.alpha;
528
+ if (ec) {
529
+ if (hf.endcard) place('endcard', ecAlpha ? END + 0.05 : END + 0.5);
530
+ else await animate(card(ec, ec.logoFile ?? path.join(cfg.work, 'logo.png')), END + 0.5, path.join(cfg.work, 'endcard'));
531
+ }
532
+ for (const [k, c] of cards.entries()) {
533
+ if (hf[`card${k}`]) place(`card${k}`, c.t1 - c.t0);
534
+ else await animate(card(c.card, c.card.logoFile ?? '', { thinkAt: c.thinkAt }), c.t1 - c.t0, path.join(cfg.work, `card${k}`));
535
+ }
536
+ if (in0) {
537
+ if (hf.intro) place('intro', INTRO);
538
+ else await animate(card(in0, in0.logoFile ?? path.join(cfg.work, 'intro-logo.png')), INTRO, path.join(cfg.work, 'intro'));
539
+ }
470
540
  await browser.close();
471
541
 
472
542
  // --- 5. ffmpeg --------------------------------------------------------------------------------
@@ -508,12 +578,28 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
508
578
  last = `v${idx++}`;
509
579
  }
510
580
  for (const [k, c] of cards.entries()) {
581
+ // a card right after the intro is already in place (its first frame held) while the intro fades
582
+ // out, so the intro dissolves into it instead of showing the page for a moment in between
583
+ const lead = in0 && Math.abs(c.t0 - INTRO) < 0.02 ? Math.min(0.2, INTRO) : 0, s0 = c.t0 - lead;
511
584
  inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, `card${k}`, '%04d.jpg'));
512
- graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${c.t0.toFixed(3)}/TB,fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1[sc${k}];\n` +
513
- `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${c.t0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
585
+ graph += `[${idx}:v]format=rgba,${lead ? `tpad=start_duration=${lead.toFixed(3)}:start_mode=clone,` : ''}setpts=PTS-STARTPTS+${s0.toFixed(3)}/TB,` +
586
+ `${lead ? '' : `fade=in:st=${c.t0.toFixed(3)}:d=0.15:alpha=1,`}fade=out:st=${Math.max(c.t0, c.t1 - 0.15).toFixed(3)}:d=0.15:alpha=1[sc${k}];\n` +
587
+ `[${last}][sc${k}]overlay=0:0:eof_action=pass:enable='between(t,${s0.toFixed(3)},${c.t1.toFixed(3)})'[v${++idx}];\n`;
514
588
  last = `v${idx}`;
515
589
  }
516
- if (ec) {
590
+ if (ec && ecAlpha) {
591
+ // launch outro: the last frame zooms into the page (an upscaled copy, so the slow zoom doesn't
592
+ // jitter), then the card draws itself over it
593
+ const q = `clip((on/${FPS}-0.05)/0.6,0,1)`, E = `(${q})*(${q})*(3-2*(${q}))`, Z = 2;
594
+ const zoom = cfg.frame
595
+ ? `z='${W}/(${W}-${E}*${W - FW})':x='${E}*${FX * Z}':y='${E}*${FY * Z}'`
596
+ : `z='1+0.06*${E}':x='(iw-iw/zoom)/2':y='(ih-ih/zoom)/2'`;
597
+ inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard', '%04d.png'));
598
+ graph += `[${last}]split[pre][post];[pre]trim=end=${VIDEO.toFixed(3)},setpts=PTS-STARTPTS,format=yuv420p,setsar=1[pre1];\n` +
599
+ `[post]trim=start=${VIDEO.toFixed(3)},setpts=PTS-STARTPTS,scale=${W * Z}:${H * Z}:flags=lanczos,zoompan=${zoom}:d=1:fps=${FPS}:s=${W}x${H}[post1];\n` +
600
+ `[${idx}:v]format=rgba,setpts=PTS-STARTPTS[ec];[post1][ec]overlay=0:0:eof_action=repeat,format=yuv420p,setsar=1[post2];[pre1][post2]concat=n=2:v=1:a=0[v${++idx}];\n`;
601
+ last = `v${idx}`;
602
+ } else if (ec) {
517
603
  const st = VIDEO - 0.25;
518
604
  inputs.push('-framerate', `${FPS}`, '-i', path.join(cfg.work, 'endcard', '%04d.jpg'));
519
605
  graph += `[${idx}:v]format=rgba,setpts=PTS-STARTPTS+${st.toFixed(3)}/TB,fade=in:st=${st.toFixed(3)}:d=0.3:alpha=1[ec];\n` +
package/src/config.mjs CHANGED
@@ -2,17 +2,18 @@
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import YAML from 'yaml';
5
- import { VOICE_DEFAULTS } from './voice.mjs';
5
+ import { VOICE_DEFAULTS, kokoroVoiceFor, qwenVoiceFor } from './voice.mjs';
6
+ import { QWEN_VOICES, qwenSupported, qwenVoice } from './qwen-tts.mjs';
6
7
 
7
8
  // Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
8
9
  export const LABELS = {
9
- en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded' },
10
- zh: { think: '先想一想', intro: '开场', downloaded: '已下载' },
11
- es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado' },
12
- pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado' },
13
- fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé' },
14
- de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen' },
15
- ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み' },
10
+ en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded', tryIt: 'try it' },
11
+ zh: { think: '先想一想', intro: '开场', downloaded: '已下载', tryIt: '试试看' },
12
+ es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado', tryIt: 'pruébalo' },
13
+ pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado', tryIt: 'experimente' },
14
+ fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé', tryIt: 'essayez' },
15
+ de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen', tryIt: 'ausprobieren' },
16
+ ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み', tryIt: '試してみて' },
16
17
  };
17
18
 
18
19
  // Load API keys (ELEVENLABS_API_KEY, OPENAI_API_KEY) from .env next to the storyboard
@@ -31,8 +32,8 @@ export function loadEnv(dir) {
31
32
  // What the video is for sets sensible defaults; anything in the YAML overrides them.
32
33
  export const PURPOSES = {
33
34
  pitch: { duration: null, music: { bpm: 120, volume: 1 }, sfx: true, highlight: false, stepNumbers: false, subtitles: false, chapters: false, clips: null, frame: 'aurora', pace: 'fast', follow: false },
34
- tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: true, highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
35
- course: { duration: 'auto', music: false, sfx: true, highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
35
+ tutorial: { duration: 'auto', music: { bpm: 96, volume: 0.35 }, sfx: 'clicks', highlight: true, stepNumbers: true, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
36
+ course: { duration: 'auto', music: false, sfx: 'clicks', highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
36
37
  docs: { duration: 'auto', music: false, sfx: false, highlight: true, stepNumbers: true, subtitles: false, chapters: false, clips: ['gif', 'png'], frame: false, pace: 'normal', follow: false },
37
38
  };
38
39
 
@@ -114,7 +115,9 @@ export function loadConfig(file, { draft = false } = {}) {
114
115
  if (endCard) endCard = { duration: 'auto', ...endCard };
115
116
  // An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
116
117
  let intro = raw.intro === false ? null : raw.intro ?? null;
117
- if (intro) intro = { duration: intro.say ? 'auto' : 2.2, ...intro };
118
+ // (a HyperFrames launch intro also turns into the app window at the end: it needs a little longer)
119
+ const launchCards = raw.hyperframes && (raw.hyperframes.template ?? (purpose === 'pitch' ? 'launch' : 'lesson')) === 'launch';
120
+ if (intro) intro = { duration: intro.say ? 'auto' : launchCards ? 3.0 : 2.2, ...intro };
118
121
  for (const card of [endCard, intro]) {
119
122
  if (card?.image) card.imageFile = path.resolve(dir, card.image);
120
123
  if (card?.logo && typeof card.logo === 'string' && /\.(png|jpe?g|svg|webp)$/i.test(card.logo)) card.logoFile = path.resolve(dir, card.logo);
@@ -134,14 +137,35 @@ export function loadConfig(file, { draft = false } = {}) {
134
137
  if (frame && !['browser', 'app'].includes(frame.window)) throw new Error('frame.window must be browser or app');
135
138
  if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
136
139
 
140
+ // hyperframes: true | { template: lesson | launch, band, bandColors, emblem, hand, backdrop } — cards
141
+ // rendered by HyperFrames (see hyperframes.mjs); promos get the launch template, the rest the lesson one
142
+ const hf0 = raw.hyperframes;
143
+ const hyperframes = hf0 ? { template: purpose === 'pitch' ? 'launch' : 'lesson', backdrop: true, ...(typeof hf0 === 'object' ? hf0 : {}) } : null;
144
+ if (hyperframes && !['lesson', 'launch'].includes(hyperframes.template)) throw new Error('hyperframes.template must be lesson or launch');
145
+ if (hyperframes?.bandColors && !['aminoacid', 'nucleotide', 'accent'].includes(hyperframes.bandColors)) throw new Error('hyperframes.bandColors must be aminoacid, nucleotide or accent');
146
+
137
147
  let voice = null;
138
148
  if (raw.voice) {
139
149
  const provider = raw.voice.provider ?? 'kokoro';
140
150
  if (!VOICE_DEFAULTS[provider]) throw new Error(`voice.provider must be one of ${Object.keys(VOICE_DEFAULTS).join(', ')}`);
141
151
  const pace0 = PACES[pick('pace')] ?? PACES.normal;
142
- voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...raw.voice, provider };
143
- if (draft && provider !== 'kokoro') {
144
- voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? VOICE_DEFAULTS.kokoro.voice, model: undefined };
152
+ // kokoro's and qwen's default voices follow the narration's language (zf_001 / serena for Chinese)
153
+ const kokoroVoice = kokoroVoiceFor(raw);
154
+ const byLang = { kokoro: kokoroVoice, qwen: qwenVoiceFor(raw) }[provider];
155
+ voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...(byLang ? { voice: byLang } : {}), ...raw.voice, provider };
156
+ if (provider === 'qwen') {
157
+ const v = qwenVoice(voice.voice);
158
+ if (!v) throw new Error(`voice: qwen has no voice "${voice.voice}" (one of ${QWEN_VOICES.join(', ')})`);
159
+ voice.voice = v;
160
+ // MLX needs Apple Silicon: anywhere else the same storyboard renders with kokoro
161
+ if (!qwenSupported()) {
162
+ const k = raw.voice.draftVoice ?? kokoroVoice;
163
+ voice = { ...voice, provider: 'kokoro', voice: k, fallback: `the qwen voice needs an Apple Silicon Mac; using kokoro ${k} instead` };
164
+ }
165
+ }
166
+ // free local voices stay; a paid one is swapped for kokoro
167
+ if (draft && !['kokoro', 'qwen'].includes(voice.provider)) {
168
+ voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? kokoroVoice, model: undefined };
145
169
  }
146
170
  }
147
171
 
@@ -186,10 +210,13 @@ export function loadConfig(file, { draft = false } = {}) {
186
210
  lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
187
211
  labels: LABELS[String(raw.lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? LABELS.zh : LABELS.en),
188
212
  frame,
213
+ hyperframes,
189
214
  follow,
190
215
  music,
191
216
  voice,
192
- sfx: pick('sfx') !== false,
217
+ // sfx: true (clicks and transition whooshes), 'clicks' (no whooshes: tutorials and lessons, where a
218
+ // sound at every card soon grates), or false
219
+ sfx: pick('sfx') === false ? false : { clicks: true, transitions: pick('sfx') !== 'clicks' },
193
220
  draft,
194
221
  out: path.resolve(dir, raw.out ?? `${name}.mp4`).replace(/(\.[^./]+)?$/, (ext) => (draft ? `.draft${ext || '.mp4'}` : ext)),
195
222
  work: path.resolve(dir, '.storyshot', name),
package/src/doctor.mjs CHANGED
@@ -6,6 +6,9 @@ import { execFileSync } from 'node:child_process';
6
6
  import { createRequire } from 'node:module';
7
7
  import path from 'node:path';
8
8
  import { hasFilter, hasEncoder, NEEDED, OPTIONAL } from './ffmpeg.mjs';
9
+ import { zhVoiceStatus } from './kokoro-zh.mjs';
10
+ import { qwenStatus } from './qwen-tts.mjs';
11
+ import { hyperframesCli, HF_VERSION } from './hyperframes.mjs';
9
12
 
10
13
  const has = (cmd, args = ['--version']) => { try { execFileSync(cmd, args, { stdio: 'ignore' }); return true; } catch { return false; } };
11
14
 
@@ -71,6 +74,20 @@ export async function doctor({ log = console.log } = {}) {
71
74
  }
72
75
 
73
76
  try { await import('kokoro-js'); lines.push('✔ free local voice (kokoro)'); } catch { lines.push('· free local voice (kokoro) not installed — optional: npm i kokoro-js'); }
77
+ // Mandarin (kokoro zf_001…): its G2P runs in a Python 3.10–3.12 venv that storyshot creates on first use
78
+ const zh = zhVoiceStatus();
79
+ lines.push(zh.ready ? `✔ Chinese voice (kokoro zf_001): Python ${zh.python?.version ?? '?'} venv in ${zh.venv}`
80
+ : zh.python ? `· Chinese voice (kokoro zf_001): Python ${zh.python.version} found; storyshot sets up its venv in ${zh.venv} on first use`
81
+ : '· Chinese voice (kokoro zf_001) needs Python 3.10–3.12 — optional: brew install python@3.12, or uv python install 3.12 (or set STORYSHOT_PYTHON)');
82
+ const q = qwenStatus();
83
+ lines.push(!q.supported ? '· Qwen3-TTS voice (provider: qwen) needs an Apple Silicon Mac; storyboards that use it fall back to kokoro here'
84
+ : q.ready ? `✔ Qwen3-TTS voice (provider: qwen): venv in ${q.venv}${q.model ? ', model downloaded' : '; the model (~2.3 GB) downloads on first use'}`
85
+ : q.python ? `· Qwen3-TTS voice (provider: qwen): Python ${q.python.version} found; storyshot sets up its venv in ${q.venv} and downloads the model (~2.3 GB) on first use`
86
+ : '· Qwen3-TTS voice (provider: qwen) needs Python 3.10–3.12 — optional: brew install python@3.12, or uv python install 3.12 (or set STORYSHOT_PYTHON)');
87
+ const hf = hyperframesCli();
88
+ lines.push(hf.error ? `· HyperFrames cards (hyperframes: true) unavailable — optional: ${hf.error}`
89
+ : hf.missing ? `· HyperFrames cards (hyperframes: true): ${HF_VERSION} installs itself on first use`
90
+ : `✔ HyperFrames cards (${hf.how})`);
74
91
  lines.push(process.env.ELEVENLABS_API_KEY ? '✔ ELEVENLABS_API_KEY set' : '· ELEVENLABS_API_KEY not set — optional, for the best voice (put it in .env)');
75
92
  return { ok: !todo.length, report: lines.join('\n'), todo };
76
93
  }
@@ -0,0 +1,45 @@
1
+ Copyright (c) 2021 - Present, Ellinor Rapp, with Reserved Font Name Virgil.
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: [scripts.sil.org/OFL](https://scripts.sil.org/OFL).
4
+
5
+ # SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
6
+
7
+ ## PREAMBLE
8
+
9
+ The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others.
10
+
11
+ The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives.
12
+
13
+ ## DEFINITIONS
14
+
15
+ "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation.
16
+
17
+ "Reserved Font Name" refers to any names specified as such after the copyright statement(s).
18
+
19
+ "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s).
20
+
21
+ "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment.
22
+
23
+ "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software.
24
+
25
+ ## PERMISSION & CONDITIONS
26
+
27
+ Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions:
28
+
29
+ 1. Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself.
30
+
31
+ 2. Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user.
32
+
33
+ 3. No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users.
34
+
35
+ 4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission.
36
+
37
+ 5. The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software.
38
+
39
+ ## TERMINATION
40
+
41
+ This license becomes null and void if any of the above conditions are not met.
42
+
43
+ ## DISCLAIMER
44
+
45
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file