@hypersocial/decks-cli 0.9.10 → 0.9.12

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 CHANGED
@@ -259,6 +259,8 @@ Generate locally, select an existing same-deck recipient share, then explicitly
259
259
 
260
260
  Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
261
261
 
262
+ To show the slide that sells the deck instead of its opening, name it and a moment of its narration: `hyperdecks preview create my-deck --scene chars --start 3.2 --share EXISTING_TOKEN --out ./preview-chars --upload --json`. `--scene` is the slide's id (its `<section class="slide" id>`; an unknown id fails and lists the deck's ids); `--start` is seconds into that slide's narration (default 0; it must fall inside the slide's clip plus its hold). The GIF then starts on exactly that moment: the guest speaking then, with its mouth and caption bubble, and every cue fired by then. It records frame by frame on the player's page clock (as `export --video` does), silently, after the slide's character heads finish loading; `--delay` still adds settle time first. Private character heads use the same in-memory, deck-scoped view grant; nothing is written to disk. `manifest.json` gets a `moment` saying which slide, which guest and which caption the first frame shows. Needs a deck on player/v1.
263
+
262
264
  `--json` prints one line on stdout, identical to `<out>/manifest.json`:
263
265
 
264
266
  ```json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hypersocial/decks-cli",
3
- "version": "0.9.10",
3
+ "version": "0.9.12",
4
4
  "description": "Command-line client for Hypersocial Decks live analytics and share management.",
5
5
  "type": "module",
6
6
  "main": "./src/commands.mjs",
@@ -150,6 +150,8 @@ Hyperdecks server; use the CLI where you have a shell and the tool otherwise.
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
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
+ | Give a deck to another account (only when asked; confirm the email) | `hyperdecks deck transfer <slug> --to <email>` (`deck transfers cancel <slug>` takes it back) | `transfer_deck` (`cancel: true`) |
154
+ | Transfers waiting on you, and accepting one (ask who narrates and, for each guest that needsPick, a voice and avatar of theirs) | `hyperdecks deck transfers`, then `deck transfers accept <id> --narrator <name\|dash\|none\|default> [--guest <n>=<voice>/<avatar>]` (or `decline <id>`) | `list_transfers`, then `accept_transfer` (`decline: true`) |
153
155
  | Present or check your own deck (owner link) | `hyperdecks deck link <slug>` (`deck open` opens it) | `ownerUrl` from `list_decks` or `check_deck` |
154
156
  | Who watched | `hyperdecks deck stats <slug>` | `deck_stats` |
155
157
  | All activity | `hyperdecks activity --deck <slug>` | `activity_overview` |
@@ -308,6 +310,20 @@ its `##` heading.
308
310
  | `glass` | dark or busy slides: head and captions on one frosted panel |
309
311
  | `corner` | the slide is the point (a product shot, a chart, a demo): small head bottom-right, subtitles along the bottom |
310
312
 
313
+ **Loading and buffering.** A deck opens on its loading card (the title and
314
+ the presenter's still), and a line still loading shows as a small waiting
315
+ pill. Once the deck has spoken, a line that stalls on the network for more
316
+ than 0.3 s makes the slide play a short
317
+ "signal" glitch in the deck's accents (`--accent-2` / `--accent-3`, else
318
+ `--accent`; set `--hd-signal-1` / `--hd-signal-2` to choose others): the
319
+ title's colour splits, thin scanlines and a slice pass over the slide, and a
320
+ `SIGNAL // BUFFERING` tag sits in the top-left corner.
321
+ Body text never moves; it calms to scanlines after 6 s and stops the moment
322
+ sound plays. It never shows in thumbnails, exports, present mode or while the
323
+ owner presents, and reduced motion gets a still dim and the tag. Where it
324
+ doesn't fit the deck, turn it off with `data-loading="calm"` on the player tag
325
+ (or on `<html>`, or `"loading": "calm"` in the manifest).
326
+
311
327
  Size it per slide with `data-presenter-size="s|m|l|xl"` on the `<section>`
312
328
  (0.7×, 1×, 1.3×, 1.6× the style's head; on the player tag it is the deck's
313
329
  default): `xl` on a slide with empty space, `s` on a busy product slide. Phones
@@ -431,8 +447,8 @@ slide that overflows a 390×844 phone.
431
447
 
432
448
  #### House style (preferences)
433
449
 
434
- Everything below is the owner's house style, not a rule: follow it by
435
- default, and depart from it when the brief asks for something else.
450
+ The owner's house style, not rules: follow it by default; depart from it
451
+ when the brief asks.
436
452
 
437
453
  **One idea per slide.** The title states the idea as a sentence a listener
438
454
  could repeat ("Narration renders in two minutes", not "Speed"). Everything
@@ -451,16 +467,15 @@ new sizes:
451
467
  | `--t-body` | 24px | 15px | list items, card text |
452
468
  | `--t-label` | 17px | 12px | kickers, sources, captions (mono, uppercase) |
453
469
 
