Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ Motion and animated materials never touch geometry; only keyframed shape and mod
- **Animated settings**: a style's `animate` maps a material param to a second value, a cycle and a mode (swing, ramp, flicker). The renderer blends the uniform each frame (colours in RGB), so any material can pulse, shift or flicker without new shader code.
- **Moving patterns**: the Pattern material's Speed slides patterns along their grain, turns sunbursts and spirals, ripples rings, and drives the kinds made to move (riding bars, mosaic, barcode rows, flow lines).
- **Moving geometry** (`state/geoMotion.ts`, `app/geoMotion.ts`): a node's `animate` keyframes its own number settings, `mod:<uid>:<field>` for a modifier and `sym:<field>` for a library shape, with the same swing, ramp and flicker as materials (whole-number fields step). These change the outline, so the layers that have them are rebuilt: while the clock runs, `movingDocAt` builds an engine document with just the renders that move (plus the cuts, masks and morph sources they're made of) at the clock's time, at most 30 times a second and one at a time, and drops a frame asked for while one is building. The outlines go straight to the renderer through `liveOutlines()`, never into the project, undo history or autosave; their fields snap to a 16 px grid so textures are reused between frames. Paused, the outlines hold at the playhead. A design without keyframed geometry runs none of this. Selection outlines show the shape at rest, as they do for motion.
- **Free keys** (`state/keys.ts`, evaluated in `motion.ts`): any property can have any number of keys, each a time, a value and the curve into it (`Key`: an easing stretched over the gap from the key before, a time spring stretched the same way, or a physics spring at its own pace). They live beside the cycles: `node.keys` for the transform (x, y, rotation, scale, opacity) and geometry (the same `mod:`/`sym:`/`morph:t`/`line:` names as `animate`), `style.keys` for material settings. A property holds its first key's value before it and its last key's after; on a looping timeline it jumps back at the seam. Transform keys are absolute values (a group's are offsets from rest): `buildScene` hands them to the renderer as a link of their own outside the node's motion (`MotionLink.keys`), posed about the layer's origin, so a layer floats and follows its keys and the result is the layer as if it had those values; a layer's opacity keys set its material's opacity directly. Setting keys go through the same paths as cycles (`effectUniforms`, `geoDrivers` in geoMotion), and win where a setting has both; the Inspector keeps them exclusive. Every edit runs `keyEdits`: a keyed property it changes gets a key at the playhead instead (a canvas drag moves the key by as much as it moved the layer), and with autokey (the timeline's record switch, not saved) a changed property starts keys. The Inspector shows keyed values at the playhead with ‹ ◆ › beside them; the timeline adds a row of diamonds per keyed property.
- **Video** (`exportVideo`): fields are built once at export size, then each frame is drawn at its own time and handed to a `MediaRecorder` on a 2D canvas, paced in real time. Moving geometry is built for every frame first (at export quality), so recording only swaps those outlines and rebuilds their fields. The preview stops drawing while an export holds the GPU. Stills (PNG, JPG, SVG, thumbnails) show the design at rest.

## UI (`ui/src`)
Expand Down
107 changes: 107 additions & 0 deletions scripts/e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -712,6 +712,113 @@ try {
);
}

