@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 +99 -0
- package/README.md +23 -2
- package/dist/animaguru.js +72 -17
- package/package.json +1 -1
- package/skills/animaguru/SKILL.md +10 -1
- package/skills/animaguru-video/SKILL.md +4 -0
- package/templates/video/README.md +3 -1
- package/templates/video/WORKSPACE-AGENTS.md +1 -1
- package/templates/video/scripts/export-mp4.mjs +91 -13
- package/templates/video/scripts/watermark/OFL.txt +93 -0
- package/templates/video/scripts/watermark/instrument-sans.woff2 +0 -0
- package/templates/video/scripts/watermark/logo-mark-512.png +0 -0
- package/templates/video/shared/locales/en/export.json +1 -0
- package/templates/video/src/exporter.ts +11 -1
- package/templates/video/src/watermark.ts +50 -0
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.
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
@@ -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" ?
|
|
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
|
+
}
|