454
- Nothing on a slide below 17px at stage size (a screenshot's frame chrome aside). A title over 28 characters needs
455
- `data-title-mobile`; write the short version as a real sentence.
470
+ Nothing on a slide below 17px at stage size (a screenshot's frame chrome aside). A title over
471
+ 28 characters needs `data-title-mobile`, written as a real sentence.
456
472
 
457
473
  **Colour and contrast.** Three colours do the work: `--ink`, `--paper` and one
458
- `--accent` (a second, `--accent-2`, only for a chart's other series, an
459
- illustration or a placeholder face). Text is ink on paper or accent, or paper on ink: never accent
474
+ `--accent` (`--accent-2` only for a chart's second series or a placeholder
475
+ face). Text is ink on paper or accent, or paper on ink: never accent
460
476
  on paper, never grey on a colour. Alternate slide grounds (`on-accent`,
461
477
  plain, `on-ink`) so the slide changes read, and keep every number, source and
462
- label at full contrast. Change the palette and fonts once, in `:root`; every
463
- layout reads them.
478
+ label at full contrast. Set the palette and fonts once, in `:root`.
464
479
 
465
480
  **Start from the brand.** The account keeps a brand: colours, fonts, a logo,
466
481
  a presenter style and a design.md (voice, do and don't). `deck new` and
@@ -468,16 +483,14 @@ a presenter style and a design.md (voice, do and don't). `deck new` and
468
483
  it first (`hyperdecks brand show --json`, `get_brand`) and write in its voice.
469
484
  An explicit request for this deck wins over the brand; `--no-brand`
470
485
  (`brand: false`) starts from the template's look. No brand yet? Offer to
471
- import it from what they already have: `hyperdecks brand import
472
- <url|file.pptx|file.pdf|deck-slug>` (`import_brand`) reads the real colours,
473
- fonts and logo (a PowerPoint's theme exactly) and returns a preview link, a
474
- sample slide in that brand, and a token. Always show them the preview and
475
- ask; save with `brand set --draft <token>` (`set_brand { draft }`) only after
476
- they say yes.
477
- To change it in words ("darker accent, serif titles"), `hyperdecks brand
478
- revise "<change>"` (`revise_brand`) gives a draft and preview the same way.
479
- Every brand save is kept in its history: `brand history` (`brand_history`)
480
- lists the last changes and `brand undo [<version>]` (`undo_brand`) goes back.
486
+ import it: `hyperdecks brand import <url|file.pptx|file.pdf|deck-slug>`
487
+ (`import_brand`) reads the real colours, fonts and logo (a PowerPoint's theme
488
+ exactly) and returns a preview link of a sample slide and a token. Show them
489
+ the preview; save with `brand set --draft <token>` (`set_brand { draft }`)
490
+ only after they say yes.
491
+ To change it in words, `brand revise "<change>"` (`revise_brand`) gives a
492
+ draft and preview the same way; `brand history` (`brand_history`) lists past
493
+ saves and `brand undo [<version>]` (`undo_brand`) goes back.
481
494
 
482
495
  **Fonts: pick a pairing.** Fonts load from Google Fonts. Use one of the
483
496
  curated pairings below (display for titles, body for text, mono for kickers
@@ -489,13 +502,19 @@ saves one.
489
502
  **Textures, icons, pictures:** the `look` topic (one texture, one icon set
490
503
  from `search_icons` or `generate_icon_set`, a picture on every other slide).
491
504
 
492
- **Pick the layout from what the slide says.** Each layout's file in
493
- `templates/layouts/` opens with its slot side, presenter and the cues that
494
- suit it.
505
+ **Pick the layout from what the slide says** (each file in
506
+ `templates/layouts/` opens with its slot, presenter and cues).
507
+
508
+ **The cover never puts text on the picture.** The title and lead sit on a
509
+ solid ground, never on a picture or one faded
510
+ with a gradient `mask-image` (`check` warns).
495
511
 
496
512
  | Layout | Use it for | Presenter, moves |
497
513
  |---|---|---|
498
- | `hero` | the opening, a section opener; the presenter is the visual, no `.art` unless it shows the real subject (check fails while the placeholder `<div class="art">` is in: swap it for an `<img class="art">` or delete it) | stage, bottom-right; `[wave]`, `[look: h1]` |
514
+ | `hero` | the default cover, a section opener: title on the ground, the real subject framed beside it (no `.art`: the presenter is the visual) | stage; `[wave]`, `[look: h1]` |
515
+ | `cover-band` | the picture is the story: full bleed, title on a panel | as `hero` |
516
+ | `cover-poster` | a wide scene: picture on top, title below | as `hero` |
517
+ | `cover-type` | no real picture yet: type only, presenter large | as `hero` |
499
518
  | `number` | one number with its source: proof, traction, a delta | stage; `[look: #<id>-figure]` as it is said |
500
519
  | `product` | a screenshot in a browser frame (`.frame.card` for a phone) with the point beside it | stage; `[look: #<id>-frame]`, `[reveal: #<id>-callout]` |
501
520
  | `split` | the workhorse: a real picture (phone, laptop, 16:9) beside sticker, title, three ticks | stage, bottom-left; `[look: #<id>-frame]`, `[go: #<id>-t1]` … `t3` |
@@ -631,7 +650,7 @@ an app icon or a face), `.ticks`, `.panel`, `.pill`, `.frame.card` (a phone),
631
650
  `<concrete subject>, <setting>, <medium: e.g. editorial photo, soft daylight>,
632
651
  palette <ink hex> <accent hex> <paper hex>, no text, no logos`
633
652
  (`generate_image { brand: true }` adds the palette and the "no text" for
634
- you). Generate at the slide's shape (wide for a hero), in one batch.
653
+ you). Generate at the slide's shape (16:9; near-square for the hero), in one batch.
635
654
 
636
655
  **Slide recipes.** Opener: `on-accent` + `bg-corner`, the logo
637
656
  or a few app-icon stickers, one display line. Product or feature: `split`
@@ -764,6 +783,8 @@ you do).
764
783
  clip that says "Minimoji" but is transcribed as "Moji" needs
765
784
  `normalize: moji=minimoji`, and with a second entry
766
785
  `normalize: moji=minimoji, dash=D.A.S.H.` (one line, not a YAML list).
786
+ British and American spellings of a word (centre/center, colour/color,
787
+ organise/organize, travelled/traveled) already pass: no entry needed.
767
788
  - Write a narrator's name as it is spoken when the two differ, or leave it to
768
789
  the narrator's spoken name: `narrator list` shows it, and narrate reads
769
790
  "D.A.S.H." as "Dash" (captions keep what you wrote).
@@ -825,7 +846,8 @@ with the player's `hyperdecks.ai` script tag. Messages and what to do:
825
846
  | `forbidden phrase` | remove it from the page, script or narration |
826
847
  | `slide <id>: needs a data-note` / ⚠ `no data-note` | add the source (required on a `data-kind="pitch"` deck and with `--strict`) |
827
848
  | `slide <id>: title … needs a data-title-mobile` | add a shorter title |
828
- | `slide <id>: the hero's art` / `the product screenshot` / `the chart … is still the layout's placeholder` | make the real file (`gen-image`, `screenshot <url>`, `chart <spec.json>`) and swap the element for the `<img>` the message names; or delete the hero's `<div class="art">` for no art |
849
+ | `slide <id>: the hero's art` / `the cover's picture` / `the product screenshot` / `the chart … is still the layout's placeholder` | make the real file (`gen-image`, `screenshot <url>`, `chart <spec.json>`) and swap the element for the `<img>` the message names; or delete the hero's `<div class="art">` for no art (on `cover-band` / `cover-poster`, switch to `cover-type` instead) |
850
+ | ⚠ `slide <id>: the … cover fades its picture into the ground with a mask-image` | the old hero's look: the title sits on picture and colour mixed. Move the deck's opening to a current cover (`hero`, `cover-band`, `cover-poster`, `cover-type`), or drop the mask and keep the title off the picture |
829
851
  | `… presenter style "…" is not one of` | use stage, bubble, glass or corner |
830
852
  | `script.md line N: [go: …] matches nothing in slide <id>` | fix the selector, or add the element to that slide |
831
853
  | `… transition "…" is not one of` | use drift, cut, push, wipe, zoom `<selector>`, morph or fly |
@@ -992,6 +1014,8 @@ Generate locally, select an existing same-deck recipient share, then explicitly
992
1014
 
993
1015
  Capture defaults to the initial slide, 4 seconds, 600px width and 10 fps, with a play/watch badge on GIF, PNG and silent MP4. `--duration` accepts 1–10 seconds, `--width` 240–1200 pixels (odd widths round up for MP4), `--delay` 0–10000 milliseconds (default 1000), and `--label "Watch · 2:41"` supplies an explicit label; no whole-deck narration duration is inferred. Aspect comes from `--aspect-ratio W:H`, `<meta name="preview-aspect-ratio" content="16:9">`, or the first slide/canvas bounds; otherwise 16:9. Output height rounds to an even pixel. Fonts get up to 5 seconds to settle. Inspect readiness warnings/poster for slow modules or media and increase the bounded delay if needed.
994
1016
 
1017
+ To show the slide that sells the deck instead of its opening, name it and a moment of its narration: `hyperdecks preview create my-deck --scene chars --start 3.2 --share EXISTING_TOKEN --out ./preview-chars --upload --json`. `--scene` is the slide's id (its `<section class="slide" id>`; an unknown id fails and lists the deck's ids); `--start` is seconds into that slide's narration (default 0; it must fall inside the slide's clip plus its hold). The GIF then starts on exactly that moment: the guest speaking then, with its mouth and caption bubble, and every cue fired by then. It records frame by frame on the player's page clock (as `export --video` does), silently, after the slide's character heads finish loading; `--delay` still adds settle time first. Private character heads use the same in-memory, deck-scoped view grant; nothing is written to disk. `manifest.json` gets a `moment` saying which slide, which guest and which caption the first frame shows. Needs a deck on player/v1.
1018
+
995
1019
  `--json` prints one line on stdout, identical to `<out>/manifest.json`:
996
1020
 
997
1021
  ```json
@@ -46,9 +46,11 @@ makes the hello deck; continue from step 5 below.
46
46
  `upload_deck_asset` for a picture the user gave you (a URL, or a file they
47
47
  uploaded). Each returns a hosted URL: put that URL in the HTML. A hosted deck
48
48
  has no folder, so a relative path like `img/a.png` is an error. The
49
- layouts' stand-ins (hero's `<div class="art">`, product's
49
+ layouts' stand-ins (the covers' `<div class="art">`, product's
50
50
  `<div class="shot">`, chart's placeholder `<svg>`) fail `check_deck` until
51
51
  you swap each for an `<img>` of its real file, or delete the hero's art.
52
+ Open on a cover (`hero`, `cover-band`, `cover-poster` or `cover-type`,
53
+ deck_guide topic layouts) and never put the title or lead on the picture.
52
54
  Icons: one set per deck (`look` topic): `search_icons { query }` for real
53
55
  line icons as inline `<svg>` in `currentColor`, or one `generate_icon_set`.
54
56
  For motion, `generate_video` (poll `check_video_job`): `loop: true` for a
@@ -126,6 +128,19 @@ makes the hello deck; continue from step 5 below.
126
128
  worker it answers with the `hyperdecks export <slug> --video` command
127
129
  instead; give that to the user to run.
128
130
 
131
+ ## Giving a deck to another account
132
+
133
+ Like moving a GitHub repo. Only when the person asks: confirm the
134
+ recipient's email, then `transfer_deck { slug, to }`. They get an email and
135
+ have 7 days; nothing moves until they accept, and `transfer_deck { slug,
136
+ cancel: true }` takes it back. On the other side, `list_transfers` shows what
137
+ waits. Before `accept_transfer`, ask who narrates it for them when
138
+ `needsNarrator` (`list_narrators`: theirs, dash, none or default), and a
139
+ voice and avatar of theirs for each guest with `needsPick`; the sender's
140
+ voice and face never move. Accepting moves the deck with its share links
141
+ (links already sent keep working), recipients and stats, and starts narrating
142
+ their picks; `release_deck` puts them on the links.
143
+
129
144
  ## Rules that matter
130
145
 
131
146
  - Every slide is `<section class="slide" id="…" data-note="<source of its facts>">`,
@@ -0,0 +1,89 @@
1
+ // Pack management never changes avatar IDs or the user's default presenter.
2
+ // Publication is a distinct command; there is no public flag on create/import.
3
+ import { makeFunctionReference } from "convex/server";
4
+ import { client } from "./runtime.mjs";
5
+
6
+ const ref = (name) => makeFunctionReference(`avatarPacks:${name}`);
7
+ const PACK_USAGE = "usage: avatar pack list | create <name> [--private-only] [--description text] | get <pack-id> | add <pack-id> <avatar-id> | grant <pack-id> --account <id>|--email <email>|--team <id> | grants <pack-id> | revoke <grant-id> | publish <pack-id> | unpublish <pack-id> | invitations | claim | catalog";
8
+ const TEAM_USAGE = "usage: avatar team list | create <name> | members <team-id> | add <team-id> --account <id>|--email <email> | remove <membership-id>";
9
+ const text = (value, label) => {
10
+ if (typeof value !== "string" || !value.trim() || value.startsWith("--")) throw new Error(`${label} requires a value`);
11
+ return value.trim();
12
+ };
13
+ function exactly(pos, count, usage) {
14
+ if (pos.length !== count || pos.some(value => !value)) throw new Error(usage);
15
+ return pos;
16
+ }
17
+ function recipient(opts, allowTeam) {
18
+ const names = allowTeam ? ["account", "email", "team"] : ["account", "email"];
19
+ const selected = names.filter(name => Object.hasOwn(opts, name));
20
+ if (selected.length !== 1 || (!allowTeam && Object.hasOwn(opts, "team"))) throw new Error(`choose exactly one of ${names.map(name => `--${name}`).join(", ")}`);
21
+ const key = selected[0], value = text(opts[key], `--${key}`);
22
+ return { [{ account: "accountId", email: "email", team: "teamId" }[key]]: value };
23
+ }
24
+ function output(result, opts, log) {
25
+ if (opts.json) log(JSON.stringify(result, null, 2));
26
+ else if (Array.isArray(result)) {
27
+ if (!result.length) log("(none)");
28
+ for (const item of result) log(`${item.id ?? item._id}\t${item.name ?? item.granteeId ?? item.memberId ?? item.email ?? item.teamId ?? ""}${item.visibility ? `\t${item.visibility}` : ""}${item.access ? ` (${item.access})` : ""}${item.privateOnly ? " · permanently private-only" : ""}${item.status ? `\t${item.status}` : ""}`);
29
+ } else log(JSON.stringify(result, null, 2));
30
+ return result;
31
+ }
32
+
33
+ export async function avatarPacksCommand(pos = [], opts = {}, { log = console.log } = {}) {
34
+ const [action = "list", ...args] = pos;
35
+ let method = "query", apiName, payload;
36
+ switch (action) {
37
+ case "list": exactly(args, 0, PACK_USAGE); apiName = "list"; payload = {}; break;
38
+ case "create":
39
+ if (!args.length) throw new Error(PACK_USAGE);
40
+ if (Object.hasOwn(opts, "public") || Object.hasOwn(opts, "publish")) throw new Error("packs are created private; use the separate publish command for an eligible pack");
41
+ apiName = "create"; method = "mutation";
42
+ payload = { name: text(args.join(" "), "pack name"), privateOnly: opts["private-only"] === true };
43
+ if (Object.hasOwn(opts, "description")) payload.description = text(opts.description, "--description");
44
+ break;
45
+ case "get": case "grants":
46
+ exactly(args, 1, PACK_USAGE); apiName = action; payload = { packId: args[0] }; break;
47
+ case "add":
48
+ exactly(args, 2, PACK_USAGE); apiName = "addAvatar"; method = "mutation";
49
+ payload = { packId: args[0], avatarId: args[1] }; break;
50
+ case "grant":
51
+ exactly(args, 1, PACK_USAGE); apiName = "grant"; method = "mutation";
52
+ payload = { packId: args[0], ...recipient(opts, true) }; break;
53
+ case "revoke":
54
+ exactly(args, 1, PACK_USAGE); apiName = "revoke"; method = "mutation"; payload = { grantId: args[0] }; break;
55
+ case "publish": case "unpublish":
56
+ exactly(args, 1, PACK_USAGE); apiName = action; method = "mutation"; payload = { packId: args[0] }; break;
57
+ case "invitations": case "claim":
58
+ exactly(args, 0, PACK_USAGE); apiName = action === "claim" ? "claimInvitations" : "invitations";
59
+ method = action === "claim" ? "mutation" : "query"; payload = {}; break;
60
+ case "catalog":
61
+ exactly(args, 0, PACK_USAGE); apiName = "publicCatalog";
62
+ payload = { paginationOpts: { numItems: 32, cursor: Object.hasOwn(opts, "cursor") ? text(opts.cursor, "--cursor") : null } }; break;
63
+ default: throw new Error(PACK_USAGE);
64
+ }
65
+ const result = await (await client(opts))[method](ref(apiName), payload);
66
+ if (action === "get" && !result) throw new Error("pack not found or unavailable to your account");
67
+ if (!opts.json && action === "create") log(result.privateOnly ? "Created privately. The private-only lock is permanent; this pack cannot be published." : "Created privately. Only an explicit publish command makes this pack public.");
68
+ return output(result, opts, log);
69
+ }
70
+
71
+ export async function avatarTeamsCommand(pos = [], opts = {}, { log = console.log } = {}) {
72
+ const [action = "list", ...args] = pos;
73
+ let method = "query", apiName, payload;
74
+ switch (action) {
75
+ case "list": exactly(args, 0, TEAM_USAGE); apiName = "teams"; payload = {}; break;
76
+ case "create":
77
+ if (!args.length) throw new Error(TEAM_USAGE);
78
+ apiName = "createTeam"; method = "mutation"; payload = { name: text(args.join(" "), "team name") }; break;
79
+ case "members":
80
+ exactly(args, 1, TEAM_USAGE); apiName = "teamMembers"; payload = { teamId: args[0] }; break;
81
+ case "add":
82
+ exactly(args, 1, TEAM_USAGE); apiName = "addTeamMember"; method = "mutation";
83
+ payload = { teamId: args[0], ...recipient(opts, false) }; break;
84
+ case "remove":
85
+ exactly(args, 1, TEAM_USAGE); apiName = "removeTeamMember"; method = "mutation"; payload = { membershipId: args[0] }; break;
86
+ default: throw new Error(TEAM_USAGE);
87
+ }
88
+ return output(await (await client(opts))[method](ref(apiName), payload), opts, log);
89
+ }
@@ -0,0 +1,34 @@
1
+ // Ephemeral HTML transform for play/capture/export. Never write its output to
2
+ // source push, release source, disk caches, or logs: it contains a view grant.
3
+ import { makeFunctionReference } from "convex/server";
4
+ const ISSUE_OWNER_VIEW = makeFunctionReference("avatarAccess:issueOwnerView");
5
+ const VIEW_META = /<meta\b[^>]*\bname\s*=\s*(?:"hd-avatar-view"|'hd-avatar-view'|hd-avatar-view(?=\s|\/?>))[^>]*>/gi;
6
+ const PRIVATE_REFS = /\/avatar\/private\/|\bdata-hd-avatar\b/i;
7
+
8
+ export async function withAvatarView(client, slug, html, scope = "draft") {
9
+ if (typeof html !== "string") throw new Error("avatar preview requires HTML text");
10
+ if (scope !== "draft" && scope !== "released") throw new Error("avatar preview scope must be draft or released");
11
+ const hadView = /<meta\b[^>]*\bname\s*=\s*(?:"hd-avatar-view"|'hd-avatar-view'|hd-avatar-view(?=\s|\/?>))/i.test(html);
12
+ const needsView = hadView || PRIVATE_REFS.test(html);
13
+ // Always request a session: guest URLs may live only in the narration
14
+ // manifest. Offline ordinary playback remains usable without a grant;
15
+ // protected endpoints still reject private models without authorization.
16
+ const clean = html.replace(VIEW_META, "");
17
+ if (!needsView && !/\bdata-(?:manifest|hd-speaker)\b/i.test(html)) return clean;
18
+ let grant;
19
+ try {
20
+ if (typeof slug !== "string" || !slug.trim()) throw new Error("a deck slug is required");
21
+ grant = await client.mutation(ISSUE_OWNER_VIEW, { slug, scope });
22
+ } catch (error) {
23
+ if (needsView) throw new Error("Cannot authorize private avatars for this preview. Sign in as the deck owner, verify pack access, and push the current deck source before retrying.", { cause: error });
24
+ return clean;
25
+ }
26
+ if (!grant) {
27
+ if (needsView) throw new Error("No current private-avatar view grant. Push the deck source and script so their avatar IDs are registered, then retry.");
28
+ return clean;
29
+ }
30
+ if (!/^[a-f0-9]{64}$/.test(grant.token) || !Number.isFinite(grant.expiresAt) || grant.expiresAt <= Date.now()) throw new Error("The server returned an invalid or expired avatar view grant");
31
+ const meta = `<meta name="hd-avatar-view" content="${grant.token}">`;
32
+ // Put the grant before any player script; the HTML string stays in memory.
33
+ return /<head\b[^>]*>/i.test(clean) ? clean.replace(/<head\b[^>]*>/i, match => `${match}\n${meta}`) : `${meta}\n${clean}`;
34
+ }
package/src/brand.mjs CHANGED
@@ -118,6 +118,21 @@ export function cleanBrand(brand) {
118
118
  return { name, colors, fonts, logoUrl, presenterStyle, hasDesignMd };
119
119
  }
120
120
 
121
+ // The templates' own ink, paper and accent (layouts/_deck.html, narrated-deck.html), for a brand that sets only some colours.
122
+ const TEMPLATE_INK = "#0b0b0f", TEMPLATE_PAPER = "#fafaf7", TEMPLATE_ACCENT = "#ffda1b";
123
+ // WCAG relative luminance and contrast ratio of two #rgb / #rrggbb colours.
124
+ function luminance(hex) {
125
+ const h = hex.replace("#", ""), full = h.length === 3 ? [...h].map((c) => c + c).join("") : h.slice(0, 6);
126
+ const [r, g, b] = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255).map((c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
127
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
128
+ }
129
+ export const contrast = (a, b) => { const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m); return (x + 0.05) / (y + 0.05); };
130
+ // Which of ink and paper the text on the accent is: ink unless it falls under 4.5:1 and paper reads better.
131
+ export function accentInk(accent, ink, paper) {
132
+ const onInk = contrast(accent, ink);
133
+ return onInk < 4.5 && contrast(accent, paper) > onInk ? "paper" : "ink";
134
+ }
135
+
121
136
  const escapeAttr = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
122
137
  const fontStack = (family, role) => `"${family}", ${FALLBACK[role]}`;
123
138
  const LOGO_CSS = `
@@ -141,6 +156,10 @@ export function applyBrand(html, brand) {
141
156
  block = decl.test(block) ? block.replace(decl, (_, k) => `${k}${value}`) : block.replace(/\s*\}$/, (end) => ` ${name}: ${value};${end}`);
142
157
  };
