@hypersocial/decks-cli 0.9.7 → 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/README.md +1 -1
- package/package.json +1 -1
- package/skills/hyperdecks/SKILL.md +78 -5
- package/skills/hyperdecks/chat.md +12 -4
- package/src/avatar.mjs +13 -2
- package/src/checkRules.mjs +100 -1
- package/src/commands.mjs +42 -21
- package/src/export-video.mjs +15 -10
- package/src/guide.mjs +1 -1
- package/src/init.mjs +8 -4
- package/src/layouts.mjs +1 -1
- package/src/mediaPreviewMake.mjs +72 -0
- package/src/mediaPreviews.mjs +160 -0
- package/src/ops.mjs +81 -17
- package/src/presenter.mjs +102 -16
- package/src/preview.mjs +2 -2
- package/src/release.mjs +90 -15
- package/src/runtime.mjs +62 -2
- package/src/source.mjs +2 -2
- package/templates/layouts/_deck.html +7 -0
- package/templates/layouts/chart.html +5 -0
- package/templates/layouts/closer.html +5 -0
- package/templates/layouts/compare.html +5 -0
- package/templates/layouts/features.html +6 -0
- package/templates/layouts/hero.html +5 -0
- package/templates/layouts/number.html +5 -0
- package/templates/layouts/phones.html +5 -0
- package/templates/layouts/product.html +5 -0
- package/templates/layouts/quote.html +6 -0
- package/templates/layouts/split.html +5 -0
- package/templates/layouts/team.html +6 -0
- package/templates/layouts/timeline.html +5 -0
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ share list <slug> [--json]
|
|
|
64
64
|
share on|off <token>
|
|
65
65
|
share mute|unmute <token>
|
|
66
66
|
share rm <token>
|
|
67
|
-
share url <token>
|
|
67
|
+
share url <token> [--backup]
|
|
68
68
|
|
|
69
69
|
recipient archive|unarchive <name>
|
|
70
70
|
import <html-or-zip-or-url> --slug <slug> [--title title] [--status draft|live|archived]
|
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 | `hyperdecks deck view <slug> [slides\|scroll\|default] [--toggle on\|off]` | `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` |
|
|
@@ -169,7 +169,15 @@ HTML says. Viewers get a slides/scroll toggle in the player, remembered per
|
|
|
169
169
|
link, unless you turn it off (`--toggle off`, `viewToggle: false`). A viewer
|
|
170
170
|
who already switched keeps their own choice while the toggle is on; turn the
|
|
171
171
|
toggle off to make every viewer see the deck's view.
|
|
172
|
-
|
|
172
|
+
Pace is the third: `--pace auto` (`pace: "auto"`, the default) plays itself,
|
|
173
|
+
`slide` waits for Next (the Continue pill, →, a swipe) after each slide's
|
|
174
|
+
narration, and `step` also stops the narration before each `[reveal]` cue,
|
|
175
|
+
so a slide's reveals are the steps the viewer clicks through: group what
|
|
176
|
+
belongs together at one word, so each stop is a meaningful step. Viewers can
|
|
177
|
+
switch pace (and slides/scroll) in the player's Preferences, in its top bar
|
|
178
|
+
(remembered per link); a video export always plays straight through. Pace
|
|
179
|
+
waits apply side to side (slides) only: scroll keeps its own flow.
|
|
180
|
+
`deck list --json` / `list_decks` and `deck stats` / `deck_stats` show all three.
|
|
173
181
|
|
|
174
182
|
### 1. Ground it in the user's own material
|
|
175
183
|
|
|
@@ -258,6 +266,18 @@ The deck contract (`check` enforces or warns on each line):
|
|
|
258
266
|
<!-- topic: presenter -->
|
|
259
267
|
#### Which presenter style
|
|
260
268
|
|
|
269
|
+
**Who presents is set at release; leave the tag alone.** `deck new --narrated`
|
|
270
|
+
writes `data-presenter="none"` on the player tag, and `release` (and
|
|
271
|
+
`release_deck`) puts the narrator's face on the copy it publishes: your own
|
|
272
|
+
narrator's avatar, or D.A.S.H.'s live head for a deck in D.A.S.H.'s voice. It
|
|
273
|
+
says `presented by …` only when the published page has that face; otherwise
|
|
274
|
+
it warns `releasing captions only` with the reason. To pick the face by hand,
|
|
275
|
+
set both attributes: `data-presenter="head" data-avatar="…"`. D.A.S.H.'s is
|
|
276
|
+
`data-presenter="head" data-avatar="https://hyperdecks.ai/player/v1/dash-avatar.json"`
|
|
277
|
+
(it loads from any host). `data-presenter="head"` with no `data-avatar` plays
|
|
278
|
+
captions only, and so does a `data-avatar` beside `data-presenter="none"`.
|
|
279
|
+
`check` warns on both, and `release` leaves a hand-set `data-avatar` alone.
|
|
280
|
+
|
|
261
281
|
Set the deck's on the player tag (`data-presenter-style="stage"`) or in
|
|
262
282
|
script.md's front matter (`presenterStyle: stage`); override one slide with
|
|
263
283
|
`data-presenter-style` on its `<section>` or `presenter-style: corner` under
|
|
@@ -596,6 +616,20 @@ with a real screen. Proof: `number` (huge, sourced) or `chart`. Plan:
|
|
|
596
616
|
`timeline` on a tint. People: `team` with real faces. Closer: `on-ink`, the
|
|
597
617
|
one action, stickers again.
|
|
598
618
|
|
|
619
|
+
**No presenter.** When nothing stands in the corner, no face (presenter
|
|
620
|
+
`none`) *and* no captions (`data-silent`, or an export with captions off), or
|
|
621
|
+
when a host calls `window.hyperdecks.setPresenterOff(true)`, the player puts
|
|
622
|
+
`hd-no-presenter` on `<html>` and hides every `.narration-slot`. Each layout
|
|
623
|
+
has a variant for that in its own `<style>` (`deck_guide layouts` says what
|
|
624
|
+
each does): compare and chart widen; features, team and timeline centre
|
|
625
|
+
vertically; split, product and phones centre their copy; number, quote,
|
|
626
|
+
closer and a hero without art re-centre. Type sizes stay. A deck with
|
|
627
|
+
captions keeps the slot (the bubble lives there). For a custom slide, keep it
|
|
628
|
+
compatible: the presenter's corner is the slide's `.narration-slot`, never a
|
|
629
|
+
hard-coded gutter (`padding-right: 400px`); if it needs one, override it in a
|
|
630
|
+
`:root.hd-no-presenter .l-yours …` rule (check warns on a right gutter until
|
|
631
|
+
you do).
|
|
632
|
+
|
|
599
633
|
<!-- topic: script -->
|
|
600
634
|
### 3. Write slides and script
|
|
601
635
|
|
|
@@ -658,6 +692,43 @@ one action, stickers again.
|
|
|
658
692
|
| `[smile]` / `[think]` | `[mood: happy]` / `[mood: thinking]` |
|
|
659
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) |
|
|
660
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.
|
|
661
732
|
- `voice: dash` narrates with D.A.S.H.'s own robot voice, a synthetic
|
|
662
733
|
character voice (not a clone of anyone) any account may use, for decks that
|
|
663
734
|
D.A.S.H. presents. It works once the deployment sets `CHARACTER_VOICE_DASH`.
|
|
@@ -833,8 +904,10 @@ hyperdecks engagement "<name>" --deck <slug> --json # MCP: recipient_engagemen
|
|
|
833
904
|
Then draft the email:
|
|
834
905
|
|
|
835
906
|
1. Lead with the slide they spent most time on (`summary.mostTime`) or
|
|
836
|
-
replayed (`replayedSlides`
|
|
837
|
-
|
|
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.
|
|
838
911
|
2. If they dropped off (`droppedOffOn`), offer the rest briefly, or a
|
|
839
912
|
2-minute call, rather than asking them to rewatch.
|
|
840
913
|
3. If they never opened it (`summary.visits` is 0), write a short nudge
|
|
@@ -855,7 +928,7 @@ the hosted MCP (`deck_guide`, `new_deck`, `get_deck_source`, `write_deck_source`
|
|
|
855
928
|
`upload_deck_asset`, `check_deck`, `release_deck`; see `chat.md`). Move between
|
|
856
929
|
the two with `hyperdecks source pull <slug> [dir]` (the hosted draft into a
|
|
857
930
|
folder) and `hyperdecks source push <slug> [dir]` (a folder's files back to the
|
|
858
|
-
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
|
|
859
932
|
[topic]` for these sections, and `--hosted` on `deck new`, `check` and `release`.
|
|
860
933
|
|
|
861
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
|
|
|
@@ -91,9 +93,15 @@ makes the hello deck; continue from step 5 below.
|
|
|
91
93
|
10. Later: `list_shares` and `deck_stats` (both show the card),
|
|
92
94
|
`activity_overview`; `email_embed { token }` for the HTML that pastes into an
|
|
93
95
|
email as the deck's moving preview (it needs a recipient's link, not a QA
|
|
94
|
-
one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle }`
|
|
95
|
-
for how the deck opens
|
|
96
|
-
|
|
96
|
+
one); `set_deck_view { slug, view: "scroll" | "slides" | null, viewToggle, pace }`
|
|
97
|
+
for how the deck opens, whether viewers may switch, and its pace (`auto`
|
|
98
|
+
plays itself, `slide` waits for Next after each slide, `step` also stops
|
|
99
|
+
before each `[reveal]`, so group a slide's reveals into meaningful steps)
|
|
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;
|
|
97
105
|
`set_recipient_archived` to tidy the activity feed; `credits` for the
|
|
98
106
|
balance. `remove_deck` deletes a deck and breaks every link to it: confirm
|
|
99
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
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// the deck's index.html and script.md as text, with the manifest and clip fetches
|
|
6
6
|
// injected.
|
|
7
7
|
|
|
8
|
+
import { DASH_AVATAR, presenterTagIssue } from "./presenter.mjs";
|
|
9
|
+
|
|
8
10
|
export const PLAYER_V1 = "https://hyperdecks.ai/player/v1/player.js";
|
|
9
11
|
// Decks released before the app moved to hyperdecks.ai load the same player
|
|
10
12
|
// from the old host, which serves it forever; both pass.
|
|
@@ -35,6 +37,7 @@ export const MESSAGES = {
|
|
|
35
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",
|
|
36
38
|
clipField: (id, field) => `clip ${id}: ${field}`,
|
|
37
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`,
|
|
38
41
|
order: (slides, clips) => `slide ids (${slides.join(", ")}) must equal the manifest clip ids (${clips.join(", ")}), in order`,
|
|
39
42
|
total: (sum, declared) => `clip durations plus holds add up to ${sum}s but the manifest declares totalDuration ${declared}s`,
|
|
40
43
|
cap: (total, cap) => `narration runs ${total}s, over the ${cap}s cap (aim for 150 to 180 s)`,
|
|
@@ -58,6 +61,8 @@ export const MESSAGES = {
|
|
|
58
61
|
selector: (line, cue, selector, id) => `script.md line ${line}: [${cue}: ${selector}] matches nothing in slide ${id}`,
|
|
59
62
|
gestureColon: (line, id) => `script.md line ${line}: [gesture ${id}] needs a colon: [gesture: ${id}]`,
|
|
60
63
|
gesture: (line, id) => `script.md line ${line}: [gesture: ${id}] is not one of the head's gestures: ${GESTURES.join(", ")}`,
|
|
64
|
+
presenterUnfilled: (mode) => `the player tag says data-presenter="${mode}" but names no face (${mode === "head" ? "data-avatar" : "data-presenter-image"}), so as written it plays captions only. release puts the narrator's face on it; to set one by hand, D.A.S.H. is data-presenter="head" data-avatar="${DASH_AVATAR}" (deck_guide topic presenter)`,
|
|
65
|
+
presenterKept: (mode) => `the player tag has a data-avatar but data-presenter="${mode ?? "none"}", so it plays captions only and release leaves it that way: set data-presenter="head" to show that face, or remove data-avatar so release adds the narrator's`,
|
|
61
66
|
presenterSize: (where, value) => `${where}: data-presenter-size "${value}" is not one of ${PRESENTER_SIZES.join(", ")}; the presenter keeps its default size`,
|
|
62
67
|
presenterStyle: (where, value) => `${where}: presenter style "${value}" is not one of ${PRESENTER_STYLES.join(", ")}`,
|
|
63
68
|
transition: (where, value) => `${where}: transition "${value}" is not one of ${TRANSITIONS.join(", ")} (zoom takes a selector: "zoom #row")`,
|
|
@@ -67,6 +72,7 @@ export const MESSAGES = {
|
|
|
67
72
|
flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
|
|
68
73
|
// Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
|
|
69
74
|
fewPictures: (shown, total) => `only ${shown} of ${total} slides show a picture (a screenshot, a recording, a photo, a chart); aim for a real one on every other slide (deck_guide topic look)`,
|
|
75
|
+
presenterGutter: (where, decl) => `${where}: ${decl} keeps a right gutter for the presenter; leave that corner to the slide's .narration-slot so a deck without a presenter can use it (deck_guide topic look)`,
|
|
70
76
|
mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
|
|
71
77
|
// A layout's stand-in left in: `how` is the tool (hosted) or command (CLI) that makes the real file.
|
|
72
78
|
placeholder: (id, what, el, how, img) => `slide ${id}: ${what} is still the layout's placeholder (${el}); ${how}, then put it in as ${img}`,
|
|
@@ -600,6 +606,23 @@ export function htmlErrors(html, { exists = () => true, forbid = [], script = ""
|
|
|
600
606
|
const isNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
601
607
|
const isStr = (v) => typeof v === "string" && v.trim().length > 0;
|
|
602
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
|
+
|
|
603
626
|
// Fields every clip needs before its other rules mean anything.
|
|
604
627
|
export function clipFieldErrors(clip, i) {
|
|
605
628
|
const id = isStr(clip?.id) ? clip.id : `#${i + 1}`;
|
|
@@ -790,6 +813,68 @@ export function lookWarnings(html) {
|
|
|
790
813
|
return out;
|
|
791
814
|
}
|
|
792
815
|
|
|
816
|
+
// A wide right padding or margin (PRESENTER_GUTTER px or more, and wider than
|
|
817
|
+
// the left: a centred column is not a gutter) is a gutter held for the
|
|
818
|
+
// presenter outside its .narration-slot: without a presenter
|
|
819
|
+
// (:root.hd-no-presenter) it stays empty. Read from inline styles on a slide
|
|
820
|
+
// and its elements, and from the deck's <style> rules (by selector). Skipped:
|
|
821
|
+
// rules in a phone media query (max-width, no min-width), in @keyframes and
|
|
822
|
+
// the like, on the slot itself, the no-presenter rules, and a selector whose
|
|
823
|
+
// gutter a :root.hd-no-presenter rule overrides (the variant the guide asks for).
|
|
824
|
+
// One linear pass over the text: a malformed paste must not stall check.
|
|
825
|
+
export const PRESENTER_GUTTER = 240;
|
|
826
|
+
const sides = (prop, value) => {
|
|
827
|
+
const px = (v) => { const m = /^(\d+(?:\.\d+)?)(px|rem|em)$/i.exec(v ?? ""); return m ? Number(m[1]) * (m[2].toLowerCase() === "px" ? 1 : 16) : 0; };
|
|
828
|
+
const parts = value.trim().split(/\s+/);
|
|
829
|
+
if (/-(right|inline-end)$/i.test(prop)) return { right: px(parts[0]), left: 0 };
|
|
830
|
+
return { right: px(parts[1] ?? parts[0]), left: px(parts[3] ?? parts[1] ?? parts[0]) };
|
|
831
|
+
};
|
|
832
|
+
const gutters = (decls) => decls.split(";").map((d) => /^\s*((?:padding|margin)(?:-right|-inline-end)?)\s*:\s*([^!]+)/i.exec(d)).filter(Boolean)
|
|
833
|
+
.filter(([, prop, value]) => { const { right, left } = sides(prop, value); return right >= PRESENTER_GUTTER && right > left; })
|
|
834
|
+
.map(([, prop, value]) => `${prop.toLowerCase()}: ${value.trim()}`);
|
|
835
|
+
// Each style rule of a stylesheet: { selector, decls, at } where `at` lists the
|
|
836
|
+
// @-rule preludes around it. Comments drop out; braces nest.
|
|
837
|
+
function cssRules(css) {
|
|
838
|
+
const out = [], stack = [];
|
|
839
|
+
let start = 0, i = 0;
|
|
840
|
+
let text = "";
|
|
841
|
+
while (i < css.length) {
|
|
842
|
+
if (css.startsWith("/*", i)) { const end = css.indexOf("*/", i + 2); text += css.slice(start, i); i = start = end === -1 ? css.length : end + 2; continue; }
|
|
843
|
+
const ch = css[i];
|
|
844
|
+
if (ch === "{" || ch === "}") {
|
|
845
|
+
text += css.slice(start, i);
|
|
846
|
+
if (ch === "{") stack.push(text.trim());
|
|
847
|
+
else {
|
|
848
|
+
const head = stack.pop();
|
|
849
|
+
if (head !== undefined && !head.startsWith("@")) out.push({ selector: head, decls: text, at: stack.filter((h) => h.startsWith("@")) });
|
|
850
|
+
}
|
|
851
|
+
text = "";
|
|
852
|
+
start = i + 1;
|
|
853
|
+
}
|
|
854
|
+
i++;
|
|
855
|
+
}
|
|
856
|
+
return out;
|
|
857
|
+
}
|
|
858
|
+
const phoneOnly = (at) => at.some((h) => /^@media\b/i.test(h) && /max-width/i.test(h) && !/min-width/i.test(h));
|
|
859
|
+
const NO_PRESENTER = /^:root\.hd-no-presenter\s+/;
|
|
860
|
+
export function presenterGutterWarnings(html) {
|
|
861
|
+
const out = [];
|
|
862
|
+
for (const [i, s] of slides(html).entries()) {
|
|
863
|
+
const where = `slide ${s.id || i + 1}`;
|
|
864
|
+
for (const [, value] of `${s.tag}\n${uncommented(s.body)}`.matchAll(/\sstyle\s*=\s*("[^"]*"|'[^']*')/gi)) {
|
|
865
|
+
for (const decl of gutters(value.slice(1, -1))) out.push(MESSAGES.presenterGutter(where, decl));
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
const rules = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].flatMap(([, css]) => cssRules(css));
|
|
869
|
+
const overridden = new Set(rules.filter((r) => NO_PRESENTER.test(r.selector) && /(padding|margin)/i.test(r.decls)).map((r) => r.selector.replace(NO_PRESENTER, "")));
|
|
870
|
+
for (const r of rules) {
|
|
871
|
+
if (r.at.some((h) => !/^@(media|supports|layer|container)\b/i.test(h)) || phoneOnly(r.at)) continue;
|
|
872
|
+
if (NO_PRESENTER.test(r.selector) || /\.narration-slot\b/.test(r.selector) || overridden.has(r.selector)) continue;
|
|
873
|
+
for (const decl of gutters(r.decls)) out.push(MESSAGES.presenterGutter(`rule ${r.selector}`, decl));
|
|
874
|
+
}
|
|
875
|
+
return out;
|
|
876
|
+
}
|
|
877
|
+
|
|
793
878
|
// Every rule on the deck's text. `exists(ref)` answers whether a local reference
|
|
794
879
|
// resolves (true, false or "outside"); `hosted: true` means there is no folder,
|
|
795
880
|
// so every local reference is an error naming upload_deck_asset, a hotlinked
|
|
@@ -809,7 +894,7 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
809
894
|
const errors = htmlErrors(html, { exists, forbid, script, hosted: !!opts.hosted, strict });
|
|
810
895
|
const warnings = [];
|
|
811
896
|
if (!notesRequired(html, { strict })) for (const id of slidesWithoutNotes(html)) warnings.push(MESSAGES.noteSoft(id));
|
|
812
|
-
warnings.push(...lookWarnings(html));
|
|
897
|
+
warnings.push(...lookWarnings(html), ...presenterGutterWarnings(html));
|
|
813
898
|
// Without a folder (hosted, or a caller that passes no reader) only the inline scripts are read.
|
|
814
899
|
const walked = deckFiles(html, opts.hosted ? {} : { pathExists: opts.pathExists, read: opts.read, isDir: opts.isDir });
|
|
815
900
|
for (const m of walked.missing) errors.push(MESSAGES.scriptRef(m.ref, m.from));
|
|
@@ -870,9 +955,23 @@ export async function checkSources({ html, script = "" }, opts = {}, { fetch: fe
|
|
|
870
955
|
continue;
|
|
871
956
|
}
|
|
872
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
|
+
}
|
|
873
969
|
}
|
|
874
970
|
}
|
|
875
971
|
warnings.push(...transitionWarnings(html, script, manifest), ...presenterSizeWarnings(html));
|
|
972
|
+
// A narrated deck's tag that shows no face and says otherwise (a silent deck has no presenter).
|
|
973
|
+
const tagIssue = silent ? null : presenterTagIssue(html);
|
|
974
|
+
if (tagIssue) warnings.push(tagIssue.kind === "kept" ? MESSAGES.presenterKept(tagIssue.presenter) : MESSAGES.presenterUnfilled(tagIssue.presenter));
|
|
876
975
|
for (const t of walked.templates) warnings.push(MESSAGES.scriptTemplate(t.from, t.samples));
|
|
877
976
|
for (const c of walked.computed) warnings.push(MESSAGES.scriptComputed(c.from, c.calls));
|
|
878
977
|
if (opts.hosted) {
|
package/src/commands.mjs
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { basename, join, resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
|
+
backupLinkUrl,
|
|
4
5
|
installHyperdecksSkill,
|
|
5
6
|
login,
|
|
6
7
|
logout,
|
|
7
8
|
openBrowser,
|
|
9
|
+
linkBase,
|
|
8
10
|
parse,
|
|
9
11
|
target,
|
|
10
12
|
VERSION,
|
|
@@ -60,7 +62,7 @@ async function deckStats(pos, opts) {
|
|
|
60
62
|
console.log(` unique viewers: ${s.viewers}`);
|
|
61
63
|
console.log(` avg engaged: ${fmtMs(s.avgMs)}`);
|
|
62
64
|
console.log(` total engaged: ${fmtMs(s.totalMs)}`);
|
|
63
|
-
if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
|
|
65
|
+
if (s.view !== undefined) console.log(` plays as: ${s.view ?? "as its HTML says"}${s.viewToggle === false ? " (viewer toggle off)" : ""}${s.pace ? `, pace ${s.pace}` : ""}${s.usesPlayer === false ? " (won't apply: the deck doesn't load player/v1)" : ""}`);
|
|
64
66
|
if (s.recent?.length) {
|
|
65
67
|
console.log(" recent:");
|
|
66
68
|
for (const r of s.recent) {
|
|
@@ -85,10 +87,6 @@ async function deckRemove(pos, opts) {
|
|
|
85
87
|
console.log(`OK removed deck ${slug}`);
|
|
86
88
|
}
|
|
87
89
|
|
|
88
|
-
function shareBase(opts) {
|
|
89
|
-
return target(opts).shareBase;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
90
|
async function shareNew(pos, opts) {
|
|
93
91
|
const [slug, recipient] = pos;
|
|
94
92
|
if (!slug || !recipient) throw new Error("usage: share new <slug> <recipient> [--email e] [--note n] [--require-email] [--expires iso] [--limit n] [--qa] [--no-notify] [--allow-forward|--no-forward]");
|
|
@@ -105,9 +103,13 @@ async function shareNew(pos, opts) {
|
|
|
105
103
|
expires: opts.expires,
|
|
106
104
|
limit: opts.limit,
|
|
107
105
|
});
|
|
108
|
-
const url = `${
|
|
109
|
-
|
|
110
|
-
|
|
106
|
+
const url = `${await linkBase(opts)}/v/${token}`;
|
|
107
|
+
const backupUrl = backupLinkUrl(url);
|
|
108
|
+
if (opts.json) console.log(JSON.stringify({ token, url, backupUrl }, null, 2));
|
|
109
|
+
else {
|
|
110
|
+
console.log(`OK share for "${recipient}" -> ${url}`);
|
|
111
|
+
if (backupUrl) console.log(` backup link (same link, other domain, if their network blocks it): ${backupUrl}`);
|
|
112
|
+
}
|
|
111
113
|
}
|
|
112
114
|
|
|
113
115
|
async function shareList(pos, opts) {
|
|
@@ -156,23 +158,32 @@ async function deckForward(pos, opts) {
|
|
|
156
158
|
console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off]";
|
|
161
|
+
const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
|
|
160
162
|
const VIEWS = { slides: "slides", scroll: "scroll", default: null };
|
|
163
|
+
const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
|
|
161
164
|
async function deckView(pos, opts) {
|
|
162
165
|
const [slug, mode] = pos;
|
|
163
166
|
const toggle = opts.toggle;
|
|
164
|
-
|
|
167
|
+
const pace = opts.pace;
|
|
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);
|
|
165
170
|
if (mode !== undefined && !Object.hasOwn(VIEWS, mode)) throw new Error(VIEW_USAGE);
|
|
166
171
|
// A trailing --toggle with no value is a mistake, not "leave it".
|
|
167
172
|
if (Object.hasOwn(opts, "toggle") && toggle !== "on" && toggle !== "off") throw new Error(VIEW_USAGE);
|
|
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);
|
|
168
175
|
const out = await ops.setDeckView(opts, slug, {
|
|
169
176
|
view: mode === undefined ? undefined : VIEWS[mode],
|
|
170
177
|
viewToggle: toggle === undefined ? undefined : toggle === "on",
|
|
178
|
+
pace: pace === undefined ? undefined : PACES[pace],
|
|
179
|
+
aiReadable: ai === undefined ? undefined : ai === "on",
|
|
171
180
|
});
|
|
172
181
|
if (opts.json) return void console.log(JSON.stringify(out, null, 2));
|
|
173
182
|
const said = [];
|
|
174
183
|
if (mode !== undefined) said.push(mode === "default" ? "opens as its HTML says" : `opens as ${mode}`);
|
|
175
184
|
if (toggle !== undefined) said.push(`viewer toggle ${toggle}`);
|
|
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}`);
|
|
176
187
|
if (out.applies === false) {
|
|
177
188
|
console.log(`OK deck ${slug} ${said.join(", ")} (saved)`);
|
|
178
189
|
console.warn(`warning: ${out.warning}`);
|
|
@@ -181,10 +192,14 @@ async function deckView(pos, opts) {
|
|
|
181
192
|
console.log(`OK deck ${slug} ${said.join(", ")} (every link, on its next load)`);
|
|
182
193
|
}
|
|
183
194
|
|
|
184
|
-
function shareUrl(pos, opts) {
|
|
195
|
+
async function shareUrl(pos, opts) {
|
|
185
196
|
const [token] = pos;
|
|
186
|
-
if (!token) throw new Error("usage: share url <token>");
|
|
187
|
-
|
|
197
|
+
if (!token) throw new Error("usage: share url <token> [--backup]");
|
|
198
|
+
const url = `${await linkBase(opts)}/v/${token}`;
|
|
199
|
+
console.log(url);
|
|
200
|
+
// Second line: the same link on the other domain (stdout stays one URL per line).
|
|
201
|
+
const backupUrl = backupLinkUrl(url);
|
|
202
|
+
if (backupUrl && opts.backup) console.log(backupUrl);
|
|
188
203
|
}
|
|
189
204
|
|
|
190
205
|
async function recipientArchive(pos, opts, archived) {
|
|
@@ -245,7 +260,7 @@ async function engagement(pos, opts) {
|
|
|
245
260
|
console.log(` visits: ${s.visits} (last ${new Date(s.lastSeenAt).toISOString().slice(0, 16).replace("T", " ")}) · ${fmtMs(s.totalMs)} engaged`);
|
|
246
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)"}`);
|
|
247
262
|
console.log(` reached: slide ${s.furthestSlide}${e.deck.slides ? ` of ${e.deck.slides}` : ""}${s.droppedOffOn ? ` · dropped off on ${slideName(s.droppedOffOn)}` : ""}`);
|
|
248
|
-
console.log(` replayed: ${list(s.replayedSlides)}`);
|
|
263
|
+
console.log(` replayed: ${list(s.replayedSlides)}${s.replayNote ? ` · ${s.replayNote}` : ""}`);
|
|
249
264
|
console.log(` skipped: ${list(s.skippedSlides)}`);
|
|
250
265
|
console.log(` most time: ${s.mostTime.map((m) => `${slideName(m)} (${fmtMs(m.ms)})`).join(", ") || "none"}`);
|
|
251
266
|
if (!s.slideData) console.log(" slide data unavailable (this deck reports time only)");
|
|
@@ -336,9 +351,10 @@ commands:
|
|
|
336
351
|
deck list|stats|open|rm|forward|view
|
|
337
352
|
deck forward on|off <slug>
|
|
338
353
|
whether viewers can forward the deck's links (on by default; a link's own setting wins)
|
|
339
|
-
deck view <slug> [slides|scroll|default] [--toggle on|off]
|
|
354
|
+
deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
|
|
340
355
|
how the deck opens in the player (default: as its HTML says, data-view or slides) and
|
|
341
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).
|
|
342
358
|
deck new <slug> "<title>" --narrated [--layouts hero,product,number,closer] [--presenter-style stage|bubble|glass|corner] [--no-brand] [--dir <path>]
|
|
343
359
|
scaffold decks/<slug>/: index.html (8 slides, or one per layout, player/v1), script.md, evidence.md,
|
|
344
360
|
in your saved brand's colours, fonts and logo (--no-brand: the template's look)
|
|
@@ -353,7 +369,7 @@ commands:
|
|
|
353
369
|
source pull <slug> [dir] | source push <slug> [dir] [--base-rev N]
|
|
354
370
|
move a hosted draft (index.html, script.md, evidence.md) to a folder and back
|
|
355
371
|
asset add <slug> <file|url> [--name n]
|
|
356
|
-
file an image, video
|
|
372
|
+
file an image, video, audio (mp3, m4a), font or JSON under the deck; prints its URL
|
|
357
373
|
guide [workflow|format|script|scripts|design|layouts|presenter|transitions|check]
|
|
358
374
|
the skill, one topic at a time (what deck_guide serves)
|
|
359
375
|
play <slug|deck-dir> [--voice <the voice it was narrated in>] [--slug <slug>] [--port <n>] [--no-open] [--json]
|
|
@@ -441,7 +457,7 @@ commands:
|
|
|
441
457
|
|
|
442
458
|
narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
|
|
443
459
|
script.md → voiced clips, timelines and a manifest, rendered server-side; only changed slides re-render
|
|
444
|
-
voice list [--json] your voices and their status
|
|
460
|
+
voice list [--json] your voices and their status, then the built-in voices anyone can narrate with
|
|
445
461
|
voice clone <file...> --name <name>
|
|
446
462
|
clone your voice from 1-3 mp3/m4a/wav recordings (30 s to 3 min in total, 10 MB each);
|
|
447
463
|
asks you to type "I consent" before anything is uploaded
|
|
@@ -851,10 +867,12 @@ function commandTable(pos, rest, opts) {
|
|
|
851
867
|
if (opts.json) console.log(JSON.stringify(out, null, 2));
|
|
852
868
|
},
|
|
853
869
|
"voice list": async () => {
|
|
854
|
-
const voices = await ops.
|
|
870
|
+
const voices = await ops.listAllVoices(opts);
|
|
855
871
|
if (opts.json) return void console.log(JSON.stringify(voices, null, 2));
|
|
856
|
-
|
|
857
|
-
|
|
872
|
+
const own = voices.filter((x) => !x.builtin);
|
|
873
|
+
for (const x of own) console.log(`${x.name}\t${x.status}\t${x.handle ?? "-"}\t${x.id}${x.error ? `\t${x.error}` : ""}`);
|
|
874
|
+
if (!own.length) console.log("(no voices of your own; voice clone <file...> --name <name> makes one)");
|
|
875
|
+
for (const x of voices.filter((x) => x.builtin)) console.log(`${x.name}\tbuilt in\t${x.description}\t${x.id}`);
|
|
858
876
|
},
|
|
859
877
|
"voice clone": async () => {
|
|
860
878
|
const { voiceClone } = await import("./voice.mjs");
|
|
@@ -885,8 +903,11 @@ function commandTable(pos, rest, opts) {
|
|
|
885
903
|
},
|
|
886
904
|
"avatar from-photo": async () => {
|
|
887
905
|
const { avatarFromPhoto } = await import("./avatar.mjs");
|
|
888
|
-
const
|
|
906
|
+
const { askLine } = await import("./init.mjs");
|
|
907
|
+
const out = await avatarFromPhoto(rest[0], opts, { ask: askLine });
|
|
889
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;
|
|
890
911
|
},
|
|
891
912
|
"avatar connect": async () => {
|
|
892
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)));
|