@docyrus/animaguru 0.25.0 → 0.26.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/NOTICE.md CHANGED
@@ -1575,6 +1575,105 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1575
1575
  SOFTWARE.
1576
1576
 
1577
1577
 
1578
+ ## Instrument Sans (watermark font)
1579
+
1580
+ The Latin variable-weight subset in templates/video/scripts/watermark/instrument-sans.woff2 draws the "Made with AnimaGuru" badge of `npm run export:mp4 -- --watermark`.
1581
+
1582
+ Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
1583
+
1584
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
1585
+ This license is copied below, and is also available with a FAQ at:
1586
+ http://scripts.sil.org/OFL
1587
+
1588
+
1589
+ -----------------------------------------------------------
1590
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
1591
+ -----------------------------------------------------------
1592
+
1593
+ PREAMBLE
1594
+ The goals of the Open Font License (OFL) are to stimulate worldwide
1595
+ development of collaborative font projects, to support the font creation
1596
+ efforts of academic and linguistic communities, and to provide a free and
1597
+ open framework in which fonts may be shared and improved in partnership
1598
+ with others.
1599
+
1600
+ The OFL allows the licensed fonts to be used, studied, modified and
1601
+ redistributed freely as long as they are not sold by themselves. The
1602
+ fonts, including any derivative works, can be bundled, embedded,
1603
+ redistributed and/or sold with any software provided that any reserved
1604
+ names are not used by derivative works. The fonts and derivatives,
1605
+ however, cannot be released under any other type of license. The
1606
+ requirement for fonts to remain under this license does not apply
1607
+ to any document created using the fonts or their derivatives.
1608
+
1609
+ DEFINITIONS
1610
+ "Font Software" refers to the set of files released by the Copyright
1611
+ Holder(s) under this license and clearly marked as such. This may
1612
+ include source files, build scripts and documentation.
1613
+
1614
+ "Reserved Font Name" refers to any names specified as such after the
1615
+ copyright statement(s).
1616
+
1617
+ "Original Version" refers to the collection of Font Software components as
1618
+ distributed by the Copyright Holder(s).
1619
+
1620
+ "Modified Version" refers to any derivative made by adding to, deleting,
1621
+ or substituting -- in part or in whole -- any of the components of the
1622
+ Original Version, by changing formats or by porting the Font Software to a
1623
+ new environment.
1624
+
1625
+ "Author" refers to any designer, engineer, programmer, technical
1626
+ writer or other person who contributed to the Font Software.
1627
+
1628
+ PERMISSION & CONDITIONS
1629
+ Permission is hereby granted, free of charge, to any person obtaining
1630
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
1631
+ redistribute, and sell modified and unmodified copies of the Font
1632
+ Software, subject to the following conditions:
1633
+
1634
+ 1) Neither the Font Software nor any of its individual components,
1635
+ in Original or Modified Versions, may be sold by itself.
1636
+
1637
+ 2) Original or Modified Versions of the Font Software may be bundled,
1638
+ redistributed and/or sold with any software, provided that each copy
1639
+ contains the above copyright notice and this license. These can be
1640
+ included either as stand-alone text files, human-readable headers or
1641
+ in the appropriate machine-readable metadata fields within text or
1642
+ binary files as long as those fields can be easily viewed by the user.
1643
+
1644
+ 3) No Modified Version of the Font Software may use the Reserved Font
1645
+ Name(s) unless explicit written permission is granted by the corresponding
1646
+ Copyright Holder. This restriction only applies to the primary font name as
1647
+ presented to the users.
1648
+
1649
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
1650
+ Software shall not be used to promote, endorse or advertise any
1651
+ Modified Version, except to acknowledge the contribution(s) of the
1652
+ Copyright Holder(s) and the Author(s) or with their explicit written
1653
+ permission.
1654
+
1655
+ 5) The Font Software, modified or unmodified, in part or in whole,
1656
+ must be distributed entirely under this license, and must not be
1657
+ distributed under any other license. The requirement for fonts to
1658
+ remain under this license does not apply to any document created
1659
+ using the Font Software.
1660
+
1661
+ TERMINATION
1662
+ This license becomes null and void if any of the above conditions are
1663
+ not met.
1664
+
1665
+ DISCLAIMER
1666
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
1667
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
1668
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
1669
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
1670
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
1671
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
1672
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
1673
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
1674
+ OTHER DEALINGS IN THE FONT SOFTWARE.
1675
+
1676
+
1578
1677
  ## Art Motion references
