-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstories.html
More file actions
390 lines (361 loc) · 22.8 KB
/
Copy pathstories.html
File metadata and controls
390 lines (361 loc) · 22.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Captionizer — story boards</title>
<style>
/* ---------------------------------------------------------------- fonts
The same three faces the caption renderer uses, from public/fonts. */
@font-face { font-family: 'Geist Sans'; src: url('/fonts/Geist-Variable.woff2') format('woff2'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Outfit'; src: url('/fonts/Outfit-Variable.woff2') format('woff2'); font-weight: 100 900; font-display: swap; }
@font-face { font-family: 'Geist Mono'; src: url('/fonts/GeistMono-Variable.woff2') format('woff2'); font-weight: 100 900; font-display: swap; }
/* --------------------------------------------------------------- tokens
DaisyUI dark values, taken from the Figma's own variables. */
:root {
--primary: #2dd4bf;
--primary-content: #04201d;
--base-100: #1d232a;
--base-200: #191e24;
--base-content: #ecf9ff;
--muted: rgb(236 249 255 / 54%);
--hairline: rgb(236 249 255 / 10%);
/* Your file is the problem. Never a background, never a border. */
--warn: #f5b642;
/* Template data, not chrome. Banner's plate stays yellow. */
--plate: #ffd400;
--ink: #12100c;
--font-title: 'Outfit', system-ui, sans-serif;
--font-body: 'Geist Sans', system-ui, sans-serif;
--font-mono: 'Geist Mono', ui-monospace, Menlo, monospace;
--board-zoom: 0.62;
}
* { box-sizing: border-box; }
body { margin: 0; background: #0d0f13; color: var(--base-content); font-family: var(--font-body); }
/* ---------------------------------------------------------- story chrome
Not part of the product. Strip this when porting. */
.story { padding: 28px 24px 8px; }
.story h2 { font: 600 15px var(--font-body); margin: 0 0 2px; }
.story p { font: 400 13px var(--font-body); margin: 0 0 14px; color: var(--muted); max-width: 78ch; line-height: 1.6; }
.story ul { margin: 0 0 14px; padding-left: 18px; max-width: 78ch; }
.story li { font: 400 13px/1.7 var(--font-body); color: var(--muted); }
.story li b { color: var(--base-content); font-weight: 600; }
.board-wrap { zoom: var(--board-zoom); }
/* ----------------------------------------------------------- the product */
.page {
width: 1442px; height: 1064px; position: relative; overflow: hidden;
background: var(--base-200); border-radius: 14px;
display: flex; flex-direction: column; align-items: center;
}
.wordmark { margin-top: 39px; font: 800 104px/0.92 var(--font-title); letter-spacing: -0.045em; }
.wordmark span { color: var(--primary); }
/* The gutter hangs from the top of the card, not the foot of it. */
.row { margin-top: 60px; display: flex; align-items: flex-start; gap: 101px; }
/* -------------------------------------------------------------- the card */
.card {
position: relative; width: 335px; height: 595px; border-radius: 20px; overflow: hidden;
background: linear-gradient(180deg, #33404f 0%, #171d26 46%, #05070a 100%);
cursor: pointer;
}
.card__glow {
position: absolute; left: 32px; top: 60px; width: 250px; height: 210px; border-radius: 50%;
background: rgb(255 214 170 / 9%); filter: blur(90px);
}
/* The live caption. Position is the template's own verticalOffset, not a
layout choice: kinetic is 0.84, so the caption centre sits at 84%. */
.caption {
position: absolute; left: 6%; right: 6%; top: 84%; transform: translateY(-50%);
display: flex; gap: 7px; align-items: center; justify-content: center;
font: 700 21px/1.2 var(--font-title); color: rgb(255 255 255 / 78%);
text-shadow: 0 1px 6px rgb(0 0 0 / 55%);
}
.caption b { background: #fff; color: #0b0d10; border-radius: 99px; padding: 3px 7px; font-weight: 700; }
.card__readout { position: absolute; left: 28px; right: 28px; bottom: 96px; text-align: center; }
.status { font: 800 17px/1.3 var(--font-title); color: var(--primary); margin-bottom: 14px; }
.mono { margin-top: 12px; font: 400 14px var(--font-mono); color: rgb(236 249 255 / 66%); font-variant-numeric: tabular-nums; }
.note { margin-top: 10px; font: 400 13px/1.55 var(--font-body); color: var(--muted); }
/* ----------------------------------------------------------- the loader
While work is happening the video block is hidden outright and this
stands in its place, straight on the page background. No card, because
there is no artifact to frame yet. */
.loader {
width: 335px; height: 595px; display: flex; flex-direction: column;
align-items: center; justify-content: center; text-align: center;
}
.ring { position: relative; width: 240px; height: 240px; }
.ring svg { width: 240px; height: 240px; transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 14; stroke-linecap: round; }
.ring__track { stroke: rgb(236 249 255 / 12%); }
.ring__arc { stroke: var(--primary); transition: stroke-dashoffset 300ms ease-out; }
.ring__pct {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font: 800 46px var(--font-title); letter-spacing: -0.02em; font-variant-numeric: tabular-nums;
}
.loader__title { font: 800 28px/1.25 var(--font-title); margin: 34px 0 0; }
.loader__sub { font: 400 17px/1.5 var(--font-body); color: var(--muted); margin: 12px 0 0; max-width: 300px; }
.loader__fact { font: 400 14px var(--font-mono); color: rgb(236 249 255 / 44%); margin: 16px 0 0; font-variant-numeric: tabular-nums; }
/* Playback: a hairline heartbeat, nothing more. Click the frame to pause. */
.card__progress { position: absolute; left: 0; right: 0; bottom: 0; height: 3px; background: rgb(255 255 255 / 14%); }
.card__progress i { display: block; height: 100%; background: var(--primary); }
/* ------------------------------------------------------------ the gutter */
.gutter { width: 310px; display: flex; flex-direction: column; gap: 16px; }
/* No panel, no fill. The illustration sits straight on the page. */
.hero { position: relative; width: 310px; height: 310px; }
.hero__art { position: absolute; inset: 0; }
.hero__art svg { display: block; }
/* The tagline, as a caption on that little video. Three units, one plate. */
.hero__bar {
position: absolute; left: 14px; right: 14px; bottom: 56px; transform: rotate(-3deg);
background: #15181d; border-radius: 3px; padding: 9px 11px;
display: flex; gap: 7px; align-items: center; justify-content: center;
}
.u {
font: 900 17px/1.05 var(--font-body); letter-spacing: -0.02em; text-transform: uppercase; white-space: nowrap;
color: var(--base-content); padding: 0.22em 0.5em; border-radius: 0.1em; background: transparent;
animation-duration: 2667ms; animation-timing-function: linear; animation-iteration-count: infinite;
}
.u1 { animation-name: plate1; } .u2 { animation-name: plate2; } .u3 { animation-name: plate3; }
@keyframes plate1 {
0% { background: var(--plate); color: var(--ink); transform: scale(1); }
4.1% { background: var(--plate); color: var(--ink); transform: scale(1.06); }
8.2%, 33.32% { background: var(--plate); color: var(--ink); transform: scale(1); }
33.33%, 100% { background: transparent; color: var(--base-content); opacity: 0.55; }
}
@keyframes plate2 {
0%, 33.32% { background: transparent; color: var(--base-content); }
33.33% { background: var(--plate); color: var(--ink); transform: scale(1); }
37.4% { background: var(--plate); color: var(--ink); transform: scale(1.06); }
41.5%, 66.65% { background: var(--plate); color: var(--ink); transform: scale(1); }
66.66%, 100% { background: transparent; color: var(--base-content); opacity: 0.55; }
}
@keyframes plate3 {
0%, 66.65% { background: transparent; color: var(--base-content); }
66.66% { background: var(--plate); color: var(--ink); transform: scale(1); }
70.7% { background: var(--plate); color: var(--ink); transform: scale(1.06); }
74.8%, 100% { background: var(--plate); color: var(--ink); transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.u { animation: none; }
.u2 { background: var(--plate); color: var(--ink); }
}
/* ---------------------------------------------------------------- actions */
.cta {
display: flex; align-items: center; gap: 18px; width: 310px; padding: 16px 24px;
border: 0; border-radius: 12px; cursor: pointer; text-align: left; font-family: var(--font-body);
background: var(--base-100); color: var(--base-content);
}
.cta--primary { background: var(--primary); color: var(--primary-content); }
.cta__glyph { flex: none; font-size: 22px; line-height: 1; }
.cta__label { display: block; font: 600 19px/1.3 var(--font-body); }
.cta__sub { display: block; font: 400 14px/1.35 var(--font-body); opacity: 0.72; margin-top: 3px; }
.actions { display: flex; flex-direction: column; gap: 16px; }
.actions--dim { opacity: 0.38; pointer-events: none; }
/* ------------------------------------------------------------------ modal
Every refusal lands here. The page behind it is untouched, still holding
the demo that was already playing, so a bad file never costs you the page. */
.modal-scrim {
position: absolute; inset: 0; background: rgb(0 0 0 / 62%);
display: flex; align-items: center; justify-content: center;
}
.modal {
width: 468px; background: var(--base-100); border: 1px solid var(--hairline);
border-radius: 16px; padding: 32px; text-align: left;
}
.modal h3 { font: 800 24px/1.25 var(--font-title); margin: 0 0 10px; color: var(--warn); }
.modal p { font: 400 15px/1.6 var(--font-body); margin: 0 0 8px; color: var(--base-content); }
.modal .fact { font: 400 13px var(--font-mono); color: var(--muted); margin: 0 0 20px; }
.modal button {
font: 600 15px var(--font-body); background: var(--primary); color: var(--primary-content);
border: 0; border-radius: 10px; padding: 12px 20px; cursor: pointer;
}
/* ------------------------------------------------------------ style strip
1520 on a 1442 page, so it clips ~39px each side and reads as scrollable. */
.strip { position: absolute; left: -39px; bottom: 108px; display: flex; gap: 16px; }
.tile {
width: 176px; height: 99px; flex: none; border-radius: 10px; background: #0a0b0e;
border: 1px solid rgb(255 255 255 / 6%); opacity: 0.6;
display: flex; align-items: center; justify-content: center; overflow: hidden;
}
.tile[aria-pressed='true'] { opacity: 1; background: #0e1014; border-color: var(--primary); box-shadow: inset 0 0 0 1px var(--primary); }
.t-banner { background: var(--plate); color: var(--ink); font: 900 19px var(--font-body); letter-spacing: -0.02em; text-transform: uppercase; padding: 4px 8px; border-radius: 2px; }
.t-punch { font: 900 22px var(--font-body); text-transform: uppercase; color: #38ff9c; -webkit-text-stroke: 1.5px #000; paint-order: stroke fill; text-shadow: 0 3px 4px rgb(0 0 0 / 70%); }
.t-karaoke{ font: 800 20px var(--font-title); color: rgb(255 255 255 / 62%); text-shadow: 0 2px 6px rgb(0 0 0 / 60%); }
.t-karaoke i { color: #ff4d80; font-style: normal; }
.t-editorial { background: rgb(12 12 14 / 55%); padding: 6px 9px; border-radius: 2px; font: 300 16px var(--font-title); letter-spacing: 0.16em; color: #f5f0e8; }
.t-editorial i { background: rgb(198 58 44 / 90%); font-style: normal; padding: 1px 3px; }
.t-kinetic { background: #fff; color: #0b0d10; border-radius: 99px; padding: 5px 13px; font: 700 19px var(--font-title); }
.t-neon { font: 800 21px var(--font-title); letter-spacing: 0.03em; text-transform: uppercase; color: #5adcff; text-shadow: 0 0 12px rgb(90 220 255 / 95%), 0 0 26px rgb(90 220 255 / 55%); transform: rotate(-4deg); }
.t-tape { background: rgb(8 10 14 / 90%); border-radius: 3px; padding: 6px 10px; font: 600 15px var(--font-mono); letter-spacing: 0.04em; color: rgb(226 232 240 / 60%); }
.t-tape i { color: #a3e635; font-style: normal; }
.t-stamp { font: 900 28px var(--font-body); letter-spacing: -0.03em; text-transform: uppercase; color: #fff; text-shadow: 2px 4px 0 #ff2d6f; }
</style>
</head>
<body>
<div id="stories"></div>
<script type="module">
import lottie from 'lottie-web';
/* Eight looks, drawn from src/style/templates.ts. The tile shows the
template's own name in the template's own treatment, so the sample IS
the label. Selected reads by opacity, not by a badge. */
const TILES = [
['banner', '<span class="t-banner">Banner</span>'],
['punch', '<span class="t-punch">Punch</span>'],
['karaoke', '<span class="t-karaoke"><i>Kara</i>oke</span>'],
['editorial','<span class="t-editorial"><i>Edit</i>orial</span>'],
['kinetic', '<span class="t-kinetic">kinetic</span>'],
['neon', '<span class="t-neon">Neon</span>'],
['tape', '<span class="t-tape">tape<i>_</i></span>'],
['stamp', '<span class="t-stamp">Stamp</span>'],
];
const strip = (selected = 'kinetic') =>
`<div class="strip">${TILES.map(([id, inner]) =>
`<div class="tile" aria-pressed="${id === selected}">${inner}</div>`).join('')}</div>`;
const hero = () => `
<div class="hero">
<div class="hero__art"></div>
<div class="hero__bar">
<span class="u u1">Cool</span><span class="u u2">Captions</span><span class="u u3">In secs</span>
</div>
</div>`;
/* The two CTAs never change. A refusal is explained in a modal, not by
rearranging the page underneath it. */
const LIMITS = '9:16, mp4 or mov, up to 90s and 250 MB';
const actions = (dim) => `
<div class="actions${dim ? ' actions--dim' : ''}">
<button class="cta">
<span class="cta__glyph">↑</span>
<span><span class="cta__label">Upload video</span>
<span class="cta__sub">${LIMITS}</span></span>
</button>
<button class="cta cta--primary">
<span class="cta__glyph">↓</span>
<span><span class="cta__label">Download</span>
<span class="cta__sub">With captions</span></span>
</button>
</div>`;
/* Every refusal: same modal, same shape, only the words differ. */
const modal = (m) => `
<div class="modal-scrim">
<div class="modal" role="alertdialog" aria-modal="true">
<h3>${m.title}</h3>
<p>${m.body}</p>
<p class="fact">${m.fact}</p>
<button>Got it</button>
</div>
</div>`;
/* The ring stands in for the card while work is happening. r=104, so the
circumference is 653.45 and the arc is drawn by dash offset. */
const R = 104, CIRC = 2 * Math.PI * R;
const loader = (s) => `
<div class="loader">
<div class="ring">
<svg viewBox="0 0 240 240" aria-hidden="true">
<circle class="ring__track" cx="120" cy="120" r="${R}"></circle>
<circle class="ring__arc" cx="120" cy="120" r="${R}"
stroke-dasharray="${CIRC.toFixed(2)}"
stroke-dashoffset="${(CIRC * (1 - s.progress / 100)).toFixed(2)}"></circle>
</svg>
<div class="ring__pct">${s.progress}%</div>
</div>
<h3 class="loader__title">${s.status}</h3>
${s.note ? `<p class="loader__sub">${s.note}</p>` : ''}
${s.mono ? `<p class="loader__fact">${s.mono}</p>` : ''}
</div>`;
const card = (s) => `
<div class="card">
<div class="card__glow"></div>
${s.caption === false ? '' : '<div class="caption">every word <b>lands</b> here</div>'}
${s.readout ? `<div class="card__readout">
${s.status ? `<div class="status">${s.status}</div>` : ''}
${s.mono ? `<div class="mono">${s.mono}</div>` : ''}
${s.note ? `<div class="note">${s.note}</div>` : ''}
</div>` : ''}
${s.playhead != null ? `<div class="card__progress"><i style="width:${s.playhead}%"></i></div>` : ''}
</div>`;
/* One template. The gutter and the strip never move; only the left slot
swaps between the video and the ring. */
const board = (s) => `
<div class="page">
<div class="wordmark">captio<span>nizer</span></div>
<div class="row">
${s.loading ? loader(s) : card(s)}
<div class="gutter">${hero()}${actions(s.loading)}</div>
</div>
${strip(s.selected)}
${s.modal ? modal(s.modal) : ''}
</div>`;
const STATES = [
{
h: 'Landing — at rest',
d: 'The demo is already mounted, captioned and playing. No empty state. The caption sits at kinetic’s real verticalOffset of 0.84. Click the frame to pause; the hairline at the foot of the card is the only playback chrome. The illustration has no panel of its own — it sits straight on the page.',
s: { playhead: 32 },
},
{
h: 'Processing — 5 seconds in',
d: 'The video block is hidden while work is happening. There is no artifact to frame yet, so there is no card: the ring stands in its place, straight on the page background. One stage name at a time, forward only. No clock this early — a timer at five seconds is noise.',
s: { loading: true, status: 'Reading your video', progress: 9,
note: 'First run downloads the speech model, about 78 MB. After that it is on your machine.',
mono: '0:05 · 66 s of video' },
},
{
h: 'Processing — 90 seconds in',
d: 'Same ring, further round. Three things answer “is this dead” with facts rather than reassurance: an elapsed clock, progress in the user’s own units rather than ours, and the word count climbing. The gutter never moves, so the page does not jump when the video comes back.',
s: { loading: true, status: 'Transcribing', progress: 72,
note: 'It all runs in this tab. Nothing is uploaded, and closing the tab stops it.',
mono: '1:30 · 44 s of 66 s heard · 118 words' },
},
{
h: 'Every refusal — one modal, six sets of words',
d: 'A bad file never costs you the page. The demo keeps playing behind the scrim, the buttons stay exactly where they were, and dismissing the modal leaves you where you started. One component, one dismiss, no rearranging. Swap the three strings per case:',
list: [
'<b>Wrong codec</b> — “This clip is HEVC (H.265).” / Captionizer decodes H.264 only, because browser support for the rest is inconsistent and a half-decoded export is worse than a clear no. Re-export as H.264. / <code>hevc · storyteller.mov</code>',
'<b>Not portrait</b> — “This clip is 4:5, not 9:16.” / The templates are drawn for portrait, so captions would land outside the frame. Your video on screen has not changed. / <code>1080 × 1350 · 4:5</code>',
'<b>Too long</b> — “This clip runs 134.2 seconds.” / Everything renders on your machine and the wait grows with the clip, so the limit is 90 seconds. Trim it and try again. / <code>134.2s · limit 90s</code>',
'<b>Too big</b> — “This file is 412 MB.” / It all runs in this tab, and a file this size will not fit in memory. The limit is 250 MB. / <code>412 MB · limit 250 MB</code>',
'<b>No speech</b> — “No speech in this clip.” / The model listened to the whole thing and found nothing to transcribe. Check it has an audio track, and that somebody is talking on it. / <code>18.0s heard · 0 words</code>',
'<b>No WebCodecs</b> — “This browser cannot encode video.” / The preview and the captions work here. To save a file, open Captionizer in Chrome or Edge 94 and up. / <code>VideoEncoder unavailable</code>',
],
s: { playhead: 32, modal: {
title: 'This clip is HEVC (H.265).',
body: 'Captionizer decodes H.264 only, because browser support for the rest is inconsistent and a half-decoded export is worse than a clear no. Re-export it as H.264 and try again.',
fact: 'hevc · storyteller.mov · your video on screen has not changed' } },
},
{
h: 'Exporting',
d: 'Export reuses the loader exactly — same ring, same three lines, same slot — so it is zero new design and near-zero new code.',
s: { loading: true, status: 'Burning captions in', progress: 62,
note: 'Every frame is rendered here in this tab. It downloads the moment it is done.',
mono: '0:16 · 38 s of 66 s' },
},
{
h: 'Ready to save',
d: 'The video is back, so the card is back. The measured render speed is a graded number, so it is stated plainly in the conventional unit — the repo currently prints its reciprocal, 0.42 render-seconds per second of video, which nobody can grade at a glance.',
s: { status: 'Ready to save', readout: true, playhead: 100,
mono: 'storyteller-captioned.mp4 · 8.4 MB',
note: '66 s rendered in 26 s, 2.5× realtime' },
},
];
document.getElementById('stories').innerHTML = STATES.map((st) => `
<section class="story">
<h2>${st.h}</h2>
<p>${st.d}</p>
${st.list ? `<ul>${st.list.map((l) => `<li>${l}</li>`).join('')}</ul>` : ''}
<div class="board-wrap">${board(st.s)}</div>
</section>`).join('');
/* The cooking loop, under the caption bar. 30 fps, 80 frames, 2.667s —
the same cycle the three caption units are timed against. Held on the
first frame when the reader has asked for less motion. */
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
for (const mount of document.querySelectorAll('.hero__art')) {
const anim = lottie.loadAnimation({
container: mount,
renderer: 'svg',
loop: !still,
autoplay: !still,
path: '/cooking.json',
});
if (still) anim.addEventListener('DOMLoaded', () => anim.goToAndStop(0, true));
}
</script>
</body>
</html>