@foldspace_npm/harness 0.1.36 → 0.1.38
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/CLAUDE.md +25 -5
- package/bin/share.mjs +74 -16
- package/package.json +1 -1
- package/src/cli-registry.mjs +25 -10
- package/src/video/README.md +6 -4
- package/src/video/card.mjs +57 -0
- package/src/video/compose.mjs +32 -13
- package/src/video/composer/card.html +14 -0
- package/src/video/composer/card.mjs +166 -0
- package/src/video/share.mjs +114 -8
- package/templates/agent-starter/skills/share-a-video/SKILL.md +37 -20
package/CLAUDE.md
CHANGED
|
@@ -516,6 +516,14 @@ save or publish:**
|
|
|
516
516
|
<committed_paths, comma-separated>`, so your own save is not taken for
|
|
517
517
|
someone else's.
|
|
518
518
|
|
|
519
|
+
**None of this is said to the human.** No develop, lines of work, pulling,
|
|
520
|
+
merging, conflicts, commits or branches - not in a status line, not when
|
|
521
|
+
something goes wrong. When nobody else saved, say nothing. When someone did,
|
|
522
|
+
one line: *"I brought in changes a teammate saved."* When a file had changes
|
|
523
|
+
both here and in theirs, name the experience in their words: *"A teammate
|
|
524
|
+
also changed the patient table; I kept theirs and put my change on top."*
|
|
525
|
+
Updating the harness is *"updating the Foldspace tools"*, never pulling it.
|
|
526
|
+
|
|
519
527
|
## Done, and what you say at the end
|
|
520
528
|
|
|
521
529
|
A first session is done when the customer has seen one experience answer from
|
|
@@ -551,15 +559,27 @@ you offer the second wave:
|
|
|
551
559
|
again. If it works, invite them to try it in that window (a reload takes
|
|
552
560
|
it off dev), and run `npx foldspace attach --daemon` before you build
|
|
553
561
|
anything else. If `published` says this window cannot show dev, say so in
|
|
554
|
-
one sentence, attach again, and
|
|
562
|
+
one sentence, attach again, and do not offer production yourself - but
|
|
563
|
+
publish it when the human asks (below).
|
|
555
564
|
3. Once the dev check passed, ask before publishing for their users:
|
|
556
565
|
*"Shall I publish it for your users?"* On a yes, publish to production. On a not yet, it stays on dev:
|
|
557
566
|
say so in one line and carry on.
|
|
558
567
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
568
|
+
**Publishing for their users is the human's call, not a step they earn.**
|
|
569
|
+
When they ask for it - at any point, whether or not the dev check ran or
|
|
570
|
+
could run - that is the yes: publish to production the way `repo_read`
|
|
571
|
+
command `instructions` says, in that turn. Do not ask them to confirm again,
|
|
572
|
+
and do not explain the process. When dev was not checked, one line as you
|
|
573
|
+
publish: *"I couldn't check it on dev here, so it goes out as you tried it
|
|
574
|
+
in the test window."* Only a refusal from the Foldspace tools stops it (below).
|
|
575
|
+
|
|
576
|
+
Step 2 needs a site that loads this agent by itself. If you attached with
|
|
577
|
+
`--bootstrap` or `--replace`, it does not yet: do step 1, keep attach
|
|
578
|
+
running, and do not offer production yourself - their site loading the agent
|
|
579
|
+
is the step the Foldspace team does with them. When the human asks for
|
|
580
|
+
production anyway, publish it (above), and add one line: *"Your users will
|
|
581
|
+
see it once your site loads the agent - that's the step the Foldspace team
|
|
582
|
+
does with you."*
|
|
563
583
|
|
|
564
584
|
A save refused because someone saved after you read is not final: pull again
|
|
565
585
|
(**Several builders, one agent**), put your change on top and save. Any other refusal from the Foldspace tools
|
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>] [--
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
const targets = parseTargets(
|
|
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
|
-
|
|
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
package/src/cli-registry.mjs
CHANGED
|
@@ -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
|
|
544
|
-
usage:
|
|
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
|
|
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
|
|
572
|
-
"upload sends the MP4 and the
|
|
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
|
|
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
|
}),
|
package/src/video/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# src/video - what `foldspace record`
|
|
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
|
|
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
|
+
}
|
package/src/video/compose.mjs
CHANGED
|
@@ -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
|
|
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}
|
|
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
|
-
/**
|
|
373
|
-
|
|
374
|
-
|
|
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
|
|
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",
|
|
393
|
+
() => server.fail(new VideoError("timeout", `${capitalized(what)} was not made within ${minutes(timeoutMs)}.`)),
|
|
389
394
|
timeoutMs,
|
|
390
395
|
);
|
|
391
396
|
try {
|
|
392
|
-
|
|
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));
|
package/src/video/share.mjs
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
143
|
-
*
|
|
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
|
|
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",
|
|
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
|
-
|
|
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",
|
|
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.
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
that
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|