@jokerized/decksmith 0.1.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.
Files changed (78) hide show
  1. package/README.md +794 -0
  2. package/dist/cli.js +8906 -0
  3. package/dist/deck-runtime.js +55 -0
  4. package/dist/index.js +8590 -0
  5. package/dist/mcp.js +7809 -0
  6. package/dist/server/errors.js +73 -0
  7. package/dist/server/http.js +504 -0
  8. package/dist/server/main.js +91 -0
  9. package/dist/server/options.js +198 -0
  10. package/dist/server/pipeline.js +356 -0
  11. package/dist/server/queue.js +195 -0
  12. package/dist/server/ui.js +1614 -0
  13. package/dist/server/upload.js +232 -0
  14. package/dist/types/cli.d.ts +1 -0
  15. package/dist/types/deck/runtime.d.ts +59 -0
  16. package/dist/types/deck/subtitles.d.ts +101 -0
  17. package/dist/types/emit/archetypes/annotated-figure.d.ts +103 -0
  18. package/dist/types/emit/archetypes/bar-compare.d.ts +30 -0
  19. package/dist/types/emit/archetypes/callout.d.ts +7 -0
  20. package/dist/types/emit/archetypes/claim-figure.d.ts +9 -0
  21. package/dist/types/emit/archetypes/data-table.d.ts +21 -0
  22. package/dist/types/emit/archetypes/equation-walk.d.ts +2 -0
  23. package/dist/types/emit/archetypes/grid.d.ts +16 -0
  24. package/dist/types/emit/archetypes/index.d.ts +19 -0
  25. package/dist/types/emit/archetypes/line-chart.d.ts +22 -0
  26. package/dist/types/emit/archetypes/pipeline.d.ts +81 -0
  27. package/dist/types/emit/archetypes/split-compare.d.ts +2 -0
  28. package/dist/types/emit/archetypes/stack.d.ts +93 -0
  29. package/dist/types/emit/archetypes/title.d.ts +188 -0
  30. package/dist/types/emit/camera.d.ts +397 -0
  31. package/dist/types/emit/composition.d.ts +191 -0
  32. package/dist/types/emit/island.d.ts +19 -0
  33. package/dist/types/emit/kit.d.ts +256 -0
  34. package/dist/types/emit/svg.d.ts +177 -0
  35. package/dist/types/emit/theme.d.ts +65 -0
  36. package/dist/types/emit/themes/index.d.ts +40 -0
  37. package/dist/types/emit/themes/ink.d.ts +12 -0
  38. package/dist/types/emit/themes/mono.d.ts +20 -0
  39. package/dist/types/emit/themes/paper.d.ts +18 -0
  40. package/dist/types/index.d.ts +200 -0
  41. package/dist/types/mcp/main.d.ts +2 -0
  42. package/dist/types/mcp/prereqs.d.ts +22 -0
  43. package/dist/types/mcp/tools.d.ts +212 -0
  44. package/dist/types/narrate/narrate.d.ts +87 -0
  45. package/dist/types/narrate/tts.d.ts +134 -0
  46. package/dist/types/narrate/voices.d.ts +29 -0
  47. package/dist/types/pack/media.d.ts +61 -0
  48. package/dist/types/pack/pack.d.ts +16 -0
  49. package/dist/types/plan/codex.d.ts +24 -0
  50. package/dist/types/plan/duration.d.ts +394 -0
  51. package/dist/types/plan/prompt.d.ts +47 -0
  52. package/dist/types/plan/refs.d.ts +22 -0
  53. package/dist/types/plan/select.d.ts +116 -0
  54. package/dist/types/prefs.d.ts +43 -0
  55. package/dist/types/render/captions.d.ts +108 -0
  56. package/dist/types/render/ffmpeg.d.ts +129 -0
  57. package/dist/types/render/render.d.ts +123 -0
  58. package/dist/types/render/timing.d.ts +290 -0
  59. package/dist/types/server/errors.d.ts +11 -0
  60. package/dist/types/server/http.d.ts +57 -0
  61. package/dist/types/server/main.d.ts +1 -0
  62. package/dist/types/server/options.d.ts +90 -0
  63. package/dist/types/server/pipeline.d.ts +21 -0
  64. package/dist/types/server/queue.d.ts +105 -0
  65. package/dist/types/server/ui.d.ts +9 -0
  66. package/dist/types/server/upload.d.ts +107 -0
  67. package/dist/types/source/assets.d.ts +11 -0
  68. package/dist/types/source/fonts.d.ts +15 -0
  69. package/dist/types/source/markdown.d.ts +8 -0
  70. package/dist/types/types.d.ts +1992 -0
  71. package/dist/types/verify/budget.d.ts +41 -0
  72. package/dist/types/verify/check.d.ts +78 -0
  73. package/dist/types/verify/drift.d.ts +158 -0
  74. package/dist/types/verify/fidelity.d.ts +247 -0
  75. package/dist/types/verify/index.d.ts +207 -0
  76. package/dist/types/verify/overprint.d.ts +133 -0
  77. package/dist/types/verify/typefloor.d.ts +50 -0
  78. package/package.json +84 -0
