storyshot 0.4.2 → 0.5.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 +50 -4
- package/package.json +7 -2
- package/skills/storyshot/SKILL.md +15 -3
- package/src/audio.mjs +2 -2
- package/src/check.mjs +11 -2
- package/src/compose.mjs +94 -8
- package/src/config.mjs +28 -14
- package/src/doctor.mjs +11 -0
- package/src/fonts/Virgil-LICENSE.md +45 -0
- package/src/fonts/Virgil.woff2 +0 -0
- package/src/hyperframes.mjs +536 -0
- package/src/kokoro-zh.mjs +204 -0
- package/src/record.mjs +4 -1
- package/src/voice.mjs +38 -8
- package/src/zh_g2p.py +118 -0
package/README.md
CHANGED
|
@@ -152,7 +152,7 @@ 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
|
|
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
156
|
| ElevenLabs | your credits | the most natural voice; many languages including Chinese. Put `ELEVENLABS_API_KEY` in `.env`. |
|
|
157
157
|
| OpenAI | your credits | `OPENAI_API_KEY` |
|
|
158
158
|
|
|
@@ -247,7 +247,7 @@ viewport: { width: 1600, height: 900 } # CSS px the site is laid out at
|
|
|
247
247
|
output: { width: 1920, height: 1080, fps: 30 } # same aspect ratio; use 1080x1920 with a 900x1600 viewport for vertical
|
|
248
248
|
theme: { accent: "#d9f75b", ink: "#0f172a", font: Inter }
|
|
249
249
|
music: { bpm: 120, volume: 1 } # or { file: track.mp3 }, or false
|
|
250
|
-
sfx: true # click / whoosh / riser sounds
|
|
250
|
+
sfx: true # click / whoosh / riser sounds; clicks = no whooshes on cards and page changes (tutorial and course default); false = none
|
|
251
251
|
voice: { provider: elevenlabs, voice: hpp4J3VqNfWAUOO0d1Us } # optional voiceover, see below
|
|
252
252
|
storageState: auth.json # optional Playwright login state
|
|
253
253
|
ignoreHTTPSErrors: true # internal site with a self-signed HTTPS certificate (plain http:// needs nothing)
|
|
@@ -393,10 +393,21 @@ mix is normalized to -14 LUFS.
|
|
|
393
393
|
|
|
394
394
|
| provider | key (env or `.env`) | notes |
|
|
395
395
|
|---|---|---|
|
|
396
|
-
| `kokoro` (default) | none | Local and free
|
|
396
|
+
| `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. |
|
|
397
397
|
| `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
398
|
| `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. |
|
|
399
|
+
| `say` | none | macOS built-in voices, robotic; fine for drafts. For Chinese, use `kokoro` with `zf_001` rather than `Tingting`. |
|
|
400
|
+
|
|
401
|
+
**Chinese narration.** `voice: { provider: kokoro, voice: zf_001 }` speaks Mandarin with Kokoro
|
|
402
|
+
v1.1-zh, free and local, and is what `kokoro` uses when the `say:` lines are Chinese and no voice is
|
|
403
|
+
named. It sounds far more natural than macOS `say -v Tingting`. Other voices: `zf_002` (female),
|
|
404
|
+
`zm_010`, `zm_011` (male), and others between `zf_001`…`zf_099` and `zm_009`…`zm_100` (not every
|
|
405
|
+
number exists). Its text-to-phoneme step (misaki) needs Python 3.10–3.12: on first use Storyshot
|
|
406
|
+
creates its own venv in `~/.cache/storyshot/zh-venv` (about 250 MB, under a minute; nothing is installed globally;
|
|
407
|
+
`STORYSHOT_PYTHON` picks the interpreter) and downloads a ~130 MB model. `storyshot doctor` says
|
|
408
|
+
whether it's ready. English words and letters in a line (`GeoGebra`, `y 等于 m x 加 b`) are spoken in
|
|
409
|
+
English, a 2 before a measure word is read 两 (`2 个单位`), and `=`, `+`, `×`, `÷`, `²` are spoken;
|
|
410
|
+
subtitles keep the line as written. Known issue: with every Kokoro voice, 逗号 can sound like 度号.
|
|
400
411
|
|
|
401
412
|
Other fields: `speed` (default 1), `volume` (default 1), and `pronounce`, a
|
|
402
413
|
map of word → spoken form used only for TTS, e.g.
|
|
@@ -430,6 +441,41 @@ usually shouldn't.
|
|
|
430
441
|
| `cta` | Dark call-to-action pill, e.g. `Start free at acme.com`. |
|
|
431
442
|
| `duration` | Seconds. Default: fits the `say` line, else 1.0 (end card) or 2.2 (intro). |
|
|
432
443
|
| `say` | Voiceover line played over the end card. |
|
|
444
|
+
| `emblem` | With `hyperframes`: one to three short monospace lines over the title (an alignment, a formula, a line of code); `false` for none. |
|
|
445
|
+
|
|
446
|
+
### HyperFrames cards (optional)
|
|
447
|
+
|
|
448
|
+
`hyperframes: true` renders the intro, section and end cards with
|
|
449
|
+
[HyperFrames](https://hyperframes.heygen.com) (HeyGen's open-source HTML-to-video
|
|
450
|
+
renderer, Apache-2.0) for more motion. Two templates, picked by `purpose`:
|
|
451
|
+
|
|
452
|
+
- **lesson** (tutorials, courses, docs): the title lands word by word over a
|
|
453
|
+
slowly drifting, blurred view of the page the section leads into; section
|
|
454
|
+
cards show a progress bar of the parts; the intro's steps and the end card's
|
|
455
|
+
recap points light up as the narration names them.
|
|
456
|
+
- **launch** (promos): hand-drawn accents (a box round the logo, an underlined
|
|
457
|
+
highlight, an arrow to the call to action). The intro's paper turns into the
|
|
458
|
+
browser window and its logo lands on the app's own logo in the first frame;
|
|
459
|
+
the outro zooms from the last frame into the page and draws the call to
|
|
460
|
+
action over it.
|
|
461
|
+
|
|
462
|
+
```yaml
|
|
463
|
+
hyperframes:
|
|
464
|
+
template: lesson # or launch (default: launch for pitch, lesson otherwise)
|
|
465
|
+
band: MVHLTPEEKSAVTALWGKV… # optional: text drifting behind the titles (a sequence, a formula)
|
|
466
|
+
bandColors: aminoacid # aminoacid | nucleotide | accent (default)
|
|
467
|
+
emblem: [LHCDKLHVDPENFRLL, "||||||||||||+|||", LHCDKLHVDPENFKLL] # optional, over the titles
|
|
468
|
+
hand: true # launch: hand lettering (Virgil) for the subtitle and call to action
|
|
469
|
+
backdrop: false # lesson: plain background instead of the blurred page
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
It needs Node 22 or newer. On first use storyshot installs the pinned
|
|
473
|
+
HyperFrames version into `~/.cache/storyshot/hyperframes/` (a local install
|
|
474
|
+
of the `hyperframes` package wins); its telemetry is switched off. Cards are
|
|
475
|
+
cached, so `compose` only re-renders the ones that changed (about 10–25 s
|
|
476
|
+
each). Any card that can't be rendered falls back to the built-in one with a
|
|
477
|
+
warning. The Virgil font is © Ellinor Rapp, SIL Open Font License 1.1
|
|
478
|
+
(`src/fonts/Virgil-LICENSE.md`).
|
|
433
479
|
|
|
434
480
|
## License
|
|
435
481
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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
|
-
"
|
|
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
|
|
69
|
-
|
|
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,11 @@ 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
|
-
|
|
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). For other languages, use
|
|
92
|
+
ElevenLabs.
|
|
89
93
|
3. **Login.** If the flow needs a logged-in page:
|
|
90
94
|
- Tell the user a browser window will open and that they should log in
|
|
91
95
|
there, preferably with a demo account (the video shows whatever is on
|
|
@@ -175,6 +179,14 @@ small controls are readable. Your job is the structure:
|
|
|
175
179
|
`note` the field the reader must not miss. Set the article's heading and
|
|
176
180
|
first paragraph with `article: { title: …, intro: … }`. Downloads show a
|
|
177
181
|
"Downloaded" bubble; `paste` and copy-to-clipboard work.
|
|
182
|
+
- **More motion on the cards** (when the user asks for livelier titles, or for
|
|
183
|
+
a launch video): `hyperframes: true` renders the intro, section and end
|
|
184
|
+
cards with HyperFrames (needs Node 22+; installs itself on first use). For a
|
|
185
|
+
subject with its own material, give the titles a backdrop from it:
|
|
186
|
+
`hyperframes: { band: <a sequence or formula>, emblem: [<1–3 short lines>] }`.
|
|
187
|
+
Promos get hand-drawn accents; `hand: true` uses hand lettering. Write the
|
|
188
|
+
intro's `bullets` and the end card's recap with the same words the `say`
|
|
189
|
+
line uses: each point lights up when the narration names it.
|
|
178
190
|
- **End with a recap**: `end_card` with `bullets` listing the steps or rules
|
|
179
191
|
to remember, and a `say` that repeats them.
|
|
180
192
|
- **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,14 @@ 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';
|
|
6
7
|
|
|
7
8
|
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'];
|
|
9
|
+
'scrollOffset', 'captionPosition', 'captions', 'highlight', 'stepNumbers', 'subtitles', 'chapters', 'clips', 'maxWait', 'setup', 'steps', 'end_card', 'hyperframes'];
|
|
9
10
|
const STEP = ['caption', 'say', 'sayAt', 'duration', 'zoom', 'actions', 'hidden', 'sfx', 'highlight', 'captionPosition', 'card', 'follow', 'doc'];
|
|
10
11
|
const ZOOM = ['target', 'scale', 'start', 'end', 'ramp', 'offset'];
|
|
11
|
-
const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think'];
|
|
12
|
+
const END = ['logo', 'title', 'subtitle', 'cta', 'duration', 'say', 'kicker', 'bullets', 'image', 'think', 'emblem'];
|
|
13
|
+
const HYPERFRAMES = ['template', 'band', 'bandColors', 'emblem', 'hand', 'backdrop'];
|
|
12
14
|
const TARGET = ['role', 'name', 'text', 'label', 'placeholder', 'alt', 'testId', 'css', 'exact', 'last', 'nth'];
|
|
13
15
|
const OPTS = ['ms', 'offset', 'timeout', 'highlight', 'color', 'box'];
|
|
14
16
|
const ACTIONS = {
|
|
@@ -84,6 +86,7 @@ export function check(file, { draft = false } = {}) {
|
|
|
84
86
|
}
|
|
85
87
|
if (raw.end_card) unknown(raw.end_card, END, 'end_card');
|
|
86
88
|
if (raw.intro) unknown(raw.intro, END, 'intro');
|
|
89
|
+
if (raw.hyperframes && typeof raw.hyperframes === 'object') unknown(raw.hyperframes, HYPERFRAMES, 'hyperframes');
|
|
87
90
|
if (raw.article) unknown(raw.article, ['title', 'intro'], 'article');
|
|
88
91
|
if (raw.frame && typeof raw.frame === 'object') unknown(raw.frame, ['background', 'style', 'window', 'title', 'icon'], 'frame');
|
|
89
92
|
const todo = (fs.readFileSync(file, 'utf8').split('\n').map((l, i) => [l.replace(/#.*$/, ''), i + 1]).filter(([l]) => /\bTODO\b/.test(l)));
|
|
@@ -154,6 +157,12 @@ export function check(file, { draft = false } = {}) {
|
|
|
154
157
|
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
158
|
}
|
|
156
159
|
}
|
|
160
|
+
// the Chinese Kokoro voice needs a Python for its G2P: say so now, not after the recording
|
|
161
|
+
const v = draft && cfg.voice ? loadConfig(file, { draft }).voice : cfg.voice;
|
|
162
|
+
if (v?.provider === 'kokoro' && isZhVoice(v.voice)) {
|
|
163
|
+
const zh = zhVoiceStatus();
|
|
164
|
+
if (zh.problem) errors.push(zh.problem);
|
|
165
|
+
}
|
|
157
166
|
if (!draft && cfg.voice && ['elevenlabs', 'openai'].includes(cfg.voice.provider)) {
|
|
158
167
|
const env = cfg.voice.provider === 'elevenlabs' ? 'ELEVENLABS_API_KEY' : 'OPENAI_API_KEY';
|
|
159
168
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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
|
|
513
|
-
|
|
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,17 @@
|
|
|
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 } from './voice.mjs';
|
|
6
6
|
|
|
7
7
|
// Built-in labels drawn into the video, by `lang` (e.g. es, es-MX); other languages get English
|
|
8
8
|
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: 'ダウンロード済み' },
|
|
9
|
+
en: { think: 'Pause and think', intro: 'Introduction', downloaded: 'Downloaded', tryIt: 'try it' },
|
|
10
|
+
zh: { think: '先想一想', intro: '开场', downloaded: '已下载', tryIt: '试试看' },
|
|
11
|
+
es: { think: 'Piénsalo un momento', intro: 'Introducción', downloaded: 'Descargado', tryIt: 'pruébalo' },
|
|
12
|
+
pt: { think: 'Pense um pouco', intro: 'Introdução', downloaded: 'Baixado', tryIt: 'experimente' },
|
|
13
|
+
fr: { think: 'Réfléchissez', intro: 'Introduction', downloaded: 'Téléchargé', tryIt: 'essayez' },
|
|
14
|
+
de: { think: 'Kurz nachdenken', intro: 'Einführung', downloaded: 'Heruntergeladen', tryIt: 'ausprobieren' },
|
|
15
|
+
ja: { think: '考えてみよう', intro: 'はじめに', downloaded: 'ダウンロード済み', tryIt: '試してみて' },
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
// Load API keys (ELEVENLABS_API_KEY, OPENAI_API_KEY) from .env next to the storyboard
|
|
@@ -31,8 +31,8 @@ export function loadEnv(dir) {
|
|
|
31
31
|
// What the video is for sets sensible defaults; anything in the YAML overrides them.
|
|
32
32
|
export const PURPOSES = {
|
|
33
33
|
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:
|
|
35
|
-
course: { duration: 'auto', music: false, sfx:
|
|
34
|
+
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 },
|
|
35
|
+
course: { duration: 'auto', music: false, sfx: 'clicks', highlight: true, stepNumbers: false, subtitles: true, chapters: true, clips: null, frame: 'studio', pace: 'slow', follow: true },
|
|
36
36
|
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
37
|
};
|
|
38
38
|
|
|
@@ -114,7 +114,9 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
114
114
|
if (endCard) endCard = { duration: 'auto', ...endCard };
|
|
115
115
|
// An intro title card (promos; optional for tutorials and lessons). Same fields as the end card.
|
|
116
116
|
let intro = raw.intro === false ? null : raw.intro ?? null;
|
|
117
|
-
|
|
117
|
+
// (a HyperFrames launch intro also turns into the app window at the end: it needs a little longer)
|
|
118
|
+
const launchCards = raw.hyperframes && (raw.hyperframes.template ?? (purpose === 'pitch' ? 'launch' : 'lesson')) === 'launch';
|
|
119
|
+
if (intro) intro = { duration: intro.say ? 'auto' : launchCards ? 3.0 : 2.2, ...intro };
|
|
118
120
|
for (const card of [endCard, intro]) {
|
|
119
121
|
if (card?.image) card.imageFile = path.resolve(dir, card.image);
|
|
120
122
|
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 +136,23 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
134
136
|
if (frame && !['browser', 'app'].includes(frame.window)) throw new Error('frame.window must be browser or app');
|
|
135
137
|
if (frame?.icon) frame.iconFile = path.resolve(dir, frame.icon);
|
|
136
138
|
|
|
139
|
+
// hyperframes: true | { template: lesson | launch, band, bandColors, emblem, hand, backdrop } — cards
|
|
140
|
+
// rendered by HyperFrames (see hyperframes.mjs); promos get the launch template, the rest the lesson one
|
|
141
|
+
const hf0 = raw.hyperframes;
|
|
142
|
+
const hyperframes = hf0 ? { template: purpose === 'pitch' ? 'launch' : 'lesson', backdrop: true, ...(typeof hf0 === 'object' ? hf0 : {}) } : null;
|
|
143
|
+
if (hyperframes && !['lesson', 'launch'].includes(hyperframes.template)) throw new Error('hyperframes.template must be lesson or launch');
|
|
144
|
+
if (hyperframes?.bandColors && !['aminoacid', 'nucleotide', 'accent'].includes(hyperframes.bandColors)) throw new Error('hyperframes.bandColors must be aminoacid, nucleotide or accent');
|
|
145
|
+
|
|
137
146
|
let voice = null;
|
|
138
147
|
if (raw.voice) {
|
|
139
148
|
const provider = raw.voice.provider ?? 'kokoro';
|
|
140
149
|
if (!VOICE_DEFAULTS[provider]) throw new Error(`voice.provider must be one of ${Object.keys(VOICE_DEFAULTS).join(', ')}`);
|
|
141
150
|
const pace0 = PACES[pick('pace')] ?? PACES.normal;
|
|
142
|
-
voice
|
|
151
|
+
// kokoro's default voice follows the narration's language (zf_001 for Chinese, af_heart otherwise)
|
|
152
|
+
const kokoroVoice = kokoroVoiceFor(raw);
|
|
153
|
+
voice = { speed: pace0.voiceSpeed, volume: 1, ...VOICE_DEFAULTS[provider], ...(provider === 'kokoro' ? { voice: kokoroVoice } : {}), ...raw.voice, provider };
|
|
143
154
|
if (draft && provider !== 'kokoro') {
|
|
144
|
-
voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ??
|
|
155
|
+
voice = { ...voice, provider: 'kokoro', voice: raw.voice.draftVoice ?? kokoroVoice, model: undefined };
|
|
145
156
|
}
|
|
146
157
|
}
|
|
147
158
|
|
|
@@ -186,10 +197,13 @@ export function loadConfig(file, { draft = false } = {}) {
|
|
|
186
197
|
lang: raw.lang ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? 'zh' : 'en'),
|
|
187
198
|
labels: LABELS[String(raw.lang ?? '').toLowerCase().split(/[-_]/)[0]] ?? (/[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(JSON.stringify(raw.steps ?? [])) ? LABELS.zh : LABELS.en),
|
|
188
199
|
frame,
|
|
200
|
+
hyperframes,
|
|
189
201
|
follow,
|
|
190
202
|
music,
|
|
191
203
|
voice,
|
|
192
|
-
sfx:
|
|
204
|
+
// sfx: true (clicks and transition whooshes), 'clicks' (no whooshes: tutorials and lessons, where a
|
|
205
|
+
// sound at every card soon grates), or false
|
|
206
|
+
sfx: pick('sfx') === false ? false : { clicks: true, transitions: pick('sfx') !== 'clicks' },
|
|
193
207
|
draft,
|
|
194
208
|
out: path.resolve(dir, raw.out ?? `${name}.mp4`).replace(/(\.[^./]+)?$/, (ext) => (draft ? `.draft${ext || '.mp4'}` : ext)),
|
|
195
209
|
work: path.resolve(dir, '.storyshot', name),
|
package/src/doctor.mjs
CHANGED
|
@@ -6,6 +6,8 @@ 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 { hyperframesCli, HF_VERSION } from './hyperframes.mjs';
|
|
9
11
|
|
|
10
12
|
const has = (cmd, args = ['--version']) => { try { execFileSync(cmd, args, { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
11
13
|
|
|
@@ -71,6 +73,15 @@ export async function doctor({ log = console.log } = {}) {
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
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'); }
|
|
76
|
+
// Mandarin (kokoro zf_001…): its G2P runs in a Python 3.10–3.12 venv that storyshot creates on first use
|
|
77
|
+
const zh = zhVoiceStatus();
|
|
78
|
+
lines.push(zh.ready ? `✔ Chinese voice (kokoro zf_001): Python ${zh.python?.version ?? '?'} venv in ${zh.venv}`
|
|
79
|
+
: zh.python ? `· Chinese voice (kokoro zf_001): Python ${zh.python.version} found; storyshot sets up its venv in ${zh.venv} on first use`
|
|
80
|
+
: '· 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)');
|
|
81
|
+
const hf = hyperframesCli();
|
|
82
|
+
lines.push(hf.error ? `· HyperFrames cards (hyperframes: true) unavailable — optional: ${hf.error}`
|
|
83
|
+
: hf.missing ? `· HyperFrames cards (hyperframes: true): ${HF_VERSION} installs itself on first use`
|
|
84
|
+
: `✔ HyperFrames cards (${hf.how})`);
|
|
74
85
|
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
86
|
return { ok: !todo.length, report: lines.join('\n'), todo };
|
|
76
87
|
}
|
|
@@ -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
|