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 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.2",
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 }) : {};
@@ -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)) <= ${H * 0.97})) break;
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
- const { W, H, dark, font, opts } = ctx, u = H / 1080, D = job.seconds, c = job.card, accent = ctx.accent, ink = ctx.ink;
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
- const diff = (i) => em.length > 1 && a[i] !== z[i];
297
- const row = (s, k) => `<div class="row r${k === 0 ? 'q' : k === em.length - 1 ? 's' : 'm'}">${[...s].map((ch, i) =>
298
- `<i class="${(k === 1 && em.length === 3 ? ch !== '|' && ch !== ' ' : diff(i)) ? 'df' : ''}">${ch === ' ' ? '&nbsp;' : esc(ch)}</i>`).join('')}</div>`;
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 === ' ' ? '&nbsp;' : 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
- const size = Math.round(H * (cjk ? 0.042 : 0.048)), edge = Math.max(2, Math.round(size * (cjk ? 0.07 : 0.09)));
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 at 18 characters
115
- if (cjk && chunk.length && len() + x.w.length > 18) flush();
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() >= 9) : chunk.length >= 7 || /[.!?;:]$/.test(x.w)) flush();
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:${Math.round(H * 0.06)}px;text-align:center;
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)},${Math.round(H * 0.06)},1
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