@docyrus/animaguru 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.8.1",
3
+ "version": "0.10.0",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,9 +114,19 @@ Submissions default to private. `--share` is a comma-separated list of verified
114
114
 
115
115
  The JSON result returns the video ID and watch URL. Owners manage details and recipients on that page, or run `animaguru videos share VIDEO_ID --visibility private --share "reviewer@example.com" --yes`; this replaces the entire recipient list. Public sharing uses `--visibility public` without `--share`. Removing a recipient or making a public film private blocks previous playback URLs immediately. `animaguru videos delete VIDEO_ID --yes` removes the hosted files.
116
116
 
117
+ For an MP4, `submit` also uploads `FILM.vtt` or `FILM.srt` from beside it as subtitles, which the AnimaGuru player shows over the picture (CLI 0.10.0). The dry run lists the file and language; use `--subtitles FILE`, `--subtitles none` or `--subtitle-language CODE` when needed. Upload the clean MP4 with its subtitles rather than a `-captioned.mp4`, unless the user wants captions in the picture. `animaguru videos subtitles VIDEO_ID --file FILE --yes` adds or replaces one language on an uploaded MP4 and `--remove CODE --yes` removes one. New subtitles on a public video send it back for staff review.
118
+
117
119
  Uploads use checksum-verified parts. After an interrupted submission, inspect the printed ID first. If all parts arrived, `animaguru videos complete VIDEO_ID` safely retries finalization and returns the existing result when already complete. Otherwise cancel that draft with `videos delete --yes` before submitting again. Do not silently delete a potentially successful publication after a network timeout.
118
120
 
119
121
 
122
+ ## Upload a local MP4 to YouTube
123
+
124
+ CLI 0.9.0 adds local YouTube uploads independently of AnimaGuru hosting. Discover `youtube` in `animaguru schema`. Run `animaguru youtube auth login --client-file PATH --yes --no-browser` only when channel authorization is requested; the user approves the printed Google URL on the same computer. Use a Google **Desktop app** OAuth JSON file. Add `--captions` only when the user wants subtitles uploaded, and tell them it grants Google's broader `youtube.force-ssl` permission, which also allows managing their videos. Never read or print saved tokens or upload session URLs.
125
+
126
+ After MP4 export, inspect with `animaguru youtube upload --file FILE --title TITLE --privacy private --made-for-kids no --synthetic-media yes --dry-run`. Choose the audience and realistic synthetic-content declarations from the actual film and the user's answers; those example values are not defaults. A request to create/export a film does not authorize sending it to YouTube. Use `--yes` only when the user authorizes the file, channel and visibility. `youtube auth status` identifies the connected channel and whether caption uploads are allowed; `--channel CHANNEL_ID` can pin the destination. By default the upload adds `FILM.srt` (or `FILM.vtt`) from beside the MP4 as a caption track, with the language from the export receipt; the dry run lists it. Use `--captions FILE`, `--captions none` or `--caption-language CODE` when needed. It also sets `FILM.jpg` from beside the MP4 (the export's `poster` frame) as the custom thumbnail; `--thumbnail FILE` or `--thumbnail none` change that, and custom thumbnails need a verified channel. A `captionsError` or `thumbnailError` in the result means the video is uploaded but that part is not; resume the printed upload ID with `youtube uploads resume UPLOAD_ID --yes`.
127
+
128
+ Uploads run locally while the CLI is open. After interruption, use `youtube uploads list` and `youtube uploads resume UPLOAD_ID --yes` instead of starting another upload. Resumption uses the original file and settings and probes Google's persisted offset. `youtube auth logout --yes` revokes access and removes local credentials and receipts; videos remain on YouTube. No AnimaGuru backend, sign-in or generation credits are used.
129
+
120
130
  ## Shared workspace commands
121
131
 
122
132
  Run tooling from the workspace root. `npm run dev` opens a hub with separate animation URLs. Use `npm run build -- --animation NAME`, `npm run export:html -- --animation NAME`, `npm run export:mp4 -- --animation NAME`, or `npm run review -- --animation NAME`. These select one film and save outputs/receipts under its `production/` directory. `npm run example -- STYLE_ID --animation NAME` installs an original study only when the selected film is untouched. Root `runtime/`, `scripts/`, skills and dependencies are shared infrastructure.
