@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.
- package/README.md +794 -0
- package/dist/cli.js +8906 -0
- package/dist/deck-runtime.js +55 -0
- package/dist/index.js +8590 -0
- package/dist/mcp.js +7809 -0
- package/dist/server/errors.js +73 -0
- package/dist/server/http.js +504 -0
- package/dist/server/main.js +91 -0
- package/dist/server/options.js +198 -0
- package/dist/server/pipeline.js +356 -0
- package/dist/server/queue.js +195 -0
- package/dist/server/ui.js +1614 -0
- package/dist/server/upload.js +232 -0
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/deck/runtime.d.ts +59 -0
- package/dist/types/deck/subtitles.d.ts +101 -0
- package/dist/types/emit/archetypes/annotated-figure.d.ts +103 -0
- package/dist/types/emit/archetypes/bar-compare.d.ts +30 -0
- package/dist/types/emit/archetypes/callout.d.ts +7 -0
- package/dist/types/emit/archetypes/claim-figure.d.ts +9 -0
- package/dist/types/emit/archetypes/data-table.d.ts +21 -0
- package/dist/types/emit/archetypes/equation-walk.d.ts +2 -0
- package/dist/types/emit/archetypes/grid.d.ts +16 -0
- package/dist/types/emit/archetypes/index.d.ts +19 -0
- package/dist/types/emit/archetypes/line-chart.d.ts +22 -0
- package/dist/types/emit/archetypes/pipeline.d.ts +81 -0
- package/dist/types/emit/archetypes/split-compare.d.ts +2 -0
- package/dist/types/emit/archetypes/stack.d.ts +93 -0
- package/dist/types/emit/archetypes/title.d.ts +188 -0
- package/dist/types/emit/camera.d.ts +397 -0
- package/dist/types/emit/composition.d.ts +191 -0
- package/dist/types/emit/island.d.ts +19 -0
- package/dist/types/emit/kit.d.ts +256 -0
- package/dist/types/emit/svg.d.ts +177 -0
- package/dist/types/emit/theme.d.ts +65 -0
- package/dist/types/emit/themes/index.d.ts +40 -0
- package/dist/types/emit/themes/ink.d.ts +12 -0
- package/dist/types/emit/themes/mono.d.ts +20 -0
- package/dist/types/emit/themes/paper.d.ts +18 -0
- package/dist/types/index.d.ts +200 -0
- package/dist/types/mcp/main.d.ts +2 -0
- package/dist/types/mcp/prereqs.d.ts +22 -0
- package/dist/types/mcp/tools.d.ts +212 -0
- package/dist/types/narrate/narrate.d.ts +87 -0
- package/dist/types/narrate/tts.d.ts +134 -0
- package/dist/types/narrate/voices.d.ts +29 -0
- package/dist/types/pack/media.d.ts +61 -0
- package/dist/types/pack/pack.d.ts +16 -0
- package/dist/types/plan/codex.d.ts +24 -0
- package/dist/types/plan/duration.d.ts +394 -0
- package/dist/types/plan/prompt.d.ts +47 -0
- package/dist/types/plan/refs.d.ts +22 -0
- package/dist/types/plan/select.d.ts +116 -0
- package/dist/types/prefs.d.ts +43 -0
- package/dist/types/render/captions.d.ts +108 -0
- package/dist/types/render/ffmpeg.d.ts +129 -0
- package/dist/types/render/render.d.ts +123 -0
- package/dist/types/render/timing.d.ts +290 -0
- package/dist/types/server/errors.d.ts +11 -0
- package/dist/types/server/http.d.ts +57 -0
- package/dist/types/server/main.d.ts +1 -0
- package/dist/types/server/options.d.ts +90 -0
- package/dist/types/server/pipeline.d.ts +21 -0
- package/dist/types/server/queue.d.ts +105 -0
- package/dist/types/server/ui.d.ts +9 -0
- package/dist/types/server/upload.d.ts +107 -0
- package/dist/types/source/assets.d.ts +11 -0
- package/dist/types/source/fonts.d.ts +15 -0
- package/dist/types/source/markdown.d.ts +8 -0
- package/dist/types/types.d.ts +1992 -0
- package/dist/types/verify/budget.d.ts +41 -0
- package/dist/types/verify/check.d.ts +78 -0
- package/dist/types/verify/drift.d.ts +158 -0
- package/dist/types/verify/fidelity.d.ts +247 -0
- package/dist/types/verify/index.d.ts +207 -0
- package/dist/types/verify/overprint.d.ts +133 -0
- package/dist/types/verify/typefloor.d.ts +50 -0
- 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 · .markdown · .txt · .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">×</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">×</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 — 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 · 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 — Korean</option>
|
|
532
|
+
<option value="ja">\u65E5\u672C\u8A9E — Japanese</option>
|
|
533
|
+
<option value="zh">\u4E2D\u6587 — Chinese</option>
|
|
534
|
+
<option value="__other">Other…</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 · ~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 · 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×</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 — 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
|
+
};
|