@docyrus/animaguru 0.25.0 → 0.27.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
@@ -29,7 +29,7 @@ The npm package requires Node.js 24+. Its default registry is https://registry.n
29
29
 
30
30
  From this repository, `npm ci && npm run cli:build` builds the CLI and `npm run cli -- --help` runs it. `npm run cli:release` prepares the npm tarball and public Mac downloads. `npm run cli:publish` publishes that reviewed release under the Docyrus organization; publishing requires a logged-in npm account with organization access and any required 2FA. Each release needs a new version in `cli/package.json`; the CLI version is read from that file. `npm run deploy` builds release downloads before uploading the site.
31
31
 
32
- The command `animaguru schema` returns the Crust command schema, and `animaguru catalog` lists supported art styles, actions, asset types, models, video profiles and speech voices; `catalog --type music` (or `sprites`, `image`, `video`, `speech`, `sfx`, `avatars`) returns only that part. Shell completions: `animaguru completion zsh` (also bash and fish).
32
+ The command `animaguru schema` returns the Crust command schema, and `animaguru catalog` lists supported art styles, actions, asset types, models, video profiles and speech voices; `catalog --type music` (or `sprites`, `image`, `vector`, `video`, `speech`, `sfx`, `avatars`) returns only that part. Shell completions: `animaguru completion zsh` (also bash and fish).
33
33
 
34
34
  ## Required updates
35
35
 