1579
1678
 
1580
1679
  English adaptations of Huashu Art Motion (https://github.com/alchaincyf/huashu-art-motion). Source revision and coverage are in skills/animaguru-art-motion/references/coverage.json.
package/README.md CHANGED
@@ -47,6 +47,8 @@ CLI 0.24.0 adds eight documentary & broadcast styles, cut from real archive foot
47
47
 
48
48
  CLI 0.25.0 adds seven contemporary motion-design styles: Aurora & Glass, Liquid Motion, Bauhaus Geometry, Synthwave & Y2K, Sticker Explainer, Variety Captions and One-Line Art. Their signature techniques follow the MIT-licensed [mg-styles-15](https://github.com/Vincentwei1021/mg-styles-15) collection; the skills, studies and preview art are original. Each has a runnable study (`npm run example -- aurora-glass`).
49
49
 
50
+ CLI 0.26.0 adds `npm run export:mp4 -- --watermark`: beside the clean MP4, the export saves `SLUG-LANGUAGE-watermarked.mp4` with AnimaGuru's "Made with AnimaGuru" badge, drawn with the font, logo and layout of the web app's watermark. AnimaGuru's lead pipeline uploads it as the film's preview, so a film no longer waits for a reviewer's browser to watermark it.
51
+
50
52
  CLI 0.16.2 also installs `animaguru-art-motion` for Codex and Claude Code: one skill with 36 English art-treatment references and nine explainer motion grammars, plus AnimaGuru generation, timing, camera and review guidance. Request it explicitly for a custom film treatment; its references are separate from the gallery presets. Source coverage and the upstream MIT notice are included in the skill.
51
53
 
52
54
  CLI 0.5.2 includes a complete local PixiJS video workspace, now with 55 visual styles:
package/dist/animaguru.js CHANGED
@@ -19761,7 +19761,7 @@ var require_ignore2 = __commonJS({
19761
19761
  // cli/package.json
19762
19762
  var package_default = {
19763
19763
  name: "@docyrus/animaguru",
19764
- version: "0.25.0",
19764
+ version: "0.26.0",
19765
19765
  description: "Agent-friendly AnimaGuru CLI built with CrustJS",
19766
19766
  type: "module",
19767
19767
  bin: {
@@ -45307,13 +45307,16 @@ var animation_styles_default = {
45307
45307
  description: "Explore the visual language of your next animation. Choose a style here, then tell your coding agent which one you like.",
45308
45308
  available: "AVAILABLE NOW",
45309
45309
  previewLabel: "{{name}} style preview",
45310
+ playLabel: "Play the {{name}} preview",
45311
+ previousStyle: "Previous style",
45312
+ nextStyle: "Next style",
45310
45313
  previewError: "The preview could not load. Try reloading the page.",
45311
45314
  visualLabel: "THE LOOK",
45312
45315
  motionLabel: "THE MOVEMENT",
45313
45316
  categoryLabel: "WORKS WITH",
45314
45317
  copyChoice: "Copy this style choice",
45315
45318
  copied: "Style choice copied",
45316
- copyFailed: "Copy the style ID shown below and send it to your agent.",
45319
+ copyFailed: "Copy the style ID and send it to your agent.",
45317
45320
  choiceMessage: "Use {{name}} ({{id}}) as the visual and animation style for my video.",
45318
45321
  idLabel: "Style ID",
45319
45322
  startEyebrow: "START WITH AN IDEA",
@@ -46292,7 +46295,7 @@ var animation_styles_default = {
46292
46295
  searchLabel: "Search visual styles",
46293
46296
  searchPlaceholder: "Try paper, product, typography\u2026",
46294
46297
  filterLabel: "Filter by visual family",
46295
- emptyResults: "No styles match. Try another word or visual family.",
46298
+ emptyResults: "No styles match. Try another word.",
46296
46299
  resultsCount_one: "{{count}} style",
46297
46300
  resultsCount_other: "{{count}} styles",
46298
46301
  exampleLink: "Try the bundled example",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -67,11 +67,13 @@ npm run export:mp4
67
67
  npm run export:mp4 -- --port 5197
68
68
  # Captions drawn into the picture, e.g. for social feeds (saved as SLUG-LANGUAGE-captioned.mp4):
69
69
  npm run export:mp4 -- --burn-captions
70
+ # Also save a copy with AnimaGuru's "Made with AnimaGuru" badge (SLUG-LANGUAGE-watermarked.mp4):
71
+ npm run export:mp4 -- --watermark
70
72
  ```
71
73
 
72
74
  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.
73
75
 
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.
76
+ 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. `--watermark` also saves `SLUG-LANGUAGE-watermarked.mp4`, the same film with AnimaGuru's "Made with AnimaGuru" badge in its bottom-right corner, drawn with the font, logo and layout AnimaGuru's web app uses; it is the preview AnimaGuru's lead pipeline plays before a lead claims the clean film. The picture is encoded again with the badge and verified like the film; the sound is copied. 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.
75
77
 
76
78
  ## Capture API
77
79
 
@@ -16,7 +16,7 @@ Run commands from the workspace root:
16
16
  - `npm run dev`: the workspace hub and separate `/animations/NAME/` players.
17
17
  - `npm run build -- --animation NAME`: typecheck and build that film.
18
18
  - `npm run export:html -- --animation NAME`: portable HTML5 ZIP with local media and common branding.
19
- - `npm run export:mp4 -- --animation NAME` (not for a branching story or a quiz film): 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. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
19
+ - `npm run export:mp4 -- --animation NAME` (not for a branching story or a quiz film): 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. `--watermark` also saves `NAME-LANG-watermarked.mp4` with AnimaGuru's "Made with AnimaGuru" badge, for an AnimaGuru lead film's preview. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
20
20
  - `npm run review -- --animation NAME --headless`: frame, text, layout (overlap, safe area, text size), seeking and soundtrack checks, one line per finding.
21
21
  - `npm run frames -- --animation NAME --at 1.2,4.8`: labelled contact sheets in `production/review/frames/` plus layout notes for those times.
22
22
  - `npm run audio:vo|audio:music|audio:mix -- --animation NAME`: cut and place the voiceover (`timing.json`), fit the music bed, and master the soundtrack.
@@ -23,10 +23,13 @@ const headless = takeHeadlessFlag(args);
23
23
  let port = 5196;
24
24
  // Captions become subtitle files beside the MP4 unless --burn-captions draws them into the picture, e.g. for social feeds.
25
25
  let burnCaptions = false;
26
+ // --watermark also saves NAME-watermarked.mp4, the film with AnimaGuru's "Made with AnimaGuru" badge, e.g. a lead film's preview.
27
+ let watermark = false;
26
28
  for (let index = 0; index < args.length; index++) {
27
29
  if (args[index] === "--burn-captions") burnCaptions = true;
30
+ else if (args[index] === "--watermark") watermark = true;
28
31
  else if (args[index] === "--port" && /^\d+$/.test(args[index + 1] ?? "")) port = Number(args[++index]);
29
- else throw new Error("Usage: npm run export:mp4 -- [--animation NAME] [--headless] [--port 5196] [--burn-captions]");
32
+ else throw new Error("Usage: npm run export:mp4 -- [--animation NAME] [--headless] [--port 5196] [--burn-captions] [--watermark]");
30
33
  }
31
34
  if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("Export port must be 1024–65535.");
32
35
  const context = await projectContext(root, selection.animation);
@@ -47,11 +50,15 @@ if (config.audio !== null) {
47
50
  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.");
48
51
  const thumbnailScale = Math.min(1, 1280 / Math.max(width, height));
49
52
  const thumbnail = config.poster == null ? null : { time: config.poster, width: Math.round(width * thumbnailScale), height: Math.round(height * thumbnailScale) };
53
+ // The capture page draws the badge with the font and logo AnimaGuru's web app uses, so they are sent to it as base64.
54
+ const watermarkFiles = watermark ? Object.fromEntries(await Promise.all([["font", "instrument-sans.woff2"], ["logo", "logo-mark-512.png"]]
55
+ .map(async ([key, file]) => [key, (await readFile(new URL(`./watermark/${file}`, import.meta.url))).toString("base64")]))) : null;
50
56
  const outputDirectory = context.output;
51
57
  await mkdir(outputDirectory, { recursive: true });
52
58
  const name = `${config.slug}-${language}${burnCaptions ? "-captioned" : ""}`;
53
59
  let destination = path.join(outputDirectory, `${name}.mp4`);
54
- for (const candidate of [destination, ...[".partial.mp4", ".srt", ".vtt", ".jpg"].map((extension) => destination.replace(/\.mp4$/, extension))]) {
60
+ const watermarkedFiles = watermark ? ["-watermarked.mp4", "-watermarked.partial.mp4"] : [];
61
+ for (const candidate of [destination, ...[".partial.mp4", ".srt", ".vtt", ".jpg", ...watermarkedFiles].map((extension) => destination.replace(/\.mp4$/, extension))]) {
55
62
  try {
56
63
  await access(candidate);
57
64
  destination = path.join(outputDirectory, `${name}-${randomUUID()}.mp4`);
@@ -59,6 +66,8 @@ for (const candidate of [destination, ...[".partial.mp4", ".srt", ".vtt", ".jpg"
59
66
  } catch (error) { if (error.code !== "ENOENT") throw error; }
60
67
  }
61
68
  const partial = destination.replace(/\.mp4$/, ".partial.mp4");
69
+ const watermarked = destination.replace(/\.mp4$/, "-watermarked.mp4");
70
+ const watermarkedPartial = destination.replace(/\.mp4$/, "-watermarked.partial.mp4");
62
71
  const token = randomUUID();
63
72
  let encoder;
64
73
  let encoderDone;
@@ -67,6 +76,9 @@ let encoderLog = "";
67
76
  let settings;
68
77
  let subtitles = [];
69
78
  let thumbnailImage;
79
+ let badge;
80
+ let watermarking;
81
+ let ownsWatermarkedPartial = false;
70
82
  let received = 0;
71
83
  let lastFrameHash;
72
84
  let frameQueue = Promise.resolve();
@@ -103,10 +115,62 @@ function jpegDimensions(bytes) {
103
115
  return null;
104
116
  }
105
117
 
118
+ function pngDimensions(bytes) {
119
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
120
+ if (bytes.length < 24 || signature.some((byte, index) => bytes[index] !== byte) || bytes.toString("latin1", 12, 16) !== "IHDR") return null;
121
+ return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
122
+ }
123
+
124
+ /** Reads an encoded MP4 back and checks that it is the whole film: frames, duration, dimensions, codecs and language. */
125
+ async function verify(file, what) {
126
+ const { stdout } = await run(ffprobe.path, ["-v", "error", "-count_frames", "-show_streams", "-show_format", "-of", "json", file], { maxBuffer: 1024 * 1024 });
127
+ const probe = JSON.parse(stdout);
128
+ const video = probe.streams.find((stream) => stream.codec_type === "video");
129
+ const audio = probe.streams.find((stream) => stream.codec_type === "audio");
130
+ 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 ${what} did not pass duration, frame count, dimensions, language, or codec verification.`);
131
+ return probe;
132
+ }
133
+
134
+ /**
135
+ Lays the badge over a copy of the encoded film. The picture is encoded again at the same quality and the sound is
136
+ copied. The badge is converted to the film's BT.709 limited-range colours before it is laid over, so it keeps the
137
+ colours of AnimaGuru's own watermarked copies.
138
+ */
139
+ async function watermarkCopy() {
140
+ await writeFile(watermarkedPartial, "", { flag: "wx" });
141
+ ownsWatermarkedPartial = true;
142
+ // The badge arrives on stdin as a single PNG frame, which the overlay keeps over the whole film.
143
+ const child = spawn(ffmpeg.path, [
144
+ "-hide_banner", "-loglevel", "warning", "-y", "-i", partial, "-f", "png_pipe", "-i", "pipe:0",
145
+ "-filter_complex", `[1:v]scale=out_color_matrix=bt709:out_range=tv,format=yuva420p[mark];[0:v][mark]overlay=x=${badge.x}:y=${badge.y}:format=yuv420,format=yuv420p[film]`,
146
+ "-map", "[film]", "-map", "0:a:0",
147
+ "-c:v", "libx264", "-preset", "medium", "-crf", "18",
148
+ "-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709",
149
+ "-c:a", "copy", "-metadata:s:a:0", `language=${languageTag}`, "-metadata:s:v:0", `language=${languageTag}`,
150
+ "-movflags", "+faststart", watermarkedPartial,
151
+ ], { stdio: ["pipe", "ignore", "pipe"] });
152
+ let log = "";
153
+ child.stderr.on("data", (chunk) => { log = (log + chunk.toString()).slice(-8000); });
154
+ // An FFmpeg that fails early closes its input; its exit code reports why.
155
+ child.stdin.on("error", () => {});
156
+ child.stdin.end(badge.image);
157
+ const done = new Promise((resolve, reject) => {
158
+ child.once("error", reject);
159
+ child.once("close", (code, signal) => code === 0 ? resolve() : reject(new Error(`FFmpeg could not make the watermarked copy (${signal ?? `exit ${code}`}): ${log}`)));
160
+ });
161
+ watermarking = { child, done };
162
+ await done;
163
+ }
164
+
106
165
  async function cleanup(error) {
107
166
  if (aborted || finished) return;
108
167
  aborted = true;
109
168
  console.error(`Export stopped: ${error.message ?? error}`);
169
+ // SIGKILL, because an FFmpeg stopped any other way finishes the MP4 and would write the partial copy again; and at
170
+ // once, because Playwright's own Ctrl+C handler exits as soon as the headless browser has closed.
171
+ const marker = watermarking?.child;
172
+ if (marker && marker.exitCode === null && marker.signalCode === null) marker.kill("SIGKILL");
173
+ if (ownsWatermarkedPartial) await rm(watermarkedPartial, { force: true });
110
174
  if (encoder && encoder.exitCode === null) {
111
175
  encoder.stdin.destroy();
112
176
  encoder.kill("SIGTERM");
@@ -156,8 +220,17 @@ async function route(request, response, url) {
156
220
  encoder.once("close", (code) => code === 0 ? resolve() : reject(new Error(`FFmpeg exited ${code}: ${encoderLog}`)));
157
221
  });
158
222
  encoderDone.catch((error) => { encoderError = error; });
159
- 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"}.`);
160
- send({ fps, frames: settings.frames, burnCaptions, thumbnail }); return;
223
+ 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"}${watermark ? ", plus a watermarked copy" : ""}.`);
224
+ send({ fps, frames: settings.frames, burnCaptions, thumbnail, watermark: watermarkFiles }); return;
225
+ }
226
+ if (url.pathname === "/__export/watermark") {
227
+ if (!watermark || !encoder || encoding || badge || received) throw new Error("Send the watermark once, before the first frame.");
228
+ const image = await readBody(request, 1024 * 1024);
229
+ const size = pngDimensions(image);
230
+ const x = Number(url.searchParams.get("x")), y = Number(url.searchParams.get("y"));
231
+ if (request.headers["content-type"] !== "image/png" || !size || !Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x + size.width > width || y + size.height > height) throw new Error("Expected a PNG watermark inside the picture.");
232
+ badge = { image, x, y, ...size };
233
+ send({ bytes: image.length }); return;
161
234
  }
162
235
  if (url.pathname === "/__export/frame") {
163
236
  if (!encoder || encoding || finished || aborted) throw new Error("No active frame capture.");
@@ -196,30 +269,34 @@ async function route(request, response, url) {
196
269
  if (url.pathname === "/__export/finish") {
197
270
  if (!encoder || encoding || received !== settings.frames) throw new Error("The capture is incomplete.");
198
271
  if (thumbnail && !thumbnailImage) throw new Error("The thumbnail was not captured.");
272
+ if (watermark && !badge) throw new Error("The watermark was not drawn.");
199
273
  encoding = true;
200
274
  encoder.stdin.end();
201
275
  await encoderDone;
202
276
  console.log("Checking the encoded video and soundtrack…");
203
- const { stdout } = await run(ffprobe.path, ["-v", "error", "-count_frames", "-show_streams", "-show_format", "-of", "json", partial], { maxBuffer: 1024 * 1024 });
204
- const probe = JSON.parse(stdout);
205
- const video = probe.streams.find((stream) => stream.codec_type === "video");
206
- const audio = probe.streams.find((stream) => stream.codec_type === "audio");
207
- 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.");
277
+ const probe = await verify(partial, "file");
278
+ if (watermark) {
279
+ console.log("Making the watermarked copy…");
280
+ await watermarkCopy();
281
+ await verify(watermarkedPartial, "watermarked copy");
282
+ }
208
283
  await rename(partial, destination);
284
+ if (watermark) await rename(watermarkedPartial, watermarked);
209
285
  // A player would show a same-named subtitle file on top of burned-in captions, so those exports get none.
210
286
  const subtitleFiles = subtitles.length && !burnCaptions ? [[".srt", toSrt(subtitles)], [".vtt", toVtt(subtitles, language)]].map(([extension, text]) => ({ path: destination.replace(/\.mp4$/, extension), text })) : [];
211
287
  for (const file of subtitleFiles) await writeFile(file.path, file.text);
212
288
  const thumbnailFile = thumbnailImage ? destination.replace(/\.mp4$/, ".jpg") : null;
213
289
  if (thumbnailFile) await writeFile(thumbnailFile, thumbnailImage);
214
- 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");
290
+ 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 }, ...(watermark ? { watermarked: { file: path.basename(watermarked), badge: { x: badge.x, y: badge.y, width: badge.width, height: badge.height } } } : {}), settings, verification: probe }, null, 2) + "\n");
215
291
  const bytes = (await stat(destination)).size;
216
292
  finished = true;
217
293
  clearInterval(idleCheck);
218
294
  console.log(`Saved: ${destination}\nSize: ${(bytes / 1024 / 1024).toFixed(1)} MiB\nVerified: ${settings.frames} frames, ${settings.duration}s, H.264 video and AAC audio.`);
219
295
  if (subtitleFiles.length) console.log(`Subtitles (${subtitles.length} captions, ${language}): ${subtitleFiles.map((file) => file.path).join(", ")}`);
220
296
  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.');
297
+ if (watermark) console.log(`Watermarked copy ("Made with AnimaGuru"): ${watermarked}`);
221
298
  response.once("finish", () => { void server.close(); });
222
- send({ path: destination, bytes, subtitles: subtitleFiles.map((file) => file.path), thumbnail: thumbnailFile }); return;
299
+ send({ path: destination, bytes, subtitles: subtitleFiles.map((file) => file.path), thumbnail: thumbnailFile, watermarked: watermark ? watermarked : null }); return;
223
300
  }
224
301
  if (url.pathname === "/__export/abort") {
225
302
  const message = JSON.parse((await readBody(request, 4096)).toString());
@@ -244,13 +321,14 @@ server = await createServer({
244
321
  });
245
322
  } }],
246
323
  });
