@docyrus/animaguru 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -72,6 +72,21 @@ Running `init` again refreshes an existing skill with the bundled version withou
|
|
|
72
72
|
|
|
73
73
|
The default server is https://animaguru.com. Use `--url http://localhost:5190` on the leaf command or `ANIMAGURU_URL` to override it. HTTP is allowed only for loopback development. Authentication is stored separately per server in `~/.config/animaguru/credentials.json`, with file permissions 0600. Set `ANIMAGURU_CONFIG_DIR` for an isolated configuration directory. Browser login uses a ten-minute, single-use device code and receives a seven-day revocable Better Auth session. `auth login --no-browser` prints the URL for manual use. `auth logout` revokes the token. `ANIMAGURU_TOKEN` supports secret injection by agent runners; do not put tokens in arguments or commit them.
|
|
74
74
|
|
|
75
|
+
## Personal and team projects
|
|
76
|
+
|
|
77
|
+
CLI 0.7.1 can select a cloud workspace for project listing, project creation, and credit balances:
|
|
78
|
+
|
|
79
|
+
```sh
|
|
80
|
+
animaguru workspaces list
|
|
81
|
+
animaguru workspaces use team_0123456789abcdef0123456789abcdef
|
|
82
|
+
animaguru projects list
|
|
83
|
+
animaguru projects create --name "Shared cast" --style pixel
|
|
84
|
+
animaguru credits
|
|
85
|
+
animaguru workspaces use personal
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Use a team ID returned by `workspaces list`. The choice is saved per server in the existing credentials file. `workspaces list` also shows invitations; accept invitations in the web app before selecting that team. `projects get`, generation, jobs, and files use the project ID and work for any project you can access, regardless of the selected workspace. With `ANIMAGURU_TOKEN`, set `ANIMAGURU_WORKSPACE=personal` or a team ID instead of saving a selection. A cloud team is separate from the local `animaguru create workspace NAME` video folder.
|
|
89
|
+
|
|
75
90
|
## Agent workflow
|
|
76
91
|
|
|
77
92
|
```sh
|
|
@@ -162,6 +177,27 @@ animaguru assets generate PROJECT_ID --type sfx --name "Magic spell" \
|
|
|
162
177
|
|
|
163
178
|
Replace `--dry-run` with `--yes` to generate within your authorized credit budget. ElevenLabs accepts a voice ID in `--voice` and defaults to George when omitted. Use audio tags in the transcript; `--delivery` is only for Gemini and is rejected for ElevenLabs. SFX has a single model, `eleven_text_to_sound_v2`; `--seconds` accepts 0.5–30 (default 5), `--loop` requests a seamless loop, and `--prompt-influence` accepts 0–1 (default 0.3). Both return MP3. `catalog` includes `sfxModels` and `elevenLabsVoices`. The server needs `AI_PROVIDER_KEY_ELEVENLABS`; provider keys never belong in CLI credentials.
|
|
164
179
|
|
|
180
|
+
CLI 0.8.0 adds MiniMax H3 Max Turbo and the H3 workflows. MiniMax H3 Max and H3 Max Turbo on fal.ai (`fal/minimax-h3-max`, `fal/minimax-h3-max-turbo`) also write videos from text and extend saved videos. Choose with `--workflow`:
|
|
181
|
+
|
|
182
|
+
```sh
|
|
183
|
+
# End on a pose, pin saved audio as the soundtrack, and keep a repeatable seed.
|
|
184
|
+
animaguru assets generate PROJECT_ID --type video --model fal/minimax-h3-max-turbo \
|
|
185
|
+
--name "Hero lands" --prompt "The hero leaps and lands in a crouch" \
|
|
186
|
+
--reference START_IMAGE_SHA --end-reference END_IMAGE_SHA --soundtrack AUDIO_SHA \
|
|
187
|
+
--prompt-expansion balanced --seed 42 --resolution 1080p --dry-run
|
|
188
|
+
# Write a scene from text alone.
|
|
189
|
+
animaguru assets generate PROJECT_ID --type video --model fal/minimax-h3-max \
|
|
190
|
+
--workflow text-to-video --name "Harbor" --prompt "A foggy harbor at dawn" \
|
|
191
|
+
--aspect-ratio 21:9 --seconds 8 --resolution 768p --dry-run
|
|
192
|
+
# Add six seconds to a saved video at 2K, guided by saved audio.
|
|
193
|
+
animaguru assets generate PROJECT_ID --type video --model fal/minimax-h3-max \
|
|
194
|
+
--workflow extend-video --source VIDEO_SHA --name "Harbor, continued" \
|
|
195
|
+
--prompt "Audio 1 for the gulls. The fog lifts and a ship sails out" \
|
|
196
|
+
--audio-references AUDIO_SHA --seconds 6 --resolution 2k --dry-run
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`--end-reference` can be used without `--reference`; the video then ends on that image. `--soundtrack` takes a music, speech or SFX asset's `file` SHA and replaces the generated sound; the video must be at least 2 seconds. Text-to-video takes `--aspect-ratio` 21:9, 16:9 (default), 4:3, 1:1, 3:4 or 9:16. Extensions take a video asset's SHA as `--source` (1.6–60 seconds, up to 50 MB), bill `--seconds` of new footage, offer 2K, and keep the source's shape unless `--aspect-ratio` crops it. `--extend-output continuation` returns only a 1.625-second overlap and the new footage. Extensions take up to three comma-separated `--audio-references` (2–15 seconds each, 15 seconds in total); refer to them in the prompt as Audio 1, Audio 2 and so on. `--prompt-expansion` is `disabled` (default), `balanced` or `quality`; for extensions, any value other than `disabled` turns expansion on. Each model's `videoProfiles` entry in `catalog` lists its workflows, resolutions and options. The server needs `AI_PROVIDER_KEY_FAL`.
|
|
200
|
+
|
|
165
201
|
Images have a full background by default; `--transparent` requests a cutout. 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.
|
|
166
202
|
|
|
167
203
|
Preview a validated request and its estimated credits offline with `--dry-run`, without signing in or spending credits:
|
package/dist/animaguru.js
CHANGED
|
@@ -542,7 +542,7 @@ var init_dist = __esm({
|
|
|
542
542
|
// cli/package.json
|
|
543
543
|
var package_default = {
|
|
544
544
|
name: "@docyrus/animaguru",
|
|
545
|
-
version: "0.
|
|
545
|
+
version: "0.8.0",
|
|
546
546
|
description: "Agent-friendly AnimaGuru CLI built with CrustJS",
|
|
547
547
|
type: "module",
|
|
548
548
|
bin: {
|
|
@@ -6111,11 +6111,20 @@ var SPRITE_IMAGE_MODEL_IDS = SPRITE_IMAGE_MODELS.map((m) => m.id);
|
|
|
6111
6111
|
function spriteImageModel(id2) {
|
|
6112
6112
|
return SPRITE_IMAGE_MODELS.find((m) => m.id === id2) ?? SPRITE_IMAGE_MODELS[0];
|
|
6113
6113
|
}
|
|
6114
|
+
var VIDEO_WORKFLOWS = ["image-to-video", "text-to-video", "extend-video"];
|
|
6115
|
+
var VIDEO_RESOLUTIONS = ["480p", "768p", "1080p", "2k"];
|
|
6116
|
+
var VIDEO_ASPECT_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
|
|
6117
|
+
var PROMPT_EXPANSION_MODES = ["disabled", "balanced", "quality"];
|
|
6118
|
+
var EXTEND_OUTPUTS = ["extended", "continuation"];
|
|
6119
|
+
var SOUNDTRACK_MAX_BYTES = 15 * 1024 * 1024;
|
|
6120
|
+
var EXTEND_SOURCE_MAX_BYTES = 50 * 1024 * 1024;
|
|
6121
|
+
var MAX_AUDIO_REFERENCES = 3;
|
|
6114
6122
|
var SPRITE_VIDEO_MODELS = [
|
|
6115
6123
|
{
|
|
6116
6124
|
id: "pruna/p-video-2-pro",
|
|
6117
6125
|
label: "Pruna P-Video 2 Pro",
|
|
6118
6126
|
provider: "pruna",
|
|
6127
|
+
workflows: ["image-to-video"],
|
|
6119
6128
|
resolutions: ["480p", "768p"],
|
|
6120
6129
|
modes: ["cost", "speed", "quality"],
|
|
6121
6130
|
lastFrame: true,
|
|
@@ -6126,6 +6135,7 @@ var SPRITE_VIDEO_MODELS = [
|
|
|
6126
6135
|
id: "heygen/heygen-video-1",
|
|
6127
6136
|
label: "HeyGen Video 1",
|
|
6128
6137
|
provider: "heygen",
|
|
6138
|
+
workflows: ["image-to-video"],
|
|
6129
6139
|
resolutions: ["480p", "768p"],
|
|
6130
6140
|
modes: ["cost"],
|
|
6131
6141
|
lastFrame: false,
|
|
@@ -6136,13 +6146,40 @@ var SPRITE_VIDEO_MODELS = [
|
|
|
6136
6146
|
id: "fal/minimax-h3-max",
|
|
6137
6147
|
label: "MiniMax H3 Max (fal.ai)",
|
|
6138
6148
|
provider: "fal",
|
|
6139
|
-
|
|
6149
|
+
workflows: VIDEO_WORKFLOWS,
|
|
6150
|
+
endpoints: { "image-to-video": "minimax/h3-max/image-to-video", "text-to-video": "minimax/h3-max/text-to-video", "extend-video": "minimax/h3-max/extend-video" },
|
|
6140
6151
|
resolutions: ["480p", "768p", "1080p"],
|
|
6152
|
+
extendResolutions: VIDEO_RESOLUTIONS,
|
|
6141
6153
|
modes: ["quality"],
|
|
6142
6154
|
lastFrame: true,
|
|
6143
|
-
|
|
6144
|
-
|
|
6145
|
-
|
|
6155
|
+
endFrameOnly: true,
|
|
6156
|
+
seed: true,
|
|
6157
|
+
promptExpansion: PROMPT_EXPANSION_MODES,
|
|
6158
|
+
audio: true,
|
|
6159
|
+
// Regular rates, not the temporary promotion ending 2026-10-15; the same for every workflow,
|
|
6160
|
+
// 2K extensions only. Extensions bill reference input beyond 4,096 tokens a request too
|
|
6161
|
+
// ($0.02 per 1,000), which fal doesn't report back, so it isn't charged.
|
|
6162
|
+
// https://fal.ai/models/minimax/h3-max/image-to-video, …/text-to-video, …/extend-video
|
|
6163
|
+
usdPerSecond: { "480p": { quality: 0.05 }, "768p": { quality: 0.08 }, "1080p": { quality: 0.16 }, "2k": { quality: 0.32 } },
|
|
6164
|
+
seconds: { min: 1, max: 15 }
|
|
6165
|
+
},
|
|
6166
|
+
{
|
|
6167
|
+
id: "fal/minimax-h3-max-turbo",
|
|
6168
|
+
label: "MiniMax H3 Max Turbo (fal.ai)",
|
|
6169
|
+
provider: "fal",
|
|
6170
|
+
workflows: VIDEO_WORKFLOWS,
|
|
6171
|
+
endpoints: { "image-to-video": "minimax/h3-max-turbo/image-to-video", "text-to-video": "minimax/h3-max-turbo/text-to-video", "extend-video": "minimax/h3-max-turbo/extend-video" },
|
|
6172
|
+
resolutions: ["480p", "768p", "1080p"],
|
|
6173
|
+
extendResolutions: VIDEO_RESOLUTIONS,
|
|
6174
|
+
modes: ["speed"],
|
|
6175
|
+
lastFrame: true,
|
|
6176
|
+
endFrameOnly: true,
|
|
6177
|
+
seed: true,
|
|
6178
|
+
promptExpansion: PROMPT_EXPANSION_MODES,
|
|
6179
|
+
audio: true,
|
|
6180
|
+
// Regular rates, not the temporary promotion ending 2026-10-15; extensions bill only the new footage.
|
|
6181
|
+
// https://fal.ai/models/minimax/h3-max-turbo/image-to-video, …/text-to-video, …/extend-video
|
|
6182
|
+
usdPerSecond: { "480p": { speed: 0.025 }, "768p": { speed: 0.04 }, "1080p": { speed: 0.08 }, "2k": { speed: 0.16 } },
|
|
6146
6183
|
seconds: { min: 1, max: 15 }
|
|
6147
6184
|
}
|
|
6148
6185
|
];
|
|
@@ -6151,6 +6188,9 @@ var DEFAULT_SPRITE_VIDEO_MODEL = "pruna/p-video-2-pro";
|
|
|
6151
6188
|
function spriteVideoModel(id2) {
|
|
6152
6189
|
return SPRITE_VIDEO_MODELS.find((m) => m.id === id2) ?? SPRITE_VIDEO_MODELS[0];
|
|
6153
6190
|
}
|
|
6191
|
+
function videoResolutions(model, workflow = "image-to-video") {
|
|
6192
|
+
return workflow === "extend-video" ? model.extendResolutions ?? model.resolutions : model.resolutions;
|
|
6193
|
+
}
|
|
6154
6194
|
function spriteVideoCostUsd(model, p) {
|
|
6155
6195
|
const byMode = model.usdPerSecond[p.resolution] ?? model.usdPerSecond[model.resolutions[0]];
|
|
6156
6196
|
const perSecond = byMode[p.mode] ?? byMode[model.modes[0]] ?? 0;
|
|
@@ -25871,6 +25911,22 @@ async function writeCredentials(credentials) {
|
|
|
25871
25911
|
await chmod2(temp, 384);
|
|
25872
25912
|
await rename2(temp, path);
|
|
25873
25913
|
}
|
|
25914
|
+
async function selectedWorkspace(origin) {
|
|
25915
|
+
const saved = process.env.ANIMAGURU_TOKEN ? void 0 : (await readCredentials())[origin]?.workspaceId;
|
|
25916
|
+
const value = process.env.ANIMAGURU_WORKSPACE ?? saved ?? "me";
|
|
25917
|
+
if (value === "personal" || value === "me") return "me";
|
|
25918
|
+
if (!/^team_[a-f0-9]{32}$/.test(value)) throw new Error("ANIMAGURU_WORKSPACE must be 'personal' or a team ID.");
|
|
25919
|
+
return value;
|
|
25920
|
+
}
|
|
25921
|
+
async function saveWorkspace(origin, workspaceId) {
|
|
25922
|
+
if (process.env.ANIMAGURU_TOKEN) throw new Error("With ANIMAGURU_TOKEN, set ANIMAGURU_WORKSPACE instead of saving a workspace.");
|
|
25923
|
+
if (process.env.ANIMAGURU_WORKSPACE) throw new Error("Unset ANIMAGURU_WORKSPACE before changing the saved workspace.");
|
|
25924
|
+
const credentials = await readCredentials();
|
|
25925
|
+
const saved = credentials[origin];
|
|
25926
|
+
if (!saved || saved.expiresAt <= Date.now()) throw new Error("Sign in with animaguru auth login first.");
|
|
25927
|
+
credentials[origin] = { ...saved, workspaceId };
|
|
25928
|
+
await writeCredentials(credentials);
|
|
25929
|
+
}
|
|
25874
25930
|
var ServiceError = class extends Error {
|
|
25875
25931
|
constructor(status, code, message) {
|
|
25876
25932
|
super(message);
|
|
@@ -26795,7 +26851,7 @@ var VIDEO_WORKFLOW_IDS = [
|
|
|
26795
26851
|
"ambient-loop",
|
|
26796
26852
|
"visual-essay"
|
|
26797
26853
|
];
|
|
26798
|
-
var
|
|
26854
|
+
var VIDEO_WORKFLOWS2 = VIDEO_WORKFLOW_IDS.map((id2, index) => ({
|
|
26799
26855
|
id: id2,
|
|
26800
26856
|
category: index < 8 ? "advertisement" : "general-video",
|
|
26801
26857
|
name: animation_styles_default.workflows[id2].name,
|
|
@@ -26824,10 +26880,10 @@ function videoTemplateCatalog() {
|
|
|
26824
26880
|
name: animation_styles_default.categories[id2].name,
|
|
26825
26881
|
description: animation_styles_default.categories[id2].description,
|
|
26826
26882
|
templates: VIDEO_TEMPLATES.filter((template) => template.categories.some((category) => category === id2)),
|
|
26827
|
-
workflows:
|
|
26883
|
+
workflows: VIDEO_WORKFLOWS2.filter((workflow) => workflow.category === id2)
|
|
26828
26884
|
})),
|
|
26829
26885
|
templates: VIDEO_TEMPLATES,
|
|
26830
|
-
workflows:
|
|
26886
|
+
workflows: VIDEO_WORKFLOWS2
|
|
26831
26887
|
};
|
|
26832
26888
|
}
|
|
26833
26889
|
|
|
@@ -27060,14 +27116,59 @@ var isElevenLabsSpeech = (model) => ELEVENLABS_SPEECH_MODEL_IDS.includes(model);
|
|
|
27060
27116
|
var MUSIC_MODELS = [{ id: "lyria-3.5", label: "Lyria 3.5" }];
|
|
27061
27117
|
var SPEECH_VOICES = ["Kore", "Puck", "Charon", "Fenrir", "Aoede", "Leda", "Orus", "Zephyr"];
|
|
27062
27118
|
var base = { name: external_exports.string().trim().min(1).max(80), prompt: external_exports.string().trim().min(3).max(4e3) };
|
|
27119
|
+
var blob = external_exports.string().regex(/^[a-f0-9]{64}$/);
|
|
27120
|
+
var videoAsset = external_exports.object({
|
|
27121
|
+
...base,
|
|
27122
|
+
type: external_exports.literal("video"),
|
|
27123
|
+
model: external_exports.enum(SPRITE_VIDEO_MODEL_IDS).default(DEFAULT_SPRITE_VIDEO_MODEL),
|
|
27124
|
+
workflow: external_exports.enum(VIDEO_WORKFLOWS).default("image-to-video"),
|
|
27125
|
+
/** First frame. */
|
|
27126
|
+
reference: blob.optional(),
|
|
27127
|
+
/** Last frame. */
|
|
27128
|
+
endReference: blob.optional(),
|
|
27129
|
+
/** The saved clip to extend. */
|
|
27130
|
+
source: blob.optional(),
|
|
27131
|
+
/** Saved audio pinned as the soundtrack (at least two seconds of video). */
|
|
27132
|
+
soundtrack: blob.optional(),
|
|
27133
|
+
/** Saved audio guiding an extension's new sound; name each clip's role in the prompt. */
|
|
27134
|
+
audioReferences: external_exports.array(blob).max(MAX_AUDIO_REFERENCES).default([]),
|
|
27135
|
+
seconds: external_exports.number().int().min(1).max(15).default(5),
|
|
27136
|
+
resolution: external_exports.enum(VIDEO_RESOLUTIONS).default("480p"),
|
|
27137
|
+
mode: external_exports.enum(["cost", "speed", "quality"]).default("cost"),
|
|
27138
|
+
/** A clip written from text (default 16:9), or an extension cropped to a new canvas ("auto" keeps the source's). */
|
|
27139
|
+
aspectRatio: external_exports.enum(["auto", ...VIDEO_ASPECT_RATIOS]).optional(),
|
|
27140
|
+
promptExpansion: external_exports.enum(PROMPT_EXPANSION_MODES).default("disabled"),
|
|
27141
|
+
seed: external_exports.number().int().min(0).max(2 ** 32 - 1).optional(),
|
|
27142
|
+
/** An extension returns the whole clip, or only the new footage after a short overlap. */
|
|
27143
|
+
extendOutput: external_exports.enum(EXTEND_OUTPUTS).default("extended")
|
|
27144
|
+
}).superRefine((p, ctx) => {
|
|
27145
|
+
const model = spriteVideoModel(p.model);
|
|
27146
|
+
const issue2 = (path, message) => ctx.addIssue({ code: "custom", message, path: [path] });
|
|
27147
|
+
if (!model.workflows.includes(p.workflow)) return issue2("workflow", "Choose a way of making video supported by the video model.");
|
|
27148
|
+
const extend2 = p.workflow === "extend-video";
|
|
27149
|
+
if (p.seconds < model.seconds.min || p.seconds > model.seconds.max) issue2("seconds", "Choose a duration supported by the video model.");
|
|
27150
|
+
if (!videoResolutions(model, p.workflow).includes(p.resolution)) issue2("resolution", "Choose a resolution supported by the video model.");
|
|
27151
|
+
if (!model.modes.includes(p.mode)) issue2("mode", "Choose a mode supported by the video model.");
|
|
27152
|
+
if (p.workflow === "image-to-video") {
|
|
27153
|
+
if (!p.reference && !(p.endReference && model.endFrameOnly)) issue2("reference", "Choose a starting image.");
|
|
27154
|
+
if (p.endReference && !model.lastFrame) issue2("endReference", "This video model can't end on a chosen image.");
|
|
27155
|
+
} else {
|
|
27156
|
+
if (p.reference) issue2("reference", "Only image-to-video starts from an image.");
|
|
27157
|
+
if (p.endReference) issue2("endReference", "Only image-to-video ends on an image.");
|
|
27158
|
+
}
|
|
27159
|
+
if (extend2 !== Boolean(p.source)) issue2("source", extend2 ? "Choose a saved video to extend." : "Only an extension starts from a saved video.");
|
|
27160
|
+
if (p.aspectRatio && p.workflow === "image-to-video") issue2("aspectRatio", "A video made from an image keeps the image's shape.");
|
|
27161
|
+
if (p.aspectRatio === "auto" && p.workflow === "text-to-video") issue2("aspectRatio", "Choose an aspect ratio for a video written from text.");
|
|
27162
|
+
if (p.soundtrack && (!model.audio || extend2)) issue2("soundtrack", extend2 ? "An extension keeps its source's soundtrack; add audio references instead." : "This video model can't use a soundtrack.");
|
|
27163
|
+
if (p.soundtrack && p.seconds < 2) issue2("seconds", "A video with a soundtrack must be at least 2 seconds long.");
|
|
27164
|
+
if (p.audioReferences.length && (!model.audio || !extend2)) issue2("audioReferences", "Only an extension takes audio references.");
|
|
27165
|
+
if (p.promptExpansion !== "disabled" && !model.promptExpansion?.includes(p.promptExpansion)) issue2("promptExpansion", "This video model doesn't rewrite prompts.");
|
|
27166
|
+
if (p.seed !== void 0 && !model.seed) issue2("seed", "This video model doesn't take a seed.");
|
|
27167
|
+
if (p.extendOutput !== "extended" && !extend2) issue2("extendOutput", "Only an extension can return the new footage alone.");
|
|
27168
|
+
});
|
|
27063
27169
|
var assetRequest = external_exports.discriminatedUnion("type", [
|
|
27064
27170
|
external_exports.object({ ...base, type: external_exports.literal("image"), model: external_exports.enum(SPRITE_IMAGE_MODEL_IDS).default(DEFAULT_SPRITE_IMAGE_MODEL), aspectRatio: external_exports.enum(["1:1", "3:2", "2:3"]).default("1:1"), transparent: external_exports.boolean().default(false) }),
|
|
27065
|
-
|
|
27066
|
-
const model = spriteVideoModel(p.model);
|
|
27067
|
-
if (p.seconds < model.seconds.min || p.seconds > model.seconds.max) ctx.addIssue({ code: "custom", message: "Choose a duration supported by the video model.", path: ["seconds"] });
|
|
27068
|
-
if (!model.resolutions.includes(p.resolution)) ctx.addIssue({ code: "custom", message: "Choose a resolution supported by the video model.", path: ["resolution"] });
|
|
27069
|
-
if (!model.modes.includes(p.mode)) ctx.addIssue({ code: "custom", message: "Choose a mode supported by the video model.", path: ["mode"] });
|
|
27070
|
-
}),
|
|
27171
|
+
videoAsset,
|
|
27071
27172
|
external_exports.object({ ...base, type: external_exports.literal("music"), model: external_exports.literal("lyria-3.5").default("lyria-3.5") }),
|
|
27072
27173
|
external_exports.object({ ...base, type: external_exports.literal("speech"), model: external_exports.enum(["gemini-3.8-flash-tts", "gemini-3.8-flash-lite-tts", ...ELEVENLABS_SPEECH_MODEL_IDS]).default("gemini-3.8-flash-lite-tts"), voice: external_exports.string().trim().max(80).default(""), delivery: external_exports.string().trim().max(600).default("") }).superRefine((p, ctx) => {
|
|
27073
27174
|
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"] });
|
|
@@ -27294,25 +27395,46 @@ async function submitVideo(client, options) {
|
|
|
27294
27395
|
// cli/src/assets.ts
|
|
27295
27396
|
import { readFile as readFile7, writeFile as writeFile7 } from "node:fs/promises";
|
|
27296
27397
|
import { resolve as resolve8 } from "node:path";
|
|
27297
|
-
var extras = { image: ["aspect-ratio", "transparent"], video: ["reference", "seconds", "resolution", "mode"], music: [], speech: ["voice", "delivery"], sfx: ["seconds", "loop", "prompt-influence"] };
|
|
27398
|
+
var extras = { image: ["aspect-ratio", "transparent"], video: ["workflow", "reference", "end-reference", "source", "soundtrack", "audio-references", "seconds", "resolution", "mode", "aspect-ratio", "prompt-expansion", "seed", "extend-output"], music: [], speech: ["voice", "delivery"], sfx: ["seconds", "loop", "prompt-influence"] };
|
|
27399
|
+
function parseAsset(input2) {
|
|
27400
|
+
const parsed = assetRequest.safeParse(input2);
|
|
27401
|
+
if (parsed.success) return parsed.data;
|
|
27402
|
+
throw new Error(parsed.error.issues.map((issue2) => `${issue2.path.length ? `${issue2.path.join(".")}: ` : ""}${issue2.message}`).join("\n"));
|
|
27403
|
+
}
|
|
27298
27404
|
function assetFromFlags(flags) {
|
|
27299
27405
|
for (const name of Object.values(extras).flat()) {
|
|
27300
27406
|
const value = flags[name];
|
|
27301
27407
|
if (value !== void 0 && value !== false && !extras[flags.type]?.includes(name)) throw new Error(`--${name} is not an option for ${flags.type} assets.`);
|
|
27302
27408
|
}
|
|
27303
27409
|
const base2 = { type: flags.type, name: flags.name, prompt: flags.prompt, model: flags.model };
|
|
27304
|
-
if (flags.type === "image") return
|
|
27410
|
+
if (flags.type === "image") return parseAsset({ ...base2, aspectRatio: flags["aspect-ratio"], transparent: flags.transparent });
|
|
27305
27411
|
if (flags.type === "video") {
|
|
27306
27412
|
const model = spriteVideoModel(flags.model);
|
|
27307
|
-
|
|
27413
|
+
const workflow = flags.workflow ?? "image-to-video";
|
|
27414
|
+
return parseAsset({
|
|
27415
|
+
...base2,
|
|
27416
|
+
workflow: flags.workflow,
|
|
27417
|
+
reference: flags.reference,
|
|
27418
|
+
endReference: flags["end-reference"],
|
|
27419
|
+
source: flags.source,
|
|
27420
|
+
soundtrack: flags.soundtrack,
|
|
27421
|
+
audioReferences: flags["audio-references"]?.split(",").map((sha) => sha.trim()).filter(Boolean),
|
|
27422
|
+
seconds: flags.seconds ?? Math.max(5, model.seconds.min),
|
|
27423
|
+
resolution: flags.resolution ?? videoResolutions(model, workflow)[0],
|
|
27424
|
+
mode: flags.mode ?? model.modes[0],
|
|
27425
|
+
aspectRatio: flags["aspect-ratio"],
|
|
27426
|
+
promptExpansion: flags["prompt-expansion"],
|
|
27427
|
+
seed: flags.seed,
|
|
27428
|
+
extendOutput: flags["extend-output"]
|
|
27429
|
+
});
|
|
27308
27430
|
}
|
|
27309
|
-
if (flags.type === "sfx") return
|
|
27310
|
-
return
|
|
27431
|
+
if (flags.type === "sfx") return parseAsset({ ...base2, seconds: flags.seconds, loop: flags.loop, promptInfluence: flags["prompt-influence"] });
|
|
27432
|
+
return parseAsset({ ...base2, ...flags.type === "speech" ? { voice: flags.voice, delivery: flags.delivery } : {} });
|
|
27311
27433
|
}
|
|
27312
27434
|
async function assetFromFile(file2, model) {
|
|
27313
27435
|
const input2 = JSON.parse(await readFile7(file2, "utf8"));
|
|
27314
27436
|
if (!input2 || typeof input2 !== "object" || Array.isArray(input2)) throw new Error("The asset file must contain a JSON object.");
|
|
27315
|
-
return
|
|
27437
|
+
return parseAsset({ ...input2, ...model ? { model } : {} });
|
|
27316
27438
|
}
|
|
27317
27439
|
async function submitAsset(client, project, body, options) {
|
|
27318
27440
|
if (options["dry-run"]) return { dryRun: true, request: body, estimatedCredits: assetEstimate(body) };
|
|
@@ -27349,6 +27471,10 @@ var id = { name: "id", type: "string", required: true, description: "Project ID"
|
|
|
27349
27471
|
var out = (stdout, data) => stdout(JSON.stringify(data));
|
|
27350
27472
|
var actions = (text) => text ? text.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
27351
27473
|
var projectPath = (project) => `/api/v1/sprites/${encodeURIComponent(project)}`;
|
|
27474
|
+
var workspaceQuery = async (client) => {
|
|
27475
|
+
const selected = await selectedWorkspace(client.origin);
|
|
27476
|
+
return selected === "me" ? "" : `?workspace=${encodeURIComponent(selected)}`;
|
|
27477
|
+
};
|
|
27352
27478
|
async function openBrowser(url2) {
|
|
27353
27479
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32" : "xdg-open";
|
|
27354
27480
|
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url2] : [url2];
|
|
@@ -27419,7 +27545,29 @@ Confirm code: ${device.user_code}`);
|
|
|
27419
27545
|
delete credentials[client.origin];
|
|
27420
27546
|
await writeCredentials(credentials);
|
|
27421
27547
|
out(stdout, { authenticated: false });
|
|
27422
|
-
})))).
|
|
27548
|
+
})))).command("workspaces", (workspaces) => workspaces.add(defineCommand("list", { description: "List Personal, joined teams and pending invitations" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).action(async ({ flags, stdout, signal }) => {
|
|
27549
|
+
const client = new Client(flags.url, signal);
|
|
27550
|
+
const data = await client.request("/api/v1/workspaces");
|
|
27551
|
+
const selected = await selectedWorkspace(client.origin);
|
|
27552
|
+
out(stdout, { ...data, current: data.workspaces.find((item) => selected === "me" ? item.type === "user" : item.id === selected) ?? null });
|
|
27553
|
+
}))).add(defineCommand("use", { description: "Select Personal or a joined team for credits and project creation/listing" }, (command) => command.args({ name: "id", type: "string", required: true, description: "'personal' or a team ID from workspaces list" }).flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).action(async ({ args, flags, stdout, signal }) => {
|
|
27554
|
+
const client = new Client(flags.url, signal);
|
|
27555
|
+
const { workspaces: workspaces2 } = await client.request("/api/v1/workspaces");
|
|
27556
|
+
const chosen = args.id === "personal" || args.id === "me" ? workspaces2.find((item) => item.type === "user") : workspaces2.find((item) => item.type === "team" && item.id === args.id);
|
|
27557
|
+
if (!chosen) throw new Error("Workspace not found in your account. Run animaguru workspaces list.");
|
|
27558
|
+
await saveWorkspace(client.origin, chosen.type === "user" ? "me" : chosen.id);
|
|
27559
|
+
out(stdout, { current: chosen });
|
|
27560
|
+
})))).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 }) => {
|
|
27561
|
+
const client = new Client(flags.url, signal);
|
|
27562
|
+
out(stdout, await client.request(`/api/v1/credits${await workspaceQuery(client)}`));
|
|
27563
|
+
}))).add(defineCommand("catalog", { description: "List styles, kinds, actions, asset types, models, video settings and speech voices" }, (command) => command.action(({ stdout }) => out(stdout, { styles: SPRITE_STYLES, kinds: SPRITE_KINDS, actions: ALL_SPRITE_ACTIONS, imageModels: SPRITE_IMAGE_MODEL_IDS, 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 })))).command("projects", (projects) => projects.add(defineCommand("list", { description: "List projects in the selected workspace" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).action(async ({ flags, stdout, signal }) => {
|
|
27564
|
+
const client = new Client(flags.url, signal);
|
|
27565
|
+
out(stdout, await client.request(`/api/v1/projects${await workspaceQuery(client)}`));
|
|
27566
|
+
}))).add(defineCommand("create", { description: "Create an empty sprite project; generate artwork with jobs submit" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).flags(...modelFlags).flags({ name: "name", type: "string", required: true }, { name: "style", type: "string", required: true, choices: SPRITE_STYLES }).action(async ({ flags, stdout, signal }) => {
|
|
27567
|
+
const client = new Client(flags.url, signal);
|
|
27568
|
+
const selected = await selectedWorkspace(client.origin);
|
|
27569
|
+
out(stdout, await client.request("/api/v1/sprites", { method: "POST", body: { owner: selected, name: flags.name, style: flags.style, ...selectedModels(flags) } }));
|
|
27570
|
+
}))).add(defineCommand("get", { description: "Read project, subjects, animations, estimates and job state" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).args(id).action(async ({ args, flags, stdout, signal }) => out(stdout, await new Client(flags.url, signal).request(projectPath(args.id)))))).add(defineCommand("rename", { description: "Rename a project" }, (command) => command.flags({ name: "url", type: "string", default: "https://animaguru.com", env: { name: "ANIMAGURU_URL" }, description: "Server origin" }).args(id).flags({ name: "name", type: "string", required: true }).action(async ({ args, flags, stdout, signal }) => out(stdout, await new Client(flags.url, signal).request(`/api/v1/projects/${encodeURIComponent(args.id)}`, { method: "PATCH", body: { name: flags.name } }))))).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 }) => {
|
|
27423
27571
|
if (!flags.yes) throw new Error("Pass --yes to delete this project.");
|
|
27424
27572
|
out(stdout, await new Client(flags.url, signal).request(`/api/v1/projects/${encodeURIComponent(args.id)}`, { method: "DELETE" }));
|
|
27425
27573
|
})))).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(
|
|
@@ -27427,12 +27575,20 @@ Confirm code: ${device.user_code}`);
|
|
|
27427
27575
|
{ name: "name", type: "string", required: true },
|
|
27428
27576
|
{ name: "prompt", type: "string", required: true, description: "Description to generate, or verbatim transcript for speech" },
|
|
27429
27577
|
{ name: "model", type: "string", choices: assetModelIds, description: "Model for the chosen asset type" },
|
|
27430
|
-
{ name: "aspect-ratio", type: "string", choices: ["1:1", "3:2", "2:3"], description: "Image
|
|
27578
|
+
{ name: "aspect-ratio", type: "string", choices: [.../* @__PURE__ */ new Set(["1:1", "3:2", "2:3", "auto", ...VIDEO_ASPECT_RATIOS])], description: "Image: 1:1 (default), 3:2 or 2:3. Video from text: 21:9\u20139:16 (default 16:9). Extension: auto (default, the source's) or a ratio to crop to" },
|
|
27431
27579
|
{ name: "transparent", type: "boolean", description: "Image: transparent background" },
|
|
27580
|
+
{ name: "workflow", type: "string", choices: VIDEO_WORKFLOWS, description: "Video: image-to-video (default), text-to-video or extend-video, as the model's videoProfiles allow" },
|
|
27432
27581
|
{ name: "reference", type: "string", description: "Video: starting image blob SHA from this project's assets or sprites" },
|
|
27433
|
-
{ name: "
|
|
27434
|
-
{ name: "
|
|
27582
|
+
{ name: "end-reference", type: "string", description: "Video: ending image blob SHA; models with endFrameOnly accept it without --reference" },
|
|
27583
|
+
{ name: "source", type: "string", description: "Extension: blob SHA of a video asset in this project to extend" },
|
|
27584
|
+
{ 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)" },
|
|
27585
|
+
{ name: "audio-references", type: "string", description: "Extension: up to 3 comma-separated audio asset blob SHAs (2\u201315 s each, 15 s total) guiding the new sound; refer to them as Audio 1, Audio 2\u2026 in the prompt" },
|
|
27586
|
+
{ name: "seconds", type: "number", description: "Video or SFX duration in seconds (default 5); extensions add this many seconds; SFX supports 0.5\u201330" },
|
|
27587
|
+
{ name: "resolution", type: "string", choices: VIDEO_RESOLUTIONS, description: "Video resolution supported by the selected model and workflow (2k for extensions)" },
|
|
27435
27588
|
{ name: "mode", type: "string", choices: ["cost", "speed", "quality"], description: "Video generation mode supported by the selected model" },
|
|
27589
|
+
{ 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" },
|
|
27590
|
+
{ name: "seed", type: "number", description: "Video: seed for repeatable results, where the model takes one" },
|
|
27591
|
+
{ 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)" },
|
|
27436
27592
|
{ name: "voice", type: "string", description: "Gemini voice name or ElevenLabs voice ID (defaults: Kore / George)" },
|
|
27437
27593
|
{ name: "delivery", type: "string", description: "Gemini speech delivery instructions; ElevenLabs uses audio tags in the transcript" },
|
|
27438
27594
|
{ name: "loop", type: "boolean", description: "SFX: create a seamless loop" },
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@ Before creative work in a multi-animation workspace, select or create the intend
|
|
|
16
16
|
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.
|
|
17
17
|
|
|
18
18
|
- 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. Use `--help` on a leaf command for details.
|
|
19
|
-
- Check `animaguru auth status` 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. Do not read or print stored credentials. Agent runners may inject `ANIMAGURU_TOKEN` as
|
|
19
|
+
- 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.
|
|
20
20
|
- 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.
|
|
21
21
|
|
|
22
22
|
## Choose the project and submit work
|
|
@@ -77,7 +77,7 @@ animaguru assets generate PROJECT_ID --type speech --name "Welcome" \
|
|
|
77
77
|
--prompt "Welcome, traveler." --voice Kore --delivery "Warm and welcoming" --yes
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
-
Use `--model` to select a model for any asset type; discover the choices with `catalog` and the server's configured subset with `assets list`. Asset defaults are per type, independently of sprite project defaults. Image backgrounds are retained unless `--transparent` is supplied. Videos require an image asset's `file` SHA from `assets list`, or a character's `art` SHA from `projects get`, in the same project. Validate video options against the selected model's `videoProfiles` in `catalog`; omitted video settings use five seconds and that provider's first resolution/mode. For Gemini speech, `--prompt` is spoken verbatim and `--delivery` is a separate instruction. Music uses Lyria; the Gemini TTS models are for speech.
|
|
80
|
+
Use `--model` to select a model for any asset type; discover the choices with `catalog` and the server's configured subset with `assets list`. Asset defaults are per type, independently of sprite project defaults. Image backgrounds are retained unless `--transparent` is supplied. Videos require an image asset's `file` SHA from `assets list`, or a character's `art` SHA from `projects get`, in the same project. Validate video options against the selected model's `videoProfiles` in `catalog`; omitted video settings use five seconds and that provider's first resolution/mode. MiniMax H3 Max and H3 Max Turbo (`fal/minimax-h3-max`, `fal/minimax-h3-max-turbo`) also take `--workflow text-to-video` (with `--aspect-ratio`) and `--workflow extend-video` (with `--source VIDEO_SHA`, 2K resolution, `--extend-output`, and up to three comma-separated `--audio-references`). From images, they accept `--end-reference` with or without `--reference` and a `--soundtrack AUDIO_SHA`. All workflows take `--prompt-expansion` and `--seed`. These need CLI 0.8.0 or newer. For Gemini speech, `--prompt` is spoken verbatim and `--delivery` is a separate instruction. Music uses Lyria; the Gemini TTS models are for speech.
|
|
81
81
|
|
|
82
82
|
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.
|
|
83
83
|
|
|
@@ -24,7 +24,7 @@ Read [visual and motion guidance](references/visual-style.md) when designing sce
|
|
|
24
24
|
|
|
25
25
|
Use image tools for bitmap artwork, cutouts and textures, and PixiJS for composition, typography, masks and motion. Treat people, products, objects, landscapes and abstract forms as equally valid cutout subjects. Choose coherent edge treatment, material, lighting and movement so the film feels intentionally assembled.
|
|
26
26
|
|
|
27
|
-
Use the user's selected generation models and budget. Otherwise inspect the current AnimaGuru catalog and configured providers to select models suited to the assets and motion. MiniMax H3 Max (`fal/minimax-h3-max`) is one option for animated photographic assets when available; cutout motion can also be built directly in PixiJS from still layers. Validate supported settings before submission.
|
|
27
|
+
Use the user's selected generation models and budget. Otherwise inspect the current AnimaGuru catalog and configured providers to select models suited to the assets and motion. MiniMax H3 Max (`fal/minimax-h3-max`) or its faster, cheaper Turbo (`fal/minimax-h3-max-turbo`) is one option for animated photographic assets when available; cutout motion can also be built directly in PixiJS from still layers. Validate supported settings before submission.
|
|
28
28
|
|
|
29
29
|
Deliver the film described in the brief with consistent cutout visuals and intentional animation. Keep story development responsive to the project's purpose rather than borrowing the narrative of an example film.
|
|
30
30
|
|
|
@@ -110,10 +110,11 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
110
110
|
if (request !== playbackRequest) { audio?.pause(); return; }
|
|
111
111
|
last = performance.now(); playing = true; update();
|
|
112
112
|
}
|
|
113
|
-
function
|
|
113
|
+
function setMuted(value: boolean) {
|
|
114
114
|
if (!audio) return;
|
|
115
|
-
muted =
|
|
115
|
+
muted = value; audio.muted = muted; update();
|
|
116
116
|
}
|
|
117
|
+
function toggleSound() { setMuted(!muted); }
|
|
117
118
|
function setCaptions(value: boolean) { film.captions = value; void render(time); update(); }
|
|
118
119
|
async function fullscreen() {
|
|
119
120
|
try { if (document.fullscreenElement) await document.exitFullscreen(); else await theater.requestFullscreen(); }
|
|
@@ -206,12 +207,12 @@ export async function mountPlayer({ config, Film, chapters, branding, exportEndp
|
|
|
206
207
|
}
|
|
207
208
|
await render(time); update();
|
|
208
209
|
const api = {
|
|
209
|
-
ready: true, width: config.width, height: config.height, duration: config.duration, fps: config.fps, language: config.language, canvas: app.canvas,
|
|
210
|
+
ready: true, width: config.width, height: config.height, duration: config.duration, fps: config.fps, language: config.language, languages: [{ code: config.language, name: config.language }], canvas: app.canvas,
|
|
210
211
|
get state() { return { time, playing, muted, captions: film.captions, audio: { configured: Boolean(audio), readyState: audio?.readyState ?? 0, error: audio?.error?.code ?? null } }; },
|
|
211
212
|
resources: film.resources,
|
|
212
213
|
get texts() { return film.texts; },
|
|
213
214
|
get cues() { return film.cues; },
|
|
214
|
-
seek, play, pause, setCaptions, saveFrame,
|
|
215
|
+
seek, play, pause, setMuted, setCaptions, saveFrame,
|
|
215
216
|
async renderFrame(frame: number, fps = config.fps) { await seek(frame / fps); },
|
|
216
217
|
};
|
|
217
218
|
Object.assign(window, { animaguru: api });
|