volito 0.1.0 → 0.3.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 +200 -9
- package/dist/browser.d.ts +50 -1
- package/dist/browser.js +352 -43
- package/dist/chunks/{chunk-CFDFWZAC.js → chunk-CUQY6YI3.js} +1032 -404
- package/dist/chunks/chunk-PM3PLI3X.js +71 -0
- package/dist/chunks/chunk-QA4SEJCZ.js +601 -0
- package/dist/chunks/chunk-ZFG3X3DU.js +67 -0
- package/dist/chunks/chunk-ZHMWEZ4C.js +186 -0
- package/dist/chunks/init-JX2BYRKT.js +189 -0
- package/dist/chunks/review-RBH5EUZN.js +18 -0
- package/dist/cli.js +232 -32
- package/dist/index.d.ts +314 -2
- package/dist/index.js +75 -21
- package/dist/mcp.js +129 -22
- package/dist/player.d.ts +643 -0
- package/dist/player.js +6320 -0
- package/dist/registry.json +1 -1
- package/examples/player.html +93 -0
- package/examples/states.html +73 -0
- package/examples/volito-intro/DESIGN.md +23 -0
- package/examples/volito-intro/frame.js +187 -0
- package/examples/volito-intro/item.json +43 -0
- package/package.json +15 -7
- package/packs/3d/pack.json +1 -1
- package/packs/annotate/emphasis/frame.js +8 -0
- package/packs/annotate/pack.json +1 -1
- package/packs/annotate/reactions/frame.js +12 -0
- package/packs/backdrops/gradient/frame.js +5 -0
- package/packs/backdrops/pack.json +1 -1
- package/packs/broadcast/pack.json +1 -1
- package/packs/data/pack.json +1 -1
- package/packs/effects/chromatic/frame.js +17 -0
- package/packs/effects/chromatic/item.json +15 -0
- package/packs/effects/glass/frame.js +44 -0
- package/packs/effects/glass/item.json +16 -0
- package/packs/effects/grain/frame.js +20 -0
- package/packs/effects/grain/item.json +16 -0
- package/packs/effects/light-leak/frame.js +24 -0
- package/packs/effects/light-leak/item.json +15 -0
- package/packs/effects/neon/frame.js +28 -0
- package/packs/effects/neon/item.json +15 -0
- package/packs/effects/pack.json +13 -0
- package/packs/effects/shared.js +23 -0
- package/packs/effects/warp/frame.js +13 -0
- package/packs/effects/warp/item.json +15 -0
- package/packs/essentials/pack.json +1 -1
- package/packs/essentials/wipe/frame.js +13 -0
- package/packs/promo/pack.json +1 -1
- package/packs/social/pack.json +1 -1
- package/packs/transitions/glitch/frame.js +14 -0
- package/packs/transitions/pack.json +1 -1
- package/packs/transitions/shared.js +12 -0
- package/packs/type/pack.json +1 -1
- package/packs/type/word-by-word/frame.js +10 -0
- package/packs/ui/bell/frame.js +24 -0
- package/packs/ui/bell/item.json +21 -0
- package/packs/ui/button/frame.js +55 -0
- package/packs/ui/button/item.json +26 -0
- package/packs/ui/like/frame.js +34 -0
- package/packs/ui/like/item.json +21 -0
- package/packs/ui/loader/frame.js +34 -0
- package/packs/ui/loader/item.json +23 -0
- package/packs/ui/pack.json +13 -0
- package/packs/ui/shared.js +54 -0
- package/packs/ui/toggle/frame.js +30 -0
- package/packs/ui/toggle/item.json +22 -0
- package/{skill → skills/volito}/SKILL.md +43 -3
- package/skills/volito/references/art-direction.md +58 -0
- package/skills/volito/references/brand.md +59 -0
- package/skills/volito/references/delivery.md +40 -0
- package/skills/volito/references/matching-a-reference.md +39 -0
- package/skills/volito/references/motion-craft.md +56 -0
- package/skills/volito/references/self-review.md +44 -0
- package/skills/volito/references/type-and-layout.md +44 -0
- package/skills/volito/references/voice-and-timing.md +37 -0
- package/skills/volito/references/writing-items.md +44 -0
- /package/{skill/template → templates/item}/frame.js +0 -0
- /package/{skill/template → templates/item}/item.json +0 -0
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUILTIN_STYLES,
|
|
3
|
+
LIBRARY_NAMES,
|
|
4
|
+
MOODS,
|
|
5
|
+
MOTION_FORMAT,
|
|
6
|
+
MotionError,
|
|
7
|
+
TrueTypeFace,
|
|
8
|
+
customItem,
|
|
9
|
+
glyphTable,
|
|
10
|
+
makeItem,
|
|
11
|
+
normalizeMotion,
|
|
12
|
+
parseAspect,
|
|
13
|
+
registerLibrary,
|
|
14
|
+
resolveProps,
|
|
15
|
+
setGlyphSource
|
|
16
|
+
} from "./chunk-CUQY6YI3.js";
|
|
17
|
+
import {
|
|
18
|
+
fontsDir,
|
|
19
|
+
libsDir,
|
|
20
|
+
packsDir
|
|
21
|
+
} from "./chunk-ZFG3X3DU.js";
|
|
22
|
+
|
|
23
|
+
// src/node/packs.ts
|
|
24
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, readdirSync, statSync } from "node:fs";
|
|
25
|
+
import { basename, join as join2, resolve } from "node:path";
|
|
26
|
+
|
|
27
|
+
// src/node/setup.ts
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import { join } from "node:path";
|
|
30
|
+
setGlyphSource((key, file) => {
|
|
31
|
+
const path = join(fontsDir(), file);
|
|
32
|
+
return existsSync(path) ? glyphTable(new TrueTypeFace(new Uint8Array(readFileSync(path))), key) : void 0;
|
|
33
|
+
});
|
|
34
|
+
for (const name of LIBRARY_NAMES) {
|
|
35
|
+
const file = join(libsDir(), `${name}.js`);
|
|
36
|
+
if (existsSync(file)) registerLibrary(name, () => readFileSync(file, "utf8"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/node/packs.ts
|
|
40
|
+
var readJson = (file) => {
|
|
41
|
+
try {
|
|
42
|
+
return JSON.parse(readFileSync2(file, "utf8"));
|
|
43
|
+
} catch (err) {
|
|
44
|
+
throw new MotionError(`${file}: ${err.message}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
function loadPack(dir) {
|
|
48
|
+
const manifest = readJson(join2(dir, "pack.json"));
|
|
49
|
+
if (manifest.format !== MOTION_FORMAT) throw new MotionError(`${dir}: pack format ${manifest.format} \u2014 this version reads format ${MOTION_FORMAT}`);
|
|
50
|
+
const shared = manifest.shared ? readFileSync2(join2(dir, manifest.shared), "utf8") : "";
|
|
51
|
+
const entries = manifest.items.map((id) => {
|
|
52
|
+
const itemDir = join2(dir, id);
|
|
53
|
+
const m = readJson(join2(itemDir, "item.json"));
|
|
54
|
+
if (m.id !== id) throw new MotionError(`${itemDir}: item.json id "${m.id}" does not match its folder`);
|
|
55
|
+
return makeItem(manifest.id, m, readFileSync2(join2(itemDir, "frame.js"), "utf8"), shared);
|
|
56
|
+
});
|
|
57
|
+
return { ...manifest, entries };
|
|
58
|
+
}
|
|
59
|
+
var builtin = null;
|
|
60
|
+
function builtinPacks() {
|
|
61
|
+
if (builtin) return builtin;
|
|
62
|
+
const dir = packsDir();
|
|
63
|
+
const order = ["essentials", "broadcast", "social", "data", "3d", "transitions", "annotate", "type", "backdrops", "promo", "ui", "effects"];
|
|
64
|
+
const rank = (d) => order.includes(d) ? order.indexOf(d) : order.length;
|
|
65
|
+
builtin = existsSync2(dir) ? readdirSync(dir).filter((d) => existsSync2(join2(dir, d, "pack.json"))).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b)).map((d) => loadPack(join2(dir, d))) : [];
|
|
66
|
+
return builtin;
|
|
67
|
+
}
|
|
68
|
+
function packsIn(dirs) {
|
|
69
|
+
const out = [];
|
|
70
|
+
for (const raw of dirs) {
|
|
71
|
+
const dir = resolve(raw);
|
|
72
|
+
if (existsSync2(join2(dir, "pack.json"))) out.push(loadPack(dir));
|
|
73
|
+
else if (existsSync2(dir) && statSync(dir).isDirectory()) {
|
|
74
|
+
for (const d of readdirSync(dir).sort()) if (existsSync2(join2(dir, d, "pack.json"))) out.push(loadPack(join2(dir, d)));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
function library(extraDirs = []) {
|
|
80
|
+
const env = (process.env.VOLITO_PACKS ?? process.env.MONARC_MOTION_PACKS ?? "").split(":").filter(Boolean);
|
|
81
|
+
const packs = [...builtinPacks(), ...packsIn([...env, ...extraDirs])];
|
|
82
|
+
const seen = /* @__PURE__ */ new Set();
|
|
83
|
+
for (const p of packs) {
|
|
84
|
+
if (seen.has(p.id)) throw new MotionError(`two packs are named "${p.id}"`);
|
|
85
|
+
seen.add(p.id);
|
|
86
|
+
}
|
|
87
|
+
return { packs, items: packs.flatMap((p) => p.entries) };
|
|
88
|
+
}
|
|
89
|
+
function findItem(name, lib = library()) {
|
|
90
|
+
const n = name.trim();
|
|
91
|
+
const byRef = lib.items.find((i) => i.ref === n);
|
|
92
|
+
if (byRef) return byRef;
|
|
93
|
+
const path = resolve(n);
|
|
94
|
+
if (existsSync2(path)) {
|
|
95
|
+
if (statSync(path).isDirectory() && existsSync2(join2(path, "item.json"))) {
|
|
96
|
+
const m = readJson(join2(path, "item.json"));
|
|
97
|
+
const shared = existsSync2(join2(path, "shared.js")) ? readFileSync2(join2(path, "shared.js"), "utf8") : "";
|
|
98
|
+
return makeItem("local", m, readFileSync2(join2(path, "frame.js"), "utf8"), shared);
|
|
99
|
+
}
|
|
100
|
+
if (/\.(m?js|txt)$/.test(path)) return customItem(readFileSync2(path, "utf8"), { id: basename(path).replace(/\.[^.]+$/, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") });
|
|
101
|
+
}
|
|
102
|
+
const byId = lib.items.filter((i) => i.id === n);
|
|
103
|
+
if (byId.length === 1) return byId[0];
|
|
104
|
+
if (byId.length > 1) throw new MotionError(`"${n}" is in more than one pack: ${byId.map((i) => i.ref).join(", ")}`);
|
|
105
|
+
const q = n.toLowerCase();
|
|
106
|
+
const score = (i) => i.id.startsWith(q) ? 0 : i.id.includes(q) || q.includes(i.id) ? 1 : i.name.toLowerCase().includes(q) ? 2 : (i.tags ?? []).some((t) => t.includes(q)) ? 3 : 9;
|
|
107
|
+
const near = lib.items.map((i) => ({ ref: i.ref, s: score(i) })).filter((r) => r.s < 9).sort((a, b) => a.s - b.s).map((r) => r.ref);
|
|
108
|
+
throw new MotionError(`no item "${n}"${near.length ? ` \u2014 did you mean ${near.slice(0, 4).join(", ")}?` : ""} (list them with: volito list)`);
|
|
109
|
+
}
|
|
110
|
+
function searchItems(q = {}, lib = library()) {
|
|
111
|
+
const words = (q.search ?? "").toLowerCase().split(/\s+/).filter(Boolean);
|
|
112
|
+
return lib.items.filter((i) => {
|
|
113
|
+
if (q.category && i.category !== q.category) return false;
|
|
114
|
+
if (q.pack && i.pack !== q.pack) return false;
|
|
115
|
+
if (q.placement && i.placement !== q.placement) return false;
|
|
116
|
+
if (!words.length) return true;
|
|
117
|
+
const hay = [i.id, i.name, i.description, i.category, ...i.tags ?? []].join(" ").toLowerCase();
|
|
118
|
+
return words.every((w) => hay.includes(w));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/spec.ts
|
|
123
|
+
import { z } from "zod";
|
|
124
|
+
var SPEC_VERSION = 1;
|
|
125
|
+
var SPEC_SCHEMA_URL = "https://volito.dev/schema/graphic.v1.json";
|
|
126
|
+
var SPEC_FORMATS = ["mov", "hevc", "webm", "mp4", "gif", "png"];
|
|
127
|
+
var hex = z.string().regex(/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/, "a hex colour like #FF6A3D");
|
|
128
|
+
var SpecColorsSchema = z.object({
|
|
129
|
+
accent: hex,
|
|
130
|
+
accent2: hex,
|
|
131
|
+
ink: hex,
|
|
132
|
+
paper: hex,
|
|
133
|
+
surface: hex,
|
|
134
|
+
mode: z.enum(["dark", "light"]),
|
|
135
|
+
display: z.string().describe('A face as "Family:weight", e.g. "Fraunces:900"'),
|
|
136
|
+
text: z.string(),
|
|
137
|
+
mono: z.string()
|
|
138
|
+
}).partial().strict();
|
|
139
|
+
var SpecWordSchema = z.object({ w: z.string(), s: z.number(), e: z.number() }).strict();
|
|
140
|
+
var GraphicSpecSchema = z.object({
|
|
141
|
+
$schema: z.string().optional(),
|
|
142
|
+
volito: z.literal(SPEC_VERSION).describe("The spec format's major version"),
|
|
143
|
+
name: z.string().optional().describe("A label for people; not used to render"),
|
|
144
|
+
item: z.string().optional().describe('A pack item: "essentials/lower-third", or its id "lower-third". Give item, program or code.'),
|
|
145
|
+
program: z.string().optional().describe("A path to your own frame(t, $) program (.js), relative to this file"),
|
|
146
|
+
code: z.string().optional().describe("Your own frame(t, $) program, inline"),
|
|
147
|
+
placement: z.enum(["overlay", "full"]).optional().describe("For a program: overlay (transparent) or full (draws its own ground)"),
|
|
148
|
+
props: z.record(z.string(), z.unknown()).optional().describe("The item's props; image props are paths relative to this file or URLs"),
|
|
149
|
+
style: z.string().optional().describe("A built-in style name, or a DESIGN.md path relative to this file"),
|
|
150
|
+
colors: SpecColorsSchema.optional().describe("Overrides on the style"),
|
|
151
|
+
aspect: z.string().optional().describe('"16:9", "9:16", "1:1", "4:5" or "WIDTHxHEIGHT"'),
|
|
152
|
+
duration: z.number().positive().max(600).optional().describe("Seconds; the item's default when omitted"),
|
|
153
|
+
fps: z.number().int().min(1).max(120).optional(),
|
|
154
|
+
width: z.number().int().min(16).max(8192).optional().describe("Output width in px; the height follows"),
|
|
155
|
+
background: z.string().optional().describe('"transparent" (default where the format has alpha) or a colour drawn under the graphic'),
|
|
156
|
+
words: z.union([z.array(SpecWordSchema), z.string()]).optional().describe("Spoken words with start/end seconds, or a transcript file ([{w,s,e}] or Whisper JSON) relative to this file"),
|
|
157
|
+
offset: z.number().optional().describe("Where the graphic starts in the transcript, in seconds"),
|
|
158
|
+
beats: z.record(z.string(), z.number()).optional().describe('Named moments, e.g. { "in": 0.4, "out": -0.6 }'),
|
|
159
|
+
motion: z.record(z.string(), z.record(z.string(), z.unknown())).optional().describe('How each part moves: { "*": { "ease": "bouncy" }, "name": { "in": "typewriter" } }'),
|
|
160
|
+
mood: z.enum(MOODS).optional(),
|
|
161
|
+
seed: z.number().optional(),
|
|
162
|
+
speed: z.number().positive().max(10).optional(),
|
|
163
|
+
sound: z.union([z.boolean(), z.object({ bed: z.string().optional(), level: z.number().min(0).max(2).optional() }).strict()]).optional().describe('true, or { bed: "Dm", level: 1 }: a track Volito synthesises, muxed into video'),
|
|
164
|
+
render: z.object({ format: z.enum(SPEC_FORMATS).optional(), out: z.string().optional().describe("Output path, relative to where you run it") }).strict().optional().describe("Defaults for volito render; command-line flags win")
|
|
165
|
+
}).strict().superRefine((s, ctx) => {
|
|
166
|
+
const sources = [s.item, s.program, s.code].filter((x) => x !== void 0 && x !== "").length;
|
|
167
|
+
if (sources === 0) ctx.addIssue({ code: "custom", path: ["item"], message: "give item, program or code" });
|
|
168
|
+
if (sources > 1) ctx.addIssue({ code: "custom", path: ["item"], message: "give only one of item, program and code" });
|
|
169
|
+
if (s.placement && s.item) ctx.addIssue({ code: "custom", path: ["placement"], message: "placement is for a program or code; an item has its own" });
|
|
170
|
+
});
|
|
171
|
+
var pathOf = (p) => p.map(String).join(".");
|
|
172
|
+
function parseSpec(input) {
|
|
173
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) return { spec: null, errors: [{ path: "", message: "a spec is a JSON object" }] };
|
|
174
|
+
const v = input.volito;
|
|
175
|
+
if (v === void 0) return { spec: null, errors: [{ path: "volito", message: `missing "volito": ${SPEC_VERSION} \u2014 the spec format's version` }] };
|
|
176
|
+
if (v !== SPEC_VERSION) return { spec: null, errors: [{ path: "volito", message: `this Volito reads spec version ${SPEC_VERSION}; got ${JSON.stringify(v)}` }] };
|
|
177
|
+
const r = GraphicSpecSchema.safeParse(input);
|
|
178
|
+
if (r.success) return { spec: r.data, errors: [] };
|
|
179
|
+
return {
|
|
180
|
+
spec: null,
|
|
181
|
+
errors: r.error.issues.map((i) => {
|
|
182
|
+
const keys = i.keys;
|
|
183
|
+
if (i.code === "unrecognized_keys" && keys?.length) return { path: pathOf(i.path), message: `unknown field${keys.length > 1 ? "s" : ""} ${keys.map((k) => `"${k}"`).join(", ")}${suggest(keys[0])}` };
|
|
184
|
+
return { path: pathOf(i.path), message: i.message };
|
|
185
|
+
})
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
var FIELDS = ["$schema", "volito", "name", "item", "program", "code", "placement", "props", "style", "colors", "aspect", "duration", "fps", "width", "background", "words", "offset", "beats", "motion", "mood", "seed", "speed", "sound", "render"];
|
|
189
|
+
function suggest(key) {
|
|
190
|
+
let best = "";
|
|
191
|
+
let dist = 3;
|
|
192
|
+
for (const f of FIELDS) {
|
|
193
|
+
const d = editDistance(key.toLowerCase(), f.toLowerCase());
|
|
194
|
+
if (d < dist) {
|
|
195
|
+
dist = d;
|
|
196
|
+
best = f;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return best ? ` \u2014 did you mean "${best}"?` : "";
|
|
200
|
+
}
|
|
201
|
+
function editDistance(a, b) {
|
|
202
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
203
|
+
for (let i = 1; i <= a.length; i++) {
|
|
204
|
+
let prev = row[0];
|
|
205
|
+
row[0] = i;
|
|
206
|
+
for (let j = 1; j <= b.length; j++) {
|
|
207
|
+
const cur = row[j];
|
|
208
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
209
|
+
prev = cur;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return row[b.length];
|
|
213
|
+
}
|
|
214
|
+
function specJsonSchema() {
|
|
215
|
+
const schema = z.toJSONSchema(GraphicSpecSchema, { target: "draft-2020-12", unrepresentable: "any" });
|
|
216
|
+
return { $id: SPEC_SCHEMA_URL, title: "Volito Graphic spec", description: "One motion graphic: the item or program, its props, brand, shape, timing, motion and sound.", ...schema };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// src/node/inputs.ts
|
|
220
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
|
|
221
|
+
import { extname, resolve as resolve2 } from "node:path";
|
|
222
|
+
var num = (v) => typeof v === "number" ? v : Number.parseFloat(String(v));
|
|
223
|
+
function normalizeWords(input, offset = 0) {
|
|
224
|
+
const rows = Array.isArray(input) ? input : input && typeof input === "object" ? Array.isArray(input.words) ? input.words : Array.isArray(input.segments) ? input.segments.flatMap((s) => s.words ?? []) : [] : [];
|
|
225
|
+
const out = [];
|
|
226
|
+
for (const r of rows) {
|
|
227
|
+
const o = r ?? {};
|
|
228
|
+
const w = String(o.w ?? o.word ?? o.text ?? "").trim();
|
|
229
|
+
const s = num(o.s ?? o.start) - offset;
|
|
230
|
+
const e = num(o.e ?? o.end) - offset;
|
|
231
|
+
if (!w || !Number.isFinite(s)) continue;
|
|
232
|
+
if (s < -0.01) continue;
|
|
233
|
+
out.push({ w, s: Math.max(0, s), e: Number.isFinite(e) ? Math.max(s, e) : s + 0.3 });
|
|
234
|
+
}
|
|
235
|
+
return out.slice(0, 600);
|
|
236
|
+
}
|
|
237
|
+
function readWords(file, offset = 0) {
|
|
238
|
+
const path = resolve2(file);
|
|
239
|
+
if (!existsSync3(path)) throw new MotionError(`no transcript at ${path}`);
|
|
240
|
+
const words = normalizeWords(JSON.parse(readFileSync3(path, "utf8")), offset);
|
|
241
|
+
if (!words.length) throw new MotionError(`${path} has no timed words (expected [{w, s, e}] or Whisper JSON)`);
|
|
242
|
+
return words;
|
|
243
|
+
}
|
|
244
|
+
var MIME = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml" };
|
|
245
|
+
async function loadImage(src) {
|
|
246
|
+
if (src.startsWith("data:image/")) return src;
|
|
247
|
+
if (/^https?:\/\//i.test(src)) {
|
|
248
|
+
const res = await fetch(src);
|
|
249
|
+
if (!res.ok) throw new MotionError(`${src}: HTTP ${res.status}`);
|
|
250
|
+
const type2 = res.headers.get("content-type")?.split(";")[0] ?? MIME[extname(new URL(src).pathname).toLowerCase()] ?? "image/png";
|
|
251
|
+
if (!type2.startsWith("image/")) throw new MotionError(`${src} is not an image (${type2})`);
|
|
252
|
+
return `data:${type2};base64,${Buffer.from(await res.arrayBuffer()).toString("base64")}`;
|
|
253
|
+
}
|
|
254
|
+
const path = resolve2(src);
|
|
255
|
+
if (!existsSync3(path)) throw new MotionError(`no picture at ${path}`);
|
|
256
|
+
const type = MIME[extname(path).toLowerCase()];
|
|
257
|
+
if (!type) throw new MotionError(`${path}: pictures are PNG, JPEG, WebP, GIF or SVG`);
|
|
258
|
+
return `data:${type};base64,${readFileSync3(path).toString("base64")}`;
|
|
259
|
+
}
|
|
260
|
+
async function loadMedia(item, props = {}) {
|
|
261
|
+
const out = {};
|
|
262
|
+
for (const [name, spec] of Object.entries(item.props)) {
|
|
263
|
+
const v = props[name] ?? spec.default;
|
|
264
|
+
if (spec.type !== "image" || typeof v !== "string" || !v.trim()) continue;
|
|
265
|
+
out[name] = await loadImage(v.trim());
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
function readStyleArg(arg) {
|
|
270
|
+
if (!arg) return void 0;
|
|
271
|
+
const path = resolve2(arg);
|
|
272
|
+
if (/\.(md|markdown|txt)$/i.test(arg) || existsSync3(path) && !/^[a-z0-9-]+$/i.test(arg)) {
|
|
273
|
+
if (!existsSync3(path)) throw new MotionError(`no style file at ${path}`);
|
|
274
|
+
return readFileSync3(path, "utf8");
|
|
275
|
+
}
|
|
276
|
+
return arg;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// src/node/spec.ts
|
|
280
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4, statSync as statSync2 } from "node:fs";
|
|
281
|
+
import { basename as basename2, dirname, isAbsolute, resolve as resolve3 } from "node:path";
|
|
282
|
+
function isSpecFile(path) {
|
|
283
|
+
if (!/\.json$/i.test(path)) return false;
|
|
284
|
+
const p = resolve3(path);
|
|
285
|
+
if (!existsSync4(p) || !statSync2(p).isFile()) return false;
|
|
286
|
+
try {
|
|
287
|
+
const doc = JSON.parse(readFileSync4(p, "utf8"));
|
|
288
|
+
return !!doc && typeof doc === "object" && !Array.isArray(doc) && "volito" in doc;
|
|
289
|
+
} catch {
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function loadSpecFile(path) {
|
|
294
|
+
const file = resolve3(path);
|
|
295
|
+
let doc;
|
|
296
|
+
try {
|
|
297
|
+
doc = JSON.parse(readFileSync4(file, "utf8"));
|
|
298
|
+
} catch (e) {
|
|
299
|
+
throw new MotionError(`${path} is not valid JSON: ${e.message}`);
|
|
300
|
+
}
|
|
301
|
+
const { spec, errors } = parseSpec(doc);
|
|
302
|
+
if (!spec) throw new MotionError(`${path} is not a valid spec:
|
|
303
|
+
${formatIssues(errors)}`);
|
|
304
|
+
return { spec, dir: dirname(file), file };
|
|
305
|
+
}
|
|
306
|
+
var at = (dir, p) => isAbsolute(p) ? p : resolve3(dir, p);
|
|
307
|
+
var isUrl = (s) => /^(https?:|data:)/i.test(s);
|
|
308
|
+
function itemOfSpec(loaded, lib = library()) {
|
|
309
|
+
const { spec, dir } = loaded;
|
|
310
|
+
if (spec.code?.trim()) return customItem(spec.code, { placement: spec.placement, duration: spec.duration, id: idFrom(spec.name ?? "graphic") });
|
|
311
|
+
if (spec.program) {
|
|
312
|
+
const p = at(dir, spec.program);
|
|
313
|
+
if (!existsSync4(p)) throw new MotionError(`program ${spec.program} not found (looked in ${p})`);
|
|
314
|
+
if (statSync2(p).isDirectory()) return findItem(p, lib);
|
|
315
|
+
return customItem(readFileSync4(p, "utf8"), { placement: spec.placement, duration: spec.duration, id: idFrom(basename2(p).replace(/\.[^.]+$/, "")) });
|
|
316
|
+
}
|
|
317
|
+
return findItem(spec.item, lib);
|
|
318
|
+
}
|
|
319
|
+
var idFrom = (s) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "graphic";
|
|
320
|
+
function specToOptions(loaded, item) {
|
|
321
|
+
const { spec, dir } = loaded;
|
|
322
|
+
const props = { ...spec.props ?? {} };
|
|
323
|
+
for (const [name, p] of Object.entries(item.props)) {
|
|
324
|
+
const v = props[name];
|
|
325
|
+
if (p.type === "image" && typeof v === "string" && v.trim() && !isUrl(v)) props[name] = at(dir, v.trim());
|
|
326
|
+
}
|
|
327
|
+
const style = spec.style ? /\.(md|markdown|txt)$/i.test(spec.style) ? readFileSync4(at(dir, spec.style), "utf8") : spec.style : void 0;
|
|
328
|
+
const words = spec.words === void 0 ? void 0 : typeof spec.words === "string" ? readWords(at(dir, spec.words), spec.offset ?? 0) : normalizeWords(spec.words, spec.offset ?? 0);
|
|
329
|
+
const sound = spec.sound;
|
|
330
|
+
return {
|
|
331
|
+
props,
|
|
332
|
+
...spec.colors ? { colors: spec.colors } : {},
|
|
333
|
+
...style ? { style } : {},
|
|
334
|
+
...spec.aspect ? { aspect: parseAspect(spec.aspect) } : {},
|
|
335
|
+
...spec.duration ? { duration: spec.duration } : {},
|
|
336
|
+
...spec.fps ? { fps: spec.fps } : {},
|
|
337
|
+
...spec.width ? { width: spec.width } : {},
|
|
338
|
+
...spec.background ? { background: spec.background } : {},
|
|
339
|
+
...words?.length ? { words } : {},
|
|
340
|
+
...spec.beats ? { beats: spec.beats } : {},
|
|
341
|
+
...spec.motion ? { motion: spec.motion } : {},
|
|
342
|
+
...spec.mood ? { mood: spec.mood } : {},
|
|
343
|
+
...spec.seed !== void 0 ? { seed: spec.seed } : {},
|
|
344
|
+
...spec.speed ? { speed: spec.speed } : {},
|
|
345
|
+
...sound ? { sound: typeof sound === "object" && sound.level !== void 0 ? { level: sound.level } : true } : {},
|
|
346
|
+
...typeof sound === "object" && sound.bed ? { bed: sound.bed } : {}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function validateSpec(input, opts = {}) {
|
|
350
|
+
const dir = opts.dir ?? process.cwd();
|
|
351
|
+
const { spec, errors } = parseSpec(input);
|
|
352
|
+
if (!spec) return { ok: false, errors, warnings: [] };
|
|
353
|
+
const warnings = [];
|
|
354
|
+
const errs = [];
|
|
355
|
+
let item;
|
|
356
|
+
try {
|
|
357
|
+
item = itemOfSpec({ spec, dir }, opts.lib ?? library());
|
|
358
|
+
} catch (e) {
|
|
359
|
+
return { ok: false, errors: [{ path: spec.item ? "item" : spec.program ? "program" : "code", message: e.message }], warnings };
|
|
360
|
+
}
|
|
361
|
+
if (spec.style && /\.(md|markdown|txt)$/i.test(spec.style)) {
|
|
362
|
+
if (!existsSync4(at(dir, spec.style))) errs.push({ path: "style", message: `no DESIGN.md at ${spec.style} (relative to the spec)` });
|
|
363
|
+
} else if (spec.style && !BUILTIN_STYLES.some((s) => s.name === spec.style)) {
|
|
364
|
+
errs.push({ path: "style", message: `no built-in style "${spec.style}" \u2014 use one of ${BUILTIN_STYLES.map((s) => s.name).join(", ")}, or a path to a DESIGN.md` });
|
|
365
|
+
}
|
|
366
|
+
if (typeof spec.words === "string" && !existsSync4(at(dir, spec.words))) errs.push({ path: "words", message: `no transcript at ${spec.words} (relative to the spec)` });
|
|
367
|
+
if (spec.aspect) {
|
|
368
|
+
try {
|
|
369
|
+
const a = parseAspect(spec.aspect);
|
|
370
|
+
if (typeof a === "string" && item.aspects?.length && !item.aspects.includes(a)) warnings.push({ path: "aspect", message: `${item.ref} is designed for ${item.aspects.join(", ")}; ${a} may not lay out well` });
|
|
371
|
+
} catch (e) {
|
|
372
|
+
errs.push({ path: "aspect", message: e.message });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (spec.duration && item.duration) {
|
|
376
|
+
const { min, max } = item.duration;
|
|
377
|
+
if (min !== void 0 && spec.duration < min || max !== void 0 && spec.duration > max) warnings.push({ path: "duration", message: `${item.ref} is designed for ${min ?? 0}\u2013${max ?? "\u221E"} s; ${spec.duration} s will be clamped or look rushed` });
|
|
378
|
+
}
|
|
379
|
+
const props = { ...spec.props ?? {} };
|
|
380
|
+
for (const [name, p] of Object.entries(item.props)) {
|
|
381
|
+
const v = props[name];
|
|
382
|
+
if (p.type === "image" && typeof v === "string" && v.trim() && !isUrl(v) && !existsSync4(at(dir, v))) errs.push({ path: `props.${name}`, message: `no image at ${v} (relative to the spec)` });
|
|
383
|
+
}
|
|
384
|
+
for (const w of resolveProps(item, props).warnings) warnings.push({ path: `props${propName(w)}`, message: w });
|
|
385
|
+
for (const w of normalizeMotion({ parts: spec.motion, mood: spec.mood, seed: spec.seed, speed: spec.speed }, Object.keys(item.parts ?? {})).warnings) warnings.push({ path: "motion", message: w });
|
|
386
|
+
return { ok: errs.length === 0, errors: errs, warnings, item: item.ref };
|
|
387
|
+
}
|
|
388
|
+
function propName(w) {
|
|
389
|
+
const m = /^unknown prop "([^"]+)"/.exec(w) ?? /^([A-Za-z0-9_]+) /.exec(w);
|
|
390
|
+
return m ? `.${m[1]}` : "";
|
|
391
|
+
}
|
|
392
|
+
function formatIssues(list, mark = "\u2717") {
|
|
393
|
+
return list.map((i) => ` ${mark} ${i.path ? `${i.path}: ` : ""}${i.message}`).join("\n");
|
|
394
|
+
}
|
|
395
|
+
function specFrom(fields) {
|
|
396
|
+
const out = { $schema: "https://volito.dev/schema/graphic.v1.json", volito: SPEC_VERSION };
|
|
397
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
398
|
+
if (v === void 0) continue;
|
|
399
|
+
if (v && typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0) continue;
|
|
400
|
+
out[k] = v;
|
|
401
|
+
}
|
|
402
|
+
return out;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/guide.ts
|
|
406
|
+
var MOTION_GUIDE = `Motion: any part, any move
|
|
407
|
+
|
|
408
|
+
Every graphic is made of named parts (volito info <item> lists them). Each part enters, may idle
|
|
409
|
+
while it holds, may take emphasis hits, and leaves \u2014 and every one of those is yours to change:
|
|
410
|
+
|
|
411
|
+
in / out any move (volito moves): rise, slam, wipe, iris, glitch, scramble, typewriter,
|
|
412
|
+
spin, swing, stretch, smear, blur, flip, bounce \u2026 An exit plays the move backwards.
|
|
413
|
+
ease brand, out, inOut, expo, back, elastic, bounce, snappy, punchy, bouncy, calm, wobbly,
|
|
414
|
+
spring(freq, damping), steps(n), cubic(x1,y1,x2,y2)
|
|
415
|
+
from the side a move comes from (left, right, up, down, up-left \u2026); outFrom for the exit
|
|
416
|
+
dur, delay seconds; at / outAt replace the item's own times (outAt negative = from the end)
|
|
417
|
+
distance how far it travels (px at 1080) \xB7 intensity 0\u20132 scales distance, size and rotation
|
|
418
|
+
blur true: motion blur on the way in and out \xB7 origin: the pivot (center, top-left, bottom \u2026)
|
|
419
|
+
idle a loop while it holds: float, bob, pulse, breathe, wiggle, sway, drift, jitter, spin, heartbeat, shimmer
|
|
420
|
+
emphasis hits: "pulse@1.2, shake@now, jump@beatName" (a time, a beat, or a spoken word)
|
|
421
|
+
split text by lines, words or letters \xB7 order forward | reverse | center | edges | random \xB7 stagger s
|
|
422
|
+
keys your own keyframes: [{"t":0,"x":-300,"op":0},{"t":0.8,"x":0,"op":1,"ease":"bouncy"}]
|
|
423
|
+
(t in clip seconds, x/y px at 1080, s scale, r degrees, skx, op, blur) \u2014 any path you like
|
|
424
|
+
|
|
425
|
+
Set them per part, or for every part with "*":
|
|
426
|
+
CLI --motion name.in=typewriter --motion "*.ease=bouncy" --motion plate.out=wipe --motion role.emphasis=pulse@2
|
|
427
|
+
API { motion: { "*": { ease: "bouncy" }, name: { in: "typewriter" } } }
|
|
428
|
+
|
|
429
|
+
Or let a mood choreograph it: --mood energetic | elegant | playful | cinematic | glitchy | minimal | retro | kinetic,
|
|
430
|
+
and --seed <n> for a new take every number (a seed alone picks a mood too). Your own settings still win.
|
|
431
|
+
--speed 2 plays every move twice as fast.
|
|
432
|
+
|
|
433
|
+
In your own code the same kit is there: const M = motion($, t);
|
|
434
|
+
M.part(name, { at, box: {x,y,w,h}, in, out, \u2026 }, ...markup) a part, posed by its motion
|
|
435
|
+
M.text(name, { ...$.text options, at, in, split, \u2026 }, text) text, whole or by unit
|
|
436
|
+
M.layout(text, textOptions, split) \u2192 { units: [{text, x, y, w, h, line, index, box}], box }
|
|
437
|
+
M.progress(name, { at, in, dur, ease }) \u2192 { in, out, at, end, outAt } for things you draw yourself
|
|
438
|
+
Give every part a box when you can: wipes, iris, shutter and blinds need it, and moods pick from more moves.`;
|
|
439
|
+
var LIBRARY_GUIDE = `Libraries: layout, patterns, 3D and maps
|
|
440
|
+
|
|
441
|
+
Layout (always there)
|
|
442
|
+
safeArea($) \u2192 {x, y, w, h, tall, square, wide}: the title-safe box (on 9:16 it clears the platforms' buttons)
|
|
443
|
+
fitFont($, str, role, size, maxW, extra) \u2192 a font in the brand's role, shrunk until str fits maxW
|
|
444
|
+
fitLines($, str, font, maxW, maxSize, minSize, maxLines) \u2192 {size, lines, font}: the biggest size that sets in \u2264 maxLines
|
|
445
|
+
landOn($, str, fallback, after) \u2192 when the first spoken word of str starts (0.08 s early), else fallback
|
|
446
|
+
widest($, lines, font) \xB7 avatar($, imageProp, cx, cy, r, {name, fill, ring}) \xB7 appIcon($, icon, x, y, size, {fill}) \xB7 initials(name)
|
|
447
|
+
|
|
448
|
+
Patterns (always there) \u2014 sixteen animated grounds in the brand's colours, pure functions of t:
|
|
449
|
+
$.pattern(t, kind, {box, color, color2, a (strength), scale, speed, angle, at: [x, y], count, seed, r}) \u2192 markup
|
|
450
|
+
kinds: dots \xB7 plus \xB7 graph \xB7 hatch \xB7 stripes \xB7 checker \xB7 halftone \xB7 dither \xB7 waves \xB7 rings \xB7 mesh \xB7 confetti \xB7 bauhaus \xB7 tiles \xB7 pills \xB7 sunburst
|
|
451
|
+
A style whose texture names one draws it on every $.panel / $.texture ground. confetti, bauhaus and pills keep to the edges.
|
|
452
|
+
|
|
453
|
+
Faces: Inter, Space Grotesk, Space Mono, Poppins, Playfair Display, Montserrat, Anton, Outfit, Gabarito, Caveat, Press Start 2P,
|
|
454
|
+
Permanent Marker, Patrick Hand, Fredoka, IM Fell English, Bebas Neue, DM Serif Display, Archivo Black, Syne, Unbounded,
|
|
455
|
+
Righteous, Orbitron, Pacifico, Fraunces, Bricolage Grotesque \u2014 name them "Family:weight" (e.g. "Fraunces:900", "Syne:800").
|
|
456
|
+
|
|
457
|
+
3D \u2014 real depth, light and a camera, drawn as SVG paths (no GPU). Call it and the library comes with your program:
|
|
458
|
+
const S = scene3d($, { camera: { fov: 35, distance, yaw, pitch, roll, target: [x, y, z], x, y }, light: { dir: [x, y, z], ambient, spec, shine } });
|
|
459
|
+
Coordinates: x right, y down, z away from you, px at the target's depth (z = 0 draws at 1 px per unit by default).
|
|
460
|
+
Meshes: S.text(str, {face: $.font("display"), size, depth, color, side, back, anchor, ls, each: (i, n, ch) => ({rotate, offset, scale, hide})})
|
|
461
|
+
S.box(w, h, d, {color, side, back, radius}) \xB7 S.card(w, h, {depth, radius, color, back}) \xB7 S.disc(r, d, {...})
|
|
462
|
+
S.shape(loops | "M\u2026 path data", depth, {scale, color, side}) \xB7 S.sphere(r, {color}) (smooth) or {colorAt(lat, lon)}
|
|
463
|
+
Place: S.add(mesh, {position, rotate: [x\xB0, y\xB0, z\xB0], scale, parent: {position, rotate, parent}, opacity, flat, decal, twoSided})
|
|
464
|
+
\u2014 parent moves text with the card it is printed on; decal draws it over that card's face.
|
|
465
|
+
Also: S.line(points3d, {stroke, width}) \xB7 S.at(point3d, (x, y, scale) => markup) flat labels at 3D points \xB7 S.project(p) \u2192 {x, y, z, s}
|
|
466
|
+
S.measure(str, {face, size}) \xB7 S.clip(poly, normal, point)
|
|
467
|
+
Finish with: return S.render(); 3D text uses the brand's display face (an item lists others in "faces3d").
|
|
468
|
+
|
|
469
|
+
Maps (Natural Earth, 177 countries). Call one and the library comes with your program:
|
|
470
|
+
worldFind("India" | "UK" | "USA" | 356) \u2192 {name, id, c: [lon, lat], rings}
|
|
471
|
+
const m = worldMap($, { x, y, w, h, center: [lon, lat], zoom }); m.path(country | name) \u2192 path data \xB7 m.project(lon, lat) \u2192 [x, y]
|
|
472
|
+
m.countries \xB7 m.outline (the map's edge) \xB7 m.clipId() (clip to it) \xB7 m.ring(points)
|
|
473
|
+
worldGlobe(S, { r, spin, tilt, colorOf: (country) => colour }) countries on a 3D globe \xB7 worldOnSphere(lon, lat, r, spin, tilt) \u2192 a 3D point`;
|
|
474
|
+
var SOUND_GUIDE = `Sound: every graphic can carry its own
|
|
475
|
+
|
|
476
|
+
A render with --sound (API: sound: true, MCP: sound) gets a stereo track synthesised by Volito itself \u2014 no
|
|
477
|
+
samples, no files to find \u2014 muxed into the .mp4 / .mov / .webm (PNG frames and GIF carry none; --sound-out
|
|
478
|
+
file.wav writes the track alone, for an editor's timeline). Two sources, both deterministic:
|
|
479
|
+
|
|
480
|
+
1. The graphic's own motion. Every part's move leaves a mark the sound design reads: a spring pops, a move
|
|
481
|
+
across the frame whooshes (louder the further it travels), a slam hits, letters tick, a typewriter types,
|
|
482
|
+
a glitch zaps, a burst sparkles. Items nobody wrote sound for sound right out of the box.
|
|
483
|
+
2. A program's sound($). Beside frame(t, $) a program may define
|
|
484
|
+
|
|
485
|
+
function sound($) {
|
|
486
|
+
return {
|
|
487
|
+
cues: [
|
|
488
|
+
{ at: 0.2, kind: "riser", dur: 1.1 }, // seconds into the clip; -1 counts from the end
|
|
489
|
+
{ at: $.beat("in", 1.3), kind: "hit", level: 1.1 }, // lands with the motion: use the same times
|
|
490
|
+
{ at: 1.5, kind: "shimmer", dur: 1.2, pan: 0.3 },
|
|
491
|
+
{ at: 2.0, kind: "ding", note: "A5", level: 0.6 },
|
|
492
|
+
],
|
|
493
|
+
bed: { key: "D", mode: "minor", from: 0.8, to: -0.4, level: 0.5, tone: 0.4 }, // a pad under it all (true, "Dm", or none)
|
|
494
|
+
auto: false, // true adds the cues read from the motion on top (the default only when no cues are given)
|
|
495
|
+
level: 1, // master, 0\u20132
|
|
496
|
+
room: 0.25, // the reverb, 0 dry \u2026 1 wet
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
It runs once with $ at t = 0, so $.dur, $.beat(name), $.at(word), $.data and $.words are all there \u2014 time
|
|
501
|
+
the sound off the same beats the picture uses. Returning just an array of cues is fine too.
|
|
502
|
+
|
|
503
|
+
Cues: { at, kind, dur, level (0\u20132, 1 = the kind's own), pitch (semitones), note ("A4", tone/chord/ding), pan (-1\u20261), seed }
|
|
504
|
+
Kinds: whoosh (air past, dur .45) \xB7 hit (impact) \xB7 stamp (heavy slam) \xB7 pop (bubble) \xB7 tick (tiny click) \xB7 click (UI)
|
|
505
|
+
riser (climbs into a moment, dur 1) \xB7 fall (downlifter, dur .8) \xB7 sub (low drop) \xB7 shimmer (sparkle, dur 1.2)
|
|
506
|
+
ding (bell) \xB7 swell (soft air, dur .8) \xB7 zap (glitch, dur .28) \xB7 type (keys for dur) \xB7 scratch (pencil for dur)
|
|
507
|
+
tone (a note for dur) \xB7 chord (the bed's chord for dur)
|
|
508
|
+
The bed steps back under every hit, stamp, sub and pop and comes back on its own. Keep a graphic's sound honest:
|
|
509
|
+
one riser into its biggest moment, a hit or a sub when it lands, ticks for the small things, silence between.
|
|
510
|
+
|
|
511
|
+
CLI volito render <item> --sound [--bed Dm] [--sound-level 1.2] [--sound-out track.wav]
|
|
512
|
+
volito sound <item> --out track.wav the track alone, with its cue list
|
|
513
|
+
API renderVideo(item, { sound: true, bed: "Dm" }) \xB7 renderSoundFile(item, { out: "track.wav" })`;
|
|
514
|
+
var AUTHORING_GUIDE = `Writing Volito graphics
|
|
515
|
+
|
|
516
|
+
A graphic is a program: plain JavaScript that defines function frame(t, $) and returns the SVG markup of
|
|
517
|
+
one frame (no <svg> root). It is a pure function of t \u2014 any frame can be drawn alone, in any order \u2014 so it
|
|
518
|
+
renders the same in MONARC PRO, in a browser and from this CLI.
|
|
519
|
+
|
|
520
|
+
Props and moments
|
|
521
|
+
An item's props arrive as $.data (const p = $.data; p.name, p.role \u2026). Never hard-code text a creator
|
|
522
|
+
would change: make it a prop with a default and a maxLength the design can hold.
|
|
523
|
+
Named moments come from $.beat(name, fallback). Pack items read "in" (when the entrance starts) and
|
|
524
|
+
"out" (when the exit starts) so a creator can move them: --beat in=0.4 --beat out=-0.6.
|
|
525
|
+
When words are given (a transcript), land each element 0.04\u20130.12 s before the word that says it:
|
|
526
|
+
$.words.length ? $.atOr(firstWord, fallback) : fallback. Without words, use the beats.
|
|
527
|
+
|
|
528
|
+
Parts
|
|
529
|
+
Draw each element through the motion kit (const M = motion($, t)) as a named part, with the item's own
|
|
530
|
+
motion as defaults: M.part("plate", { at, in: "wipe", box }, markup), M.text("name", { ...font, at, in: "rise" }, text).
|
|
531
|
+
List the parts in item.json ("parts": { "plate": "the card" }) so creators know what they can move.
|
|
532
|
+
Backgrounds (scrims, panels) pass vary: false so a mood leaves them still.
|
|
533
|
+
|
|
534
|
+
Shape of a good item
|
|
535
|
+
- Enter in the first ~0.8 s, hold, leave in the last ~0.4 s (or hold to the end when out \u2265 duration).
|
|
536
|
+
- Lay out from $.W, $.H and scale every size by $.k (= min(W, H) / 1080): one design fits 16:9, 9:16, 1:1, 4:5.
|
|
537
|
+
- Keep text inside the title-safe area: ~5% of the width at the sides on 16:9; on 9:16 leave ~13% at the
|
|
538
|
+
top and ~22% at the bottom for platform UI (captions, buttons).
|
|
539
|
+
- Size text to fit: measure it ($.measure) and shrink the size until it fits the box it owns; wrap long
|
|
540
|
+
text with maxW. The check reports any line that runs off the frame.
|
|
541
|
+
- Colour comes from the brand: $.c.bg / $.c.fg for ground and text, $.c.accent for the one thing that
|
|
542
|
+
matters, $.c.accent2 for markers, $.c.surface for cards. Faces from $.font("display" | "text" | "mono").
|
|
543
|
+
$.case(str) applies the brand's case. Never invent colours \u2014 a creator's DESIGN.md re-skins the item.
|
|
544
|
+
- overlay items draw nothing behind themselves (footage shows through); put a $.scrim under text that sits
|
|
545
|
+
on footage. full items draw their own ground with $.panel(t, { box: { x: 0, y: 0, w: $.W, h: $.H } }).
|
|
546
|
+
- Escape any text you put in raw markup with $.esc(); $.text, $.chip and friends escape for you.
|
|
547
|
+
|
|
548
|
+
An item on disk
|
|
549
|
+
my-item/item.json { "id": "my-item", "name": "\u2026", "description": "\u2026", "category": "title",
|
|
550
|
+
"placement": "overlay" | "full", "duration": { "default": 5, "min": 2, "max": 20 },
|
|
551
|
+
"aspects": ["16:9", "9:16", "1:1", "4:5"],
|
|
552
|
+
"props": { "title": { "type": "text", "default": "Hello", "maxLength": 28 } },
|
|
553
|
+
"beats": { "in": 0.15, "out": -0.45 } }
|
|
554
|
+
my-item/frame.js function frame(t, $) { const p = $.data; \u2026 return svg; }
|
|
555
|
+
Prop types: text, longtext (line breaks kept), number (min, max, step), boolean, choice (options),
|
|
556
|
+
color (#hex, "" = the brand's), list (rows of text, or of {field: "text" | "number"}; maxItems),
|
|
557
|
+
image (a path or URL the caller loads; draw it with $.image(propName, x, y, w, h)), icon (an icon name).
|
|
558
|
+
|
|
559
|
+
A pack
|
|
560
|
+
my-pack/pack.json { "format": 1, "id": "my-pack", "name": "\u2026", "version": "1.0.0", "description": "\u2026",
|
|
561
|
+
"author": "\u2026", "license": "\u2026", "style": "ember", "shared": "shared.js",
|
|
562
|
+
"items": ["my-item", \u2026] }
|
|
563
|
+
my-pack/shared.js helpers prepended to every item's code (function declarations only).
|
|
564
|
+
Load extra packs with --packs <dir> or VOLITO_PACKS=<dir>.
|
|
565
|
+
|
|
566
|
+
The loop
|
|
567
|
+
volito new my-item a template to start from
|
|
568
|
+
volito check ./my-item errors and warnings at every aspect
|
|
569
|
+
volito sheet ./my-item frames across time in one PNG \u2014 look at it
|
|
570
|
+
volito render ./my-item --format mov
|
|
571
|
+
Agents: motion_check, then motion_preview, fix what you see, then motion_render.`;
|
|
572
|
+
|
|
573
|
+
export {
|
|
574
|
+
loadPack,
|
|
575
|
+
builtinPacks,
|
|
576
|
+
packsIn,
|
|
577
|
+
library,
|
|
578
|
+
findItem,
|
|
579
|
+
searchItems,
|
|
580
|
+
SPEC_VERSION,
|
|
581
|
+
SPEC_SCHEMA_URL,
|
|
582
|
+
GraphicSpecSchema,
|
|
583
|
+
parseSpec,
|
|
584
|
+
specJsonSchema,
|
|
585
|
+
normalizeWords,
|
|
586
|
+
readWords,
|
|
587
|
+
loadImage,
|
|
588
|
+
loadMedia,
|
|
589
|
+
readStyleArg,
|
|
590
|
+
isSpecFile,
|
|
591
|
+
loadSpecFile,
|
|
592
|
+
itemOfSpec,
|
|
593
|
+
specToOptions,
|
|
594
|
+
validateSpec,
|
|
595
|
+
formatIssues,
|
|
596
|
+
specFrom,
|
|
597
|
+
MOTION_GUIDE,
|
|
598
|
+
LIBRARY_GUIDE,
|
|
599
|
+
SOUND_GUIDE,
|
|
600
|
+
AUTHORING_GUIDE
|
|
601
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// src/node/paths.ts
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
var PACKAGE_NAME = "volito";
|
|
6
|
+
var root = null;
|
|
7
|
+
function packageRoot() {
|
|
8
|
+
if (root) return root;
|
|
9
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
for (let i = 0; i < 6; i++) {
|
|
11
|
+
const pj = join(dir, "package.json");
|
|
12
|
+
if (existsSync(pj)) {
|
|
13
|
+
try {
|
|
14
|
+
if (JSON.parse(readFileSync(pj, "utf8")).name === PACKAGE_NAME) return root = dir;
|
|
15
|
+
} catch {
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
dir = dirname(dir);
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`${PACKAGE_NAME}: cannot find its package folder from ${fileURLToPath(import.meta.url)}`);
|
|
21
|
+
}
|
|
22
|
+
var fontsDir = () => join(packageRoot(), "fonts");
|
|
23
|
+
var packsDir = () => join(packageRoot(), "packs");
|
|
24
|
+
var libsDir = () => join(packageRoot(), "libs");
|
|
25
|
+
function packageVersion() {
|
|
26
|
+
return JSON.parse(readFileSync(join(packageRoot(), "package.json"), "utf8")).version;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// src/node/ffmpeg.ts
|
|
30
|
+
import { execFileSync } from "node:child_process";
|
|
31
|
+
var probed;
|
|
32
|
+
function ffmpegPath() {
|
|
33
|
+
return process.env.VOLITO_FFMPEG || process.env.MONARC_MOTION_FFMPEG || "ffmpeg";
|
|
34
|
+
}
|
|
35
|
+
function probeFfmpeg() {
|
|
36
|
+
if (probed !== void 0) return probed;
|
|
37
|
+
const path = ffmpegPath();
|
|
38
|
+
try {
|
|
39
|
+
const out = execFileSync(path, ["-hide_banner", "-encoders"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15e3 });
|
|
40
|
+
const encoders = /* @__PURE__ */ new Set();
|
|
41
|
+
for (const line of out.split("\n")) {
|
|
42
|
+
const m = /^\s*[VAS][A-Z.]{5}\s+(\S+)/.exec(line);
|
|
43
|
+
if (m) encoders.add(m[1]);
|
|
44
|
+
}
|
|
45
|
+
probed = { path, encoders };
|
|
46
|
+
} catch {
|
|
47
|
+
probed = null;
|
|
48
|
+
}
|
|
49
|
+
return probed;
|
|
50
|
+
}
|
|
51
|
+
var FfmpegMissing = class extends Error {
|
|
52
|
+
constructor(what) {
|
|
53
|
+
super(`${what} needs ffmpeg \u2014 install it (macOS: brew install ffmpeg \xB7 Debian/Ubuntu: apt install ffmpeg \xB7 Windows: winget install ffmpeg) or set VOLITO_FFMPEG to its path. PNG sequences and stills need no ffmpeg.`);
|
|
54
|
+
this.name = "FfmpegMissing";
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
packageRoot,
|
|
60
|
+
fontsDir,
|
|
61
|
+
packsDir,
|
|
62
|
+
libsDir,
|
|
63
|
+
packageVersion,
|
|
64
|
+
ffmpegPath,
|
|
65
|
+
probeFfmpeg,
|
|
66
|
+
FfmpegMissing
|
|
67
|
+
};
|