@docyrus/animaguru 0.8.1 → 0.9.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/README.md +47 -1
- package/dist/animaguru.js +853 -37
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +8 -0
- package/skills/animaguru-video/SKILL.md +1 -1
- package/templates/video/README.md +3 -1
- package/templates/video/WORKSPACE-AGENTS.md +1 -1
- package/templates/video/film.config.json +2 -1
- package/templates/video/scripts/export-mp4.mjs +44 -10
- package/templates/video/scripts/subtitles.mjs +42 -0
- package/templates/video/shared/locales/en/export.json +3 -1
- package/templates/video/src/exporter.ts +23 -2
- package/templates/video/src/player-types.ts +2 -0
package/package.json
CHANGED
|
@@ -117,6 +117,14 @@ The JSON result returns the video ID and watch URL. Owners manage details and re
|
|
|
117
117
|
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
118
|
|
|
119
119
|
|
|
120
|
+
## Upload a local MP4 to YouTube
|
|
121
|
+
|
|
122
|
+
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.
|
|
123
|
+
|
|
124
|
+
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`.
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
120
128
|
## Shared workspace commands
|
|
121
129
|
|
|
122
130
|
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
|
|
|
@@ -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
|
|
|
@@ -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.
|
|
@@ -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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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, `${
|
|
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,
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
|
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>;
|