143
158
  for (const [k, v] of Object.entries(b.colors)) set(CSS_VAR[k], v);
159
+ // Text on an accent ground reads --accent-ink (ink by default): a dark accent
160
+ // that ink cannot be read on takes the paper instead, so a cover on the accent stays legible.
161
+ const { accent = TEMPLATE_ACCENT, ink = TEMPLATE_INK, paper = TEMPLATE_PAPER } = b.colors;
162
+ if ((b.colors.accent || b.colors.ink || b.colors.paper) && accentInk(accent, ink, paper) === "paper") set("--accent-ink", "var(--paper)");
144
163
  if (b.fonts.body) set("--font", fontStack(b.fonts.body, "body"));
145
164
  if (b.fonts.mono) set("--mono", fontStack(b.fonts.mono, "mono"));
146
165
  if (b.fonts.display) {
@@ -72,6 +72,7 @@ export const MESSAGES = {
72
72
  transitionStale: (where, script, manifest) => `${where}: script.md says transition "${script}" but the manifest has "${manifest}": run \`hyperdecks narrate\` so the player gets it`,
73
73
  flyTwice: (ids) => `fly is for the deck's one big moment, but slides ${ids.join(", ")} all use it`,
74
74
  // Warnings from the look topic (deck_guide look): the deck passes, but a person should look.
75
+ coverFade: (where, layout) => `${where}: the ${layout} cover fades its picture into the ground with a mask-image, so the title sits on a mix of picture and colour that is hard to read: keep the text off the picture (layouts hero, cover-band, cover-poster or cover-type; deck_guide topic layouts)`,
75
76
  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)`,
76
77
  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)`,
77
78
  mixedIcons: (sets) => `the deck mixes icon sets (${sets.join(" and ")}); keep one set per deck (deck_guide topic look)`,
@@ -316,6 +317,19 @@ export function slides(html) {
316
317
  });