// ---- free keys: a star turns through four keys, set in the Inspector, edited in the timeline, honoured in video ----
{
const starId = await page.evaluate(() => {
const m = window.morphotype;
const p = m.store.get().project;
const star = { id: `lkeys${Date.now().toString(36)}`, name: 'Spinner', visible: true, op: 'add', x: 0, y: 0, rotation: 0, scale: 1, sx: 1, sy: 1, modifiers: [], content: { type: 'symbol', symbol: { kind: 'star', points: 5, inner: 0.45, rounding: 0.1 }, size: 400 }, style: { effect: 'flat', params: { fill: '#ff3366' } } };
m.clock.pause({ duration: 4, loop: true });
m.clock.seek(0);
m.store.set({ project: { ...p, layers: [star], timeline: { duration: 4, loop: true } }, selection: [star.id], tab: 'design', dialog: null, screen: 'editor', mode: 'design', past: [], future: [] });
return star.id;
});
await settle(900);
const starOf = (st) => layersOf(st).find((l) => l.id === starId);
const keysOf = (st, k = 'rotation') => (starOf(st).keys?.[k] ?? []).map((x) => [Math.round(x.t * 100) / 100, Math.round(x.v * 10) / 10]);
// ◆ starts keys at the playhead; typing a value elsewhere on the clock adds a key there.
await page.getByRole('button', { name: 'Key Rotation' }).click();
const rot = page.locator('.keyed-box', { has: page.getByRole('button', { name: 'Key Rotation' }) }).locator('input');
// Typed as a person would: click the box (it selects its text a frame later), select all, type, Enter.
const twoFrames = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))));
let typedIn = '';
const typeInto = async (box, v) => {
await box.click();
await twoFrames();
typedIn = await page.evaluate(() => `${document.activeElement?.tagName} "${document.activeElement?.value ?? ''}"`);
await page.keyboard.press(`${mod}+a`);
await page.keyboard.type(String(v));
await page.keyboard.press('Enter');
await twoFrames();
};
for (const [t, v] of [[1, 45], [2.5, 30], [3.5, -90]]) {
await page.evaluate((t) => window.morphotype.clock.seek(t), t);
await settle(150);
await typeInto(rot, v);
await until((st) => keysOf(st).some(([kt, kv]) => kt === t && kv === v), 5000);
}
let st = await state();
check(JSON.stringify(keysOf(st)) === '[[0,0],[1,45],[2.5,30],[3.5,-90]]', `◆ and the rotation field key the star at four times (${JSON.stringify(keysOf(st))})`);
const angleAt = (t) =>
page.evaluate(
({ id, t }) => {
const m = window.morphotype;
m.clock.seek(t);
const o = m.renderer.objs.find((x) => x.item.id === id);
const pl = m.renderer.placed(o);
return pl ? Math.round((Math.atan2(pl.m[1], pl.m[0]) * 180) / Math.PI) : null;
},
{ id: starId, t },
);
// Its own rotation sits at the last key typed; the keys turn it from there.
const own = starOf(st).rotation;
const angles = [await angleAt(0), await angleAt(1), await angleAt(1.75), await angleAt(3.5)].map((a) => Math.round(a + own));
check(angles.join() === '0,45,38,-90', `the canvas follows the keys (${angles.join('° → ')}°)`);
// The timeline: a row of diamonds to drag, click and delete.
if (await page.getByRole('button', { name: 'Open the timeline' }).count()) await page.getByRole('button', { name: 'Open the timeline' }).click();
await page.locator('.tl-key').first().waitFor({ timeout: 5000 });
check((await page.locator('.tl-key').count()) === 4 && (await page.locator('.tl-sub .tl-name', { hasText: 'Rotation' }).count()) === 1, 'the timeline shows a Rotation row with four diamonds');
const [k2, k3] = [await page.locator('.tl-key').nth(2).boundingBox(), await page.locator('.tl-key').nth(3).boundingBox()];
const perS = (k3.x - k2.x) / 1;
await page.mouse.move(k2.x + k2.width / 2, k2.y + k2.height / 2);
await page.mouse.down();
await page.mouse.move(k2.x + k2.width / 2 + perS * 0.25, k2.y + k2.height / 2, { steps: 4 });
await page.mouse.move(k2.x + k2.width / 2 + perS * 0.5, k2.y + k2.height / 2, { steps: 4 });
await page.mouse.up();
st = await state();
check(JSON.stringify(keysOf(st)) === '[[0,0],[1,45],[3,30],[3.5,-90]]', `dragging a diamond retimes its key, on a frame (${JSON.stringify(keysOf(st))})`);
await page.locator('.tl-key').nth(1).click();
await page.locator('.tl-edit', { hasText: 'key 2 of 4' }).waitFor({ timeout: 5000 });
check(await page.evaluate(() => Math.abs(window.morphotype.clock.time({ duration: 4, loop: true }) - 1) < 1e-6), 'clicking a diamond opens its key and puts the playhead on it');
await page.keyboard.press('Escape');
await page.locator('.tl-key').nth(2).click();
await page.keyboard.press('Escape');
await page.locator('.tl-key').nth(2).focus();
await page.keyboard.press('Backspace');
st = await state();
check(JSON.stringify(keysOf(st)) === '[[0,0],[1,45],[3.5,-90]]' && !!starOf(st), `⌫ deletes the picked key, not the layer (${JSON.stringify(keysOf(st))})`);
await page.keyboard.press(`${mod}+z`);
st = await state();
check(keysOf(st).length === 4, 'undo brings the key back');
// Autokey: with record on, moving the star keys its position at the playhead.
await page.getByRole('button', { name: 'Autokey' }).click();
await page.evaluate(() => window.morphotype.clock.seek(2));
await settle(150);
const xBox = page.locator('.keyed-box', { has: page.getByRole('button', { name: 'Key X' }) }).locator('input');
await typeInto(xBox, 120);
await until((st) => keysOf(st, 'x').length === 2, 5000);
await page.getByRole('button', { name: 'Autokey' }).click();
st = await state();
check(JSON.stringify(keysOf(st, 'x')) === '[[0,0],[2,120]]' && (await page.locator('.tl-sub .tl-name', { hasText: 'Position X' }).count()) === 1, `autokey keys a change at the playhead, from where it was (${JSON.stringify(keysOf(st, 'x'))}; x ${starOf(st).x}; typed into ${typedIn})`);
await page.screenshot({ path: path.join(out, 'new-01d-keys.png') });
// Video: every frame is drawn at its own time on the keys.
await page.evaluate((id) => {
const r = window.morphotype.renderer;
window.__starFrames = [];
const placed = r.placed.bind(r);
r.placed = (o) => {
const pl = placed(o);
if (r.exporting && o.item.id === id && pl) window.__starFrames.push([r.pinned?.t, Math.round((Math.atan2(pl.m[1], pl.m[0]) * 180) / Math.PI)]);
return pl;
};
}, starId);
await download(async () => {}, () => page.getByRole('button', { name: 'Export video' }).first().click());
const frames = await page.evaluate(() => window.__starFrames);
const own2 = starOf(await state()).rotation;
const turn = (t) => Math.round((frames.find((f) => Math.abs(f[0] - t) < 1 / 60)?.[1] ?? NaN) + own2);
check(frames.length >= 120 && turn(1) === 45 && turn(3) === 30 && turn(3.5) === -90, `exported video turns the star through its keys (${frames.length} frames: ${[0, 1, 3, 3.5].map(turn).join('° → ')}°)`);
}