@@ -64,7 +64,7 @@ Run `npm run build`. Review the actual browser at desktop and mobile sizes, play
64
64
 
65
65
  Deliver the playable HTML film first unless the user asked for MP4 too. For an embeddable HTML5 deliverable, use the download menu or `npm run export:html`. Unzip and test the static package over HTTP(S), including its iframe example; it should play without the authoring workspace or an AnimaGuru account. `npm run build` includes the same download in the static player. Preserve embed mode and hide authoring controls there.
66
66
 
67
- For MP4, run `npm run export:mp4`, open the printed local capture URL in an available browser, and keep it open until completion. The script verifies H.264/AAC, dimensions, duration and exact frame count and writes a JSON report next to the MP4. Exporting existing local assets spends no generation credits. If browser control is unavailable, give the user the printed URL and explain the one manual step.
67
+ For MP4, run `npm run export:mp4`, open the printed local capture URL in an available browser, and keep it open until completion. The script verifies H.264/AAC, dimensions, duration and exact frame count and writes a JSON report next to the MP4. Captions stay out of the picture; the `captions` resource track becomes `.srt` (YouTube, desktop players) and `.vtt` (HTML5) subtitle files with the MP4's name, so keep that track's clips equal to the spoken lines. For social feeds that need open captions, add `-- --burn-captions`; it saves `SLUG-LANGUAGE-captioned.mp4` with captions in the picture and no subtitle files. Set `"poster"` in `film.config.json` to the seconds of the film's most representative frame (title, product or end card, never a fade or a blank moment); the export saves it beside the MP4 as a 1280-pixel JPEG thumbnail, which `animaguru youtube upload` and video pages can use. Exporting existing local assets spends no generation credits. If browser control is unavailable, give the user the printed URL and explain the one manual step.
68
68
 
69
69
  `npm run dev` starts the preview. The capture API and file map are documented in the generated project README. Publishing a website, package or campaign is separate from creating the local video.
70
70
 
@@ -78,7 +78,7 @@ Use `src/studies/timing.ts` for analytic easing, measured stroke reveals and cue
78
78
 
79
79
  ## Sharing a finished film
80
80
 
81
- When the user requests hosting or sharing, use `animaguru submit --yes` from this workspace; it builds and submits the portable HTML5 animation. For an MP4 use `--file production/exports/film.mp4`. Read the local AnimaGuru skill's submission section for audience, recipient and recovery rules. Private is the default; use `--share "reviewer@example.com"` for selected verified accounts or `--visibility public` for [the public Videos section](https://animaguru.com/videos). Preserve the user's audience choice and include a cover using `--poster` when available. Local creation or export alone does not imply publication.
81
+ When the user requests hosting or sharing, use `animaguru submit --yes` from this workspace; it builds and submits the portable HTML5 animation. For an MP4 use `--file production/exports/film.mp4`; its `.vtt`/`.srt` subtitles beside it are uploaded too and shown by the AnimaGuru player, so submit the clean MP4 rather than a `-captioned.mp4`. Read the local AnimaGuru skill's submission section for audience, recipient and recovery rules. Private is the default; use `--share "reviewer@example.com"` for selected verified accounts or `--visibility public` for [the public Videos section](https://animaguru.com/videos). Preserve the user's audience choice and include a cover using `--poster` when available. Local creation or export alone does not imply publication.
82
82
 
83
83
 
84
84
  ## Multi-animation workspace