@@ -47,6 +47,10 @@ 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
+
52
+ CLI 0.27.0 adds vector assets: `--type vector` draws an editable SVG with Ideogram on fal, from a prompt (optionally following up to five saved images) or by tracing a saved image. `assets download` saves it as `.svg`. See [Vectors](#vectors).
53
+
50
54
  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
55
 
52
56
  CLI 0.5.2 includes a complete local PixiJS video workspace, now with 55 visual styles:
@@ -288,6 +292,23 @@ animaguru assets generate PROJECT_ID --type video --model fal/flux-3-max \
288
292
 
289
293
  `--keyframes` takes 1–10 `seconds,SHA` pairs, separated by semicolons, of this project's images or sprite art. Each is reached at its second, within `--seconds`; the clip has 24 frames a second and each keyframe needs a frame of its own. `--aspect-ratio auto` (default) follows the first keyframe, or give a ratio. FLUX 3 Max always works through the prompt before filming: `--prompt-expansion quality` asks for its thorough, slower level, any other value its light one. It takes `--seed`. Its dry runs are local estimates.
290
294
 
295
+ ### Vectors
296
+
297
+ CLI 0.27.0 adds vectors: editable SVG files drawn by Ideogram on fal.ai (`fal/ideogram-vector`), for logos, icons, mascots and flat illustrations that stay sharp at any size and can be animated shape by shape.
298
+
299
+ ```sh
300
+ # Draw from a prompt; --references guide the look, the first one leading.
301
+ animaguru assets generate PROJECT_ID --type vector --name "Fox mascot" \
302
+ --prompt "A friendly fox mascot waving, flat colors, bold clean outlines" \
303
+ --quality medium --aspect-ratio 1:1 --references CHARACTER_SHEET_SHA --dry-run
304
+
305
+ # Trace a saved image or sprite into shapes; it takes no prompt.
306
+ animaguru assets generate PROJECT_ID --type vector --name "Logo" \
307
+ --workflow image-to-vector --source IMAGE_SHA --shapes simple --dry-run
308
+ ```
309
+
310
+ `--workflow` is `text-to-vector` (default) or `image-to-vector`. From text, `--quality` is `low` (13 credits), `medium` (default, 16) or `high` (20, or 32 with references); `--aspect-ratio` is `auto` (default, the model's choice) or a ratio from 1:4 to 4:1 (`catalog --type vector` lists them); `--references` takes up to five of this project's images or sprite art of up to 10 MB each, named Image 1, Image 2… in the prompt; `--seed` repeats a result. `image-to-vector` traces `--source`, one of this project's images or sprite art of up to 50 MB, for 10 credits: `--shapes detailed` (default) keeps fine detail and `simple` uses fewer, simpler shapes. The result is saved as `image/svg+xml` and downloads as `NAME.svg`. Vectors can't be used as image or video references; trace or draw one from an image instead.
311
+
291
312
  Images have a full background by default; `--transparent` requests a cutout. CLI 0.17.0 adds `--references`, `--mask` and the 16:9 and 9:16 shapes. `--aspect-ratio` is 1:1 (default, 1024×1024), 3:2 (1536×1024), 2:3 (1024×1536), 16:9 (1536×864) or 9:16 (864×1536). `--references` takes up to four comma-separated blob SHAs of this project's images (including attached uploads) or sprite art, and the new image follows them: generate a character sheet once, then reference it in every scene to keep the character consistent. Refer to them in the prompt as Image 1, Image 2 and so on, and say what to take from each. Each reference adds a little to the estimate. `--mask` edits only part of Image 1, the first reference: give up to eight rectangles as `x,y,width,height` fractions of its size from the top left, separated by semicolons (`0,0,1,0.4` is the top 40%). The rest of the image is kept, and the result keeps Image 1's shape, which must be one of the five above. Gemini speech's `--prompt` is the verbatim transcript, and `--delivery` controls how it sounds without being spoken. Gemini speech supports both `gemini-3.8-flash-tts` and `gemini-3.8-flash-lite-tts`. Music uses Lyria. Google audio requires the server's `AI_PROVIDER_KEY_GOOGLE` secret; no provider keys are stored in the CLI.
292
313
 
293
314
  Preview a validated request and its estimated credits with `--dry-run`, without spending credits; most dry runs work offline without signing in:
@@ -310,7 +331,7 @@ animaguru assets submit PROJECT_ID --file speech.json --model gemini-3.8-flash-t
310
331
 
311
332
  `--model` overrides the file's model. Unrelated type-specific flags are rejected rather than ignored, and the error lists the flags that type accepts.
312
333
 
313
- `assets generate` and `assets submit` accept `--wait` to block until the job finishes (failing if generation fails) and `--download PATH` to wait and then save the result; `--timeout` sets the wait in seconds (default 600). A `PATH` that is an existing folder or ends in `/` receives `NAME.EXT`, named after the asset and its type (`.wav`, `.mp3`, `.png`…). The JSON output adds `job`, `asset` (`id`, `name`, `type`, `mimeType`) and `download` (`path`, `size`) to the submission receipt.
334
+ `assets generate` and `assets submit` accept `--wait` to block until the job finishes (failing if generation fails) and `--download PATH` to wait and then save the result; `--timeout` sets the wait in seconds (default 600). A `PATH` that is an existing folder or ends in `/` receives `NAME.EXT`, named after the asset and its type (`.wav`, `.mp3`, `.png`, `.svg`…). The JSON output adds `job`, `asset` (`id`, `name`, `type`, `mimeType`) and `download` (`path`, `size`) to the submission receipt.
314
335
 
315
336
  ```sh
316
337
  animaguru assets generate PROJECT_ID --type speech --name vo-01-hook \
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.27.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",
@@ -46723,7 +46726,7 @@ async function createAnimation(options) {
46723
46726
  }
46724
46727
 
46725
46728
  // shared/assets.ts
46726
- var ASSET_TYPES = ["image", "video", "music", "speech", "sfx"];
46729
+ var ASSET_TYPES = ["image", "vector", "video", "music", "speech", "sfx"];
46727
46730
  var SPEECH_MODELS = [
46728
46731
  { id: "gemini-3.8-flash-tts", label: "Gemini 3.8 Flash TTS" },
46729
46732
  { id: "gemini-3.8-flash-lite-tts", label: "Gemini 3.8 Flash-Lite TTS" },
@@ -46976,6 +46979,49 @@ var imageAsset = external_exports.object({
46976
46979
  var imageChecks = (p, ctx) => {
46977
46980
  if (p.mask && !p.references.length) ctx.addIssue({ code: "custom", message: "A mask edits Image 1; add the image to edit as the first reference.", path: ["mask"] });
46978
46981
  };
46982
+ var VECTOR_MODELS = [{ id: "fal/ideogram-vector", label: "Ideogram Vector (fal)" }];
46983
+ var VECTOR_WORKFLOWS = ["text-to-vector", "image-to-vector"];
46984
+ var VECTOR_QUALITIES = ["low", "medium", "high"];
46985
+ var VECTOR_ASPECT_RATIOS = ["auto", "1:1", "1:4", "1:3", "1:2", "9:16", "10:16", "2:3", "3:4", "4:5", "5:4", "4:3", "3:2", "16:10", "16:9", "2:1", "3:1", "4:1"];
46986
+ var VECTOR_SHAPES = ["detailed", "simple"];
46987
+ var MAX_VECTOR_REFERENCES = 5;
46988
+ var VECTOR_REFERENCE_MAX_BYTES = 10 * 1024 * 1024;
46989
+ var VECTOR_SOURCE_MAX_BYTES = 50 * 1024 * 1024;
46990
+ var vectorAsset = external_exports.object({
46991
+ name: base.name,
46992
+ /** Image-to-vector takes no prompt. */
46993
+ prompt: external_exports.string().trim().max(4e3).default(""),
46994
+ type: external_exports.literal("vector"),
46995
+ model: external_exports.literal("fal/ideogram-vector").default("fal/ideogram-vector"),
46996
+ workflow: external_exports.enum(VECTOR_WORKFLOWS).default("text-to-vector"),
46997
+ /** Text-to-vector; defaults to medium. */
46998
+ quality: external_exports.enum(VECTOR_QUALITIES).optional(),
46999
+ /** Text-to-vector; defaults to auto. */
47000
+ aspectRatio: external_exports.enum(VECTOR_ASPECT_RATIOS).optional(),
47001
+ /** Text-to-vector: saved images or sprite art to follow, the first being the main one. */
47002
+ references: external_exports.array(blob).max(MAX_VECTOR_REFERENCES).default([]),
47003
+ /** Image-to-vector: the saved image or sprite art to trace. */
47004
+ source: blob.optional(),
47005
+ /** Image-to-vector; defaults to detailed. */
47006
+ shapes: external_exports.enum(VECTOR_SHAPES).optional(),
47007
+ /** Text-to-vector. */
47008
+ seed: external_exports.number().int().min(0).max(2 ** 32 - 1).optional()
47009
+ });
47010
+ function vectorChecks(p, ctx) {
47011
+ const issue2 = (path, message) => ctx.addIssue({ code: "custom", message, path: [path] });
47012
+ if (new Set(p.references).size !== p.references.length) issue2("references", "Choose each reference image once.");
47013
+ if (p.workflow === "text-to-vector") {
47014
+ if (p.prompt.length < 3) issue2("prompt", "Describe the vector in at least 3 characters.");
47015
+ if (p.source) issue2("source", "Only image-to-vector traces a saved image; use references to guide text-to-vector.");
47016
+ if (p.shapes) issue2("shapes", "Only image-to-vector takes shapes.");
47017
+ return;
47018
+ }
47019
+ if (p.prompt) issue2("prompt", "image-to-vector takes no prompt; leave it out.");
47020
+ if (!p.source) issue2("source", "Choose a saved image or sprite to trace.");
47021
+ for (const field of ["quality", "aspectRatio", "seed"]) if (p[field] !== void 0) issue2(field, `image-to-vector doesn't take ${field === "aspectRatio" ? "an aspect ratio" : field === "seed" ? "a seed" : "a quality"}; the SVG follows the image.`);
47022
+ if (p.references.length) issue2("references", "image-to-vector traces its source alone and takes no references.");
47023
+ }
47024
+ var vectorDefaults = (p) => p.workflow === "text-to-vector" ? { ...p, quality: p.quality ?? "medium", aspectRatio: p.aspectRatio ?? "auto" } : { ...p, shapes: p.shapes ?? "detailed" };
46979
47025
  var assetRequest = external_exports.discriminatedUnion("type", [
46980
47026
  imageAsset.superRefine(imageChecks),
46981
47027
  videoAsset.superRefine(videoChecks).transform(videoDefaults),
@@ -46984,7 +47030,8 @@ var assetRequest = external_exports.discriminatedUnion("type", [
46984
47030
  if (p.voice && (isElevenLabsSpeech(p.model) ? !/^[a-zA-Z0-9]{20}$/.test(p.voice) : !SPEECH_VOICES.includes(p.voice))) ctx.addIssue({ code: "custom", message: "Choose a voice supported by the speech provider.", path: ["voice"] });
46985
47031
  if (isElevenLabsSpeech(p.model) && p.delivery) ctx.addIssue({ code: "custom", message: "ElevenLabs uses audio tags in the transcript; separate delivery instructions are only supported by Gemini.", path: ["delivery"] });
46986
47032
  }).transform((p) => ({ ...p, voice: p.voice || (isElevenLabsSpeech(p.model) ? DEFAULT_ELEVENLABS_VOICE : "Kore") })),
46987
- external_exports.object({ ...base, type: external_exports.literal("sfx"), model: external_exports.literal("eleven_text_to_sound_v2").default("eleven_text_to_sound_v2"), seconds: external_exports.number().min(0.5).max(30).default(5), loop: external_exports.boolean().default(false), promptInfluence: external_exports.number().min(0).max(1).default(0.3) })
47033
+ external_exports.object({ ...base, type: external_exports.literal("sfx"), model: external_exports.literal("eleven_text_to_sound_v2").default("eleven_text_to_sound_v2"), seconds: external_exports.number().min(0.5).max(30).default(5), loop: external_exports.boolean().default(false), promptInfluence: external_exports.number().min(0).max(1).default(0.3) }),
47034
+ vectorAsset.superRefine(vectorChecks).transform(vectorDefaults)
46988
47035
  ]);
46989
47036
  var assetGenerationRequest = external_exports.discriminatedUnion("type", [imageAsset.omit({ model: true }).superRefine(imageChecks), ...assetRequest.options.slice(1)]);
46990
47037
  var LIBRARY_AUDIO_MODEL = "library-audio";
@@ -46994,6 +47041,7 @@ var uploadedMedia = external_exports.object({ name: base.name, prompt: external_
46994
47041
  var savedAssetOptions = external_exports.union([assetRequest, libraryAudio, uploadedMedia]);
46995
47042
  var speechCharacters = (text2) => Array.from(text2).length;
46996
47043
  var imageEstimate = (p) => Math.ceil((130 + 40 * p.references.length) * spriteImageModel(p.model).relCost);
47044
+ var vectorEstimate = (p) => p.workflow === "image-to-vector" ? 100 : p.quality === "low" ? 130 : p.quality === "high" ? p.references.length ? 320 : 200 : 160;
46997
47045
  function videoExtras(p, facts = {}) {
46998
47046
  const w = p.workflow;
46999
47047
  if (w !== "reference-to-video" && w !== "insert-video" && w !== "3d-to-video") return {};
@@ -47005,6 +47053,7 @@ function videoExtras(p, facts = {}) {
47005
47053
  }
47006
47054
  function assetEstimate(p, now = Date.now()) {
47007
47055
  if (p.type === "image") return imageEstimate(p);
47056
+ if (p.type === "vector") return vectorEstimate(p);
47008
47057
  if (p.type === "video") return spriteVideoEstimate(spriteVideoModel(p.model), p, videoExtras(p));
47009
47058
  if (p.type === "music") return 80;
47010
47059
  const units = (credits) => Math.max(1, Math.ceil(credits * UNITS_PER_CREDIT));
@@ -48239,7 +48288,8 @@ var extras = {
48239
48288
  ],
48240
48289
  music: [],
48241
48290
  speech: ["voice", "delivery"],
48242
- sfx: ["seconds", "loop", "prompt-influence"]
48291
+ sfx: ["seconds", "loop", "prompt-influence"],
48292
+ vector: ["workflow", "references", "source", "aspect-ratio", "quality", "shapes", "seed"]
48243
48293
  };
48244
48294
  var list2 = (items) => items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
48245
48295
  function accepted(type) {
@@ -48276,7 +48326,7 @@ function assetFromFlags(flags) {
48276
48326
  const value = flags[name2];
48277
48327
  if (value !== void 0 && value !== false && !extras[flags.type]?.includes(name2)) throw new Error(`--${name2} is not an option for ${flags.type} assets. ${accepted(flags.type)}`.trim());
48278
48328
  }
48279
- if (flags.type !== "video" && flags.prompt === void 0) throw new Error(`--prompt is required for ${flags.type} assets.`);
48329
+ if (flags.type !== "video" && flags.type !== "vector" && flags.prompt === void 0) throw new Error(`--prompt is required for ${flags.type} assets.`);
48280
48330
  const base2 = { type: flags.type, name: flags.name, prompt: flags.prompt, model: flags.model };
48281
48331
  if (flags.type === "image") return parseAsset({ ...base2, aspectRatio: flags["aspect-ratio"], transparent: flags.transparent, references: shas(flags.references), mask: regions(flags.mask) });
48282
48332
  if (flags.type === "video") {
@@ -48314,6 +48364,7 @@ function assetFromFlags(flags) {
48314
48364
  });
48315
48365
  }
48316
48366
  if (flags.type === "sfx") return parseAsset({ ...base2, seconds: flags.seconds, loop: flags.loop, promptInfluence: flags["prompt-influence"] });
48367
+ if (flags.type === "vector") return parseAsset({ ...base2, workflow: flags.workflow, quality: flags.quality, aspectRatio: flags["aspect-ratio"], references: shas(flags.references), source: flags.source, shapes: flags.shapes, seed: flags.seed });
48317
48368
  return parseAsset({ ...base2, ...flags.type === "speech" ? { voice: flags.voice, delivery: flags.delivery } : {} });
48318
48369
  }
48319
48370
  async function assetFromFile(file2, model) {
@@ -48409,6 +48460,7 @@ var MIME_EXTENSIONS = {
48409
48460
  "image/png": "png",
48410
48461
  "image/jpeg": "jpg",
48411
48462
  "image/webp": "webp",
48463
+ "image/svg+xml": "svg",
48412
48464
  "video/mp4": "mp4",
48413
48465
  "video/webm": "webm"
48414
48466
  };
@@ -50292,7 +50344,7 @@ async function curl(options, io) {
50292
50344
  }
50293
50345
 
50294
50346
  // cli/src/commands.ts
50295
- var assetModelIds = [...SPRITE_VIDEO_MODEL_IDS, ...MUSIC_MODELS.map((m) => m.id), ...SPEECH_MODELS.map((m) => m.id), ...SFX_MODELS.map((m) => m.id)];
50347
+ var assetModelIds = [...SPRITE_VIDEO_MODEL_IDS, ...MUSIC_MODELS.map((m) => m.id), ...SPEECH_MODELS.map((m) => m.id), ...SFX_MODELS.map((m) => m.id), ...VECTOR_MODELS.map((m) => m.id)];
50296
50348
  var assetAuthorizationFlags = [
50297
50349
  { name: "yes", type: "boolean", description: "Authorize spending credits" },
50298
50350
  { name: "dry-run", type: "boolean", description: "Validate and estimate locally without submitting a paid job" }
@@ -50304,13 +50356,14 @@ var assetWaitFlags = [
50304
50356
  var catalogSections = {
50305
50357
  sprites: ["styles", "kinds", "actions", "videoModels"],
50306
50358
  image: ["assetTypes"],
50359
+ vector: ["assetTypes", "vectorModels", "vectorOptions"],
50307
50360
  video: ["assetTypes", "videoModels", "videoProfiles"],
50308
50361
  music: ["assetTypes", "musicModels"],
50309
50362
  speech: ["assetTypes", "speechModels", "voices", "elevenLabsVoices"],
50310
50363
  sfx: ["assetTypes", "sfxModels"],
50311
50364
  avatars: ["avatarTypes", "avatarLookTypes"]
50312
50365
  };
50313
- var catalog = () => ({ styles: SPRITE_STYLES, kinds: SPRITE_KINDS, actions: ALL_SPRITE_ACTIONS, videoModels: SPRITE_VIDEO_MODEL_IDS, assetTypes: ASSET_TYPES, musicModels: MUSIC_MODELS.map((m) => m.id), speechModels: SPEECH_MODELS.map((m) => m.id), sfxModels: SFX_MODELS.map((m) => m.id), elevenLabsVoices: ELEVENLABS_VOICES, voices: SPEECH_VOICES, videoProfiles: SPRITE_VIDEO_MODELS, avatarTypes: AVATAR_CREATE_TYPES, avatarLookTypes: AVATAR_LOOK_TYPES });
50366
+ var catalog = () => ({ styles: SPRITE_STYLES, kinds: SPRITE_KINDS, actions: ALL_SPRITE_ACTIONS, videoModels: SPRITE_VIDEO_MODEL_IDS, assetTypes: ASSET_TYPES, musicModels: MUSIC_MODELS.map((m) => m.id), speechModels: SPEECH_MODELS.map((m) => m.id), sfxModels: SFX_MODELS.map((m) => m.id), vectorModels: VECTOR_MODELS.map((m) => m.id), vectorOptions: { workflows: VECTOR_WORKFLOWS, qualities: VECTOR_QUALITIES, aspectRatios: VECTOR_ASPECT_RATIOS, shapes: VECTOR_SHAPES, maxReferences: MAX_VECTOR_REFERENCES }, elevenLabsVoices: ELEVENLABS_VOICES, voices: SPEECH_VOICES, videoProfiles: SPRITE_VIDEO_MODELS, avatarTypes: AVATAR_CREATE_TYPES, avatarLookTypes: AVATAR_LOOK_TYPES });
50314
50367
  var modelFlags = [
50315
50368
  { name: "video-model", type: "string", choices: SPRITE_VIDEO_MODEL_IDS, description: "Video model override for this operation" }
50316
50369
  ];
@@ -50539,7 +50592,7 @@ Confirm code: ${device.user_code}`);
50539
50592
  })))).add(defineCommand("credits", { description: "Show the selected workspace's remaining credit balance" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).action(async ({ flags, stdout, signal }) => {
50540
50593
  const client = new Client(flags.url, signal);
50541
50594
  out(stdout, await client.request(`/api/v1/credits${await workspaceQuery(client)}`));
50542
- }))).add(defineCommand("catalog", { description: "List styles, kinds, actions, asset types, models, video settings, speech voices and avatar types" }, (command) => command.flags({ name: "type", type: "string", choices: Object.keys(catalogSections), description: "Only one part: sprites, image, video, music, speech, sfx or avatars" }).action(({ flags, stdout }) => {
50595
+ }))).add(defineCommand("catalog", { description: "List styles, kinds, actions, asset types, models, video settings, speech voices and avatar types" }, (command) => command.flags({ name: "type", type: "string", choices: Object.keys(catalogSections), description: "Only one part: sprites, image, vector, video, music, speech, sfx or avatars" }).action(({ flags, stdout }) => {
50543
50596
  const full = catalog();
50544
50597
  const section = flags.type;
50545
50598
  out(stdout, section ? Object.fromEntries(catalogSections[section].map((key) => [key, full[key]])) : full);
@@ -50606,21 +50659,21 @@ Confirm code: ${device.user_code}`);
50606
50659
  ).action(async ({ args, flags, stdout, signal }) => out(stdout, await moveWork(new Client(flags.url, signal), args.id, flags))))).add(defineCommand("delete", { description: "Delete a project; requires --yes" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).args(id).flags({ name: "yes", type: "boolean", required: true }).action(async ({ args, flags, stdout, signal }) => {
50607
50660
  if (!flags.yes) throw new Error("Pass --yes to delete this project.");
50608
50661
  out(stdout, await new Client(flags.url, signal).request(`/api/v1/projects/${encodeURIComponent(args.id)}`, { method: "DELETE" }));
50609
- })))).command("assets", (assets) => assets.add(defineCommand("list", { description: "List saved assets, their blob hashes, and configured generation models" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }).args(id).action(async ({ args, flags, stdout, signal }) => out(stdout, await new Client(flags.url, signal).request(`${projectPath(args.id)}/assets`))))).add(defineCommand("generate", { description: "Generate an image, video, music, speech or sound-effect asset; --yes authorizes credits" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }).args(id).flags(
50662
+ })))).command("assets", (assets) => assets.add(defineCommand("list", { description: "List saved assets, their blob hashes, and configured generation models" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }).args(id).action(async ({ args, flags, stdout, signal }) => out(stdout, await new Client(flags.url, signal).request(`${projectPath(args.id)}/assets`))))).add(defineCommand("generate", { description: "Generate an image, vector (SVG), video, music, speech or sound-effect asset; --yes authorizes credits" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" } }).args(id).flags(
50610
50663
  { name: "type", type: "string", required: true, choices: ASSET_TYPES },
50611
50664
  { name: "name", type: "string", required: true },
50612
- { name: "prompt", type: "string", description: "Description to generate, or verbatim transcript for speech; video lip-sync and relight take none, and camera-controls, insert-video, recast and 3d-to-video may leave it out" },
50665
+ { name: "prompt", type: "string", description: "Description to generate, or verbatim transcript for speech; video lip-sync and relight and vector image-to-vector take none, and camera-controls, insert-video, recast and 3d-to-video may leave it out" },
50613
50666
  { name: "model", type: "string", choices: assetModelIds, description: "Model for the chosen asset type" },
50614
- { name: "aspect-ratio", type: "string", choices: [.../* @__PURE__ */ new Set([...IMAGE_ASPECT_RATIOS, "auto", ...VIDEO_ASPECT_RATIOS])], description: "Image: 1:1 (default), 3:2, 2:3, 16:9 or 9:16; a masked edit keeps Image 1's shape. Video from text or a style without --reference: 21:9\u20139:16 (default 16:9). Extension, reference-to-video, relight and keyframes-to-video: auto (default, the inputs' shape) or a ratio to crop to" },
50667
+ { name: "aspect-ratio", type: "string", choices: [.../* @__PURE__ */ new Set([...IMAGE_ASPECT_RATIOS, "auto", ...VIDEO_ASPECT_RATIOS, ...VECTOR_ASPECT_RATIOS])], description: "Image: 1:1 (default), 3:2, 2:3, 16:9 or 9:16; a masked edit keeps Image 1's shape. Video from text or a style without --reference: 21:9\u20139:16 (default 16:9). Extension, reference-to-video, relight and keyframes-to-video: auto (default, the inputs' shape) or a ratio to crop to. Vector from text: auto (default, the model's choice) or 1:4\u20134:1" },
50615
50668
  { name: "transparent", type: "boolean", description: "Image: transparent background" },
50616
- { name: "references", type: "string", description: "Comma-separated blob SHAs of this project's images or sprite art, named Image 1, Image 2\u2026 in the prompt. Image: up to 4 to follow, such as a character sheet. Video: subjects or looks to keep (reference-to-video and insert-video up to 9, 3d-to-video up to 8), or recast's new people, one photo each (up to 4)" },
50669
+ { name: "references", type: "string", description: "Comma-separated blob SHAs of this project's images or sprite art, named Image 1, Image 2\u2026 in the prompt. Image: up to 4 to follow, such as a character sheet. Vector from text: up to 5, the first leading. Video: subjects or looks to keep (reference-to-video and insert-video up to 9, 3d-to-video up to 8), or recast's new people, one photo each (up to 4)" },
50617
50670
  { name: "mask", type: "string", description: "Image: edit only these rectangles of Image 1 (the first --references image), as x,y,width,height fractions of its size from the top left, up to 8 separated by semicolons; the rest is kept" },
50618
- { name: "workflow", type: "string", choices: VIDEO_WORKFLOWS, description: "Video: image-to-video (default), text-to-video or extend-video; H3 Max also reference-to-video, lip-sync, camera-controls, insert-video, recast, relight, 3d-to-video and style, FLUX 3 Max keyframes-to-video, as the model's videoProfiles allow" },
50671
+ { name: "workflow", type: "string", choices: [...VIDEO_WORKFLOWS, ...VECTOR_WORKFLOWS], description: "Video: image-to-video (default), text-to-video or extend-video; H3 Max also reference-to-video, lip-sync, camera-controls, insert-video, recast, relight, 3d-to-video and style, FLUX 3 Max keyframes-to-video, as the model's videoProfiles allow. Vector: text-to-vector (default) or image-to-vector, which traces --source" },
50619
50672
  { name: "reference", type: "string", description: "Video: starting image blob SHA from this project's assets or sprites; the speaking face for lip-sync, the image the camera moves around for camera-controls" },
50620
50673
  { name: "end-reference", type: "string", description: "Video: ending image blob SHA; models with endFrameOnly accept it without --reference" },
50621
50674
  { name: "middle-reference", type: "string", description: "Reference-to-video: image blob SHA reached at --middle-seconds; needs --reference and --end-reference, 480p or 768p" },
50622
50675
  { name: "middle-seconds", type: "number", description: "Reference-to-video: when the middle image is reached, in seconds" },
50623
- { name: "source", type: "string", description: "Blob SHA of a video asset in this project: the clip to extend, insert a scene into, recast or relight, or the 3D render 3d-to-video turns into footage" },
50676
+ { name: "source", type: "string", description: "Blob SHA of a video asset in this project: the clip to extend, insert a scene into, recast or relight, or the 3D render 3d-to-video turns into footage. Vector image-to-vector: the image or sprite art to trace" },
50624
50677
  { name: "soundtrack", type: "string", description: "Video from an image or text: blob SHA of a music, speech or SFX asset pinned as the soundtrack (2 s or longer). Lip-sync: the speech to say (5 s\u201315 min), which sets the length" },
50625
50678
  { name: "audio-references", type: "string", description: "Extension or reference-to-video: up to 3 comma-separated audio asset blob SHAs (2\u201315 s each, 15 s total) guiding the sound; refer to them as Audio 1, Audio 2\u2026 in the prompt" },
50626
50679
  { name: "reference-videos", type: "string", description: "Reference-to-video or insert-video: up to 3 comma-separated video asset blob SHAs (2\u201315 s each, 15 s total) guiding motion, named Video 1, Video 2\u2026 in the prompt" },
@@ -50640,12 +50693,14 @@ Confirm code: ${device.user_code}`);
50640
50693
  { name: "resolution", type: "string", choices: VIDEO_RESOLUTIONS, description: "Video resolution supported by the selected model and workflow (default its first)" },
50641
50694
  { name: "mode", type: "string", choices: ["cost", "speed", "quality"], description: "Video generation mode supported by the selected model (default its first)" },
50642
50695
  { name: "prompt-expansion", type: "string", choices: PROMPT_EXPANSION_MODES, description: "Video: how much the model may rewrite the prompt (default disabled); extensions treat any other value as on. FLUX 3 Max always reasons about the prompt: quality thoroughly, any other value lightly" },
50643
- { name: "seed", type: "number", description: "Video: seed for repeatable results, where the model takes one" },
50696
+ { name: "seed", type: "number", description: "Video or vector from text: seed for repeatable results, where the model takes one" },
50644
50697
  { name: "extend-output", type: "string", choices: EXTEND_OUTPUTS, description: "Extension: extended (default, source plus new footage) or continuation (a 1.625 s overlap, then the new footage)" },
50645
50698
  { name: "voice", type: "string", description: "Gemini voice name or ElevenLabs voice ID (defaults: Kore / George)" },
50646
50699
  { name: "delivery", type: "string", description: "Gemini speech delivery instructions; ElevenLabs uses audio tags in the transcript" },
50647
50700
  { name: "loop", type: "boolean", description: "SFX: create a seamless loop" },
50648
50701
  { name: "prompt-influence", type: "number", description: "SFX: prompt adherence, 0\u20131 (default 0.3)" },
50702
+ { name: "quality", type: "string", choices: VECTOR_QUALITIES, description: "Vector from text: low (fastest, 13 credits), medium (default, 16) or high (20, or 32 with --references)" },
50703
+ { name: "shapes", type: "string", choices: VECTOR_SHAPES, description: "Vector image-to-vector (10 credits): detailed (default) keeps fine detail, simple uses fewer, simpler shapes" },
50649
50704
  ...assetAuthorizationFlags,
50650
50705
  ...assetWaitFlags,
50651
50706
  { name: "download", type: "string", description: "Wait, then save the result to this file, or into this folder (existing, or ending in /) as NAME.EXT; never overwrites" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docyrus/animaguru",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "Agent-friendly AnimaGuru CLI built with CrustJS",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@ Before creative work in a multi-animation workspace, select or create the intend
17
17
 
18
18
  Share the [animation-style gallery](https://animaguru.com/animation-styles), or open that URL in the internal browser when available, while asking the user to choose a style. Read the installed `animaguru.templates.json` for available styles and their skill names; `animaguru templates` lists the current CLI catalog. Offer the local catalog's choices so users select a skill actually installed in their workspace. Templates define appearance and motion; derive audience, message and story from the brief. Local scaffolding is separate from `projects create`, which creates a remote asset project. Never run `create` over an existing workspace.
19
19
 
20
- - Run `animaguru schema` for the installed command structure and flags, and `animaguru catalog` for styles, subject kinds, actions, asset types, models, video settings, and speech voices; `catalog --type music|speech|sfx|image|video|sprites|avatars` prints one section. Use `--help` on a leaf command for details.
20
+ - Run `animaguru schema` for the installed command structure and flags, and `animaguru catalog` for styles, subject kinds, actions, asset types, models, video settings, and speech voices; `catalog --type music|speech|sfx|image|vector|video|sprites|avatars` prints one section. Use `--help` on a leaf command for details.
21
21
  - Check `animaguru auth status`, `animaguru workspaces list`, and `animaguru credits` before generation. If sign-in is needed, run `animaguru auth login --no-browser` and let the user approve the printed browser URL. Select the user's requested Personal or team workspace with `animaguru workspaces use personal|TEAM_ID` before listing or creating projects. Do not read or print stored credentials. Agent runners may inject `ANIMAGURU_TOKEN` and `ANIMAGURU_WORKSPACE` as secrets or configuration.
22
22
  - The default service is `https://animaguru.com`. Respect an existing `ANIMAGURU_URL` or pass `--url` to the leaf command when the user chooses another service. HTTP is supported only for loopback development.
23
23
 
@@ -65,6 +65,15 @@ Images use the staff-managed route automatically. Use `--model` to select a mode
65
65
 
66
66
  For ElevenLabs speech, choose `eleven_v4` or `eleven_v4_turbo`, pass an ElevenLabs voice ID with `--voice` (George is the default), and put audio tags in `--prompt` rather than using `--delivery`. For sound effects, use `--type sfx`; the only model is `eleven_text_to_sound_v2`. SFX supports `--seconds` from 0.5 to 30 (default 5), `--loop`, and `--prompt-influence` from 0 to 1 (default 0.3). These additions require CLI 0.5.0 or newer; check `catalog` before using them with an installed CLI.
67
67
 
68
+ CLI 0.27.0 adds vectors: `--type vector` draws an editable SVG with Ideogram on fal (`fal/ideogram-vector`), for logos, icons, mascots and flat illustrations that must stay sharp at any size or move shape by shape. `--workflow text-to-vector` (default) draws `--prompt` at `--quality low|medium|high` (13, 16 or 20 credits; 32 at high with references), with `--aspect-ratio auto` (default) or 1:4–4:1, `--seed`, and up to five `--references` (this project's images or sprite art, the first leading, named Image 1… in the prompt). `--workflow image-to-vector --source IMAGE_SHA` traces a saved image or sprite for 10 credits, with `--shapes detailed|simple` and no `--prompt`; to vectorize a brand logo or a generated picture, save it as an image asset first. `--download` saves `NAME.svg`. Vectors can't be used as image or video references. Check `catalog --type vector` before using them with an installed CLI.
69
+
70
+ ```sh
71
+ animaguru assets generate PROJECT_ID --type vector --name "Fox mascot" \
72
+ --prompt "A friendly fox mascot waving, flat colors, bold clean outlines" --quality medium --dry-run
73
+ animaguru assets generate PROJECT_ID --type vector --name "Logo" \
74
+ --workflow image-to-vector --source IMAGE_SHA --shapes simple --dry-run
75
+ ```
76
+
68
77
  Use `--dry-run` for local request validation and estimated credits without sign-in or paid work. The estimate does not prove the server has the provider, the credits, or the reference image. Use `--yes` only within the user's existing spending authorization. For file-based requests, write the API asset request as JSON and use `assets submit PROJECT_ID --file asset.json --yes`; `--model` overrides the file's selection for non-image assets. Image model fields in old files are ignored.
69
78
 
70
79
  Add `--download PATH` to `assets generate` or `assets submit` to wait for the job and save the result in one call; a folder PATH (existing or ending in `/`) receives `NAME.EXT`, and existing files are never overwritten. `--wait` waits without downloading; `--timeout` sets the limit (default 600 s). For several assets, such as one speech file per voiceover line, write `{"defaults": {"type": "speech", "model": "…", "voice": "…", "delivery": "…"}, "assets": [{"name": "vo-01-hook", "prompt": "…"}]}` and run `assets batch PROJECT_ID --file lines.json --dry-run`, then `--yes --download production/audio/vo/`. Every item is validated before anything is submitted. If a submission fails, the batch stops and prints the receipts already submitted; check those (and `jobs list`) instead of resubmitting. Older CLIs lack these flags: check `--help`, and otherwise use `jobs wait PROJECT_ID JOB_ID`, read the asset id from `assets list` and download with `assets download PROJECT_ID ASSET_ID --output welcome.wav`, or export the complete project ZIP.
@@ -93,6 +93,10 @@ Before choreographing a scene from scratch, search the AnimaGuru motion library
93
93
 
94
94
  Pro and Team accounts can take ready-made music, sound effects, voice-overs, clips, images and sprites from the AnimaGuru asset library instead of generating them: `animaguru library search DESCRIPTION --type music|sfx|speech|video|image|sprite` (natural language and keywords). `animaguru library download ID --output public/assets/` saves a file into the film (sprites save their sheets with `animation.json` frame rectangles and timing); `animaguru library import ID --project PROJECT_ID` adds it to an AnimaGuru project's assets instead. Prefer a library whoosh or background track over generating one; it spends no credits.
95
95
 
96
+ ## Vectors
97
+
98
+ Logos, icons and flat mascots can be generated as SVG (`animaguru assets generate PROJECT_ID --type vector …`, see the `animaguru` skill) or traced from a saved image (`--workflow image-to-vector`), then saved into the film with `--download public/assets/`. Draw one with the kit's `rasterSvg(url, width)` at the width it appears on screen, so it stays sharp; for shapes that scale or move separately, load it as geometry with `Assets.load({ src, data: { parseAsGraphicsContext: true } })` and `new Graphics(context)` (see `pixijs-assets`). Ask for `--shapes simple` or `--quality low` when the art will be small or animated part by part.
99
+
96
100
  ## Voiceover, music and captions
97
101
 
98
102
  1. Generate the lines in one call: a batch file with shared speech defaults and one `{ name, prompt }` per segment, `animaguru assets batch PROJECT_ID --file production/generation/vo-lines.json --dry-run`, then `--yes --download production/audio/vo/`. Generate music with `assets generate … --type music --yes --download production/audio/music/`. See the `animaguru` skill for authorization.
@@ -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
+ }