// ---- depth, glass and the icon grid ----
await openTemplate('Frosted 404');
await page.locator('.row', { hasText: 'Caption' }).click();
Expand Down
11 changes: 10 additions & 1 deletion ui/src/agent/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,23 @@ const KEYFRAMES = (): string =>
' animate["sym:<field>"] a library shape field',
' animate["morph:t"] a font morph blend, or a morph layer',
' animate["line:start|end|offset"] how much of a pen line is drawn (draw-on: end 0 → 1)',
'keys: any number per property, each at its own time with its own value and curve (free keyframes)',
" set them: Star.keys.rotation=[[0,0],[1,45],[2.5,30]] · Star.keys.x=[[0,-200],[1.5,200,\"back out\"]] · Star.keys.fill=[[0,\"#ffffff\"],[2,\"#ff3366\"]]",
' each key: [t (s), value, curve into it?] or { t, v, ease }; null removes them',
' curves: ' + [...EASES.map((e) => e.label), ...FEELS.map((f) => f.label)].map((x) => x.toLowerCase()).join(', ') + ' (default ease in out), 4 Bézier numbers, or a transition',
' keys.x | y | rotation | scale | opacity a layer\'s own values (a group\'s: offsets from rest); they ride on top of motion',
' keys[<param>] a material setting (lands in style.keys), numbers or colours',
' keys["mod:<uid>:<field>"], ["sym:<field>"], ["morph:t"], ["line:end"]… geometry, as for animate',
' before its first key a property holds that value, after its last it holds that one (a looping clock jumps back at the seam)',
' a setting with keys ignores its cycle and its plain value; keys past the timeline\'s end never play (set lengthens it to fit)',
].join('\n');

