@pixerate/editor 0.61.0 → 0.63.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/THIRD_PARTY_NOTICES.md +3 -0
- package/dist/animation-editor/index.cjs +226 -44
- package/dist/animation-editor/index.cjs.map +1 -1
- package/dist/animation-editor/index.d.cts +92 -3
- package/dist/animation-editor/index.d.ts +92 -3
- package/dist/animation-editor/index.js +201 -19
- package/dist/animation-editor/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -149,6 +149,8 @@ var MOTION_ISSUE_CODES = {
|
|
|
149
149
|
"out-of-frame": { severity: "warning", wording: "A node a command animates ends up outside the camera." },
|
|
150
150
|
"too-fast": { severity: "warning", wording: "A change is too quick to follow." },
|
|
151
151
|
"spring-cut-short": { severity: "warning", wording: "A spring is given a duration shorter than it takes to settle, so it jumps to its end." },
|
|
152
|
+
"unknown-cue": { severity: "error", wording: "A shot is cued to a beat with no beat grid, or to a word the narration does not have." },
|
|
153
|
+
"cue-missed": { severity: "warning", wording: "A shot starts after its cue, because the shot before runs past it." },
|
|
152
154
|
"scene-too-long": { severity: "warning", wording: "The scene runs past its target length." },
|
|
153
155
|
"scene-too-short": { severity: "warning", wording: "The scene ends before its target length." },
|
|
154
156
|
"unknown-control-color": { severity: "warning", wording: "A control stands for a colour the scene never uses." },
|
|
@@ -206,8 +208,8 @@ ${problems.map((p) => `- ${p}`).join("\n")}`);
|
|
|
206
208
|
this.problems = problems;
|
|
207
209
|
}
|
|
208
210
|
};
|
|
209
|
-
var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "loop", "fps", "steps", "target", "controls", "presets"];
|
|
210
|
-
var SHOT_KEYS = ["id", "label", "camera", "run", "hold"];
|
|
211
|
+
var SCENE_KEYS = ["version", "name", "description", "artwork", "shots", "loop", "fps", "steps", "beat", "narration", "target", "controls", "presets"];
|
|
212
|
+
var SHOT_KEYS = ["id", "label", "camera", "at", "run", "hold", "speed"];
|
|
211
213
|
var CAMERA_KEYS = ["x", "y", "width", "height", "frame", "padding", "ease", "ms", "geometric"];
|
|
212
214
|
var BASE_KEYS = ["type", "targets", "duration", "delay", "stagger", "with", "ease", "note", "repeat", "repeatMode", "steps"];
|
|
213
215
|
var COMMAND_KEYS = {
|
|
@@ -360,6 +362,17 @@ function shotShapeProblems(shot, where) {
|
|
|
360
362
|
const name = typeof shot.id === "string" ? `Shot "${shot.id}"` : where;
|
|
361
363
|
if (shot.label !== void 0 && typeof shot.label !== "string") problems.push(`${name}: "label" must be a string.`);
|
|
362
364
|
if (shot.hold !== void 0 && !(isNum(shot.hold) && shot.hold >= 0)) problems.push(`${name}: "hold" must be a number of milliseconds \u2265 0.`);
|
|
365
|
+
if (shot.speed !== void 0 && !(isNum(shot.speed) && shot.speed >= 0.05 && shot.speed <= 20)) problems.push(`${name}: "speed" must be a number from 0.05 to 20 (2 plays it twice as fast).`);
|
|
366
|
+
if (shot.at !== void 0) {
|
|
367
|
+
const at = shot.at;
|
|
368
|
+
const given = isObject(at) ? ["beat", "word", "ms"].filter((k) => at[k] !== void 0) : [];
|
|
369
|
+
if (!isObject(at) || given.length !== 1) problems.push(`${name}: "at" must give one of { "beat": number }, { "word": index } or { "ms": number }.`);
|
|
370
|
+
else {
|
|
371
|
+
unknownKeys(at, ["beat", "word", "ms"], `${name} at`, problems);
|
|
372
|
+
const k = given[0];
|
|
373
|
+
if (!isNum(at[k]) || at[k] < 0 || k === "word" && !Number.isInteger(at[k])) problems.push(`${name}: "at.${k}" must be ${k === "word" ? "a word index \u2265 0" : "a number \u2265 0"}.`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
363
376
|
if (shot.camera !== void 0) {
|
|
364
377
|
const c = shot.camera;
|
|
365
378
|
if (!isObject(c)) problems.push(`${name}: "camera" must be an object.`);
|
|
@@ -390,6 +403,16 @@ function motionSceneShapeProblems(input) {
|
|
|
390
403
|
problems.push('Scene: "artwork" must be a vector document { width, height, nodes }.');
|
|
391
404
|
if (input.loop !== void 0 && !["none", "loop", "ping-pong"].includes(input.loop)) problems.push('Scene: "loop" must be "none", "loop" or "ping-pong".');
|
|
392
405
|
if (input.fps !== void 0 && !(isNum(input.fps) && input.fps > 0 && input.fps <= 120)) problems.push('Scene: "fps" must be a number from 1 to 120.');
|
|
406
|
+
if (input.beat !== void 0) {
|
|
407
|
+
const b = input.beat;
|
|
408
|
+
if (!isObject(b) || !(isNum(b.bpm) && b.bpm >= 1 && b.bpm <= 1e3) || b.offset !== void 0 && !isNum(b.offset) || b.meter !== void 0 && !(Number.isInteger(b.meter) && b.meter >= 1))
|
|
409
|
+
problems.push('Scene: "beat" must be { "bpm": 1\u20131000, "offset"?: ms, "meter"?: beats a bar }.');
|
|
410
|
+
}
|
|
411
|
+
if (input.narration !== void 0) {
|
|
412
|
+
const n = input.narration;
|
|
413
|
+
if (!isObject(n) || !Array.isArray(n.words) || !n.words.every((w, i, all) => isObject(w) && typeof w.text === "string" && isNum(w.start) && w.start >= 0 && (w.end === void 0 || isNum(w.end) && w.end >= w.start) && (i === 0 || w.start >= (all[i - 1].start ?? 0))))
|
|
414
|
+
problems.push('Scene: "narration" must be { "words": [{ "text", "start": ms, "end"?: ms }, \u2026] } in time order.');
|
|
415
|
+
}
|
|
393
416
|
if (input.steps !== void 0 && !(isNum(input.steps) && input.steps > 0 && input.steps <= 120)) problems.push('Scene: "steps" must be frames per second from above 0 to 120.');
|
|
394
417
|
if (input.target !== void 0 && !(Array.isArray(input.target) && input.target.length === 2 && input.target.every(isNum) && input.target[0] <= input.target[1]))
|
|
395
418
|
problems.push('Scene: "target" must be [min, max] seconds.');
|
|
@@ -695,6 +718,24 @@ function motionPathLength(node, parentWorld = IDENTITY_MATRIX) {
|
|
|
695
718
|
}
|
|
696
719
|
return longest;
|
|
697
720
|
}
|
|
721
|
+
function motionShotTimeScale(shot) {
|
|
722
|
+
return typeof shot.speed === "number" && shot.speed > 0 && Number.isFinite(shot.speed) ? 1 / shot.speed : 1;
|
|
723
|
+
}
|
|
724
|
+
function motionBeatTime(grid, beat) {
|
|
725
|
+
return (grid.offset ?? 0) + beat * 6e4 / grid.bpm;
|
|
726
|
+
}
|
|
727
|
+
function motionBeatAt(grid, ms) {
|
|
728
|
+
const beat = (ms - (grid.offset ?? 0)) * grid.bpm / 6e4;
|
|
729
|
+
const meter = Math.max(1, Math.round(grid.meter ?? 4));
|
|
730
|
+
const whole = Math.floor(beat + 1e-9);
|
|
731
|
+
return { beat, bar: Math.floor(whole / meter) + 1, beatInBar: (whole % meter + meter) % meter + 1 };
|
|
732
|
+
}
|
|
733
|
+
function motionCueTime(scene, cue) {
|
|
734
|
+
if (typeof cue.ms === "number") return Math.max(0, cue.ms);
|
|
735
|
+
if (typeof cue.beat === "number") return scene.beat && scene.beat.bpm > 0 ? Math.max(0, motionBeatTime(scene.beat, cue.beat)) : void 0;
|
|
736
|
+
if (typeof cue.word === "number") return scene.narration?.words[cue.word]?.start;
|
|
737
|
+
return void 0;
|
|
738
|
+
}
|
|
698
739
|
function motionCommandRepeat(command2) {
|
|
699
740
|
if (command2.type === "wait" || command2.type === "enter" || command2.type === "exit") return 1;
|
|
700
741
|
const n = command2.repeat ?? 1;
|
|
@@ -732,12 +773,19 @@ function compileMotionScene(scene) {
|
|
|
732
773
|
const cameraMoves = [];
|
|
733
774
|
let t = 0;
|
|
734
775
|
scene.shots.forEach((shot, index) => {
|
|
776
|
+
const cue = shot.at ? motionCueTime(scene, shot.at) : void 0;
|
|
777
|
+
if (cue !== void 0 && cue > t) {
|
|
778
|
+
if (shots.length) shots[shots.length - 1].end = cue;
|
|
779
|
+
t = cue;
|
|
780
|
+
}
|
|
781
|
+
const missedBy = cue !== void 0 && t - cue > 0.5 ? t - cue : void 0;
|
|
735
782
|
const start = t;
|
|
736
783
|
let cursor = start;
|
|
784
|
+
const f = motionShotTimeScale(shot);
|
|
737
785
|
if (shot.camera) {
|
|
738
786
|
const rect = resolveMotionCamera(shot.camera, scene);
|
|
739
787
|
if (rect && rect.width > 0 && rect.height > 0) {
|
|
740
|
-
const ms = index === 0 ? 0 : shot.camera.ms ?? MOTION_CAMERA_MS;
|
|
788
|
+
const ms = index === 0 ? 0 : (shot.camera.ms ?? MOTION_CAMERA_MS) * f;
|
|
741
789
|
cameraMoves.push({ shot: shot.id, start, end: start + ms, to: rect, ease: shot.camera.ease ?? MOTION_EASE_IN_OUT, geometric: shot.camera.geometric === true });
|
|
742
790
|
cursor = start + ms;
|
|
743
791
|
}
|
|
@@ -747,17 +795,17 @@ function compileMotionScene(scene) {
|
|
|
747
795
|
let prevStart = cursor;
|
|
748
796
|
shot.run.forEach((command2, i) => {
|
|
749
797
|
const base = command2.with === "previous" && i > 0 ? prevStart : runEnd;
|
|
750
|
-
const begin = base + (command2.type === "wait" ? 0 : command2.delay ?? 0);
|
|
798
|
+
const begin = base + (command2.type === "wait" ? 0 : (command2.delay ?? 0) * f);
|
|
751
799
|
const targets = command2.type === "wait" ? [] : command2.targets;
|
|
752
|
-
const durations = command2.type === "wait" ? [command2.duration] : targets.map((id) => motionCommandDuration(command2, scene, id));
|
|
753
|
-
const stagger = command2.type === "wait" ? 0 : command2.stagger ?? 0;
|
|
800
|
+
const durations = command2.type === "wait" ? [command2.duration * f] : targets.map((id) => motionCommandDuration(command2, scene, id) * f);
|
|
801
|
+
const stagger = command2.type === "wait" ? 0 : (command2.stagger ?? 0) * f;
|
|
754
802
|
const end2 = durations.reduce((m, d, k) => Math.max(m, begin + k * stagger + d), begin);
|
|
755
803
|
commands.push({ shot: shot.id, shotIndex: index, command: i, type: command2.type, start: begin, end: end2, duration: durations[0] ?? 0 });
|
|
756
804
|
prevStart = begin;
|
|
757
805
|
runEnd = Math.max(runEnd, end2);
|
|
758
806
|
});
|
|
759
|
-
const end = runEnd + (shot.hold ?? MOTION_DEFAULT_HOLD);
|
|
760
|
-
shots.push({ id: shot.id, index, ...shot.label ? { label: shot.label } : {}, start, commandsStart, commandsEnd: runEnd, end });
|
|
807
|
+
const end = runEnd + (shot.hold ?? MOTION_DEFAULT_HOLD) * f;
|
|
808
|
+
shots.push({ id: shot.id, index, ...shot.label ? { label: shot.label } : {}, start, commandsStart, commandsEnd: runEnd, end, ...cue !== void 0 ? { cue } : {}, ...missedBy !== void 0 ? { missedBy } : {} });
|
|
761
809
|
t = end;
|
|
762
810
|
});
|
|
763
811
|
const tracks = /* @__PURE__ */ new Map();
|
|
@@ -789,8 +837,9 @@ function compileMotionScene(scene) {
|
|
|
789
837
|
command2.targets.forEach((target, k) => {
|
|
790
838
|
const entry = nodes.get(target);
|
|
791
839
|
if (!entry) return;
|
|
792
|
-
const
|
|
793
|
-
const
|
|
840
|
+
const f = motionShotTimeScale(shot);
|
|
841
|
+
const start = timing.start + k * (command2.stagger ?? 0) * f;
|
|
842
|
+
const cycle = motionCycleDuration(command2, scene, target) * f;
|
|
794
843
|
jobs.push({ start, order: order++, run: () => compileRepeated(command2, target, entry, start, cycle, timing) });
|
|
795
844
|
});
|
|
796
845
|
}
|
|
@@ -1360,6 +1409,17 @@ function checkMotionScene(scene) {
|
|
|
1360
1409
|
}
|
|
1361
1410
|
});
|
|
1362
1411
|
});
|
|
1412
|
+
scene.shots.forEach((shot, i) => {
|
|
1413
|
+
if (!shot.at) return;
|
|
1414
|
+
const timing = timeline.shots[i];
|
|
1415
|
+
if (timing.cue === void 0) {
|
|
1416
|
+
const what = shot.at.beat !== void 0 ? `beat ${shot.at.beat}, but the scene has no beat grid; set one with set_animation_settings ({ "beat": { "bpm": \u2026 } })` : `word ${shot.at.word}, but the narration has ${scene.narration?.words.length ?? 0} words (indexes from 0)`;
|
|
1417
|
+
issues.push(motionIssue("unknown-cue", `Shot "${shot.id}" is cued to ${what}.`, { shot: shot.id }));
|
|
1418
|
+
} else if (timing.missedBy !== void 0) {
|
|
1419
|
+
const late = Math.round(timing.missedBy);
|
|
1420
|
+
issues.push(motionIssue("cue-missed", `Shot "${shot.id}" is cued at ${Math.round(timing.cue)} ms but starts ${late} ms late, because the shot before runs until ${Math.round(timing.start)} ms; shorten that shot (its hold, durations or speed) or cue this one later.`, { shot: shot.id, measured: late, needed: 0, unit: "ms" }));
|
|
1421
|
+
}
|
|
1422
|
+
});
|
|
1363
1423
|
for (const [key, track] of timeline.tracks) {
|
|
1364
1424
|
if (key.endsWith("\0add")) continue;
|
|
1365
1425
|
for (let i = 1; i < track.length; i++) {
|
|
@@ -1404,8 +1464,8 @@ function checkMotionScene(scene) {
|
|
|
1404
1464
|
if (scene.target && scene.shots.length) {
|
|
1405
1465
|
const seconds = Math.round(timeline.duration / 100) / 10;
|
|
1406
1466
|
const [min, max] = scene.target;
|
|
1407
|
-
if (seconds > max) issues.push(motionIssue("scene-too-long", `The scene runs ${seconds} s, past its ${max} s target; shorten holds or durations.`, { measured: seconds, needed: max, unit: "s" }));
|
|
1408
|
-
if (seconds < min) issues.push(motionIssue("scene-too-short", `The scene runs ${seconds} s, under its ${min} s target; hold longer or
|
|
1467
|
+
if (seconds > max) issues.push(motionIssue("scene-too-long", `The scene runs ${seconds} s, past its ${max} s target; shorten holds or durations, or fit it (fit_animation).`, { measured: seconds, needed: max, unit: "s" }));
|
|
1468
|
+
if (seconds < min) issues.push(motionIssue("scene-too-short", `The scene runs ${seconds} s, under its ${min} s target; hold longer, add shots, or fit it (fit_animation).`, { measured: seconds, needed: min, unit: "s" }));
|
|
1409
1469
|
}
|
|
1410
1470
|
return issues;
|
|
1411
1471
|
}
|
|
@@ -1461,6 +1521,14 @@ function applyOne(scene, edit) {
|
|
|
1461
1521
|
if (!(typeof edit.steps === "number" && edit.steps > 0 && edit.steps <= 120)) fail('"steps" must be frames per second from above 0 to 120 (12 is "on twos" at 24 fps), or null for smooth.');
|
|
1462
1522
|
next.steps = edit.steps;
|
|
1463
1523
|
}
|
|
1524
|
+
for (const key of ["beat", "narration"]) {
|
|
1525
|
+
if (edit[key] === null) delete next[key];
|
|
1526
|
+
else if (edit[key] !== void 0) next[key] = edit[key];
|
|
1527
|
+
}
|
|
1528
|
+
{
|
|
1529
|
+
const problems = motionSceneShapeProblems(next).filter((p) => /"beat"|"narration"/.test(p));
|
|
1530
|
+
if (problems.length) fail(problems.join(" "));
|
|
1531
|
+
}
|
|
1464
1532
|
if (edit.target === null) delete next.target;
|
|
1465
1533
|
else if (edit.target !== void 0) next.target = edit.target;
|
|
1466
1534
|
return next;
|
|
@@ -1590,6 +1658,34 @@ function createMotionScene(artwork, init = {}) {
|
|
|
1590
1658
|
return { version: 1, name: init.name ?? "animation", ...init, artwork, shots: init.shots ?? [] };
|
|
1591
1659
|
}
|
|
1592
1660
|
|
|
1661
|
+
// src/animation-editor/fit.ts
|
|
1662
|
+
var MOTION_SPEED_RANGE = [0.05, 20];
|
|
1663
|
+
function fitMotionScene(scene, ms, options = {}) {
|
|
1664
|
+
if (!(Number.isFinite(ms) && ms > 0)) throw new Error("Give the length to fit as a number of milliseconds above 0.");
|
|
1665
|
+
const ids = scene.shots.map((s) => s.id);
|
|
1666
|
+
const keep = new Set(options.keep ?? []);
|
|
1667
|
+
for (const id of keep) if (!ids.includes(id)) throw new Error(`There is no shot ${namedWithSuggestion(id, ids)} to keep.`);
|
|
1668
|
+
const timeline = compileMotionScene(scene);
|
|
1669
|
+
let kept = 0;
|
|
1670
|
+
let rest = 0;
|
|
1671
|
+
timeline.shots.forEach((t) => keep.has(t.id) ? kept += t.end - t.start : rest += t.end - t.start);
|
|
1672
|
+
if (rest <= 0) throw new Error(keep.size ? "Every shot is kept, so nothing can change pace; keep fewer shots." : "The scene has no length to fit; add shots first.");
|
|
1673
|
+
if (ms <= kept) throw new Error(`The kept shots alone last ${Math.round(kept)} ms, so the scene cannot fit ${Math.round(ms)} ms; keep fewer shots or give at least ${Math.ceil(kept) + 1} ms.`);
|
|
1674
|
+
const k = (ms - kept) / rest;
|
|
1675
|
+
const speeds = {};
|
|
1676
|
+
const shots = scene.shots.map((shot) => {
|
|
1677
|
+
if (keep.has(shot.id)) return shot;
|
|
1678
|
+
const speed = Math.round((shot.speed ?? 1) / k * 1e4) / 1e4;
|
|
1679
|
+
if (speed < MOTION_SPEED_RANGE[0] || speed > MOTION_SPEED_RANGE[1])
|
|
1680
|
+
throw new Error(`Fitting ${Math.round(ms)} ms would play shot "${shot.id}" at ${speed}\xD7 (speeds run ${MOTION_SPEED_RANGE[0]}\u2013${MOTION_SPEED_RANGE[1]}); aim nearer the current ${Math.round(timeline.duration)} ms.`);
|
|
1681
|
+
speeds[shot.id] = speed;
|
|
1682
|
+
const { speed: _old, ...rest2 } = shot;
|
|
1683
|
+
return speed === 1 ? rest2 : { ...rest2, speed };
|
|
1684
|
+
});
|
|
1685
|
+
const fitted = { ...scene, shots };
|
|
1686
|
+
return { scene: fitted, duration: compileMotionScene(fitted).duration, speeds };
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1593
1689
|
// src/animation-editor/flair.ts
|
|
1594
1690
|
var FLAIR_EASES = {
|
|
1595
1691
|
linear: (t) => t,
|
|
@@ -2460,7 +2556,13 @@ var SHOT_FIELDS = {
|
|
|
2460
2556
|
label: { type: "string", description: "What happens, in a few words." },
|
|
2461
2557
|
camera: CAMERA,
|
|
2462
2558
|
run: { type: "array", items: MOTION_COMMAND_SCHEMA, description: "Commands, in order." },
|
|
2463
|
-
hold: MS("Hold after the commands (default 600)")
|
|
2559
|
+
hold: MS("Hold after the commands (default 600)"),
|
|
2560
|
+
speed: { type: "number", minimum: 0.05, maximum: 20, description: "Play the whole shot this many times as fast (default 1; 0.5 is half speed). fit_animation sets it." },
|
|
2561
|
+
at: object(
|
|
2562
|
+
{ beat: { type: "number", minimum: 0, description: `Beat number on the scene's beat grid, from 0 (2.5 is the "and" of beat 3).` }, word: { type: "integer", minimum: 0, description: "Index of a narration word, from 0." }, ms: { type: "number", minimum: 0, description: "A time in ms." } },
|
|
2563
|
+
[],
|
|
2564
|
+
"Start this shot on a cue (give one of beat, word or ms); the shot before holds until then. get_timing_grid lists beats and words."
|
|
2565
|
+
)
|
|
2464
2566
|
};
|
|
2465
2567
|
var SHOT = object({ id: { type: "string", pattern: "^[A-Za-z0-9_][\\w.-]*$", description: 'Unique shot id, e.g. "intro".' }, ...SHOT_FIELDS }, ["id", "run"], "A shot.");
|
|
2466
2568
|
var CONTROL = object(
|
|
@@ -2480,6 +2582,20 @@ var SETTINGS = {
|
|
|
2480
2582
|
loop: { type: "string", enum: ["none", "loop", "ping-pong"], description: "Playback past the end (default none)." },
|
|
2481
2583
|
fps: { type: "number", minimum: 1, maximum: 120, description: "Sampling rate for exports (default 30)." },
|
|
2482
2584
|
steps: { anyOf: [{ type: "number", exclusiveMinimum: 0, maximum: 120 }, { type: "null" }], description: 'Stepped time for every command without its own: changes hold on a grid of this many frames per second (12 = "on twos" at 24 fps) for a hand-drawn look; null makes it smooth again (default).' },
|
|
2585
|
+
beat: {
|
|
2586
|
+
anyOf: [
|
|
2587
|
+
object({ bpm: { type: "number", minimum: 1, maximum: 1e3, description: "Beats a minute." }, offset: { type: "number", description: "When beat 0 falls, ms (default 0)." }, meter: { type: "integer", minimum: 1, description: "Beats a bar (default 4)." } }, ["bpm"]),
|
|
2588
|
+
{ type: "null" }
|
|
2589
|
+
],
|
|
2590
|
+
description: `A beat grid (music or a voiceover's rhythm) that shots can be cued to with "at": { "beat": n }; null clears.`
|
|
2591
|
+
},
|
|
2592
|
+
narration: {
|
|
2593
|
+
anyOf: [
|
|
2594
|
+
object({ words: { type: "array", items: object({ text: { type: "string", description: "The word." }, start: { type: "number", minimum: 0, description: "When it is said, ms." }, end: { type: "number", minimum: 0, description: "When it ends, ms." } }, ["text", "start"]), description: "Words in time order, from a TTS or speech-to-text word timing." } }, ["words"]),
|
|
2595
|
+
{ type: "null" }
|
|
2596
|
+
],
|
|
2597
|
+
description: `A voiceover's word timings that shots can be cued to with "at": { "word": index }; null clears.`
|
|
2598
|
+
},
|
|
2483
2599
|
target: { anyOf: [{ type: "array", items: { type: "number", minimum: 0 }, minItems: 2, maxItems: 2 }, { type: "null" }], description: "Wanted length [min, max] in seconds; null clears." }
|
|
2484
2600
|
};
|
|
2485
2601
|
var EDIT_OP = {
|
|
@@ -2487,7 +2603,7 @@ var EDIT_OP = {
|
|
|
2487
2603
|
oneOf: [
|
|
2488
2604
|
object({ op: { const: "set_settings", description: "Like set_animation_settings." }, ...SETTINGS }, ["op"]),
|
|
2489
2605
|
object({ op: { const: "insert_shots", description: "Like insert_shots." }, shots: { type: "array", items: SHOT, minItems: 1, description: "Shots to add." }, at: { type: "integer", minimum: 0, description: "Position (default: the end)." } }, ["op", "shots"]),
|
|
2490
|
-
object({ op: { const: "update_shot", description: "Like update_shot." }, shot: SHOT_ID, set: object({ id: SHOT.properties.id, ...SHOT_FIELDS }, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold"] }, description: "Fields to clear." } }, ["op", "shot"]),
|
|
2606
|
+
object({ op: { const: "update_shot", description: "Like update_shot." }, shot: SHOT_ID, set: object({ id: SHOT.properties.id, ...SHOT_FIELDS }, [], "Fields to replace."), remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold", "speed", "at"] }, description: "Fields to clear." } }, ["op", "shot"]),
|
|
2491
2607
|
object({ op: { const: "delete_shots", description: "Like delete_shots." }, shots: { type: "array", items: SHOT_ID, minItems: 1, description: "Shot ids." } }, ["op", "shots"]),
|
|
2492
2608
|
object({ op: { const: "move_shot", description: "Like move_shot." }, shot: SHOT_ID, to: { type: "integer", minimum: 0, description: "New position (0 = first)." } }, ["op", "shot", "to"]),
|
|
2493
2609
|
object({ op: { const: "add_commands", description: "Like add_commands." }, shot: SHOT_ID, commands: { type: "array", items: MOTION_COMMAND_SCHEMA, minItems: 1, description: "Commands to add." }, at: { type: "integer", minimum: 0, description: "Index in the shot's run (default: the end)." } }, ["op", "shot", "commands"]),
|
|
@@ -2532,6 +2648,8 @@ function describeMotionShots(scene) {
|
|
|
2532
2648
|
end: t.end,
|
|
2533
2649
|
...shot.camera ? { camera: shot.camera } : {},
|
|
2534
2650
|
...shot.hold !== void 0 ? { hold: shot.hold } : {},
|
|
2651
|
+
...t.cue !== void 0 ? { cue: Math.round(t.cue) } : {},
|
|
2652
|
+
...t.missedBy !== void 0 ? { missedBy: Math.round(t.missedBy) } : {},
|
|
2535
2653
|
commands: shot.run.map((c, index) => {
|
|
2536
2654
|
const ct = timeline.commands.find((x) => x.shotIndex === i && x.command === index);
|
|
2537
2655
|
const primitive = motionFlairPrimitive(c);
|
|
@@ -2660,6 +2778,42 @@ function createAnimationEditorToolProvider(controller, options = {}) {
|
|
|
2660
2778
|
const CURVES_DESCRIPTION = "Write cubic-bezier easings (ease, ease-in, ease-out, ease-in-out, back eases, custom curves) as one change with ease: [x1, y1, x2, y2] (default true). Needs Flair 0.36 or later; false samples them into linear steps for older Flair.";
|
|
2661
2779
|
const FLAIR_DESCRIPTION = "The Flair version to export for (default 0.39): springs and stepped time need 0.39, behaviours 0.38, cubic-bezier curves 0.36; older versions get those sampled into linear steps.";
|
|
2662
2780
|
const BEHAVIORS_DESCRIPTION = "Write pulses, shakes, teeters, waves, wiggles and colour pulses as one change with a Flair behavior, so they play exactly (default true; needs Flair 0.38 or later). False samples them into linear steps for older Flair.";
|
|
2781
|
+
const getTimingGrid = {
|
|
2782
|
+
name: "get_timing_grid",
|
|
2783
|
+
title: "Read the beat grid and narration",
|
|
2784
|
+
description: 'Returns the beats (number, time in ms, bar and beat in the bar) and narration words (index, text, start, end) between two times, to cue shots to with "at". Also says which shot plays at each. Without a beat grid or narration, that part is empty and says how to add one.',
|
|
2785
|
+
inputSchema: object({
|
|
2786
|
+
from: { type: "number", minimum: 0, description: "From this time, ms (default 0)." },
|
|
2787
|
+
to: { type: "number", minimum: 0, description: "Up to this time, ms (default: the end of the animation, or of the narration if later)." },
|
|
2788
|
+
limit: { type: "integer", minimum: 1, maximum: 1e3, description: "At most this many beats and words each (default 200)." }
|
|
2789
|
+
}),
|
|
2790
|
+
annotations: { title: "Read the beat grid and narration", ...READ },
|
|
2791
|
+
handler: ({ from = 0, to, limit = 200 }) => {
|
|
2792
|
+
const s = scene();
|
|
2793
|
+
const timeline = compileMotionScene(s);
|
|
2794
|
+
const words = s.narration?.words ?? [];
|
|
2795
|
+
const end = to ?? Math.max(timeline.duration, words.length ? words[words.length - 1].end ?? words[words.length - 1].start : 0);
|
|
2796
|
+
const shotAt = (ms) => timeline.shots.find((x) => ms >= x.start && ms < x.end)?.id;
|
|
2797
|
+
const beats = [];
|
|
2798
|
+
if (s.beat && s.beat.bpm > 0) {
|
|
2799
|
+
const first = Math.max(0, Math.ceil(motionBeatAt(s.beat, from).beat - 1e-9));
|
|
2800
|
+
for (let b = first; beats.length < limit; b++) {
|
|
2801
|
+
const ms = motionBeatTime(s.beat, b);
|
|
2802
|
+
if (ms > end) break;
|
|
2803
|
+
const at = motionBeatAt(s.beat, ms);
|
|
2804
|
+
const shot = shotAt(ms);
|
|
2805
|
+
beats.push({ beat: b, ms: Math.round(ms), bar: at.bar, beatInBar: at.beatInBar, ...shot ? { shot } : {} });
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
2808
|
+
const listed = words.map((w, index) => ({ index, text: w.text, start: w.start, ...w.end !== void 0 ? { end: w.end } : {}, ...shotAt(w.start) ? { shot: shotAt(w.start) } : {} })).filter((w) => w.start >= from && w.start <= end).slice(0, limit);
|
|
2809
|
+
return {
|
|
2810
|
+
from,
|
|
2811
|
+
to: Math.round(end),
|
|
2812
|
+
...s.beat ? { grid: s.beat, beats } : { beats: [], hint: 'No beat grid: set_animation_settings with { "beat": { "bpm": 120 } }.' },
|
|
2813
|
+
...words.length ? { words: listed } : { words: [], narrationHint: 'No narration: set_animation_settings with { "narration": { "words": [{ "text", "start" }, \u2026] } }.' }
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
};
|
|
2663
2817
|
const exportFlairMotion = {
|
|
2664
2818
|
name: "export_flair_motion",
|
|
2665
2819
|
title: "Export as Flair vector motion",
|
|
@@ -2734,6 +2888,27 @@ function createAnimationEditorToolProvider(controller, options = {}) {
|
|
|
2734
2888
|
return outcome(applied, applied.ok ? { ...report, elements: describeMotionElements(result.scene) } : {});
|
|
2735
2889
|
}
|
|
2736
2890
|
};
|
|
2891
|
+
const fitAnimation = {
|
|
2892
|
+
name: "fit_animation",
|
|
2893
|
+
title: "Fit the animation to a length",
|
|
2894
|
+
description: `Makes the animation last a given time by setting its shots' speed (one undo step): shots in "keep" play as they are and the others stretch or squeeze together, keeping their proportions. Returns the new length, each changed shot's speed and the timing. With dry_run it only reports them.`,
|
|
2895
|
+
inputSchema: object(
|
|
2896
|
+
{
|
|
2897
|
+
seconds: { type: "number", exclusiveMinimum: 0, description: "The length to fit, in seconds." },
|
|
2898
|
+
keep: { type: "array", items: SHOT_ID, description: "Shots that keep their pace, e.g. a logo hold (default none)." },
|
|
2899
|
+
dry_run: { type: "boolean", description: "Report without changing anything (default false)." }
|
|
2900
|
+
},
|
|
2901
|
+
["seconds"]
|
|
2902
|
+
),
|
|
2903
|
+
annotations: { title: "Fit the animation to a length", ...WRITE_IDEMPOTENT },
|
|
2904
|
+
handler: ({ seconds, keep, dry_run = false }) => {
|
|
2905
|
+
const fit = fitMotionScene(controller.getState().scene, seconds * 1e3, { keep: keep ?? [] });
|
|
2906
|
+
const report = { duration: fit.duration, speeds: fit.speeds };
|
|
2907
|
+
if (dry_run) return { dry_run: true, ...report, hint: "Nothing was changed; call again without dry_run to fit." };
|
|
2908
|
+
const edits = Object.entries(fit.speeds).map(([shot, speed]) => speed === 1 ? { op: "update_shot", shot, remove: ["speed"] } : { op: "update_shot", shot, set: { speed } });
|
|
2909
|
+
return apply(edits, "Fit to length", () => report);
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2737
2912
|
const insertShots = {
|
|
2738
2913
|
name: "insert_shots",
|
|
2739
2914
|
title: "Add shots",
|
|
@@ -2750,7 +2925,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
|
|
|
2750
2925
|
{
|
|
2751
2926
|
shot: SHOT_ID,
|
|
2752
2927
|
set: object({ id: SHOT.properties.id, ...SHOT_FIELDS }, [], "Fields to replace."),
|
|
2753
|
-
remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold"] }, description: "Fields to clear." }
|
|
2928
|
+
remove: { type: "array", items: { type: "string", enum: ["label", "camera", "hold", "speed", "at"] }, description: "Fields to clear." }
|
|
2754
2929
|
},
|
|
2755
2930
|
["shot"]
|
|
2756
2931
|
),
|
|
@@ -2816,7 +2991,7 @@ function createAnimationEditorToolProvider(controller, options = {}) {
|
|
|
2816
2991
|
const setSettings = {
|
|
2817
2992
|
name: "set_animation_settings",
|
|
2818
2993
|
title: "Change animation settings",
|
|
2819
|
-
description: "Sets the name, description, loop mode, export frame rate, stepped time or target length. Returns the timing.",
|
|
2994
|
+
description: "Sets the name, description, loop mode, export frame rate, stepped time, beat grid, narration word timings or target length. Returns the timing.",
|
|
2820
2995
|
inputSchema: object(SETTINGS),
|
|
2821
2996
|
annotations: { title: "Change animation settings", ...WRITE_IDEMPOTENT },
|
|
2822
2997
|
handler: (args) => apply({ op: "set_settings", ...args }, "Change settings")
|
|
@@ -2894,8 +3069,8 @@ function createAnimationEditorToolProvider(controller, options = {}) {
|
|
|
2894
3069
|
return { redone, ...redone ? { label } : {} };
|
|
2895
3070
|
}
|
|
2896
3071
|
};
|
|
2897
|
-
const reads = [getAnimation, getShot, findElements, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, seekAnimation];
|
|
2898
|
-
const writes = [setArtworkSvg, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
|
|
3072
|
+
const reads = [getAnimation, getShot, findElements, getTimingGrid, checkAnimation, renderFrame, exportFlairMotion, exportFlairEffect, seekAnimation];
|
|
3073
|
+
const writes = [setArtworkSvg, fitAnimation, importFlairEffect, insertShots, updateShot, deleteShots, moveShot, addCommands, updateCommand, deleteCommands, setSettings, setControls, editAnimation, writeAnimation, undo, redo];
|
|
2899
3074
|
return {
|
|
2900
3075
|
id: options.id ?? "animation",
|
|
2901
3076
|
namespace: options.namespace,
|
|
@@ -2933,6 +3108,7 @@ Commands
|
|
|
2933
3108
|
- Emphasis stacks on other motion: a shake during a slide shakes as it slides. Give move, rotate or scale "blend": "add" to stack them too (what they add stays); "blend": "replace" makes a change take over instead.
|
|
2934
3109
|
- Any command but enter and exit can "repeat" (loop, mirror back and forth, or offset to carry on: rotate by 90, repeat 4 is a full turn); "duration" is one cycle.
|
|
2935
3110
|
- Springs ("spring-gentle", "spring-quick", "spring-bouncy", "spring-slow" or { "type": "spring", \u2026 }) feel physical; leave "duration" out and the spring sets it.
|
|
3111
|
+
- To time shots to music or a voiceover: set a "beat" grid or "narration" word timings with set_animation_settings, read them with get_timing_grid, and give a shot "at": { "beat": n } or { "word": i }. The shot before holds until the cue; check_animation warns when a cue is missed.
|
|
2936
3112
|
- "steps": 12 (on a command, or for the whole scene with set_animation_settings) holds poses "on twos" for a hand-drawn look. "geometric": true on scale or a camera zooms at an even rate.
|
|
2937
3113
|
- Commands share their names and defaults with Flair's motion primitives (get_animation shows each one's "flairPrimitive"), and emphasis, reveals and follows export to Flair exactly.
|
|
2938
3114
|
- effect tweens a numeric parameter of a live path effect already on the node (twist angle, wiggle time, zig zag size).
|
|
@@ -2940,7 +3116,7 @@ Commands
|
|
|
2940
3116
|
Working method
|
|
2941
3117
|
1. get_animation to learn the element ids (set_animation_artwork_svg first if there is no artwork: give every element you will animate an id; or import_flair_effect to start from a Flair effect and edit it).
|
|
2942
3118
|
2. Plan a few shots, each one idea. Add them with insert_shots, or edit_animation to make several edits as one step (dry_run first when unsure).
|
|
2943
|
-
3. check_animation and fix every error and warning; render_animation_frame (shot + "end") to look at the result.
|
|
3119
|
+
3. To hit a length, fit_animation (keep the shots whose pace matters). check_animation and fix every error and warning; render_animation_frame (shot + "end") to look at the result.
|
|
2944
3120
|
4. To make a reusable Flair effect, set_animation_controls (colour controls stand for every use of their default colour) and export_flair_effect.
|
|
2945
3121
|
|
|
2946
3122
|
Keep motion purposeful: one thing moving at a time reads best, ease-out for arrivals, ease-in for exits, and hold long enough to see each result.`;
|
|
@@ -2969,6 +3145,7 @@ export {
|
|
|
2969
3145
|
MOTION_ISSUE_CODES,
|
|
2970
3146
|
MOTION_MIN_CHANGE_MS,
|
|
2971
3147
|
MOTION_REPEAT_MODES,
|
|
3148
|
+
MOTION_SPEED_RANGE,
|
|
2972
3149
|
MOTION_SPLIT_ORIENTATIONS,
|
|
2973
3150
|
MotionSceneFormatError,
|
|
2974
3151
|
activeSegment,
|
|
@@ -2982,6 +3159,7 @@ export {
|
|
|
2982
3159
|
describeMotionElements,
|
|
2983
3160
|
describeMotionShots,
|
|
2984
3161
|
fitMotionRect,
|
|
3162
|
+
fitMotionScene,
|
|
2985
3163
|
flairAtLeast,
|
|
2986
3164
|
flairCurveFor,
|
|
2987
3165
|
flairEaseFor,
|
|
@@ -2994,9 +3172,12 @@ export {
|
|
|
2994
3172
|
isMotionShot,
|
|
2995
3173
|
matchMotionNodes,
|
|
2996
3174
|
motionAddTrackKey,
|
|
3175
|
+
motionBeatAt,
|
|
3176
|
+
motionBeatTime,
|
|
2997
3177
|
motionClipAttrs,
|
|
2998
3178
|
motionCommandDuration,
|
|
2999
3179
|
motionCommandRepeat,
|
|
3180
|
+
motionCueTime,
|
|
3000
3181
|
motionCycleDuration,
|
|
3001
3182
|
motionFlairPrimitive,
|
|
3002
3183
|
motionFollowRoute,
|
|
@@ -3014,6 +3195,7 @@ export {
|
|
|
3014
3195
|
motionSceneShapeProblems,
|
|
3015
3196
|
motionSceneToJson,
|
|
3016
3197
|
motionShotAt,
|
|
3198
|
+
motionShotTimeScale,
|
|
3017
3199
|
motionTrackKey,
|
|
3018
3200
|
namedWithSuggestion,
|
|
3019
3201
|
parseMotionScene,
|