From 68270e933c767bcb660cfe6519c2c25f7bdeffb2 Mon Sep 17 00:00:00 2001 From: srivtx Date: Sat, 24 Oct 2026 02:39:07 +0530 Subject: [PATCH 1/5] Free keyframes: model, evaluation and edits Keys per property (transform, material, modifier and shape settings), each with its own time, value and curve. Transform keys ride as a pose link outside a node's motion; setting keys win over cycles in the renderer and geoMotion; edits to keyed properties key at the playhead (autokey starts keys). Files without keys load unchanged. Co-Authored-By: Claude Opus 5.5 --- ui/src/components/canvas/Viewport.tsx | 5 +- ui/src/render/renderer.ts | 27 ++- ui/src/state/geoMotion.ts | 43 ++-- ui/src/state/keys.test.ts | 243 ++++++++++++++++++++ ui/src/state/keys.ts | 312 ++++++++++++++++++++++++++ ui/src/state/letters.ts | 4 +- ui/src/state/migrate.ts | 7 + ui/src/state/model.ts | 8 +- ui/src/state/motion.ts | 96 +++++++- ui/src/state/store.ts | 6 +- ui/src/state/tree.ts | 16 +- 11 files changed, 728 insertions(+), 39 deletions(-) create mode 100644 ui/src/state/keys.test.ts create mode 100644 ui/src/state/keys.ts diff --git a/ui/src/components/canvas/Viewport.tsx b/ui/src/components/canvas/Viewport.tsx index e8de2e9..780c466 100644 --- a/ui/src/components/canvas/Viewport.tsx +++ b/ui/src/components/canvas/Viewport.tsx @@ -59,13 +59,15 @@ export function syncScene(r: Renderer) { const out = renders.get(o.id); if (!out) continue; const clipD = o.clip ? renders.get(o.clip)?.d : null; - const links = o.motion?.map((l) => ({ motion: l.motion, pivot: pivot(l.at), offset: l.offset })); + // Transform keys turn and scale about the layer's origin, as its own rotation and scale do. + const links = o.motion?.map((l) => ({ motion: l.motion, pivot: l.keys?.origin ?? pivot(l.at), offset: l.offset, keys: l.keys })); // A text moving letter by letter: one object per letter (or word), each about its own centre, in turn. const pieces = o.letters && links ? piecesOf(out, o.letters) : null; if (pieces?.length && links) { pieceLinks(links, pieces, o.id).forEach((motion, i) => { const item = sceneItem(`${o.id}#${i}`, pieces[i], o.style, o.guide, clipD); item.frame = out.bbox; + item.opacityKeys = o.opacityKeys; item.motion = motion; if (live?.has(o.id)) item.live = true; if (item.clipD) item.motionReach = motionReach(motion, pieces[i].bbox, reachTimes, loopLength); @@ -74,6 +76,7 @@ export function syncScene(r: Renderer) { continue; } const item = sceneItem(o.id, out, o.style, o.guide, clipD); + item.opacityKeys = o.opacityKeys; if (live?.has(o.id)) item.live = true; if (links) { item.motion = links; diff --git a/ui/src/render/renderer.ts b/ui/src/render/renderer.ts index b13558d..7369e90 100644 --- a/ui/src/render/renderer.ts +++ b/ui/src/render/renderer.ts @@ -4,7 +4,7 @@ import { ambientOf, backgroundUniforms, BG_FS } from './background'; import type { Background, BlendMode, Depth, Stroke, Style } from '../state/model'; import { imageAsset, imageElement, onImagesReady } from '../state/assets'; import { clock, DEFAULT_TIMELINE, type Timeline } from '../state/clock'; -import { animatedValue, applyMat, compose, placement, type Mat, type Motion, type ParamAnim } from '../state/motion'; +import { animatedValue, applyMat, compose, keyedNumber, keyedValue, placement, type Key, type KeyedTransform, type Mat, type Motion, type ParamAnim } from '../state/motion'; import { bindTextures, formats, freeTarget, GLError, hexToRgb, program, setUniforms, target, texture, type Program, type Target, type UniformValue } from './gl'; const EFFECT_FS = (body: string) => /* glsl */ `#version 300 es @@ -83,10 +83,14 @@ export interface SceneItem { guide?: boolean; /** A pen line's centre line, for materials that colour along it. */ spine?: number[] | null; - /** Motions moving it at draw time (its own first), each about a rest centre in artboard coordinates. */ - motion?: { motion: Motion; pivot: [number, number]; offset: number }[]; + /** Motions moving it at draw time (its own first), each about a rest centre in artboard coordinates, with any transform keys. */ + motion?: { motion: Motion; pivot: [number, number]; offset: number; keys?: KeyedTransform }[]; /** Material settings that change over time. */ anim?: Record; + /** Material settings keyed on the clock (they win over `anim`). */ + keys?: Record; + /** Its opacity, keyed on the clock. */ + opacityKeys?: Key[]; /** How far (px) motion carries it from rest: a clipped object's field covers that much more, so its frame can stay still. */ motionReach?: number; /** Its outline is rebuilt on the scene clock: the field's area snaps to a coarse grid so its textures are reused from frame to frame. */ @@ -97,7 +101,7 @@ export interface SceneItem { /** The scene item for one built object and its style. */ export function sceneItem(id: string, out: { d: string; bbox: Box | null; spine?: number[] }, st: Style, guide?: boolean, clipD?: string | null): SceneItem { - return { id, d: out.d, bbox: out.bbox, clipD, spine: out.spine ?? null, effect: effectById(st.effect), params: st.params, anim: st.animate, stroke: st.stroke, opacity: st.opacity, blend: st.blend, depth: st.depth, soften: st.soften, guide }; + return { id, d: out.d, bbox: out.bbox, clipD, spine: out.spine ?? null, effect: effectById(st.effect), params: st.params, anim: st.animate, keys: st.keys, stroke: st.stroke, opacity: st.opacity, blend: st.blend, depth: st.depth, soften: st.soften, guide }; } interface Field { @@ -288,7 +292,7 @@ export class Renderer { /** Anything that changes with time: animated materials or motion. */ moving() { - return this.objs.some((o) => o.item.motion?.length || (o.item.anim && Object.keys(o.item.anim).length) || o.item.effect.animated?.(o.item.params)); + return this.objs.some((o) => o.item.motion?.length || (o.item.anim && Object.keys(o.item.anim).length) || (o.item.keys && Object.keys(o.item.keys).length) || o.item.opacityKeys?.length || o.item.effect.animated?.(o.item.params)); } /** Seconds on the scene clock for this frame; null holds everything at rest. */ @@ -297,6 +301,12 @@ export class Renderer { return this.paused ? null : clock.time(this.timeline); } + /** An object's own opacity now: its style's, or its keys'. */ + private opacityOf(o: Obj): number { + const t = this.now(); + return t === null ? (o.item.opacity ?? 1) : keyedNumber(o.item.opacityKeys, t, o.item.opacity ?? 1); + } + /** Where motion puts an object now (artboard map), and its fade. */ private placed(o: Obj): { m: Mat; opacity: number } | null { const t = this.now(); @@ -573,7 +583,8 @@ export class Renderer { for (const def of o.item.effect.params) { const base = o.item.params[def.key] ?? def.default; const a = o.item.anim?.[def.key]; - const v = a && t !== null ? animatedValue(base, a, t, loopLength) : base; + const k = o.item.keys?.[def.key]; + const v = t === null ? base : k?.length ? keyedValue(k, t) : a ? animatedValue(base, a, t, loopLength) : base; u[`u_${def.key}`] = def.type === 'color' ? hexToRgb(String(v)) : Number(v); } const st = o.item.stroke; @@ -581,7 +592,7 @@ export class Renderer { u.u_strokeAlign = st?.align === 'center' ? 1 : st?.align === 'inside' ? 2 : 0; u.u_strokeOpacity = st?.opacity ?? 1; u.u_strokeColor = hexToRgb(st?.color ?? '#000000'); - u.u_opacity = Math.max(0, Math.min(1, o.item.opacity ?? 1)); + u.u_opacity = Math.max(0, Math.min(1, this.opacityOf(o))); u.u_soften = Math.max(0, o.item.soften ?? 0); const dp = o.item.depth; u.u_sideDepth = dp && dp.amount > 0 ? dp.amount : 0; @@ -685,7 +696,7 @@ export class Renderer { u_rect: rectOf(o.region), u_xf: xf ? [xf[0], xf[1], xf[2], xf[3]] : [1, 0, 0, 1], u_xfT: xf ? [xf[4], xf[5]] : [0, 0], - ...(at ? { u_opacity: Math.max(0, Math.min(1, (o.item.opacity ?? 1) * at.opacity)) } : {}), + ...(at ? { u_opacity: Math.max(0, Math.min(1, this.opacityOf(o) * at.opacity)) } : {}), u_hasClip: o.field.clip ? 1 : 0, ...(o.item.effect.photo ? { u_hasPhoto: photo ? 1 : 0, u_imgAspect: meta ? meta.w / meta.h : 1 } : {}), ...(o.item.effect.backdrop ? (bdu ?? { u_hasBackdrop: 0 }) : {}), diff --git a/ui/src/state/geoMotion.ts b/ui/src/state/geoMotion.ts index cd2e122..a457adc 100644 --- a/ui/src/state/geoMotion.ts +++ b/ui/src/state/geoMotion.ts @@ -9,12 +9,17 @@ // sym: a library shape's number field // morph:t the blend of a text's font morph, or of a morph layer // line: how much of a pen line is drawn (its Trim: start, end, offset) +// +// The same keys on the node's `keys` are free keyframes (see keys.ts); where a +// setting has both, its keys win. import type { EngineDocument, ModEntry, Trim } from '../engine/types'; import { isChoice, isGroup, MODIFIER_UI, SYMBOL_UI, type Field, type Node, type NumberField, type Project, type ShapeLayer } from './model'; -import { animatedValue, type ParamAnim } from './motion'; +import { geoKeysOf } from './keys'; +import { animatedValue, keyedValue, type Key, type ParamAnim } from './motion'; import { buildScene, walk } from './tree'; -export type GeoAnims = Record; +/** What drives each moving setting: a cycle, or keys. */ +export type GeoAnims = Record; export const modAnimKey = (uid: string, field: string) => `mod:${uid}:${field}`; export const symAnimKey = (field: string) => `sym:${field}`; @@ -37,8 +42,14 @@ function applies(n: Node, key: string): boolean { return typeof (n.content.symbol as unknown as Record)[key.slice(4)] === 'number'; } +/** A node's moving geometry settings: its cycles, with keys in place of any it has keys for. */ +export const geoDrivers = (n: Node): GeoAnims | undefined => (n.keys ? { ...n.animate, ...geoKeysOf(n) } : n.animate); + /** Whether a node has any moving geometry setting. */ -export const hasGeoAnim = (n: Node) => !!n.animate && Object.keys(n.animate).some((k) => applies(n, k)); +export const hasGeoAnim = (n: Node) => { + const d = geoDrivers(n); + return !!d && Object.keys(d).some((k) => applies(n, k)); +}; /** Whether anything in the tree has moving geometry. */ export function anyGeoAnim(nodes: Node[]): boolean { @@ -48,9 +59,9 @@ export function anyGeoAnim(nodes: Node[]): boolean { const numberField = (fields: Field[], key: string) => fields.find((f): f is NumberField => f.key === key && !isChoice(f)); /** A number setting at `t`: whole-number fields (counts, seeds) step. */ -function valueAt(from: number, a: ParamAnim, t: number, loopLength: number | undefined, f: NumberField | undefined): number { - if (typeof a.to !== 'number') return from; - const v = animatedValue(from, a, t, loopLength) as number; +function valueAt(from: number, a: ParamAnim | Key[], t: number, loopLength: number | undefined, f: NumberField | undefined): number { + const v = Array.isArray(a) ? (a.length ? keyedValue(a, t) : from) : typeof a.to === 'number' ? animatedValue(from, a, t, loopLength) : from; + if (typeof v !== 'number') return from; return f?.integer ? Math.round(v) : v; } @@ -109,9 +120,10 @@ export function contentAt(c: ShapeLayer['content'], anims: GeoAnims | undefined, /** A node as it is at `t` (the same object when it has no moving geometry). */ export function nodeAt(n: T, t: number, loopLength?: number): T { if (!hasGeoAnim(n)) return n; - if (isGroup(n)) return { ...n, modifiers: modifiersAt(n.modifiers, n.animate, t, loopLength) }; + const d = geoDrivers(n); + if (isGroup(n)) return { ...n, modifiers: modifiersAt(n.modifiers, d, t, loopLength) }; const l = n as ShapeLayer; - return { ...l, modifiers: modifiersAt(l.modifiers, l.animate, t, loopLength), content: contentAt(l.content, l.animate, t, loopLength) } as T; + return { ...l, modifiers: modifiersAt(l.modifiers, d, t, loopLength), content: contentAt(l.content, d, t, loopLength) } as T; } function nodesAt(nodes: Node[], t: number, loopLength?: number): Node[] { @@ -165,13 +177,16 @@ export function movingDocAt(p: Project, t: number, loopLength?: number, quality? return { layers: doc.layers.filter((l) => need.has(l.id)), renders, quality: doc.quality }; } -/** Drop keyframes whose modifier is gone (after removing a modifier). */ +/** Drop cycles and keys whose modifier is gone (after removing a modifier). */ export function pruneGeoAnims(n: Node) { - if (!n.animate) return; const uids = new Set(n.modifiers.map((m) => m.uid)); - for (const k of Object.keys(n.animate)) { - const m = /^mod:([^:]+):/.exec(k); - if (m && !uids.has(m[1])) delete n.animate[k]; + for (const bag of ['animate', 'keys'] as const) { + const o = n[bag]; + if (!o) continue; + for (const k of Object.keys(o)) { + const m = /^mod:([^:]+):/.exec(k); + if (m && !uids.has(m[1])) delete o[k]; + } + if (!Object.keys(o).length) delete n[bag]; } - if (!Object.keys(n.animate).length) delete n.animate; } diff --git a/ui/src/state/keys.test.ts b/ui/src/state/keys.test.ts new file mode 100644 index 0000000..42e801f --- /dev/null +++ b/ui/src/state/keys.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it } from 'vitest'; +import { geoDrivers, hasGeoAnim, modAnimKey, nodeAt, symAnimKey } from './geoMotion'; +import { anyKeys, autokey, cleanKeys, keyEdits, keyProps, lastKeyTime, parseKeys, setKey, transformKeysOf, updateKeys } from './keys'; +import { migrate } from './migrate'; +import { emptyProject, group, newModifier, symbolLayer, textLayer, type Project, type ShapeLayer } from './model'; +import { applyMat, keyedValue, keyPose, newMotion, placement, type Key } from './motion'; +import { buildScene } from './tree'; + +const project = (...layers: Project['layers']): Project => ({ ...emptyProject(), layers }); +const lin = { type: 'easing' as const, duration: 1, ease: [0, 0, 1, 1] as [number, number, number, number], bounce: 0, stiffness: 200, damping: 20, mass: 1 }; + +describe('key evaluation', () => { + it('rotates 0° at 0 s → 45° at 1 s → 30° at 2.5 s', () => { + const keys: Key[] = [ + { t: 0, v: 0 }, + { t: 1, v: 45 }, + { t: 2.5, v: 30 }, + ]; + expect(keyedValue(keys, 0)).toBe(0); + expect(keyedValue(keys, 1)).toBe(45); + expect(keyedValue(keys, 2.5)).toBe(30); + // Ease in and out by default: halfway in time is halfway in value. + expect(keyedValue(keys, 0.5)).toBeCloseTo(22.5, 6); + expect(keyedValue(keys, 1.75)).toBeCloseTo(37.5, 6); + // Slow near each key. + expect(keyedValue(keys, 0.1) as number).toBeLessThan(4.5); + }); + + it('holds the first value before the first key and the last after the last', () => { + const keys: Key[] = [ + { t: 1, v: 10 }, + { t: 2, v: 20 }, + ]; + expect(keyedValue(keys, 0)).toBe(10); + expect(keyedValue(keys, 5)).toBe(20); + expect(keyedValue([{ t: 3, v: 7 }], 0)).toBe(7); + }); + + it('uses the curve into each key', () => { + const keys: Key[] = [ + { t: 0, v: 0 }, + { t: 2, v: 100, ease: lin }, + ]; + expect(keyedValue(keys, 0.5)).toBeCloseTo(25, 6); + // A time spring is stretched over the gap and overshoots on the way. + const spring: Key[] = [ + { t: 0, v: 0 }, + { t: 1, v: 100, ease: { ...lin, type: 'time', bounce: 0.5 } }, + ]; + const peak = Math.max(...Array.from({ length: 50 }, (_, i) => Number(keyedValue(spring, i / 50)))); + expect(peak).toBeGreaterThan(100); + }); + + it('blends colours in RGB, as cycles do', () => { + const keys: Key[] = [ + { t: 0, v: '#000000' }, + { t: 1, v: '#ff0000', ease: lin }, + { t: 2, v: '#ffffff', ease: lin }, + ]; + expect(keyedValue(keys, 0.5)).toBe('#800000'); + expect(keyedValue(keys, 1.5)).toBe('#ff8080'); + }); +}); + +describe('transform keys', () => { + it('pose away from rest: the layer as if it had the keyed values', () => { + const l = symbolLayer('star', 200, { x: 100, rotation: 10, keys: { rotation: [{ t: 0, v: 10 }, { t: 1, v: 100, ease: lin }], x: [{ t: 0, v: 100 }, { t: 1, v: 300, ease: lin }] } }); + const k = transformKeysOf(l)!; + expect(k.origin).toEqual([100, 0]); + expect(keyPose(k, 0)).toEqual({ x: 0, y: 0, rotate: 0, scale: 1, opacity: 1 }); + const p = keyPose(k, 0.5); + expect(p.x).toBeCloseTo(100, 6); + expect(p.rotate).toBeCloseTo(45, 6); + }); + + it('composes with the motion pose: it floats and follows its keys', () => { + const l = symbolLayer('star', 200, { motion: newMotion({ loop: 'float', amount: 20, period: 2 }), keys: { x: [{ t: 0, v: 0 }, { t: 2, v: 400, ease: lin }] } }); + const { objects } = buildScene(project(l)); + const links = objects[0].motion!; + // Its own motion first, then its keys around it. + expect(links.map((x) => !!x.keys)).toEqual([false, true]); + const placed = links.map((x) => ({ motion: x.motion, pivot: x.keys?.origin ?? ([0, 0] as [number, number]), offset: x.offset, keys: x.keys })); + const [X, Y] = applyMat(placement(placed, 0.5).m, 0, 0); + expect(X).toBeCloseTo(100, 6); + expect(Y).toBeCloseTo(-20, 6); + }); + + it('turns about the layer origin, like its own rotation', () => { + const l = symbolLayer('star', 200, { x: 50, y: 50, keys: { rotation: [{ t: 0, v: 90 }] } }); + const k = transformKeysOf(l)!; + const m = placement([{ motion: newMotion(), pivot: k.origin!, offset: 0, keys: k }], 0).m; + const [X, Y] = applyMat(m, 150, 50); + expect(X).toBeCloseTo(50, 6); + expect(Y).toBeCloseTo(150, 6); + }); + + it('a group keys offsets from rest and fades its children', () => { + const g = group([symbolLayer('star')], { keys: { y: [{ t: 0, v: 0 }, { t: 1, v: -80, ease: lin }], opacity: [{ t: 0, v: 0 }, { t: 1, v: 1, ease: lin }] } }); + const k = transformKeysOf(g)!; + const p = keyPose(k, 0.25); + expect(p.y).toBeCloseTo(-20, 6); + expect(p.opacity).toBeCloseTo(0.25, 6); + // Handed down to the child's object like a group's motion. + expect(buildScene(project(g)).objects[0].motion?.[0].keys?.tracks).toEqual(k.tracks); + }); + + it("a layer's opacity keys go to its object, not its pose", () => { + const l = symbolLayer('star', 200, { keys: { opacity: [{ t: 0, v: 0 }, { t: 1, v: 1 }] } }); + expect(transformKeysOf(l)).toBeNull(); + expect(buildScene(project(l)).objects[0].opacityKeys).toHaveLength(2); + }); +}); + +describe('setting keys', () => { + it('drive modifier and shape settings, and win over a cycle', () => { + const wave = newModifier('wave', { amplitude: 0.1, wavelength: 2, phase: 0 } as never); + const k = modAnimKey(wave.uid, 'amplitude'); + const l = symbolLayer('star', 200, { + modifiers: [wave], + animate: { [k]: { to: 0.5, period: 2, phase: 0, mode: 'swing' } }, + keys: { [k]: [{ t: 0, v: 0 }, { t: 1, v: 0.2, ease: lin }], [symAnimKey('points')]: [{ t: 0, v: 3 }, { t: 1, v: 9, ease: lin }] }, + }); + expect(hasGeoAnim(l)).toBe(true); + expect(geoDrivers(l)?.[k]).toBe(l.keys![k]); + const at = nodeAt(l, 0.5); + expect((at.modifiers[0] as unknown as Record).amplitude).toBeCloseTo(0.1, 6); + // Whole-number fields step. + expect(at.content.type === 'symbol' && at.content.symbol).toMatchObject({ points: 6 }); + }); + + it('a layer with only keys moves geometry too', () => { + const l = symbolLayer('star', 200, { keys: { [symAnimKey('inner')]: [{ t: 0, v: 0.2 }, { t: 2, v: 0.8 }] } }); + expect(hasGeoAnim(l)).toBe(true); + expect(anyKeys([group([l])])).toBe(true); + expect(lastKeyTime([group([l])])).toBe(2); + }); + + it('a key replaces a cycle on the same setting', () => { + const l = symbolLayer('star', 200, { style: { effect: 'flat', params: { fill: '#000000' }, animate: { fill: { to: '#ffffff', period: 2, phase: 0, mode: 'swing' } } } }); + updateKeys(l, 'style', 'fill', (k) => setKey(k, 0.5, '#ff0000')); + expect(l.style.animate).toBeUndefined(); + expect(l.style.keys?.fill).toEqual([{ t: 0.5, v: '#ff0000' }]); + updateKeys(l, 'style', 'fill', () => null); + expect(l.style.keys).toBeUndefined(); + }); +}); + +describe('editing keyed properties', () => { + const keyed = () => project(symbolLayer('star', 200, { keys: { x: [{ t: 0, v: 0 }, { t: 2, v: 200, ease: lin }] } })); + const star = (p: Project) => p.layers[0] as ShapeLayer; + + it('a drag moves the key at the playhead by as much as it moved the layer', () => { + const before = keyed(); + const after = structuredClone(before); + star(after).x += 30; + keyEdits(before, after, 1, false); + // At 1 s the keys put it at 100; dragged 30 further. + expect(star(after).keys!.x).toEqual([ + { t: 0, v: 0 }, + { t: 1, v: 130 }, + { t: 2, v: 200, ease: lin }, + ]); + }); + + it('updates a key already at the playhead', () => { + const before = keyed(); + const after = structuredClone(before); + star(after).x = -50; + keyEdits(before, after, 2, false); + expect(star(after).keys!.x.map((k) => k.v)).toEqual([0, 150]); + }); + + it('leaves unkeyed properties alone unless autokey is on', () => { + const before = keyed(); + const after = structuredClone(before); + star(after).rotation = 30; + keyEdits(before, after, 1, false); + expect(star(after).keys!.rotation).toBeUndefined(); + keyEdits(before, after, 1, true); + expect(star(after).keys!.rotation).toEqual([ + { t: 0, v: 0 }, + { t: 1, v: 30 }, + ]); + }); + + it('autokey keys materials (absolute) and snaps to frames', () => { + const before = project(symbolLayer('star')); + const after = structuredClone(before); + star(after).style.params.fill = '#ff0000'; + keyEdits(before, after, 0.51, true); + const k = star(after).style.keys!.fill; + expect(k[1]).toEqual({ t: 0.5, v: '#ff0000' }); + expect(String(k[0].v).toLowerCase()).toBe(String(star(before).style.params.fill).toLowerCase()); + }); + + it("doesn't autokey a setting that cycles", () => { + const before = project(symbolLayer('star', 200, { style: { effect: 'flat', params: { fill: '#000000' }, animate: { fill: { to: '#ffffff', period: 2, phase: 0, mode: 'swing' } } } })); + const after = structuredClone(before); + star(after).style.params.fill = '#ff0000'; + keyEdits(before, after, 1, true); + expect(star(after).style.keys).toBeUndefined(); + }); + + it('lists transform, material, modifier and shape settings', () => { + const l = symbolLayer('star', 200, { modifiers: [newModifier('wave')] }); + const keys = keyProps(l).map((p) => p.key); + expect(keys).toEqual(expect.arrayContaining(['x', 'y', 'rotation', 'scale', 'opacity', 'fill', `mod:${l.modifiers[0].uid}:amplitude`, 'sym:points'])); + expect(keyProps(group([l])).map((p) => p.key)).toEqual([]); + }); + + it('autokey is off until switched on', () => { + expect(autokey.on).toBe(false); + }); +}); + +describe('reading keys', () => { + it('parses the short form agents write', () => { + const k = parseKeys([ + [2.5, 30], + [0, 0], + [1, 45, 'linear'], + ]); + expect(k.map((x) => x.t)).toEqual([0, 1, 2.5]); + expect(k[1].ease?.ease).toEqual([0, 0, 1, 1]); + expect(parseKeys([{ t: 0, v: '#ffffff', ease: 'bouncy' }])[0].ease?.type).toBe('time'); + expect(() => parseKeys([[0, 0, 'wobbly']])).toThrow(/no curve/); + expect(() => parseKeys({ t: 0 })).toThrow(/list/); + }); + + it('old files load unchanged; keys are sorted and cleaned', () => { + const old = { version: 2, name: 'Old', canvas: { width: 800, height: 800 }, layers: [textLayer('Hi')], fonts: [] }; + const p = migrate(structuredClone(old)); + expect(p.layers[0].keys).toBeUndefined(); + expect(p.layers[0].style.keys).toBeUndefined(); + const withKeys = structuredClone(old); + Object.assign(withKeys.layers[0], { keys: { rotation: [{ t: 2, v: 30 }, { t: 0, v: 0 }, { t: 'x', v: 1 }], x: [] } }); + Object.assign(withKeys.layers[0].style, { keys: { fill: [{ t: 0, v: '#ffffff', ease: 'nope' }] } }); + const q = migrate(withKeys); + expect(q.layers[0].keys).toEqual({ rotation: [{ t: 0, v: 0 }, { t: 2, v: 30 }] }); + expect(q.layers[0].style.keys).toEqual({ fill: [{ t: 0, v: '#ffffff' }] }); + expect(cleanKeys({})).toBeUndefined(); + }); +}); diff --git a/ui/src/state/keys.ts b/ui/src/state/keys.ts new file mode 100644 index 0000000..43da972 --- /dev/null +++ b/ui/src/state/keys.ts @@ -0,0 +1,312 @@ +// Free keyframes: any number of keys per property, each at its own time on +// the scene clock with its own value and its own curve into it, like Figma +// Motion's or After Effects' keys. Presets (motion.ts) stay the quick path; +// keys are the "more control" layer, and the two act together: a layer can +// float and follow keys. Evaluation lives in motion.ts (keyedValue, keyPose). +// +// Where keys live: +// node.keys.x | y | rotation | scale the transform: absolute values, as the +// Inspector shows them (a group: offsets from rest) +// node.keys.opacity the style's opacity (a group: a fade) +// node.keys["mod::"], ["sym:"], ["morph:t"], ["line:start|end|offset"] +// geometry, rebuilt like geoMotion.ts's cycles +// node.style.keys[] a material setting (numbers and colours) +// +// A property holds its first key's value before it and its last key's after +// it; on a looping timeline it jumps back to the first at the seam. A setting +// with both a cycle (`animate`) and keys follows its keys: keys win, and the +// Inspector keeps the two exclusive (a key drops the cycle, a cycle drops the +// keys). +// +// Once a property has keys, changing it (on the canvas or in the Inspector) +// writes a key at the playhead instead (`keyEdits`, run on every edit). With +// autokey on, changing a property without keys starts them: its old value at +// 0 s and the new one at the playhead. +import { effectById, type ParamDef } from '../render/effects'; +import { isChoice, isGroup, MODIFIER_UI, SYMBOL_UI, type Field, type Node, type Project } from './model'; +import { EASES, FEELS, keyedValue, type Bezier, type Key, type KeyedTransform, type Transition, KEY_EASE } from './motion'; + +export type { Key }; +export type KeyTracks = Record; +export type KeyScope = 'node' | 'style'; + +export const TRANSFORM_PROPS = ['x', 'y', 'rotation', 'scale', 'opacity'] as const; +export type TransformProp = (typeof TRANSFORM_PROPS)[number]; +export const TRANSFORM_LABELS: Record = { x: 'Position X', y: 'Position Y', rotation: 'Rotation', scale: 'Scale', opacity: 'Opacity' }; +export const isTransformProp = (k: string): k is TransformProp => (TRANSFORM_PROPS as readonly string[]).includes(k); + +/** Keys within half a frame of a time are "at" it. */ +export const KEY_SNAP = 1 / 60; + +/** Times land on whole frames (30 a second, as video export renders). */ +export const onFrame = (t: number) => Math.max(0, Math.round(t * 30) / 30); + +const live = (b: KeyTracks | undefined) => !!b && Object.values(b).some((k) => k.length > 0); + +/** Whether a node has keys of its own (transform, geometry or material). */ +export const hasKeys = (n: Node) => live(n.keys) || live(n.style.keys); + +/** Whether anything in the tree has keys. */ +export function anyKeys(nodes: Node[]): boolean { + return nodes.some((n) => hasKeys(n) || (isGroup(n) && anyKeys(n.children))); +} + +/** The latest key in the tree (0 without any). */ +export function lastKeyTime(nodes: Node[]): number { + let t = 0; + for (const n of nodes) { + for (const k of [...Object.values(n.keys ?? {}), ...Object.values(n.style.keys ?? {})]) if (k.length) t = Math.max(t, k[k.length - 1].t); + if (isGroup(n)) t = Math.max(t, lastKeyTime(n.children)); + } + return t; +} + +/** A node's transform keys as the renderer applies them (null without any). */ +export function transformKeysOf(n: Node): KeyedTransform | null { + const k = n.keys; + if (!k) return null; + const group = isGroup(n); + const props = group ? TRANSFORM_PROPS : TRANSFORM_PROPS.filter((p) => p !== 'opacity'); + if (!props.some((p) => k[p]?.length)) return null; + const tracks = Object.fromEntries(props.filter((p) => k[p]?.length).map((p) => [p, k[p]])); + if (group) return { tracks, rest: { x: 0, y: 0, rotation: 0, scale: 1 }, fade: true }; + return { tracks, rest: { x: n.x, y: n.y, rotation: n.rotation, scale: n.scale }, origin: [n.x, n.y] }; +} + +/** A layer's or merged group's opacity keys: they set its material's opacity directly. */ +export const opacityKeysOf = (n: Node) => (!isGroup(n) || n.merge ? (n.keys?.opacity?.length ? n.keys.opacity : undefined) : undefined); + +/** Geometry keys (modifier, shape, morph and line settings), for geoMotion. */ +export function geoKeysOf(n: Node): KeyTracks | undefined { + if (!n.keys) return undefined; + const out = Object.entries(n.keys).filter(([k, v]) => v.length && !isTransformProp(k)); + return out.length ? Object.fromEntries(out) : undefined; +} + +/** Index of the key at `t` (within half a frame), or -1. */ +export const keyAt = (keys: Key[] | undefined, t: number) => keys?.findIndex((k) => Math.abs(k.t - t) < KEY_SNAP) ?? -1; + +/** The key before / after `t` (not counting one at it). */ +export const prevKey = (keys: Key[] | undefined, t: number) => [...(keys ?? [])].reverse().find((k) => k.t < t - KEY_SNAP); +export const nextKey = (keys: Key[] | undefined, t: number) => keys?.find((k) => k.t > t + KEY_SNAP); + +export const sortKeys = (keys: Key[]) => keys.sort((a, b) => a.t - b.t); + +/** Add a key at `t`, or change the one there (keeping its curve). Returns the keys, sorted. */ +export function setKey(keys: Key[], t: number, v: number | string, ease?: Transition): Key[] { + const at = onFrame(t); + const i = keyAt(keys, at); + if (i >= 0) { + keys[i].v = v; + if (ease) keys[i].ease = ease; + } else keys.push(ease ? { t: at, v, ease } : { t: at, v }); + return sortKeys(keys); +} + +/** The keys bag a property lives in: material settings on the style, everything else on the node. */ +export const bagOf = (n: Node, scope: KeyScope) => (scope === 'style' ? n.style.keys : n.keys); + +/** + * Change one property's keys in place: `fn` edits them (or returns new ones, + * or null to clear them). Empty lists and bags are dropped, and keys replace a + * cycle on the same setting. + */ +export function updateKeys(n: Node, scope: KeyScope, key: string, fn: (keys: Key[]) => Key[] | null | void) { + const bag = scope === 'style' ? (n.style.keys ??= {}) : (n.keys ??= {}); + const r = fn(bag[key] ?? []); + const next = r === null ? [] : sortKeys(r ?? bag[key] ?? []); + if (next.length) { + bag[key] = next; + const cycles = scope === 'style' ? n.style.animate : n.animate; + if (cycles?.[key]) { + delete cycles[key]; + if (!Object.keys(cycles).length) delete (scope === 'style' ? n.style : n).animate; + } + } else delete bag[key]; + if (!Object.keys(bag).length) delete (scope === 'style' ? n.style : n).keys; +} + +// ---------- properties: what can be keyed on a node ---------- + +export interface KeyProp { + scope: KeyScope; + key: string; + get: (n: Node) => number | string | undefined; + /** How an edit to a keyed property moves its key: by the change (canvas drags, nudges), by the ratio (scale), or to the new value. */ + rel?: 'add' | 'mul'; + /** Whole numbers only (counts, seeds). */ + integer?: boolean; +} + +/** A material setting that can move: numbers (but not choices or switches) and colours. */ +export const keyableParam = (p: ParamDef) => p.type === 'color' || (!p.options && !(p.min === 0 && p.max === 1 && p.step === 1)); + +const numberFields = (fields: Field[]) => fields.filter((f): f is Extract => !isChoice(f) && !('toggle' in f && f.toggle)); +const rec = (o: unknown) => o as Record; +const num = (v: unknown) => (typeof v === 'number' ? v : undefined); + +/** Every keyable property of `n` that `was` (the same node before an edit) has too. */ +export function keyProps(n: Node, was: Node = n): KeyProp[] { + const out: KeyProp[] = []; + if (!isGroup(n)) { + out.push({ scope: 'node', key: 'x', rel: 'add', get: (x) => num(rec(x).x) }, { scope: 'node', key: 'y', rel: 'add', get: (x) => num(rec(x).y) }); + out.push({ scope: 'node', key: 'rotation', rel: 'add', get: (x) => num(rec(x).rotation) }, { scope: 'node', key: 'scale', rel: 'mul', get: (x) => num(rec(x).scale) }); + } + if (!isGroup(n) || n.merge) { + out.push({ scope: 'node', key: 'opacity', get: (x) => x.style.opacity ?? 1 }); + if (n.style.effect === was.style.effect) { + for (const p of effectById(n.style.effect).params) if (keyableParam(p)) out.push({ scope: 'style', key: p.key, get: (x) => x.style.params[p.key] ?? p.default }); + } + } + for (const m of n.modifiers) { + const before = was.modifiers.find((x) => x.uid === m.uid); + if (!before || before.kind !== m.kind) continue; + for (const f of numberFields(MODIFIER_UI[m.kind].fields)) { + out.push({ scope: 'node', key: `mod:${m.uid}:${f.key}`, integer: f.integer, get: (x) => num(rec(x.modifiers.find((y) => y.uid === m.uid))?.[f.key]) }); + } + } + if (isGroup(n) || isGroup(was)) return out; + const c = n.content; + const b = (was as typeof n).content; + if (c.type === 'symbol' && b.type === 'symbol' && c.symbol.kind === b.symbol.kind) { + for (const f of numberFields(SYMBOL_UI[c.symbol.kind].fields)) out.push({ scope: 'node', key: `sym:${f.key}`, integer: f.integer, get: (x) => (!isGroup(x) && x.content.type === 'symbol' ? num(rec(x.content.symbol)[f.key]) : undefined) }); + } + if ((c.type === 'text' && c.morph) || c.type === 'morph') { + out.push({ scope: 'node', key: 'morph:t', get: (x) => (isGroup(x) ? undefined : x.content.type === 'morph' ? x.content.t : x.content.type === 'text' ? x.content.morph?.t : undefined) }); + } + if (c.type === 'line') { + for (const f of ['start', 'end', 'offset'] as const) out.push({ scope: 'node', key: `line:${f}`, get: (x) => (!isGroup(x) && x.content.type === 'line' ? (x.content.trim?.[f] ?? (f === 'end' ? 1 : 0)) : undefined) }); + } + return out; +} + +const same = (a: number | string, b: number | string) => (typeof a === 'number' && typeof b === 'number' ? Math.abs(a - b) < 1e-9 : String(a).toLowerCase() === String(b).toLowerCase()); + +function nodesById(nodes: Node[], out = new Map()) { + for (const n of nodes) { + out.set(n.id, n); + if (isGroup(n)) nodesById(n.children, out); + } + return out; +} + +/** + * After an edit: every keyed property it changed gets a key at the playhead + * `t` instead, and with `autokey` every keyable property it changed starts + * keys (its old value at 0 s, the new one at `t`). A drag moves a keyed + * position or angle by as much as it moved the layer, and a scale by the same + * ratio, so the object follows the pointer wherever the keys had put it; + * everything else takes the new value. Settings that cycle aren't autokeyed. + */ +export function keyEdits(before: Project, after: Project, t: number, autokey: boolean) { + if (!autokey && !anyKeys(after.layers)) return; + const old = nodesById(before.layers); + const at = onFrame(t); + const visit = (nodes: Node[]) => { + for (const n of nodes) { + if (isGroup(n)) visit(n.children); + const b = old.get(n.id); + if (!b || (!autokey && !hasKeys(n)) || isGroup(n) !== isGroup(b)) continue; + for (const p of keyProps(n, b)) { + const [was, now] = [p.get(b), p.get(n)]; + if (was === undefined || now === undefined || same(was, now)) continue; + const keys = bagOf(n, p.scope)?.[p.key]; + if (keys?.length) { + const cur = keyedValue(keys, at); + let v = now; + if (typeof cur === 'number' && typeof was === 'number' && typeof now === 'number') { + if (p.rel === 'add') v = cur + (now - was); + else if (p.rel === 'mul') v = Math.abs(was) > 1e-9 ? cur * (now / was) : now; + } + setKey(keys, at, typeof v === 'number' && p.integer ? Math.round(v) : v); + } else if (autokey && !(p.scope === 'style' ? n.style.animate : n.animate)?.[p.key]) { + updateKeys(n, p.scope, p.key, () => (at > KEY_SNAP ? [{ t: 0, v: was }, { t: at, v: now }] : [{ t: 0, v: now }])); + } + } + } + }; + visit(after.layers); +} + +// ---------- autokey: a record switch in the timeline, not saved ---------- + +let recording = false; +const recSubs = new Set<() => void>(); +export const autokey = { + get on() { + return recording; + }, + set(v: boolean) { + recording = v; + recSubs.forEach((f) => f()); + }, + subscribe(f: () => void) { + recSubs.add(f); + return () => void recSubs.delete(f); + }, +}; + +// ---------- reading keys from files and agents ---------- + +/** Named curves for keys: the easing presets ("ease out", "linear"…) and the feels ("smooth", "bouncy"…). */ +export function easeNamed(name: string): Transition | undefined { + const s = name.trim().toLowerCase().replace(/[-_]/g, ' '); + const e = EASES.find((x) => x.label.toLowerCase() === s); + if (e) return { ...KEY_EASE, ease: [...e.value] as Bezier }; + const f = FEELS.find((x) => x.label.toLowerCase() === s); + return f ? { ...f.value } : undefined; +} + +/** The name of a key's curve, when it has one. */ +export function easeName(tr: Transition | undefined): string { + if (!tr) return 'ease in out'; + if (tr.type === 'easing') { + const e = EASES.find((x) => x.value.every((v, i) => Math.abs(v - tr.ease[i]) < 1e-3)); + return e ? e.label.toLowerCase() : `[${tr.ease.join(',')}]`; + } + const f = FEELS.find((x) => x.value.type === tr.type && Math.abs(x.value.bounce - tr.bounce) < 1e-3); + return f && tr.type === 'time' ? f.label.toLowerCase() : tr.type === 'time' ? `spring bounce ${tr.bounce}` : 'physics spring'; +} + +const isTransition = (o: unknown): o is Transition => !!o && typeof o === 'object' && ['easing', 'time', 'physics'].includes(String(rec(o).type)); + +function easeOf(raw: unknown): Transition | undefined { + if (raw === undefined || raw === null) return undefined; + if (typeof raw === 'string') { + const e = easeNamed(raw); + if (!e) throw new Error(`no curve "${raw}"; use ${[...EASES.map((x) => x.label), ...FEELS.map((x) => x.label)].map((x) => x.toLowerCase()).join(', ')}, 4 Bézier numbers or a transition`); + return e; + } + if (Array.isArray(raw) && raw.length === 4 && raw.every((v) => typeof v === 'number')) return { ...KEY_EASE, ease: raw as Bezier }; + if (isTransition(raw)) return { ...KEY_EASE, ...raw }; + throw new Error(`a key's curve is a name, 4 Bézier numbers or a transition, got ${JSON.stringify(raw)}`); +} + +/** + * Keys as written by hand or by an agent: [[t, v], [t, v, curve]…] or + * [{ t, v, ease }…], in any order. Throws on anything else. + */ +export function parseKeys(raw: unknown): Key[] { + if (!Array.isArray(raw)) throw new Error(`keys are a list like [[0,0],[1,45,"linear"]], got ${JSON.stringify(raw)}`); + const keys = raw.map((k): Key => { + const [t, v, ease] = Array.isArray(k) ? k : k && typeof k === 'object' ? [rec(k).t, rec(k).v, rec(k).ease] : []; + if (typeof t !== 'number' || !Number.isFinite(t) || t < 0) throw new Error(`a key's time is seconds ≥ 0, got ${JSON.stringify(k)}`); + if (typeof v !== 'number' && typeof v !== 'string') throw new Error(`a key's value is a number or a colour, got ${JSON.stringify(k)}`); + const tr = easeOf(ease); + return tr ? { t, v, ease: tr } : { t, v }; + }); + return sortKeys(keys); +} + +/** Keys as loaded from a file: well-formed ones, sorted, empty lists dropped. */ +export function cleanKeys(bag: unknown): KeyTracks | undefined { + if (!bag || typeof bag !== 'object') return undefined; + const out: KeyTracks = {}; + for (const [k, list] of Object.entries(bag)) { + if (!Array.isArray(list)) continue; + const keys = list.filter((x): x is Key => !!x && typeof x === 'object' && Number.isFinite(x.t) && (typeof x.v === 'number' || typeof x.v === 'string')); + for (const x of keys) if (x.ease && !isTransition(x.ease)) delete x.ease; + if (keys.length) out[k] = sortKeys(keys); + } + return Object.keys(out).length ? out : undefined; +} diff --git a/ui/src/state/letters.ts b/ui/src/state/letters.ts index 582c6ad..fb1fb4a 100644 --- a/ui/src/state/letters.ts +++ b/ui/src/state/letters.ts @@ -6,7 +6,7 @@ // in the text's order, as a staggered group does with its children. import type { Box, RenderOut } from '../engine/types'; import { isGroup, type Node, type ShapeLayer } from './model'; -import { isStill, seedOf, staggerSlot, type LetterMode, type Motion, type StaggerOrder } from './motion'; +import { isStill, seedOf, staggerSlot, type KeyedTransform, type LetterMode, type Motion, type StaggerOrder } from './motion'; /** A text layer whose letters or words move on their own. */ export const splitsText = (n: Node | undefined): n is ShapeLayer => !!n && !isGroup(n) && n.content.type === 'text' && !!n.motion?.letters && !isStill(n.motion); @@ -74,6 +74,8 @@ export interface PlacedLink { motion: Motion; pivot: [number, number]; offset: number; + /** Transform keys (a link of their own, see tree.ts). */ + keys?: KeyedTransform; } /** diff --git a/ui/src/state/migrate.ts b/ui/src/state/migrate.ts index ceefb9a..1c4e463 100644 --- a/ui/src/state/migrate.ts +++ b/ui/src/state/migrate.ts @@ -1,3 +1,4 @@ +import { cleanKeys } from './keys'; import type { EngineLayer } from '../engine/types'; import { defaultParams, effectById, type ParamValues } from '../render/effects'; import { background, fill, group, isGroup, tile, type Background, type Node, type Project, type ShapeLayer, type Style } from './model'; @@ -63,6 +64,12 @@ function normalize(p: Project): Project { n.style ??= { effect: 'flat', params: defaultParams(effectById('flat')) }; } if (!isGroup(n) || n.style) n.style.params = { ...defaultParams(effectById(n.style.effect)), ...n.style.params }; + // Keys: well-formed and in time order (files from before them have none). + for (const o of [n, n.style]) { + const k = cleanKeys(o.keys); + if (k) o.keys = k; + else delete o.keys; + } }; p.layers.forEach(fix); p.background ??= background(); diff --git a/ui/src/state/model.ts b/ui/src/state/model.ts index eacf21c..f1d7587 100644 --- a/ui/src/state/model.ts +++ b/ui/src/state/model.ts @@ -1,6 +1,6 @@ import type { Content, EngineLayer, GlyphPart, LineCap, ModEntry, Modifier, ModifierKind, Op, SymbolKind, SymbolSpec, TextMorph } from '../engine/types'; import { defaultParams, effectById, type ParamValues } from '../render/effects'; -import type { Motion, ParamAnim } from './motion'; +import type { Key, Motion, ParamAnim } from './motion'; import type { Timeline } from './clock'; export interface ProjectFont { @@ -52,6 +52,8 @@ export interface Style { soften?: number; /** Settings that change over time on the scene clock, by param key. */ animate?: Record; + /** Settings keyed on the scene clock, by param key (see keys.ts; keys win over `animate`). */ + keys?: Record; } export function stroke(width = 6, color = '#0a0a0a', align: StrokeAlign = 'outside'): Stroke { @@ -69,6 +71,8 @@ export interface ShapeLayer extends EngineLayer { motion?: Motion; /** Shape and modifier settings that change over time, rebuilt on the scene clock (see geoMotion.ts). */ animate?: Record; + /** Keys on the scene clock: x, y, rotation, scale, opacity and geometry settings (see keys.ts). */ + keys?: Record; } /** @@ -95,6 +99,8 @@ export interface Group { motion?: Motion; /** Merged groups: modifier settings that change over time (see geoMotion.ts). */ animate?: Record; + /** Keys on the scene clock: offsets from rest (x, y, rotation, scale), a fade, and merged groups' geometry (see keys.ts). */ + keys?: Record; } export type Node = ShapeLayer | Group; diff --git a/ui/src/state/motion.ts b/ui/src/state/motion.ts index c769e37..1c3b311 100644 --- a/ui/src/state/motion.ts +++ b/ui/src/state/motion.ts @@ -371,14 +371,84 @@ export function animMix(a: ParamAnim, t: number, loopLength?: number): number { return 0.5 - 0.5 * Math.cos(2 * Math.PI * u); } -/** The setting's value at `t`: numbers blend, colours blend in RGB. */ -export function animatedValue(from: number | string, a: ParamAnim, t: number, loopLength?: number): number | string { - const k = animMix(a, t, loopLength); - if (typeof from === 'string' || typeof a.to === 'string') { - const [p, q] = [hexRgb(String(from)), hexRgb(String(a.to))]; +/** `k` of the way from `a` to `b`: numbers blend, colours blend in RGB. */ +export function mixValue(a: number | string, b: number | string, k: number): number | string { + if (typeof a === 'string' || typeof b === 'string') { + const [p, q] = [hexRgb(String(a)), hexRgb(String(b))]; return rgbHex(p.map((v, i) => v + (q[i] - v) * k)); } - return from + (a.to - from) * k; + return a + (b - a) * k; +} + +/** The setting's value at `t`: numbers blend, colours blend in RGB. */ +export function animatedValue(from: number | string, a: ParamAnim, t: number, loopLength?: number): number | string { + return mixValue(from, a.to, animMix(a, t, loopLength)); +} + +// ---------- free keys ---------- + +/** + * One key on a property (see keys.ts for where keys live): its value at `t` + * seconds on the clock, reached from the key before through `ease`. + */ +export interface Key { + t: number; + v: number | string; + /** The curve into this key from the one before (KEY_EASE when absent). */ + ease?: Transition; +} + +/** Keys ease in and out unless they say otherwise. */ +export const KEY_EASE: Transition = { type: 'easing', duration: 1, ease: [0.65, 0, 0.35, 1], bounce: 0.25, stiffness: 200, damping: 20, mass: 1 }; + +/** + * Progress from one key to the next, `gap` s apart, `dt` s after the first: + * an easing curve and a time spring are stretched over the gap; a physics + * spring runs at its own pace (and may still be moving at the next key). + */ +export function keyProgress(tr: Transition, dt: number, gap: number): number { + if (gap <= 1e-6) return 1; + if (tr.type === 'easing') return bezierAt(tr.ease, dt / gap); + return progressAt(tr.type === 'time' ? { ...tr, duration: gap } : tr, dt); +} + +/** A keyed property's value at `t`: the first key's before it, the last key's after (keys sorted by time). */ +export function keyedValue(keys: Key[], t: number): number | string { + if (t <= keys[0].t) return keys[0].v; + const i = keys.findIndex((k) => k.t > t); + if (i < 0) return keys[keys.length - 1].v; + const [a, b] = [keys[i - 1], keys[i]]; + return mixValue(a.v, b.v, keyProgress(b.ease ?? KEY_EASE, t - a.t, b.t - a.t)); +} + +/** A number at `t` from its keys, or `rest` when it has none. */ +export const keyedNumber = (keys: Key[] | undefined, t: number, rest: number) => (keys?.length ? Number(keyedValue(keys, t)) : rest); + +/** + * Transform keys acting on a scene object: absolute values (a layer's own x, + * y, rotation and scale; a group's offsets from rest), drawn as a pose away + * from `rest` about the layer's origin, so the result is the layer as if it + * had those values. `fade`: opacity keys act here too (a group's; a layer's + * opacity goes straight to its material, see SceneItem.opacityKeys). + */ +export interface KeyedTransform { + tracks: Record; + rest: { x: number; y: number; rotation: number; scale: number }; + /** Artboard point keys turn and scale about (a layer's origin); a group's centre when absent. */ + origin?: [number, number]; + fade?: boolean; +} + +/** The pose transform keys give at `t`. */ +export function keyPose(k: KeyedTransform, t: number): Pose { + const { tracks: tr, rest } = k; + return { + x: keyedNumber(tr.x, t, rest.x) - rest.x, + y: keyedNumber(tr.y, t, rest.y) - rest.y, + rotate: keyedNumber(tr.rotation, t, rest.rotation) - rest.rotation, + scale: Math.abs(rest.scale) > 1e-6 ? keyedNumber(tr.scale, t, rest.scale) / rest.scale : 1, + opacity: k.fade ? Math.min(1, Math.max(0, keyedNumber(tr.opacity, t, 1))) : 1, + }; } // ---------- poses ---------- @@ -623,10 +693,17 @@ export interface MotionLink { at: string; /** Seconds this object runs behind (a group's stagger). */ offset: number; + /** A node's transform keys (with STILL as the motion): a link of their own, outside the node's motion. */ + keys?: KeyedTransform; } +/** The motion of a link that only carries keys. */ +export const STILL: Motion = newMotion(); + +type PlacedLink = { motion: Motion; pivot: [number, number]; offset: number; keys?: KeyedTransform }; + /** Farthest (px) any corner of `box` travels from rest over `times`. */ -export function motionReach(links: { motion: Motion; pivot: [number, number]; offset: number }[], box: [number, number, number, number], times: number[], loopLength?: number): number { +export function motionReach(links: PlacedLink[], box: [number, number, number, number], times: number[], loopLength?: number): number { const corners: [number, number][] = [ [box[0], box[1]], [box[2], box[1]], @@ -645,11 +722,12 @@ export function motionReach(links: { motion: Motion; pivot: [number, number]; of } /** Where an object is drawn at `t`: its motions, innermost first, about their pivots. */ -export function placement(links: { motion: Motion; pivot: [number, number]; offset: number }[], t: number, loopLength?: number): { m: Mat; opacity: number } { +export function placement(links: PlacedLink[], t: number, loopLength?: number): { m: Mat; opacity: number } { let m = IDENTITY; let opacity = 1; for (const l of links) { - const p = poseAt(l.motion, t - l.offset, loopLength); + let p = poseAt(l.motion, t - l.offset, loopLength); + if (l.keys) p = join(p, keyPose(l.keys, t - l.offset)); m = compose(poseMatrix(p, l.pivot), m); opacity *= p.opacity; } diff --git a/ui/src/state/store.ts b/ui/src/state/store.ts index 23a3670..cc216ad 100644 --- a/ui/src/state/store.ts +++ b/ui/src/state/store.ts @@ -1,6 +1,8 @@ import { useSyncExternalStore } from 'react'; import type { BuildResult } from '../engine/client'; import type { FontEntry } from '../fonts/library'; +import { clock, timelineOf } from './clock'; +import { autokey, keyEdits } from './keys'; import { emptyProject, uid, type Project } from './model'; import { loadSettings, saveSettings, type Settings } from './settings'; import { find } from './tree'; @@ -128,12 +130,14 @@ export function useStore(select: (s: State) => T): T { /** * Apply an edit to the project. Edits sharing `key` within a short window - * (e.g. one slider drag) collapse into a single undo step. + * (e.g. one slider drag) collapse into a single undo step. A keyed property + * it changes takes a key at the playhead instead (see keys.ts). */ export function edit(fn: (draft: Project) => void, key = '') { const before = state.project; const draft = structuredClone(before); fn(draft); + keyEdits(before, draft, clock.time(timelineOf(draft)), autokey.on); const now = performance.now(); const coalesce = key !== '' && key === lastKey && (gesture || now - lastTime < COALESCE_MS); lastKey = key; diff --git a/ui/src/state/tree.ts b/ui/src/state/tree.ts index 95b85b2..62d01e9 100644 --- a/ui/src/state/tree.ts +++ b/ui/src/state/tree.ts @@ -1,7 +1,8 @@ import type { EngineDocument, EngineLayer, Member, Op, RenderSpec } from '../engine/types'; import { isGroup, type Group, type Node, type Project, type ShapeLayer, type Style } from './model'; import { splitsText } from './letters'; -import { isStill, seedOf, staggerSlot, type LetterMode, type MotionLink } from './motion'; +import { opacityKeysOf, transformKeysOf } from './keys'; +import { isStill, seedOf, staggerSlot, STILL, type Key, type LetterMode, type MotionLink } from './motion'; export interface Found { node: Node; @@ -127,6 +128,8 @@ export interface SceneObject { motion?: MotionLink[]; /** A text whose own motion plays on each letter or word in turn (its render says which outline is which). */ letters?: LetterMode; + /** Its opacity, keyed on the clock. */ + opacityKeys?: Key[]; } /** A node's own motion, plus the links its group hands down to its `index`-th child. */ @@ -136,6 +139,11 @@ function ownLink(n: Node): MotionLink | null { function childLink(g: Group, child: Node, index: number): MotionLink | null { return g.motion && !isStill(g.motion) && g.motion.stagger > 0 ? { motion: g.motion, at: child.id, offset: staggerSlot(g.motion.order, index, g.children.length, seedOf(g.id)) * g.motion.stagger } : null; } +/** A node's transform keys: outside its own motion, so a float rides on top of where the keys put it. */ +function keyLink(n: Node): MotionLink | null { + const keys = transformKeysOf(n); + return keys ? { motion: STILL, at: n.id, offset: 0, keys } : null; +} const withLink = (l: MotionLink | null, outer: MotionLink[]) => (l ? [l, ...outer] : outer); /** Strip UI-only fields so style edits don't trigger geometry rebuilds. */ @@ -186,7 +194,7 @@ export function buildScene(p: Project, quality?: number, typing?: string | null) const made: RenderSpec[] = []; let base: RenderSpec[] = []; for (const [i, n] of nodes.entries()) { - const links = withLink(ownLink(n), parent ? withLink(childLink(parent, n, i), outer) : outer); + const links = withLink(ownLink(n), withLink(keyLink(n), parent ? withLink(childLink(parent, n, i), outer) : outer)); const motion = links.length ? links : undefined; const off = hidden || !n.visible; if (n.op !== 'add') { @@ -198,7 +206,7 @@ export function buildScene(p: Project, quality?: number, typing?: string | null) const r: RenderSpec = { id: n.id, members: off ? [] : n.children.flatMap((c) => members(c)), modifiers: n.modifiers }; if (!off) { made.push(r); - objects.push({ id: n.id, style: n.style, motion }); + objects.push({ id: n.id, style: n.style, motion, opacityKeys: opacityKeysOf(n) }); } base = off ? [] : [r]; } else if (isGroup(n)) { @@ -220,7 +228,7 @@ export function buildScene(p: Project, quality?: number, typing?: string | null) const letters = splitsText(n) ? n.motion!.letters : undefined; if (letters) r.letters = true; made.push(r); - objects.push({ id: n.id, style: n.style, guide: n.guide || undefined, motion, letters }); + objects.push({ id: n.id, style: n.style, guide: n.guide || undefined, motion, letters, opacityKeys: opacityKeysOf(n) }); base = [r]; } } From b2bc32837e175d7c9e4fafc310a9d3160bb8e173 Mon Sep 17 00:00:00 2001 From: srivtx Date: Sat, 24 Oct 2026 02:46:13 +0530 Subject: [PATCH 2/5] =?UTF-8?q?Free=20keyframes:=20Inspector=20=E2=97=86,?= =?UTF-8?q?=20timeline=20key=20rows,=20autokey,=20morpho=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ‹ ◆ › next to the transform fields, opacity and every animatable setting (↻ keeps the cycle); a row per keyed property in the timeline with draggable diamonds, a key editor and Backspace to delete; a record switch for autokey. morpho describe and timing show keys, set writes them. Co-Authored-By: Claude Opus 5.5 --- ui/src/agent/catalog.ts | 11 +- ui/src/agent/describe.ts | 11 +- ui/src/agent/edit.ts | 26 +++ ui/src/agent/keys.test.ts | 50 ++++++ ui/src/agent/page.ts | 3 +- ui/src/agent/timing.ts | 32 +++- ui/src/components/Inspector.tsx | 54 ++++-- ui/src/components/Keyframed.tsx | 99 ++++++++++- ui/src/components/StylePanel.tsx | 45 +++-- ui/src/components/canvas/Timeline.tsx | 243 +++++++++++++++++++++++--- ui/src/components/icons.tsx | 2 + ui/src/state/tracks.test.ts | 16 ++ ui/src/state/tracks.ts | 27 ++- ui/src/styles.css | 96 +++++++++- 14 files changed, 648 insertions(+), 67 deletions(-) create mode 100644 ui/src/agent/keys.test.ts diff --git a/ui/src/agent/catalog.ts b/ui/src/agent/catalog.ts index bf36130..ee8ebf8 100644 --- a/ui/src/agent/catalog.ts +++ b/ui/src/agent/catalog.ts @@ -104,6 +104,15 @@ const KEYFRAMES = (): string => ' animate["sym:"] 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[] a material setting (lands in style.keys), numbers or colours', + ' keys["mod::"], ["sym:"], ["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 => @@ -111,7 +120,7 @@ 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`)", diff --git a/ui/src/agent/describe.ts b/ui/src/agent/describe.ts index 0b8b9d5..3279b6b 100644 --- a/ui/src/agent/describe.ts +++ b/ui/src/agent/describe.ts @@ -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'; @@ -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 { + 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`; @@ -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(' · '); } @@ -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); } diff --git a/ui/src/agent/edit.ts b/ui/src/agent/edit.ts index ce0a005..32be751 100644 --- a/ui/src/agent/edit.ts +++ b/ui/src/agent/edit.ts @@ -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'; @@ -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) }; @@ -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 ??= {}; @@ -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::, sym:, morph:t, line:end`); + let keys: ReturnType = []; + 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[] = []; diff --git a/ui/src/agent/keys.test.ts b/ui/src/agent/keys.test.ts new file mode 100644 index 0000000..4a49483 --- /dev/null +++ b/ui/src/agent/keys.test.ts @@ -0,0 +1,50 @@ +import { describe as group, expect, it } from 'vitest'; +import { style, symbolLayer, type Project, type ShapeLayer } from '../state/model'; +import { catalog } from './catalog'; +import { describe } from './describe'; +import { applyEdits, EditError } from './edit'; +import { timing } from './timing'; + +const project = (layers: Project['layers']): Project => ({ version: 2, name: 'Keys', canvas: { width: 1000, height: 800 }, background: { fill: { kind: 'solid', colors: ['#000000'], angle: 180 }, grain: 0, tile: null }, layers, fonts: [], timeline: { duration: 3, loop: true } }); +const star = () => symbolLayer('star', 300, { name: 'Star', style: style('flat', { fill: '#ffcc00' }) }); + +group('free keys from the command line', () => { + it('sets keys with set, in short form, and lengthens the clock to hold them', () => { + const { project: p, changes } = applyEdits(project([star()]), ['Star.keys.rotation=[[0,0],[1,45],[2.5,30,"linear"],[4,0]]', 'Star.keys.fill=[[0,"#ffffff"],[2,"#ff3366"]]']); + const s = p.layers[0] as ShapeLayer; + expect(s.keys?.rotation.map((k) => [k.t, k.v])).toEqual([ + [0, 0], + [1, 45], + [2.5, 30], + [4, 0], + ]); + expect(s.keys?.rotation[2].ease?.ease).toEqual([0, 0, 1, 1]); + // Material settings land on the style. + expect(s.style.keys?.fill).toHaveLength(2); + expect(p.timeline?.duration).toBe(4); + expect(changes[0]).toMatch(/keys\.rotation/); + // null removes them. + expect((applyEdits(p, ['Star.keys.rotation=null']).project.layers[0] as ShapeLayer).keys).toBeUndefined(); + }); + + it('checks what it keys', () => { + expect(() => applyEdits(project([star()]), ['Star.keys.wobble=[[0,1]]'])).toThrow(EditError); + expect(() => applyEdits(project([star()]), ['Star.keys.fill=[[0,12]]'])).toThrow(/colour/); + expect(() => applyEdits(project([star()]), ['Star.keys.x=[[0,0,"zigzag"]]'])).toThrow(/no curve/); + }); + + it('describe, timing and catalog show them', () => { + const { project: p } = applyEdits(project([star()]), ['Star.keys.rotation=[[0,0],[1,45],[2.5,30,"linear"]]']); + expect(describe(p)).toContain('keys rotation 0s 0 → 1s 45 → 2.5s 30 linear'); + const t = timing(p); + expect(t).toMatch(/rotation keys\s+◆─+◆─+◆/); + expect(t).toContain('0s 0 → 1s 45 → 2.5s 30 linear'); + expect(catalog('keyframes', [])).toContain('Star.keys.rotation=[[0,0],[1,45],[2.5,30]]'); + }); + + it('warns about keys after the end of the clock', () => { + const p = project([star()]); + (p.layers[0] as ShapeLayer).keys = { x: [{ t: 0, v: 0 }, { t: 5, v: 100 }] }; + expect(timing(p)).toContain('! a position x key at 5s is after the 3s clock: it never plays'); + }); +}); diff --git a/ui/src/agent/page.ts b/ui/src/agent/page.ts index 80c49e4..e71f589 100644 --- a/ui/src/agent/page.ts +++ b/ui/src/agent/page.ts @@ -13,6 +13,7 @@ import { geometryFrames, holdLiveGeometry } from '../app/geoMotion'; import { addImage, encodeImage, imageElement, packImages, unpackImages } from '../state/assets'; import { clock, timelineOf } from '../state/clock'; import { anyGeoAnim } from '../state/geoMotion'; +import { hasKeys } from '../state/keys'; import { migrate } from '../state/migrate'; import { isGroup, style, symbolLayer, type Node, type Project, type ShapeLayer } from '../state/model'; import { isStill } from '../state/motion'; @@ -236,7 +237,7 @@ function moves(p: Project): boolean { let any = anyGeoAnim(p.layers); const walk = (ns: Node[]) => ns.forEach((n) => { - if (!isStill(n.motion) || Object.keys(n.style.animate ?? {}).length || effectById(n.style.effect).animated?.(n.style.params)) any = true; + if (!isStill(n.motion) || Object.keys(n.style.animate ?? {}).length || hasKeys(n) || effectById(n.style.effect).animated?.(n.style.params)) any = true; if (isGroup(n)) walk(n.children); }); walk(p.layers); diff --git a/ui/src/agent/timing.ts b/ui/src/agent/timing.ts index 1f0db83..590acd2 100644 --- a/ui/src/agent/timing.ts +++ b/ui/src/agent/timing.ts @@ -1,13 +1,14 @@ // When everything moves, as a text chart: one row per moving layer (the // timeline dock's rows, top first) with entrances and exits as bars on the -// clock, loops and keyframes as their cycles, and a few notes on rhythm: many +// clock, loops and keyframes as their cycles, keys as ◆, and a few notes on rhythm: many // things starting at once, or long stretches where nothing happens. import { timelineOf } from '../state/clock'; -import { feelOf, HOLD_GO, HOLD_IN, HOLD_OUT } from '../state/motion'; +import { easeName } from '../state/keys'; +import { feelOf, HOLD_GO, HOLD_IN, HOLD_OUT, type Key } from '../state/motion'; import type { Project } from '../state/model'; import { nodeLabel } from '../state/model'; import { rowsOf, type Track } from '../state/tracks'; -import { num } from './describe'; +import { num, val } from './describe'; const COLS = 48; @@ -34,6 +35,18 @@ function holds(period: number, phase: number, D: number): string { }).join(''); } +/** Keys across the clock: ◆ at each, ─ between the first and the last. */ +function diamonds(keys: Key[], D: number): string { + const cell = (t: number) => Math.max(0, Math.min(COLS - 1, Math.floor((t / D) * COLS))); + const out = Array(COLS).fill('·'); + for (let i = cell(keys[0].t); i <= cell(keys[keys.length - 1].t); i++) out[i] = '─'; + for (const k of keys) if (k.t <= D) out[cell(k.t)] = '◆'; + return out.join(''); +} + +/** "0s 0 → 1s 45 linear → 2.5s 30": each key's time and value, and its curve when it isn't the default. */ +export const keysText = (keys: Key[]) => keys.map((k, i) => `${num(k.t)}s ${val(k.v)}${i && k.ease ? ` ${easeName(k.ease)}` : ''}`).join(' → '); + function trackLine(t: Track, D: number): [string, string, string] { switch (t.kind) { case 'enter': @@ -48,6 +61,8 @@ function trackLine(t: Track, D: number): [string, string, string] { return [t.label.toLowerCase(), t.anim.mode === 'hold' ? holds(t.period, t.anim.phase, D) : cycles(t.period, D, t.anim.mode === 'ramp' ? '/' : t.anim.mode === 'flicker' ? ':' : '∿'), `${t.anim.mode} ${String(t.from)}→${String(t.anim.to)} every ${num(t.period)}s`]; case 'flow': return [t.title.toLowerCase(), '═'.repeat(COLS), t.label]; + case 'keys': + return [`${t.label} keys`.toLowerCase(), diamonds(t.keys, D), keysText(t.keys)]; } } @@ -66,7 +81,7 @@ export function timing(p: Project): string { const at = Math.min(COLS - label.length, Math.round((t / D) * COLS)); for (let i = 0; i < label.length; i++) ruler[at + i] = label[i]; } - const out = [`"${p.name}" clock ${num(D)}s${tl.loop ? ' loop' : ''} · █ in/out, | cycle starts, ∿ swing, / ramp, : flicker, hold _/‾\\ (rest, go, at To, back)`, `${' '.repeat(24 + 24)}${ruler.join('')}`]; + const out = [`"${p.name}" clock ${num(D)}s${tl.loop ? ' loop' : ''} · █ in/out, ◆ keys, | cycle starts, ∿ swing, / ramp, : flicker, hold _/‾\\ (rest, go, at To, back)`, `${' '.repeat(24 + 24)}${ruler.join('')}`]; for (const r of rows) { const name = `${' '.repeat(r.depth)}${nodeLabel(r.node)}`; if (!r.tracks.length) out.push(pad(name, 24)); @@ -90,9 +105,10 @@ export function rhythm(tracks: Track[], D: number, loop: boolean): string[] { if (j - i + 1 >= 3) notes.push(`· ${j - i + 1} entrances start together at ${num(starts[i])}s: a stagger gives them rhythm`); i = j + 1; } + const keyed = tracks.filter((t): t is Extract => t.kind === 'keys'); const always = tracks.some((t) => t.kind === 'loop' || t.kind === 'param' || t.kind === 'flow'); - if (clips.length && !always) { - const spans = clips.map((c) => [c.start, c.end] as const).sort((a, b) => a[0] - b[0]); + if ((clips.length || keyed.length) && !always) { + const spans = [...clips.map((c) => [c.start, c.end] as const), ...keyed.map((k) => [k.keys[0].t, k.keys[k.keys.length - 1].t] as const)].sort((a, b) => a[0] - b[0]); let edge = 0; const gaps: [number, number][] = []; for (const [a, b] of spans) { @@ -104,5 +120,9 @@ export function rhythm(tracks: Track[], D: number, loop: boolean): string[] { } const late = clips.filter((c) => c.end > D + 1e-3); for (const c of late) notes.push(`! a move ends at ${num(c.end)}s, after the ${num(D)}s clock: it gets cut off`); + for (const k of keyed) { + const last = k.keys[k.keys.length - 1].t; + if (last > D + 1e-3) notes.push(`! a ${k.label.toLowerCase()} key at ${num(last)}s is after the ${num(D)}s clock: it never plays`); + } return notes; } diff --git a/ui/src/components/Inspector.tsx b/ui/src/components/Inspector.tsx index 7ba165f..dcd4ba7 100644 --- a/ui/src/components/Inspector.tsx +++ b/ui/src/components/Inspector.tsx @@ -15,13 +15,14 @@ import { edit, select, setSettings, store, toast, undo, useStore } from '../stat import { applyIconTarget, ICON_TARGETS, iconTarget, type IconTarget } from '../state/icon'; import { allLayers, findNode } from '../state/tree'; import { lineAnimKey, modAnimKey, MORPH_KEY, pruneGeoAnims, symAnimKey, TRIM_DEFAULT } from '../state/geoMotion'; -import type { ParamAnim } from '../state/motion'; +import { updateKeys, type KeyTracks } from '../state/keys'; +import { keyedNumber, type ParamAnim } from '../state/motion'; import { ColorChip, ColorField } from './ColorPicker'; import { FontPicker } from './FontPicker'; import { MotionSection } from './MotionPanel'; import { Icon } from './icons'; import { EffectThumb } from './StylePanel'; -import { Keyframed, type SetAnimOf } from './Keyframed'; +import { Keyframed, KeyedField, useKeyed, usePlayhead, type SetAnimOf, type SetKeysOf } from './Keyframed'; import { ModifierPicker, ShapePicker } from './pickers'; import { IconButton, Menu, MenuItem, MenuSep, NumberBox, Popover, Section, Segmented, Select, Slider, Toggle } from './ui'; @@ -176,11 +177,14 @@ function shownColors(st: Style) { /** Material and colour, right where you select: the most common edits. */ function Appearance({ nodes }: { nodes: Node[] }) { + const t = usePlayhead(); const owners = nodes.filter((n) => !isGroup(n) || n.merge); const plainGroup = nodes.length === 1 && isGroup(nodes[0]) && !nodes[0].merge; if (plainGroup) return null; if (!owners.length) return null; const st = styleOf(owners.at(-1)!)!; + const opacityKeys = owners.at(-1)!.keys?.opacity; + const opacity = keyedNumber(opacityKeys, t, st.opacity ?? 1); const def = effectById(st.effect); const clipOnly = owners.every((n) => n.op !== 'add'); const colors = shownColors(st); @@ -227,7 +231,9 @@ function Appearance({ nodes }: { nodes: Node[] }) {
edit((p) => ids.forEach((id) => { const n = findNode(p.layers, id); if (n) n.style.soften = v || undefined; }), `soft:${ids.join()}`)} /> - edit((p) => ids.forEach((id) => { const n = findNode(p.layers, id); if (n) n.style.opacity = v; }), `op:${ids.join()}`)} /> + edit((p) => ids.forEach((id) => { const n = findNode(p.layers, id); if (n) updateKeys(n, 'node', 'opacity', fn); }), `opkeys:${ids.join()}`)}> + edit((p) => ids.forEach((id) => { const n = findNode(p.layers, id); if (n) n.style.opacity = v; }), `op:${ids.join()}`)} /> +
Blend s.build); const [lockRatio, setLockRatio] = useState(true); + const t = usePlayhead(); const only = nodes.length === 1 && !isGroup(nodes[0]) ? (nodes[0] as ShapeLayer) : null; const local = only ? build?.layers.find((o) => o.id === only.id)?.local : null; const box = unionBox(nodes); @@ -330,6 +337,12 @@ function TransformSection({ nodes }: { nodes: Node[] }) { const lh = local[3] - local[1]; const w = Math.abs(lw * only.scale * (only.sx ?? 1)); const h = Math.abs(lh * only.scale * (only.sy ?? 1)); + // Keyed, the fields show the keys' values at the playhead; typing moves the key there (see keys.ts keyEdits). + const x = keyedNumber(only.keys?.x, t, only.x); + const y = keyedNumber(only.keys?.y, t, only.y); + const rotation = keyedNumber(only.keys?.rotation, t, only.rotation); + const scale = keyedNumber(only.keys?.scale, t, only.scale); + const setKeys = (k: string): SetKeysOf => (fn) => updateNode(only.id, (n) => updateKeys(n, 'node', k, fn), `keys:${only.id}:${k}`); const setSize = (nw: number, nh: number) => updateNode(only.id, (n) => { if (isGroup(n) || lw <= 0 || lh <= 0) return; @@ -341,15 +354,23 @@ function TransformSection({ nodes }: { nodes: Node[] }) { return (
- updateNode(only.id, (n) => !isGroup(n) && (n.x = v), 'x')} title="Horizontal position of the layer origin (0 = artboard centre)" /> - updateNode(only.id, (n) => !isGroup(n) && (n.y = v), 'y')} title="Vertical position (0 = artboard centre)" /> + + updateNode(only.id, (n) => !isGroup(n) && (n.x += v - x), 'x')} title="Horizontal position of the layer origin (0 = artboard centre)" /> + + + updateNode(only.id, (n) => !isGroup(n) && (n.y += v - y), 'y')} title="Vertical position (0 = artboard centre)" /> + setSize(v, lockRatio ? (v / (w || 1)) * h : h)} />
setSize(lockRatio ? (v / (h || 1)) * w : w, v)} /> setLockRatio(!lockRatio)} size={13} className="tiny" />
- updateNode(only.id, (n) => !isGroup(n) && (n.rotation = v), 'rot')} /> - updateNode(only.id, (n) => !isGroup(n) && (n.scale = Math.max(0.01, v / 100)), 'scale')} /> + + updateNode(only.id, (n) => !isGroup(n) && (n.rotation += v - rotation), 'rot')} /> + + + updateNode(only.id, (n) => !isGroup(n) && (n.scale = Math.max(0.01, (n.scale * Math.max(0.01, v / 100)) / Math.max(1e-3, scale))), 'scale')} /> +
{(Math.abs((only.sx ?? 1) - 1) > 1e-3 || Math.abs((only.sy ?? 1) - 1) > 1e-3) && ( + )} + + {keyed && ( + + )} + + ); +} + /** - * A setting with a keyframe button: turned on, the setting moves between its - * own value and To on the scene clock, swinging back and forth, ramping round - * or flickering. Used for materials, modifiers and shapes. + * A setting that can move on the scene clock, two ways: keys (◆, see + * KeyButtons) at any times with any values, or a cycle (↻) between its own + * value and To, swinging back and forth, ramping round or flickering. The two + * are exclusive: a key drops the cycle, a cycle drops the keys. Used for + * materials, modifiers and shapes. */ -export function Keyframed({ label, anim, from, color, range, start, set, children }: { label: string; anim: ParamAnim | undefined; from: number | string; color?: boolean; range: { min: number; max: number; step: number }; start?: Partial; set: SetAnimOf; children: React.ReactNode }) { +export function Keyframed({ label, anim, from, color, range, start, set, keys, setKeys, children }: { label: string; anim: ParamAnim | undefined; from: number | string; color?: boolean; range: { min: number; max: number; step: number }; start?: Partial; set: SetAnimOf; keys?: Key[]; setKeys?: SetKeysOf; children: React.ReactNode }) { + const shown = useKeyed(keys, from); const toggle = () => { if (anim) return set(null); // A sensible far end: the other half of the range, or a contrasting colour. @@ -26,13 +93,17 @@ export function Keyframed({ label, anim, from, color, range, start, set, childre set(() => undefined, { to, period: 2, phase: 0, mode: 'swing', ...start }); clock.play(timelineOf(store.get().project)); }; + const keyed = !!keys?.length; return (
{children} - + + {setKeys && } + +
{anim && (
@@ -62,3 +133,13 @@ export function Keyframed({ label, anim, from, color, range, start, set, childre
); } + +/** A field with ‹ ◆ › and no cycle: the transform and opacity. */ +export function KeyedField({ label, keys, value, set, children, className = 'animatable-row' }: { label: string; keys: Key[] | undefined; value: number | string; set: SetKeysOf; children: React.ReactNode; className?: string }) { + return ( +
+ {children} + +
+ ); +} diff --git a/ui/src/components/StylePanel.tsx b/ui/src/components/StylePanel.tsx index 26c9505..ef1da55 100644 --- a/ui/src/components/StylePanel.tsx +++ b/ui/src/components/StylePanel.tsx @@ -4,15 +4,16 @@ import { allImages } from '../state/assets'; import { shortcutOf } from '../app/commands'; import { engine } from '../engine/client'; import { defaultParams, EFFECTS, effectById, PRESETS, type EffectDef, type ParamDef, type ParamValues, type Preset } from '../render/effects'; -import type { ParamAnim } from '../state/motion'; +import { updateKeys } from '../state/keys'; +import { keyedValue, type ParamAnim } from '../state/motion'; import { renderThumb, useThumb } from '../render/thumbs'; import type { SceneItem } from '../render/renderer'; -import { background, fill, isGroup, pattern, textLayer, type Style } from '../state/model'; +import { background, fill, isGroup, pattern, textLayer, type Node, type Style } from '../state/model'; import { edit, setSettings, useStore } from '../state/store'; import { findNode } from '../state/tree'; import { ColorField } from './ColorPicker'; import { Icon } from './icons'; -import { Keyframed } from './Keyframed'; +import { Keyframed, usePlayhead, type SetKeysOf } from './Keyframed'; import { IconButton, Section, Select, Slider, Toggle } from './ui'; // One shared sample outline for every preview card. @@ -82,6 +83,7 @@ export function StylePanel() { const nodes = selection.map((id) => findNode(project.layers, id)).filter((n) => !!n); const primary = nodes.at(-1); const st = primary ? styleOf(primary) : undefined; + const playhead = usePlayhead(); const presets = useMemo(() => { const t = q.trim().toLowerCase(); return t ? PRESETS.filter((p) => `${p.name} ${p.effect} ${p.tags ?? ''} ${effectById(p.effect).name}`.toLowerCase().includes(t)) : PRESETS; @@ -120,11 +122,30 @@ export function StylePanel() { if (t.style.effect !== st.effect) continue; const all = (t.style.animate ??= {}); if (!fn) delete all[k]; - else fn((all[k] ??= structuredClone(init ?? st.animate?.[k] ?? { to: 0, period: 2, phase: 0, mode: 'swing' }))); + else { + // A cycle replaces keys on the same setting. + if (!all[k]) updateKeys(t, 'style', k, () => null); + fn((all[k] ??= structuredClone(init ?? st.animate?.[k] ?? { to: 0, period: 2, phase: 0, mode: 'swing' }))); + } if (!Object.keys(all).length) delete t.style.animate; } } }, fn ? `anim:${selection.join()}:${k}` : ''); + /** Change a setting's keys on every selected layer with this material (see keys.ts). */ + const setKeys = (k: string, fn: Parameters[0]) => + edit((p) => { + for (const n of selectedNodes()) { + const target = findNode(p.layers, n.id); + if (!target) continue; + const targets = isGroup(target) && !target.merge ? collect(target) : [target]; + for (const t of targets) if (t.style.effect === st.effect) updateKeys(t, 'style', k, fn); + } + }, `keys:${selection.join()}:${k}`); + /** A setting as it is at the playhead: its keys' value, else its own. */ + const shown = (k: string, base: number | string) => { + const keys = st.keys?.[k]; + return keys?.length ? keyedValue(keys, playhead) : base; + }; const matches = (p: Preset) => p.effect === st.effect && Object.entries(p.params).every(([k, v]) => st.params[k] === v); const visible = showAll || q ? presets : presets.slice(0, 12); return ( @@ -195,8 +216,8 @@ export function StylePanel() { .filter((p) => advanced || !p.advanced) .map((p) => p.type === 'color' ? ( - - setParam(p.key, v)} /> + + setParam(p.key, v)} /> ) : p.options ? (
@@ -206,8 +227,8 @@ export function StylePanel() { ) : p.min === 0 && p.max === 1 && p.step === 1 ? ( 0.5} onChange={(v) => setParam(p.key, v ? 1 : 0)} /> ) : ( - - setParam(p.key, v)} /> + + setParam(p.key, v)} /> ), )} @@ -221,16 +242,16 @@ export function StylePanel() { type SetAnim = (k: string, fn: ((a: ParamAnim) => void) | null, init?: ParamAnim) => void; -/** A material setting with a keyframe button (see Keyframed). */ -function Animatable({ p, st, setAnim, children }: { p: ParamDef; st: Style; setAnim: SetAnim; children: React.ReactNode }) { +/** A material setting with keys and a cycle (see Keyframed). */ +function Animatable({ p, st, setAnim, setKeys, children }: { p: ParamDef; st: Style; setAnim: SetAnim; setKeys: (k: string, fn: Parameters[0]) => void; children: React.ReactNode }) { return ( - setAnim(p.key, fn, init)}> + setAnim(p.key, fn, init)} keys={st.keys?.[p.key]} setKeys={(fn) => setKeys(p.key, fn)}> {children} ); } -function collect(g: import('../state/model').Group): { style: Style }[] { +function collect(g: import('../state/model').Group): Node[] { return g.children.flatMap((c) => (isGroup(c) && !c.merge ? collect(c) : [c])); } diff --git a/ui/src/components/canvas/Timeline.tsx b/ui/src/components/canvas/Timeline.tsx index 308f6a3..8274aad 100644 --- a/ui/src/components/canvas/Timeline.tsx +++ b/ui/src/components/canvas/Timeline.tsx @@ -3,36 +3,39 @@ // layer that moves and a track for each thing that moves it, placed in time // exactly as it plays: entrances as clips from start to settle, loops and // moving settings as their cycles with the real curve drawn in, colours as -// the colours they pass through. Drag a clip to change when it starts, its -// end to change how long it takes, a cycle to shift it, a cycle mark to -// change its length; click to edit it. The ruler counts seconds and frames. +// the colours they pass through, keys as diamonds on a row per property. +// Drag a clip to change when it starts, its end to change how long it takes, +// a cycle to shift it, a cycle mark to change its length, a diamond to +// retime its key; click to edit it. The ruler counts seconds and frames, and +// the record switch (autokey) keys every change at the playhead. import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; import { exportVideo } from '../../app/actions'; import { effectById } from '../../render/effects'; import { clock, DEFAULT_TIMELINE, timelineOf, type Timeline } from '../../state/clock'; -import { addStop, animatedValue, animMix, ENTER_UI, isStill, lastSlot, legsOf, LOOP_UI, motionEnd, newMotion, poseAt, progressAt, seedOf, settleTime, staggerSlot, thenTransition, type LoopKind, type Motion, type ParamAnim, type Transition } from '../../state/motion'; +import { autokey, easeName, easeNamed, hasKeys, KEY_SNAP, lastKeyTime, onFrame, updateKeys } from '../../state/keys'; +import { addStop, animatedValue, animMix, ENTER_UI, isStill, KEY_EASE, keyedValue, lastSlot, legsOf, LOOP_UI, motionEnd, newMotion, poseAt, progressAt, seedOf, settleTime, staggerSlot, thenTransition, type Key, type LoopKind, type Motion, type ParamAnim, type Transition } from '../../state/motion'; import { isGroup, type Node, type Project } from '../../state/model'; import { beginGesture, commitEdit, edit, select, store, useStore } from '../../state/store'; import { frameAt, loopLengthOf, rowsOf, snapTime, type Clip, type Row, type Track } from '../../state/tracks'; import { findNode } from '../../state/tree'; import { ColorField } from '../ColorPicker'; import { Icon } from '../icons'; -import { AdvancedSwitch, ChildrenControls, CycleSlider, EnterControls, LettersControls, LoopControls, StopControls, ThenControls } from '../MotionPanel'; +import { AdvancedSwitch, ChildrenControls, CycleSlider, EnterControls, LettersControls, LoopControls, StopControls, ThenControls, TransitionEditor, useMotionAdvanced } from '../MotionPanel'; import { textPieces } from '../../state/letters'; -import { Popover, Segmented, Slider } from '../ui'; +import { Popover, Segmented, Select, Slider } from '../ui'; /** Frames per second: what video export renders, and what the ruler and drags count in. */ export const FPS = 30; -/** Anything on the clock: motion on a node, a moving material or moving geometry. */ +/** Anything on the clock: motion on a node, a moving material, moving geometry or keys. */ export function sceneMoves(p: Project): boolean { - const any = (ns: Node[]): boolean => ns.some((n) => !isStill(n.motion) || Object.keys(n.style.animate ?? {}).length > 0 || Object.keys(n.animate ?? {}).length > 0 || !!effectById(n.style.effect).animated?.(n.style.params) || (isGroup(n) && any(n.children))); + const any = (ns: Node[]): boolean => ns.some((n) => !isStill(n.motion) || Object.keys(n.style.animate ?? {}).length > 0 || Object.keys(n.animate ?? {}).length > 0 || hasKeys(n) || !!effectById(n.style.effect).animated?.(n.style.params) || (isGroup(n) && any(n.children))); return any(p.layers); } -/** When the last entrance or Then settles, so the timeline can be fitted to it. */ +/** When the last entrance or Then settles, or the last key, so the timeline can be fitted to it. */ function lastEntrance(ns: Node[]): number { - return Math.max(0, ...ns.map((n) => Math.max(n.motion ? motionEnd(n.motion, isGroup(n) && !n.merge ? n.children.length : textPieces(n)) : 0, isGroup(n) ? lastEntrance(n.children) : 0))); + return Math.max(lastKeyTime(ns), ...ns.map((n) => Math.max(n.motion ? motionEnd(n.motion, isGroup(n) && !n.merge ? n.children.length : textPieces(n)) : 0, isGroup(n) ? lastEntrance(n.children) : 0))); } /** Re-render every frame while playing, and on play / pause / seek (scrubbing while paused included). */ @@ -133,9 +136,30 @@ function TimeReadout({ tl, frames }: { tl: Timeline; frames?: boolean }) { ); } +/** Autokey: while it's on, changing any setting writes a key at the playhead (see keys.ts). */ +function RecordButton({ tl }: { tl: Timeline }) { + const on = useSyncExternalStore(autokey.subscribe, () => autokey.on); + return ( + + ); +} + function LoopAndExport({ tl }: { tl: Timeline }) { return ( <> + @@ -213,11 +237,11 @@ interface Editing { row: string; id: string; anchor: HTMLElement; - /** Then with stops: which step of the path (0 is Move to… itself). */ + /** Then with stops: which step of the path (0 is Move to… itself); keys: which key. */ step?: number; } -const trackId = (k: Track) => (k.kind === 'param' ? `${k.kind}:${k.scope}:${k.key}` : (k.kind === 'enter' || k.kind === 'leave') && k.index !== undefined ? `${k.kind}:inherited` : k.kind); +const trackId = (k: Track) => (k.kind === 'param' || k.kind === 'keys' ? `${k.kind}:${k.scope}:${k.key}` : (k.kind === 'enter' || k.kind === 'leave') && k.index !== undefined ? `${k.kind}:inherited` : k.kind); function Dock({ project, height, onHeight, onFold }: { project: Project; height: number; onHeight: (h: number) => void; onFold: () => void }) { const tl = timelineOf(project); @@ -296,7 +320,7 @@ function Dock({ project, height, onHeight, onFold }: { project: Project; height:
- {!rows.length &&

Select a layer to give it an entrance or a loop. To move a colour or any setting, click ◆ next to it in the panel.

} + {!rows.length &&

Select a layer to give it an entrance or a loop. To key a colour, a position or any setting, click ◆ next to it in the panel.

}
@@ -359,17 +383,17 @@ interface RowsProps { /** The rows don't depend on the playhead, so they only re-render when the design or selection changes. */ const Rows = memo(function Rows({ rows, tl, laneW, selection, folded, onFold, onEdit }: RowsProps) { const all = rows.flatMap((r) => r.tracks); - // Clip edges anything can snap to. - const edges = [0, ...all.flatMap((k) => (k.kind === 'enter' || k.kind === 'leave' ? [k.start, k.end, ...(k.steps ?? []).flatMap((p) => [p.start, p.end])] : []))]; + // Clip edges and keys anything can snap to. + const edges = [0, ...all.flatMap((k) => (k.kind === 'enter' || k.kind === 'leave' ? [k.start, k.end, ...(k.steps ?? []).flatMap((p) => [p.start, p.end])] : k.kind === 'keys' ? k.keys.map((x) => x.t) : []))]; const ctx: LaneCtx = { tl, laneW, edges, onEdit }; return ( <> {rows.map((r) => { const sel = selection.includes(r.node.id); const pick = (e: React.MouseEvent) => select(e.shiftKey || e.metaKey ? (sel ? selection.filter((x) => x !== r.node.id) : [...selection, r.node.id]) : [r.node.id]); - // One track sits on the layer's own row; more get a row each under it. - const own = r.tracks.length === 1 ? r.tracks : []; - const subs = r.tracks.length > 1 ? r.tracks : []; + // One track sits on the layer's own row; more (or keys, which need their property's name) get a row each under it. + const own = r.tracks.length === 1 && r.tracks[0].kind !== 'keys' ? r.tracks : []; + const subs = own.length ? [] : r.tracks; return (
@@ -417,7 +441,7 @@ const Rows = memo(function Rows({ rows, tl, laneW, selection, folded, onFold, on ); }); -const trackName = (k: Track) => (k.kind === 'enter' ? 'Comes in' : k.kind === 'leave' ? 'Then' : k.kind === 'loop' ? 'Loop' : k.kind === 'flow' ? k.title : k.label); +const trackName = (k: Track) => (k.kind === 'enter' ? 'Comes in' : k.kind === 'leave' ? 'Then' : k.kind === 'loop' ? 'Loop' : k.kind === 'flow' ? k.title : k.kind === 'keys' ? `◆ ${k.label}` : k.label); /** A layer's tracks summed up on its own row: where each one is busy. */ function Overview({ tracks, tl }: { tracks: Track[]; tl: Timeline }) { @@ -425,7 +449,7 @@ function Overview({ tracks, tl }: { tracks: Track[]; tl: Timeline }) { return (
{tracks.map((k) => { - const [a, b] = k.kind === 'enter' || k.kind === 'leave' ? [Math.min(k.start, d), Math.min(k.end, d)] : [0, d]; + const [a, b] = k.kind === 'enter' || k.kind === 'leave' ? [Math.min(k.start, d), Math.min(k.end, d)] : k.kind === 'keys' ? [Math.min(k.keys[0].t, d), Math.min(k.keys[k.keys.length - 1].t, d)] : [0, d]; return ; })}
@@ -509,7 +533,7 @@ function AddMenu({ node }: { node: Node }) { Add a stop after Then -

To move a colour or any setting, click ◆ next to it in the Design or Style panel.

+

To key a position, a colour or any setting, click ◆ next to it in the Design or Style panel; ↻ makes it cycle on its own.

)} @@ -572,6 +596,7 @@ function TrackView({ row, track: k, ctx }: { row: Node; track: Track; ctx: LaneC // Snap to frames, the playhead and other clips' edges within 6 px. const snap = (t: number) => snapTime(t, FPS, [...ctx.edges, clock.time(tl)], (6 * tl.duration) / Math.max(1, ctx.laneW)); if (k.kind === 'enter' || k.kind === 'leave') return ; + if (k.kind === 'keys') return ; if (k.kind === 'flow') return (
{})} data-tip={k.key === 'speed' ? 'It moves by itself the whole time, a whole number of times round each loop' : 'It flickers by itself the whole time'}> @@ -820,6 +845,7 @@ function CycleClip({ row, k, ctx, open }: { row: Node; k: Extract void }) { + if (k.kind === 'keys') return ; const owner = k.owner; const setMotion = (fn: (m: Motion) => void, key = '') => editNode( @@ -949,3 +975,178 @@ function ParamAdvanced({ anim, set }: { anim: ParamAnim; set: (fn: (a: ParamAnim ); } + +type KeyTrack = Extract; + +/** Change one property's keys on the row's node (see keys.ts updateKeys). */ +const editKeys = (row: Node, k: KeyTrack, fn: (keys: Key[]) => Key[] | null | void, field = '') => editNode(row.id, (n) => updateKeys(n, k.scope, k.key, fn), field && `tl:${row.id}:${trackId(k)}:${field}`); + +/** Stop at a key: paused there, so the panels show (and edit) its value. */ +const seekTo = (t: number, tl: Timeline) => { + clock.pause(tl); + clock.seek(t); +}; + +/** + * A property's keys: a diamond at each over the value they trace (or the + * colours they pass through). Drag a diamond to retime its key, click it to + * edit it, ⌫ to delete it, ← → to nudge it a frame (⇧: ten). + */ +function KeyLane({ row, k, ctx, open }: { row: Node; k: KeyTrack; ctx: LaneCtx; open: (el: HTMLElement, step?: number) => void }) { + const { tl } = ctx; + const d = tl.duration; + const { path, gradient } = useMemo(() => { + const N = 200; + const v = Array.from({ length: N + 1 }, (_, i) => keyedValue(k.keys, (i / N) * d)); + if (k.color) return { path: '', gradient: `linear-gradient(90deg, ${v.filter((_, i) => i % 5 === 0).map((c, j, all) => `${c} ${((j / (all.length - 1)) * 100).toFixed(1)}%`).join(', ')})` }; + const nums = v.map(Number); + const lo = Math.min(...nums); + const hi = Math.max(...nums); + return { path: nums.map((y, i) => `${i ? 'L' : 'M'}${((i / N) * 1000).toFixed(1)} ${(88 - ((y - lo) / (hi - lo || 1)) * 76).toFixed(2)}`).join(' '), gradient: undefined }; + }, [k.keys, k.color, d]); + const first = Math.min(k.keys[0].t, d); + const last = Math.min(k.keys[k.keys.length - 1].t, d); + // Snap to frames, the playhead, clip edges and other keys within 6 px. + const snap = (t: number) => snapTime(t, FPS, [...ctx.edges, clock.time(tl)], (6 * d) / Math.max(1, ctx.laneW)); + /** Key `i` moved to `t`: a key it lands on gives way. */ + const retime = (i: number, t: number, field: string) => + editKeys( + row, + k, + () => { + const at = Math.max(0, Math.min(d, onFrame(t))); + return [...k.keys.filter((x, j) => j !== i && Math.abs(x.t - at) >= KEY_SNAP), { ...k.keys[i], t: at }]; + }, + field, + ); + return ( +
+ {gradient ? ( + + ) : ( + + + + )} + + {k.keys.map((key, i) => ( + + ))} +
+ ); +} + +/** Curves a key can come in on, by name (Advanced edits any curve or spring). */ +const KEY_CURVES = ['ease in out', 'ease out', 'ease in', 'linear', 'back out', 'bouncy', 'snappy'].map((v) => ({ value: v, label: v[0].toUpperCase() + v.slice(1) })); + +/** The editor on a key: its value, when it is, and the curve into it. */ +function KeyEditor({ row, k, index, onDone }: { row: Node; k: KeyTrack; index: number; onDone: () => void }) { + const adv = useMotionAdvanced(); + const tl = timelineOf(store.get().project); + // Found by its time, which this editor can change. + const [at, setAt] = useState(k.keys[index]?.t ?? 0); + const i = k.keys.findIndex((x) => Math.abs(x.t - at) < 1e-6); + const key = k.keys[i]; + if (!key) return null; + const set = (fn: (x: Key, all: Key[]) => void, field: string) => + editKeys( + row, + k, + (keys) => { + const x = keys.find((y) => Math.abs(y.t - at) < 1e-6); + if (x) fn(x, keys); + }, + field, + ); + const go = (j: number) => { + const t = k.keys[j].t; + setAt(t); + seekTo(t, tl); + }; + const curve = easeName(key.ease); + return ( +
+
+ ◆ {k.label} + + {row.name} · key {i + 1} of {k.keys.length} + + +
+ {k.color ? set((x) => (x.v = v), 'v')} /> : set((x) => (x.v = v), 'v')} />} + { + const t = Math.max(0, Math.min(tl.duration, onFrame(v))); + set((x, all) => { + for (let j = all.length - 1; j >= 0; j--) if (all[j] !== x && Math.abs(all[j].t - t) < KEY_SNAP) all.splice(j, 1); + x.t = t; + }, 't'); + setAt(t); + }} + /> + {i > 0 && + (adv ? ( + <> + set((x) => (x.ease = tr), `ease.${field}`)} /> +

Stretched over the {(key.t - k.keys[i - 1].t).toFixed(2)} s from the key before (a physics spring keeps its own pace).

+ + ) : ( +
+ Curve in +