const LAYERS = (): string =>
[
'project: { version: 2, name, canvas: { width, height }, background, layers: [bottom … top], fonts: [], timeline? }',
'layer: { id, name?, visible, op: add|subtract|intersect, x, y (px from the canvas centre, y down), rotation (°), scale, sx, sy,',
' modifiers: [], content, style: { effect, params, stroke?, opacity?, blend?: normal|multiply|screen, depth?, soften?, animate? },',
' motion?, animate?, guide? (shown while designing, left out of exports), locked? }',
' motion?, animate?, keys? (see `keyframes`), guide? (shown while designing, left out of exports), locked? }',
'content:',
" { type: 'text', text, font, size (px), tracking, lineHeight, align: left|center|right, axes: {}, morph: null|{ font, axes, t }, ligatures }",
" { type: 'symbol', symbol: { kind, … }, size } (see `shapes`)",
Expand Down
11 changes: 10 additions & 1 deletion ui/src/agent/describe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
// agent can read a whole design without its source or a screenshot. Names and
// ids in the output are what `set` (edit.ts) takes to address a layer.
import { defaultParams, effectById } from '../render/effects';
import { feelOf, type Motion, type ParamAnim, type Transition } from '../state/motion';
import { easeName } from '../state/keys';
import { feelOf, type Key, type Motion, type ParamAnim, type Transition } from '../state/motion';
import { isGroup, MODIFIER_DEFAULTS, SYMBOL_DEFAULTS, type Background, type Node, type Project, type Style } from '../state/model';
import type { ModEntry } from '../engine/types';

Expand All @@ -22,6 +23,12 @@ function anim(k: string, a: ParamAnim): string {
return `${k}→${val(a.to)} ${a.mode} ${num(a.period)}s${a.phase ? ` @${num(a.phase)}` : ''}`;
}

/** "keys rotation 0s 0 → 1s 45 linear → 2.5s 30": each keyed property's keys, with curves that aren't the default. */
function keysLine(bag: Record<string, Key[]>): string {
const each = Object.entries(bag).filter(([, k]) => k.length);
return each.length ? `keys ${each.map(([k, keys]) => `${k} ${keys.map((x, i) => `${num(x.t)}s ${val(x.v)}${i && x.ease ? ` ${easeName(x.ease)}` : ''}`).join(' → ')}`).join('; ')}` : '';
}

function transition(t: Transition): string {
const feel = feelOf(t);
if (feel) return `${feel} ${num(t.duration)}s`;
Expand Down Expand Up @@ -51,6 +58,7 @@ export function styleText(s: Style): string {
if (s.soften) out.push(`soften ${num(s.soften)}`);
const a = Object.entries(s.animate ?? {});
if (a.length) out.push(`animate ${a.map(([k, v]) => anim(k, v)).join('; ')}`);
if (s.keys) out.push(keysLine(s.keys));
return out.join(' · ');
}

Expand Down Expand Up @@ -120,6 +128,7 @@ function nodeLines(n: Node, depth: number, out: string[]) {
if (m) parts.push(`motion ${m}`);
const a = Object.entries(n.animate ?? {});
if (a.length) parts.push(`animate ${a.map(([k, v]) => anim(k, v)).join('; ')}`);
if (n.keys) parts.push(keysLine(n.keys));
out.push(`${head} ${parts.filter(Boolean).join(' · ')}`);
if (isGroup(n)) for (const c of [...n.children].reverse()) nodeLines(c, depth + 1, out);
}
Expand Down
26 changes: 26 additions & 0 deletions ui/src/agent/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
// Title.wave.amplitude=0.08 the layer's first modifier of that kind
// Title.motion.loop=float motion is created when missing
// Title.animate.speed={"to":1,"period":4,"phase":0,"mode":"ramp"}
// Star.keys.rotation=[[0,0],[1,45],[2.5,30,"linear"]] free keys: [t, value, curve?]
// canvas.width=1080 · background.fill.colors=["#000"] · timeline.duration=4
//
// Values are JSON when they parse as JSON, else plain text; null removes a
// key. Numbers are checked against the setting's range, and unknown layers
// or settings fail with the names that would have worked.
import { defaultParams, EFFECTS, effectById } from '../render/effects';
import { isTransformProp, parseKeys, updateKeys } from '../state/keys';
import { ENTER_UI, LEAVE_UI, LOOP_UI, newMotion } from '../state/motion';
import { isChoice, isGroup, MODIFIER_UI, SYMBOL_UI, type Field, type Node, type Project } from '../state/model';
import type { ModifierKind } from '../engine/types';
Expand Down Expand Up @@ -86,6 +88,8 @@ function resolve(n: Node, path: string[]): { path: string[]; check?: (v: unknown
if (head === 'params' && rest.length === 1) return { path: ['style', 'params', rest[0]], check: (v) => checkParam(n.style.effect, rest[0], v) };
// Material settings animate on the style; modifier, shape and line keys (mod:…, sym:…, line:…) on the layer.
if (head === 'animate' && rest.length === 1) return { path: rest[0] in n.style.params ? ['style', 'animate', rest[0]] : ['animate', rest[0]] };
// Free keys likewise (see keyEdit).
if (head === 'keys' && rest.length === 1) return { path: rest[0] in n.style.params ? ['style', 'keys', rest[0]] : ['keys', rest[0]] };
if (head === 'motion') return { path };
if (head in o) {
if (head === 'style' && rest[0] === 'params' && rest.length === 2) return { path, check: (v) => checkParam(n.style.effect, rest[1], v) };
Expand Down Expand Up @@ -155,6 +159,7 @@ export function applyEdit(p: Project, assignment: string): Edit {
const n = findNode(project, target);
const r = resolve(n, path);
r.check?.(value);
if (r.path[r.path.length - 2] === 'keys') return keyEdit(project, n, r.path, value);
if (r.path[0] === 'motion') n.motion ??= newMotion();
if (r.path[0] === 'animate') n.animate ??= {};
if (r.path[0] === 'style' && r.path[1] === 'animate') n.style.animate ??= {};
Expand All @@ -180,6 +185,27 @@ export function applyEdit(p: Project, assignment: string): Edit {

const fmtv = (v: unknown) => (v === undefined ? '(unset)' : JSON.stringify(v));

/** Free keys on a layer (null removes them): checked like the setting they key; the timeline grows to hold the last one. */
function keyEdit(project: Project, n: Node, path: string[], value: unknown): Edit {
const style = path[0] === 'style';
const key = path[path.length - 1];
const who = n.name ?? '#' + n.id;
if (!style && !isTransformProp(key) && !/^(mod:[^:]+:.+|sym:.+|morph:t|line:(start|end|offset))$/.test(key)) throw new EditError(`${who} can't key "${key}": use x, y, rotation, scale, opacity, a material setting (${Object.keys(n.style.params).join(', ')}), or mod:<uid>:<field>, sym:<field>, morph:t, line:end`);
let keys: ReturnType<typeof parseKeys> = [];
try {
if (value !== null) keys = parseKeys(value);
} catch (e) {
throw new EditError(`${who}.keys.${key}: ${(e as Error).message}`);
}
if (style) for (const k of keys) checkParam(n.style.effect, key, k.v);
const before = (style ? n.style.keys : n.keys)?.[key];
updateKeys(n, style ? 'style' : 'node', key, () => keys);
const last = keys.at(-1)?.t ?? 0;
const tl = project.timeline ?? { duration: 6, loop: true };
if (last > tl.duration) project.timeline = { ...tl, duration: Math.ceil(last * 10) / 10 };
return { project, change: `${n.name ? `"${n.name}"` : '#' + n.id} ${path.join('.')}: ${fmtv(before)} → ${fmtv(keys.length ? keys : undefined)}` };
}

/** Apply several assignments in order. */
export function applyEdits(p: Project, assignments: string[]): { project: Project; changes: string[] } {
const changes: string[] = [];
Expand Down
Loading
Loading