@foldspace_npm/harness 0.1.36 → 0.1.37

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/bin/share.mjs CHANGED
@@ -1,15 +1,26 @@
1
1
  #!/usr/bin/env node
2
- // foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']
2
+ // foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--title '<title>'] [--card <file.jpg>]
3
+ // [--targets '<json>' | --targets-file <path>]
3
4
  //
4
5
  // inspect - sizes and checks the MP4 and its poster here; uploads nothing.
5
- // Its video.bytes and poster.bytes go into the MCP tool
6
- // create_video_share.
7
- // upload - sends both files to Foldspace's video storage with the one-time
8
- // targets create_video_share returned (its upload command), then
9
- // the agent calls publish_video_share for the link.
6
+ // With --title (the title the human chose) it also draws the
7
+ // link's 1200x630 preview card next to the MP4 (<name>.card.jpg)
8
+ // in a hidden Chrome; --card uses a card someone made instead.
9
+ // Its video.bytes, poster.bytes and card.bytes go into the MCP
10
+ // tool create_video_share (card_bytes, with the same title).
11
+ // No Chrome, or the card fails: `card: null` and `cardReason`,
12
+ // and the share goes ahead without a card.
13
+ // upload - sends the files to Foldspace's video storage with the one-time
14
+ // targets create_video_share returned (its upload command): the
15
+ // MP4, the poster, then the card when there is a card target.
16
+ // Then the agent calls publish_video_share for the link.
17
+ // --targets-file reads the same JSON from a file, for shells that
18
+ // break a quoted JSON argument (Windows PowerShell and cmd).
10
19
  //
11
20
  // The CLI holds no Foldspace sign-in: creating and publishing a share are MCP
12
- // tools, only the bytes go through here. The targets are never printed.
21
+ // tools, only the bytes go through here. The targets are never printed, and a
22
+ // targets file is never deleted: it holds one-time upload targets, the
23
+ // human's to delete.
13
24
  // Sharing is always the human's call: run upload only after they said yes.
14
25
 
15
26
  import path from "node:path";
@@ -17,38 +28,71 @@ import path from "node:path";
17
28
  import { parseArgs, printJson } from "../src/cdp-client.mjs";
18
29
  import { recordEvent } from "../src/session-events.mjs";
19
30
  import { resolveProjectDir } from "../src/upgrade.mjs";
20
- import { ShareError, defaultVideo, inspectForShare, parseTargets, posterFor, resumableUpload } from "../src/video/share.mjs";
31
+ import { renderCard } from "../src/video/card.mjs";
32
+ import {
33
+ ShareError,
34
+ cardFor,
35
+ defaultVideo,
36
+ inspectCard,
37
+ inspectForShare,
38
+ parseTargets,
39
+ posterFor,
40
+ readTargets,
41
+ resumableUpload,
42
+ } from "../src/video/share.mjs";
21
43
 
22
- const { flags, rest } = parseArgs(process.argv.slice(2), ["poster", "targets"]);
44
+ const USAGE =
45
+ "Usage: foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--title \"<title>\"] [--card <file.jpg>] [--targets '<json>' | --targets-file <path>]";
46
+
47
+ const { flags, rest } = parseArgs(process.argv.slice(2), ["poster", "targets", "targets-file", "title", "card"]);
23
48
  const [action, videoArg] = rest;
24
49
  const projectDir = path.resolve(resolveProjectDir());
25
50
 