@@ -0,0 +1,1614 @@
1
+ import {
2
+ FORMATS,
3
+ LEGIBLE_W,
4
+ MAX_ASPECT,
5
+ MIN_EDGE,
6
+ resolveTheme,
7
+ THEME_NAMES,
8
+ THEMES
9
+ } from "../index.js";
10
+ import { MAX_PIXELS, MAX_SIDE } from "./options.js";
11
+ const ink = resolveTheme("ink");
12
+ const paper = resolveTheme("paper");
13
+ const FALLBACK_FORMATS = Object.values(FORMATS).map((f) => ({
14
+ id: f.id,
15
+ width: f.width,
16
+ height: f.height,
17
+ navigable: f.navigable
18
+ }));
19
+ function parseHex(hex) {
20
+ const h = hex.replace("#", "");
21
+ const n = h.length === 3 ? h.replace(/./g, (c) => c + c) : h;
22
+ return [0, 2, 4].map((i) => Number.parseInt(n.slice(i, i + 2), 16));
23
+ }
24
+ function toHex(c) {
25
+ return `#${c.map(
26
+ (v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0")
27
+ ).join("")}`;
28
+ }
29
+ function mix(a, b, t) {
30
+ const [x, y] = [parseHex(a), parseHex(b)];
31
+ return toHex(x.map((v, i) => v * t + y[i] * (1 - t)));
32
+ }
33
+ function luminance(c) {
34
+ const f = c.map((v) => {
35
+ const x = v / 255;
36
+ return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
37
+ });
38
+ return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2];
39
+ }
40
+ function contrast(a, b) {
41
+ const [hi, lo] = [luminance(parseHex(a)), luminance(parseHex(b))].sort((p, q) => q - p);
42
+ return (hi + 0.05) / (lo + 0.05);
43
+ }
44
+ function readable(from, toward, on, target = 4.6) {
45
+ for (let t = 0; t <= 1.0001; t += 0.02) {
46
+ const c = mix(toward, from, t);
47
+ if (on.every((g) => contrast(c, g) >= target)) return c;
48
+ }
49
+ return toward;
50
+ }
51
+ function scheme(t) {
52
+ const base = {
53
+ bg: t.bg,
54
+ fg: t.fg,
55
+ muted: t.muted,
56
+ dim: t.dim,
57
+ rule: t.rule,
58
+ panel: t.panel,
59
+ accent: t.accent,
60
+ ok: t.tones.d,
61
+ warn: t.tones.b,
62
+ bad: t.tones.c
63
+ };
64
+ const tint = mix(base.accent, base.panel, 0.1);
65
+ return {
66
+ ...base,
67
+ tint,
68
+ sub: readable(base.dim, base.fg, [base.panel, tint]),
69
+ /**
70
+ * What goes ON the accent — the primary button's label.
71
+ *
72
+ * Measured: white on ink's #3d8bfd is 3.33:1, which fails AA on the one
73
+ * control the whole page exists to get pressed. The theme's own ground is
74
+ * 5.85:1 on it and 6.03:1 on paper's blue, so the label is simply the
75
+ * scheme's background colour. Darkening the accent instead would have
76
+ * worked too and was rejected: ink's numbers are frozen, and a button in a
77
+ * slightly-off brand blue is a worse answer than a legible label.
78
+ */
79
+ onAccent: base.bg,
80
+ /* The tile glyph carries the aspect ratio, which makes it a meaningful
81
+ graphic — WCAG 1.4.11's 3:1, not a decorative hairline. Walked up from
82
+ the panel until it clears, so neither scheme is guessed at. */
83
+ glyph: readable(base.panel, base.fg, [base.panel, tint], 3),
84
+ glyphLine: readable(base.panel, base.fg, [base.panel, tint], 3.2),
85
+ runRow: mix(base.accent, base.panel, 0.07),
86
+ failBg: mix(base.bad, base.panel, 0.07),
87
+ failRule: mix(base.bad, base.rule, 0.45),
88
+ zipRule: mix(base.accent, base.rule, 0.45),
89
+ /* The drop zone under a dragged file: stronger than a selection tint,
90
+ because "let go here" has to be unmistakable from across the screen. */
91
+ dropOver: mix(base.accent, base.panel, 0.14)
92
+ };
93
+ }
94
+ const DARK = scheme(ink);
95
+ const LIGHT = scheme(paper);
96
+ function vars(s) {
97
+ return Object.entries(s).map(([k, v]) => `--${k.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}:${v};`).join(" ");
98
+ }
99
+ const THEME_CHIPS = THEME_NAMES.map((name) => {
100
+ const t = THEMES[name];
101
+ if (!t) throw new Error(`theme "${name}" is listed but missing`);
102
+ return { name, bg: t.bg, fg: t.fg, accent: t.accent, tone: t.tones.a };
103
+ });
104
+ function uiPage() {
105
+ return `<!doctype html>
106
+ <html lang="en">
107
+ <head>
108
+ <meta charset="utf-8">
109
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
110
+ <meta name="color-scheme" content="dark light">
111
+ <title>DeckSmith</title>
112
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230b0d10'/%3E%3Crect x='6' y='9' width='6' height='14' rx='1.5' fill='%233d8bfd'/%3E%3Crect x='15' y='9' width='11' height='3' rx='1.5' fill='%23e8eaed'/%3E%3Crect x='15' y='15' width='11' height='2.5' rx='1.25' fill='%239aa7b5'/%3E%3Crect x='15' y='20' width='7' height='2.5' rx='1.25' fill='%239aa7b5'/%3E%3C/svg%3E">
113
+ <style>
114
+ /* ------------------------------------------------------------- tokens */
115
+ /* Dark is the ink theme, light is paper. Not two arbitrary palettes: the page
116
+ is a frame around a deck built from exactly these values. */
117
+ /* Every value below is a literal, computed in src/server/ui.ts from the ink and
118
+ paper themes. See the note on scheme() in ui.ts for why none of it is a
119
+ runtime color-mix(). */
120
+ :root{
121
+ color-scheme: dark light;
122
+ ${vars(DARK)}
123
+ --lift: rgba(255,255,255,.035);
124
+ --shadow: 0 1px 2px rgba(0,0,0,.45), 0 12px 34px rgba(0,0,0,.30);
125
+ }
126
+ @media (prefers-color-scheme: light){
127
+ :root{
128
+ ${vars(LIGHT)}
129
+ --lift: rgba(0,0,0,.025);
130
+ --shadow: 0 1px 2px rgba(20,17,13,.06), 0 12px 30px rgba(20,17,13,.07);
131
+ }
132
+ }
133
+
134
+ /* ------------------------------------------------------------- reset */
135
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
136
+ html{-webkit-text-size-adjust:100%}
137
+ body{
138
+ background:var(--bg); color:var(--fg);
139
+ font-family:"Inter",ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
140
+ font-size:15px; line-height:1.55;
141
+ -webkit-font-smoothing:antialiased;
142
+ font-feature-settings:"cv05" 1,"ss01" 1;
143
+ }
144
+ [hidden]{display:none !important}
145
+ button,input,select,textarea{font:inherit;color:inherit}
146
+ a{color:var(--accent);text-decoration-thickness:1px;text-underline-offset:3px}
147
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
148
+ ::selection{background:var(--tint)}
149
+
150
+ /* Motion is the exception, not the default: everything below is a state
151
+ change of 120-200ms and nothing loops except the one running-step pulse. */
152
+ @media (prefers-reduced-motion: reduce){
153
+ *,*::before,*::after{animation-duration:.001ms !important;animation-iteration-count:1 !important;transition-duration:.001ms !important;scroll-behavior:auto !important}
154
+ }
155
+
156
+ /* ------------------------------------------------------------- shell */
157
+ .wrap{max-width:940px;margin:0 auto;padding:52px 24px 96px}
158
+
159
+ /* A masthead, not a toolbar. At 34px the wordmark no longer sits on a shared
160
+ baseline with its tagline without one of them looking like an afterthought,
161
+ so the pair stacks: title, then the line that explains it. clamp() keeps it
162
+ from crowding a 390px phone, where 34px of a 6-space word is most of the
163
+ width. */
164
+ header.top{margin-bottom:40px}
165
+ .mark{display:flex;align-items:center;gap:14px;
166
+ font-size:clamp(26px,6.4vw,34px);font-weight:650;letter-spacing:-.032em;line-height:1.1}
167
+ .mark svg{display:block;flex:none;width:1em;height:1em}
168
+ .tag{color:var(--dim);font-size:15px;margin-top:10px;max-width:44ch}
169
+
170
+ /* Wider tracking and more air beneath. A micro-label's whole job is to be read
171
+ as a label and not as content, and at .09em it was still reading as small
172
+ text; the space below it is what makes the group under it feel like a group. */
173
+ h2.sec{font-size:11.5px;font-weight:650;letter-spacing:.13em;text-transform:uppercase;color:var(--dim);margin:0 0 14px}
174
+
175
+ /* ------------------------------------------------------------- drop */
176
+ .drop{
177
+ position:relative;display:block;width:100%;text-align:center;
178
+ border:1.5px dashed var(--rule); border-radius:20px; background:var(--panel);
179
+ padding:30px 24px; cursor:pointer;
180
+ transition:border-color .18s ease, background-color .18s ease, transform .18s ease;
181
+ }
182
+ .drop:hover{border-color:var(--dim)}
183
+ .drop.over{border-style:solid;border-color:var(--accent);background:var(--drop-over);transform:scale(1.004)}
184
+ .drop.over .dropicon{color:var(--accent)}
185
+ .dropicon{color:var(--dim);transition:color .18s ease}
186
+ .drop b{display:block;margin-top:14px;font-size:16px;font-weight:600;letter-spacing:-.01em}
187
+ .drop span{display:block;margin-top:5px;color:var(--muted);font-size:13.5px}
188
+ .drop em{color:var(--accent);font-style:normal;text-decoration:underline;text-underline-offset:3px}
189
+ .drop small{display:block;margin-top:16px;color:var(--sub);font-size:12px;letter-spacing:.01em}
190
+
191
+ /* ------------------------------------------------------------- file card */
192
+ .file{display:flex;gap:14px;align-items:flex-start;border:1px solid var(--rule);border-radius:16px;background:var(--panel);padding:15px 16px}
193
+ .fileicon{flex:none;width:38px;height:38px;border-radius:9px;background:var(--tint);color:var(--accent);display:grid;place-items:center;font-size:11px;font-weight:700;letter-spacing:.04em}
194
+ .filebody{min-width:0;flex:1}
195
+ .filename{font-weight:600;font-size:14.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:-.005em}
196
+ .filemeta{color:var(--muted);font-size:12.5px;margin-top:2px;font-variant-numeric:tabular-nums}
197
+ .zip{display:flex;flex-wrap:wrap;gap:5px;margin-top:9px;list-style:none}
198
+ .zip li{font-size:11.5px;color:var(--muted);background:var(--lift);border:1px solid var(--rule);border-radius:6px;padding:2px 7px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
199
+ .zip li.main{color:var(--fg);border-color:var(--zip-rule)}
200
+ .zip li.rest{border-style:dashed}
201
+ .x{flex:none;background:none;border:1px solid transparent;border-radius:8px;color:var(--dim);width:30px;height:30px;cursor:pointer;line-height:1;font-size:17px;transition:color .15s,background-color .15s}
202
+ .x:hover{color:var(--fg);background:var(--lift)}
203
+
204
+ /* ------------------------------------------------------------- fields */
205
+ .opts{margin-top:34px;display:grid;gap:28px}
206
+ .row{display:grid;gap:26px;grid-template-columns:repeat(auto-fit,minmax(210px,1fr))}
207
+
208
+ .tiles{display:grid;gap:9px;grid-template-columns:repeat(auto-fit,minmax(94px,1fr))}
209
+ .tile{position:relative;display:flex;flex-direction:column;align-items:center;gap:3px;padding:16px 8px 13px;border:1px solid var(--rule);border-radius:16px;background:var(--panel);cursor:pointer;transition:border-color .15s,background-color .15s}
210
+ .tile input{position:absolute;opacity:0;width:0;height:0}
211
+ .tile:hover{border-color:var(--dim)}
212
+ .tile:has(input:checked){border-color:var(--accent);background:var(--tint)}
213
+ .tile:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:2px}
214
+ .ratio{height:34px;display:grid;place-items:center;margin-bottom:5px}
215
+ /* The glyph is the only thing on the tile that carries the SHAPE, so it has to
216
+ survive a light ground: --rule is a hairline colour and disappears as a fill. */
217
+ .ratio i{display:block;border-radius:3px;background:var(--glyph);transition:background-color .15s,border-color .15s}
218
+ .ratio i.any{background:none;border:1.5px dashed var(--glyph-line)}
219
+ .tile:has(input:checked) .ratio i{background:var(--accent)}
220
+ .tile:has(input:checked) .ratio i.any{background:none;border-color:var(--accent)}
221
+ .tname{font-size:13px;font-weight:600;letter-spacing:-.005em}
222
+ .tdim{font-size:10.5px;color:var(--sub);font-variant-numeric:tabular-nums;letter-spacing:.02em;white-space:nowrap}
223
+
224
+ /* Deck and Video are both 1920x1080; nothing on the tiles can show the
225
+ difference, so one line under them says what the chosen one actually is. */
226
+ .fmtnote{margin-top:10px;color:var(--dim);font-size:12.5px;font-variant-numeric:tabular-nums}
227
+ .custom{display:flex;align-items:center;gap:8px;margin-top:11px}
228
+ .custom input{width:100%;min-width:0;background:var(--panel);border:1px solid var(--rule);border-radius:9px;padding:9px 11px;font-variant-numeric:tabular-nums}
229
+ .custom input:focus{border-color:var(--accent);outline:none}
230
+ .custom span{color:var(--dim);flex:none}
231
+
232
+ .chips{display:flex;flex-wrap:wrap;gap:8px}
233
+ .chip{position:relative;display:flex;align-items:center;gap:8px;border:1px solid var(--rule);border-radius:999px;padding:6px 13px 6px 7px;background:var(--panel);cursor:pointer;font-size:13.5px;transition:border-color .15s,background-color .15s}
234
+ .chip input{position:absolute;opacity:0;width:0;height:0}
235
+ .chip:hover{border-color:var(--dim)}
236
+ .chip:has(input:checked){border-color:var(--accent);background:var(--tint)}
237
+ .chip:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:2px}
238
+ .sw{flex:none;width:22px;height:22px;border-radius:999px;border:1px solid var(--rule);display:flex;overflow:hidden}
239
+ .sw i{flex:1}
240
+
241
+ .seg{display:inline-flex;border:1px solid var(--rule);border-radius:12px;background:var(--panel);padding:3px;gap:3px;flex-wrap:wrap}
242
+ .seg label{position:relative;padding:5px 12px;border-radius:7px;font-size:13px;cursor:pointer;color:var(--muted);transition:background-color .15s,color .15s}
243
+ .seg input{position:absolute;opacity:0;width:0;height:0}
244
+ .seg label:hover{color:var(--fg)}
245
+ .seg label:has(input:checked){background:var(--tint);color:var(--fg);font-weight:500}
246
+ .seg label:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:1px}
247
+
248
+ .slider{display:flex;align-items:center;gap:13px}
249
+ .slider input[type=range]{flex:1;min-width:0;accent-color:var(--accent);height:22px}
250
+ .num{font-variant-numeric:tabular-nums;font-size:13.5px;color:var(--muted);min-width:74px;text-align:right}
251
+
252
+ select,.txt{width:100%;background:var(--panel);border:1px solid var(--rule);border-radius:12px;padding:11px 13px;font-size:14px}
253
+ select:focus,.txt:focus{border-color:var(--accent);outline:none}
254
+
255
+ .switches{display:grid;gap:9px;align-content:start}
256
+ /* 5 + the grid's 9 = the 14px .sec leaves everywhere else. The gap
257
+ applies between every grid item, so the heading's own margin has to be
258
+ the difference or this column's label sits further off than the next. */
259
+ .switches h2.sec{margin-bottom:5px}
260
+ .sws{display:flex;align-items:center;justify-content:space-between;gap:14px;border:1px solid var(--rule);border-radius:14px;background:var(--panel);padding:13px 15px;cursor:pointer}
261
+ .sws:hover{border-color:var(--dim)}
262
+ .sws:has(input:focus-visible){outline:2px solid var(--accent);outline-offset:2px}
263
+ .sws .lbl{font-size:13.5px}
264
+ .sws .sub{display:block;color:var(--sub);font-size:11.5px;line-height:1.35}
265
+ .track{position:relative;flex:none;width:38px;height:22px;border-radius:999px;background:var(--rule);transition:background-color .16s ease}
266
+ .track input{position:absolute;opacity:0;width:0;height:0}
267
+ .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:999px;background:var(--bg);transition:transform .16s ease}
268
+ .track:has(input:checked){background:var(--accent)}
269
+ .track:has(input:checked)::after{transform:translateX(16px)}
270
+
271
+ details.more{border-top:1px solid var(--rule);padding-top:20px}
272
+ details.more summary{cursor:pointer;list-style:none;color:var(--muted);font-size:13px;display:inline-flex;align-items:center;gap:7px;border-radius:6px}
273
+ details.more summary::-webkit-details-marker{display:none}
274
+ details.more summary::before{content:"";width:6px;height:6px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .16s ease;margin-left:2px}
275
+ details.more[open] summary::before{transform:rotate(45deg)}
276
+ details.more summary:hover{color:var(--fg)}
277
+ details.more .row{margin-top:20px}
278
+
279
+ /* ------------------------------------------------------------- action */
280
+ .action{margin-top:34px;display:flex;flex-direction:column;gap:9px;align-items:stretch}
281
+ .go{
282
+ display:flex;align-items:center;justify-content:center;gap:10px;
283
+ background:var(--accent); color:var(--on-accent); border:none; border-radius:14px;
284
+ padding:15px 22px; font-size:15.5px; font-weight:600; letter-spacing:-.01em;
285
+ cursor:pointer; box-shadow:var(--shadow);
286
+ transition:filter .15s ease, transform .12s ease, opacity .15s ease;
287
+ }
288
+ .go:hover:not(:disabled){filter:brightness(1.08)}
289
+ .go:active:not(:disabled){transform:translateY(1px)}
290
+ /* A greyed-out ACCENT block still reads as the button you should press. A
291
+ disabled primary must look like furniture, not like a dimmed invitation. */
292
+ .go:disabled{background:var(--panel);color:var(--dim);border:1px solid var(--rule);cursor:not-allowed;box-shadow:none}
293
+ .hint{text-align:center;color:var(--dim);font-size:12.5px}
294
+
295
+ .ghost{background:var(--panel);border:1px solid var(--rule);border-radius:12px;padding:10px 16px;font-size:13.5px;cursor:pointer;transition:border-color .15s,background-color .15s}
296
+ .ghost:hover{border-color:var(--dim);background:var(--lift)}
297
+
298
+ /* ------------------------------------------------------------- progress */
299
+ .stage{display:flex;align-items:baseline;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:22px}
300
+ .stage h1{font-size:26px;font-weight:600;letter-spacing:-.025em}
301
+ .stage .clock{font-variant-numeric:tabular-nums;font-size:26px;color:var(--muted);letter-spacing:-.02em}
302
+ .stage p{color:var(--muted);font-size:13.5px;flex-basis:100%;margin-top:-2px}
303
+
304
+ .steps{list-style:none;border:1px solid var(--rule);border-radius:14px;background:var(--panel);overflow:hidden}
305
+ .steps li{display:flex;align-items:center;gap:13px;padding:13px 16px;border-top:1px solid var(--rule)}
306
+ .steps li:first-child{border-top:none}
307
+ .steps li .nm{font-size:14px;font-weight:500;text-transform:capitalize}
308
+ .steps li .dt{color:var(--sub);font-size:12.5px;margin-top:1px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
309
+ .steps li .grow{min-width:0;flex:1}
310
+ .steps li .ms{font-variant-numeric:tabular-nums;font-size:12.5px;color:var(--sub);flex:none}
311
+ .steps li[data-s=pending]{color:var(--sub)}
312
+ .steps li[data-s=running]{background:var(--run-row)}
313
+ .steps li[data-s=running] .nm{color:var(--fg)}
314
+ .steps li[data-s=skipped]{opacity:.45}
315
+
316
+ .bead{flex:none;width:18px;height:18px;border-radius:999px;border:1.5px solid var(--rule);display:grid;place-items:center;position:relative}
317
+ li[data-s=done] .bead{border-color:var(--ok);background:var(--ok)}
318
+ li[data-s=done] .bead::after{content:"";width:8px;height:4.5px;border-left:1.6px solid var(--bg);border-bottom:1.6px solid var(--bg);transform:rotate(-45deg) translate(1px,-1px)}
319
+ li[data-s=error] .bead{border-color:var(--bad);background:var(--bad)}
320
+ li[data-s=error] .bead::after{content:"\\00d7";color:var(--bg);font-size:13px;line-height:1;font-weight:700}
321
+ li[data-s=running] .bead{border-color:var(--accent)}
322
+ li[data-s=running] .bead::after{content:"";width:7px;height:7px;border-radius:999px;background:var(--accent)}
323
+ @media (prefers-reduced-motion: no-preference){
324
+ li[data-s=running] .bead::after{animation:beat 1.25s ease-in-out infinite}
325
+ }
326
+ @keyframes beat{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.62)}}
327
+
328
+ .log{margin-top:18px}
329
+ .log summary{cursor:pointer;color:var(--muted);font-size:12.5px;list-style:none;border-radius:6px}
330
+ .log summary::-webkit-details-marker{display:none}
331
+ .log pre{
332
+ margin-top:9px;max-height:190px;overflow:auto;
333
+ border:1px solid var(--rule);border-radius:11px;background:var(--panel);
334
+ padding:12px 14px; color:var(--muted);
335
+ font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,monospace;
336
+ font-size:11.5px;line-height:1.7;white-space:pre-wrap;word-break:break-word;
337
+ }
338
+ .log pre:empty::after{content:"waiting for output\u2026";color:var(--sub)}
339
+
340
+ /* ------------------------------------------------------------- result */
341
+ .tabs{display:inline-flex;gap:3px;border:1px solid var(--rule);border-radius:10px;background:var(--panel);padding:3px;margin-bottom:14px}
342
+ .tabs button{background:none;border:none;border-radius:7px;padding:6px 14px;font-size:13px;color:var(--muted);cursor:pointer;transition:background-color .15s,color .15s}
343
+ .tabs button[aria-selected=true]{background:var(--tint);color:var(--fg);font-weight:500}
344
+
345
+ .viewer{border:1px solid var(--rule);border-radius:14px;background:var(--panel);overflow:hidden;container-type:inline-size}
346
+ .chrome{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 8px 8px 14px;border-bottom:1px solid var(--rule)}
347
+ .chrome .who{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
348
+ .chrome .btns{display:flex;gap:6px;flex:none}
349
+ .iconbtn{display:inline-flex;align-items:center;gap:6px;background:none;border:1px solid transparent;border-radius:8px;color:var(--muted);padding:5px 9px;font-size:12.5px;cursor:pointer;transition:color .15s,background-color .15s,border-color .15s}
350
+ .iconbtn:hover{color:var(--fg);background:var(--lift);border-color:var(--rule)}
351
+ .iconbtn svg{display:block}
352
+ /* HEIGHT, NOT ASPECT-RATIO, BECAUSE THE DECK BRINGS ITS OWN CHROME.
353
+ deck.html draws a subtitle line, a slide counter and a progress bar BELOW the
354
+ composition \u2014 a fixed 77px at every frame width, measured. A box sized to the
355
+ composition's ratio alone therefore hands the player a stage that is too short
356
+ and it pillarboxes to compensate: the 1920x1080 deck came out 714px wide in an
357
+ 850px frame (16% of the width black) and 219px wide in a 356px frame (39%).
358
+ So the box is the composition's ratio PLUS whatever the deck's own chrome
359
+ turns out to be, measured from the loaded iframe rather than hardcoded, and
360
+ 100cqw is the frame's inline size \u2014 which is why .viewer is a container.
361
+ --chrome is 0 for the video, which has no chrome of its own. */
362
+ .canvas{background:${ink.bg};display:grid;place-items:center;width:100%;height:calc(100cqw / var(--arn, 1.7778) + var(--chrome, 0px))}
363
+ .canvas > *{width:100%;height:100%;border:0;display:block;background:${ink.bg}}
364
+ .viewer:fullscreen{border:none;border-radius:0;background:#000;display:flex;flex-direction:column}
365
+ .viewer:fullscreen .canvas{flex:1;min-height:0;background:#000;aspect-ratio:auto !important;height:auto !important}
366
+ .viewer:fullscreen .canvas > *{width:auto;height:auto;max-width:100%;max-height:100%;aspect-ratio:var(--ar)}
367
+
368
+ .facts{display:flex;flex-wrap:wrap;gap:8px 22px;margin-top:16px;color:var(--muted);font-size:13px;font-variant-numeric:tabular-nums}
369
+ .facts b{color:var(--fg);font-weight:600}
370
+ .gets{display:flex;flex-wrap:wrap;gap:8px;margin-top:18px}
371
+ .gets a,.gets button{display:inline-flex;align-items:center;gap:8px;text-decoration:none;color:var(--fg);background:var(--panel);border:1px solid var(--rule);border-radius:10px;padding:9px 14px;font-size:13.5px;cursor:pointer;transition:border-color .15s,background-color .15s}
372
+ .gets a:hover,.gets button:hover{border-color:var(--dim);background:var(--lift)}
373
+ .gets .k{color:var(--sub);font-size:11.5px;font-variant-numeric:tabular-nums}
374
+
375
+ .warns{list-style:none;margin-top:20px;display:grid;gap:7px}
376
+ .warns li{display:flex;gap:10px;align-items:flex-start;font-size:13px;color:var(--muted);border-left:2px solid var(--warn);padding:2px 0 2px 12px}
377
+
378
+ /* ------------------------------------------------------------- error */
379
+ .fail{border:1px solid var(--fail-rule);border-radius:14px;background:var(--fail-bg);padding:22px}
380
+ .fail .who{display:flex;align-items:center;gap:9px;color:var(--bad);font-size:12px;font-weight:600;letter-spacing:.09em;text-transform:uppercase}
381
+ .fail h1{font-size:19px;font-weight:600;letter-spacing:-.015em;margin-top:11px;overflow-wrap:break-word}
382
+ .fail .hint{text-align:left;color:var(--muted);font-size:14px;margin-top:10px;line-height:1.6;overflow-wrap:break-word}
383
+ .fail .acts{display:flex;gap:8px;margin-top:20px;flex-wrap:wrap}
384
+
385
+ .toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);background:var(--fg);color:var(--bg);border-radius:999px;padding:9px 18px;font-size:13px;font-weight:500;box-shadow:var(--shadow);opacity:0;pointer-events:none;transition:opacity .18s ease}
386
+ .toast.on{opacity:1}
387
+
388
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
389
+
390
+ /* ------------------------------------------------------------- narrow */
391
+ @media (max-width:560px){
392
+ .wrap{padding:22px 16px 72px}
393
+ header.top{margin-bottom:24px;gap:6px}
394
+ .tag{flex-basis:100%}
395
+ .drop{padding:38px 18px;border-radius:14px}
396
+ .opts{gap:22px}
397
+ .row{gap:20px}
398
+ .stage h1{font-size:22px}
399
+ .stage .clock{font-size:22px}
400
+ .steps li{padding:12px 14px}
401
+ .seg{width:100%}
402
+ .seg label{flex:1;text-align:center}
403
+ .go{padding:16px 20px}
404
+ .gets a,.gets button{flex:1;justify-content:center;padding:12px 14px}
405
+ /* A finger is not a mouse pointer: the viewer's own controls get taller so
406
+ nothing on this page is under the 40px a thumb needs. */
407
+ .tabs button{padding:10px 18px}
408
+ .iconbtn{padding:9px 11px}
409
+ .chip{padding:9px 15px 9px 8px}
410
+ .x{width:38px;height:38px}
411
+ .seg label{padding:9px 12px}
412
+ }
413
+ </style>
414
+ </head>
415
+ <body>
416
+ <div class="wrap">
417
+
418
+ <header class="top">
419
+ <div class="mark">
420
+ <svg viewBox="0 0 32 32" aria-hidden="true">
421
+ <rect x="1" y="1" width="30" height="30" rx="7" fill="none" stroke="currentColor" stroke-width="1.6" opacity=".28"/>
422
+ <rect x="6" y="9" width="6" height="14" rx="1.5" fill="var(--accent)"/>
423
+ <rect x="15" y="9" width="11" height="3" rx="1.5" fill="currentColor"/>
424
+ <rect x="15" y="15" width="11" height="2.5" rx="1.25" fill="currentColor" opacity=".55"/>
425
+ <rect x="15" y="20" width="7" height="2.5" rx="1.25" fill="currentColor" opacity=".55"/>
426
+ </svg>
427
+ DeckSmith
428
+ </div>
429
+ <p class="tag">A document in. An animated deck out.</p>
430
+ </header>
431
+
432
+ <main>
433
+ <!-- ================================================== compose -->
434
+ <section id="v-compose" aria-label="Build a deck">
435
+ <!--
436
+ A real form, not a div with a button on it. Enter submits from any field,
437
+ the browser gives the controls their native grouping, and the FormData built
438
+ from it IS the option set \u2014 so the fields a person sees and the fields the
439
+ server receives cannot drift apart. The submit handler cancels the native
440
+ post: the upload is a fetch, because the page has to stay on screen for the
441
+ two to four minutes that follow.
442
+ -->
443
+ <form id="compose" method="post" action="/api/jobs" enctype="multipart/form-data">
444
+ <div id="pick">
445
+ <div class="drop" id="drop" role="button" tabindex="0"
446
+ aria-label="Choose a document, or drop one here">
447
+ <svg class="dropicon" width="34" height="34" viewBox="0 0 24 24" fill="none" aria-hidden="true">
448
+ <path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
449
+ <path d="M4 15v3a2 2 0 002 2h12a2 2 0 002-2v-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
450
+ </svg>
451
+ <b>Drop a document</b>
452
+ <span>or <em>browse your files</em></span>
453
+ <small>.md &middot; .markdown &middot; .txt &middot; .zip with figures</small>
454
+ </div>
455
+ <!-- The drop zone is the labelled control that opens this picker, so the
456
+ input itself is off the tab order: two stops for one action reads as a
457
+ dead key press to anyone driving by keyboard. -->
458
+ <input type="file" id="fileinput" class="sr" tabindex="-1" aria-label="Choose a document"
459
+ accept=".md,.markdown,.txt,.zip,text/markdown,text/plain,application/zip">
460
+ </div>
461
+
462
+ <div id="chosen" hidden>
463
+ <div class="file">
464
+ <div class="fileicon" id="f-kind">MD</div>
465
+ <div class="filebody">
466
+ <div class="filename" id="f-name">\u2014</div>
467
+ <div class="filemeta" id="f-meta">\u2014</div>
468
+ <ul class="zip" id="f-zip" hidden></ul>
469
+ </div>
470
+ <!-- type=button is load-bearing. A bare <button> inside a form is a
471
+ SUBMIT button, and this one sits above the real one in tree order \u2014
472
+ so Enter in any field would fire this instead, silently clearing the
473
+ file and cancelling the build the user just asked for. -->
474
+ <button class="x" id="f-clear" type="button" aria-label="Remove this file" title="Remove">&times;</button>
475
+ </div>
476
+ </div>
477
+
478
+ <div class="opts">
479
+ <div>
480
+ <h2 class="sec" id="lbl-format">Format</h2>
481
+ <div class="tiles" id="formats" role="radiogroup" aria-labelledby="lbl-format"></div>
482
+ <p class="fmtnote" id="fmtnote"></p>
483
+ <div class="custom" id="customsize" hidden>
484
+ <input type="number" id="cw" name="width" min="${MIN_EDGE}" max="${MAX_SIDE}" step="2" value="1920" aria-label="Custom width in pixels" inputmode="numeric">
485
+ <span aria-hidden="true">&times;</span>
486
+ <input type="number" id="ch" name="height" min="${MIN_EDGE}" max="${MAX_SIDE}" step="2" value="1080" aria-label="Custom height in pixels" inputmode="numeric">
487
+ <span>px</span>
488
+ </div>
489
+ <p class="fmtnote" id="sizenote" hidden></p>
490
+ </div>
491
+
492
+ <div class="row">
493
+ <div>
494
+ <h2 class="sec" id="lbl-theme">Theme</h2>
495
+ <div class="chips" id="themes" role="radiogroup" aria-labelledby="lbl-theme"></div>
496
+ </div>
497
+ <div>
498
+ <h2 class="sec"><label for="slides">Slides</label></h2>
499
+ <div class="slider">
500
+ <input type="range" id="slides" name="slides" min="3" max="40" value="12" aria-describedby="slidesout">
501
+ <span class="num" id="slidesout">12 slides</span>
502
+ </div>
503
+ </div>
504
+ </div>
505
+
506
+ <!-- The three length knobs, and nothing else about length: duration and slide
507
+ count here, narration density beside them. How many words a sentence gets
508
+ and how fast the animation runs are DERIVED from these \u2014 see durationPlan
509
+ in src/plan/duration.ts \u2014 because a 60-second target over the demo's 37
510
+ narrated stops is four words a stop, not narration. Exposing the derived
511
+ numbers as well would only be a way to disagree with the target. -->
512
+ <div class="row">
513
+ <div>
514
+ <h2 class="sec"><label for="duration">Duration</label></h2>
515
+ <input class="txt" type="number" id="duration" name="duration" min="10" max="1800" step="5"
516
+ placeholder="Automatic &mdash; as long as it needs" aria-describedby="durhint" inputmode="numeric">
517
+ <span class="sub" id="durhint">Seconds. Shortens what is said, then speeds up playback to close the gap.</span>
518
+ </div>
519
+ <div>
520
+ <h2 class="sec" id="lbl-nd">Narration density</h2>
521
+ <div class="seg" id="narrationDensity" role="radiogroup" aria-labelledby="lbl-nd"></div>
522
+ <span class="sub" id="ndhint">high speaks at every reveal &middot; low speaks once a slide</span>
523
+ </div>
524
+ </div>
525
+
526
+ <div class="row">
527
+ <div>
528
+ <h2 class="sec"><label for="lang">Language</label></h2>
529
+ <select id="lang" name="lang">
530
+ <option value="en">English</option>
531
+ <option value="ko">\uD55C\uAD6D\uC5B4 &mdash; Korean</option>
532
+ <option value="ja">\u65E5\u672C\u8A9E &mdash; Japanese</option>
533
+ <option value="zh">\u4E2D\u6587 &mdash; Chinese</option>
534
+ <option value="__other">Other&hellip;</option>
535
+ </select>
536
+ <input class="txt txt-other" id="langother" hidden placeholder="BCP-47 tag, e.g. de-DE" aria-label="Language tag" style="margin-top:8px">
537
+ </div>
538
+ <div class="switches">
539
+ <!-- Labelled like every other group, which it needs to be anyway: the
540
+ column beside it opens with a .sec heading, so without one here
541
+ the select started a label's height BELOW the first switch and the
542
+ row read as misaligned rather than as two columns. -->
543
+ <h2 class="sec">Include</h2>
544
+ <label class="sws">
545
+ <span><span class="lbl">Narration</span><span class="sub">Synthesised voice and subtitles &middot; ~25s</span></span>
546
+ <span class="track"><input type="checkbox" id="narrate" name="narrate" checked></span>
547
+ </label>
548
+ <label class="sws">
549
+ <span><span class="lbl">Video</span><span class="sub">Render an mp4 as well &middot; adds ~2 min</span></span>
550
+ <span class="track"><input type="checkbox" id="video" name="video"></span>
551
+ </label>
552
+ </div>
553
+ </div>
554
+
555
+ <details class="more">
556
+ <summary>More options</summary>
557
+ <div class="row">
558
+ <div>
559
+ <h2 class="sec" id="lbl-tone">Tone</h2>
560
+ <div class="seg" id="tone" role="radiogroup" aria-labelledby="lbl-tone"></div>
561
+ </div>
562
+ <div>
563
+ <h2 class="sec" id="lbl-density">Density</h2>
564
+ <div class="seg" id="density" role="radiogroup" aria-labelledby="lbl-density"></div>
565
+ </div>
566
+ </div>
567
+ <div class="row">
568
+ <div>
569
+ <h2 class="sec"><label for="speed">Animation speed</label></h2>
570
+ <div class="slider">
571
+ <input type="range" id="speed" name="speed" min="0.25" max="3" step="0.25" value="1" aria-describedby="speedout">
572
+ <span class="num" id="speedout">1&times;</span>
573
+ </div>
574
+ <!-- Demoted rather than deleted: it is still the knob for a deck with no
575
+ target, and it is IGNORED when there is one. Said here, and warned
576
+ about on the job, rather than silently losing. -->
577
+ <span class="sub">Ignored when a duration is set &mdash; the target sets the pace.</span>
578
+ </div>
579
+ <div>
580
+ <h2 class="sec"><label for="voice">Voice</label></h2>
581
+ <input class="txt" id="voice" name="voice" placeholder="Automatic for the language and tone">
582
+ </div>
583
+ </div>
584
+ </details>
585
+ </div>
586
+
587
+ <div class="action">
588
+ <button class="go" id="go" type="submit" disabled>
589
+ <svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
590
+ <path d="M5 12h14m0 0l-5.5-5.5M19 12l-5.5 5.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
591
+ </svg>
592
+ <span id="golabel">Generate deck</span>
593
+ </button>
594
+ <p class="hint" id="gohint">Choose a document to begin.</p>
595
+ </div>
596
+ </form>
597
+ </section>
598
+
599
+ <!-- ================================================== running -->
600
+ <section id="v-run" hidden aria-label="Building">
601
+ <div class="stage">
602
+ <h1 id="r-stage">Queued</h1>
603
+ <div class="clock" id="r-clock">0:00</div>
604
+ <p id="r-note">This usually takes two to four minutes. You can leave this tab open in the background.</p>
605
+ </div>
606
+ <ol class="steps" id="r-steps"></ol>
607
+ <details class="log" open>
608
+ <summary>Output</summary>
609
+ <pre id="r-log" aria-live="polite" aria-atomic="false"></pre>
610
+ </details>
611
+ <p class="sr" id="r-sr" role="status" aria-live="polite"></p>
612
+ </section>
613
+
614
+ <!-- ================================================== done -->
615
+ <section id="v-done" hidden aria-label="Result">
616
+ <div class="stage">
617
+ <h1 id="d-title">Your deck is ready</h1>
618
+ <div class="clock" id="d-clock">0:00</div>
619
+ </div>
620
+
621
+ <div class="tabs" id="d-tabs" role="tablist" hidden>
622
+ <button role="tab" id="tab-deck" aria-selected="true" aria-controls="d-viewer">Deck</button>
623
+ <button role="tab" id="tab-video" aria-selected="false" aria-controls="d-viewer">Video</button>
624
+ </div>
625
+
626
+ <div class="viewer" id="d-viewer">
627
+ <div class="chrome">
628
+ <div class="who" id="d-who">deck.html</div>
629
+ <div class="btns">
630
+ <button class="iconbtn" id="d-full">
631
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M4 9V5.5A1.5 1.5 0 015.5 4H9M15 4h3.5A1.5 1.5 0 0120 5.5V9M20 15v3.5a1.5 1.5 0 01-1.5 1.5H15M9 20H5.5A1.5 1.5 0 014 18.5V15" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>
632
+ Full screen
633
+ </button>
634
+ <button class="iconbtn" id="d-open">
635
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M14 4h6v6M20 4l-9 9M18 14v4.5A1.5 1.5 0 0116.5 20h-11A1.5 1.5 0 014 18.5v-11A1.5 1.5 0 015.5 6H10" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
636
+ Open
637
+ </button>
638
+ </div>
639
+ </div>
640
+ <div class="canvas" id="d-canvas"></div>
641
+ </div>
642
+
643
+ <div class="facts" id="d-facts"></div>
644
+ <ul class="warns" id="d-warns" hidden></ul>
645
+ <div class="gets" id="d-gets"></div>
646
+ <div class="action" style="margin-top:26px">
647
+ <button class="ghost" id="d-again" style="align-self:flex-start">Make another deck</button>
648
+ </div>
649
+ </section>
650
+
651
+ <!-- ================================================== error -->
652
+ <section id="v-error" hidden aria-label="Failed">
653
+ <div class="fail">
654
+ <div class="who">
655
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M12 8v5M12 16.5v.5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/></svg>
656
+ <span id="e-where">Failed</span>
657
+ </div>
658
+ <h1 id="e-msg">Something went wrong.</h1>
659
+ <p class="hint" id="e-hint"></p>
660
+ <div class="acts">
661
+ <button class="ghost" id="e-retry">Try again</button>
662
+ <button class="ghost" id="e-reset">Start over</button>
663
+ </div>
664
+ </div>
665
+ <details class="log" id="e-logwrap" hidden>
666
+ <summary>Output</summary>
667
+ <pre id="e-log"></pre>
668
+ </details>
669
+ </section>
670
+ </main>
671
+ </div>
672
+
673
+ <div class="toast" id="toast" role="status" aria-live="polite"></div>
674
+
675
+ <script>
676
+ (function(){
677
+ "use strict";
678
+
679
+ /* Server tables, inlined so the page renders correctly before /api/formats
680
+ answers \u2014 and refreshed from that endpoint the moment it does. */
681
+ var FORMATS = ${JSON.stringify(FALLBACK_FORMATS)};
682
+ var THEME_CHIPS = ${JSON.stringify(THEME_CHIPS)};
683
+
684
+ /* What this server will actually BUILD, not what a number input happens to
685
+ allow. These four were hardcoded here as 240..7680 while the server refused
686
+ anything outside 320..2560 \u2014 so the picker's own maximum, 3840x2160, came
687
+ back 400 with a hint this page then threw away. Interpolated from the server's
688
+ own constants and re-read from /api/formats, so there is one table. */
689
+ var CANVAS = {
690
+ minSide: ${MIN_EDGE},
691
+ maxSide: ${MAX_SIDE},
692
+ maxPixels: ${MAX_PIXELS},
693
+ maxAspect: ${MAX_ASPECT},
694
+ legibleWidth: ${LEGIBLE_W}
695
+ };
696
+
697
+ /* What each stage typically costs on the demo document. Shown as a muted "~"
698
+ next to a step that has not started, and never as a bar: the point is to set
699
+ an expectation, not to imply knowledge of progress the server has not sent.
700
+ The one thing worse than a slow build is a slow build you think has hung. */
701
+ var TYPICAL = { ingest: 3, plan: 60, narrate: 25, build: 8, render: 120 };
702
+ var STAGE_WORDS = {
703
+ ingest: "Reading the document",
704
+ plan: "Planning the story",
705
+ narrate: "Recording the narration",
706
+ build: "Building the deck",
707
+ render: "Rendering the video"
708
+ };
709
+
710
+ var $ = function(id){ return document.getElementById(id); };
711
+ var on = function(node, ev, fn){ node.addEventListener(ev, fn); };
712
+ function el(tag, cls, text){
713
+ var n = document.createElement(tag);
714
+ if (cls) n.className = cls;
715
+ if (text !== undefined && text !== null) n.textContent = String(text);
716
+ return n;
717
+ }
718
+
719
+ /* ------------------------------------------------------------- state */
720
+ var file = null; // the File the user picked
721
+ var jobId = null; // the id the server gave us
722
+ var startedAt = 0; // client clock, for the elapsed readout
723
+ var ticker = null;
724
+ var stop = null; // tears down whichever watcher is live
725
+ var lastJob = null; // last payload, for retry and for the error view
726
+
727
+ /* ------------------------------------------------------------- helpers */
728
+ function bytes(n){
729
+ if (n < 1024) return n + " B";
730
+ var u = ["KB","MB","GB"], i = -1, v = n;
731
+ do { v = v / 1024; i++; } while (v >= 1024 && i < u.length - 1);
732
+ return (v >= 10 ? Math.round(v) : Math.round(v * 10) / 10) + " " + u[i];
733
+ }
734
+ function clock(ms){
735
+ var s = Math.max(0, Math.round(ms / 1000));
736
+ return Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
737
+ }
738
+ function shortMs(ms){
739
+ if (!(ms > 0)) return "";
740
+ return ms < 1000 ? Math.round(ms) + "ms" : (ms < 60000
741
+ ? (Math.round(ms / 100) / 10) + "s"
742
+ : Math.floor(ms / 60000) + "m " + Math.round((ms % 60000) / 1000) + "s");
743
+ }
744
+ function approx(sec){ return sec >= 60 ? "~" + Math.round(sec / 60) + "m" : "~" + sec + "s"; }
745
+ function gcd(a, b){ return b ? gcd(b, a % b) : a; }
746
+ function ratio(w, h){ var g = gcd(w, h) || 1; return (w / g) + ":" + (h / g); }
747
+
748
+ var toastTimer = null;
749
+ function toast(msg){
750
+ var t = $("toast");
751
+ t.textContent = msg;
752
+ t.classList.add("on");
753
+ clearTimeout(toastTimer);
754
+ toastTimer = setTimeout(function(){ t.classList.remove("on"); }, 1900);
755
+ }
756
+
757
+ function show(which){
758
+ ["v-compose","v-run","v-done","v-error"].forEach(function(id){
759
+ $(id).hidden = (id !== which);
760
+ });
761
+ window.scrollTo(0, 0);
762
+ }
763
+
764
+ /* ------------------------------------------------------------- formats */
765
+ var chosenFormat = "deck-16x9";
766
+
767
+ var DESCRIBE = {};
768
+
769
+ function paintFormats(){
770
+ var box = $("formats");
771
+ box.textContent = "";
772
+ DESCRIBE = { custom: "Any canvas you type. Everything else follows the deck profile." };
773
+ FORMATS.forEach(function(f){
774
+ /* Deck and Video are both 1920x1080, so the pixel count cannot be what
775
+ tells them apart \u2014 what differs is whether a person clicks through it or
776
+ watches it play. The tile carries the shape; this sentence carries the
777
+ rest, under whichever tile is chosen. */
778
+ var how = f.navigable ? "you click through it" : "it plays start to finish";
779
+ DESCRIBE[f.id] = f.width + "\\u00d7" + f.height + " \\u00b7 " + how;
780
+ box.appendChild(tile(f.id, label(f), ratio(f.width, f.height),
781
+ label(f) + " \\u2014 " + DESCRIBE[f.id], f.width / f.height));
782
+ });
783
+ box.appendChild(tile("custom", "Custom", "any size", "Custom \\u2014 " + DESCRIBE.custom, 0));
784
+ select(chosenFormat);
785
+ }
786
+ function label(f){
787
+ var n = f.id.split("-")[0];
788
+ return n.charAt(0).toUpperCase() + n.slice(1);
789
+ }
790
+ function tile(id, name, dim, full, num){
791
+ var l = el("label", "tile");
792
+ l.title = full;
793
+ var input = document.createElement("input");
794
+ input.type = "radio"; input.name = "format"; input.value = id;
795
+ input.setAttribute("aria-label", full);
796
+ var box = el("span", "ratio");
797
+ // An <i>, because the ".ratio i" rule below styles the element, not a class.
798
+ var i = el("i");
799
+ if (num > 0) {
800
+ /* Drawn at the format's TRUE ratio \u2014 a 9:16 tile that looks square teaches
801
+ the wrong thing about the output. */
802
+ i.style.aspectRatio = String(num);
803
+ if (num >= 1) i.style.width = "44px"; else i.style.height = "32px";
804
+ } else {
805
+ i.className = "any";
806
+ i.style.width = "38px";
807
+ i.style.height = "28px";
808
+ }
809
+ box.appendChild(i);
810
+ l.appendChild(input);
811
+ l.appendChild(box);
812
+ l.appendChild(el("span", "tname", name));
813
+ l.appendChild(el("span", "tdim", dim));
814
+ on(input, "change", function(){ if (input.checked) select(id); });
815
+ return l;
816
+ }
817
+ function select(id){
818
+ chosenFormat = id;
819
+ var found = false;
820
+ Array.prototype.forEach.call(document.querySelectorAll('input[name=format]'), function(r){
821
+ r.checked = (r.value === id);
822
+ if (r.checked) found = true;
823
+ });
824
+ if (!found) {
825
+ var first = document.querySelector('input[name=format]');
826
+ if (first) { first.checked = true; chosenFormat = first.value; }
827
+ }
828
+ var custom = (chosenFormat === "custom");
829
+ $("customsize").hidden = !custom;
830
+ /* DISABLED, not merely hidden, and that is load-bearing twice over. A
831
+ display:none number input is still constraint-validated, so a stale "50"
832
+ left in the width box after switching back to Deck would block the form
833
+ with a bubble the browser cannot show \u2014 the button would simply do
834
+ nothing. Disabling also drops the pair from the FormData, which is exactly
835
+ the "no size unless custom" the contract asks for. */
836
+ $("cw").disabled = !custom;
837
+ $("ch").disabled = !custom;
838
+ $("fmtnote").textContent = DESCRIBE[chosenFormat] || "";
839
+ refreshSize();
840
+ }
841
+
842
+ function currentSize(){
843
+ if (chosenFormat === "custom") {
844
+ return { w: clampPx($("cw").value, 1920), h: clampPx($("ch").value, 1080) };
845
+ }
846
+ var f = FORMATS.filter(function(x){ return x.id === chosenFormat; })[0];
847
+ return f ? { w: f.width, h: f.height } : { w: 1920, h: 1080 };
848
+ }
849
+ /* Even, because h264 needs it and the server would otherwise round and warn. */
850
+ function clampPx(v, dflt){
851
+ var n = Math.round(Number(v));
852
+ if (!isFinite(n) || n < CANVAS.minSide) return dflt;
853
+ n = Math.min(CANVAS.maxSide, n);
854
+ return n % 2 === 0 ? n : n - 1;
855
+ }
856
+
857
+ /**
858
+ * Why a custom canvas would be refused, in the page's own words, or null.
859
+ *
860
+ * The same three rules the server applies, checked here only so the answer
861
+ * arrives while the number is being typed rather than after an upload. The
862
+ * SERVER is still the authority \u2014 this never suppresses a request it would have
863
+ * accepted, and its refusals are the ones that get displayed.
864
+ */
865
+ function sizeProblem(w, h){
866
+ if (w * h > CANVAS.maxPixels) {
867
+ return (w * h / 1e6).toFixed(1) + " megapixels is over this server's "
868
+ + (CANVAS.maxPixels / 1e6) + " megapixel ceiling.";
869
+ }
870
+ var aspect = Math.max(w / h, h / w);
871
+ if (aspect > CANVAS.maxAspect) {
872
+ return aspect.toFixed(1) + ":1 is too " + (w > h ? "wide" : "tall")
873
+ + " \u2014 the scene padding would take most of the frame. Keep it within 1:"
874
+ + CANVAS.maxAspect + " and " + CANVAS.maxAspect + ":1.";
875
+ }
876
+ return null;
877
+ }
878
+
879
+ /** Advisory only, and phrased as such: legal, probably not what was meant. */
880
+ function sizeWarning(w, h){
881
+ if (w < CANVAS.legibleWidth) {
882
+ return w + "px wide is under " + CANVAS.legibleWidth
883
+ + "px \u2014 this will build, but the type will be too small to read. Open a frame before you ship it.";
884
+ }
885
+ return null;
886
+ }
887
+
888
+ /* Say it under the boxes, live, and disable the button on a hard refusal. */
889
+ function refreshSize(){
890
+ var note = $("sizenote");
891
+ if (chosenFormat !== "custom") { note.hidden = true; refreshAction(); return; }
892
+ var s = currentSize();
893
+ var bad = sizeProblem(s.w, s.h);
894
+ var warn = bad ? null : sizeWarning(s.w, s.h);
895
+ note.textContent = bad || warn || (s.w + "\\u00d7" + s.h + " \\u00b7 " + ratio(s.w, s.h));
896
+ note.className = "fmtnote" + (bad ? " bad" : "");
897
+ note.hidden = false;
898
+ refreshAction();
899
+ }
900
+
901
+ /* The preset a custom size borrows its behaviour from. A custom canvas still
902
+ has to be one of the pipeline's profiles, and the only bit that matters
903
+ downstream is whether the deck is click-through-able \u2014 so a custom deck is a
904
+ deck unless the user asked for a video. Stated here because it is the one
905
+ place this page decides something the contract did not. */
906
+ function basePreset(){
907
+ if (chosenFormat !== "custom") return chosenFormat;
908
+ return $("video").checked ? "video-16x9" : "deck-16x9";
909
+ }
910
+
911
+ /* ------------------------------------------------------------- themes/segs */
912
+ function paintThemes(){
913
+ var box = $("themes");
914
+ THEME_CHIPS.forEach(function(t, idx){
915
+ var l = el("label", "chip");
916
+ var input = document.createElement("input");
917
+ input.type = "radio"; input.name = "theme"; input.value = t.name;
918
+ input.checked = (t.name === "ink");
919
+ var sw = el("span", "sw");
920
+ [t.bg, t.accent, t.tone].forEach(function(c){
921
+ var i = el("i"); i.style.background = c; sw.appendChild(i);
922
+ });
923
+ l.appendChild(input); l.appendChild(sw); l.appendChild(el("span", null, t.name));
924
+ box.appendChild(l);
925
+ if (idx === 0 && !THEME_CHIPS.some(function(x){ return x.name === "ink"; })) input.checked = true;
926
+ });
927
+ }
928
+ function paintSeg(id, values, dflt){
929
+ var box = $(id);
930
+ values.forEach(function(v){
931
+ var l = el("label");
932
+ var input = document.createElement("input");
933
+ input.type = "radio"; input.name = id; input.value = v;
934
+ input.checked = (v === dflt);
935
+ l.appendChild(input); l.appendChild(el("span", null, v));
936
+ box.appendChild(l);
937
+ });
938
+ }
939
+
940
+ /* ------------------------------------------------------------- the file */
941
+ var ACCEPT = /\\.(md|markdown|txt|zip)$/i;
942
+
943
+ function setFile(f){
944
+ if (!f) return;
945
+ if (!ACCEPT.test(f.name)) {
946
+ toast("Needs a .md, .markdown, .txt or .zip file");
947
+ return;
948
+ }
949
+ file = f;
950
+ var isZip = /\\.zip$/i.test(f.name);
951
+ $("f-kind").textContent = isZip ? "ZIP" : (f.name.split(".").pop() || "MD").toUpperCase().slice(0, 4);
952
+ $("f-name").textContent = f.name;
953
+ $("f-meta").textContent = bytes(f.size);
954
+ $("f-zip").hidden = true;
955
+ $("f-zip").textContent = "";
956
+ $("pick").hidden = true;
957
+ $("chosen").hidden = false;
958
+ refreshAction();
959
+ if (isZip) peekZip(f);
960
+ }
961
+
962
+ function clearFile(){
963
+ file = null;
964
+ $("fileinput").value = "";
965
+ $("chosen").hidden = true;
966
+ $("pick").hidden = false;
967
+ refreshAction();
968
+ $("drop").focus();
969
+ }
970
+
971
+ /**
972
+ * Name the entries in a zip without unzipping it.
973
+ *
974
+ * A zip's central directory sits at the END of the file, so this reads at most
975
+ * the last 96KB plus the directory itself \u2014 a 200MB archive costs two small
976
+ * range reads, which is what makes it cheap enough to do on every pick. Zip64
977
+ * and encrypted archives are simply not described; the card still shows the
978
+ * name and the size, which is the part that must never fail.
979
+ */
980
+ function peekZip(f){
981
+ var tailLen = Math.min(f.size, 96 * 1024);
982
+ f.slice(f.size - tailLen).arrayBuffer().then(function(buf){
983
+ var tail = new DataView(buf);
984
+ var eocd = -1;
985
+ for (var i = tail.byteLength - 22; i >= 0; i--) {
986
+ if (tail.getUint32(i, true) === 0x06054b50) { eocd = i; break; }
987
+ }
988
+ if (eocd < 0) return;
989
+ var count = tail.getUint16(eocd + 10, true);
990
+ var size = tail.getUint32(eocd + 12, true);
991
+ var offset = tail.getUint32(eocd + 16, true);
992
+ if (offset === 0xffffffff || size === 0xffffffff) return; // zip64, not our business
993
+ return f.slice(offset, offset + size).arrayBuffer().then(function(cd){
994
+ return { view: new DataView(cd), bytes: new Uint8Array(cd), count: count };
995
+ });
996
+ }).then(function(cd){
997
+ if (!cd) return;
998
+ var names = [], p = 0, dec = new TextDecoder("utf-8");
999
+ while (p + 46 <= cd.view.byteLength && cd.view.getUint32(p, true) === 0x02014b50) {
1000
+ var nlen = cd.view.getUint16(p + 28, true);
1001
+ var elen = cd.view.getUint16(p + 30, true);
1002
+ var clen = cd.view.getUint16(p + 32, true);
1003
+ var name = dec.decode(cd.bytes.subarray(p + 46, p + 46 + nlen));
1004
+ if (name.charAt(name.length - 1) !== "/" && name.indexOf("__MACOSX/") !== 0) names.push(name);
1005
+ p += 46 + nlen + elen + clen;
1006
+ }
1007
+ if (!names.length) return;
1008
+ paintZip(names);
1009
+ }).catch(function(){ /* an unreadable zip is the server's problem to report */ });
1010
+ }
1011
+
1012
+ function paintZip(names){
1013
+ var docs = names.filter(function(n){ return /\\.(md|markdown|txt)$/i.test(n); });
1014
+ var imgs = names.filter(function(n){ return /\\.(png|jpe?g|gif|svg|webp|pdf)$/i.test(n); });
1015
+ var ul = $("f-zip");
1016
+ ul.textContent = "";
1017
+ docs.slice(0, 2).forEach(function(n){
1018
+ ul.appendChild(el("li", "main", n.split("/").pop()));
1019
+ });
1020
+ if (imgs.length) ul.appendChild(el("li", "rest", imgs.length + (imgs.length === 1 ? " figure" : " figures")));
1021
+ var others = names.length - docs.length - imgs.length;
1022
+ if (others > 0) ul.appendChild(el("li", "rest", others + " other" + (others === 1 ? "" : "s")));
1023
+ ul.hidden = false;
1024
+ $("f-meta").textContent = bytes(file.size) + " \\u00b7 " + names.length + " file" + (names.length === 1 ? "" : "s");
1025
+ if (!docs.length) {
1026
+ ul.appendChild(el("li", "rest", "no markdown found"));
1027
+ }
1028
+ }
1029
+
1030
+ /* ------------------------------------------------------------- action state */
1031
+ function refreshAction(){
1032
+ var s = currentSize();
1033
+ var ready = !!file && !sizeProblem(s.w, s.h);
1034
+ $("go").disabled = !ready;
1035
+ $("golabel").textContent = $("video").checked ? "Generate deck and video" : "Generate deck";
1036
+ var est = $("video").checked ? "about four minutes" : "about two minutes";
1037
+ $("gohint").textContent = ready
1038
+ ? "Takes " + est + ". Keep this tab open."
1039
+ : "Choose a document to begin.";
1040
+ }
1041
+
1042
+ /* ------------------------------------------------------------- submit */
1043
+ /**
1044
+ * Build the multipart body from the form, then fix up the four things the
1045
+ * browser's own serialisation gets wrong for this contract.
1046
+ *
1047
+ * Reading the form rather than listing the fields again is what stops a control
1048
+ * being added to the markup and quietly never reaching the server.
1049
+ */
1050
+ function submit(){
1051
+ if (!file) return;
1052
+ var size = currentSize();
1053
+ var fd = new FormData($("compose"));
1054
+
1055
+ // 1. The file. Deliberately not a named input: a DROPPED file never lands in
1056
+ // <input type=file>, so there is exactly one source of truth for it.
1057
+ fd.append("file", file, file.name);
1058
+
1059
+ // 2. Format. "custom" is this page's word, not the API's \u2014 it means a preset
1060
+ // plus an explicit canvas. The size pair is absent for every other format
1061
+ // because select() disables the inputs; here it is only re-stated so an
1062
+ // EMPTY box (which passes validation) becomes a number rather than "".
1063
+ fd.set("format", basePreset());
1064
+ if (chosenFormat === "custom") {
1065
+ fd.set("width", String(size.w));
1066
+ fd.set("height", String(size.h));
1067
+ }
1068
+
1069
+ // 3. Checkboxes. An unticked box is ABSENT from a form, which the server
1070
+ // cannot tell from "the user said nothing" \u2014 so both are stated outright.
1071
+ fd.set("narrate", $("narrate").checked ? "true" : "false");
1072
+ fd.set("video", $("video").checked ? "true" : "false");
1073
+
1074
+ // 4. Language may come from the free-text box behind "Other\u2026", and an empty
1075
+ // voice means "pick one", which is the server's default, not a value.
1076
+ fd.set("lang", langValue());
1077
+ if (!$("voice").value.trim()) fd.delete("voice");
1078
+
1079
+ lastSize = size;
1080
+ startedAt = Date.now();
1081
+ jobId = null;
1082
+ lastJob = null;
1083
+ paintSteps(plannedSteps(), null);
1084
+ $("r-stage").textContent = "Uploading";
1085
+ $("r-log").textContent = "";
1086
+ $("r-clock").textContent = "0:00";
1087
+ show("v-run");
1088
+ tick(true);
1089
+
1090
+ fetch("/api/jobs", { method: "POST", body: fd })
1091
+ .then(function(res){
1092
+ return res.json().catch(function(){ return {}; }).then(function(body){
1093
+ /* CARRY THE SERVER'S OWN HINT. Every refusal from /api/jobs arrives as
1094
+ {message, hint} and the hint is the half that says what to do about
1095
+ it \u2014 "Canvases run 64-5656px a side", "Export the document to markdown
1096
+ first". This used to throw a bare Error and then attach a fixed hint
1097
+ of its own ("check that the server is running"), which was not merely
1098
+ less useful but WRONG: the server had just answered, in detail. */
1099
+ if (!res.ok) {
1100
+ var e = (body && body.error) || {};
1101
+ throw Object.assign(new Error(e.message || "The server refused the upload (HTTP " + res.status + ")."),
1102
+ { hint: e.hint || "", where: "upload" });
1103
+ }
1104
+ if (!body || !body.id) throw new Error("The server accepted the upload but did not return a job id.");
1105
+ return body.id;
1106
+ });
1107
+ })
1108
+ .then(function(id){ jobId = id; $("r-stage").textContent = "Queued"; watch(id); })
1109
+ .catch(function(err){
1110
+ /* A hint only when we actually have one. The fallback below is for the
1111
+ case this really is \u2014 fetch itself rejected, so nothing answered. */
1112
+ fail({ message: err && err.message ? err.message : String(err),
1113
+ hint: (err && err.hint) ||
1114
+ "Check that the DeckSmith server is running and reachable, then try again." }, "upload");
1115
+ });
1116
+ }
1117
+
1118
+ var lastSize = { w: 1920, h: 1080 };
1119
+
1120
+ function langValue(){
1121
+ var v = $("lang").value;
1122
+ if (v !== "__other") return v;
1123
+ return $("langother").value.trim() || "en";
1124
+ }
1125
+
1126
+ /** The steps we expect, used only until the server sends its own list. */
1127
+ function plannedSteps(){
1128
+ var names = ["ingest", "plan"];
1129
+ if ($("narrate").checked) names.push("narrate");
1130
+ names.push("build");
1131
+ if ($("video").checked) names.push("render");
1132
+ return names.map(function(n){ return { name: n, state: "pending" }; });
1133
+ }
1134
+
1135
+ /* ------------------------------------------------------------- watching */
1136
+ /**
1137
+ * Follow a job. SSE when the server has it, polling when it does not.
1138
+ *
1139
+ * The events endpoint is optional in the contract, so this cannot depend on it:
1140
+ * a 404 fires onerror and we fall back. The silence watchdog covers the nastier
1141
+ * case \u2014 a proxy that holds the stream open and buffers every event, which looks
1142
+ * exactly like a hung build from here and would otherwise never recover.
1143
+ */
1144
+ function watch(id){
1145
+ var closed = false;
1146
+ var timer = null, es = null, watchdog = null;
1147
+
1148
+ function teardown(){
1149
+ closed = true;
1150
+ if (timer) clearTimeout(timer);
1151
+ if (watchdog) clearTimeout(watchdog);
1152
+ if (es) { try { es.close(); } catch (e) {} }
1153
+ }
1154
+ stop = teardown;
1155
+
1156
+ /* A 404 is terminal, and treating it as one is the whole difference between an
1157
+ error and a hang. The status was ignored here: a 404 body has no state field, so
1158
+ apply() did nothing and the timer went round again \u2014 for as long as the tab
1159
+ stayed open, several requests a second in the console and a progress panel
1160
+ frozen mid-stage. That is what "it hangs" looks like from the outside. */
1161
+ function gone(){
1162
+ closed = true;
1163
+ teardown();
1164
+ fail({ message: "This job is no longer on the server.",
1165
+ hint: "The server keeps its job list in memory, so a restart loses track of a "
1166
+ + "run in progress. Choose the file again." }, "upload");
1167
+ }
1168
+
1169
+ /* The server kept the upload, so the honest response is an offer rather than an
1170
+ apology: one request re-queues the same file with the same settings. */
1171
+ function interrupted(job){
1172
+ closed = true;
1173
+ teardown();
1174
+ fail({ message: "The server restarted while this deck was being made.",
1175
+ hint: "Your file (" + (job.filename || "the upload") + ") is still here, with the "
1176
+ + "settings you chose.",
1177
+ retry: id }, "upload");
1178
+ }
1179
+
1180
+ function poll(){
1181
+ if (closed) return;
1182
+ fetch("/api/jobs/" + encodeURIComponent(id), { cache: "no-store" })
1183
+ .then(function(r){ if (r.status === 404) { gone(); return null; } return r.json(); })
1184
+ .then(function(job){ if (!job || closed) return;
1185
+ if (job.state === "interrupted") { interrupted(job); return; }
1186
+ apply(job); timer = setTimeout(poll, 1800); })
1187
+ .catch(function(){ if (!closed) timer = setTimeout(poll, 3000); });
1188
+ }
1189
+
1190
+ function fallback(){
1191
+ if (closed || !es) return;
1192
+ try { es.close(); } catch (e) {}
1193
+ es = null;
1194
+ poll();
1195
+ }
1196
+
1197
+ /* One immediate read regardless of transport, so the first paint does not
1198
+ wait on an SSE handshake. */
1199
+ fetch("/api/jobs/" + encodeURIComponent(id), { cache: "no-store" })
1200
+ .then(function(r){ if (r.status === 404) { gone(); return null; } return r.json(); })
1201
+ .then(function(job){ if (!job) return;
1202
+ if (job.state === "interrupted") { interrupted(job); return; }
1203
+ apply(job); })
1204
+ .catch(function(){});
1205
+
1206
+ if (typeof EventSource === "function") {
1207
+ try { es = new EventSource("/api/jobs/" + encodeURIComponent(id) + "/events"); } catch (e) { es = null; }
1208
+ }
1209
+ if (es) {
1210
+ watchdog = setTimeout(fallback, 12000);
1211
+ es.onmessage = function(ev){
1212
+ clearTimeout(watchdog);
1213
+ watchdog = setTimeout(fallback, 45000);
1214
+ try { apply(JSON.parse(ev.data)); } catch (e) {}
1215
+ };
1216
+ es.onerror = fallback;
1217
+ } else {
1218
+ timer = setTimeout(poll, 1500);
1219
+ }
1220
+ }
1221
+
1222
+ /* The server may spell a step's state any number of ways; normalise once. */
1223
+ function stateOf(s){
1224
+ var v = String(s || "pending").toLowerCase();
1225
+ if (v === "ok" || v === "complete" || v === "completed" || v === "success") return "done";
1226
+ if (v === "active" || v === "started" || v === "busy") return "running";
1227
+ if (v === "failed" || v === "err") return "error";
1228
+ if (v === "skip" || v === "skipped") return "skipped";
1229
+ if (v === "done" || v === "running" || v === "error") return v;
1230
+ return "pending";
1231
+ }
1232
+
1233
+ function apply(job){
1234
+ if (!job || typeof job !== "object") return;
1235
+ lastJob = job;
1236
+
1237
+ var steps = Array.isArray(job.steps) && job.steps.length ? job.steps : plannedSteps();
1238
+ paintSteps(steps, job.stage);
1239
+
1240
+ /* QUEUED IS NOT A STAGE, AND IT IS THE ONE STATE WITH NOTHING TO SHOW. The
1241
+ server runs one job at a time and sends queuePosition for exactly this
1242
+ moment; without it a wait behind three other papers is indistinguishable
1243
+ from a hang, and the step list is all "pending" the whole time. */
1244
+ var stage = job.stage && STAGE_WORDS[job.stage] ? STAGE_WORDS[job.stage] : null;
1245
+ var queued = job.state === "queued";
1246
+ /* queuePosition is 1 for the first job WAITING, and a running job has none
1247
+ \u2014 so the number is exactly how many jobs are ahead of this one. */
1248
+ var ahead = typeof job.queuePosition === "number" ? job.queuePosition : 0;
1249
+ $("r-stage").textContent = queued
1250
+ ? (ahead > 0 ? "Queued \\u00b7 " + ahead + (ahead === 1 ? " job" : " jobs") + " ahead" : "Queued")
1251
+ : (stage || "Working");
1252
+
1253
+ if (Array.isArray(job.log)) {
1254
+ var pre = $("r-log");
1255
+ var pinned = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24;
1256
+ var text = job.log.slice(-300).join("\\n");
1257
+ if (text !== pre.textContent) {
1258
+ pre.textContent = text;
1259
+ if (pinned) pre.scrollTop = pre.scrollHeight;
1260
+ }
1261
+ }
1262
+
1263
+ /* Elapsed prefers the server's own clock: a reload mid-build must not restart
1264
+ the timer at zero, which would read as "it started over". */
1265
+ var earliest = firstStart(steps);
1266
+ if (earliest && earliest < startedAt) startedAt = earliest;
1267
+
1268
+ if (job.state === "error") { fail(job.error, job.stage); return; }
1269
+ if (job.state === "done") { finish(job); return; }
1270
+ }
1271
+
1272
+ function firstStart(steps){
1273
+ var best = 0;
1274
+ steps.forEach(function(s){
1275
+ var t = typeof s.startedAt === "number" ? s.startedAt : Date.parse(s.startedAt || "");
1276
+ if (t && (!best || t < best)) best = t;
1277
+ });
1278
+ return best || 0;
1279
+ }
1280
+
1281
+ function paintSteps(steps, stage){
1282
+ var ol = $("r-steps");
1283
+ ol.textContent = "";
1284
+ steps.forEach(function(s){
1285
+ var st = stateOf(s.state);
1286
+ if (st === "pending" && stage && s.name === stage) st = "running";
1287
+ var li = el("li");
1288
+ li.setAttribute("data-s", st);
1289
+ li.appendChild(el("span", "bead"));
1290
+ var grow = el("div", "grow");
1291
+ grow.appendChild(el("div", "nm", s.name));
1292
+ var detail = s.detail || (st === "pending" && TYPICAL[s.name] ? "typically " + approx(TYPICAL[s.name]) : "");
1293
+ if (detail) grow.appendChild(el("div", "dt", detail));
1294
+ li.appendChild(grow);
1295
+ var ms = shortMs(s.ms);
1296
+ if (ms) li.appendChild(el("span", "ms", ms));
1297
+ ol.appendChild(li);
1298
+ });
1299
+ var running = steps.filter(function(s){ return stateOf(s.state) === "running"; })[0];
1300
+ $("r-sr").textContent = running ? (STAGE_WORDS[running.name] || running.name) : "";
1301
+ }
1302
+
1303
+ function tick(start){
1304
+ if (ticker) clearInterval(ticker);
1305
+ if (!start) return;
1306
+ ticker = setInterval(function(){
1307
+ $("r-clock").textContent = clock(Date.now() - startedAt);
1308
+ }, 1000);
1309
+ }
1310
+
1311
+ /* ------------------------------------------------------------- done */
1312
+ function finish(job){
1313
+ if (stop) stop();
1314
+ tick(false);
1315
+ var r = job.result || {};
1316
+ $("d-clock").textContent = clock(Date.now() - startedAt);
1317
+
1318
+ var ar = lastSize.w + " / " + lastSize.h;
1319
+ var canvas = $("d-canvas");
1320
+ var viewer = $("d-viewer");
1321
+ viewer.style.setProperty("--ar", ar);
1322
+ // --arn is the same ratio as a bare number, which is what the height calc in
1323
+ // .canvas divides by; --ar stays for the fullscreen rule's aspect-ratio.
1324
+ viewer.style.setProperty("--arn", String(lastSize.w / lastSize.h));
1325
+
1326
+ var hasVideo = !!r.videoUrl;
1327
+ $("d-title").textContent = hasVideo && r.deckUrl
1328
+ ? "Your deck and video are ready"
1329
+ : (hasVideo ? "Your video is ready" : "Your deck is ready");
1330
+ $("d-tabs").hidden = !hasVideo || !r.deckUrl;
1331
+ $("tab-deck").setAttribute("aria-selected", "true");
1332
+ $("tab-video").setAttribute("aria-selected", "false");
1333
+ mount(r.deckUrl ? "deck" : "video", r);
1334
+
1335
+ var facts = $("d-facts");
1336
+ facts.textContent = "";
1337
+ addFact(facts, r.slides, "slides");
1338
+ if (r.duration) addFact(facts, clock(Number(r.duration) * 1000), "long");
1339
+ addFact(facts, lastSize.w + "\\u00d7" + lastSize.h, ratio(lastSize.w, lastSize.h));
1340
+
1341
+ var gets = $("d-gets");
1342
+ gets.textContent = "";
1343
+ if (r.videoUrl) gets.appendChild(link(r.videoUrl, "Download mp4"));
1344
+ if (r.srtUrl) gets.appendChild(link(r.srtUrl, "Subtitles .srt"));
1345
+ if (r.packUrl) gets.appendChild(link(r.packUrl, "Deck pack .deck"));
1346
+ if (r.deckUrl) {
1347
+ var copy = el("button", null, "Copy link");
1348
+ on(copy, "click", function(){
1349
+ var url = new URL(r.deckUrl, location.href).href;
1350
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1351
+ navigator.clipboard.writeText(url).then(function(){ toast("Link copied"); },
1352
+ function(){ prompt("Copy this link:", url); });
1353
+ } else { prompt("Copy this link:", url); }
1354
+ });
1355
+ gets.appendChild(copy);
1356
+ }
1357
+
1358
+ var warns = $("d-warns");
1359
+ warns.textContent = "";
1360
+ var list = Array.isArray(r.warnings) ? r.warnings : [];
1361
+ list.forEach(function(w){
1362
+ warns.appendChild(el("li", null, typeof w === "string" ? w : (w && w.message) || JSON.stringify(w)));
1363
+ });
1364
+ warns.hidden = list.length === 0;
1365
+
1366
+ show("v-done");
1367
+ }
1368
+
1369
+ function addFact(box, value, word){
1370
+ if (value === undefined || value === null || value === "") return;
1371
+ var span = el("span");
1372
+ span.appendChild(el("b", null, value));
1373
+ span.appendChild(document.createTextNode(" " + word));
1374
+ box.appendChild(span);
1375
+ }
1376
+ function link(href, text){
1377
+ var a = el("a", null, text);
1378
+ a.href = href;
1379
+ a.setAttribute("download", "");
1380
+ return a;
1381
+ }
1382
+
1383
+ /**
1384
+ * SRT IS NOT A FORMAT THE TRACK ELEMENT READS.
1385
+ *
1386
+ * Pointing a subtitles track straight at video.srt looks right and does
1387
+ * nothing: the element only parses WebVTT, so Chrome sets readyState 3 (ERROR)
1388
+ * and the cue list stays empty \u2014 measured, with the captions button never
1389
+ * appearing at all. The two formats differ by a header line and a comma, so the
1390
+ * conversion happens here rather than adding a second subtitle file to the
1391
+ * pipeline's output contract.
1392
+ */
1393
+ function vttFrom(srt){
1394
+ return "WEBVTT\\n\\n" + String(srt)
1395
+ .replace(/\\r\\n/g, "\\n")
1396
+ .replace(/(\\d\\d:\\d\\d:\\d\\d),(\\d\\d\\d)/g, "$1.$2");
1397
+ }
1398
+
1399
+ var mounted = { kind: null, result: null };
1400
+ function mount(kind, r){
1401
+ mounted = { kind: kind, result: r };
1402
+ var canvas = $("d-canvas");
1403
+ var viewer = $("d-viewer");
1404
+ canvas.textContent = "";
1405
+ if (kind === "video" && r.videoUrl) {
1406
+ // The video is the composition and nothing else; no chrome to make room for.
1407
+ viewer.style.setProperty("--chrome", "0px");
1408
+ var v = document.createElement("video");
1409
+ v.src = r.videoUrl; v.controls = true; v.playsInline = true; v.preload = "metadata";
1410
+ if (r.srtUrl) {
1411
+ var t = document.createElement("track");
1412
+ // Deliberately not defaulted on: a slide often carries its own line of
1413
+ // text along the bottom of the frame, and a caption forced on lands right
1414
+ // above it. The track being loadable is what was missing; whether it shows
1415
+ // is the viewer's call, through the player's own captions control.
1416
+ t.kind = "subtitles"; t.srclang = "en"; t.label = "Subtitles";
1417
+ // A blob, not the .srt URL \u2014 see vttFrom. Failure is silent and costs only
1418
+ // the captions: a video with no usable track still plays.
1419
+ fetch(r.srtUrl).then(function(res){ return res.text(); }).then(function(srt){
1420
+ t.src = URL.createObjectURL(new Blob([vttFrom(srt)], { type: "text/vtt" }));
1421
+ }, function(){});
1422
+ v.appendChild(t);
1423
+ }
1424
+ canvas.appendChild(v);
1425
+ $("d-who").textContent = String(r.videoUrl).split("/").pop() || "video.mp4";
1426
+ } else if (r.deckUrl) {
1427
+ var f = document.createElement("iframe");
1428
+ f.src = r.deckUrl;
1429
+ f.setAttribute("allow", "fullscreen; autoplay");
1430
+ f.setAttribute("allowfullscreen", "");
1431
+ f.title = "The built deck";
1432
+ // Measured, not assumed: the deck's chrome is whatever the player it ships
1433
+ // with draws below the stage, and hardcoding today's 77px would pillarbox
1434
+ // again the day that number moves. Same-origin, so this is readable; if it
1435
+ // ever is not, --chrome stays 0 and the deck is no worse than it was.
1436
+ f.addEventListener("load", function(){
1437
+ var extra = 0;
1438
+ try {
1439
+ var stage = f.contentDocument.querySelector("hyperframes-player");
1440
+ if (stage) extra = Math.max(0, Math.round(f.clientHeight - stage.getBoundingClientRect().height));
1441
+ } catch (e) { extra = 0; }
1442
+ viewer.style.setProperty("--chrome", extra + "px");
1443
+ });
1444
+ canvas.appendChild(f);
1445
+ $("d-who").textContent = String(r.deckUrl).replace(/^https?:\\/\\/[^/]+/, "");
1446
+ }
1447
+ }
1448
+
1449
+ /* ------------------------------------------------------------- error */
1450
+ function fail(err, where){
1451
+ if (stop) stop();
1452
+ tick(false);
1453
+ var e = err || {};
1454
+ $("e-where").textContent = where ? ("Failed during " + where) : "Failed";
1455
+ $("e-msg").textContent = e.message || "The build stopped without saying why.";
1456
+ $("e-hint").textContent = e.hint || "";
1457
+ $("e-hint").hidden = !e.hint;
1458
+ var log = lastJob && Array.isArray(lastJob.log) ? lastJob.log : [];
1459
+ $("e-log").textContent = log.slice(-300).join("\\n");
1460
+ $("e-logwrap").hidden = log.length === 0;
1461
+ /* An error with a retained upload is recoverable in one click, so it gets a
1462
+ button rather than only an explanation. Built here rather than in the markup
1463
+ because it exists for exactly one failure shape. */
1464
+ var old = $("e-retry");
1465
+ if (old) old.remove();
1466
+ if (e.retry) {
1467
+ var b = document.createElement("button");
1468
+ b.className = "go"; b.id = "e-retry"; b.type = "button"; b.textContent = "Run it again";
1469
+ b.onclick = function(){
1470
+ b.disabled = true; b.textContent = "Starting\u2026";
1471
+ fetch("/api/jobs/" + encodeURIComponent(e.retry) + "/retry", { method: "POST" })
1472
+ .then(function(r){ return r.json().then(function(j){ return { ok: r.ok, j: j }; }); })
1473
+ .then(function(o){
1474
+ if (!o.ok || !o.j || !o.j.id) throw new Error((o.j && o.j.error && o.j.error.message) || "Retry failed.");
1475
+ jobId = o.j.id;
1476
+ show("v-run");
1477
+ $("r-stage").textContent = "Queued";
1478
+ watch(o.j.id);
1479
+ })
1480
+ .catch(function(err){
1481
+ b.disabled = false; b.textContent = "Run it again";
1482
+ $("e-hint").textContent = err.message;
1483
+ });
1484
+ };
1485
+ $("e-hint").insertAdjacentElement("afterend", b);
1486
+ }
1487
+ show("v-error");
1488
+ }
1489
+
1490
+ /* ------------------------------------------------------------- wiring */
1491
+ paintFormats();
1492
+ paintThemes();
1493
+ paintSeg("tone", ["plain", "academic", "conversational", "punchy"], "plain");
1494
+ paintSeg("density", ["sparse", "normal", "dense"], "normal");
1495
+ paintSeg("narrationDensity", ["high", "medium", "low"], "high");
1496
+
1497
+ /* Refresh the picker from the server when it can tell us; the inlined table is
1498
+ only a floor. Shape-checked because a stray 200 with an HTML body would
1499
+ otherwise wipe the picker. */
1500
+ fetch("/api/formats", { cache: "no-store" })
1501
+ .then(function(r){ return r.ok ? r.json() : null; })
1502
+ .then(function(list){
1503
+ var arr = Array.isArray(list) ? list : (list && Array.isArray(list.formats) ? list.formats : null);
1504
+ if (!arr) return;
1505
+ var ok = arr.filter(function(f){ return f && f.id && f.width > 0 && f.height > 0; });
1506
+ if (!ok.length) return;
1507
+ FORMATS = ok;
1508
+ /* The canvas bounds travel with the format table. Taken field by field, so a
1509
+ server that sends only some of them keeps the interpolated rest. */
1510
+ var c = list && list.canvas;
1511
+ if (c) {
1512
+ ["minSide","maxSide","maxPixels","maxAspect","legibleWidth"].forEach(function(k){
1513
+ if (typeof c[k] === "number" && c[k] > 0) CANVAS[k] = c[k];
1514
+ });
1515
+ $("cw").min = $("ch").min = String(CANVAS.minSide);
1516
+ $("cw").max = $("ch").max = String(CANVAS.maxSide);
1517
+ }
1518
+ paintFormats();
1519
+ })
1520
+ .catch(function(){});
1521
+
1522
+ var drop = $("drop"), input = $("fileinput");
1523
+ on(drop, "click", function(){ input.click(); });
1524
+ on(drop, "keydown", function(e){
1525
+ if (e.key === "Enter" || e.key === " ") { e.preventDefault(); input.click(); }
1526
+ });
1527
+ on(input, "change", function(){ setFile(input.files && input.files[0]); });
1528
+ on($("f-clear"), "click", clearFile);
1529
+
1530
+ /* Drag state is counted, not toggled: dragleave fires when the pointer crosses
1531
+ onto a CHILD of the drop zone, and a plain toggle flickers the whole time the
1532
+ file is being moved around inside it. */
1533
+ var depth = 0;
1534
+ ["dragenter","dragover"].forEach(function(ev){
1535
+ on(window, ev, function(e){
1536
+ if (!hasFiles(e)) return;
1537
+ e.preventDefault();
1538
+ if (ev === "dragenter") depth++;
1539
+ if (!$("pick").hidden || ev === "dragenter") drop.classList.add("over");
1540
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
1541
+ });
1542
+ });
1543
+ on(window, "dragleave", function(e){ if (hasFiles(e) && --depth <= 0) { depth = 0; drop.classList.remove("over"); } });
1544
+ on(window, "drop", function(e){
1545
+ if (!hasFiles(e)) return;
1546
+ e.preventDefault();
1547
+ depth = 0;
1548
+ drop.classList.remove("over");
1549
+ setFile(e.dataTransfer.files[0]);
1550
+ });
1551
+ function hasFiles(e){
1552
+ var dt = e.dataTransfer;
1553
+ if (!dt) return false;
1554
+ if (dt.types && Array.prototype.indexOf.call(dt.types, "Files") >= 0) return true;
1555
+ return !!(dt.files && dt.files.length);
1556
+ }
1557
+
1558
+ on($("cw"), "input", refreshSize);
1559
+ on($("ch"), "input", refreshSize);
1560
+ on($("slides"), "input", function(){ $("slidesout").textContent = $("slides").value + " slides"; });
1561
+ on($("speed"), "input", function(){ $("speedout").textContent = $("speed").value + "\\u00d7"; });
1562
+ on($("lang"), "change", function(){
1563
+ var other = $("lang").value === "__other";
1564
+ $("langother").hidden = !other;
1565
+ if (other) $("langother").focus();
1566
+ });
1567
+ on($("narrate"), "change", function(){
1568
+ /* A video with no voice is legal, so this only nudges: silence is almost
1569
+ never what someone meant by "render me a video". */
1570
+ if (!$("narrate").checked && $("video").checked) toast("The video will have no narration");
1571
+ refreshAction();
1572
+ });
1573
+ on($("video"), "change", refreshAction);
1574
+ /* The form, not the button: this also catches Enter pressed in the custom-size
1575
+ or voice field, which is what a person expects a form to do. */
1576
+ on($("compose"), "submit", function(e){ e.preventDefault(); submit(); });
1577
+
1578
+ on($("tab-deck"), "click", function(){ pickTab("deck"); });
1579
+ on($("tab-video"), "click", function(){ pickTab("video"); });
1580
+ function pickTab(kind){
1581
+ $("tab-deck").setAttribute("aria-selected", String(kind === "deck"));
1582
+ $("tab-video").setAttribute("aria-selected", String(kind === "video"));
1583
+ mount(kind, mounted.result || {});
1584
+ }
1585
+
1586
+ on($("d-full"), "click", function(){
1587
+ var v = $("d-viewer");
1588
+ if (document.fullscreenElement) document.exitFullscreen();
1589
+ else if (v.requestFullscreen) v.requestFullscreen().catch(function(){ toast("Full screen was blocked"); });
1590
+ else toast("This browser will not go full screen");
1591
+ });
1592
+ on($("d-open"), "click", function(){
1593
+ var r = mounted.result || {};
1594
+ var url = mounted.kind === "video" ? r.videoUrl : r.deckUrl;
1595
+ if (url) window.open(url, "_blank", "noopener");
1596
+ });
1597
+ on($("d-again"), "click", function(){ clearFile(); show("v-compose"); });
1598
+ on($("e-reset"), "click", function(){ clearFile(); show("v-compose"); });
1599
+ on($("e-retry"), "click", function(){ if (file) submit(); else show("v-compose"); });
1600
+
1601
+ /* Leaving mid-build loses the job, so say so. Only while one is live. */
1602
+ on(window, "beforeunload", function(e){
1603
+ if (!$("v-run").hidden && jobId) { e.preventDefault(); e.returnValue = ""; }
1604
+ });
1605
+
1606
+ refreshAction();
1607
+ })();
1608
+ </script>
1609
+ </body>
1610
+ </html>`;
1611
+ }
1612
+ export {
1613
+ uiPage
1614
+ };