324
+ // Finishing, which for a long film with a watermarked copy can take minutes, waits for no capture request.
247
325
  const idleCheck = setInterval(() => {
248
- if (Date.now() - lastActivity > 10 * 60 * 1000) void cleanup(new Error("No capture activity for ten minutes."));
326
+ if (!encoding && Date.now() - lastActivity > 10 * 60 * 1000) void cleanup(new Error("No capture activity for ten minutes."));
249
327
  }, 30000);
250
328
  process.once("SIGINT", () => { void cleanup(new Error("Cancelled.")); });
251
329
  process.once("SIGTERM", () => { void cleanup(new Error("Cancelled.")); });
252
330
  try { await server.listen(); } catch (error) { await cleanup(error); throw error; }
253
- console.log(`Open this local export page and keep it open until complete:\nhttp://127.0.0.1:${port}${context.route}?capture=1&export=${token}\nOutput: ${destination}\nThe preview can stay open. This capture runs locally without generation credits.`);
331
+ console.log(`Open this local export page and keep it open until complete:\nhttp://127.0.0.1:${port}${context.route}?capture=1&export=${token}\nOutput: ${destination}${watermark ? `\nWatermarked copy: ${watermarked}` : ""}\nThe preview can stay open. This capture runs locally without generation credits.`);
254
332
  if (headless) {
255
333
  try { await captureHeadless(`http://127.0.0.1:${port}${context.route}?capture=1&export=${token}`, { endpoint: "/__export/finish" }); }
256
334
  catch (error) { await cleanup(error); }
@@ -0,0 +1,93 @@
1
+ Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "preparing": "Preparing MP4 export…",
3
3
  "rendering": "Rendering MP4 · {percent}% · {frame} / {total} frames. Keep this tab open.",
4
+ "watermark": "Drawing the Made with AnimaGuru watermark…",
4
5
  "thumbnail": "Saving the thumbnail…",
5
6
  "finishing": "Finishing the MP4 and checking its video and audio…",
6
7
  "saved": "MP4 saved: {path}",
@@ -24,11 +24,13 @@ export async function exportMp4(api: CaptureApi, token: string) {
24
24
  status.style.cssText = "position:fixed;z-index:100;left:24px;bottom:24px;padding:16px 22px;border-radius:12px;background:#162224ed;color:white;font:15px Arial,sans-serif;box-shadow:0 8px 30px #0002;max-width:calc(100vw - 48px)";
25
25
  status.textContent = copy.preparing;
26
26
  document.body.append(status);
27
+ // With --watermark, finishing also encodes the watermarked copy, which takes longer.
28
+ let finishTimeout = 180000;
27
29
  const request = async (route: string, body?: BodyInit, type = "application/json") => {
28
30
  const attempts = route.startsWith("frame?") ? 3 : 1;
29
31
  for (let attempt = 0; attempt < attempts; attempt++) {
30
32
  try {
31
- const response = await fetch(`/__export/${route}`, { method: "POST", headers: { "Content-Type": type, "X-AnimaGuru-Export": token }, body, signal: AbortSignal.timeout(route === "finish" ? 180000 : 45000) });
33
+ const response = await fetch(`/__export/${route}`, { method: "POST", headers: { "Content-Type": type, "X-AnimaGuru-Export": token }, body, signal: AbortSignal.timeout(route === "finish" ? finishTimeout : 45000) });
32
34
  if (!response.ok) throw new Error(await response.text());
33
35
  return await response.json();
34
36
  } catch (error) {
@@ -46,6 +48,14 @@ export async function exportMp4(api: CaptureApi, token: string) {
46
48
  const settings = await request("start", JSON.stringify({ width: api.width, height: api.height, duration: api.duration, fps: api.fps, language, subtitles }));
47
49
  // The export command decides: captions drawn into the picture, or saved as .srt/.vtt files beside a clean MP4.
48
50
  api.setCaptions(settings.burnCaptions === true);
51
+ if (settings.watermark) {
52
+ // --watermark: the badge is drawn here once, before any frame, and the export command lays it over a copy.
53
+ status.textContent = copy.watermark;
54
+ const { watermarkBadge } = await import("./watermark");
55
+ const badge = await watermarkBadge(api.width, api.height, settings.watermark);
56
+ await request(`watermark?x=${badge.x}&y=${badge.y}`, badge.image, "image/png");
57
+ finishTimeout = 15 * 60000;
58
+ }
49
59
  state.total = settings.frames;
50
60
  state.status = "rendering";
51
61
  for (let frame = 0; frame < settings.frames; frame++) {
@@ -0,0 +1,50 @@
1
+ /*
2
+ The "Made with AnimaGuru" badge of an AnimaGuru lead film, drawn the way AnimaGuru's web app draws it in the viewer's
3
+ browser, with the same Instrument Sans and logo files, which the export command sends. `npm run export:mp4 --
4
+ --watermark` lays it over a copy of the film, so the copy matches one made on animaguru.com.
5
+ */
6
+ const FAMILY = "AnimaGuru Watermark Sans";
7
+ export const WATERMARK_LEAD = "Made with ";
8
+ export const WATERMARK_NAME = "AnimaGuru";
9
+
10
+ /** The badge's sizes for a width×height picture, from its shorter side so portrait and landscape videos match. */
11
+ export function watermarkMetrics(width: number, height: number) {
12
+ const short = Math.min(width, height);
13
+ const font = Math.min(64, Math.max(12, Math.round(short * 0.028)));
14
+ const badge = Math.round(font * 2.1);
15
+ return { font, badge, logo: Math.round(font * 1.4), gap: Math.round(font * 0.45), padding: Math.round(font * 0.75), margin: Math.max(8, Math.round(short * 0.035)) };
16
+ }
17
+ /** Where a badge of `size` goes in the picture: the bottom-right corner, shrunk when it would cover too much of a small video. */
18
+ export function watermarkPlacement(width: number, height: number, size: { width: number; height: number }, margin: number) {
19
+ const scale = Math.min(1, (width * 0.6) / size.width, (height * 0.2) / size.height);
20
+ const w = Math.round(size.width * scale), h = Math.round(size.height * scale);
21
+ return { x: Math.round(width - margin - w), y: Math.round(height - margin - h), width: w, height: h };
22
+ }
23
+
24
+ const bytes = (base64: string) => Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
25
+
26
+ /** The badge for a width×height film as a PNG at its final size, and where it goes. `files` holds the font and logo as base64. */
27
+ export async function watermarkBadge(width: number, height: number, files: { font: string; logo: string }) {
28
+ const size = watermarkMetrics(width, height);
29
+ document.fonts.add(await new FontFace(FAMILY, bytes(files.font), { weight: "400 700" }).load());
30
+ const lead = `500 ${size.font}px "${FAMILY}"`, name = `700 ${size.font}px "${FAMILY}"`;
31
+ const logo = await createImageBitmap(new Blob([bytes(files.logo)], { type: "image/png" }));
32
+ const measure = new OffscreenCanvas(1, 1).getContext("2d")!;
33
+ measure.font = lead; const leadWidth = measure.measureText(WATERMARK_LEAD).width;
34
+ measure.font = name; const nameWidth = measure.measureText(WATERMARK_NAME).width;
35
+ const inset = (size.badge - size.logo) / 2;
36
+ const canvas = new OffscreenCanvas(Math.ceil(inset + size.logo + size.gap + leadWidth + nameWidth + size.padding), size.badge);
37
+ const context = canvas.getContext("2d")!;
38
+ context.fillStyle = "rgba(12, 14, 18, 0.58)";
39
+ context.beginPath(); context.roundRect(0, 0, canvas.width, canvas.height, canvas.height / 2); context.fill();
40
+ context.drawImage(logo, inset, inset, size.logo, size.logo); logo.close();
41
+ context.textBaseline = "middle";
42
+ const x = inset + size.logo + size.gap, y = canvas.height / 2 + size.font * 0.04;
43
+ context.font = lead; context.fillStyle = "rgba(255, 255, 255, 0.82)"; context.fillText(WATERMARK_LEAD, x, y);
44
+ context.font = name; context.fillStyle = "#ffffff"; context.fillText(WATERMARK_NAME, x + leadWidth, y);
45
+ // The web app scales the badge as it draws it on each frame; here it is scaled once, so FFmpeg lays it over as it is.
46
+ const place = watermarkPlacement(width, height, canvas, size.margin);
47
+ const placed = new OffscreenCanvas(place.width, place.height);
48
+ placed.getContext("2d")!.drawImage(canvas, 0, 0, place.width, place.height);
49
+ return { image: await placed.convertToBlob({ type: "image/png" }), x: place.x, y: place.y, width: place.width, height: place.height };
50
+ }