storyshot 0.7.2 → 0.7.3
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 +7 -1
- package/package.json +1 -1
- package/src/compose.mjs +1 -1
- package/src/hyperframes.mjs +15 -10
- package/src/outputs.mjs +11 -7
package/README.md
CHANGED
|
@@ -15,6 +15,12 @@ second or two on screen.
|
|
|
15
15
|
The agent plans, writes and fixes the storyboard. Storyshot does the
|
|
16
16
|
recording and editing, and the same storyboard always renders the same video.
|
|
17
17
|
|
|
18
|
+
https://github.com/user-attachments/assets/05dd9a73-2edc-46af-9b2b-a8e33c7005b4
|
|
19
|
+
|
|
20
|
+
A 68-second pitch for Storyshot, made with [HyperFrames](https://hyperframes.heygen.com) and a free
|
|
21
|
+
local voice (Kokoro). The request, storyboard, recording, contact sheet and render log in it, and the
|
|
22
|
+
16 videos on the wall, are real Storyshot output.
|
|
23
|
+
|
|
18
24
|
## Gallery
|
|
19
25
|
|
|
20
26
|
**[boheling.github.io/storyshot-gallery](https://boheling.github.io/storyshot-gallery/)**: promos,
|
|
@@ -257,7 +263,7 @@ captions: true # false keeps caption pills off
|
|
|
257
263
|
purpose: pitch # pitch | tutorial | course | docs (sets the defaults below)
|
|
258
264
|
highlight: false # spotlight targets before click/type (per step or per action too)
|
|
259
265
|
stepNumbers: false # numbered caption badges
|
|
260
|
-
subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top); uses ffmpeg's libass when it has it, else draws the words itself
|
|
266
|
+
subtitles: false # write <out>.srt (narration, else captions); `burn` also burns the narration into the video, each word lighting up as it is spoken (captions move to the top; in a vertical video the lines sit above the bottom fifth, which short-video apps cover, and the cards keep above them); uses ffmpeg's libass when it has it, else draws the words itself
|
|
261
267
|
pace: slow # fast (pitch) | normal (docs) | slow (tutorial, course): cursor and typing speed, a beat after each click, hold after narration, voice speed, how much of a wait is shown
|
|
262
268
|
follow: true # tutorial/course default: the camera zooms in on what each step touches and pans between steps (a step's own zoom wins; follow: false on a step skips it); or { scale: 1.8 }
|
|
263
269
|
article: { title: Export a drawing, intro: … } # docs: heading and first paragraph of <out>-steps/README.md
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storyshot",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
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": {
|
package/src/compose.mjs
CHANGED
|
@@ -538,7 +538,7 @@ export async function compose(cfg, { log = console.log, voiceLens = { steps: {},
|
|
|
538
538
|
// doesn't render) the built-in ones. A launch outro is drawn with transparency over the last frame,
|
|
539
539
|
// which the ffmpeg pass below zooms into the page.
|
|
540
540
|
const hf = cfg.hyperframes ? await renderHyperframesCards(hfJobs(), {
|
|
541
|
-
W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work,
|
|
541
|
+
W, H, FPS, dark: darkCards, accent, ink, font, lang: cfg.lang, labels: cfg.labels, opts: cfg.hyperframes, work: cfg.work, subs: cfg.subtitles === 'burn',
|
|
542
542
|
frame: { FX, FY, FW, FH, ...Object.fromEntries([['frameBg', 'frame-bg.png'], ['chrome', 'frame-chrome.png'], ['url', 'frame-url0.png']]
|
|
543
543
|
.map(([k, f]) => [k, cfg.frame && fs.existsSync(path.join(cfg.work, f)) ? path.join(cfg.work, f) : null])) },
|
|
544
544
|
}, { log, warn }) : {};
|
package/src/hyperframes.mjs
CHANGED
|
@@ -233,7 +233,7 @@ function pageTimeline(D) {
|
|
|
233
233
|
return { fromTo: (sel, from, to, at = 0) => add(sel, from, to, at), to: (sel, to, at = 0) => add(sel, null, to, at), build };
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
const doc = ({ W, H, D, css, body, js, bg = 'transparent' }) => `<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=${W}, height=${H}">
|
|
236
|
+
const doc = ({ W, H, D, css, body, js, bg = 'transparent', floor = H * 0.97 }) => `<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=${W}, height=${H}">
|
|
237
237
|
<style>
|
|
238
238
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
239
239
|
html,body{width:${W}px;height:${H}px;overflow:hidden;background:${bg}}
|
|
@@ -267,7 +267,7 @@ const refit = () => {
|
|
|
267
267
|
const box = document.querySelector('[data-fit-box]');
|
|
268
268
|
for (let k = 0; box && fits.length && k < 30; k++) {
|
|
269
269
|
const kids = [...box.querySelectorAll(':scope > *, :scope > .right > *')].filter((c) => c.offsetHeight && !c.classList.contains('right'));
|
|
270
|
-
if (!kids.length || (Math.min(...kids.map((c) => c.offsetTop)) >= ${H * 0.03} && Math.max(...kids.map((c) => c.offsetTop + c.offsetHeight)) <= ${
|
|
270
|
+
if (!kids.length || (Math.min(...kids.map((c) => c.offsetTop)) >= ${H * 0.03} && Math.max(...kids.map((c) => c.offsetTop + c.offsetHeight)) <= ${floor})) break;
|
|
271
271
|
fits.forEach(shrink);
|
|
272
272
|
}
|
|
273
273
|
};
|
|
@@ -279,7 +279,10 @@ tl.build();
|
|
|
279
279
|
|
|
280
280
|
// --- lesson: tutorials, courses, docs ---------------------------------------------------------------
|
|
281
281
|
function lesson(job, ctx) {
|
|
282
|
-
|
|
282
|
+
// sizes are designed for 1920x1080; a portrait frame (1080x1920) sizes them by its width instead
|
|
283
|
+
const { W, H, dark, font, opts } = ctx, u = Math.min(H, W * 1.15) / 1080, D = job.seconds, c = job.card, accent = ctx.accent, ink = ctx.ink;
|
|
284
|
+
// portrait with burned-in subtitles: the subtitles sit above the bottom fifth, so the card keeps above them
|
|
285
|
+
const lift = ctx.subs && H > W ? H * 0.22 : 0;
|
|
283
286
|
const P = dark
|
|
284
287
|
? { ground: '#0c1222', ground2: '#070b16', glow: '#1b2744', text: '#eef2f8', muted: '#9aa8bd', line: '#2a3550', chip: '#131c31', chipLine: '#26324d', band: 0.14, shadow: 'rgba(0,0,0,.35)' }
|
|
285
288
|
: { ground: '#eef2f7', ground2: '#dfe6ef', glow: '#ffffff', text: ink, muted: '#56667d', line: '#c6d0dc', chip: '#ffffff', chipLine: '#d6dee8', band: 0.17, shadow: 'rgba(13,27,46,.08)' };
|
|
@@ -293,9 +296,11 @@ function lesson(job, ctx) {
|
|
|
293
296
|
const em = job.kind === 'end' ? null : c.emblem === false ? null : [].concat(c.emblem ?? opts.emblem ?? []).filter(Boolean).slice(0, 3).map(String);
|
|
294
297
|
const emblem = !em?.length ? '' : (() => {
|
|
295
298
|
const a = em[0], z = em.at(-1);
|
|
296
|
-
|
|
297
|
-
const
|
|
298
|
-
|
|
299
|
+
// (only lines of the same length line up like an alignment; lines of code differ everywhere)
|
|
300
|
+
const aligned = em.length > 1 && em.every((x) => x.length === a.length);
|
|
301
|
+
const diff = (i) => aligned && a[i] !== z[i];
|
|
302
|
+
const row = (s, k) => `<div class="row r${k === 0 || !aligned ? 'q' : k === em.length - 1 ? 's' : 'm'}">${[...s].map((ch, i) =>
|
|
303
|
+
`<i class="${aligned && (k === 1 && em.length === 3 ? ch !== '|' && ch !== ' ' : diff(i)) ? 'df' : ''}">${ch === ' ' ? ' ' : esc(ch)}</i>`).join('')}</div>`;
|
|
299
304
|
return `<div id="aln" class="aln" data-layout-ignore>${em.map(row).join('')}</div>`;
|
|
300
305
|
})();
|
|
301
306
|
const kicker = c.kicker ?? (job.kind === 'end' && c.subtitle && c.bullets?.length && String(c.subtitle).length <= 14 ? c.subtitle : null);
|
|
@@ -328,7 +333,7 @@ function lesson(job, ctx) {
|
|
|
328
333
|
.band-wrap{position:absolute;left:0;right:0;height:${80 * u}px;overflow:hidden;opacity:${P.band};-webkit-mask-image:linear-gradient(90deg,transparent,#000 18%,#000 82%,transparent)}
|
|
329
334
|
.band{position:absolute;left:0;top:0;white-space:nowrap;font:500 ${54 * u}px 'IBM Plex Mono',monospace;letter-spacing:.3em}
|
|
330
335
|
.band i,.aln i{font-style:normal}
|
|
331
|
-
.col{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 ${W * 0.06}px}
|
|
336
|
+
.col{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:0 ${W * 0.06}px ${lift}px}
|
|
332
337
|
.aln{font:500 ${30 * u}px 'IBM Plex Mono',monospace;letter-spacing:.34em;line-height:1.12;margin-bottom:${44 * u}px;color:${P.muted}}
|
|
333
338
|
.aln .row{white-space:nowrap}.aln i{display:inline-block}
|
|
334
339
|
.aln .rm{color:${strong};opacity:.8}.aln i.df{color:${dark ? accent : P.text};background:${dark ? 'transparent' : accent};font-weight:700}
|
|
@@ -364,7 +369,7 @@ h1{font-weight:600;letter-spacing:-.03em;line-height:1.08;max-width:${W * 0.82}p
|
|
|
364
369
|
.cta.cmd{font:500 ${36 * u}px 'IBM Plex Mono',monospace;background:${dark ? '#1a2238' : ink};color:#e2e8f0;text-align:left}
|
|
365
370
|
.cta.cmd b{color:${accent}}
|
|
366
371
|
#type{display:inline-block;white-space:nowrap}
|
|
367
|
-
.think{position:absolute;left:50%;bottom:${H * 0.1}px;width:${W * 0.3}px;margin-left:${-W * 0.15}px;text-align:center;font-size:${26 * u}px;color:${P.muted}}
|
|
372
|
+
.think{position:absolute;left:50%;bottom:${lift ? H * 0.2 : H * 0.1}px;width:${W * 0.3}px;margin-left:${-W * 0.15}px;text-align:center;font-size:${26 * u}px;color:${P.muted}}
|
|
368
373
|
.tlabel{margin-bottom:${12 * u}px}
|
|
369
374
|
.track{height:${8 * u}px;border-radius:99px;background:${P.line};overflow:hidden}
|
|
370
375
|
.drain{height:100%;background:${accent};transform-origin:0 50%}`;
|
|
@@ -427,7 +432,7 @@ ${isCmd(c.cta) ? `tl.fromTo('#type', { clipPath: 'inset(0 100% 0 0)' }, { clipPa
|
|
|
427
432
|
${think ? `blurIn('#think', ${n(job.thinkAt)});
|
|
428
433
|
tl.fromTo('#drain', { scaleX: 1 }, { scaleX: 0, duration: ${c.think}, ease: 'none' }, ${n(job.thinkAt)});` : ''}
|
|
429
434
|
`;
|
|
430
|
-
return doc({ W, H, D, css, body, js, bg: P.ground });
|
|
435
|
+
return doc({ W, H, D, css, body, js, bg: P.ground, floor: H * 0.97 - lift });
|
|
431
436
|
}
|
|
432
437
|
|
|
433
438
|
// --- launch: promos ----------------------------------------------------------------------------------
|
|
@@ -575,7 +580,7 @@ const hashOf = (...parts) => {
|
|
|
575
580
|
|
|
576
581
|
// jobs: [{ name, kind: intro|section|end, seconds, card, voice: {text, start, len}?, index, of, thinkAt,
|
|
577
582
|
// backdrop?, page?, logo?, logoSize?, logoTarget?, image?, alpha? }]
|
|
578
|
-
// ctx: { W, H, FPS, dark, accent, ink, font, lang, labels, opts, frame: {FX,FY,FW,FH,frameBg,chrome,url}, work }
|
|
583
|
+
// ctx: { W, H, FPS, dark, accent, ink, font, lang, labels, opts, frame: {FX,FY,FW,FH,frameBg,chrome,url}, work, subs (burned-in subtitles) }
|
|
579
584
|
// Returns { [name]: { dir, ext, alpha } } for the cards that rendered; the rest fall back.
|
|
580
585
|
export async function renderHyperframesCards(jobs, ctx, { log = console.log, warn = log } = {}) {
|
|
581
586
|
if (!jobs.length) return {};
|
package/src/outputs.mjs
CHANGED
|
@@ -90,7 +90,11 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
90
90
|
// Chinese, Japanese, Korean: no spaces between words, so lines are cut by characters (one line of
|
|
91
91
|
// at most ~18), in a lighter weight and a font that has the glyphs
|
|
92
92
|
const cjk = cfg.lang === 'zh' || narration.some((l) => /[\u3040-\u30ff\u3400-\u9fff\uac00-\ud7af]/.test(l.text));
|
|
93
|
-
|
|
93
|
+
// portrait (9:16): size by the width, and keep the lines above the bottom fifth, which short-video
|
|
94
|
+
// apps cover with the title, the account name and their buttons
|
|
95
|
+
const portrait = H > W;
|
|
96
|
+
const size = Math.round(portrait ? W * (cjk ? 0.068 : 0.062) : H * (cjk ? 0.042 : 0.048)), edge = Math.max(2, Math.round(size * (cjk ? 0.07 : 0.09)));
|
|
97
|
+
const lift = Math.round(H * (portrait ? 0.2 : 0.06)), most = portrait ? Math.floor((W * 0.84) / size) : 18;
|
|
94
98
|
const fontName = cjk && cfg.theme.font === 'Inter' ? (process.platform === 'darwin' ? 'PingFang SC' : 'Noto Sans CJK SC') : cfg.theme.font;
|
|
95
99
|
const cues = []; // { words: [{ w, start, d }] }: one on-screen line each
|
|
96
100
|
for (const line of narration) {
|
|
@@ -102,7 +106,7 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
102
106
|
const total = words.reduce((a, w) => a + weight(w), 0), span = line.end - line.start;
|
|
103
107
|
let at = line.start;
|
|
104
108
|
const timed = words.map((w) => { const d = (span * weight(w)) / total; const r = { w, start: at, d }; at += d; return r; });
|
|
105
|
-
// chunks of up to 7 words, broken after sentence punctuation
|
|
109
|
+
// chunks of up to 7 words (5 in portrait), broken after sentence punctuation
|
|
106
110
|
let chunk = [];
|
|
107
111
|
const flush = () => {
|
|
108
112
|
if (!chunk.length) return;
|
|
@@ -111,10 +115,10 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
111
115
|
};
|
|
112
116
|
const len = () => chunk.reduce((a, x) => a + x.w.length, 0);
|
|
113
117
|
for (const x of timed) {
|
|
114
|
-
// a CJK line ends at a sentence break, or at a comma once it is half full, or
|
|
115
|
-
if (cjk && chunk.length && len() + x.w.length >
|
|
118
|
+
// a CJK line ends at a sentence break, or at a comma once it is half full, or when full (18 characters; about 12 in portrait)
|
|
119
|
+
if (cjk && chunk.length && len() + x.w.length > most) flush();
|
|
116
120
|
chunk.push(x);
|
|
117
|
-
if (cjk ? /[。!?;!?;]$/.test(x.w) || (/[,、:,:]$/.test(x.w) && len() >=
|
|
121
|
+
if (cjk ? /[。!?;!?;]$/.test(x.w) || (/[,、:,:]$/.test(x.w) && len() >= most / 2) : chunk.length >= (portrait ? 5 : 7) || /[.!?;:]$/.test(x.w)) flush();
|
|
118
122
|
}
|
|
119
123
|
flush();
|
|
120
124
|
}
|
|
@@ -157,7 +161,7 @@ export async function burnSubtitles(cfg, narration, { log = console.log, rendere
|
|
|
157
161
|
for (const [j, x] of cue.words.entries()) {
|
|
158
162
|
const file = `s${String(n++).padStart(5, '0')}.png`;
|
|
159
163
|
const text = cue.words.map((y, k) => `<span style="color:${k <= j ? sweep : '#fff'}">${esc(y.w)}</span>`).join(cjk ? '' : ' ');
|
|
160
|
-
await shot(`<div style="position:absolute;left:${Math.round(W * 0.08)}px;right:${Math.round(W * 0.08)}px;bottom:${
|
|
164
|
+
await shot(`<div style="position:absolute;left:${Math.round(W * 0.08)}px;right:${Math.round(W * 0.08)}px;bottom:${lift}px;text-align:center;
|
|
161
165
|
font-family:${font};font-size:${px}px;font-weight:${cjk ? 400 : 700};line-height:1.2;
|
|
162
166
|
-webkit-text-stroke:${edge * 2}px ${edgeCol};paint-order:stroke fill;text-shadow:${Math.round(edge * 0.8)}px ${Math.round(edge * 0.8)}px 0 rgba(0,0,0,.5)">${text}</div>`, path.join(dir, file));
|
|
163
167
|
push(file, j + 1 < cue.words.length ? cue.words[j + 1].start : end);
|
|
@@ -184,7 +188,7 @@ WrapStyle: 0
|
|
|
184
188
|
|
|
185
189
|
[V4+ Styles]
|
|
186
190
|
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
|
187
|
-
Style: Sub,${fontName},${size},${bgr(sweep)},&H00FFFFFF,${bgr(edgeCol)},&H80000000,${cjk ? 0 : 1},0,0,0,100,100,0,0,1,${edge},${Math.round(edge * 0.8)},2,${Math.round(W * 0.08)},${Math.round(W * 0.08)},${
|
|
191
|
+
Style: Sub,${fontName},${size},${bgr(sweep)},&H00FFFFFF,${bgr(edgeCol)},&H80000000,${cjk ? 0 : 1},0,0,0,100,100,0,0,1,${edge},${Math.round(edge * 0.8)},2,${Math.round(W * 0.08)},${Math.round(W * 0.08)},${lift},1
|
|
188
192
|
|
|
189
193
|
[Events]
|
|
190
194
|
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|