@@ -31,4 +31,4 @@ Original 2.5D study recipes are in `src/studies/`. In an untouched starter, `npm
31
31
 
32
32
  ## Submit to AnimaGuru
33
33
 
34
- When asked to host or share the finished film, read the local AnimaGuru skill's submission section. `animaguru submit --yes` builds the portable HTML5 export and uploads it privately; `--file` accepts an existing HTML5 ZIP or MP4. `--share "reviewer@example.com"` selects verified AnimaGuru accounts, while `--visibility public` publishes to https://animaguru.com/videos and allows watching, downloading and HTML5 embedding. Include a cover with `--poster public/assets/cover.png` when available. Do not infer public sharing from a request to create or export a film. The CLI returns the hosted watch URL and video ID. Owners can change details and sharing from that page.
34
+ When asked to host or share the finished film, read the local AnimaGuru skill's submission section. `animaguru submit --yes` builds the portable HTML5 export and uploads it privately; `--file` accepts an existing HTML5 ZIP or MP4; an MP4's `.vtt`/`.srt` subtitles beside it are uploaded with it, so prefer the clean MP4 over a `-captioned.mp4`. `--share "reviewer@example.com"` selects verified AnimaGuru accounts, while `--visibility public` publishes to https://animaguru.com/videos and allows watching, downloading and HTML5 embedding. Include a cover with `--poster public/assets/cover.png` when available. Do not infer public sharing from a request to create or export a film. The CLI returns the hosted watch URL and video ID. Owners can change details and sharing from that page.
@@ -16,4 +16,4 @@ Accept schema 2 projects and saved `advertorial` as Advertisement with editorial
16
16
 
17
17
  Original 2.5D study recipes are in `src/studies/`. In an untouched starter, `npm run example -- STYLE_ID` installs one; it refuses to overwrite an authored film. Import the recipe directly for an existing production. `npm run review` checks actual visible text bounds and seek consistency, then saves frames and a report. Keep the HTML5-first delivery and use the client's identity in the film. Physically rendered 3D remains deferred.
18
18
 
19
- For requested hosting or sharing, use `animaguru submit --yes` in this workspace (private by default). `--share "reviewer@example.com"` shares with selected verified AnimaGuru accounts; `--visibility public` publishes to https://animaguru.com/videos. `--file` accepts an existing HTML5 ZIP or MP4. Read the local AnimaGuru skill's submission section for audience selection and upload recovery. Creation and local export do not imply publication.
19
+ For requested hosting or sharing, use `animaguru submit --yes` in this workspace (private by default). `--share "reviewer@example.com"` shares with selected verified AnimaGuru accounts; `--visibility public` publishes to https://animaguru.com/videos. `--file` accepts an existing HTML5 ZIP or MP4; an MP4's `.vtt`/`.srt` subtitles beside it are uploaded with it. Read the local AnimaGuru skill's submission section for audience selection and upload recovery. Creation and local export do not imply publication.
@@ -65,11 +65,13 @@ Allow extra iframe height for controls if needed. `?autoplay=1&loop=1&controls=0
65
65
  npm run export:mp4
66
66
  # If the export port is occupied:
67
67
  npm run export:mp4 -- --port 5197
