@hypersocial/decks-cli 0.9.8 → 0.9.9
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/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +43 -4
- package/skills/hyperdecks/chat.md +8 -2
- package/src/avatar.mjs +13 -2
- package/src/checkRules.mjs +29 -0
- package/src/commands.mjs +14 -6
- package/src/export-video.mjs +15 -10
- package/src/mediaPreviewMake.mjs +72 -0
- package/src/mediaPreviews.mjs +160 -0
- package/src/ops.mjs +45 -9
- package/src/release.mjs +79 -8
- package/src/runtime.mjs +1 -1
- package/src/source.mjs +2 -2
package/package.json
CHANGED
|
@@ -149,7 +149,7 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
|
|
|
149
149
|
| One more link | `hyperdecks share new <slug> "<name>" [--no-forward]` | `create_share` (`allowForward`) |
|
|
150
150
|
| Forwarding, one link | `hyperdecks share forward on\|off\|default <token>` | `set_share_forwarding` |
|
|
151
151
|
| Forwarding, whole deck | `hyperdecks deck forward on\|off <slug>` | `set_deck_forwarding` |
|
|
152
|
-
| Slides or scroll, viewer toggle, pace | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default]` | `set_deck_view` |
|
|
152
|
+
| Slides or scroll, viewer toggle, pace, AI-readable version | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off] [--pace auto\|slide\|step\|default] [--ai-readable on\|off]` | `set_deck_view` |
|
|
153
153
|
| Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
|
|
154
154
|
| All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
|
|
155
155
|
| What one person watched | `hyperdecks engagement "<name>" --deck <slug> --json` | `recipient_engagement` |
|
|
@@ -692,6 +692,43 @@ you do).
|
|
|
692
692
|
| `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
|
|
693
693
|
| `[gesture: <id>]` | one of the head's tracks: `knowing-nod`, `ponder-tilt`, `lightbulb`, `side-eye-hmm`, `chuckle-bob`, `sunny-nod`, `double-take`… (check lists them all) |
|
|
694
694
|
| `[custom: name]` | only the slide's controller sees it |
|
|
695
|
+
| `[after: reveal #id]` | any stage cue above, fired at the END of the word before it (reveal at the end of a phrase) instead of its start |
|
|
696
|
+
- Guest speakers: other avatars that speak their own lines on a slide, no
|
|
697
|
+
slide JS. A `[speaker: <selector> voice=<voice> avatar=<face> name=<caption name>]`
|
|
698
|
+
line runs to the end of its paragraph; the narrator takes the next one:
|
|
699
|
+
|
|
700
|
+
```
|
|
701
|
+
## own
|
|
702
|
+
Let's hear them.
|
|
703
|
+
|
|
704
|
+
[speaker: #own-c1 voice=stock:sarah avatar=cat] Ooh, nice move!
|
|
705
|
+
|
|
706
|
+
[speaker: #own-c2 voice=matilda avatar=monkey name="Mono"] [reveal: #own-c2] Hola, ¿qué tal?
|
|
707
|
+
|
|
708
|
+
That's the idea.
|
|
709
|
+
```
|
|
710
|
+
|
|
711
|
+
`voice` is one of the user's voices, a built-in `stock:<id>` or a character
|
|
712
|
+
voice. `avatar` is a cast id (`cat`, `kitty`, `monkey`, `dog`, `rabbit`,
|
|
713
|
+
`penguin`, `ghost`, `alien`, `agent`…), one of the user's avatars or a
|
|
714
|
+
narrator's name; without one the guest speaks with captions only. `name`
|
|
715
|
+
labels its captions (default: the avatar, capitalised). The guest's head
|
|
716
|
+
fills the element the selector names, or the `[data-hd-speaker]` spot
|
|
717
|
+
inside it, so give each card a face box (e.g. 140×140). Narrate renders
|
|
718
|
+
each line in its own voice (cached, transcript-checked) into the slide's
|
|
719
|
+
timeline: the narrator pauses, the guests speak in turn with lip-sync and a
|
|
720
|
+
caption bubble by their element, its tail at their face, the spoken word
|
|
721
|
+
highlighted ("Cat: Ooh, nice move!"), only while they talk; the speaker's
|
|
722
|
+
element is spotlit and the other guests dim. The player draws that bubble:
|
|
723
|
+
don't put static speech bubbles or the guest's line on the slide too. The presenter
|
|
724
|
+
glances at whoever speaks, then the narrator goes on. A cue may open a
|
|
725
|
+
guest's line and fires as it starts. Inside a guest's line, `[nod]`,
|
|
726
|
+
`[shake]`, `[brow]` and `[gesture]` move the guest's head; the other cues
|
|
727
|
+
(`[reveal]`, `[go]`, `[mood]`, `[wave]`…) act on the slide and the presenter. Replay, Per step (each guest line is a
|
|
728
|
+
step), mute, You present and video export all include them.
|
|
729
|
+
- Audio and data files a slide uses itself (a sound effect, a chart's JSON):
|
|
730
|
+
`hyperdecks asset add` / `upload_deck_asset` take `.mp3`, `.m4a` and
|
|
731
|
+
`.json` as well as images, video and fonts.
|
|
695
732
|
- `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
|
|
696
733
|
character voice (not a clone of anyone) any account may use, for decks that
|
|
697
734
|
D.A.S.H. presents. It works once the deployment sets `CHARACTER_VOICE_DASH`.
|
|
@@ -867,8 +904,10 @@ hyperdecks engagement "<name>" --deck <slug> --json # MCP: recipient_engagemen
|
|
|
867
904
|
Then draft the email:
|
|
868
905
|
|
|
869
906
|
1. Lead with the slide they spent most time on (`summary.mostTime`) or
|
|
870
|
-
replayed (`replayedSlides`
|
|
871
|
-
|
|
907
|
+
replayed (`replayedSlides`; `slideReplays` are the ones they replayed on
|
|
908
|
+
purpose, most first, and `replayNote` says it in one line), and answer what
|
|
909
|
+
that slide raises. Each slide's `title` says what it is about. Watching it
|
|
910
|
+
again from the start (`watchedAgain`) is strong interest.
|
|
872
911
|
2. If they dropped off (`droppedOffOn`), offer the rest briefly, or a
|
|
873
912
|
2-minute call, rather than asking them to rewatch.
|
|
874
913
|
3. If they never opened it (`summary.visits` is 0), write a short nudge
|
|
@@ -889,7 +928,7 @@ the hosted MCP (`deck_guide`, `new_deck`, `get_deck_source`, `write_deck_source`
|
|
|
889
928
|
`upload_deck_asset`, `check_deck`, `release_deck`; see `chat.md`). Move between
|
|
890
929
|
the two with `hyperdecks source pull <slug> [dir]` (the hosted draft into a
|
|
891
930
|
folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
|
|
892
|
-
draft), `hyperdecks asset add <slug> <file|url>` for an image, `hyperdecks guide
|
|
931
|
+
draft), `hyperdecks asset add <slug> <file|url>` for an image, video, audio (.mp3, .m4a), font or JSON file, `hyperdecks guide
|
|
893
932
|
[topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
|
|
894
933
|
|
|
895
934
|
## After you ship: say what cost you
|
|
@@ -15,7 +15,9 @@ narrators, credits) and the steps. No voice of their own: `clone_voice_link`
|
|
|
15
15
|
in the chat, only after they agree to the consent statement, word for word.
|
|
16
16
|
`hear_voice` plays a voice or a narrator in the chat. A face from a photo they
|
|
17
17
|
attach: `avatar_from_photo { file }`, then `check_avatar_draft` until it hands
|
|
18
|
-
back the link they approve it on
|
|
18
|
+
back the link they approve it on (a pet photo comes back with an `offer`, say
|
|
19
|
+
"That looks like a cat. Make a cat avatar?": ask them, and on a yes
|
|
20
|
+
`new_avatar_link { species }`). `rename_narrator` and `set_narrator_face`
|
|
19
21
|
change a narrator when they ask. Then `get_started { name, about, ask, voice }`
|
|
20
22
|
makes the hello deck; continue from step 5 below.
|
|
21
23
|
|
|
@@ -95,7 +97,11 @@ makes the hello deck; continue from step 5 below.
|
|
|
95
97
|
for how the deck opens, whether viewers may switch, and its pace (`auto`
|
|
96
98
|
plays itself, `slide` waits for Next after each slide, `step` also stops
|
|
97
99
|
before each `[reveal]`, so group a slide's reveals into meaningful steps)
|
|
98
|
-
(every link, next load, no re-release)
|
|
100
|
+
(every link, next load, no re-release). Any share link + `/md` (e.g.
|
|
101
|
+
https://decks.hypersocial.ai/v/<token>/md) returns the deck as markdown
|
|
102
|
+
(slide text, image descriptions and the narration), honouring the link's
|
|
103
|
+
gates; owners can turn it off with `set_deck_view { slug, aiReadable: false }`.
|
|
104
|
+
`set_share_notify` to mute a link's "opened" alerts;
|
|
99
105
|
`set_recipient_archived` to tidy the activity feed; `credits` for the
|
|
100
106
|
balance. `remove_deck` deletes a deck and breaks every link to it: confirm
|
|
101
107
|
with the user first.
|
package/src/avatar.mjs
CHANGED
|
@@ -59,7 +59,7 @@ export async function avatarNew(opts, { ask, log = console.log, interactive = in
|
|
|
59
59
|
|
|
60
60
|
// A face from a photo file, or (no file) a selfie in the browser. Either way the
|
|
61
61
|
// person approves it on the link before it is used.
|
|
62
|
-
export async function avatarFromPhoto(file, opts, { log = console.log, interactive = interactiveOf(opts) } = {}) {
|
|
62
|
+
export async function avatarFromPhoto(file, opts, { ask, log = console.log, interactive = interactiveOf(opts) } = {}) {
|
|
63
63
|
let photo = {};
|
|
64
64
|
if (file) {
|
|
65
65
|
const contentType = PHOTO_TYPES[extname(file).toLowerCase()];
|
|
@@ -67,8 +67,19 @@ export async function avatarFromPhoto(file, opts, { log = console.log, interacti
|
|
|
67
67
|
photo = { bytes: readFileSync(file), contentType };
|
|
68
68
|
}
|
|
69
69
|
const out = await ops.avatarFromPhoto(opts, photo, { onProgress: (m) => !opts.json && log(m) });
|
|
70
|
-
const url = out.reviewUrl ?? out.selfieUrl;
|
|
71
70
|
if (opts.json) return out;
|
|
71
|
+
if (out.status === "failed" && out.suggestedSpecies) {
|
|
72
|
+
// No human face, but a pet: offer that species' faces instead.
|
|
73
|
+
log(out.offer);
|
|
74
|
+
if (!interactive || !ask) {
|
|
75
|
+
log(` hyperdecks avatar new --species ${out.suggestedSpecies}`);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
const a = (await ask("Make one? [Y/n]: ")).trim().toLowerCase();
|
|
79
|
+
if (a && a !== "y" && a !== "yes") throw new Error(out.error || "no face was found in that photo");
|
|
80
|
+
return avatarNew({ ...opts, species: out.suggestedSpecies }, { ask, log, interactive });
|
|
81
|
+
}
|
|
82
|
+
const url = out.reviewUrl ?? out.selfieUrl;
|
|
72
83
|
log(file ? `✓ Drew a cartoon of you. Check it before it's used:\n ${url}` : `No photo given. Take a selfie in your browser:\n ${url}`);
|
|
73
84
|
if (opts["save-to-minimoji"]) log(" (tick \"Also save it to my Minimoji account\" there)");
|
|
74
85
|
if (!interactive) return out;
|
package/src/checkRules.mjs
CHANGED
|
@@ -37,6 +37,7 @@ export const MESSAGES = {
|
|
|
37
37
|
noLoading: () => "no loading state: run `hyperdecks check <deck> --fix` (`check <slug> --hosted --fix`; over the MCP, write index.html again with write_deck_source), which needs a <head>…</head> and the player script; or copy the <style data-hd-loading> block and the lines above it from the narrated template into <head>; set --hd-ground to the first slide's colour",
|
|
38
38
|
clipField: (id, field) => `clip ${id}: ${field}`,
|
|
39
39
|
noCaptions: (id) => `clip ${id}: timeline has no captions`,
|
|
40
|
+
speakerMissing: (id, name, selector) => `slide ${id}: guest ${name} speaks from "${selector}", which is not on the slide, so its lines play with captions only and no face`,
|
|
40
41
|
order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
|
|
41
42
|
total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
|
|
42
43
|
cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
|
|
@@ -605,6 +606,23 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
|
|
|
605
606
|
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
606
607
|
const isStr = (v) => typeof v === "string" && v.trim().length > 0;
|
|
607
608
|
|
|
609
|
+
// Whether a guest's selector names something on its slide: an #id, or a
|
|
610
|
+
// .class or [attr] inside that <section>. Other selectors are taken on trust.
|
|
611
|
+
export function speakerOnSlide(html, slideId, selector) {
|
|
612
|
+
const page = uncommented(html);
|
|
613
|
+
const open = new RegExp(`<section\\b[^>]*\\sid\\s*=\\s*["']${slideId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["'][^>]*>`, "i").exec(page);
|
|
614
|
+
if (!open) return false;
|
|
615
|
+
const end = page.indexOf("</section>", open.index);
|
|
616
|
+
const section = page.slice(open.index, end < 0 ? undefined : end);
|
|
617
|
+
const id = /^#([\w-]+)$/.exec(selector.trim());
|
|
618
|
+
if (id) return new RegExp(`\\sid\\s*=\\s*["']${id[1]}["']`).test(section);
|
|
619
|
+
const cls = /^\.([\w-]+)$/.exec(selector.trim());
|
|
620
|
+
if (cls) return new RegExp(`\\sclass\\s*=\\s*["'][^"']*\\b${cls[1]}\\b`).test(section);
|
|
621
|
+
const attr = /^\[([\w-]+)/.exec(selector.trim());
|
|
622
|
+
if (attr) return new RegExp(`\\s${attr[1]}(\\s*=|[\\s>])`).test(section);
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
|
|
608
626
|
// Fields every clip needs before its other rules mean anything.
|
|
609
627
|
export function clipFieldErrors(clip, i) {
|
|
610
628
|
const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
|
|
@@ -937,6 +955,17 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
937
955
|
continue;
|
|
938
956
|
}
|
|
939
957
|
errors.push(...timelineErrors(clip, timeline));
|
|
958
|
+
// Guest lines ([speaker: …]): each is audio of its own, and each speaker needs its element on the slide.
|
|
959
|
+
for (const [g, guest] of (Array.isArray(clip.guests) ? clip.guests : []).entries()) {
|
|
960
|
+
let url;
|
|
961
|
+
try { url = new URL(guest?.audio, player.manifest).href; } catch { errors.push(MESSAGES.clipField(clip.id, `guest line ${g + 1} needs an audio URL`)); continue; }
|
|
962
|
+
const got = await fetchCheck(fetchImpl, url, AUDIO);
|
|
963
|
+
if (got.why) errors.push(MESSAGES.url(clip.id, `guest line ${g + 1} audio`, url, got.why));
|
|
964
|
+
else await got.response.body?.cancel?.().catch(() => {});
|
|
965
|
+
}
|
|
966
|
+
for (const sp of Array.isArray(clip.speakers) ? clip.speakers : []) {
|
|
967
|
+
if (sp?.selector && !speakerOnSlide(html, clip.id, sp.selector)) warnings.push(MESSAGES.speakerMissing(clip.id, sp.name ?? "a guest", sp.selector));
|
|
968
|
+
}
|
|
940
969
|
}
|
|
941
970
|
}
|
|
942
971
|
warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
|
package/src/commands.mjs
CHANGED
|
@@ -158,28 +158,32 @@ async function deckForward(pos, opts) {
|
|
|
158
158
|
console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]";
|
|
161
|
+
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
|
|
162
162
|
const VIEWS = { slides: "slides", scroll: "scroll", default: null };
|
|
163
163
|
const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
|
|
164
164
|
async function deckView(pos, opts) {
|
|
165
165
|
const [slug, mode] = pos;
|
|
166
166
|
const toggle = opts.toggle;
|
|
167
167
|
const pace = opts.pace;
|
|
168
|
-
|
|
168
|
+
const ai = opts["ai-readable"];
|
|
169
|
+
if (!slug || (mode === undefined && !["toggle", "pace", "ai-readable"].some((k) => Object.hasOwn(opts, k)))) throw new Error(VIEW_USAGE);
|
|
169
170
|
if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
|
|
170
171
|
// A trailing --toggle with no value is a mistake, not "leave it".
|
|
171
172
|
if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
|
|
172
173
|
if (Object.hasOwn(opts, "pace") && !Object.hasOwn(PACES, pace)) throw new Error(VIEW_USAGE);
|
|
174
|
+
if (Object.hasOwn(opts, "ai-readable") && ai !== "on" && ai !== "off") throw new Error(VIEW_USAGE);
|
|
173
175
|
const out = await ops.setDeckView(opts, slug, {
|
|
174
176
|
view: mode === undefined ? undefined : VIEWS[mode],
|
|
175
177
|
viewToggle: toggle === undefined ? undefined : toggle === "on",
|
|
176
178
|
pace: pace === undefined ? undefined : PACES[pace],
|
|
179
|
+
aiReadable: ai === undefined ? undefined : ai === "on",
|
|
177
180
|
});
|
|
178
181
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
179
182
|
const said = [];
|
|
180
183
|
if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
|
|
181
184
|
if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
|
|
182
185
|
if (pace !== undefined) said.push(pace === "default" ? "paces as its HTML says" : `pace ${pace}`);
|
|
186
|
+
if (ai !== undefined) said.push(`AI-readable version (/v/<token>/md) ${ai}`);
|
|
183
187
|
if (out.applies === false) {
|
|
184
188
|
console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
|
|
185
189
|
console.warn(`warning: ${out.warning}`);
|
|
@@ -256,7 +260,7 @@ async function engagement(pos, opts) {
|
|
|
256
260
|
console.log(` visits: ${s.visits} (last ${new Date(s.lastSeenAt).toISOString().slice(0, 16).replace("T", " ")}) · ${fmtMs(s.totalMs)} engaged`);
|
|
257
261
|
console.log(` narration: ${s.narration.finished ? "finished" : s.narration.started ? "started" : "not started"} · heard ${pct(s.narration.heardPct)}${s.narration.telemetry === "v3" ? "" : " (playback detail unavailable)"}`);
|
|
258
262
|
console.log(` reached: slide ${s.furthestSlide}${e.deck.slides ? ` of ${e.deck.slides}` : ""}${s.droppedOffOn ? ` · dropped off on ${slideName(s.droppedOffOn)}` : ""}`);
|
|
259
|
-
console.log(` replayed: ${list(s.replayedSlides)}`);
|
|
263
|
+
console.log(` replayed: ${list(s.replayedSlides)}${s.replayNote ? ` · ${s.replayNote}` : ""}`);
|
|
260
264
|
console.log(` skipped: ${list(s.skippedSlides)}`);
|
|
261
265
|
console.log(` most time: ${s.mostTime.map((m) => `${slideName(m)} (${fmtMs(m.ms)})`).join(", ") || "none"}`);
|
|
262
266
|
if (!s.slideData) console.log(" slide data unavailable (this deck reports time only)");
|
|
@@ -347,9 +351,10 @@ commands:
|
|
|
347
351
|
deck list|stats|open|rm|forward|view
|
|
348
352
|
deck forward on|off <slug>
|
|
349
353
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
350
|
-
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default]
|
|
354
|
+
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
|
|
351
355
|
how the deck opens in the player (default: as its HTML says, data-view or slides) and
|
|
352
356
|
whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
|
|
357
|
+
--ai-readable: whether each link + /md serves the deck as markdown for AI assistants (on by default).
|
|
353
358
|
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
|
|
354
359
|
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
|
|
355
360
|
in your saved brand's colours, fonts and logo (--no-brand: the template's look)
|
|
@@ -364,7 +369,7 @@ commands:
|
|
|
364
369
|
source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
|
|
365
370
|
move a hosted draft (index.html, script.md, evidence.md) to a folder and back
|
|
366
371
|
asset add <slug> <file|url> [--name n]
|
|
367
|
-
file an image, video
|
|
372
|
+
file an image, video, audio (mp3, m4a), font or JSON under the deck; prints its URL
|
|
368
373
|
guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
|
|
369
374
|
the skill, one topic at a time (what deck_guide serves)
|
|
370
375
|
play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
|
|
@@ -898,8 +903,11 @@ function commandTable(pos, rest, opts) {
|
|
|
898
903
|
},
|
|
899
904
|
"avatar from-photo": async () => {
|
|
900
905
|
const { avatarFromPhoto } = await import("./avatar.mjs");
|
|
901
|
-
const
|
|
906
|
+
const { askLine } = await import("./init.mjs");
|
|
907
|
+
const out = await avatarFromPhoto(rest[0], opts, { ask: askLine });
|
|
902
908
|
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
909
|
+
// A pet photo is still a failed drawing: the offer is printed, the exit says so.
|
|
910
|
+
if (out?.status === "failed") process.exitCode = 1;
|
|
903
911
|
},
|
|
904
912
|
"avatar connect": async () => {
|
|
905
913
|
const { avatarConnect } = await import("./avatar.mjs");
|
package/src/export-video.mjs
CHANGED
|
@@ -115,7 +115,8 @@ export function planTimeline(plan) {
|
|
|
115
115
|
export function audioTimeline(log, markMs, lead = 0) {
|
|
116
116
|
const audioOf = new Map((log.plan ?? []).map((s) => [s.id, s.audio]));
|
|
117
117
|
return (log.clips ?? [])
|
|
118
|
-
|
|
118
|
+
// A guest line's entry names its own audio; `seek`: the slide's track picking up again after one.
|
|
119
|
+
.map((c) => ({ id: c.id, audio: c.audio ?? audioOf.get(c.id) ?? null, offset: lead + Math.max(0, (c.at - markMs) / 1000), ...(c.seek > 0 ? { seek: c.seek } : {}) }))
|
|
119
120
|
.filter((c) => c.audio);
|
|
120
121
|
}
|
|
121
122
|
|
|
@@ -125,7 +126,8 @@ export function audioTimeline(log, markMs, lead = 0) {
|
|
|
125
126
|
export function timelineDrift(log, markMs) {
|
|
126
127
|
const plan = planTimeline(log.plan ?? []);
|
|
127
128
|
const startOf = new Map(plan.ids.map((id, i) => [id, plan.starts[i]]));
|
|
128
|
-
|
|
129
|
+
// Only each slide's own start: guest lines and resumes (entries with their own audio) are inside it.
|
|
130
|
+
return (log.clips ?? []).filter((c) => startOf.has(c.id) && !c.audio).map((c) => ({ id: c.id, drift: (c.at - markMs) / 1000 - startOf.get(c.id) }));
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
// ffmpeg args for the audio track, `seconds` long: silence up to the first
|
|
@@ -147,7 +149,9 @@ export function audioMixArgs({ inputs, seconds, out }) {
|
|
|
147
149
|
}
|
|
148
150
|
clips.forEach((c, n) => {
|
|
149
151
|
const end = n + 1 < clips.length ? clips[n + 1].offset : seconds;
|
|
150
|
-
|
|
152
|
+
// `seek`: start that far into the file (a slide's track resuming after a guest line).
|
|
153
|
+
const from = c.seek > 0 ? `,atrim=start=${Number(c.seek).toFixed(3)},asetpts=PTS-STARTPTS` : '';
|
|
154
|
+
parts.push(`[${n}:a]${fmt}${from},apad,atrim=0:${Math.max(0.001, end - c.offset).toFixed(3)},asetpts=N/SR/TB[c${n}]`);
|
|
151
155
|
labels.push(`[c${n}]`);
|
|
152
156
|
});
|
|
153
157
|
parts.push(`${labels.join('')}concat=n=${labels.length}:v=0:a=1[mix]`);
|
|
@@ -185,7 +189,7 @@ export const SEAM_SECONDS = 2;
|
|
|
185
189
|
export const SEAM_FRAMES = SEAM_SECONDS * VIDEO_FPS;
|
|
186
190
|
export const MAX_SPLIT_CLIPS = 200;
|
|
187
191
|
export function splittableAudio(plan) {
|
|
188
|
-
const audio = plan.
|
|
192
|
+
const audio = plan.flatMap((s) => [s.audio, ...(Array.isArray(s.guests) ? s.guests : [])]).filter(Boolean);
|
|
189
193
|
return audio.length <= MAX_SPLIT_CLIPS && audio.every((a) => typeof a === 'string' && a.length <= MAX_SPLIT_AUDIO_URL && /^https?:\/\//i.test(a));
|
|
190
194
|
}
|
|
191
195
|
|
|
@@ -223,7 +227,7 @@ export function joinTimeline(segments, fps = VIDEO_FPS) {
|
|
|
223
227
|
let frames = 0;
|
|
224
228
|
const clips = [];
|
|
225
229
|
for (const s of segments) {
|
|
226
|
-
for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)) });
|
|
230
|
+
for (const c of s.clips ?? []) clips.push({ id: c.id, audio: c.audio, offset: Math.max(0, frames / fps + (Number(c.offset) || 0)), ...(c.seek > 0 ? { seek: c.seek } : {}) });
|
|
227
231
|
frames += s.frames;
|
|
228
232
|
}
|
|
229
233
|
return { clips, frames, seconds: frames / fps };
|
|
@@ -503,8 +507,9 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
503
507
|
// A range needs only its own slides' clips.
|
|
504
508
|
const audioFiles = new Map(), clipLengths = {};
|
|
505
509
|
const wanted = (state.plan ?? []).filter((s) => inRange(s.id));
|
|
506
|
-
|
|
507
|
-
|
|
510
|
+
// A slide's guest lines are audio of their own.
|
|
511
|
+
for (const [n, url] of [...new Set(wanted.flatMap((s) => [s.audio, ...(Array.isArray(s.guests) ? s.guests : [])]).filter(Boolean))].entries()) {
|
|
512
|
+
const id = wanted.find((s) => s.audio === url || s.guests?.includes(url)).id;
|
|
508
513
|
const path = clipPath(temp, n, url);
|
|
509
514
|
await downloadClip(url, path, { id, entryUrl: pageUrl });
|
|
510
515
|
audioFiles.set(url, path);
|
|
@@ -643,13 +648,13 @@ export async function recordVideo(config, { log = () => {}, chromium: injected,
|
|
|
643
648
|
file: out, seconds: kept / VIDEO_FPS, width: geometry.width, height: geometry.height, bytes, slides: to - from + 1, clips: own.length, render, warnings,
|
|
644
649
|
// frames: in the file. A clip's offset is from the file's first frame:
|
|
645
650
|
// after a dropped seam, a slide's clip may start (just) before it.
|
|
646
|
-
segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS })) },
|
|
651
|
+
segment: { from, to, frames: kept, tail, clips: own.map((c) => ({ id: c.id, audio: c.audio, offset: c.offset - skip / VIDEO_FPS, ...(c.seek ? { seek: c.seek } : {}) })) },
|
|
647
652
|
};
|
|
648
653
|
}
|
|
649
654
|
const inputs = clips.map((c) => {
|
|
650
655
|
const path = audioFiles.get(c.audio);
|
|
651
656
|
if (!path) throw new Error(`clip ${c.id} played audio the plan did not list (${c.audio})`);
|
|
652
|
-
return { path, offset: c.offset };
|
|
657
|
+
return { path, offset: c.offset, seek: c.seek };
|
|
653
658
|
});
|
|
654
659
|
const audio = join(temp, 'audio.wav');
|
|
655
660
|
await ffmpeg(ff, audioMixArgs({ inputs, seconds: total, out: audio }), 300000);
|
|
@@ -695,7 +700,7 @@ export async function joinVideo({ segments, out, origin = null }, { log = () =>
|
|
|
695
700
|
}
|
|
696
701
|
phase = 'audio';
|
|
697
702
|
const audio = join(temp, 'audio.wav');
|
|
698
|
-
await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset })), seconds: timeline.seconds, out: audio }), 300000);
|
|
703
|
+
await ffmpeg(ff, audioMixArgs({ inputs: timeline.clips.map((c) => ({ path: files.get(c.audio), offset: c.offset, seek: c.seek })), seconds: timeline.seconds, out: audio }), 300000);
|
|
699
704
|
phase = 'join';
|
|
700
705
|
const list = join(temp, 'segments.txt');
|
|
701
706
|
await writeFile(list, concatList(segments.map((s) => s.file)));
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// The pixels behind mediaPreviews.mjs: ffmpeg (the @ffmpeg-installer build the
|
|
2
|
+
// CLI's video export and Convex's videoStack already use) reads one image or a
|
|
3
|
+
// video's first frame and writes
|
|
4
|
+
// - a ~20 px wide JPEG as a data URL (the blur preview, well under 1 KB), and
|
|
5
|
+
// - for a video, a poster: its first frame as a JPEG at most 1280 px wide.
|
|
6
|
+
// Used by the CLI release (local files) and convex/mediaPreviewsNode.ts (fetched
|
|
7
|
+
// bytes written to a temp file). The size comes from ffmpeg's own stream line.
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { streamSize } from "./mediaPreviews.mjs";
|
|
10
|
+
|
|
11
|
+
export const PREVIEW_WIDTH = 20;
|
|
12
|
+
const TIMEOUT_MS = 30_000;
|
|
13
|
+
|
|
14
|
+
function run(ffmpeg, args) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
execFile(ffmpeg, args, { encoding: "buffer", maxBuffer: 16 * 1024 * 1024, timeout: TIMEOUT_MS }, (err, stdout, stderr) => {
|
|
17
|
+
const text = stderr.toString("utf8");
|
|
18
|
+
if (err) {
|
|
19
|
+
const why = err.killed ? `ffmpeg timed out after ${TIMEOUT_MS / 1000}s` : text.trim().split("\n").slice(-2).join(" ") || err.message;
|
|
20
|
+
return reject(new Error(why.slice(0, 300)));
|
|
21
|
+
}
|
|
22
|
+
resolve({ stdout, stderr: text });
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Only local files by default: a "video" that is really a playlist (HLS) would
|
|
28
|
+
// otherwise make ffmpeg fetch its segment URLs itself, past any SSRF guard.
|
|
29
|
+
// Nested demuxers inherit the whitelist. The CLI widens it for an https input.
|
|
30
|
+
export const LOCAL_ONLY = "file,pipe";
|
|
31
|
+
export const HTTPS_TOO = "file,pipe,https,tls,tcp,crypto";
|
|
32
|
+
// A picture with more pixels than this (a "pixel bomb": a small file that
|
|
33
|
+
// decodes to gigabytes) is refused by the decoder instead of exhausting memory.
|
|
34
|
+
export const MAX_PIXELS = 64 * 1024 * 1024;
|
|
35
|
+
const frameArgs = (input, allow, vf, q, codec = "mjpeg") => ["-hide_banner", "-nostdin", "-protocol_whitelist", allow, "-max_pixels", String(MAX_PIXELS), "-i", input, "-map", "0:v:0", "-frames:v", "1", "-vf", vf, ...(codec === "mjpeg" ? ["-q:v", String(q)] : []), "-f", "image2pipe", "-c:v", codec, "pipe:1"];
|
|
36
|
+
// A picture with an alpha channel (a background-removed cutout) keeps it: a
|
|
37
|
+
// JPEG preview would put a black box behind the cutout while it loads.
|
|
38
|
+
export const hasAlpha = (stderr) => /Stream #[^\n]*?Video:[^\n]*?\b(rgba|bgra|argb|abgr|ya8|ya16\w*|yuva\w*|gbrap\w*|rgba64\w*|pal8)\b/.test(stderr ?? "");
|
|
39
|
+
|
|
40
|
+
// { w, h, blur, poster? } for one file (a path, or a URL ffmpeg can open).
|
|
41
|
+
// `poster` (video only) is the JPEG bytes; the caller stores it and swaps in its URL.
|
|
42
|
+
// A picture or video container by its first bytes: JPEG, PNG, GIF, WebP, ISO
|
|
43
|
+
// BMFF (MP4/MOV/AVIF/HEIC) or EBML (WebM). Anything else (an HLS playlist, an
|
|
44
|
+
// HTML error page) never reaches ffmpeg.
|
|
45
|
+
export function knownMedia(bytes) {
|
|
46
|
+
const b = bytes.subarray(0, 12), ascii = (from, to) => String.fromCharCode(...b.subarray(from, to));
|
|
47
|
+
return (b[0] === 0xff && b[1] === 0xd8) || ascii(1, 4) === "PNG" || ascii(0, 3) === "GIF"
|
|
48
|
+
|| (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP") || ascii(4, 8) === "ftyp"
|
|
49
|
+
|| (b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function makePreview(ffmpeg, input, { video = false, allow = LOCAL_ONLY } = {}) {
|
|
53
|
+
let small = await run(ffmpeg, frameArgs(input, allow, `scale=${PREVIEW_WIDTH}:-2:flags=area,format=yuvj420p`, 12));
|
|
54
|
+
const size = streamSize(small.stderr);
|
|
55
|
+
if (!size) throw new Error(`no picture in ${input}`);
|
|
56
|
+
let type = "image/jpeg";
|
|
57
|
+
if (!video && hasAlpha(small.stderr)) {
|
|
58
|
+
small = await run(ffmpeg, frameArgs(input, allow, `scale=${PREVIEW_WIDTH}:-2:flags=area,format=rgba`, 0, "png"));
|
|
59
|
+
type = "image/png";
|
|
60
|
+
}
|
|
61
|
+
if (!small.stdout.length) throw new Error(`ffmpeg wrote no preview for ${input}`);
|
|
62
|
+
const out = { ...size, blur: `data:${type};base64,${small.stdout.toString("base64")}` };
|
|
63
|
+
if (!video) return out;
|
|
64
|
+
const frame = await run(ffmpeg, frameArgs(input, allow, "scale='min(1280,iw)':-2,format=yuvj420p", 4));
|
|
65
|
+
if (!frame.stdout.length) throw new Error(`ffmpeg wrote no poster for ${input}`);
|
|
66
|
+
return { ...out, poster: frame.stdout };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The bundled ffmpeg's path, loaded only when release needs it.
|
|
70
|
+
export async function bundledFfmpeg() {
|
|
71
|
+
return (await import("@ffmpeg-installer/ffmpeg")).default.path;
|
|
72
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Blur previews for a deck's media, added at release (CLI release.mjs and the
|
|
2
|
+
// hosted release in convex/deckSources.ts). Pure string work, no Node APIs, so
|
|
3
|
+
// the Convex mutation can run it; the pixels come from mediaPreviewMake.mjs.
|
|
4
|
+
//
|
|
5
|
+
// mediaRefs(html) lists what needs a preview: each <img>, each <video> without
|
|
6
|
+
// a poster (its first frame becomes one) and each inline background image.
|
|
7
|
+
// withMediaPreviews(html, previews) writes them onto the tags:
|
|
8
|
+
// data-hd-blur="data:image/jpeg;base64,…" a ~20 px wide preview (< 1 KB)
|
|
9
|
+
// style="--hd-ar:W/H;…" so the box has its shape before the pixels arrive
|
|
10
|
+
// (player.css: :where([data-hd-blur]) { aspect-ratio: var(--hd-ar) })
|
|
11
|
+
// poster="…" a <video> that had none
|
|
12
|
+
// The player (packages/player/media-load.js) shows the preview, blurred and
|
|
13
|
+
// shimmering, until the real media is ready. Idempotent: a tag that already has
|
|
14
|
+
// data-hd-blur (or opts out with data-hd-no-blur) is left as it is, so a
|
|
15
|
+
// re-release changes nothing.
|
|
16
|
+
|
|
17
|
+
// Media this small is an icon: a blurred icon is noise, not a preview.
|
|
18
|
+
export const MIN_PREVIEW_SIDE = 64;
|
|
19
|
+
// The longest data URL written onto a tag; a bigger preview is dropped.
|
|
20
|
+
export const MAX_BLUR_CHARS = 1600;
|
|
21
|
+
|
|
22
|
+
const SKIP = /^(data:|blob:|javascript:|#)/i;
|
|
23
|
+
const VECTOR = /\.svg(?:[?#]|$)/i;
|
|
24
|
+
|
|
25
|
+
export function tagAttr(tag, name) {
|
|
26
|
+
const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, "i").exec(tag);
|
|
27
|
+
if (!m) return null;
|
|
28
|
+
return (m[1] ?? m[2] ?? m[3]).replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'");
|
|
29
|
+
}
|
|
30
|
+
const hasAttr = (tag, name) => new RegExp(`\\s${name}(?:\\s*=|[\\s/>])`, "i").test(tag);
|
|
31
|
+
|
|
32
|
+
function usable(src) {
|
|
33
|
+
const s = (src ?? "").trim();
|
|
34
|
+
return s && !SKIP.test(s) && !VECTOR.test(s) ? s : null;
|
|
35
|
+
}
|
|
36
|
+
// A tag the author sized as an icon (both sides given and small) is skipped up front.
|
|
37
|
+
function iconSized(tag) {
|
|
38
|
+
const w = Number(tagAttr(tag, "width")), h = Number(tagAttr(tag, "height"));
|
|
39
|
+
return w > 0 && h > 0 && Math.max(w, h) < MIN_PREVIEW_SIDE;
|
|
40
|
+
}
|
|
41
|
+
const done = (tag) => hasAttr(tag, "data-hd-blur") || hasAttr(tag, "data-hd-no-blur");
|
|
42
|
+
|
|
43
|
+
// Tags are matched quote-aware, so a `>` inside an attribute value (alt="a > b") does not end the tag.
|
|
44
|
+
const ATTRS = `(?:[^>"']|"[^"]*"|'[^']*')*`;
|
|
45
|
+
const IMG = new RegExp(`<img\\b${ATTRS}>`, "gi");
|
|
46
|
+
const VIDEO_OPEN = new RegExp(`^<video\\b${ATTRS}>`, "i");
|
|
47
|
+
const VIDEO = new RegExp(`<video\\b${ATTRS}>([\\s\\S]*?)<\\/video\\s*>`, "gi");
|
|
48
|
+
const STYLED = new RegExp(`<[a-z][\\w-]*\\b(?:[^>"']|"[^"]*"|'[^']*')*?\\sstyle\\s*=\\s*(?:"[^"]*"|'[^']*')${ATTRS}>`, "gi");
|
|
49
|
+
// Script, style, comment and template text is code, not markup: never read or rewritten.
|
|
50
|
+
const OPAQUE = /<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>|<!--[\s\S]*?-->|<template\b[\s\S]*?<\/template\s*>/gi;
|
|
51
|
+
function markupOnly(html, rewrite) {
|
|
52
|
+
const kept = [];
|
|
53
|
+
const masked = html.replace(OPAQUE, (m) => `\u0000${kept.push(m) - 1}\u0000`);
|
|
54
|
+
return rewrite(masked).replace(/\u0000(\d+)\u0000/g, (_, i) => kept[Number(i)]);
|
|
55
|
+
}
|
|
56
|
+
const BG_URL = /background(?:-image)?\s*:[^;]*?url\(\s*(?:"([^"]+)"|'([^']+)'|([^)\s]+))\s*\)/i;
|
|
57
|
+
|
|
58
|
+
function videoSrc(open, body) {
|
|
59
|
+
return usable(tagAttr(open, "src")) ?? usable([...body.matchAll(/<source\b[^>]*>/gi)].map((m) => tagAttr(m[0], "src")).find(usable));
|
|
60
|
+
}
|
|
61
|
+
function bgSrc(tag) {
|
|
62
|
+
const m = BG_URL.exec(tagAttr(tag, "style") ?? "");
|
|
63
|
+
return m ? usable(m[1] ?? m[2] ?? m[3]) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// What release must make previews for: { images: [src], videos: [src] }, each
|
|
67
|
+
// src once, as written in the page (relative or absolute).
|
|
68
|
+
export function mediaRefs(page) {
|
|
69
|
+
const images = new Set(), videos = new Set();
|
|
70
|
+
const html = page.replace(OPAQUE, "");
|
|
71
|
+
for (const [tag] of html.matchAll(IMG)) {
|
|
72
|
+
const src = usable(tagAttr(tag, "src"));
|
|
73
|
+
if (src && !done(tag) && !iconSized(tag)) images.add(src);
|
|
74
|
+
}
|
|
75
|
+
for (const m of html.matchAll(VIDEO)) {
|
|
76
|
+
const open = VIDEO_OPEN.exec(m[0])[0];
|
|
77
|
+
// A transparent clip (alpha-video.js swaps it for a canvas) has no frame to blur.
|
|
78
|
+
if (done(open) || hasAttr(open, "data-alpha-stacked")) continue;
|
|
79
|
+
const src = videoSrc(open, m[1]);
|
|
80
|
+
if (src) videos.add(src);
|
|
81
|
+
}
|
|
82
|
+
for (const [tag] of html.matchAll(STYLED)) {
|
|
83
|
+
if (/^<(img|video)\b/i.test(tag) || done(tag)) continue;
|
|
84
|
+
const src = bgSrc(tag);
|
|
85
|
+
if (src) images.add(src);
|
|
86
|
+
}
|
|
87
|
+
return { images: [...images], videos: [...videos] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// A preview is usable when it is a small data URL and its media is not an icon.
|
|
91
|
+
export function usablePreview(p) {
|
|
92
|
+
return !!p && typeof p.blur === "string" && p.blur.startsWith("data:image/") && p.blur.length <= MAX_BLUR_CHARS && !/["'<>\s]/.test(p.blur)
|
|
93
|
+
&& Number.isFinite(p.w) && Number.isFinite(p.h) && Math.min(p.w, p.h) > 0 && Math.max(p.w, p.h) >= MIN_PREVIEW_SIDE;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const escapeAttr = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
97
|
+
|
|
98
|
+
// Add attributes before the tag's closing `>` (or `/>`). The shape goes in as
|
|
99
|
+
// --hd-ar, which player.css applies with zero specificity, so any aspect-ratio
|
|
100
|
+
// the deck sets (in a stylesheet or the tag) still wins.
|
|
101
|
+
function annotate(tag, p, { ratio = true, poster = null } = {}) {
|
|
102
|
+
let out = tag;
|
|
103
|
+
if (ratio) {
|
|
104
|
+
const rule = `--hd-ar:${Math.round(p.w)}/${Math.round(p.h)};`;
|
|
105
|
+
const m = /\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(out);
|
|
106
|
+
if (m) {
|
|
107
|
+
const q = m[1] !== undefined ? '"' : "'";
|
|
108
|
+
out = out.replace(m[0], ` style=${q}${rule}${m[1] ?? m[2]}${q}`);
|
|
109
|
+
} else out = out.replace(/\s*\/?>$/, (end) => ` style="${rule}"${end}`);
|
|
110
|
+
}
|
|
111
|
+
const extra = `${poster ? ` poster="${escapeAttr(poster)}"` : ""} data-hd-blur="${p.blur}"`;
|
|
112
|
+
return out.replace(/\s*(\/?)>$/, (_, slash) => `${extra}${slash ? " /" : ""}>`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// previews: { [src]: { w, h, blur, poster? } } keyed by the src as mediaRefs
|
|
116
|
+
// returned it. Media without a usable preview is left as written. Returns the
|
|
117
|
+
// new page and how many tags it changed.
|
|
118
|
+
export function withMediaPreviews(page, previews) {
|
|
119
|
+
const get = (src) => (src && usablePreview(previews[src]) ? previews[src] : null);
|
|
120
|
+
let count = 0;
|
|
121
|
+
const html = markupOnly(page, (html) => {
|
|
122
|
+
let out = html.replace(IMG, (tag) => {
|
|
123
|
+
const p = done(tag) || iconSized(tag) ? null : get(usable(tagAttr(tag, "src")));
|
|
124
|
+
if (!p) return tag;
|
|
125
|
+
count++;
|
|
126
|
+
// An author who sized both sides chose the shape already.
|
|
127
|
+
return annotate(tag, p, { ratio: !(hasAttr(tag, "width") && hasAttr(tag, "height")) });
|
|
128
|
+
});
|
|
129
|
+
out = out.replace(VIDEO, (whole, body) => {
|
|
130
|
+
const open = VIDEO_OPEN.exec(whole)[0];
|
|
131
|
+
if (done(open) || hasAttr(open, "data-alpha-stacked")) return whole;
|
|
132
|
+
const p = get(videoSrc(open, body));
|
|
133
|
+
if (!p) return whole;
|
|
134
|
+
count++;
|
|
135
|
+
const poster = hasAttr(open, "poster") ? null : p.poster ?? null;
|
|
136
|
+
return annotate(open, p, { ratio: !(hasAttr(open, "width") && hasAttr(open, "height")), poster }) + whole.slice(open.length);
|
|
137
|
+
});
|
|
138
|
+
out = out.replace(STYLED, (tag) => {
|
|
139
|
+
if (/^<(img|video)\b/i.test(tag) || done(tag)) return tag;
|
|
140
|
+
const p = get(bgSrc(tag));
|
|
141
|
+
if (!p) return tag;
|
|
142
|
+
count++;
|
|
143
|
+
// A background's box is the element's own layout, never the image's shape.
|
|
144
|
+
return annotate(tag, p, { ratio: false });
|
|
145
|
+
});
|
|
146
|
+
return out;
|
|
147
|
+
});
|
|
148
|
+
return { html, count };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The size ffmpeg reports for the first video stream ("Stream #0:0: Video: …, 1920x1080").
|
|
152
|
+
export function streamSize(stderr) {
|
|
153
|
+
const m = /Stream #[^\n]*?Video:[^\n]*?\b(\d{2,5})x(\d{2,5})\b/.exec(stderr ?? "");
|
|
154
|
+
if (!m) return null;
|
|
155
|
+
let w = Number(m[1]), h = Number(m[2]);
|
|
156
|
+
// A phone video stored sideways carries a rotation: its shown shape is swapped.
|
|
157
|
+
const rot = /rotate\s*:\s*-?(90|270)\b|rotation of -?(90|270)/.exec(stderr ?? "");
|
|
158
|
+
if (rot) [w, h] = [h, w];
|
|
159
|
+
return { w, h };
|
|
160
|
+
}
|
package/src/ops.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deckPlayer, importPlayerWarning, viewPlayerWarning } from "./deckPlayer.mjs";
|
|
2
2
|
import { zipSync } from "fflate";
|
|
3
|
+
import { SPECIES } from "./species.mjs";
|
|
3
4
|
import { COLOR_KEYS, FONT_PAIRINGS, FONT_ROLES, findPairing, googleFontsUrl } from "./brand.mjs";
|
|
4
5
|
import { MAX_IMPORT_FILE_BYTES, draftArgs, extractFile } from "./brandImport.mjs";
|
|
5
6
|
import { api, client, linkBase, target, uploadBytes as runtimeUploadBytes } from "./runtime.mjs";
|
|
@@ -33,6 +34,8 @@ export async function listDecks(opts = {}, { all = false } = {}) {
|
|
|
33
34
|
viewToggle: d.viewToggle !== false,
|
|
34
35
|
// How the player moves on: auto, slide or step (null: as its HTML says, else auto).
|
|
35
36
|
pace: d.pace ?? null,
|
|
37
|
+
// Whether each link's AI-readable version (/v/<token>/md) serves (on unless set off).
|
|
38
|
+
aiReadable: d.aiReadable !== false,
|
|
36
39
|
// false: the deck runs its own player, so view/viewToggle do not apply
|
|
37
40
|
// (null: not known yet, treated as on).
|
|
38
41
|
usesPlayer: d.usesPlayer ?? null,
|
|
@@ -53,7 +56,11 @@ export async function activityOverview(opts = {}) {
|
|
|
53
56
|
// looked up as a token; anything else is a recipient name.
|
|
54
57
|
export const RECIPIENT_ENGAGEMENT_DESCRIPTION =
|
|
55
58
|
"What one recipient watched, per deck link: visits, narration heard (%), replayed / skipped slides, " +
|
|
56
|
-
"
|
|
59
|
+
"the slides they replayed on purpose and how often (summary.slideReplays), whether they watched it again " +
|
|
60
|
+
"from the start (summary.watchedAgain), a one-line read of both (summary.replayNote), " +
|
|
61
|
+
"where they dropped off, time per slide, each visit, and forwards. Each link's aiReads lists the AI " +
|
|
62
|
+
"assistants (ChatGPT, Claude, ...) that read its text version (/v/<token>/md), separately: those are " +
|
|
63
|
+
"not views. Use it to draft a follow-up: lead " +
|
|
57
64
|
"with what they spent time on, and never tell the recipient they were tracked.";
|
|
58
65
|
export const SHARE_TOKEN = /^[a-f0-9]{16}$/;
|
|
59
66
|
export async function recipientEngagement(opts, who, deck) {
|
|
@@ -626,14 +633,16 @@ export async function setDeckForwarding(opts, slug, allowForward) {
|
|
|
626
633
|
// "slides", "scroll", or null to follow the deck's own HTML again; viewToggle
|
|
627
634
|
// true/false; pace is "auto" (plays itself), "slide" (waits for Next after each
|
|
628
635
|
// slide), "step" (also stops before each [reveal] cue), or null for the deck's
|
|
629
|
-
// own HTML.
|
|
630
|
-
//
|
|
636
|
+
// own HTML. aiReadable true/false: whether each link's AI-readable version
|
|
637
|
+
// (/v/<token>/md) serves (on by default; off is a 404 there). Any may be left
|
|
638
|
+
// out; all go in one owner-only mutation, so a failure changes none.
|
|
631
639
|
// Applies on every link's next load (the route injects it), with no re-release.
|
|
632
640
|
export const DECK_VIEWS = ["slides", "scroll"];
|
|
633
641
|
export const DECK_PACES = ["auto", "slide", "step"];
|
|
634
|
-
export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
|
|
642
|
+
export async function setDeckView(opts, slug, { view, viewToggle, pace, aiReadable } = {}) {
|
|
635
643
|
if (!slug) throw new Error("slug is required");
|
|
636
|
-
|
|
644
|
+
const player = view !== undefined || viewToggle !== undefined || pace !== undefined;
|
|
645
|
+
if (!player && aiReadable === undefined) throw new Error("give a view (slides, scroll or default), a viewToggle, a pace (auto, slide, step or default) and/or aiReadable");
|
|
637
646
|
if (view !== undefined && view !== null && !DECK_VIEWS.includes(view)) throw new Error(`view must be slides, scroll or null (got ${JSON.stringify(view)})`);
|
|
638
647
|
if (pace !== undefined && pace !== null && !DECK_PACES.includes(pace)) throw new Error(`pace must be auto, slide, step or null (got ${JSON.stringify(pace)})`);
|
|
639
648
|
const res = await (await client(opts)).mutation(api.decks.setViewSettings, {
|
|
@@ -641,14 +650,17 @@ export async function setDeckView(opts, slug, { view, viewToggle, pace } = {}) {
|
|
|
641
650
|
...(view !== undefined ? { view } : {}),
|
|
642
651
|
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
643
652
|
...(pace !== undefined ? { pace } : {}),
|
|
653
|
+
...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
|
|
644
654
|
});
|
|
645
|
-
// Saved either way; a deck on its own player says the setting won't apply
|
|
646
|
-
|
|
655
|
+
// Saved either way; a deck on its own player says the setting won't apply
|
|
656
|
+
// (aiReadable alone always applies: /md doesn't need the player).
|
|
657
|
+
const applies = !player || res?.applies !== false;
|
|
647
658
|
return {
|
|
648
659
|
slug,
|
|
649
660
|
...(view !== undefined ? { view } : {}),
|
|
650
661
|
...(viewToggle !== undefined ? { viewToggle: !!viewToggle } : {}),
|
|
651
662
|
...(pace !== undefined ? { pace } : {}),
|
|
663
|
+
...(aiReadable !== undefined ? { aiReadable: !!aiReadable } : {}),
|
|
652
664
|
applies,
|
|
653
665
|
...(applies ? {} : { warning: viewPlayerWarning(slug, res?.ownScript ?? null) }),
|
|
654
666
|
};
|
|
@@ -1114,6 +1126,22 @@ export async function addPickedAvatar(opts, avatarKey) {
|
|
|
1114
1126
|
return (await client(opts)).mutation(api.avatars.addPicked, { avatarKey });
|
|
1115
1127
|
}
|
|
1116
1128
|
|
|
1129
|
+
// A drawing that failed on a pet photo (no human face, but Minimoji saw a cat,
|
|
1130
|
+
// a dog…): the offer to make that species' avatar instead, or null. The
|
|
1131
|
+
// person still picks the face (new_avatar_link with the species).
|
|
1132
|
+
export function petOffer(draft) {
|
|
1133
|
+
const s = SPECIES.find((x) => x.id === draft?.suggestedSpecies && x.id !== "human");
|
|
1134
|
+
if (!s) return null;
|
|
1135
|
+
const word = s.label.toLowerCase();
|
|
1136
|
+
return {
|
|
1137
|
+
status: "failed",
|
|
1138
|
+
error: draft.error ?? null,
|
|
1139
|
+
suggestedSpecies: s.id,
|
|
1140
|
+
offer: `That looks like a ${word}. Make a ${word} avatar?`,
|
|
1141
|
+
next: `if they want one: new_avatar_link with species "${s.id}" (a link to pick from the ${word} faces), or \`hyperdecks avatar new --species ${s.id}\``,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1117
1145
|
// A face from a photo. With bytes: upload and draw it, then hand back the link
|
|
1118
1146
|
// where the person approves it (nothing is used before). Without: the link
|
|
1119
1147
|
// where the person takes a selfie in their browser.
|
|
@@ -1129,7 +1157,11 @@ export async function avatarFromPhoto(opts, { bytes, contentType } = {}, { onPro
|
|
|
1129
1157
|
onProgress?.("drawing…");
|
|
1130
1158
|
const draft = await pollUntil(() => c.query(api.avatars.getDraft, { draftId }), (d) => !d || d.status !== "drawing", { timeoutMs: 6 * 60_000 });
|
|
1131
1159
|
if (!draft) throw new Error("the drawing is gone; run the command again");
|
|
1132
|
-
if (draft.status === "failed")
|
|
1160
|
+
if (draft.status === "failed") {
|
|
1161
|
+
const offer = petOffer(draft);
|
|
1162
|
+
if (offer) return { ...offer, draftId };
|
|
1163
|
+
throw new Error(draft.error || "the drawing failed; try another photo");
|
|
1164
|
+
}
|
|
1133
1165
|
const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
|
|
1134
1166
|
return { status: "waiting", reviewUrl: link.url, token: link.token, draftId, expiresAt: link.expiresAt };
|
|
1135
1167
|
}
|
|
@@ -1287,7 +1319,11 @@ export async function avatarDraftStatus(opts, draftId) {
|
|
|
1287
1319
|
const draft = await c.query(api.avatars.getDraft, { draftId: String(draftId) });
|
|
1288
1320
|
if (!draft) throw new Error("the drawing is gone; start again with avatar_from_photo");
|
|
1289
1321
|
if (draft.status === "drawing") return { status: "drawing", draftId };
|
|
1290
|
-
if (draft.status === "failed")
|
|
1322
|
+
if (draft.status === "failed") {
|
|
1323
|
+
const offer = petOffer(draft);
|
|
1324
|
+
if (offer) return { ...offer, draftId };
|
|
1325
|
+
throw new Error(draft.error || "the drawing failed; try another photo");
|
|
1326
|
+
}
|
|
1291
1327
|
if (draft.status === "added") return { status: "added", draftId, avatarId: draft.avatarId ?? null };
|
|
1292
1328
|
if (draft.status !== "drawn") throw new Error(`the drawing is in an unexpected state (${draft.status}); start again with avatar_from_photo`);
|
|
1293
1329
|
const link = await c.mutation(api.avatars.createLink, { kind: "review", draftId });
|
package/src/release.mjs
CHANGED
|
@@ -12,13 +12,16 @@
|
|
|
12
12
|
// folder: script.md, evidence.md and narration/ stay private);
|
|
13
13
|
// 5. creates one share per --to (paired in order with --email) and prints the URLs.
|
|
14
14
|
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
15
16
|
import { readFileSync } from "node:fs";
|
|
16
17
|
import { join } from "node:path";
|
|
17
18
|
import { zipSync } from "fflate";
|
|
18
19
|
import * as ops from "./ops.mjs";
|
|
19
20
|
import { releasedPresenter, withAvatar } from "./presenter.mjs";
|
|
20
21
|
import { linkBase } from "./runtime.mjs";
|
|
21
|
-
import { checkDeck, deckFiles, deckFolder, resolveDeckDir } from "./check.mjs";
|
|
22
|
+
import { checkDeck, deckFiles, deckFolder, deckPathResolver, resolveDeckDir } from "./check.mjs";
|
|
23
|
+
import { mediaRefs, withMediaPreviews } from "./mediaPreviews.mjs";
|
|
24
|
+
import { bundledFfmpeg, HTTPS_TOO, makePreview } from "./mediaPreviewMake.mjs";
|
|
22
25
|
import { narrate as runNarrate } from "./narrate.mjs";
|
|
23
26
|
|
|
24
27
|
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
@@ -54,19 +57,79 @@ export function deckCommit(deckDir) {
|
|
|
54
57
|
// it brought in, so release can say what came from where.
|
|
55
58
|
// `presenter` (from avatars.presenterFor) puts the owner's avatar on the
|
|
56
59
|
// uploaded page's player tag; the committed index.html is never changed.
|
|
57
|
-
|
|
60
|
+
// `media` (from mediaPreviewsFor) writes the blur previews onto the uploaded
|
|
61
|
+
// page and adds the posters it made; like the avatar, never to the commit.
|
|
62
|
+
export function releaseBundle(deckDir, presenter = null, media = null) {
|
|
58
63
|
const raw = readFileSync(join(deckDir, "index.html"));
|
|
59
|
-
const
|
|
64
|
+
const page = presenter ? withAvatar(raw.toString("utf8"), presenter) : raw.toString("utf8");
|
|
60
65
|
// check has already refused refs that leave the folder or do not resolve.
|
|
61
|
-
const found = deckFiles(
|
|
66
|
+
const found = deckFiles(page, deckFolder(deckDir)).files.filter((f) => f.path !== "index.html");
|
|
67
|
+
const html = media ? Buffer.from(withMediaPreviews(page, media.previews).html) : presenter ? Buffer.from(page) : raw;
|
|
68
|
+
const made = Object.entries(media?.files ?? {});
|
|
62
69
|
const added = {};
|
|
63
70
|
for (const f of found) if (f.from !== "html") (added[f.from] ??= []).push(f.path);
|
|
64
|
-
if (!found.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
|
|
71
|
+
if (!found.length && !made.length) return { bytes: html, filename: "index.html", files: ["index.html"], added };
|
|
65
72
|
const files = { "index.html": html };
|
|
66
73
|
for (const f of found) files[f.path] = readFileSync(join(deckDir, f.path));
|
|
74
|
+
for (const [path, bytes] of made) files[path] = bytes;
|
|
67
75
|
return { bytes: Buffer.from(zipSync(files)), filename: "deck.zip", htmlFile: "index.html", files: Object.keys(files), added };
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
// Blur previews (and posters for videos without one) for the media the page
|
|
79
|
+
// shows: local files inside the deck folder and https URLs, read by ffmpeg.
|
|
80
|
+
// Returns { previews, files, failed }: previews keyed by src as written, the
|
|
81
|
+
// poster files to bundle (hd-media/<hash>.jpg), and what could not be read. A
|
|
82
|
+
// preview is an extra, never a reason to stop a release.
|
|
83
|
+
export async function mediaPreviewsFor(deckDir, html, { make = makePreview, ffmpeg } = {}) {
|
|
84
|
+
const { images, videos } = mediaRefs(html);
|
|
85
|
+
const previews = {}, files = {}, failed = [];
|
|
86
|
+
if (!images.length && !videos.length) return { previews, files, failed };
|
|
87
|
+
let bin = ffmpeg;
|
|
88
|
+
try {
|
|
89
|
+
bin ??= await bundledFfmpeg();
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return { previews, files, failed: [...images, ...videos].map((src) => ({ src, why: `no ffmpeg (${e.message})` })) };
|
|
92
|
+
}
|
|
93
|
+
const inside = deckPathResolver(deckDir);
|
|
94
|
+
const inputOf = (src) => {
|
|
95
|
+
if (/^https:\/\//i.test(src)) return src;
|
|
96
|
+
if (/^[a-z][a-z0-9+.-]*:|^\/\//i.test(src)) return null; // http:, file:, protocol-relative: not read
|
|
97
|
+
let path;
|
|
98
|
+
try {
|
|
99
|
+
path = decodeURIComponent(src.split(/[?#]/)[0]).replace(/^\.\//, "");
|
|
100
|
+
} catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
return inside(path) === true ? join(deckDir, path) : null;
|
|
104
|
+
};
|
|
105
|
+
const jobs = [...images.map((src) => ({ src, video: false })), ...videos.map((src) => ({ src, video: true }))];
|
|
106
|
+
const work = async ({ src, video }) => {
|
|
107
|
+
const input = inputOf(src);
|
|
108
|
+
if (!input) return failed.push({ src, why: "not a file in the deck folder or an https URL" });
|
|
109
|
+
try {
|
|
110
|
+
// A URL input may use https (the user's own machine reads it); a file stays local-only.
|
|
111
|
+
const p = await make(bin, input, { video, ...(input === src ? { allow: HTTPS_TOO } : {}) });
|
|
112
|
+
if (p.poster) {
|
|
113
|
+
const path = `hd-media/${createHash("sha256").update(src).digest("hex").slice(0, 16)}.jpg`;
|
|
114
|
+
files[path] = p.poster;
|
|
115
|
+
p.poster = path;
|
|
116
|
+
}
|
|
117
|
+
previews[src] = p;
|
|
118
|
+
} catch (e) {
|
|
119
|
+
failed.push({ src, why: e.message });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
// A few at a time: each is an ffmpeg process.
|
|
123
|
+
for (let i = 0; i < jobs.length; i += 4) await Promise.all(jobs.slice(i, i + 4).map(work));
|
|
124
|
+
return { previews, files, failed };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function logPreviews(log, media) {
|
|
128
|
+
const n = Object.keys(media.previews).length;
|
|
129
|
+
if (n) log(`✓ blur previews for ${n} image(s)/video(s)${Object.keys(media.files).length ? `, ${Object.keys(media.files).length} poster(s) made` : ""}`);
|
|
130
|
+
for (const f of media.failed) log(` ⚠ no blur preview for ${f.src}: ${f.why}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
70
133
|
// From the CLI, parse() collects opts.recipients in order (each --email follows
|
|
71
134
|
// its --to); MCP passes the same shape.
|
|
72
135
|
export function recipients(opts) {
|
|
@@ -77,7 +140,7 @@ export function recipients(opts) {
|
|
|
77
140
|
}
|
|
78
141
|
|
|
79
142
|
export async function release(pos, opts = {}, io = {}) {
|
|
80
|
-
const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, fetch, cwd, measure, measureFit } = io;
|
|
143
|
+
const { log = (line) => console.log(line), changes = deckChanges, untracked = untrackedFiles, commit = deckCommit, narrate = runNarrate, previews = mediaPreviewsFor, fetch, cwd, measure, measureFit } = io;
|
|
81
144
|
const deckDir = resolveDeckDir(pos[0], cwd);
|
|
82
145
|
const to = recipients(opts);
|
|
83
146
|
|
|
@@ -97,7 +160,15 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
97
160
|
for (const w of checked.warnings) log(` ⚠ ${w}`);
|
|
98
161
|
for (const [from, paths] of Object.entries(bundle.added)) log(` +${paths.length} file(s) referenced from ${from}: ${paths.join(", ")}`);
|
|
99
162
|
|
|
100
|
-
|
|
163
|
+
let media;
|
|
164
|
+
try {
|
|
165
|
+
media = await previews(deckDir, readFileSync(join(deckDir, "index.html"), "utf8"));
|
|
166
|
+
logPreviews(log, media);
|
|
167
|
+
} catch (e) {
|
|
168
|
+
media = null; // a preview is an extra: the release goes on without them
|
|
169
|
+
log(` ⚠ no blur previews: ${e.message}`);
|
|
170
|
+
}
|
|
171
|
+
if (checked.silent) return publish({ opts, io: { log, commit }, deckDir, slug, to, checked, bundle, released: releaseBundle(deckDir, null, media), summary: null });
|
|
101
172
|
// Cached slides cost nothing, so this only renders what changed since the commit.
|
|
102
173
|
// Only the options narrate understands: an undefined --slide/--voice key would fail its flag check.
|
|
103
174
|
const narrateOpts = { ...opts, slug };
|
|
@@ -118,7 +189,7 @@ export async function release(pos, opts = {}, io = {}) {
|
|
|
118
189
|
} catch (e) {
|
|
119
190
|
lookupError = ops.errorText(e);
|
|
120
191
|
}
|
|
121
|
-
const released =
|
|
192
|
+
const released = releaseBundle(deckDir, presenter, media);
|
|
122
193
|
// Say who presents from the page that ships, never from the lookup alone: a
|
|
123
194
|
// resolved face that did not land on the player tag must not be claimed, and
|
|
124
195
|
// a failed lookup is captions only only when the tag names no face of its own.
|
package/src/runtime.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url";
|
|
|
8
8
|
import { ConvexHttpClient } from "convex/browser";
|
|
9
9
|
import { makeFunctionReference } from "convex/server";
|
|
10
10
|
|
|
11
|
-
export const VERSION = "0.9.
|
|
11
|
+
export const VERSION = "0.9.9";
|
|
12
12
|
const UPLOAD_TIMEOUT_MS = 125_000;
|
|
13
13
|
|
|
14
14
|
// The app's home. HYPERDECKS_HOST points the prod target's broker and share
|
package/src/source.mjs
CHANGED
|
@@ -112,7 +112,7 @@ export async function sourcePush(pos, opts = {}, { log = (line) => console.log(l
|
|
|
112
112
|
return { slug, rev, files };
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".woff2": "font/woff2", ".woff": "font/woff" };
|
|
115
|
+
const TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".webm": "video/webm", ".woff2": "font/woff2", ".woff": "font/woff", ".mp3": "audio/mpeg", ".m4a": "audio/mp4", ".json": "application/json" };
|
|
116
116
|
|
|
117
117
|
export async function assetAdd(pos, opts = {}, { log = (line) => console.log(line), cwd = process.cwd() } = {}) {
|
|
118
118
|
const [slug, from] = pos;
|
|
@@ -122,7 +122,7 @@ export async function assetAdd(pos, opts = {}, { log = (line) => console.log(lin
|
|
|
122
122
|
else {
|
|
123
123
|
const path = resolve(/*turbopackIgnore: true*/ cwd, from);
|
|
124
124
|
const contentType = TYPES[extname(path).toLowerCase()];
|
|
125
|
-
if (!contentType) throw new Error(`${from}: asset add takes images, video
|
|
125
|
+
if (!contentType) throw new Error(`${from}: asset add takes images, video, audio, fonts and JSON (${Object.keys(TYPES).join(" ")})`);
|
|
126
126
|
const c = await client(opts);
|
|
127
127
|
const bytes = readFileSync(path);
|
|
128
128
|
const storageId = await uploadBytes(c, bytes, contentType);
|