diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/actions.md | 23 | ||||
| -rw-r--r-- | docs/effects-renderer.md | 64 | ||||
| -rw-r--r-- | docs/keyframes.md | 204 |
3 files changed, 291 insertions, 0 deletions
diff --git a/docs/actions.md b/docs/actions.md index f9586a86..44c2153f 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -17,6 +17,29 @@ Add an entry to the `ACTIONS` object: }, ``` +**If your shortcut uses a special key** (not a letter/digit), check `getPressedKey` in `src/stores/keybindings-store.ts` and add a case if it's missing: + +```typescript +if (key === "escape") return "escape"; +``` + +**If your action has a `defaultShortcuts`**, also add a keybindings migration so existing users get it (keybindings are persisted in localStorage — new defaults only apply to fresh installs): + +1. Create `src/stores/keybindings/migrations/vN-to-vN+1.ts`: + +```typescript +export function vNToVN1({ state }: { state: unknown }): unknown { + const s = state as { keybindings: Record<string, string>; isCustomized: boolean }; + const keybindings = { ...s.keybindings }; + if (!keybindings["my-key"]) { + keybindings["my-key"] = "my-action"; + } + return { ...s, keybindings }; +} +``` + +2. Register it in `src/stores/keybindings/migrations/index.ts` and bump `CURRENT_VERSION`. + ### 2. Register the handler — `src/hooks/actions/use-editor-actions.ts` ```typescript diff --git a/docs/effects-renderer.md b/docs/effects-renderer.md new file mode 100644 index 00000000..fd41282b --- /dev/null +++ b/docs/effects-renderer.md @@ -0,0 +1,64 @@ +# Effects & WebGL Renderer + +## How to add a new effect + +1. Create a new file in `apps/web/src/lib/effects/definitions/` (e.g. `brightness.ts`) +2. Export an `EffectDefinition` — see `blur.ts` as a reference +3. Register it in `apps/web/src/lib/effects/definitions/index.ts` + +An effect definition has: +- `type` — unique string identifier +- `name` — display name +- `keywords` — for search +- `params` — user-facing controls (sliders, toggles, etc.) +- `renderer` — always `webgl` + +All effects use WebGL. Even simple single-value effects like brightness or contrast are trivial shaders — there's no reason to leave the GPU pipeline for them. + +## Single-pass vs multi-pass + +The `webgl` renderer supports a `passes` array. Single-pass effects (e.g. color grading) just have one entry. Multi-pass is needed when an effect has to process its own output — blur (H then V), bloom (extract → blur → composite), glow, etc. + +```typescript +renderer: { + type: "webgl", + passes: [ + { fragmentShader: myShader, uniforms: ({ effectParams }) => ({ ... }) }, + ], +} +``` + +All WebGL rendering — both the main renderer and the effect preview — goes through `applyMultiPassEffect` in `apps/web/src/services/renderer/webgl-utils.ts`. Don't add a new rendering path somewhere else; update that function if needed. + +## Writing fragment shaders + +Shaders live in `apps/web/src/lib/effects/definitions/`. The shared vertex shader (`effect.vert.glsl`) maps clip space to UV coordinates — don't replace it unless you have a specific reason. + +Available uniforms (automatically injected, no need to pass them manually): +- `u_texture` — the input texture (sampler2D) +- `u_resolution` — canvas size in pixels (vec2) + +Any additional uniforms come from the `uniforms()` function in the pass definition. + +**Sampling density — the most common mistake** + +Always use a step of 1 texel when sampling neighbors. Do not scale the step size with the blur radius or intensity — it creates visible discrete artifacts (ghosting/glow look) because there are large gaps between samples that the GPU fills with linear interpolation instead of your intended curve. + +```glsl +// correct — step is always 1 texel, loop count controls radius +for (int i = -30; i <= 30; i++) { + color += texture2D(u_texture, v_texCoord + texelSize * u_direction * float(i)) * weight; +} + +// wrong — stepping 6 texels at a time looks ghosty at high intensity +vec2 offset = texelSize * u_direction * u_radius; +color += texture2D(u_texture, v_texCoord + offset * 2.0) * someWeight; +``` + +If you need a large radius with a fixed kernel size, increase the number of samples rather than the step. + +## Y-flip and coordinate systems + +Source textures (uploaded from canvas) are Y-flipped via `UNPACK_FLIP_Y_WEBGL`. Intermediate FBO textures (rendered by WebGL between passes) are not. In practice this cancels out correctly as long as you use the shared vertex shader — it maps clip space Y consistently so both texture types sample correctly. + +If you write a custom vertex shader or do manual coordinate math, be aware that canvas and WebGL have opposite Y origins (canvas: top-left, WebGL: bottom-left). Getting this wrong produces an upside-down result with no obvious error. diff --git a/docs/keyframes.md b/docs/keyframes.md new file mode 100644 index 00000000..97619626 --- /dev/null +++ b/docs/keyframes.md @@ -0,0 +1,204 @@ +# Keyframe System + +Keyframes allow element properties to change over time. The system is split into three layers: the **data model** (how keyframes are stored), the **registry** (which properties support keyframes and how to read/write them), and the **UI** (hooks and components that wire it all together). + +## How It Works + +### Data model + +Every `BaseTimelineElement` has an optional `animations?: ElementAnimations` field: + +```typescript +interface ElementAnimations { + channels: Record<string, AnimationChannel | undefined>; +} +``` + +A channel is a typed bucket of keyframes keyed by property path (e.g. `"opacity"`, `"background.color"`). Three channel types exist: `NumberAnimationChannel`, `ColorAnimationChannel`, and `DiscreteAnimationChannel`. + +### Registry + +`src/lib/animation/property-registry.ts` defines which property paths are animatable and how to read/write their values on an element. `src/types/animation.ts` holds the canonical list of valid paths in `ANIMATION_PROPERTY_PATHS`. + +### Resolver + +`src/lib/animation/resolve.ts` provides functions that return the effective value of a property at a given local time — falling back to the element's static value when no keyframes exist. + +### Renderer + +Nodes in `src/services/renderer/` call the resolve functions before drawing so that animated properties interpolate correctly during export and preview. + +### UI + +Two hooks in `src/components/editor/panels/properties/hooks/` handle the keyframe-aware field logic: + +- `useKeyframedNumberProperty` — for numeric fields (opacity, position, scale, etc.) +- `useKeyframedColorProperty` — for color pickers + +Both hooks handle the toggle/add/remove keyframe flow and automatically switch between writing to the static property and writing to the animation channel depending on whether keyframes are active. + +--- + +## Adding a New Animatable Property + +Using `"background.paddingX"` as an example. + +### 1. Register the path — `src/types/animation.ts` + +```typescript +export const ANIMATION_PROPERTY_PATHS = [ + // ...existing paths + "background.paddingX", +] as const; +``` + +### 2. Add a registry entry — `src/lib/animation/property-registry.ts` + +```typescript +"background.paddingX": { + valueKind: "number", // "number" | "color" | "discrete" + defaultInterpolation: "linear", + numericRange: { min: 0 }, // optional, only for number properties + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, background: { ...element.background, paddingX: value as number } } + : element, +}, +``` + +**Notes:** +- `getValue` must return the effective value including any defaults — this is what gets recorded when a keyframe is added. +- `setValue` receives `AnimationValue` (`number | string | boolean`). Cast to the correct type since `coerceAnimationValueForProperty` already validated it upstream. +- For color properties, use `valueKind: "color"` and cast `value as string`. + +### 3. Add a resolve function — `src/lib/animation/resolve.ts` + +For **numbers**, use the existing generic `resolveNumberAtTime`: + +```typescript +import { resolveNumberAtTime } from "@/lib/animation"; + +const resolvedPaddingX = resolveNumberAtTime({ + baseValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, +}); +``` + +For **colors**, use `resolveColorAtTime`: + +```typescript +const resolvedColor = resolveColorAtTime({ + baseColor: element.color, + animations: element.animations, + propertyPath: "color", + localTime, +}); +``` + +If neither fits (new value kind), add a dedicated resolve function following the same pattern as `resolveOpacityAtTime` and export it from `src/lib/animation/index.ts`. + +### 4. Wire the renderer + +In the relevant node (`src/services/renderer/nodes/`), call the resolve function before drawing: + +```typescript +const resolvedPaddingX = resolveNumberAtTime({ + baseValue: this.params.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: this.params.animations, + propertyPath: "background.paddingX", + localTime, +}); +``` + +Use the resolved value (not `this.params.*`) anywhere that value affects rendering. + +### 5. Wire the UI + +In the properties panel, replace `usePropertyDraft` with the appropriate keyframe hook and add a `KeyframeToggle` to the field. + +**For number fields:** + +```typescript +const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, +}); + +const resolvedPaddingX = resolveNumberAtTime({ + baseValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, +}); + +const paddingX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedPaddingX).toString(), + parse: (input) => { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed)); + }, + valueAtPlayhead: resolvedPaddingX, + buildBaseUpdates: ({ value }) => ({ + background: { ...element.background, paddingX: value }, + }), +}); +``` + +In JSX: + +```tsx +<SectionField + label="Width" + beforeLabel={ + <KeyframeToggle + isActive={paddingX.isKeyframedAtTime} + isDisabled={!isPlayheadWithinElementRange} + title="Toggle background width keyframe" + onToggle={paddingX.toggleKeyframe} + /> + } +> + <NumberField + value={paddingX.displayValue} + onFocus={paddingX.onFocus} + onChange={paddingX.onChange} + onBlur={paddingX.onBlur} + onScrub={paddingX.scrubTo} + onScrubEnd={paddingX.commitScrub} + onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })} + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedPaddingX, + staticValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX, + defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX, + })} + /> +</SectionField> +``` + +**For color fields**, use `useKeyframedColorProperty` instead. It returns `{ onChange, onChangeEnd, toggleKeyframe, isKeyframedAtTime }` — wire `onChange({ color })` and `onChangeEnd` directly to the `ColorPicker`. + +--- + +## Checklist + +- [ ] Path added to `ANIMATION_PROPERTY_PATHS` +- [ ] Registry entry added with correct `valueKind`, `supportsElement`, `getValue`, `setValue` +- [ ] Resolve call added in the renderer node +- [ ] UI field uses `useKeyframedNumberProperty` or `useKeyframedColorProperty` (not `usePropertyDraft`) +- [ ] `KeyframeToggle` added to the `SectionField` +- [ ] `onReset` calls `commitValue` (not `editor.timeline.updateElements` directly) |