317
318
  }
318
319
 
320
+ // Each slide's titles written short for phones: { id, titles: [{ desktop, phone }] },
321
+ // only where the phone text differs. The player swaps data-title-mobile in below
322
+ // 650px, so review must not read the shorter phone title as cut off.
323
+ export function mobileTitles(html) {
324
+ return slides(html).map(({ id, body }) => ({
325
+ id,
326
+ titles: [...body.matchAll(/<([a-z][\w-]*)\b([^>]*\sdata-title-mobile\s*=[^>]*)>([\s\S]*?)<\/\1>/gi)]
327
+ .map(([, , attrs, inner]) => ({ desktop: textOf(inner), phone: attr(attrs, "data-title-mobile") }))
328
+ .filter((t) => t.phone != null && decode(t.phone).trim() && decode(t.phone).trim() !== t.desktop)
329
+ .map((t) => ({ desktop: t.desktop.slice(0, 200), phone: decode(t.phone).trim().slice(0, 200) })),
330
+ })).filter((s) => s.id && s.titles.length);
331
+ }
332
+
319
333
  // The player tag also carries the deck's declarations: data-silent (no
320
334
  // narration, on purpose) and data-kind="pitch" (every slide sources its facts).
321
335
  // The check reads what the deck says, never guesses from its content.
