summaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/actions.md23
-rw-r--r--docs/effects-renderer.md64
-rw-r--r--docs/keyframes.md204
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)