26
51
  try {
27
- if (action !== "inspect" && action !== "upload") {
28
- throw new ShareError("USAGE", "Usage: foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']");
29
- }
52
+ if (action !== "inspect" && action !== "upload") throw new ShareError("USAGE", USAGE);
30
53
  const videoPath = videoArg ? path.resolve(projectDir, videoArg) : defaultVideo(projectDir);
31
54
  const posterPath = flags.poster ? path.resolve(projectDir, String(flags.poster)) : posterFor(videoPath);
55
+ const cardPath = typeof flags.card === "string" ? path.resolve(projectDir, flags.card) : cardFor(videoPath);
32
56
  const files = inspectForShare({ videoPath, posterPath });
33
57
 
34
58
  if (action === "inspect") {
59
+ const { card, reason } = await previewCard(files.video.durationS, cardPath);
35
60
  printJson({
36
61
  ok: true,
37
62
  video: { path: path.relative(projectDir, files.video.path), bytes: files.video.bytes, durationS: round(files.video.durationS) },
38
63
  poster: { path: path.relative(projectDir, files.poster.path), bytes: files.poster.bytes },
39
- next: "Only if the human agreed to share it: call create_video_share with a title, video_bytes and poster_bytes, then run the upload command it returns.",
64
+ card: card ? { path: path.relative(projectDir, card.path), bytes: card.bytes } : null,
65
+ ...(card ? {} : { cardReason: reason }),
66
+ next: card
67
+ ? "Only if the human agreed to share it: call create_video_share with the same title, a description if they gave one (up to 500 characters), video_bytes, poster_bytes and card_bytes, then run the upload command it returns."
68
+ : "Only if the human agreed to share it: call create_video_share with a title, a description if they gave one (up to 500 characters), video_bytes and poster_bytes, then run the upload command it returns.",
40
69
  });
41
70
  } else {
42
- if (!flags.targets) throw new ShareError("BAD_TARGETS", "--targets is required: run the upload command create_video_share returned.");
43
- const targets = parseTargets(flags.targets);
71
+ const { text, source } = readTargets({ targets: flags.targets, targetsFile: flags["targets-file"] }, projectDir);
72
+ const targets = parseTargets(text, source);
73
+ // Checked before anything is sent: a card target with no card would fail publish after the whole upload.
74
+ let card = null;
75
+ if (targets.card) {
76
+ try {
77
+ card = inspectCard(cardPath);
78
+ } catch (error) {
79
+ if (!(error instanceof ShareError) || error.code !== "NO_CARD") throw error;
80
+ throw new ShareError(
81
+ "NO_CARD",
82
+ `create_video_share returned a card target, but there is no preview card at ${path.relative(projectDir, cardPath)}. Run \`npx foldspace share inspect --title '<the same title>'\` again (pass upload the same --card if you gave inspect one), or call create_video_share again without card_bytes.`,
83
+ );
84
+ }
85
+ }
44
86
  await resumableUpload(targets.video, files.video.path, { label: "video" });
45
87
  await resumableUpload(targets.poster, files.poster.path, { label: "poster" });
46
- recordEvent(projectDir, "share-upload", { videoBytes: files.video.bytes });
88
+ if (card) await resumableUpload(targets.card, card.path, { label: "preview card" });
89
+ recordEvent(projectDir, "share-upload", { videoBytes: files.video.bytes, card: Boolean(card) });
47
90
  printJson({
48
91
  ok: true,
49
92
  status: "uploaded",
50
93
  videoBytes: files.video.bytes,
51
94
  posterBytes: files.poster.bytes,
95
+ ...(card ? { cardBytes: card.bytes } : {}),
52
96
  next: "Call publish_video_share with the share_id from create_video_share; it returns the public link.",
53
97
  });
54
98
  }
@@ -58,6 +102,20 @@ try {
58
102
  process.exitCode = 1;
59
103
  }
60
104
 
105
+ /**
106
+ * The preview card for inspect: the one given with --card (checked, and a bad
107
+ * one refused - it was asked for), else one drawn for --title at `cardPath`.
108
+ * Drawing never fails the share: no card, and the reason.
109
+ */
110
+ async function previewCard(durationS, cardPath) {
111
+ if (typeof flags.card === "string") return { card: inspectCard(cardPath) };
112
+ const title = typeof flags.title === "string" ? flags.title.trim() : "";
113
+ if (!title) {
114
+ return { card: null, reason: "No --title, so no preview card: run inspect again with --title '<the title the human chose>' for one." };
115
+ }
116
+ return renderCard({ title, durationS, outPath: cardPath });
117
+ }
118
+
61
119
  function round(seconds) {
62
120
  return Math.round(seconds * 10) / 10;
63
121
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -540,18 +540,19 @@ export const CLI_COMMANDS = Object.freeze([
540
540
  name: "share",
541
541
  entry: "share.mjs",
542
542
  group: "share",
543
- summary: "Size and check a video for a Foldspace share link, then upload it with the targets create_video_share returned",
544
- usage: "foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--targets '<json>']",
543
+ summary: "Size and check a video for a Foldspace share link and draw its preview card, then upload them with the targets create_video_share returned",
544
+ usage:
545
+ "foldspace share <inspect|upload> [<video.mp4>] [--poster <file.jpg>] [--title \"<title>\"] [--card <file.jpg>] [--targets '<json>' | --targets-file <path>]",
545
546
  risk: "remote-upload",
546
547
  environment: "node",
547
- environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
548
- capabilities: ["video.upload"],
548
+ environmentVariables: ["FOLDSPACE_PROJECT_DIR", "CHROME_PATH"],
549
+ capabilities: ["video.upload", "browser.launch"],
549
550
  positionals: [
550
551
  Object.freeze({
551
552
  name: "action",
552
553
  required: true,
553
554
  description:
554
- "inspect: size and check the MP4 and its poster here, uploading nothing | upload: send both to Foldspace's video storage with the targets create_video_share returned",
555
+ "inspect: size and check the MP4 and its poster here, and draw the preview card for --title, uploading nothing | upload: send them to Foldspace's video storage with the targets create_video_share returned",
555
556
  }),
556
557
  Object.freeze({
557
558
  name: "video",
@@ -561,20 +562,34 @@ export const CLI_COMMANDS = Object.freeze([
561
562
  ],
562
563
  options: [
563
564
  value("--poster", "file.jpg", "The JPEG poster", { default: "the MP4's name with .jpg" }),
565
+ value(
566
+ "--title",
567
+ "title",
568
+ "inspect only: the title the human chose (the same one goes to create_video_share); draws the link's 1200x630 preview card as the MP4's name with .card.jpg",
569
+ ),
570
+ value("--card", "file.jpg", "A 1200x630 JPEG preview card to use instead of drawing one (at most 1 MiB)", {
571
+ default: "the MP4's name with .card.jpg",
572
+ }),
564
573
  value("--targets", "json", "upload only: the targets JSON exactly as the upload command from create_video_share has it"),
574
+ value(
575
+ "--targets-file",
576
+ "path",
577
+ "upload only, instead of --targets: a file holding that JSON, for shells that break a quoted JSON argument (Windows PowerShell, cmd). It holds one-time upload targets: never commit it; the human may delete it after",
578
+ ),
565
579
  ],
566
580
  prerequisites: [
567
- "An H.264 MP4 with its moov box first (what `record` makes) and a JPEG poster",
581
+ "An H.264 MP4 with its moov box first (what `record` makes), at most 10 minutes, and a JPEG poster",
582
+ "inspect --title: Google Chrome to draw the preview card; without it inspect still succeeds, with no card",
568
583
  "upload: the human said yes to a public link, and the Foldspace MCP tool create_video_share returned the upload command (its targets last 30 minutes)",
569
584
  ],
570
585
  effects: [
571
- "inspect reads the two files and nothing else",
572
- "upload sends the MP4 and the poster to Foldspace's video storage, and only there; it reads no Foldspace sign-in",
573
- "Never prints the upload targets: each is a one-time credential for one file",
586
+ "inspect reads the two files; with --title it draws the preview card in a hidden Chrome and writes it next to the MP4. A card that cannot be drawn gives `card: null` and `cardReason`, never a failure",
587
+ "upload sends the MP4, the poster and - when create_video_share returned a card target - the preview card to Foldspace's video storage, and only there; it reads no Foldspace sign-in",
588
+ "Never prints the upload targets: each is a one-time credential for one file. A --targets-file is read, never deleted",
574
589
  "Anyone with the link publish_video_share returns can watch the video, which shows real data from the account",
575
590
  ],
576
591
  next: [
577
- "After inspect, with the human's yes: create_video_share with a title, video_bytes and poster_bytes, then run its upload command",
592
+ "After inspect, with the human's yes: create_video_share with the same title, an optional description, video_bytes, poster_bytes and card_bytes (when inspect made a card), then run its upload command",
578
593
  "After upload: publish_video_share with the share_id; give the human the link",
579
594
  ],
580
595
  }),
@@ -1,4 +1,4 @@
1
- # src/video - what `foldspace record` is made of
1
+ # src/video - what `foldspace record` and `foldspace share` are made of
2
2
 
3
3
  | File | What it is |
4
4
  |---|---|
@@ -6,11 +6,13 @@
6
6
  | `timeline.mjs` | Pure timing and layout: shortened waits, caption fades, dissolves, the caption card (its rows, the line wrapped evenly in two, the `*highlights*`), where the logo goes, colour contrast. Runs in Node (tests) and in the composer page |
7
7
  | `scenes.mjs` | `video/scenes.json`: checking it, the five capability names (one list: renaming a pill is a one-line change), each caption's "2 of 4", the file name |
8
8
  | `compose.mjs` | The plan, a short-lived 127.0.0.1 server and a hidden Chrome that makes the MP4 |
9
- | `composer/` | The page that draws each frame on a canvas, encodes H.264 with WebCodecs and muxes |
9
+ | `composer/` | The page that draws each frame on a canvas, encodes H.264 with WebCodecs and muxes; and `card.html`, the page that draws a share link's preview card |
10
+ | `share.mjs` | `foldspace share`: the checks `inspect` runs before anything leaves the machine (the server's caps: 500 MB, 10 minutes, fast-start MP4, JPEG poster and card), the upload targets (`--targets` or `--targets-file`, Google Cloud Storage only) and the resumable upload |
11
+ | `card.mjs` | The share link's 1200 x 630 preview card (og:image) for `inspect --title`: drawn in a hidden Chrome as `compose.mjs` starts one, a JPEG under 300 KB (WhatsApp shows no bigger preview), next to the MP4 as `<name>.card.jpg`. Optional: without Chrome there is no card and the link shows the poster |
10
12
  | `brand.mjs` | Foldspace's brand, as its product video's storyboard draws it - its one blue on black, white, one muted grey - the caption card's fill and edge, and the logo files. Never the app's colours |
11
13
 
12
- Nothing is installed and nothing native runs: the video is encoded by the
13
- Google Chrome that is already there.
14
+ Nothing is installed and nothing native runs: the video is encoded, and the
15
+ preview card drawn, by the Google Chrome that is already there.
14
16
 
15
17
  ## Vendored, with their licences
16
18
 
@@ -0,0 +1,57 @@
1
+ // The preview card of a share link: what WhatsApp, Slack, LinkedIn or X show
2
+ // for the link (the share page's og:image). Without one the page offers the
3
+ // poster, which is a frame of the video at its own size, so the card is
4
+ // optional end to end: making it never stops a share.
5
+ //
6
+ // Drawn by composer/card.mjs in a hidden Chrome, started the way the video's
7
+ // composer is (compose.mjs: a throwaway profile, a 127.0.0.1 server under a
8
+ // random token), and written next to the video as <name>.card.jpg.
9
+
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ import { findChrome } from "../chrome-binary.mjs";
14
+ import { renderPage } from "./compose.mjs";
15
+ import { CARD_HEIGHT, CARD_WIDTH, formatLength } from "./share.mjs";
16
+
17
+ /** WhatsApp shows no preview image over 300 KB, so a card we draw stays under it. */
18
+ export const CARD_TARGET_BYTES = 300 * 1000;
19
+ // The first quality under CARD_TARGET_BYTES is kept; a dark card with one
20
+ // title is about 60 KB at the first.
21
+ const CARD_QUALITIES = Object.freeze([0.9, 0.82, 0.74, 0.66, 0.58, 0.5]);
22
+ const CARD_TIMEOUT_MS = 60_000;
23
+
24
+ /**
25
+ * Draw the card for `title` and a video `durationS` long into `outPath`.
26
+ * Never throws: { card: { path, bytes } }, or { card: null, reason } when it
27
+ * could not be made (no Chrome, Chrome failed, too big) - the share then goes
28
+ * ahead without one.
29
+ */
30
+ export async function renderCard({ title, durationS, outPath, chromePath = findChrome(), timeoutMs = CARD_TIMEOUT_MS }) {
31
+ const without = "the share page shows the poster instead";
32
+ if (!chromePath) {
33
+ return { card: null, reason: `No Google Chrome here to draw the preview card (set CHROME_PATH to one); ${without}.` };
34
+ }
35
+ try {
36
+ const plan = {
37
+ width: CARD_WIDTH,
38
+ height: CARD_HEIGHT,
39
+ title: String(title).replace(/\s+/g, " ").trim(),
40
+ length: formatLength(durationS),
41
+ maxBytes: CARD_TARGET_BYTES,
42
+ qualities: CARD_QUALITIES,
43
+ };
44
+ const result = await renderPage({ plan, page: "composer/card.html", chromePath, what: "the preview card", timeoutMs });
45
+ const jpeg = result.card;
46
+ if (!jpeg || jpeg.length > CARD_TARGET_BYTES || !(jpeg[0] === 0xff && jpeg[1] === 0xd8 && jpeg[2] === 0xff)) {
47
+ return { card: null, reason: `The preview card did not come out as a JPEG under ${CARD_TARGET_BYTES / 1000} KB; ${without}.` };
48
+ }
49
+ // Written whole or not at all: a half-written card would be uploaded as it is.
50
+ const partial = `${outPath}.partial`;
51
+ fs.writeFileSync(partial, jpeg);
52
+ fs.renameSync(partial, outPath);
53
+ return { card: { path: path.resolve(outPath), bytes: jpeg.length } };
54
+ } catch (error) {
55
+ return { card: null, reason: `The preview card was not made (${error instanceof Error ? error.message : String(error)}); ${without}.` };
56
+ }
57
+ }
@@ -288,10 +288,11 @@ function inside(root, relative) {
288
288
  * The page's server: 127.0.0.1 only, every path under a random token, so no
289
289
  * other page or process on the machine can guess its way to the recorded
290
290
  * pictures (they show the customer's real data). Resolves `done` when the
291
- * page has posted the video, or fails with what the page reported.
291
+ * page has posted what it made, or fails with what the page reported.
292
+ * Without a `recordDir` (the share card) no recording is served.
292
293
  */
293
- function startServer({ plan, recordDir: givenDir, onProgress }) {
294
- const recordDir = path.resolve(givenDir);
294
+ function startServer({ plan, recordDir: givenDir, page, onProgress }) {
295
+ const recordDir = givenDir ? path.resolve(givenDir) : null;
295
296
  const token = crypto.randomBytes(16).toString("hex");
296
297
  const posted = {};
297
298
  let settle;
@@ -344,7 +345,7 @@ function startServer({ plan, recordDir: givenDir, onProgress }) {
344
345
  response.end(JSON.stringify(plan));
345
346
  return;
346
347
  }
347
- if (rest.startsWith("rec/")) file = inside(recordDir, rest.slice(4));
348
+ if (rest.startsWith("rec/")) file = recordDir ? inside(recordDir, rest.slice(4)) : null;
348
349
  else if (STATIC_FILES.includes(rest) || STATIC_ROOTS.includes(rest.split("/")[0])) file = inside(videoRoot, rest);
349
350
  if (!file || !fs.existsSync(file) || !fs.statSync(file).isFile()) {
350
351
  response.writeHead(404).end();
@@ -360,7 +361,7 @@ function startServer({ plan, recordDir: givenDir, onProgress }) {
360
361
  server.listen(0, "127.0.0.1", () => {
361
362
  const { port } = server.address();
362
363
  resolve({
363
- url: `http://127.0.0.1:${port}/${token}/composer/index.html`,
364
+ url: `http://127.0.0.1:${port}/${token}/${page}`,
364
365
  finished,
365
366
  close: () => new Promise((done) => server.close(() => done())),
366
367
  fail: (error) => settle.reject(error),
@@ -369,15 +370,19 @@ function startServer({ plan, recordDir: givenDir, onProgress }) {
369
370
  });
370
371
  }
371
372
 
372
- /** Run the composer page in a hidden Chrome; returns { mp4, poster, done }. */
373
- export async function renderInChrome({ plan, recordDir, chromePath, onProgress, timeoutMs = 15 * 60 * 1000 }) {
374
- const server = await startServer({ plan, recordDir, onProgress });
373
+ /**
374
+ * Run one of the composer's pages (`page`, under src/video/) in a hidden
375
+ * Chrome; returns every file it posted, by name, and its `done` report.
376
+ * `what` names what it makes, for the errors: "the video", "the preview card".
377
+ */
378
+ export async function renderPage({ plan, recordDir = null, page, chromePath, onProgress, what, timeoutMs }) {
379
+ const server = await startServer({ plan, recordDir, page, onProgress });
375
380
  const chrome = launchHeadless(chromePath, server.url);
376
381
  chrome.child.on("exit", (code, signal) =>
377
382
  server.fail(
378
383
  new VideoError(
379
384
  "chrome_exited",
380
- `The hidden Chrome closed before the video was made (${signal ? `stopped by ${signal}` : `exit code ${code}`}).`,
385
+ `The hidden Chrome closed before ${what} was made (${signal ? `stopped by ${signal}` : `exit code ${code}`}).`,
381
386
  ),
382
387
  ),
383
388
  );
@@ -385,13 +390,11 @@ export async function renderInChrome({ plan, recordDir, chromePath, onProgress,
385
390
  server.fail(new VideoError("chrome_failed", `Could not start Chrome at ${chromePath}: ${error.message}`)),
386
391
  );
387
392
  const timer = setTimeout(
388
- () => server.fail(new VideoError("timeout", `The video was not made within ${Math.round(timeoutMs / 60000)} minutes.`)),
393
+ () => server.fail(new VideoError("timeout", `${capitalized(what)} was not made within ${minutes(timeoutMs)}.`)),
389
394
  timeoutMs,
390
395
  );
391
396
  try {
392
- const result = await server.finished;
393
- if (!result.out) throw new VideoError("page_error", "The composer page finished without a video.");
394
- return { mp4: result.out, poster: result.poster || null, done: result.done };
397
+ return await server.finished;
395
398
  } finally {
396
399
  clearTimeout(timer);
397
400
  await chrome.close();
@@ -399,6 +402,22 @@ export async function renderInChrome({ plan, recordDir, chromePath, onProgress,
399
402
  }
400
403
  }
401
404
 
405
+ /** Run the composer page in a hidden Chrome; returns { mp4, poster, done }. */
406
+ export async function renderInChrome({ plan, recordDir, chromePath, onProgress, timeoutMs = 15 * 60 * 1000 }) {
407
+ const result = await renderPage({ plan, recordDir, page: "composer/index.html", chromePath, onProgress, what: "the video", timeoutMs });
408
+ if (!result.out) throw new VideoError("page_error", "The composer page finished without a video.");
409
+ return { mp4: result.out, poster: result.poster || null, done: result.done };
410
+ }
411
+
412
+ function capitalized(text) {
413
+ return text.charAt(0).toUpperCase() + text.slice(1);
414
+ }
415
+
416
+ function minutes(ms) {
417
+ const count = Math.max(1, Math.round(ms / 60000));
418
+ return count === 1 ? "a minute" : `${count} minutes`;
419
+ }
420
+
402
421
  const PROBE_PAGE = `<!doctype html><meta charset="utf-8"><script type="module">
403
422
  const configs = ["avc1.640028", "avc1.4d0028", "avc1.42e028"].map((codec) => ({
404
423
  codec, width: 1920, height: 1080, bitrate: 8000000, framerate: 30, avc: { format: "avc" } }));
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>foldspace share - preview card</title>
6
+ <style>html, body { margin: 0; background: #000000; }</style>
7
+ </head>
8
+ <body>
9
+ <!-- Loaded in a hidden Chrome by `foldspace share inspect`. It draws the
10
+ share link's preview card and hands the JPEG back; nothing here is ever
11
+ shown. -->
12
+ <script type="module" src="card.mjs"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,166 @@
1
+ // The share link's preview card: runs in a hidden Chrome that `foldspace
2
+ // share inspect` launches. It reads the plan (the title, the length, the size
3
+ // cap), draws the card on a canvas - what a chat or a feed shows for the link
4
+ // (og:image) - and posts it back as a JPEG under the cap.
5
+ //
6
+ // 1200 x 630, in Foldspace's brand as the video's own cards draw it: black,
7
+ // the title in white Poppins ExtraBold, a big play button in the brand blue,
8
+ // the length in a small pill, the official white wordmark. The play button
9
+ // and the title are centred, so a chat that crops the card square still
10
+ // shows both.
11
+
12
+ import { BRAND, LOGO_FILES, WORDMARK_RATIO } from "../brand.mjs";
13
+ import { parseHex, wrapWords } from "../timeline.mjs";
14
+
15
+ const FONT = "Poppins, Roboto, sans-serif";
16
+ const rgba = (hex, alpha) => `rgba(${parseHex(hex).join(", ")}, ${alpha})`;
17
+ const font = (weight, size) => `${weight} ${size}px ${FONT}`;
18
+
19
+ const post = (name, body, type = "application/json") =>
20
+ fetch(`../${name}`, { method: "POST", body, headers: { "content-type": type } });
21
+ const fail = (code, message) => post("fail", JSON.stringify({ code, message: String(message || code) }));
22
+
23
+ window.addEventListener("error", (event) => fail("page_error", event.message));
24
+ window.addEventListener("unhandledrejection", (event) => fail("page_error", event.reason?.message || event.reason));
25
+
26
+ // On the 1200 x 630 card. A title stays on one line down to `titleOneLine`,
27
+ // then takes two down to `titleMin`; past that its end gives way to "...".
28
+ const CARD = {
29
+ side: 64, // the logo's and the pill's distance from the edges
30
+ logo: 28, // the wordmark's height
31
+ play: 86, // the play button's radius
32
+ glow: 250, // the radius of the blue glow behind it
33
+ gap: 46, // between the play button and the title
34
+ title: 60,
35
+ titleOneLine: 50,
36
+ titleMin: 40,
37
+ titleLines: 2,
38
+ titleWidth: 1000,
39
+ lineHeight: 1.18,
40
+ pill: 26, // the length's text size
41
+ pillPadX: 16,
42
+ pillPadY: 7,
43
+ };
44
+
45
+ async function loadFonts() {
46
+ for (const [file, weight] of [
47
+ ["Poppins-SemiBold.woff2", "600"],
48
+ ["Poppins-ExtraBold.woff2", "800"],
49
+ ]) {
50
+ const face = new FontFace("Poppins", `url(../fonts/${file})`, { weight });
51
+ await face.load();
52
+ document.fonts.add(face);
53
+ }
54
+ }
55
+
56
+ async function loadImage(url) {
57
+ const image = new Image();
58
+ image.src = url;
59
+ await image.decode();
60
+ return image;
61
+ }
62
+
63
+ /** One line if it fits big enough, else the largest size for two lines; at the smallest, cut with "...". */
64
+ function fitTitle(ctx, title) {
65
+ const measure = (value) => ctx.measureText(value).width;
66
+ for (const [count, min] of [
67
+ [1, CARD.titleOneLine],
68
+ [CARD.titleLines, CARD.titleMin],
69
+ ]) {
70
+ for (let size = CARD.title; size >= min; size -= 2) {
71
+ ctx.font = font(800, size);
72
+ const lines = wrapWords(title, CARD.titleWidth, measure);
73
+ if (lines.length <= count && lines.every((line) => measure(line) <= CARD.titleWidth)) return { size, lines };
74
+ }
75
+ }
76
+ ctx.font = font(800, CARD.titleMin);
77
+ const lines = wrapWords(title, CARD.titleWidth, measure).slice(0, CARD.titleLines);
78
+ const last = lines.length - 1;
79
+ // The last line always ends in "..." here: either more words follow it, or it is one word too wide.
80
+ let cut = lines[last];
81
+ while (cut && measure(`${cut}...`) > CARD.titleWidth) cut = cut.slice(0, -1);
82
+ lines[last] = `${cut.trimEnd()}...`;
83
+ for (let index = 0; index < last; index++) {
84
+ while (measure(lines[index]) > CARD.titleWidth) lines[index] = lines[index].slice(0, -1);
85
+ }
86
+ return { size: CARD.titleMin, lines };
87
+ }
88
+
89
+ function drawCard(ctx, plan, logo) {
90
+ const { width, height } = plan;
91
+ ctx.fillStyle = BRAND.black;
92
+ ctx.fillRect(0, 0, width, height);
93
+
94
+ const title = fitTitle(ctx, plan.title);
95
+ const step = title.size * CARD.lineHeight;
96
+ const block = CARD.play * 2 + CARD.gap + title.lines.length * step;
97
+ const cx = width / 2;
98
+ const top = Math.round((height - block) / 2);
99
+ const cy = top + CARD.play;
100
+
101
+ // The play button: the brand blue on a soft glow of itself, a white triangle
102
+ // nudged right so it looks centred (a triangle's middle is not its box's).
103
+ const glow = ctx.createRadialGradient(cx, cy, CARD.play * 0.6, cx, cy, CARD.glow);
104
+ glow.addColorStop(0, rgba(BRAND.blue, 0.38));
105
+ glow.addColorStop(1, rgba(BRAND.blue, 0));
106
+ ctx.fillStyle = glow;
107
+ ctx.fillRect(cx - CARD.glow, cy - CARD.glow, CARD.glow * 2, CARD.glow * 2);
108
+ ctx.fillStyle = BRAND.blue;
109
+ ctx.beginPath();
110
+ ctx.arc(cx, cy, CARD.play, 0, Math.PI * 2);
111
+ ctx.fill();
112
+ const side = CARD.play * 0.78;
113
+ const nudge = CARD.play * 0.1;
114
+ ctx.fillStyle = BRAND.white;
115
+ ctx.beginPath();
116
+ ctx.moveTo(cx - side / 2 + nudge, cy - side / 2);
117
+ ctx.lineTo(cx + side / 2 + nudge, cy);
118
+ ctx.lineTo(cx - side / 2 + nudge, cy + side / 2);
119
+ ctx.closePath();
120
+ ctx.fill();
121
+
122
+ ctx.fillStyle = BRAND.white;
123
+ ctx.font = font(800, title.size);
124
+ ctx.textAlign = "center";
125
+ ctx.textBaseline = "top";
126
+ title.lines.forEach((line, index) => ctx.fillText(line, cx, top + CARD.play * 2 + CARD.gap + index * step));
127
+
128
+ // The white wordmark, top-left.
129
+ ctx.drawImage(logo, CARD.side, CARD.side, CARD.logo * WORDMARK_RATIO, CARD.logo);
130
+
131
+ // The length, bottom-right, as a video thumbnail has it.
132
+ ctx.font = font(600, CARD.pill);
133
+ ctx.textAlign = "left";
134
+ ctx.textBaseline = "middle";
135
+ const textW = ctx.measureText(plan.length).width;
136
+ const pillW = textW + CARD.pillPadX * 2;
137
+ const pillH = CARD.pill + CARD.pillPadY * 2;
138
+ const pillX = width - CARD.side - pillW;
139
+ const pillY = height - CARD.side - pillH;
140
+ ctx.fillStyle = rgba(BRAND.white, 0.14);
141
+ ctx.beginPath();
142
+ ctx.roundRect(pillX, pillY, pillW, pillH, pillH / 2);
143
+ ctx.fill();
144
+ ctx.fillStyle = BRAND.white;
145
+ ctx.fillText(plan.length, pillX + CARD.pillPadX, pillY + pillH / 2 + 1);
146
+ }
147
+
148
+ async function main() {
149
+ const plan = await (await fetch("../plan.json")).json();
150
+ await loadFonts();
151
+ const logo = await loadImage(`../assets/${LOGO_FILES.onDark}`);
152
+ const canvas = new OffscreenCanvas(plan.width, plan.height);
153
+ drawCard(canvas.getContext("2d"), plan, logo);
154
+ // The best quality that stays under the cap.
155
+ for (const quality of plan.qualities) {
156
+ const jpeg = await canvas.convertToBlob({ type: "image/jpeg", quality });
157
+ if (jpeg.size <= plan.maxBytes) {
158
+ await post("card", jpeg, "image/jpeg");
159
+ await post("done", JSON.stringify({ quality, bytes: jpeg.size }));
160
+ return;
161
+ }
162
+ }
163
+ await fail("too_large", `The preview card is over ${Math.round(plan.maxBytes / 1000)} KB at every quality.`);
164
+ }
165
+
166
+ main().catch((error) => fail("page_error", error?.message || error));
@@ -2,10 +2,12 @@
2
2
  //
3
3
  // The CLI holds no Foldspace sign-in (the editor's MCP does), so sharing is
4
4
  // three steps the agent takes, only after the human said yes:
5
- // 1. `npx foldspace share inspect` - sizes and checks the MP4 and poster here
5
+ // 1. `npx foldspace share inspect --title '...'` - sizes and checks the MP4
6
+ // and poster here, and draws the link's preview card (card.mjs)
6
7
  // 2. MCP `create_video_share` - returns one-time signed upload targets
7
8
  // (`upload_command`), then `npx foldspace share upload` sends the files
8
- // straight to Foldspace's video storage with them
9
+ // straight to Foldspace's video storage with them (the card only when
10
+ // create_video_share was given card_bytes and so returned a card target)
9
11
  // 3. MCP `publish_video_share` - the server checks the files and returns
10
12
  // the public link
11
13
  // Nothing here talks to Foldspace except the upload targets the MCP issued,
@@ -38,6 +40,14 @@ export function isAllowedUploadUrl(value) {
38
40
  /** Mirrors the server's caps (share-service SHARE_MAX_*): checked here so a too-big file fails before any upload. */
39
41
  export const MAX_VIDEO_BYTES = 500 * 1024 * 1024;
40
42
  export const MAX_POSTER_BYTES = 5 * 1024 * 1024;
43
+ export const MAX_CARD_BYTES = 1024 * 1024;
44
+ /** share-service SHARE_MAX_DURATION_SECONDS: publish refuses a longer video, after the whole upload. */
45
+ export const MAX_VIDEO_SECONDS = 600;
46
+ /** The preview card's size (share-service: the 1200x630 og:image). */
47
+ export const CARD_WIDTH = 1200;
48
+ export const CARD_HEIGHT = 630;
49
+ /** A targets file holds three small JSON objects; anything this big is not one. */
50
+ const MAX_TARGETS_FILE_BYTES = 64 * 1024;
41
51
 
42
52
  export class ShareError extends Error {
43
53
  constructor(code, message) {
@@ -68,6 +78,11 @@ export function posterFor(videoPath) {
68
78
  return videoPath.replace(/\.mp4$/i, ".jpg");
69
79
  }
70
80
 
81
+ /** Where `inspect --title` writes the preview card, and where `upload` looks for it. */
82
+ export function cardFor(videoPath) {
83
+ return videoPath.replace(/\.mp4$/i, ".card.jpg");
84
+ }
85
+
71
86
  /**
72
87
  * Sizes the two files and runs the same structural checks the server's publish
73
88
  * check runs (MP4 with ftyp, moov before mdat; JPEG poster), so the agent hears
@@ -84,9 +99,58 @@ export function inspectForShare({ videoPath, posterPath }) {
84
99
  throw new ShareError("NOT_JPEG", "The poster must be a JPEG image.");
85
100
  }
86
101
  const durationS = readMp4Duration(videoPath, video.bytes);
102
+ if (durationS > MAX_VIDEO_SECONDS) {
103
+ throw new ShareError("TOO_LONG", `The video is ${formatLength(Math.ceil(durationS))} long; a share link takes at most ${formatLength(MAX_VIDEO_SECONDS)} (10 minutes). Send the file instead, or share a shorter cut.`);
104
+ }
87
105
  return { video: { ...video, durationS }, poster };
88
106
  }
89
107
 
108
+ /**
109
+ * A preview card someone made (`--card`), or the one `inspect` drew: a JPEG
110
+ * of 1200x630, read from its frame header (SOF), at most 1 MiB.
111
+ */
112
+ export function inspectCard(cardPath) {
113
+ const card = statFile(cardPath, "NO_CARD", "preview card");
114
+ if (card.bytes > MAX_CARD_BYTES) throw new ShareError("TOO_LARGE", `The preview card is ${Math.round(card.bytes / 1024)} KB; the limit is ${MAX_CARD_BYTES / 1024} KB.`);
115
+ const size = jpegSize(readSlice(cardPath, 0, Math.min(card.bytes, 256 * 1024)));
116
+ if (!size) throw new ShareError("NOT_JPEG", "The preview card must be a JPEG image.");
117
+ if (size.width !== CARD_WIDTH || size.height !== CARD_HEIGHT) {
118
+ throw new ShareError("BAD_CARD", `The preview card is ${size.width}x${size.height}; it must be ${CARD_WIDTH}x${CARD_HEIGHT}.`);
119
+ }
120
+ return card;
121
+ }
122
+
123
+ /**
124
+ * A JPEG's width and height, from the first SOF marker; null when the bytes
125
+ * are not a JPEG or end before one. Walks the segments from SOI: every one
126
+ * but the standalone markers carries its length.
127
+ */
128
+ export function jpegSize(bytes) {
129
+ if (!(bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff)) return null;
130
+ let offset = 2;
131
+ while (offset + 4 <= bytes.length) {
132
+ if (bytes[offset] !== 0xff) return null;
133
+ const marker = bytes[offset + 1];
134
+ if (marker === 0xff) {
135
+ offset += 1; // fill byte
136
+ continue;
137
+ }
138
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
139
+ offset += 2; // TEM, RSTn, SOI: no length
140
+ continue;
141
+ }
142
+ if (marker === 0xd9 || marker === 0xda) return null; // EOI or SOS before any frame header
143
+ const length = bytes.readUInt16BE(offset + 2);
144
+ // SOF0-SOF15, except DHT (C4), JPG (C8) and DAC (CC), which share the range.
145
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
146
+ if (offset + 9 > bytes.length) return null;
147
+ return { height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7) };
148
+ }
149
+ offset += 2 + length;
150
+ }
151
+ return null;
152
+ }
153
+
90
154
  /** Walks top-level boxes: ftyp first, moov before mdat; returns mvhd duration in seconds. */
91
155
  function readMp4Duration(file, size) {
92
156
  const first = boxAt(file, 0, size);
@@ -139,20 +203,56 @@ function boxAt(file, offset, fileSize) {
139
203
  }
140
204
 
141
205
  /**
142
- * Parses the `--targets` JSON that `create_video_share` put in the upload
143
- * command: { video: {url, method, headers}, poster: {...} }.
206
+ * The targets JSON, from exactly one of `--targets '<json>'` and
207
+ * `--targets-file <path>` (for shells that break a quoted JSON argument:
208
+ * Windows PowerShell and cmd). The file is read, never printed and never
209
+ * deleted: it holds one-time upload targets, the human's to delete.
144
210
  */
145
- export function parseTargets(text) {
211
+ export function readTargets({ targets, targetsFile }, projectDir) {
212
+ if (targets !== undefined && targetsFile !== undefined) {
213
+ throw new ShareError("BAD_TARGETS", "Give --targets or --targets-file, not both.");
214
+ }
215
+ if (targetsFile === undefined) {
216
+ if (typeof targets !== "string" || !targets) {
217
+ throw new ShareError("BAD_TARGETS", "--targets (or --targets-file) is required: run the upload command create_video_share returned.");
218
+ }
219
+ return { text: targets, source: "--targets" };
220
+ }
221
+ if (typeof targetsFile !== "string" || !targetsFile) throw new ShareError("BAD_TARGETS", "--targets-file needs the path of the file that holds the targets JSON.");
222
+ const file = path.resolve(projectDir, targetsFile);
223
+ let bytes;
224
+ try {
225
+ if (fs.statSync(file).size > MAX_TARGETS_FILE_BYTES) throw new ShareError("BAD_TARGETS", "The --targets-file is too big to be the targets JSON.");
226
+ bytes = fs.readFileSync(file);
227
+ } catch (error) {
228
+ if (error instanceof ShareError) throw error;
229
+ throw new ShareError("BAD_TARGETS", `No targets file at ${file}.`);
230
+ }
231
+ // PowerShell writes UTF-16 with `>` and a UTF-8 byte-order mark with Out-File in some versions.
232
+ let text;
233
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) text = bytes.subarray(2).toString("utf16le");
234
+ else text = bytes.toString("utf8").replace(/^\uFEFF/, "");
235
+ return { text, source: "--targets-file" };
236
+ }
237
+
238
+ /**
239
+ * Parses the targets JSON that `create_video_share` put in the upload
240
+ * command: { video: {url, method, headers}, poster: {...}, card?: {...} }.
241
+ * `card` is there only when create_video_share was given card_bytes.
242
+ * `source` names the flag it came from, for the errors.
243
+ */
244
+ export function parseTargets(text, source = "--targets") {
146
245
  let parsed;
147
246
  try {
148
247
  parsed = JSON.parse(String(text || ""));
149
248
  } catch {
150
- throw new ShareError("BAD_TARGETS", "--targets is not valid JSON. Copy the upload command from create_video_share exactly.");
249
+ throw new ShareError("BAD_TARGETS", `${source} is not valid JSON. Copy the targets from the upload command create_video_share returned exactly.`);
151
250
  }
152
- for (const name of ["video", "poster"]) {
251
+ const names = ["video", "poster", ...(parsed?.card != null ? ["card"] : [])];
252
+ for (const name of names) {
153
253
  const target = parsed?.[name];
154
254
  if (!target || typeof target.url !== "string" || target.method !== "POST" || typeof target.headers !== "object") {
155
- throw new ShareError("BAD_TARGETS", `--targets has no valid ${name} target. Copy the upload command from create_video_share exactly.`);
255
+ throw new ShareError("BAD_TARGETS", `${source} has no valid ${name} target. Copy the targets from the upload command create_video_share returned exactly.`);
156
256
  }
157
257
  if (!isAllowedUploadUrl(target.url)) {
158
258
  throw new ShareError("BAD_TARGETS", `The ${name} upload target is not Foldspace's video storage (https://storage.googleapis.com). Use only the command create_video_share returned.`);
@@ -282,3 +382,9 @@ function readSlice(file, offset, length) {
282
382
  function mb(bytes) {
283
383
  return Math.round(bytes / (1024 * 1024));
284
384
  }
385
+
386
+ /** "0:42", "10:00": a length as the card and the errors show it. */
387
+ export function formatLength(seconds) {
388
+ const whole = Math.max(0, Math.round(Number(seconds) || 0));
389
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
390
+ }
@@ -214,27 +214,44 @@ anything leaves this computer, ask once, as a choice: *"A share link is public
214
214
  to anyone who has it, and the video shows [what is on screen]. Create one?"*
215
215
  Only on a yes:
216
216
 
217
- 1. `npx foldspace share inspect` (the newest `video/*.mp4` and its poster, or
218
- pass the path). It uploads nothing. If it says the MP4 is not fast-start,
219
- that file did not come from `record`: say so and stop.
220
- 2. The Foldspace tool `create_video_share` with a title in their words (the
221
- product and what the agent does, up to 200 characters), and `video_bytes`
222
- and `poster_bytes` from step 1.
223
- 3. Run the upload command it returns, exactly - it carries one-time upload
224
- targets for 30 minutes. Pass `upload` the same video path and `--poster`
225
- you gave `inspect`, if any, so it uploads the files `inspect` sized. Never
226
- paste the targets into chat, a file or a commit. In Codex (or any editor
227
- that runs commands in a sandbox), this command needs the network: run it
228
- outside the sandbox through the editor's own approval, with one plain line
229
- on why.
230
- 4. When it prints `"status": "uploaded"`, the tool `publish_video_share` with
217
+ 1. Ask for the title - in their words, the product and what the agent does,
218
+ up to 200 characters - and, optionally, a line or two of description (up
219
+ to 500 characters). The title goes on the link's preview card, so agree it
220
+ first.
221
+ 2. `npx foldspace share inspect --title '<the title>'` (the newest
222
+ `video/*.mp4` and its poster, or pass the path). Keep the title in single
223
+ quotes so the shell expands nothing in it, escaping any single quote it
224
+ contains the way your shell needs. It uploads nothing. It
225
+ checks the files and draws the link's preview card next to the MP4 - what
226
+ WhatsApp, Slack or LinkedIn show for the link. If it says the MP4 is not
227
+ fast-start, that file did not come from `record`: say so and stop. If it
228
+ is over 10 minutes, a share link cannot take it: offer the file instead.
229
+ `card: null` with a `cardReason` (no Google Chrome, say) is not a
230
+ failure: the link shows the poster instead - carry on without a card.
231
+ 3. The Foldspace tool `create_video_share` with the same `title`, the
232
+ `description` if they gave one, `video_bytes` and `poster_bytes`, and
233
+ `card_bytes` from `card.bytes` when step 2 made a card (none when `card`
234
+ is null).
235
+ 4. Run the upload command it returns, exactly - it carries one-time upload
236
+ targets for 30 minutes. Pass `upload` the same video path, `--poster` and
237
+ `--card` you gave `inspect`, if any, so it uploads the files `inspect`
238
+ sized. Never paste the targets into chat or a commit. On Windows
239
+ (PowerShell or cmd), where a quoted JSON argument breaks: write the
240
+ targets JSON from that command, exactly, to a file outside the project
241
+ (for example in the temp folder), and run
242
+ `npx foldspace share upload --targets-file <that file>` with the same
243
+ video path and flags; once it is uploaded, delete the file or tell them
244
+ they may. In Codex (or any editor that runs commands in a sandbox), this
245
+ command needs the network: run it outside the sandbox through the
246
+ editor's own approval, with one plain line on why.
247
+ 5. When it prints `"status": "uploaded"`, the tool `publish_video_share` with
231
248
  the `share_id`. It returns the link: give it to them as it is.
232
- 5. Tell them in one line that they get an email at the first view and a daily
233
- summary on days it is watched, and that you can show the numbers
249
+ 6. Tell them in one line that they get an email at the first view, then at
250
+ most one an hour while it gets new views, and that you can show the numbers
234
251
  (`get_video_share_stats`) or delete the link (`delete_video_share`) whenever
235
252
  they ask. `publish_video_share` says whether those emails are on for this
236
253
  share.
237
- 6. They want the emails off, or back on: the tool
254
+ 7. They want the emails off, or back on: the tool
238
255
  `set_video_share_notifications` with the `share_id` and
239
256
  `email_notifications` (`false` stops them, `true` turns them on). It covers
240
257
  that one video only.
@@ -243,11 +260,11 @@ The tools refuse with a reason you pass on as it is: an EU account (share
243
260
  links are not offered there yet - send the file instead), a cap (shares per
244
261
  day, or a trial's monthly limit), or a sign-in by API key rather than as a
245
262
  person. The CLI never signs in to Foldspace and never reaches the Foldspace
246
- tools itself: steps 2 and 4 are tool calls you make.
263
+ tools itself: steps 3 and 5 are tool calls you make.
247
264
 
248
265
  The video holds real data, so it is never saved with the code: `record` writes
249
- `video/.gitignore` (`*.mp4`, `*.jpg`) in every project. `video/scenes.json`
250
- stays in git.
266
+ `video/.gitignore` (`*.mp4`, `*.jpg` - the preview card too) in every project.
267
+ `video/scenes.json` stays in git.
251
268
 
252
269
  ## Change a caption without recording again
253
270