@@ -535,6 +549,14 @@ const PLACEHOLDERS = [
535
549
  layout: "l-hero", tag: "div", cls: "art", what: "the hero's art", img: '<img class="art" src="…" alt="">, or delete the div for no art (the presenter is the visual)',
536
550
  how: { hosted: "make a picture of the real subject with generate_image", cli: "make a picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
537
551
  },
552
+ {
553
+ layout: "l-cover-band", tag: "div", cls: "art", what: "the cover's picture", img: '<img class="art" src="…" alt="">, or use the cover-type layout for no picture',
554
+ how: { hosted: "make a wide picture of the real subject with generate_image", cli: "make a wide picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
555
+ },
556
+ {
557
+ layout: "l-cover-poster", tag: "div", cls: "art", what: "the cover's picture", img: '<img class="art" src="…" alt="">, or use the cover-type layout for no picture',
558
+ how: { hosted: "make a wide picture of the real subject with generate_image", cli: "make a wide picture of the real subject with `hyperdecks gen-image \"<prompt>\"`" },
559
+ },
538
560
  {
539
561
  layout: "l-product", tag: "div", cls: "shot", what: "the product screenshot", img: '<img class="shot" src="…" alt="…">',
540
562
  how: { hosted: "capture the real page with capture_url", cli: "capture the real page with `hyperdecks screenshot <url>`" },
@@ -814,10 +836,27 @@ export function lookWarnings(html) {
814
836
  if (iconImg(tag)) sets.add("image icons");
815
837
  }
816
838
  if (sets.size > 1) out.push(MESSAGES.mixedIcons([...sets].sort()));
817
- out.push(...textureWarnings(all, html));
839
+ out.push(...textureWarnings(all, html), ...coverFadeWarnings(all, html));
818
840
  return out;
819
841
  }
820
842
 
843
+ // Covers never put text on the picture. Where the text overlaps a picture can only be
844
+ // measured by rendering, so this reads the one source pattern that always does: a cover
845
+ // layout whose CSS fades its picture with a gradient mask-image (the old hero's look,
846
+ // still inlined in decks made from it). A deck's own picture and text placement beyond
847
+ // that is left to the layouts and the review.
848
+ export const COVER_LAYOUTS = ["l-hero", "l-cover-band", "l-cover-poster", "l-cover-type"];
849
+ function coverFadeWarnings(all, html) {
850
+ const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
851
+ const faded = new Set();
852
+ for (const [, sel, body] of css.matchAll(/([^{}]*)\{([^{}]*)\}/g)) {
853
+ if (!/(?:^|;|\s)(?:-webkit-)?mask(?:-image)?\s*:[^;]*gradient\(/i.test(body)) continue;
854
+ for (const l of COVER_LAYOUTS) if (new RegExp(`\\.${l}(?![\\w-])`).test(sel)) faded.add(l);
855
+ }
856
+ if (!faded.size) return [];
857
+ return all.flatMap((s, i) => classes(s.tag).filter((c) => faded.has(c)).map((c) => MESSAGES.coverFade(`slide ${s.id || i + 1}`, c.slice(2))));
858
+ }
859
+
821
860
  // Backgrounds (the look topic): the kit's textures are confined to the object they
822
861
  // back. The retired whole-slide textures warn rather than vanish silently (a deck made
823
862
  // before keeps their rules in its own inlined shell, where they still paint). Read from each slide's class attribute; a texture on a layout whose shell
@@ -832,10 +871,11 @@ const RETIRED_TEXTURES = {
832
871
  const SIGNATURES = ["bg-corner", "bg-mark"];
833
872
  export const MAX_SIGNATURE_SLIDES = 3;
834
873
  const BUSY_LAYOUTS = ["l-number", "l-features", "l-team", "l-timeline", "l-compare"]; // full-width content: nothing to back
874
+ const PICTURE_COVERS = ["l-cover-band", "l-cover-poster"]; // the picture is the ground: a texture would only sit under it
835
875
  export const TEXTURE_OFF = {
836
- "bg-corner": ["l-split", "l-product", "l-chart", "l-quote", ...BUSY_LAYOUTS],
837
- "bg-stage": ["l-split", "l-chart", "l-quote", "l-closer", ...BUSY_LAYOUTS],
838
- "bg-mark": ["l-split", "l-product", "l-chart", "l-phones", ...BUSY_LAYOUTS],
876
+ "bg-corner": ["l-split", "l-product", "l-chart", "l-quote", ...BUSY_LAYOUTS, ...PICTURE_COVERS],
877
+ "bg-stage": ["l-split", "l-chart", "l-quote", "l-closer", ...BUSY_LAYOUTS, ...PICTURE_COVERS, "l-cover-type"],
878
+ "bg-mark": ["l-split", "l-product", "l-chart", "l-phones", ...BUSY_LAYOUTS, ...PICTURE_COVERS],
839
879
  };
840
880
  function textureWarnings(all, html) {
841
881
  const css = [...uncommented(html).matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)].map((m) => m[1].replace(/\/\*[\s\S]*?\*\//g, "")).join("\n");
package/src/commands.mjs CHANGED
@@ -173,6 +173,65 @@ async function deckForward(pos, opts) {
173
173
  console.log(`OK forwarding ${mode} for deck ${slug} (links with their own setting keep it)`);
174
174
  }
175
175
 
176
+ const TRANSFER_USAGE = "usage: deck transfer <slug> --to <email>";
177
+ async function deckTransfer(pos, opts) {
178
+ const [slug] = pos;
179
+ if (!slug || !opts.to) throw new Error(TRANSFER_USAGE);
180
+ const out = await ops.transferDeck(opts, slug, opts.to);
181
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
182
+ console.log(`OK ${out.existing ? "already offered" : "offered"} ${slug} to ${out.to}, until ${out.expiresAt.slice(0, 10)}`);
183
+ console.log(" Nothing moves until they accept (signed in to Hyperdecks with that email). Cancel: deck transfers cancel " + slug);
184
+ }
185
+
186
+ const TRANSFERS_USAGE = "usage: deck transfers [accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]... [--no-narrate] | decline <id> | cancel <slug>]";
187
+ // "1=Ana/Ana Face,2=stock:liam" → [{ guest: 1, voice: "Ana", avatar: "Ana Face" }, …]
188
+ export function parseGuestPicks(spec) {
189
+ if (!spec) return [];
190
+ return String(spec).split(",").map((part) => {
191
+ const m = /^\s*(\d+)\s*=\s*([^/]+?)\s*(?:\/\s*(.+?)\s*)?$/.exec(part);
192
+ if (!m) throw new Error(`--guest ${part}: write <n>=<voice> or <n>=<voice>/<avatar>`);
193
+ return { guest: Number(m[1]), voice: m[2], ...(m[3] ? { avatar: m[3] } : {}) };
194
+ });
195
+ }
196
+
197
+ async function deckTransfers(pos, opts) {
198
+ const [action, arg] = pos;
199
+ if (action === "accept") {
200
+ if (!arg) throw new Error(TRANSFERS_USAGE);
201
+ const out = await ops.acceptTransfer(opts, arg, { narrator: opts.narrator, guests: parseGuestPicks(opts.guest), renarrate: !opts["no-narrate"] });
202
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
203
+ console.log(`OK ${out.slug} is yours, with its share links and stats; links already sent keep working`);
204
+ if (out.narration?.jobId) console.log(` narrating your picks: job ${out.narration.jobId} (narrate status), then release ${out.slug}`);
205
+ else if (out.narration?.error) console.log(` ⚠ narration did not start: ${out.narration.error}\n ${out.narration.note}`);
206
+ else if (out.narration?.note) console.log(` ${out.narration.note}`);
207
+ return;
208
+ }
209
+ if (action === "decline") {
210
+ if (!arg) throw new Error(TRANSFERS_USAGE);
211
+ const out = await ops.declineTransfer(opts, arg);
212
+ return void console.log(opts.json ? JSON.stringify(out) : `OK declined ${out.slug}`);
213
+ }
214
+ if (action === "cancel") {
215
+ if (!arg) throw new Error(TRANSFERS_USAGE);
216
+ const out = await ops.cancelTransfer(opts, arg);
217
+ return void console.log(opts.json ? JSON.stringify(out) : `OK cancelled the transfer of ${out.slug} to ${out.to}`);
218
+ }
219
+ if (action) throw new Error(TRANSFERS_USAGE);
220
+ const out = await ops.listTransfers(opts);
221
+ if (opts.json) return void console.log(JSON.stringify(out, null, 2));
222
+ if (out.emailUnverified) console.log("⚠ your account's email is not verified, so transfers sent to it can't be shown or accepted");
223
+ if (!out.incoming.length && !out.outgoing.length) return void console.log("No transfers waiting.");
224
+ for (const t of out.incoming) {
225
+ console.log(`← ${t.slug} "${t.title}" from ${t.from ?? "someone"} (until ${t.expiresAt.slice(0, 10)}) id ${t.id}`);
226
+ if (t.needsNarrator) console.log(" narrated in the sender's narrator or voice: pick yours with --narrator <name|dash|none|default>");
227
+ for (const g of t.guests) {
228
+ console.log(` guest ${g.guest} ${g.name} (${g.selector}): voice ${g.voice}${g.avatar ? `, avatar ${g.avatar}` : ""}${g.needsPick ? " → pick with --guest " + g.guest + "=<voice>/<avatar>" : ""}`);
229
+ }
230
+ console.log(` accept: deck transfers accept ${t.id}${t.needsNarrator ? " --narrator <…>" : ""}${t.guests.filter((g) => g.needsPick).map((g) => ` --guest ${g.guest}=<voice>/<avatar>`).join("")}`);
231
+ }
232
+ for (const t of out.outgoing) console.log(`→ ${t.slug} "${t.title}" to ${t.to} (until ${t.expiresAt.slice(0, 10)}); cancel: deck transfers cancel ${t.slug}`);
233
+ }
234
+
176
235
  const VIEW_USAGE = "usage: deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]";
177
236
  const VIEWS = { slides: "slides", scroll: "scroll", default: null };
178
237
  const PACES = { auto: "auto", slide: "slide", step: "step", default: null };
@@ -363,12 +422,20 @@ commands:
363
422
  login [--token <pat>] [--broker <url>] [--dev]
364
423
  logout [--dev]
365
424
  whoami [--json] [--dev]
366
- deck list|stats|open|link|rm|forward|view
425
+ deck list|stats|open|link|rm|forward|view|transfer|transfers
367
426
  deck link <slug> [--json]
368
427
  the owner link: present or check your own deck (only you, signed in; untracked;
369
428
  Present or H hides your owner tools). Use it instead of a QA link. deck open opens it.
370
429
  deck forward on|off <slug>
371
430
  whether viewers can forward the deck's links (on by default; a link's own setting wins)
431
+ deck transfer <slug> --to <email>
432
+ give the deck to another Hyperdecks account (like a GitHub repo transfer). Nothing moves until
433
+ they accept, within 7 days; then its share links, recipients and stats are theirs, not yours.
434
+ deck transfers [accept <id> [--narrator <name|dash|none|default>] [--guest <n>=<voice>[/<avatar>]]... [--no-narrate]
435
+ | decline <id> | cancel <slug>]
436
+ transfers waiting on you and the ones you sent. Accepting picks your narrator and a voice and
437
+ avatar for each guest in the sender's own voice or face, then narrates those lines as you;
438
+ the deck plays as released until you release it.
372
439
  deck view <slug> [slides|scroll|default] [--toggle on|off] [--pace auto|slide|step|default] [--ai-readable on|off]
373
440
  how the deck opens in the player (default: as its HTML says, data-view or slides) and
374
441
  whether viewers get the slides/scroll toggle (on by default). Every link, next load; no re-release.
@@ -471,6 +538,9 @@ commands:
471
538
 
472
539
  preview create <slug> --out <directory> [--share <token>] [--upload] [--json]
473
540
  preview create --file <index.html> --out <directory> [--duration 4] [--width 600] [--delay 1000] [--label "Watch"] [--aspect-ratio 16:9]
541
+ preview create <slug> --out <directory> --scene <slide-id> [--start <seconds>] [--share <token> --upload]
542
+ the email GIF of one slide, from <seconds> into its narration (default 0): the guest
543
+ speaking then, its caption bubble and every cue fired by then; needs a player/v1 deck
474
544
  preview setup install isolated Chromium (Linux: see README for system dependencies)
475
545
 
476
546
  narrate <deck-dir> [--slide <id>] [--voice <name|id>] [--slug <slug>] [--dry-run] [--json]
@@ -483,6 +553,9 @@ commands:
483
553
  voice rm <name|id> delete a voice, also at the provider
484
554
  voice hear <voice> | voice hear --narrator <name|id>
485
555
  a short sample of the voice: its clip URL and the line it says
556
+ avatar pack [list|create|get|add|grant|grants|revoke|publish|unpublish|invitations|claim|catalog]
557
+ owner-managed packs; create <name> --private-only permanently prevents publication
558
+ avatar team [list|create|members|add|remove] owner-managed account groups for pack grants
486
559
  avatar [--json] your avatars (who presents decks in your voice) and the default
487
560
  avatar new [--species <s>] pick a species, then an avatar (s shuffles); --json or an agent gets a pick link
488
561
  avatar from-photo [file] [--save-to-minimoji]
@@ -805,7 +878,8 @@ function commandTable(pos, rest, opts) {
805
878
  "preview create": async () => {
806
879
  const { createPreview } = await import("./preview.mjs");
807
880
  const result = await createPreview(rest, opts);
808
- console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
881
+ const shows = result.moment ? `Shows ${result.moment.scene} at ${result.moment.start}s${result.moment.guest ? `: ${result.moment.guest.caption}` : ""}\n` : "";
882
+ console.log(opts.json ? JSON.stringify(result) : `Preview saved: ${result.manifestPath}\n${shows}${result.email.sendable ? result.email.path : "Insert the GIF or PNG manually; upload with an existing share for linked email HTML."}`);
809
883
  },
810
884
  init: async () => {
811
885
  const { init } = await import("./init.mjs");
@@ -913,6 +987,8 @@ function commandTable(pos, rest, opts) {
913
987
  const out = await avatarList(opts);
914
988
  if (opts.json) console.log(JSON.stringify(out, null, 2));
915
989
  },
990
+ "avatar pack": async () => (await import("./avatar-packs.mjs")).avatarPacksCommand(rest, opts),
991
+ "avatar team": async () => (await import("./avatar-packs.mjs")).avatarTeamsCommand(rest, opts),
916
992
  "avatar new": async () => {
917
993
  const { avatarNew } = await import("./avatar.mjs");
918
994
  const { askLine } = await import("./init.mjs");
@@ -1019,6 +1095,8 @@ function commandTable(pos, rest, opts) {
1019
1095
  "deck rm": () => deckRemove(rest, opts),
1020
1096
  "deck forward": () => deckForward(rest, opts),
1021
1097
  "deck view": () => deckView(rest, opts),
1098
+ "deck transfer": () => deckTransfer(rest, opts),
1099
+ "deck transfers": () => deckTransfers(rest, opts),
1022
1100
  "share new": () => shareNew(rest, opts),
1023
1101
  "share list": () => shareList(rest, opts),
1024
1102
  "share on": () => shareToggle(rest, opts, true),
@@ -5,7 +5,8 @@
5
5
  // runs its own runtime (a legacy copy-and-swap deck.js, a plain HTML export)
6
6
  // gets none of them.
7
7
 
8
- export const loadsPlayer = (html) => /\/player\/v1\/player\.js\b/.test(String(html ?? ""));
8
+ // A copy of a page our routes served names a pinned build (/player/v1/b/<hash>/player.js).
9
+ export const loadsPlayer = (html) => /\/player\/v1\/(?:b\/[0-9a-f]+\/)?player\.js\b/.test(String(html ?? ""));
9
10
 
10
11
  const SCRIPT_SRC = /<script\b[^>]*\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>/gi;
11
12
  const MAX_SCRIPT_NAME = 120;