diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e8b48c4..6ba9a58 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -118,6 +118,7 @@ Motion and animated materials never touch geometry; only keyframed shape and mod - **Animated settings**: a style's `animate` maps a material param to a second value, a cycle and a mode (swing, ramp, flicker). The renderer blends the uniform each frame (colours in RGB), so any material can pulse, shift or flicker without new shader code. - **Moving patterns**: the Pattern material's Speed slides patterns along their grain, turns sunbursts and spirals, ripples rings, and drives the kinds made to move (riding bars, mosaic, barcode rows, flow lines). - **Moving geometry** (`state/geoMotion.ts`, `app/geoMotion.ts`): a node's `animate` keyframes its own number settings, `mod::` for a modifier and `sym:` for a library shape, with the same swing, ramp and flicker as materials (whole-number fields step). These change the outline, so the layers that have them are rebuilt: while the clock runs, `movingDocAt` builds an engine document with just the renders that move (plus the cuts, masks and morph sources they're made of) at the clock's time, at most 30 times a second and one at a time, and drops a frame asked for while one is building. The outlines go straight to the renderer through `liveOutlines()`, never into the project, undo history or autosave; their fields snap to a 16 px grid so textures are reused between frames. Paused, the outlines hold at the playhead. A design without keyframed geometry runs none of this. Selection outlines show the shape at rest, as they do for motion. +- **Free keys** (`state/keys.ts`, evaluated in `motion.ts`): any property can have any number of keys, each a time, a value and the curve into it (`Key`: an easing stretched over the gap from the key before, a time spring stretched the same way, or a physics spring at its own pace). They live beside the cycles: `node.keys` for the transform (x, y, rotation, scale, opacity) and geometry (the same `mod:`/`sym:`/`morph:t`/`line:` names as `animate`), `style.keys` for material settings. A property holds its first key's value before it and its last key's after; on a looping timeline it jumps back at the seam. Transform keys are absolute values (a group's are offsets from rest): `buildScene` hands them to the renderer as a link of their own outside the node's motion (`MotionLink.keys`), posed about the layer's origin, so a layer floats and follows its keys and the result is the layer as if it had those values; a layer's opacity keys set its material's opacity directly. Setting keys go through the same paths as cycles (`effectUniforms`, `geoDrivers` in geoMotion), and win where a setting has both; the Inspector keeps them exclusive. Every edit runs `keyEdits`: a keyed property it changes gets a key at the playhead instead (a canvas drag moves the key by as much as it moved the layer), and with autokey (the timeline's record switch, not saved) a changed property starts keys. The Inspector shows keyed values at the playhead with ‹ ◆ › beside them; the timeline adds a row of diamonds per keyed property. - **Video** (`exportVideo`): fields are built once at export size, then each frame is drawn at its own time and handed to a `MediaRecorder` on a 2D canvas, paced in real time. Moving geometry is built for every frame first (at export quality), so recording only swaps those outlines and rebuilds their fields. The preview stops drawing while an export holds the GPU. Stills (PNG, JPG, SVG, thumbnails) show the design at rest. ## UI (`ui/src`) diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs index 4da8f27..21a6a68 100644 --- a/scripts/e2e.mjs +++ b/scripts/e2e.mjs @@ -712,6 +712,113 @@ try { ); } + // ---- free keys: a star turns through four keys, set in the Inspector, edited in the timeline, honoured in video ---- + { + const starId = await page.evaluate(() => { + const m = window.morphotype; + const p = m.store.get().project; + const star = { id: `lkeys${Date.now().toString(36)}`, name: 'Spinner', visible: true, op: 'add', x: 0, y: 0, rotation: 0, scale: 1, sx: 1, sy: 1, modifiers: [], content: { type: 'symbol', symbol: { kind: 'star', points: 5, inner: 0.45, rounding: 0.1 }, size: 400 }, style: { effect: 'flat', params: { fill: '#ff3366' } } }; + m.clock.pause({ duration: 4, loop: true }); + m.clock.seek(0); + m.store.set({ project: { ...p, layers: [star], timeline: { duration: 4, loop: true } }, selection: [star.id], tab: 'design', dialog: null, screen: 'editor', mode: 'design', past: [], future: [] }); + return star.id; + }); + await settle(900); + const starOf = (st) => layersOf(st).find((l) => l.id === starId); + const keysOf = (st, k = 'rotation') => (starOf(st).keys?.[k] ?? []).map((x) => [Math.round(x.t * 100) / 100, Math.round(x.v * 10) / 10]); + // ◆ starts keys at the playhead; typing a value elsewhere on the clock adds a key there. + await page.getByRole('button', { name: 'Key Rotation' }).click(); + const rot = page.locator('.keyed-box', { has: page.getByRole('button', { name: 'Key Rotation' }) }).locator('input'); + // Typed as a person would: click the box (it selects its text a frame later), select all, type, Enter. + const twoFrames = () => page.evaluate(() => new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))); + let typedIn = ''; + const typeInto = async (box, v) => { + await box.click(); + await twoFrames(); + typedIn = await page.evaluate(() => `${document.activeElement?.tagName} "${document.activeElement?.value ?? ''}"`); + await page.keyboard.press(`${mod}+a`); + await page.keyboard.type(String(v)); + await page.keyboard.press('Enter'); + await twoFrames(); + }; + for (const [t, v] of [[1, 45], [2.5, 30], [3.5, -90]]) { + await page.evaluate((t) => window.morphotype.clock.seek(t), t); + await settle(150); + await typeInto(rot, v); + await until((st) => keysOf(st).some(([kt, kv]) => kt === t && kv === v), 5000); + } + let st = await state(); + check(JSON.stringify(keysOf(st)) === '[[0,0],[1,45],[2.5,30],[3.5,-90]]', `◆ and the rotation field key the star at four times (${JSON.stringify(keysOf(st))})`); + const angleAt = (t) => + page.evaluate( + ({ id, t }) => { + const m = window.morphotype; + m.clock.seek(t); + const o = m.renderer.objs.find((x) => x.item.id === id); + const pl = m.renderer.placed(o); + return pl ? Math.round((Math.atan2(pl.m[1], pl.m[0]) * 180) / Math.PI) : null; + }, + { id: starId, t }, + ); + // Its own rotation sits at the last key typed; the keys turn it from there. + const own = starOf(st).rotation; + const angles = [await angleAt(0), await angleAt(1), await angleAt(1.75), await angleAt(3.5)].map((a) => Math.round(a + own)); + check(angles.join() === '0,45,38,-90', `the canvas follows the keys (${angles.join('° → ')}°)`); + // The timeline: a row of diamonds to drag, click and delete. + if (await page.getByRole('button', { name: 'Open the timeline' }).count()) await page.getByRole('button', { name: 'Open the timeline' }).click(); + await page.locator('.tl-key').first().waitFor({ timeout: 5000 }); + check((await page.locator('.tl-key').count()) === 4 && (await page.locator('.tl-sub .tl-name', { hasText: 'Rotation' }).count()) === 1, 'the timeline shows a Rotation row with four diamonds'); + const [k2, k3] = [await page.locator('.tl-key').nth(2).boundingBox(), await page.locator('.tl-key').nth(3).boundingBox()]; + const perS = (k3.x - k2.x) / 1; + await page.mouse.move(k2.x + k2.width / 2, k2.y + k2.height / 2); + await page.mouse.down(); + await page.mouse.move(k2.x + k2.width / 2 + perS * 0.25, k2.y + k2.height / 2, { steps: 4 }); + await page.mouse.move(k2.x + k2.width / 2 + perS * 0.5, k2.y + k2.height / 2, { steps: 4 }); + await page.mouse.up(); + st = await state(); + check(JSON.stringify(keysOf(st)) === '[[0,0],[1,45],[3,30],[3.5,-90]]', `dragging a diamond retimes its key, on a frame (${JSON.stringify(keysOf(st))})`); + await page.locator('.tl-key').nth(1).click(); + await page.locator('.tl-edit', { hasText: 'key 2 of 4' }).waitFor({ timeout: 5000 }); + check(await page.evaluate(() => Math.abs(window.morphotype.clock.time({ duration: 4, loop: true }) - 1) < 1e-6), 'clicking a diamond opens its key and puts the playhead on it'); + await page.keyboard.press('Escape'); + await page.locator('.tl-key').nth(2).click(); + await page.keyboard.press('Escape'); + await page.locator('.tl-key').nth(2).focus(); + await page.keyboard.press('Backspace'); + st = await state(); + check(JSON.stringify(keysOf(st)) === '[[0,0],[1,45],[3.5,-90]]' && !!starOf(st), `⌫ deletes the picked key, not the layer (${JSON.stringify(keysOf(st))})`); + await page.keyboard.press(`${mod}+z`); + st = await state(); + check(keysOf(st).length === 4, 'undo brings the key back'); + // Autokey: with record on, moving the star keys its position at the playhead. + await page.getByRole('button', { name: 'Autokey' }).click(); + await page.evaluate(() => window.morphotype.clock.seek(2)); + await settle(150); + const xBox = page.locator('.keyed-box', { has: page.getByRole('button', { name: 'Key X' }) }).locator('input'); + await typeInto(xBox, 120); + await until((st) => keysOf(st, 'x').length === 2, 5000); + await page.getByRole('button', { name: 'Autokey' }).click(); + st = await state(); + check(JSON.stringify(keysOf(st, 'x')) === '[[0,0],[2,120]]' && (await page.locator('.tl-sub .tl-name', { hasText: 'Position X' }).count()) === 1, `autokey keys a change at the playhead, from where it was (${JSON.stringify(keysOf(st, 'x'))}; x ${starOf(st).x}; typed into ${typedIn})`); + await page.screenshot({ path: path.join(out, 'new-01d-keys.png') }); + // Video: every frame is drawn at its own time on the keys. + await page.evaluate((id) => { + const r = window.morphotype.renderer; + window.__starFrames = []; + const placed = r.placed.bind(r); + r.placed = (o) => { + const pl = placed(o); + if (r.exporting && o.item.id === id && pl) window.__starFrames.push([r.pinned?.t, Math.round((Math.atan2(pl.m[1], pl.m[0]) * 180) / Math.PI)]); + return pl; + }; + }, starId); + await download(async () => {}, () => page.getByRole('button', { name: 'Export video' }).first().click()); + const frames = await page.evaluate(() => window.__starFrames); + const own2 = starOf(await state()).rotation; + const turn = (t) => Math.round((frames.find((f) => Math.abs(f[0] - t) < 1 / 60)?.[1] ?? NaN) + own2); + check(frames.length >= 120 && turn(1) === 45 && turn(3) === 30 && turn(3.5) === -90, `exported video turns the star through its keys (${frames.length} frames: ${[0, 1, 3, 3.5].map(turn).join('° → ')}°)`); + } + // ---- depth, glass and the icon grid ---- await openTemplate('Frosted 404'); await page.locator('.row', { hasText: 'Caption' }).click(); diff --git a/ui/src/agent/catalog.ts b/ui/src/agent/catalog.ts index 56d4d6b..fec303e 100644 --- a/ui/src/agent/catalog.ts +++ b/ui/src/agent/catalog.ts @@ -107,6 +107,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 => @@ -114,7 +123,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..fb3b1ba --- /dev/null +++ b/ui/src/agent/keys.test.ts @@ -0,0 +1,51 @@ +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]]'); + expect(t).toContain('· rotation jumps from 30 back to 0 at the loop seam'); + }); + + 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 ebf9617..ddc35f4 100644 --- a/ui/src/agent/page.ts +++ b/ui/src/agent/page.ts @@ -12,6 +12,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'; @@ -239,7 +240,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..42030c3 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,11 @@ 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`); + const [v0, v1] = [k.keys[0].v, k.keys[k.keys.length - 1].v]; + if (loop && String(v0).toLowerCase() !== String(v1).toLowerCase()) notes.push(`· ${k.label.toLowerCase()} jumps from ${val(v1)} back to ${val(v0)} at the loop seam: end on a key equal to the first to loop smoothly`); + } return notes; } diff --git a/ui/src/components/Inspector.tsx b/ui/src/components/Inspector.tsx index 7ba165f..15b1722 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, keyedValue, 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); @@ -212,7 +216,7 @@ function Appearance({ nodes }: { nodes: Node[] }) {
{colors.map((c) => ( - setParam(c.key, v)} /> + setParam(c.key, v)} /> ))}
@@ -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 +98,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 +138,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..91d4b1b 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 { isGroup, type Node, type Project } from '../../state/model'; +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, nodeLabel, 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,21 +383,21 @@ 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 (
-
+
{r.parent || subs.length ? (
@@ -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 +