68
+ # Captions drawn into the picture, e.g. for social feeds (saved as SLUG-LANGUAGE-captioned.mp4):
69
+ npm run export:mp4 -- --burn-captions
68
70
  ```
69
71
 
70
72
  Open the local capture URL printed by the command and keep it open until complete. The script runs a separate loopback Vite server (default port 5196), streams high-quality JPEG frames from the Pixi canvas to the bundled FFmpeg binary, and mixes the configured soundtrack. The initial starter exports with a silent AAC track. Exporting local media does not call AnimaGuru or spend credits. FFmpeg/FFprobe binaries are supplied through platform-specific npm dependencies; installation needs a supported platform and network access.
71
73
 
72
- Completed H.264/AAC MP4s and JSON verification reports appear in `production/exports/`. Existing files are preserved. Frame retries are deduplicated. Ctrl+C removes only this session's unfinished output. The verification checks duration, exact frame count, dimensions, pixel format, codecs and audio language tag. The exporter supports even dimensions up to 4096 pixels, 24/25/30/60 fps and films up to ten minutes. Set duration to an exact number of frames.
74
+ Completed H.264/AAC MP4s and JSON verification reports appear in `production/exports/`. Captions are not drawn into the MP4 picture: the film's `captions` resource track is saved beside it as `SLUG-LANGUAGE.srt` (for YouTube and desktop players, which load a same-named file automatically) and `SLUG-LANGUAGE.vtt` (for an HTML5 `<track>`). `--burn-captions` instead draws them into the picture and writes no subtitle files, so players cannot show them twice. `"poster"` in `film.config.json` (seconds) picks the frame saved as `SLUG-LANGUAGE.jpg`, a caption-free thumbnail 1280 pixels on its longer side and under YouTube's 2 MB limit; without it, no thumbnail is saved. The live player and HTML5 export keep their CC toggle. Existing files are preserved. Frame retries are deduplicated. Ctrl+C removes only this session's unfinished output. The verification checks duration, exact frame count, dimensions, pixel format, codecs and audio language tag. The exporter supports even dimensions up to 4096 pixels, 24/25/30/60 fps and films up to ten minutes. Set duration to an exact number of frames.
73
75
 
74
76
  ## Capture API
75
77
 
@@ -98,4 +100,4 @@ Browse `animaguru.templates.json` for all 31 styles and purpose workflows. The e
98
100
 
99
101
  ## Share on AnimaGuru
100
102
 
101
- After signing in with `animaguru auth login`, run `animaguru submit --yes` here to build and host the finished HTML5 animation privately. Add `--share "reviewer@example.com"` to invite selected verified AnimaGuru accounts, or `--visibility public` to list it at [Videos](https://animaguru.com/videos). `--poster public/assets/cover.png` adds a cover. Already exported an MP4? Pass `--file production/exports/film.mp4`. The CLI returns your hosted player URL; public HTML5 animations can be embedded from that page. Owners manage details and access there. Only the exported player/media are uploaded.
103
+ After signing in with `animaguru auth login`, run `animaguru submit --yes` here to build and host the finished HTML5 animation privately. Add `--share "reviewer@example.com"` to invite selected verified AnimaGuru accounts, or `--visibility public` to list it at [Videos](https://animaguru.com/videos). `--poster public/assets/cover.png` adds a cover. Already exported an MP4? Pass `--file production/exports/film.mp4`; the `.vtt` subtitles beside it are uploaded too, and the AnimaGuru player shows them over the picture with a CC button. The CLI returns your hosted player URL; public HTML5 animations can be embedded from that page. Owners manage details and access there. Only the exported player/media are uploaded.
@@ -13,7 +13,7 @@ Run commands from the workspace root:
13
13
  - `npm run dev`: the workspace hub and separate `/animations/NAME/` players.
14
14
  - `npm run build -- --animation NAME`: typecheck and build that film.
15
15
  - `npm run export:html -- --animation NAME`: portable HTML5 ZIP with local media and common branding.
16
- - `npm run export:mp4 -- --animation NAME`: verified MP4; open the printed capture page.
16
+ - `npm run export:mp4 -- --animation NAME`: verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json; open the printed capture page.
17
17
  - `npm run review -- --animation NAME`: frame, text, seeking and soundtrack checks.
18
18
  - `npm run example -- STYLE_ID --animation NAME`: install a study only in an untouched selected film.
19
19
  - `animaguru submit animations/NAME --yes`: private hosting; choose recipients/public sharing from the user's request.
@@ -6,5 +6,6 @@
6
6
  "fps": 30,
7
7
  "duration": 24,
8
8
  "language": "en",
9
- "audio": null
9
+ "audio": null,
10
+ "poster": 3
10
11
  }
@@ -9,12 +9,19 @@ import ffmpeg from "@ffmpeg-installer/ffmpeg";
9
9
  import ffprobe from "@ffprobe-installer/ffprobe";
10
10
  import { projectArguments, projectContext, publicFile } from "./project-context.mjs";
11
11
  import { workspacePlugin } from "./workspace-plugin.mjs";
12
+ import { subtitleCues, toSrt, toVtt } from "./subtitles.mjs";
12
13
 
13
14
  const root = fileURLToPath(new URL("../", import.meta.url));
14
15
  const selection = projectArguments(process.argv.slice(2));
15
16
  const args = selection.rest;
16
- if (args.length && (args.length !== 2 || args[0] !== "--port" || !/^\d+$/.test(args[1]))) throw new Error("Usage: npm run export:mp4 -- [--port 5196]");
17
- const port = Number(args[1] ?? 5196);
17
+ let port = 5196;
18
+ // Captions become subtitle files beside the MP4 unless --burn-captions draws them into the picture, e.g. for social feeds.
19
+ let burnCaptions = false;
20
+ for (let index = 0; index < args.length; index++) {
21
+ if (args[index] === "--burn-captions") burnCaptions = true;
22
+ else if (args[index] === "--port" && /^\d+$/.test(args[index + 1] ?? "")) port = Number(args[++index]);
23
+ else throw new Error("Usage: npm run export:mp4 -- [--port 5196] [--burn-captions]");
24
+ }
18
25
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Export port must be 1024–65535.");
19
26
  const context = await projectContext(root, selection.animation);
20
27
  const config = context.config;
@@ -28,13 +35,18 @@ if (config.audio !== null) {
28
35
  if (typeof config.audio !== "string" || !config.audio) throw new Error("audio must be null or a local path relative to public/.");
29
36
  audioSource = await publicFile(context, config.audio);
30
37
  }
38
+ // "poster": seconds in film.config.json picks the frame saved as a 1280-pixel JPEG thumbnail beside the MP4.
39
+ if (config.poster != null && (typeof config.poster !== "number" || !Number.isFinite(config.poster) || config.poster < 0 || config.poster >= duration)) throw new Error("Set poster in film.config.json to a time within the film, in seconds, or leave it out.");
40
+ const thumbnailScale = Math.min(1, 1280 / Math.max(width, height));
41
+ const thumbnail = config.poster == null ? null : { time: config.poster, width: Math.round(width * thumbnailScale), height: Math.round(height * thumbnailScale) };
31
42
  const outputDirectory = context.output;
32
43
  await mkdir(outputDirectory, { recursive: true });
33
- let destination = path.join(outputDirectory, `${config.slug}-${language}.mp4`);
34
- for (const candidate of [destination, destination.replace(/\.mp4$/, ".partial.mp4")]) {
44
+ const name = `${config.slug}-${language}${burnCaptions ? "-captioned" : ""}`;
45
+ let destination = path.join(outputDirectory, `${name}.mp4`);
46
+ for (const candidate of [destination, ...[".partial.mp4", ".srt", ".vtt", ".jpg"].map((extension) => destination.replace(/\.mp4$/, extension))]) {
35
47
  try {
36
48
  await access(candidate);
37
- destination = path.join(outputDirectory, `${config.slug}-${language}-${randomUUID()}.mp4`);
49
+ destination = path.join(outputDirectory, `${name}-${randomUUID()}.mp4`);
38
50
  break;
39
51
  } catch (error) { if (error.code !== "ENOENT") throw error; }
40
52
  }
@@ -45,6 +57,8 @@ let encoderDone;
45
57
  let encoderError;
46
58
  let encoderLog = "";
47
59
  let settings;
60
+ let subtitles = [];
61
+ let thumbnailImage;
48
62
  let received = 0;
49
63
  let lastFrameHash;
50
64
  let frameQueue = Promise.resolve();
@@ -106,10 +120,13 @@ async function route(request, response, url) {
106
120
  const send = (value) => response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(value));
107
121
  if (url.pathname === "/__export/start") {
108
122
  if (encoder) throw new Error("Export has already started. Keep the original capture tab open.");
109
- settings = JSON.parse((await readBody(request, 2048)).toString());
123
+ settings = JSON.parse((await readBody(request, 1024 * 1024)).toString());
110
124
  if (settings.language !== language) throw new Error(`Expected ${language} text and captions. Refusing to pair a different language with the audio.`);
111
125
  if (settings.width !== width || settings.height !== height || settings.fps !== fps || settings.duration !== duration) throw new Error("The player settings differ from film.config.json. Restart the export after changing the configuration.");
112
126
  settings.frames = Math.round(settings.duration * fps);
127
+ subtitles = subtitleCues(settings.subtitles ?? [], settings.duration);
128
+ delete settings.subtitles;
129
+ settings.captions = !subtitles.length ? "none" : burnCaptions ? "burned" : "subtitle-files";
113
130
  await writeFile(partial, "", { flag: "wx" });
114
131
  ownsPartial = true;
115
132
  encoder = spawn(ffmpeg.path, [
@@ -131,8 +148,8 @@ async function route(request, response, url) {
131
148
  encoder.once("close", (code) => code === 0 ? resolve() : reject(new Error(`FFmpeg exited ${code}: ${encoderLog}`)));
132
149
  });
133
150
  encoderDone.catch((error) => { encoderError = error; });
134
- console.log(`Rendering ${language.toUpperCase()}: ${settings.frames} frames (${settings.duration}s), ${width} × ${height} / ${fps} fps.`);
135
- send({ fps, frames: settings.frames }); return;
151
+ console.log(`Rendering ${language.toUpperCase()}: ${settings.frames} frames (${settings.duration}s), ${width} × ${height} / ${fps} fps, ${subtitles.length ? `${subtitles.length} captions ${burnCaptions ? "drawn into the picture" : "as subtitle files"}` : "no captions"}.`);
152
+ send({ fps, frames: settings.frames, burnCaptions, thumbnail }); return;
136
153
  }
137
154
  if (url.pathname === "/__export/frame") {
138
155
  if (!encoder || encoding || finished || aborted) throw new Error("No active frame capture.");
@@ -159,8 +176,18 @@ async function route(request, response, url) {
159
176
  await operation;
160
177
  send({ received }); return;
161
178
  }
179
+ if (url.pathname === "/__export/thumbnail") {
180
+ if (!encoder || encoding || !thumbnail || thumbnailImage || received !== settings.frames) throw new Error("Capture the thumbnail once, after every frame.");
181
+ // YouTube accepts custom thumbnails up to 2 MB.
182
+ const image = await readBody(request, 2 * 1024 * 1024);
183
+ const dimensions = jpegDimensions(image);
184
+ if (request.headers["content-type"] !== "image/jpeg" || dimensions?.width !== thumbnail.width || dimensions?.height !== thumbnail.height) throw new Error("Expected a JPEG thumbnail of the configured size.");
185
+ thumbnailImage = image;
186
+ send({ bytes: image.length }); return;
187
+ }
162
188
  if (url.pathname === "/__export/finish") {
163
189
  if (!encoder || encoding || received !== settings.frames) throw new Error("The capture is incomplete.");
190
+ if (thumbnail && !thumbnailImage) throw new Error("The thumbnail was not captured.");
164
191
  encoding = true;
165
192
  encoder.stdin.end();
166
193
  await encoderDone;
@@ -171,13 +198,20 @@ async function route(request, response, url) {
171
198
  const audio = probe.streams.find((stream) => stream.codec_type === "audio");
172
199
  if (!video || video.codec_name !== "h264" || video.width !== width || video.height !== height || Number(video.nb_read_frames) !== settings.frames || video.pix_fmt !== "yuv420p" || !audio || audio.codec_name !== "aac" || audio.tags?.language !== languageTag || Math.abs(Number(probe.format.duration) - settings.duration) > 0.15) throw new Error("The encoded file did not pass duration, frame count, dimensions, language, or codec verification.");
173
200
  await rename(partial, destination);
174
- await writeFile(destination.replace(/\.mp4$/, ".json"), JSON.stringify({ file: path.basename(destination), audio: config.audio, settings, verification: probe }, null, 2) + "\n");
201
+ // A player would show a same-named subtitle file on top of burned-in captions, so those exports get none.
202
+ const subtitleFiles = subtitles.length && !burnCaptions ? [[".srt", toSrt(subtitles)], [".vtt", toVtt(subtitles, language)]].map(([extension, text]) => ({ path: destination.replace(/\.mp4$/, extension), text })) : [];
203
+ for (const file of subtitleFiles) await writeFile(file.path, file.text);
204
+ const thumbnailFile = thumbnailImage ? destination.replace(/\.mp4$/, ".jpg") : null;
205
+ if (thumbnailFile) await writeFile(thumbnailFile, thumbnailImage);
206
+ await writeFile(destination.replace(/\.mp4$/, ".json"), JSON.stringify({ file: path.basename(destination), title: config.title, audio: config.audio, subtitles: subtitleFiles.map((file) => path.basename(file.path)), thumbnail: thumbnailFile && { file: path.basename(thumbnailFile), time: thumbnail.time }, settings, verification: probe }, null, 2) + "\n");
175
207
  const bytes = (await stat(destination)).size;
176
208
  finished = true;
177
209
  clearInterval(idleCheck);
178
210
  console.log(`Saved: ${destination}\nSize: ${(bytes / 1024 / 1024).toFixed(1)} MiB\nVerified: ${settings.frames} frames, ${settings.duration}s, H.264 video and AAC audio.`);
211
+ if (subtitleFiles.length) console.log(`Subtitles (${subtitles.length} captions, ${language}): ${subtitleFiles.map((file) => file.path).join(", ")}`);
212
+ console.log(thumbnailFile ? `Thumbnail (${thumbnail.time}s): ${thumbnailFile}` : 'No thumbnail: set "poster" in film.config.json to the seconds of the frame to save as one.');
179
213
  response.once("finish", () => { void server.close(); });
180
- send({ path: destination, bytes }); return;
214
+ send({ path: destination, bytes, subtitles: subtitleFiles.map((file) => file.path), thumbnail: thumbnailFile }); return;
181
215
  }
182
216
  if (url.pathname === "/__export/abort") {
183
217
  const message = JSON.parse((await readBody(request, 4096)).toString());
@@ -0,0 +1,42 @@
1
+ /** Subtitle files written beside an exported MP4: SubRip for YouTube and desktop players, WebVTT for HTML5 <track>. */
2
+
3
+ const LINE_LENGTH = 42;
4
+
5
+ /** Validates caption clips from the player and returns them in display order, clipped to the film. */
6
+ export function subtitleCues(input, duration) {
7
+ if (!Array.isArray(input) || input.length > 5000) throw new Error("Subtitles must be a list of at most 5000 captions.");
8
+ return input.map((cue) => {
9
+ const start = Number(cue?.start), end = Math.min(Number(cue?.end), duration);
10
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || typeof cue.text !== "string" || cue.text.length > 1000) throw new Error("Each caption needs a start, an end and text of up to 1000 characters.");
11
+ const text = cue.text.replace(/\r\n?/g, "\n").split("\n").map((line) => line.replace(/\s+/g, " ").trim()).filter(Boolean).join("\n");
12
+ return { start, end, text };
13
+ }).filter((cue) => cue.text && cue.end > cue.start).sort((a, b) => a.start - b.start || a.end - b.end);
14
+ }
15
+
16
+ /** Splits a long single-line caption into two lines; captions with their own line breaks are kept. */
17
+ export function subtitleLines(text) {
18
+ if (text.includes("\n") || text.length <= LINE_LENGTH) return text;
19
+ let best = -1, bestCost = Infinity;
20
+ for (let index = text.indexOf(" "); index > 0; index = text.indexOf(" ", index + 1)) {
21
+ const first = text.slice(0, index), second = text.slice(index + 1);
22
+ // The shorter the longer line the better; a break after punctuation reads better, one after a short word like "a" worse.
23
+ const cost = Math.max(first.length, second.length) - (/[,;:.!?]$/.test(first) ? 8 : 0) + (/(?:^|\s)[\p{L}\p{N}']{1,3}$/u.test(first) ? 6 : 0);
24
+ if (cost < bestCost) { best = index; bestCost = cost; }
25
+ }
26
+ return best < 0 ? text : `${text.slice(0, best)}\n${text.slice(best + 1)}`;
27
+ }
28
+
29
+ function timestamp(seconds, separator) {
30
+ const ms = Math.round(seconds * 1000);
31
+ const pad = (value, size = 2) => String(value).padStart(size, "0");
32
+ return `${pad(Math.floor(ms / 3600000))}:${pad(Math.floor(ms / 60000) % 60)}:${pad(Math.floor(ms / 1000) % 60)}${separator}${pad(ms % 1000, 3)}`;
33
+ }
34
+
35
+ export function toSrt(cues) {
36
+ return cues.map((cue, index) => `${index + 1}\n${timestamp(cue.start, ",")} --> ${timestamp(cue.end, ",")}\n${subtitleLines(cue.text)}\n`).join("\n");
37
+ }
38
+
39
+ export function toVtt(cues, language) {
40
+ const escape = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
41
+ return `WEBVTT\nLanguage: ${language}\n\n${cues.map((cue) => `${timestamp(cue.start, ".")} --> ${timestamp(cue.end, ".")}\n${escape(subtitleLines(cue.text))}\n`).join("\n")}`;
42
+ }
@@ -1,10 +1,12 @@
1
1
  {
2
2
  "preparing": "Preparing MP4 export…",
3
3
  "rendering": "Rendering MP4 · {percent}% · {frame} / {total} frames. Keep this tab open.",
4
+ "thumbnail": "Saving the thumbnail…",
4
5
  "finishing": "Finishing the MP4 and checking its video and audio…",
5
6
  "saved": "MP4 saved: {path}",
6
7
  "failed": "Export stopped: {error}",
7
8
  "requestFailed": "Capture request failed.",
8
9
  "languageChanged": "The language changed during capture. Restart the export.",
9
- "captureFailed": "Could not capture frame {frame}."
10
+ "captureFailed": "Could not capture frame {frame}.",
11
+ "thumbnailFailed": "Could not capture the thumbnail."
10
12
  }
@@ -10,6 +10,7 @@ type CaptureApi = {
10
10
  canvas: HTMLCanvasElement;
11
11
  fps: number;
12
12
  language: string;
13
+ resources: { kind: string; clips: { start: number; end: number; label: string }[] }[];
13
14
  renderFrame(frame: number, fps: number): Promise<void>;
14
15
  setCaptions(enabled: boolean): void;
15
16
  pause(): void;
@@ -40,9 +41,11 @@ export async function exportMp4(api: CaptureApi, token: string) {
40
41
  };
41
42
  try {
42
43
  api.pause();
43
- api.setCaptions(true);
44
44
  const language = api.language;
45
- const settings = await request("start", JSON.stringify({ width: api.width, height: api.height, duration: api.duration, fps: api.fps, language }));
45
+ const subtitles = api.resources.filter((track) => track.kind === "captions").flatMap((track) => track.clips.map((clip) => ({ start: clip.start, end: clip.end, text: clip.label })));
46
+ const settings = await request("start", JSON.stringify({ width: api.width, height: api.height, duration: api.duration, fps: api.fps, language, subtitles }));
47
+ // The export command decides: captions drawn into the picture, or saved as .srt/.vtt files beside a clean MP4.
48
+ api.setCaptions(settings.burnCaptions === true);
46
49
  state.total = settings.frames;
47
50
  state.status = "rendering";
48
51
  for (let frame = 0; frame < settings.frames; frame++) {
@@ -54,6 +57,24 @@ export async function exportMp4(api: CaptureApi, token: string) {
54
57
  state.frame = frame + 1;
55
58
  status.textContent = message("rendering", { percent: Math.round(state.frame / state.total * 100), frame: state.frame.toLocaleString(), total: state.total.toLocaleString() });
56
59
  }
60
+ if (settings.thumbnail) {
61
+ // A clean frame without captions, scaled to YouTube's 1280-pixel thumbnail size and kept under its 2 MB limit.
62
+ status.textContent = copy.thumbnail;
63
+ api.setCaptions(false);
64
+ await api.renderFrame(Math.min(settings.frames - 1, Math.round(settings.thumbnail.time * settings.fps)), settings.fps);
65
+ const canvas = document.createElement("canvas");
66
+ canvas.width = settings.thumbnail.width; canvas.height = settings.thumbnail.height;
67
+ const context = canvas.getContext("2d")!;
68
+ context.imageSmoothingQuality = "high";
69
+ context.drawImage(api.canvas, 0, 0, canvas.width, canvas.height);
70
+ let image: Blob | null = null;
71
+ for (const quality of [0.92, 0.82, 0.7]) {
72
+ image = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/jpeg", quality));
73
+ if (image && image.size <= 2 * 1024 * 1024) break;
74
+ }
75
+ if (!image) throw new Error(copy.thumbnailFailed);
76
+ await request("thumbnail", image, "image/jpeg");
77
+ }
57
78
  state.status = "encoding";
58
79
  status.textContent = copy.finishing;
59
80
  const result = await request("finish", "{}");
@@ -4,6 +4,8 @@ import type { ResourceTrack } from "./timeline-model";
4
4
  export type FilmConfig = {
5
5
  title: string; slug: string; width: number; height: number; fps: number;
6
6
  duration: number; language: string; audio: string | null;
7
+ /** Seconds of the frame the MP4 export saves as its JPEG thumbnail. */
8
+ poster?: number | null;
7
9
  };
8
10
  export type Branding = {
9
11
  name: string; tagline: string; logo: string | null; assets?: Record<string, string>;