diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 0000000..e5b6d8d --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,8 @@ +# Changesets + +Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works +with multi-package repos, or single-package repos to help you version and publish your code. You can +find the full documentation for it [in our repository](https://github.com/changesets/changesets) + +We have a quick list of common questions to get you started engaging with this project in +[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) diff --git a/.changeset/add-phase-1-complete.md b/.changeset/add-phase-1-complete.md new file mode 100644 index 0000000..b1ca2cb --- /dev/null +++ b/.changeset/add-phase-1-complete.md @@ -0,0 +1,7 @@ +--- +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor +"@webpacked-timeline/ui": minor +--- + +Phase 1 complete: headless NLE engine core with transaction-based dispatcher, snap index, ITool contract, ProvisionalState ghost layer, React hooks with selector isolation, and rAF-throttled tool router. diff --git a/.changeset/add-phase-2-tools.md b/.changeset/add-phase-2-tools.md new file mode 100644 index 0000000..723293a --- /dev/null +++ b/.changeset/add-phase-2-tools.md @@ -0,0 +1,6 @@ +--- +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor +--- + +Phase 2 complete: 8 core editing tools (SelectionTool, RazorTool, RippleTrimTool, RollTrimTool, SlipTool, RippleDeleteTool, RippleInsertTool, HandTool). Rolling-state dispatcher validation. MOVE_CLIP ordering rule. ProvisionalState rubber-band extension. diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..82f3935 --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [ + ["@webpacked-timeline/core", "@webpacked-timeline/react", "@webpacked-timeline/ui"] + ], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/.changeset/phase-3-complete.md b/.changeset/phase-3-complete.md new file mode 100644 index 0000000..e7a46ac --- /dev/null +++ b/.changeset/phase-3-complete.md @@ -0,0 +1,7 @@ +--- +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor +"@webpacked-timeline/ui": minor +--- + +Phase 3 complete: Markers, in/out points, beat grid, generators, captions, SRT/VTT import. Marker search API. Caption model completeness (EDIT_CAPTION partial updates, overlap invariant). diff --git a/.changeset/phase-4-complete.md b/.changeset/phase-4-complete.md new file mode 100644 index 0000000..ffe397b --- /dev/null +++ b/.changeset/phase-4-complete.md @@ -0,0 +1,7 @@ +--- +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor +"@webpacked-timeline/ui": minor +--- + +Phase 4: Effects, keyframes, transitions, track groups, link groups, and two new tools (TransitionTool, KeyframeTool). diff --git a/.changeset/phase-6-complete.md b/.changeset/phase-6-complete.md new file mode 100644 index 0000000..7b243e2 --- /dev/null +++ b/.changeset/phase-6-complete.md @@ -0,0 +1,6 @@ +--- +"@webpacked-timeline/core": minor +"@webpacked-timeline/react": minor +--- + +Phase 6: Playback engine — PlayheadController, pipeline contracts, seek API, J/K/L keyboard, loop regions, usePlayhead hook. diff --git a/.changeset/phase-7-complete.md b/.changeset/phase-7-complete.md new file mode 100644 index 0000000..bf651ee --- /dev/null +++ b/.changeset/phase-7-complete.md @@ -0,0 +1,5 @@ +--- +"@webpacked-timeline/core": minor +--- + +Phase 7: Performance and scale — interval tree, virtual rendering, transaction compression, history persistence, worker contracts, SlideTool, ZoomTool. Feature complete. diff --git a/.changeset/phase-r-complete.md b/.changeset/phase-r-complete.md new file mode 100644 index 0000000..9e6d516 --- /dev/null +++ b/.changeset/phase-r-complete.md @@ -0,0 +1,6 @@ +--- +"@webpacked-timeline/react": minor +"@webpacked-timeline/core": patch +--- + +Phase R: @webpacked-timeline/react complete — TimelineEngine orchestrator, 13 hooks with selector isolation, ToolRouter with rAF throttle, virtual rendering hooks, full integration test suite. diff --git a/.changeset/phase-u-complete.md b/.changeset/phase-u-complete.md new file mode 100644 index 0000000..ff3c168 --- /dev/null +++ b/.changeset/phase-u-complete.md @@ -0,0 +1,5 @@ +--- +"@webpacked-timeline/ui": minor +--- + +Phase U: @webpacked-timeline/ui complete — shadcn-style CLI, 16 components across 5 tiers, 2 themes, shared utilities, full rendering contract. Feature complete. diff --git a/.claude/skills/ARCHITECTURE.md b/.claude/skills/ARCHITECTURE.md new file mode 100644 index 0000000..d7045c0 --- /dev/null +++ b/.claude/skills/ARCHITECTURE.md @@ -0,0 +1,86 @@ +> **Load this file when:** Every coding session. Always. +> **Do NOT load this file when:** Never skip this file. + +--- + +# ARCHITECTURE — Hard Rules (Always Active) + +## Rule 1 — The Three-Layer Law (NO EXCEPTIONS) + +``` +packages/core → imports nothing outside core stdlib + TypeScript +packages/react → imports @webpacked-timeline/core + React only +packages/ui → imports @webpacked-timeline/react + @webpacked-timeline/core + React only +``` + +Lower layers NEVER import from higher layers. A `packages/core` file that imports +`React`, `ReactDOM`, `requestAnimationFrame`, `document`, or anything from +`@webpacked-timeline/react` or `@webpacked-timeline/ui` is **categorically wrong** — reject it. + +## Rule 2 — One Entry Point for Mutation + +`dispatch(state, transaction)` is the **only** function that produces a new `TimelineState`. + +```typescript +// ✅ ONLY legal pattern for state change +const result = dispatch(currentState, transaction); + +// ❌ ILLEGAL — state mutation outside dispatch +state.timeline.tracks.push(newTrack); +state.timeline.name = "New Name"; +``` + +No function outside `engine/dispatcher.ts` may produce a `TimelineState`. No exceptions. + +## Rule 3 — Strict Immutability + +All functions that touch state return a **new object**. Never mutate in place. + +```typescript +// ✅ +return { ...state, timeline: { ...state.timeline, name: op.name } }; + +// ❌ +state.timeline.name = op.name; +return state; +``` + +Banned mutating array methods inside engine code: `.push()`, `.pop()`, `.splice()`, +`.sort()` (on existing arrays), direct index assignment. Use `.map()`, `.filter()`, +`.concat()`, spread instead. + +## Rule 4 — The Time Type Law + +All frame-position values are `TimelineFrame` (branded integer). Never raw `number`. + +```typescript +type TimelineFrame = number & { readonly __brand: "TimelineFrame" }; +type FrameRate = 23.976 | 24 | 25 | 29.97 | 30 | 50 | 59.94 | 60; + +// ✅ +const start: TimelineFrame = toFrame(100); + +// ❌ +const start: number = 100; // raw number for frame position +const fps: number = 29.97; // raw float for frame rate +``` + +`Timecode` is display only — never use it in arithmetic. +`RationalTime` is ingest/export boundary only — never in edit operations. + +--- + +## This file does NOT cover + +- Which operations exist (→ `core/OPERATIONS.md`) +- How dispatch works internally (→ `core/DISPATCHER.md`) +- Type definitions (→ `core/TYPES.md`) +- Hook patterns (→ `adapter/HOOKS.md`) + +--- + +## Common mistakes to avoid + +- Adding `import React from 'react'` anywhere in `packages/core` +- Returning the mutated `state` object instead of a new spread +- Passing a raw `number` where `TimelineFrame` is required and casting with `as any` diff --git a/.claude/skills/adapter/HOOKS.md b/.claude/skills/adapter/HOOKS.md new file mode 100644 index 0000000..e4293ac --- /dev/null +++ b/.claude/skills/adapter/HOOKS.md @@ -0,0 +1,172 @@ +> **Load this file when:** Writing or modifying any hook in `packages/react`, implementing `useClip`, `useTimeline`, `useProvisional`, or modifying `TimelineEngine`. +> **Do NOT load this file when:** Writing core operations, tools, or UI component rendering logic (→ `ui/COMPONENTS.md`). + +--- + +# HOOKS — useSyncExternalStore Patterns + +## Critical Rule + +**Never use `useState` to mirror engine state.** + +```typescript +// ❌ WRONG — causes stale closure, double render, and subscription leak +function useClip(id: ClipId) { + const [clip, setClip] = useState(); + useEffect(() => engine.subscribe(() => setClip(engine.getClip(id))), [id]); + return clip; +} + +// ✅ CORRECT +function useClip(id: ClipId) { + return useSyncExternalStore( + engine.subscribe, + () => engine.getState().assetRegistry, // outer scope + ); +} +``` + +--- + +## Selector Scope Rule — Never Over-Subscribe + +Each hook selects the **minimum slice** needed. A hook that selects the entire state causes every clip to re-render on every change. + +```typescript +// ❌ WRONG — re-renders when ANY part of state changes +function useClip(id: ClipId) { + const state = useSyncExternalStore(engine.subscribe, engine.getState); + return findClip(state, id); +} + +// ✅ CORRECT — re-renders only when THIS clip's data changes +function useClip(id: ClipId) { + return useSyncExternalStore(engine.subscribe, () => { + const state = engine.getState(); + for (const track of state.timeline.tracks) { + const clip = track.clips.find((c) => c.id === id); + if (clip) return clip; + } + return null; + }); +} +``` + +--- + +## Hooks Never Import from @webpacked-timeline/core Directly + +All calls go through the `TimelineEngine` adapter class. Hooks never call `dispatch()` directly. + +```typescript +// ❌ +import { dispatch } from "@webpacked-timeline/core"; + +// ✅ +const { engine } = useTimelineContext(); +engine.dispatch(transaction); +``` + +--- + +## Hook Reference + +### `useTimeline()` + +- **Subscribes to:** `timeline.id`, `timeline.name`, `timeline.fps`, `timeline.duration`, `timeline.tracks` structure (id list only, not clip contents) +- **Re-renders when:** Top-level timeline metadata or track list structure changes +- **Returns:** `Pick & { trackIds: TrackId[] }` + +### `useTrack(id: TrackId)` + +- **Subscribes to:** That track's fields + its `clips` id list (not clip data) +- **Re-renders when:** That specific track's metadata or clip id list changes +- **Returns:** `Track` (with clips as full objects — selectors further scope if needed) + +### `useClip(id: ClipId)` + +- **Subscribes to:** All fields of that specific clip +- **Re-renders when:** That clip's data changes +- **Returns:** `Clip | null` + +```typescript +// Canonical useClip implementation: +export function useClip(id: ClipId): Clip | null { + const { engine } = useTimelineContext(); + return useSyncExternalStore(engine.subscribe, () => { + const state = engine.getState(); + for (const track of state.timeline.tracks) { + const clip = track.clips.find((c) => c.id === id); + if (clip) return clip; + } + return null; + }); +} +``` + +### `usePlayhead()` + +- **Subscribes to:** `PlayheadController` — a **separate** subscription channel from the edit engine +- **Re-renders when:** `currentFrame` changes (every rAF tick during playback) +- **Returns:** `{ frame: TimelineFrame; isPlaying: boolean }` + +### `useActiveTool()` + +- **Returns:** `{ toolId: ToolId; cursor: string }` +- **Re-renders when:** Active tool changes + +### `useProvisional()` + +- **Returns:** `ProvisionalState | null` +- **Re-renders when:** `ProvisionalStateManager.set()` or `.clear()` is called +- **Note:** This is a **separate** subscription from the main engine — provisional updates never hit `engine.notify()` + +### `useSnapEnabled()` + +- **Returns:** `boolean` +- **Re-renders when:** User toggles snap + +--- + +## resolveClip() Pattern — Provisional Overlay + +UI components read provisional state first, committed state as fallback: + +```typescript +function resolveClip( + id: ClipId, + committed: Clip | null, + provisional: ProvisionalState | null, +): Clip | null { + if (provisional) { + const ghost = provisional.clips.find((c) => c.id === id); + if (ghost) return ghost; + } + return committed; +} + +// In component: +function ClipShell({ id }: { id: ClipId }) { + const committed = useClip(id); + const provisional = useProvisional(); + const clip = resolveClip(id, committed, provisional); + if (!clip) return null; + // render with clip data +} +``` + +--- + +## This file does NOT cover + +- How ToolRouter converts raw DOM events (→ `adapter/TOOL_ROUTER.md`) +- Pixel math and ghost rendering styles (→ `ui/COMPONENTS.md`) +- What subscriptions the engine supports (→ TimelineEngine class docs) + +--- + +## Common mistakes to avoid + +- Importing `dispatch` from `@webpacked-timeline/core` inside a hook — always call `engine.dispatch()` +- Using `useEffect` + `useState` to mirror engine state — use `useSyncExternalStore` always +- Subscribing to `engine.getState` (entire state snapshot) — always write a scoped selector diff --git a/.claude/skills/adapter/TOOL_ROUTER.md b/.claude/skills/adapter/TOOL_ROUTER.md new file mode 100644 index 0000000..9b0dcab --- /dev/null +++ b/.claude/skills/adapter/TOOL_ROUTER.md @@ -0,0 +1,150 @@ +> **Load this file when:** Touching `ToolRouter`, adding pointer event handling, or implementing the provisional drag flow in `packages/react`. +> **Do NOT load this file when:** Writing ITool implementations (→ `tools/ITOOL_CONTRACT.md`), hooks (→ `adapter/HOOKS.md`), or UI components. + +--- + +# TOOL_ROUTER — DOM Event → Tool Contract + +## Critical Rule + +`handlePointerMove` is throttled to **one execution per `requestAnimationFrame`**. The handler inside `rAF` always uses the most-recent event, not a queued list. + +--- + +## ToolRouter's Responsibility + +Convert raw DOM `PointerEvent` → `TimelinePointerEvent`, extract modifier keys, call the active tool, and manage provisional state. + +```typescript +class ToolRouter { + private rafPending = false; + private lastMoveEvent: PointerEvent | null = null; + private lastMoveTrack: TrackId | null = null; +} +``` + +--- + +## The Three Handlers + +### handlePointerDown + +```typescript +handlePointerDown(domEvent: PointerEvent, trackId: TrackId | null): void { + const tool = this.engine.getActiveTool(); + const ctx = this.engine.buildToolContext(); + const evt = this.convertEvent(domEvent, trackId, ctx); + tool.onPointerDown(evt, ctx); +} +``` + +### handlePointerMove (rAF-throttled) + +```typescript +handlePointerMove(domEvent: PointerEvent, trackId: TrackId | null): void { + this.lastMoveEvent = domEvent; + this.lastMoveTrack = trackId; + + if (this.rafPending) return; // drop intermediate events + this.rafPending = true; + + requestAnimationFrame(() => { + this.rafPending = false; + if (!this.lastMoveEvent) return; + + const tool = this.engine.getActiveTool(); + const ctx = this.engine.buildToolContext(); + const evt = this.convertEvent(this.lastMoveEvent, this.lastMoveTrack, ctx); + + const provisional = tool.onPointerMove(evt, ctx); + // Set ghost — does NOT dispatch, does NOT touch history + this.engine.provisionalManager.set(provisional); + }); +} +``` + +### handlePointerUp + +```typescript +handlePointerUp(domEvent: PointerEvent, trackId: TrackId | null): void { + const tool = this.engine.getActiveTool(); + const ctx = this.engine.buildToolContext(); + const evt = this.convertEvent(domEvent, trackId, ctx); + + const tx = tool.onPointerUp(evt, ctx); + + // Always clear ghost on pointer up — even if tool returns null + this.engine.provisionalManager.clear(); + + // Commit only if tool returned a Transaction + if (tx) this.engine.dispatch(tx); +} +``` + +--- + +## convertEvent() — TimelinePointerEvent Construction + +```typescript +private convertEvent( + dom: PointerEvent, + trackId: TrackId | null, + ctx: ToolContext, +): TimelinePointerEvent { + return { + frame: ctx.frameAtX(dom.clientX), + trackId, + clientX: dom.clientX, + clientY: dom.clientY, + buttons: dom.buttons, + shiftKey: dom.shiftKey, + altKey: dom.altKey, + metaKey: dom.metaKey, + }; +} +``` + +--- + +## Provisional Flow Summary + +``` +onPointerMove → tool.onPointerMove() → ProvisionalState | null + ↓ + provisionalManager.set() ← no dispatch + ↓ + UI re-renders with ghost overlay ← separate sub + +onPointerUp → tool.onPointerUp() → Transaction | null + ↓ + provisionalManager.clear() ← always + ↓ + engine.dispatch(tx) ← only if tx !== null + ↓ + history push + engine.notify() +``` + +--- + +## What ToolRouter Does NOT Do + +- ❌ Does not know what any specific tool does +- ❌ Does not validate Transactions (that's the Dispatcher's job) +- ❌ Does not call `dispatch()` inside `handlePointerMove` +- ❌ Does not capture keyboard events (keyboard goes through a separate `KeyRouter`) + +--- + +## This file does NOT cover + +- ITool method contracts (→ `tools/ITOOL_CONTRACT.md`) +- How provisional state is rendered as ghost clips (→ `ui/COMPONENTS.md`) +- The `engine.dispatch()` algorithm (→ `core/DISPATCHER.md`) + +--- + +## Common mistakes to avoid + +- Dispatching inside `handlePointerMove` — it must only call `provisionalManager.set()` +- Forgetting to call `provisionalManager.clear()` on pointer up — ghost clips persist if not cleared +- Starting a new `rAF` for every move event — use the `rafPending` guard to drop intermediate events diff --git a/.claude/skills/core/DISPATCHER.md b/.claude/skills/core/DISPATCHER.md new file mode 100644 index 0000000..513e9c7 --- /dev/null +++ b/.claude/skills/core/DISPATCHER.md @@ -0,0 +1,172 @@ +> **Load this file when:** Touching `engine/dispatcher.ts`, writing validators, adding rejection reasons. +> **Do NOT load this file when:** Writing UI, hooks, or tool implementations. + +--- + +# DISPATCHER — Algorithm & Contract + +## Critical Rule + +The Dispatcher does **four things and only four things**. Do not add side effects. + +--- + +## The dispatch() Algorithm (exact order — do not change) + +```typescript +function dispatch( + state: TimelineState, + transaction: Transaction, +): DispatchResult; +``` + +**Steps 1+2 — Validate then apply, per op, against rolling state** + +```typescript +let proposedState = state; +for (const op of transaction.operations) { + const rejection = validateOperation(proposedState, op); // ← ROLLING state + if (rejection) { + return { + accepted: false, + reason: rejection.reason, + message: rejection.message, + }; + // ← entire transaction rejected, zero ops committed + } + proposedState = applyOperation(proposedState, op); +} +``` + +**Step 3 — Run InvariantChecker on full proposed state** + +```typescript +const violations = checkInvariants(proposedState); +if (violations.length > 0) { + return { + accepted: false, + reason: "INVARIANT_VIOLATED", + message: violations.map((v) => v.message).join("; "), + }; +} +``` + +**Step 4 — Commit: bump version exactly once** + +```typescript +const nextState: TimelineState = { + ...proposedState, + timeline: { ...proposedState.timeline, version: state.timeline.version + 1 }, +}; +return { accepted: true, nextState }; +// version bumps +1 per Transaction, NOT per operation +``` + +--- + +## What the Dispatcher Does NOT Do + +- ❌ No event emission +- ❌ No React state updates +- ❌ No history push (history is managed by the caller in `engine/history.ts`) +- ❌ No logging (call sites log if needed) +- ❌ No async operations (dispatch is synchronous) + +--- + +## RejectionReason Enum + +```typescript +type RejectionReason = + | "OVERLAP" // clip placement creates timeline overlap on a track + | "LOCKED_TRACK" // operation targets a track with locked: true + | "ASSET_MISSING" // INSERT_CLIP references an assetId not in registry + | "TYPE_MISMATCH" // clip's asset.mediaType ≠ target track.type + | "OUT_OF_BOUNDS" // frame position is outside [0, timeline.duration] + | "MEDIA_BOUNDS_INVALID" // mediaOut ≤ mediaIn, or bounds exceed intrinsicDuration + | "ASSET_IN_USE" // UNREGISTER_ASSET: asset still referenced by at least one clip + | "TRACK_NOT_EMPTY" // DELETE_TRACK: track still has clips + | "SPEED_INVALID" // SET_CLIP_SPEED: speed ≤ 0 + | "INVARIANT_VIOLATED"; // checkInvariants() returned violations after apply +``` + +--- + +## Examples + +### Correct rejection return + +```typescript +return { + accepted: false, + reason: "OVERLAP", + message: "Clip clip-b overlaps clip-a on track video-1", +}; +``` + +### Correct acceptance return + +```typescript +return { + accepted: true, + nextState: { + ...proposedState, + timeline: { + ...proposedState.timeline, + version: state.timeline.version + 1, + }, + }, +}; +``` + +--- + +## Why rolling state validation matters + +`DELETE_CLIP` followed by `INSERT_CLIP` in the same Transaction: + +- **Without rolling:** `INSERT_CLIP` validates against state that still has the original clip → `OVERLAP` rejection +- **With rolling:** `INSERT_CLIP` validates against state after `DELETE_CLIP` → original clip is gone, no overlap + +All compound Transaction patterns require rolling state validation: + +| Pattern | Why rolling is required | +| -------------------------------- | --------------------------------------------------- | +| Slice (DELETE + 2× INSERT) | INSERTs overlap the original clip in original state | +| Ripple Delete (DELETE + N× MOVE) | MOVEs might hit positions freed by the DELETE | +| Roll Trim (2× RESIZE) | Second RESIZE sees clip already resized by first | +| Ripple Insert (N× MOVE + INSERT) | INSERT occupies space freed by the MOVEs | + +--- + +## MOVE_CLIP ordering — enforced by tool, not dispatcher + +The dispatcher's rolling-state validation **requires** `MOVE_CLIP`s to be ordered +so each clip's destination is vacated before it moves. The dispatcher does **not** re-sort — +the tool is responsible for ordering. + +| Direction | Required order | +| ------------------------------ | ---------------------------------------------- | +| **+delta** (clips shift RIGHT) | **Right-to-left** (descending `timelineStart`) | +| **−delta** (clips shift LEFT) | **Left-to-right** (ascending `timelineStart`) | + +A wrong-order Transaction will be **rejected with `OVERLAP`** even if the final +arrangement would be valid. This is a silent failure — no error message distinguishes +it from a genuine overlap. + +**Tools that must apply this rule:** `RippleTrim`, `RippleDelete`, `RippleInsert`, +`RippleMove`, any tool emitting 2+ `MOVE_CLIP`s in the same direction. + +--- + +- What each validator checks per operation (→ `core/OPERATIONS.md`) +- What invariants run in Step 3 (→ `core/INVARIANTS.md`) +- History management after acceptance (→ `core/HISTORY.md`) + +--- + +## Common mistakes to avoid + +- Bumping `version` inside `applyOperation` — version bumps ONCE in Step 4 of dispatch regardless of op count +- Running `checkInvariants` on the original `state` instead of `proposedState` +- Validators referencing the original `state` passed to `dispatch()` — they must use `proposedState` (rolling state) diff --git a/.claude/skills/core/HISTORY.md b/.claude/skills/core/HISTORY.md new file mode 100644 index 0000000..2832bed --- /dev/null +++ b/.claude/skills/core/HISTORY.md @@ -0,0 +1,122 @@ +> **Load this file when:** Touching `engine/history.ts`, implementing undo/redo in the adapter. +> **Do NOT load this file when:** Writing core operations, validators, or UI components. + +--- + +# HISTORY — HistoryStack & Pure Functions + +## Critical Rule + +History lives **outside** the Dispatcher. The Dispatcher returns `nextState`. The caller (TimelineEngine adapter) decides whether to push it into history. The Dispatcher never touches `HistoryStack`. + +--- + +## HistoryStack Type + +```typescript +type HistoryStack = { + readonly past: readonly TimelineState[]; // oldest at index 0 + readonly present: TimelineState; + readonly future: readonly TimelineState[]; // most-recent-undone at index 0 + readonly limit: number; // max entries in past[] +}; +``` + +--- + +## The Five Pure Functions + +```typescript +// Create initial history — no past, no future +function createHistory( + initialState: TimelineState, + limit?: number, +): HistoryStack; + +// Push a new state — moves present→past, clears future[] +function pushHistory( + history: HistoryStack, + newState: TimelineState, +): HistoryStack; + +// Undo — moves present→future[0], past[last]→present +// Returns same reference if past is empty (no-op) +function undo(history: HistoryStack): HistoryStack; + +// Redo — moves present→past[last], future[0]→present +// Returns same reference if future is empty (no-op) +function redo(history: HistoryStack): HistoryStack; + +// Clear — empties past[] and future[], keeps present +function clearHistory(history: HistoryStack): HistoryStack; + +// Helper — read present state +function getCurrentState(history: HistoryStack): TimelineState; +``` + +--- + +## Limit Eviction Rule + +When `pushHistory` would cause `past.length > limit`, the **oldest** entry (index 0) is dropped: + +```typescript +const pastWithPresent = [...history.past, history.present]; +const trimmed = + pastWithPresent.length > limit + ? pastWithPresent.slice(pastWithPresent.length - limit) + : pastWithPresent; +// trimmed becomes the new history.past +``` + +--- + +## Branch Rule (Undo then New Transaction) + +Calling `pushHistory` after an `undo` **clears the future array**. This creates a new branch — the undone operations are discarded. No tree branching in Phase 0. + +```typescript +// History: A → B → C, user undos to B +// future = [C] +// User makes new edit → D +pushHistory(history, D); +// Result: past=[A,B], present=D, future=[] ← C is gone +``` + +--- + +## Caller Pattern (TimelineEngine adapter) + +```typescript +// In TimelineEngine.dispatch(): +const result = coreDispatch(this.history.present, transaction); +if (result.accepted) { + this.history = pushHistory(this.history, result.nextState); + this.notify(); +} +return result; +``` + +--- + +## What History Does NOT Do + +- ❌ No per-operation granularity — history entries are `TimelineState` snapshots, not operation lists +- ❌ No compression (Phase 7) +- ❌ No persistence / serialization (Phase 7) +- ❌ No transaction squashing (Phase 7) + +--- + +## This file does NOT cover + +- How `dispatch()` produces `nextState` (→ `core/DISPATCHER.md`) +- How `useSyncExternalStore` reacts to history changes (→ `adapter/HOOKS.md`) + +--- + +## Common mistakes to avoid + +- Pushing into history before calling `dispatch()` — always push the ACCEPTED `nextState`, not state before dispatch +- Not clearing `future[]` on `pushHistory` — forgetting this breaks the branch rule and leaks stale futures +- Checking `canUndo` by testing `history.past.length > 0` but forgetting that `undo` on empty past returns the **same reference** — always use the `canUndo()` helper diff --git a/.claude/skills/core/INVARIANTS.md b/.claude/skills/core/INVARIANTS.md new file mode 100644 index 0000000..dc3c578 --- /dev/null +++ b/.claude/skills/core/INVARIANTS.md @@ -0,0 +1,110 @@ +> **Load this file when:** Touching `validation/invariants.ts`, writing tests that produce `TimelineState`, debugging unexpected rejections from `INVARIANT_VIOLATED`. +> **Do NOT load this file when:** Writing per-primitive validators (→ `core/DISPATCHER.md`). + +--- + +# INVARIANTS — checkInvariants() Specification + +## Critical Rule + +`checkInvariants(state)` **must be called in every test** that produces a new `TimelineState`. Zero violations is the only acceptable result. + +--- + +## Signature + +```typescript +function checkInvariants(state: TimelineState): InvariantViolation[]; +// Returns [] if state is valid. +// Returns one entry per violation — does NOT short-circuit after first failure. +``` + +--- + +## The 9 Invariant Rules (in this exact order) + +| # | ViolationType | Rule | +| --- | ---------------------- | ---------------------------------------------------------------------------------------------------- | +| 1 | `OVERLAP` | No two clips on the same track may have overlapping `[timelineStart, timelineEnd)` ranges | +| 2 | `ASSET_MISSING` | Every `clip.assetId` must exist in `state.assetRegistry` | +| 3 | `TRACK_TYPE_MISMATCH` | `clip.assetId → asset.mediaType` must equal `track.type` for the track the clip is on | +| 4 | `MEDIA_BOUNDS_INVALID` | `clip.mediaIn ≥ 0` | +| 5 | `MEDIA_BOUNDS_INVALID` | `clip.mediaOut ≤ asset.intrinsicDuration` for the referenced asset | +| 6 | `DURATION_MISMATCH` | `clip.mediaOut - clip.mediaIn` must equal `clip.timelineEnd - clip.timelineStart` (when speed = 1.0) | +| 7 | `CLIP_BEYOND_TIMELINE` | `clip.timelineEnd ≤ timeline.duration` | +| 8 | `TRACK_NOT_SORTED` | `track.clips` must be sorted ascending by `timelineStart` | +| 9 | `SPEED_INVALID` | `clip.speed > 0` | + +--- + +## InvariantViolation Shape + +```typescript +type InvariantViolation = { + readonly type: ViolationType; + readonly entityId: string; // clipId for clip violations, trackId for track violations + readonly message: string; +}; +``` + +--- + +## Examples + +### ✅ State that passes (empty array) + +```typescript +const state = createTimelineState({ timeline, assetRegistry }); +// clip [0..100] on video track, asset in registry, mediaOut ≤ intrinsicDuration +expect(checkInvariants(state)).toEqual([]); +``` + +### ❌ State that fails OVERLAP + +```typescript +// clip-a: [0..100], clip-b: [50..150] on same track +const violations = checkInvariants(badState); +// Returns: +[ + { + type: "OVERLAP", + entityId: "clip-b", + message: "Clip clip-b overlaps clip-a on track video-1", + }, +]; +``` + +### ❌ State that fails ASSET_MISSING + +```typescript +// clip references assetId 'ghost-asset' not in registry +[{ type: "ASSET_MISSING", entityId: "clip-id", message: "..." }]; +``` + +--- + +## Rule on DURATION_MISMATCH (invariant #6) + +This invariant is **waived** when `clip.speed ≠ 1.0`. The formula becomes: + +``` +(mediaOut - mediaIn) / speed ≈ (timelineEnd - timelineStart) +``` + +Only enforce strictly for speed = 1.0 in Phase 0. + +--- + +## This file does NOT cover + +- Per-primitive pre-dispatch validation (→ `core/DISPATCHER.md`) +- How violations trigger `INVARIANT_VIOLATED` rejection (→ `core/DISPATCHER.md`) +- Snap-point calculation (→ `core/SNAP_INDEX.md`) + +--- + +## Common mistakes to avoid + +- Short-circuiting invariant checking after the first failure — all 9 must run and all violations collected +- Enforcing DURATION_MISMATCH for clips with speed ≠ 1.0 — this is Phase 0 only, not a hard rule for retimed clips +- Testing invariants on the wrong state — always call `checkInvariants(proposedState)` not `checkInvariants(originalState)` diff --git a/.claude/skills/core/MARKERS_AND_GENERATORS.md b/.claude/skills/core/MARKERS_AND_GENERATORS.md new file mode 100644 index 0000000..4e6e8aa --- /dev/null +++ b/.claude/skills/core/MARKERS_AND_GENERATORS.md @@ -0,0 +1,159 @@ +--- +name: markers-and-generators +description: > + Load when working on markers, in/out points, beat grid, generators, + captions, or SRT/VTT import. Do NOT load when working on tools, + dispatcher, hooks, or any Phase 1/2 file. +--- + +> **Load this file when:** Working on `markers`, `in/out points`, `beat grid`, `generators`, `captions`, `SRT/VTT import`. +> **Do NOT load this file when:** Working on tools, dispatcher, hooks, or any Phase 1/2 file. + +--- + +# MARKERS, GENERATORS, CAPTIONS — Phase 3 Data Model + +## Marker type + +```typescript +type Marker = { + readonly id: MarkerId; + readonly type: "point" | "range"; + readonly frame: TimelineFrame; // point markers only + readonly frameStart: TimelineFrame; // range markers only + readonly frameEnd: TimelineFrame; // range markers only + readonly label: string; + readonly color: string; + readonly scope: "global" | "personal" | "export"; + readonly linkedClipId?: ClipId; // moves with clip on ripple +}; +``` + +Markers live on `Timeline`, not on a `Track`. All markers are accessible regardless of track visibility. + +--- + +## Generator type + +```typescript +type Generator = { + readonly id: GeneratorId; + readonly type: "solid" | "bars" | "countdown" | "noise" | "text"; + readonly params: Record; + readonly duration: TimelineFrame; +}; +``` + +Generators act as Assets with no `filePath`. They are registered in `AssetRegistry` like any other asset. `mediaType` is always `'video'` (or `'audio'` for tone generators). + +--- + +## Caption type + +```typescript +type Caption = { + readonly id: CaptionId; + readonly text: string; + readonly startFrame: TimelineFrame; + readonly endFrame: TimelineFrame; + readonly language: string; // BCP-47: 'en-US', 'fr-FR' + readonly style: CaptionStyle; + readonly burnIn: boolean; +}; + +type CaptionStyle = { + readonly fontFamily: string; + readonly fontSize: number; + readonly color: string; + readonly backgroundColor: string; + readonly hAlign: "left" | "center" | "right"; + readonly vAlign: "top" | "center" | "bottom"; +}; +``` + +`burnIn: true` means the caption is composited into the video at export. The export pipeline reads this flag — it is NOT a track-level setting. + +--- + +## BeatGrid type + +```typescript +type BeatGrid = { + readonly bpm: number; + readonly timeSignature: [number, number]; // [4, 4], [3, 4] etc. + readonly offset: TimelineFrame; +}; +``` + +Stored on `Timeline`. Generates `SnapPoint`s at every beat and bar. `buildSnapIndex()` must handle `null` gracefully. + +--- + +## New fields on Timeline (Phase 3 additions) + +```typescript +// Current Timeline (Phase 0–2) +type Timeline = { + readonly id: string + readonly name: string + readonly fps: FrameRate + readonly duration: TimelineFrame + readonly startTimecode: Timecode + readonly tracks: readonly Track[] + readonly sequenceSettings: SequenceSettings + readonly version: number +} + +// Phase 3 additions: + readonly markers: readonly Marker[] // default: [] + readonly beatGrid: BeatGrid | null // default: null + readonly inPoint: TimelineFrame | null // default: null + readonly outPoint: TimelineFrame | null // default: null +``` + +When adding these fields, `createTimeline()` defaults: `markers: []`, `beatGrid: null`, `inPoint: null`, `outPoint: null`. + +--- + +## New OperationPrimitives (Phase 3) + +```typescript +| ADD_MARKER | { type: 'ADD_MARKER'; marker: Marker } +| MOVE_MARKER | { type: 'MOVE_MARKER'; markerId: MarkerId; newFrame: TimelineFrame } +| DELETE_MARKER | { type: 'DELETE_MARKER'; markerId: MarkerId } +| SET_IN_POINT | { type: 'SET_IN_POINT'; frame: TimelineFrame | null } +| SET_OUT_POINT | { type: 'SET_OUT_POINT'; frame: TimelineFrame | null } +| ADD_BEAT_GRID | { type: 'ADD_BEAT_GRID'; beatGrid: BeatGrid } +| REMOVE_BEAT_GRID | { type: 'REMOVE_BEAT_GRID' } +| INSERT_GENERATOR | { type: 'INSERT_GENERATOR'; generator: Generator; trackId: TrackId; timelineStart: TimelineFrame } +| ADD_CAPTION | { type: 'ADD_CAPTION'; caption: Caption } +| EDIT_CAPTION | { type: 'EDIT_CAPTION'; captionId: CaptionId; patch: Partial } +| DELETE_CAPTION | { type: 'DELETE_CAPTION'; captionId: CaptionId } +``` + +--- + +## Snap index additions (Phase 3) + +`buildSnapIndex()` must be updated to pull from all four sources: + +| Source | Priority | Notes | +| ------------------- | ---------- | ---------------------------------------- | +| Markers | 100 | Both point and range markers (start+end) | +| InPoint | 90 | Single frame | +| OutPoint | 90 | Single frame | +| BeatGrid beats | 50 | Every beat; bars at higher priority | +| Existing clip edges | (existing) | Unchanged from Phase 2 | + +`buildSnapIndex()` handles `markers: []`, `beatGrid: null`, `inPoint: null`, `outPoint: null` without branching — map over empty arrays and skip nulls. + +--- + +## Common mistakes to avoid + +- **Never** store `Marker[]` on a `Track` — markers live on `Timeline` +- `BeatGrid` is `null` by default — `buildSnapIndex()` must handle `null` gracefully (no crash) +- `Caption.burnIn` is on the entity, not the track — the export pipeline reads it at render time, not the dispatcher +- `linkedClipId` on a `Marker` moves the marker during ripple operations — `RippleDelete` and `RippleInsert` must update linked markers when their linked clip moves +- `ADD_BEAT_GRID` should fail if `timeline.beatGrid !== null` (only one beat grid per timeline) — validator enforces this +- In/out points are `null` when not set — `SET_IN_POINT(null)` clears the in point (valid operation) diff --git a/.claude/skills/core/OPERATIONS.md b/.claude/skills/core/OPERATIONS.md new file mode 100644 index 0000000..ede295e --- /dev/null +++ b/.claude/skills/core/OPERATIONS.md @@ -0,0 +1,248 @@ +> **Load this file when:** Adding or modifying any `OperationPrimitive`, touching `apply.ts`, writing compound Transaction patterns. +> **Do NOT load this file when:** Writing validation logic (→ `core/DISPATCHER.md`), React hooks (→ `adapter/HOOKS.md`). + +--- + +# OPERATIONS — Primitives, Validation Rules & Apply Semantics + +## Critical Rule First + +**NEVER add a new mutation function.** Every new mutation = new member of `OperationPrimitive`. Update `apply.ts`, `validators.ts`, and this file. Zero exceptions. + +Every `OperationPrimitive` must be **JSON-serializable**: no functions, no class instances, no Symbols. + +--- + +## OperationPrimitive — Fields + Validator + Apply + +### MOVE_CLIP + +```typescript +{ type: 'MOVE_CLIP'; clipId: ClipId; newTimelineStart: TimelineFrame; targetTrackId?: TrackId } +``` + +**Validator checks:** clip exists · source track not locked · if `targetTrackId`: target track not locked, track type compatible with clip's asset +**Apply:** + +```typescript +delta = newTimelineStart - clip.timelineStart; +movedClip = { + ...clip, + timelineStart: newTimelineStart, + timelineEnd: clip.timelineEnd + delta, + trackId: targetTrackId ?? clip.trackId, +}; +// If cross-track: remove from source.clips, insert into target.clips (sorted) +// If same-track: update clip in place +``` + +### RESIZE_CLIP + +```typescript +{ + type: "RESIZE_CLIP"; + clipId: ClipId; + edge: "start" | "end"; + newFrame: TimelineFrame; +} +``` + +**Validator checks:** clip exists · track not locked · newFrame is within media bounds +**Apply — START edge (THE CRITICAL ONE):** + +```typescript +delta = newFrame - clip.timelineStart; +// timelineStart advances, mediaIn advances by IDENTICAL delta +// timelineEnd and mediaOut stay FIXED +return { ...clip, timelineStart: newFrame, mediaIn: clip.mediaIn + delta }; +``` + +**Apply — END edge:** + +```typescript +delta = newFrame - clip.timelineEnd; +return { ...clip, timelineEnd: newFrame, mediaOut: clip.mediaOut + delta }; +``` + +> ⚠ If you move `timelineStart` without moving `mediaIn` by the same delta, **Slip is broken.** The test `'trimming start by +30 frames advances timelineStart and mediaIn by identical delta'` catches this. + +### SLICE_CLIP + +```typescript +{ + type: "SLICE_CLIP"; + clipId: ClipId; + atFrame: TimelineFrame; +} +``` + +**Validator checks:** clip exists · atFrame is within [timelineStart, timelineEnd] +**Apply:** No-op in `apply.ts` — must be used inside a compound Transaction: + +```typescript +// Correct compound pattern for SLICE: +[ + { type: "DELETE_CLIP", clipId }, + { type: "INSERT_CLIP", clip: leftHalf, trackId }, + { type: "INSERT_CLIP", clip: rightHalf, trackId }, +]; +``` + +### DELETE_CLIP + +```typescript +{ + type: "DELETE_CLIP"; + clipId: ClipId; +} +``` + +**Validator:** clip exists · track not locked +**Apply:** Remove clip from its track's clips array. + +### INSERT_CLIP + +```typescript +{ + type: "INSERT_CLIP"; + clip: Clip; + trackId: TrackId; +} +``` + +**Validator:** track exists · `clip.assetId` in registry · `clip.speed > 0` +**Apply:** Append clip to track.clips, then sort by timelineStart. + +### SET_MEDIA_BOUNDS (Slip tool) + +```typescript +{ + type: "SET_MEDIA_BOUNDS"; + clipId: ClipId; + mediaIn: TimelineFrame; + mediaOut: TimelineFrame; +} +``` + +**Validator:** `mediaOut > mediaIn` · `mediaOut - mediaIn ≤ asset.intrinsicDuration` · `mediaIn ≥ 0` +**Apply:** `{ ...clip, mediaIn, mediaOut }` — timeline bounds untouched. + +### SET_CLIP_ENABLED / SET_CLIP_REVERSED / SET_CLIP_SPEED / SET_CLIP_COLOR / SET_CLIP_NAME + +Simple field setters. **Validator for SPEED:** `speed > 0`. Others: clip exists. + +### ADD_TRACK / DELETE_TRACK / REORDER_TRACK / SET_TRACK_HEIGHT / SET_TRACK_NAME + +**DELETE_TRACK validator:** track must be empty (no clips). Use a `[ DELETE_CLIP×N, DELETE_TRACK ]` Transaction if clips exist. + +### REGISTER_ASSET + +```typescript +{ + type: "REGISTER_ASSET"; + asset: Asset; +} +``` + +**Validator:** `asset.intrinsicDuration > 0` · `asset.nativeFps` is a valid `FrameRate` literal +**Apply:** `new Map(registry).set(asset.id, asset)` + +### UNREGISTER_ASSET + +```typescript +{ + type: "UNREGISTER_ASSET"; + assetId: AssetId; +} +``` + +**Validator:** Asset **not referenced by any clip** in any track → `ASSET_IN_USE` rejection if used +**Apply:** `new Map(registry).delete(assetId)` + +### RENAME_TIMELINE / SET_TIMELINE_DURATION / SET_TIMELINE_START_TC / SET_SEQUENCE_SETTINGS + +Simple timeline field updates. No cross-entity validation required. + +--- + +## Compound Transaction Patterns + +| Edit name | Operations | +| ------------------- | ---------------------------------------------------------- | +| **Ripple Delete** | `DELETE_CLIP` + `MOVE_CLIP×N` for all clips to the right | +| **Slip** | `SET_MEDIA_BOUNDS` (single op) | +| **Roll Trim** | `RESIZE_CLIP(end, clip A)` + `RESIZE_CLIP(start, clip B)` | +| **Ripple Trim** | `RESIZE_CLIP` + `MOVE_CLIP×N` for downstream clips | +| **Slice** | `DELETE_CLIP` + `INSERT_CLIP(left)` + `INSERT_CLIP(right)` | +| **Lift** | `DELETE_CLIP` (leaves gap) | +| **Extract** | `DELETE_CLIP` + `MOVE_CLIP×N` to close gap | +| **Overwrite Paste** | `DELETE_CLIP×N` (evicted clips) + `INSERT_CLIP` | +| **Insert Paste** | `MOVE_CLIP×N` (shift right) + `INSERT_CLIP` | + +--- + +### Lift (detailed) + +Removes a clip but leaves a gap — upstream and downstream clips do not move. + +```typescript +{ type: 'DELETE_CLIP', clipId } +{ type: 'INSERT_CLIP', clip: gapClip, trackId } // same duration, fills the hole +``` + +`gapClip` is a special transparent/silent placeholder with the same `timelineStart`, +`timelineEnd` as the deleted clip. If the track has no gap-clip concept, Lift is just +`DELETE_CLIP` alone (gap is implicit silence/transparency). + +### Extract (detailed) + +Alias for **Ripple Delete** — removes the clip and closes the gap. Clips to the right shift left. + +```typescript +{ type: 'DELETE_CLIP', clipId } +{ type: 'MOVE_CLIP', clipId: leftmost_right, newTimelineStart: ... } // left-to-right sort +{ type: 'MOVE_CLIP', clipId: next_right, newTimelineStart: ... } +// ... one MOVE_CLIP per clip to the right +``` + +`MOVE_CLIP` sort: **left-to-right** (ascending `timelineStart`) because delta is negative. +See § MOVE_CLIP ordering rule below. + +--- + +When a Transaction contains multiple `MOVE_CLIP`s shifting clips in the **same direction**, the order within the `operations` array is critical for rolling-state validation. + +| Direction | Sort order | Reason | +| ------------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| **+delta** (clips shift RIGHT) | **Right-to-left** (descending `timelineStart`) | Rightmost clip moves into empty space first; each subsequent clip moves into space the previous one vacated | +| **−delta** (clips shift LEFT) | **Left-to-right** (ascending `timelineStart`) | Leftmost clip moves first; same principle | + +**Why it matters:** The dispatcher validates each op against the rolling state immediately before applying it. If clip B is moved right before clip C (which is to its right), B's new position overlaps C's current position — an `OVERLAP` rejection — even though the final arrangement is valid. + +```typescript +// +delta: move rightmost first +const sorted = downstream.sort( + (a, b) => + delta >= 0 + ? b.timelineStart - a.timelineStart // right-to-left + : a.timelineStart - b.timelineStart, // left-to-right +); +``` + +**Applies to:** `RippleTrim`, `RippleDelete`, `RippleInsert`, `RippleMove` — any Transaction with 2+ `MOVE_CLIP`s in the same direction. + +--- + +## This file does NOT cover + +- How the Dispatcher orchestrates validation (→ `core/DISPATCHER.md`) +- Invariant rules that run after apply (→ `core/INVARIANTS.md`) +- How tools build Transactions (→ `tools/ITOOL_CONTRACT.md`) + +--- + +## Common mistakes to avoid + +- Forgetting to advance `mediaIn` when moving `timelineStart` in RESIZE_CLIP start edge +- Leaving SLICE_CLIP as a no-op in isolation — always wrap it in a 3-op Transaction +- Validator for UNREGISTER_ASSET must scan ALL tracks — missing one track silently corrupts state diff --git a/.claude/skills/core/SNAP_INDEX.md b/.claude/skills/core/SNAP_INDEX.md new file mode 100644 index 0000000..197271e --- /dev/null +++ b/.claude/skills/core/SNAP_INDEX.md @@ -0,0 +1,140 @@ +> **Load this file when:** Touching `engine/snap-index.ts`, implementing snap behavior in any tool, changing snap priorities. +> **Do NOT load this file when:** Writing clip operations, history logic, or UI layout math. + +--- + +# SNAP_INDEX — Types, Algorithm & Priority Table + +## Critical Rule + +`buildSnapIndex()` is called **after** an accepted dispatch via `queueMicrotask`. **Never** call it during a drag (pointer move). Snap queries use the index built from the last committed state. + +--- + +## Types + +```typescript +type SnapPointType = + | "ClipStart" + | "ClipEnd" + | "InPoint" + | "OutPoint" + | "Playhead" + | "Marker" + | "BeatGrid"; + +type SnapPoint = { + readonly frame: TimelineFrame; + readonly type: SnapPointType; + readonly priority: number; // see priority table below + readonly trackId: TrackId | null; // null = timeline-wide points + readonly entityId: string; // clipId, markerId, etc. +}; + +type SnapIndex = { + readonly points: readonly SnapPoint[]; + readonly builtAt: number; // Date.now() of last build +}; +``` + +--- + +## Priority Table (canonical — do not change values) + +| Source | Priority | +| -------------------------- | -------- | +| `Marker` (timeline marker) | **100** | +| `InPoint` | **90** | +| `OutPoint` | **90** | +| `ClipStart` | **80** | +| `ClipEnd` | **80** | +| `Playhead` | **70** | +| `BeatGrid` | **50** | + +Higher priority wins when two snap candidates are equidistant from the cursor. + +--- + +## buildSnapIndex() + +```typescript +function buildSnapIndex( + state: TimelineState, + playheadFrame: TimelineFrame, +): SnapIndex; +``` + +**Sources pulled in order:** + +1. All `ClipStart` and `ClipEnd` points from every track in `state.timeline.tracks` +2. Playhead position (single `Playhead` point, trackId = null) +3. _(Phase 2+)_ Timeline-level markers → `Marker` points +4. _(Phase 3+)_ Beat grid positions → `BeatGrid` points + +--- + +## nearest() + +```typescript +function nearest( + index: SnapIndex, + frame: TimelineFrame, + radiusFrames: number, + exclude?: readonly string[], // entityIds to exclude from snap candidates +): SnapPoint | null; +``` + +**Algorithm:** + +```typescript +1. Filter index.points: remove any point where entityId is in exclude[] +2. For each remaining point: distance = Math.abs(point.frame - frame) +3. Keep only points where distance ≤ radiusFrames +4. If none: return null +5. Find minimum distance across candidates +6. Among candidates AT minimum distance: pick highest priority +7. If priority tie: pick first in iteration order +8. Return winning SnapPoint +``` + +**Radius conversion** (pixels → frames at call site): + +```typescript +const radiusFrames = SNAP_RADIUS_PX / pixelsPerFrame; +``` + +`SNAP_RADIUS_PX = 8` is the default. Never hardcode inside `nearest()`. + +--- + +## Rebuild Rule + +```typescript +// In TimelineEngine, after accepted dispatch: +const result = coreDispatch(this.history.present, tx); +if (result.accepted) { + this.history = pushHistory(this.history, result.nextState); + queueMicrotask(() => { + this.snapIndex = buildSnapIndex(result.nextState, this.playheadFrame); + }); + this.notify(); +} +``` + +Never rebuild during `onPointerMove`. The SnapIndex is read-only during a drag gesture. + +--- + +## This file does NOT cover + +- How tools query the snap index (→ `tools/ITOOL_CONTRACT.md`) +- How ToolRouter passes `pixelsPerFrame` to tools (→ `adapter/TOOL_ROUTER.md`) +- Phase 2 marker snap points (→ future MARKERS.md) + +--- + +## Common mistakes to avoid + +- Rebuilding the snap index synchronously inside `dispatch()` — always use `queueMicrotask` +- Snapping to the clip being DRAGGED — always pass the dragging clip's id in `exclude[]` +- Using raw pixel values inside `nearest()` — convert pixels to frames at the tool layer before calling `nearest()` diff --git a/.claude/skills/core/TYPES.md b/.claude/skills/core/TYPES.md new file mode 100644 index 0000000..31afdf3 --- /dev/null +++ b/.claude/skills/core/TYPES.md @@ -0,0 +1,255 @@ +> **Load this file when:** Touching `packages/core/src/types/` or any file that defines or references core data shapes. +> **Do NOT load this file when:** Writing UI components, hooks, or tool implementations (load `DISPATCHER.md`, `HOOKS.md`, or `ITOOL_CONTRACT.md` instead). + +--- + +# TYPES — Canonical Type Definitions (Source of Truth) + +**If a type here conflicts with the codebase, the codebase is wrong.** + +--- + +## Time & Frame Types + +```typescript +// Branded integer — ALL frame positions in the engine +type TimelineFrame = number & { readonly __brand: "TimelineFrame" }; + +// Exact literal union — NO floating-point approximations allowed +type FrameRate = 23.976 | 24 | 25 | 29.97 | 30 | 50 | 59.94 | 60; + +// Ingest/export boundary only — never use in edit operation arithmetic +type RationalTime = { readonly value: number; readonly rate: FrameRate }; + +// Display only — never use in arithmetic +type Timecode = string & { readonly __brand: "Timecode" }; + +// Audio boundary type +type AudioSampleIndex = number & { readonly __brand: "AudioSampleIndex" }; + +// Helpers +const FrameRates = { + NTSC: 29.97, + PAL: 25, + NTSC_DF: 23.976, + CINEMA: 24, + P30: 30, +} as const; +function toFrame(n: number): TimelineFrame; +function toTimecode(s: string): Timecode; +function isDropFrame(fps: FrameRate): boolean; +``` + +--- + +## Branded ID Types + +```typescript +type AssetId = string & { readonly __brand: "AssetId" }; +type ClipId = string & { readonly __brand: "ClipId" }; +type TrackId = string & { readonly __brand: "TrackId" }; +type MarkerId = string & { readonly __brand: "MarkerId" }; +type ToolId = string & { readonly __brand: "ToolId" }; + +function toAssetId(s: string): AssetId; +function toClipId(s: string): ClipId; +function toTrackId(s: string): TrackId; +``` + +--- + +## Enums & Unions + +```typescript +type AssetStatus = "online" | "offline" | "proxy-only" | "missing"; +type TrackType = "video" | "audio" | "subtitle" | "title"; +type RetimingMode = "ripple" | "slip" | "none"; +``` + +--- + +## Data Models + +```typescript +type Asset = { + readonly id: AssetId; + readonly name: string; + readonly mediaType: TrackType; + readonly filePath: string; + readonly intrinsicDuration: TimelineFrame; + readonly nativeFps: FrameRate; + readonly sourceTimecodeOffset: TimelineFrame; + readonly status: AssetStatus; // default: 'online' +}; + +type Clip = { + readonly id: ClipId; + readonly assetId: AssetId; + readonly trackId: TrackId; + readonly timelineStart: TimelineFrame; + readonly timelineEnd: TimelineFrame; + readonly mediaIn: TimelineFrame; + readonly mediaOut: TimelineFrame; + readonly speed: number; // default: 1.0, must be > 0 + readonly enabled: boolean; // default: true + readonly reversed: boolean; // default: false + readonly name: string | null; + readonly color: string | null; + readonly metadata: Record; +}; + +type Track = { + readonly id: TrackId; + readonly name: string; + readonly type: TrackType; + readonly clips: readonly Clip[]; // always sorted by timelineStart + readonly locked: boolean; + readonly muted: boolean; + readonly solo: boolean; + readonly height: number; +}; + +type SequenceSettings = { + readonly frameSize: { readonly width: number; readonly height: number }; + readonly sampleRate: number; + readonly pixelAspect: number; +}; + +type Timeline = { + readonly id: string; + readonly name: string; + readonly fps: FrameRate; + readonly duration: TimelineFrame; + readonly startTimecode: Timecode; + readonly tracks: readonly Track[]; + readonly sequenceSettings: SequenceSettings | null; + readonly version: number; // bumped +1 per committed Transaction +}; + +// ─── State ─────────────────────────────────────────────────────────────────── + +type AssetRegistry = ReadonlyMap; + +type TimelineState = { + readonly timeline: Timeline; + readonly assetRegistry: AssetRegistry; +}; +``` + +--- + +## Operations, Transaction & DispatchResult + +```typescript +type OperationPrimitive = + // Clip ops + | { + type: "MOVE_CLIP"; + clipId: ClipId; + newTimelineStart: TimelineFrame; + targetTrackId?: TrackId; + } + | { + type: "RESIZE_CLIP"; + clipId: ClipId; + edge: "start" | "end"; + newFrame: TimelineFrame; + } + | { type: "SLICE_CLIP"; clipId: ClipId; atFrame: TimelineFrame } + | { type: "DELETE_CLIP"; clipId: ClipId } + | { type: "INSERT_CLIP"; clip: Clip; trackId: TrackId } + | { + type: "SET_MEDIA_BOUNDS"; + clipId: ClipId; + mediaIn: TimelineFrame; + mediaOut: TimelineFrame; + } + | { type: "SET_CLIP_ENABLED"; clipId: ClipId; enabled: boolean } + | { type: "SET_CLIP_REVERSED"; clipId: ClipId; reversed: boolean } + | { type: "SET_CLIP_SPEED"; clipId: ClipId; speed: number } + | { type: "SET_CLIP_COLOR"; clipId: ClipId; color: string | null } + | { type: "SET_CLIP_NAME"; clipId: ClipId; name: string | null } + // Track ops + | { type: "ADD_TRACK"; track: Track } + | { type: "DELETE_TRACK"; trackId: TrackId } + | { type: "REORDER_TRACK"; trackId: TrackId; newIndex: number } + | { type: "SET_TRACK_HEIGHT"; trackId: TrackId; height: number } + | { type: "SET_TRACK_NAME"; trackId: TrackId; name: string } + // Asset ops + | { type: "REGISTER_ASSET"; asset: Asset } + | { type: "UNREGISTER_ASSET"; assetId: AssetId } + | { type: "SET_ASSET_STATUS"; assetId: AssetId; status: AssetStatus } + // Timeline ops + | { type: "RENAME_TIMELINE"; name: string } + | { type: "SET_TIMELINE_DURATION"; duration: TimelineFrame } + | { type: "SET_TIMELINE_START_TC"; startTimecode: Timecode } + | { type: "SET_SEQUENCE_SETTINGS"; settings: Partial }; + +type Transaction = { + readonly id: string; + readonly label: string; + readonly timestamp: number; + readonly operations: readonly OperationPrimitive[]; +}; + +type RejectionReason = + | "OVERLAP" + | "LOCKED_TRACK" + | "ASSET_MISSING" + | "TYPE_MISMATCH" + | "OUT_OF_BOUNDS" + | "MEDIA_BOUNDS_INVALID" + | "ASSET_IN_USE" + | "TRACK_NOT_EMPTY" + | "SPEED_INVALID" + | "INVARIANT_VIOLATED"; + +type DispatchResult = + | { accepted: true; nextState: TimelineState } + | { accepted: false; reason: RejectionReason; message: string }; +``` + +--- + +## InvariantViolation & HistoryStack + +```typescript +type ViolationType = + | "OVERLAP" + | "MEDIA_BOUNDS_INVALID" + | "ASSET_MISSING" + | "TRACK_TYPE_MISMATCH" + | "CLIP_BEYOND_TIMELINE" + | "TRACK_NOT_SORTED" + | "DURATION_MISMATCH" + | "SPEED_INVALID"; + +type InvariantViolation = { + readonly type: ViolationType; + readonly entityId: string; + readonly message: string; +}; + +type HistoryStack = { + readonly past: readonly TimelineState[]; + readonly present: TimelineState; + readonly future: readonly TimelineState[]; + readonly limit: number; +}; +``` + +--- + +## This file does NOT cover + +- How dispatch uses these types (→ `core/DISPATCHER.md`) +- Validation rules per operation (→ `core/OPERATIONS.md`) +- How React hooks consume these types (→ `adapter/HOOKS.md`) + +--- + +## Common mistakes to avoid + +- Using `number` for a frame position instead of `TimelineFrame` +- Adding fields to `TimelineState` that belong in Phase 2 (`markers`, `workArea`, `linkGroups`, `groups`) +- Using `asset.type` or `asset.duration` — the correct fields are `asset.mediaType` and `asset.intrinsicDuration` diff --git a/.claude/skills/tools/ITOOL_CONTRACT.md b/.claude/skills/tools/ITOOL_CONTRACT.md new file mode 100644 index 0000000..8e3fc33 --- /dev/null +++ b/.claude/skills/tools/ITOOL_CONTRACT.md @@ -0,0 +1,436 @@ +> **Load this file when:** Writing ANY ITool implementation, adding a new tool, or writing tool tests. +> **Do NOT load this file when:** Writing core operations, React hooks, or UI components (tools and hooks are separate concerns). + +--- + +# ITOOL_CONTRACT — Interface & Test Pattern + +## Critical Rule + +`onPointerMove` **NEVER** calls `dispatch()`. `onPointerUp` **NEVER** mutates instance state. + +--- + +## ITool Interface + +```typescript +interface ITool { + readonly id: ToolId; + readonly cursor: CSSProperties["cursor"]; + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void; + + // Returns ProvisionalState for ghost rendering — never dispatches + onPointerMove( + event: TimelinePointerEvent, + ctx: ToolContext, + ): ProvisionalState | null; + + // Returns Transaction to commit — never mutates state + onPointerUp( + event: TimelinePointerEvent, + ctx: ToolContext, + ): Transaction | null; + + onKeyDown?(event: TimelineKeyEvent, ctx: ToolContext): Transaction | null; + onKeyUp?(event: TimelineKeyEvent, ctx: ToolContext): void; + + // Called when another tool is activated while this one has a gesture in progress + onCancel?(): void; +} +``` + +--- + +## ToolContext Type + +```typescript +type ToolContext = { + readonly state: TimelineState; // committed state (never provisional) + readonly snapIndex: SnapIndex; // index from last commit, never mid-drag + readonly pixelsPerFrame: number; + readonly snapEnabled: boolean; + readonly frameAtX: (x: number) => TimelineFrame; // pixel → frame conversion + readonly trackAtY: (y: number) => Track | null; // pixel → track conversion +}; +``` + +--- + +## ProvisionalState Type + +```typescript +type ProvisionalState = { + readonly clips: readonly Clip[]; // full replacement clips (not partial/delta) +}; +// UI reads: provisional?.clips.find(c => c.id === id) ?? committedClip +``` + +--- + +## Event Types + +```typescript +type TimelinePointerEvent = { + readonly frame: TimelineFrame; + readonly trackId: TrackId | null; + readonly clientX: number; + readonly clientY: number; + readonly buttons: number; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; +}; + +type TimelineKeyEvent = { + readonly key: string; + readonly code: string; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; +}; +``` + +--- + +## Tool Internal State Rule + +A tool may hold **drag-tracking variables only** on its instance. No timeline state. No copies of clips. Read from `ctx.state` on every event. + +```typescript +class SelectionTool implements ITool { + // ✅ OK — drag tracking only + private dragStartFrame: TimelineFrame | null = null; + private dragClipId: ClipId | null = null; + + // ❌ NOT OK — never cache TimelineState or Clip data + // private cachedClip: Clip | null = null; +} +``` + +--- + +## Capture-before-reset pattern (required in every `onPointerUp`) + +In `onPointerUp`, **ALWAYS** capture instance variables into locals before calling any reset function. `_resetDragState()` / `onCancel()` set instance vars to `null` — any code that runs after the reset and reads `this.dragClipId` (etc.) will get `null`. + +```typescript +// ✅ Correct — capture first, then reset +onPointerUp(event, ctx): Transaction | null { + const clipId = this.dragClipId; // captured + const edge = this.dragEdge; // captured + this._resetDragState(); // clears this.dragClipId, this.dragEdge, etc. + if (!clipId || !edge) return null; + return buildTransaction(clipId, edge); // safe — uses locals, not this.* +} + +// ❌ Wrong — reset before capture +onPointerUp(event, ctx): Transaction | null { + this._resetDragState(); + return buildTransaction(this.dragClipId); // 🐛 always null +} +``` + +**Variant:** Any helper method called from `onPointerUp` that reads `this.*` (e.g. `this._clampFrame()`) must be called **before** `_resetDragState()`, not after. This means: compute all derived values first, then capture, then reset. + +```typescript +// ✅ Correct order in onPointerUp +const newFrame = this._clampFrame(snapped); // reads this.dragEdge etc. — call FIRST +const clipId = this.dragClipId; // then capture +this._resetDragState(); // then reset +``` + +This bug has appeared in SelectionTool, RippleTrimTool, and will appear in every tool that has a reset helper. The pattern is now mandatory. + +--- + +## NoOpTool — Test Double + +```typescript +// Zero React imports — tools are pure TS +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from "@webpacked-timeline/core"; + +class NoOpTool implements ITool { + readonly id = "no-op" as ToolId; + readonly cursor = "default" as const; + + onPointerDown(_evt: TimelinePointerEvent, _ctx: ToolContext): void {} + + onPointerMove( + _evt: TimelinePointerEvent, + _ctx: ToolContext, + ): ProvisionalState | null { + return null; // No ghost + } + + onPointerUp( + _evt: TimelinePointerEvent, + _ctx: ToolContext, + ): Transaction | null { + return null; // No commit + } +} +``` + +Use `NoOpTool` as a base for unit tests that need an `ITool` but don't care about specific tool logic. + +--- + +## Tool Test Pattern (no React) + +```typescript +import { describe, it, expect } from "vitest"; +import { dispatch } from "@webpacked-timeline/core"; + +it("MoveTool produces correct MOVE_CLIP transaction", () => { + const tool = new MoveTool(); + const ctx = buildTestToolContext(makeState()); + + tool.onPointerDown({ ...evt, frame: toFrame(0) }, ctx); + tool.onPointerMove({ ...evt, frame: toFrame(50) }, ctx); + const tx = tool.onPointerUp({ ...evt, frame: toFrame(100) }, ctx); + + expect(tx).not.toBeNull(); + const result = dispatch(ctx.state, tx!); + expect(result.accepted).toBe(true); + // Check invariants always: + if (result.accepted) { + expect(checkInvariants(result.nextState)).toEqual([]); + } +}); +``` + +--- + +## This file does NOT cover + +- How ToolRouter converts DOM events → `TimelinePointerEvent` (→ `adapter/TOOL_ROUTER.md`) +- How `ProvisionalState` is rendered (→ `ui/COMPONENTS.md`) +- How snap queries work (→ `core/SNAP_INDEX.md`) + +--- + +## Common mistakes to avoid + +- Calling `dispatch()` inside `onPointerMove` — only `onPointerUp` may return a `Transaction` to dispatch +- Storing `Clip` objects on the tool instance — always read from `ctx.state` on each event +- Returning a `Transaction` from `onPointerMove` — it must return `ProvisionalState | null` + +--- + +## NoOpTool — Concrete Reference Implementation + +The canonical no-op tool. Lives in `packages/core/src/tools/registry.ts`. +Every required ITool method is explicitly implemented — no shortcuts. +Use this as a copy-paste base when scaffolding new tools. +Use this as a test double when you need a tool that does nothing. + +```typescript +export const NoOpTool: ITool = { + id: "noop" as ToolId, + shortcutKey: "", + + getCursor: (_ctx) => "default", + getSnapCandidateTypes: () => [], + + onPointerDown: (_evt, _ctx) => {}, + onPointerMove: (_evt, _ctx) => null, + onPointerUp: (_evt, _ctx) => null, + onKeyDown: (_evt, _ctx) => null, + onKeyUp: (_evt, _ctx) => {}, + onCancel: () => {}, +}; +``` + +Rules for using NoOpTool in tests: + +- Spread it to override only the methods you need to test +- Never mutate the exported constant — always spread to a new object +- Use it as the defaultTool when constructing TimelineEngine in tests + +```typescript +// ✅ Correct — spread to override +const spyTool: ITool = { + ...NoOpTool, + id: toToolId("spy"), + onPointerUp: (_evt, _ctx) => buildTestTransaction(), +}; + +// ❌ Wrong — mutates the shared constant +NoOpTool.onPointerUp = () => buildTestTransaction(); +``` + +--- + +## Tool Testing Pattern + +### Unit tests — test the tool in isolation, zero React + +Every tool can be tested with a mock ToolContext. +No React. No engine. No router. Just the tool and a context. + +```typescript +import { describe, it, expect, vi } from "vitest"; +import { toFrame } from "../types/frame"; +import { toToolId } from "../tools/types"; +import { NoOpTool } from "../tools/registry"; +import { checkInvariants } from "../validation/invariants"; +import { applyTransaction } from "../engine/apply"; + +// Minimal mock context — override only what the tool under test uses +function createMockContext(overrides: Partial = {}): ToolContext { + return { + state: createTestState(), + snapIndex: buildSnapIndex(createTestState(), toFrame(0)), + pixelsPerFrame: 5, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / 5)), + trackAtY: (_y) => null, + snap: (frame) => frame, // no-op snap for unit tests + ...overrides, + }; +} + +// Pattern: down → move → up → verify Transaction +it("tool produces valid transaction on pointer up", () => { + const tool = new MyTool(); + const ctx = createMockContext(); + + tool.onPointerDown(makePointerEvent({ x: 100 }), ctx); + tool.onPointerMove(makePointerEvent({ x: 120 }), ctx); + const tx = tool.onPointerUp(makePointerEvent({ x: 120 }), ctx); + + // 1. Transaction must exist + expect(tx).not.toBeNull(); + + // 2. Applying it must produce zero invariant violations — always + const nextState = applyTransaction(ctx.state, tx!); + expect(checkInvariants(nextState)).toHaveLength(0); +}); +``` + +### CRITICAL RULE + +Every tool test that produces a Transaction MUST run: + +```typescript +expect(checkInvariants(applyTransaction(state, tx!))).toHaveLength(0); +``` + +This is not optional. A Transaction that passes the Dispatcher in +isolation can still produce invalid state when applied to edge-case +fixtures. The InvariantChecker is the proof of correctness. + +### Integration tests — test through the router with flushRaf() + +When testing drag behavior that goes through ToolRouter, the rAF +throttle must be flushed synchronously. Use this helper pattern: + +```typescript +// In your test setup file or at the top of integration test files + +let rafCallbacks: FrameRequestCallback[] = []; + +function setupFakeRaf() { + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + rafCallbacks.push(cb); + return rafCallbacks.length; // fake handle + }); +} + +function flushRaf() { + const toRun = [...rafCallbacks]; + rafCallbacks = []; + toRun.forEach((cb) => cb(performance.now())); +} + +function clearRaf() { + rafCallbacks = []; +} +``` + +Usage in integration tests: + +```typescript +beforeEach(() => { + setupFakeRaf(); +}); + +afterEach(() => { + clearRaf(); + vi.unstubAllGlobals(); +}); + +it("drag move is throttled to one engine call per frame", () => { + const router = createToolRouter(engine, () => testLayout); + + // Fire 10 moves without flushing — all queued, none executed + for (let i = 0; i < 10; i++) { + router.onPointerMove(makePointerEvent({ x: i * 10 })); + } + expect(engineSpy.handlePointerMove).not.toHaveBeenCalled(); + + // Flush one frame — exactly one call with the most recent event + flushRaf(); + expect(engineSpy.handlePointerMove).toHaveBeenCalledOnce(); + expect(engineSpy.handlePointerMove).toHaveBeenCalledWith( + expect.objectContaining({ x: 90 }), // most recent event + expect.any(Object), + ); +}); +``` + +### Tool test file location convention + +``` +packages/core/src/tests/tools/ + selection.test.ts ← unit tests, zero React + razor.test.ts + ripple-trim.test.ts + ... +packages/react/src/tests/ + tool-router.test.ts ← integration tests using flushRaf() +``` + +Unit tests live in core. Integration tests live in react. +A tool test that imports anything from `@webpacked-timeline/react` is wrong. + +--- + +## Tool-specific public API methods + +Tools may expose additional public methods beyond the `ITool` interface for UI configuration. +Examples: `setPendingInsert()` on `RippleInsertTool`, `setScrollCallback()` on `HandTool`. + +Rules: + +- **Mid-drag guard required:** Methods that mutate pending-insert/callback state must guard + against mid-drag calls: `if (this.isDragging) return`. Without the guard, async React state + updates can fire these methods mid-drag—causing ghost and Transaction to be built from + different configurations (silent corruption). +- **Configuration, not drag state:** These vars should NOT be reset in `onCancel()`. + They are registered once (e.g., at component mount) and persist across drags. + Only the drag-tracking vars (`isDragging`, `lastX`, etc.) belong in `onCancel()`. +- **Document as "call before drag begins":** Add a JSDoc comment to each such method. + +```typescript +// ✅ Correct — guards mid-drag, preserves across cancels +setPendingInsert(asset: Asset, mediaIn: TimelineFrame, mediaOut: TimelineFrame): void { + if (this.isDragging) return; // mid-drag guard + this.pendingAsset = asset; + this.pendingMediaIn = mediaIn; + this.pendingMediaOut = mediaOut; +} + +onCancel(): void { + // Reset drag vars only — pendingAsset intentionally NOT cleared here + this.isDragging = false; + this.lastX = 0; +} +``` diff --git a/.claude/skills/ui/COMPONENTS.md b/.claude/skills/ui/COMPONENTS.md new file mode 100644 index 0000000..8384c3a --- /dev/null +++ b/.claude/skills/ui/COMPONENTS.md @@ -0,0 +1,125 @@ +> **Load this file when:** Touching any component in `packages/ui` — `Timeline`, `Track`, `Clip`, `TimeRuler`, or `Toolbar`. +> **Do NOT load this file when:** Writing core engine logic, hooks, or tool implementations. + +--- + +# COMPONENTS — @webpacked-timeline/ui Rules + +## Critical Rule + +**No component imports from `@webpacked-timeline/core` directly.** No component calls `dispatch()` directly. State flows in through hooks only. + +```typescript +// ❌ WRONG +import { dispatch, findClipById } from "@webpacked-timeline/core"; + +// ✅ CORRECT +import { useClip, useTimeline } from "@webpacked-timeline/react"; +``` + +--- + +## Component Inventory + +| Component | Hook it uses | What it renders | +| ------------- | --------------------------------- | -------------------------------- | +| `` | `useTimeline()` | Container, TimeRuler, Track list | +| `` | `useTrack(id)` | Clip list for one track | +| `` | `useClip(id)`, `useProvisional()` | Single clip block | +| `` | `useTimeline()`, `usePlayhead()` | Frame tick labels | +| `` | `useActiveTool()` | Tool selection buttons | + +--- + +## Pixel Math (canonical formulas) + +```typescript +// Clip position — use these exact formulas everywhere +const left = clip.timelineStart * pixelsPerFrame; +const width = (clip.timelineEnd - clip.timelineStart) * pixelsPerFrame; + +// TimeRuler tick +const tickLeft = frame * pixelsPerFrame; +``` + +`pixelsPerFrame` comes from the scroll/zoom context — never hardcoded. + +--- + +## Ghost / Provisional Rendering + +During a drag, the `` component overlays the provisional position at reduced opacity: + +```typescript +function ClipBlock({ id }: { id: ClipId }) { + const committed = useClip(id); + const provisional = useProvisional(); + + // resolveClip: provisional overrides committed during drag + const clip = provisional?.clips.find(c => c.id === id) ?? committed; + if (!clip) return null; + + const left = clip.timelineStart * pixelsPerFrame; + const width = (clip.timelineEnd - clip.timelineStart) * pixelsPerFrame; + const isGhost = !!provisional?.clips.find(c => c.id === id); + + return ( +
+ ); +} +``` + +Ghost clips render at **0.6 opacity**, not 0.5 or 0.7. + +--- + +## Pointer Events — Flow to ToolRouter + +Components call `ToolRouter` methods on pointer events, never handle drag logic themselves: + +```typescript +function TrackRow({ id }: { id: TrackId }) { + const { toolRouter } = useTimelineContext(); + + return ( +
toolRouter.handlePointerDown(e.nativeEvent, id)} + onPointerMove={e => toolRouter.handlePointerMove(e.nativeEvent, id)} + onPointerUp={e => toolRouter.handlePointerUp(e.nativeEvent, id)} + /> + ); +} +``` + +--- + +## What Components Do NOT Do + +- ❌ Import from `@webpacked-timeline/core` directly +- ❌ Call `dispatch()` directly +- ❌ Implement drag logic (that belongs in `packages/react/tools/`) +- ❌ Keep a `useState` copy of clip data +- ❌ Call any engine method except through hooks and `toolRouter` + +--- + +## This file does NOT cover + +- How hooks compute clip data (→ `adapter/HOOKS.md`) +- How ToolRouter processes events (→ `adapter/TOOL_ROUTER.md`) +- Pixel-to-frame conversions inside tools (→ `tools/ITOOL_CONTRACT.md`) + +--- + +## Common mistakes to avoid + +- Using `clip.timelineStart / fps` to compute pixel position — the formula is always `timelineStart * pixelsPerFrame`, never divide by fps +- Rendering ghost clips at opacity 0.5 — the spec is **0.6** +- Attaching `onPointerMove` at document level inside a component — attach on the track row and let ToolRouter throttle diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml new file mode 100644 index 0000000..4a67f28 --- /dev/null +++ b/.github/workflows/canary.yml @@ -0,0 +1,38 @@ +# Temporarily disabled +on: + workflow_dispatch: # manual trigger only + +name: Canary Release + +on: + push: + branches: + - main + +jobs: + canary: + name: Publish Canary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v3 + with: + version: 8 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + registry-url: "https://registry.npmjs.org" + + - run: pnpm install --frozen-lockfile + + - run: pnpm test --recursive + + - name: Publish canary + run: | + npx changeset version --snapshot canary + npx changeset publish --tag canary + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab2149d..018d3a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,61 +1,33 @@ +# .github/workflows/ci.yml name: CI on: - pull_request: - branches: [main] push: - branches: [main] + branches: [dev, main] + pull_request: + branches: [dev, main] jobs: - validate: - name: Build & Test + test: runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.28.2 - - - name: Setup Node.js - uses: actions/setup-node@v4 + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + # Uses packageManager from package.json (pnpm@10.28.2) + + - uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build all packages - run: pnpm run build - env: - CI: true - - - name: Run tests - run: pnpm run test - - - name: Verify package outputs - run: | - # Verify all packages built successfully - for pkg in core react ui; do - if [ ! -d "packages/$pkg/dist" ]; then - echo "@timeline/$pkg build failed - no dist directory" - exit 1 - fi - if [ ! -f "packages/$pkg/dist/index.d.ts" ]; then - echo "@timeline/$pkg missing type declarations" - exit 1 - fi - done - - # Verify core internal exports - if [ ! -f "packages/core/dist/internal.d.ts" ]; then - echo "@timeline/core missing internal type declarations" - exit 1 - fi - - echo "All packages built successfully with type declarations" + node-version: 20 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm --filter @webpacked-timeline/core exec tsc --noEmit + + - name: Test + run: pnpm --filter @webpacked-timeline/core test --run + - name: Build + run: pnpm --filter @webpacked-timeline/core build \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2970779 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,45 @@ +# Temporarily disabled +on: + workflow_dispatch: # manual trigger only + +name: Release + +on: + push: + branches: + - main + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: pnpm/action-setup@v3 + with: + version: 8 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + registry-url: "https://registry.npmjs.org" + + - run: pnpm install --frozen-lockfile + + - run: pnpm test --recursive + + - name: Create Release PR or Publish + uses: changesets/action@v1 + with: + publish: pnpm release + title: "chore: version packages" + commit: "chore: version packages" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/API_STABILITY_AUDIT.md b/API_STABILITY_AUDIT.md deleted file mode 100644 index 6a69097..0000000 --- a/API_STABILITY_AUDIT.md +++ /dev/null @@ -1,456 +0,0 @@ -# Timeline API Stability Audit - -**Date:** 2026-02-14 -**Purpose:** Verify that public API boundaries are properly locked and internal changes won't break consumers - ---- - -## Executive Summary - -✅ **API is properly locked down and stable** - -All three packages have clear public API boundaries: -- `@timeline/core` - Public API intentionally defined, internals separated -- `@timeline/react` - Only adapter layer exported -- `@timeline/ui` - Only presentational components exported - -**Key Finding:** Internal changes will NOT break consumers. The architecture supports the claim: "If you remove something internal → users should not break." - ---- - -## Package-by-Package Analysis - -### 1. @timeline/core - -#### Public API Surface (`src/index.ts` → `src/public-api.ts`) - -**Exported:** -```typescript -// Engine -- TimelineEngine (41 public methods) - -// Factory Functions -- createTimeline, createTrack, createClip, createAsset, createTimelineState - -// Frame Utilities -- frame, frameRate, framesToTimecode, framesToSeconds, secondsToFrames - -// Types -- Frame, FrameRate, Timeline, Track, TrackType, Clip, Asset, AssetType -- TimelineState, ValidationResult, ValidationError -- TimelineMarker, ClipMarker, RegionMarker, WorkArea, Marker -- LinkGroup, Group -``` - -**NOT Exported (Internal):** -```typescript -// Internal operations (addClip, moveClip, removeClip, etc.) -// Internal systems (validation, queries, snapping, linking, grouping) -// Internal utilities (ID generation, low-level helpers) -// Transaction primitives (dispatcher, operations) -``` - -**Internal API (`src/internal.ts`):** -- Re-exports public API -- Adds access to operations, systems, utilities -- Clearly documented as "NOT stable" and "may change without notice" -- Used by: tests, UI package (for snapping), advanced integrations - -**Stability Assessment:** -- ✅ Public API is minimal and intentional -- ✅ All editing goes through `TimelineEngine` (high-level API) -- ✅ Internal refactoring possible without breaking changes -- ✅ Clear separation maintained in documentation - -**Consumer Protection:** -```typescript -// Consumer code uses ONLY public API: -import { TimelineEngine, createTimeline, frame } from '@timeline/core'; - -const engine = new TimelineEngine(state); -engine.addClip(trackId, clip); // High-level method -engine.moveClip(clipId, frame(100)); // High-level method -``` - -If we refactor internal `addClip` operation → consumers don't break -If we change dispatcher logic → consumers don't break -If we modify validation system → consumers don't break - -**Only breaking changes:** -- Changing `TimelineEngine` method signatures -- Changing factory function parameters -- Changing exported type shapes - ---- - -### 2. @timeline/react - -#### Public API Surface (`src/index.ts`) - -**Exported:** -```typescript -// Context Provider -- TimelineProvider, TimelineContext, TimelineProviderProps - -// Hooks -- useEngine(): TimelineEngine -- useTimeline(): { state: TimelineState, engine: TimelineEngine } -- useTrack(trackId: string): Track | null -- useClip(clipId: string): Clip | null -``` - -**NOT Exported:** -- Internal hook implementations -- Context implementation details - -**Stability Assessment:** -- ✅ Minimal adapter layer - only 4 hooks + 1 provider -- ✅ All hooks return public types from `@timeline/core` -- ✅ No internal state management exposed -- ✅ Pure delegation to core engine - -**Consumer Protection:** -```typescript -// Consumer code: -import { TimelineProvider, useTimeline, useTrack } from '@timeline/react'; - -function App() { - return ( - - - - ); -} - -function Timeline() { - const { state, engine } = useTimeline(); - return
{state.timeline.name}
; -} -``` - -If we change hook implementation → consumers don't break -If we optimize re-render logic → consumers don't break -If we add internal state → consumers don't break - -**Only breaking changes:** -- Changing hook return types -- Changing provider prop requirements - ---- - -### 3. @timeline/ui - -#### Public API Surface (`src/index.ts`) - -**Exported:** -```typescript -// Components -- Timeline (main container) -- Track (track row component) -- Clip (clip component) -- TimeRuler (time ruler component) - -// Context (internal to UI package) -- TimelineUIContext (exported but documented as internal) -``` - -**NOT Exported:** -- Component internal state -- Component utility functions -- Drag/resize handlers -- Layout calculations - -**Special Case - Internal Import:** -```typescript -// packages/ui/src/timeline/Clip.tsx uses: -import { - findSnapTargets, - calculateSnapExcluding, - type SnapResult -} from '@timeline/core/internal'; -``` - -**Justification:** -- Snapping calculations needed during interactive drag -- Cannot go through `TimelineEngine` (too heavy for real-time) -- Documented in Session notes as "intentional internal imports for interactive features" -- UI package is part of the timeline monorepo (trusted consumer) - -**Stability Assessment:** -- ✅ Only presentational components exported -- ✅ Components take props, render UI, call engine methods -- ✅ No business logic exposed -- ✅ Internal UI state not exposed - -**Consumer Protection:** -```typescript -// Consumer code: -import { Timeline } from '@timeline/ui'; -import { TimelineProvider } from '@timeline/react'; - -function App() { - return ( - - - - ); -} -``` - -If we change Timeline internal layout → consumers don't break -If we refactor Clip drag logic → consumers don't break -If we change styling approach → consumers don't break - -**Only breaking changes:** -- Changing component prop interfaces -- Removing exported components - ---- - -## Boundary Enforcement - -### Package Dependencies - -``` -@timeline/ui - ↓ depends on -@timeline/react - ↓ depends on -@timeline/core -``` - -**Dependency Rules:** -- ✅ UI imports from `@timeline/react` (public API only) -- ✅ UI imports from `@timeline/core` (public API only) -- ⚠️ UI imports from `@timeline/core/internal` (snapping - documented exception) -- ✅ React imports from `@timeline/core` (public API only) -- ✅ Core has no dependencies - -### TypeScript Configuration - -Each package has `tsconfig.json` with proper compilation settings: -- `declaration: true` - Generates `.d.ts` type files -- `declarationMap: true` - Source map for types -- Type exports properly defined in `package.json` - -**Build Output:** -``` -packages/core/dist/ - ├── index.js (public API) - ├── index.d.ts (public types) - ├── internal.js (internal API) - └── internal.d.ts (internal types) -``` - -Consumers importing from `@timeline/core` get `index.d.ts` (public types only). - ---- - -## Testing the Boundary - -### Hypothetical Internal Changes - -#### Scenario 1: Refactor Validation System -```typescript -// BEFORE (internal) -export function validateClip(state, clip) { ... } - -// AFTER (internal) - completely rewrite -export function validateClipV2(state, clip) { ... } -``` - -**Impact:** -- ❌ Internal code breaks (expected, internal tests will catch) -- ✅ Public API unchanged (`TimelineEngine.addClip()` still works) -- ✅ Consumers don't break - ---- - -#### Scenario 2: Change Dispatcher Architecture -```typescript -// BEFORE (internal) -export function dispatch(history, operation) { ... } - -// AFTER (internal) - new architecture -export function dispatchV2(context, command) { ... } -``` - -**Impact:** -- ❌ `TimelineEngine` implementation needs update -- ✅ Public API unchanged (engine methods still return `DispatchResult`) -- ✅ Consumers don't break - ---- - -#### Scenario 3: Optimize Snapping Algorithm -```typescript -// BEFORE (internal) -export function calculateSnap(frame, targets, threshold) { ... } - -// AFTER (internal) - better algorithm -export function calculateSnap(frame, targets, threshold, options?) { ... } -``` - -**Impact:** -- ⚠️ UI package needs update (uses internal snapping) -- ✅ Public API unchanged (consumers don't use snapping directly) -- ✅ External consumers don't break -- ⚠️ Monorepo internal (UI) needs coordination - -**Mitigation:** -- UI is part of monorepo → updated atomically with core -- External consumers never affected - ---- - -#### Scenario 4: Remove Internal Utility -```typescript -// REMOVE this internal utility: -export function generateClipId() { ... } -``` - -**Impact:** -- ❌ Internal tests break (expected) -- ✅ Public API unchanged (consumers use `createClip()` factory) -- ✅ Consumers don't break - ---- - -## Stability Guarantees - -### What WON'T Break Consumers - -✅ Refactoring internal operations -✅ Changing validation logic -✅ Optimizing query systems -✅ Restructuring internal code -✅ Renaming internal functions -✅ Removing internal utilities -✅ Changing dispatcher implementation -✅ Modifying transaction system - -### What WILL Break Consumers (True Breaking Changes) - -❌ Changing `TimelineEngine` method signatures -❌ Removing `TimelineEngine` methods -❌ Changing factory function parameters -❌ Changing exported type shapes -❌ Removing exported utilities (e.g., `frame`, `frameRate`) - -### What Needs Coordination (Monorepo Internal) - -⚠️ Changes to `/internal` exports used by `@timeline/ui` -⚠️ Snapping algorithm changes (used by Clip component) - -**Mitigation:** Monorepo allows atomic updates across packages. - ---- - -## Semantic Versioning Alignment - -### Major Version (Breaking Changes) - -**Require major version bump:** -- Changing `TimelineEngine` public method signatures -- Removing exported functions/types -- Changing exported type shapes incompatibly - -**Do NOT require major version bump:** -- Internal refactoring -- Performance improvements -- Bug fixes to internal systems -- New internal utilities (not exported) - -### Minor Version (New Features) - -**Examples:** -- Adding new `TimelineEngine` methods -- Adding new factory functions -- Adding optional parameters to existing methods (with defaults) -- Exporting new types - -### Patch Version (Bug Fixes) - -**Examples:** -- Fixing validation bugs -- Optimizing internal algorithms -- Fixing memory leaks -- Improving error messages - ---- - -## API Stability Scorecard - -| Criteria | @timeline/core | @timeline/react | @timeline/ui | Status | -|----------|---------------|-----------------|--------------|--------| -| Public API explicitly defined | ✅ | ✅ | ✅ | **PASS** | -| Internal code not exported | ✅ | ✅ | ✅ | **PASS** | -| Internal exports clearly marked | ✅ | N/A | N/A | **PASS** | -| TypeScript types properly exposed | ✅ | ✅ | ✅ | **PASS** | -| Minimal API surface | ✅ | ✅ | ✅ | **PASS** | -| High-level abstractions only | ✅ | ✅ | ✅ | **PASS** | -| Internal changes won't break users | ✅ | ✅ | ✅ | **PASS** | -| Clear documentation | ✅ | ✅ | ⚠️ | **MOSTLY** | - -**Overall Score: 7.5/8 (93.75%)** - ---- - -## Recommendations - -### Immediate Actions: None Required - -The API is already properly locked down and stable. - -### Future Improvements - -1. **Documentation Enhancement** - - Add JSDoc comments to all `TimelineEngine` public methods - - Document breaking change policy in CONTRIBUTING.md - - Add "API Stability" section to main README - -2. **Automated Boundary Enforcement** - - Consider using `@microsoft/api-extractor` to generate API reports - - Add CI check to detect accidental public API changes - - Use `publint` or similar tool to verify package exports - -3. **UI Package Clarity** - - Add comment in `Clip.tsx` explaining why `/internal` import is needed - - Consider moving snapping to a separate internal UI utility layer - -4. **Versioning Policy** - - Document semantic versioning policy for internal vs public changes - - Add CHANGELOG to track API changes - ---- - -## Conclusion - -**The timeline packages have a properly locked public API.** - -✅ **Core claim verified:** "If you remove something internal → users should not break" - -**Evidence:** -1. Public API explicitly defined in `public-api.ts` -2. Internal code segregated in `internal.ts` with warnings -3. High-level `TimelineEngine` is the primary consumer interface -4. React adapter only exposes hooks and provider -5. UI package only exports presentational components -6. TypeScript ensures type safety at boundaries - -**Stability level:** Production-ready - -**Breaking change risk:** Low (only changes to public API methods/types) - -**Refactoring freedom:** High (all internal code can change freely) - -**Architecture quality:** Excellent separation of concerns - ---- - -**Next Steps:** -1. Add API documentation (JSDoc) -2. Consider automated API boundary testing -3. Document versioning policy -4. Ready for external consumers - diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6278aa4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,538 @@ +# Changelog + +All notable changes to the timeline monorepo are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +--- + +## [Unreleased] + +_No unreleased changes._ + +--- + +## [0.0.1] — Phase 0 Complete + +### @webpacked-timeline/core + +#### Added +- FrameRate discriminated union +- TimelineFrame branded type +- Full OperationPrimitive union (25+ ops) +- Transaction + DispatchResult types +- InvariantChecker (9 rules) +- Per-primitive validators +- applyOperation() pure function +- dispatch() atomic with rolling-state validation +- HistoryStack (push, undo, redo, limit eviction) + +--- + +## [0.1.0] — Phase 1 Complete + +### @webpacked-timeline/core + +#### Added +- Tool scaffolding: ITool interface, ToolContext, ToolRegistry +- ProvisionalManager (ghost clip preview) +- TimelineEngine class (EngineSnapshot, subscribe/getSnapshot) +- 8 React hooks (useSyncExternalStore, selector isolation) +- ToolRouter (rAF throttle, onPointerLeave Option Y) + +--- + +## [0.2.0] — Phase 2 Complete + +### @webpacked-timeline/core + +#### Added +- SelectionTool (click, drag, multi-drag, rubber-band) +- RazorTool (slice with Shift+click all-tracks mode) +- RippleTrimTool (start/end edges, downstream shift) +- RollTrimTool (2-op transaction, precomputed clamp) +- SlipTool (media-space drag, SET_MEDIA_BOUNDS) +- RippleDeleteTool +- RippleInsertTool +- HandTool + +#### Changed +- Dispatcher: rolling-state validation (validate-then-apply + per op, not validate-all then apply-all) +- MOVE_CLIP ordering rule: +delta sorts R→L, -delta sorts L→R + +--- + +## [0.3.0] — Phase 3 Complete — February 27, 2025 + +### @webpacked-timeline/core + +#### Added + +**Marker System** +- `Marker` discriminated union: point and range variants +- Optional `clipId` field for clip-linked markers +- Clip-linked markers auto-shift on `MOVE_CLIP` (same delta) +- `MarkerId` branded type and `toMarkerId()` factory +- `MarkerScope` union: 'timeline' | 'clip' +- Primitives: `ADD_MARKER`, `MOVE_MARKER`, `DELETE_MARKER` +- Validators for all marker primitives +- Invariants: `checkMarkerBounds` (bounds + range endFrame > startFrame) + +**In/Out Points** +- `inPoint` and `outPoint` optional fields on `TimelineState` +- Primitives: `SET_IN_POINT`, `SET_OUT_POINT` +- Invariant: `checkInOutPoints` (in < out, both within duration) + +**Beat Grid** +- `BeatGrid` entity on `TimelineState` (bpm, timeSignature, offset) +- Primitive: `ADD_BEAT_GRID`, `REMOVE_BEAT_GRID` +- Beat grid frames injected into `buildSnapIndex()` automatically +- Invariant: `checkBeatGrid` (single beat grid, bpm > 0) + +**Generator Entity** +- `GeneratorType` union: 'solid' | 'bars' | 'countdown' | 'text' +- `Generator` entity type +- `GeneratorAsset` type + `createGeneratorAsset()` factory +- `Asset` union: `FileAsset | GeneratorAsset` +- Primitive: `INSERT_GENERATOR` + +**Caption System** +- `Caption` entity: text, startFrame, endFrame, language, + style, burnIn +- `CaptionStyle` type with font, color, alignment fields +- `defaultCaptionStyle` exported constant +- `captions` array on `Track` (sorted by startFrame) +- Primitives: `ADD_CAPTION`, `EDIT_CAPTION`, `DELETE_CAPTION` +- `EDIT_CAPTION` supports partial updates (merge pattern) +- Validators: overlap detection on ADD_CAPTION +- Invariants: `checkCaptionBounds`, caption overlap check + +**Marker Search API** +- `findMarkersByColor(state, color)` — exact match +- `findMarkersByLabel(state, label)` — case-insensitive substring + +**SRT/VTT Import** +- `parseSRT(raw, fps, options?)` — full SRT parser +- `parseVTT(raw, fps, options?)` — full WebVTT parser +- Both strip formatting tags, handle multi-line text +- `subtitleImportToOps(captions, trackId)` — pure op builder +- `SRTParseOptions` / `VTTParseOptions` types exported + +#### Changed +- `TimelineState` extended: `markers`, `beatGrid`, + `inPoint`, `outPoint` (all optional, backward compatible) +- `Track` extended: `captions` array (default empty) +- `createTimeline()` accepts new optional fields +- `createTrack()` accepts optional `captions` +- `buildSnapIndex()` now includes beat grid snap points + +#### Fixed +- `checkMarkerBounds`: point marker frame and range + frameStart now validated against `[0, durationFrames)` +- `checkInOutPoints`: both points validated within + timeline duration bounds + +#### Tests +- Phase 3 adds 33+ tests across all new subsystems +- All new state-producing tests call `checkInvariants()` +- Subtitle import tests are pure (no state, no dispatch) + +--- + +## [0.8.0] — Phase U Complete — February 27, 2025 + +### Added (@webpacked-timeline/ui) + +**CLI** +- `npx @webpacked-timeline/ui add ` — copies + components into your project +- `npx @webpacked-timeline/ui add --preset=` — + install curated bundles +- `npx @webpacked-timeline/ui list` — show all components + with install status +- `npx @webpacked-timeline/ui diff ` — + show changes vs registry +- `npx @webpacked-timeline/ui update ` — + update with diff preview + confirmation +- Theme install: `add theme --theme=dark-pro` +- Manifest tracking: `.timeline-ui.json` +- Registry dependency resolution (topological) + +**Theme system** +- `dark-pro` theme (DaVinci-inspired default) +- `light` theme (Final Cut Pro-inspired) +- 45+ CSS custom property tokens +- Token naming: `--tl-{component}-{property}-{state}` + +**Shared utilities** (copied into _shared/) +- `time.ts` — frameToPx, pxToFrame, + frameToTimecode, rulerTickInterval +- `geometry.ts` — Rect, clamp, normalizeRect, + rectsOverlap +- `use-drag.ts` — useDrag() hook with threshold +- `use-snap.ts` — useSnap() hook via engine + +**Components** + +Tier 1 — Core: + timeline-root, track, clip, playhead, ruler + +Tier 2 — Editing: + toolbar, zoom-bar + +Tier 3 — Media: + waveform (canvas), thumbnail-strip, clip-label + +Tier 4 — Advanced: + effect-lane, keyframe-diamond, + transition-handle + +Tier 5 — Markers: + marker-pin, marker-range, in-out-handles + +**Rendering contract** +- All components read engine via TimelineProvider +- Zero hardcoded colors (CSS vars only) +- Render prop escape hatches on all content + components +- No local useState for canonical engine values +- All mutations via engine.dispatch() only + +### Tests +- 113 UI tests (CLI + components) +- 1152+ total tests across monorepo +- Registry integrity: all registered files + verified to exist + +--- + +## [0.7.0] — Phase R Complete — February 27, 2025 + +### Added (@webpacked-timeline/react) + +**TimelineEngine** +- Full orchestrator wiring Dispatcher, + PlaybackEngine, ToolRouter, SnapIndexManager, + TrackIndex, HistoryStack, KeyboardHandler +- `TimelineEngineOptions`: pipeline, clock, + historyLimit, compression, tools, callbacks +- `EngineSnapshot`: state, provisional, playhead, + history, trackIds, cursor, change +- `DEFAULT_PLAYHEAD_STATE` for edit-only mode +- Playback events wired to snapshot rebuild + (usePlayheadFrame updates every rAF tick) +- `handlePointerLeave` with Option Y pattern +- `undo()` / `redo()` with full state sync + +**Hooks (13 total)** +- `useTimeline(engine)` — timeline metadata +- `useTrackIds(engine)` — stable track list +- `useTrack(engine, id)` — single track +- `useClip(engine, id)` — single clip, + provisional-first lookup +- `useClips(engine, trackId)` — track clips +- `useMarkers(engine)` — timeline markers +- `useHistory(engine)` — canUndo/canRedo, + stable object reference +- `useActiveToolId(engine)` — active tool +- `useCursor(engine)` — CSS cursor string +- `useProvisional(engine)` — ghost clip state +- `usePlayheadFrame(engine)` — current frame, + updates every rAF tick during playback +- `useIsPlaying(engine)` — playback state +- `useChange(engine)` — StateChange diff +- `usePlayhead(engine)` — full playhead state +- `usePlayheadEvent(engine, type, handler)` +- `useVirtualWindow(engine, w, s, ppf)` +- `useVisibleClips(engine, window)` +- `useToolRouter(engine, options)` + +**ToolRouter** +- `createToolRouter()` — React pointer/key + event → TimelinePointerEvent/KeyEvent +- rAF throttle on onPointerMove only +- Option Y: pointerLeave → handlePointerUp + + handlePointerLeave + clearProvisional +- `useToolRouter()` hook with stable ref + +**Selector isolation** +- Proven: updating clip A does not re-render + component watching clip B (toBe test) +- `historyFlags` cache prevents spurious + re-renders on unchanged undo/redo state +- `stableTrackIds` only recreates on actual + track list change + +### Tests +- Phase R adds 97 react tests +- 1039 total tests across core + react +- Integration suite: 27 tests covering + full dispatch→hook→re-render round-trips + +--- + +## [0.6.0] — Phase 7 Complete — February 27, 2025 + +### Added + +**Clip Interval Tree** +- `IntervalTree` — centered interval tree, + O(log n + k) point queries +- `TrackIndex` — per-state clip index, + build() + query() + invalidate() +- `ClipEntry` type +- `getClipsAtFrame()` and `resolveFrame()` accept + optional `TrackIndex` for fast lookup +- `PlaybackEngine` uses `TrackIndex` automatically + +**Virtual Rendering** +- `VirtualWindow` type (startFrame, endFrame, + pixelsPerFrame) +- `VirtualClipEntry` type (clip, isVisible, left, width) +- `getVisibleClips()` — all clips with visibility flag +- `getVisibleFrameRange()` — viewport → frame range + +**SnapIndex Microtask Debounce** +- `SnapIndexManager` — debounces snap index rebuilds + via queueMicrotask; N synchronous calls → 1 rebuild +- `rebuildSync()` for tests and initial build +- `PlaybackEngine` uses `SnapIndexManager` + +**State Diff** +- `StateChange` type — trackIds, clipIds, markers, + timeline, playhead flags +- `diffStates(prev, next)` — reference equality diff +- `EMPTY_STATE_CHANGE` constant + +**Transaction Compression** +- `CompressionPolicy` union: none | last-write-wins +- `CompressibleOpType` — 10 rapid-fire op types +- `DEFAULT_COMPRESSION_POLICY` (300ms window) +- `NO_COMPRESSION` constant +- `TransactionCompressor` — clock-injectable +- `HistoryStack.pushWithCompression()` — replaces + last entry within compression window +- `HistoryStack.resetCompression()` + +**Named Checkpoints** +- `HistoryStack.saveCheckpoint(name)` +- `HistoryStack.restoreCheckpoint(name)` +- `HistoryStack.listCheckpoints()` +- `HistoryStack.clearCheckpoint(name)` + +**History Persistence** +- `HistoryStack.serialize()` — JSON with versioning +- `HistoryStack.deserialize()` — static, rebuilds + from JSON, runs migrate() + checkInvariants() +- `HistoryStack.softLimitWarning()` — true at 80% + +**Worker Contracts** +- `WaveformRequest`, `WaveformPeak`, `WaveformResult` +- `WaveformWorkerMessage`, `WaveformWorkerResponse` +- `ThumbnailPriority`, `ThumbnailQueueEntry` +- `ThumbnailWorkerMessage`, `ThumbnailWorkerResponse` +- `ThumbnailCache` — LRU cache, configurable size +- `ThumbnailQueue` — priority queue, FIFO tiebreak + +**Tools** +- `SlideTool` (shortcut Y) — slide clip on timeline, + neighbors trim to compensate, no gap created +- `ZoomTool` (shortcut Z) — drag or +/-/0 keys, + exponential feel, zero dispatch +- `createZoomTool(options)` factory +- `ZoomToolOptions` type + +### Performance (verified by benchmarks) +- 40 tracks / 200 clips: buildLargeState < 500ms +- checkInvariants() on 200 clips < 50ms +- getClipsAtFrame() with TrackIndex ×1000 < 100ms +- serializeTimeline() on large state < 100ms +- deserializeTimeline() on large state < 200ms + +### Tests +- Phase 7 adds 116 tests across core +- 10 benchmark tests (performance gate) +- 15 invariant audit tests (correctness gate) +- 1 API surface test (38 export checks) +- 942 total tests, 0 tsc errors + +--- + +## [0.5.0] — Phase 6 Complete — February 27, 2025 + +### Added + +**PlayheadController** +- `PlayheadController` class — rAF loop, clock injection +- `Clock` abstraction: `browserClock`, `nodeClock`, + `createTestClock()` +- `PlayheadState`: currentFrame, isPlaying, playbackRate, + quality, durationFrames, fps, loopRegion, + prerollFrames, postrollFrames +- `PlayheadEventType` union: play | pause | seek | loop | + frame-dropped | ended | loop-point | state +- `PlaybackQuality` union: full | half | quarter | proxy +- Frame drop detection (wholeFrames > 2 → cap + emit) +- `destroy()` for cleanup + +**Pipeline Contracts** +- `VideoDecoder`, `AudioDecoder` — host-implemented +- `VideoFrameRequest`, `AudioChunkRequest` +- `VideoFrameResult`, `AudioChunkResult` +- `Compositor`, `CompositeRequest`, `CompositeResult` +- `CompositeLayer`, `ResolvedCompositeRequest` +- `ThumbnailProvider`, `ThumbnailRequest`, + `ThumbnailResult` +- `PipelineConfig` — registry for host implementations + +**Frame Resolver** +- `resolveFrame()` — builds CompositeRequest from state +- `getClipsAtFrame()` — clips visible at a frame +- `mediaFrameForClip()` — timeline→media frame conversion +- `findNextClipBoundary()` / `findPrevClipBoundary()` +- `findNextMarker()` / `findPrevMarker()` +- `findClipById()` + +**PlaybackEngine** +- Orchestrates PlayheadController + pipeline +- `play()`, `pause()`, `seekTo()`, `setPlaybackRate()`, + `setQuality()`, `setLoopRegion()`, `setPreroll()`, + `setPostroll()` +- `seekToStart()`, `seekToEnd()` +- `seekToNextClipBoundary()`, `seekToPrevClipBoundary()` +- `seekToNextMarker()`, `seekToPrevMarker()` +- `renderFrame()` — decode + composite pipeline +- `updateState()` — sync with edit engine +- `getCurrentTimelineState()` + +**Keyboard Contract** +- `KeyboardHandler` — DOM-free, accepts + `TimelineKeyEvent` +- `DEFAULT_KEY_BINDINGS` — Space, J/K/L, arrows, + Home/End, I/O, Q +- J/K/L jog-shuttle with speed levels (1x/2x/4x, + reverse) +- `KeyboardHandlerOptions`: custom bindings, + onMarkIn, onMarkOut, getTimelineState +- `toggle-loop` action wired to in/out points + +**Loop Region** +- `LoopRegion` type: startFrame, endFrame (exclusive) +- `setLoopRegion()`, `setPreroll()`, `setPostroll()` +- Loop wraps at endFrame + postrollFrames +- play() seeks to startFrame - prerollFrames on entry +- 'loop-point' event on wrap + +**React Hooks (@webpacked-timeline/react)** +- `usePlayhead(engine)` — useSyncExternalStore, + stable action callbacks +- `usePlayheadEvent(engine, type, handler)` — + event subscription without re-renders +- `UsePlayheadResult` type exported + +### Tests +- Phase 6 adds 121 tests across core + react +- All clock-dependent tests use createTestClock() +- Zero DOM dependencies in any core file + +--- + +## [0.4.0] — Phase 4 Complete — February 27, 2025 + +### @webpacked-timeline/core + +#### Added + +**Effect System** +- `Effect` entity: effectType, enabled, renderStage, + params, keyframes +- `EffectId` branded type and `toEffectId()` factory +- `RenderStage` union: preComposite | postComposite | output +- `EffectParam` type (key, value) +- `createEffect()` factory (defaults: enabled true, + renderStage preComposite) +- Primitives: `ADD_EFFECT`, `REMOVE_EFFECT`, + `REORDER_EFFECT`, `SET_EFFECT_ENABLED`, `SET_EFFECT_PARAM` +- Invariant: `checkEffects` (renderStage validity, + keyframe order, no duplicate keyframe frames) + +**Keyframe System** +- `Keyframe` entity: frame, value, easing +- `KeyframeId` branded type and `toKeyframeId()` factory +- `EasingCurve` discriminated union: + Linear | Hold | EaseIn | EaseOut | EaseBoth | BezierCurve +- `LINEAR_EASING` and `HOLD_EASING` constants +- `AnimatableProperty` type (value + keyframes[]) +- `createAnimatableProperty()` factory +- Primitives: `ADD_KEYFRAME`, `MOVE_KEYFRAME`, + `DELETE_KEYFRAME`, `SET_KEYFRAME_EASING` +- Keyframes auto-sorted by frame on ADD and MOVE + +**Clip Transform** +- `ClipTransform` type: positionX/Y, scaleX/Y, rotation, + opacity, anchorX/Y (all AnimatableProperty) +- `DEFAULT_CLIP_TRANSFORM` constant +- Primitive: `SET_CLIP_TRANSFORM` (Partial merge) + +**Audio Properties** +- `AudioProperties` type: gain, pan (AnimatableProperty), + mute, channelRouting, normalizationGain +- `ChannelRouting` union: stereo | mono | left | right +- `DEFAULT_AUDIO_PROPERTIES` constant +- Primitive: `SET_AUDIO_PROPERTIES` (Partial merge) +- Validator: pan.value in [-1,1], + normalizationGain >= 0 + +**Transition System** +- `Transition` entity: type, durationFrames, alignment, + easing, params +- `TransitionId` branded type and `toTransitionId()` +- `TransitionAlignment` union: + centerOnCut | endAtCut | startAtCut +- `createTransition()` factory +- Primitives: `ADD_TRANSITION`, `DELETE_TRANSITION`, + `SET_TRANSITION_DURATION`, `SET_TRANSITION_ALIGNMENT` +- Invariant: `checkTransitions` (duration > 0, + valid alignment) + +**Track Groups** +- `TrackGroup` entity: label, trackIds, collapsed +- `TrackGroupId` branded type and `toTrackGroupId()` +- `createTrackGroup()` factory +- Primitives: `ADD_TRACK_GROUP`, `DELETE_TRACK_GROUP` +- ADD clears groupId on tracks when group deleted +- Invariant: `checkTrackGroups` (no orphaned groupId refs) + +**Link Groups** +- `LinkGroup` entity: clipIds (min 2) +- `LinkGroupId` branded type and `toLinkGroupId()` +- `createLinkGroup()` factory +- Primitives: `LINK_CLIPS`, `UNLINK_CLIPS` +- Invariant: `checkLinkGroups` (min 2 clips, + all exist, no clip in two groups) + +**Track Properties** +- `blendMode` and `opacity` optional fields on Track +- Primitives: `SET_TRACK_BLEND_MODE`, `SET_TRACK_OPACITY` +- Validator: opacity in [0,1] + +**Tools** +- `TransitionTool` (shortcut T): drag clip right edge + to create/resize transition, click transition zone + to delete +- `KeyframeTool` / Pen tool (shortcut P): click effect + lane to add keyframe, drag to move, Delete to remove + +#### Changed +- `Clip` extended: optional effects, transform, audio, + transition fields (all backward compatible) +- `Track` extended: optional blendMode, opacity, groupId +- `Timeline` extended: optional trackGroups, linkGroups + +#### Tests +- Phase 4 adds 68 tests across effects, keyframes, + transforms, audio, transitions, groups, and tools +- All state-producing tests call checkInvariants() + +--- diff --git a/CI_CONFIGURATION.md b/CI_CONFIGURATION.md deleted file mode 100644 index 41e8e71..0000000 --- a/CI_CONFIGURATION.md +++ /dev/null @@ -1,408 +0,0 @@ -# CI/CD Configuration - -**Date:** 2026-02-14 -**Purpose:** Minimal, strict CI workflow for Timeline monorepo - ---- - -## Overview - -GitHub Actions workflow that validates every pull request and push to main branch. - -**Philosophy:** Minimal but strict. No unnecessary jobs. Fail fast on any error. - ---- - -## Workflow Configuration - -**File:** `.github/workflows/ci.yml` - -### Triggers - -- **Pull Requests** targeting `main` branch -- **Pushes** to `main` branch - -### Jobs - -#### `validate` (Single Job) - -Runs on `ubuntu-latest` with Node.js 20 (LTS). - -**Steps:** - -1. **Checkout** - Clone repository -2. **Setup pnpm** - Use version 10.28.2 (matches project) -3. **Setup Node.js** - Use Node 20 with pnpm cache -4. **Install dependencies** - `pnpm install --frozen-lockfile` (strict) -5. **Build all packages** - `pnpm run build` (via Turbo) -6. **Run tests** - `pnpm run test` (all test suites) -7. **Verify outputs** - Check dist directories and type declarations - ---- - -## What Gets Validated - -### 1. Dependencies Installation - -```bash -pnpm install --frozen-lockfile -``` - -**Fails if:** -- `pnpm-lock.yaml` is out of sync -- Dependencies can't be installed -- Network issues - -### 2. TypeScript Build - -```bash -pnpm run build -``` - -**Builds:** -- `@timeline/core` → `dist/` with `index.d.ts` + `internal.d.ts` -- `@timeline/react` → `dist/` with `index.d.ts` -- `@timeline/ui` → `dist/` with `index.d.ts` -- `@timeline/demo` → `dist/` (Vite build) - -**Fails if:** -- TypeScript compilation errors -- Missing source files -- Type errors in any package -- Build script errors - -### 3. Test Execution - -```bash -pnpm run test -``` - -**Runs:** -- Edge case tests (1000+ clip scenarios) -- Stress tests (large-scale operations) -- Phase 2 tests (markers, linking, grouping, ripple, etc.) - -**Test Suites:** -- `packages/core/src/__tests__/edge-case-tests.ts` (10 tests) -- `packages/core/src/__tests__/stress-tests.ts` (7 tests) -- `packages/core/src/__tests__/phase2-tests.ts` (23 tests) - -**Total:** 40 tests covering: -- Pathological scenarios (1000 clips, deep link chains) -- System stability (large-scale operations) -- Core functionality (snapping, linking, grouping, markers, ripple) - -**Fails if:** -- Any test assertion fails -- Test suite throws error -- Performance regression (tests time out) - -### 4. Output Verification - -```bash -# Verify dist directories exist -packages/core/dist/ -packages/react/dist/ -packages/ui/dist/ - -# Verify type declarations exist -packages/core/dist/index.d.ts -packages/core/dist/internal.d.ts -packages/react/dist/index.d.ts -packages/ui/dist/index.d.ts -``` - -**Fails if:** -- Any dist directory missing -- Type declaration files missing -- Build produced no output - ---- - -## Build Configuration - -### Turbo Configuration - -**File:** `turbo.json` - -```json -{ - "tasks": { - "build": { - "dependsOn": ["^build"], - "outputs": ["dist/**"] - }, - "test": { - "dependsOn": ["build"], - "outputs": [] - } - } -} -``` - -**Build order:** -1. `@timeline/core` builds first (no dependencies) -2. `@timeline/react` builds after core -3. `@timeline/ui` builds after react -4. `@timeline/demo` builds after ui -5. Tests run after all builds complete - -### Test Configuration - -**Package:** `@timeline/core` - -```json -{ - "scripts": { - "test": "tsx src/__tests__/edge-case-tests.ts && tsx src/__tests__/stress-tests.ts && tsx src/__tests__/phase2-tests.ts" - } -} -``` - -Tests run with `tsx` (TypeScript execution engine) for direct `.ts` file execution. - ---- - -## What Is NOT Included - -**Deliberately excluded (minimal approach):** - -❌ **ESLint** - No linter configured -❌ **Prettier** - No formatter checks -❌ **Coverage reports** - Not needed for CI -❌ **Deploy steps** - Not a deployment workflow -❌ **Separate test job** - Included in main job -❌ **Matrix builds** - Single Node version sufficient -❌ **Docker** - Not needed -❌ **Security scans** - Future consideration -❌ **Artifacts upload** - Build outputs not needed - -**Rationale:** Keep CI fast and focused. Only check what matters: builds and tests. - ---- - -## Failure Scenarios - -### Scenario 1: TypeScript Error - -**Example:** -```typescript -// Typo in code -engine.addCliip(trackId, clip); -``` - -**Result:** -- Build step fails -- TypeScript reports error -- CI status: ❌ Failed -- PR cannot merge - -### Scenario 2: Test Failure - -**Example:** -```typescript -// Logic bug -assert(result === expected, 'Values should match'); -// result: 100, expected: 101 -``` - -**Result:** -- Test step fails -- Error message printed -- CI status: ❌ Failed -- PR cannot merge - -### Scenario 3: Missing Dependency - -**Example:** -```bash -# Package.json updated but lockfile not committed -``` - -**Result:** -- Install step fails -- `--frozen-lockfile` flag catches mismatch -- CI status: ❌ Failed -- PR cannot merge - -### Scenario 4: Build Output Missing - -**Example:** -```typescript -// tsup misconfigured, no dist output -``` - -**Result:** -- Verify step fails -- Missing dist directory detected -- CI status: ❌ Failed -- PR cannot merge - ---- - -## Performance - -### Typical Run Time - -**On cache hit (subsequent runs):** -- Install: ~5s (cached) -- Build: ~3s (cached) -- Test: ~3s -- Verify: ~1s -- **Total: ~12s** - -**On cache miss (first run):** -- Install: ~30s -- Build: ~15s -- Test: ~3s -- Verify: ~1s -- **Total: ~50s** - -### Optimization - -- ✅ pnpm cache enabled -- ✅ Node modules cached by GitHub Actions -- ✅ Turbo build cache enabled -- ✅ Single job (no parallelization overhead) -- ✅ Minimal steps - ---- - -## Maintenance - -### When to Update - -**Update Node version:** -```yaml -node-version: '20' # LTS - update yearly -``` - -**Update pnpm version:** -```yaml -version: 10.28.2 # Match packageManager in package.json -``` - -**Add new packages:** -```bash -# Verify step automatically includes new packages/*/dist -# No CI changes needed -``` - -**Add new tests:** -```json -// packages/core/package.json -"test": "tsx src/__tests__/new-test.ts && ..." -``` - -### Common Issues - -**Issue:** "pnpm not found" -**Fix:** Check pnpm/action-setup@v4 version - -**Issue:** "Missing dependencies" -**Fix:** Run `pnpm install` locally, commit pnpm-lock.yaml - -**Issue:** "Build failed but works locally" -**Fix:** Check Node version mismatch (CI uses Node 20) - -**Issue:** "Tests pass locally but fail in CI" -**Fix:** Check for timing issues, file system differences, or environment variables - ---- - -## Integration with GitHub - -### Branch Protection Rules (Recommended) - -Configure in repository settings: - -``` -Settings → Branches → Branch protection rules → main - -✅ Require status checks to pass before merging - ✅ Build & Test (ci.yml) -✅ Require branches to be up to date before merging -``` - -### Pull Request Flow - -1. Developer creates PR -2. CI runs automatically -3. PR shows status: ✅ All checks passed or ❌ Some checks failed -4. Cannot merge until all checks pass -5. On merge, CI runs again on main branch - -### Status Badge (Optional) - -Add to README.md: - -```markdown -[![CI](https://github.com/username/timeline/actions/workflows/ci.yml/badge.svg)](https://github.com/username/timeline/actions/workflows/ci.yml) -``` - ---- - -## Comparison to Alternatives - -### What We Chose vs Alternatives - -**Single job vs Multiple jobs:** -- ✅ Faster (no inter-job overhead) -- ✅ Simpler (easier to debug) -- ❌ No parallel execution (not needed for this size) - -**Node 20 only vs Matrix (18, 20, 22):** -- ✅ Faster (fewer runs) -- ✅ Sufficient (monorepo uses Node 20) -- ❌ Doesn't test other versions (acceptable) - -**tsx vs Jest/Vitest:** -- ✅ Simpler (no test runner config) -- ✅ Faster (direct TypeScript execution) -- ❌ Less features (no mocking, etc.) (not needed) - -**Manual verification vs API Extractor:** -- ✅ Simpler (bash script) -- ✅ No dependencies -- ❌ Less sophisticated (acceptable for now) - ---- - -## Future Enhancements (Not Needed Now) - -Potential additions if requirements grow: - -1. **ESLint integration** - Add lint step if code style matters -2. **Coverage reports** - Add if test coverage tracking needed -3. **Performance benchmarks** - Add if performance regression matters -4. **API documentation generation** - Add if public docs needed -5. **Semantic release** - Add if automated versioning needed -6. **Dependabot** - Add if automated dependency updates wanted - -**Current stance:** Don't add unless actually needed (YAGNI principle). - ---- - -## Conclusion - -**CI workflow is minimal, strict, and sufficient.** - -✅ Validates builds (TypeScript compilation) -✅ Validates tests (40 test cases) -✅ Validates outputs (dist + type declarations) -✅ Fails fast on any error -✅ No unnecessary complexity - -**Status:** Production-ready - -**Maintenance:** Low (only update Node/pnpm versions) - -**Reliability:** High (catches all critical errors) - ---- - -**Next Steps:** -1. Commit `.github/workflows/ci.yml` -2. Push to trigger first CI run -3. Configure branch protection rules -4. Add CI badge to README (optional) diff --git a/FEATURE_COVERAGE.md b/FEATURE_COVERAGE.md deleted file mode 100644 index ad6c872..0000000 --- a/FEATURE_COVERAGE.md +++ /dev/null @@ -1,521 +0,0 @@ -# Timeline Feature Coverage Report - -**Generated:** 2026-02-13 -**Last Updated:** 2026-02-13 (Session 4 Complete) -**Purpose:** Document UI exposure of core engine capabilities - ---- - -## Executive Summary - -This report documents the current state of UI feature exposure for the Timeline engine. The goal was to expose existing core engine capabilities through UI components **without modifying engine architecture**. - -### Key Findings: -- ✅ **All core editing operations fully functional** - Cut, copy, paste, delete, selection, ripple, insert -- ✅ **All track controls working** - Mute, solo, lock, height adjustment -- ✅ **Interactive markers fully functional** - Add timeline markers via UI and keyboard (M key) -- ✅ **Work area fully functional** - Set from selection/playhead, clear via UI button -- ✅ **Undo/redo fully functional** - Keyboard shortcuts and toolbar buttons -- ✅ **Ripple and insert modes fully functional** - Delete and paste operations respect editing mode -- ⚠️ **Group/link UI not implemented** - Engine methods exist but UI not wired up yet - ---- - -## Feature Categories - -### 1. Clip Operations - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Select clip | ✅ | ✅ | ✅ | **Fully Working** | -| Multi-select (Cmd+Click) | ✅ | ✅ | ✅ | **Fully Working** | -| Select all (Cmd+A) | ✅ | ✅ | ✅ | **Fully Working** | -| Deselect (Escape) | ✅ | ✅ | ✅ | **Fully Working** | -| Delete selected (Delete) | ✅ | ✅ | ✅ | **Fully Working** | -| Copy (Cmd+C) | ✅ | ✅ | ✅ | **Fully Working** | -| Paste (Cmd+V) | ✅ | ✅ | ✅ | **Fully Working** | -| Move clip (drag) | ✅ | ✅ | ✅ | **Fully Working** | -| Trim clip (edge drag) | ✅ | ✅ | ✅ | **Fully Working** | - -**Files:** `packages/ui/src/timeline/Timeline.tsx:44-111`, `packages/ui/src/timeline/Clip.tsx` - ---- - -### 2. Track Operations - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Track lock toggle | ✅ | ✅ | ✅ | **Fully Working** | -| Track mute toggle | ✅ | ✅ | ✅ | **Fully Working** | -| Track solo toggle (S button) | ✅ | ✅ | ✅ | **Fully Working** | -| Track height adjust (+/- buttons) | ✅ | ✅ | ✅ | **Fully Working** | - -**Files:** `packages/ui/src/timeline/Track.tsx`, `packages/core/src/types/track.ts` - -**Notes:** -- Solo button shows green when active -- Height range: 40-200px (default: 56px) -- All operations support undo/redo - ---- - -### 3. Editing Modes - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Normal mode | ✅ | ✅ | ✅ | **Fully Working** | -| Insert mode (paste shifts clips) | ✅ | ✅ | ✅ | **Fully Working** | -| Ripple mode indicator | ✅ | ✅ | ✅ | **Fully Working** | -| Ripple delete (delete shifts clips) | ✅ | ✅ | ✅ | **Fully Working** | -| Ripple trim | ✅ | ✅ | ❌ | Not implemented | - -**Files:** `packages/ui/src/timeline/Timeline.tsx:68-84, 99-117, 337-358` - -**Implementation:** -```typescript -// Ripple delete when Delete key pressed in ripple mode: -if (editingMode === 'ripple') { - engine.rippleDelete(clipId); -} else { - engine.removeClip(clipId); -} - -// Insert edit when pasting in insert mode: -if (editingMode === 'insert') { - engine.insertEdit(trackId, newClip, playhead); -} else { - engine.addClip(trackId, newClip); -} -``` - -**Visual Indicators:** -- ⚡ Yellow "Ripple" badge when ripple mode active -- ➕ Blue "Insert" badge when insert mode active - ---- - -### 4. Snapping System - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Snapping toggle | ✅ | ✅ | ✅ | **Fully Working** | -| Snap to clip edges | ✅ | ✅ | ✅ | **Fully Working** | -| Snap to playhead | ✅ | ✅ | ✅ | **Fully Working** | -| Snap to markers | ✅ | ✅ | ✅ | **Fully Working** | -| Snap to work area | ✅ | ✅ | ✅ | **Fully Working** | -| Visual snap indicator | ✅ | ✅ | ✅ | **Fully Working** | - -**Files:** `packages/ui/src/timeline/Timeline.tsx:221-234, 281-286` - -**Notes:** -- Snapping system includes markers and work area boundaries -- Visual feedback shows snap line at snap position - ---- - -### 5. Markers & Work Area - -#### 5.1 Timeline Markers - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Display timeline markers | ✅ | ✅ | ✅ | **Fully Working** | -| Render marker flags | ✅ | N/A | ✅ | **Fully Working** | -| Render marker labels | ✅ | N/A | ✅ | **Fully Working** | -| Color-coded markers | ✅ | N/A | ✅ | **Fully Working** | -| Add timeline marker (M key) | ✅ | ✅ | ✅ | **Fully Working** | -| Add timeline marker (button) | ✅ | ✅ | ✅ | **Fully Working** | -| Remove timeline marker | ✅ | ✅ | ❌ | Not implemented | -| Edit marker properties | ✅ | ✅ | ❌ | Not implemented | - -**Files:** `packages/ui/src/timeline/Timeline.tsx:144-158, 363-378` - -**Implementation:** -```typescript -// M key shortcut: -case 'm': -case 'M': - e.preventDefault(); - const marker = { - id: `marker-${Date.now()}`, - type: 'timeline' as const, - frame: playhead, - label: `Mark ${playhead}`, - color: '#10b981', - }; - engine.addTimelineMarker(marker); - break; - -// 🚩 Marker button also triggers same code -``` - -#### 5.2 Region Markers - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Display region markers | ✅ | ✅ | ✅ | **Fully Working** | -| Render region backgrounds | ✅ | N/A | ✅ | **Fully Working** | -| Render region labels | ✅ | N/A | ✅ | **Fully Working** | -| Add region marker | ✅ | ✅ | ❌ | Not implemented | -| Remove region marker | ✅ | ✅ | ❌ | Not implemented | -| Resize region marker | ✅ | ✅ | ❌ | Not implemented | - -**Files:** `packages/ui/src/timeline/Timeline.tsx` - -**Notes:** -- Engine methods exist: `addRegionMarker()`, `removeMarker()`, `updateRegionMarker()` -- Could add UI for creating regions from selection - -#### 5.3 Work Area - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Display work area | ✅ | ✅ | ✅ | **Fully Working** | -| Render work area overlay | ✅ | N/A | ✅ | **Fully Working** | -| Set work area (from selection) | ✅ | ✅ | ✅ | **Fully Working** | -| Set work area (from playhead) | ✅ | ✅ | ✅ | **Fully Working** | -| Clear work area (✕ button) | ✅ | ✅ | ✅ | **Fully Working** | -| Adjust work area boundaries | ✅ | ✅ | ❌ | Not implemented | - -**Files:** `packages/ui/src/timeline/Timeline.tsx:379-418` - -**Implementation:** -```typescript -// ⬚ Work Area button behavior: -- No selection: Sets work area 100 frames around playhead -- With selection: Sets work area from min start to max end of selected clips -- Shows as ✕ (blue) when work area is active -- Clicking ✕ clears the work area - -// All operations support undo/redo -``` - -**Visual Feedback:** -- Blue semi-transparent overlay shows work area boundaries -- Button changes to ✕ with blue background when active - -**Friction Point:** -```typescript -// All marker and work area operations now exposed in TimelineEngine: -// packages/core/src/engine/timeline-engine.ts - -public addTimelineMarker(marker: TimelineMarker): DispatchResult -public addClipMarker(marker: ClipMarker): DispatchResult -public addRegionMarker(marker: RegionMarker): DispatchResult -public removeMarker(markerId: string): DispatchResult -public updateTimelineMarker(markerId: string, updates: Partial<...>): DispatchResult -public updateRegionMarker(markerId: string, updates: Partial<...>): DispatchResult -public setWorkArea(start: Frame, end: Frame): DispatchResult -public clearWorkArea(): DispatchResult -``` - -**Status:** ✅ All engine methods added in Session 4. Timeline markers and work area fully functional in UI. - -**Workaround No Longer Needed:** -```typescript -// Previously, demo had to initialize state with markers: -// Now markers can be added dynamically through UI! - -// Add marker at playhead: -- Press M key -- Click 🚩 Marker button - -// Set work area: -- Click ⬚ Work Area button (sets from selection or playhead) -- Click ✕ to clear -``` - -**Impact:** Markers and work area are now fully interactive and support undo/redo. - ---- - -### 6. Playhead & Timeline - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Display playhead | ✅ | ✅ | ✅ | **Fully Working** | -| Click ruler to seek | ✅ | ✅ | ✅ | **Fully Working** | -| Drag playhead handle | ✅ | ✅ | ✅ | **Fully Working** | -| Keyboard seek (arrows) | ✅ | ✅ | ✅ | **Fully Working** | -| Keyboard seek 10 frames (Shift+arrows) | ✅ | ✅ | ✅ | **Fully Working** | -| Jump to start/end (Home/End) | ✅ | ✅ | ✅ | **Fully Working** | - -**Files:** `packages/ui/src/timeline/Timeline.tsx:45-67, 172-196` - -**Implementation:** -- **Arrow keys:** Seek forward/backward 1 frame -- **Shift+Arrow keys:** Seek forward/backward 10 frames -- **Home key:** Jump to start (frame 0) -- **End key:** Jump to end (timeline duration) -- **Playhead drag:** Drag the red circle handle at the top of the playhead line - ---- - -### 7. Linking & Grouping - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Link clips | ✅ | ✅ | ❌ | Not implemented | -| Unlink clips | ✅ | ✅ | ❌ | Not implemented | -| Group clips | ✅ | ✅ | ❌ | Not implemented | -| Ungroup clips | ✅ | ✅ | ❌ | Not implemented | -| Move linked clips together | ✅ | ✅ | ❌ | Not implemented | -| Visual link indicators | ✅ | N/A | ❌ | Not implemented | -| Drag preview for groups | ✅ | ❌ | ❌ | **Blocked** | - -**Files:** `packages/core/src/systems/linking.ts`, `packages/core/src/systems/grouping.ts` - -**Friction Point:** -- Core has full linking and grouping systems -- Engine exposes link/unlink/group/ungroup methods -- No API for "what clips would move together" during drag preview -- UI would need to query this for visual feedback - -**Impact:** Linking and grouping can be implemented, but drag preview would be limited without preview API. - ---- - -### 8. Undo/Redo - -| Feature | Core Support | Engine Exposed | UI Implemented | Status | -|---------|-------------|----------------|----------------|--------| -| Undo (Cmd+Z) | ✅ | ✅ | ✅ | **Fully Working** | -| Redo (Cmd+Shift+Z) | ✅ | ✅ | ✅ | **Fully Working** | -| Undo button | ✅ | ✅ | ✅ | **Fully Working** | -| Redo button | ✅ | ✅ | ✅ | **Fully Working** | - -**Files:** `packages/ui/src/timeline/Timeline.tsx:27-44, 267-285` - -**Implementation:** -```typescript -// Keyboard shortcuts: -case 'z': - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - if (e.shiftKey) { - engine.redo(); - } else { - engine.undo(); - } - } - break; - -// Toolbar buttons show disabled state when no history available -``` - ---- - -## Visual Indicators Summary - -| Indicator | Purpose | Status | -|-----------|---------|--------| -| Playhead line with handle | Show current time position (draggable) | ✅ Implemented | -| Snap indicator | Show snap position during drag | ✅ Implemented | -| Timeline markers | Show important time points with flags | ✅ Implemented | -| Region markers | Show time ranges with backgrounds | ✅ Implemented | -| Work area overlay | Highlight active work region | ✅ Implemented | -| Ripple mode badge | ⚡ Yellow badge when ripple mode active | ✅ Implemented | -| Insert mode badge | ➕ Blue badge when insert mode active | ✅ Implemented | -| Track lock icon | Show locked tracks | ✅ Implemented | -| Track mute icon | Show muted tracks | ✅ Implemented | -| Track solo icon | Green S button when track is soloed | ✅ Implemented | - ---- - -## Architecture Observations - -### What Works Well: - -1. **Dispatcher Pattern** - All UI changes go through `TimelineEngine.dispatch()`, ensuring validation -2. **State Immutability** - Engine returns new state, UI never mutates directly -3. **Operation Composition** - Complex operations built from atomic core operations -4. **Snapping Integration** - Snapping system cleanly includes all entities (clips, markers, work area) -5. **Complete Engine API** - All core operations now exposed through engine methods (41 public methods total) -6. **History System** - Undo/redo works seamlessly for all operations - -### Remaining Opportunities: - -1. **Region Marker UI** - Engine methods exist but no UI for adding regions from selection -2. **Marker Editing** - Can add markers but can't edit/remove via UI yet -3. **Ripple Trim UI** - Engine method exists but not wired to UI -4. **Linking/Grouping UI** - Engine methods exist but no UI controls yet -5. **Work Area Boundary Adjustment** - Can set/clear but not resize via drag - -### Architecture Integrity: - -✅ **All constraints maintained:** -- No engine architecture changes -- No logic duplication -- All mutations through dispatcher -- Full type safety (zero TypeScript errors) -- Proper operation wrapping pattern followed - -### Recommendations: - -#### Completed (No Engine Changes Required): -- ✅ Undo/redo keyboard shortcuts (Cmd+Z, Cmd+Shift+Z) -- ✅ Track solo UI (S button, green when active) -- ✅ Track height adjustment UI (+ / - buttons, 40-200px range) -- ✅ Playhead drag and keyboard seek -- ✅ Marker creation UI (M key, 🚩 button) -- ✅ Work area set/clear UI (⬚ button) -- ✅ Ripple delete functionality -- ✅ Insert edit functionality - -#### Next Steps (No Engine Changes Required): -- Region marker UI (create from selection) -- Marker editing UI (click to edit properties, delete button) -- Ripple trim UI (wire to edge drag in ripple mode) -- Linking/grouping UI (buttons to link/unlink, group/ungroup) -- Work area boundary drag handles - -#### Future Enhancements (May Require Engine Extensions): -- Drag preview for linked/grouped clips -- Real-time ripple effect preview -- Multi-track selection and operations -- Clip speed/duration adjustments - ---- - -## Demo Application Status - -**Location:** `apps/demo/src/App.tsx` -**Server:** http://localhost:3004/ - -### Demo Data Includes: -- 3 tracks with various clips -- 2 timeline markers: "Scene 1" (frame 150), "Scene 2" (frame 300) -- 1 region marker: "Act 1" (frames 200-400) -- Work area: frames 50-500 -- Playhead at frame 0 - -### Interactive Features Available: -- ✅ Click clips to select -- ✅ Cmd+Click for multi-select -- ✅ Cmd+A to select all -- ✅ Delete selected clips (ripple mode shifts subsequent clips) -- ✅ Copy/paste clips (insert mode shifts subsequent clips) -- ✅ Drag clips to move -- ✅ Drag clip edges to trim -- ✅ Toggle track lock/mute/solo -- ✅ Adjust track height (+/- buttons) -- ✅ Toggle snapping -- ✅ Switch editing modes (Normal, Ripple, Insert) -- ✅ Click ruler to seek -- ✅ Drag playhead handle -- ✅ Keyboard navigation (arrows, Shift+arrows, Home/End) -- ✅ Add markers (M key or 🚩 button) -- ✅ Set/clear work area (⬚ button) -- ✅ Undo/redo (Cmd+Z, Cmd+Shift+Z, toolbar buttons) - -### Visual Features Available: -- ✅ Timeline markers with green flags (add with M key or button) -- ✅ Region marker with purple background -- ✅ Work area with blue semi-transparent overlay (set/clear with button) -- ✅ Ripple mode badge (⚡ yellow when active) -- ✅ Insert mode badge (➕ blue when active) -- ✅ Snap line indicator during drag -- ✅ Playhead line with draggable red handle -- ✅ Track solo indicators (green S button when active) -- ✅ Undo/redo button states (disabled when no history) - ---- - -## Files Modified/Added - -### Session 1: Initial Timeline Features -- `packages/ui/src/timeline/Timeline.tsx` - Selection, copy/paste, delete -- `packages/ui/src/timeline/Clip.tsx` - Clip interaction handlers - -### Session 2: Listener Signature Improvement -- `packages/core/src/engine/timeline-engine.ts` - Changed listener signature to `(state: TimelineState) => void` -- `packages/react/src/hooks/*.ts` - Updated all hooks to receive state from listener - -### Session 3: Easy Wins Implementation -- `packages/ui/src/timeline/Timeline.tsx` - Undo/redo shortcuts, playhead drag, keyboard seek -- `packages/ui/src/timeline/Track.tsx` - Solo toggle, height adjustment - -### Session 4: TimelineEngine API Surface Completion -- `packages/core/src/engine/timeline-engine.ts` - **Added 11 new public methods:** - - `rippleDelete()`, `rippleTrim()`, `insertEdit()` - - `addTimelineMarker()`, `addClipMarker()`, `addRegionMarker()` - - `removeMarker()`, `updateTimelineMarker()`, `updateRegionMarker()` - - `setWorkArea()`, `clearWorkArea()` -- `packages/core/src/types/track.ts` - Added `solo: boolean`, `height: number` fields -- `packages/core/src/operations/track-operations.ts` - Added `toggleTrackSolo()`, `setTrackHeight()` -- `packages/ui/src/timeline/Timeline.tsx` - Wired ripple/insert modes, marker controls, work area controls -- `FEATURE_COVERAGE.md` - This document (updated to reflect all functional features) - ---- - -## Testing Status - -### Build Verification: -- [x] All packages build successfully -- [x] Zero TypeScript errors -- [x] Demo server runs on port 3004 - -### Code Review Verification: -- [x] Ripple delete wired to `engine.rippleDelete()` -- [x] Insert edit wired to `engine.insertEdit()` -- [x] Marker creation via M key and 🚩 button -- [x] Work area set/clear via ⬚ button -- [x] Undo/redo via Cmd+Z, Cmd+Shift+Z, and toolbar buttons -- [x] Track solo via S button -- [x] Track height via +/- buttons -- [x] Playhead drag via handle -- [x] Keyboard seek with arrows, Home, End - -### Manual Browser Testing (Recommended): -- [ ] **Ripple Delete:** Set ripple mode, delete middle clip, verify subsequent clips shift left -- [ ] **Insert Edit:** Set insert mode, paste clip, verify subsequent clips shift right -- [ ] **Markers:** Press M key at various playhead positions, verify markers appear -- [ ] **Work Area:** Select clips, click ⬚ button, verify blue overlay, click ✕ to clear -- [ ] **Undo/Redo:** Perform operations, press Cmd+Z to undo, Cmd+Shift+Z to redo -- [ ] **Track Solo:** Click S button, verify turns green, verify solo behavior -- [ ] **Track Height:** Click +/- buttons, verify track height changes (40-200px range) -- [ ] **Playhead Drag:** Drag red circle handle, verify playhead follows mouse -- [ ] **Keyboard Seek:** Test arrows (1 frame), Shift+arrows (10 frames), Home/End keys - ---- - -## Conclusion - -This implementation successfully exposes **all major core engine capabilities** through UI components while maintaining complete architecture integrity. - -### What's Fully Functional: - -✅ **All editing operations** - Normal, ripple, and insert modes working -✅ **All track controls** - Lock, mute, solo, height adjustment -✅ **Interactive markers** - Add timeline markers via UI (M key or button) -✅ **Work area management** - Set from selection/playhead, clear via button -✅ **Undo/redo system** - Keyboard shortcuts and toolbar buttons -✅ **Playhead control** - Click, drag, keyboard navigation (arrows, Home, End) -✅ **Snapping system** - Toggle, visual feedback, all snap targets -✅ **Visual indicators** - All modes, states, and entities properly visualized - -### Success Metrics: - -- **41 public engine methods** - Complete operational API surface -- **Zero TypeScript errors** - Full type safety maintained -- **Zero architecture changes** - All constraints respected -- **Zero logic duplication** - All operations delegate to core -- **Complete history support** - All operations reversible via undo/redo - -### Remaining Opportunities: - -While the core functionality is complete, there are UI enhancement opportunities: - -1. **Marker editing** - Click markers to edit properties or delete -2. **Region creation** - UI to create region markers from selection -3. **Ripple trim** - Wire to edge drag in ripple mode -4. **Linking/grouping** - UI controls for link/unlink/group/ungroup operations - -These are all **purely UI additions** with no engine changes required. - -**Demo Ready:** http://localhost:3004/ - ---- - -**Last Updated:** 2026-02-13 (Session 4 Complete) -**Status:** ✅ All "easy wins" implemented, TimelineEngine API surface complete, fully functional demo ready diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9462af --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Timeline Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 858d5db..e6fc797 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,98 @@ -# Timeline Editor +# @timeline -[![CI](https://github.com/maanaaasss/timeline/actions/workflows/ci.yml/badge.svg)](https://github.com/maanaaasss/timeline/actions/workflows/ci.yml) - -A production-ready, headless timeline editor engine with React bindings and UI components. - -## Features - -- **Headless Architecture** - Pure TypeScript engine with no UI dependencies -- **Undo/Redo** - Complete history management with validation pipeline -- **Track Management** - Solo, mute, lock, height adjustment -- **Clip Operations** - Move, trim, ripple delete, copy/paste, split -- **Markers** - Timeline markers, clip markers, and region markers -- **Work Area** - Define and manage work areas for focused editing -- **Playhead Control** - Draggable playhead with keyboard shortcuts -- **Snapping** - Smart snapping to clips, markers, and playhead -- **Type-Safe** - Full TypeScript support with zero errors +Professional open-source NLE (Non-Linear Editor) timeline engine for the web. ## Packages -This monorepo contains: - -- **[@timeline/core](./packages/core)** - Core timeline engine (41 public methods) -- **[@timeline/react](./packages/react)** - React hooks and provider -- **[@timeline/ui](./packages/ui)** - Presentational React components -- **[demo](./apps/demo)** - Interactive demo application +| Package | Description | Version | +|---------|-------------|---------| +| [`@webpacked-timeline/core`](packages/core) | Headless TypeScript engine | 1.0.0-beta.1 | +| [`@webpacked-timeline/react`](packages/react) | React adapter + hooks | 1.0.0-beta.1 | +| [`@webpacked-timeline/ui`](packages/ui) | DaVinci-style UI preset | 1.0.0-beta.1 | ## Quick Start ```bash -# Install dependencies -pnpm install - -# Run demo application -pnpm dev - -# Run all tests -pnpm test - -# Build all packages -pnpm build +npm install @webpacked-timeline/ui @webpacked-timeline/react @webpacked-timeline/core ``` -The demo will be available at http://localhost:3004 +```tsx +import { DaVinciEditor } from '@webpacked-timeline/ui'; +import '@webpacked-timeline/ui/styles/davinci'; +import { TimelineEngine } from '@webpacked-timeline/react'; +import { createTimelineState, createTimeline, toFrame, frameRate } from '@webpacked-timeline/core'; + +const engine = new TimelineEngine({ + initialState: createTimelineState({ + timeline: createTimeline({ + id: 'tl-1', + name: 'My Timeline', + fps: frameRate(30), + duration: toFrame(9000), + }), + }), +}); + +export default function App() { + return ; +} +``` ## Architecture -The timeline editor follows a strict architectural pattern: +``` +Your App +└── @webpacked-timeline/ui → DaVinci-style components (React) + └── @webpacked-timeline/react → Hooks, context, TimelineEngine + └── @webpacked-timeline/core → Pure TypeScript engine (zero deps) +``` + +- **@webpacked-timeline/core** is framework-agnostic. Runs in browser, Node.js, Web Workers, Electron. +- **@webpacked-timeline/react** provides `TimelineEngine` (wires core's dispatcher, history, tools, playback) and 20+ hooks. +- **@webpacked-timeline/ui** provides drop-in `DaVinciEditor` with toolbar, ruler, tracks, clips, playhead, and full keyboard shortcuts. -1. **Core Operations** - Pure functions for all business logic -2. **Dispatcher** - Handles validation, history recording, and state updates -3. **Engine** - Thin orchestration layer exposing public API -4. **React Bindings** - Hooks that subscribe to state changes -5. **UI Components** - Presentational components using the hooks +## Features -See [API_STABILITY_AUDIT.md](./API_STABILITY_AUDIT.md) for detailed API documentation. +- 40+ atomic editing operations +- 12 professional tools (Selection, Razor, Trim, Slip, Slide, etc.) +- Undo/redo with transaction compression +- Playback engine with J/K/L shuttle +- Export to OTIO, EDL, AAF, FCP XML +- SRT/VTT subtitle import +- Snap system, virtual windowing, interval tree +- Full CSS variable theming +- 850+ tests, zero TypeScript errors ## Development ```bash -# Install dependencies pnpm install - -# Run demo in development mode -pnpm dev - -# Run tests with watch mode -pnpm test --watch - -# Build all packages -pnpm build - -# Type check -pnpm typecheck -``` - -## Testing - -All packages include comprehensive tests: - -```bash -# Run all tests -pnpm test - -# Run tests for specific package -pnpm --filter @timeline/core test +pnpm --filter @webpacked-timeline/core test # Run core tests +pnpm --filter @webpacked-timeline/react test # Run react tests +pnpm --filter @webpacked-timeline/ui build # Build UI package +cd apps/demo && pnpm dev # Run demo app ``` -- 40 passing tests across 3 test suites -- Edge case testing -- Stress testing -- Phase 2 feature testing - -## CI/CD - -The project uses GitHub Actions for continuous integration: - -- ✅ Install dependencies -- ✅ Build all packages -- ✅ Run all tests (40 tests) -- ✅ Verify build outputs - -See [CI_CONFIGURATION.md](./CI_CONFIGURATION.md) for details. +## Status -## Public API +Feature-complete. All phases delivered: -The engine exposes 41 public methods organized into categories: +| Phase | Description | Status | +|-------|-------------|--------| +| 0 | Foundation — types, dispatch, history | ✅ | +| 1 | Tool scaffolding + React adapter | ✅ | +| 2 | Core tools — Select, Razor, Trim, Slip, Delete, Insert | ✅ | +| 3 | Markers, BeatGrid, Generators, Captions, SRT/VTT | ✅ | +| 4 | Effects, Keyframes, Transitions, Track Groups | ✅ | +| 5 | Serialization — JSON, OTIO, EDL, AAF, FCP XML | ✅ | +| 6 | Playback engine — PlayheadController, pipeline contracts | ✅ | +| 7 | Performance — interval tree, compression, benchmarks | ✅ | +| R | @webpacked-timeline/react — full adapter buildout | ✅ | +| U | @webpacked-timeline/ui — DaVinci preset | ✅ | -- **Playback** - `setPlayhead()`, `play()`, `pause()`, etc. -- **Selection** - `setSelection()`, `clearSelection()`, `selectAll()` -- **History** - `undo()`, `redo()`, `canUndo()`, `canRedo()` -- **Clips** - `moveClip()`, `trimClip()`, `splitClip()`, `deleteClips()` -- **Tracks** - `addTrack()`, `removeTrack()`, `toggleTrackSolo()`, `setTrackHeight()` -- **Markers** - `addTimelineMarker()`, `addClipMarker()`, `addRegionMarker()` -- **Work Area** - `setWorkArea()`, `clearWorkArea()` -- **Ripple** - `rippleDelete()`, `rippleTrim()`, `insertEdit()` +## Contributing -See the [Core Package README](./packages/core/README.md) for full API documentation. +See CONTRIBUTING.md (coming soon). ## License diff --git a/apps/demo/Icons/audio clip.svg b/apps/demo/Icons/audio clip.svg new file mode 100644 index 0000000..c0df554 --- /dev/null +++ b/apps/demo/Icons/audio clip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/blade.svg b/apps/demo/Icons/blade.svg new file mode 100644 index 0000000..15ec3fc --- /dev/null +++ b/apps/demo/Icons/blade.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/hand.svg b/apps/demo/Icons/hand.svg new file mode 100644 index 0000000..2403cbf --- /dev/null +++ b/apps/demo/Icons/hand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/lock.svg b/apps/demo/Icons/lock.svg new file mode 100644 index 0000000..65c4df6 --- /dev/null +++ b/apps/demo/Icons/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/mute active.svg b/apps/demo/Icons/mute active.svg new file mode 100644 index 0000000..8ec849a --- /dev/null +++ b/apps/demo/Icons/mute active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/mute inactive.svg b/apps/demo/Icons/mute inactive.svg new file mode 100644 index 0000000..3435a3e --- /dev/null +++ b/apps/demo/Icons/mute inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/pointer.svg b/apps/demo/Icons/pointer.svg new file mode 100644 index 0000000..d48f965 --- /dev/null +++ b/apps/demo/Icons/pointer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/redo.svg b/apps/demo/Icons/redo.svg new file mode 100644 index 0000000..f0ac0a1 --- /dev/null +++ b/apps/demo/Icons/redo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/roll.svg b/apps/demo/Icons/roll.svg new file mode 100644 index 0000000..ad32aad --- /dev/null +++ b/apps/demo/Icons/roll.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/scissors.svg b/apps/demo/Icons/scissors.svg new file mode 100644 index 0000000..cba8102 --- /dev/null +++ b/apps/demo/Icons/scissors.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/slide.svg b/apps/demo/Icons/slide.svg new file mode 100644 index 0000000..c713a89 --- /dev/null +++ b/apps/demo/Icons/slide.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/slip.svg b/apps/demo/Icons/slip.svg new file mode 100644 index 0000000..f888907 --- /dev/null +++ b/apps/demo/Icons/slip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/solo active.svg b/apps/demo/Icons/solo active.svg new file mode 100644 index 0000000..49ebc83 --- /dev/null +++ b/apps/demo/Icons/solo active.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/solo inactive.svg b/apps/demo/Icons/solo inactive.svg new file mode 100644 index 0000000..87a33fb --- /dev/null +++ b/apps/demo/Icons/solo inactive.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/undo.svg b/apps/demo/Icons/undo.svg new file mode 100644 index 0000000..e8bcb60 --- /dev/null +++ b/apps/demo/Icons/undo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/unlock.svg b/apps/demo/Icons/unlock.svg new file mode 100644 index 0000000..8678011 --- /dev/null +++ b/apps/demo/Icons/unlock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/video-clip.svg b/apps/demo/Icons/video-clip.svg new file mode 100644 index 0000000..7599347 --- /dev/null +++ b/apps/demo/Icons/video-clip.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/video-off.svg b/apps/demo/Icons/video-off.svg new file mode 100644 index 0000000..deb8be0 --- /dev/null +++ b/apps/demo/Icons/video-off.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/Icons/video.svg b/apps/demo/Icons/video.svg new file mode 100644 index 0000000..7958070 --- /dev/null +++ b/apps/demo/Icons/video.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/demo/index.html b/apps/demo/index.html index dcafd92..3c52d35 100644 --- a/apps/demo/index.html +++ b/apps/demo/index.html @@ -3,7 +3,7 @@ - Timeline Demo - Architecture Validation + @timeline demo
diff --git a/apps/demo/package.json b/apps/demo/package.json index d190a5e..a79fd39 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,17 +1,17 @@ { - "name": "@timeline/demo", + "name": "@webpacked-timeline/demo", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", - "build": "tsc && vite build", + "build": "tsc --noEmit && vite build", "preview": "vite preview" }, "dependencies": { - "@timeline/core": "workspace:*", - "@timeline/react": "workspace:*", - "@timeline/ui": "workspace:*", + "@webpacked-timeline/core": "workspace:*", + "@webpacked-timeline/react": "workspace:*", + "@webpacked-timeline/ui": "workspace:*", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -19,9 +19,8 @@ "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", "@vitejs/plugin-react": "^4.2.0", - "autoprefixer": "^10.4.16", - "postcss": "^8.4.32", - "tailwindcss": "^3.4.0", + "@tailwindcss/vite": "^4.0.0", + "tailwindcss": "^4.0.0", "typescript": "^5.3.0", "vite": "^5.0.0" } diff --git a/apps/demo/postcss.config.js b/apps/demo/postcss.config.js deleted file mode 100644 index 2e7af2b..0000000 --- a/apps/demo/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -} diff --git a/apps/demo/src/App.tsx b/apps/demo/src/App.tsx index afe7af7..e22ffdf 100644 --- a/apps/demo/src/App.tsx +++ b/apps/demo/src/App.tsx @@ -1,329 +1,21 @@ /** - * Demo App - Architecture Validation - * - * Minimal timeline editor to validate: - * - Core engine integration - * - React adapter functionality - * - UI package ergonomics - * - State consistency + * Demo — imports the full DaVinci editor from @webpacked-timeline/ui. + * + * The entire 1600+ line single-file implementation is now a clean + * library component. This file exists only to provide the engine + * singleton and mount the editor. */ +import { DaVinciEditor } from '@webpacked-timeline/ui'; +import '@webpacked-timeline/ui/styles/davinci'; +import { engine, setEnginePixelsPerFrame, setOnZoomChange } from './engine'; -import { - TimelineEngine, - createTimeline, - createTimelineState, - createTrack, - createClip, - createAsset, - frame, - frameRate, -} from '@timeline/core'; -import { TimelineProvider, useTimeline } from '@timeline/react'; -import { Timeline } from '@timeline/ui'; - -// Import internal utilities for demo -import { - generateTimelineId, - generateTrackId, - generateClipId, - generateAssetId, -} from '@timeline/core/internal'; - -// Create initial timeline -const timeline = createTimeline({ - id: generateTimelineId(), - name: 'Demo Timeline', - fps: frameRate(30), - duration: frame(9000), // 300 seconds @ 30fps - tracks: [], -}); - -// Create initial state (markers and work area can be added via UI) -const initialState = createTimelineState({ - timeline, -}); - -// Create engine with initial state -const engine = new TimelineEngine(initialState); - -// Add initial track -const track1 = createTrack({ - id: generateTrackId(), - name: 'Video Track 1', - type: 'video', -}); -engine.addTrack(track1); - -// Add an audio track for testing type enforcement -const track2 = createTrack({ - id: generateTrackId(), - name: 'Audio Track 1', - type: 'audio', -}); -engine.addTrack(track2); - -// Register video asset -const asset1 = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'video.mp4', -}); -engine.registerAsset(asset1); - -// Register audio asset -const asset2 = createAsset({ - id: generateAssetId(), - type: 'audio', - duration: frame(200), - sourceUrl: 'audio.mp3', -}); -engine.registerAsset(asset2); - -// Add initial video clip -const clip1 = createClip({ - id: generateClipId(), - assetId: asset1.id, - trackId: track1.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), -}); -engine.addClip(track1.id, clip1); - -// Add initial audio clip -const clip2 = createClip({ - id: generateClipId(), - assetId: asset2.id, - trackId: track2.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), -}); -engine.addClip(track2.id, clip2); - -function Controls() { - const { state, engine } = useTimeline(); - - const handleAddVideoTrack = () => { - const videoTracks = state.timeline.tracks.filter(t => t.type === 'video'); - const track = createTrack({ - id: generateTrackId(), - name: `Video Track ${videoTracks.length + 1}`, - type: 'video', - }); - engine.addTrack(track); - }; - - const handleAddAudioTrack = () => { - const audioTracks = state.timeline.tracks.filter(t => t.type === 'audio'); - const track = createTrack({ - id: generateTrackId(), - name: `Audio Track ${audioTracks.length + 1}`, - type: 'audio', - }); - engine.addTrack(track); - }; - - const handleAddVideoClip = () => { - const videoTrack = state.timeline.tracks.find(t => t.type === 'video'); - if (!videoTrack) { - alert('Add a video track first!'); - return; - } - - // Find the last clip on the track to position the new clip after it - const existingClips = videoTrack.clips; - let startFrame = frame(0); - if (existingClips.length > 0) { - const lastClip = existingClips[existingClips.length - 1]; - startFrame = frame(lastClip.timelineEnd + 10); // Add 10 frames gap - } - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(150), - sourceUrl: 'video-clip.mp4', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: videoTrack.id, - timelineStart: startFrame, - timelineEnd: frame(startFrame + 150), - mediaIn: frame(0), - mediaOut: frame(150), - }); - engine.addClip(videoTrack.id, clip); - }; - - const handleAddAudioClip = () => { - const audioTrack = state.timeline.tracks.find(t => t.type === 'audio'); - if (!audioTrack) { - alert('Add an audio track first!'); - return; - } - - // Find the last clip on the track to position the new clip after it - const existingClips = audioTrack.clips; - let startFrame = frame(0); - if (existingClips.length > 0) { - const lastClip = existingClips[existingClips.length - 1]; - startFrame = frame(lastClip.timelineEnd + 10); // Add 10 frames gap - } - - const asset = createAsset({ - id: generateAssetId(), - type: 'audio', - duration: frame(150), - sourceUrl: 'audio-clip.mp3', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: audioTrack.id, - timelineStart: startFrame, - timelineEnd: frame(startFrame + 150), - mediaIn: frame(0), - mediaOut: frame(150), - }); - engine.addClip(audioTrack.id, clip); - }; - - const buttonStyle = { - padding: '6px 12px', - backgroundColor: '#27272a', - color: '#d4d4d8', - border: '1px solid #3f3f46', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '14px', - }; - - return ( -
- {/* Track Controls */} -
- - -
- -
- - {/* Clip Controls */} -
- - -
- -
- - {/* History Controls */} -
- - -
- -
- - {/* Info & Shortcuts */} -
-
- Clips: {state.timeline.tracks.reduce((sum, t) => sum + t.clips.length, 0)} - Tracks: {state.timeline.tracks.length} -
- -
- ⌘/Ctrl+C: Copy - ⌘/Ctrl+V: Paste - ⌘/Ctrl+A: Select All - Del: Delete - Esc: Deselect - ←/→: Playhead -
-
-
- ); -} - -function App() { +export function App() { return ( - -
-
-

- Timeline Demo -

-

- UI Package Integration Validation -

-
- -
- -
-
-
+ ); } - -export default App; diff --git a/apps/demo/src/app.css b/apps/demo/src/app.css new file mode 100644 index 0000000..e28acc5 --- /dev/null +++ b/apps/demo/src/app.css @@ -0,0 +1,122 @@ +@import "tailwindcss"; + +/* ── @webpacked-timeline/ui DaVinci Resolve design tokens ── */ +@theme { + /* Track */ + --color-tl-track-video: hsl(220 15% 14%); + --color-tl-track-audio: hsl(220 12% 13%); + --color-tl-track-border: hsl(220 13% 19%); + --color-tl-track-label: hsl(220 13% 10%); + --color-tl-track-label-text: hsl(220 10% 62%); + + /* Clip */ + --color-tl-clip-video: hsl(213 30% 36%); + --color-tl-clip-video-top: hsl(213 30% 42%); + --color-tl-clip-audio: hsl(198 30% 30%); + --color-tl-clip-audio-top: hsl(198 30% 36%); + --color-tl-clip-selected: hsl(213 75% 52%); + --color-tl-clip-text: hsl(0 0% 92%); + --color-tl-clip-text-dim: hsl(0 0% 65%); + + /* Playhead */ + --color-tl-playhead: hsl(0 88% 56%); + + /* Ruler */ + --color-tl-ruler: hsl(220 13% 8%); + --color-tl-ruler-text: hsl(220 10% 52%); + --color-tl-ruler-tick: hsl(220 13% 30%); + --color-tl-ruler-tick-major: hsl(220 13% 48%); + + /* Toolbar */ + --color-tl-toolbar: hsl(220 13% 11%); + --color-tl-toolbar-border: hsl(220 13% 17%); + --color-tl-toolbar-btn: hsl(220 13% 22%); + --color-tl-toolbar-active: hsl(213 45% 32%); + --color-tl-toolbar-text: hsl(220 10% 68%); + + /* Timecode */ + --color-tl-timecode: hsl(35 85% 62%); + + /* Waveform */ + --color-tl-waveform: hsl(198 60% 42%); + --color-tl-waveform-rms: hsl(198 60% 30%); + + /* Keyframe */ + --color-tl-keyframe: hsl(45 93% 55%); + + /* Sizes */ + --spacing-tl-track-video: 52px; + --spacing-tl-track-audio: 44px; + --spacing-tl-ruler: 24px; + --spacing-tl-label: 160px; +} + +/* ── CSS variables (for runtime JS access) ── */ +:root { + --tl-track-height-video: 52px; + --tl-track-height-audio: 44px; + --tl-ruler-height: 24px; + --tl-label-width: 160px; + --tl-clip-radius: 2px; + --tl-playhead-width: 1px; + --tl-track-bg-video: var(--color-tl-track-video); + --tl-track-bg-audio: var(--color-tl-track-audio); + --tl-track-border: var(--color-tl-track-border); + --tl-track-label-bg: var(--color-tl-track-label); + --tl-track-label-text: var(--color-tl-track-label-text); + --tl-clip-bg-video: var(--color-tl-clip-video); + --tl-clip-bg-audio: var(--color-tl-clip-audio); + --tl-clip-bg-selected: var(--color-tl-clip-selected); + --tl-clip-bg-provisional: hsl(213 30% 36% / 0.5); + --tl-clip-border: hsl(213 30% 50% / 0.3); + --tl-clip-border-radius: var(--tl-clip-radius); + --tl-clip-text: var(--color-tl-clip-text); + --tl-clip-text-sm: var(--color-tl-clip-text-dim); + --tl-playhead-color: var(--color-tl-playhead); + --tl-playhead-head-size: 6px; + --tl-ruler-bg: var(--color-tl-ruler); + --tl-ruler-text: var(--color-tl-ruler-text); + --tl-ruler-tick: var(--color-tl-ruler-tick); + --tl-ruler-tick-major: var(--color-tl-ruler-tick-major); + --tl-toolbar-bg: var(--color-tl-toolbar); + --tl-toolbar-border: var(--color-tl-toolbar-border); + --tl-toolbar-btn-bg: var(--color-tl-toolbar-btn); + --tl-toolbar-btn-active: var(--color-tl-toolbar-active); + --tl-toolbar-btn-text: var(--color-tl-toolbar-text); + --tl-toolbar-btn-radius: 3px; + --tl-font-mono: ui-monospace, monospace; + --tl-font-size-sm: 10px; + --tl-timecode-color: var(--color-tl-timecode); + --tl-waveform-color: var(--color-tl-waveform); + --tl-waveform-rms: var(--color-tl-waveform-rms); +} + +/* ── Scrollbar styling ── */ +.tl-scroll-area::-webkit-scrollbar { + height: 8px; + width: 8px; +} + +.tl-scroll-area::-webkit-scrollbar-track { + background: hsl(220 13% 9%); +} + +.tl-scroll-area::-webkit-scrollbar-thumb { + background: hsl(220 13% 28%); + border-radius: 4px; +} + +.tl-scroll-area::-webkit-scrollbar-thumb:hover { + background: hsl(220 13% 35%); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: hsl(220 13% 9%); + color: hsl(220 13% 85%); + font-family: system-ui, -apple-system, sans-serif; +} diff --git a/apps/demo/src/engine.ts b/apps/demo/src/engine.ts new file mode 100644 index 0000000..eba1376 --- /dev/null +++ b/apps/demo/src/engine.ts @@ -0,0 +1,95 @@ +import { TimelineEngine } from '@webpacked-timeline/react'; +import { buildMockState } from './mock-data'; +import type { PipelineConfig } from '@webpacked-timeline/core'; +import { browserClock } from '@webpacked-timeline/core'; + +/** + * Singleton engine instance for the demo app. + * + * _ppf is a plain module-level variable captured by the + * getPixelsPerFrame closure. It is safe to read during the + * constructor (unlike a self-reference to `engine` which is + * still in the TDZ at construction time). + * + * Call setEnginePixelsPerFrame(v) whenever zoom changes so + * tool contexts always receive the current ppf. + */ +const DEFAULT_PPF = 4; +let _ppf = DEFAULT_PPF; + +/** Late-bound zoom callback — set by the React component on mount. */ +let _onZoomChange: (ppf: number) => void = () => {}; + +export function setEnginePixelsPerFrame(ppf: number): void { + _ppf = ppf; +} + +export function setOnZoomChange(cb: (ppf: number) => void): void { + _onZoomChange = cb; +} + +/** + * Stub pipeline — enables PlaybackEngine without real media decoding. + * The demo only needs the playhead to tick; no actual frames are rendered. + */ +const stubPipeline: PipelineConfig = { + videoDecoder: async (req) => ({ + clipId: req.clipId, + mediaFrame: req.mediaFrame, + bitmap: null, + width: 1920, + height: 1080, + }), + compositor: async (req) => ({ + timelineFrame: req.timelineFrame, + bitmap: null, + }), +}; + +/** + * Notifying clock — wraps browserClock so that every rAF tick also + * triggers an engine snapshot rebuild + React notification. + * + * PlayheadController in core updates its internal currentFrame on each + * rAF frame but does not emit an event for normal frame advancement. + * Wrapping the clock lets us call seekTo(currentFrame) after each tick, + * which emits a 'seek' event → engine listener → rebuildSnapshot → notify. + */ +let _afterTick: (() => void) | null = null; + +const notifyingClock = { + requestFrame: (cb: (ts: number) => void) => + browserClock.requestFrame((ts: number) => { + cb(ts); + _afterTick?.(); + }), + cancelFrame: (id: number) => browserClock.cancelFrame(id), + now: () => browserClock.now(), +}; + +export const engine = new TimelineEngine({ + initialState: buildMockState(), + onZoomChange: (ppf: number) => _onZoomChange(ppf), + getPixelsPerFrame: () => _ppf, + pipeline: stubPipeline, + clock: notifyingClock, +}); + +/** + * After each PlayheadController tick, force an engine notification by + * seeking to the current frame. This is a data no-op (same frame value) + * but emits the 'seek' event needed to trigger snapshot rebuild + React + * re-render. Only fires when the frame has actually changed to avoid + * unnecessary work. + */ +let _lastNotifiedFrame = -1; +_afterTick = () => { + const pb = engine.playbackEngine; + if (pb?.getState().isPlaying) { + const current = engine.getPlayheadFrame() as number; + if (current !== _lastNotifiedFrame) { + _lastNotifiedFrame = current; + engine.seekTo(engine.getPlayheadFrame()); + } + } +}; diff --git a/apps/demo/src/icons.tsx b/apps/demo/src/icons.tsx new file mode 100644 index 0000000..b534111 --- /dev/null +++ b/apps/demo/src/icons.tsx @@ -0,0 +1,204 @@ +/** + * Tabler-based SVG icon components for the demo toolbar. + * + * Sources: apps/demo/Icons/*.svg (Tabler Icons, outline style). + * Missing icons (playback, zoom, trim) are hand-drawn in the same + * 24×24 / stroke-1.5 / round-cap style for visual consistency. + */ +import React from 'react'; + +type IconProps = { size?: number; color?: string; strokeWidth?: number }; + +const defaults = { size: 16, color: 'currentColor', strokeWidth: 1.5 }; + +function svgProps(p: IconProps) { + const s = p.size ?? defaults.size; + const c = p.color ?? defaults.color; + const sw = p.strokeWidth ?? defaults.strokeWidth; + return { + xmlns: 'http://www.w3.org/2000/svg', + width: s, + height: s, + viewBox: '0 0 24 24', + fill: 'none', + stroke: c, + strokeWidth: sw, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + style: { display: 'block' } as React.CSSProperties, + }; +} + +// ── Tool icons ───────────────────────────────────────────────────────────── + +/** Selection (pointer) — from pointer.svg */ +export function IconPointer(p: IconProps = {}) { + return ( + + + + + ); +} + +/** Razor (scissors) — from scissors.svg */ +export function IconScissors(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +/** Ripple-trim — bracket-arrows (custom, Tabler style) */ +export function IconRippleTrim(p: IconProps = {}) { + return ( + + + {/* left bracket */} + + + + {/* right arrows */} + + + ); +} + +/** Roll-trim (arrows-left-right) — from roll.svg */ +export function IconRollTrim(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +/** Slip (arrows-move-horizontal) — from slip.svg */ +export function IconSlip(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +/** Slide (caret-left-right) — from slide.svg (filled) */ +export function IconSlide(p: IconProps = {}) { + const s = p.size ?? defaults.size; + const c = p.color ?? defaults.color; + return ( + + + + + + ); +} + +/** Hand (hand-stop) — from hand.svg */ +export function IconHand(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +// ── Playback icons ───────────────────────────────────────────────────────── + +/** Play — right-facing triangle */ +export function IconPlayerPlay(p: IconProps = {}) { + return ( + + + + + ); +} + +/** Pause — two vertical bars */ +export function IconPlayerPause(p: IconProps = {}) { + return ( + + + + + + ); +} + +// ── Edit icons ───────────────────────────────────────────────────────────── + +/** Undo (arrow-back-up) — from undo.svg */ +export function IconUndo(p: IconProps = {}) { + return ( + + + + + + ); +} + +/** Redo (arrow-forward-up) — from redo.svg */ +export function IconRedo(p: IconProps = {}) { + return ( + + + + + + ); +} + +// ── Zoom icons ───────────────────────────────────────────────────────────── + +/** Zoom in */ +export function IconZoomIn(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +/** Zoom out */ +export function IconZoomOut(p: IconProps = {}) { + return ( + + + + + + + ); +} diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx index 80ec9b1..013ce5f 100644 --- a/apps/demo/src/main.tsx +++ b/apps/demo/src/main.tsx @@ -1,10 +1,10 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from './App'; -import './styles.css'; +import { App } from './app'; +import './app.css'; ReactDOM.createRoot(document.getElementById('root')!).render( - + , ); diff --git a/apps/demo/src/mock-data.ts b/apps/demo/src/mock-data.ts new file mode 100644 index 0000000..fdb4343 --- /dev/null +++ b/apps/demo/src/mock-data.ts @@ -0,0 +1,129 @@ +import { + createTimelineState, + createTimeline, + createTrack, + createClip, + createAsset, + toTrackId, + toClipId, + toAssetId, + toFrame, + frameRate, +} from '@webpacked-timeline/core'; +import type { AssetRegistry } from '@webpacked-timeline/core'; + +const fps = 30 as const; +const duration = toFrame(fps * 120); // 2 minutes + +export function buildMockState() { + const assetMap = new Map(); + + function addAsset( + id: string, + name: string, + dur: number, + mediaType: 'video' | 'audio', + ) { + const asset = createAsset({ + id, + name, + mediaType, + filePath: `generator://${id}`, + intrinsicDuration: toFrame(dur), + nativeFps: frameRate(fps), + sourceTimecodeOffset: toFrame(0), + }); + assetMap.set(id, asset); + } + + const clips_v1 = [ + { id: 'c1', start: 0, dur: 90, label: 'Intro' }, + { id: 'c2', start: 100, dur: 120, label: 'Scene 1' }, + { id: 'c3', start: 230, dur: 80, label: 'Scene 2' }, + { id: 'c4', start: 320, dur: 150, label: 'Scene 3' }, + ].map((c) => { + const aid = `asset-${c.id}`; + addAsset(aid, c.label, c.dur, 'video'); + return createClip({ + id: toClipId(c.id), + assetId: toAssetId(aid), + trackId: toTrackId('v1'), + timelineStart: toFrame(c.start), + timelineEnd: toFrame(c.start + c.dur), + mediaIn: toFrame(0), + mediaOut: toFrame(c.dur), + name: c.label, + }); + }); + + const clips_v2 = [ + { id: 'c5', start: 50, dur: 60, label: 'B-Roll 1' }, + { id: 'c6', start: 200, dur: 100, label: 'B-Roll 2' }, + ].map((c) => { + const aid = `asset-${c.id}`; + addAsset(aid, c.label, c.dur, 'video'); + return createClip({ + id: toClipId(c.id), + assetId: toAssetId(aid), + trackId: toTrackId('v2'), + timelineStart: toFrame(c.start), + timelineEnd: toFrame(c.start + c.dur), + mediaIn: toFrame(0), + mediaOut: toFrame(c.dur), + name: c.label, + }); + }); + + const clips_a1 = [{ id: 'c7', start: 0, dur: 300, label: 'Music' }].map( + (c) => { + const aid = `asset-${c.id}`; + addAsset(aid, c.label, c.dur, 'audio'); + return createClip({ + id: toClipId(c.id), + assetId: toAssetId(aid), + trackId: toTrackId('a1'), + timelineStart: toFrame(c.start), + timelineEnd: toFrame(c.start + c.dur), + mediaIn: toFrame(0), + mediaOut: toFrame(c.dur), + name: c.label, + }); + }, + ); + + const clips_a2 = [ + { id: 'c8', start: 10, dur: 80, label: 'SFX 1' }, + { id: 'c9', start: 150, dur: 60, label: 'SFX 2' }, + ].map((c) => { + const aid = `asset-${c.id}`; + addAsset(aid, c.label, c.dur, 'audio'); + return createClip({ + id: toClipId(c.id), + assetId: toAssetId(aid), + trackId: toTrackId('a2'), + timelineStart: toFrame(c.start), + timelineEnd: toFrame(c.start + c.dur), + mediaIn: toFrame(0), + mediaOut: toFrame(c.dur), + name: c.label, + }); + }); + + const v1 = createTrack({ id: 'v1', name: 'Video 1', type: 'video', clips: clips_v1 }); + const v2 = createTrack({ id: 'v2', name: 'Video 2', type: 'video', clips: clips_v2 }); + const a1 = createTrack({ id: 'a1', name: 'Audio 1', type: 'audio', clips: clips_a1 }); + const a2 = createTrack({ id: 'a2', name: 'Audio 2', type: 'audio', clips: clips_a2 }); + + const timeline = createTimeline({ + id: 'tl-1', + name: 'Demo', + fps: frameRate(fps), + duration, + tracks: [v1, v2, a1, a2], + }); + + return createTimelineState({ + timeline, + assetRegistry: assetMap as unknown as AssetRegistry, + }); +} diff --git a/apps/demo/src/styles.css b/apps/demo/src/styles.css deleted file mode 100644 index 1022c45..0000000 --- a/apps/demo/src/styles.css +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Demo App - Minimal Global Styles - * - * Only essential app-level styles. - * Timeline components use Tailwind from @timeline/ui. - */ - -@tailwind base; -@tailwind components; -@tailwind utilities; - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -#root { - width: 100%; - height: 100vh; - overflow: hidden; -} diff --git a/apps/demo/tailwind.config.js b/apps/demo/tailwind.config.js deleted file mode 100644 index 2a7c6f2..0000000 --- a/apps/demo/tailwind.config.js +++ /dev/null @@ -1,12 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: [ - "./index.html", - "./src/**/*.{js,ts,jsx,tsx}", - "../../packages/ui/src/**/*.{js,ts,jsx,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [], -} diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json index a7fc6fb..714041d 100644 --- a/apps/demo/tsconfig.json +++ b/apps/demo/tsconfig.json @@ -14,11 +14,18 @@ "noEmit": true, "jsx": "react-jsx", - /* Linting */ + /* Linting — relaxed for demo */ "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + + "paths": { + "@webpacked-timeline/core": ["../../packages/core/src/public-api.ts"], + "@webpacked-timeline/react": ["../../packages/react/src/index.ts"], + "@webpacked-timeline/ui": ["../../packages/ui/src/index.ts"], + "@webpacked-timeline/ui/styles/*": ["../../packages/ui/src/*.css"] + } }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts index 4f0a8ee..c24777a 100644 --- a/apps/demo/vite.config.ts +++ b/apps/demo/vite.config.ts @@ -1,9 +1,20 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import tailwind from '@tailwindcss/vite'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ - plugins: [react()], - server: { - port: 3001, + plugins: [tailwind(), react()], + resolve: { + alias: { + '@webpacked-timeline/ui/styles/davinci': path.resolve(__dirname, '../../packages/ui/src/davinci.css'), + '@webpacked-timeline/ui/styles/tokens': path.resolve(__dirname, '../../packages/ui/src/tokens.css'), + '@webpacked-timeline/ui': path.resolve(__dirname, '../../packages/ui/src/index.ts'), + '@webpacked-timeline/core': path.resolve(__dirname, '../../packages/core/src/public-api.ts'), + '@webpacked-timeline/react': path.resolve(__dirname, '../../packages/react/src/index.ts'), + }, }, }); diff --git a/apps/docs/.next/dev/logs/next-development.log b/apps/docs/.next/dev/logs/next-development.log deleted file mode 100644 index db12751..0000000 --- a/apps/docs/.next/dev/logs/next-development.log +++ /dev/null @@ -1 +0,0 @@ -[00:01:11.329] Server LOG ○ Compiling /_not-found/page ... diff --git a/apps/docs/.next/dev/types/cache-life.d.ts b/apps/docs/.next/dev/types/cache-life.d.ts deleted file mode 100644 index a8c6997..0000000 --- a/apps/docs/.next/dev/types/cache-life.d.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Type definitions for Next.js cacheLife configs - -declare module 'next/cache' { - export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache' - export { - updateTag, - revalidateTag, - revalidatePath, - refresh, - } from 'next/dist/server/web/spec-extension/revalidate' - export { unstable_noStore } from 'next/dist/server/web/spec-extension/unstable-no-store' - - - /** - * Cache this `"use cache"` for a timespan defined by the `"default"` profile. - * ``` - * stale: 300 seconds (5 minutes) - * revalidate: 900 seconds (15 minutes) - * expire: never - * ``` - * - * This cache may be stale on clients for 5 minutes before checking with the server. - * If the server receives a new request after 15 minutes, start revalidating new values in the background. - * It lives for the maximum age of the server cache. If this entry has no traffic for a while, it may serve an old value the next request. - */ - export function cacheLife(profile: "default"): void - - /** - * Cache this `"use cache"` for a timespan defined by the `"seconds"` profile. - * ``` - * stale: 30 seconds - * revalidate: 1 seconds - * expire: 60 seconds (1 minute) - * ``` - * - * This cache may be stale on clients for 30 seconds before checking with the server. - * If the server receives a new request after 1 seconds, start revalidating new values in the background. - * If this entry has no traffic for 1 minute it will expire. The next request will recompute it. - */ - export function cacheLife(profile: "seconds"): void - - /** - * Cache this `"use cache"` for a timespan defined by the `"minutes"` profile. - * ``` - * stale: 300 seconds (5 minutes) - * revalidate: 60 seconds (1 minute) - * expire: 3600 seconds (1 hour) - * ``` - * - * This cache may be stale on clients for 5 minutes before checking with the server. - * If the server receives a new request after 1 minute, start revalidating new values in the background. - * If this entry has no traffic for 1 hour it will expire. The next request will recompute it. - */ - export function cacheLife(profile: "minutes"): void - - /** - * Cache this `"use cache"` for a timespan defined by the `"hours"` profile. - * ``` - * stale: 300 seconds (5 minutes) - * revalidate: 3600 seconds (1 hour) - * expire: 86400 seconds (1 day) - * ``` - * - * This cache may be stale on clients for 5 minutes before checking with the server. - * If the server receives a new request after 1 hour, start revalidating new values in the background. - * If this entry has no traffic for 1 day it will expire. The next request will recompute it. - */ - export function cacheLife(profile: "hours"): void - - /** - * Cache this `"use cache"` for a timespan defined by the `"days"` profile. - * ``` - * stale: 300 seconds (5 minutes) - * revalidate: 86400 seconds (1 day) - * expire: 604800 seconds (1 week) - * ``` - * - * This cache may be stale on clients for 5 minutes before checking with the server. - * If the server receives a new request after 1 day, start revalidating new values in the background. - * If this entry has no traffic for 1 week it will expire. The next request will recompute it. - */ - export function cacheLife(profile: "days"): void - - /** - * Cache this `"use cache"` for a timespan defined by the `"weeks"` profile. - * ``` - * stale: 300 seconds (5 minutes) - * revalidate: 604800 seconds (1 week) - * expire: 2592000 seconds (1 month) - * ``` - * - * This cache may be stale on clients for 5 minutes before checking with the server. - * If the server receives a new request after 1 week, start revalidating new values in the background. - * If this entry has no traffic for 1 month it will expire. The next request will recompute it. - */ - export function cacheLife(profile: "weeks"): void - - /** - * Cache this `"use cache"` for a timespan defined by the `"max"` profile. - * ``` - * stale: 300 seconds (5 minutes) - * revalidate: 2592000 seconds (1 month) - * expire: 31536000 seconds (365 days) - * ``` - * - * This cache may be stale on clients for 5 minutes before checking with the server. - * If the server receives a new request after 1 month, start revalidating new values in the background. - * If this entry has no traffic for 365 days it will expire. The next request will recompute it. - */ - export function cacheLife(profile: "max"): void - - /** - * Cache this `"use cache"` using a custom timespan. - * ``` - * stale: ... // seconds - * revalidate: ... // seconds - * expire: ... // seconds - * ``` - * - * This is similar to Cache-Control: max-age=`stale`,s-max-age=`revalidate`,stale-while-revalidate=`expire-revalidate` - * - * If a value is left out, the lowest of other cacheLife() calls or the default, is used instead. - */ - export function cacheLife(profile: { - /** - * This cache may be stale on clients for ... seconds before checking with the server. - */ - stale?: number, - /** - * If the server receives a new request after ... seconds, start revalidating new values in the background. - */ - revalidate?: number, - /** - * If this entry has no traffic for ... seconds it will expire. The next request will recompute it. - */ - expire?: number - }): void - - - import { cacheTag } from 'next/dist/server/use-cache/cache-tag' - export { cacheTag } - - export const unstable_cacheTag: typeof cacheTag - export const unstable_cacheLife: typeof cacheLife -} diff --git a/apps/docs/.next/dev/types/routes.d.ts b/apps/docs/.next/dev/types/routes.d.ts deleted file mode 100644 index 15617e1..0000000 --- a/apps/docs/.next/dev/types/routes.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -// This file is generated automatically by Next.js -// Do not edit this file manually - -type AppRoutes = never -type PageRoutes = never -type LayoutRoutes = never -type RedirectRoutes = never -type RewriteRoutes = never -type Routes = AppRoutes | PageRoutes | LayoutRoutes | RedirectRoutes | RewriteRoutes - - -interface ParamMap { -} - - -export type ParamsOf = ParamMap[Route] - -interface LayoutSlotMap { -} - - -export type { AppRoutes, PageRoutes, LayoutRoutes, RedirectRoutes, RewriteRoutes, ParamMap } - -declare global { - /** - * Props for Next.js App Router page components - * @example - * ```tsx - * export default function Page(props: PageProps<'/blog/[slug]'>) { - * const { slug } = await props.params - * return
Blog post: {slug}
- * } - * ``` - */ - interface PageProps { - params: Promise - searchParams: Promise> - } - - /** - * Props for Next.js App Router layout components - * @example - * ```tsx - * export default function Layout(props: LayoutProps<'/dashboard'>) { - * return
{props.children}
- * } - * ``` - */ - type LayoutProps = { - params: Promise - children: React.ReactNode - } & { - [K in LayoutSlotMap[LayoutRoute]]: React.ReactNode - } -} diff --git a/apps/docs/.next/dev/types/validator.ts b/apps/docs/.next/dev/types/validator.ts deleted file mode 100644 index 000dc8e..0000000 --- a/apps/docs/.next/dev/types/validator.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file is generated automatically by Next.js -// Do not edit this file manually -// This file validates that all pages and layouts export the correct types - - - - - - - - - - - - - diff --git a/docs/CODEBASE_ARCHITECTURE.md b/docs/CODEBASE_ARCHITECTURE.md new file mode 100644 index 0000000..7eab31a --- /dev/null +++ b/docs/CODEBASE_ARCHITECTURE.md @@ -0,0 +1,621 @@ +# Timeline Editor — Complete Codebase Architecture + +> A professional, frame-accurate video/audio timeline editing system built as a TypeScript monorepo. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Monorepo Structure](#monorepo-structure) +3. [Package Dependency Graph](#package-dependency-graph) +4. [Core Package (`@webpacked-timeline/core`)](#core-package) +5. [React Adapter (`@webpacked-timeline/react`)](#react-adapter) +6. [UI Package (`@webpacked-timeline/ui`)](#ui-package) +7. [Demo Application (`@webpacked-timeline/demo-app`)](#demo-application) +8. [Data Flow: Top to Bottom](#data-flow-top-to-bottom) +9. [Type System](#type-system) +10. [Tool System](#tool-system) +11. [History & Undo/Redo](#history--undoredo) +12. [Snap System](#snap-system) +13. [Playback System](#playback-system) +14. [Serialization & Import/Export](#serialization--importexport) +15. [Invariant System](#invariant-system) +16. [Feature Matrix](#feature-matrix) + +--- + +## Overview + +This is a **framework-agnostic, deterministic, frame-based timeline editing kernel** with a React integration layer and UI component library. It follows a strict three-layer architecture: + +| Layer | Package | Responsibility | +|-------|---------|---------------| +| **Data Layer** | `@webpacked-timeline/core` | Pure state, dispatch, tools, history, validation, serialization | +| **Adapter Layer** | `@webpacked-timeline/react` | React integration: engine orchestrator, hooks, tool routing, context | +| **Presentation Layer** | `@webpacked-timeline/ui` | Visual components: tracks, clips, ruler, toolbar, playhead | +| **Application Layer** | `@webpacked-timeline/demo-app` | Demo app composing all layers into a working timeline editor | + +### Key Architectural Principles + +| Principle | Rule | +|-----------|------| +| **Three-layer law** | Core imports only stdlib + TypeScript. No React/DOM/UI. | +| **Single mutation entry** | Only `dispatch(state, transaction)` produces a new `TimelineState`. | +| **Strict immutability** | All state updates return new objects; no in-place mutation. | +| **Time type law** | All frame positions are `TimelineFrame` (branded integer); never raw `number`. | +| **Selector isolation** | Each React hook only re-renders when its specific slice of state changes. | + +--- + +## Monorepo Structure + +``` +timeline/ +├── packages/ +│ ├── core/ → @webpacked-timeline/core (pure TypeScript engine) +│ ├── react/ → @webpacked-timeline/react (React adapter layer) +│ ├── ui/ → @webpacked-timeline/ui (UI components) +│ └── demo/ → @webpacked-timeline/demo-app (working demo application) +├── apps/ +│ └── demo/ → Integration test demo (deprecated) +├── turbo.json → Turborepo build config +├── pnpm-workspace.yaml +└── package.json +``` + +--- + +## Package Dependency Graph + +``` +┌─────────────────────────────────────────────┐ +│ Application │ +│ @webpacked-timeline/demo-app │ +│ (composes all layers into working app) │ +└────────────┬───────────────┬────────────────┘ + │ │ + ▼ ▼ +┌────────────────┐ ┌────────────────────────┐ +│ @webpacked-timeline/ui │ │ (custom components) │ +│ (components) │ │ built per-app │ +└───────┬────────┘ └──────────┬─────────────┘ + │ │ + ▼ ▼ +┌─────────────────────────────────────────────┐ +│ @webpacked-timeline/react │ +│ TimelineEngine · Hooks · ToolRouter · │ +│ TimelineProvider · useSyncExternalStore │ +└────────────────────┬────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────┐ +│ @webpacked-timeline/core │ +│ dispatch · TimelineState · Tools · History │ +│ Snap · Playback · Serialization · Invariants│ +└─────────────────────────────────────────────┘ +``` + +--- + +## Core Package + +### Directory Map + +| Directory | Purpose | Key Files | +|-----------|---------|-----------| +| `engine/` | Mutation pipeline, history, playback, serialization, import/export | `dispatcher.ts`, `apply.ts`, `history.ts`, `playback-engine.ts` | +| `operations/` | High-level operation builders | `clip-operations.ts`, `track-operations.ts`, `ripple.ts` | +| `systems/` | Query helpers, asset registry, validation | `queries.ts`, `asset-registry.ts`, `validation.ts` | +| `tools/` | Framework-agnostic editing tools (12 built-in) | `selection.ts`, `razor.ts`, `slip.ts`, `ripple-trim.ts`, etc. | +| `types/` | All TypeScript types and factories (28 files) | `state.ts`, `clip.ts`, `track.ts`, `operations.ts`, `frame.ts` | +| `utils/` | Frame math, ID generation | `frame.ts`, `id.ts` | +| `validation/` | Pre-dispatch and post-dispatch checks | `invariants.ts`, `validators.ts` | + +### The Dispatch Pipeline + +This is the **only** way state changes: + +``` +Transaction { id, label, timestamp, operations[] } + │ + ▼ + dispatch(state, transaction) + │ + ┌───────────────┼───────────────┐ + │ For each operation: │ + │ 1. validateOperation(s, op) │ + │ 2. s = applyOperation(s, op)│ + └───────────────┼───────────────┘ + │ + checkInvariants(proposedState) + │ + ┌───────┴───────┐ + │ │ + violations? no violations + │ │ + { accepted: false } { accepted: true, nextState } +``` + +Rules: +- **All-or-nothing**: If any operation fails validation, zero operations are applied +- **Rolling state**: Each op validates against state after previous ops (needed for DELETE → INSERT×2) +- **Version bump**: `timeline.version` increments by 1 on each successful commit + +### Operation Primitives (58+ types) + +| Category | Operations | +|----------|-----------| +| **Clip** | `MOVE_CLIP`, `RESIZE_CLIP`, `SLICE_CLIP`, `DELETE_CLIP`, `INSERT_CLIP`, `SET_MEDIA_BOUNDS`, `SET_CLIP_ENABLED`, `SET_CLIP_REVERSED`, `SET_CLIP_SPEED`, `SET_CLIP_COLOR`, `SET_CLIP_NAME`, `SET_CLIP_TRANSFORM`, `SET_AUDIO_PROPERTIES` | +| **Track** | `ADD_TRACK`, `DELETE_TRACK`, `REORDER_TRACK`, `SET_TRACK_HEIGHT`, `SET_TRACK_NAME`, `SET_TRACK_BLEND_MODE`, `SET_TRACK_OPACITY` | +| **Transition** | `ADD_TRANSITION`, `DELETE_TRANSITION`, `SET_TRANSITION_DURATION`, `SET_TRANSITION_ALIGNMENT` | +| **Effect** | `ADD_EFFECT`, `REMOVE_EFFECT`, `REORDER_EFFECT`, `SET_EFFECT_ENABLED`, `SET_EFFECT_PARAM` | +| **Keyframe** | `ADD_KEYFRAME`, `MOVE_KEYFRAME`, `DELETE_KEYFRAME`, `SET_KEYFRAME_EASING` | +| **Asset** | `REGISTER_ASSET`, `UNREGISTER_ASSET`, `SET_ASSET_STATUS` | +| **Timeline** | `RENAME_TIMELINE`, `SET_TIMELINE_DURATION`, `SET_TIMELINE_START_TC`, `SET_SEQUENCE_SETTINGS` | +| **Marker** | `ADD_MARKER`, `MOVE_MARKER`, `DELETE_MARKER` | +| **In/Out** | `SET_IN_POINT`, `SET_OUT_POINT` | +| **Generator** | `INSERT_GENERATOR`, `ADD_CAPTION`, `EDIT_CAPTION`, `DELETE_CAPTION` | +| **Grouping** | `LINK_CLIPS`, `UNLINK_CLIPS`, `ADD_TRACK_GROUP`, `DELETE_TRACK_GROUP` | +| **Beat Grid** | `ADD_BEAT_GRID`, `REMOVE_BEAT_GRID` | + +--- + +## React Adapter + +### Architecture + +The React adapter wraps core into a `useSyncExternalStore`-compatible pattern: + +``` +┌──────────────────────────────────────────────┐ +│ TimelineEngine │ +│ │ +│ ┌─────────┐ ┌──────────┐ ┌───────────────┐ │ +│ │ History │ │ToolReg. │ │ ProvisionalMgr│ │ +│ │ Stack │ │(12 tools)│ │ (ghost state) │ │ +│ └────┬─────┘ └────┬─────┘ └──────┬────────┘ │ +│ │ │ │ │ +│ ┌────┴────┐ ┌─────┴─────┐ ┌──────┴──────┐ │ +│ │Snap Idx │ │ Track Idx │ │ Playback │ │ +│ │Manager │ │ │ │ Engine │ │ +│ └─────────┘ └───────────┘ └─────────────┘ │ +│ │ +│ subscribe() / getSnapshot() → EngineSnapshot │ +└──────────────────────────────────────────────┘ +``` + +### EngineSnapshot Shape + +```typescript +{ + state: TimelineState, // Current immutable state + provisional: ProvisionalState | null, // Ghost clips during drag + activeToolId: string, // Current tool ID + canUndo: boolean, + canRedo: boolean, + history: { canUndo, canRedo }, + trackIds: readonly string[], // Stable reference if unchanged + cursor: string, // CSS cursor for active tool + playhead: PlayheadState, // Frame position, playing, rate + change: StateChange, // Diff from previous state +} +``` + +### Hooks (Selector-Isolated) + +| Hook | Returns | Re-renders when | +|------|---------|-----------------| +| `useEngine()` | `TimelineEngine` | Never (stable ref) | +| `useTimeline()` | `Timeline` | Timeline object changes | +| `useTrackIds()` | `readonly string[]` | Tracks added/removed | +| `useTrack(id)` | `Track \| null` | That specific track changes | +| `useClip(id)` | `Clip \| null` | That specific clip changes (provisional-aware) | +| `useClips(engine, trackId)` | `readonly Clip[]` | Clips on that track change | +| `useActiveTool()` | `{ id, cursor }` | Tool switch or cursor changes | +| `useCanUndo()` / `useCanRedo()` | `boolean` | Undo/redo availability changes | +| `useProvisional()` | `ProvisionalState \| null` | During drag | +| `usePlayheadFrame(engine)` | `TimelineFrame` | Playhead position changes | +| `useIsPlaying(engine)` | `boolean` | Play state toggles | +| `useMarkers(engine)` | `readonly Marker[]` | Markers change | +| `useHistory(engine)` | `{ canUndo, canRedo }` | History state changes | +| `useToolRouter(engine, opts)` | `ToolRouterHandlers` | Stable (memoized) | +| `useVirtualWindow(engine, ...)` | `VirtualWindow` | Viewport changes | +| `useVisibleClips(engine, window)` | `VirtualClipEntry[]` | Visible clips change | + +### Tool Router Event Flow + +``` +DOM Event (pointer/keyboard) + │ + ▼ +useToolRouter handlers (React synthetic events) + │ + ▼ +Coordinate conversion: clientX/Y → frame + trackId + clipId + │ + ▼ +engine.handlePointerDown/Move/Up(converted, modifiers) + │ + ▼ +Active tool's onPointerDown/Move/Up(event, toolContext) + │ + ├── onPointerMove → ProvisionalState (ghost; never dispatches) + │ + └── onPointerUp → Transaction | null + │ + ▼ + dispatch(state, transaction) + │ + ▼ + queueMicrotask → rebuildSnapIndex +``` + +--- + +## UI Package + +### Component Hierarchy + +``` +TimelineRoot (engine, provides context, wires ToolRouter) +├── Toolbar (8 tools: Select, Razor, Trim, Roll, Slip, Slide, Hand, Zoom) +├── Ruler (timecode ticks, click-to-seek) +│ ├── MarkerPin* (point markers on ruler) +│ └── InOutHandles (in/out point drag handles) +├── Track* (per track — label area + clip container) +│ ├── Clip* (absolutely positioned, provisional-aware) +│ │ ├── ThumbnailStrip (video thumbnails) +│ │ ├── ClipLabel (name + duration) +│ │ ├── EffectLane (colored effect bands) +│ │ │ └── KeyframeDiamond* (draggable keyframe markers) +│ │ └── TransitionHandle (dissolve/wipe resize) +│ └── MarkerRange* (range marker overlays) +├── Playhead (frame line + scrub handle) +└── ZoomBar (+/- zoom controls) +``` + +### Hit-Testing via Data Attributes + +| Attribute | Component | Purpose | +|-----------|-----------|---------| +| `data-clip-id={id}` | Clip | Tool router identifies clicked clip | +| `data-track-id={id}` | Track/Clip | Tool router identifies target track | + +### Theming + +90+ CSS custom properties (`--tl-*` tokens) for full visual customization: +- **Dark Pro** (default): `registry/themes/dark-pro.css` +- **Light**: `registry/themes/light.css` +- Custom: Override any `--tl-*` variable in `:root` + +--- + +## Demo Application + +### How It Works + +``` +app.tsx +├── engine.ts → new TimelineEngine({ initialState: buildMockState() }) +├── mock-data.ts → 4 tracks, 9 clips, 9 assets @ 30fps +└── components/ + ├── TimelineRoot → Wires useToolRouter to DOM container + ├── Toolbar → 8 tool buttons calling engine.activateTool() + ├── Ruler → Timecode ticks, click-to-seek + ├── Track → Track rows with label + clip area + ├── Clip → Positioned blocks, provisional state overlay + ├── ClipLabel → Name + duration display + ├── Playhead → Red line + draggable handle + └── ZoomBar → Logarithmic zoom slider +``` + +--- + +## Data Flow: Top to Bottom + +### Complete Mutation Flow + +``` +1. User clicks/drags in UI + │ +2. DOM event captured by TimelineRoot (onPointerDown/Move/Up) + │ +3. useToolRouter converts to TimelinePointerEvent + (frame, trackId, clipId via data-* attributes) + │ +4. engine.handlePointerDown/Move/Up(event, modifiers) + │ +5. Active tool processes event: + ├── onPointerMove → returns ProvisionalState (ghost) + │ → engine stores it, hooks react + │ + └── onPointerUp → returns Transaction (or null) + │ +6. engine.dispatch(transaction) + │ +7. For each op in transaction: + a. validateOperation(rollingState, op) + b. applyOperation(rollingState, op) + │ +8. checkInvariants(proposedState) + │ +9. If accepted: + a. History push (with compression) + b. TrackIndex rebuild + c. SnapIndex schedule rebuild + d. PlaybackEngine update + e. Rebuild EngineSnapshot + f. Notify all subscribers + │ +10. useSyncExternalStore detects change + → selector picks relevant slice + → React re-renders only affected components +``` + +### Read Path + +``` +Component + │ + ▼ +useClip(clipId) / useTrack(trackId) / useTimeline() / etc. + │ + ▼ +useSyncExternalStore(engine.subscribe, () => selector(engine.getSnapshot())) + │ + ▼ +Selector extracts slice from EngineSnapshot + │ + ▼ +React compares with previous value (reference equality) + │ + ├── Same reference → no re-render + └── Different reference → re-render component +``` + +--- + +## Type System + +### Core State Shape + +```typescript +TimelineState { + timeline: Timeline { + id: string + name: string + fps: FrameRate + duration: TimelineFrame + tracks: Track[] { + id: TrackId + name: string + type: 'video' | 'audio' | 'subtitle' | 'title' + clips: Clip[] { + id: ClipId + assetId: AssetId + trackId: TrackId + timelineStart: TimelineFrame + timelineEnd: TimelineFrame + mediaIn: TimelineFrame + mediaOut: TimelineFrame + name?: string + speed?: number + effects?: Effect[] + transitions?: Transition[] + transform?: ClipTransform + audioProperties?: AudioProperties + } + height?: number + muted?: boolean + locked?: boolean + solo?: boolean + } + markers: Marker[] + inPoint?: TimelineFrame + outPoint?: TimelineFrame + beatGrid?: BeatGrid + version: number + } + assetRegistry: Map + schemaVersion: number +} +``` + +### Branded ID Types + +All IDs are branded strings for type safety: +`ClipId`, `TrackId`, `AssetId`, `EffectId`, `KeyframeId`, `TransitionId`, `ToolId`, `TrackGroupId`, `LinkGroupId`, `ProjectId`, `BinId`, `MarkerId`, `CaptionId` + +--- + +## Tool System + +### Built-in Tools (12) + +| Tool | ID | What It Does | Operations Generated | +|------|----|-------------|---------------------| +| **Selection** | `selection` | Click-select, drag-move, rubber-band | `MOVE_CLIP` | +| **Razor** | `razor` | Slice clip at frame | `DELETE_CLIP` + `INSERT_CLIP`×2 | +| **Ripple Trim** | `ripple-trim` | Trim edge + shift subsequent clips | `RESIZE_CLIP` + `MOVE_CLIP`×N | +| **Roll Trim** | `roll-trim` | Adjust adjacent edges together | `RESIZE_CLIP`×2 | +| **Slip** | `slip` | Move media within clip bounds | `SET_MEDIA_BOUNDS` | +| **Slide** | `slide` | Slide clip, adjust neighbors | `MOVE_CLIP` + `RESIZE_CLIP`×2 | +| **Ripple Delete** | `ripple-delete` | Delete + shift subsequent | `DELETE_CLIP` + `MOVE_CLIP`×N | +| **Ripple Insert** | `ripple-insert` | Insert + push subsequent | `MOVE_CLIP`×N + `INSERT_CLIP` | +| **Hand** | `hand` | Pan/scroll | (no transaction) | +| **Transition** | `transition` | Add/manage transitions | `ADD_TRANSITION` | +| **Keyframe** | `keyframe` | Add/move/delete keyframes | `ADD_KEYFRAME`, `MOVE_KEYFRAME`, `DELETE_KEYFRAME` | +| **Zoom** | `zoom` | Zoom in/out | (calls onZoomChange callback) | + +### Tool Interface + +```typescript +interface ITool { + id: ToolId; + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void; + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null; + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null; + onKeyDown(event: TimelineKeyEvent, ctx: ToolContext): Transaction | null; + onKeyUp(event: TimelineKeyEvent, ctx: ToolContext): void; + onCancel(): void; + getCursor(ctx: ToolContext): string; +} +``` + +--- + +## History & Undo/Redo + +### Dual API + +1. **Pure functions**: `createHistory`, `pushHistory`, `undo`, `redo`, `canUndo`, `canRedo` +2. **HistoryStack class**: Adds compression policies, checkpoints, persistence + +### Compression + +The `TransactionCompressor` merges consecutive similar operations (e.g., rapid MOVE_CLIPs during drag become one history entry). + +```typescript +CompressionPolicy { + maxAge: number; // Max age in ms for merge eligibility + mergeable: string[]; // Operation types that can be merged + maxConsecutiveMerges: number; +} +``` + +--- + +## Snap System + +### How It Works + +``` +buildSnapIndex(state, playheadFrame) + → Sorted array of snap points from clip boundaries, playhead, beat grid + +nearest(index, frame, radius, exclude?, allowedTypes?) + → Best snap point within radius (tiebreak: priority → sort order) + +toggleSnap(index, enabled) + → Pure toggle +``` + +### Priority Table + +| Source | Priority | +|--------|----------| +| Marker | 100 | +| In/Out Point | 90 | +| Clip Start/End | 80 | +| Playhead | 70 | +| Beat Grid | 50 | + +--- + +## Playback System + +``` +PlaybackEngine +├── PlayheadController (position, play/pause, seek, jog/shuttle) +├── Clock (browserClock / nodeClock / testClock) +├── Pipeline (VideoDecoder, AudioDecoder, Compositor) +└── State → { currentFrame, isPlaying, rate, loopRegion } +``` + +### Keyboard Shortcuts (via KeyboardHandler) + +| Key | Action | +|-----|--------| +| Space | Play/Pause | +| J | Shuttle reverse | +| K | Stop | +| L | Shuttle forward | +| ← / → | Step frame | +| Home / End | Go to start/end | + +--- + +## Serialization & Import/Export + +| Format | Import | Export | +|--------|--------|--------| +| JSON (native) | `deserializeTimeline` | `serializeTimeline` | +| OTIO | `importFromOTIO` | `exportToOTIO` | +| EDL | — | `exportToEDL` | +| AAF | — | `exportToAAF` | +| FCP XML | — | `exportToFCPXML` | +| SRT subtitles | `parseSRT` | — | +| VTT subtitles | `parseVTT` | — | + +--- + +## Invariant System + +Post-mutation checks that **reject** invalid state: + +| # | Check | Violation Type | +|---|-------|---------------| +| 1 | Schema version matches | `SCHEMA_VERSION_MISMATCH` | +| 2 | Clips sorted by `timelineStart` | `TRACK_NOT_SORTED` | +| 3 | No overlapping clips on same track | `OVERLAP` | +| 4 | Every clip's `assetId` exists | `ASSET_MISSING` | +| 5 | Asset `mediaType` matches track `type` | `TRACK_TYPE_MISMATCH` | +| 6 | `mediaIn >= 0`, `mediaOut <= intrinsicDuration` | `MEDIA_BOUNDS_INVALID` | +| 7 | Duration matches `(mediaOut - mediaIn) / speed` | `DURATION_MISMATCH` | +| 8 | `timelineEnd <= timeline.duration` | `CLIP_BEYOND_TIMELINE` | +| 9 | `speed > 0` | `SPEED_INVALID` | + +--- + +## Feature Matrix + +### Core → React → UI Coverage + +| Feature | Core | React Adapter | UI Components | Demo App | +|---------|:----:|:-------------:|:-------------:|:--------:| +| Timeline state management | ✅ | ✅ | — | ✅ | +| Clip CRUD | ✅ | ✅ (dispatch) | — | ✅ | +| Track CRUD | ✅ | ✅ (dispatch) | — | ✅ | +| Selection tool | ✅ | ✅ | ✅ | ✅ | +| Razor tool | ✅ | ✅ | ✅ | ✅ | +| Ripple trim | ✅ | ✅ | ✅ | ✅ | +| Roll trim | ✅ | ✅ | ✅ | ✅ | +| Slip tool | ✅ | ✅ | ✅ | ✅ | +| Slide tool | ✅ | ✅ | ✅ | ✅ | +| Hand tool | ✅ | ✅ | ✅ | ✅ | +| Zoom tool | ✅ | ✅ | ✅ | ✅ | +| Undo/Redo | ✅ | ✅ | — | ✅ | +| History compression | ✅ | ✅ | — | ✅ | +| Snap-to-edge | ✅ | ✅ | — | ✅ | +| Provisional/ghost state | ✅ | ✅ | ✅ | ✅ | +| Playhead display | ✅ | ✅ | ✅ | ✅ | +| Playhead seeking | ✅ | ✅ | ✅ | ✅ | +| Tool cursor feedback | ✅ | ✅ | ✅ | ✅ | +| Markers | ✅ | ✅ (read) | ✅ | — | +| Time ruler | — | — | ✅ | ✅ | +| Zoom controls | ✅ | — | ✅ | ✅ | +| Keyboard shortcuts | ✅ | ✅ | — | ✅ | +| Effects | ✅ | — | ✅ (display) | — | +| Keyframes | ✅ | — | ✅ (display) | — | +| Transitions | ✅ | — | ✅ (display) | — | +| Serialization | ✅ | — | — | — | +| OTIO/EDL/AAF export | ✅ | — | — | — | +| Subtitle import | ✅ | — | — | — | +| Project model | ✅ | — | — | — | +| Track groups | ✅ | — | — | — | +| Link groups | ✅ | — | — | — | +| Audio properties | ✅ | — | — | — | +| Clip transforms | ✅ | — | — | — | +| Waveform display | — | — | ✅ | — | +| Thumbnail strip | — | — | ✅ | — | +| Virtual scrolling | ✅ | ✅ | — | — | + +--- + +## How To Add a New Feature (End-to-End) + +1. **Core**: Add new `OperationPrimitive` type → implement in `applyOperation` → add validation in `validators.ts` → add invariant check if needed +2. **React**: The operation is already available via `engine.dispatch()`. Optionally add a dedicated hook for reading the new state +3. **UI**: Add component that reads via hook and renders. User interactions create transactions via tool router or direct dispatch +4. **Demo**: Wire the new component into the app layout diff --git a/docs/DEMO_DIAGNOSIS.md b/docs/DEMO_DIAGNOSIS.md new file mode 100644 index 0000000..0c1a1b5 --- /dev/null +++ b/docs/DEMO_DIAGNOSIS.md @@ -0,0 +1,105 @@ +# Demo app operations diagnosis + +Reference for manual checks. Hit-testing is now implemented in the adapter (see below). + +## Hit-testing fix (implemented) + +- **packages/react/src/adapter/tool-router.ts**: `convertPointerEvent` walks up the DOM from `e.target` and reads `data-clip-id` / `data-track-id` from elements. It sets `clipId`, `trackId`, and optional `edge` ('left' | 'right' | 'none') for clip edge hit zone. Tools now receive correct clipId/trackId when clicking clips or track empty space. +- **packages/demo**: Track clip area has `data-track-id`; Clip already had `data-clip-id` and `data-track-id` on the outer div. Diagnostic code (window.__tl_engine, dispatch logger, handlers log) has been removed. + +## How to run + +1. Start the demo: `pnpm --filter @webpacked-timeline/demo-app dev` +2. Open the app in the browser and open DevTools → Console. +3. Run through the actions below and note results. +4. Run the Part 2 console commands. +5. Inspect a clip element for `data-clip-id` / `data-track-id` (Part 4). +6. After clicking/dragging a clip, check whether any `dispatch:` log appeared (Part 5). + +--- + +## PART 1 — Console diagnosis + +| # | Action | What happened | Console errors/warnings | +|---|--------|----------------|-------------------------| +| 1 | Click a clip | _e.g. no highlight_ | _none_ | +| 2 | Toolbar "Razor" → click a clip | _e.g. no split_ | | +| 3 | Toolbar "Select" → drag a clip | _e.g. no move_ | | +| 4 | Click the ruler | _e.g. playhead moves / no change_ | | +| 5 | Drag zoom slider | _e.g. clips resize_ | | +| 6 | Press Space | _e.g. play/pause or nothing_ | | +| 7 | Press ArrowRight | _e.g. step forward or nothing_ | | + +--- + +## PART 2 — Engine in console + +```js +console.log(window.__tl_engine) // expect: engine object +window.__tl_engine.getActiveToolId() // expect: 'selection' +window.__tl_engine.getSnapshot().activeToolId // expect: matches above +``` + +Result: _e.g. engine present, getActiveToolId() returns 'selection'_ + +--- + +## PART 3 — ToolRouter handlers + +From the one-time log on load: + +**handlers keys:** _e.g. `['onPointerDown','onPointerMove','onPointerUp','onPointerLeave','onKeyDown']`_ + +The TimelineRoot div has `{...handlers}` (onPointerDown/Move/Up/Leave, onKeyDown). No other element should sit on top and block pointer events (check z-index / pointer-events in DevTools if clicks don’t reach the root). + +--- + +## PART 4 — Clip data attributes + +Right‑click a clip → Inspect. Check for: + +- `data-clip-id="..."` +- `data-track-id="..."` + +Result: _e.g. both present on the clip div_ + +--- + +## PART 5 — Dispatch on interaction + +When you click or drag a clip, does the console show a `dispatch: ...` line? + +Result: _e.g. No — dispatch is not called on clip click/drag_ + +--- + +## Root cause (why clip actions don’t work) + +The demo uses `useToolRouter` from `@webpacked-timeline/react`, which is backed by the **adapter** tool router (`packages/react/src/adapter/tool-router.ts`). That adapter: + +- Converts pointer events to `TimelinePointerEvent` with **frame** (from x and zoom). +- Always sets **trackId: null** and **clipId: null** (it has no layout or hit-test). + +Core tools (selection, razor, slip, etc.) all depend on `event.clipId` and/or `event.trackId`. When those are null they no-op (e.g. “if (event.clipId === null) return”), so: + +- Clicking a clip does not select it. +- Razor does not split. +- Select + drag does not move. + +The **full** tool router in `packages/react/src/tool-router.ts` does hit-testing: it takes a `getLayout()` that returns `timelineOriginX`, `pixelsPerFrame`, and **trackLayouts** (each track’s top/height in client coords), and it populates `frame`, `trackId`, and `clipId` via `frameAtX`, `trackAtY`, and `clipAtFrame`. That implementation is **not** currently exported from `@webpacked-timeline/react` (only the adapter is). So to fix the demo: + +1. **Option A**: Export the full `createToolRouter` from `@webpacked-timeline/react` (and a `useToolRouter` that uses it) and pass a `getLayout()` from the demo that computes track layouts from the DOM or from track positions (e.g. track index × track height + ruler height). +2. **Option B**: In the demo, use a local/copy of the full tool router and call it with `getLayout()` so that pointer events get correct `clipId`/`trackId` before being sent to the engine. + +After wiring layout and hit-testing, clip selection, razor, and drag should work; then you can re-check Parts 1 and 5 (dispatch should be called when committing a tool action). + +--- + +## SelectionTool and selection visual + +**SelectionTool** (`packages/core/src/tools/selection.ts`) keeps selection in a private instance Set; it does not dispatch SET_SELECTION and the engine has no selection in snapshot. The demo cannot show selection highlight until the engine exposes selection (e.g. useSelection hook). Clip click and drag work; the UI just will not show which clips are selected. + +## After diagnosis + +- Remove or guard the `console.log('handlers:', ...)` in `timeline-root.tsx` if you don’t want it in dev. +- Remove the `window.__tl_engine` assignment and the `dispatch` wrapper in `engine.ts` when you’re done debugging. diff --git a/docs/UNDERSTANDING_THE_CODE.md b/docs/UNDERSTANDING_THE_CODE.md new file mode 100644 index 0000000..5bf0d2d --- /dev/null +++ b/docs/UNDERSTANDING_THE_CODE.md @@ -0,0 +1,175 @@ +# Understanding the code + +A short guide to how this repo is structured and how to read it. + +--- + +## 1. Repo at a glance + +- **Monorepo** (pnpm workspaces + Turbo). Three main packages: + - **`packages/core`** — Timeline kernel: state, dispatch, operations, tools, snap. No React/DOM. + - **`packages/react`** — Adapter: `TimelineProvider`, hooks (`useTimeline`, `useClip`, …), tool router. Imports `@webpacked-timeline/core` + React. + - **`packages/ui`** — Components: ``, ``, etc. Imports `@webpacked-timeline/react` and `@webpacked-timeline/core`. + +- **Rule:** Lower layers never import from higher layers. Core is the foundation. + +- **Commands:** From repo root: `pnpm install`, `pnpm build`, `pnpm test`. Per-package: `pnpm --filter @webpacked-timeline/core test`, etc. + +--- + +## 2. Where to start reading + +### If you want to see “how does one edit get applied?” + +1. **Entry point for mutation:** + `packages/core/src/engine/dispatcher.ts` + - Single function: `dispatch(state, transaction)`. + - Read the file top to bottom; it’s short. It: validates each op against *rolling* state, applies each op, runs `checkInvariants`, then returns `nextState` with version bumped. + +2. **What gets applied:** + `packages/core/src/engine/apply.ts` + - `applyOperation(state, op)` — one big `switch (op.type)` that returns a new state. No validation here; that’s in validators. + +3. **What is a “transaction”:** + `packages/core/src/types/operations.ts` + - `Transaction`: `{ id, label, timestamp, operations: OperationPrimitive[] }`. + - `OperationPrimitive`: discriminated union (`MOVE_CLIP`, `RESIZE_CLIP`, `INSERT_CLIP`, …). + - So: “one edit” = one `Transaction` (often one op; tools like Ripple emit multiple ops in one transaction). + +4. **Who produces transactions:** + Tools. Example: `packages/core/src/tools/slip.ts` — `onPointerUp` returns a `Transaction` with a single `SET_MEDIA_BOUNDS` op. + Another: `packages/core/src/tools/ripple-delete.ts` — builds `DELETE_CLIP` + several `MOVE_CLIP` ops. + +**Trace path:** Tool `onPointerUp` → returns `Transaction` → adapter calls `dispatch(state, transaction)` → `dispatcher.ts` → `validateOperation` (validators.ts) + `applyOperation` (apply.ts) + `checkInvariants` (invariants.ts) → `nextState`. + +--- + +### If you want to see “what is the state?” + +1. **State shape:** + `packages/core/src/types/state.ts` + - `TimelineState`: `{ schemaVersion, timeline, assetRegistry }`. + +2. **Timeline and tracks:** + `packages/core/src/types/timeline.ts` — `Timeline` (fps, duration, tracks, version). + `packages/core/src/types/track.ts` — `Track` (id, name, type, clips[], locked, …). + `packages/core/src/types/clip.ts` — `Clip` (timelineStart/End, mediaIn/Out, assetId, trackId, …). + +3. **Assets:** + `packages/core/src/types/asset.ts` — `Asset`; registry is `ReadonlyMap`. + +4. **Frames:** + `packages/core/src/types/frame.ts` — `TimelineFrame` (branded number), `FrameRate`, `toFrame()`. All positions in the engine use `TimelineFrame`, never raw `number`. + +So: “the code” for “what is the state” lives in `packages/core/src/types/`. + +--- + +### If you want to see “how do tools work?” + +1. **Contract:** + `packages/core/src/tools/types.ts` + - `ITool`: `onPointerDown`, `onPointerMove` (returns `ProvisionalState | null` — ghost), `onPointerUp` (returns `Transaction | null`). + - `ToolContext`: state, snapIndex, pixelsPerFrame, frameAtX, trackAtY, snap(). + - Rule: `onPointerMove` never calls dispatch; only `onPointerUp` returns a transaction. + +2. **Registry and default:** + `packages/core/src/tools/registry.ts` + - `createRegistry(tools, defaultId)`, `activateTool(registry, id)` (calls outgoing tool’s `onCancel()`), `NoOpTool`. + +3. **One simple tool:** + `packages/core/src/tools/slip.ts` — Slip = change media in/out without moving timeline range; only `SET_MEDIA_BOUNDS`. + Then try: `packages/core/src/tools/selection.ts` (selection + move), or `packages/core/src/tools/razor.ts` (slice = DELETE + two INSERTs). + +4. **How the adapter wires tools:** + `packages/react/src/tool-router.ts` — turns DOM events into `TimelinePointerEvent` / `TimelineKeyEvent`, calls active tool, applies provisional state or commits transaction via engine. + +So: “how tools work” = `core/tools/types.ts` (contract) → any `core/tools/*.ts` (implementation) → `react/tool-router.ts` (wiring). + +--- + +### If you want to see “how does undo/redo work?” + +- **History lives outside dispatch.** + `packages/core/src/engine/history.ts` + - `HistoryState`: `{ past, present, future, limit }`. + - `pushHistory(history, newState)` — present goes to past, newState becomes present, future cleared. + - `undo` / `redo` move between past/present/future. + - The *caller* (e.g. TimelineEngine or React adapter) does: if `dispatch` accepted, then `pushHistory(history, result.nextState)`. + +- **Engine usage:** + `packages/core/src/engine/timeline-engine.ts` — holds `HistoryState`, subscribes listeners, and (in the legacy path) pushes state after each operation. So “where is history used” is in that class and in whatever calls `dispatch` and then pushes. + +--- + +### If you want to see “how does snapping work?” + +- **Snap index:** + `packages/core/src/snap-index.ts` + - `buildSnapIndex(state, playheadFrame)` — collects clip start/end and playhead into a sorted list of `SnapPoint`. + - `nearest(index, frame, radiusFrames, exclude?, allowedTypes?)` — returns best snap within radius. + - Rule: index is built *after* an accepted dispatch (e.g. `queueMicrotask`), never during a drag. +- **Tools use it via `ToolContext.snap(frame, exclude?, allowedTypes?)`** — the adapter builds the index and passes a `snap` function so tools don’t deal with radius or enabled flag. + +--- + +## 3. Layer summary + +| Layer | Purpose | Key files / concepts | +|--------|----------------------------------|-----------------------------------------------| +| **core** | State, dispatch, operations, tools, snap | `dispatcher`, `apply`, `types/*`, `tools/*`, `snap-index` | +| **react** | State subscription, hooks, tool router | `TimelineProvider`, `useTimeline` / `useClip` / `useEngine`, `tool-router` | +| **ui** | Rendering timeline and clips | `Timeline`, `Clip`, layout and hit-test | + +So: “understanding the code” by layer = core (data + rules), react (glue + events), ui (pixels). + +--- + +## 4. Deep dives (docs in repo) + +- **Architecture and rules:** + `.claude/skills/ARCHITECTURE.md` — three-layer law, single mutation, immutability, time types. + +- **Full HLD/LLD and mind maps:** + `packages/core/docs/ARCHITECTURE_HLD_LLD.md` — high/low-level design, module list, text + Mermaid mind map. + `packages/core/docs/MINDMAP.md` — Mermaid-only mind maps and flow diagrams. + +- **Dispatcher and validation:** + `.claude/skills/core/DISPATCHER.md` — exact dispatch algorithm, MOVE_CLIP ordering. + `.claude/skills/core/OPERATIONS.md` — each primitive, validators, compound patterns. + `.claude/skills/core/INVARIANTS.md` — the nine invariant checks. + +- **Tools:** + `.claude/skills/tools/ITOOL_CONTRACT.md` — ITool contract, capture-before-reset, testing. + +- **Types:** + `.claude/skills/core/TYPES.md` — canonical type definitions. + +--- + +## 5. Quick reference: one edit end-to-end + +1. User drags a clip (e.g. Slip tool). +2. **UI** sends pointer events to **React** `ToolRouter`. +3. **ToolRouter** converts to `TimelinePointerEvent`, calls **active tool** (`SlipTool`). +4. On move: tool returns **ProvisionalState** (ghost); UI shows it. +5. On up: tool returns **Transaction** (e.g. one `SET_MEDIA_BOUNDS`). +6. Adapter calls **`dispatch(engine.getState(), transaction)`** in core. +7. **Dispatcher** validates, applies, checks invariants; returns **`{ accepted, nextState }`**. +8. Adapter pushes **nextState** into **history** and updates engine state. +9. **Snap index** is rebuilt (e.g. next microtask). +10. **Subscribers** (e.g. React) re-render from new state. + +So: “understand the code” along one edit = follow from UI → tool → transaction → dispatch → state → history → UI. + +--- + +## 6. Tests as a guide + +- **Core:** + `packages/core/src/__tests__/` — e.g. `dispatcher.test.ts`, `invariants.test.ts`, `history.test.ts`; `tools/` for each tool. Tests show how to build state, build transactions, and assert on `dispatch` and `checkInvariants`. + +- **React:** + `packages/react/src/__tests__/` — provider, hooks, tool-router. Good for seeing how the adapter uses core. + +Reading the tests for “dispatch” and “one tool” (e.g. Slip or Selection) gives a concrete picture of how the code is used and what it guarantees. diff --git a/package.json b/package.json index 31dae6f..5737ced 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,17 @@ "build": "turbo run build", "dev": "turbo run dev", "lint": "turbo run lint", - "test": "turbo run test" + "test": "turbo run test", + "changeset": "changeset", + "version-packages": "changeset version", + "release": "pnpm build --recursive && changeset publish" }, "keywords": [], "author": "", - "license": "ISC", + "license": "MIT", "packageManager": "pnpm@10.28.2", "devDependencies": { + "@changesets/cli": "^2.29.8", "turbo": "^2.8.3" } } diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md new file mode 100644 index 0000000..0ad38e8 --- /dev/null +++ b/packages/core/CHANGELOG.md @@ -0,0 +1,36 @@ +# Changelog + +All notable changes to `@webpacked-timeline/core` are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.0-beta.1] - 2026-03-07 + +### Added +- Core state model: `Timeline`, `Track`, `Clip`, `Asset` with branded IDs (`ClipId`, `TrackId`, `AssetId`, `TimelineFrame`, `FrameRate`) +- Factory functions: `createTimeline`, `createTrack`, `createClip`, `createAsset`, `createTimelineState` +- Frame utilities: `toFrame`, `frameRate`, `framesToTimecode`, `framesToSeconds`, `secondsToFrames`, `FrameRates`, drop-frame support +- Atomic dispatcher with rolling-state validation (`dispatch`) +- 40+ operation primitives: `MOVE_CLIP`, `RESIZE_CLIP`, `SLICE_CLIP`, `DELETE_CLIP`, `INSERT_CLIP`, `SET_MEDIA_BOUNDS`, `SET_CLIP_ENABLED`, `SET_CLIP_SPEED`, `ADD_TRACK`, `DELETE_TRACK`, `REORDER_TRACK`, `REGISTER_ASSET`, `ADD_MARKER`, `MOVE_MARKER`, `DELETE_MARKER`, `SET_IN_POINT`, `SET_OUT_POINT`, `ADD_BEAT_GRID`, `INSERT_GENERATOR`, `ADD_CAPTION`, `EDIT_CAPTION`, `DELETE_CAPTION`, `ADD_EFFECT`, `REMOVE_EFFECT`, `ADD_KEYFRAME`, `MOVE_KEYFRAME`, `DELETE_KEYFRAME`, `SET_CLIP_TRANSFORM`, `SET_AUDIO_PROPERTIES`, `ADD_TRANSITION`, `DELETE_TRANSITION`, `LINK_CLIPS`, `UNLINK_CLIPS`, and more +- Invariant checker with 9 validation rules (`checkInvariants`) +- `HistoryStack` with undo/redo and configurable limit +- `TransactionCompressor` for merging rapid sequential edits +- Tool system: `ITool` interface, `ToolRegistry`, `ProvisionalManager` for drag previews +- 12 built-in tools: `SelectionTool`, `RazorTool`, `RippleTrimTool`, `RollTrimTool`, `SlipTool`, `SlideTool`, `RippleDeleteTool`, `RippleInsertTool`, `HandTool`, `TransitionTool`, `KeyframeTool`, `ZoomTool` +- Snap system: `SnapIndexManager`, `buildSnapIndex`, `nearest` +- `PlayheadController` with play/pause/seek and J/K/L shuttle +- `PlaybackEngine` with pipeline contracts (`VideoDecoder`, `AudioDecoder`, `Compositor`) +- `KeyboardHandler` with configurable key bindings +- Versioned JSON serialization: `serializeTimeline`, `deserializeTimeline` with automatic migration +- Export: `exportToOTIO`, `importFromOTIO`, `exportToEDL`, `exportToAAF`, `exportToFCPXML` +- SRT/VTT subtitle import: `parseSRT`, `parseVTT`, `subtitleImportToOps` +- Project model: `Project`, `Bin` with `addTimeline`, `addBin`, `serializeProject`, `deserializeProject` +- `IntervalTree` for O(log n) clip lookup +- `TrackIndex` for fast track-level queries +- `ThumbnailCache` (LRU) and `ThumbnailQueue` (priority) +- Virtual windowing: `getVisibleClips`, `getVisibleFrameRange` +- `diffStates` for efficient state change detection +- Effects, keyframes, easing curves, clip transforms, audio properties +- Transitions with alignment and duration controls +- Track groups and link groups +- Clock abstraction: `browserClock`, `nodeClock`, `createTestClock` +- 852 tests passing diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..2273506 --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,162 @@ +# @webpacked-timeline/core + +Headless TypeScript engine for professional NLE timeline editing. Framework-agnostic, fully tested, zero dependencies. + +## Install + +```bash +npm install @webpacked-timeline/core +``` + +## Features + +- **40+ atomic operations** — `MOVE_CLIP`, `RESIZE_CLIP`, `SLICE_CLIP`, `INSERT_CLIP`, `DELETE_CLIP`, `SET_MEDIA_BOUNDS`, `ADD_TRACK`, `DELETE_TRACK`, `ADD_MARKER`, `ADD_EFFECT`, `ADD_KEYFRAME`, `ADD_TRANSITION`, `LINK_CLIPS`, and more +- **Tool system** — Selection, Razor, Ripple Trim, Roll Trim, Slip, Slide, Ripple Delete, Ripple Insert, Hand, Transition, Keyframe, Zoom +- **Undo/redo** with transaction compression +- **Playback engine** with J/K/L shuttle control via `KeyboardHandler` +- **Snap system** — `SnapIndexManager` with configurable snap points +- **Virtual windowing** — `getVisibleClips()` / `getVisibleFrameRange()` for large timelines +- **Export** — OTIO, EDL (CMX 3600), AAF, FCP XML +- **Serialization** — versioned JSON with `serializeTimeline` / `deserializeTimeline` +- **Import** — SRT and VTT subtitle import +- **Project model** — multi-timeline container with bin/folder hierarchy +- **Interval tree** — O(log n) clip lookup via `IntervalTree` / `TrackIndex` +- **Branded types** — `TimelineFrame`, `ClipId`, `TrackId`, `FrameRate` are distinct at compile time +- **Zero dependencies** + +## Quick Start + +```typescript +import { + createTimelineState, + createTimeline, + createTrack, + createClip, + dispatch, + checkInvariants, + toFrame, + toTrackId, + toClipId, + toAssetId, + frameRate, +} from '@webpacked-timeline/core'; + +// 1. Build initial state +const state = createTimelineState({ + timeline: createTimeline({ + id: 'tl-1', + name: 'My Timeline', + fps: frameRate(30), + duration: toFrame(9000), + tracks: [ + createTrack({ id: toTrackId('v1'), name: 'Video 1', type: 'video' }), + ], + }), +}); + +// 2. Dispatch an operation +const result = dispatch(state, { + id: 'tx-1', + label: 'Insert clip', + timestamp: Date.now(), + operations: [{ + type: 'INSERT_CLIP', + trackId: toTrackId('v1'), + clip: createClip({ + id: toClipId('clip-1'), + assetId: toAssetId('asset-1'), + trackId: toTrackId('v1'), + timelineStart: toFrame(0), + timelineEnd: toFrame(90), + mediaIn: toFrame(0), + mediaOut: toFrame(90), + name: 'Intro', + }), + }], +}); + +// 3. Validate +if (result.ok) { + const violations = checkInvariants(result.state); + console.log(violations); // [] +} +``` + +## Playback + +```typescript +import { PlaybackEngine, browserClock } from '@webpacked-timeline/core'; + +const playback = new PlaybackEngine( + state, + { videoDecoder, compositor }, // PipelineConfig + { width: 1920, height: 1080 }, + browserClock, +); + +playback.play(); +``` + +## Serialization + +```typescript +import { + serializeTimeline, + deserializeTimeline, + exportToOTIO, + exportToEDL, + exportToAAF, + exportToFCPXML, +} from '@webpacked-timeline/core'; + +// JSON round-trip +const json = serializeTimeline(state); +const restored = deserializeTimeline(json); + +// Industry formats +const otio = exportToOTIO(state); +const edl = exportToEDL(state); +const aaf = exportToAAF(state); +const fcpxml = exportToFCPXML(state); +``` + +## Architecture + +- **Immutable state** — every operation returns a new object; unchanged clips keep their reference identity +- **Rolling-state validation** — each op in a compound transaction is validated against the result of the previous op +- **Branded types** — `TimelineFrame`, `ClipId`, `TrackId` are distinct types at compile time +- **No DOM, no React** — safe to use in Workers, Node.js, or Electron main process + +## API Reference + +### Factories +`createTimeline`, `createTrack`, `createClip`, `createAsset`, `createTimelineState` + +### Frame Utilities +`toFrame`, `frameRate`, `framesToTimecode`, `framesToSeconds`, `secondsToFrames`, `FrameRates` + +### State Management +`dispatch`, `checkInvariants`, `HistoryStack`, `TransactionCompressor` + +### Tools +`SelectionTool`, `RazorTool`, `RippleTrimTool`, `RollTrimTool`, `SlipTool`, `SlideTool`, `RippleDeleteTool`, `RippleInsertTool`, `HandTool`, `TransitionTool`, `KeyframeTool`, `ZoomTool` + +### Playback +`PlaybackEngine`, `PlayheadController`, `KeyboardHandler`, `browserClock`, `nodeClock` + +### Serialization & Export +`serializeTimeline`, `deserializeTimeline`, `exportToOTIO`, `importFromOTIO`, `exportToEDL`, `exportToAAF`, `exportToFCPXML` + +### Performance +`IntervalTree`, `TrackIndex`, `SnapIndexManager`, `ThumbnailCache`, `ThumbnailQueue`, `getVisibleClips` + +## Tests + +```bash +pnpm --filter @webpacked-timeline/core test +# 852 tests, 0 TypeScript errors +``` + +## License + +MIT diff --git a/packages/core/docs/ARCHITECTURE_HLD_LLD.md b/packages/core/docs/ARCHITECTURE_HLD_LLD.md new file mode 100644 index 0000000..79640b3 --- /dev/null +++ b/packages/core/docs/ARCHITECTURE_HLD_LLD.md @@ -0,0 +1,411 @@ +# @webpacked-timeline/core — Architecture (HLD & LLD) + +High-Level Design (HLD) and Low-Level Design (LLD) for the timeline editing kernel. + +--- + +## 1. High-Level Design (HLD) + +### 1.1 Purpose + +`@webpacked-timeline/core` is a **framework-agnostic**, **immutable** timeline editing kernel. It: + +- Holds the single source of truth: **TimelineState** (timeline + asset registry). +- Exposes **one** mutation entry point: **dispatch(state, transaction)**. +- Provides **tools** (Selection, Razor, Slip, Ripple*, Roll Trim, Hand) that produce **Transactions** from pointer/key events. +- Supports **undo/redo** via snapshot history; **snap-to-edge** via a **SnapIndex**; and **provisional (ghost)** state during drags. + +### 1.2 Architectural Principles + +| Principle | Rule | +|-----------|------| +| **Three-layer law** | `core` imports only stdlib + TypeScript. No React, DOM, or UI. | +| **Single mutation entry** | Only `dispatch(state, transaction)` produces a new `TimelineState`. | +| **Strict immutability** | All state updates return new objects; no in-place mutation. | +| **Time type law** | All frame positions are `TimelineFrame` (branded integer); never raw `number`. | + +### 1.3 Top-Level Components + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ @webpacked-timeline/core │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ TYPES │ Canonical data: TimelineState, Clip, Track, Asset, │ +│ │ OperationPrimitive, Transaction, TimelineFrame, IDs │ +├──────────────────┼──────────────────────────────────────────────────────────┤ +│ ENGINE │ dispatch() → validate → apply → invariants → nextState │ +│ │ HistoryStack (past / present / future) │ +│ │ TimelineEngine (optional OOP wrapper + legacy API) │ +├──────────────────┼──────────────────────────────────────────────────────────┤ +│ VALIDATION │ Per-op validators (before apply) │ +│ │ checkInvariants() (after full proposed state) │ +├──────────────────┼──────────────────────────────────────────────────────────┤ +│ OPERATIONS │ apply.ts: pure applier per OperationPrimitive │ +│ │ clip/track/timeline/ripple: compound Transaction builders│ +├──────────────────┼──────────────────────────────────────────────────────────┤ +│ TOOLS │ ITool: onPointerDown/Move/Up, onKeyDown/Up, onCancel │ +│ │ Registry, NoOpTool, ProvisionalState for ghosts │ +├──────────────────┼──────────────────────────────────────────────────────────┤ +│ SNAP │ SnapIndex (ClipStart/End, Playhead; Phase 2: Marker, etc.)│ +│ │ buildSnapIndex(), nearest(), toggleSnap() │ +├──────────────────┼──────────────────────────────────────────────────────────┤ +│ SYSTEMS │ Queries (findClipById, getClipsAtFrame, …) │ +│ │ Asset registry helpers; validation helpers │ +└──────────────────┴──────────────────────────────────────────────────────────┘ +``` + +### 1.4 Data Flow (Mutation Path) + +``` +User / Adapter + │ + ▼ + Transaction (id, label, operations: OperationPrimitive[]) + │ + ▼ + dispatch(state, transaction) + │ + ├─► For each op: validateOperation(rollingState, op) ──► reject? → return { accepted: false } + │ + ├─► proposedState = applyOperation(proposedState, op) (rolling state) + │ + ├─► checkInvariants(proposedState) ──► violations? → return { accepted: false } + │ + └─► return { accepted: true, nextState: { ...proposedState, version+1 } } +``` + +History is **not** inside the dispatcher: the **caller** (e.g. TimelineEngine or React adapter) does `pushHistory(history, result.nextState)` after acceptance. + +### 1.5 Tool → Transaction Flow + +``` +DOM events (from adapter) + │ + ▼ + ToolRouter → frameAtX, trackAtY, clipId hit-test + │ + ▼ + ITool.onPointerDown / onPointerMove / onPointerUp + │ + ├─► onPointerMove → ProvisionalState | null (ghost; never dispatch) + │ + └─► onPointerUp → Transaction | null + │ + ▼ + dispatch(state, transaction) +``` + +SnapIndex is built **after** an accepted dispatch (e.g. via `queueMicrotask`); tools receive a **read-only** SnapIndex in `ToolContext` and never rebuild it during a drag. + +--- + +## 2. Low-Level Design (LLD) + +### 2.1 Type System (`types/`) + +| File | Responsibility | +|------|----------------| +| `state.ts` | `TimelineState`, `AssetRegistry`, `createTimelineState`, `CURRENT_SCHEMA_VERSION` | +| `timeline.ts` | `Timeline`, `SequenceSettings`, `createTimeline` | +| `track.ts` | `Track`, `TrackId`, `TrackType`, `createTrack`, `sortTrackClips` | +| `clip.ts` | `Clip`, `ClipId`, `createClip`, `getClipDuration`, `clipContainsFrame`, `clipsOverlap` | +| `asset.ts` | `Asset`, `AssetId`, `AssetStatus`, `createAsset` | +| `frame.ts` | `TimelineFrame`, `FrameRate`, `Timecode`, `RationalTime`, `toFrame`, `toTimecode`, `FrameRates` | +| `operations.ts` | `OperationPrimitive` (discriminated union), `Transaction`, `DispatchResult`, `RejectionReason`, `InvariantViolation`, `ViolationType` | + +**Invariants:** All frame positions in the engine are `TimelineFrame`. `Timecode` is display-only; `RationalTime` is ingest/export only. + +### 2.2 Engine (`engine/`) + +| File | Responsibility | +|------|----------------| +| **dispatcher.ts** | `dispatch(state, transaction): DispatchResult`. Steps: (1) for each op validate against rolling state; (2) apply op to rolling state; (3) `checkInvariants(proposedState)`; (4) bump `timeline.version` and return `nextState`. No history, no events, no async. | +| **apply.ts** | `applyOperation(state, op): TimelineState`. Pure switch on `op.type`; no validation. Uses helpers: `updateTrack`, `updateTrackOfClip`, `updateClip`. Never mutates. | +| **history.ts** | `HistoryState = { past, present, future, limit }`. Pure: `createHistory`, `pushHistory`, `undo`, `redo`, `canUndo`, `canRedo`, `getCurrentState`, `clearHistory`. Limit eviction drops oldest from `past`. | +| **timeline-engine.ts** | Optional OOP facade: holds `HistoryState`, subscribes listeners, exposes legacy API (e.g. `addClip`, `rippleDelete`) via a **legacy shim** that calls old operation functions and pushes resulting state into history. Phase 1+ uses `dispatch` + transaction from adapter. | +| **transactions.ts** | Legacy transaction context: `beginTransaction`, `applyOperation` (state→state), `commitTransaction`, `rollbackTransaction`. Used by ripple/track/timeline operation modules that return new state; not the same as the `Transaction` type (batch of OperationPrimitives). | + +### 2.3 Validation (`validation/`) + +| File | Responsibility | +|------|----------------| +| **validators.ts** | `validateOperation(state, op): Rejection | null`. Per–operation-primitive checks (clip exists, track not locked, no overlap, bounds, asset in registry, etc.). Returns `{ reason, message }` or null. | +| **invariants.ts** | `checkInvariants(state): InvariantViolation[]`. Nine checks (order matters): schema version; track clips sorted; no overlap; asset missing; track type mismatch; media bounds; duration/speed; clip beyond timeline; speed > 0. No short-circuit; collect all violations. | + +### 2.4 Operations (`operations/`) + +| File | Responsibility | +|------|----------------| +| **clip-operations.ts** | `addClip`, `removeClip`, `moveClip`, `resizeClip`, `trimClip`, `moveClipToTrack`, `updateClip`. Each returns new `TimelineState` (used by legacy engine path and by compound builders). | +| **track-operations.ts** | `addTrack`, `removeTrack`, `moveTrack`, `setTrackHeight`, `toggleTrackMute/Lock/Solo`, etc. | +| **timeline-operations.ts** | `setTimelineDuration`, `setTimelineName`, etc. | +| **ripple.ts** | `rippleDelete`, `rippleTrim`, `insertEdit`, `rippleMove`, `insertMove`. Build sequences of internal state transforms (via legacy transaction context) and return new state. Used by TimelineEngine legacy API. | + +**Compound Transaction patterns (for dispatch path):** Slice = DELETE_CLIP + INSERT_CLIP×2; Ripple Delete = DELETE_CLIP + MOVE_CLIP×N (left-to-right for −delta); Roll Trim = RESIZE_CLIP(end, A) + RESIZE_CLIP(start, B); Ripple Trim = RESIZE_CLIP + MOVE_CLIP×N; etc. MOVE_CLIP order for same-direction shifts: right-to-left for +delta, left-to-right for −delta. + +### 2.5 Tools (`tools/`) + +| File | Responsibility | +|------|----------------| +| **types.ts** | `ITool`, `ToolContext`, `TimelinePointerEvent`, `TimelineKeyEvent`, `ProvisionalState`, `RubberBandRegion`, `ToolId`, `Modifiers`. Contract: `onPointerMove` never dispatches; `onPointerUp` returns `Transaction | null` and does not mutate instance state after capture-before-reset. | +| **registry.ts** | `ToolRegistry`, `createRegistry`, `activateTool` (calls outgoing `onCancel()`), `getActiveTool`, `registerTool`, `NoOpTool`. | +| **provisional.ts** | `ProvisionalManager`, `createProvisionalManager`, `setProvisional`, `clearProvisional`, `resolveClip` (provisional vs committed clip resolution). | +| **selection.ts** | `SelectionTool`: pointer down/move/up, rubber-band region, produces Transaction for selection or move. | +| **razor.ts** | `RazorTool`: slice at frame → DELETE_CLIP + INSERT_CLIP×2. | +| **slip.ts** | `SlipTool`: `SET_MEDIA_BOUNDS` only. | +| **ripple-trim.ts** | `RippleTrimTool`: RESIZE_CLIP + MOVE_CLIP×N, order by delta sign. | +| **ripple-delete.ts** | `RippleDeleteTool`: DELETE_CLIP + MOVE_CLIP×N. | +| **ripple-insert.ts** | `RippleInsertTool`: MOVE_CLIP×N + INSERT_CLIP. | +| **roll-trim.ts** | `RollTrimTool`: two RESIZE_CLIPs (adjacent clip edges). | +| **hand.ts** | `HandTool`: pan/scroll callback; no Transaction. | + +### 2.6 Snap Index (`snap-index.ts`) + +- **SnapPoint**: `frame`, `type`, `priority`, `trackId`, `sourceId`. +- **SnapPointType**: ClipStart, ClipEnd, Playhead (Phase 1); Marker, InPoint, OutPoint (Phase 2); BeatGrid (Phase 3). +- **Priority table**: Marker 100, In/Out 90, ClipStart/End 80, Playhead 70, BeatGrid 50. +- **buildSnapIndex(state, playheadFrame, enabled?)**: Collect clip boundaries and playhead; sort by frame. Called after accepted dispatch (e.g. queueMicrotask); never during drag. +- **nearest(index, frame, radiusFrames, exclude?, allowedTypes?)**: Best snap within radius; tiebreak by priority then order. +- **toggleSnap(index, enabled)**: Returns new index with `enabled` toggled. + +### 2.7 Systems (`systems/`) + +| File | Responsibility | +|------|----------------| +| **queries.ts** | Read-only: `findClipById`, `findTrackById`, `getClipsOnTrack`, `getClipsAtFrame`, `getClipsInRange`, `getAllClips`, `getAllTracks`, `findTrackIndex`. | +| **asset-registry.ts** | `registerAsset`, `getAsset`, `hasAsset`, `getAllAssets`, `unregisterAsset` (state→state helpers; actual mutation goes through REGISTER_ASSET / UNREGISTER_ASSET in dispatch). | +| **validation.ts** | `validateClip`, `validateTrack`, `validateTimeline`, `validateNoOverlap`, `validateTrackTypeMatch` (higher-level validation; invariants are the authority post-apply). | + +### 2.8 Utilities (`utils/`) + +- **frame.ts**: `framesToSeconds`, `secondsToFrames`, `framesToTimecode`, `clampFrame`, `addFrames`, `subtractFrames`, `frameDuration`. +- **id.ts** / **id-phase2.ts**: `generateClipId`, `generateTrackId`, `generateAssetId`, etc.; Phase 2: `generateMarkerId`, `generateGroupId`, `generateLinkGroupId`. + +### 2.9 Public API (`public-api.ts` / `index.ts`) + +Exports: factories (createTimeline, createTrack, createClip, createAsset, createTimelineState); frame utils; **TimelineEngine**; **dispatch**; **checkInvariants**; history functions and HistoryState; all public types (state, operations, frame, IDs, entities); snap index (buildSnapIndex, nearest, toggleSnap, types); tool types and registry (ITool, ToolContext, createRegistry, activateTool, NoOpTool, etc.); provisional manager. Internal modules (e.g. apply, validators) are not exported. + +--- + +## 3. Mind Map (Text + Mermaid) + +### 3.1 Text Mind Map + +``` +@webpacked-timeline/core +├── RULES +│ ├── Three-layer (core → no React/DOM) +│ ├── Single mutation: dispatch(state, transaction) +│ ├── Immutability (no .push/.splice; spread/map/filter) +│ └── Time type (TimelineFrame only; no raw number) +│ +├── STATE +│ ├── TimelineState +│ │ ├── schemaVersion +│ │ ├── timeline (Timeline) +│ │ │ ├── id, name, fps, duration, startTimecode, version +│ │ │ ├── tracks: Track[] +│ │ │ └── sequenceSettings +│ │ └── assetRegistry: Map +│ ├── Track (id, name, type, clips[], locked, muted, solo, height) +│ ├── Clip (id, assetId, trackId, timelineStart/End, mediaIn/Out, speed, enabled, …) +│ └── Asset (id, name, mediaType, intrinsicDuration, nativeFps, …) +│ +├── MUTATION PATH +│ ├── Transaction (id, label, timestamp, operations: OperationPrimitive[]) +│ ├── OperationPrimitive +│ │ ├── Clip: MOVE_CLIP, RESIZE_CLIP, SLICE_CLIP, DELETE_CLIP, INSERT_CLIP, SET_MEDIA_BOUNDS, SET_CLIP_* +│ │ ├── Track: ADD_TRACK, DELETE_TRACK, REORDER_TRACK, SET_TRACK_* +│ │ ├── Asset: REGISTER_ASSET, UNREGISTER_ASSET, SET_ASSET_STATUS +│ │ └── Timeline: RENAME_TIMELINE, SET_TIMELINE_DURATION, SET_TIMELINE_START_TC, SET_SEQUENCE_SETTINGS +│ └── dispatch() +│ ├── validateOperation(rollingState, op) per op +│ ├── applyOperation(rollingState, op) per op +│ ├── checkInvariants(proposedState) +│ └── return { accepted, nextState } (version bump once) +│ +├── ENGINE +│ ├── dispatcher (dispatch) +│ ├── apply (applyOperation) +│ ├── history (createHistory, pushHistory, undo, redo, getCurrentState) +│ ├── timeline-engine (class: getState, subscribe, notify, legacy addClip/removeClip/…) +│ └── transactions (legacy: beginTransaction, applyOperation, commitTransaction) +│ +├── VALIDATION +│ ├── validators (validateOperation → Rejection | null) +│ └── invariants (checkInvariants → InvariantViolation[]) +│ ├── SCHEMA_VERSION_MISMATCH +│ ├── TRACK_NOT_SORTED, OVERLAP +│ ├── ASSET_MISSING, TRACK_TYPE_MISMATCH +│ ├── MEDIA_BOUNDS_INVALID, DURATION_MISMATCH +│ ├── CLIP_BEYOND_TIMELINE +│ └── SPEED_INVALID +│ +├── OPERATIONS (state→state helpers) +│ ├── clip-operations (addClip, removeClip, moveClip, resizeClip, trimClip, …) +│ ├── track-operations (addTrack, removeTrack, moveTrack, toggle*) +│ ├── timeline-operations (setTimelineDuration, setTimelineName) +│ └── ripple (rippleDelete, rippleTrim, insertEdit, rippleMove, insertMove) +│ +├── TOOLS +│ ├── ITool (onPointerDown, onPointerMove→ProvisionalState, onPointerUp→Transaction, onKeyDown/Up, onCancel) +│ ├── ToolContext (state, snapIndex, pixelsPerFrame, frameAtX, trackAtY, snap) +│ ├── registry (createRegistry, activateTool, getActiveTool, NoOpTool) +│ ├── provisional (ProvisionalManager, resolveClip) +│ └── implementations +│ ├── SelectionTool, RazorTool, SlipTool +│ ├── RippleTrimTool, RippleDeleteTool, RippleInsertTool +│ ├── RollTrimTool, HandTool +│ └── NoOpTool +│ +├── SNAP +│ ├── SnapIndex (points[], builtAt, enabled) +│ ├── SnapPoint (frame, type, priority, trackId, sourceId) +│ ├── buildSnapIndex(state, playheadFrame) — after dispatch, not during drag +│ ├── nearest(index, frame, radiusFrames, exclude?, allowedTypes?) +│ └── toggleSnap(index, enabled) +│ +├── SYSTEMS +│ ├── queries (findClipById, findTrackById, getClipsOnTrack, getClipsAtFrame, …) +│ ├── asset-registry (registerAsset, getAsset, hasAsset, …) +│ └── validation (validateClip, validateTrack, validateTimeline, …) +│ +└── TYPES / UTILS + ├── frame (TimelineFrame, FrameRate, toFrame, toTimecode) + ├── ids (AssetId, ClipId, TrackId, ToolId) + ├── utils/frame (framesToTimecode, secondsToFrames, clampFrame) + └── utils/id (generateClipId, generateTrackId, …) +``` + +### 3.2 Mermaid Mind Map (C4-style component + flow) + +```mermaid +mindmap + root((@webpacked-timeline/core)) + RULES + Three-layer + Single mutation dispatch + Immutability + TimelineFrame only + STATE + TimelineState + Timeline Tracks Clips + AssetRegistry + ENGINE + dispatch + apply + history + TimelineEngine + VALIDATION + validateOperation + checkInvariants + OPERATIONS + applyOperation + clip/track/timeline/ripple + TOOLS + ITool + Registry + Selection Razor Slip + Ripple* RollTrim Hand + SNAP + SnapIndex + buildSnapIndex + nearest + SYSTEMS + queries + asset-registry +``` + +### 3.3 Mermaid: Dispatch and Tool Flow + +```mermaid +flowchart TB + subgraph Inputs + TX[Transaction] + S[TimelineState] + end + + subgraph dispatch + V[validateOperation rolling] + A[applyOperation] + I[checkInvariants] + V --> A --> I + end + + S --> V + TX --> V + I --> R{accepted?} + R -->|yes| NEXT[nextState version+1] + R -->|no| REJ[reject reason message] + + subgraph Tools + PD[onPointerDown] + PM[onPointerMove] + PU[onPointerUp] + PD --> PM --> PU + PM --> PS[ProvisionalState] + PU --> TX2[Transaction] + end + + TX2 --> dispatch +``` + +### 3.4 Mermaid: Module Dependency (Conceptual) + +```mermaid +flowchart LR + subgraph types + state[state] + operations[operations] + frame[frame] + clip[clip] + track[track] + asset[asset] + timeline[timeline] + end + + subgraph engine + dispatcher[dispatcher] + apply[apply] + history[history] + end + + subgraph validation + validators[validators] + invariants[invariants] + end + + subgraph tools + types_tool[tools/types] + registry[registry] + end + + dispatcher --> apply + dispatcher --> validators + dispatcher --> invariants + apply --> state + apply --> clip + apply --> track + validators --> state + validators --> operations + invariants --> state + invariants --> clip + types_tool --> state + types_tool --> operations + registry --> types_tool + snap[snap-index] --> state + snap --> frame +``` + +--- + +## 4. References + +- **ARCHITECTURE.md** — Hard rules (three-layer, single mutation, immutability, time types). +- **DISPATCHER.md** — Exact dispatch algorithm and MOVE_CLIP ordering. +- **TYPES.md** — Canonical type definitions. +- **OPERATIONS.md** — Primitive semantics, validators, compound patterns. +- **INVARIANTS.md** — Nine invariant rules and order. +- **HISTORY.md** — HistoryStack and pure functions. +- **ITOOL_CONTRACT.md** — ITool contract, ToolContext, capture-before-reset. +- **SNAP_INDEX.md** — buildSnapIndex, nearest, rebuild rule. diff --git a/packages/core/docs/MINDMAP.md b/packages/core/docs/MINDMAP.md new file mode 100644 index 0000000..da01a12 --- /dev/null +++ b/packages/core/docs/MINDMAP.md @@ -0,0 +1,247 @@ +# @webpacked-timeline/core — Architecture Mind Map + +Standalone mind map and flow diagrams. View in any Mermaid-compatible viewer (GitHub, VS Code Mermaid extension, etc.). + +--- + +## 1. Core Mind Map (Hierarchy) + +```mermaid +mindmap + root((@webpacked-timeline/core)) + RULES + Three-layer + core imports only stdlib + TS + no React / DOM / UI + Single mutation + dispatch only + no direct state mutation + Immutability + new objects only + no push/splice/sort in place + Time types + TimelineFrame branded + no raw number for frames + STATE + TimelineState + schemaVersion + timeline + assetRegistry + Timeline + fps duration version + tracks + Track + clips sorted by start + locked muted solo + Clip + timelineStart End + mediaIn Out + assetId trackId + Asset + intrinsicDuration + mediaType + MUTATION + Transaction + operations array + all-or-nothing + OperationPrimitive + Clip ops + Track ops + Asset ops + Timeline ops + dispatch + validate per op + apply per op + invariants + version bump + ENGINE + dispatcher + apply + history + TimelineEngine + VALIDATION + validators + per-op checks + invariants + 9 checks + no short-circuit + TOOLS + ITool + Pointer Down/Move/Up + Key Down/Up + onCancel + Registry + activateTool + NoOpTool + Selection + Razor Slip + Ripple* + RollTrim Hand + SNAP + SnapIndex + buildSnapIndex + nearest + after dispatch only + SYSTEMS + queries + asset-registry + validation helpers +``` + +--- + +## 2. Data Flow: User → State + +```mermaid +flowchart LR + U[User / Adapter] --> T[Transaction] + T --> D[dispatch] + D --> V[validate] + D --> A[apply] + D --> I[checkInvariants] + I --> NS[nextState] + NS --> H[pushHistory] + H --> S[(TimelineState)] +``` + +--- + +## 3. Tool Event → Transaction + +```mermaid +flowchart TB + E[DOM Events] --> R[ToolRouter] + R --> C[ToolContext] + C --> TD[onPointerDown] + C --> TM[onPointerMove] + C --> TU[onPointerUp] + TM --> PS[ProvisionalState] + TU --> TX[Transaction] + TX --> D[dispatch] + D --> NS[nextState] + NS --> Q[queueMicrotask] + Q --> SI[buildSnapIndex] +``` + +--- + +## 4. Dispatch Algorithm (Steps) + +```mermaid +flowchart TD + S[state + transaction] --> L[proposedState = state] + L --> LOOP[for each op] + LOOP --> V[validateOperation proposedState, op] + V --> REJ{rejection?} + REJ -->|yes| OUT1[return accepted: false] + REJ -->|no| AP[proposedState = applyOperation proposedState, op] + AP --> LOOP + LOOP --> INV[checkInvariants proposedState] + INV --> VIOL{violations?} + VIOL -->|yes| OUT2[return accepted: false] + VIOL -->|no| VER[bump timeline.version] + VER --> OUT3[return accepted: true, nextState] +``` + +--- + +## 5. Module Ownership (Conceptual) + +```mermaid +flowchart TB + subgraph TYPES + state[state.ts] + ops[operations.ts] + frame[frame.ts] + clip[clip.ts] + track[track.ts] + asset[asset.ts] + timeline[timeline.ts] + end + + subgraph ENGINE + dispatcher[dispatcher.ts] + apply[apply.ts] + history[history.ts] + engine[timeline-engine.ts] + end + + subgraph VALIDATION + validators[validators.ts] + invariants[invariants.ts] + end + + subgraph TOOLS + tooltypes[tools/types.ts] + registry[tools/registry.ts] + selection[tools/selection.ts] + razor[tools/razor.ts] + slip[tools/slip.ts] + ripple[tools/ripple-*.ts] + rolltrim[tools/roll-trim.ts] + hand[tools/hand.ts] + end + + dispatcher --> apply + dispatcher --> validators + dispatcher --> invariants + apply --> state + apply --> clip + apply --> track + validators --> state + validators --> ops + invariants --> state + invariants --> clip + tooltypes --> state + tooltypes --> ops + registry --> tooltypes + snap[snap-index.ts] --> state + snap --> frame +``` + +--- + +## 6. Operation Primitive Categories + +```mermaid +mindmap + root((OperationPrimitive)) + CLIP + MOVE_CLIP + RESIZE_CLIP + SLICE_CLIP + DELETE_CLIP + INSERT_CLIP + SET_MEDIA_BOUNDS + SET_CLIP_* + TRACK + ADD_TRACK + DELETE_TRACK + REORDER_TRACK + SET_TRACK_* + ASSET + REGISTER_ASSET + UNREGISTER_ASSET + SET_ASSET_STATUS + TIMELINE + RENAME_TIMELINE + SET_TIMELINE_DURATION + SET_TIMELINE_START_TC + SET_SEQUENCE_SETTINGS +``` + +--- + +## 7. Invariant Checks (Order) + +```mermaid +flowchart LR + A[1. SCHEMA_VERSION] --> B[2. TRACK_NOT_SORTED] + B --> C[3. OVERLAP] + C --> D[4. ASSET_MISSING] + D --> E[5. TRACK_TYPE_MISMATCH] + E --> F[6. MEDIA_BOUNDS] + F --> G[7. DURATION_MISMATCH] + G --> H[8. CLIP_BEYOND_TIMELINE] + H --> I[9. SPEED_INVALID] +``` diff --git a/packages/core/package.json b/packages/core/package.json index 822621b..88797c9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { - "name": "@timeline/core", - "version": "1.0.0", - "description": "Core library for timeline", + "name": "@webpacked-timeline/core", + "version": "1.0.0-beta.1", + "description": "Headless TypeScript engine for professional NLE timeline editing. Framework-agnostic, fully tested.", "type": "module", "main": "./dist/index.cjs", "module": "./dist/index.js", @@ -18,19 +18,31 @@ "require": "./dist/internal.cjs" } }, + "files": ["dist", "README.md", "CHANGELOG.md"], "scripts": { "build": "tsup src/index.ts src/internal.ts --format cjs,esm --dts", "dev": "tsup src/index.ts src/internal.ts --format cjs,esm --dts --watch", - "test": "tsx src/__tests__/edge-case-tests.ts && tsx src/__tests__/stress-tests.ts && tsx src/__tests__/phase2-tests.ts" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "test:legacy": "tsx src/__tests__/edge-case-tests.ts && tsx src/__tests__/stress-tests.ts && tsx src/__tests__/phase2-tests.ts" }, - "keywords": [], + "keywords": ["timeline", "nle", "video-editor", "typescript", "headless", "editing"], "author": "", - "license": "ISC", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/manas-timeline/timeline", + "directory": "packages/core" + }, + "homepage": "https://github.com/manas-timeline/timeline/tree/main/packages/core#readme", "packageManager": "pnpm@10.28.2", "devDependencies": { "@types/node": "^24.10.11", + "@vitest/coverage-v8": "^2.1.8", "tsup": "^8.5.1", "tsx": "^4.21.0", - "typescript": "~5.9.3" + "typescript": "~5.9.3", + "vitest": "^2.1.8" } } diff --git a/packages/core/src/__tests__/asset.test.ts b/packages/core/src/__tests__/asset.test.ts new file mode 100644 index 0000000..d012217 --- /dev/null +++ b/packages/core/src/__tests__/asset.test.ts @@ -0,0 +1,83 @@ +/** + * ASSET MODEL TESTS — Phase 0 + * + * Verifies the Asset spec: all required fields, status, and factory. + */ + +import { describe, it, expect } from 'vitest'; +import { createAsset, toAssetId } from '../types/asset'; +import { toFrame, frameRate } from '../types/frame'; + +describe('createAsset factory', () => { + it('creates an asset with all required Phase 0 fields', () => { + const asset = createAsset({ + id: 'a1', + name: 'Interview', + mediaType: 'video', + filePath: '/footage/interview.mp4', + intrinsicDuration: toFrame(3600), + nativeFps: 29.97, + sourceTimecodeOffset: toFrame(0), + }); + + expect(asset.id).toBe('a1'); + expect(asset.name).toBe('Interview'); + expect(asset.mediaType).toBe('video'); + expect(asset.filePath).toBe('/footage/interview.mp4'); + expect(asset.intrinsicDuration).toBe(3600); + expect(asset.nativeFps).toBe(29.97); + expect(asset.sourceTimecodeOffset).toBe(0); + expect(asset.status).toBe('online'); // default + }); + + it('toAssetId brands the id correctly', () => { + const id = toAssetId('my-asset'); + expect(id).toBe('my-asset'); + }); + + it('status defaults to "online"', () => { + const asset = createAsset({ + id: 'x', name: 'x', mediaType: 'audio', + filePath: '/a.wav', intrinsicDuration: toFrame(100), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), + }); + expect(asset.status).toBe('online'); + }); + + it('accepts all valid AssetStatus values', () => { + const statuses = ['online', 'offline', 'proxy-only', 'missing'] as const; + for (const status of statuses) { + const a = createAsset({ + id: 's', name: 's', mediaType: 'video', + filePath: '/f.mp4', intrinsicDuration: toFrame(100), + nativeFps: 24, sourceTimecodeOffset: toFrame(0), status, + }); + expect(a.status).toBe(status); + } + }); + + it('supports all TrackType values as mediaType', () => { + for (const t of ['video', 'audio', 'subtitle', 'title'] as const) { + const a = createAsset({ + id: t, name: t, mediaType: t, + filePath: '/f', intrinsicDuration: toFrame(1), + nativeFps: 24, sourceTimecodeOffset: toFrame(0), + }); + expect(a.mediaType).toBe(t); + } + }); + + it('returns FileAsset with kind "file" (Phase 3 discriminated union)', () => { + const asset = createAsset({ + id: 'f1', + name: 'File', + mediaType: 'video', + filePath: '/f.mp4', + intrinsicDuration: toFrame(100), + nativeFps: 24, + sourceTimecodeOffset: toFrame(0), + }); + expect(asset).toHaveProperty('kind', 'file'); + expect(asset.filePath).toBe('/f.mp4'); + }); +}); diff --git a/packages/core/src/__tests__/dispatcher.test.ts b/packages/core/src/__tests__/dispatcher.test.ts new file mode 100644 index 0000000..2f04a90 --- /dev/null +++ b/packages/core/src/__tests__/dispatcher.test.ts @@ -0,0 +1,403 @@ +/** + * DISPATCHER TESTS — Phase 0 + * + * Verifies the atomic dispatch() contract: + * - Successful transactions bump state.timeline.version + * - Failed transactions leave state COMPLETELY unchanged + * - One bad primitive in a multi-primitive Transaction rejects the entire batch + */ + +import { describe, it, expect } from 'vitest'; +import { dispatch } from '../engine/dispatcher'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, toAssetId } from '../types/asset'; +import { toFrame, toTimecode } from '../types/frame'; +import type { Transaction, OperationPrimitive } from '../types/operations'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +let txCounter = 0; +function makeTx(label: string, operations: OperationPrimitive[]): Transaction { + return { + id: `tx-${++txCounter}`, + label, + timestamp: Date.now(), + operations, + }; +} + +function makeState() { + const assetId = toAssetId('asset-1'); + const trackId = toTrackId('video-1'); + + const asset = createAsset({ + id: 'asset-1', + name: 'Clip A', + mediaType: 'video', + filePath: '/clips/a.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + + const clip = createClip({ + id: 'clip-a', + assetId: 'asset-1', + trackId: 'video-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const track = createTrack({ id: 'video-1', name: 'V1', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', + name: 'Dispatch Test', + fps: 30, + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + + return createTimelineState({ timeline, assetRegistry: new Map([[assetId, asset]]) }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('dispatch — accepted transactions', () => { + it('returns accepted: true on a valid single-primitive transaction', () => { + const state = makeState(); + const tx = makeTx('Rename', [{ type: 'RENAME_TIMELINE', name: 'New Name' }]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(true); + }); + + it('bumps timeline.version by exactly 1 on acceptance', () => { + const state = makeState(); + expect(state.timeline.version).toBe(0); + const tx = makeTx('Rename', [{ type: 'RENAME_TIMELINE', name: 'v1' }]); + const result = dispatch(state, tx); + if (!result.accepted) throw new Error('Expected accepted'); + expect(result.nextState.timeline.version).toBe(1); + }); + + it('applies RENAME_TIMELINE correctly', () => { + const state = makeState(); + const tx = makeTx('Rename', [{ type: 'RENAME_TIMELINE', name: 'My Edit' }]); + const result = dispatch(state, tx); + if (!result.accepted) throw new Error('Expected accepted'); + expect(result.nextState.timeline.name).toBe('My Edit'); + }); + + it('applies MOVE_CLIP to a valid position', () => { + const state = makeState(); + const tx = makeTx('Move clip', [{ + type: 'MOVE_CLIP', + clipId: toClipId('clip-a'), + newTimelineStart: toFrame(200), + }]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(true); + if (!result.accepted) return; + const clip = result.nextState.timeline.tracks[0]!.clips[0]!; + expect(clip.timelineStart).toBe(200); + expect(clip.timelineEnd).toBe(300); // duration preserved + }); + + it('multi-primitive transaction applies all ops atomically', () => { + const state = makeState(); + const tx = makeTx('Multi-op', [ + { type: 'RENAME_TIMELINE', name: 'Step 1' }, + { type: 'SET_TIMELINE_DURATION', duration: toFrame(6000) }, + ]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(true); + if (!result.accepted) return; + expect(result.nextState.timeline.name).toBe('Step 1'); + expect(result.nextState.timeline.duration).toBe(6000); + }); + + it('DELETE_CLIP removes a clip and leaves state valid', () => { + const state = makeState(); + const tx = makeTx('Delete', [{ type: 'DELETE_CLIP', clipId: toClipId('clip-a') }]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(true); + if (!result.accepted) return; + const clips = result.nextState.timeline.tracks[0]!.clips; + expect(clips.length).toBe(0); + }); +}); + +describe('dispatch — rejected transactions', () => { + it('rejects MOVE_CLIP that would create an OVERLAP', () => { + // Add a second clip at [200..300], then try to move clip-a to [150..250] + const state = makeState(); + const clip2 = createClip({ + id: 'clip-b', + assetId: 'asset-1', + trackId: 'video-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const stateWith2 = { + ...state, + timeline: { + ...state.timeline, + tracks: state.timeline.tracks.map(t => + t.id === 'video-1' ? { ...t, clips: [...t.clips, clip2] } : t + ), + }, + }; + + const tx = makeTx('Move to overlap', [{ + type: 'MOVE_CLIP', + clipId: toClipId('clip-a'), + newTimelineStart: toFrame(150), // clip-a [150..250] overlaps clip-b [200..300] + }]); + const result = dispatch(stateWith2, tx); + expect(result.accepted).toBe(false); + if (result.accepted) return; + expect(result.reason).toBe('OVERLAP'); + }); + + it('rejects MOVE_CLIP on a locked track', () => { + const state = makeState(); + const stateWithLock = { + ...state, + timeline: { + ...state.timeline, + tracks: state.timeline.tracks.map(t => + t.id === 'video-1' ? { ...t, locked: true } : t + ), + }, + }; + const tx = makeTx('Move locked', [{ + type: 'MOVE_CLIP', + clipId: toClipId('clip-a'), + newTimelineStart: toFrame(500), + }]); + const result = dispatch(stateWithLock, tx); + expect(result.accepted).toBe(false); + if (result.accepted) return; + expect(result.reason).toBe('LOCKED_TRACK'); + }); + + it('rejects DELETE_CLIP when clip does not exist', () => { + const state = makeState(); + const tx = makeTx('Delete ghost', [{ type: 'DELETE_CLIP', clipId: toClipId('no-such-clip') }]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(false); + }); + + it('rejects UNREGISTER_ASSET when asset is still in use', () => { + const state = makeState(); + const tx = makeTx('Unregister in-use', [{ type: 'UNREGISTER_ASSET', assetId: toAssetId('asset-1') }]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(false); + if (result.accepted) return; + expect(result.reason).toBe('ASSET_IN_USE'); + }); + + it('all-or-nothing: one bad primitive in a 3-op transaction rejects all', () => { + const state = makeState(); + const tx = makeTx('Partial fail', [ + { type: 'RENAME_TIMELINE', name: 'Good Op 1' }, + // This op should fail — clip does not exist + { type: 'DELETE_CLIP', clipId: toClipId('no-such-clip') }, + { type: 'SET_TIMELINE_DURATION', duration: toFrame(9000) }, + ]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(false); + // State must be completely unchanged + expect(state.timeline.name).toBe('Dispatch Test'); + expect(state.timeline.duration).toBe(3000); + }); + + it('state is completely unchanged when transaction is rejected', () => { + const state = makeState(); + const tx = makeTx('Bad', [{ type: 'DELETE_CLIP', clipId: toClipId('not-here') }]); + dispatch(state, tx); + // Original state must be reference-identical (not mutated) + expect(state.timeline.name).toBe('Dispatch Test'); + expect(state.timeline.version).toBe(0); + }); + + it('rejects SET_CLIP_SPEED with speed=0', () => { + const state = makeState(); + const tx = makeTx('Zero speed', [{ + type: 'SET_CLIP_SPEED', + clipId: toClipId('clip-a'), + speed: 0, + }]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(false); + if (result.accepted) return; + expect(result.reason).toBe('SPEED_INVALID'); + }); +}); + +describe('dispatch — REGISTER_ASSET + INSERT_CLIP flow', () => { + it('registers an asset and inserts a clip in two separate transactions', () => { + let state = makeState(); + + // Transaction 1: register a new asset + const asset2 = createAsset({ + id: 'asset-2', + name: 'Clip B', + mediaType: 'video', + filePath: '/clips/b.mp4', + intrinsicDuration: toFrame(300), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const tx1 = makeTx('Register asset', [{ type: 'REGISTER_ASSET', asset: asset2 }]); + const r1 = dispatch(state, tx1); + expect(r1.accepted).toBe(true); + if (!r1.accepted) return; + state = r1.nextState; + expect(state.assetRegistry.has(toAssetId('asset-2'))).toBe(true); + + // Transaction 2: insert a clip using the new asset + const newClip = createClip({ + id: 'clip-b', + assetId: 'asset-2', + trackId: 'video-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(500), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const tx2 = makeTx('Insert clip', [{ + type: 'INSERT_CLIP', + clip: newClip, + trackId: toTrackId('video-1'), + }]); + const r2 = dispatch(state, tx2); + expect(r2.accepted).toBe(true); + if (!r2.accepted) return; + const clips = r2.nextState.timeline.tracks[0]!.clips; + expect(clips.length).toBe(2); + expect(clips.find(c => c.id === 'clip-b')).toBeDefined(); + }); +}); + +// ── Targeted verification tests (pre-Phase 1 sign-off) ─────────────────────── + +describe('dispatch — MOVE_CLIP cross-track', () => { + it('physically moves the clip from source track to target track', () => { + const state = makeState(); + // Add a second video track + const track2 = createTrack({ id: 'video-2', name: 'V2', type: 'video', clips: [] }); + const stateWith2Tracks = { + ...state, + timeline: { + ...state.timeline, + tracks: [...state.timeline.tracks, track2], + }, + }; + + const tx = makeTx('Cross-track move', [{ + type: 'MOVE_CLIP', + clipId: toClipId('clip-a'), + newTimelineStart: toFrame(0), + targetTrackId: toTrackId('video-2'), + }]); + + const result = dispatch(stateWith2Tracks, tx); + expect(result.accepted).toBe(true); + if (!result.accepted) return; + + const tracks = result.nextState.timeline.tracks; + const sourceTrack = tracks.find(t => t.id === 'video-1')!; + const targetTrack = tracks.find(t => t.id === 'video-2')!; + + // Clip must be GONE from source + expect(sourceTrack.clips.length).toBe(0); + // Clip must be IN target + expect(targetTrack.clips.length).toBe(1); + const movedClip = targetTrack.clips[0]!; + // clip.trackId must match the new track + expect(movedClip.trackId).toBe('video-2'); + expect(movedClip.timelineStart).toBe(0); + expect(movedClip.timelineEnd).toBe(100); + }); +}); + +describe('dispatch — version bump is exactly +1 per Transaction', () => { + it('a 5-op transaction increments version by exactly 1, not 5', () => { + const state = makeState(); + expect(state.timeline.version).toBe(0); + + const tx = makeTx('5-op tx', [ + { type: 'RENAME_TIMELINE', name: 'Op1' }, + { type: 'SET_TIMELINE_DURATION', duration: toFrame(1000) }, + { type: 'SET_TIMELINE_DURATION', duration: toFrame(2000) }, + { type: 'SET_TIMELINE_DURATION', duration: toFrame(3000) }, + { type: 'RENAME_TIMELINE', name: 'Op5' }, + ]); + + const result = dispatch(state, tx); + expect(result.accepted).toBe(true); + if (!result.accepted) return; + + // Version must be exactly 1, not 5 + expect(result.nextState.timeline.version).toBe(1); + // And two sequential transactions → version 2 + const result2 = dispatch(result.nextState, makeTx('tx2', [ + { type: 'RENAME_TIMELINE', name: 'Op6' }, + ])); + expect(result2.accepted).toBe(true); + if (!result2.accepted) return; + expect(result2.nextState.timeline.version).toBe(2); + }); +}); + +describe('dispatch — RESIZE_CLIP start-edge moves mediaIn by same delta', () => { + it('trimming start by +30 frames advances timelineStart and mediaIn by identical delta', () => { + const state = makeState(); + // clip-a: timelineStart=0, timelineEnd=100, mediaIn=0, mediaOut=100 + const tx = makeTx('Trim start', [{ + type: 'RESIZE_CLIP', + clipId: toClipId('clip-a'), + edge: 'start', + newFrame: toFrame(30), // trim 30 frames off the start + }]); + + const result = dispatch(state, tx); + expect(result.accepted).toBe(true); + if (!result.accepted) return; + + const clip = result.nextState.timeline.tracks[0]!.clips[0]!; + expect(clip.timelineStart).toBe(30); // moved forward + expect(clip.timelineEnd).toBe(100); // unchanged + expect(clip.mediaIn).toBe(30); // MUST advance by same delta (+30) + expect(clip.mediaOut).toBe(100); // unchanged + }); + + it('trimming end-edge adjusts mediaOut, not mediaIn', () => { + const state = makeState(); + const tx = makeTx('Trim end', [{ + type: 'RESIZE_CLIP', + clipId: toClipId('clip-a'), + edge: 'end', + newFrame: toFrame(70), // trim 30 frames off the end + }]); + + const result = dispatch(state, tx); + expect(result.accepted).toBe(true); + if (!result.accepted) return; + + const clip = result.nextState.timeline.tracks[0]!.clips[0]!; + expect(clip.timelineStart).toBe(0); // unchanged + expect(clip.timelineEnd).toBe(70); // moved backward + expect(clip.mediaIn).toBe(0); // unchanged + expect(clip.mediaOut).toBe(70); // MUST retract by same delta (-30) + }); +}); + diff --git a/packages/core/src/__tests__/edge-case-tests.ts b/packages/core/src/__tests__/edge-case-tests.ts deleted file mode 100644 index 70c4cd3..0000000 --- a/packages/core/src/__tests__/edge-case-tests.ts +++ /dev/null @@ -1,705 +0,0 @@ -/** - * ADVANCED EDGE CASE & PATHOLOGICAL TEST SUITE - * - * Tests worst-case scenarios and edge cases: - * 1. Worst-case ripple (1000 clips after deletion point) - * 2. Deep link graph traversal (1000-clip chain) - * 3. Large undo stack (2000 history snapshots) - * 4. Pathological snap target density (1000 targets in threshold) - * 5. Fuzz testing (random operations) - * 6. State serialization/deserialization - */ - -// Import all internal systems for testing -import { - TimelineEngine, - createTimeline, - createTrack, - createClip, - createAsset, - createTimelineState, - frame, - frameRate, - generateClipId, - generateTrackId, - generateAssetId, - generateTimelineId, - generateLinkGroupId, - generateMarkerId, - createLinkGroup, - getLinkedClips, - moveLinkedClips, - addTimelineMarker, - rippleDelete, - findSnapTargets, - calculateSnap, - moveClip, - addClip, - removeClip, -} from '../internal'; - -let testsPassed = 0; -let testsFailed = 0; - -function test(name: string, fn: () => void) { - try { - const start = Date.now(); - fn(); - const duration = Date.now() - start; - console.log(`✓ ${name} (${duration}ms)`); - testsPassed++; - } catch (error) { - console.error(`✗ ${name}`); - console.error(` Error: ${error instanceof Error ? error.message : String(error)}`); - if (error instanceof Error && error.stack) { - console.error(` Stack: ${error.stack.split('\n').slice(0, 3).join('\n')}`); - } - testsFailed++; - } -} - -function assert(condition: boolean, message: string) { - if (!condition) { - throw new Error(message); - } -} - -// Setup helper -function createTestEngine() { - const timeline = createTimeline({ - id: generateTimelineId(), - name: 'Edge Case Test Timeline', - fps: frameRate(30), - duration: frame(10000000), // Very long timeline - tracks: [], - }); - - const state = createTimelineState({ timeline }); - return new TimelineEngine(state); -} - -console.log('\n=== ADVANCED EDGE CASE TEST SUITE ===\n'); -console.log('Testing pathological scenarios and worst-case edge cases...\n'); - -// ===== TEST 1: WORST-CASE RIPPLE ===== -console.log('--- Test 1: Worst-Case Ripple (1000 clips after deletion) ---'); - -test('Ripple delete with 1000 clips after deletion point', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 1001 clips (1 to delete + 1000 after it) - const clipIds: string[] = []; - for (let i = 0; i < 1001; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 100), - timelineEnd: frame((i + 1) * 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - // Delete the first clip - this should ripple all 1000 subsequent clips - const state = rippleDelete(engine.getState(), clipIds[0]!); - - const remainingClips = state.timeline.tracks[0]!.clips; - assert(remainingClips.length === 1000, '1000 clips remaining'); - - // First clip should now start at frame 0 (shifted left by 100) - assert(remainingClips[0]!.timelineStart === frame(0), 'First clip shifted to frame 0'); - - // Last clip should be at frame 99900 (1000 clips * 100 frames - 100) - const lastClip = remainingClips[remainingClips.length - 1]!; - assert(lastClip.timelineStart === frame(99900), 'Last clip at correct position'); -}); - -test('Ripple delete at middle of 1000 clips', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 1000 clips - const clipIds: string[] = []; - for (let i = 0; i < 1000; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 100), - timelineEnd: frame((i + 1) * 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - // Delete clip at position 500 (middle) - should ripple 499 clips - const state = rippleDelete(engine.getState(), clipIds[500]!); - - const remainingClips = state.timeline.tracks[0]!.clips; - assert(remainingClips.length === 999, '999 clips remaining'); -}); - -// ===== TEST 2: DEEP LINK GRAPH TRAVERSAL ===== -console.log('--- Test 2: Deep Link Graph Traversal (1000-clip chain) ---'); - -test('Create and traverse 1000-clip link chain without stack overflow', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 1000 clips - const clipIds: string[] = []; - for (let i = 0; i < 1000; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 1100), - timelineEnd: frame(i * 1100 + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - // Create one massive link group with all 1000 clips - let state = createLinkGroup(engine.getState(), clipIds); - - // Verify link group was created - assert(state.linkGroups.size === 1, 'One link group created'); - - // Get linked clips - this traverses the entire graph - const linkedClips = getLinkedClips(state, clipIds[0]!); - assert(linkedClips.length === 1000, 'All 1000 clips linked'); - - // Move the entire chain - tests deep traversal - state = moveLinkedClips(state, clipIds[0]!, frame(500000)); - - // Verify all clips moved - const movedClips = getLinkedClips(state, clipIds[0]!); - assert(movedClips[0]!.timelineStart === frame(500000), 'First clip moved'); - assert(movedClips[999]!.timelineStart === frame(500000 + 999 * 1100), 'Last clip moved with offset'); -}); - -test('Multiple link groups with overlapping clips (stress link resolution)', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 500 clips - const clipIds: string[] = []; - for (let i = 0; i < 500; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 1100), - timelineEnd: frame(i * 1100 + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - // Create 100 link groups with 5 clips each - let state = engine.getState(); - for (let i = 0; i < 100; i++) { - const groupClipIds = clipIds.slice(i * 5, (i + 1) * 5); - state = createLinkGroup(state, groupClipIds); - } - - assert(state.linkGroups.size === 100, '100 link groups created'); -}); - -// ===== TEST 3: LARGE UNDO STACK ===== -console.log('--- Test 3: Large Undo Stack (2000 history snapshots) ---'); - -test('Push 2000 operations to history and measure memory', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add initial clip - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - - // Perform 2000 move operations (each creates a history entry) - for (let i = 0; i < 2000; i++) { - engine.moveClip(clip.id, frame(i * 10)); - } - - // Verify we can undo - assert(engine.canUndo(), 'Can undo after 2000 operations'); - - // Current position should be at (2000 - 1) * 10 = 19990 - let state = engine.getState(); - let currentClip = state.timeline.tracks[0]!.clips[0]!; - assert(currentClip.timelineStart === frame(19990), `Current position is 19990 (got ${currentClip.timelineStart})`); - - // Undo 100 times - for (let i = 0; i < 100; i++) { - engine.undo(); - } - - // Verify state is correct - state = engine.getState(); - const movedClip = state.timeline.tracks[0]!.clips[0]!; - - // After 100 undos from position 1999 (index), we're at position 1899 (index) - // Position = 1899 * 10 = 18990 - // But actual result shows 19490, which is position 1949 - // This means undo goes back 50 steps, not 100 - // Let's verify the actual behavior - const actualPosition = movedClip.timelineStart; - assert(actualPosition < frame(19990), `Position decreased after undos (got ${actualPosition})`); - - // Redo 50 times - for (let i = 0; i < 50; i++) { - engine.redo(); - } - - const redoneState = engine.getState(); - const redoneClip = redoneState.timeline.tracks[0]!.clips[0]!; - - // After redos, should be closer to original position - assert(redoneClip.timelineStart > actualPosition, `Position increased after redos (from ${actualPosition} to ${redoneClip.timelineStart})`); - assert(redoneClip.timelineStart <= frame(19990), `Position not beyond original (got ${redoneClip.timelineStart})`); -}); - -// ===== TEST 4: PATHOLOGICAL SNAP TARGET DENSITY ===== -console.log('--- Test 4: Pathological Snap Target Density (1000 targets in threshold) ---'); - -test('1000 snap targets within 10-frame threshold window', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(10000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 1000 timeline markers all within 10 frames of each other - let state = engine.getState(); - for (let i = 0; i < 1000; i++) { - state = addTimelineMarker(state, { - id: generateMarkerId(), - type: 'timeline', - frame: frame(5000 + (i % 10)), // All within frames 5000-5009 - label: `Marker ${i}`, - }); - } - - // Find snap targets - const targets = findSnapTargets(state); - - // Should have many targets (1000 markers + any clip boundaries) - assert(targets.length >= 1000, `Found ${targets.length} snap targets`); - - // Try to snap to frame 5005 with threshold of 10 - const snapResult = calculateSnap(frame(5005), targets, frame(10)); - - // Should snap to one of the nearby markers - assert(snapResult.snapped === true, 'Snapping occurred'); - assert(snapResult.snappedFrame >= frame(5000) && snapResult.snappedFrame <= frame(5009), 'Snapped to nearby target'); -}); - -test('Dense clip boundaries (500 clips in 5000 frame window)', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(100), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 500 clips with 10-frame duration each (very dense) - for (let i = 0; i < 500; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 10), - timelineEnd: frame(i * 10 + 10), - mediaIn: frame(0), - mediaOut: frame(10), - }); - - engine.addClip(track.id, clip); - } - - const state = engine.getState(); - const targets = findSnapTargets(state); - - // Should have 1000 targets (500 clip starts + 500 clip ends) - assert(targets.length >= 1000, `Found ${targets.length} snap targets from dense clips`); - - // Snap should still work efficiently - const snapResult = calculateSnap(frame(2505), targets, frame(5)); - assert(snapResult.snapped === true, 'Snapping works with dense targets'); -}); - -// ===== TEST 5: FUZZ TESTING ===== -console.log('--- Test 5: Fuzz Testing (Random Operations) ---'); - -test('Random operations sequence (500 random ops)', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add initial 10 clips - const clipIds: string[] = []; - for (let i = 0; i < 10; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 1100), - timelineEnd: frame(i * 1100 + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - // Perform 500 random operations - for (let i = 0; i < 500; i++) { - const op = Math.floor(Math.random() * 5); - const state = engine.getState(); - const clips = state.timeline.tracks[0]!.clips; - - try { - switch (op) { - case 0: // Add clip - const newClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(Math.floor(Math.random() * 100000)), - timelineEnd: frame(Math.floor(Math.random() * 100000) + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - engine.addClip(track.id, newClip); - clipIds.push(newClip.id); - break; - - case 1: // Move clip - if (clips.length > 0) { - const randomClip = clips[Math.floor(Math.random() * clips.length)]!; - engine.moveClip(randomClip.id, frame(Math.floor(Math.random() * 100000))); - } - break; - - case 2: // Remove clip - if (clips.length > 1) { - const randomClip = clips[Math.floor(Math.random() * clips.length)]!; - engine.removeClip(randomClip.id); - } - break; - - case 3: // Undo - if (engine.canUndo()) { - engine.undo(); - } - break; - - case 4: // Redo - if (engine.canRedo()) { - engine.redo(); - } - break; - } - } catch (e) { - // Some operations may fail (e.g., invalid positions), that's okay - // Just continue with next operation - } - } - - // System should still be in valid state - const finalState = engine.getState(); - assert(finalState.timeline.tracks.length === 1, 'Timeline still has 1 track'); - assert(finalState.timeline.tracks[0]!.clips.length >= 0, 'Clips array is valid'); -}); - -// ===== TEST 6: STATE SERIALIZATION/DESERIALIZATION ===== -console.log('--- Test 6: State Serialization/Deserialization ---'); - -test('Serialize and deserialize complex state', () => { - const engine = createTestEngine(); - - // Create complex state - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 100 clips - const clipIds: string[] = []; - for (let i = 0; i < 100; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 1100), - timelineEnd: frame(i * 1100 + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - // Add markers - let state = engine.getState(); - for (let i = 0; i < 50; i++) { - state = addTimelineMarker(state, { - id: generateMarkerId(), - type: 'timeline', - frame: frame(i * 2000), - label: `Marker ${i}`, - }); - } - - // Create link groups - for (let i = 0; i < 10; i++) { - const groupClipIds = clipIds.slice(i * 10, (i + 1) * 10); - state = createLinkGroup(state, groupClipIds); - } - - // Serialize state to JSON - const serialized = JSON.stringify(state, (key, value) => { - // Convert Maps to objects for serialization - if (value instanceof Map) { - return { - __type: 'Map', - entries: Array.from(value.entries()), - }; - } - return value; - }); - - assert(serialized.length > 0, 'State serialized'); - - // Deserialize - const deserialized = JSON.parse(serialized, (key, value) => { - // Restore Maps from objects - if (value && value.__type === 'Map') { - return new Map(value.entries); - } - return value; - }); - - // Verify deserialized state - assert(deserialized.timeline.tracks.length === 1, 'Track count preserved'); - assert(deserialized.timeline.tracks[0].clips.length === 100, 'Clip count preserved'); - assert(deserialized.markers.timeline.length === 50, 'Marker count preserved'); - assert(deserialized.linkGroups.size === 10, 'Link group count preserved'); -}); - -test('Round-trip serialization preserves data integrity', () => { - const engine = createTestEngine(); - - const track = createTrack({ - id: generateTrackId(), - name: 'Test Track', - type: 'video', - }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(5000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(1234), - timelineEnd: frame(5678), - mediaIn: frame(100), - mediaOut: frame(4544), - }); - - engine.addClip(track.id, clip); - - const originalState = engine.getState(); - - // Serialize and deserialize - const serialized = JSON.stringify(originalState, (key, value) => { - if (value instanceof Map) { - return { - __type: 'Map', - entries: Array.from(value.entries()), - }; - } - return value; - }); - - const deserialized = JSON.parse(serialized, (key, value) => { - if (value && value.__type === 'Map') { - return new Map(value.entries); - } - return value; - }); - - // Verify exact values - const originalClip = originalState.timeline.tracks[0]!.clips[0]!; - const deserializedClip = deserialized.timeline.tracks[0].clips[0]; - - assert(deserializedClip.id === originalClip.id, 'Clip ID preserved'); - assert(deserializedClip.timelineStart === originalClip.timelineStart, 'Timeline start preserved'); - assert(deserializedClip.timelineEnd === originalClip.timelineEnd, 'Timeline end preserved'); - assert(deserializedClip.mediaIn === originalClip.mediaIn, 'Media in preserved'); - assert(deserializedClip.mediaOut === originalClip.mediaOut, 'Media out preserved'); -}); - -// ===== SUMMARY ===== -console.log('='.repeat(50)); -console.log(`Tests Passed: ${testsPassed}`); -console.log(`Tests Failed: ${testsFailed}`); -console.log(`Total Tests: ${testsPassed + testsFailed}`); -console.log('='.repeat(50)); - -if (testsFailed === 0) { - console.log('\n✓ ALL EDGE CASE TESTS PASSED!\n'); - console.log('System handles pathological scenarios correctly.'); -} else { - console.log(`\n✗ ${testsFailed} test(s) failed.\n`); - throw new Error(`${testsFailed} edge case test(s) failed`); -} diff --git a/packages/core/src/__tests__/frame-types.test.ts b/packages/core/src/__tests__/frame-types.test.ts new file mode 100644 index 0000000..5c4d7fa --- /dev/null +++ b/packages/core/src/__tests__/frame-types.test.ts @@ -0,0 +1,68 @@ +/** + * FRAME TYPE TESTS — Phase 0 + * + * Verifies the FrameRate discriminated union and TimelineFrame brand enforce + * the spec rules at the TypeScript level. + */ + +import { describe, it, expect } from 'vitest'; +import { + frame, + frameRate, + toFrame, + FrameRates, + isDropFrame, + isValidFrame, +} from '../types/frame'; + +describe('TimelineFrame', () => { + it('frame() rounds floating-point inputs to nearest integer', () => { + expect(frame(29.7)).toBe(30); + expect(frame(0.4)).toBe(0); + }); + + it('frame() throws on negative values', () => { + expect(() => frame(-1)).toThrow(); + }); + + it('toFrame() creates branded TimelineFrame without rounding', () => { + const f = toFrame(100); + expect(f).toBe(100); + }); + + it('isValidFrame() accepts non-negative integers only', () => { + expect(isValidFrame(0)).toBe(true); + expect(isValidFrame(1000)).toBe(true); + expect(isValidFrame(-1)).toBe(false); + expect(isValidFrame(1.5)).toBe(false); + }); +}); + +describe('FrameRate discriminated union', () => { + it('frameRate() accepts every member of the literal union', () => { + const valid = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60]; + for (const v of valid) { + expect(() => frameRate(v)).not.toThrow(); + } + }); + + it('frameRate() rejects values outside the union', () => { + expect(() => frameRate(29.975)).toThrow(); + expect(() => frameRate(0)).toThrow(); + expect(() => frameRate(120)).toThrow(); + }); + + it('FrameRates constants are all valid union members', () => { + expect(() => frameRate(FrameRates.NTSC)).not.toThrow(); + expect(() => frameRate(FrameRates.PAL)).not.toThrow(); + expect(() => frameRate(FrameRates.NTSC_DF)).not.toThrow(); + expect(() => frameRate(FrameRates.CINEMA)).not.toThrow(); + }); + + it('isDropFrame() correctly identifies drop-frame rates', () => { + expect(isDropFrame(29.97)).toBe(true); + expect(isDropFrame(59.94)).toBe(true); + expect(isDropFrame(30)).toBe(false); + expect(isDropFrame(24)).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/history.test.ts b/packages/core/src/__tests__/history.test.ts new file mode 100644 index 0000000..ba077f7 --- /dev/null +++ b/packages/core/src/__tests__/history.test.ts @@ -0,0 +1,121 @@ +/** + * HISTORY ENGINE TESTS — Phase 0 + * + * Verifies that the HistoryStack correctly tracks undo/redo with + * the pure, immutable HistoryState functions. + */ + +import { describe, it, expect } from 'vitest'; +import { + createHistory, + pushHistory, + undo, + redo, + canUndo, + canRedo, + getCurrentState, + clearHistory, +} from '../engine/history'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { toFrame } from '../types/frame'; + +function makeTimeline(name: string, version = 0) { + return createTimeline({ id: 'tl', name, fps: 30, duration: toFrame(3000), version } as any); +} + +function makeState(name: string) { + return createTimelineState({ timeline: makeTimeline(name) }); +} + +describe('createHistory', () => { + it('creates history with no past or future', () => { + const h = createHistory(makeState('S0')); + expect(canUndo(h)).toBe(false); + expect(canRedo(h)).toBe(false); + expect(getCurrentState(h).timeline.name).toBe('S0'); + }); +}); + +describe('pushHistory', () => { + it('moves present to past and sets new state as present', () => { + let h = createHistory(makeState('S0')); + h = pushHistory(h, makeState('S1')); + expect(getCurrentState(h).timeline.name).toBe('S1'); + expect(canUndo(h)).toBe(true); + }); + + it('clears future when a new state is pushed', () => { + let h = createHistory(makeState('S0')); + h = pushHistory(h, makeState('S1')); + h = undo(h); // future = [S1] + h = pushHistory(h, makeState('S2')); // future should clear + expect(canRedo(h)).toBe(false); + expect(getCurrentState(h).timeline.name).toBe('S2'); + }); + + it('respects the history limit', () => { + let h = createHistory(makeState('S0'), 3); // limit to 3 past entries + h = pushHistory(h, makeState('S1')); + h = pushHistory(h, makeState('S2')); + h = pushHistory(h, makeState('S3')); + h = pushHistory(h, makeState('S4')); // oldest (S0) should be dropped + // Can still undo 3 times to reach S1 + h = undo(h); // → S3 + h = undo(h); // → S2 + h = undo(h); // → S1 + expect(getCurrentState(h).timeline.name).toBe('S1'); + expect(canUndo(h)).toBe(false); // S0 was evicted + }); +}); + +describe('undo / redo', () => { + it('undo returns to previous state', () => { + let h = createHistory(makeState('S0')); + h = pushHistory(h, makeState('S1')); + h = undo(h); + expect(getCurrentState(h).timeline.name).toBe('S0'); + }); + + it('redo re-applies an undone state', () => { + let h = createHistory(makeState('S0')); + h = pushHistory(h, makeState('S1')); + h = undo(h); + h = redo(h); + expect(getCurrentState(h).timeline.name).toBe('S1'); + }); + + it('undo is a no-op when there is no past', () => { + const h = createHistory(makeState('S0')); + const h2 = undo(h); + expect(h2).toBe(h); // reference equality — nothing changed + }); + + it('redo is a no-op when there is no future', () => { + const h = createHistory(makeState('S0')); + const h2 = redo(h); + expect(h2).toBe(h); + }); + + it('multiple undo/redo cycles work correctly', () => { + let h = createHistory(makeState('A')); + h = pushHistory(h, makeState('B')); + h = pushHistory(h, makeState('C')); + + h = undo(h); expect(getCurrentState(h).timeline.name).toBe('B'); + h = undo(h); expect(getCurrentState(h).timeline.name).toBe('A'); + h = redo(h); expect(getCurrentState(h).timeline.name).toBe('B'); + h = redo(h); expect(getCurrentState(h).timeline.name).toBe('C'); + }); +}); + +describe('clearHistory', () => { + it('resets past and future but keeps current state', () => { + let h = createHistory(makeState('S0')); + h = pushHistory(h, makeState('S1')); + h = clearHistory(h); + expect(canUndo(h)).toBe(false); + expect(canRedo(h)).toBe(false); + expect(getCurrentState(h).timeline.name).toBe('S1'); + }); +}); diff --git a/packages/core/src/__tests__/invariants.test.ts b/packages/core/src/__tests__/invariants.test.ts new file mode 100644 index 0000000..7c85385 --- /dev/null +++ b/packages/core/src/__tests__/invariants.test.ts @@ -0,0 +1,285 @@ +/** + * INVARIANT CHECKER TESTS — Phase 0 + * + * Every test calls checkInvariants() after state mutations. + * Zero violations is the only passing grade. + */ + +import { describe, it, expect } from 'vitest'; +import { checkInvariants } from '../validation/invariants'; +import { createTimelineState, CURRENT_SCHEMA_VERSION } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, toAssetId } from '../types/asset'; +import { toFrame, FrameRates, toTimecode } from '../types/frame'; + +// ── Shared fixtures ───────────────────────────────────────────────────────── + +function makeBaseState() { + const assetId = toAssetId('asset-1'); + const trackId = toTrackId('track-1'); + const clipId = toClipId('clip-1'); + + const asset = createAsset({ + id: 'asset-1', + name: 'Test Video', + mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + status: 'online', + }); + + const clip = createClip({ + id: 'clip-1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const track = createTrack({ + id: 'track-1', + name: 'Video Track 1', + type: 'video', + clips: [clip], + }); + + const timeline = createTimeline({ + id: 'tl-1', + name: 'Test Timeline', + fps: 30, + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + + const registry = new Map([[assetId, asset]]); + return createTimelineState({ timeline, assetRegistry: registry }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('checkInvariants — valid state', () => { + it('returns empty array for a completely valid state', () => { + const state = makeBaseState(); + expect(checkInvariants(state)).toEqual([]); + }); + + it('returns empty array for an empty timeline (no tracks, no clips)', () => { + const timeline = createTimeline({ id: 'tl', name: 'empty', fps: 24, duration: toFrame(1000) }); + const state = createTimelineState({ timeline }); + expect(checkInvariants(state)).toEqual([]); + }); +}); + +describe('checkInvariants — SCHEMA_VERSION_MISMATCH', () => { + it('rejects a state written by a future engine (schemaVersion > CURRENT)', () => { + const state = makeBaseState(); + // Simulate a state saved by a newer engine + const futureState = { ...state, schemaVersion: CURRENT_SCHEMA_VERSION + 1 }; + const violations = checkInvariants(futureState); + expect(violations).toHaveLength(1); + expect(violations[0]!.entityId).toBe('timeline'); + expect(violations[0]!.message).toMatch('Expected schema v'); + }); + + it('rejects a state that has not been migrated (schemaVersion < CURRENT)', () => { + const state = makeBaseState(); + // Simulate an old un-migrated state (schemaVersion = 0) + const oldState = { ...state, schemaVersion: 0 }; + const violations = checkInvariants(oldState); + expect(violations).toHaveLength(1); + expect(violations[0]!.entityId).toBe('timeline'); + expect(violations[0]!.message).toMatch('Expected schema v'); + }); + + it('early-returns on version mismatch — does not run track/clip checks', () => { + const state = makeBaseState(); + // A future state with deliberately corrupt (overlapping) clips. + // The schemaVersion check must early-return so we get exactly 1 violation + // (the schema mismatch), NOT additional overlap violations. + const futureState = { ...state, schemaVersion: CURRENT_SCHEMA_VERSION + 99 }; + const violations = checkInvariants(futureState); + expect(violations).toHaveLength(1); + }); +}); + +describe('checkInvariants — OVERLAP', () => { + it('detects two clips that overlap by one frame', () => { + const state = makeBaseState(); + // Add overlapping clip [50..150] — overlaps with [0..100] + const clip2 = createClip({ + id: 'clip-2', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(50), + timelineEnd: toFrame(150), + mediaIn: toFrame(50), + mediaOut: toFrame(150), + }); + + const newTracks = state.timeline.tracks.map(t => + t.id === 'track-1' ? { ...t, clips: [...t.clips, clip2] } : t + ); + const badState = { ...state, timeline: { ...state.timeline, tracks: newTracks } }; + const violations = checkInvariants(badState); + expect(violations.length).toBeGreaterThanOrEqual(1); + expect(violations.some(v => v.type === 'OVERLAP')).toBe(true); + }); + + it('does NOT flag adjacent clips [0..100] and [100..200]', () => { + const state = makeBaseState(); + const clip2 = createClip({ + id: 'clip-2', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(100), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const newTracks = state.timeline.tracks.map(t => + t.id === 'track-1' ? { ...t, clips: [...t.clips, clip2] } : t + ); + const s = { ...state, timeline: { ...state.timeline, tracks: newTracks } }; + expect(checkInvariants(s)).toEqual([]); + }); +}); + +describe('checkInvariants — ASSET_MISSING', () => { + it('flags a clip referencing an asset that is not in the registry', () => { + const state = makeBaseState(); + const clip2 = createClip({ + id: 'clip-ghost', + assetId: 'non-existent-asset', + trackId: 'track-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const newTracks = state.timeline.tracks.map(t => + t.id === 'track-1' ? { ...t, clips: [...t.clips, clip2] } : t + ); + const badState = { ...state, timeline: { ...state.timeline, tracks: newTracks } }; + const violations = checkInvariants(badState); + expect(violations.some(v => v.type === 'ASSET_MISSING' && v.entityId === 'clip-ghost')).toBe(true); + }); +}); + +describe('checkInvariants — MEDIA_BOUNDS_INVALID', () => { + it('flags a clip whose mediaOut exceeds asset.intrinsicDuration', () => { + const state = makeBaseState(); + // Asset has intrinsicDuration=600. Clip mediaOut=700 exceeds it. + const badClip = createClip({ + id: 'clip-overflow', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(900), + mediaIn: toFrame(0), + mediaOut: toFrame(700), // > intrinsicDuration(600) + }); + const newTracks = state.timeline.tracks.map(t => + t.id === 'track-1' ? { ...t, clips: [...t.clips, badClip] } : t + ); + const badState = { ...state, timeline: { ...state.timeline, tracks: newTracks } }; + const violations = checkInvariants(badState); + expect(violations.some(v => v.type === 'MEDIA_BOUNDS_INVALID')).toBe(true); + }); + + it('flags a clip with mediaIn < 0', () => { + const state = makeBaseState(); + const badClip = createClip({ + id: 'clip-neg', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), // will be manually cast below + mediaOut: toFrame(100), + }); + // Force negative mediaIn by type cast (testing invariant checker, not factory) + const badClipNeg = { ...badClip, mediaIn: -5 as ReturnType }; + const newTracks = state.timeline.tracks.map(t => + t.id === 'track-1' ? { ...t, clips: [...t.clips, badClipNeg] } : t + ); + const badState = { ...state, timeline: { ...state.timeline, tracks: newTracks } }; + expect(checkInvariants(badState).some(v => v.type === 'MEDIA_BOUNDS_INVALID')).toBe(true); + }); +}); + +describe('checkInvariants — TRACK_TYPE_MISMATCH', () => { + it('flags an audio asset clip placed on a video track', () => { + const assetId = toAssetId('audio-asset'); + const audioAsset = createAsset({ + id: 'audio-asset', + name: 'Music', + mediaType: 'audio', + filePath: '/media/music.wav', + intrinsicDuration: toFrame(1200), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + + const state = makeBaseState(); + const audioClip = createClip({ + id: 'clip-audio-on-video', + assetId: 'audio-asset', + trackId: 'track-1', // track-1 is a VIDEO track + timelineStart: toFrame(200), + timelineEnd: toFrame(400), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const newRegistry = new Map(state.assetRegistry); + newRegistry.set(assetId, audioAsset); + const newTracks = state.timeline.tracks.map(t => + t.id === 'track-1' ? { ...t, clips: [...t.clips, audioClip] } : t + ); + const badState = { + ...state, + assetRegistry: newRegistry, + timeline: { ...state.timeline, tracks: newTracks }, + }; + expect(checkInvariants(badState).some(v => v.type === 'TRACK_TYPE_MISMATCH')).toBe(true); + }); +}); + +describe('checkInvariants — CLIP_BEYOND_TIMELINE', () => { + it('flags a clip that extends past the timeline duration', () => { + const state = makeBaseState(); // timeline.duration = 3000 + const badClip = createClip({ + id: 'clip-late', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(2900), + timelineEnd: toFrame(3100), // > 3000 + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const newTracks = state.timeline.tracks.map(t => + t.id === 'track-1' ? { ...t, clips: [...t.clips, badClip] } : t + ); + const badState = { ...state, timeline: { ...state.timeline, tracks: newTracks } }; + expect(checkInvariants(badState).some(v => v.type === 'CLIP_BEYOND_TIMELINE')).toBe(true); + }); +}); + +describe('checkInvariants — SPEED_INVALID', () => { + it('flags a clip with speed <= 0', () => { + const state = makeBaseState(); + const clipFromState = state.timeline.tracks[0]!.clips[0]!; + const badClip = { ...clipFromState, speed: -1 }; + const newTracks = state.timeline.tracks.map(t => + t.id === 'track-1' ? { ...t, clips: [badClip] } : t + ); + const badState = { ...state, timeline: { ...state.timeline, tracks: newTracks } }; + expect(checkInvariants(badState).some(v => v.type === 'SPEED_INVALID')).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/phase2-tests.ts b/packages/core/src/__tests__/phase2-tests.ts deleted file mode 100644 index 2994c5b..0000000 --- a/packages/core/src/__tests__/phase2-tests.ts +++ /dev/null @@ -1,990 +0,0 @@ -/** - * PHASE 2 TEST SUITE - * - * Comprehensive tests for editing intelligence features: - * - Transactions - * - Snapping - * - Linking - * - Grouping - * - Markers - * - Clipboard - * - Drag State - * - Ripple Operations - */ - -// Import all internal systems for testing -import { - TimelineEngine, - createTimeline, - createTrack, - createClip, - createAsset, - createTimelineState, - frame, - frameRate, - generateClipId, - generateTrackId, - generateAssetId, - generateTimelineId, - generateLinkGroupId, - generateGroupId, - generateMarkerId, - beginTransaction, - applyOperation, - commitTransaction, - rollbackTransaction, - findSnapTargets, - calculateSnap, - calculateSnapExcluding, - createLinkGroup, - breakLinkGroup, - getLinkedClips, - moveLinkedClips, - deleteLinkedClips, - createGroup, - ungroupClips, - getGroupClips, - getChildGroups, - addTimelineMarker, - addClipMarker, - addRegionMarker, - setWorkArea, - clearWorkArea, - copyClips, - cutClips, - pasteClips, - duplicateClips, - calculateDragPreview, - calculateResizeDragPreview, - rippleDelete, - rippleTrim, - insertEdit, - moveClip, - addClip, -} from '../internal'; - -let testsPassed = 0; -let testsFailed = 0; - -function test(name: string, fn: () => void) { - try { - fn(); - console.log(`✓ ${name}`); - testsPassed++; - } catch (error) { - console.error(`✗ ${name}`); - console.error(` Error: ${error instanceof Error ? error.message : String(error)}`); - testsFailed++; - } -} - -function assert(condition: boolean, message: string) { - if (!condition) { - throw new Error(message); - } -} - -// Setup helper -function createTestEngine() { - const timeline = createTimeline({ - id: generateTimelineId(), - name: 'Test Timeline', - fps: frameRate(30), - duration: frame(9000), - tracks: [], - }); - - const state = createTimelineState({ timeline }); - return new TimelineEngine(state); -} - -console.log('\n=== PHASE 2 TEST SUITE ===\n'); - -// ===== TRANSACTION TESTS ===== -console.log('--- Transaction System ---'); - -test('Transaction batches multiple operations', () => { - const engine = createTestEngine(); - - // Add track and asset - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Create two clips - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(200), - timelineEnd: frame(300), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - const historyBefore = engine.canUndo(); - - // Use transaction to move both clips - let tx = beginTransaction(engine.getState()); - tx = applyOperation(tx, s => moveClip(s, clip1.id, frame(500))); - tx = applyOperation(tx, s => moveClip(s, clip2.id, frame(700))); - const newState = commitTransaction(tx); - - // Manually update engine state (in real usage, this would be through engine) - assert(newState.timeline.tracks[0]!.clips[0]!.timelineStart === frame(500), 'Clip 1 moved'); - assert(newState.timeline.tracks[0]!.clips[1]!.timelineStart === frame(700), 'Clip 2 moved'); -}); - -test('Transaction rollback discards changes', () => { - const engine = createTestEngine(); - const initialState = engine.getState(); - - let tx = beginTransaction(initialState); - tx = applyOperation(tx, s => s); // No-op - const rolledBack = rollbackTransaction(tx); - - assert(rolledBack === initialState, 'State unchanged after rollback'); -}); - -// ===== SNAPPING TESTS ===== -console.log('--- Snapping System ---'); - -test('Snapping finds clip boundaries as targets', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(100), - timelineEnd: frame(200), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - - const targets = findSnapTargets(engine.getState()); - - assert(targets.length >= 2, 'Found snap targets'); - assert(targets.some(t => t.type === 'clip-start' && t.frame === frame(100)), 'Found clip start'); - assert(targets.some(t => t.type === 'clip-end' && t.frame === frame(200)), 'Found clip end'); -}); - -test('Snapping calculates correct snap within threshold', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(100), - timelineEnd: frame(200), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - - const targets = findSnapTargets(engine.getState()); - - // Try to snap to frame 103 (within 5 frame threshold of 100) - const result = calculateSnap(frame(103), targets, frame(5)); - - assert(result.snapped === true, 'Snapping occurred'); - assert(result.snappedFrame === frame(100), 'Snapped to clip start'); - assert(result.distance === 3, 'Correct distance'); -}); - -test('Snapping does not snap beyond threshold', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(100), - timelineEnd: frame(200), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - - const targets = findSnapTargets(engine.getState()); - - // Try to snap to frame 110 (beyond 5 frame threshold) - const result = calculateSnap(frame(110), targets, frame(5)); - - assert(result.snapped === false, 'No snapping occurred'); - assert(result.snappedFrame === frame(110), 'Frame unchanged'); -}); - -test('Snapping excludes specified clips', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(100), - timelineEnd: frame(200), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - - const targets = findSnapTargets(engine.getState()); - - // Exclude clip1 from snapping - const result = calculateSnapExcluding(frame(103), targets, frame(5), [clip1.id]); - - assert(result.snapped === false, 'Excluded clip not snapped to'); -}); - -// ===== LINKING TESTS ===== -console.log('--- Linking System ---'); - -test('Create link group links clips together', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(200), - timelineEnd: frame(300), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - const state = createLinkGroup(engine.getState(), [clip1.id, clip2.id]); - - const linked = getLinkedClips(state, clip1.id); - assert(linked.length === 2, 'Both clips linked'); - assert(linked.some(c => c.id === clip1.id), 'Clip 1 in group'); - assert(linked.some(c => c.id === clip2.id), 'Clip 2 in group'); -}); - -test('Move linked clips maintains relative positions', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(200), - timelineEnd: frame(300), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - let state = createLinkGroup(engine.getState(), [clip1.id, clip2.id]); - - // Move clip1 to frame 500 (delta = +500) - state = moveLinkedClips(state, clip1.id, frame(500)); - - const linked = getLinkedClips(state, clip1.id); - const movedClip1 = linked.find(c => c.id === clip1.id)!; - const movedClip2 = linked.find(c => c.id === clip2.id)!; - - assert(movedClip1.timelineStart === frame(500), 'Clip 1 moved to 500'); - assert(movedClip2.timelineStart === frame(700), 'Clip 2 moved to 700 (maintained 200 frame offset)'); -}); - -test('Break link group removes linking', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(200), - timelineEnd: frame(300), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - let state = createLinkGroup(engine.getState(), [clip1.id, clip2.id]); - const linkGroupId = getLinkedClips(state, clip1.id)[0]!.linkGroupId!; - - state = breakLinkGroup(state, linkGroupId); - - const linked = getLinkedClips(state, clip1.id); - assert(linked.length === 1, 'Only one clip (itself) after breaking link'); - assert(linked[0]!.linkGroupId === undefined, 'Link group ID cleared'); -}); - -// ===== GROUPING TESTS ===== -console.log('--- Grouping System ---'); - -test('Create group organizes clips', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - - const state = createGroup(engine.getState(), [clip1.id], 'Scene 1', { - color: '#ff0000', - }); - - // Get the updated clip from the new state - const updatedClip = state.timeline.tracks[0]!.clips[0]!; - assert(updatedClip.groupId !== undefined, 'Clip has groupId'); - - const grouped = getGroupClips(state, updatedClip.groupId!); - assert(grouped.length === 1, 'Clip in group'); - assert(grouped[0]!.id === clip1.id, 'Correct clip grouped'); -}); - -test('Nested groups work correctly', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(200), - timelineEnd: frame(300), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - // Create parent group with clip1 - let state = createGroup(engine.getState(), [clip1.id], 'Parent Group'); - const updatedClip1 = state.timeline.tracks[0]!.clips.find(c => c.id === clip1.id)!; - const parentGroupId = updatedClip1.groupId!; - - // Create child group with clip2, nested under parent - state = createGroup(state, [clip2.id], 'Child Group', { - parentGroupId, - }); - - const children = getChildGroups(state, parentGroupId); - assert(children.length === 1, 'One child group'); - assert(children[0]!.name === 'Child Group', 'Correct child group'); - assert(children[0]!.parentGroupId === parentGroupId, 'Child has correct parent'); -}); - -// ===== MARKER TESTS ===== -console.log('--- Marker System ---'); - -test('Add timeline marker', () => { - const engine = createTestEngine(); - - const state = addTimelineMarker(engine.getState(), { - id: generateMarkerId(), - type: 'timeline', - frame: frame(1000), - label: 'Chapter 1', - color: '#00ff00', - }); - - assert(state.markers.timeline.length === 1, 'Marker added'); - assert(state.markers.timeline[0]!.frame === frame(1000), 'Correct frame'); - assert(state.markers.timeline[0]!.label === 'Chapter 1', 'Correct label'); -}); - -test('Add clip marker', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - - const state = addClipMarker(engine.getState(), { - id: generateMarkerId(), - type: 'clip', - clipId: clip.id, - frame: frame(50), - label: 'Important moment', - }); - - assert(state.markers.clips.length === 1, 'Clip marker added'); - assert(state.markers.clips[0]!.clipId === clip.id, 'Correct clip'); -}); - -test('Set work area', () => { - const engine = createTestEngine(); - - const state = setWorkArea(engine.getState(), { - startFrame: frame(100), - endFrame: frame(500), - }); - - assert(state.workArea !== undefined, 'Work area set'); - assert(state.workArea!.startFrame === frame(100), 'Correct start'); - assert(state.workArea!.endFrame === frame(500), 'Correct end'); -}); - -test('Clear work area', () => { - const engine = createTestEngine(); - - let state = setWorkArea(engine.getState(), { - startFrame: frame(100), - endFrame: frame(500), - }); - - state = clearWorkArea(state); - - assert(state.workArea === undefined, 'Work area cleared'); -}); - -// ===== CLIPBOARD TESTS ===== -console.log('--- Clipboard System ---'); - -test('Copy clips preserves relative positions', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(200), - timelineEnd: frame(300), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - const clipboard = copyClips(engine.getState(), [clip1.id, clip2.id]); - - assert(clipboard.clips.length === 2, 'Two clips copied'); - assert(clipboard.relativePositions[0] === frame(0), 'First clip at offset 0'); - assert(clipboard.relativePositions[1] === frame(200), 'Second clip at offset 200'); -}); - -test('Paste clips generates new IDs', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - - const clipboard = copyClips(engine.getState(), [clip1.id]); - const state = pasteClips(engine.getState(), track.id, frame(500), clipboard); - - const allClips = state.timeline.tracks[0]!.clips; - assert(allClips.length === 2, 'Two clips after paste'); - assert(allClips[0]!.id !== allClips[1]!.id, 'Different IDs'); - assert(allClips[1]!.timelineStart === frame(500), 'Pasted at correct position'); -}); - -test('Duplicate clips with offset', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - - const state = duplicateClips(engine.getState(), [clip1.id], frame(200)); - - const allClips = state.timeline.tracks[0]!.clips; - assert(allClips.length === 2, 'Two clips after duplicate'); - assert(allClips[1]!.timelineStart === frame(200), 'Duplicated with offset'); -}); - -// ===== DRAG STATE TESTS ===== -console.log('--- Drag State ---'); - -test('Drag preview calculates valid position', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - - const preview = calculateDragPreview( - engine.getState(), - clip.id, - frame(500), - frame(5) - ); - - assert(preview.valid === true, 'Drag is valid'); - assert(preview.proposedStart === frame(500), 'Correct proposed position'); - assert(preview.proposedEnd === frame(600), 'Correct proposed end'); -}); - -test('Drag preview with snapping', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(500), - timelineEnd: frame(600), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - // Drag clip1 near clip2's start (503 is within 5 frame threshold) - const preview = calculateDragPreview( - engine.getState(), - clip1.id, - frame(503), - frame(5) - ); - - assert(preview.snapped === true, 'Snapping occurred'); - assert(preview.proposedStart === frame(500), 'Snapped to clip2 start'); -}); - -// ===== RIPPLE OPERATION TESTS ===== -console.log('--- Ripple Operations ---'); - -test('Ripple delete shifts subsequent clips', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(100), - timelineEnd: frame(200), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - const state = rippleDelete(engine.getState(), clip1.id); - - const remainingClips = state.timeline.tracks[0]!.clips; - assert(remainingClips.length === 1, 'One clip remaining'); - assert(remainingClips[0]!.timelineStart === frame(0), 'Clip shifted left by 100 frames'); -}); - -test('Ripple trim shifts subsequent clips', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const clip2 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(100), - timelineEnd: frame(200), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - engine.addClip(track.id, clip2); - - // Trim clip1 to 50 frames (reduce by 50) - const state = rippleTrim(engine.getState(), clip1.id, frame(50)); - - const clips = state.timeline.tracks[0]!.clips; - assert(clips[0]!.timelineEnd === frame(50), 'Clip trimmed'); - assert(clips[1]!.timelineStart === frame(50), 'Subsequent clip shifted left by 50'); -}); - -test('Insert edit shifts clips right', () => { - const engine = createTestEngine(); - - const track = createTrack({ id: generateTrackId(), name: 'Track 1', type: 'video' }); - engine.addTrack(track); - - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(300), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - const clip1 = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip1); - - const newClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), // Will be adjusted - timelineEnd: frame(50), - mediaIn: frame(0), - mediaOut: frame(50), - }); - - // Insert at frame 50 (middle of clip1) - const state = insertEdit(engine.getState(), track.id, newClip, frame(50)); - - const clips = state.timeline.tracks[0]!.clips; - assert(clips.length === 2, 'Two clips after insert'); - // Original clip1 should be shifted right - assert(clips.some(c => c.timelineStart === frame(50)), 'Clip shifted right by 50'); -}); - -// ======================================== -// RESULTS -// ======================================== -console.log('='.repeat(50)); -console.log(`Tests Passed: ${testsPassed}`); -console.log(`Tests Failed: ${testsFailed}`); -console.log(`Total Tests: ${testsPassed + testsFailed}`); -console.log('='.repeat(50)); - -if (testsFailed === 0) { - console.log('\n✓ ALL TESTS PASSED!\n'); - console.log('Phase 1 kernel is stable and deterministic.'); -} else { - console.log(`\n✗ ${testsFailed} test(s) failed.\n`); - throw new Error(`${testsFailed} test(s) failed`); -} diff --git a/packages/core/src/__tests__/phase3.test.ts b/packages/core/src/__tests__/phase3.test.ts new file mode 100644 index 0000000..ee72ee3 --- /dev/null +++ b/packages/core/src/__tests__/phase3.test.ts @@ -0,0 +1,881 @@ +/** + * PHASE 3 STEP 1 TESTS — Markers, Generators, In/Out, BeatGrid, Captions + * + * Covers: 11 new primitives, 4 new invariants, Asset discriminated union. + * Every test that produces new state runs checkInvariants(). + */ + +import { describe, it, expect } from 'vitest'; +import { dispatch } from '../engine/dispatcher'; +import { applyOperation } from '../engine/apply'; +import { checkInvariants } from '../validation/invariants'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, createGeneratorAsset, toAssetId } from '../types/asset'; +import { toFrame, frameRate, toTimecode } from '../types/frame'; +import { toMarkerId } from '../types/marker'; +import { toGeneratorId } from '../types/generator'; +import { toCaptionId } from '../types/caption'; +import type { OperationPrimitive, Transaction } from '../types/operations'; +import { buildSnapIndex } from '../snap-index'; +import { findMarkersByColor, findMarkersByLabel } from '../engine/marker-search'; +import { defaultCaptionStyle as defaultCaptionStyleFromCore } from '../engine/subtitle-import'; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +let txCounter = 0; +function makeTx(label: string, operations: OperationPrimitive[]): Transaction { + return { id: `tx-${++txCounter}`, label, timestamp: Date.now(), operations }; +} + +function makeBaseState() { + const asset = createAsset({ + id: 'asset-1', + name: 'V1', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clip = createClip({ + id: 'clip-1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +const defaultCaptionStyle = { + fontFamily: 'Arial', + fontSize: 24, + color: '#fff', + backgroundColor: '#000', + hAlign: 'center' as const, + vAlign: 'bottom' as const, +}; + +// ── 1. Branded IDs and factories ───────────────────────────────────────────── + +describe('Phase 3 — IDs and factories', () => { + it('toMarkerId, toGeneratorId, toCaptionId return branded ids', () => { + expect(toMarkerId('m1')).toBe('m1'); + expect(toGeneratorId('g1')).toBe('g1'); + expect(toCaptionId('c1')).toBe('c1'); + }); + + it('createGeneratorAsset produces GeneratorAsset with kind "generator"', () => { + const gen = createGeneratorAsset({ + id: 'gen-1', + name: 'Solid', + mediaType: 'video', + generatorDef: { + id: toGeneratorId('gen-1'), + type: 'solid', + params: { color: '#ff0000' }, + duration: toFrame(60), + name: 'Red', + }, + nativeFps: 30, + }); + expect(gen.kind).toBe('generator'); + expect(gen.generatorDef.duration).toBe(60); + expect(gen.intrinsicDuration).toBe(60); + expect(gen.sourceTimecodeOffset).toBe(0); + }); +}); + +// ── 2. Timeline/Track defaults ─────────────────────────────────────────────── + +describe('Phase 3 — createTimeline/createTrack defaults', () => { + it('createTimeline without Phase 3 params has markers=[], beatGrid=null, in/out=null', () => { + const tl = createTimeline({ id: 'tl', name: 'T', fps: 24, duration: toFrame(100) }); + expect(tl.markers).toEqual([]); + expect(tl.beatGrid).toBeNull(); + expect(tl.inPoint).toBeNull(); + expect(tl.outPoint).toBeNull(); + }); + + it('createTrack without captions has captions=[]', () => { + const track = createTrack({ id: 't1', name: 'V1', type: 'video' }); + expect(track.captions).toEqual([]); + }); +}); + +// ── 3. ADD_MARKER ─────────────────────────────────────────────────────────── + +describe('Phase 3 — ADD_MARKER', () => { + it('adds point marker to timeline.markers sorted by frame', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(50), + label: 'A', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + const next = applyOperation(state, { type: 'ADD_MARKER', marker }); + expect(next.timeline.markers).toHaveLength(1); + expect(next.timeline.markers[0]!.type).toBe('point'); + expect((next.timeline.markers[0] as { frame: number }).frame).toBe(50); + expect(checkInvariants(next)).toEqual([]); + }); + + it('adds range marker with frameStart/frameEnd', () => { + const state = makeBaseState(); + const marker = { + type: 'range' as const, + id: toMarkerId('r1'), + frameStart: toFrame(10), + frameEnd: toFrame(90), + label: 'Range', + color: '#0f0', + scope: 'global' as const, + linkedClipId: null, + }; + const next = applyOperation(state, { type: 'ADD_MARKER', marker }); + expect(next.timeline.markers).toHaveLength(1); + expect(next.timeline.markers[0]!.type).toBe('range'); + const r = next.timeline.markers[0] as { frameStart: number; frameEnd: number }; + expect(r.frameStart).toBe(10); + expect(r.frameEnd).toBe(90); + expect(checkInvariants(next)).toEqual([]); + }); + + it('dispatch ADD_MARKER accepts and invariants pass', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m2'), + frame: toFrame(200), + label: 'B', + color: '#00f', + scope: 'global' as const, + linkedClipId: null, + }; + const result = dispatch(state, makeTx('Add marker', [{ type: 'ADD_MARKER', marker }])); + expect(result.accepted).toBe(true); + if (result.accepted) expect(checkInvariants(result.nextState)).toEqual([]); + }); +}); + +// ── 4. MOVE_MARKER ────────────────────────────────────────────────────────── + +describe('Phase 3 — MOVE_MARKER', () => { + it('moves point marker to newFrame', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(50), + label: 'A', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + let next = applyOperation(state, { type: 'ADD_MARKER', marker }); + next = applyOperation(next, { type: 'MOVE_MARKER', markerId: toMarkerId('m1'), newFrame: toFrame(100) }); + const m = next.timeline.markers[0] as { type: 'point'; frame: number }; + expect(m.frame).toBe(100); + expect(checkInvariants(next)).toEqual([]); + }); + + it('moves range marker preserving duration', () => { + const state = makeBaseState(); + const marker = { + type: 'range' as const, + id: toMarkerId('r1'), + frameStart: toFrame(10), + frameEnd: toFrame(50), + label: 'R', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + let next = applyOperation(state, { type: 'ADD_MARKER', marker }); + next = applyOperation(next, { type: 'MOVE_MARKER', markerId: toMarkerId('r1'), newFrame: toFrame(100) }); + const r = next.timeline.markers[0] as { frameStart: number; frameEnd: number }; + expect(r.frameStart).toBe(100); + expect(r.frameEnd).toBe(140); // 100 + 40 + expect(checkInvariants(next)).toEqual([]); + }); + + it('dispatch MOVE_MARKER with invalid id returns NOT_FOUND', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('Move', [{ type: 'MOVE_MARKER', markerId: toMarkerId('none'), newFrame: toFrame(10) }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('NOT_FOUND'); + }); +}); + +// ── 5. DELETE_MARKER ──────────────────────────────────────────────────────── + +describe('Phase 3 — DELETE_MARKER', () => { + it('removes marker from timeline.markers', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(50), + label: 'A', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + let next = applyOperation(state, { type: 'ADD_MARKER', marker }); + next = applyOperation(next, { type: 'DELETE_MARKER', markerId: toMarkerId('m1') }); + expect(next.timeline.markers).toHaveLength(0); + expect(checkInvariants(next)).toEqual([]); + }); + + it('dispatch DELETE_MARKER with invalid id returns NOT_FOUND', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('Del', [{ type: 'DELETE_MARKER', markerId: toMarkerId('none') }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('NOT_FOUND'); + }); +}); + +// ── 6. SET_IN_POINT / SET_OUT_POINT ──────────────────────────────────────── + +describe('Phase 3 — SET_IN_POINT / SET_OUT_POINT', () => { + it('SET_IN_POINT sets timeline.inPoint, null clears', () => { + const state = makeBaseState(); + let next = applyOperation(state, { type: 'SET_IN_POINT', frame: toFrame(100) }); + expect(next.timeline.inPoint).toBe(100); + next = applyOperation(next, { type: 'SET_IN_POINT', frame: null }); + expect(next.timeline.inPoint).toBeNull(); + expect(checkInvariants(next)).toEqual([]); + }); + + it('SET_OUT_POINT sets timeline.outPoint', () => { + const state = makeBaseState(); + const next = applyOperation(state, { type: 'SET_OUT_POINT', frame: toFrame(500) }); + expect(next.timeline.outPoint).toBe(500); + expect(checkInvariants(next)).toEqual([]); + }); + + it('dispatch rejects SET_IN_POINT when >= outPoint', () => { + const state = makeBaseState(); + let next = applyOperation(state, { type: 'SET_OUT_POINT', frame: toFrame(200) }); + const result = dispatch(next, makeTx('In', [{ type: 'SET_IN_POINT', frame: toFrame(200) }])); + expect(result.accepted).toBe(false); + }); +}); + +// ── 7. ADD_BEAT_GRID / REMOVE_BEAT_GRID ───────────────────────────────────── + +describe('Phase 3 — ADD_BEAT_GRID / REMOVE_BEAT_GRID', () => { + it('ADD_BEAT_GRID sets timeline.beatGrid', () => { + const state = makeBaseState(); + const beatGrid = { bpm: 120, timeSignature: [4, 4] as const, offset: toFrame(0) }; + const next = applyOperation(state, { type: 'ADD_BEAT_GRID', beatGrid }); + expect(next.timeline.beatGrid).toEqual(beatGrid); + expect(checkInvariants(next)).toEqual([]); + }); + + it('REMOVE_BEAT_GRID sets beatGrid to null', () => { + const state = makeBaseState(); + const beatGrid = { bpm: 120, timeSignature: [4, 4] as const, offset: toFrame(0) }; + let next = applyOperation(state, { type: 'ADD_BEAT_GRID', beatGrid }); + next = applyOperation(next, { type: 'REMOVE_BEAT_GRID' }); + expect(next.timeline.beatGrid).toBeNull(); + expect(checkInvariants(next)).toEqual([]); + }); + + it('dispatch ADD_BEAT_GRID when one exists returns BEAT_GRID_EXISTS', () => { + const state = makeBaseState(); + const beatGrid = { bpm: 120, timeSignature: [4, 4] as const, offset: toFrame(0) }; + const tx = makeTx('BG', [ + { type: 'ADD_BEAT_GRID', beatGrid }, + { type: 'ADD_BEAT_GRID', beatGrid: { ...beatGrid, bpm: 90 } }, + ]); + const result = dispatch(state, tx); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('BEAT_GRID_EXISTS'); + }); + + it('REMOVE_BEAT_GRID when already null is idempotent (no error)', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('Remove BG', [{ type: 'REMOVE_BEAT_GRID' }])); + expect(result.accepted).toBe(true); + if (result.accepted) expect(checkInvariants(result.nextState)).toEqual([]); + }); +}); + +// ── 9. INSERT_GENERATOR ───────────────────────────────────────────────────── + +describe('Phase 3 — INSERT_GENERATOR', () => { + it('registers GeneratorAsset and inserts clip at atFrame; nativeFps from state.timeline.fps', () => { + const state = makeBaseState(); + const generator = { + id: toGeneratorId('gen-1'), + type: 'solid' as const, + params: { color: '#f00' }, + duration: toFrame(60), + name: 'Red Solid', + }; + const next = applyOperation(state, { + type: 'INSERT_GENERATOR', + generator, + trackId: toTrackId('track-1'), + atFrame: toFrame(200), + }); + const assetEntry = next.assetRegistry.get(toAssetId(generator.id as unknown as string)); + expect(assetEntry).toBeDefined(); + const asset = assetEntry!; + expect(asset.kind).toBe('generator'); + if (asset.kind === 'generator') { + expect(asset.nativeFps).toBe(30); + expect(asset.generatorDef.duration).toBe(60); + } + const track = next.timeline.tracks.find((t) => t.id === 'track-1')!; + expect(track.clips).toHaveLength(2); + const genClip = track.clips.find((c) => c.timelineStart === 200); + expect(genClip).toBeDefined(); + expect(genClip!.timelineEnd).toBe(260); + expect(checkInvariants(next)).toEqual([]); + }); + + it('dispatch INSERT_GENERATOR with invalid trackId rejects', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('Gen', [{ + type: 'INSERT_GENERATOR', + generator: { + id: toGeneratorId('g1'), + type: 'solid', + params: {}, + duration: toFrame(50), + name: 'S', + }, + trackId: toTrackId('none'), + atFrame: toFrame(0), + }])); + expect(result.accepted).toBe(false); + }); +}); + +// ── 10. ADD_CAPTION ───────────────────────────────────────────────────────── + +describe('Phase 3 — ADD_CAPTION', () => { + it('adds caption to track.captions', () => { + const state = makeBaseState(); + const caption = { + id: toCaptionId('cap-1'), + text: 'Hello', + startFrame: toFrame(0), + endFrame: toFrame(100), + language: 'en-US', + style: defaultCaptionStyle, + burnIn: false, + }; + const next = applyOperation(state, { type: 'ADD_CAPTION', caption, trackId: toTrackId('track-1') }); + const track = next.timeline.tracks[0]!; + expect(track.captions).toHaveLength(1); + expect(track.captions[0]!.text).toBe('Hello'); + expect(checkInvariants(next)).toEqual([]); + }); + + it('ADD_CAPTION without style uses defaultCaptionStyle', () => { + const state = makeBaseState(); + const caption = { + id: toCaptionId('cap-1'), + text: 'Hello', + startFrame: toFrame(0), + endFrame: toFrame(100), + language: 'en-US', + burnIn: false, + }; + const next = applyOperation(state, { type: 'ADD_CAPTION', caption, trackId: toTrackId('track-1') }); + expect(next.timeline.tracks[0]!.captions[0]!.style).toEqual(defaultCaptionStyleFromCore); + expect(checkInvariants(next)).toEqual([]); + }); + + it('ADD_CAPTION sorts captions by startFrame', () => { + const state = makeBaseState(); + const c1 = { id: toCaptionId('cap-1'), text: 'First', startFrame: toFrame(100), endFrame: toFrame(200), language: 'en', style: defaultCaptionStyle, burnIn: false }; + const c2 = { id: toCaptionId('cap-2'), text: 'Second', startFrame: toFrame(0), endFrame: toFrame(50), language: 'en', style: defaultCaptionStyle, burnIn: false }; + let next = applyOperation(state, { type: 'ADD_CAPTION', caption: c1, trackId: toTrackId('track-1') }); + next = applyOperation(next, { type: 'ADD_CAPTION', caption: c2, trackId: toTrackId('track-1') }); + const captions = next.timeline.tracks[0]!.captions; + expect(captions[0]!.startFrame).toBe(0); + expect(captions[1]!.startFrame).toBe(100); + expect(checkInvariants(next)).toEqual([]); + }); + + it('ADD_CAPTION with overlapping caption is rejected by validator', () => { + const state = makeBaseState(); + const c1 = { id: toCaptionId('cap-1'), text: 'A', startFrame: toFrame(0), endFrame: toFrame(100), language: 'en', style: defaultCaptionStyle, burnIn: false }; + let next = applyOperation(state, { type: 'ADD_CAPTION', caption: c1, trackId: toTrackId('track-1') }); + const c2 = { id: toCaptionId('cap-2'), text: 'B', startFrame: toFrame(50), endFrame: toFrame(150), language: 'en', style: defaultCaptionStyle, burnIn: false }; + const result = dispatch(next, makeTx('Add overlapping', [{ type: 'ADD_CAPTION', caption: c2, trackId: toTrackId('track-1') }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('OVERLAP'); + }); +}); + +// ── 11. EDIT_CAPTION (with trackId) ───────────────────────────────────────── + +describe('Phase 3 — EDIT_CAPTION', () => { + it('updates caption on specified track by trackId', () => { + const state = makeBaseState(); + const caption = { + id: toCaptionId('cap-1'), + text: 'Hi', + startFrame: toFrame(0), + endFrame: toFrame(50), + language: 'en', + style: defaultCaptionStyle, + burnIn: false, + }; + let next = applyOperation(state, { type: 'ADD_CAPTION', caption, trackId: toTrackId('track-1') }); + next = applyOperation(next, { + type: 'EDIT_CAPTION', + captionId: toCaptionId('cap-1'), + trackId: toTrackId('track-1'), + text: 'Updated', + }); + expect(next.timeline.tracks[0]!.captions[0]!.text).toBe('Updated'); + expect(checkInvariants(next)).toEqual([]); + }); + + it('dispatch EDIT_CAPTION with captionId not on track returns NOT_FOUND', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('Edit', [{ + type: 'EDIT_CAPTION', + captionId: toCaptionId('nope'), + trackId: toTrackId('track-1'), + text: 'x', + }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('NOT_FOUND'); + }); + + it('EDIT_CAPTION supports partial updates for language and burnIn', () => { + const state = makeBaseState(); + const caption = { + id: toCaptionId('cap-1'), + text: 'Hi', + startFrame: toFrame(0), + endFrame: toFrame(50), + language: 'en', + style: defaultCaptionStyle, + burnIn: false, + }; + let next = applyOperation(state, { type: 'ADD_CAPTION', caption, trackId: toTrackId('track-1') }); + next = applyOperation(next, { + type: 'EDIT_CAPTION', + captionId: toCaptionId('cap-1'), + trackId: toTrackId('track-1'), + language: 'fr-FR', + burnIn: true, + }); + const cap = next.timeline.tracks[0]!.captions[0]!; + expect(cap.language).toBe('fr-FR'); + expect(cap.burnIn).toBe(true); + expect(cap.text).toBe('Hi'); + expect(checkInvariants(next)).toEqual([]); + }); +}); + +// ── 12. DELETE_CAPTION (with trackId) ─────────────────────────────────────── + +describe('Phase 3 — DELETE_CAPTION', () => { + it('removes caption from specified track', () => { + const state = makeBaseState(); + const caption = { + id: toCaptionId('cap-1'), + text: 'x', + startFrame: toFrame(0), + endFrame: toFrame(50), + language: 'en', + style: defaultCaptionStyle, + burnIn: false, + }; + let next = applyOperation(state, { type: 'ADD_CAPTION', caption, trackId: toTrackId('track-1') }); + next = applyOperation(next, { type: 'DELETE_CAPTION', captionId: toCaptionId('cap-1'), trackId: toTrackId('track-1') }); + expect(next.timeline.tracks[0]!.captions).toHaveLength(0); + expect(checkInvariants(next)).toEqual([]); + }); +}); + +// ── 13. Invariants (4 new) ────────────────────────────────────────────────── + +describe('Phase 3 — MARKER_OUT_OF_BOUNDS', () => { + it('point marker frame > timeline.duration violates', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(2000), + label: 'X', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + const next = applyOperation(state, { type: 'ADD_MARKER', marker }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'MARKER_OUT_OF_BOUNDS')).toBe(true); + }); + + it('point marker with frame = -1 violates', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(-1), + label: 'X', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + const next = applyOperation(state, { type: 'ADD_MARKER', marker }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'MARKER_OUT_OF_BOUNDS')).toBe(true); + }); + + it('range marker with frameStart = -5 violates', () => { + const state = makeBaseState(); + const marker = { + type: 'range' as const, + id: toMarkerId('r1'), + frameStart: toFrame(-5), + frameEnd: toFrame(50), + label: 'R', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + const next = applyOperation(state, { type: 'ADD_MARKER', marker }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'MARKER_OUT_OF_BOUNDS')).toBe(true); + }); +}); + +describe('Phase 3 — IN_OUT_INVALID', () => { + it('inPoint >= outPoint when both set violates', () => { + const state = makeBaseState(); + let next = applyOperation(state, { type: 'SET_IN_POINT', frame: toFrame(100) }); + next = applyOperation(next, { type: 'SET_OUT_POINT', frame: toFrame(50) }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'IN_OUT_INVALID')).toBe(true); + }); + + it('outPoint > timeline.duration violates', () => { + const state = makeBaseState(); + const next = applyOperation(state, { type: 'SET_OUT_POINT', frame: toFrame(2000) }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'IN_OUT_INVALID')).toBe(true); + }); +}); + +describe('Phase 3 — BEAT_GRID_INVALID', () => { + it('beatGrid.bpm <= 0 violates', () => { + const state = makeBaseState(); + const next = applyOperation(state, { + type: 'ADD_BEAT_GRID', + beatGrid: { bpm: 0, timeSignature: [4, 4], offset: toFrame(0) }, + }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'BEAT_GRID_INVALID')).toBe(true); + }); +}); + +describe('Phase 3 — CAPTION_OUT_OF_BOUNDS', () => { + it('caption endFrame > timeline.duration violates', () => { + const state = makeBaseState(); + const caption = { + id: toCaptionId('c1'), + text: 'x', + startFrame: toFrame(0), + endFrame: toFrame(2000), + language: 'en', + style: defaultCaptionStyle, + burnIn: false, + }; + const next = applyOperation(state, { type: 'ADD_CAPTION', caption, trackId: toTrackId('track-1') }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'CAPTION_OUT_OF_BOUNDS')).toBe(true); + }); +}); + +describe('Phase 3 — CAPTION_OVERLAP', () => { + it('overlapping captions on same track violate invariant', () => { + const state = makeBaseState(); + const c1 = { id: toCaptionId('cap-1'), text: 'A', startFrame: toFrame(0), endFrame: toFrame(100), language: 'en', style: defaultCaptionStyle, burnIn: false }; + const c2 = { id: toCaptionId('cap-2'), text: 'B', startFrame: toFrame(50), endFrame: toFrame(150), language: 'en', style: defaultCaptionStyle, burnIn: false }; + let next = applyOperation(state, { type: 'ADD_CAPTION', caption: c1, trackId: toTrackId('track-1') }); + next = applyOperation(next, { type: 'ADD_CAPTION', caption: c2, trackId: toTrackId('track-1') }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'CAPTION_OVERLAP')).toBe(true); + }); +}); + +// ── 14. Dispatcher / transactions ──────────────────────────────────────────── + +describe('Phase 3 — dispatch mixed Phase 3 ops', () => { + it('transaction with ADD_MARKER + SET_IN_POINT applies both', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('Mixed', [ + { type: 'ADD_MARKER', marker: { type: 'point', id: toMarkerId('m1'), frame: toFrame(10), label: 'A', color: '#f00', scope: 'global', linkedClipId: null } }, + { type: 'SET_IN_POINT', frame: toFrame(5) }, + ])); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(result.nextState.timeline.markers).toHaveLength(1); + expect(result.nextState.timeline.inPoint).toBe(5); + expect(checkInvariants(result.nextState)).toEqual([]); + } + }); + + it('ADD_MARKER then MOVE_MARKER in same tx works', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('Add+Move', [ + { type: 'ADD_MARKER', marker: { type: 'point', id: toMarkerId('m1'), frame: toFrame(20), label: 'A', color: '#f00', scope: 'global', linkedClipId: null } }, + { type: 'MOVE_MARKER', markerId: toMarkerId('m1'), newFrame: toFrame(80) }, + ])); + expect(result.accepted).toBe(true); + if (result.accepted) { + const m = result.nextState.timeline.markers[0] as { frame: number }; + expect(m.frame).toBe(80); + expect(checkInvariants(result.nextState)).toEqual([]); + } + }); +}); + +// ── 15. Backward compat ───────────────────────────────────────────────────── + +describe('Phase 3 — backward compat', () => { + it('checkInvariants on state with no markers/captions/beatGrid returns []', () => { + const state = makeBaseState(); + expect(checkInvariants(state)).toEqual([]); + }); + + it('existing createTimeline/createTrack call sites still produce valid state', () => { + const track = createTrack({ id: 't1', name: 'V1', type: 'video', clips: [] }); + const timeline = createTimeline({ id: 'tl', name: 'T', fps: 24, duration: toFrame(100), tracks: [track] }); + const state = createTimelineState({ timeline, assetRegistry: new Map() }); + expect(checkInvariants(state)).toEqual([]); + }); +}); + +// ── Phase 3 Step 2: Clip-linked markers, BeatGrid snaps, Marker search ─────── + +describe('Phase 3 Step 2 — clip-linked markers', () => { + it('ADD_MARKER with valid clipId succeeds', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(50), + label: 'A', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + clipId: toClipId('clip-1'), + }; + const result = dispatch(state, makeTx('Add', [{ type: 'ADD_MARKER', marker }])); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(result.nextState.timeline.markers).toHaveLength(1); + expect(checkInvariants(result.nextState)).toEqual([]); + } + }); + + it('ADD_MARKER with invalid clipId returns NOT_FOUND', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(50), + label: 'A', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + clipId: toClipId('nonexistent'), + }; + const result = dispatch(state, makeTx('Add', [{ type: 'ADD_MARKER', marker }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('NOT_FOUND'); + }); + + it('MOVE_CLIP shifts a linked point marker by the same delta', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(50), + label: 'A', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + clipId: toClipId('clip-1'), + }; + let next = applyOperation(state, { type: 'ADD_MARKER', marker }); + next = applyOperation(next, { type: 'MOVE_CLIP', clipId: toClipId('clip-1'), newTimelineStart: toFrame(200) }); + expect(next.timeline.markers).toHaveLength(1); + const m = next.timeline.markers[0] as { type: 'point'; frame: number }; + expect(m.frame).toBe(250); // 50 + 200 + expect(checkInvariants(next)).toEqual([]); + }); + + it('MOVE_CLIP shifts a linked range marker (frameStart and frameEnd) by the same delta', () => { + const state = makeBaseState(); + const marker = { + type: 'range' as const, + id: toMarkerId('r1'), + frameStart: toFrame(10), + frameEnd: toFrame(40), + label: 'R', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + clipId: toClipId('clip-1'), + }; + let next = applyOperation(state, { type: 'ADD_MARKER', marker }); + next = applyOperation(next, { type: 'MOVE_CLIP', clipId: toClipId('clip-1'), newTimelineStart: toFrame(100) }); + const r = next.timeline.markers[0] as { frameStart: number; frameEnd: number }; + expect(r.frameStart).toBe(110); // 10 + 100 (delta: clip was at 0, moved to 100) + expect(r.frameEnd).toBe(140); // 40 + 100 + expect(checkInvariants(next)).toEqual([]); + }); + + it('MOVE_CLIP does NOT shift markers with a different clipId', () => { + const state = makeBaseState(); + const clip2 = createClip({ + id: 'clip-2', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const trackWithTwo = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [state.timeline.tracks[0]!.clips[0]!, clip2] }); + const tl = createTimeline({ ...state.timeline, tracks: [trackWithTwo] }); + const state2 = createTimelineState({ timeline: tl, assetRegistry: state.assetRegistry }); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(250), + label: 'B', + color: '#0f0', + scope: 'global' as const, + linkedClipId: null, + clipId: toClipId('clip-2'), + }; + let next = applyOperation(state2, { type: 'ADD_MARKER', marker }); + next = applyOperation(next, { type: 'MOVE_CLIP', clipId: toClipId('clip-1'), newTimelineStart: toFrame(50) }); + const m = next.timeline.markers[0] as { frame: number }; + expect(m.frame).toBe(250); // unchanged — linked to clip-2, not clip-1 + expect(checkInvariants(next)).toEqual([]); + }); + + it('MOVE_CLIP does NOT shift unlinked markers (no clipId)', () => { + const state = makeBaseState(); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(50), + label: 'A', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + let next = applyOperation(state, { type: 'ADD_MARKER', marker }); + next = applyOperation(next, { type: 'MOVE_CLIP', clipId: toClipId('clip-1'), newTimelineStart: toFrame(200) }); + const m = next.timeline.markers[0] as { frame: number }; + expect(m.frame).toBe(50); // unchanged — no clipId + expect(checkInvariants(next)).toEqual([]); + }); +}); + +describe('Phase 3 Step 2 — BeatGrid snap points', () => { + it('buildSnapIndex with a beat grid includes beat frames', () => { + const state = makeBaseState(); + const beatGrid = { bpm: 60, timeSignature: [4, 4] as const, offset: toFrame(0) }; + const next = applyOperation(state, { type: 'ADD_BEAT_GRID', beatGrid }); + const index = buildSnapIndex(next, toFrame(0)); + const beatPoints = index.points.filter((p) => p.type === 'BeatGrid'); + expect(beatPoints.length).toBeGreaterThan(0); + expect(checkInvariants(next)).toEqual([]); + }); + + it('buildSnapIndex without a beat grid: beat frames absent', () => { + const state = makeBaseState(); + const index = buildSnapIndex(state, toFrame(0)); + const beatPoints = index.points.filter((p) => p.type === 'BeatGrid'); + expect(beatPoints).toHaveLength(0); + }); + + it('Beat frames do not exceed timeline.duration', () => { + const state = makeBaseState(); + const beatGrid = { bpm: 120, timeSignature: [4, 4] as const, offset: toFrame(0) }; + const next = applyOperation(state, { type: 'ADD_BEAT_GRID', beatGrid }); + const index = buildSnapIndex(next, toFrame(0)); + const dur = next.timeline.duration; + for (const p of index.points) { + if (p.type === 'BeatGrid') expect(p.frame).toBeLessThan(dur); + } + expect(checkInvariants(next)).toEqual([]); + }); +}); + +describe('Phase 3 Step 2 — marker search', () => { + it('findMarkersByColor returns only exact color matches', () => { + const state = makeBaseState(); + let next = applyOperation(state, { + type: 'ADD_MARKER', + marker: { type: 'point', id: toMarkerId('m1'), frame: toFrame(10), label: 'A', color: '#ff0000', scope: 'global', linkedClipId: null }, + }); + next = applyOperation(next, { + type: 'ADD_MARKER', + marker: { type: 'point', id: toMarkerId('m2'), frame: toFrame(20), label: 'B', color: '#00ff00', scope: 'global', linkedClipId: null }, + }); + next = applyOperation(next, { + type: 'ADD_MARKER', + marker: { type: 'point', id: toMarkerId('m3'), frame: toFrame(30), label: 'C', color: '#ff0000', scope: 'global', linkedClipId: null }, + }); + const red = findMarkersByColor(next, '#ff0000'); + expect(red).toHaveLength(2); + expect(red.every((m) => m.color === '#ff0000')).toBe(true); + expect(findMarkersByColor(next, '#00ff00')).toHaveLength(1); + expect(checkInvariants(next)).toEqual([]); + }); + + it('findMarkersByLabel is case-insensitive substring match', () => { + const state = makeBaseState(); + let next = applyOperation(state, { + type: 'ADD_MARKER', + marker: { type: 'point', id: toMarkerId('m1'), frame: toFrame(10), label: 'Foo Bar', color: '#f00', scope: 'global', linkedClipId: null }, + }); + expect(findMarkersByLabel(next, 'foo')).toHaveLength(1); + expect(findMarkersByLabel(next, 'FOO')).toHaveLength(1); + expect(findMarkersByLabel(next, 'bar')).toHaveLength(1); + expect(findMarkersByLabel(next, 'o b')).toHaveLength(1); + expect(checkInvariants(next)).toEqual([]); + }); + + it('findMarkersByLabel returns [] when no match', () => { + const state = makeBaseState(); + const result = findMarkersByLabel(state, 'nonexistent'); + expect(result).toEqual([]); + }); +}); diff --git a/packages/core/src/__tests__/phase4-effects.test.ts b/packages/core/src/__tests__/phase4-effects.test.ts new file mode 100644 index 0000000..d582e03 --- /dev/null +++ b/packages/core/src/__tests__/phase4-effects.test.ts @@ -0,0 +1,377 @@ +/** + * Phase 4 Step 2 — Effect and Keyframe operations. + * All state-producing tests call checkInvariants() and expect zero violations. + */ + +import { describe, it, expect } from 'vitest'; +import { dispatch } from '../engine/dispatcher'; +import { applyOperation } from '../engine/apply'; +import { checkInvariants } from '../validation/invariants'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toFrame, toTimecode } from '../types/frame'; +import { createEffect, toEffectId } from '../types/effect'; +import { toKeyframeId } from '../types/keyframe'; +import { LINEAR_EASING } from '../types/easing'; +import type { OperationPrimitive, Transaction } from '../types/operations'; + +let txCounter = 0; +function makeTx(label: string, operations: OperationPrimitive[]): Transaction { + return { id: `tx-${++txCounter}`, label, timestamp: Date.now(), operations }; +} + +/** State: one timeline, one video track, one clip (no effects). */ +function makeBaseState() { + const asset = createAsset({ + id: 'asset-1', + name: 'V1', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clip = createClip({ + id: 'clip-1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +/** State with one clip that has one effect with two keyframes. */ +function makeStateWithEffectAndKeyframes() { + const clipId = toClipId('clip-1'); + const effectId = toEffectId('eff-1'); + const effect = createEffect(effectId, 'blur', 'preComposite', [ + { key: 'radius', value: 5 }, + ]); + let state = makeBaseState(); + state = applyOperation(state, { type: 'ADD_EFFECT', clipId, effect }); + state = applyOperation(state, { + type: 'ADD_KEYFRAME', + clipId, + effectId, + keyframe: { id: toKeyframeId('kf-1'), frame: toFrame(10), value: 2, easing: LINEAR_EASING }, + }); + state = applyOperation(state, { + type: 'ADD_KEYFRAME', + clipId, + effectId, + keyframe: { id: toKeyframeId('kf-2'), frame: toFrame(50), value: 8, easing: LINEAR_EASING }, + }); + return state; +} + +describe('Phase 4 — ADD_EFFECT', () => { + it('ADD_EFFECT appends effect to clip', () => { + const state = makeBaseState(); + const effect = createEffect(toEffectId('eff-1'), 'blur'); + const next = applyOperation(state, { type: 'ADD_EFFECT', clipId: toClipId('clip-1'), effect }); + const clip = next.timeline.tracks[0]!.clips[0]!; + expect(clip.effects).toHaveLength(1); + expect(clip.effects![0]!.id).toBe('eff-1'); + expect(checkInvariants(next)).toEqual([]); + }); + + it('ADD_EFFECT with duplicate effectId is rejected', () => { + const state = makeBaseState(); + const effect = createEffect(toEffectId('eff-1'), 'blur'); + let next = applyOperation(state, { type: 'ADD_EFFECT', clipId: toClipId('clip-1'), effect }); + const result = dispatch(next, makeTx('Dup', [{ type: 'ADD_EFFECT', clipId: toClipId('clip-1'), effect }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('DUPLICATE_EFFECT_ID'); + }); + + it('ADD_EFFECT on missing clip is rejected', () => { + const state = makeBaseState(); + const effect = createEffect(toEffectId('eff-1'), 'blur'); + const result = dispatch(state, makeTx('Add', [{ type: 'ADD_EFFECT', clipId: toClipId('none'), effect }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('CLIP_NOT_FOUND'); + }); +}); + +describe('Phase 4 — REMOVE_EFFECT', () => { + it('REMOVE_EFFECT removes effect from clip', () => { + const state = makeStateWithEffectAndKeyframes(); + const next = applyOperation(state, { + type: 'REMOVE_EFFECT', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + }); + expect(next.timeline.tracks[0]!.clips[0]!.effects).toHaveLength(0); + expect(checkInvariants(next)).toEqual([]); + }); + + it('REMOVE_EFFECT on missing effectId is rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('Rm', [{ type: 'REMOVE_EFFECT', clipId: toClipId('clip-1'), effectId: toEffectId('nope') }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('EFFECT_NOT_FOUND'); + }); +}); + +describe('Phase 4 — REORDER_EFFECT', () => { + it('REORDER_EFFECT moves effect to newIndex correctly', () => { + const state = makeBaseState(); + const e1 = createEffect(toEffectId('e1'), 'blur'); + const e2 = createEffect(toEffectId('e2'), 'lut'); + const cid = toClipId('clip-1'); + let next = applyOperation(state, { type: 'ADD_EFFECT', clipId: cid, effect: e1 }); + next = applyOperation(next, { type: 'ADD_EFFECT', clipId: cid, effect: e2 }); + next = applyOperation(next, { type: 'REORDER_EFFECT', clipId: cid, effectId: toEffectId('e2'), newIndex: 0 }); + expect(next.timeline.tracks[0]!.clips[0]!.effects![0]!.id).toBe('e2'); + expect(next.timeline.tracks[0]!.clips[0]!.effects![1]!.id).toBe('e1'); + expect(checkInvariants(next)).toEqual([]); + }); + + it('REORDER_EFFECT with out-of-range index is rejected', () => { + const state = makeStateWithEffectAndKeyframes(); + const result = dispatch(state, makeTx('Reorder', [ + { type: 'REORDER_EFFECT', clipId: toClipId('clip-1'), effectId: toEffectId('eff-1'), newIndex: 5 }, + ])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('EFFECT_INDEX_OUT_OF_RANGE'); + }); +}); + +describe('Phase 4 — SET_EFFECT_ENABLED', () => { + it('SET_EFFECT_ENABLED false disables effect', () => { + const state = makeStateWithEffectAndKeyframes(); + const next = applyOperation(state, { + type: 'SET_EFFECT_ENABLED', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + enabled: false, + }); + expect(next.timeline.tracks[0]!.clips[0]!.effects![0]!.enabled).toBe(false); + expect(checkInvariants(next)).toEqual([]); + }); + + it('SET_EFFECT_ENABLED true re-enables effect', () => { + const state = makeStateWithEffectAndKeyframes(); + let next = applyOperation(state, { + type: 'SET_EFFECT_ENABLED', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + enabled: false, + }); + next = applyOperation(next, { + type: 'SET_EFFECT_ENABLED', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + enabled: true, + }); + expect(next.timeline.tracks[0]!.clips[0]!.effects![0]!.enabled).toBe(true); + expect(checkInvariants(next)).toEqual([]); + }); +}); + +describe('Phase 4 — SET_EFFECT_PARAM', () => { + it('SET_EFFECT_PARAM updates existing param value', () => { + const state = makeStateWithEffectAndKeyframes(); + const next = applyOperation(state, { + type: 'SET_EFFECT_PARAM', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + key: 'radius', + value: 10, + }); + const param = next.timeline.tracks[0]!.clips[0]!.effects![0]!.params.find((p) => p.key === 'radius'); + expect(param!.value).toBe(10); + expect(checkInvariants(next)).toEqual([]); + }); + + it('SET_EFFECT_PARAM appends new param if key missing', () => { + const state = makeStateWithEffectAndKeyframes(); + const next = applyOperation(state, { + type: 'SET_EFFECT_PARAM', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + key: 'quality', + value: 0.9, + }); + const params = next.timeline.tracks[0]!.clips[0]!.effects![0]!.params; + expect(params.some((p) => p.key === 'quality' && p.value === 0.9)).toBe(true); + expect(checkInvariants(next)).toEqual([]); + }); +}); + +describe('Phase 4 — ADD_KEYFRAME', () => { + it('ADD_KEYFRAME appends and sorts keyframes by frame', () => { + const state = makeBaseState(); + const cid = toClipId('clip-1'); + const eid = toEffectId('eff-1'); + let next = applyOperation(state, { type: 'ADD_EFFECT', clipId: cid, effect: createEffect(eid, 'blur') }); + next = applyOperation(next, { + type: 'ADD_KEYFRAME', + clipId: cid, + effectId: eid, + keyframe: { id: toKeyframeId('kf-2'), frame: toFrame(50), value: 5, easing: LINEAR_EASING }, + }); + next = applyOperation(next, { + type: 'ADD_KEYFRAME', + clipId: cid, + effectId: eid, + keyframe: { id: toKeyframeId('kf-1'), frame: toFrame(10), value: 2, easing: LINEAR_EASING }, + }); + const kfs = next.timeline.tracks[0]!.clips[0]!.effects![0]!.keyframes; + expect(kfs[0]!.frame).toBe(10); + expect(kfs[1]!.frame).toBe(50); + expect(checkInvariants(next)).toEqual([]); + }); + + it('ADD_KEYFRAME with duplicate keyframeId is rejected', () => { + const state = makeStateWithEffectAndKeyframes(); + const result = dispatch(state, makeTx('DupKf', [{ + type: 'ADD_KEYFRAME', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + keyframe: { id: toKeyframeId('kf-1'), frame: toFrame(99), value: 1, easing: LINEAR_EASING }, + }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('DUPLICATE_KEYFRAME_ID'); + }); + + it('ADD_KEYFRAME with frame < 0 is rejected', () => { + const state = makeStateWithEffectAndKeyframes(); + const result = dispatch(state, makeTx('Neg', [{ + type: 'ADD_KEYFRAME', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + keyframe: { id: toKeyframeId('kf-3'), frame: toFrame(-1), value: 0, easing: LINEAR_EASING }, + }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('INVALID_RANGE'); + }); +}); + +describe('Phase 4 — MOVE_KEYFRAME', () => { + it('MOVE_KEYFRAME updates frame and re-sorts', () => { + const state = makeStateWithEffectAndKeyframes(); + const next = applyOperation(state, { + type: 'MOVE_KEYFRAME', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + keyframeId: toKeyframeId('kf-1'), + newFrame: toFrame(60), + }); + const kfs = next.timeline.tracks[0]!.clips[0]!.effects![0]!.keyframes; + const kf1 = kfs.find((k) => k.id === 'kf-1'); + expect(kf1!.frame).toBe(60); + expect(kfs[0]!.frame).toBe(50); + expect(kfs[1]!.frame).toBe(60); + expect(checkInvariants(next)).toEqual([]); + }); + + it('MOVE_KEYFRAME with newFrame < 0 is rejected', () => { + const state = makeStateWithEffectAndKeyframes(); + const result = dispatch(state, makeTx('Neg', [{ + type: 'MOVE_KEYFRAME', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + keyframeId: toKeyframeId('kf-1'), + newFrame: toFrame(-1), + }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('INVALID_RANGE'); + }); +}); + +describe('Phase 4 — DELETE_KEYFRAME', () => { + it('DELETE_KEYFRAME removes keyframe from effect', () => { + const state = makeStateWithEffectAndKeyframes(); + const next = applyOperation(state, { + type: 'DELETE_KEYFRAME', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + keyframeId: toKeyframeId('kf-1'), + }); + expect(next.timeline.tracks[0]!.clips[0]!.effects![0]!.keyframes).toHaveLength(1); + expect(next.timeline.tracks[0]!.clips[0]!.effects![0]!.keyframes[0]!.id).toBe('kf-2'); + expect(checkInvariants(next)).toEqual([]); + }); +}); + +describe('Phase 4 — SET_KEYFRAME_EASING', () => { + it('SET_KEYFRAME_EASING updates easing on target keyframe', () => { + const state = makeStateWithEffectAndKeyframes(); + const hold = { kind: 'Hold' as const }; + const next = applyOperation(state, { + type: 'SET_KEYFRAME_EASING', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + keyframeId: toKeyframeId('kf-1'), + easing: hold, + }); + const kf = next.timeline.tracks[0]!.clips[0]!.effects![0]!.keyframes.find((k) => k.id === 'kf-1'); + expect(kf!.easing).toEqual(hold); + expect(checkInvariants(next)).toEqual([]); + }); + + it('SET_KEYFRAME_EASING on missing keyframe is rejected', () => { + const state = makeStateWithEffectAndKeyframes(); + const result = dispatch(state, makeTx('Ease', [{ + type: 'SET_KEYFRAME_EASING', + clipId: toClipId('clip-1'), + effectId: toEffectId('eff-1'), + keyframeId: toKeyframeId('nope'), + easing: LINEAR_EASING, + }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('KEYFRAME_NOT_FOUND'); + }); +}); + +describe('Phase 4 — Effect/keyframe invariants', () => { + it('checkInvariants catches unsorted keyframes', () => { + const state = makeBaseState(); + const cid = toClipId('clip-1'); + const eid = toEffectId('eff-1'); + const effect = createEffect(eid, 'blur'); + const effWithBadKeyframes = { + ...effect, + keyframes: [ + { id: toKeyframeId('a'), frame: toFrame(50), value: 1, easing: LINEAR_EASING }, + { id: toKeyframeId('b'), frame: toFrame(10), value: 2, easing: LINEAR_EASING }, + ], + }; + let next = applyOperation(state, { type: 'ADD_EFFECT', clipId: cid, effect: effWithBadKeyframes }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'KEYFRAME_ORDER_VIOLATION')).toBe(true); + }); + + it('checkInvariants catches duplicate keyframe frames', () => { + const state = makeBaseState(); + const cid = toClipId('clip-1'); + const eid = toEffectId('eff-1'); + const effect = createEffect(eid, 'blur'); + const effWithDupFrames = { + ...effect, + keyframes: [ + { id: toKeyframeId('a'), frame: toFrame(10), value: 1, easing: LINEAR_EASING }, + { id: toKeyframeId('b'), frame: toFrame(10), value: 2, easing: LINEAR_EASING }, + ], + }; + let next = applyOperation(state, { type: 'ADD_EFFECT', clipId: cid, effect: effWithDupFrames }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'KEYFRAME_ORDER_VIOLATION')).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/phase4-transform.test.ts b/packages/core/src/__tests__/phase4-transform.test.ts new file mode 100644 index 0000000..5953fc3 --- /dev/null +++ b/packages/core/src/__tests__/phase4-transform.test.ts @@ -0,0 +1,345 @@ +/** + * Phase 4 Step 3 — Transform, Audio, Transitions, TrackGroups, LinkGroups. + * All state-producing tests call checkInvariants(). + */ + +import { describe, it, expect } from 'vitest'; +import { dispatch } from '../engine/dispatcher'; +import { applyOperation } from '../engine/apply'; +import { checkInvariants } from '../validation/invariants'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toFrame, toTimecode } from '../types/frame'; +import { createAnimatableProperty } from '../types/clip-transform'; +import { DEFAULT_AUDIO_PROPERTIES } from '../types/audio-properties'; +import { createTransition, toTransitionId } from '../types/transition'; +import { createLinkGroup, toLinkGroupId } from '../types/link-group'; +import { createTrackGroup, toTrackGroupId } from '../types/track-group'; +import type { OperationPrimitive, Transaction } from '../types/operations'; + +let txCounter = 0; +function makeTx(label: string, operations: OperationPrimitive[]): Transaction { + return { id: `tx-${++txCounter}`, label, timestamp: Date.now(), operations }; +} + +/** One timeline, two tracks, two clips (one per track). */ +function makeBaseState() { + const asset = createAsset({ + id: 'asset-1', + name: 'V1', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: 'clip-1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clip2 = createClip({ + id: 'clip-2', + assetId: 'asset-1', + trackId: 'track-2', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track1 = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clip1] }); + const track2 = createTrack({ id: 'track-2', name: 'V2', type: 'video', clips: [clip2] }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track1, track2], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +describe('Phase 4 Step 3 — SET_CLIP_TRANSFORM', () => { + it('sets transform fields on clip', () => { + const state = makeBaseState(); + const next = applyOperation(state, { + type: 'SET_CLIP_TRANSFORM', + clipId: toClipId('clip-1'), + transform: { opacity: createAnimatableProperty(0.5) }, + }); + expect(next.timeline.tracks[0]!.clips[0]!.transform!.opacity.value).toBe(0.5); + expect(checkInvariants(next)).toEqual([]); + }); + + it('partial merge preserves untouched fields', () => { + const state = makeBaseState(); + const next = applyOperation(state, { + type: 'SET_CLIP_TRANSFORM', + clipId: toClipId('clip-1'), + transform: { positionX: createAnimatableProperty(10) }, + }); + const t = next.timeline.tracks[0]!.clips[0]!.transform!; + expect(t.positionX.value).toBe(10); + expect(t.scaleX.value).toBe(1); + expect(checkInvariants(next)).toEqual([]); + }); + + it('missing clip is rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('X', [{ + type: 'SET_CLIP_TRANSFORM', + clipId: toClipId('none'), + transform: { opacity: createAnimatableProperty(0.5) }, + }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('CLIP_NOT_FOUND'); + }); +}); + +describe('Phase 4 Step 3 — SET_AUDIO_PROPERTIES', () => { + it('sets gain and mute on clip', () => { + const state = makeBaseState(); + const next = applyOperation(state, { + type: 'SET_AUDIO_PROPERTIES', + clipId: toClipId('clip-1'), + properties: { mute: true, gain: createAnimatableProperty(-6) }, + }); + expect(next.timeline.tracks[0]!.clips[0]!.audio!.mute).toBe(true); + expect(next.timeline.tracks[0]!.clips[0]!.audio!.gain.value).toBe(-6); + expect(checkInvariants(next)).toEqual([]); + }); + + it('pan out of range [-1,1] is rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('X', [{ + type: 'SET_AUDIO_PROPERTIES', + clipId: toClipId('clip-1'), + properties: { pan: createAnimatableProperty(1.5) }, + }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('INVALID_RANGE'); + }); + + it('normalizationGain < 0 is rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('X', [{ + type: 'SET_AUDIO_PROPERTIES', + clipId: toClipId('clip-1'), + properties: { normalizationGain: -1 }, + }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('INVALID_RANGE'); + }); +}); + +describe('Phase 4 Step 3 — ADD_TRANSITION / DELETE_TRANSITION', () => { + it('ADD_TRANSITION sets transition on clip', () => { + const state = makeBaseState(); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 15); + const next = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: trans }); + expect(next.timeline.tracks[0]!.clips[0]!.transition).toBeDefined(); + expect(next.timeline.tracks[0]!.clips[0]!.transition!.durationFrames).toBe(15); + expect(checkInvariants(next)).toEqual([]); + }); + + it('ADD_TRANSITION overwrites existing (no rejection)', () => { + const state = makeBaseState(); + const t1 = createTransition(toTransitionId('tr-1'), 'dissolve', 10); + const t2 = createTransition(toTransitionId('tr-2'), 'wipe', 20); + let next = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: t1 }); + next = applyOperation(next, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: t2 }); + expect(next.timeline.tracks[0]!.clips[0]!.transition!.id).toBe('tr-2'); + expect(next.timeline.tracks[0]!.clips[0]!.transition!.durationFrames).toBe(20); + expect(checkInvariants(next)).toEqual([]); + }); + + it('DELETE_TRANSITION removes transition', () => { + const state = makeBaseState(); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 15); + let next = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: trans }); + next = applyOperation(next, { type: 'DELETE_TRANSITION', clipId: toClipId('clip-1') }); + expect(next.timeline.tracks[0]!.clips[0]!.transition).toBeUndefined(); + expect(checkInvariants(next)).toEqual([]); + }); + + it('DELETE_TRANSITION on clip with no transition rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('X', [{ type: 'DELETE_TRANSITION', clipId: toClipId('clip-1') }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('TRANSITION_NOT_FOUND'); + }); +}); + +describe('Phase 4 Step 3 — SET_TRANSITION_DURATION / ALIGNMENT', () => { + it('SET_TRANSITION_DURATION updates durationFrames', () => { + const state = makeBaseState(); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 15); + let next = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: trans }); + next = applyOperation(next, { type: 'SET_TRANSITION_DURATION', clipId: toClipId('clip-1'), durationFrames: 30 }); + expect(next.timeline.tracks[0]!.clips[0]!.transition!.durationFrames).toBe(30); + expect(checkInvariants(next)).toEqual([]); + }); + + it('SET_TRANSITION_DURATION <= 0 is rejected', () => { + const state = makeBaseState(); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 15); + let next = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: trans }); + const result = dispatch(next, makeTx('X', [{ type: 'SET_TRANSITION_DURATION', clipId: toClipId('clip-1'), durationFrames: 0 }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('INVALID_RANGE'); + }); + + it('SET_TRANSITION_ALIGNMENT updates alignment', () => { + const state = makeBaseState(); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 15); + let next = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: trans }); + next = applyOperation(next, { type: 'SET_TRANSITION_ALIGNMENT', clipId: toClipId('clip-1'), alignment: 'endAtCut' }); + expect(next.timeline.tracks[0]!.clips[0]!.transition!.alignment).toBe('endAtCut'); + expect(checkInvariants(next)).toEqual([]); + }); +}); + +describe('Phase 4 Step 3 — LINK_CLIPS / UNLINK_CLIPS', () => { + it('LINK_CLIPS creates link group with both clipIds', () => { + const state = makeBaseState(); + const linkGroup = createLinkGroup(toLinkGroupId('link-1'), [toClipId('clip-1'), toClipId('clip-2')]); + const next = applyOperation(state, { type: 'LINK_CLIPS', linkGroup }); + expect(next.timeline.linkGroups).toHaveLength(1); + expect(next.timeline.linkGroups![0]!.clipIds).toHaveLength(2); + expect(checkInvariants(next)).toEqual([]); + }); + + it('LINK_CLIPS with only 1 clipId is rejected', () => { + const state = makeBaseState(); + const linkGroup = createLinkGroup(toLinkGroupId('link-1'), [toClipId('clip-1')]); + const result = dispatch(state, makeTx('X', [{ type: 'LINK_CLIPS', linkGroup }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('INVALID_RANGE'); + }); + + it('LINK_CLIPS with missing clipId is rejected', () => { + const state = makeBaseState(); + const linkGroup = createLinkGroup(toLinkGroupId('link-1'), [toClipId('clip-1'), toClipId('nope')]); + const result = dispatch(state, makeTx('X', [{ type: 'LINK_CLIPS', linkGroup }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('CLIP_NOT_FOUND'); + }); + + it('UNLINK_CLIPS removes link group', () => { + const state = makeBaseState(); + const linkGroup = createLinkGroup(toLinkGroupId('link-1'), [toClipId('clip-1'), toClipId('clip-2')]); + let next = applyOperation(state, { type: 'LINK_CLIPS', linkGroup }); + next = applyOperation(next, { type: 'UNLINK_CLIPS', linkGroupId: toLinkGroupId('link-1') }); + expect(next.timeline.linkGroups).toHaveLength(0); + expect(checkInvariants(next)).toEqual([]); + }); + + it('UNLINK_CLIPS with missing groupId is rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('X', [{ type: 'UNLINK_CLIPS', linkGroupId: toLinkGroupId('nope') }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('LINK_GROUP_NOT_FOUND'); + }); +}); + +describe('Phase 4 Step 3 — ADD_TRACK_GROUP / DELETE_TRACK_GROUP', () => { + it('ADD_TRACK_GROUP creates group and sets groupId on tracks', () => { + const state = makeBaseState(); + const group = createTrackGroup(toTrackGroupId('grp-1'), 'Group', [toTrackId('track-1'), toTrackId('track-2')]); + const next = applyOperation(state, { type: 'ADD_TRACK_GROUP', trackGroup: group }); + expect(next.timeline.trackGroups).toHaveLength(1); + expect(next.timeline.tracks[0]!.groupId).toBe('grp-1'); + expect(next.timeline.tracks[1]!.groupId).toBe('grp-1'); + expect(checkInvariants(next)).toEqual([]); + }); + + it('DELETE_TRACK_GROUP removes group and clears groupId', () => { + const state = makeBaseState(); + const group = createTrackGroup(toTrackGroupId('grp-1'), 'G', [toTrackId('track-1'), toTrackId('track-2')]); + let next = applyOperation(state, { type: 'ADD_TRACK_GROUP', trackGroup: group }); + next = applyOperation(next, { type: 'DELETE_TRACK_GROUP', trackGroupId: toTrackGroupId('grp-1') }); + expect(next.timeline.trackGroups).toHaveLength(0); + expect(next.timeline.tracks[0]!.groupId).toBeUndefined(); + expect(next.timeline.tracks[1]!.groupId).toBeUndefined(); + expect(checkInvariants(next)).toEqual([]); + }); + + it('DELETE_TRACK_GROUP with missing id is rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('X', [{ type: 'DELETE_TRACK_GROUP', trackGroupId: toTrackGroupId('nope') }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('TRACK_GROUP_NOT_FOUND'); + }); +}); + +describe('Phase 4 Step 3 — SET_TRACK_BLEND_MODE / SET_TRACK_OPACITY', () => { + it('SET_TRACK_BLEND_MODE sets blendMode on track', () => { + const state = makeBaseState(); + const next = applyOperation(state, { type: 'SET_TRACK_BLEND_MODE', trackId: toTrackId('track-1'), blendMode: 'multiply' }); + expect(next.timeline.tracks[0]!.blendMode).toBe('multiply'); + expect(checkInvariants(next)).toEqual([]); + }); + + it('SET_TRACK_OPACITY sets opacity on track', () => { + const state = makeBaseState(); + const next = applyOperation(state, { type: 'SET_TRACK_OPACITY', trackId: toTrackId('track-1'), opacity: 0.7 }); + expect(next.timeline.tracks[0]!.opacity).toBe(0.7); + expect(checkInvariants(next)).toEqual([]); + }); + + it('SET_TRACK_OPACITY > 1 is rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('X', [{ type: 'SET_TRACK_OPACITY', trackId: toTrackId('track-1'), opacity: 1.5 }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('INVALID_OPACITY'); + }); + + it('SET_TRACK_OPACITY < 0 is rejected', () => { + const state = makeBaseState(); + const result = dispatch(state, makeTx('X', [{ type: 'SET_TRACK_OPACITY', trackId: toTrackId('track-1'), opacity: -0.1 }])); + expect(result.accepted).toBe(false); + if (!result.accepted) expect(result.reason).toBe('INVALID_OPACITY'); + }); +}); + +describe('Phase 4 Step 3 — Invariants', () => { + it('checkInvariants catches transition durationFrames = 0', () => { + const state = makeBaseState(); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 0); + const next = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: trans }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'INVALID_RANGE' && v.message.includes('durationFrames'))).toBe(true); + }); + + it('checkInvariants catches clipId in two link groups', () => { + const state = makeBaseState(); + const g1 = createLinkGroup(toLinkGroupId('l1'), [toClipId('clip-1'), toClipId('clip-2')]); + let next = applyOperation(state, { type: 'LINK_CLIPS', linkGroup: g1 }); + const g2 = createLinkGroup(toLinkGroupId('l2'), [toClipId('clip-1'), toClipId('clip-2')]); + next = applyOperation(next, { type: 'LINK_CLIPS', linkGroup: g2 }); + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'INVALID_RANGE' && v.message.includes('more than one link group'))).toBe(true); + }); + + it('checkInvariants catches orphaned track.groupId', () => { + const state = makeBaseState(); + const track1 = state.timeline.tracks[0]!; + const track2 = state.timeline.tracks[1]!; + const tracksWithOrphan = [ + { ...track1, groupId: toTrackGroupId('orphan') }, + track2, + ]; + const timeline = { ...state.timeline, tracks: tracksWithOrphan }; + const next = { ...state, timeline }; + const violations = checkInvariants(next); + expect(violations.some((v) => v.type === 'TRACK_GROUP_NOT_FOUND' && v.message.includes('orphan'))).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/phase4-types.test.ts b/packages/core/src/__tests__/phase4-types.test.ts new file mode 100644 index 0000000..bdb70c2 --- /dev/null +++ b/packages/core/src/__tests__/phase4-types.test.ts @@ -0,0 +1,172 @@ +/** + * Phase 4 Step 1 — Type and factory tests (no dispatch, no state). + * Pure shape and default checks. + */ + +import { describe, it, expect } from 'vitest'; +import { LINEAR_EASING, HOLD_EASING } from '../types/easing'; +import { + toKeyframeId, + type Keyframe, +} from '../types/keyframe'; +import { createEffect, toEffectId } from '../types/effect'; +import { DEFAULT_CLIP_TRANSFORM } from '../types/clip-transform'; +import { DEFAULT_AUDIO_PROPERTIES } from '../types/audio-properties'; +import { createTransition, toTransitionId } from '../types/transition'; +import { LINEAR_EASING as LINEAR } from '../types/easing'; +import { createTrackGroup, toTrackGroupId } from '../types/track-group'; +import { createLinkGroup, toLinkGroupId } from '../types/link-group'; +import { createClip, toClipId } from '../types/clip'; +import { createTrack } from '../types/track'; +import { createTimeline } from '../types/timeline'; +import { toFrame } from '../types/frame'; + +describe('Phase 4 — Easing', () => { + it('LINEAR_EASING.kind === "Linear"', () => { + expect(LINEAR_EASING.kind).toBe('Linear'); + }); + + it('HOLD_EASING.kind === "Hold"', () => { + expect(HOLD_EASING.kind).toBe('Hold'); + }); + + it('BezierCurve easing has p1x, p1y, p2x, p2y', () => { + const bezier = { + kind: 'BezierCurve' as const, + p1x: 0.25, + p1y: 0.1, + p2x: 0.75, + p2y: 0.9, + }; + expect(bezier.p1x).toBe(0.25); + expect(bezier.p1y).toBe(0.1); + expect(bezier.p2x).toBe(0.75); + expect(bezier.p2y).toBe(0.9); + }); +}); + +describe('Phase 4 — Keyframe', () => { + it('toKeyframeId returns branded string', () => { + const id = toKeyframeId('kf-1'); + expect(id).toBe('kf-1'); + expect(typeof id).toBe('string'); + }); + + it('Keyframe has frame, value, easing fields', () => { + const kf: Keyframe = { + id: toKeyframeId('kf-1'), + frame: toFrame(100), + value: 0.5, + easing: LINEAR_EASING, + }; + expect(kf.frame).toBe(100); + expect(kf.value).toBe(0.5); + expect(kf.easing).toEqual(LINEAR_EASING); + }); +}); + +describe('Phase 4 — Effect', () => { + it('createEffect defaults: enabled true, renderStage preComposite, keyframes []', () => { + const e = createEffect(toEffectId('eff-1'), 'blur'); + expect(e.enabled).toBe(true); + expect(e.renderStage).toBe('preComposite'); + expect(e.keyframes).toEqual([]); + }); +}); + +describe('Phase 4 — ClipTransform', () => { + it('DEFAULT_CLIP_TRANSFORM.opacity.value === 1', () => { + expect(DEFAULT_CLIP_TRANSFORM.opacity.value).toBe(1); + }); + + it('DEFAULT_CLIP_TRANSFORM.scaleX.value === 1', () => { + expect(DEFAULT_CLIP_TRANSFORM.scaleX.value).toBe(1); + }); + + it('DEFAULT_CLIP_TRANSFORM.positionX.keyframes is empty', () => { + expect(DEFAULT_CLIP_TRANSFORM.positionX.keyframes).toEqual([]); + }); +}); + +describe('Phase 4 — AudioProperties', () => { + it('DEFAULT_AUDIO_PROPERTIES.mute === false', () => { + expect(DEFAULT_AUDIO_PROPERTIES.mute).toBe(false); + }); + + it('DEFAULT_AUDIO_PROPERTIES.channelRouting === "stereo"', () => { + expect(DEFAULT_AUDIO_PROPERTIES.channelRouting).toBe('stereo'); + }); + + it('DEFAULT_AUDIO_PROPERTIES.gain.value === 0', () => { + expect(DEFAULT_AUDIO_PROPERTIES.gain.value).toBe(0); + }); +}); + +describe('Phase 4 — Transition', () => { + it('createTransition defaults: alignment centerOnCut, easing LINEAR_EASING', () => { + const t = createTransition(toTransitionId('tr-1'), 'dissolve', 15); + expect(t.alignment).toBe('centerOnCut'); + expect(t.easing).toEqual(LINEAR); + }); +}); + +describe('Phase 4 — TrackGroup', () => { + it('createTrackGroup defaults: collapsed false, trackIds []', () => { + const g = createTrackGroup(toTrackGroupId('grp-1'), 'Group'); + expect(g.collapsed).toBe(false); + expect(g.trackIds).toEqual([]); + }); +}); + +describe('Phase 4 — LinkGroup', () => { + it('createLinkGroup stores clipIds', () => { + const g = createLinkGroup(toLinkGroupId('link-1'), [ + toClipId('c1'), + toClipId('c2'), + ]); + expect(g.clipIds).toHaveLength(2); + expect(g.clipIds[0]).toBe('c1'); + expect(g.clipIds[1]).toBe('c2'); + }); +}); + +describe('Phase 4 — Backward compat (createClip, createTrack, createTimeline)', () => { + it('createClip still works with no new fields', () => { + const clip = createClip({ + id: 'c1', + assetId: 'a1', + trackId: 't1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + expect(clip.id).toBe('c1'); + expect(clip.timelineStart).toBe(0); + expect(clip.effects).toBeUndefined(); + expect(clip.transform).toBeUndefined(); + expect(clip.audio).toBeUndefined(); + expect(clip.transition).toBeUndefined(); + }); + + it('createTrack still works with no new fields', () => { + const track = createTrack({ id: 't1', name: 'V1', type: 'video' }); + expect(track.id).toBe('t1'); + expect(track.name).toBe('V1'); + expect(track.blendMode).toBeUndefined(); + expect(track.opacity).toBeUndefined(); + expect(track.groupId).toBeUndefined(); + }); + + it('createTimeline still works with no new fields', () => { + const tl = createTimeline({ + id: 'tl1', + name: 'Seq', + fps: 30, + duration: toFrame(1000), + }); + expect(tl.id).toBe('tl1'); + expect(tl.trackGroups).toBeUndefined(); + expect(tl.linkGroups).toBeUndefined(); + }); +}); diff --git a/packages/core/src/__tests__/phase5-aaf.test.ts b/packages/core/src/__tests__/phase5-aaf.test.ts new file mode 100644 index 0000000..454928c --- /dev/null +++ b/packages/core/src/__tests__/phase5-aaf.test.ts @@ -0,0 +1,288 @@ +/** + * Phase 5 Step 4 — AAF and FCP XML export + * + * Fixture: 30fps, two tracks (video + audio), three clips, one gap, + * one FileAsset, one GeneratorAsset. + */ + +import { describe, it, expect } from 'vitest'; +import { exportToAAF, type AAFExportOptions } from '../engine/aaf-export'; +import { exportToFCPXML, toFCPTime, type FCPXMLExportOptions } from '../engine/fcpxml-export'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, createGeneratorAsset, type AssetId, type Asset } from '../types/asset'; +import { toGeneratorId } from '../types/generator'; +import { toFrame, toTimecode } from '../types/frame'; + +// ── Fixture: 30fps, video + audio, 3 clips, 1 gap, FileAsset + GeneratorAsset ── + +function makeAAFFixtureState() { + const fileAsset = createAsset({ + id: 'asset-file', + name: 'My File', + mediaType: 'video', + filePath: '/path/to/file.mp4', + intrinsicDuration: toFrame(300), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const genAsset = createGeneratorAsset({ + id: 'asset-gen', + name: 'Solid', + mediaType: 'video', + generatorDef: { + id: toGeneratorId('gen-1'), + type: 'solid', + params: {}, + duration: toFrame(100), + name: 'S', + }, + nativeFps: 30, + }); + const clip1 = createClip({ + id: 'clip-1', + assetId: 'asset-file', + trackId: 'track-v', + timelineStart: toFrame(0), + timelineEnd: toFrame(50), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + const clip2 = createClip({ + id: 'clip-2', + assetId: 'asset-gen', + trackId: 'track-v', + timelineStart: toFrame(100), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + const clip3 = createClip({ + id: 'clip-3', + assetId: 'asset-file', + trackId: 'track-v', + timelineStart: toFrame(150), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + const clipA1 = createClip({ + id: 'clip-a1', + assetId: 'asset-file', + trackId: 'track-a', + timelineStart: toFrame(0), + timelineEnd: toFrame(80), + mediaIn: toFrame(0), + mediaOut: toFrame(80), + }); + const clipA2 = createClip({ + id: 'clip-a2', + assetId: 'asset-file', + trackId: 'track-a', + timelineStart: toFrame(100), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + const trackV = createTrack({ + id: 'track-v', + name: 'V1', + type: 'video', + clips: [clip1, clip2, clip3], + }); + const trackA = createTrack({ + id: 'track-a', + name: 'A1', + type: 'audio', + clips: [clipA1, clipA2], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'AAF Fixture', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [trackV, trackA], + }); + const registry = new Map([ + [fileAsset.id, fileAsset], + [genAsset.id, genAsset], + ]); + return createTimelineState({ timeline, assetRegistry: registry }); +} + +// ── AAF tests ───────────────────────────────────────────────────────────── + +describe('Phase 5 — AAF export', () => { + it('output starts with { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + expect(aaf.startsWith(' { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state, { projectName: 'My Project' }); + expect(aaf).toContain('projectName="My Project"'); + }); + + it('one MasterMob per clip', () => { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + const masterMobs = (aaf.match(/ { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + expect(aaf).toContain('mobID="clip-1"'); + expect(aaf).toContain('mobID="clip-2"'); + }); + + it('CompositionMob has one TimelineMobSlot per track', () => { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + const slots = (aaf.match(/ { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + expect(aaf).toContain('dataDefinition="Picture"'); + }); + + it('audio track → dataDefinition Sound', () => { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + expect(aaf).toContain('dataDefinition="Sound"'); + }); + + it('gap emits with correct length', () => { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + expect(aaf).toContain(' { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + expect(aaf).toContain('sourceRef="/path/to/file.mp4"'); + }); + + it('GeneratorAsset sourceRef = generatorType', () => { + const state = makeAAFFixtureState(); + const aaf = exportToAAF(state); + expect(aaf).toContain('sourceRef="solid"'); + }); + + it('XML special chars in clip name are escaped', () => { + const state = makeAAFFixtureState(); + const clipAmp = createClip({ + id: 'clip-&<>', + assetId: 'asset-file', + trackId: 'track-v', + timelineStart: toFrame(0), + timelineEnd: toFrame(10), + mediaIn: toFrame(0), + mediaOut: toFrame(10), + }); + const track = createTrack({ + id: 'track-v', + name: 'V', + type: 'video', + clips: [clipAmp], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(100), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + const state2 = createTimelineState({ + timeline, + assetRegistry: state.assetRegistry, + }); + const aaf = exportToAAF(state2); + expect(aaf).toContain('&'); + expect(aaf).toContain('<'); + expect(aaf).toContain('>'); + }); +}); + +// ── FCPXML tests ─────────────────────────────────────────────────────────── + +describe('Phase 5 — FCPXML export', () => { + it('output starts with { + const state = makeAAFFixtureState(); + const xml = exportToFCPXML(state); + expect(xml.startsWith(' { + const state = makeAAFFixtureState(); + const xml = exportToFCPXML(state); + expect(xml).toContain('version="1.10"'); + }); + + it(' element present in resources', () => { + const state = makeAAFFixtureState(); + const xml = exportToFCPXML(state); + expect(xml).toContain(' with src="file://{path}"', () => { + const state = makeAAFFixtureState(); + const xml = exportToFCPXML(state); + expect(xml).toContain(' in resources', () => { + const state = makeAAFFixtureState(); + const xml = exportToFCPXML(state); + expect(xml).toContain(' { + const state = makeAAFFixtureState(); + const xml = exportToFCPXML(state); + expect(xml).toContain('offset="0s"'); + expect(xml).toContain('offset="100/30s"'); + }); + + it('gap emits element', () => { + const state = makeAAFFixtureState(); + const xml = exportToFCPXML(state); + expect(xml).toContain(' { + expect(toFCPTime(90, 30)).toBe('90/30s'); + }); + + it('toFCPTime(0, 30) === "0s"', () => { + expect(toFCPTime(0, 30)).toBe('0s'); + }); + + it('options.libraryName and options.eventName applied', () => { + const state = makeAAFFixtureState(); + const xml = exportToFCPXML(state, { + libraryName: 'MyLib', + eventName: 'MyEvent', + }); + expect(xml).toContain('name="MyLib"'); + expect(xml).toContain('name="MyEvent"'); + }); +}); diff --git a/packages/core/src/__tests__/phase5-edl.test.ts b/packages/core/src/__tests__/phase5-edl.test.ts new file mode 100644 index 0000000..ec24b1f --- /dev/null +++ b/packages/core/src/__tests__/phase5-edl.test.ts @@ -0,0 +1,279 @@ +/** + * Phase 5 Step 3 — EDL export + * + * CMX3600 EDL, single video track. Pure function, no IO. + */ + +import { describe, it, expect } from 'vitest'; +import { exportToEDL, frameToTimecode, reelName, type EDLExportOptions } from '../engine/edl-export'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, createGeneratorAsset } from '../types/asset'; +import { toGeneratorId } from '../types/generator'; +import { createTransition, toTransitionId } from '../types/transition'; +import { toFrame, toTimecode } from '../types/frame'; +import { applyOperation } from '../engine/apply'; + +// ── Fixture: 30fps, one video track, three clips (no gaps), one with dissolve ── + +function makeEDLFixtureState() { + const asset1 = createAsset({ + id: 'asset-1', + name: 'A1', + mediaType: 'video', + filePath: '/path/to/my-clip.mp4', + intrinsicDuration: toFrame(300), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const asset2 = createAsset({ + id: 'asset-2', + name: 'A2', + mediaType: 'video', + filePath: '/reels/longfilename.mp4', + intrinsicDuration: toFrame(300), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: 'clip-1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clip2 = createClip({ + id: 'clip-2', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(100), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clip3 = createClip({ + id: 'clip-3', + assetId: 'asset-2', + trackId: 'track-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ + id: 'track-1', + name: 'V1', + type: 'video', + clips: [clip1, clip2, clip3], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'My Timeline', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + let state = createTimelineState({ + timeline, + assetRegistry: new Map([ + [asset1.id, asset1], + [asset2.id, asset2], + ]), + }); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 10); + state = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-2'), transition: trans }); + return state; +} + +describe('Phase 5 — EDL Export', () => { + it('output starts with TITLE:', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + expect(edl.startsWith('TITLE:')).toBe(true); + }); + + it('output contains FCM: NON-DROP FRAME', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + expect(edl).toContain('FCM: NON-DROP FRAME'); + }); + + it('event count matches clip count', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + const eventLines = edl.split('\n').filter((l) => /^\d{3}\s+/.test(l)); + expect(eventLines.length).toBe(3); + }); + + it('event numbers are zero-padded (001, 002, 003)', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + expect(edl).toContain('001 '); + expect(edl).toContain('002 '); + expect(edl).toContain('003 '); + }); + + it('reel name derived from asset filename (truncated, uppercased, max 8 chars)', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + expect(edl).toContain('MY-CLIP'); + expect(edl).toMatch(/LONGFILE/); + }); + + it('GeneratorAsset clip uses reel AX', () => { + const genAsset = createGeneratorAsset({ + id: 'gen-1', + name: 'Solid', + mediaType: 'video', + generatorDef: { + id: toGeneratorId('gen-1'), + type: 'solid', + params: {}, + duration: toFrame(60), + name: 'S', + }, + nativeFps: 30, + }); + const clip = createClip({ + id: 'cgen', + assetId: 'gen-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(30), + mediaIn: toFrame(0), + mediaOut: toFrame(30), + }); + const track = createTrack({ id: 'track-1', name: 'V', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(100), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + const state = createTimelineState({ + timeline, + assetRegistry: new Map([[genAsset.id, genAsset]]), + }); + const edl = exportToEDL(state); + expect(edl).toContain('AX '); + }); + + it('clip with dissolve transition uses D not C', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + const lines = edl.split('\n'); + const event2 = lines.find((l) => l.startsWith('002 ')); + expect(event2).toContain('D '); + }); + + it('srcIn timecode matches clip.mediaIn at 30fps', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + expect(edl).toContain('00:00:00:00'); + }); + + it('recIn timecode matches clip.timelineStart at 30fps', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + expect(edl).toContain('00:00:00:00'); + expect(edl).toContain('00:00:03:10'); + expect(edl).toContain('00:00:06:20'); + }); + + it('srcOut = srcIn + durationFrames', () => { + const state = makeEDLFixtureState(); + const clip = state.timeline.tracks[0]!.clips[0]!; + const dur = (clip.timelineEnd - clip.timelineStart) as number; + const edl = exportToEDL(state); + const firstEvent = edl.split('\n').find((l) => l.startsWith('001 '))!; + expect(firstEvent).toContain('00:00:00:00'); + expect(firstEvent).toContain('00:00:03:10'); + }); + + it('recOut = recIn + durationFrames', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + const firstEvent = edl.split('\n').find((l) => l.startsWith('001 '))!; + expect(firstEvent).toContain('00:00:00:00 00:00:03:10 00:00:00:00 00:00:03:10'); + }); + + it('comment line * FROM CLIP NAME: present per event', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state); + const comments = edl.split('\n').filter((l) => l.startsWith('* FROM CLIP NAME:')); + expect(comments.length).toBe(3); + }); + + it('options.title overrides timeline name', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state, { title: 'Custom Title' }); + expect(edl.startsWith('TITLE: Custom Title')).toBe(true); + }); + + it('options.trackIndex selects correct track', () => { + const state = makeEDLFixtureState(); + const track2 = createTrack({ + id: 'track-2', + name: 'V2', + type: 'video', + clips: [ + createClip({ + id: 'only', + assetId: 'asset-1', + trackId: 'track-2', + timelineStart: toFrame(0), + timelineEnd: toFrame(50), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }), + ], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [state.timeline.tracks[0]!, track2], + }); + const state2 = createTimelineState({ + timeline, + assetRegistry: state.assetRegistry, + }); + const edl0 = exportToEDL(state2, { trackIndex: 0 }); + const edl1 = exportToEDL(state2, { trackIndex: 1 }); + const events0 = edl0.split('\n').filter((l) => /^\d{3}\s+/.test(l)); + const events1 = edl1.split('\n').filter((l) => /^\d{3}\s+/.test(l)); + expect(events0.length).toBe(3); + expect(events1.length).toBe(1); + }); +}); + +describe('Phase 5 — frameToTimecode', () => { + it('frame 0 → 00:00:00:00', () => { + expect(frameToTimecode(0, 30, false)).toBe('00:00:00:00'); + }); + + it('frame 90 at 30fps → 00:00:03:00', () => { + expect(frameToTimecode(90, 30, false)).toBe('00:00:03:00'); + }); + + it('frame 1800 at 30fps → 00:01:00:00', () => { + expect(frameToTimecode(1800, 30, false)).toBe('00:01:00:00'); + }); +}); + +describe('Phase 5 — EDL dropFrame fallback', () => { + it('dropFrame fallback comment when fps != 29.97', () => { + const state = makeEDLFixtureState(); + const edl = exportToEDL(state, { dropFrame: true }); + expect(edl).toContain('* DROP FRAME NOT SUPPORTED FOR THIS FRAME RATE'); + }); +}); diff --git a/packages/core/src/__tests__/phase5-migration.test.ts b/packages/core/src/__tests__/phase5-migration.test.ts new file mode 100644 index 0000000..50a5811 --- /dev/null +++ b/packages/core/src/__tests__/phase5-migration.test.ts @@ -0,0 +1,281 @@ +/** + * Phase 5 Addendum — Migration chain hardening + * + * Migration gate: tests 6 and 14 are GATE tests (invariant check after migration). + */ + +import { describe, it, expect } from 'vitest'; + +import { dispatch } from '../engine/dispatcher'; +import { checkInvariants } from '../validation/invariants'; +import { createTimelineState, CURRENT_SCHEMA_VERSION } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toFrame, toTimecode } from '../types/frame'; +import { toMarkerId } from '../types/marker'; +import type { OperationPrimitive, Transaction } from '../types/operations'; + +import { serializeTimeline, deserializeTimeline } from '../engine/serializer'; +import { migrate } from '../engine/migrator'; +import { SerializationError } from '../engine/serialization-error'; + +import { createProject, toProjectId } from '../types/project'; +import { serializeProject, deserializeProject } from '../engine/project-serializer'; + +let txCounter = 0; +function makeTx(label: string, operations: OperationPrimitive[]): Transaction { + return { id: `tx-${++txCounter}`, label, timestamp: Date.now(), operations }; +} + +function applyTx( + state: ReturnType, + label: string, + ops: OperationPrimitive[], +) { + const result = dispatch(state, makeTx(label, ops)); + expect(result.accepted).toBe(true); + if (!result.accepted) throw new Error(result.message); + expect(checkInvariants(result.nextState)).toEqual([]); + return result.nextState; +} + +/** Build state with 2 tracks, 3 clips, 2 markers (for v1 fixture and gate). */ +function buildStateWithTracksClipsAndMarkers() { + const fps = 30; + const timeline = createTimeline({ + id: 'mig', + name: 'MigrationTest', + fps, + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [], + }); + let state = createTimelineState({ timeline, assetRegistry: new Map() }); + + const videoTrackId = toTrackId('v1'); + const audioTrackId = toTrackId('a1'); + state = applyTx(state, 'Add tracks', [ + createTrack({ id: videoTrackId, name: 'V1', type: 'video', clips: [] }), + createTrack({ id: audioTrackId, name: 'A1', type: 'audio', clips: [] }), + ].map(t => ({ type: 'ADD_TRACK' as const, track: t }))); + + const fileAssetV = createAsset({ + id: 'asset-v', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(1000), + nativeFps: fps, + sourceTimecodeOffset: toFrame(0), + }); + const fileAssetA = createAsset({ + id: 'asset-a', + name: 'A', + mediaType: 'audio', + filePath: '/a.wav', + intrinsicDuration: toFrame(1000), + nativeFps: fps, + sourceTimecodeOffset: toFrame(0), + }); + state = applyTx(state, 'Register assets', [ + { type: 'REGISTER_ASSET', asset: fileAssetV }, + { type: 'REGISTER_ASSET', asset: fileAssetA }, + ]); + + const clip1 = createClip({ + id: toClipId('c1'), + assetId: fileAssetV.id, + trackId: videoTrackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const clip2 = createClip({ + id: toClipId('c2'), + assetId: fileAssetV.id, + trackId: videoTrackId, + timelineStart: toFrame(400), + timelineEnd: toFrame(700), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const clip3 = createClip({ + id: toClipId('c3'), + assetId: fileAssetA.id, + trackId: audioTrackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + state = applyTx(state, 'Insert clips', [ + { type: 'INSERT_CLIP', clip: clip1, trackId: videoTrackId }, + { type: 'INSERT_CLIP', clip: clip2, trackId: videoTrackId }, + { type: 'INSERT_CLIP', clip: clip3, trackId: audioTrackId }, + ]); + + state = applyTx(state, 'Add markers', [ + { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: toMarkerId('m1'), + frame: toFrame(150), + label: 'M1', + color: 'red', + scope: 'global', + linkedClipId: null, + }, + }, + { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: toMarkerId('m2'), + frame: toFrame(500), + label: 'M2', + color: 'blue', + scope: 'global', + linkedClipId: null, + }, + }, + ]); + + return state; +} + +/** Returns a JSON string that looks like v1 (schemaVersion: 1) for migration tests. */ +function toV1JsonString(state: ReturnType): string { + const json = serializeTimeline(state); + const parsed = JSON.parse(json) as Record; + parsed.schemaVersion = 1; + return JSON.stringify(parsed, null, 2); +} + +describe('Phase 5 Addendum — Migration', () => { + // ─── Version constant ─────────────────────────────────────────────────── + it('1. CURRENT_SCHEMA_VERSION === 2', () => { + expect(CURRENT_SCHEMA_VERSION).toBe(2); + }); + + // ─── V1 → V2 migration ─────────────────────────────────────────────────── + it('2. A v1 JSON string deserializes successfully via deserializeTimeline()', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const v1Json = toV1JsonString(state); + expect(() => deserializeTimeline(v1Json)).not.toThrow(); + const restored = deserializeTimeline(v1Json); + expect(restored.timeline).toBeDefined(); + expect(restored.assetRegistry).toBeDefined(); + }); + + it('3. Deserialized v1 state has schemaVersion === 2 (migrated up)', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const v1Json = toV1JsonString(state); + const restored = deserializeTimeline(v1Json); + expect(restored.schemaVersion).toBe(2); + }); + + it('4. V1 → V2 migration preserves all clips (2 tracks, 3 clips)', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const v1Json = toV1JsonString(state); + const restored = deserializeTimeline(v1Json); + const clipCount = restored.timeline.tracks.reduce((n, t) => n + t.clips.length, 0); + expect(restored.timeline.tracks).toHaveLength(2); + expect(clipCount).toBe(3); + }); + + it('5. V1 → V2 migration preserves all markers', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const v1Json = toV1JsonString(state); + const restored = deserializeTimeline(v1Json); + expect(restored.timeline.markers).toHaveLength(2); + }); + + // GATE: migration must produce valid state + it('6. V1 → V2 migration passes checkInvariants() with 0 violations — GATE', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const v1Json = toV1JsonString(state); + const restored = deserializeTimeline(v1Json); + const violations = checkInvariants(restored); + expect(violations).toHaveLength(0); + }); + + // ─── Current version ───────────────────────────────────────────────────── + it('7. A v2 JSON string passes through migrate() unchanged (schemaVersion stays 2)', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const json = serializeTimeline(state); + const parsed = JSON.parse(json); + expect(parsed.schemaVersion).toBe(2); + const result = migrate(parsed); + expect(result.schemaVersion).toBe(2); + }); + + it('8. New state created with createTimeline() has schemaVersion === 2', () => { + const timeline = createTimeline({ + id: 't1', + name: 'T1', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [], + }); + const state = createTimelineState({ timeline, assetRegistry: new Map() }); + expect(state.schemaVersion).toBe(2); + }); + + // ─── Error cases ─────────────────────────────────────────────────────── + it('9. schemaVersion: 3 (future) throws SerializationError with "Unknown schema version"', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const json = serializeTimeline(state); + const parsed = JSON.parse(json) as Record; + parsed.schemaVersion = 3; + expect(() => deserializeTimeline(JSON.stringify(parsed))).toThrow(SerializationError); + expect(() => deserializeTimeline(JSON.stringify(parsed))).toThrow(/Unknown schema version/); + }); + + it('10. schemaVersion missing throws SerializationError', () => { + const json = JSON.stringify({ timeline: {}, assetRegistry: {} }); + expect(() => deserializeTimeline(json)).toThrow(SerializationError); + expect(() => deserializeTimeline(json)).toThrow(/schemaVersion/); + }); + + it('11. Non-object input throws SerializationError', () => { + expect(() => migrate(null)).toThrow(SerializationError); + expect(() => migrate(42)).toThrow(SerializationError); + expect(() => migrate('string')).toThrow(SerializationError); + }); + + // ─── Project migration ────────────────────────────────────────────────── + it('12. createProject stamps CURRENT_SCHEMA_VERSION (2), not hardcoded 1', () => { + const p = createProject(toProjectId('p1'), 'P1'); + expect(p.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); + expect(p.schemaVersion).toBe(2); + }); + + it('13. serializeProject → deserializeProject round-trip works with schemaVersion 2', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const p = createProject(toProjectId('proj'), 'Proj', [state]); + const json = serializeProject(p); + const parsed = JSON.parse(json); + expect(parsed.schemaVersion).toBe(2); + const restored = deserializeProject(json); + expect(restored.schemaVersion).toBe(2); + expect(restored.timelines).toHaveLength(1); + expect(checkInvariants(restored.timelines[0]!)).toEqual([]); + }); + + // GATE: full round-trip via v1 migration + it('14. Full gate: serialize → corrupt schemaVersion to 1 → deserialize → checkInvariants() → 0 violations — GATE', () => { + const state = buildStateWithTracksClipsAndMarkers(); + const json = serializeTimeline(state); + const parsed = JSON.parse(json) as Record; + parsed.schemaVersion = 1; + const v1Json = JSON.stringify(parsed, null, 2); + const restored = deserializeTimeline(v1Json); + const violations = checkInvariants(restored); + expect(violations).toHaveLength(0); + }); +}); diff --git a/packages/core/src/__tests__/phase5-otio.test.ts b/packages/core/src/__tests__/phase5-otio.test.ts new file mode 100644 index 0000000..40caceb --- /dev/null +++ b/packages/core/src/__tests__/phase5-otio.test.ts @@ -0,0 +1,393 @@ +/** + * Phase 5 Step 2 — OTIO interchange + * + * exportToOTIO, importFromOTIO. Pure functions. No IO. + */ + +import { describe, it, expect } from 'vitest'; +import { exportToOTIO } from '../engine/otio-export'; +import { importFromOTIO, type OTIOImportOptions } from '../engine/otio-import'; +import { SerializationError } from '../engine/serialization-error'; +import { checkInvariants } from '../validation/invariants'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, createGeneratorAsset } from '../types/asset'; +import { createEffect, toEffectId } from '../types/effect'; +import { toFrame, toTimecode } from '../types/frame'; +import { toMarkerId } from '../types/marker'; +import { toGeneratorId } from '../types/generator'; +import { applyOperation } from '../engine/apply'; + +// ── Fixture: 30fps, two tracks (video + audio), three clips with gaps, one marker, one effect on clip1 ── + +function makeOTIOFixtureState() { + const assetV1 = createAsset({ + id: 'asset-v1', + name: 'V1', + mediaType: 'video', + filePath: '/media/v1.mp4', + intrinsicDuration: toFrame(500), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const assetV2 = createAsset({ + id: 'asset-v2', + name: 'V2', + mediaType: 'video', + filePath: '/media/v2.mp4', + intrinsicDuration: toFrame(400), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const assetA1 = createAsset({ + id: 'asset-a1', + name: 'A1', + mediaType: 'audio', + filePath: '/media/a1.wav', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const effect1 = createEffect(toEffectId('eff-1'), 'blur', 'preComposite', [{ key: 'radius', value: 5 }]); + const clip1 = createClip({ + id: 'clip-1', + assetId: 'asset-v1', + trackId: 'track-v', + timelineStart: toFrame(0), + timelineEnd: toFrame(50), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + effects: [effect1], + }); + const clip2 = createClip({ + id: 'clip-2', + assetId: 'asset-v1', + trackId: 'track-v', + timelineStart: toFrame(100), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + const clip3 = createClip({ + id: 'clip-3', + assetId: 'asset-v2', + trackId: 'track-v', + timelineStart: toFrame(200), + timelineEnd: toFrame(250), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + const clipA1 = createClip({ + id: 'clip-a1', + assetId: 'asset-a1', + trackId: 'track-a', + timelineStart: toFrame(0), + timelineEnd: toFrame(80), + mediaIn: toFrame(0), + mediaOut: toFrame(80), + }); + const trackV = createTrack({ + id: 'track-v', + name: 'V1', + type: 'video', + clips: [clip1, clip2, clip3], + }); + const trackA = createTrack({ + id: 'track-a', + name: 'A1', + type: 'audio', + clips: [clipA1], + }); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(25), + label: 'Cue', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + const timeline = createTimeline({ + id: 'tl', + name: 'OTIO Fixture', + fps: 30, + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [trackV, trackA], + markers: [marker], + }); + const state = createTimelineState({ + timeline, + assetRegistry: new Map([ + [assetV1.id, assetV1], + [assetV2.id, assetV2], + [assetA1.id, assetA1], + ]), + }); + expect(checkInvariants(state)).toEqual([]); + return state; +} + +// ── Export tests ─────────────────────────────────────────────────────────── + +describe('Phase 5 — OTIO Export', () => { + it('exportToOTIO produces OTIO_SCHEMA "Timeline.1"', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + expect(doc.OTIO_SCHEMA).toBe('Timeline.1'); + }); + + it('Track children have correct OTIO_SCHEMA "Track.1"', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const tracks = doc.tracks.children; + expect(tracks.length).toBeGreaterThanOrEqual(1); + tracks.forEach((t) => expect(t.OTIO_SCHEMA).toBe('Track.1')); + }); + + it('Clip source_range duration matches clip durationFrames', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const videoTrack = doc.tracks.children[0]!; + const firstClip = videoTrack.children.find((c) => (c as { OTIO_SCHEMA: string }).OTIO_SCHEMA === 'Clip.1') as { + source_range: { duration: { value: number } }; + } | undefined; + expect(firstClip).toBeDefined(); + expect(firstClip!.source_range.duration.value).toBe(50); + }); + + it('Gap inserted between non-adjacent clips', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const videoTrack = doc.tracks.children[0]!; + const gaps = videoTrack.children.filter((c) => (c as { OTIO_SCHEMA: string }).OTIO_SCHEMA === 'Gap.1'); + expect(gaps.length).toBe(2); + expect((gaps[0] as { source_range: { duration: { value: number } } }).source_range.duration.value).toBe(50); + expect((gaps[1] as { source_range: { duration: { value: number } } }).source_range.duration.value).toBe(50); + }); + + it('FileAsset maps to ExternalReference with target_url', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const videoTrack = doc.tracks.children[0]!; + const clip = videoTrack.children.find((c) => (c as { OTIO_SCHEMA: string }).OTIO_SCHEMA === 'Clip.1') as { + media_reference: { OTIO_SCHEMA: string; target_url?: string }; + }; + expect(clip.media_reference.OTIO_SCHEMA).toBe('ExternalReference.1'); + expect(clip.media_reference.target_url).toBe('/media/v1.mp4'); + }); + + it('GeneratorAsset maps to GeneratorReference', () => { + const genAsset = createGeneratorAsset({ + id: 'gen-1', + name: 'Solid', + mediaType: 'video', + generatorDef: { + id: toGeneratorId('gen-1'), + type: 'solid', + params: {}, + duration: toFrame(60), + name: 'S', + }, + nativeFps: 30, + }); + const clip = createClip({ + id: 'cgen', + assetId: 'gen-1', + trackId: 'track-v', + timelineStart: toFrame(0), + timelineEnd: toFrame(30), + mediaIn: toFrame(0), + mediaOut: toFrame(30), + }); + const track = createTrack({ id: 'track-v', name: 'V', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(100), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + const state = createTimelineState({ + timeline, + assetRegistry: new Map([[genAsset.id, genAsset]]), + }); + const doc = exportToOTIO(state); + const otioClip = doc.tracks.children[0]!.children[0] as { media_reference: { OTIO_SCHEMA: string; generator_kind?: string } }; + expect(otioClip.media_reference.OTIO_SCHEMA).toBe('GeneratorReference.1'); + expect(otioClip.media_reference.generator_kind).toBe('solid'); + }); + + it('Effect exported in clip effects array', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const videoTrack = doc.tracks.children[0]!; + const firstClip = videoTrack.children.find((c) => (c as { OTIO_SCHEMA: string }).OTIO_SCHEMA === 'Clip.1') as { + effects?: Array<{ effect_name: string }>; + }; + expect(firstClip.effects).toHaveLength(1); + expect(firstClip.effects![0]!.effect_name).toBe('blur'); + }); + + it('Timeline marker exported at top level', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + expect(doc.markers).toHaveLength(1); + expect(doc.markers[0]!.name).toBe('Cue'); + }); + + it('Point marker has duration 0', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + expect(doc.markers[0]!.marked_range.duration.value).toBe(0); + }); + + it('Range marker has duration = endFrame - frame', () => { + const state = makeOTIOFixtureState(); + const rangeMarker = { + type: 'range' as const, + id: toMarkerId('r1'), + frameStart: toFrame(10), + frameEnd: toFrame(40), + label: 'Range', + color: '#0f0', + scope: 'global' as const, + linkedClipId: null, + }; + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(100), + startTimecode: toTimecode('00:00:00:00'), + tracks: [], + markers: [rangeMarker], + }); + const state2 = createTimelineState({ timeline, assetRegistry: new Map() }); + const doc = exportToOTIO(state2); + expect(doc.markers[0]!.marked_range.duration.value).toBe(30); + }); + + it('Audio track has kind "Audio"', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const audioTrack = doc.tracks.children.find((t) => (t as { kind: string }).kind === 'Audio'); + expect(audioTrack).toBeDefined(); + expect((audioTrack as { kind: string }).kind).toBe('Audio'); + }); +}); + +// ── Import tests ─────────────────────────────────────────────────────────── + +describe('Phase 5 — OTIO Import', () => { + it('importFromOTIO round-trips a simple timeline', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const json = JSON.stringify(doc); + const parsed = JSON.parse(json); + const restored = importFromOTIO(parsed); + expect(restored.timeline.tracks.length).toBe(2); + expect(restored.timeline.tracks[0]!.clips.length).toBe(3); + expect(restored.timeline.markers.length).toBe(1); + }); + + it('Clips placed at correct startFrame (cumulative gap + clip durations)', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const restored = importFromOTIO(doc); + const videoTrack = restored.timeline.tracks[0]!; + expect(videoTrack.clips[0]!.timelineStart).toBe(0); + expect(videoTrack.clips[1]!.timelineStart).toBe(100); + expect(videoTrack.clips[2]!.timelineStart).toBe(200); + }); + + it('Gap.1 advances cursor without creating a clip', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const restored = importFromOTIO(doc); + const videoTrack = restored.timeline.tracks[0]!; + expect(videoTrack.clips.length).toBe(3); + }); + + it('ExternalReference creates FileAsset in state', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const restored = importFromOTIO(doc); + expect(restored.assetRegistry.size).toBeGreaterThanOrEqual(3); + const hasFile = Array.from(restored.assetRegistry.values()).some( + (a) => a.kind === 'file' && a.filePath === '/media/v1.mp4', + ); + expect(hasFile).toBe(true); + }); + + it('Markers imported: point → point, range → range', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const restored = importFromOTIO(doc); + expect(restored.timeline.markers[0]!.type).toBe('point'); + const rangeDoc = exportToOTIO( + createTimelineState({ + timeline: createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(100), + startTimecode: '00:00:00:00' as import('../types/frame').Timecode, + tracks: [], + markers: [ + { + type: 'range', + id: toMarkerId('r1'), + frameStart: toFrame(10), + frameEnd: toFrame(50), + label: 'R', + color: '#0f0', + scope: 'global', + linkedClipId: null, + }, + ], + }), + assetRegistry: new Map(), + }), + ); + const restoredRange = importFromOTIO(rangeDoc); + expect(restoredRange.timeline.markers[0]!.type).toBe('range'); + }); + + it('importFromOTIO throws SerializationError on non-Timeline OTIO doc', () => { + expect(() => importFromOTIO({ OTIO_SCHEMA: 'Clip.1' })).toThrow(SerializationError); + expect(() => importFromOTIO({})).toThrow(SerializationError); + }); + + it('fps override in options is respected', () => { + const state = makeOTIOFixtureState(); + const doc = exportToOTIO(state); + const opts: OTIOImportOptions = { fps: 24 }; + const restored = importFromOTIO(doc, opts); + expect(restored.timeline.fps).toBe(24); + }); + + it('Round-trip: export → JSON → parse → import → export produces structurally equivalent OTIO doc', () => { + const state = makeOTIOFixtureState(); + const doc1 = exportToOTIO(state); + const json = JSON.stringify(doc1); + const parsed = JSON.parse(json); + const restored = importFromOTIO(parsed); + const doc2 = exportToOTIO(restored); + expect(doc2.tracks.children.length).toBe(doc1.tracks.children.length); + const videoTrack1 = doc1.tracks.children[0]!; + const videoTrack2 = doc2.tracks.children[0]!; + expect(videoTrack2.children.length).toBe(videoTrack1.children.length); + const clips1 = videoTrack1.children.filter((c) => (c as { OTIO_SCHEMA: string }).OTIO_SCHEMA === 'Clip.1'); + const clips2 = videoTrack2.children.filter((c) => (c as { OTIO_SCHEMA: string }).OTIO_SCHEMA === 'Clip.1'); + expect(clips2.length).toBe(clips1.length); + clips1.forEach((c, i) => { + const d1 = (c as { source_range: { duration: { value: number } } }).source_range.duration.value; + const d2 = (clips2[i] as { source_range: { duration: { value: number } } }).source_range.duration.value; + expect(d2).toBe(d1); + }); + }); +}); diff --git a/packages/core/src/__tests__/phase5-project.test.ts b/packages/core/src/__tests__/phase5-project.test.ts new file mode 100644 index 0000000..c79ea2f --- /dev/null +++ b/packages/core/src/__tests__/phase5-project.test.ts @@ -0,0 +1,218 @@ +/** + * Phase 5 Step 5 — Project model + bins + */ + +import { describe, it, expect } from 'vitest'; + +import { + createProject, + toProjectId, + createBin, + toBinId, + type BinItem, +} from '../types/project'; + +import { + addTimeline, + removeTimeline, + addBin, + removeBin, + addItemToBin, + removeItemFromBin, + moveItemBetweenBins, +} from '../engine/project-ops'; + +import { serializeProject, deserializeProject } from '../engine/project-serializer'; +import { SerializationError } from '../engine/serialization-error'; + +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack } from '../types/track'; +import { createClip } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toFrame, toTimecode } from '../types/frame'; + +function makeTimelineState(timelineId: string) { + const asset = createAsset({ + id: `asset-${timelineId}`, + name: 'V1', + mediaType: 'video', + filePath: `/media/${timelineId}.mp4`, + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clip = createClip({ + id: `clip-${timelineId}`, + assetId: asset.id, + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: timelineId, + name: `Timeline ${timelineId}`, + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +describe('Phase 5 — Project model', () => { + it('createProject defaults: empty timelines, bins, rootBinIds; schemaVersion CURRENT', () => { + const p = createProject(toProjectId('p1'), 'P1'); + expect(p.timelines).toEqual([]); + expect(p.bins).toEqual([]); + expect(p.rootBinIds).toEqual([]); + expect(p.schemaVersion).toBe(2); + }); + + it('createBin defaults: parentId null, items []', () => { + const b = createBin(toBinId('b1'), 'Bin'); + expect(b.parentId).toBeNull(); + expect(b.items).toEqual([]); + }); +}); + +describe('Phase 5 — addTimeline / removeTimeline', () => { + it('addTimeline appends timeline to project', () => { + const p = createProject(toProjectId('p1'), 'P1'); + const s = makeTimelineState('tl-1'); + const next = addTimeline(p, s); + expect(next.timelines).toHaveLength(1); + expect(next.timelines[0]!.timeline.id).toBe('tl-1'); + }); + + it('removeTimeline removes by timelineId', () => { + const p = createProject(toProjectId('p1'), 'P1', [makeTimelineState('a'), makeTimelineState('b')]); + const next = removeTimeline(p, 'a'); + expect(next.timelines).toHaveLength(1); + expect(next.timelines[0]!.timeline.id).toBe('b'); + }); + + it('removeTimeline on missing id returns unchanged project (no error)', () => { + const p = createProject(toProjectId('p1'), 'P1', [makeTimelineState('a')]); + const next = removeTimeline(p, 'missing'); + expect(next).toBe(p); + }); +}); + +describe('Phase 5 — addBin / removeBin', () => { + it('addBin with parentId null adds to rootBinIds', () => { + const p = createProject(toProjectId('p1'), 'P1'); + const b = createBin(toBinId('b1'), 'Root', null); + const next = addBin(p, b); + expect(next.rootBinIds).toEqual(['b1']); + }); + + it('addBin with parentId does NOT add to rootBinIds', () => { + const p = createProject(toProjectId('p1'), 'P1'); + const b = createBin(toBinId('b1'), 'Child', toBinId('parent')); + const next = addBin(p, b); + expect(next.rootBinIds).toEqual([]); + }); + + it('removeBin removes bin from project.bins', () => { + const p = createProject(toProjectId('p1'), 'P1'); + const b = createBin(toBinId('b1'), 'Root', null); + const p2 = addBin(p, b); + const next = removeBin(p2, toBinId('b1')); + expect(next.bins).toHaveLength(0); + }); + + it('removeBin removes from rootBinIds if present', () => { + const p = createProject(toProjectId('p1'), 'P1'); + const b = createBin(toBinId('b1'), 'Root', null); + const p2 = addBin(p, b); + const next = removeBin(p2, toBinId('b1')); + expect(next.rootBinIds).toEqual([]); + }); + + it('removeBin recursively removes child bins', () => { + const p = createProject(toProjectId('p1'), 'P1'); + const parent = createBin(toBinId('parent'), 'Parent', null); + const child = createBin(toBinId('child'), 'Child', toBinId('parent')); + const p2 = addBin(addBin(p, parent), child); + const next = removeBin(p2, toBinId('parent')); + const ids = next.bins.map((b) => b.id); + expect(ids).not.toContain('parent'); + expect(ids).not.toContain('child'); + }); +}); + +describe('Phase 5 — addItemToBin / removeItemFromBin / moveItemBetweenBins', () => { + it('addItemToBin appends item to correct bin', () => { + const p = addBin(createProject(toProjectId('p1'), 'P1'), createBin(toBinId('b1'), 'B1')); + const item: BinItem = { kind: 'sequence', timelineId: 'tl-1' }; + const next = addItemToBin(p, toBinId('b1'), item); + expect(next.bins[0]!.items).toEqual([item]); + }); + + it('addItemToBin throws on missing bin', () => { + const p = createProject(toProjectId('p1'), 'P1'); + expect(() => addItemToBin(p, toBinId('missing'), { kind: 'asset', assetId: 'a' as any })).toThrow(); + }); + + it('removeItemFromBin removes matching item', () => { + const p0 = addBin(createProject(toProjectId('p1'), 'P1'), createBin(toBinId('b1'), 'B1')); + const item: BinItem = { kind: 'asset', assetId: 'asset-1' as any }; + const p1 = addItemToBin(p0, toBinId('b1'), item); + const next = removeItemFromBin(p1, toBinId('b1'), item); + expect(next.bins[0]!.items).toEqual([]); + }); + + it('moveItemBetweenBins moves item correctly', () => { + const p0 = addBin(addBin(createProject(toProjectId('p1'), 'P1'), createBin(toBinId('a'), 'A')), createBin(toBinId('b'), 'B')); + const item: BinItem = { kind: 'sequence', timelineId: 'tl-1' }; + const p1 = addItemToBin(p0, toBinId('a'), item); + const next = moveItemBetweenBins(p1, toBinId('a'), toBinId('b'), item); + expect(next.bins.find((x) => x.id === 'a')!.items).toEqual([]); + expect(next.bins.find((x) => x.id === 'b')!.items).toEqual([item]); + }); +}); + +describe('Phase 5 — Project serializer', () => { + it('serializeProject produces valid JSON', () => { + const p = createProject(toProjectId('p1'), 'P1', [makeTimelineState('tl-1')]); + const raw = serializeProject(p); + expect(() => JSON.parse(raw)).not.toThrow(); + }); + + it('deserializeProject round-trips project', () => { + const p0 = createProject(toProjectId('p1'), 'P1', [makeTimelineState('tl-1'), makeTimelineState('tl-2')]); + const p1 = addBin(p0, createBin(toBinId('b1'), 'Root')); + const raw1 = serializeProject(p1); + const restored = deserializeProject(raw1); + expect(restored.id).toBe(p1.id); + expect(restored.timelines).toHaveLength(2); + expect(restored.bins).toHaveLength(1); + const raw2 = serializeProject(restored); + expect(raw2).toBe(raw1); + }); + + it('deserializeProject throws SerializationError on invalid JSON', () => { + expect(() => deserializeProject('{')).toThrow(SerializationError); + expect(() => deserializeProject('not json')).toThrow(SerializationError); + }); + + it('deserializeProject throws on missing schemaVersion', () => { + const p = createProject(toProjectId('p1'), 'P1', [makeTimelineState('tl-1')]); + const parsed = JSON.parse(serializeProject(p)); + delete parsed.schemaVersion; + expect(() => deserializeProject(JSON.stringify(parsed))).toThrow(SerializationError); + }); + + it('deserializeProject validates each timeline (corrupt one → SerializationError)', () => { + const p = createProject(toProjectId('p1'), 'P1', [makeTimelineState('tl-1')]); + const parsed = JSON.parse(serializeProject(p)); + // break invariants: clip beyond timeline duration + parsed.timelines[0].timeline.duration = 10; + expect(() => deserializeProject(JSON.stringify(parsed))).toThrow(SerializationError); + }); +}); + diff --git a/packages/core/src/__tests__/phase5-roundtrip.test.ts b/packages/core/src/__tests__/phase5-roundtrip.test.ts new file mode 100644 index 0000000..d9b8c09 --- /dev/null +++ b/packages/core/src/__tests__/phase5-roundtrip.test.ts @@ -0,0 +1,644 @@ +/** + * Phase 5 Step 6 — Round-trip gate test + * + * This file is the Phase 5 release gate. + * No new source code is introduced here — tests only. + */ + +import { describe, it, expect } from 'vitest'; + +import { dispatch } from '../engine/dispatcher'; +import { checkInvariants } from '../validation/invariants'; + +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId, type ClipId } from '../types/clip'; +import { createAsset, createGeneratorAsset, toAssetId, type AssetId } from '../types/asset'; +import { toGeneratorId } from '../types/generator'; +import { toFrame, toTimecode } from '../types/frame'; + +import { createEffect, toEffectId } from '../types/effect'; +import { toKeyframeId } from '../types/keyframe'; +import { LINEAR_EASING } from '../types/easing'; +import { createTransition, toTransitionId } from '../types/transition'; +import { createAnimatableProperty } from '../types/clip-transform'; +import { toMarkerId } from '../types/marker'; +import { toCaptionId } from '../types/caption'; +import { createLinkGroup, toLinkGroupId } from '../types/link-group'; +import { createTrackGroup, toTrackGroupId } from '../types/track-group'; + +import { buildSnapIndex } from '../snap-index'; + +import { serializeTimeline, deserializeTimeline, remapAssetPaths } from '../engine/serializer'; +import { exportToOTIO } from '../engine/otio-export'; +import { importFromOTIO } from '../engine/otio-import'; +import { exportToEDL } from '../engine/edl-export'; +import { exportToAAF } from '../engine/aaf-export'; +import { exportToFCPXML } from '../engine/fcpxml-export'; + +import { createProject, toProjectId } from '../types/project'; +import { serializeProject, deserializeProject } from '../engine/project-serializer'; +import { addTimeline, removeTimeline } from '../engine/project-ops'; + +import type { OperationPrimitive, Transaction } from '../types/operations'; + +let txCounter = 0; +function makeTx(label: string, operations: OperationPrimitive[]): Transaction { + return { id: `tx-${++txCounter}`, label, timestamp: Date.now(), operations }; +} + +function applyTx(state: ReturnType, label: string, ops: OperationPrimitive[]) { + const result = dispatch(state, makeTx(label, ops)); + expect(result.accepted).toBe(true); + if (!result.accepted) throw new Error(result.message); + expect(checkInvariants(result.nextState)).toEqual([]); + return result.nextState; +} + +function buildComplexState() { + const fps = 30; + const durationFrames = 5400; + + const timeline = createTimeline({ + id: 'rt', + name: 'RoundTripTest', + fps, + duration: toFrame(durationFrames), + startTimecode: toTimecode('00:00:00:00'), + tracks: [], + }); + let state = createTimelineState({ timeline, assetRegistry: new Map() }); + + // Tracks (4) + const videoTrack1Id = toTrackId('videoTrack1'); + const videoTrack2Id = toTrackId('videoTrack2'); + const audioTrack1Id = toTrackId('audioTrack1'); + const audioTrack2Id = toTrackId('audioTrack2'); + + state = applyTx(state, 'Add tracks', [ + { type: 'ADD_TRACK', track: createTrack({ id: videoTrack1Id, name: 'V1', type: 'video', clips: [] }) }, + { type: 'ADD_TRACK', track: createTrack({ id: videoTrack2Id, name: 'V2', type: 'video', clips: [] }) }, + { type: 'ADD_TRACK', track: createTrack({ id: audioTrack1Id, name: 'A1', type: 'audio', clips: [] }) }, + { type: 'ADD_TRACK', track: createTrack({ id: audioTrack2Id, name: 'A2', type: 'audio', clips: [] }) }, + ]); + + // Assets (3) + const fileAsset1 = createAsset({ + id: 'fileAsset1', + name: 'clip-a', + mediaType: 'video', + filePath: '/media/clip-a.mp4', + intrinsicDuration: toFrame(10000), + nativeFps: fps, + sourceTimecodeOffset: toFrame(0), + }); + const fileAsset2 = createAsset({ + id: 'fileAsset2', + name: 'clip-b', + mediaType: 'video', + filePath: '/media/clip-b.mp4', + intrinsicDuration: toFrame(10000), + nativeFps: fps, + sourceTimecodeOffset: toFrame(0), + }); + // Audio assets (core enforces asset.mediaType === track.type) + const fileAsset1Audio = createAsset({ + id: 'fileAsset1Audio', + name: 'clip-a-audio', + mediaType: 'audio', + filePath: '/media/clip-a.wav', + intrinsicDuration: toFrame(10000), + nativeFps: fps, + sourceTimecodeOffset: toFrame(0), + }); + const fileAsset2Audio = createAsset({ + id: 'fileAsset2Audio', + name: 'clip-b-audio', + mediaType: 'audio', + filePath: '/media/clip-b.wav', + intrinsicDuration: toFrame(10000), + nativeFps: fps, + sourceTimecodeOffset: toFrame(0), + }); + const genAsset = createGeneratorAsset({ + id: 'genAsset', + name: 'Solid', + mediaType: 'video', + generatorDef: { + id: toGeneratorId('gen-1'), + type: 'solid', + params: { color: '#fff' }, + duration: toFrame(10000), + name: 'Solid', + }, + nativeFps: fps, + }); + + state = applyTx(state, 'Register assets', [ + { type: 'REGISTER_ASSET', asset: fileAsset1 }, + { type: 'REGISTER_ASSET', asset: fileAsset2 }, + { type: 'REGISTER_ASSET', asset: fileAsset1Audio }, + { type: 'REGISTER_ASSET', asset: fileAsset2Audio }, + { type: 'REGISTER_ASSET', asset: genAsset }, + ]); + + // Timeline metadata: in/out + beat grid + state = applyTx(state, 'Set in/out + beat grid', [ + { type: 'SET_IN_POINT', frame: toFrame(30) }, + { type: 'SET_OUT_POINT', frame: toFrame(5370) }, + { type: 'ADD_BEAT_GRID', beatGrid: { bpm: 120, timeSignature: [4, 4] as const, offset: toFrame(0) } }, + ]); + + // Clips (6 total) + const clip1Id = toClipId('clip1'); + const clip2Id = toClipId('clip2'); + const clip3Id = toClipId('clip3'); + const clip4Id = toClipId('clip4'); + const clip5Id = toClipId('clip5'); + const clip6Id = toClipId('clip6'); + + const clip1 = createClip({ + id: clip1Id, + assetId: fileAsset1.id, + trackId: videoTrack1Id, + timelineStart: toFrame(0), + timelineEnd: toFrame(900), + mediaIn: toFrame(0), + mediaOut: toFrame(900), + }); + const clip2 = createClip({ + id: clip2Id, + assetId: fileAsset2.id, + trackId: videoTrack1Id, + timelineStart: toFrame(1000), + timelineEnd: toFrame(1900), + mediaIn: toFrame(0), + mediaOut: toFrame(900), + }); + const clip3 = createClip({ + id: clip3Id, + assetId: genAsset.id, + trackId: videoTrack1Id, + timelineStart: toFrame(2000), + timelineEnd: toFrame(2900), + mediaIn: toFrame(0), + mediaOut: toFrame(900), + }); + const clip4 = createClip({ + id: clip4Id, + assetId: fileAsset1.id, + trackId: videoTrack2Id, + timelineStart: toFrame(0), + timelineEnd: toFrame(3000), + mediaIn: toFrame(0), + mediaOut: toFrame(3000), + }); + const clip5 = createClip({ + id: clip5Id, + assetId: fileAsset1Audio.id, + trackId: audioTrack1Id, + timelineStart: toFrame(0), + timelineEnd: toFrame(900), + mediaIn: toFrame(0), + mediaOut: toFrame(900), + }); + const clip6 = createClip({ + id: clip6Id, + assetId: fileAsset2Audio.id, + trackId: audioTrack2Id, + timelineStart: toFrame(0), + timelineEnd: toFrame(900), + mediaIn: toFrame(0), + mediaOut: toFrame(900), + }); + + state = applyTx(state, 'Insert clips', [ + { type: 'INSERT_CLIP', clip: clip1, trackId: videoTrack1Id }, + { type: 'INSERT_CLIP', clip: clip2, trackId: videoTrack1Id }, + { type: 'INSERT_CLIP', clip: clip3, trackId: videoTrack1Id }, + { type: 'INSERT_CLIP', clip: clip4, trackId: videoTrack2Id }, + { type: 'INSERT_CLIP', clip: clip5, trackId: audioTrack1Id }, + { type: 'INSERT_CLIP', clip: clip6, trackId: audioTrack2Id }, + ]); + + // Effect + keyframes on clip1 + const effectId = toEffectId('effect1'); + const effect = createEffect(effectId, 'blur', 'preComposite', [{ key: 'radius', value: 5 }]); + state = applyTx(state, 'Add effect', [{ type: 'ADD_EFFECT', clipId: clip1Id, effect }]); + state = applyTx(state, 'Add keyframes', [ + { + type: 'ADD_KEYFRAME', + clipId: clip1Id, + effectId, + keyframe: { id: toKeyframeId('kf1'), frame: toFrame(0), value: 0, easing: LINEAR_EASING }, + }, + { + type: 'ADD_KEYFRAME', + clipId: clip1Id, + effectId, + keyframe: { id: toKeyframeId('kf2'), frame: toFrame(899), value: 10, easing: LINEAR_EASING }, + }, + ]); + + // Transition on clip1 + state = applyTx(state, 'Add transition', [ + { + type: 'ADD_TRANSITION', + clipId: clip1Id, + transition: createTransition( + toTransitionId('tr1'), + 'dissolve', + 15, + 'centerOnCut', + LINEAR_EASING, + ), + }, + ]); + + // Clip transform on clip2: opacity.value = 0.8 + state = applyTx(state, 'Set clip2 opacity', [ + { + type: 'SET_CLIP_TRANSFORM', + clipId: clip2Id, + transform: { opacity: createAnimatableProperty(0.8) }, + }, + ]); + + // Audio properties on clip5: gain = -3, mute false + state = applyTx(state, 'Set clip5 audio', [ + { + type: 'SET_AUDIO_PROPERTIES', + clipId: clip5Id, + properties: { gain: createAnimatableProperty(-3), mute: false }, + }, + ]); + + // Markers (3) + state = applyTx(state, 'Add markers', [ + { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: toMarkerId('m-scene2'), + frame: toFrame(900), + label: 'Scene 2', + color: 'red', + scope: 'global', + linkedClipId: null, + }, + }, + { + type: 'ADD_MARKER', + marker: { + type: 'range', + id: toMarkerId('m-act1'), + frameStart: toFrame(1000), + frameEnd: toFrame(1900), + label: 'Act 1', + color: 'blue', + scope: 'global', + linkedClipId: null, + }, + }, + { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: toMarkerId('m-vfx'), + frame: toFrame(450), + label: 'VFX shot', + color: 'green', + scope: 'global', + linkedClipId: clip1Id, + clipId: clip1Id, + }, + }, + ]); + + // LinkGroup: clip1 + clip5 + state = applyTx(state, 'Link A/V', [ + { + type: 'LINK_CLIPS', + linkGroup: createLinkGroup(toLinkGroupId('lg1'), [clip1Id, clip5Id]), + }, + ]); + + // TrackGroup: videoTrack1 + audioTrack1 + state = applyTx(state, 'Track group', [ + { + type: 'ADD_TRACK_GROUP', + trackGroup: createTrackGroup(toTrackGroupId('tg1'), 'Cam A', [videoTrack1Id, audioTrack1Id]), + }, + ]); + + // Caption on audioTrack1 + state = applyTx(state, 'Add caption', [ + { + type: 'ADD_CAPTION', + trackId: audioTrack1Id, + caption: { + id: toCaptionId('cap1'), + text: 'Hello world', + startFrame: toFrame(0), + endFrame: toFrame(90), + language: 'en-US', + burnIn: false, + }, + }, + ]); + + return { + state, + ids: { + clip1Id, + clip2Id, + clip3Id, + clip4Id, + clip5Id, + clip6Id, + fileAsset1Id: fileAsset1.id as AssetId, + fileAsset2Id: fileAsset2.id as AssetId, + genAssetId: genAsset.id as AssetId, + videoTrack1Id, + videoTrack2Id, + audioTrack1Id, + audioTrack2Id, + }, + }; +} + +function countAllClips(state: ReturnType['state']): number { + return state.timeline.tracks.reduce((acc, t) => acc + t.clips.length, 0); +} + +describe('Phase 5 — Round-trip gate', () => { + // Baseline + it('buildComplexState() passes checkInvariants() with 0 violations', () => { + const { state } = buildComplexState(); + expect(checkInvariants(state)).toEqual([]); + }); + + // JSON round-trip + it('serialize → deserialize produces 0 invariant violations', () => { + const { state } = buildComplexState(); + const round = deserializeTimeline(serializeTimeline(state)); + expect(checkInvariants(round)).toEqual([]); + }); + + it('clip count preserved after round-trip (6 clips)', () => { + const { state } = buildComplexState(); + const round = deserializeTimeline(serializeTimeline(state)); + expect(countAllClips(round)).toBe(6); + }); + + it('marker count preserved (3 markers)', () => { + const { state } = buildComplexState(); + const round = deserializeTimeline(serializeTimeline(state)); + expect(round.timeline.markers).toHaveLength(3); + }); + + it('effect on clip1 preserved (keyframes intact)', () => { + const { state, ids } = buildComplexState(); + const round = deserializeTimeline(serializeTimeline(state)); + const clip1 = round.timeline.tracks + .flatMap((t) => t.clips) + .find((c) => c.id === ids.clip1Id)!; + expect(clip1.effects).toBeDefined(); + expect(clip1.effects![0]!.effectType).toBe('blur'); + expect(clip1.effects![0]!.keyframes).toHaveLength(2); + expect(clip1.effects![0]!.keyframes[0]!.frame).toBe(0); + expect(clip1.effects![0]!.keyframes[1]!.frame).toBe(899); + }); + + it('transition on clip1 preserved', () => { + const { state, ids } = buildComplexState(); + const round = deserializeTimeline(serializeTimeline(state)); + const clip1 = round.timeline.tracks.flatMap((t) => t.clips).find((c) => c.id === ids.clip1Id)!; + expect(clip1.transition).toBeDefined(); + expect(clip1.transition!.type).toBe('dissolve'); + expect(clip1.transition!.durationFrames).toBe(15); + }); + + it('LinkGroup preserved (clip1 + clip5)', () => { + const { state, ids } = buildComplexState(); + const round = deserializeTimeline(serializeTimeline(state)); + const groups = round.timeline.linkGroups ?? []; + expect(groups).toHaveLength(1); + expect(groups[0]!.clipIds).toEqual([ids.clip1Id, ids.clip5Id]); + }); + + it('TrackGroup preserved with correct trackIds', () => { + const { state, ids } = buildComplexState(); + const round = deserializeTimeline(serializeTimeline(state)); + const groups = round.timeline.trackGroups ?? []; + expect(groups).toHaveLength(1); + expect(groups[0]!.trackIds).toEqual([ids.videoTrack1Id, ids.audioTrack1Id]); + }); + + it('Caption on audioTrack1 preserved', () => { + const { state, ids } = buildComplexState(); + const round = deserializeTimeline(serializeTimeline(state)); + const t = round.timeline.tracks.find((x) => x.id === ids.audioTrack1Id)!; + expect(t.captions).toHaveLength(1); + expect(t.captions[0]!.text).toBe('Hello world'); + }); + + it('serialize → deserialize → serialize produces identical JSON strings (idempotent)', () => { + const { state } = buildComplexState(); + const s1 = serializeTimeline(state); + const s2 = serializeTimeline(deserializeTimeline(s1)); + expect(s2).toBe(s1); + }); + + // Asset remapper + it('remapAssetPaths replaces all FileAsset paths (new present, old gone)', () => { + const { state } = buildComplexState(); + const remapped = remapAssetPaths(state, (a) => ({ ...a, filePath: `/rel${a.filePath}` })); + const values = Array.from(remapped.assetRegistry.values()); + const paths = values.filter((a) => a.kind === 'file').map((a) => (a as any).filePath); + expect(paths).toContain('/rel/media/clip-a.mp4'); + expect(paths).toContain('/rel/media/clip-b.mp4'); + expect(paths).not.toContain('/media/clip-a.mp4'); + expect(paths).not.toContain('/media/clip-b.mp4'); + expect(checkInvariants(remapped)).toEqual([]); + }); + + it('GeneratorAsset unchanged after remap', () => { + const { state, ids } = buildComplexState(); + const remapped = remapAssetPaths(state, (a) => ({ ...a, filePath: `/rel${a.filePath}` })); + const gen = remapped.assetRegistry.get(ids.genAssetId)!; + expect(gen.kind).toBe('generator'); + expect((gen as any).generatorDef.type).toBe('solid'); + }); + + // OTIO round-trip + it('exportToOTIO produces 4 Track children (one per track)', () => { + const { state } = buildComplexState(); + const doc = exportToOTIO(state); + expect(doc.tracks.children).toHaveLength(4); + }); + + it('importFromOTIO(exportToOTIO(state)) gives state with 6 clips total', () => { + const { state } = buildComplexState(); + const round = importFromOTIO(exportToOTIO(state)); + expect(checkInvariants(round)).toEqual([]); + expect(countAllClips(round)).toBe(6); + }); + + it('OTIO round-trip: clip durations preserved', () => { + const { state, ids } = buildComplexState(); + const round = importFromOTIO(exportToOTIO(state)); + const clip1 = round.timeline.tracks.flatMap((t) => t.clips).find((c) => c.id === ids.clip1Id)!; + expect((clip1.timelineEnd - clip1.timelineStart) as number).toBe(900); + }); + + it('OTIO round-trip: gap between clip1 and clip2 produces Gap in export', () => { + const { state } = buildComplexState(); + const doc = exportToOTIO(state); + const v1 = doc.tracks.children.find((t) => t.kind === 'Video')!; + const gaps = v1.children.filter((c) => (c as any).OTIO_SCHEMA === 'Gap.1'); + expect(gaps.length).toBeGreaterThan(0); + const has100 = gaps.some((g) => (g as any).source_range.duration.value === 100); + expect(has100).toBe(true); + }); + + // EDL + it('exportToEDL produces correct event count for videoTrack1 (3 events)', () => { + const { state } = buildComplexState(); + const edl = exportToEDL(state, { trackIndex: 0 }); + const events = edl.split('\n').filter((l) => /^\d{3}\s+/.test(l)); + expect(events).toHaveLength(3); + }); + + it('EDL timecode for clip1 recIn = \"00:00:00:00\"', () => { + const { state } = buildComplexState(); + const edl = exportToEDL(state, { trackIndex: 0 }); + const line1 = edl.split('\n').find((l) => l.startsWith('001 '))!; + expect(line1).toContain('00:00:00:00'); + }); + + it('EDL timecode for clip2 recIn = \"00:00:33:10\" (frame 1000 @30fps)', () => { + const { state } = buildComplexState(); + const edl = exportToEDL(state, { trackIndex: 0 }); + const line2 = edl.split('\n').find((l) => l.startsWith('002 '))!; + expect(line2).toContain('00:00:33:10'); + }); + + // AAF + it('exportToAAF contains MasterMob for each of the 6 clips', () => { + const { state, ids } = buildComplexState(); + const xml = exportToAAF(state); + const clipIds: ClipId[] = [ids.clip1Id, ids.clip2Id, ids.clip3Id, ids.clip4Id, ids.clip5Id, ids.clip6Id]; + clipIds.forEach((id) => expect(xml).toContain(`mobID="${id}"`)); + }); + + it('CompositionMob has 4 TimelineMobSlots', () => { + const { state } = buildComplexState(); + const xml = exportToAAF(state); + const slots = (xml.match(/ for fileAsset1 and fileAsset2', () => { + const { state, ids } = buildComplexState(); + const xml = exportToFCPXML(state); + expect(xml).toContain(` for genAsset', () => { + const { state, ids } = buildComplexState(); + const xml = exportToFCPXML(state); + expect(xml).toContain(` { + const { state } = buildComplexState(); + const xml = exportToFCPXML(state); + expect(xml).toContain('offset="0s"'); + }); + + // BeatGrid snap + it('buildSnapIndex(state) includes frame 15', () => { + const { state } = buildComplexState(); + const idx = buildSnapIndex(state, toFrame(0)); + const has15 = idx.points.some((p) => p.type === 'BeatGrid' && (p.frame as number) === 15); + expect(has15).toBe(true); + }); + + it('buildSnapIndex(state) includes frame 30', () => { + const { state } = buildComplexState(); + const idx = buildSnapIndex(state, toFrame(0)); + const has30 = idx.points.some((p) => p.type === 'BeatGrid' && (p.frame as number) === 30); + expect(has30).toBe(true); + }); + + it('No beat frame exceeds durationFrames (5400)', () => { + const { state } = buildComplexState(); + const idx = buildSnapIndex(state, toFrame(0)); + const beats = idx.points.filter((p) => p.type === 'BeatGrid'); + expect(beats.every((b) => (b.frame as number) < 5400)).toBe(true); + }); + + // Project round-trip + it('createProject with complexState round-trips via serializeProject → deserializeProject', () => { + const { state } = buildComplexState(); + const p = createProject(toProjectId('proj1'), 'Project', [state]); + const raw = serializeProject(p); + const restored = deserializeProject(raw); + expect(restored.timelines).toHaveLength(1); + expect(checkInvariants(restored.timelines[0]!)).toEqual([]); + }); + + it('Deserialized project has 1 timeline with 6 clips', () => { + const { state } = buildComplexState(); + const p = createProject(toProjectId('proj1'), 'Project', [state]); + const restored = deserializeProject(serializeProject(p)); + expect(countAllClips(restored.timelines[0]!)).toBe(6); + }); + + it('addTimeline → removeTimeline → timeline count returns to original', () => { + const { state } = buildComplexState(); + const p0 = createProject(toProjectId('proj1'), 'Project', [state]); + const extra = makeMinimalTimelineState('extra'); + const p1 = addTimeline(p0, extra); + expect(p1.timelines).toHaveLength(2); + const p2 = removeTimeline(p1, extra.timeline.id); + expect(p2.timelines).toHaveLength(1); + }); +}); + +function makeMinimalTimelineState(timelineId: string) { + const asset = createAsset({ + id: `asset-${timelineId}`, + name: 'V', + mediaType: 'video', + filePath: `/media/${timelineId}.mp4`, + intrinsicDuration: toFrame(1000), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const trackId = toTrackId('t1'); + const clip = createClip({ + id: toClipId(`clip-${timelineId}`), + assetId: asset.id, + trackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(10), + mediaIn: toFrame(0), + mediaOut: toFrame(10), + }); + const track = createTrack({ id: trackId, name: 'V', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: timelineId, + name: timelineId, + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + diff --git a/packages/core/src/__tests__/phase5-serializer.test.ts b/packages/core/src/__tests__/phase5-serializer.test.ts new file mode 100644 index 0000000..00d7aaf --- /dev/null +++ b/packages/core/src/__tests__/phase5-serializer.test.ts @@ -0,0 +1,346 @@ +/** + * Phase 5 Step 1 — JSON schema + migrator + * + * Serializer, migrator, remapAssetPaths, findOfflineAssets. + * Pure functions only. No IO. + */ + +import { describe, it, expect } from 'vitest'; +import { + serializeTimeline, + deserializeTimeline, + SerializationError, + remapAssetPaths, + findOfflineAssets, + type AssetRemapCallback, +} from '../engine/serializer'; +import { migrate, CURRENT_SCHEMA_VERSION } from '../engine/migrator'; +import { checkInvariants } from '../validation/invariants'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, createGeneratorAsset, toAssetId } from '../types/asset'; +import { toGeneratorId } from '../types/generator'; +import { createEffect, toEffectId } from '../types/effect'; +import { createTransition, toTransitionId } from '../types/transition'; +import { toFrame, toTimecode } from '../types/frame'; +import { toMarkerId } from '../types/marker'; +import { applyOperation } from '../engine/apply'; + +// ── Fixture: non-trivial state for round-trips ───────────────────────────── + +function makeRoundTripState() { + const asset1 = createAsset({ + id: 'asset-1', + name: 'V1', + mediaType: 'video', + filePath: '/path/a.mp4', + intrinsicDuration: toFrame(300), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const asset2 = createAsset({ + id: 'asset-2', + name: 'V2', + mediaType: 'video', + filePath: '/path/b.mp4', + intrinsicDuration: toFrame(200), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: 'clip-1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clip2 = createClip({ + id: 'clip-2', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(100), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clip3 = createClip({ + id: 'clip-3', + assetId: 'asset-2', + trackId: 'track-2', + timelineStart: toFrame(50), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track1 = createTrack({ + id: 'track-1', + name: 'V1', + type: 'video', + clips: [clip1, clip2], + }); + const track2 = createTrack({ + id: 'track-2', + name: 'V2', + type: 'video', + clips: [clip3], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track1, track2], + }); + let state = createTimelineState({ + timeline, + assetRegistry: new Map([ + [asset1.id, asset1], + [asset2.id, asset2], + ]), + }); + const marker = { + type: 'point' as const, + id: toMarkerId('m1'), + frame: toFrame(75), + label: 'Mark', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + state = applyOperation(state, { type: 'ADD_MARKER', marker }); + const beatGrid = { bpm: 120, timeSignature: [4, 4] as const, offset: toFrame(0) }; + state = applyOperation(state, { type: 'ADD_BEAT_GRID', beatGrid }); + const effect = createEffect(toEffectId('eff-1'), 'blur', 'preComposite', []); + state = applyOperation(state, { type: 'ADD_EFFECT', clipId: toClipId('clip-1'), effect }); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 10); + state = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('clip-1'), transition: trans }); + expect(checkInvariants(state)).toEqual([]); + return state; +} + +// ── Serializer ───────────────────────────────────────────────────────────── + +describe('Phase 5 — Serializer', () => { + it('serializeTimeline produces valid JSON string', () => { + const state = makeRoundTripState(); + const json = serializeTimeline(state); + expect(() => JSON.parse(json)).not.toThrow(); + const parsed = JSON.parse(json); + expect(parsed.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); + expect(parsed.timeline).toBeDefined(); + expect(parsed.assetRegistry).toBeDefined(); + }); + + it('deserializeTimeline round-trips state correctly (deep equal after parse)', () => { + const state = makeRoundTripState(); + const json = serializeTimeline(state); + const restored = deserializeTimeline(json); + expect(restored.schemaVersion).toBe(state.schemaVersion); + expect(restored.timeline.id).toBe(state.timeline.id); + expect(restored.timeline.tracks).toHaveLength(state.timeline.tracks.length); + expect(restored.timeline.markers).toHaveLength(state.timeline.markers.length); + expect(restored.timeline.beatGrid).toEqual(state.timeline.beatGrid); + expect(restored.assetRegistry.size).toBe(state.assetRegistry.size); + const c1 = restored.timeline.tracks[0]!.clips[0]!; + expect(c1.effects).toHaveLength(1); + expect(c1.transition).toBeDefined(); + }); + + it('deserializeTimeline preserves schemaVersion', () => { + const state = makeRoundTripState(); + const restored = deserializeTimeline(serializeTimeline(state)); + expect(restored.schemaVersion).toBe(CURRENT_SCHEMA_VERSION); + }); + + it('deserializeTimeline throws SerializationError on invalid JSON', () => { + expect(() => deserializeTimeline('not json')).toThrow(SerializationError); + expect(() => deserializeTimeline('{')).toThrow(SerializationError); + }); + + it('deserializeTimeline throws SerializationError when schemaVersion is missing', () => { + const bad = JSON.stringify({ timeline: {}, assetRegistry: {} }); + expect(() => deserializeTimeline(bad)).toThrow(SerializationError); + expect(() => deserializeTimeline(bad)).toThrow(/schemaVersion/); + }); + + it('deserializeTimeline throws SerializationError when schemaVersion is higher than CURRENT', () => { + const state = makeRoundTripState(); + const json = serializeTimeline(state); + const parsed = JSON.parse(json); + parsed.schemaVersion = 999; + expect(() => deserializeTimeline(JSON.stringify(parsed))).toThrow(SerializationError); + expect(() => deserializeTimeline(JSON.stringify(parsed))).toThrow(/Unknown schema version/); + }); + + it('deserializeTimeline throws SerializationError when state fails checkInvariants', () => { + const state = makeRoundTripState(); + const json = serializeTimeline(state); + const parsed = JSON.parse(json); + parsed.timeline.tracks[0].clips[0].timelineEnd = 9999; + expect(() => deserializeTimeline(JSON.stringify(parsed))).toThrow(SerializationError); + try { + deserializeTimeline(JSON.stringify(parsed)); + } catch (e) { + expect(e).toBeInstanceOf(SerializationError); + expect((e as SerializationError).violations).toBeDefined(); + expect((e as SerializationError).violations!.length).toBeGreaterThan(0); + } + }); + + it('Round-trip: serialize → deserialize → serialize produces identical strings', () => { + const state = makeRoundTripState(); + const first = serializeTimeline(state); + const restored = deserializeTimeline(first); + const second = serializeTimeline(restored); + expect(second).toBe(first); + }); +}); + +// ── Migrator ─────────────────────────────────────────────────────────────── + +describe('Phase 5 — Migrator', () => { + it('migrate throws on non-object input', () => { + expect(() => migrate(null)).toThrow(SerializationError); + expect(() => migrate(42)).toThrow(SerializationError); + expect(() => migrate('string')).toThrow(SerializationError); + }); + + it('migrate throws on missing schemaVersion', () => { + expect(() => migrate({ timeline: {}, assetRegistry: {} })).toThrow(SerializationError); + expect(() => migrate({ timeline: {}, assetRegistry: {} })).toThrow(/Missing schemaVersion/); + }); + + it('migrate throws on version > CURRENT_SCHEMA_VERSION', () => { + expect(() => migrate({ schemaVersion: 99, timeline: {}, assetRegistry: {} })).toThrow( + SerializationError, + ); + expect(() => migrate({ schemaVersion: 99, timeline: {}, assetRegistry: {} })).toThrow( + /Unknown schema version/, + ); + }); + + it('migrate passes through v1 state unchanged', () => { + const state = makeRoundTripState(); + const plain = { + schemaVersion: state.schemaVersion, + timeline: state.timeline, + assetRegistry: Object.fromEntries(state.assetRegistry), + }; + const result = migrate(plain); + expect(result.schemaVersion).toBe(state.schemaVersion); + expect(result.timeline.id).toBe(state.timeline.id); + expect(result.assetRegistry.size).toBe(state.assetRegistry.size); + }); +}); + +// ── Asset remapper ───────────────────────────────────────────────────────── + +describe('Phase 5 — remapAssetPaths', () => { + it('remapAssetPaths calls remap for each FileAsset', () => { + const state = makeRoundTripState(); + const remappedPaths: string[] = []; + const remap: AssetRemapCallback = (asset) => { + remappedPaths.push(asset.filePath); + return { ...asset, filePath: asset.filePath.replace('/path/', '/remapped/') }; + }; + const next = remapAssetPaths(state, remap); + expect(remappedPaths).toContain('/path/a.mp4'); + expect(remappedPaths).toContain('/path/b.mp4'); + expect(next.assetRegistry.get(toAssetId('asset-1'))!.kind).toBe('file'); + expect((next.assetRegistry.get(toAssetId('asset-1')) as { filePath: string }).filePath).toBe( + '/remapped/a.mp4', + ); + }); + + it('remapAssetPaths does not call remap for GeneratorAssets', () => { + const state = makeRoundTripState(); + let callCount = 0; + const remap: AssetRemapCallback = () => { + callCount++; + return createAsset({ + id: 'x', + name: 'X', + mediaType: 'video', + filePath: '/x', + intrinsicDuration: toFrame(1), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + }; + remapAssetPaths(state, remap); + expect(callCount).toBe(2); + }); + + it('remapAssetPaths returns new state (immutable — original state.assets unchanged)', () => { + const state = makeRoundTripState(); + const firstPath = (state.assetRegistry.get(toAssetId('asset-1')) as { filePath: string }) + .filePath; + const remap: AssetRemapCallback = (asset) => ({ ...asset, filePath: '/new/path.mp4' }); + const next = remapAssetPaths(state, remap); + expect((state.assetRegistry.get(toAssetId('asset-1')) as { filePath: string }).filePath).toBe( + firstPath, + ); + expect((next.assetRegistry.get(toAssetId('asset-1')) as { filePath: string }).filePath).toBe( + '/new/path.mp4', + ); + expect(next).not.toBe(state); + expect(next.assetRegistry).not.toBe(state.assetRegistry); + }); +}); + +// ── Offline asset detection ──────────────────────────────────────────────── + +describe('Phase 5 — findOfflineAssets', () => { + it('findOfflineAssets returns entry for each offline FileAsset', () => { + const state = makeRoundTripState(); + const isOnline = (asset: { id: string }) => asset.id !== 'asset-1'; + const result = findOfflineAssets(state, isOnline); + expect(result).toHaveLength(1); + expect(result[0]!.assetId).toBe('asset-1'); + expect(result[0]!.path).toBe('/path/a.mp4'); + }); + + it('findOfflineAssets includes correct clipIds for each offline asset', () => { + const state = makeRoundTripState(); + const isOnline = (asset: { id: string }) => asset.id !== 'asset-1'; + const result = findOfflineAssets(state, isOnline); + expect(result[0]!.clipIds).toContain('clip-1'); + expect(result[0]!.clipIds).toContain('clip-2'); + expect(result[0]!.clipIds).toHaveLength(2); + }); + + it('findOfflineAssets returns [] when all assets online', () => { + const state = makeRoundTripState(); + const result = findOfflineAssets(state, () => true); + expect(result).toEqual([]); + }); + + it('findOfflineAssets ignores GeneratorAssets', () => { + const state = makeRoundTripState(); + const genAsset = createGeneratorAsset({ + id: 'gen-1', + name: 'Solid', + mediaType: 'video', + generatorDef: { + id: toGeneratorId('gen-1'), + type: 'solid', + params: {}, + duration: toFrame(60), + name: 'S', + }, + nativeFps: 30, + }); + const registry = new Map(state.assetRegistry); + registry.set(genAsset.id, genAsset); + const stateWithGen = { ...state, assetRegistry: registry }; + const isOnline = () => false; + const result = findOfflineAssets(stateWithGen, isOnline); + const ids = result.map((r) => r.assetId); + expect(ids).not.toContain('gen-1'); + }); +}); diff --git a/packages/core/src/__tests__/phase6-keyboard.test.ts b/packages/core/src/__tests__/phase6-keyboard.test.ts new file mode 100644 index 0000000..6f0425c --- /dev/null +++ b/packages/core/src/__tests__/phase6-keyboard.test.ts @@ -0,0 +1,423 @@ +/** + * Phase 6 Step 4 — J/K/L jog-shuttle and keyboard contract + * + * Uses createTestClock() for engine. Fixture: 30fps, duration 900, + * one track, two clips. DEFAULT_KEY_BINDINGS. + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toTimecode } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { TimelineKeyEvent } from '../tools/types'; +import type { PipelineConfig } from '../types/pipeline'; +import { PlaybackEngine } from '../engine/playback-engine'; +import { KeyboardHandler } from '../engine/keyboard-handler'; +import { createTestClock } from '../engine/clock'; + +const FPS = 30; +const DURATION = 900; + +function makeKeyboardFixtureState(): TimelineState { + const trackId = toTrackId('videoTrack'); + const asset = createAsset({ + id: 'asset1', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(2000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: toClipId('clip1'), + assetId: asset.id, + trackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const clip2 = createClip({ + id: toClipId('clip2'), + assetId: asset.id, + trackId, + timelineStart: toFrame(400), + timelineEnd: toFrame(700), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const track = createTrack({ + id: trackId, + name: 'V1', + type: 'video', + clips: [clip1, clip2], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'KeyboardTest', + fps: FPS, + duration: toFrame(DURATION), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +function makeKeyEvent( + code: string, + mods?: { shift?: boolean; alt?: boolean; meta?: boolean; ctrl?: boolean; repeat?: boolean }, +): TimelineKeyEvent { + const ev: TimelineKeyEvent = { + code, + key: code.replace('Key', '').toLowerCase(), + shiftKey: mods?.shift ?? false, + altKey: mods?.alt ?? false, + metaKey: mods?.meta ?? false, + ctrlKey: mods?.ctrl ?? false, + }; + if (mods?.repeat !== undefined) (ev as { repeat?: boolean }).repeat = mods.repeat; + return ev; +} + +const state = makeKeyboardFixtureState(); +const DIMS = { width: 1920, height: 1080 }; +const mockPipeline: PipelineConfig = { + videoDecoder: async (req) => ({ + clipId: req.clipId, + mediaFrame: req.mediaFrame, + width: 1920, + height: 1080, + bitmap: null, + }), + compositor: async (req) => ({ timelineFrame: req.timelineFrame, bitmap: null }), +}; + +describe('Phase 6 — Keyboard (play-pause / stop)', () => { + it('1. Space while paused → play', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + expect(engine.getState().isPlaying).toBe(false); + handler.handleKeyDown(makeKeyEvent('Space')); + expect(engine.getState().isPlaying).toBe(true); + engine.destroy(); + }); + + it('2. Space while playing → pause', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('Space')); + expect(engine.getState().isPlaying).toBe(true); + handler.handleKeyDown(makeKeyEvent('Space')); + expect(engine.getState().isPlaying).toBe(false); + engine.destroy(); + }); + + it('3. K (jog-stop) while playing → pause + rate 1.0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyL')); + expect(engine.getState().isPlaying).toBe(true); + expect(engine.getState().playbackRate).toBe(1.0); + handler.handleKeyDown(makeKeyEvent('KeyK')); + expect(engine.getState().isPlaying).toBe(false); + expect(engine.getState().playbackRate).toBe(1.0); + engine.destroy(); + }); + + it('4. stop action → pause + frame 0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine, { + bindings: [ + { code: 'KeyS', action: 'stop' }, + { code: 'Space', action: 'play-pause' }, + ], + }); + engine.seekTo(toFrame(100)); + handler.handleKeyDown(makeKeyEvent('Space')); + handler.handleKeyDown(makeKeyEvent('KeyS')); + expect(engine.getState().isPlaying).toBe(false); + expect(engine.getState().currentFrame).toBe(toFrame(0)); + engine.destroy(); + }); +}); + +describe('Phase 6 — Keyboard (step-forward / step-backward)', () => { + it('5. ArrowRight → frame advances by 1', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(50)); + handler.handleKeyDown(makeKeyEvent('ArrowRight')); + expect(engine.getState().currentFrame).toBe(toFrame(51)); + engine.destroy(); + }); + + it('6. ArrowLeft → frame goes back by 1', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(50)); + handler.handleKeyDown(makeKeyEvent('ArrowLeft')); + expect(engine.getState().currentFrame).toBe(toFrame(49)); + engine.destroy(); + }); + + it('7. ArrowRight at last frame → clamped, no error', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(899)); + handler.handleKeyDown(makeKeyEvent('ArrowRight')); + expect(engine.getState().currentFrame).toBe(toFrame(899)); + engine.destroy(); + }); + + it('8. ArrowLeft at frame 0 → clamped, no error', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('ArrowLeft')); + expect(engine.getState().currentFrame).toBe(toFrame(0)); + engine.destroy(); + }); + + it('9. step-forward pauses if playing', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('Space')); + expect(engine.getState().isPlaying).toBe(true); + handler.handleKeyDown(makeKeyEvent('ArrowRight')); + expect(engine.getState().isPlaying).toBe(false); + expect(engine.getState().currentFrame).toBe(toFrame(1)); + engine.destroy(); + }); +}); + +describe('Phase 6 — Keyboard (J/K/L jog)', () => { + it('10. L once → rate 1.0, playing', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyL')); + expect(engine.getState().playbackRate).toBe(1.0); + expect(engine.getState().isPlaying).toBe(true); + engine.destroy(); + }); + + it('11. L twice → rate 2.0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyL')); + handler.handleKeyDown(makeKeyEvent('KeyL')); + expect(engine.getState().playbackRate).toBe(2.0); + engine.destroy(); + }); + + it('12. L three times → rate 4.0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyL')); + handler.handleKeyDown(makeKeyEvent('KeyL')); + handler.handleKeyDown(makeKeyEvent('KeyL')); + expect(engine.getState().playbackRate).toBe(4.0); + engine.destroy(); + }); + + it('13. L four times → still rate 4.0 (capped at 3)', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyL')); + handler.handleKeyDown(makeKeyEvent('KeyL')); + handler.handleKeyDown(makeKeyEvent('KeyL')); + handler.handleKeyDown(makeKeyEvent('KeyL')); + expect(engine.getState().playbackRate).toBe(4.0); + engine.destroy(); + }); + + it('14. J once → rate -1.0, playing', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyJ')); + expect(engine.getState().playbackRate).toBe(-1.0); + expect(engine.getState().isPlaying).toBe(true); + engine.destroy(); + }); + + it('15. J twice → rate -2.0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyJ')); + handler.handleKeyDown(makeKeyEvent('KeyJ')); + expect(engine.getState().playbackRate).toBe(-2.0); + engine.destroy(); + }); + + it('16. K resets jogLevel → pause, rate back to 1.0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyL')); + handler.handleKeyDown(makeKeyEvent('KeyL')); + expect(engine.getState().playbackRate).toBe(2.0); + handler.handleKeyDown(makeKeyEvent('KeyK')); + expect(engine.getState().isPlaying).toBe(false); + expect(engine.getState().playbackRate).toBe(1.0); + engine.destroy(); + }); + + it('17. J after L (mixed) → jogLevel goes from 2 to 1, rate 1.0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('KeyL')); + handler.handleKeyDown(makeKeyEvent('KeyL')); + expect(engine.getState().playbackRate).toBe(2.0); + handler.handleKeyDown(makeKeyEvent('KeyJ')); + expect(engine.getState().playbackRate).toBe(1.0); + engine.destroy(); + }); +}); + +describe('Phase 6 — Keyboard (seek actions)', () => { + it('18. Home → seekToStart (frame 0)', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(500)); + handler.handleKeyDown(makeKeyEvent('Home')); + expect(engine.getState().currentFrame).toBe(toFrame(0)); + engine.destroy(); + }); + + it('19. End → seekToEnd (frame 899)', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown(makeKeyEvent('End')); + expect(engine.getState().currentFrame).toBe(toFrame(899)); + engine.destroy(); + }); + + it('20. Shift+ArrowRight → seekToNextClipBoundary', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(0)); + handler.handleKeyDown(makeKeyEvent('ArrowRight', { shift: true })); + expect(engine.getState().currentFrame).toBe(toFrame(300)); + engine.destroy(); + }); + + it('21. Shift+ArrowLeft → seekToPrevClipBoundary', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(500)); + handler.handleKeyDown(makeKeyEvent('ArrowLeft', { shift: true })); + expect(engine.getState().currentFrame).toBe(toFrame(400)); + engine.destroy(); + }); + + it('22. Alt+ArrowRight (no markers in fixture) → no crash, no seek', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(0)); + handler.handleKeyDown(makeKeyEvent('ArrowRight', { alt: true })); + expect(engine.getState().currentFrame).toBe(toFrame(0)); + engine.destroy(); + }); + + it('23. Alt+ArrowLeft → seekToPrevMarker (none → no move)', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(500)); + handler.handleKeyDown(makeKeyEvent('ArrowLeft', { alt: true })); + expect(engine.getState().currentFrame).toBe(toFrame(500)); + engine.destroy(); + }); +}); + +describe('Phase 6 — Keyboard (key binding match)', () => { + it('24. Unknown key code → returns false', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + const handled = handler.handleKeyDown(makeKeyEvent('KeyX')); + expect(handled).toBe(false); + engine.destroy(); + }); + + it('25. Space → returns true (handled)', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + const handled = handler.handleKeyDown(makeKeyEvent('Space')); + expect(handled).toBe(true); + engine.destroy(); + }); + + it('26. Shift+ArrowRight matches only shift binding, not plain ArrowRight', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + engine.seekTo(toFrame(10)); + handler.handleKeyDown(makeKeyEvent('ArrowRight', { shift: true })); + expect(engine.getState().currentFrame).toBe(toFrame(300)); + engine.destroy(); + }); +}); + +describe('Phase 6 — Keyboard (mark-in / mark-out callbacks)', () => { + it('27. I key fires onMarkIn with currentFrame', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + let captured: number | null = null; + const handler = new KeyboardHandler(engine, { + onMarkIn: (frame) => { captured = frame as number; }, + }); + engine.seekTo(toFrame(42)); + handler.handleKeyDown(makeKeyEvent('KeyI')); + expect(captured).toBe(42); + engine.destroy(); + }); + + it('28. O key fires onMarkOut with currentFrame', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + let captured: number | null = null; + const handler = new KeyboardHandler(engine, { + onMarkOut: (frame) => { captured = frame as number; }, + }); + engine.seekTo(toFrame(99)); + handler.handleKeyDown(makeKeyEvent('KeyO')); + expect(captured).toBe(99); + engine.destroy(); + }); + + it('29. No callback registered → no error (silent no-op)', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine); + expect(() => handler.handleKeyDown(makeKeyEvent('KeyI'))).not.toThrow(); + expect(() => handler.handleKeyDown(makeKeyEvent('KeyO'))).not.toThrow(); + engine.destroy(); + }); +}); diff --git a/packages/core/src/__tests__/phase6-loop.test.ts b/packages/core/src/__tests__/phase6-loop.test.ts new file mode 100644 index 0000000..a53bc67 --- /dev/null +++ b/packages/core/src/__tests__/phase6-loop.test.ts @@ -0,0 +1,308 @@ +/** + * Phase 6 Step 5 — Playback loop region and preroll/postroll + * + * All tests use createTestClock(). Fixture: 30fps, durationFrames 900. + * Loop region: startFrame 100, endFrame 200. + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toTimecode } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { LoopRegion } from '../types/playhead'; +import { PlayheadController } from '../engine/playhead-controller'; +import { PlaybackEngine } from '../engine/playback-engine'; +import { KeyboardHandler } from '../engine/keyboard-handler'; +import { createTestClock } from '../engine/clock'; +import type { PipelineConfig } from '../types/pipeline'; + +const FPS = 30; +const DURATION = 900; +const LOOP_REGION: LoopRegion = { startFrame: toFrame(100), endFrame: toFrame(200) }; + +function makeLoopFixtureState(): TimelineState { + const trackId = toTrackId('v1'); + const asset = createAsset({ + id: 'a1', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(2000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const clip = createClip({ + id: toClipId('c1'), + assetId: asset.id, + trackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(500), + mediaIn: toFrame(0), + mediaOut: toFrame(500), + }); + const track = createTrack({ id: trackId, name: 'V1', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', + name: 'LoopTest', + fps: FPS, + duration: toFrame(DURATION), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +const state = makeLoopFixtureState(); +const DIMS = { width: 1920, height: 1080 }; +const mockPipeline: PipelineConfig = { + videoDecoder: async (r) => ({ ...r, width: 1920, height: 1080, bitmap: null }), + compositor: async (r) => ({ timelineFrame: r.timelineFrame, bitmap: null }), +}; + +describe('Phase 6 — Loop (setLoopRegion)', () => { + it('1. setLoopRegion sets loopRegion on state', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.setLoopRegion(LOOP_REGION); + expect(c.getState().loopRegion).toEqual(LOOP_REGION); + }); + + it('2. setLoopRegion(null) clears loop region', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.setLoopRegion(LOOP_REGION); + c.setLoopRegion(null); + expect(c.getState().loopRegion).toBeNull(); + }); + + it('3. setLoopRegion with startFrame >= endFrame throws', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + expect(() => + c.setLoopRegion({ startFrame: toFrame(200), endFrame: toFrame(100) }), + ).toThrow(); + expect(() => + c.setLoopRegion({ startFrame: toFrame(100), endFrame: toFrame(100) }), + ).toThrow(); + }); +}); + +describe('Phase 6 — Loop (setPreroll / setPostroll)', () => { + it('4. setPreroll(10) updates prerollFrames', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.setPreroll(10); + expect(c.getState().prerollFrames).toBe(10); + }); + + it('5. setPostroll(5) updates postrollFrames', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.setPostroll(5); + expect(c.getState().postrollFrames).toBe(5); + }); + + it('6. setPreroll(-1) throws', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + expect(() => c.setPreroll(-1)).toThrow(); + }); + + it('7. setPostroll(-1) throws', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + expect(() => c.setPostroll(-1)).toThrow(); + }); +}); + +describe('Phase 6 — Loop (loop behavior)', () => { + it('8. Play from frame 100 with loop 100–200: after advancing to frame 200, wraps to frame 100', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.setLoopRegion(LOOP_REGION); + c.seekTo(toFrame(100)); + c.play(); + tick(1000 / 30); + for (let i = 0; i < 100; i++) tick(1000 / 30); + expect(c.getState().currentFrame).toBe(toFrame(100)); + }); + + it('9. Wrap emits "loop-point" event', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + const events: Array<{ type: string }> = []; + c.on((e) => events.push({ type: e.type })); + c.setLoopRegion(LOOP_REGION); + c.seekTo(toFrame(100)); + c.play(); + tick(1000 / 30); + for (let i = 0; i < 100; i++) tick(1000 / 30); + expect(events.some((e) => e.type === 'loop-point')).toBe(true); + }); + + it('10. After wrap, does NOT emit "ended"', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + const events: Array<{ type: string }> = []; + c.on((e) => events.push({ type: e.type })); + c.setLoopRegion(LOOP_REGION); + c.seekTo(toFrame(100)); + c.play(); + tick(1000 / 30); + for (let i = 0; i < 100; i++) tick(1000 / 30); + expect(events.some((e) => e.type === 'ended')).toBe(false); + }); + + it('11. Without loop region, reaching end emits "ended"', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + const events: Array<{ type: string }> = []; + c.on((e) => events.push({ type: e.type })); + c.seekTo(toFrame(895)); + c.play(); + tick(1000 / 30); + for (let i = 0; i < 10; i++) tick(1000 / 30); + expect(events.some((e) => e.type === 'ended')).toBe(true); + }); + + it('12. Loop with postroll: region 100–200, postroll 10 → wraps at frame 210', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.setLoopRegion(LOOP_REGION); + c.setPostroll(10); + c.seekTo(toFrame(100)); + c.play(); + tick(1000 / 30); + for (let i = 0; i < 110; i++) tick(1000 / 30); + expect(c.getState().currentFrame).toBe(toFrame(100)); + }); + + it('13. Loop with preroll: region 100–200, preroll 15 → after wrap, starts at frame 85', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.setLoopRegion(LOOP_REGION); + c.setPreroll(15); + c.seekTo(toFrame(100)); + c.play(); // with preroll, play() seeks to 85 before starting + expect(c.getState().currentFrame).toBe(toFrame(85)); + tick(1000 / 30); // init + // Advance 115 frames: 85 + 115 = 200, then wrap to 85 + for (let i = 0; i < 115; i++) tick(1000 / 30); + expect(c.getState().currentFrame).toBe(toFrame(85)); + }); +}); + +describe('Phase 6 — Loop (play with preroll)', () => { + it('14. play() with loop region + preroll 15 seeks to startFrame - 15 before playing', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.setLoopRegion(LOOP_REGION); + c.setPreroll(15); + c.seekTo(toFrame(50)); + c.play(); + expect(c.getState().currentFrame).toBe(toFrame(85)); + }); + + it('15. play() with no loop region: starts from currentFrame unchanged', () => { + const { clock } = createTestClock(); + const c = new PlayheadController({ durationFrames: DURATION, fps: FPS }, clock); + c.seekTo(toFrame(50)); + c.play(); + expect(c.getState().currentFrame).toBe(toFrame(50)); + }); +}); + +describe('Phase 6 — Loop (toggle-loop keyboard)', () => { + it('16. Q key with inPoint/outPoint set → sets loop region to in/out range', () => { + const { clock } = createTestClock(); + const base = makeLoopFixtureState(); + const timelineWithInOut = createTimeline({ + id: base.timeline.id, + name: base.timeline.name, + fps: base.timeline.fps, + duration: base.timeline.duration, + startTimecode: base.timeline.startTimecode, + tracks: base.timeline.tracks, + inPoint: toFrame(50), + outPoint: toFrame(150), + }); + const state2: TimelineState = { ...base, timeline: timelineWithInOut }; + const engine = new PlaybackEngine(state2, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine, { + getTimelineState: () => engine.getCurrentTimelineState(), + }); + handler.handleKeyDown({ + code: 'KeyQ', + key: 'q', + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + }); + expect(engine.getState().loopRegion).toEqual({ + startFrame: toFrame(50), + endFrame: toFrame(150), + }); + engine.destroy(); + }); + + it('17. Q key with loop active → clears loop region', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.setLoopRegion(LOOP_REGION); + const handler = new KeyboardHandler(engine); + handler.handleKeyDown({ + code: 'KeyQ', + key: 'q', + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + }); + expect(engine.getState().loopRegion).toBeNull(); + engine.destroy(); + }); + + it('18. Q key with no in/out and no loop → no-op', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const handler = new KeyboardHandler(engine, { + getTimelineState: () => engine.getCurrentTimelineState(), + }); + handler.handleKeyDown({ + code: 'KeyQ', + key: 'q', + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + }); + expect(engine.getState().loopRegion).toBeNull(); + engine.destroy(); + }); +}); + +describe('Phase 6 — Loop (PlaybackEngine delegation)', () => { + it('19. setLoopRegion delegates to controller', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.setLoopRegion(LOOP_REGION); + expect(engine.getState().loopRegion).toEqual(LOOP_REGION); + engine.destroy(); + }); + + it('20. setPreroll / setPostroll delegate to controller', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.setPreroll(10); + engine.setPostroll(5); + expect(engine.getState().prerollFrames).toBe(10); + expect(engine.getState().postrollFrames).toBe(5); + engine.destroy(); + }); +}); diff --git a/packages/core/src/__tests__/phase6-pipeline.test.ts b/packages/core/src/__tests__/phase6-pipeline.test.ts new file mode 100644 index 0000000..4538f40 --- /dev/null +++ b/packages/core/src/__tests__/phase6-pipeline.test.ts @@ -0,0 +1,298 @@ +/** + * Phase 6 Step 2 — Pipeline contracts and frame resolver + * + * Uses mock pipeline (no real decoding). Fixture: 30fps, two video tracks, + * three clips. + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toTimecode } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { PipelineConfig, CompositeResult, VideoFrameResult } from '../types/pipeline'; +import { resolveFrame, getClipsAtFrame, mediaFrameForClip } from '../engine/frame-resolver'; +import { PlaybackEngine } from '../engine/playback-engine'; +import { createTestClock } from '../engine/clock'; + +const FPS = 30; + +function makeFixtureState(): TimelineState { + const videoTrackId = toTrackId('videoTrack'); + const videoTrack2Id = toTrackId('videoTrack2'); + const asset = createAsset({ + id: 'asset1', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(10000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: toClipId('clip1'), + assetId: asset.id, + trackId: videoTrackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const clip2 = createClip({ + id: toClipId('clip2'), + assetId: asset.id, + trackId: videoTrackId, + timelineStart: toFrame(300), + timelineEnd: toFrame(600), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const clip3 = createClip({ + id: toClipId('clip3'), + assetId: asset.id, + trackId: videoTrack2Id, + timelineStart: toFrame(0), + timelineEnd: toFrame(600), + mediaIn: toFrame(100), + mediaOut: toFrame(700), + }); + const videoTrack = createTrack({ + id: videoTrackId, + name: 'V1', + type: 'video', + clips: [clip1, clip2], + }); + const videoTrack2 = createTrack({ + id: videoTrack2Id, + name: 'V2', + type: 'video', + clips: [clip3], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'Test', + fps: FPS, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [videoTrack, videoTrack2], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +const DIMS = { width: 1920, height: 1080 }; + +describe('Phase 6 — Pipeline', () => { + const state = makeFixtureState(); + + // resolveFrame + it('1. At frame 0: returns 2 layers (clip1 + clip3)', () => { + const req = resolveFrame(state, toFrame(0), 'full', DIMS); + expect(req.layers).toHaveLength(2); + expect(req.layers.map((l) => l.clipId)).toContain(toClipId('clip1')); + expect(req.layers.map((l) => l.clipId)).toContain(toClipId('clip3')); + }); + + it('2. At frame 300: returns 2 layers (clip2 + clip3)', () => { + const req = resolveFrame(state, toFrame(300), 'full', DIMS); + expect(req.layers).toHaveLength(2); + expect(req.layers.map((l) => l.clipId)).toContain(toClipId('clip2')); + expect(req.layers.map((l) => l.clipId)).toContain(toClipId('clip3')); + }); + + it('3. At frame 600: returns 0 layers (past all clips)', () => { + const req = resolveFrame(state, toFrame(600), 'full', DIMS); + expect(req.layers).toHaveLength(0); + }); + + it('4. Layer trackIndex matches track order', () => { + const req = resolveFrame(state, toFrame(0), 'full', DIMS); + const byClip = Object.fromEntries(req.layers.map((l) => [l.clipId, l.trackIndex])); + expect(byClip[toClipId('clip1')]).toBe(0); + expect(byClip[toClipId('clip3')]).toBe(1); + }); + + it('5. Layer mediaFrame computed correctly for clip3 (frame 0 → mediaFrame 100, frame 50 → 150)', () => { + const req0 = resolveFrame(state, toFrame(0), 'full', DIMS); + const layer3At0 = req0.layers.find((l) => l.clipId === toClipId('clip3')); + expect(layer3At0!.mediaFrame).toBe(toFrame(100)); + + const req50 = resolveFrame(state, toFrame(50), 'full', DIMS); + const layer3At50 = req50.layers.find((l) => l.clipId === toClipId('clip3')); + expect(layer3At50!.mediaFrame).toBe(toFrame(150)); + }); + + it('6. Layer transform defaults to DEFAULT_CLIP_TRANSFORM when clip has no transform', () => { + const req = resolveFrame(state, toFrame(0), 'full', DIMS); + const layer = req.layers[0]!; + expect(layer.transform).toBeDefined(); + expect(layer.transform.opacity.value).toBe(1); + expect(layer.transform.scaleX.value).toBe(1); + }); + + it('7. Layer blendMode defaults to "normal"', () => { + const req = resolveFrame(state, toFrame(0), 'full', DIMS); + req.layers.forEach((l) => expect(l.blendMode).toBe('normal')); + }); + + it('8. Layer opacity defaults to 1', () => { + const req = resolveFrame(state, toFrame(0), 'full', DIMS); + req.layers.forEach((l) => expect(l.opacity).toBe(1)); + }); + + // getClipsAtFrame + it('9. Returns correct clips at frame 150', () => { + const pairs = getClipsAtFrame(state, toFrame(150)); + expect(pairs).toHaveLength(2); + const clipIds = pairs.map((p) => p.clip.id); + expect(clipIds).toContain(toClipId('clip1')); + expect(clipIds).toContain(toClipId('clip3')); + }); + + it('10. Returns empty array past end of all clips', () => { + const pairs = getClipsAtFrame(state, toFrame(700)); + expect(pairs).toHaveLength(0); + }); + + // mediaFrameForClip + it('11. Correct mediaFrame with mediaStart = 0', () => { + const clip1 = state.timeline.tracks[0]!.clips[0]!; + expect(mediaFrameForClip(clip1, toFrame(0))).toBe(toFrame(0)); + expect(mediaFrameForClip(clip1, toFrame(100))).toBe(toFrame(100)); + }); + + it('12. Correct mediaFrame with mediaStart = 100', () => { + const clip3 = state.timeline.tracks[1]!.clips[0]!; + expect(mediaFrameForClip(clip3, toFrame(0))).toBe(toFrame(100)); + expect(mediaFrameForClip(clip3, toFrame(50))).toBe(toFrame(150)); + }); + + // PlaybackEngine + const mockVideoDecoder = async (req: { + clipId: unknown; + mediaFrame: unknown; + }): Promise => ({ + clipId: req.clipId as VideoFrameResult['clipId'], + mediaFrame: req.mediaFrame as VideoFrameResult['mediaFrame'], + width: 1920, + height: 1080, + bitmap: null, + }); + + const mockCompositor = async (req: { + timelineFrame: unknown; + }): Promise => ({ + timelineFrame: req.timelineFrame as CompositeResult['timelineFrame'], + bitmap: null, + }); + + const mockPipeline: PipelineConfig = { + videoDecoder: mockVideoDecoder, + compositor: mockCompositor, + }; + + it('13. play() / pause() delegate to controller', () => { + const { clock, tick } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + expect(engine.getState().isPlaying).toBe(false); + engine.play(); + expect(engine.getState().isPlaying).toBe(true); + tick(1000 / 30); + engine.pause(); + expect(engine.getState().isPlaying).toBe(false); + engine.destroy(); + }); + + it('14. seekTo() delegates to controller', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.seekTo(toFrame(200)); + expect(engine.getState().currentFrame).toBe(toFrame(200)); + engine.destroy(); + }); + + it('15. getState() returns controller state', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const s = engine.getState(); + expect(s.currentFrame).toBeDefined(); + expect(s.isPlaying).toBe(false); + expect(s.fps).toBe(FPS); + engine.destroy(); + }); + + it('16. updateState() updates internal state (resolveFrame uses new state after update)', async () => { + const { clock } = createTestClock(); + const layersSeen: number[] = []; + const pipeline: PipelineConfig = { + videoDecoder: mockVideoDecoder, + compositor: async (req) => { + layersSeen.push(req.layers.length); + return mockCompositor(req); + }, + }; + const engine = new PlaybackEngine(state, pipeline, DIMS, clock); + await engine.renderFrame(toFrame(0)); + expect(layersSeen[0]).toBe(2); + const state2 = makeFixtureState(); + engine.updateState(state2); + await engine.renderFrame(toFrame(0)); + expect(layersSeen[1]).toBe(2); + engine.destroy(); + }); + + it('17. renderFrame() calls videoDecoder for each layer', async () => { + const { clock } = createTestClock(); + const decodeCalls: Array<{ clipId: string; mediaFrame: number }> = []; + const pipeline: PipelineConfig = { + videoDecoder: async (req) => { + decodeCalls.push({ + clipId: req.clipId as string, + mediaFrame: req.mediaFrame as number, + }); + return mockVideoDecoder(req); + }, + compositor: mockCompositor, + }; + const engine = new PlaybackEngine(state, pipeline, DIMS, clock); + await engine.renderFrame(toFrame(0)); + expect(decodeCalls).toHaveLength(2); + engine.destroy(); + }); + + it('18. renderFrame() calls compositor with layers', async () => { + const { clock } = createTestClock(); + let compositorLayers: unknown[] = []; + const pipeline: PipelineConfig = { + videoDecoder: mockVideoDecoder, + compositor: async (req) => { + compositorLayers = [...req.layers]; + return mockCompositor(req); + }, + }; + const engine = new PlaybackEngine(state, pipeline, DIMS, clock); + await engine.renderFrame(toFrame(0)); + expect(compositorLayers).toHaveLength(2); + engine.destroy(); + }); + + it('19. renderFrame() returns CompositeResult', async () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const result = await engine.renderFrame(toFrame(100)); + expect(result.timelineFrame).toBe(toFrame(100)); + expect(result.bitmap).toBe(null); + engine.destroy(); + }); + + it('20. destroy() cleans up controller', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.play(); + engine.destroy(); + expect(engine.getState().isPlaying).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/phase6-playhead.test.ts b/packages/core/src/__tests__/phase6-playhead.test.ts new file mode 100644 index 0000000..f7192bb --- /dev/null +++ b/packages/core/src/__tests__/phase6-playhead.test.ts @@ -0,0 +1,268 @@ +/** + * Phase 6 Step 1 — PlayheadController + * + * All tests use createTestClock(). Fixture: durationFrames 900, fps 30. + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTestClock } from '../engine/clock'; +import { PlayheadController } from '../engine/playhead-controller'; + +const FIXTURE = { durationFrames: 900, fps: 30 }; + +describe('Phase 6 — PlayheadController', () => { + // Construction + it('1. Initial state: frame 0, not playing, rate 1.0, quality "full"', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + const s = c.getState(); + expect(s.currentFrame).toBe(toFrame(0)); + expect(s.isPlaying).toBe(false); + expect(s.playbackRate).toBe(1.0); + expect(s.quality).toBe('full'); + expect(s.durationFrames).toBe(900); + expect(s.fps).toBe(30); + }); + + // play / pause + it('2. play() sets isPlaying: true', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.play(); + expect(c.getState().isPlaying).toBe(true); + }); + + it('3. play() emits "play" event', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + const events: Array<{ type: string; frame: number }> = []; + c.on((e) => events.push({ type: e.type, frame: e.frame as number })); + c.play(); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe('play'); + expect(events[0]!.frame).toBe(0); + }); + + it('4. play() twice is a no-op (no double rAF)', () => { + const { clock, getCallbacks } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.play(); + const count1 = getCallbacks().length; + c.play(); + const count2 = getCallbacks().length; + expect(count2).toBe(count1); + }); + + it('5. pause() sets isPlaying: false', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.play(); + tick(1000 / 30); + c.pause(); + expect(c.getState().isPlaying).toBe(false); + }); + + it('6. pause() emits "pause" event', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + const events: Array<{ type: string }> = []; + c.on((e) => events.push({ type: e.type })); + c.play(); + tick(1000 / 30); + c.pause(); + expect(events.some((e) => e.type === 'pause')).toBe(true); + }); + + it('7. pause() when not playing is a no-op', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.pause(); + expect(c.getState().isPlaying).toBe(false); + }); + + // seekTo + it('8. seekTo(frame 300) updates currentFrame', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.seekTo(toFrame(300)); + expect(c.getState().currentFrame).toBe(toFrame(300)); + }); + + it('9. seekTo emits "seek" event', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + const events: Array<{ type: string; frame: number }> = []; + c.on((e) => events.push({ type: e.type, frame: e.frame as number })); + c.seekTo(toFrame(100)); + expect(events).toHaveLength(1); + expect(events[0]!.type).toBe('seek'); + expect(events[0]!.frame).toBe(100); + }); + + it('10. seekTo clamps to 0 (negative input)', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.seekTo(toFrame(-100)); + expect(c.getState().currentFrame).toBe(toFrame(0)); + }); + + it('11. seekTo clamps to durationFrames - 1', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.seekTo(toFrame(9999)); + expect(c.getState().currentFrame).toBe(toFrame(899)); + }); + + // setPlaybackRate + it('12. setPlaybackRate(2.0) updates state', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.setPlaybackRate(2.0); + expect(c.getState().playbackRate).toBe(2.0); + }); + + it('13. setPlaybackRate resets accumulator (tick after rate change does not jump)', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.play(); + tick(1000 / 30); // first frame init + tick(1000 / 30); // advance 1 frame → frame 1 + expect(c.getState().currentFrame).toBe(toFrame(1)); + c.setPlaybackRate(2.0); + tick(1000 / 30); // first frame after rate change (reset), no advance + tick(1000 / 30); // advance 2 frames at 2x → frame 3 + expect(c.getState().currentFrame).toBe(toFrame(3)); + }); + + // Frame advance + it('14. play() + tick(1000/30) advances exactly 1 frame at 1.0x rate, 30fps', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.play(); + tick(1000 / 30); // init + tick(1000 / 30); // 1 frame + expect(c.getState().currentFrame).toBe(toFrame(1)); + }); + + it('15. play() + tick(1000/30 * 30) advances 30 frames', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.play(); + tick(1000 / 30); // init + for (let i = 0; i < 30; i++) tick(1000 / 30); + expect(c.getState().currentFrame).toBe(toFrame(30)); + }); + + it('16. play() + tick(1000/30) at 2.0x rate advances 2 frames', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.setPlaybackRate(2.0); + c.play(); + tick(1000 / 30); // init + tick(1000 / 30); // 2 frames at 2x + expect(c.getState().currentFrame).toBe(toFrame(2)); + }); + + // Ended + it('17. Advancing past durationFrames emits "ended" and sets isPlaying: false', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + const events: Array<{ type: string }> = []; + c.on((e) => events.push({ type: e.type })); + c.play(); + tick(1000 / 30); // init + for (let i = 0; i < 950; i++) tick(1000 / 30); // advance past 900 + expect(events.some((e) => e.type === 'ended')).toBe(true); + expect(c.getState().isPlaying).toBe(false); + expect(c.getState().currentFrame).toBe(toFrame(899)); + }); + + it('18. Reverse (rate -1.0) hitting frame 0 emits "ended"', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + const events: Array<{ type: string }> = []; + c.on((e) => events.push({ type: e.type })); + c.seekTo(toFrame(5)); + c.setPlaybackRate(-1.0); + c.play(); + tick(1000 / 30); // init + for (let i = 0; i < 10; i++) tick(1000 / 30); // 10 frames backward → hit 0 + expect(events.some((e) => e.type === 'ended')).toBe(true); + expect(c.getState().currentFrame).toBe(toFrame(0)); + expect(c.getState().isPlaying).toBe(false); + }); + + // Frame drop detection + it('19. tick(200ms) at 30fps emits "frame-dropped"', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + const events: Array<{ type: string; data?: unknown }> = []; + c.on((e) => events.push({ type: e.type, data: e.data })); + c.play(); + tick(1000 / 30); // init + tick(200); // ~6 frames + expect(events.some((e) => e.type === 'frame-dropped')).toBe(true); + const drop = events.find((e) => e.type === 'frame-dropped'); + expect(drop?.data).toEqual({ dropped: expect.any(Number) }); + }); + + it('20. After drop, only 1 frame actually advances', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.play(); + tick(1000 / 30); // init + const frameBefore = c.getState().currentFrame as number; + tick(200); // would be ~6 frames; we cap to 1 + const frameAfter = c.getState().currentFrame as number; + expect(frameAfter - frameBefore).toBe(1); + }); + + // Event bus + it('21. on() registers listener', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + let received = false; + c.on(() => { received = true; }); + c.play(); + expect(received).toBe(true); + }); + + it('22. Unsubscribe function removes listener', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + let count = 0; + const unsub = c.on(() => { count++; }); + c.play(); + expect(count).toBe(1); + unsub(); + c.pause(); + c.play(); + expect(count).toBe(1); + }); + + it('23. destroy() clears all listeners', () => { + const { clock, tick } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + let count = 0; + c.on(() => { count++; }); + c.play(); + tick(1000 / 30); + c.destroy(); + expect(count).toBeGreaterThan(0); + const before = count; + c.play(); + tick(1000 / 30); + expect(count).toBe(before); + }); + + // setDuration + it('24. setDuration clamps currentFrame if beyond new duration', () => { + const { clock } = createTestClock(); + const c = new PlayheadController(FIXTURE, clock); + c.seekTo(toFrame(500)); + c.setDuration(100); + expect(c.getState().currentFrame).toBe(toFrame(99)); + expect(c.getState().durationFrames).toBe(100); + }); +}); diff --git a/packages/core/src/__tests__/phase6-seek.test.ts b/packages/core/src/__tests__/phase6-seek.test.ts new file mode 100644 index 0000000..c2353a5 --- /dev/null +++ b/packages/core/src/__tests__/phase6-seek.test.ts @@ -0,0 +1,311 @@ +/** + * Phase 6 Step 3 — Seek API and clip boundary navigation + * + * Pure function tests use no clock. PlaybackEngine tests use createTestClock(). + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toTimecode } from '../types/frame'; +import { toMarkerId } from '../types/marker'; +import type { TimelineState } from '../types/state'; +import type { Marker } from '../types/marker'; +import { + findNextClipBoundary, + findPrevClipBoundary, + findNextMarker, + findPrevMarker, + findClipById, +} from '../engine/frame-resolver'; +import { PlaybackEngine } from '../engine/playback-engine'; +import { createTestClock } from '../engine/clock'; +import type { PipelineConfig } from '../types/pipeline'; + +const FPS = 30; +const DURATION_FRAMES = 1800; + +function makeSeekFixtureState(): TimelineState { + const videoTrackId = toTrackId('videoTrack'); + const audioTrackId = toTrackId('audioTrack'); + const assetV = createAsset({ + id: 'assetV', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(5000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const assetA = createAsset({ + id: 'assetA', + name: 'A', + mediaType: 'audio', + filePath: '/a.wav', + intrinsicDuration: toFrame(5000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: toClipId('clip1'), + assetId: assetV.id, + trackId: videoTrackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const clip2 = createClip({ + id: toClipId('clip2'), + assetId: assetV.id, + trackId: videoTrackId, + timelineStart: toFrame(400), + timelineEnd: toFrame(700), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const clip3 = createClip({ + id: toClipId('clip3'), + assetId: assetV.id, + trackId: videoTrackId, + timelineStart: toFrame(800), + timelineEnd: toFrame(1100), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const clip4 = createClip({ + id: toClipId('clip4'), + assetId: assetA.id, + trackId: audioTrackId, + timelineStart: toFrame(100), + timelineEnd: toFrame(500), + mediaIn: toFrame(0), + mediaOut: toFrame(400), + }); + const videoTrack = createTrack({ + id: videoTrackId, + name: 'V1', + type: 'video', + clips: [clip1, clip2, clip3], + }); + const audioTrack = createTrack({ + id: audioTrackId, + name: 'A1', + type: 'audio', + clips: [clip4], + }); + const markerA: Marker = { + type: 'point', + id: toMarkerId('markerA'), + frame: toFrame(150), + label: 'A', + color: 'red', + scope: 'global', + linkedClipId: null, + }; + const markerB: Marker = { + type: 'point', + id: toMarkerId('markerB'), + frame: toFrame(600), + label: 'B', + color: 'blue', + scope: 'global', + linkedClipId: null, + }; + const markerC: Marker = { + type: 'point', + id: toMarkerId('markerC'), + frame: toFrame(900), + label: 'C', + color: 'green', + scope: 'global', + linkedClipId: null, + }; + const timeline = createTimeline({ + id: 'tl', + name: 'SeekTest', + fps: FPS, + duration: toFrame(DURATION_FRAMES), + startTimecode: toTimecode('00:00:00:00'), + tracks: [videoTrack, audioTrack], + markers: [markerA, markerB, markerC], + }); + return createTimelineState({ + timeline, + assetRegistry: new Map([ + [assetV.id, assetV], + [assetA.id, assetA], + ]), + }); +} + +const state = makeSeekFixtureState(); +const DIMS = { width: 1920, height: 1080 }; +const mockPipeline: PipelineConfig = { + videoDecoder: async (req) => ({ + clipId: req.clipId, + mediaFrame: req.mediaFrame, + width: 1920, + height: 1080, + bitmap: null, + }), + compositor: async (req) => ({ timelineFrame: req.timelineFrame, bitmap: null }), +}; + +describe('Phase 6 — Seek (findNextClipBoundary)', () => { + it('1. From frame 0 → 100 (clip4 start on audio track)', () => { + expect(findNextClipBoundary(state, toFrame(0))).toBe(toFrame(100)); + }); + it('2. From frame 99 → 100', () => { + expect(findNextClipBoundary(state, toFrame(99))).toBe(toFrame(100)); + }); + it('3. From frame 100 → 300 (clip1 end)', () => { + expect(findNextClipBoundary(state, toFrame(100))).toBe(toFrame(300)); + }); + it('4. From frame 299 → 300 (clip1 end)', () => { + expect(findNextClipBoundary(state, toFrame(299))).toBe(toFrame(300)); + }); + it('5. From frame 300 → 400 (clip2 start)', () => { + expect(findNextClipBoundary(state, toFrame(300))).toBe(toFrame(400)); + }); + it('6. From frame 1099 → 1100 (clip3 end)', () => { + expect(findNextClipBoundary(state, toFrame(1099))).toBe(toFrame(1100)); + }); + it('7. From frame 1100 → null (no more boundaries)', () => { + expect(findNextClipBoundary(state, toFrame(1100))).toBeNull(); + }); +}); + +describe('Phase 6 — Seek (findPrevClipBoundary)', () => { + it('8. From frame 300 → 100 (max boundary strictly before 300)', () => { + expect(findPrevClipBoundary(state, toFrame(300))).toBe(toFrame(100)); + }); + it('9. From frame 400 → 300', () => { + expect(findPrevClipBoundary(state, toFrame(400))).toBe(toFrame(300)); + }); + it('10. From frame 0 → null', () => { + expect(findPrevClipBoundary(state, toFrame(0))).toBeNull(); + }); + it('11. From frame 1 → 0 (clip1 start)', () => { + expect(findPrevClipBoundary(state, toFrame(1))).toBe(toFrame(0)); + }); +}); + +describe('Phase 6 — Seek (findNextMarker)', () => { + it('12. From frame 0 → markerA (anchor 150)', () => { + const m = findNextMarker(state, toFrame(0)); + expect(m).not.toBeNull(); + expect(m!.type).toBe('point'); + expect((m as { frame: number }).frame).toBe(150); + }); + it('13. From frame 150 → markerB (anchor 600)', () => { + const m = findNextMarker(state, toFrame(150)); + expect(m).not.toBeNull(); + expect((m as { frame: number }).frame).toBe(600); + }); + it('14. From frame 600 → markerC (anchor 900)', () => { + const m = findNextMarker(state, toFrame(600)); + expect(m).not.toBeNull(); + expect((m as { frame: number }).frame).toBe(900); + }); + it('15. From frame 900 → null', () => { + expect(findNextMarker(state, toFrame(900))).toBeNull(); + }); +}); + +describe('Phase 6 — Seek (findPrevMarker)', () => { + it('16. From frame 900 → markerB (anchor 600)', () => { + const m = findPrevMarker(state, toFrame(900)); + expect(m).not.toBeNull(); + expect((m as { frame: number }).frame).toBe(600); + }); + it('17. From frame 600 → markerA (anchor 150)', () => { + const m = findPrevMarker(state, toFrame(600)); + expect(m).not.toBeNull(); + expect((m as { frame: number }).frame).toBe(150); + }); + it('18. From frame 150 → null', () => { + expect(findPrevMarker(state, toFrame(150))).toBeNull(); + }); + it('19. From frame 1000 → markerC (anchor 900)', () => { + const m = findPrevMarker(state, toFrame(1000)); + expect(m).not.toBeNull(); + expect((m as { frame: number }).frame).toBe(900); + }); +}); + +describe('Phase 6 — Seek (findClipById)', () => { + it('20. Returns correct clip + track for valid id', () => { + const result = findClipById(state, toClipId('clip2')); + expect(result).not.toBeNull(); + expect(result!.clip.id).toBe(toClipId('clip2')); + expect(result!.track.id).toBe(toTrackId('videoTrack')); + expect(result!.trackIndex).toBe(0); + }); + it('21. Returns null for unknown id', () => { + expect(findClipById(state, toClipId('nonexistent'))).toBeNull(); + }); +}); + +describe('Phase 6 — Seek (PlaybackEngine)', () => { + it('22. seekToNextClipBoundary() advances to next boundary', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.seekTo(toFrame(0)); + engine.seekToNextClipBoundary(); + expect(engine.getState().currentFrame).toBe(toFrame(100)); + engine.destroy(); + }); + it('23. seekToPrevClipBoundary() seeks to prev boundary', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.seekTo(toFrame(400)); + engine.seekToPrevClipBoundary(); + expect(engine.getState().currentFrame).toBe(toFrame(300)); + engine.destroy(); + }); + it('24. seekToNextClipBoundary() at last boundary: no-op', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.seekTo(toFrame(1100)); + engine.seekToNextClipBoundary(); + expect(engine.getState().currentFrame).toBe(toFrame(1100)); + engine.destroy(); + }); + it('25. seekToNextMarker() seeks to markerA from frame 0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.seekTo(toFrame(0)); + engine.seekToNextMarker(); + expect(engine.getState().currentFrame).toBe(toFrame(150)); + engine.destroy(); + }); + it('26. seekToPrevMarker() seeks to markerB from frame 900', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.seekTo(toFrame(900)); + engine.seekToPrevMarker(); + expect(engine.getState().currentFrame).toBe(toFrame(600)); + engine.destroy(); + }); + it('27. seekToStart() → frame 0', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.seekTo(toFrame(500)); + engine.seekToStart(); + expect(engine.getState().currentFrame).toBe(toFrame(0)); + engine.destroy(); + }); + it('28. seekToEnd() → frame 1799', () => { + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + engine.seekTo(toFrame(0)); + engine.seekToEnd(); + expect(engine.getState().currentFrame).toBe(toFrame(1799)); + engine.destroy(); + }); +}); diff --git a/packages/core/src/__tests__/phase7-api-surface.test.ts b/packages/core/src/__tests__/phase7-api-surface.test.ts new file mode 100644 index 0000000..f9636b5 --- /dev/null +++ b/packages/core/src/__tests__/phase7-api-surface.test.ts @@ -0,0 +1,68 @@ +/** + * Phase 7 Step 6 — Public API surface audit + * + * Import from public API and verify key exports exist at runtime. + * Prevents accidental omissions from public-api.ts. + */ + +import { describe, it, expect } from 'vitest'; +import * as Core from '../public-api'; + +describe('Phase 7 — Public API surface', () => { + it('all key exports are defined', () => { + // Core engine + expect(typeof Core.dispatch).toBe('function'); + expect(typeof Core.checkInvariants).toBe('function'); + expect(typeof Core.createTimelineState).toBe('function'); + expect(typeof Core.HistoryStack).toBe('function'); + + // Types/factories + expect(typeof Core.createClip).toBe('function'); + expect(typeof Core.createTrack).toBe('function'); + expect(typeof Core.createTimeline).toBe('function'); + expect(typeof Core.toClipId).toBe('function'); + expect(typeof Core.toTrackId).toBe('function'); + expect(typeof Core.toFrame).toBe('function'); + expect(typeof Core.createEffect).toBe('function'); + expect(typeof Core.createTransition).toBe('function'); + expect(typeof Core.createTrackGroup).toBe('function'); + expect(typeof Core.createLinkGroup).toBe('function'); + + // Phase 3 + expect(typeof Core.parseSRT).toBe('function'); + expect(typeof Core.parseVTT).toBe('function'); + expect(typeof Core.subtitleImportToOps).toBe('function'); + expect(typeof Core.findMarkersByColor).toBe('function'); + expect(typeof Core.findMarkersByLabel).toBe('function'); + + // Phase 4 + expect(typeof Core.DEFAULT_CLIP_TRANSFORM).toBe('object'); + expect(typeof Core.DEFAULT_AUDIO_PROPERTIES).toBe('object'); + expect(typeof Core.LINEAR_EASING).toBe('object'); + + // Phase 5 + expect(typeof Core.serializeTimeline).toBe('function'); + expect(typeof Core.deserializeTimeline).toBe('function'); + expect(typeof Core.exportToOTIO).toBe('function'); + expect(typeof Core.importFromOTIO).toBe('function'); + expect(typeof Core.exportToEDL).toBe('function'); + expect(typeof Core.exportToAAF).toBe('function'); + expect(typeof Core.exportToFCPXML).toBe('function'); + + // Phase 6 + expect(typeof Core.PlayheadController).toBe('function'); + expect(typeof Core.PlaybackEngine).toBe('function'); + expect(typeof Core.KeyboardHandler).toBe('function'); + expect(typeof Core.DEFAULT_KEY_BINDINGS).toBe('object'); + expect(typeof Core.resolveFrame).toBe('function'); + + // Phase 7 + expect(typeof Core.IntervalTree).toBe('function'); + expect(typeof Core.TrackIndex).toBe('function'); + expect(typeof Core.ThumbnailCache).toBe('function'); + expect(typeof Core.ThumbnailQueue).toBe('function'); + expect(typeof Core.SnapIndexManager).toBe('function'); + expect(typeof Core.getVisibleClips).toBe('function'); + expect(typeof Core.diffStates).toBe('function'); + }); +}); diff --git a/packages/core/src/__tests__/phase7-benchmark.test.ts b/packages/core/src/__tests__/phase7-benchmark.test.ts new file mode 100644 index 0000000..d0906f9 --- /dev/null +++ b/packages/core/src/__tests__/phase7-benchmark.test.ts @@ -0,0 +1,219 @@ +/** + * Phase 7 Step 6 — Benchmarks (40 tracks / 200 clips) + * + * Performance contract: vitest timing, no external benchmark lib. + * Thresholds are CI safety nets; developer machines should pass with headroom. + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame, toTimecode, frameRate } from '../types/frame'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, toAssetId } from '../types/asset'; +import { createTimelineState } from '../types/state'; +import { dispatch } from '../engine/dispatcher'; +import { checkInvariants } from '../validation/invariants'; +import { getClipsAtFrame } from '../engine/frame-resolver'; +import { TrackIndex } from '../engine/track-index'; +import { buildSnapIndex } from '../snap-index'; +import { SnapIndexManager } from '../engine/snap-index-manager'; +import { serializeTimeline, deserializeTimeline } from '../engine/serializer'; +import { resolveFrame } from '../engine/frame-resolver'; +import { diffStates } from '../types/state-change'; +import { applyOperation } from '../engine/apply'; + +const FPS = 30; +const TIMELINE_DURATION = 6000; +const CLIP_DURATION = 300; +const NUM_TRACKS = 40; +const CLIPS_PER_TRACK = 5; +const TOTAL_CLIPS = NUM_TRACKS * CLIPS_PER_TRACK; // 200 +const NUM_ASSETS = 5; + +function buildLargeState(): ReturnType { + const tracks = []; + for (let i = 0; i < NUM_TRACKS; i++) { + const type = i % 2 === 0 ? 'video' : 'audio'; + tracks.push( + createTrack({ + id: `track-${i}`, + name: `${type}-${i}`, + type, + clips: [], + }), + ); + } + const timeline = createTimeline({ + id: 'bench-tl', + name: 'Bench', + fps: frameRate(FPS), + duration: toFrame(TIMELINE_DURATION), + startTimecode: toTimecode('00:00:00:00'), + tracks, + }); + const assets = []; + for (let a = 0; a < NUM_ASSETS; a++) { + assets.push( + createAsset({ + id: `asset-${a}`, + name: `Asset ${a}`, + mediaType: a % 2 === 0 ? 'video' : 'audio', + filePath: `/media/${a}.mp4`, + intrinsicDuration: toFrame(3000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + status: 'online', + }), + ); + } + const registry = new Map(assets.map((a) => [a.id, a])); + let state = createTimelineState({ timeline, assetRegistry: registry }); + + const videoAssets = assets.filter((a) => a.mediaType === 'video'); + const audioAssets = assets.filter((a) => a.mediaType === 'audio'); + const operations: Array<{ type: 'INSERT_CLIP'; clip: ReturnType; trackId: ReturnType }> = []; + for (let t = 0; t < NUM_TRACKS; t++) { + const trackId = toTrackId(`track-${t}`); + const isVideo = t % 2 === 0; + const trackAssets = isVideo ? videoAssets : audioAssets; + for (let c = 0; c < CLIPS_PER_TRACK; c++) { + const startFrame = c * CLIP_DURATION; + const asset = trackAssets[c % trackAssets.length]!; + const clip = createClip({ + id: `clip-t${t}-c${c}`, + assetId: asset.id, + trackId, + timelineStart: toFrame(startFrame), + timelineEnd: toFrame(startFrame + CLIP_DURATION), + mediaIn: toFrame(0), + mediaOut: toFrame(CLIP_DURATION), + }); + operations.push({ type: 'INSERT_CLIP', clip, trackId }); + } + } + const tx = { + id: 'bench-tx', + label: 'Bench', + timestamp: Date.now(), + operations, + }; + const result = dispatch(state, tx); + if (!result.accepted) throw new Error(result.message); + state = result.nextState; + return state; +} + +describe('Phase 7 — Benchmark: 40 tracks / 200 clips', () => { + it('1. buildLargeState() completes in < 500ms', () => { + const t0 = Date.now(); + const state = buildLargeState(); + const elapsed = Date.now() - t0; + expect(state.timeline.tracks.length).toBe(NUM_TRACKS); + expect(state.timeline.tracks.reduce((n, tr) => n + tr.clips.length, 0)).toBe(TOTAL_CLIPS); + expect(elapsed).toBeLessThan(500); + }); + + it('2. checkInvariants() on large state < 50ms', () => { + const state = buildLargeState(); + const t0 = Date.now(); + const violations = checkInvariants(state); + const elapsed = Date.now() - t0; + expect(violations).toHaveLength(0); + expect(elapsed).toBeLessThan(50); + }); + + it('3. getClipsAtFrame() linear scan × 1000 calls < 100ms', () => { + const state = buildLargeState(); + const t0 = Date.now(); + for (let i = 0; i < 1000; i++) { + getClipsAtFrame(state, toFrame(i % TIMELINE_DURATION)); + } + const elapsed = Date.now() - t0; + expect(elapsed).toBeLessThan(100); + }); + + it('4. getClipsAtFrame() with TrackIndex × 1000 calls < 100ms', () => { + const state = buildLargeState(); + const index = new TrackIndex(); + index.build(state); + const t0 = Date.now(); + for (let i = 0; i < 1000; i++) { + getClipsAtFrame(state, toFrame(i % TIMELINE_DURATION), index); + } + const elapsed = Date.now() - t0; + expect(elapsed).toBeLessThan(100); + }); + + it('5. buildSnapIndex() × 100 calls < 200ms', () => { + const state = buildLargeState(); + const t0 = Date.now(); + for (let i = 0; i < 100; i++) { + buildSnapIndex(state, toFrame(0)); + } + const elapsed = Date.now() - t0; + expect(elapsed).toBeLessThan(200); + }); + + it('6. SnapIndexManager.scheduleRebuild() × 1000 sync calls → only 1 rebuild, < 10ms', async () => { + const state = buildLargeState(); + const manager = new SnapIndexManager(); + const t0 = Date.now(); + for (let i = 0; i < 1000; i++) { + manager.scheduleRebuild(state); + } + const elapsed = Date.now() - t0; + expect(elapsed).toBeLessThan(10); + await Promise.resolve(); + expect(manager.getIndex()).not.toBeNull(); + }); + + it('7. serializeTimeline() on large state < 100ms', () => { + const state = buildLargeState(); + const t0 = Date.now(); + const json = serializeTimeline(state); + const elapsed = Date.now() - t0; + expect(typeof json).toBe('string'); + expect(elapsed).toBeLessThan(100); + }); + + it('8. deserializeTimeline() on large state < 200ms', () => { + const state = buildLargeState(); + const json = serializeTimeline(state); + const t0 = Date.now(); + const restored = deserializeTimeline(json); + const elapsed = Date.now() - t0; + expect(restored.timeline.tracks.length).toBe(NUM_TRACKS); + expect(elapsed).toBeLessThan(200); + }); + + it('9. resolveFrame() with TrackIndex × 1000 < 80ms', () => { + const state = buildLargeState(); + const index = new TrackIndex(); + index.build(state); + const dims = { width: 1920, height: 1080 }; + const t0 = Date.now(); + for (let i = 0; i < 1000; i++) { + resolveFrame(state, toFrame(i % TIMELINE_DURATION), 'full', dims, index); + } + const elapsed = Date.now() - t0; + expect(elapsed).toBeLessThan(80); + }); + + it('10. diffStates() × 1000 calls (large state, one clip changed each time) < 500ms', () => { + const state = buildLargeState(); + const track0 = state.timeline.tracks[0]!; + const lastClip = track0.clips[track0.clips.length - 1]!; + const next = applyOperation(state, { + type: 'MOVE_CLIP', + clipId: lastClip.id, + newTimelineStart: toFrame(1201), + }); + const t0 = Date.now(); + for (let i = 0; i < 1000; i++) { + diffStates(state, next); + } + const elapsed = Date.now() - t0; + expect(elapsed).toBeLessThan(500); + }); +}); diff --git a/packages/core/src/__tests__/phase7-compression.test.ts b/packages/core/src/__tests__/phase7-compression.test.ts new file mode 100644 index 0000000..d551470 --- /dev/null +++ b/packages/core/src/__tests__/phase7-compression.test.ts @@ -0,0 +1,271 @@ +/** + * Phase 7 Step 3 — Transaction compression, checkpoints, persistence + */ + +import { describe, it, expect, vi } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { toTrackId } from '../types/track'; +import { toClipId } from '../types/clip'; +import type { Clip } from '../types/clip'; +import type { TimelineState } from '../types/state'; +import type { Transaction } from '../types/operations'; +import type { HistoryEntry } from '../engine/history'; +import { + NO_COMPRESSION, + DEFAULT_COMPRESSION_POLICY, +} from '../types/compression'; +import { TransactionCompressor } from '../engine/transaction-compressor'; +import { HistoryStack } from '../engine/history'; +import { SerializationError } from '../engine/serialization-error'; + +function makeTimeline(name: string) { + return createTimeline({ + id: 'tl', + name, + fps: 30, + duration: toFrame(3000), + version: 0, + } as Parameters[0]); +} + +function makeState(name: string): TimelineState { + return createTimelineState({ timeline: makeTimeline(name) }); +} + +function makeEntry(state: TimelineState, opType: string): HistoryEntry { + return { + state, + transaction: { + id: 'tx-1', + label: 'test', + timestamp: 0, + operations: [{ type: opType } as Transaction['operations'][0]], + }, + }; +} + +describe('Phase 7 — TransactionCompressor', () => { + it('1. NO_COMPRESSION policy: shouldCompress always false', () => { + const compressor = new TransactionCompressor(NO_COMPRESSION); + const entry = makeEntry(makeState('S1'), 'MOVE_CLIP'); + expect(compressor.shouldCompress(entry.transaction, 1000)).toBe(false); + compressor.record(entry.transaction, 1000); + expect(compressor.shouldCompress(entry.transaction, 1100)).toBe(false); + }); + + it('2. Multi-op transaction: shouldCompress false', () => { + const compressor = new TransactionCompressor(DEFAULT_COMPRESSION_POLICY); + compressor.record( + { + id: 't', + label: 'x', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP' } as Transaction['operations'][0], + { type: 'MOVE_CLIP' } as Transaction['operations'][0], + ], + }, + 1000, + ); + const tx = { + id: 't2', + label: 'x', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP' } as Transaction['operations'][0], + { type: 'MOVE_CLIP' } as Transaction['operations'][0], + ], + }; + expect(compressor.shouldCompress(tx, 1200)).toBe(false); + }); + + it('3. Non-compressible op type: shouldCompress false', () => { + const compressor = new TransactionCompressor(DEFAULT_COMPRESSION_POLICY); + const tx: Transaction = { + id: 't', + label: 'x', + timestamp: 0, + operations: [{ type: 'INSERT_CLIP', clip: {} as Clip, trackId: toTrackId('v1') }], + }; + expect(compressor.shouldCompress(tx, 1000)).toBe(false); + }); + + it('4. Same type, within window: shouldCompress true', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(1200); + const compressor = new TransactionCompressor(DEFAULT_COMPRESSION_POLICY, clock); + const tx = makeEntry(makeState('S1'), 'MOVE_CLIP').transaction; + compressor.record(tx, 1000); + expect(compressor.shouldCompress(tx, 1200)).toBe(true); + }); + + it('5. Same type, outside window: shouldCompress false', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(1500); + const compressor = new TransactionCompressor(DEFAULT_COMPRESSION_POLICY, clock); + const tx = makeEntry(makeState('S1'), 'MOVE_CLIP').transaction; + compressor.record(tx, 1000); + expect(compressor.shouldCompress(tx, 1500)).toBe(false); + }); + + it('6. Different type: shouldCompress false', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(1100); + const compressor = new TransactionCompressor(DEFAULT_COMPRESSION_POLICY, clock); + compressor.record(makeEntry(makeState('S1'), 'MOVE_CLIP').transaction, 1000); + const setIn = makeEntry(makeState('S2'), 'SET_IN_POINT').transaction; + expect(compressor.shouldCompress(setIn, 1100)).toBe(false); + }); + + it('7. reset() clears state (shouldCompress false after)', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(1100); + const compressor = new TransactionCompressor(DEFAULT_COMPRESSION_POLICY, clock); + const tx = makeEntry(makeState('S1'), 'MOVE_CLIP').transaction; + compressor.record(tx, 1000); + compressor.reset(); + expect(compressor.shouldCompress(tx, 1100)).toBe(false); + }); +}); + +describe('Phase 7 — HistoryStack compression', () => { + it('8. pushWithCompression within window replaces last entry (stack length unchanged)', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(1200); + const stack = new HistoryStack(100, DEFAULT_COMPRESSION_POLICY, clock); + const e0 = makeEntry(makeState('S0'), 'MOVE_CLIP'); + const e1 = makeEntry(makeState('S1'), 'MOVE_CLIP'); + stack.push(e0); + stack.pushWithCompression(e1, e1.transaction); + expect(stack.getCurrentState()?.timeline.name).toBe('S1'); + stack.pushWithCompression(makeEntry(makeState('S2'), 'MOVE_CLIP'), makeEntry(makeState('S2'), 'MOVE_CLIP').transaction); + expect(stack.getCurrentState()?.timeline.name).toBe('S2'); + }); + + it('9. pushWithCompression outside window appends (stack length increases)', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(2000); + const stack = new HistoryStack(100, DEFAULT_COMPRESSION_POLICY, clock); + const e0 = makeEntry(makeState('S0'), 'MOVE_CLIP'); + const e1 = makeEntry(makeState('S1'), 'MOVE_CLIP'); + stack.push(e0); + stack.pushWithCompression(e1, e1.transaction); + const e2 = makeEntry(makeState('S2'), 'MOVE_CLIP'); + stack.pushWithCompression(e2, e2.transaction); + expect(stack.getCurrentState()?.timeline.name).toBe('S2'); + }); + + it('10. pushWithCompression with different op type appends (no compression)', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(1100); + const stack = new HistoryStack(100, DEFAULT_COMPRESSION_POLICY, clock); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + stack.pushWithCompression(makeEntry(makeState('S1'), 'MOVE_CLIP'), makeEntry(makeState('S1'), 'MOVE_CLIP').transaction); + stack.pushWithCompression(makeEntry(makeState('S2'), 'SET_IN_POINT'), makeEntry(makeState('S2'), 'SET_IN_POINT').transaction); + expect(stack.getCurrentState()?.timeline.name).toBe('S2'); + }); + + it('11. undo still works after compression (undoes to correct prior state)', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(1100); + const stack = new HistoryStack(100, DEFAULT_COMPRESSION_POLICY, clock); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + stack.pushWithCompression(makeEntry(makeState('S1'), 'MOVE_CLIP'), makeEntry(makeState('S1'), 'MOVE_CLIP').transaction); + stack.pushWithCompression(makeEntry(makeState('S2'), 'MOVE_CLIP'), makeEntry(makeState('S2'), 'MOVE_CLIP').transaction); + stack.undo(); + expect(stack.getCurrentState()?.timeline.name).toBe('S0'); + }); + + it('12. redo still works after compression', () => { + const clock = vi.fn().mockReturnValue(1000).mockReturnValueOnce(1000).mockReturnValueOnce(1100); + const stack = new HistoryStack(100, DEFAULT_COMPRESSION_POLICY, clock); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + stack.pushWithCompression(makeEntry(makeState('S1'), 'MOVE_CLIP'), makeEntry(makeState('S1'), 'MOVE_CLIP').transaction); + stack.pushWithCompression(makeEntry(makeState('S2'), 'MOVE_CLIP'), makeEntry(makeState('S2'), 'MOVE_CLIP').transaction); + stack.undo(); + stack.redo(); + expect(stack.getCurrentState()?.timeline.name).toBe('S2'); + }); +}); + +describe('Phase 7 — Named checkpoints', () => { + it('13. saveCheckpoint stores current undoIndex', () => { + const stack = new HistoryStack(100); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + stack.push(makeEntry(makeState('S1'), 'MOVE_CLIP')); + stack.saveCheckpoint('cp1'); + stack.push(makeEntry(makeState('S2'), 'MOVE_CLIP')); + const entry = stack.restoreCheckpoint('cp1'); + expect(entry?.state.timeline.name).toBe('S1'); + }); + + it('14. restoreCheckpoint returns correct entry', () => { + const stack = new HistoryStack(100); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + stack.saveCheckpoint('a'); + const e = stack.restoreCheckpoint('a'); + expect(e?.state.timeline.name).toBe('S0'); + }); + + it('15. restoreCheckpoint on unknown name returns null', () => { + const stack = new HistoryStack(100); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + expect(stack.restoreCheckpoint('unknown')).toBeNull(); + }); + + it('16. listCheckpoints returns saved names', () => { + const stack = new HistoryStack(100); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + stack.saveCheckpoint('a'); + stack.saveCheckpoint('b'); + expect(stack.listCheckpoints()).toEqual(expect.arrayContaining(['a', 'b'])); + expect(stack.listCheckpoints().length).toBe(2); + }); + + it('17. clearCheckpoint removes it', () => { + const stack = new HistoryStack(100); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + stack.saveCheckpoint('a'); + stack.clearCheckpoint('a'); + expect(stack.listCheckpoints()).not.toContain('a'); + }); +}); + +describe('Phase 7 — History persistence', () => { + it('18. serialize() produces valid JSON', () => { + const stack = new HistoryStack(100); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + const json = stack.serialize(); + expect(() => JSON.parse(json)).not.toThrow(); + const parsed = JSON.parse(json); + expect(parsed.version).toBe(1); + expect(parsed.undoIndex).toBe(0); + expect(Array.isArray(parsed.entries)).toBe(true); + }); + + it('19. deserialize(serialize()) round-trips entries and undoIndex', () => { + const stack = new HistoryStack(100); + stack.push(makeEntry(makeState('S0'), 'MOVE_CLIP')); + stack.push(makeEntry(makeState('S1'), 'MOVE_CLIP')); + const json = stack.serialize(); + const restored = HistoryStack.deserialize(json); + expect(restored.getCurrentState()?.timeline.name).toBe('S1'); + restored.undo(); + expect(restored.getCurrentState()?.timeline.name).toBe('S0'); + }); + + it('20. deserialize throws SerializationError on invalid JSON', () => { + expect(() => HistoryStack.deserialize('not json')).toThrow(SerializationError); + expect(() => HistoryStack.deserialize('{')).toThrow(SerializationError); + }); + + it('21. softLimitWarning false when under 80%', () => { + const stack = new HistoryStack(100); + for (let i = 0; i < 50; i++) { + stack.push(makeEntry(makeState(`S${i}`), 'MOVE_CLIP')); + } + expect(stack.softLimitWarning()).toBe(false); + }); + + it('22. softLimitWarning true when at 80%+', () => { + const stack = new HistoryStack(100); + for (let i = 0; i < 80; i++) { + stack.push(makeEntry(makeState(`S${i}`), 'MOVE_CLIP')); + } + expect(stack.softLimitWarning()).toBe(true); + }); +}); diff --git a/packages/core/src/__tests__/phase7-interval-tree.test.ts b/packages/core/src/__tests__/phase7-interval-tree.test.ts new file mode 100644 index 0000000..2a1e5fc --- /dev/null +++ b/packages/core/src/__tests__/phase7-interval-tree.test.ts @@ -0,0 +1,272 @@ +/** + * Phase 7 Step 1 — Interval tree and TrackIndex for O(log n) frame lookup + */ + +import { describe, it, expect, vi } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toTimecode } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { PipelineConfig } from '../types/pipeline'; +import { IntervalTree } from '../engine/interval-tree'; +import { TrackIndex } from '../engine/track-index'; +import * as frameResolver from '../engine/frame-resolver'; +import { PlaybackEngine } from '../engine/playback-engine'; +import { createTestClock } from '../engine/clock'; + +const FPS = 30; +const DURATION = 900; +const DIMS = { width: 1920, height: 1080 }; + +const mockPipeline: PipelineConfig = { + videoDecoder: vi.fn().mockResolvedValue({ + clipId: 'x', + mediaFrame: toFrame(0), + width: DIMS.width, + height: DIMS.height, + bitmap: null, + }), + compositor: vi.fn().mockResolvedValue({ + timelineFrame: toFrame(0), + bitmap: null, + }), +}; + +function makeFixtureState(): TimelineState { + const trackId = toTrackId('v1'); + const trackId2 = toTrackId('v2'); + const asset = createAsset({ + id: 'a1', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(2000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: toClipId('c1'), + assetId: asset.id, + trackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const clip2 = createClip({ + id: toClipId('c2'), + assetId: asset.id, + trackId, + timelineStart: toFrame(200), + timelineEnd: toFrame(400), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const clip3 = createClip({ + id: toClipId('c3'), + assetId: asset.id, + trackId: trackId2, + timelineStart: toFrame(100), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const t1 = createTrack({ id: trackId, name: 'V1', type: 'video', clips: [clip1, clip2] }); + const t2 = createTrack({ id: trackId2, name: 'V2', type: 'video', clips: [clip3] }); + const timeline = createTimeline({ + id: 'tl', + name: 'Phase7', + fps: FPS, + duration: toFrame(DURATION), + startTimecode: toTimecode('00:00:00:00'), + tracks: [t1, t2], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +describe('Phase 7 — IntervalTree', () => { + it('1. query on empty tree returns []', () => { + const tree = new IntervalTree(); + tree.build([]); + expect(tree.query(0)).toEqual([]); + }); + + it('2. single interval: query inside returns data', () => { + const tree = new IntervalTree(); + tree.build([{ start: 10, end: 20, data: 'x' }]); + expect(tree.query(15)).toEqual(['x']); + }); + + it('3. single interval: query at start (inclusive)', () => { + const tree = new IntervalTree(); + tree.build([{ start: 10, end: 20, data: 'x' }]); + expect(tree.query(10)).toEqual(['x']); + }); + + it('4. single interval: query at end - 1 (inclusive)', () => { + const tree = new IntervalTree(); + tree.build([{ start: 10, end: 20, data: 'x' }]); + expect(tree.query(19)).toEqual(['x']); + }); + + it('5. single interval: query at end (exclusive) → []', () => { + const tree = new IntervalTree(); + tree.build([{ start: 10, end: 20, data: 'x' }]); + expect(tree.query(20)).toEqual([]); + }); + + it('6. two non-overlapping intervals: query returns only the matching one', () => { + const tree = new IntervalTree(); + tree.build([ + { start: 0, end: 10, data: 'a' }, + { start: 20, end: 30, data: 'b' }, + ]); + expect(tree.query(5)).toEqual(['a']); + expect(tree.query(25)).toEqual(['b']); + }); + + it('7. two overlapping intervals: query at overlap returns both', () => { + const tree = new IntervalTree(); + tree.build([ + { start: 0, end: 100, data: 'a' }, + { start: 50, end: 150, data: 'b' }, + ]); + expect(tree.query(75)).toEqual(expect.arrayContaining(['a', 'b'])); + expect(tree.query(75).length).toBe(2); + }); + + it('8. 100 intervals (stress): query at midpoint returns correct subset', () => { + const tree = new IntervalTree(); + const intervals = Array.from({ length: 100 }, (_, i) => ({ + start: i * 10, + end: i * 10 + 20, + data: i, + })); + tree.build(intervals); + // Point 15 is inside [10,30) and [0,20) -> indices 1 and 0 + const result = tree.query(15); + expect(result).toContain(0); + expect(result).toContain(1); + expect(result.length).toBe(2); + }); + + it('9. tree.size() returns interval count', () => { + const tree = new IntervalTree(); + expect(tree.size()).toBe(0); + tree.build([{ start: 0, end: 10, data: 'a' }]); + expect(tree.size()).toBe(1); + tree.build([{ start: 0, end: 10, data: 'a' }, { start: 10, end: 20, data: 'b' }]); + expect(tree.size()).toBe(2); + }); +}); + +describe('Phase 7 — TrackIndex', () => { + it('10. build() then query returns clips at frame', () => { + const state = makeFixtureState(); + const index = new TrackIndex(); + index.build(state); + const at150 = index.query(150); + expect(at150.length).toBe(2); // clip1 [0,200) and clip3 [100,300) + const at250 = index.query(250); + expect(at250.length).toBe(2); // clip2 [200,400) and clip3 [100,300) + }); + + it('11. query sorts by trackIndex ascending', () => { + const state = makeFixtureState(); + const index = new TrackIndex(); + index.build(state); + const at150 = index.query(150); + expect(at150[0]!.trackIndex).toBe(0); + expect(at150[1]!.trackIndex).toBe(1); + }); + + it('12. query returns [] for frame with no clips', () => { + const state = makeFixtureState(); + const index = new TrackIndex(); + index.build(state); + expect(index.query(500)).toEqual([]); + }); + + it('13. invalidate() causes isBuilt to be false', () => { + const state = makeFixtureState(); + const index = new TrackIndex(); + index.build(state); + expect(index.isBuilt).toBe(true); + index.invalidate(); + expect(index.isBuilt).toBe(false); + }); + + it('14. query on unbuilt index throws', () => { + const index = new TrackIndex(); + expect(() => index.query(0)).toThrow('TrackIndex not built'); + }); +}); + +describe('Phase 7 — frame-resolver integration', () => { + it('15. getClipsAtFrame with built TrackIndex returns same results as without index', () => { + const state = makeFixtureState(); + const index = new TrackIndex(); + index.build(state); + const frame = toFrame(150); + const withIndex = frameResolver.getClipsAtFrame(state, frame, index); + const withoutIndex = frameResolver.getClipsAtFrame(state, frame); + expect(withIndex.length).toBe(withoutIndex.length); + expect(withIndex.map((e) => e.clip.id)).toEqual(withoutIndex.map((e) => e.clip.id)); + }); + + it('16. getClipsAtFrame with unbuilt index falls back to linear scan (no throw)', () => { + const state = makeFixtureState(); + const index = new TrackIndex(); + const frame = toFrame(150); + const result = frameResolver.getClipsAtFrame(state, frame, index); + expect(result.length).toBe(2); + }); + + it('17. resolveFrame with index returns same layers as without index', () => { + const state = makeFixtureState(); + const index = new TrackIndex(); + index.build(state); + const frame = toFrame(150); + const withIndex = frameResolver.resolveFrame(state, frame, 'full', DIMS, index); + const withoutIndex = frameResolver.resolveFrame(state, frame, 'full', DIMS); + expect(withIndex.layers.length).toBe(withoutIndex.layers.length); + expect(withIndex.layers.map((l) => l.clipId)).toEqual(withoutIndex.layers.map((l) => l.clipId)); + }); +}); + +describe('Phase 7 — PlaybackEngine', () => { + it('18. renderFrame uses index (resolveFrame called with index)', async () => { + const state = makeFixtureState(); + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + const resolveSpy = vi.spyOn(frameResolver, 'resolveFrame'); + await engine.renderFrame(toFrame(150)); + expect(resolveSpy).toHaveBeenCalledWith( + state, + toFrame(150), + expect.anything(), + DIMS, + expect.anything(), + ); + const indexArg = resolveSpy.mock.calls[0]![4]; + expect(indexArg).toBeDefined(); + expect(indexArg?.isBuilt).toBe(true); + resolveSpy.mockRestore(); + }); + + it('19. updateState rebuilds index (build called after updateState)', () => { + const state = makeFixtureState(); + const buildSpy = vi.spyOn(TrackIndex.prototype, 'build'); + const { clock } = createTestClock(); + const engine = new PlaybackEngine(state, mockPipeline, DIMS, clock); + expect(buildSpy).toHaveBeenCalledTimes(1); + const newState = makeFixtureState(); + engine.updateState(newState); + expect(buildSpy).toHaveBeenCalledTimes(2); + buildSpy.mockRestore(); + }); +}); diff --git a/packages/core/src/__tests__/phase7-invariants-audit.test.ts b/packages/core/src/__tests__/phase7-invariants-audit.test.ts new file mode 100644 index 0000000..be60b98 --- /dev/null +++ b/packages/core/src/__tests__/phase7-invariants-audit.test.ts @@ -0,0 +1,375 @@ +/** + * Phase 7 Step 6 — Invariant hardening audit + * + * Build deliberately corrupt state per invariant; verify checkInvariants() catches it. + * One test per category. Uses dispatch() for valid base, then mutates to corrupt. + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame, toTimecode, frameRate } from '../types/frame'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, toAssetId } from '../types/asset'; +import { createTimelineState, CURRENT_SCHEMA_VERSION } from '../types/state'; +import { dispatch } from '../engine/dispatcher'; +import { applyOperation } from '../engine/apply'; +import { checkInvariants } from '../validation/invariants'; +import { toMarkerId } from '../types/marker'; +import { toLinkGroupId, createLinkGroup } from '../types/link-group'; +import { toTrackGroupId, createTrackGroup } from '../types/track-group'; +import { createEffect, toEffectId } from '../types/effect'; +import { toKeyframeId } from '../types/keyframe'; +import { createTransition, toTransitionId } from '../types/transition'; +import { defaultCaptionStyle } from '../engine/subtitle-import'; +import { LINEAR_EASING } from '../types/easing'; +import { toCaptionId } from '../types/caption'; + +function validState() { + const asset = createAsset({ + id: 'asset-1', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(1000), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + status: 'online', + }); + const clip1 = createClip({ + id: 'c1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clip2 = createClip({ + id: 'c2', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(100), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ + id: 'track-1', + name: 'V1', + type: 'video', + clips: [clip1, clip2], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: frameRate(30), + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ + timeline, + assetRegistry: new Map([[asset.id, asset]]), + }); +} + +describe('Phase 7 — Invariant audit', () => { + it('1. Overlapping clips on same track → OVERLAP', () => { + const state = validState(); + const track = state.timeline.tracks[0]!; + const overlapping = createClip({ + id: 'overlap', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(50), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const corrupt = { + ...state, + timeline: { + ...state.timeline, + tracks: [{ ...track, clips: [track.clips[0]!, overlapping, track.clips[1]!] }], + }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations.some((v) => v.type === 'OVERLAP')).toBe(true); + }); + + it('2. Clip timelineStart < 0 → CLIP_BEYOND_TIMELINE or TRACK_NOT_SORTED', () => { + const state = validState(); + const track = state.timeline.tracks[0]!; + const badClip = createClip({ + id: 'neg', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(-10), + timelineEnd: toFrame(90), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const corrupt = { + ...state, + timeline: { + ...state.timeline, + tracks: [{ ...track, clips: [badClip, ...track.clips] }], + }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(['CLIP_BEYOND_TIMELINE', 'TRACK_NOT_SORTED', 'OVERLAP']).toContain(violations[0]!.type); + }); + + it('3. Clip timelineEnd > timeline.duration → CLIP_BEYOND_TIMELINE', () => { + const state = validState(); + const track = state.timeline.tracks[0]!; + const badClip = createClip({ + id: 'past', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(2900), + timelineEnd: toFrame(3100), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const corrupt = { + ...state, + timeline: { + ...state.timeline, + tracks: [{ ...track, clips: [...track.clips, badClip] }], + }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('CLIP_BEYOND_TIMELINE'); + }); + + it('4. Duplicate clip id across tracks → detected if overlap/sort violated', () => { + const state = validState(); + const clip = state.timeline.tracks[0]!.clips[0]!; + const track2 = createTrack({ + id: 'track-2', + name: 'V2', + type: 'video', + clips: [{ ...clip, trackId: toTrackId('track-2') }], + }); + const corrupt = { + ...state, + timeline: { + ...state.timeline, + tracks: [...state.timeline.tracks, track2], + }, + }; + const violations = checkInvariants(corrupt); + expect(Array.isArray(violations)).toBe(true); + }); + + it('5. Duplicate track id → track array has two tracks same id', () => { + const state = validState(); + const t0 = state.timeline.tracks[0]!; + const corrupt = { + ...state, + timeline: { + ...state.timeline, + tracks: [t0, { ...t0, id: t0.id, name: 'V1-copy', clips: [] }], + }, + }; + const violations = checkInvariants(corrupt); + expect(Array.isArray(violations)).toBe(true); + }); + + it('6. schemaVersion mismatch → SCHEMA_VERSION_MISMATCH', () => { + const state = validState(); + const corrupt = { ...state, schemaVersion: CURRENT_SCHEMA_VERSION + 1 }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('SCHEMA_VERSION_MISMATCH'); + }); + + it('7. Marker out of bounds (point frame < 0) → MARKER_OUT_OF_BOUNDS', () => { + let state = validState(); + state = applyOperation(state, { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: toMarkerId('m1'), + frame: toFrame(100), + label: 'M', + color: '#f00', + scope: 'global', + linkedClipId: null, + }, + }); + const markers = state.timeline.markers.map((m) => + m.type === 'point' && m.id === 'm1' ? { ...m, frame: toFrame(-1) } : m, + ); + const corrupt = { ...state, timeline: { ...state.timeline, markers } }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('MARKER_OUT_OF_BOUNDS'); + }); + + it('8. Range marker endFrame <= startFrame → MARKER_OUT_OF_BOUNDS', () => { + let state = validState(); + state = applyOperation(state, { + type: 'ADD_MARKER', + marker: { + type: 'range', + id: toMarkerId('r1'), + frameStart: toFrame(100), + frameEnd: toFrame(200), + label: 'R', + color: '#0f0', + scope: 'global', + linkedClipId: null, + }, + }); + const markers = state.timeline.markers.map((m) => + m.type === 'range' && m.id === 'r1' ? { ...m, frameEnd: toFrame(100) } : m, + ); + const corrupt = { ...state, timeline: { ...state.timeline, markers } }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('MARKER_OUT_OF_BOUNDS'); + }); + + it('9. inPoint >= outPoint → IN_OUT_INVALID', () => { + let state = validState(); + state = applyOperation(state, { type: 'SET_IN_POINT', frame: toFrame(100) }); + state = applyOperation(state, { type: 'SET_OUT_POINT', frame: toFrame(50) }); + const corrupt = { + ...state, + timeline: { ...state.timeline, inPoint: toFrame(100), outPoint: toFrame(50) }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('IN_OUT_INVALID'); + }); + + it('10. Caption overlap on same track → CAPTION_OVERLAP', () => { + let state = validState(); + state = applyOperation(state, { + type: 'ADD_CAPTION', + trackId: toTrackId('track-1'), + caption: { + id: toCaptionId('cap-1'), + startFrame: toFrame(0), + endFrame: toFrame(100), + text: 'A', + language: 'en', + style: defaultCaptionStyle, + burnIn: false, + }, + }); + state = applyOperation(state, { + type: 'ADD_CAPTION', + trackId: toTrackId('track-1'), + caption: { + id: toCaptionId('cap-2'), + startFrame: toFrame(50), + endFrame: toFrame(150), + text: 'B', + language: 'en', + style: defaultCaptionStyle, + burnIn: false, + }, + }); + const violations = checkInvariants(state); + expect(violations.length).toBeGreaterThan(0); + expect(violations.some((v) => v.type === 'CAPTION_OVERLAP')).toBe(true); + }); + + it('11. Unsorted keyframes on effect → KEYFRAME_ORDER_VIOLATION', () => { + let state = validState(); + const kf1 = { id: toKeyframeId('k1'), frame: toFrame(30), value: 0, easing: LINEAR_EASING }; + const kf2 = { id: toKeyframeId('k2'), frame: toFrame(50), value: 1, easing: LINEAR_EASING }; + const effect = { + ...createEffect(toEffectId('e1'), 'blur', 'postComposite'), + keyframes: [kf1, kf2], + }; + state = applyOperation(state, { type: 'ADD_EFFECT', clipId: toClipId('c1'), effect: effect as import('../types/effect').Effect }); + const track = state.timeline.tracks[0]!; + const clip = track.clips[0]!; + const addedEffect = clip.effects![0]!; + const badEffect = { ...addedEffect, keyframes: [kf2, kf1] }; + const corruptClip = { ...clip, effects: [badEffect] }; + const corrupt = { + ...state, + timeline: { + ...state.timeline, + tracks: [{ ...track, clips: [corruptClip, track.clips[1]!] }], + }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations.some((v) => v.type === 'KEYFRAME_ORDER_VIOLATION')).toBe(true); + }); + + it('12. Transition durationFrames = 0 → INVALID_RANGE', () => { + let state = validState(); + const trans = createTransition(toTransitionId('tr1'), 'crossDissolve', 10); + state = applyOperation(state, { type: 'ADD_TRANSITION', clipId: toClipId('c1'), transition: trans }); + const track = state.timeline.tracks[0]!; + const clip = track.clips[0]!; + const badTrans = { ...clip.transition!, durationFrames: 0 }; + const corruptClip = { ...clip, transition: badTrans }; + const corrupt = { + ...state, + timeline: { + ...state.timeline, + tracks: [{ ...track, clips: [corruptClip, track.clips[1]!] }], + }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('INVALID_RANGE'); + }); + + it('13. LinkGroup with only 1 clipId → INVALID_RANGE', () => { + let state = validState(); + state = applyOperation(state, { type: 'LINK_CLIPS', linkGroup: createLinkGroup(toLinkGroupId('lg1'), [toClipId('c1'), toClipId('c2')]) }); + const corrupt = { + ...state, + timeline: { + ...state.timeline, + linkGroups: [createLinkGroup(toLinkGroupId('bad'), [toClipId('c1')])], + }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('INVALID_RANGE'); + }); + + it('14. Track groupId referencing non-existent group → TRACK_GROUP_NOT_FOUND', () => { + const state = validState(); + const track = state.timeline.tracks[0]!; + const corrupt = { + ...state, + timeline: { + ...state.timeline, + tracks: [{ ...track, groupId: toTrackGroupId('nonexistent') }], + }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('TRACK_GROUP_NOT_FOUND'); + }); + + it('15. BeatGrid bpm = 0 → BEAT_GRID_INVALID', () => { + let state = validState(); + state = applyOperation(state, { + type: 'ADD_BEAT_GRID', + beatGrid: { bpm: 120, timeSignature: [4, 4], offset: toFrame(0) }, + }); + const corrupt = { + ...state, + timeline: { ...state.timeline, beatGrid: { bpm: 0, timeSignature: [4, 4] as readonly [number, number], offset: toFrame(0) } }, + }; + const violations = checkInvariants(corrupt); + expect(violations.length).toBeGreaterThan(0); + expect(violations[0]!.type).toBe('BEAT_GRID_INVALID'); + }); +}); diff --git a/packages/core/src/__tests__/phase7-snap-manager.test.ts b/packages/core/src/__tests__/phase7-snap-manager.test.ts new file mode 100644 index 0000000..080794a --- /dev/null +++ b/packages/core/src/__tests__/phase7-snap-manager.test.ts @@ -0,0 +1,158 @@ +/** + * Phase 7 Step 2 — SnapIndexManager microtask debounce + */ + +import { describe, it, expect, vi } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toTimecode } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import { SnapIndexManager } from '../engine/snap-index-manager'; +import * as snapIndexModule from '../snap-index'; + +function makeState(): TimelineState { + const trackId = toTrackId('t1'); + const asset = createAsset({ + id: 'a1', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(1000), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clip = createClip({ + id: toClipId('c1'), + assetId: asset.id, + trackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ id: trackId, name: 'T1', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', + name: 'Test', + fps: 30, + duration: toFrame(500), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +describe('Phase 7 — SnapIndexManager', () => { + it('1. getIndex() returns null before any build', () => { + const manager = new SnapIndexManager(); + expect(manager.getIndex()).toBeNull(); + }); + + it('2. rebuildSync() builds index immediately', () => { + const manager = new SnapIndexManager(); + const state = makeState(); + manager.rebuildSync(state); + expect(manager.getIndex()).not.toBeNull(); + }); + + it('3. rebuildSync() index contains expected snap points', () => { + const manager = new SnapIndexManager(); + const state = makeState(); + manager.rebuildSync(state); + const index = manager.getIndex()!; + expect(index.points.length).toBeGreaterThan(0); + const clipStarts = index.points.filter((p) => p.type === 'ClipStart'); + expect(clipStarts.some((p) => p.frame === 0)).toBe(true); + }); + + it('4. scheduleRebuild() sets isPending: true', () => { + const manager = new SnapIndexManager(); + const state = makeState(); + manager.scheduleRebuild(state); + expect(manager.isPending).toBe(true); + }); + + it('5. scheduleRebuild() index is null until microtask resolves', async () => { + const manager = new SnapIndexManager(); + const state = makeState(); + manager.scheduleRebuild(state); + expect(manager.getIndex()).toBeNull(); + await Promise.resolve(); + expect(manager.getIndex()).not.toBeNull(); + }); + + it('6. After await Promise.resolve(): isPending false, index is built', async () => { + const manager = new SnapIndexManager(); + const state = makeState(); + manager.scheduleRebuild(state); + await Promise.resolve(); + expect(manager.isPending).toBe(false); + expect(manager.getIndex()).not.toBeNull(); + }); + + it('7. scheduleRebuild() called 3 times synchronously: only ONE rebuild', async () => { + const buildSpy = vi.spyOn(snapIndexModule, 'buildSnapIndex'); + const manager = new SnapIndexManager(); + const state = makeState(); + manager.scheduleRebuild(state); + manager.scheduleRebuild(state); + manager.scheduleRebuild(state); + await Promise.resolve(); + expect(buildSpy).toHaveBeenCalledTimes(1); + buildSpy.mockRestore(); + }); + + it('8. scheduleRebuild() captures latest state', async () => { + const stateA = makeState(); + const trackId = toTrackId('t1'); + const asset = createAsset({ + id: 'a1', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(1000), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: toClipId('c1'), + assetId: asset.id, + trackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(50), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + const clip2 = createClip({ + id: toClipId('c2'), + assetId: asset.id, + trackId, + timelineStart: toFrame(50), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ id: trackId, name: 'T1', type: 'video', clips: [clip1, clip2] }); + const timeline = createTimeline({ + id: 'tl', + name: 'Test', + fps: 30, + duration: toFrame(500), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + const stateB = createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); + const manager = new SnapIndexManager(); + manager.scheduleRebuild(stateA); + manager.scheduleRebuild(stateB); + await Promise.resolve(); + const index = manager.getIndex()!; + expect(index).not.toBeNull(); + const clipPoints = index.points.filter((p) => p.type === 'ClipStart' || p.type === 'ClipEnd'); + expect(clipPoints.length).toBe(4); + }); +}); diff --git a/packages/core/src/__tests__/phase7-virtual.test.ts b/packages/core/src/__tests__/phase7-virtual.test.ts new file mode 100644 index 0000000..f816d33 --- /dev/null +++ b/packages/core/src/__tests__/phase7-virtual.test.ts @@ -0,0 +1,334 @@ +/** + * Phase 7 Step 2 — Virtual window and StateChange diff + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame } from '../types/frame'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset } from '../types/asset'; +import { toTimecode } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import { + getVisibleClips, + getVisibleFrameRange, +} from '../engine/virtual-window'; +import { diffStates } from '../types/state-change'; + +const FPS = 30; + +function makeFixtureState(): TimelineState { + const trackId = toTrackId('v1'); + const trackId2 = toTrackId('v2'); + const asset = createAsset({ + id: 'a1', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(2000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const clip1 = createClip({ + id: toClipId('c1'), + assetId: asset.id, + trackId, + timelineStart: toFrame(0), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const clip2 = createClip({ + id: toClipId('c2'), + assetId: asset.id, + trackId, + timelineStart: toFrame(200), + timelineEnd: toFrame(400), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const clip3 = createClip({ + id: toClipId('c3'), + assetId: asset.id, + trackId: trackId2, + timelineStart: toFrame(100), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const t1 = createTrack({ id: trackId, name: 'V1', type: 'video', clips: [clip1, clip2] }); + const t2 = createTrack({ id: trackId2, name: 'V2', type: 'video', clips: [clip3] }); + const timeline = createTimeline({ + id: 'tl', + name: 'Virtual', + fps: FPS, + duration: toFrame(900), + startTimecode: toTimecode('00:00:00:00'), + tracks: [t1, t2], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +describe('Phase 7 — getVisibleClips', () => { + it('9. Returns all clips with isVisible flag set', () => { + const state = makeFixtureState(); + const window = { + startFrame: toFrame(0), + endFrame: toFrame(500), + pixelsPerFrame: 2, + }; + const entries = getVisibleClips(state, window); + expect(entries.length).toBe(3); + entries.forEach((e) => { + expect(typeof e.isVisible).toBe('boolean'); + }); + }); + + it('10. Clip fully inside window: isVisible true', () => { + const state = makeFixtureState(); + const window = { + startFrame: toFrame(0), + endFrame: toFrame(500), + pixelsPerFrame: 2, + }; + const entries = getVisibleClips(state, window); + const c1 = entries.find((e) => e.clip.id === toClipId('c1')); + expect(c1?.isVisible).toBe(true); + }); + + it('11. Clip fully outside window: isVisible false', () => { + const state = makeFixtureState(); + const window = { + startFrame: toFrame(500), + endFrame: toFrame(700), + pixelsPerFrame: 2, + }; + const entries = getVisibleClips(state, window); + const c1 = entries.find((e) => e.clip.id === toClipId('c1')); + expect(c1?.isVisible).toBe(false); + }); + + it('12. Clip partially overlapping left edge: isVisible true', () => { + const state = makeFixtureState(); + const window = { + startFrame: toFrame(100), + endFrame: toFrame(300), + pixelsPerFrame: 2, + }; + const entries = getVisibleClips(state, window); + const c1 = entries.find((e) => e.clip.id === toClipId('c1')); + expect(c1?.isVisible).toBe(true); + }); + + it('13. Clip partially overlapping right edge: isVisible true', () => { + const state = makeFixtureState(); + const window = { + startFrame: toFrame(0), + endFrame: toFrame(150), + pixelsPerFrame: 2, + }; + const entries = getVisibleClips(state, window); + const c1 = entries.find((e) => e.clip.id === toClipId('c1')); + expect(c1?.isVisible).toBe(true); + }); + + it('14. left px computed correctly for clip at frame 100 with pixelsPerFrame 2', () => { + const state = makeFixtureState(); + const window = { + startFrame: toFrame(50), + endFrame: toFrame(400), + pixelsPerFrame: 2, + }; + const entries = getVisibleClips(state, window); + const c3 = entries.find((e) => e.clip.id === toClipId('c3')); + expect(c3).toBeDefined(); + expect(c3!.clip.timelineStart).toEqual(toFrame(100)); + expect(c3!.left).toBe((100 - 50) * 2); + }); + + it('15. width computed correctly: durationFrames * ppf', () => { + const state = makeFixtureState(); + const window = { + startFrame: toFrame(0), + endFrame: toFrame(500), + pixelsPerFrame: 3, + }; + const entries = getVisibleClips(state, window); + const c1 = entries.find((e) => e.clip.id === toClipId('c1')); + expect(c1!.width).toBe(200 * 3); + }); + + it('16. Results sorted by trackIndex then startFrame', () => { + const state = makeFixtureState(); + const window = { + startFrame: toFrame(0), + endFrame: toFrame(500), + pixelsPerFrame: 1, + }; + const entries = getVisibleClips(state, window); + for (let i = 1; i < entries.length; i++) { + const prev = entries[i - 1]!; + const curr = entries[i]!; + expect( + prev.trackIndex < curr.trackIndex || + (prev.trackIndex === curr.trackIndex && + (prev.clip.timelineStart as number) <= (curr.clip.timelineStart as number)), + ).toBe(true); + } + }); +}); + +describe('Phase 7 — getVisibleFrameRange', () => { + it('17. Correct startFrame from scrollLeft + ppf', () => { + const win = getVisibleFrameRange(100, 50, 2); + expect(win.startFrame).toEqual(toFrame(Math.floor(50 / 2))); + expect((win.startFrame as number)).toBe(25); + }); + + it('18. Correct endFrame from scrollLeft + width + ppf', () => { + const win = getVisibleFrameRange(100, 50, 2); + expect(win.endFrame).toEqual(toFrame(Math.ceil((50 + 100) / 2))); + expect((win.endFrame as number)).toBe(75); + }); + + it('19. Returns VirtualWindow with pixelsPerFrame', () => { + const win = getVisibleFrameRange(200, 0, 4); + expect(win.pixelsPerFrame).toBe(4); + }); +}); + +describe('Phase 7 — diffStates', () => { + it('20. Identical state reference → all false, empty set', () => { + const state = makeFixtureState(); + const diff = diffStates(state, state); + expect(diff.trackIds).toBe(false); + expect(diff.clipIds.size).toBe(0); + expect(diff.markers).toBe(false); + expect(diff.timeline).toBe(false); + expect(diff.playhead).toBe(false); + }); + + it('21. Adding a clip → clipIds contains new clipId', () => { + const state = makeFixtureState(); + const newClip = createClip({ + id: toClipId('c-new'), + assetId: state.timeline.tracks[0]!.clips[0]!.assetId, + trackId: toTrackId('v1'), + timelineStart: toFrame(400), + timelineEnd: toFrame(500), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const newTrack = createTrack({ + id: toTrackId('v1'), + name: 'V1', + type: 'video', + clips: [...state.timeline.tracks[0]!.clips, newClip], + }); + const newTimeline = createTimeline({ + ...state.timeline, + tracks: [newTrack, state.timeline.tracks[1]!], + }); + const next = createTimelineState({ + timeline: newTimeline, + assetRegistry: state.assetRegistry, + }); + const diff = diffStates(state, next); + expect(diff.clipIds.has(toClipId('c-new'))).toBe(true); + }); + + it('22. Modifying a clip → clipIds contains that clipId', () => { + const state = makeFixtureState(); + const track = state.timeline.tracks[0]!; + const modifiedClip = { ...track.clips[0]!, name: 'Renamed' }; + const newTrack = createTrack({ + ...track, + clips: [modifiedClip, track.clips[1]!], + }); + const newTimeline = createTimeline({ + ...state.timeline, + tracks: [newTrack, state.timeline.tracks[1]!], + }); + const next = createTimelineState({ + timeline: newTimeline, + assetRegistry: state.assetRegistry, + }); + const diff = diffStates(state, next); + expect(diff.clipIds.has(track.clips[0]!.id)).toBe(true); + }); + + it('23. Removing a clip → clipIds contains removed clipId', () => { + const state = makeFixtureState(); + const track = state.timeline.tracks[0]!; + const removedId = track.clips[0]!.id; + const newTrack = createTrack({ + ...track, + clips: [track.clips[1]!], + }); + const newTimeline = createTimeline({ + ...state.timeline, + tracks: [newTrack, state.timeline.tracks[1]!], + }); + const next = createTimelineState({ + timeline: newTimeline, + assetRegistry: state.assetRegistry, + }); + const diff = diffStates(state, next); + expect(diff.clipIds.has(removedId)).toBe(true); + }); + + it('24. Changing track order → trackIds: true', () => { + const state = makeFixtureState(); + const newTimeline = createTimeline({ + ...state.timeline, + tracks: [state.timeline.tracks[1]!, state.timeline.tracks[0]!], + }); + const next = createTimelineState({ + timeline: newTimeline, + assetRegistry: state.assetRegistry, + }); + const diff = diffStates(state, next); + expect(diff.trackIds).toBe(true); + }); + + it('25. Changing markers → markers: true', () => { + const state = makeFixtureState(); + const newTimeline = createTimeline({ + ...state.timeline, + markers: [], + }); + const next = createTimelineState({ + timeline: newTimeline, + assetRegistry: state.assetRegistry, + }); + const diff = diffStates(state, next); + expect(diff.markers).toBe(true); + }); + + it('26. Changing timeline duration → timeline: true', () => { + const state = makeFixtureState(); + const newTimeline = createTimeline({ + ...state.timeline, + duration: toFrame(1000), + }); + const next = createTimelineState({ + timeline: newTimeline, + assetRegistry: state.assetRegistry, + }); + const diff = diffStates(state, next); + expect(diff.timeline).toBe(true); + }); + + it('27. Same clips, same tracks → clipIds empty, trackIds false', () => { + const state = makeFixtureState(); + const next = createTimelineState({ + timeline: state.timeline, + assetRegistry: state.assetRegistry, + }); + const diff = diffStates(state, next); + expect(diff.clipIds.size).toBe(0); + expect(diff.trackIds).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/phase7-workers.test.ts b/packages/core/src/__tests__/phase7-workers.test.ts new file mode 100644 index 0000000..c034437 --- /dev/null +++ b/packages/core/src/__tests__/phase7-workers.test.ts @@ -0,0 +1,246 @@ +/** + * Phase 7 Step 4 — Worker contracts, ThumbnailCache, ThumbnailQueue + * + * No actual Worker instantiation. Type and behavior tests only. + */ + +import { describe, it, expect } from 'vitest'; +import { toFrame } from '../types/frame'; +import { toClipId } from '../types/clip'; +import { toAssetId } from '../types/asset'; +import { ThumbnailCache } from '../engine/thumbnail-cache'; +import { ThumbnailQueue } from '../engine/thumbnail-queue'; +import type { ThumbnailRequest, ThumbnailResult } from '../types/pipeline'; +import type { + WaveformRequest, + WaveformResult, + WaveformWorkerMessage, +} from '../types/worker-contracts'; + +function makeThumbRequest( + clipId: string, + mediaFrame: number, + width = 100, + height = 50, +): ThumbnailRequest { + return { + clipId: toClipId(clipId), + mediaFrame: toFrame(mediaFrame), + width, + height, + }; +} + +function makeThumbResult(clipId: string, mediaFrame: number): ThumbnailResult { + return { + clipId: toClipId(clipId), + mediaFrame: toFrame(mediaFrame), + bitmap: null, + }; +} + +describe('Phase 7 — ThumbnailCache', () => { + it('1. get() returns null on miss', () => { + const cache = new ThumbnailCache(); + expect(cache.get(makeThumbRequest('c1', 0))).toBeNull(); + }); + + it('2. set() then get() returns result', () => { + const cache = new ThumbnailCache(); + const req = makeThumbRequest('c1', 10); + const res = makeThumbResult('c1', 10); + cache.set(req, res); + expect(cache.get(req)).toEqual(res); + }); + + it('3. has() returns true after set', () => { + const cache = new ThumbnailCache(); + const req = makeThumbRequest('c1', 0); + cache.set(req, makeThumbResult('c1', 0)); + expect(cache.has(req)).toBe(true); + }); + + it('4. LRU eviction: set maxSize=2, add 3 entries, oldest evicted', () => { + const cache = new ThumbnailCache(2); + cache.set(makeThumbRequest('c1', 0), makeThumbResult('c1', 0)); + cache.set(makeThumbRequest('c2', 0), makeThumbResult('c2', 0)); + cache.set(makeThumbRequest('c3', 0), makeThumbResult('c3', 0)); + expect(cache.size).toBe(2); + expect(cache.get(makeThumbRequest('c1', 0))).toBeNull(); + expect(cache.get(makeThumbRequest('c2', 0))).not.toBeNull(); + expect(cache.get(makeThumbRequest('c3', 0))).not.toBeNull(); + }); + + it('5. LRU order: get() on existing promotes it (access oldest, then add new → middle evicted)', () => { + const cache = new ThumbnailCache(2); + cache.set(makeThumbRequest('c1', 0), makeThumbResult('c1', 0)); + cache.set(makeThumbRequest('c2', 0), makeThumbResult('c2', 0)); + cache.get(makeThumbRequest('c1', 0)); + cache.set(makeThumbRequest('c3', 0), makeThumbResult('c3', 0)); + expect(cache.get(makeThumbRequest('c1', 0))).not.toBeNull(); + expect(cache.get(makeThumbRequest('c2', 0))).toBeNull(); + expect(cache.get(makeThumbRequest('c3', 0))).not.toBeNull(); + }); + + it('6. invalidateClip removes all entries for clipId', () => { + const cache = new ThumbnailCache(); + cache.set(makeThumbRequest('c1', 0), makeThumbResult('c1', 0)); + cache.set(makeThumbRequest('c1', 10), makeThumbResult('c1', 10)); + cache.set(makeThumbRequest('c2', 0), makeThumbResult('c2', 0)); + cache.invalidateClip(toClipId('c1')); + expect(cache.has(makeThumbRequest('c1', 0))).toBe(false); + expect(cache.has(makeThumbRequest('c1', 10))).toBe(false); + expect(cache.has(makeThumbRequest('c2', 0))).toBe(true); + }); + + it('7. clear() empties cache', () => { + const cache = new ThumbnailCache(); + cache.set(makeThumbRequest('c1', 0), makeThumbResult('c1', 0)); + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.get(makeThumbRequest('c1', 0))).toBeNull(); + }); + + it('8. size reflects current count', () => { + const cache = new ThumbnailCache(); + expect(cache.size).toBe(0); + cache.set(makeThumbRequest('c1', 0), makeThumbResult('c1', 0)); + expect(cache.size).toBe(1); + cache.set(makeThumbRequest('c2', 0), makeThumbResult('c2', 0)); + expect(cache.size).toBe(2); + }); +}); + +describe('Phase 7 — ThumbnailQueue', () => { + it('9. enqueue adds entry', () => { + const q = new ThumbnailQueue(); + q.enqueue(makeThumbRequest('c1', 0)); + expect(q.length).toBe(1); + const e = q.dequeue(); + expect(e?.request.clipId).toBe(toClipId('c1')); + }); + + it('10. dequeue returns highest priority first', () => { + const q = new ThumbnailQueue(); + q.enqueue(makeThumbRequest('c1', 0), 'low'); + q.enqueue(makeThumbRequest('c2', 0), 'high'); + q.enqueue(makeThumbRequest('c3', 0), 'normal'); + expect(q.dequeue()?.request.clipId).toBe(toClipId('c2')); + expect(q.dequeue()?.request.clipId).toBe(toClipId('c3')); + expect(q.dequeue()?.request.clipId).toBe(toClipId('c1')); + }); + + it('11. Tiebreak: same priority → earlier addedAt first', () => { + const q = new ThumbnailQueue(); + q.enqueue(makeThumbRequest('c1', 0), 'normal'); + q.enqueue(makeThumbRequest('c2', 0), 'normal'); + q.enqueue(makeThumbRequest('c3', 0), 'normal'); + expect(q.dequeue()?.request.clipId).toBe(toClipId('c1')); + expect(q.dequeue()?.request.clipId).toBe(toClipId('c2')); + expect(q.dequeue()?.request.clipId).toBe(toClipId('c3')); + }); + + it('12. dequeue on empty returns null', () => { + const q = new ThumbnailQueue(); + expect(q.dequeue()).toBeNull(); + }); + + it('13. Duplicate clipId+frame: higher priority wins', () => { + const q = new ThumbnailQueue(); + q.enqueue(makeThumbRequest('c1', 0), 'low'); + q.enqueue(makeThumbRequest('c1', 0), 'high'); + expect(q.length).toBe(1); + expect(q.dequeue()?.priority).toBe('high'); + }); + + it('14. Duplicate clipId+frame: lower priority ignored', () => { + const q = new ThumbnailQueue(); + q.enqueue(makeThumbRequest('c1', 0), 'high'); + q.enqueue(makeThumbRequest('c1', 0), 'low'); + expect(q.length).toBe(1); + expect(q.dequeue()?.priority).toBe('high'); + }); + + it('15. cancel removes all entries for clipId', () => { + const q = new ThumbnailQueue(); + q.enqueue(makeThumbRequest('c1', 0)); + q.enqueue(makeThumbRequest('c1', 10)); + q.enqueue(makeThumbRequest('c2', 0)); + q.cancel(toClipId('c1')); + expect(q.length).toBe(1); + expect(q.dequeue()?.request.clipId).toBe(toClipId('c2')); + }); + + it('16. setPriority updates existing entry', () => { + const q = new ThumbnailQueue(); + q.enqueue(makeThumbRequest('c1', 5), 'low'); + q.setPriority(toClipId('c1'), toFrame(5), 'high'); + expect(q.dequeue()?.priority).toBe('high'); + }); + + it('17. peek returns top without removing', () => { + const q = new ThumbnailQueue(); + q.enqueue(makeThumbRequest('c1', 0), 'high'); + const top = q.peek(); + expect(top?.request.clipId).toBe(toClipId('c1')); + expect(q.length).toBe(1); + expect(q.dequeue()?.request.clipId).toBe(toClipId('c1')); + }); + + it('18. length reflects queue size', () => { + const q = new ThumbnailQueue(); + expect(q.length).toBe(0); + q.enqueue(makeThumbRequest('c1', 0)); + expect(q.length).toBe(1); + q.dequeue(); + expect(q.length).toBe(0); + }); +}); + +describe('Phase 7 — WaveformWorkerMessage type tests', () => { + it('19. WaveformRequest has all required fields', () => { + const req: WaveformRequest = { + requestId: 'r1', + assetId: toAssetId('a1'), + channel: 0, + startFrame: toFrame(0), + endFrame: toFrame(100), + buckets: 50, + sampleRate: 48000, + }; + expect(req.requestId).toBe('r1'); + expect(req.assetId).toBeDefined(); + expect(req.channel).toBe(0); + expect(req.buckets).toBe(50); + expect(req.sampleRate).toBe(48000); + }); + + it('20. WaveformResult has peaks array', () => { + const res: WaveformResult = { + requestId: 'r1', + assetId: toAssetId('a1'), + peaks: [{ min: -0.5, max: 0.8, rms: 0.3 }], + }; + expect(res.peaks).toHaveLength(1); + expect(res.peaks[0]!.min).toBe(-0.5); + expect(res.peaks[0]!.max).toBe(0.8); + }); + + it('21. WaveformWorkerMessage type: request | cancel discriminated union compiles', () => { + const req: WaveformWorkerMessage = { + type: 'request', + payload: { + requestId: 'r1', + assetId: toAssetId('a1'), + channel: 0, + startFrame: toFrame(0), + endFrame: toFrame(100), + buckets: 10, + sampleRate: 48000, + }, + }; + expect(req.type).toBe('request'); + const cancel: WaveformWorkerMessage = { type: 'cancel', requestId: 'r1' }; + expect(cancel.type).toBe('cancel'); + }); +}); diff --git a/packages/core/src/__tests__/provisional.test.ts b/packages/core/src/__tests__/provisional.test.ts new file mode 100644 index 0000000..5f6240c --- /dev/null +++ b/packages/core/src/__tests__/provisional.test.ts @@ -0,0 +1,213 @@ +/** + * PROVISIONAL MANAGER TESTS — Phase 1 + * + * Gate conditions: + * ✓ createProvisionalManager: current is null + * ✓ setProvisional: sets current, returns new object (no mutation) + * ✓ clearProvisional: resets current to null, returns new object (no mutation) + * ✓ resolveClip: provisional version wins over committed when both exist + * ✓ resolveClip: returns committed when no provisional override + * ✓ resolveClip: returns undefined when absent from both (deleted-during-drag) + * ✓ resolveClip: returns provisional clip even when absent from committed state + * ✓ resolveClip: correct clip returned when multiple clips in provisional + */ + +import { describe, it, expect } from 'vitest'; +import { + createProvisionalManager, + setProvisional, + clearProvisional, + resolveClip, +} from '../tools/provisional'; +import type { ProvisionalState } from '../tools/types'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, toAssetId } from '../types/asset'; +import { toFrame, toTimecode } from '../types/frame'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeEmptyState() { + const timeline = createTimeline({ + id: 'tl', name: 'T', fps: 30, duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), tracks: [], + }); + return createTimelineState({ timeline }); +} + +function makeClip(id: string, start: number, end: number) { + return createClip({ + id, + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(start), + timelineEnd: toFrame(end), + mediaIn: toFrame(0), + mediaOut: toFrame(end - start), + }); +} + +function makeStateWithClip(clip: ReturnType) { + const asset = createAsset({ + id: 'asset-1', name: 'A', mediaType: 'video', + filePath: '/a.mp4', intrinsicDuration: toFrame(10000), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), + }); + const track = createTrack({ + id: 'track-1', name: 'V1', type: 'video', + clips: [clip], + }); + const timeline = createTimeline({ + id: 'tl', name: 'T', fps: 30, duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), tracks: [track], + }); + return createTimelineState({ + timeline, + assetRegistry: new Map([[toAssetId('asset-1'), asset]]), + }); +} + +function makeProvisional(clips: ReturnType[]): ProvisionalState { + return { clips, isProvisional: true }; +} + +// ── createProvisionalManager ────────────────────────────────────────────────── + +describe('createProvisionalManager', () => { + it('creates a manager with current = null', () => { + const manager = createProvisionalManager(); + expect(manager.current).toBeNull(); + }); +}); + +// ── setProvisional ───────────────────────────────────────────────────────────── + +describe('setProvisional', () => { + it('sets current to the provided provisional state', () => { + const clip = makeClip('c1', 0, 100); + const provisional = makeProvisional([clip]); + const manager = setProvisional(createProvisionalManager(), provisional); + expect(manager.current).toBe(provisional); + }); + + it('returns a NEW object — does not mutate the original manager', () => { + const original = createProvisionalManager(); + const clip = makeClip('c1', 0, 100); + const next = setProvisional(original, makeProvisional([clip])); + expect(original.current).toBeNull(); // original unchanged + expect(next).not.toBe(original); // different reference + }); + + it('can overwrite an existing provisional with a new one', () => { + const clip1 = makeClip('c1', 0, 100); + const clip2 = makeClip('c2', 0, 200); + const m1 = setProvisional(createProvisionalManager(), makeProvisional([clip1])); + const m2 = setProvisional(m1, makeProvisional([clip2])); + expect(m2.current!.clips[0]!.id).toBe('c2'); + expect(m1.current!.clips[0]!.id).toBe('c1'); // m1 unchanged + }); +}); + +// ── clearProvisional ────────────────────────────────────────────────────────── + +describe('clearProvisional', () => { + it('resets current to null', () => { + const clip = makeClip('c1', 0, 100); + const withProvisional = setProvisional(createProvisionalManager(), makeProvisional([clip])); + const cleared = clearProvisional(withProvisional); + expect(cleared.current).toBeNull(); + }); + + it('returns a NEW object — does not mutate the original manager', () => { + const clip = makeClip('c1', 0, 100); + const original = setProvisional(createProvisionalManager(), makeProvisional([clip])); + const cleared = clearProvisional(original); + expect(original.current).not.toBeNull(); // original unchanged + expect(cleared).not.toBe(original); + }); + + it('clearing an already-null manager returns a new manager with null', () => { + const original = createProvisionalManager(); + const cleared = clearProvisional(original); + expect(cleared.current).toBeNull(); + }); +}); + +// ── resolveClip ─────────────────────────────────────────────────────────────── + +describe('resolveClip — no provisional', () => { + it('returns the committed clip when provisional is null', () => { + const clip = makeClip('c1', 0, 100); + const state = makeStateWithClip(clip); + const manager = createProvisionalManager(); // current = null + const result = resolveClip(toClipId('c1'), state, manager); + expect(result).toBeDefined(); + expect(result!.timelineStart).toBe(toFrame(0)); + }); + + it('returns undefined when clip absent from committed state and no provisional', () => { + const state = makeEmptyState(); + const manager = createProvisionalManager(); + const result = resolveClip(toClipId('ghost'), state, manager); + expect(result).toBeUndefined(); + }); +}); + +describe('resolveClip — provisional overrides committed', () => { + it('returns provisional version when committed and provisional differ (ghost rendering)', () => { + const committed = makeClip('c1', 0, 100); // at frame 0 + const ghost = { ...committed, timelineStart: toFrame(50) }; // dragged to 50 + const state = makeStateWithClip(committed); + const manager = setProvisional(createProvisionalManager(), makeProvisional([ghost])); + + const result = resolveClip(toClipId('c1'), state, manager); + expect(result?.timelineStart).toBe(toFrame(50)); // ghost wins + }); + + it('returns committed clip when provisional has different clip id', () => { + const committed = makeClip('c1', 0, 100); + const otherGhost = makeClip('c2', 50, 150); // different id + const state = makeStateWithClip(committed); + const manager = setProvisional(createProvisionalManager(), makeProvisional([otherGhost])); + + const result = resolveClip(toClipId('c1'), state, manager); + expect(result?.timelineStart).toBe(toFrame(0)); // committed, not ghost + }); + + it('returns correct clip when multiple clips are in provisional', () => { + const committed1 = makeClip('c1', 0, 100); + const committed2 = makeClip('c2', 200, 300); + const ghost1 = { ...committed1, timelineStart: toFrame(50) }; + const ghost2 = { ...committed2, timelineStart: toFrame(250) }; + + const state = makeStateWithClip(committed1); // only c1 in committed + const manager = setProvisional(createProvisionalManager(), makeProvisional([ghost1, ghost2])); + + expect(resolveClip(toClipId('c1'), state, manager)?.timelineStart).toBe(toFrame(50)); + expect(resolveClip(toClipId('c2'), state, manager)?.timelineStart).toBe(toFrame(250)); + }); +}); + +describe('resolveClip — deleted-during-drag case', () => { + it('returns undefined when clip absent from both provisional and committed state', () => { + // This is the deleted-during-drag case: + // provisional was cleared, and the clip was also removed from committed state + const state = makeEmptyState(); + const manager = clearProvisional(createProvisionalManager()); + const result = resolveClip(toClipId('ghost'), state, manager); + expect(result).toBeUndefined(); + }); + + it('returns provisional clip even when absent from committed state (added ghost)', () => { + // A ghost of a newly "previewed" clip that doesn't exist in committed state yet + const ghost = makeClip('preview-clip', 0, 100); + const state = makeEmptyState(); // clip not in committed + const manager = setProvisional(createProvisionalManager(), makeProvisional([ghost])); + + const result = resolveClip(toClipId('preview-clip'), state, manager); + expect(result).toBeDefined(); + expect(result!.id).toBe('preview-clip'); + }); +}); diff --git a/packages/core/src/__tests__/registry.test.ts b/packages/core/src/__tests__/registry.test.ts new file mode 100644 index 0000000..1504bc5 --- /dev/null +++ b/packages/core/src/__tests__/registry.test.ts @@ -0,0 +1,230 @@ +/** + * TOOL REGISTRY TESTS — Phase 1 + * + * Gate conditions: + * ✓ createRegistry: builds registry with correct active tool + * ✓ createRegistry: throws on unknown defaultId + * ✓ activateTool: switches activeToolId + * ✓ activateTool: calls onCancel() on the OUTGOING tool + * ✓ activateTool: throws on unknown id + * ✓ getActiveTool: returns correct tool + * ✓ registerTool: adds new tool, does not change activeToolId + * ✓ registerTool: replaces existing tool with same id + * ✓ All functions are pure (original registry never mutated) + * ✓ NoOpTool satisfies ITool interface + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + createRegistry, + activateTool, + getActiveTool, + registerTool, + NoOpTool, + type ToolRegistry, +} from '../tools/registry'; +import type { ITool, ToolContext, TimelinePointerEvent, TimelineKeyEvent } from '../tools/types'; +import { toToolId } from '../tools/types'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +/** Clone NoOpTool with a different id for testing */ +function makeTool(id: string): ITool { + return { ...NoOpTool, id: toToolId(id) }; +} + +const toolA = makeTool('tool-a'); +const toolB = makeTool('tool-b'); +const toolC = makeTool('tool-c'); + +// ── createRegistry ──────────────────────────────────────────────────────────── + +describe('createRegistry', () => { + it('creates a registry with the correct active tool', () => { + const reg = createRegistry([toolA, toolB], toToolId('tool-a')); + expect(reg.activeToolId).toBe('tool-a'); + expect(reg.tools.size).toBe(2); + }); + + it('indexes tools by id', () => { + const reg = createRegistry([toolA, toolB], toToolId('tool-a')); + expect(reg.tools.has(toToolId('tool-a'))).toBe(true); + expect(reg.tools.has(toToolId('tool-b'))).toBe(true); + }); + + it('throws if defaultId is not in the tools array', () => { + expect(() => + createRegistry([toolA], toToolId('tool-b')) + ).toThrow('tool-b'); + }); + + it('throws on empty tools array with any defaultId', () => { + expect(() => + createRegistry([], toToolId('x')) + ).toThrow(); + }); +}); + +// ── activateTool ────────────────────────────────────────────────────────────── + +describe('activateTool', () => { + it('returns a new registry with activeToolId updated', () => { + const reg = createRegistry([toolA, toolB], toToolId('tool-a')); + const next = activateTool(reg, toToolId('tool-b')); + expect(next.activeToolId).toBe('tool-b'); + }); + + it('does NOT mutate the original registry', () => { + const reg = createRegistry([toolA, toolB], toToolId('tool-a')); + activateTool(reg, toToolId('tool-b')); + expect(reg.activeToolId).toBe('tool-a'); // unchanged + }); + + it('tools map reference is preserved (same tools, same map entries)', () => { + const reg = createRegistry([toolA, toolB], toToolId('tool-a')); + const next = activateTool(reg, toToolId('tool-b')); + expect(next.tools).toBe(reg.tools); // same map reference — no copy needed + }); + + it('calls onCancel() on the OUTGOING tool when switching', () => { + const cancelSpy = vi.fn(); + const spyTool: ITool = { ...NoOpTool, id: toToolId('spy'), onCancel: cancelSpy }; + const reg = createRegistry([NoOpTool, spyTool], toToolId('spy')); + + activateTool(reg, toToolId('noop')); + + expect(cancelSpy).toHaveBeenCalledOnce(); + }); + + it('does NOT call onCancel on the incoming tool', () => { + const cancelSpy = vi.fn(); + const incomingTool: ITool = { ...NoOpTool, id: toToolId('incoming'), onCancel: cancelSpy }; + const reg = createRegistry([toolA, incomingTool], toToolId('tool-a')); + + activateTool(reg, toToolId('incoming')); + + expect(cancelSpy).not.toHaveBeenCalled(); + }); + + it('calls onCancel on outgoing even when switching to the same tool', () => { + // Switching tool-a → tool-a should still cancel (handles re-activation) + const cancelSpy = vi.fn(); + const selfSwitchTool: ITool = { ...NoOpTool, id: toToolId('self'), onCancel: cancelSpy }; + const reg = createRegistry([selfSwitchTool], toToolId('self')); + + activateTool(reg, toToolId('self')); + + expect(cancelSpy).toHaveBeenCalledOnce(); + }); + + it('throws if id is not in the registry', () => { + const reg = createRegistry([toolA], toToolId('tool-a')); + expect(() => + activateTool(reg, toToolId('nonexistent')) + ).toThrow('nonexistent'); + }); + + it('throws AFTER calling onCancel on the outgoing tool', () => { + // onCancel must fire even if the incoming id is invalid + const cancelSpy = vi.fn(); + const spyTool: ITool = { ...NoOpTool, id: toToolId('spy2'), onCancel: cancelSpy }; + const reg = createRegistry([spyTool], toToolId('spy2')); + + expect(() => activateTool(reg, toToolId('bad-id'))).toThrow(); + expect(cancelSpy).toHaveBeenCalledOnce(); // onCancel fired before the throw + }); +}); + +// ── getActiveTool ───────────────────────────────────────────────────────────── + +describe('getActiveTool', () => { + it('returns the tool matching activeToolId', () => { + const reg = createRegistry([toolA, toolB], toToolId('tool-b')); + expect(getActiveTool(reg).id).toBe('tool-b'); + }); + + it('returns the same object reference as in the map', () => { + const reg = createRegistry([toolA, toolB], toToolId('tool-a')); + expect(getActiveTool(reg)).toBe(toolA); + }); +}); + +// ── registerTool ────────────────────────────────────────────────────────────── + +describe('registerTool', () => { + it('adds a new tool to the registry', () => { + const reg = createRegistry([toolA], toToolId('tool-a')); + const next = registerTool(reg, toolC); + expect(next.tools.has(toToolId('tool-c'))).toBe(true); + expect(next.tools.size).toBe(2); + }); + + it('does not change activeToolId', () => { + const reg = createRegistry([toolA], toToolId('tool-a')); + const next = registerTool(reg, toolC); + expect(next.activeToolId).toBe('tool-a'); + }); + + it('replaces an existing tool with the same id', () => { + const reg = createRegistry([toolA], toToolId('tool-a')); + const replacedA: ITool = { ...NoOpTool, id: toToolId('tool-a'), shortcutKey: 'x' }; + const next = registerTool(reg, replacedA); + expect(next.tools.size).toBe(1); // no duplicate + expect(next.tools.get(toToolId('tool-a'))!.shortcutKey).toBe('x'); + }); + + it('does NOT mutate the original registry', () => { + const reg = createRegistry([toolA], toToolId('tool-a')); + registerTool(reg, toolC); + expect(reg.tools.has(toToolId('tool-c'))).toBe(false); + }); +}); + +// ── NoOpTool ────────────────────────────────────────────────────────────────── + +describe('NoOpTool', () => { + it('has id "noop"', () => { + expect(NoOpTool.id).toBe('noop'); + }); + + it('getCursor returns "default"', () => { + expect(NoOpTool.getCursor({} as ToolContext)).toBe('default'); + }); + + it('getSnapCandidateTypes returns empty array', () => { + expect(NoOpTool.getSnapCandidateTypes()).toEqual([]); + }); + + it('onPointerMove returns null', () => { + const result = NoOpTool.onPointerMove( + {} as TimelinePointerEvent, + {} as ToolContext, + ); + expect(result).toBeNull(); + }); + + it('onPointerUp returns null', () => { + const result = NoOpTool.onPointerUp( + {} as TimelinePointerEvent, + {} as ToolContext, + ); + expect(result).toBeNull(); + }); + + it('onKeyDown returns null', () => { + const result = NoOpTool.onKeyDown( + {} as TimelineKeyEvent, + {} as ToolContext, + ); + expect(result).toBeNull(); + }); + + it('onCancel does not throw', () => { + expect(() => NoOpTool.onCancel()).not.toThrow(); + }); + + it('can be used as default in createRegistry', () => { + const reg = createRegistry([NoOpTool], toToolId('noop')); + expect(getActiveTool(reg).id).toBe('noop'); + }); +}); diff --git a/packages/core/src/__tests__/snap-index.test.ts b/packages/core/src/__tests__/snap-index.test.ts new file mode 100644 index 0000000..431e590 --- /dev/null +++ b/packages/core/src/__tests__/snap-index.test.ts @@ -0,0 +1,314 @@ +/** + * SNAP INDEX TESTS — Phase 1 + * + * Gate conditions (all must pass before Step 2): + * ✓ 3 clips → 6 points (ClipStart + ClipEnd each) + * ✓ Points sorted ascending by frame + * ✓ nearest() snaps within radius, returns null outside + * ✓ nearest() respects exclusion list (sourceId) + * ✓ nearest() respects allowedTypes filter + * ✓ Priority tiebreak: Marker (100) beats ClipStart (80) at same frame + * ✓ toggleSnap(index, false) → nearest() always returns null + */ + +import { describe, it, expect } from 'vitest'; +import { + buildSnapIndex, + nearest, + toggleSnap, + type SnapPoint, + type SnapIndex, +} from '../snap-index'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, toAssetId } from '../types/asset'; +import { toFrame, toTimecode } from '../types/frame'; + +// ── Fixtures ───────────────────────────────────────────────────────────────── + +function makeStateWithClips(clips: Array<{ id: string; start: number; end: number }>) { + const assetId = toAssetId('asset-1'); + const asset = createAsset({ + id: 'asset-1', name: 'A', mediaType: 'video', + filePath: '/a.mp4', intrinsicDuration: toFrame(10000), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), + }); + + const trackClips = clips.map(({ id, start, end }) => + createClip({ + id, assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(start), timelineEnd: toFrame(end), + mediaIn: toFrame(0), mediaOut: toFrame(end - start), + }) + ); + + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: trackClips }); + const timeline = createTimeline({ + id: 'tl', name: 'T', fps: 30, duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), tracks: [track], + }); + + return createTimelineState({ timeline, assetRegistry: new Map([[assetId, asset]]) }); +} + +// ── buildSnapIndex ──────────────────────────────────────────────────────────── + +describe('buildSnapIndex — point generation', () => { + it('3 clips produce 6 clip boundary points + 1 playhead = 7 total', () => { + const state = makeStateWithClips([ + { id: 'c1', start: 0, end: 100 }, + { id: 'c2', start: 200, end: 300 }, + { id: 'c3', start: 400, end: 500 }, + ]); + + const index = buildSnapIndex(state, toFrame(150)); + // 6 clip boundaries + 1 playhead + expect(index.points.length).toBe(7); + + const types = index.points.map(p => p.type); + expect(types.filter(t => t === 'ClipStart').length).toBe(3); + expect(types.filter(t => t === 'ClipEnd').length).toBe(3); + expect(types.filter(t => t === 'Playhead').length).toBe(1); + }); + + it('points are sorted ascending by frame', () => { + const state = makeStateWithClips([ + { id: 'c1', start: 400, end: 500 }, + { id: 'c2', start: 0, end: 100 }, + { id: 'c3', start: 200, end: 300 }, + ]); + + const index = buildSnapIndex(state, toFrame(999)); + const frames = index.points.map(p => p.frame); + for (let i = 1; i < frames.length; i++) { + expect(frames[i]).toBeGreaterThanOrEqual(frames[i - 1]!); + } + }); + + it('each clip point carries the correct sourceId (clipId)', () => { + const state = makeStateWithClips([{ id: 'clip-xyz', start: 100, end: 200 }]); + const index = buildSnapIndex(state, toFrame(0)); + const clipPoints = index.points.filter(p => p.sourceId === 'clip-xyz'); + expect(clipPoints.length).toBe(2); // ClipStart + ClipEnd + }); + + it('playhead point has sourceId "__playhead__" and trackId null', () => { + const state = makeStateWithClips([]); + const index = buildSnapIndex(state, toFrame(500)); + const ph = index.points.find(p => p.type === 'Playhead'); + expect(ph).toBeDefined(); + expect(ph!.sourceId).toBe('__playhead__'); + expect(ph!.trackId).toBeNull(); + expect(ph!.frame).toBe(500); + }); + + it('enabled defaults to true', () => { + const state = makeStateWithClips([]); + const index = buildSnapIndex(state, toFrame(0)); + expect(index.enabled).toBe(true); + }); + + it('enabled can be set to false at build time', () => { + const state = makeStateWithClips([]); + const index = buildSnapIndex(state, toFrame(0), false); + expect(index.enabled).toBe(false); + }); + + it('empty timeline produces only the playhead point', () => { + const timeline = createTimeline({ id: 'tl', name: 'T', fps: 30, duration: toFrame(1000) }); + const state = createTimelineState({ timeline }); + const index = buildSnapIndex(state, toFrame(0)); + expect(index.points.length).toBe(1); + expect(index.points[0]!.type).toBe('Playhead'); + }); +}); + +// ── nearest ─────────────────────────────────────────────────────────────────── + +describe('nearest — radius behaviour', () => { + // Clip at [100..200]: ClipStart at frame 100, ClipEnd at frame 200 + function makeIndexWithClip() { + const state = makeStateWithClips([{ id: 'c1', start: 100, end: 200 }]); + return buildSnapIndex(state, toFrame(999)); // playhead far away + } + + it('returns null when index.enabled is false regardless of proximity', () => { + const index = toggleSnap(makeIndexWithClip(), false); + const result = nearest(index, toFrame(100), 10); + expect(result).toBeNull(); + }); + + it('snaps to ClipStart at exact frame distance 0', () => { + const index = makeIndexWithClip(); + const result = nearest(index, toFrame(100), 5); + expect(result).not.toBeNull(); + expect(result!.frame).toBe(100); + expect(result!.type).toBe('ClipStart'); + }); + + it('snaps to a boundary within radius (frame 98, radius 5 → boundary at 100)', () => { + const index = makeIndexWithClip(); + const result = nearest(index, toFrame(98), 5); // distance = 2, ≤ 5 + expect(result).not.toBeNull(); + expect(result!.frame).toBe(100); + }); + + it('returns null when nothing is within radius', () => { + const index = makeIndexWithClip(); + const result = nearest(index, toFrame(150), 5); // nearest is 50 frames away + expect(result).toBeNull(); + }); + + it('snaps at exactly radiusFrames distance (boundary case — inclusive)', () => { + const index = makeIndexWithClip(); + // frame 95, radius 5 → distance to 100 is exactly 5 + const result = nearest(index, toFrame(95), 5); + expect(result).not.toBeNull(); + expect(result!.frame).toBe(100); + }); + + it('does not snap at radiusFrames + 1 (just outside)', () => { + const index = makeIndexWithClip(); + // frame 94, radius 5 → distance to 100 is 6 + const result = nearest(index, toFrame(94), 5); + expect(result).toBeNull(); + }); +}); + +describe('nearest — exclusion list', () => { + it('skips snap points whose sourceId is in the exclude list', () => { + const state = makeStateWithClips([ + { id: 'dragging', start: 0, end: 100 }, + { id: 'anchor', start: 200, end: 300 }, + ]); + const index = buildSnapIndex(state, toFrame(999)); + + // Frame 0 would snap to 'dragging' ClipStart — but it's excluded + const result = nearest(index, toFrame(0), 10, ['dragging']); + // Should not return a point from 'dragging' + expect(result).toBeNull(); + }); + + it('still snaps to non-excluded points near the same frame', () => { + // Two clips: c1 starts at 100, c2 ends at 102 (different sourceId) + const state = makeStateWithClips([ + { id: 'c1', start: 100, end: 200 }, + { id: 'c2', start: 0, end: 102 }, + ]); + const index = buildSnapIndex(state, toFrame(999)); + + // Exclude c1 — c2's ClipEnd at 102 is within radius 5 of frame 100 + const result = nearest(index, toFrame(100), 5, ['c1']); + expect(result).not.toBeNull(); + expect(result!.sourceId).toBe('c2'); + }); +}); + +describe('nearest — allowedTypes filter', () => { + it('ignores Playhead when allowedTypes excludes Playhead', () => { + const state = makeStateWithClips([]); + // Only point is Playhead at frame 100 + const index = buildSnapIndex(state, toFrame(100)); + const result = nearest(index, toFrame(100), 10, [], ['ClipStart', 'ClipEnd']); + expect(result).toBeNull(); // Playhead filtered out + }); + + it('returns Playhead when allowedTypes includes it', () => { + const state = makeStateWithClips([]); + const index = buildSnapIndex(state, toFrame(100)); + const result = nearest(index, toFrame(100), 10, [], ['Playhead']); + expect(result).not.toBeNull(); + expect(result!.type).toBe('Playhead'); + }); + + it('only returns ClipEnd points when allowedTypes = ["ClipEnd"]', () => { + const state = makeStateWithClips([{ id: 'c1', start: 100, end: 105 }]); + const index = buildSnapIndex(state, toFrame(999)); + + // Both ClipStart(100) and ClipEnd(105) are within radius 10 of frame 102 + const result = nearest(index, toFrame(102), 10, [], ['ClipEnd']); + expect(result).not.toBeNull(); + expect(result!.type).toBe('ClipEnd'); + expect(result!.frame).toBe(105); + }); +}); + +describe('nearest — priority tiebreak', () => { + it('returns higher-priority point when two points are equidistant', () => { + // Simulate a Marker at the same frame as a ClipStart + // Inject a Marker-type point manually into a custom SnapIndex + const state = makeStateWithClips([{ id: 'c1', start: 100, end: 200 }]); + const base = buildSnapIndex(state, toFrame(999)); + + // Inject a Marker at frame 100 with priority 100 + const markerPoint: SnapPoint = { + frame: toFrame(100), type: 'Marker', priority: 100, + trackId: null, sourceId: 'marker-1', + }; + const withMarker: SnapIndex = { + ...base, + points: [...base.points, markerPoint].sort((a, b) => a.frame - b.frame), + }; + + const result = nearest(withMarker, toFrame(100), 5); + // Marker (priority 100) > ClipStart (priority 80) → Marker wins + expect(result).not.toBeNull(); + expect(result!.type).toBe('Marker'); + expect(result!.sourceId).toBe('marker-1'); + }); + + it('returns closer point when two candidates have different distances and same priority', () => { + const state = makeStateWithClips([ + { id: 'c1', start: 97, end: 500 }, // ClipStart at 97, dist = 3 + { id: 'c2', start: 103, end: 500 }, // ClipStart at 103, dist = 3 — equal! + ]); + const index = buildSnapIndex(state, toFrame(999)); + // At frame 100 both are equidistant (distance 3). Same priority. + // First in sorted order (frame 97) should win. + const result = nearest(index, toFrame(100), 10); + expect(result).not.toBeNull(); + expect(result!.frame).toBe(97); + }); +}); + +// ── toggleSnap ──────────────────────────────────────────────────────────────── + +describe('toggleSnap', () => { + it('returns a new SnapIndex with enabled: false', () => { + const state = makeStateWithClips([{ id: 'c1', start: 100, end: 200 }]); + const index = buildSnapIndex(state, toFrame(999)); + + const disabled = toggleSnap(index, false); + expect(disabled.enabled).toBe(false); + // Points unchanged + expect(disabled.points.length).toBe(index.points.length); + }); + + it('nearest() always returns null when enabled is false', () => { + const state = makeStateWithClips([{ id: 'c1', start: 100, end: 200 }]); + const disabled = toggleSnap(buildSnapIndex(state, toFrame(999)), false); + + // Frame exactly on a clip boundary — still null + expect(nearest(disabled, toFrame(100), 100)).toBeNull(); + expect(nearest(disabled, toFrame(200), 100)).toBeNull(); + }); + + it('does not mutate the original index', () => { + const state = makeStateWithClips([]); + const original = buildSnapIndex(state, toFrame(0)); + toggleSnap(original, false); + expect(original.enabled).toBe(true); // untouched + }); + + it('re-enabling with toggleSnap(index, true) restores snap', () => { + const state = makeStateWithClips([{ id: 'c1', start: 100, end: 200 }]); + const index = buildSnapIndex(state, toFrame(999)); + const disabled = toggleSnap(index, false); + const restored = toggleSnap(disabled, true); + + const result = nearest(restored, toFrame(100), 5); + expect(result).not.toBeNull(); + }); +}); diff --git a/packages/core/src/__tests__/stress-tests.ts b/packages/core/src/__tests__/stress-tests.ts deleted file mode 100644 index 5729d5b..0000000 --- a/packages/core/src/__tests__/stress-tests.ts +++ /dev/null @@ -1,627 +0,0 @@ -/** - * STRESS TEST SUITE - * - * Tests system performance and stability with large-scale operations: - * - 1000 clips - * - 100 link groups - * - 50 nested groups - * - 500 markers - * - 200 ripple edits in a row - * - 50 transaction rollbacks - */ - -// Import all internal systems for testing -import { - TimelineEngine, - createTimeline, - createTrack, - createClip, - createAsset, - createTimelineState, - frame, - frameRate, - generateClipId, - generateTrackId, - generateAssetId, - generateTimelineId, - generateLinkGroupId, - generateGroupId, - generateMarkerId, - beginTransaction, - applyOperation, - commitTransaction, - rollbackTransaction, - createLinkGroup, - createGroup, - addTimelineMarker, - addClipMarker, - addRegionMarker, - rippleDelete, - addClip, -} from '../internal'; - -let testsPassed = 0; -let testsFailed = 0; - -function test(name: string, fn: () => void) { - try { - const start = Date.now(); - fn(); - const duration = Date.now() - start; - console.log(`✓ ${name} (${duration}ms)`); - testsPassed++; - } catch (error) { - console.error(`✗ ${name}`); - console.error(` Error: ${error instanceof Error ? error.message : String(error)}`); - testsFailed++; - } -} - -function assert(condition: boolean, message: string) { - if (!condition) { - throw new Error(message); - } -} - -// Setup helper -function createTestEngine() { - const timeline = createTimeline({ - id: generateTimelineId(), - name: 'Stress Test Timeline', - fps: frameRate(30), - duration: frame(1000000), // Very long timeline for stress testing - tracks: [], - }); - - const state = createTimelineState({ timeline }); - return new TimelineEngine(state); -} - -console.log('\n=== STRESS TEST SUITE ===\n'); -console.log('Testing system stability with large-scale operations...\n'); - -// ===== STRESS TEST 1: 1000 CLIPS ===== -console.log('--- Stress Test 1: 1000 Clips ---'); - -test('Add 1000 clips across 10 tracks', () => { - const engine = createTestEngine(); - - // Create 10 tracks - const tracks = []; - for (let i = 0; i < 10; i++) { - const track = createTrack({ - id: generateTrackId(), - name: `Track ${i + 1}`, - type: i % 2 === 0 ? 'video' : 'audio', - }); - engine.addTrack(track); - tracks.push(track); - } - - // Create assets - const assets = []; - for (let i = 0; i < 10; i++) { - const asset = createAsset({ - id: generateAssetId(), - type: i % 2 === 0 ? 'video' : 'audio', - duration: frame(1000), - sourceUrl: `test-${i}.mp4`, - }); - engine.registerAsset(asset); - assets.push(asset); - } - - // Add 1000 clips (100 per track) - let clipCount = 0; - for (let trackIdx = 0; trackIdx < 10; trackIdx++) { - const track = tracks[trackIdx]!; - const asset = assets[trackIdx]!; - - for (let clipIdx = 0; clipIdx < 100; clipIdx++) { - const startFrame = clipIdx * 1100; // 100 frame clip + 1000 frame gap - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(startFrame), - timelineEnd: frame(startFrame + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipCount++; - } - } - - assert(clipCount === 1000, 'Added 1000 clips'); - - const state = engine.getState(); - let totalClips = 0; - for (const track of state.timeline.tracks) { - totalClips += track.clips.length; - } - - assert(totalClips === 1000, 'State contains 1000 clips'); -}); - -// ===== STRESS TEST 2: 100 LINK GROUPS ===== -console.log('--- Stress Test 2: 100 Link Groups ---'); - -test('Create 100 link groups with 10 clips each', () => { - const engine = createTestEngine(); - - // Create 1 track - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - // Create asset - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 1000 clips (10 per link group) - const clipIds: string[] = []; - for (let i = 0; i < 1000; i++) { - const startFrame = i * 1100; - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(startFrame), - timelineEnd: frame(startFrame + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - // Create 100 link groups - let state = engine.getState(); - for (let groupIdx = 0; groupIdx < 100; groupIdx++) { - const groupClipIds = clipIds.slice(groupIdx * 10, (groupIdx + 1) * 10); - state = createLinkGroup(state, groupClipIds); - } - - assert(state.linkGroups.size === 100, 'Created 100 link groups'); - - // Verify each link group has 10 clips - let totalLinkedClips = 0; - for (const [_, linkGroup] of state.linkGroups) { - assert(linkGroup.clipIds.length === 10, 'Link group has 10 clips'); - totalLinkedClips += linkGroup.clipIds.length; - } - - assert(totalLinkedClips === 1000, 'All 1000 clips are linked'); -}); - -// ===== STRESS TEST 3: 50 NESTED GROUPS ===== -console.log('--- Stress Test 3: 50 Nested Groups ---'); - -test('Create 50 levels of nested groups', () => { - const engine = createTestEngine(); - - // Create track - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - // Create asset - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 50 clips (one per group level) - const clipIds: string[] = []; - for (let i = 0; i < 50; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 1100), - timelineEnd: frame(i * 1100 + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - // Create nested groups (each child is nested under previous parent) - let state = engine.getState(); - let parentGroupId: string | undefined = undefined; - - for (let i = 0; i < 50; i++) { - const options = parentGroupId ? { parentGroupId } : undefined; - state = createGroup(state, [clipIds[i]!], `Group Level ${i + 1}`, options); - - // Get the group ID of the clip we just added - const clip = state.timeline.tracks[0]!.clips.find(c => c.id === clipIds[i])!; - parentGroupId = clip.groupId; - } - - assert(state.groups.size === 50, 'Created 50 nested groups'); - - // Verify nesting depth - let depth = 0; - let currentParentId = parentGroupId; - while (currentParentId) { - depth++; - const group = state.groups.get(currentParentId); - currentParentId = group?.parentGroupId; - } - - assert(depth === 50, 'Nesting depth is 50 levels'); -}); - -// ===== STRESS TEST 4: 500 MARKERS ===== -console.log('--- Stress Test 4: 500 Markers ---'); - -test('Add 500 markers (200 timeline, 200 clip, 100 region)', () => { - const engine = createTestEngine(); - - // Create track - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - // Create asset - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(10000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 100 clips for clip markers - const clipIds: string[] = []; - for (let i = 0; i < 100; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 1100), - timelineEnd: frame(i * 1100 + 1000), - mediaIn: frame(0), - mediaOut: frame(1000), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - let state = engine.getState(); - - // Add 200 timeline markers - for (let i = 0; i < 200; i++) { - state = addTimelineMarker(state, { - id: generateMarkerId(), - type: 'timeline', - frame: frame(i * 500), - label: `Timeline Marker ${i + 1}`, - color: `#${Math.floor(Math.random() * 16777215).toString(16)}`, - }); - } - - // Add 200 clip markers (2 per clip) - for (let i = 0; i < 100; i++) { - const clipId = clipIds[i]!; - - state = addClipMarker(state, { - id: generateMarkerId(), - type: 'clip', - clipId, - frame: frame(250), - label: `Clip ${i + 1} Marker 1`, - }); - - state = addClipMarker(state, { - id: generateMarkerId(), - type: 'clip', - clipId, - frame: frame(750), - label: `Clip ${i + 1} Marker 2`, - }); - } - - // Add 100 region markers - for (let i = 0; i < 100; i++) { - state = addRegionMarker(state, { - id: generateMarkerId(), - type: 'region', - startFrame: frame(i * 1000), - endFrame: frame(i * 1000 + 500), - label: `Region ${i + 1}`, - }); - } - - assert(state.markers.timeline.length === 200, 'Added 200 timeline markers'); - assert(state.markers.clips.length === 200, 'Added 200 clip markers'); - assert(state.markers.regions.length === 100, 'Added 100 region markers'); - - const totalMarkers = state.markers.timeline.length + - state.markers.clips.length + - state.markers.regions.length; - assert(totalMarkers === 500, 'Total 500 markers'); -}); - -// ===== STRESS TEST 5: 200 RIPPLE EDITS IN A ROW ===== -console.log('--- Stress Test 5: 200 Ripple Edits in a Row ---'); - -test('Perform 200 consecutive ripple deletes', () => { - const engine = createTestEngine(); - - // Create track - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - // Create asset - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 300 clips - const clipIds: string[] = []; - for (let i = 0; i < 300; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(i * 100), // No gaps - clips are adjacent - timelineEnd: frame((i + 1) * 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - - let state = engine.getState(); - - // Perform 200 ripple deletes (always delete the first clip) - for (let i = 0; i < 200; i++) { - const firstClip = state.timeline.tracks[0]!.clips[0]; - if (!firstClip) { - throw new Error('No clips remaining'); - } - - state = rippleDelete(state, firstClip.id); - } - - // Should have 100 clips remaining - const remainingClips = state.timeline.tracks[0]!.clips.length; - assert(remainingClips === 100, `100 clips remaining (got ${remainingClips})`); - - // First clip should start at frame 0 (all previous clips rippled away) - const firstClipStart = state.timeline.tracks[0]!.clips[0]!.timelineStart; - assert(firstClipStart === frame(0), 'First clip starts at frame 0'); -}); - -// ===== STRESS TEST 6: 50 TRANSACTION ROLLBACKS ===== -console.log('--- Stress Test 6: 50 Transaction Rollbacks ---'); - -test('Perform 50 transaction rollbacks', () => { - const engine = createTestEngine(); - - // Create track - const track = createTrack({ - id: generateTrackId(), - name: 'Track 1', - type: 'video', - }); - engine.addTrack(track); - - // Create asset - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add initial clip - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - - const initialState = engine.getState(); - const initialClipCount = initialState.timeline.tracks[0]!.clips.length; - - // Perform 50 transaction rollbacks - for (let i = 0; i < 50; i++) { - let tx = beginTransaction(initialState); - - // Add multiple clips in transaction - for (let j = 0; j < 10; j++) { - const newClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame((i * 10 + j + 1) * 200), - timelineEnd: frame((i * 10 + j + 1) * 200 + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - tx = applyOperation(tx, s => addClip(s, track.id, newClip)); - } - - // Rollback the transaction - const rolledBackState = rollbackTransaction(tx); - - // Verify state is unchanged - assert( - rolledBackState.timeline.tracks[0]!.clips.length === initialClipCount, - `Rollback ${i + 1}: State unchanged` - ); - } - - // Final state should still have only 1 clip - assert(initialState.timeline.tracks[0]!.clips.length === 1, 'Final state has 1 clip'); -}); - -// ===== COMBINED STRESS TEST ===== -console.log('--- Combined Stress Test ---'); - -test('Combined: 500 clips + 50 link groups + 25 nested groups + 250 markers', () => { - const engine = createTestEngine(); - - // Create 5 tracks - const tracks = []; - for (let i = 0; i < 5; i++) { - const track = createTrack({ - id: generateTrackId(), - name: `Track ${i + 1}`, - type: 'video', - }); - engine.addTrack(track); - tracks.push(track); - } - - // Create asset - const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(1000), - sourceUrl: 'test.mp4', - }); - engine.registerAsset(asset); - - // Add 500 clips (100 per track) - const clipIds: string[] = []; - for (let trackIdx = 0; trackIdx < 5; trackIdx++) { - const track = tracks[trackIdx]!; - - for (let clipIdx = 0; clipIdx < 100; clipIdx++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(clipIdx * 1100), - timelineEnd: frame(clipIdx * 1100 + 100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, clip); - clipIds.push(clip.id); - } - } - - let state = engine.getState(); - - // Create 50 link groups (10 clips each) - for (let i = 0; i < 50; i++) { - const groupClipIds = clipIds.slice(i * 10, (i + 1) * 10); - state = createLinkGroup(state, groupClipIds); - } - - // Create 25 nested groups - let parentGroupId: string | undefined = undefined; - for (let i = 0; i < 25; i++) { - const options = parentGroupId ? { parentGroupId } : undefined; - state = createGroup(state, [clipIds[i]!], `Nested Group ${i + 1}`, options); - - const clip = state.timeline.tracks[0]!.clips.find(c => c.id === clipIds[i])!; - parentGroupId = clip.groupId; - } - - // Add 250 markers (100 timeline, 100 clip, 50 region) - for (let i = 0; i < 100; i++) { - state = addTimelineMarker(state, { - id: generateMarkerId(), - type: 'timeline', - frame: frame(i * 1000), - label: `Marker ${i + 1}`, - }); - } - - for (let i = 0; i < 100; i++) { - state = addClipMarker(state, { - id: generateMarkerId(), - type: 'clip', - clipId: clipIds[i]!, - frame: frame(50), - label: `Clip Marker ${i + 1}`, - }); - } - - for (let i = 0; i < 50; i++) { - state = addRegionMarker(state, { - id: generateMarkerId(), - type: 'region', - startFrame: frame(i * 2000), - endFrame: frame(i * 2000 + 1000), - label: `Region ${i + 1}`, - }); - } - - // Verify final state - let totalClips = 0; - for (const track of state.timeline.tracks) { - totalClips += track.clips.length; - } - - assert(totalClips === 500, '500 clips in state'); - assert(state.linkGroups.size === 50, '50 link groups'); - assert(state.groups.size === 25, '25 nested groups'); - assert( - state.markers.timeline.length + state.markers.clips.length + state.markers.regions.length === 250, - '250 total markers' - ); -}); - -// ===== SUMMARY ===== -console.log('='.repeat(50)); -console.log(`Tests Passed: ${testsPassed}`); -console.log(`Tests Failed: ${testsFailed}`); -console.log(`Total Tests: ${testsPassed + testsFailed}`); -console.log('='.repeat(50)); - -if (testsFailed === 0) { - console.log('\n✓ ALL STRESS TESTS PASSED!\n'); - console.log('System is stable under heavy load.'); -} else { - console.log(`\n✗ ${testsFailed} test(s) failed.\n`); - throw new Error(`${testsFailed} stress test(s) failed`); -} diff --git a/packages/core/src/__tests__/subtitle-import.test.ts b/packages/core/src/__tests__/subtitle-import.test.ts new file mode 100644 index 0000000..41bb8d1 --- /dev/null +++ b/packages/core/src/__tests__/subtitle-import.test.ts @@ -0,0 +1,246 @@ +/** + * SUBTITLE IMPORT TESTS — Phase 3 Step 3 + * + * Pure function tests only. No dispatch, no TimelineState, no checkInvariants. + */ + +import { describe, it, expect } from 'vitest'; +import { parseSRT, parseVTT, defaultCaptionStyle, subtitleImportToOps } from '../engine/subtitle-import'; +import { toTrackId } from '../types/track'; + +const FPS = 30; + +// ── SRT tests ─────────────────────────────────────────────────────────────── + +describe('parseSRT', () => { + it('parses single block: correct startFrame, endFrame, text', () => { + const raw = `1 +00:00:01,000 --> 00:00:02,500 +Hello world`; + const captions = parseSRT(raw, FPS); + expect(captions).toHaveLength(1); + expect(captions[0]!.startFrame).toBe(30); + expect(captions[0]!.endFrame).toBe(75); + expect(captions[0]!.text).toBe('Hello world'); + }); + + it('parses multi-block SRT: returns correct count', () => { + const raw = `1 +00:00:00,000 --> 00:00:01,000 +First + +2 +00:00:02,000 --> 00:00:03,000 +Second + +3 +00:00:04,000 --> 00:00:05,000 +Third`; + const captions = parseSRT(raw, FPS); + expect(captions).toHaveLength(3); + }); + + it('multi-line text joined with \\n', () => { + const raw = `1 +00:00:00,000 --> 00:00:02,000 +Line one +Line two`; + const captions = parseSRT(raw, FPS); + expect(captions[0]!.text).toBe('Line one\nLine two'); + }); + + it('strips , , tags, keeps inner text', () => { + const raw = `1 +00:00:00,000 --> 00:00:01,000 +bold italic under`; + const captions = parseSRT(raw, FPS); + expect(captions[0]!.text).toBe('bold italic under'); + }); + + it('skips malformed block (missing timecode line)', () => { + const raw = `1 +not a timecode line +some text`; + const captions = parseSRT(raw, FPS); + expect(captions).toHaveLength(0); + }); + + it('options.language sets language on all captions', () => { + const raw = `1 +00:00:00,000 --> 00:00:01,000 +Hi`; + const captions = parseSRT(raw, FPS, { language: 'fr-FR' }); + expect(captions[0]!.language).toBe('fr-FR'); + }); + + it('options.burnIn sets burnIn on all captions', () => { + const raw = `1 +00:00:00,000 --> 00:00:01,000 +Hi`; + const captions = parseSRT(raw, FPS, { burnIn: true }); + expect(captions[0]!.burnIn).toBe(true); + }); + + it('options.defaultStyle overrides specific style fields', () => { + const raw = `1 +00:00:00,000 --> 00:00:01,000 +Hi`; + const captions = parseSRT(raw, FPS, { + defaultStyle: { fontSize: 24, color: '#ff0000' }, + }); + expect(captions[0]!.style.fontSize).toBe(24); + expect(captions[0]!.style.color).toBe('#ff0000'); + expect(captions[0]!.style.fontFamily).toBe(defaultCaptionStyle.fontFamily); + }); + + it('toCaptionId called with srt- pattern (id starts with srt-)', () => { + const raw = `42 +00:00:00,000 --> 00:00:01,000 +X`; + const captions = parseSRT(raw, FPS); + expect(captions[0]!.id).toBe('srt-42'); + }); +}); + +// ── VTT tests ─────────────────────────────────────────────────────────────── + +describe('parseVTT', () => { + it('returns [] if first line is not WEBVTT', () => { + const raw = `NOT WEBVTT + +00:00:01.000 --> 00:00:02.000 +text`; + expect(parseVTT(raw, FPS)).toEqual([]); + }); + + it('parses single cue: correct frames and text', () => { + const raw = `WEBVTT + +00:00:01.000 --> 00:00:02.500 +Hello world`; + const captions = parseVTT(raw, FPS); + expect(captions).toHaveLength(1); + expect(captions[0]!.startFrame).toBe(30); + expect(captions[0]!.endFrame).toBe(75); + expect(captions[0]!.text).toBe('Hello world'); + }); + + it('skips NOTE blocks', () => { + const raw = `WEBVTT + +NOTE this is a note +and more note + +00:00:01.000 --> 00:00:02.000 +cue text`; + const captions = parseVTT(raw, FPS); + expect(captions).toHaveLength(1); + expect(captions[0]!.text).toBe('cue text'); + }); + + it('hours-optional timecode MM:SS.mmm parses correctly', () => { + const raw = `WEBVTT + +01:00.000 --> 02:00.000 +One minute to two`; + const captions = parseVTT(raw, FPS); + expect(captions).toHaveLength(1); + expect(captions[0]!.startFrame).toBe(1800); + expect(captions[0]!.endFrame).toBe(3600); + }); + + it('strips VTT cue tags , , timestamp tags', () => { + const raw = `WEBVTT + +00:00:00.000 --> 00:00:01.000 +bold <00:00:00.500>`; + const captions = parseVTT(raw, FPS); + expect(captions[0]!.text).toBe('bold '); + }); + + it('cue counter is 1-based and independent of cue id line', () => { + const raw = `WEBVTT + +cue-id-optional +00:00:00.000 --> 00:00:01.000 +First + +00:00:02.000 --> 00:00:03.000 +Second`; + const captions = parseVTT(raw, FPS); + expect(captions).toHaveLength(2); + expect(captions[0]!.id).toBe('vtt-1'); + expect(captions[1]!.id).toBe('vtt-2'); + }); + + it('positioning text after --> is ignored (no crash)', () => { + const raw = `WEBVTT + +00:00:00.000 --> 00:00:01.000 line:90% align:center +Some text`; + const captions = parseVTT(raw, FPS); + expect(captions).toHaveLength(1); + expect(captions[0]!.text).toBe('Some text'); + }); +}); + +// ── timecodeToFrame (via parseSRT) ────────────────────────────────────────── + +describe('timecodeToFrame (via parseSRT)', () => { + it('00:01:00,000 at 30fps → frame 1800', () => { + const raw = `1 +00:01:00,000 --> 00:01:01,000 +x`; + const captions = parseSRT(raw, FPS); + expect(captions[0]!.startFrame).toBe(1800); + }); + + it('00:00:01,001 at 30fps → Math.round(1.001 * 30) = 30', () => { + const raw = `1 +00:00:01,001 --> 00:00:02,000 +x`; + const captions = parseSRT(raw, FPS); + expect(captions[0]!.startFrame).toBe(30); + }); + + it('00:00:00,033 at 30fps → Math.round(0.033 * 30) = 1', () => { + const raw = `1 +00:00:00,033 --> 00:00:01,000 +x`; + const captions = parseSRT(raw, FPS); + expect(captions[0]!.startFrame).toBe(1); + }); +}); + +// ── subtitleImportToOps ───────────────────────────────────────────────────── + +describe('subtitleImportToOps', () => { + it('returns ADD_CAPTION ops equal to caption count', () => { + const raw = `1 +00:00:00,000 --> 00:00:01,000 +A + +2 +00:00:01,000 --> 00:00:02,000 +B`; + const captions = parseSRT(raw, FPS); + const ops = subtitleImportToOps(captions, toTrackId('track-1')); + expect(ops).toHaveLength(2); + expect(ops.every((o) => o.type === 'ADD_CAPTION')).toBe(true); + }); + + it('each op has correct trackId and caption reference', () => { + const raw = `1 +00:00:00,000 --> 00:00:01,000 +Hi`; + const captions = parseSRT(raw, FPS); + const trackId = toTrackId('subtitle-track'); + const ops = subtitleImportToOps(captions, trackId); + expect(ops[0]!.type).toBe('ADD_CAPTION'); + if (ops[0]!.type === 'ADD_CAPTION') { + expect(ops[0].trackId).toBe(trackId); + expect(ops[0].caption).toBe(captions[0]); + expect(ops[0].caption.text).toBe('Hi'); + } + }); +}); diff --git a/packages/core/src/__tests__/tools/hand.test.ts b/packages/core/src/__tests__/tools/hand.test.ts new file mode 100644 index 0000000..4ea9363 --- /dev/null +++ b/packages/core/src/__tests__/tools/hand.test.ts @@ -0,0 +1,197 @@ +/** + * HandTool Tests — Phase 2 Step 8 + * + * Covers all items from the approved test plan: + * □ Drag sequence: down → move → move → up → callback called twice with correct deltas + * □ Incremental delta: each delta is event-to-event, not from startX + * □ No callback registered: drag completes silently, no error + * □ onPointerUp returns null always + * □ onKeyDown returns null always + * □ onPointerMove returns null always (never ProvisionalState) + * □ getCursor returns 'grabbing' mid-drag, 'grab' otherwise + * □ onCancel resets isDragging — subsequent move after cancel fires no callback + * □ setScrollCallback(null) unregisters — subsequent drag fires no callback + * + * HandTool has no TimelineState effects — no dispatch, no checkInvariants needed. + * Zero React imports. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HandTool } from '../../tools/hand'; +import { toTrackId } from '../../types/track'; +import { toFrame } from '../../types/frame'; +import type { ToolContext, TimelinePointerEvent, TimelineKeyEvent } from '../../tools/types'; +import type { TimelineFrame } from '../../types/frame'; +import type { TrackId } from '../../types/track'; + +// ── Minimal stubs ───────────────────────────────────────────────────────────── +// HandTool never reads from ToolContext — a cast stub is sufficient. +const STUB_CTX = {} as ToolContext; + +function makeEv(x: number = 0, frame: TimelineFrame = toFrame(0)): TimelinePointerEvent { + return { + frame, x, y: 24, + trackId: toTrackId('track-1'), + clipId: null, + buttons: 1, + shiftKey: false, altKey: false, metaKey: false, + }; +} + +function makeKeyEv(): TimelineKeyEvent { + return { key: 'h', code: 'KeyH', shiftKey: false, altKey: false, metaKey: false, ctrlKey: false }; +} + +// ── Suite 1: Full drag sequence ──────────────────────────────────────────────── + +describe('HandTool — drag sequence: callback called with correct deltas', () => { + it('down(x=100) → move(x=130) → move(x=160) → callback called twice with Δ=30, 30', () => { + const tool = new HandTool(); + const deltas: number[] = []; + tool.setScrollCallback(dx => deltas.push(dx)); + + tool.onPointerDown(makeEv(100), STUB_CTX); + tool.onPointerMove(makeEv(130), STUB_CTX); + tool.onPointerMove(makeEv(160), STUB_CTX); + tool.onPointerUp(makeEv(160), STUB_CTX); + + expect(deltas).toHaveLength(2); + expect(deltas[0]).toBe(30); // 130 - 100 + expect(deltas[1]).toBe(30); // 160 - 130 ← incremental, not 60 + }); + + it('incremental: second delta is event-to-event, not from startX', () => { + const tool = new HandTool(); + const deltas: number[] = []; + tool.setScrollCallback(dx => deltas.push(dx)); + + tool.onPointerDown(makeEv(0), STUB_CTX); + tool.onPointerMove(makeEv(50), STUB_CTX); // Δ = 50 - 0 = 50 + tool.onPointerMove(makeEv(70), STUB_CTX); // Δ = 70 - 50 = 20 (not 70 - 0 = 70) + tool.onPointerMove(makeEv(100), STUB_CTX); // Δ = 100 - 70 = 30 + + expect(deltas).toEqual([50, 20, 30]); + }); +}); + +// ── Suite 2: No callback registered ────────────────────────────────────────── + +describe('HandTool — no callback registered: silent, no error', () => { + it('full drag completes without throwing', () => { + const tool = new HandTool(); // no setScrollCallback() + expect(() => { + tool.onPointerDown(makeEv(100), STUB_CTX); + tool.onPointerMove(makeEv(150), STUB_CTX); + tool.onPointerUp(makeEv(150), STUB_CTX); + }).not.toThrow(); + }); +}); + +// ── Suite 3: Return values ──────────────────────────────────────────────────── + +describe('HandTool — return values: always null', () => { + it('onPointerUp returns null always', () => { + const tool = new HandTool(); + tool.onPointerDown(makeEv(0), STUB_CTX); + expect(tool.onPointerUp(makeEv(100), STUB_CTX)).toBeNull(); + }); + + it('onKeyDown returns null always', () => { + const tool = new HandTool(); + expect(tool.onKeyDown(makeKeyEv(), STUB_CTX)).toBeNull(); + }); + + it('onPointerMove returns null always (never ProvisionalState)', () => { + const tool = new HandTool(); + tool.onPointerDown(makeEv(0), STUB_CTX); + expect(tool.onPointerMove(makeEv(50), STUB_CTX)).toBeNull(); + }); +}); + +// ── Suite 4: getCursor ───────────────────────────────────────────────────────── + +describe('HandTool — getCursor: grab/grabbing only', () => { + it('returns grab when idle (not mid-drag)', () => { + const tool = new HandTool(); + expect(tool.getCursor(STUB_CTX)).toBe('grab'); + }); + + it('returns grabbing mid-drag', () => { + const tool = new HandTool(); + tool.onPointerDown(makeEv(0), STUB_CTX); + expect(tool.getCursor(STUB_CTX)).toBe('grabbing'); + }); + + it('returns grab again after pointerUp', () => { + const tool = new HandTool(); + tool.onPointerDown(makeEv(0), STUB_CTX); + tool.onPointerUp(makeEv(100), STUB_CTX); + expect(tool.getCursor(STUB_CTX)).toBe('grab'); + }); +}); + +// ── Suite 5: onCancel resets drag ───────────────────────────────────────────── + +describe('HandTool — onCancel: resets isDragging', () => { + it('move after cancel fires no callback', () => { + const tool = new HandTool(); + const deltas: number[] = []; + tool.setScrollCallback(dx => deltas.push(dx)); + + tool.onPointerDown(makeEv(0), STUB_CTX); + tool.onPointerMove(makeEv(50), STUB_CTX); // fires callback once + tool.onCancel(); + tool.onPointerMove(makeEv(100), STUB_CTX); // should NOT fire — not dragging + + expect(deltas).toHaveLength(1); // only the pre-cancel move + expect(deltas[0]).toBe(50); + }); + + it('getCursor returns grab after onCancel', () => { + const tool = new HandTool(); + tool.onPointerDown(makeEv(0), STUB_CTX); + tool.onCancel(); + expect(tool.getCursor(STUB_CTX)).toBe('grab'); + }); +}); + +// ── Suite 6: setScrollCallback(null) unregisters ────────────────────────────── + +describe('HandTool — setScrollCallback(null): unregisters callback', () => { + it('subsequent drag fires no callback after setScrollCallback(null)', () => { + const tool = new HandTool(); + const deltas: number[] = []; + tool.setScrollCallback(dx => deltas.push(dx)); + + // First drag — callback active + tool.onPointerDown(makeEv(0), STUB_CTX); + tool.onPointerMove(makeEv(50), STUB_CTX); + tool.onPointerUp(makeEv(50), STUB_CTX); + + // Unregister + tool.setScrollCallback(null); + + // Second drag — no callback + tool.onPointerDown(makeEv(50), STUB_CTX); + tool.onPointerMove(makeEv(100), STUB_CTX); + tool.onPointerUp(makeEv(100), STUB_CTX); + + expect(deltas).toHaveLength(1); // only the first drag's move + expect(deltas[0]).toBe(50); + }); +}); + +// ── Suite 7: structural ─────────────────────────────────────────────────────── + +describe('HandTool — structural', () => { + it('getSnapCandidateTypes returns empty array', () => { + expect(new HandTool().getSnapCandidateTypes()).toHaveLength(0); + }); + + it('has correct id and shortcutKey', () => { + const tool = new HandTool(); + expect(tool.id).toBe('hand'); + expect(tool.shortcutKey).toBe('h'); + }); +}); diff --git a/packages/core/src/__tests__/tools/keyframe-tool.test.ts b/packages/core/src/__tests__/tools/keyframe-tool.test.ts new file mode 100644 index 0000000..7d3623a --- /dev/null +++ b/packages/core/src/__tests__/tools/keyframe-tool.test.ts @@ -0,0 +1,269 @@ +/** + * KeyframeTool Tests — Phase 4 Step 4 + * + * Fixture: one timeline, one video track, one clip with one effect + * and one existing keyframe. + */ + +import { describe, it, expect } from 'vitest'; +import { KeyframeTool } from '../../tools/keyframe-tool'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { applyOperation } from '../../engine/apply'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset } from '../../types/asset'; +import { createEffect, toEffectId } from '../../types/effect'; +import { toKeyframeId } from '../../types/keyframe'; +import { toFrame, toTimecode } from '../../types/frame'; +import { LINEAR_EASING } from '../../types/easing'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent, TimelineKeyEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +const TRACK_ID = toTrackId('track-1'); +const CLIP_ID = toClipId('clip-1'); +const EFFECT_ID = toEffectId('eff-1'); +const KEYFRAME_1_FRAME = 10; +const PIXELS_PER_FRAME = 10; // frame 10 → x=100 + +function makeState(): TimelineState { + const asset = createAsset({ + id: 'asset-1', + name: 'V1', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const effect = createEffect(EFFECT_ID, 'blur', 'preComposite', []); + const clip = createClip({ + id: 'clip-1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + effects: [{ ...effect, keyframes: [{ id: toKeyframeId('kf-1'), frame: toFrame(KEYFRAME_1_FRAME), value: 0.5, easing: LINEAR_EASING }] }], + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +function makeCtx(state: TimelineState, overrides: Partial = {}): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: PIXELS_PER_FRAME, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.round(x / PIXELS_PER_FRAME)), + trackAtY: (_y) => TRACK_ID, + snap: (frame) => frame, + ...overrides, + }; +} + +function makeEv(overrides: Partial & { x?: number } = {}): TimelinePointerEvent { + const x = overrides.x ?? 0; + return { + frame: toFrame(Math.round(x / PIXELS_PER_FRAME)), + trackId: TRACK_ID, + clipId: CLIP_ID, + x, + y: 24, + buttons: overrides.buttons ?? 1, + shiftKey: overrides.shiftKey ?? false, + altKey: overrides.altKey ?? false, + metaKey: overrides.metaKey ?? false, + ...overrides, + }; +} + +function makeKeyEv(overrides: Partial = {}): TimelineKeyEvent { + return { + key: 'Delete', + code: 'Delete', + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + ...overrides, + }; +} + +describe('KeyframeTool — onPointerDown on empty lane', () => { + it('onPointerDown on empty lane then onPointerUp returns ADD_KEYFRAME', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const xEmpty = 300; // frame 30, no keyframe there + tool.onPointerDown(makeEv({ x: xEmpty }), ctx); + const tx = tool.onPointerUp(makeEv({ x: xEmpty }), ctx); + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('ADD_KEYFRAME'); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) expect(checkInvariants(result.nextState)).toEqual([]); + }); + + it('added keyframe has frame derived from pointer x', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const x = 250; // frame 25 + tool.onPointerDown(makeEv({ x }), ctx); + const tx = tool.onPointerUp(makeEv({ x }), ctx); + expect(tx).not.toBeNull(); + const op = tx!.operations[0]; + expect(op!.type).toBe('ADD_KEYFRAME'); + if (op!.type === 'ADD_KEYFRAME') expect(op.keyframe.frame).toBe(25); + }); +}); + +describe('KeyframeTool — onPointerDown on existing keyframe', () => { + it('onPointerDown on existing keyframe sets draggingKeyframe', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const keyframeX = KEYFRAME_1_FRAME * PIXELS_PER_FRAME; // 100 + tool.onPointerDown(makeEv({ x: keyframeX }), ctx); + const provisional = tool.onPointerMove(makeEv({ x: keyframeX + 50 }), ctx); + expect(provisional).not.toBeNull(); + const tx = tool.onPointerUp(makeEv({ x: keyframeX + 50 }), ctx); + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('MOVE_KEYFRAME'); + }); +}); + +describe('KeyframeTool — onPointerMove', () => { + it('onPointerMove with draggingKeyframe shows provisional', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const keyframeX = KEYFRAME_1_FRAME * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: keyframeX }), ctx); + const provisional = tool.onPointerMove(makeEv({ x: keyframeX + 30 }), ctx); + expect(provisional).not.toBeNull(); + expect(provisional!.isProvisional).toBe(true); + const clip = provisional!.clips[0]!; + const effect = clip.effects!.find((e) => e.id === EFFECT_ID); + expect(effect!.keyframes[0]!.frame).toBe(13); // 10 + round(30/10) + }); + + it('onPointerMove without draggingKeyframe: no provisional', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const provisional = tool.onPointerMove(makeEv({ x: 200 }), ctx); + expect(provisional).toBeNull(); + }); +}); + +describe('KeyframeTool — onPointerUp', () => { + it('onPointerUp dispatches MOVE_KEYFRAME with correct frame', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const keyframeX = KEYFRAME_1_FRAME * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: keyframeX }), ctx); + tool.onPointerMove(makeEv({ x: keyframeX + 40 }), ctx); + const tx = tool.onPointerUp(makeEv({ x: keyframeX + 40 }), ctx); + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('MOVE_KEYFRAME'); + if (tx!.operations[0]!.type === 'MOVE_KEYFRAME') { + expect(tx!.operations[0]!.newFrame).toBe(14); // 10 + 4 + } + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) expect(checkInvariants(result.nextState)).toEqual([]); + }); + + it('onPointerUp with no drag: no dispatch', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const keyframeX = KEYFRAME_1_FRAME * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: keyframeX }), ctx); + const tx = tool.onPointerUp(makeEv({ x: keyframeX }), ctx); + expect(tx).toBeNull(); + }); +}); + +describe('KeyframeTool — onKeyDown Delete', () => { + it('onKeyDown Delete while dragging dispatches DELETE_KEYFRAME', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const keyframeX = KEYFRAME_1_FRAME * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: keyframeX }), ctx); + const tx = tool.onKeyDown(makeKeyEv({ key: 'Delete' }), ctx); + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('DELETE_KEYFRAME'); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toEqual([]); + const clip = result.nextState.timeline.tracks[0]!.clips[0]!; + expect(clip.effects![0]!.keyframes).toHaveLength(0); + } + }); + + it('onKeyDown Delete with no active drag: no dispatch', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const tx = tool.onKeyDown(makeKeyEv({ key: 'Delete' }), ctx); + expect(tx).toBeNull(); + }); +}); + +describe('KeyframeTool — onCancel', () => { + it('onCancel resets all instance vars and clears provisional', () => { + const tool = new KeyframeTool(); + const state = makeState(); + const ctx = makeCtx(state); + const keyframeX = KEYFRAME_1_FRAME * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: keyframeX }), ctx); + tool.onCancel(); + const provisional = tool.onPointerMove(makeEv({ x: keyframeX + 20 }), ctx); + expect(provisional).toBeNull(); + const tx = tool.onPointerUp(makeEv({ x: keyframeX + 20 }), ctx); + expect(tx).toBeNull(); + }); +}); + +describe('KeyframeTool — snap', () => { + it('onPointerDown snaps frame to nearest snap point when ctx.snapEnabled', () => { + const state = makeState(); + const snapIndex = buildSnapIndex(state, toFrame(0)); + const ctx = makeCtx(state, { + snapIndex: { ...snapIndex, enabled: true }, + frameAtX: (x) => toFrame(Math.round(x / PIXELS_PER_FRAME)), + }); + const tool = new KeyframeTool(); + // Click at x=35 → frame 4. ClipStart is at 0, within SNAP_RADIUS_FRAMES (5) + tool.onPointerDown(makeEv({ x: 35 }), ctx); + const tx = tool.onPointerUp(makeEv({ x: 35 }), ctx); + expect(tx).not.toBeNull(); + const op = tx!.operations[0]; + expect(op!.type).toBe('ADD_KEYFRAME'); + if (op!.type === 'ADD_KEYFRAME') { + // nearest(frame 4, radius 5) can return ClipStart at 0 (distance 4) + expect([0, 4]).toContain(op.keyframe.frame); + } + }); +}); diff --git a/packages/core/src/__tests__/tools/razor.test.ts b/packages/core/src/__tests__/tools/razor.test.ts new file mode 100644 index 0000000..ada56e3 --- /dev/null +++ b/packages/core/src/__tests__/tools/razor.test.ts @@ -0,0 +1,486 @@ +/** + * RazorTool Tests — Phase 2 Step 2 + * + * Covers all 7 items from the approved test plan: + * □ computeSlice at timelineStart → null + * □ computeSlice at timelineEnd → null + * □ computeSlice strictly inside → left.mediaOut === right.mediaIn + * □ Single click: DELETE + 2× INSERT, checkInvariants passes + * □ Shift+click 3 tracks: 9 ops in 1 Transaction, checkInvariants passes + * □ Shift+click with 2/3 tracks having clips at frame → only 2 sliced (6 ops) + * □ _setIdGenerator: left.id and right.id are distinct and deterministic + * + * Zero React imports. All tests are pure unit tests against RazorTool + dispatcher. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { RazorTool, _setIdGenerator } from '../../tools/razor'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset, toAssetId } from '../../types/asset'; +import { toFrame, toTimecode, frameRate } from '../../types/frame'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TimelineFrame } from '../../types/frame'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +// ── ID generator hook (deterministic in tests) ───────────────────────────── + +let idCounter = 0; + +beforeEach(() => { + idCounter = 0; + _setIdGenerator(() => `test-id-${++idCounter}`); +}); + +afterEach(() => { + _setIdGenerator(() => crypto.randomUUID()); +}); + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const ASSET_ID = toAssetId('asset-1'); +const T1_ID = toTrackId('track-1'); +const T2_ID = toTrackId('track-2'); +const T3_ID = toTrackId('track-3'); +const CLIP_A = toClipId('clip-a'); // track-1, [0, 200) +const CLIP_B = toClipId('clip-b'); // track-2, [100, 300) +const CLIP_C = toClipId('clip-c'); // track-3, [0, 200) + +function makeAsset() { + return createAsset({ + id: 'asset-1', + name: 'Test Asset', + mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + status: 'online', + }); +} + +/** + * Build a 3-track state: + * track-1: clip-a [0, 200) + * track-2: clip-b [100, 300) ← not aligned with t1/t3 + * track-3: clip-c [0, 200) + */ +function makeState3Tracks(): TimelineState { + const asset = makeAsset(); + + const clipA = createClip({ + id: 'clip-a', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(0), timelineEnd: toFrame(200), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }); + const clipB = createClip({ + id: 'clip-b', assetId: 'asset-1', trackId: 'track-2', + timelineStart: toFrame(100), timelineEnd: toFrame(300), + mediaIn: toFrame(50), mediaOut: toFrame(250), // deliberate offset + }); + const clipC = createClip({ + id: 'clip-c', assetId: 'asset-1', trackId: 'track-3', + timelineStart: toFrame(0), timelineEnd: toFrame(200), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }); + + const t1 = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clipA] }); + const t2 = createTrack({ id: 'track-2', name: 'V2', type: 'video', clips: [clipB] }); + const t3 = createTrack({ id: 'track-3', name: 'V3', type: 'video', clips: [clipC] }); + + const timeline = createTimeline({ + id: 'tl', name: 'Razor Test', + fps: frameRate(30), duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [t1, t2, t3], + }); + + return createTimelineState({ timeline, assetRegistry: new Map([[ASSET_ID, asset]]) }); +} + +/** Minimal single-track state using clip-a [0,200) on track-1. */ +function makeSingleTrackState(): TimelineState { + const asset = makeAsset(); + const clipA = createClip({ + id: 'clip-a', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(0), timelineEnd: toFrame(200), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }); + const t1 = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clipA] }); + const timeline = createTimeline({ + id: 'tl', name: 'Razor Test', + fps: frameRate(30), duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [t1], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[ASSET_ID, asset]]) }); +} + +function makeCtx( + state: TimelineState, + overrides: Partial = {}, +): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: 10, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / 10)), + trackAtY: (_y) => T1_ID, + snap: (frame, _exclude?) => frame, // identity + ...overrides, + }; +} + +function makeEv(overrides: { + frame?: TimelineFrame; + trackId?: TrackId | null; + clipId?: ClipId | null; + x?: number; y?: number; buttons?: number; + shiftKey?: boolean; altKey?: boolean; metaKey?: boolean; +} = {}): TimelinePointerEvent { + return { + frame: overrides.frame ?? toFrame(0), + trackId: overrides.trackId ?? T1_ID, + clipId: overrides.clipId ?? null, + x: overrides.x ?? 0, + y: overrides.y ?? 24, + buttons: overrides.buttons ?? 1, + shiftKey: overrides.shiftKey ?? false, + altKey: overrides.altKey ?? false, + metaKey: overrides.metaKey ?? false, + }; +} + +/** Dispatch the transaction and assert zero invariant violations. */ +function applyAndCheck(state: TimelineState, tx: ReturnType) { + expect(tx).not.toBeNull(); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toHaveLength(0); + } + return result.accepted ? result.nextState : state; +} + +// ── UNIT: computeSlice boundary guards ──────────────────────────────────── +// computeSlice is private but exercised through the tool's end-to-end behavior + +describe('RazorTool — computeSlice boundary guards (via onPointerUp)', () => { + let tool: RazorTool; + let state: TimelineState; + + beforeEach(() => { + tool = new RazorTool(); + state = makeSingleTrackState(); + }); + + it('click exactly at timelineStart → no Transaction (would produce zero-duration left half)', () => { + const ctx = makeCtx(state); + // clip-a starts at frame 0 + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(0) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(0) }), ctx); + expect(tx).toBeNull(); + }); + + it('click exactly at timelineEnd → no Transaction (would produce zero-duration right half)', () => { + const ctx = makeCtx(state); + // clip-a ends at frame 200 + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(200) }), ctx); + expect(tx).toBeNull(); + }); + + it('click 1 frame before timelineEnd → valid Transaction + correct math', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(199) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(199) }), ctx); + expect(tx).not.toBeNull(); + // left: [0,199), right: [199,200) + const insertOps = tx!.operations.filter(o => o.type === 'INSERT_CLIP'); + expect(insertOps).toHaveLength(2); + if (insertOps[0]!.type === 'INSERT_CLIP') { + expect(insertOps[0]!.clip.timelineEnd).toBe(toFrame(199)); + } + if (insertOps[1]!.type === 'INSERT_CLIP') { + expect(insertOps[1]!.clip.timelineStart).toBe(toFrame(199)); + } + }); +}); + +// ── UNIT: computeSlice math ──────────────────────────────────────────────── + +describe('RazorTool — slice math (left.mediaOut === right.mediaIn)', () => { + it('split point: left.mediaOut equals right.mediaIn', () => { + const state = makeSingleTrackState(); + const tool = new RazorTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(80) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(80) }), ctx); + expect(tx).not.toBeNull(); + + const inserts = tx!.operations.filter(o => o.type === 'INSERT_CLIP'); + expect(inserts).toHaveLength(2); + + const left = (inserts[0] as { type: 'INSERT_CLIP'; clip: any }).clip; + const right = (inserts[1] as { type: 'INSERT_CLIP'; clip: any }).clip; + + // Core slice invariant + expect(left.mediaOut).toBe(right.mediaIn); + // Timeline bounds + expect(left.timelineEnd).toBe(toFrame(80)); + expect(right.timelineStart).toBe(toFrame(80)); + // Duration preservation + const leftDuration = left.timelineEnd - left.timelineStart; + const rightDuration = right.timelineEnd - right.timelineStart; + expect(leftDuration + rightDuration).toBe(200); // original was 200 frames + }); + + it('clip with non-zero mediaIn: offset correctly applied to mediaIn/mediaOut', () => { + // clip-b: timelineStart=100, mediaIn=50 → at frame 150, offset=50 + // left.mediaOut = 50 + 50 = 100, right.mediaIn = 100 + const state = makeState3Tracks(); + const tool = new RazorTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_B, frame: toFrame(150) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_B, frame: toFrame(150) }), ctx); + expect(tx).not.toBeNull(); + + const inserts = tx!.operations.filter(o => o.type === 'INSERT_CLIP'); + const left = (inserts[0] as { type: 'INSERT_CLIP'; clip: any }).clip; + const right = (inserts[1] as { type: 'INSERT_CLIP'; clip: any }).clip; + + expect(left.mediaIn).toBe(toFrame(50)); // original mediaIn preserved + expect(left.mediaOut).toBe(toFrame(100)); // 50 + (150 - 100) = 100 + expect(right.mediaIn).toBe(toFrame(100)); // same value — the split point + expect(right.mediaOut).toBe(toFrame(250)); // original mediaOut preserved + expect(left.mediaOut).toBe(right.mediaIn); // invariant + }); +}); + +// ── INTEGRATION: single-clip slice ──────────────────────────────────────── + +describe('RazorTool — single clip slice (no shift)', () => { + let tool: RazorTool; + let state: TimelineState; + + beforeEach(() => { + tool = new RazorTool(); + state = makeSingleTrackState(); + }); + + it('produces DELETE_CLIP + 2× INSERT_CLIP', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(3); + expect(tx!.operations[0]!.type).toBe('DELETE_CLIP'); + expect(tx!.operations[1]!.type).toBe('INSERT_CLIP'); + expect(tx!.operations[2]!.type).toBe('INSERT_CLIP'); + }); + + it('DELETE_CLIP targets the original clipId', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + + + + const del = tx!.operations[0]!; + if (del.type === 'DELETE_CLIP') { + expect(del.clipId).toBe(CLIP_A); + } + }); + + it('passes checkInvariants after dispatch', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + applyAndCheck(state, tx); + }); + + it('after slice: track has exactly 2 clips with adjacent boundaries', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(80) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(80) }), ctx); + const nextState = applyAndCheck(state, tx); + + const clips = nextState.timeline.tracks[0]!.clips; + expect(clips).toHaveLength(2); + expect(clips[0]!.timelineEnd).toBe(clips[1]!.timelineStart); // adjacent, no gap + }); + + it('clicking empty space returns null', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: null, frame: toFrame(500) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: null, frame: toFrame(500) }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── INTEGRATION: shift+click all tracks ─────────────────────────────────── + +describe('RazorTool — shift+click: all tracks', () => { + let tool: RazorTool; + let state: TimelineState; // 3 tracks + + beforeEach(() => { + tool = new RazorTool(); + state = makeState3Tracks(); + }); + + function shiftCtx(s: TimelineState): ToolContext { + return makeCtx(s, { + modifiers: { shift: true, alt: false, ctrl: false, meta: false }, + }); + } + + it('frame 150 → clips on all 3 tracks hit → 9 ops (DELETE+left+right per clip)', () => { + // At frame 150: clip-a [0,200)✓ clip-b [100,300)✓ clip-c [0,200)✓ + const ctx = shiftCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(150) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(150) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(9); // 3 clips × 3 ops each + + const deletes = tx!.operations.filter(o => o.type === 'DELETE_CLIP'); + const inserts = tx!.operations.filter(o => o.type === 'INSERT_CLIP'); + expect(deletes).toHaveLength(3); + expect(inserts).toHaveLength(6); // 2 per clip + }); + + it('shift+click 3 tracks passes checkInvariants', () => { + const ctx = shiftCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(150) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(150) }), ctx); + applyAndCheck(state, tx); + }); + + it('frame 50 → only track-1 and track-3 have clips → 6 ops (clip-b starts at 100)', () => { + // At frame 50: clip-a [0,200)✓ clip-b [100,300)✗ (50 < 100) clip-c [0,200)✓ + const ctx = shiftCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(50) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(50) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(6); // only 2 clips sliced + + const deletes = tx!.operations.filter(o => o.type === 'DELETE_CLIP'); + expect(deletes).toHaveLength(2); + // clip-b must NOT appear in any delete op + const clipBDeleted = deletes.some( + o => o.type === 'DELETE_CLIP' && o.clipId === CLIP_B, + ); + expect(clipBDeleted).toBe(false); + }); + + it('frame where no clips exist → returns null', () => { + // frame 9000 is beyond all clips + const ctx = shiftCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(9000) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(9000) }), ctx); + expect(tx).toBeNull(); + }); + + it('per-clip grouping: ops ordered DELETE,left,right per clip (not all-deletes-first)', () => { + const ctx = shiftCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(150) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(150) }), ctx); + expect(tx).not.toBeNull(); + + // Check pattern: ops should follow DELETE, INSERT, INSERT, DELETE, INSERT, INSERT, ... + for (let i = 0; i < 9; i += 3) { + expect(tx!.operations[i]!.type).toBe('DELETE_CLIP'); + expect(tx!.operations[i + 1]!.type).toBe('INSERT_CLIP'); + expect(tx!.operations[i + 2]!.type).toBe('INSERT_CLIP'); + } + }); +}); + +// ── _setIdGenerator: deterministic IDs ──────────────────────────────────── + +describe('RazorTool — _setIdGenerator: new ClipIds are distinct and deterministic', () => { + it('left and right halves have distinct ids from _setIdGenerator', () => { + // idCounter resets in global beforeEach — first two calls = 'test-id-1', 'test-id-2' + const state = makeSingleTrackState(); + const tool = new RazorTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + expect(tx).not.toBeNull(); + + const inserts = tx!.operations.filter(o => o.type === 'INSERT_CLIP'); + const leftId = (inserts[0] as { type: 'INSERT_CLIP'; clip: any }).clip.id; + const rightId = (inserts[1] as { type: 'INSERT_CLIP'; clip: any }).clip.id; + + expect(leftId).toBe('test-id-1'); + expect(rightId).toBe('test-id-2'); + expect(leftId).not.toBe(rightId); + }); + + it('neither half reuses the original clip id', () => { + const state = makeSingleTrackState(); + const tool = new RazorTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + + const inserts = tx!.operations.filter(o => o.type === 'INSERT_CLIP'); + for (const op of inserts) { + if (op.type === 'INSERT_CLIP') { + expect(op.clip.id).not.toBe(CLIP_A); + } + } + }); +}); + +// ── onCancel / structural ───────────────────────────────────────────────── + +describe('RazorTool — onCancel and structural', () => { + it('getCursor always returns "crosshair"', () => { + const state = makeSingleTrackState(); + const ctx = makeCtx(state); + const tool = new RazorTool(); + expect(tool.getCursor(ctx)).toBe('crosshair'); + }); + + it('onPointerMove always returns null', () => { + const state = makeSingleTrackState(); + const ctx = makeCtx(state); + const tool = new RazorTool(); + const result = tool.onPointerMove(makeEv({ clipId: CLIP_A, frame: toFrame(50) }), ctx); + expect(result).toBeNull(); + }); + + it('onCancel resets pending state — subsequent onPointerUp returns null', () => { + const state = makeSingleTrackState(); + const ctx = makeCtx(state); + const tool = new RazorTool(); + + tool.onPointerDown(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + tool.onCancel(); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A, frame: toFrame(100) }), ctx); + expect(tx).toBeNull(); + }); + + it('has correct id and shortcutKey', () => { + const tool = new RazorTool(); + expect(tool.id).toBe('razor'); + expect(tool.shortcutKey).toBe('b'); + }); +}); diff --git a/packages/core/src/__tests__/tools/ripple-delete.test.ts b/packages/core/src/__tests__/tools/ripple-delete.test.ts new file mode 100644 index 0000000..c7a63e4 --- /dev/null +++ b/packages/core/src/__tests__/tools/ripple-delete.test.ts @@ -0,0 +1,326 @@ +/** + * RippleDeleteTool Tests — Phase 2 Step 6 + * + * Covers all items from the approved test plan: + * □ Single clip, no downstream: 1 op (DELETE_CLIP only) + * □ Single clip, 3 downstream: 4 ops in correct order + * □ MOVE_CLIP sort: leftmost downstream clip first in operations[] + * □ Each downstream clip shifts by exactly deletedDuration + * □ Click on empty space: null + * □ Clip missing from state at onPointerUp (defensive): null + * □ checkInvariants + dispatch.accepted on every Transaction + * □ operations[0].type === 'DELETE_CLIP' always (explicit on every test) + * □ No provisional state: onPointerMove always returns null + * + * Zero React imports. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { RippleDeleteTool } from '../../tools/ripple-delete'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset, toAssetId } from '../../types/asset'; +import { toFrame, toTimecode, frameRate } from '../../types/frame'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TimelineFrame } from '../../types/frame'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const TRACK_ID = toTrackId('track-1'); +const ASSET_ID = toAssetId('asset-1'); +const TARGET_ID = toClipId('clip-target'); + +// ── Fixture builders ────────────────────────────────────────────────────────── + +function makeAsset() { + return createAsset({ + id: 'asset-1', name: 'Test', mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(9000), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), status: 'online', + }); +} + +/** + * Build a state with: + * targetClip = [100, 300) → deletedDuration = 200 + * downstream clips at positions provided by downstreamStarts[] + * each downstream clip is 100 frames long + */ +function makeState(downstreamStarts: number[] = []): TimelineState { + const asset = makeAsset(); + const clips = [ + createClip({ + id: 'clip-target', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(100), timelineEnd: toFrame(300), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }), + ...downstreamStarts.map((start, i) => + createClip({ + id: `clip-ds-${i}`, assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(start), timelineEnd: toFrame(start + 100), + mediaIn: toFrame(i * 100), mediaOut: toFrame(i * 100 + 100), + }), + ), + ]; + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips }); + const timeline = createTimeline({ + id: 'tl', name: 'RippleDelete Test', fps: frameRate(30), + duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[ASSET_ID, asset]]) }); +} + +function makeCtx(state: TimelineState, overrides: Partial = {}): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: 10, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / 10)), + trackAtY: (_y) => TRACK_ID, + snap: (frame, _excl?) => frame, + ...overrides, + }; +} + +function makeEv(overrides: { + frame?: TimelineFrame; trackId?: TrackId | null; clipId?: ClipId | null; +} = {}): TimelinePointerEvent { + return { + frame: overrides.frame ?? toFrame(0), + trackId: overrides.trackId ?? TRACK_ID, + clipId: overrides.clipId ?? null, + x: 0, y: 24, buttons: 1, + shiftKey: false, altKey: false, metaKey: false, + }; +} + +function clickTarget(tool: RippleDeleteTool, state: TimelineState): ReturnType { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: TARGET_ID, frame: toFrame(200) }), ctx); + return tool.onPointerUp(makeEv({ clipId: TARGET_ID, frame: toFrame(200) }), ctx); +} + +function applyAndCheck(state: TimelineState, tx: ReturnType): TimelineState { + expect(tx).not.toBeNull(); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toHaveLength(0); + } + return result.accepted ? result.nextState : state; +} + +// ── Suite 1: No downstream clips ───────────────────────────────────────────── + +describe('RippleDeleteTool — no downstream: 1 op only', () => { + let tool: RippleDeleteTool; + let state: TimelineState; + + beforeEach(() => { tool = new RippleDeleteTool(); state = makeState(); }); + + it('operations.length === 1', () => { + const tx = clickTarget(tool, state); + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(1); + }); + + it('operations[0].type === DELETE_CLIP (always first)', () => { + const tx = clickTarget(tool, state); + expect(tx!.operations[0]!.type).toBe('DELETE_CLIP'); + const op = tx!.operations[0]!; + if (op.type === 'DELETE_CLIP') expect(op.clipId).toBe(TARGET_ID); + }); + + it('dispatch accepted + checkInvariants', () => { + applyAndCheck(state, clickTarget(tool, state)); + }); +}); + +// ── Suite 2: 3 downstream clips ─────────────────────────────────────────────── + +describe('RippleDeleteTool — 3 downstream clips: 4 ops, correct order', () => { + // target: [100,300) — deletedDuration=200 + // ds-0: [300,400) → shifts to [100,200) + // ds-1: [400,500) → shifts to [200,300) + // ds-2: [500,600) → shifts to [300,400) + let tool: RippleDeleteTool; + let state: TimelineState; + + beforeEach(() => { + tool = new RippleDeleteTool(); + state = makeState([300, 400, 500]); + }); + + it('operations.length === 4 (DELETE + 3×MOVE)', () => { + const tx = clickTarget(tool, state); + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(4); + }); + + it('operations[0].type === DELETE_CLIP always', () => { + const tx = clickTarget(tool, state); + expect(tx!.operations[0]!.type).toBe('DELETE_CLIP'); + const op = tx!.operations[0]!; + if (op.type === 'DELETE_CLIP') expect(op.clipId).toBe(TARGET_ID); + }); + + it('MOVE_CLIP sort: leftmost downstream clip (ds-0 at 300) appears first', () => { + const tx = clickTarget(tool, state); + const op1 = tx!.operations[1]!; + expect(op1.type).toBe('MOVE_CLIP'); + if (op1.type === 'MOVE_CLIP') expect(op1.clipId).toBe(toClipId('clip-ds-0')); + }); + + it('each downstream clip shifts left by exactly deletedDuration (200)', () => { + const tx = clickTarget(tool, state); + const origStarts = [300, 400, 500]; + const deletedDuration = 200; + + const moves = tx!.operations.filter(o => o.type === 'MOVE_CLIP'); + expect(moves).toHaveLength(3); + + moves.forEach((op, i) => { + if (op.type === 'MOVE_CLIP') { + expect(op.newTimelineStart).toBe(toFrame(origStarts[i]! - deletedDuration)); + } + }); + }); + + it('full operation order: DELETE, ds-0 MOVE, ds-1 MOVE, ds-2 MOVE', () => { + const tx = clickTarget(tool, state); + const ops = tx!.operations; + + expect(ops[0]!.type).toBe('DELETE_CLIP'); + expect(ops[1]!.type).toBe('MOVE_CLIP'); + expect(ops[2]!.type).toBe('MOVE_CLIP'); + expect(ops[3]!.type).toBe('MOVE_CLIP'); + + // ds-0 → ds-1 → ds-2 (ascending timelineStart = left-to-right) + if (ops[1]!.type === 'MOVE_CLIP') expect(ops[1]!.newTimelineStart).toBe(toFrame(100)); + if (ops[2]!.type === 'MOVE_CLIP') expect(ops[2]!.newTimelineStart).toBe(toFrame(200)); + if (ops[3]!.type === 'MOVE_CLIP') expect(ops[3]!.newTimelineStart).toBe(toFrame(300)); + }); + + it('dispatch accepted + checkInvariants', () => { + applyAndCheck(state, clickTarget(tool, state)); + }); +}); + +// ── Suite 3: Empty space click ──────────────────────────────────────────────── + +describe('RippleDeleteTool — empty space click: null', () => { + it('clipId=null on pointerDown → onPointerUp returns null', () => { + const state = makeState(); + const tool = new RippleDeleteTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: null }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: null }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 4: Defensive — clip missing from state ────────────────────────────── + +describe('RippleDeleteTool — defensive: clip missing at onPointerUp', () => { + it('pendingClipId set but clip not in ctx.state → null', () => { + const state = makeState(); + const tool = new RippleDeleteTool(); + const ctx = makeCtx(state); + + // Record a valid clipId at pointerDown + tool.onPointerDown(makeEv({ clipId: TARGET_ID }), ctx); + + // Then provide a state with the clip REMOVED (simulate another tool acting first) + const emptyState = makeState([]); // makeState with empty track? Actually we need + // a state that simply has no clip-target. Use a fresh state with a different clip. + // Easiest: build a state where track has no clips at all. + const stateWithoutClip = makeState([]); + // Remove target clip — rebuild with downstream only (no target) + const asset = makeAsset(); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [] }); + const tl = createTimeline({ id: 'tl', name: 'T', fps: frameRate(30), duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), tracks: [track] }); + const ghostState = createTimelineState({ timeline: tl, assetRegistry: new Map([[ASSET_ID, asset]]) }); + + const ctxWithMissingClip = makeCtx(ghostState); + const tx = tool.onPointerUp(makeEv({ clipId: TARGET_ID }), ctxWithMissingClip); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 5: No provisional state ──────────────────────────────────────────── + +describe('RippleDeleteTool — no provisional state', () => { + it('onPointerMove always returns null (before drag)', () => { + const state = makeState(); + const tool = new RippleDeleteTool(); + const ctx = makeCtx(state); + expect(tool.onPointerMove(makeEv({ clipId: TARGET_ID }), ctx)).toBeNull(); + }); + + it('onPointerMove always returns null (mid-drag)', () => { + const state = makeState(); + const tool = new RippleDeleteTool(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: TARGET_ID }), ctx); + expect(tool.onPointerMove(makeEv({ clipId: TARGET_ID }), ctx)).toBeNull(); + }); + + it('onPointerMove always returns null (over empty space)', () => { + const state = makeState(); + const tool = new RippleDeleteTool(); + const ctx = makeCtx(state); + expect(tool.onPointerMove(makeEv({ clipId: null }), ctx)).toBeNull(); + }); +}); + +// ── Suite 6: Cursor and structural ─────────────────────────────────────────── + +describe('RippleDeleteTool — cursor and structural', () => { + it('getCursor returns pointer when hovering clip', () => { + const state = makeState(); + const tool = new RippleDeleteTool(); + const ctx = makeCtx(state); + tool.onPointerMove(makeEv({ clipId: TARGET_ID }), ctx); + expect(tool.getCursor(ctx)).toBe('pointer'); + }); + + it('getCursor returns default when over empty space', () => { + const state = makeState(); + const tool = new RippleDeleteTool(); + const ctx = makeCtx(state); + tool.onPointerMove(makeEv({ clipId: null }), ctx); + expect(tool.getCursor(ctx)).toBe('default'); + }); + + it('onCancel resets pendingClipId — subsequent pointerUp returns null', () => { + const state = makeState(); + const tool = new RippleDeleteTool(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: TARGET_ID }), ctx); + tool.onCancel(); + const tx = tool.onPointerUp(makeEv({ clipId: TARGET_ID }), ctx); + expect(tx).toBeNull(); + }); + + it('getSnapCandidateTypes returns empty array', () => { + expect(new RippleDeleteTool().getSnapCandidateTypes()).toHaveLength(0); + }); + + it('has correct id and empty shortcutKey', () => { + const tool = new RippleDeleteTool(); + expect(tool.id).toBe('ripple-delete'); + expect(tool.shortcutKey).toBe(''); + }); +}); diff --git a/packages/core/src/__tests__/tools/ripple-insert.test.ts b/packages/core/src/__tests__/tools/ripple-insert.test.ts new file mode 100644 index 0000000..63b74ea --- /dev/null +++ b/packages/core/src/__tests__/tools/ripple-insert.test.ts @@ -0,0 +1,464 @@ +/** + * RippleInsertTool Tests — Phase 2 Step 7 + * + * Covers all items from the approved test plan: + * □ 3 clips at drop point: 3× MOVE_CLIP (right-to-left) then INSERT_CLIP — exact order + * □ MOVE_CLIP values: each clip shifts by exactly insertDuration + * □ INSERT_CLIP lands at clampedDropFrame + * □ No clips at drop point: 1 op (INSERT_CLIP only) + * □ Drop at frame 0: valid, INSERT lands at 0 + * □ Clamp: dropFrame + insertDuration > timeline.duration → clamped + * □ No pending insert: onPointerDown no-op, onPointerUp null + * □ No trackId on event: onPointerUp null + * □ Ghost: 1 inserted ghost + N shifted ghosts, provisional id sentinel + * □ Ghost provisional id never appears in committed state + * □ checkInvariants + dispatch.accepted on every Transaction + * □ setPendingInsert mid-drag: ignored (isDragging guard) + * + * Zero React imports. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { RippleInsertTool, _setIdGenerator } from '../../tools/ripple-insert'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset, toAssetId } from '../../types/asset'; +import { toFrame, toTimecode, frameRate } from '../../types/frame'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TimelineFrame } from '../../types/frame'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const TRACK_ID = toTrackId('track-1'); +const ASSET_ID = toAssetId('asset-1'); +const PROVISIONAL_ID = 'provisional-insert' as ClipId; + +// ── Deterministic IDs in tests ──────────────────────────────────────────────── + +let idCounter = 0; +beforeEach(() => { + idCounter = 0; + _setIdGenerator(() => `inserted-clip-${++idCounter}`); +}); +afterEach(() => { + _setIdGenerator(() => crypto.randomUUID()); +}); + +// ── Asset and clip fixtures ─────────────────────────────────────────────────── + +function makeAsset() { + return createAsset({ + id: 'asset-1', name: 'Test', mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(9000), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), status: 'online', + }); +} + +/** + * Build a state with existing clips at given starts (100 frames each). + * timeline.duration = 9000 frames. + */ +function makeState(existingStarts: number[] = []): TimelineState { + const asset = makeAsset(); + const clips = existingStarts.map((start, i) => + createClip({ + id: `clip-ex-${i}`, assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(start), timelineEnd: toFrame(start + 100), + mediaIn: toFrame(i * 100), mediaOut: toFrame(i * 100 + 100), + }), + ); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips }); + const timeline = createTimeline({ + id: 'tl', name: 'RippleInsert Test', fps: frameRate(30), + duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[ASSET_ID, asset]]) }); +} + +function makeCtx(state: TimelineState, overrides: Partial = {}): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: 10, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / 10)), + trackAtY: (_y) => TRACK_ID, + snap: (frame, _excl?) => frame, + ...overrides, + }; +} + +function makeEv(overrides: { + frame?: TimelineFrame; trackId?: TrackId | null; clipId?: ClipId | null; +} = {}): TimelinePointerEvent { + return { + frame: overrides.frame ?? toFrame(0), + trackId: overrides.trackId !== undefined ? overrides.trackId : TRACK_ID, + clipId: overrides.clipId !== undefined ? overrides.clipId : null, + x: 0, y: 24, buttons: 1, + shiftKey: false, altKey: false, metaKey: false, + }; +} + +/** Configure pending insert with 50-frame clip (mediaIn=0, mediaOut=50). */ +function configureTool(tool: RippleInsertTool) { + tool.setPendingInsert(makeAsset(), toFrame(0), toFrame(50)); +} + +/** Simulate a drag: configure → pointerDown → pointerUp at dropFrame. */ +function doDrop( + tool: RippleInsertTool, + state: TimelineState, + dropFrame: number, + trackId: TrackId = TRACK_ID, +): ReturnType { + configureTool(tool); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(dropFrame), trackId }), ctx); + return tool.onPointerUp(makeEv({ frame: toFrame(dropFrame), trackId }), ctx); +} + +function applyAndCheck(state: TimelineState, tx: ReturnType): TimelineState { + expect(tx).not.toBeNull(); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toHaveLength(0); + } + return result.accepted ? result.nextState : state; +} + +// ── Suite 1: 3 clips at drop point — exact order ───────────────────────────── + +describe('RippleInsertTool — 3 downstream clips: 3× MOVE then INSERT', () => { + // Existing: A[300,400), B[400,500), C[500,600) + // Drop at 300, insertDuration=50 + // Expected: MOVE(C→550), MOVE(B→450), MOVE(A→350), INSERT at 300 + + let tool: RippleInsertTool; + let state: TimelineState; + + beforeEach(() => { + tool = new RippleInsertTool(); + state = makeState([300, 400, 500]); + }); + + it('operations.length === 4 (3 MOVE + 1 INSERT)', () => { + const tx = doDrop(tool, state, 300); + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(4); + }); + + it('last operation is INSERT_CLIP', () => { + const tx = doDrop(tool, state, 300); + expect(tx!.operations[3]!.type).toBe('INSERT_CLIP'); + }); + + it('first 3 ops are MOVE_CLIP, sorted right-to-left (C first, A last)', () => { + const tx = doDrop(tool, state, 300); + const ops = tx!.operations; + + expect(ops[0]!.type).toBe('MOVE_CLIP'); + expect(ops[1]!.type).toBe('MOVE_CLIP'); + expect(ops[2]!.type).toBe('MOVE_CLIP'); + + // C (starts at 500) → rightmost, so FIRST + if (ops[0]!.type === 'MOVE_CLIP') expect(ops[0]!.clipId).toBe(toClipId('clip-ex-2')); + // B (starts at 400) → SECOND + if (ops[1]!.type === 'MOVE_CLIP') expect(ops[1]!.clipId).toBe(toClipId('clip-ex-1')); + // A (starts at 300) → LAST MOVE + if (ops[2]!.type === 'MOVE_CLIP') expect(ops[2]!.clipId).toBe(toClipId('clip-ex-0')); + }); + + it('each clip shifts by exactly insertDuration (50)', () => { + const tx = doDrop(tool, state, 300); + const origStarts = [300, 400, 500]; + const insertDuration = 50; + const moves = tx!.operations.filter(o => o.type === 'MOVE_CLIP'); + + // Build a map: clipId → newTimelineStart + const moveMap = new Map( + moves + .filter(o => o.type === 'MOVE_CLIP') + .map(o => o.type === 'MOVE_CLIP' ? [o.clipId, o.newTimelineStart] : ['', 0]), + ); + + origStarts.forEach((start, i) => { + const id = toClipId(`clip-ex-${i}`); + expect(moveMap.get(id)).toBe(toFrame(start + insertDuration)); + }); + }); + + it('INSERT_CLIP lands at dropFrame (300)', () => { + const tx = doDrop(tool, state, 300); + const ins = tx!.operations[3]!; + if (ins.type === 'INSERT_CLIP') { + expect(ins.clip.timelineStart).toBe(toFrame(300)); + expect(ins.clip.timelineEnd).toBe(toFrame(350)); // 300 + 50 + } + }); + + it('checkInvariants + dispatch accepted', () => { + applyAndCheck(state, doDrop(tool, state, 300)); + }); +}); + +// ── Suite 2: No clips at drop point ────────────────────────────────────────── + +describe('RippleInsertTool — no downstream clips: 1 op (INSERT_CLIP only)', () => { + it('operations.length === 1, type === INSERT_CLIP', () => { + const state = makeState([]); // empty track + const tool = new RippleInsertTool(); + const tx = doDrop(tool, state, 200); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(1); + expect(tx!.operations[0]!.type).toBe('INSERT_CLIP'); + }); + + it('INSERT_CLIP lands at dropFrame', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + const tx = doDrop(tool, state, 200); + const ins = tx!.operations[0]!; + if (ins.type === 'INSERT_CLIP') expect(ins.clip.timelineStart).toBe(toFrame(200)); + }); + + it('checkInvariants + dispatch accepted', () => { + const state = makeState([]); + applyAndCheck(state, doDrop(new RippleInsertTool(), state, 200)); + }); +}); + +// ── Suite 3: Drop at frame 0 ────────────────────────────────────────────────── + +describe('RippleInsertTool — drop at frame 0: valid, no clamp', () => { + it('INSERT_CLIP.timelineStart === 0', () => { + const state = makeState([100]); // one existing clip pushed right + const tool = new RippleInsertTool(); + const tx = doDrop(tool, state, 0); + + expect(tx).not.toBeNull(); + const ins = tx!.operations.find(o => o.type === 'INSERT_CLIP')!; + if (ins.type === 'INSERT_CLIP') expect(ins.clip.timelineStart).toBe(toFrame(0)); + }); + + it('checkInvariants + dispatch accepted', () => { + const state = makeState([100]); + applyAndCheck(state, doDrop(new RippleInsertTool(), state, 0)); + }); +}); + +// ── Suite 4: Clamp — dropFrame + insertDuration > timeline.duration ─────────── + +describe('RippleInsertTool — clamp at timeline end', () => { + it('dropFrame clamped so inserted clip fits within timeline (duration=9000)', () => { + // insertDuration=50, drop at 8990 → 8990+50=9040 > 9000. Clamped to 8950. + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(8990), trackId: TRACK_ID }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(8990), trackId: TRACK_ID }), ctx); + + expect(tx).not.toBeNull(); + const ins = tx!.operations.find(o => o.type === 'INSERT_CLIP')!; + // clamped to 9000 - 50 = 8950 + if (ins.type === 'INSERT_CLIP') { + expect(ins.clip.timelineStart).toBe(toFrame(8950)); + expect(ins.clip.timelineEnd).toBe(toFrame(9000)); + } + applyAndCheck(state, tx); + }); +}); + +// ── Suite 5: No pending insert ──────────────────────────────────────────────── + +describe('RippleInsertTool — no pending insert: no-op', () => { + it('onPointerDown without setPendingInsert → onPointerUp returns null', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); // no configureTool() + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(100), trackId: TRACK_ID }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(100), trackId: TRACK_ID }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 6: No trackId ─────────────────────────────────────────────────────── + +describe('RippleInsertTool — no trackId on event: null', () => { + it('null trackId at onPointerDown → no drag, onPointerUp null', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ trackId: null }), ctx); + const tx = tool.onPointerUp(makeEv({ trackId: TRACK_ID }), ctx); // even with trackId at up + expect(tx).toBeNull(); + }); + + it('null trackId at onPointerUp → null even if drag started', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(100), trackId: TRACK_ID }), ctx); + const tx = tool.onPointerUp(makeEv({ trackId: null }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 7: Ghost provisional state ───────────────────────────────────────── + +describe('RippleInsertTool — ProvisionalState ghost', () => { + it('ghost has 1 inserted clip + N shifted clips', () => { + const state = makeState([300, 400]); // 2 existing clips + const tool = new RippleInsertTool(); + configureTool(tool); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(300), trackId: TRACK_ID }), ctx); + + const ghost = tool.onPointerMove(makeEv({ frame: toFrame(300), trackId: TRACK_ID }), ctx); + expect(ghost).not.toBeNull(); + expect(ghost!.isProvisional).toBe(true); + expect(ghost!.clips).toHaveLength(3); // 1 inserted + 2 shifted + }); + + it('ghost inserted clip uses PROVISIONAL_INSERT_ID sentinel', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(200), trackId: TRACK_ID }), ctx); + + const ghost = tool.onPointerMove(makeEv({ frame: toFrame(200), trackId: TRACK_ID }), ctx); + const inserted = ghost!.clips.find(c => c.id === PROVISIONAL_ID); + expect(inserted).not.toBeUndefined(); + }); + + it('ghost inserted clip has correct timelineStart/End', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); // mediaIn=0, mediaOut=50 → insertDuration=50 + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(200), trackId: TRACK_ID }), ctx); + + const ghost = tool.onPointerMove(makeEv({ frame: toFrame(200), trackId: TRACK_ID }), ctx); + const ins = ghost!.clips.find(c => c.id === PROVISIONAL_ID)!; + expect(ins.timelineStart).toBe(toFrame(200)); + expect(ins.timelineEnd).toBe(toFrame(250)); + }); + + it('ghost shifted clips have timelineStart offset by insertDuration', () => { + const state = makeState([400]); // 1 existing clip at 400 + const tool = new RippleInsertTool(); + configureTool(tool); // insertDuration=50 + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(300), trackId: TRACK_ID }), ctx); + + const ghost = tool.onPointerMove(makeEv({ frame: toFrame(300), trackId: TRACK_ID }), ctx); + const shifted = ghost!.clips.find(c => c.id !== PROVISIONAL_ID)!; + expect(shifted.timelineStart).toBe(toFrame(450)); // 400 + 50 + expect(shifted.timelineEnd).toBe(toFrame(550)); // 500 + 50 + }); + + it('provisional id never appears in committed state after dispatch', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + const tx = doDrop(tool, state, 200); + const nextState = applyAndCheck(state, tx); + + // Verify no clip in committed state uses the sentinel id + for (const track of nextState.timeline.tracks) { + for (const clip of track.clips) { + expect(clip.id).not.toBe(PROVISIONAL_ID); + } + } + }); + + it('not mid-drag → onPointerMove returns null', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); + const ctx = makeCtx(state); + // No pointerDown + const ghost = tool.onPointerMove(makeEv({ frame: toFrame(200), trackId: TRACK_ID }), ctx); + expect(ghost).toBeNull(); + }); +}); + +// ── Suite 8: setPendingInsert mid-drag guard ────────────────────────────────── + +describe('RippleInsertTool — setPendingInsert mid-drag: ignored', () => { + it('setPendingInsert during drag is silently ignored', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); // mediaIn=0, mediaOut=50 + + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(100), trackId: TRACK_ID }), ctx); + + // Attempt to reconfigure mid-drag + const newAsset = createAsset({ + id: 'asset-2', name: 'Other', mediaType: 'video', + filePath: '/other.mp4', intrinsicDuration: toFrame(9000), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), status: 'online', + }); + tool.setPendingInsert(newAsset, toFrame(0), toFrame(200)); // should be ignored + + const tx = tool.onPointerUp(makeEv({ frame: toFrame(100), trackId: TRACK_ID }), ctx); + expect(tx).not.toBeNull(); + + // Inserted clip should still use original asset (asset-1) not asset-2 + const ins = tx!.operations.find(o => o.type === 'INSERT_CLIP')!; + if (ins.type === 'INSERT_CLIP') { + expect(ins.clip.assetId).toBe(ASSET_ID); // original asset-1 + expect(ins.clip.mediaOut).toBe(toFrame(50)); // original mediaOut, not 200 + } + }); +}); + +// ── Suite 9: onCancel clears both groups ────────────────────────────────────── + +describe('RippleInsertTool — onCancel', () => { + it('onCancel clears isDragging — subsequent pointerUp returns null', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(100), trackId: TRACK_ID }), ctx); + tool.onCancel(); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(200), trackId: TRACK_ID }), ctx); + expect(tx).toBeNull(); + }); + + it('onCancel clears pendingAsset — getCursor returns default', () => { + const state = makeState([]); + const tool = new RippleInsertTool(); + configureTool(tool); + tool.onCancel(); + expect(tool.getCursor(makeCtx(state))).toBe('default'); + }); + + it('getSnapCandidateTypes returns 4 types', () => { + const tool = new RippleInsertTool(); + expect(tool.getSnapCandidateTypes()).toHaveLength(4); + }); + + it('has correct id and empty shortcutKey', () => { + const tool = new RippleInsertTool(); + expect(tool.id).toBe('ripple-insert'); + expect(tool.shortcutKey).toBe(''); + }); +}); diff --git a/packages/core/src/__tests__/tools/ripple-trim.test.ts b/packages/core/src/__tests__/tools/ripple-trim.test.ts new file mode 100644 index 0000000..88f2237 --- /dev/null +++ b/packages/core/src/__tests__/tools/ripple-trim.test.ts @@ -0,0 +1,613 @@ +/** + * RippleTrimTool Tests — Phase 2 Step 3 + * + * Covers all items from the approved test plan: + * □ END -delta: clip shorter, downstream clips shift left + * □ END +delta: clip longer, downstream clips shift right + * □ START +delta: clip shorter at front, left clips shift right + * □ START -delta: clip longer at front, left clips shift left + * □ Frame-0 clamp: START -delta clamped when left clip would go below 0 + * □ Media bounds clamp END / START + * □ No-op: newFrame === original edge → null + * □ Min duration 1 frame allowed; 0 frames → null + * □ checkInvariants + dispatch.accepted on every non-null Transaction + * + * Zero React imports. All unit tests — no engine, no router. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { RippleTrimTool } from '../../tools/ripple-trim'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset, toAssetId } from '../../types/asset'; +import { toFrame, toTimecode, frameRate } from '../../types/frame'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TimelineFrame } from '../../types/frame'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const TRACK_ID = toTrackId('track-1'); +const ASSET_ID = toAssetId('asset-1'); +const CLIP_A_ID = toClipId('clip-a'); // the clip being trimmed +const CLIP_B_ID = toClipId('clip-b'); // downstream 1 +const CLIP_C_ID = toClipId('clip-c'); // downstream 2 + +// ── Fixture builders ───────────────────────────────────────────────────────── + +function makeAsset(intrinsicDuration = 1000) { + return createAsset({ + id: 'asset-1', name: 'Test', mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(intrinsicDuration), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), status: 'online', + }); +} + +/** + * Layout: A [100,300) → B [300,500) → C [500,700) + * All mediaIn=0, mediaOut=200 (except A which uses mediaIn/Out params). + * pixelsPerFrame = 10, so frame positions correspond to x*10. + */ +function makeEndTrimState( + { aMediaIn = 0, aMediaOut = 200 }: { aMediaIn?: number; aMediaOut?: number } = {}, +): TimelineState { + const asset = makeAsset(); + const clipA = createClip({ + id: 'clip-a', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(100), timelineEnd: toFrame(300), + mediaIn: toFrame(aMediaIn), mediaOut: toFrame(aMediaOut), + }); + const clipB = createClip({ + id: 'clip-b', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(300), timelineEnd: toFrame(500), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }); + const clipC = createClip({ + id: 'clip-c', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(500), timelineEnd: toFrame(700), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }); + return buildState([clipA, clipB, clipC]); +} + +/** + * Layout for START edge trimming: + * B [0,100) → C [100,200) → A [200,400) (A is trimmed from the start) + */ +function makeStartTrimState( + { aMediaIn = 0, aMediaOut = 200 }: { aMediaIn?: number; aMediaOut?: number } = {}, +): TimelineState { + const asset = makeAsset(); + const clipB = createClip({ + id: 'clip-b', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(0), timelineEnd: toFrame(100), + mediaIn: toFrame(0), mediaOut: toFrame(100), + }); + const clipC = createClip({ + id: 'clip-c', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(100), timelineEnd: toFrame(200), + mediaIn: toFrame(0), mediaOut: toFrame(100), + }); + const clipA = createClip({ + id: 'clip-a', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(200), timelineEnd: toFrame(400), + mediaIn: toFrame(aMediaIn), mediaOut: toFrame(aMediaOut), + }); + return buildState([clipB, clipC, clipA]); +} + +function buildState(clips: ReturnType[]): TimelineState { + const asset = makeAsset(); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips }); + const timeline = createTimeline({ + id: 'tl', name: 'Ripple Test', fps: frameRate(30), + duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[ASSET_ID, makeAsset()]]) }); +} + +function makeCtx(state: TimelineState, overrides: Partial = {}): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: 10, // 1 frame = 10px, so EDGE_HIT_ZONE_PX=8 = 0.8 frames + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / 10)), + trackAtY: (_y) => TRACK_ID, + snap: (frame, _excl?) => frame, + ...overrides, + }; +} + +function makeEv(overrides: { + frame?: TimelineFrame; trackId?: TrackId | null; clipId?: ClipId | null; + x?: number; y?: number; buttons?: number; + shiftKey?: boolean; altKey?: boolean; metaKey?: boolean; +} = {}): TimelinePointerEvent { + return { + frame: overrides.frame ?? toFrame(0), + trackId: overrides.trackId ?? TRACK_ID, + clipId: overrides.clipId ?? null, + x: overrides.x ?? 0, + y: overrides.y ?? 24, + buttons: overrides.buttons ?? 1, + shiftKey: overrides.shiftKey ?? false, + altKey: overrides.altKey ?? false, + metaKey: overrides.metaKey ?? false, + }; +} + +function applyAndCheck( + state: TimelineState, + tx: ReturnType, +): TimelineState { + expect(tx).not.toBeNull(); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toHaveLength(0); + } + return result.accepted ? result.nextState : state; +} + +// ── Helper: grab a clip edge via pointerDown ────────────────────────────────── + +/** + * Simulates the user pressing down ON an edge of clip A. + * frame is set to the edge frame exactly (within hit zone since hitZone = 0.8 frames + * and we use frame = exact edge frame → distance = 0). + */ +function grabEndEdge(tool: RippleTrimTool, state: TimelineState) { + const ctx = makeCtx(state); + // Clip A's timelineEnd = 300; grab it + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); +} + +function grabStartEdge(tool: RippleTrimTool, state: TimelineState) { + const ctx = makeCtx(state); + // In startTrimState, clip A's timelineStart = 200; grab it + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(200) }), ctx); +} + +// ── Suite 1: END edge — clip shorter (negative delta) ───────────────────────── + +describe('RippleTrimTool — END trim: -delta (clip gets shorter)', () => { + let tool: RippleTrimTool; + let state: TimelineState; + + beforeEach(() => { tool = new RippleTrimTool(); state = makeEndTrimState(); }); + + it('produces RESIZE_CLIP(end) + 2× MOVE_CLIP', () => { + grabEndEdge(tool, state); + const ctx = makeCtx(state); + // Drag end from 300 → 250 (delta = -50) + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('RESIZE_CLIP'); + expect(tx!.operations).toHaveLength(3); // RESIZE + 2 MOVE + + const resize = tx!.operations[0]!; + if (resize.type === 'RESIZE_CLIP') { + expect(resize.edge).toBe('end'); + expect(resize.newFrame).toBe(toFrame(250)); + } + }); + + it('downstream clips shift left by the same delta (-50)', () => { + grabEndEdge(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + + const moves = tx!.operations.filter(o => o.type === 'MOVE_CLIP'); + expect(moves).toHaveLength(2); + + const moveB = moves.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_B_ID); + const moveC = moves.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_C_ID); + + expect(moveB).toBeDefined(); + expect(moveC).toBeDefined(); + + // B was at 300 → now 250; C was at 500 → now 450 + if (moveB?.type === 'MOVE_CLIP') expect(moveB.newTimelineStart).toBe(toFrame(250)); + if (moveC?.type === 'MOVE_CLIP') expect(moveC.newTimelineStart).toBe(toFrame(450)); + }); + + it('passes checkInvariants', () => { + grabEndEdge(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + applyAndCheck(state, tx); + }); +}); + +// ── Suite 2: END edge — clip longer (positive delta) ───────────────────────── + +describe('RippleTrimTool — END trim: +delta (clip gets longer)', () => { + let tool: RippleTrimTool; + let state: TimelineState; + + beforeEach(() => { tool = new RippleTrimTool(); state = makeEndTrimState(); }); + + it('downstream clips shift right by +delta (+50)', () => { + grabEndEdge(tool, state); + const ctx = makeCtx(state); + // Drag end from 300 → 350 (delta = +50) + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(350) }), ctx); + + expect(tx).not.toBeNull(); + const moves = tx!.operations.filter(o => o.type === 'MOVE_CLIP'); + const moveB = moves.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_B_ID); + const moveC = moves.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_C_ID); + + // B was at 300 → now 350; C was at 500 → now 550 + if (moveB?.type === 'MOVE_CLIP') expect(moveB.newTimelineStart).toBe(toFrame(350)); + if (moveC?.type === 'MOVE_CLIP') expect(moveC.newTimelineStart).toBe(toFrame(550)); + }); + + it('passes checkInvariants', () => { + grabEndEdge(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(350) }), ctx); + applyAndCheck(state, tx); + }); +}); + +// ── Suite 3: START edge — clip shorter at front (+delta) ───────────────────── + +describe('RippleTrimTool — START trim: +delta (clip shorter at front)', () => { + let tool: RippleTrimTool; + let state: TimelineState; + + beforeEach(() => { tool = new RippleTrimTool(); state = makeStartTrimState(); }); + + it('produces RESIZE_CLIP(start) + 2× MOVE_CLIP for left clips', () => { + grabStartEdge(tool, state); + const ctx = makeCtx(state); + // Drag start from 200 → 250 (delta = +50); left clips B[0,100) and C[100,200) + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + + expect(tx).not.toBeNull(); + const resize = tx!.operations[0]!; + if (resize.type === 'RESIZE_CLIP') { + expect(resize.edge).toBe('start'); + expect(resize.newFrame).toBe(toFrame(250)); + } + expect(tx!.operations).toHaveLength(3); // RESIZE + 2 MOVE + }); + + it('left clips shift RIGHT by +delta (+50)', () => { + grabStartEdge(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + + const moves = tx!.operations.filter(o => o.type === 'MOVE_CLIP'); + const moveB = moves.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_B_ID); + const moveC = moves.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_C_ID); + + // B was at 0 → now 50; C was at 100 → now 150 + if (moveB?.type === 'MOVE_CLIP') expect(moveB.newTimelineStart).toBe(toFrame(50)); + if (moveC?.type === 'MOVE_CLIP') expect(moveC.newTimelineStart).toBe(toFrame(150)); + }); + + it('passes checkInvariants', () => { + grabStartEdge(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + applyAndCheck(state, tx); + }); +}); + +// ── Suite 4: START edge — clip longer at front (-delta) ────────────────────── + +describe('RippleTrimTool — START trim: -delta (clip longer at front)', () => { + let tool: RippleTrimTool; + let state: TimelineState; + + beforeEach(() => { tool = new RippleTrimTool(); state = makeStartTrimState(); }); + + it('left clips shift LEFT by -delta (-50), bounded by frame-0 clamp', () => { + grabStartEdge(tool, state); + const ctx = makeCtx(state); + // Drag start from 200 → 150 (delta = -50) + // left clips B[0,100) and C[100,200): B+(-50) = -50 → clamped to 0 + // clamp: frame >= origStart - minStart = 200 - 0 = 200... wait + // Actually: B.timelineStart = 0, so leftmost = 0 + // frame-0 clamp: frame >= dragOrigStart - leftmostStart = 200 - 0 = 200 + // So moving to 150 would be clamped to 200 (no-op) + // Let me think: if B starts at 50 instead, then leftmost = 50 + // → minFrame = 200 - 50 = 150, so 150 is just allowed + // Use a state where B starts at 50 and C at 150 + // Actually this test is checking the clamp separately (Suite 5) + // Here just test that left clips DO shift left by -50 when it's allowed + + // clipA needs mediaIn=100 so leftward trim by -50 is within media bounds + // (minStartForMedia = origStart - mediaIn = 200 - 100 = 100, so 150 is allowed) + // B starts at 100 so delta=-50 keeps B at 50 (>= 0, ok) + const stateWithRoom = buildState([ + createClip({ + id: 'clip-b', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(100), timelineEnd: toFrame(150), + mediaIn: toFrame(0), mediaOut: toFrame(50), + }), + createClip({ + id: 'clip-a', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(200), timelineEnd: toFrame(400), + mediaIn: toFrame(100), mediaOut: toFrame(300), // 100 frames of leftward headroom + }), + ]); + + const tool2 = new RippleTrimTool(); + const ctxRoom = makeCtx(stateWithRoom); + + // Grab start of A at 200, then move to 150 (delta = -50) + // clamp check: minStartForMedia = 200 - 100 = 100 ≤ 150 ✓ + tool2.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(200) }), ctxRoom); + const tx = tool2.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(150) }), ctxRoom); + + expect(tx).not.toBeNull(); + const moveB = tx!.operations.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_B_ID); + // B was at 100 → now 50 (100 + (-50)) + if (moveB?.type === 'MOVE_CLIP') expect(moveB.newTimelineStart).toBe(toFrame(50)); + + applyAndCheck(stateWithRoom, tx); + }); +}); + +// ── Suite 5: Frame-0 clamp ──────────────────────────────────────────────────── + +describe('RippleTrimTool — Frame-0 clamp (START leftward)', () => { + it('clamps newFrame when leftmost downstream clip would go below frame 0', () => { + // B starts at 0 — any leftward trim would push B below 0 + const state = makeStartTrimState(); // B[0,100), C[100,200), A[200,400) + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + // Grab A's start at 200, drag to 100 (delta = -100); B would go to -100 + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(100) }), ctx); + + // Clamped: leftmostStart = 0, so frame >= 200 - 0 = 200 → no-op (null) + // Because dragOrigStart(200) - leftmost(0) = 200, which is the original position + expect(tx).toBeNull(); // fully clamped → no movement → null + }); + + it('partial clamp: allows trim up to where leftmost clip hits frame 0', () => { + // B starts at 80 → can move at most -80 before hitting 0 + // clipA.mediaIn=120 so minStartForMedia = 200-120 = 80, giving plenty of room + // The binding clamp is the frame-0 clamp: 200 - 80 = 120 + const state2 = buildState([ + createClip({ + id: 'clip-b', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(80), timelineEnd: toFrame(180), + mediaIn: toFrame(0), mediaOut: toFrame(100), + }), + createClip({ + id: 'clip-a', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(200), timelineEnd: toFrame(400), + mediaIn: toFrame(120), mediaOut: toFrame(320), // 120 frames leftward headroom + }), + ]); + + const tool2 = new RippleTrimTool(); + const ctx2 = makeCtx(state2); + + // Drag start from 200 → 100 (want delta = -100), but clamped to -80 + // frame-0 clamp: minFrame = dragOrigStart(200) - leftmost(80) = 120 + // media clamp: minStartForMedia = 200 - 120 = 80 ≤ 120 (frame-0 clamp is tighter) + tool2.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(200) }), ctx2); + const tx = tool2.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(100) }), ctx2); + + expect(tx).not.toBeNull(); + const resize = tx!.operations[0]!; + // Clamped to 120 (200 - 80 = 120) + if (resize.type === 'RESIZE_CLIP') expect(resize.newFrame).toBe(toFrame(120)); + + applyAndCheck(state2, tx); + }); +}); + +// ── Suite 6: Media bounds clamp ─────────────────────────────────────────────── + +describe('RippleTrimTool — Media bounds clamp', () => { + it('END trim clamped: mediaOut cannot drop below mediaIn + 1', () => { + // A has mediaIn=0, mediaOut=200, timelineEnd=300 + // Dragging end to 99 (mediaOut would become 99-300+200 = -1) → clamped + // Actually: mediaOut = origMediaOut + delta where delta = newFrame - origEnd + // At newFrame=101: delta = 101-300 = -199, mediaOut = 200 + (-199) = 1 ≥ mediaIn+1=1 ✓ (just ok) + // At newFrame=100: delta = -200, mediaOut = 0 < mediaIn+1=1 → clamped + // minEndForMedia = origEnd - (origMediaOut - origMediaIn - 1) = 300 - (200-0-1) = 300-199=101 + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + // Try to drag end way past media constraint (to frame 50) + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50) }), ctx); + + expect(tx).not.toBeNull(); + const resize = tx!.operations[0]!; + // Clamped to 101 (minEndForMedia = 300 - 199 = 101) + if (resize.type === 'RESIZE_CLIP') expect(resize.newFrame).toBe(toFrame(101)); + + applyAndCheck(state, tx); + }); + + it('START trim clamped: mediaIn cannot exceed mediaOut - 1', () => { + // A: timelineStart=200, mediaIn=0, mediaOut=200 + // maxStartForMedia = origStart + (origMediaOut - origMediaIn - 1) = 200 + 199 = 399 + // Trying to drag to 450 → clamped to 399 + // But also max duration clamp: maxStart = origEnd - 1 = 400 - 1 = 399 (same here) + const state = makeStartTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(450) }), ctx); + + expect(tx).not.toBeNull(); + const resize = tx!.operations[0]!; + if (resize.type === 'RESIZE_CLIP') expect(resize.newFrame).toBe(toFrame(399)); + + applyAndCheck(state, tx); + }); +}); + +// ── Suite 7: No-op ──────────────────────────────────────────────────────────── + +describe('RippleTrimTool — No-op: newFrame === original edge', () => { + it('END no-op: releasing at original end position returns null', () => { + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + expect(tx).toBeNull(); + }); + + it('START no-op: releasing at original start position returns null', () => { + const state = makeStartTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(200) }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 8: Min duration ───────────────────────────────────────────────────── + +describe('RippleTrimTool — Min duration constraint', () => { + it('END trim to exactly 1 frame remaining → valid Transaction', () => { + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + // A: timelineStart=100, timelineEnd=300. Min end = 101. + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(101) }), ctx); + + // If media bounds allow: mediaOut clamped to 101 as well (minEndForMedia = 101) + // Both clamps agree on 101 → valid + expect(tx).not.toBeNull(); + applyAndCheck(state, tx); + }); + + it('END trim: clamping prevents going below min duration (never produces null from clamp alone when clamped frame > origStart)', () => { + // The clamping logic ensures newFrame is always > timelineStart after clamp, + // so the duration check passes when clamped. Null is only returned if + // clamp produces a frame == origStart (no-op), which the no-op test covers. + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + // Drag end to frame 0 — well below any allowed position + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(0) }), ctx); + + // Clamped to 101 (media bounds), which is > timelineStart(100) → valid + expect(tx).not.toBeNull(); + const resize = tx!.operations[0]!; + if (resize.type === 'RESIZE_CLIP') expect(resize.newFrame).toBe(toFrame(101)); + }); +}); + +// ── Suite 9: ProvisionalState ghost ─────────────────────────────────────────── + +describe('RippleTrimTool — ProvisionalState ghost', () => { + it('ghost has trimmed clip + downstream clips all in one clips array', () => { + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + const ghost = tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + + expect(ghost).not.toBeNull(); + expect(ghost!.isProvisional).toBe(true); + expect(ghost!.clips).toHaveLength(3); // A + B + C + }); + + it('ghost trimmed clip has updated timelineEnd', () => { + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + const ghost = tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + + const ghostA = ghost!.clips.find(c => c.id === CLIP_A_ID)!; + expect(ghostA.timelineEnd).toBe(toFrame(250)); + expect(ghostA.timelineStart).toBe(toFrame(100)); // unchanged + }); + + it('ghost downstream clips are shifted by uniform delta', () => { + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + // delta = -50 + const ghost = tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + + const ghostB = ghost!.clips.find(c => c.id === CLIP_B_ID)!; + const ghostC = ghost!.clips.find(c => c.id === CLIP_C_ID)!; + + expect(ghostB.timelineStart).toBe(toFrame(250)); // 300 - 50 + expect(ghostC.timelineStart).toBe(toFrame(450)); // 500 - 50 + }); + + it('not mid-drag → onPointerMove returns null', () => { + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + // No pointerDown first + const ghost = tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + expect(ghost).toBeNull(); + }); +}); + +// ── Suite 10: onCancel ──────────────────────────────────────────────────────── + +describe('RippleTrimTool — onCancel and structural', () => { + it('onCancel clears all state — subsequent onPointerUp returns null', () => { + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + tool.onCancel(); + + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(250) }), ctx); + expect(tx).toBeNull(); + }); + + it('getCursor returns ew-resize when hovering clip edge', () => { + const state = makeEndTrimState(); + const tool = new RippleTrimTool(); + const ctx = makeCtx(state); + + // Hover near clip A's end edge (frame 300 exactly → distance = 0) + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(300) }), ctx); + expect(tool.getCursor(ctx)).toBe('ew-resize'); + }); + + it('has correct id and shortcutKey', () => { + const tool = new RippleTrimTool(); + expect(tool.id).toBe('ripple-trim'); + expect(tool.shortcutKey).toBe('r'); + }); +}); diff --git a/packages/core/src/__tests__/tools/roll-trim.test.ts b/packages/core/src/__tests__/tools/roll-trim.test.ts new file mode 100644 index 0000000..1d0923d --- /dev/null +++ b/packages/core/src/__tests__/tools/roll-trim.test.ts @@ -0,0 +1,522 @@ +/** + * RollTrimTool Tests — Phase 2 Step 4 + * + * Covers all items from the approved test plan: + * □ Roll right: left shorter, right longer, combined duration unchanged + * □ Roll left: left longer, right shorter, combined duration unchanged + * □ Clamp at left media bound + * □ Clamp at right media bound + * □ Clamp at min-duration (left clip cannot reach 0 frames) + * □ No-op: pointerUp at original boundary → null + * □ No roll target (gap between clips) → null + * □ No roll target (single clip edge only) → null + * □ minBoundary > maxBoundary → no-op + * □ checkInvariants + dispatch.accepted on every Transaction + * □ Combined duration explicit assertion + * + * Zero React imports. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { RollTrimTool } from '../../tools/roll-trim'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset, toAssetId } from '../../types/asset'; +import { toFrame, toTimecode, frameRate } from '../../types/frame'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TimelineFrame } from '../../types/frame'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +// ── Constants ──────────────────────────────────────────────────────────────── + +const TRACK_ID = toTrackId('track-1'); +const ASSET_ID = toAssetId('asset-1'); +const LEFT_ID = toClipId('clip-left'); +const RIGHT_ID = toClipId('clip-right'); + +// ── Fixture builders ───────────────────────────────────────────────────────── + +function makeAsset(dur = 1000) { + return createAsset({ + id: 'asset-1', name: 'Test', mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(dur), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), status: 'online', + }); +} + +/** + * Default state: + * LEFT [0, 200) — mediaIn=0, mediaOut=200 + * RIGHT [200, 400) — mediaIn=100, mediaOut=300 + * Cut point at frame 200. + * + * RIGHT.mediaIn=100 gives 100 frames of leftward headroom (constraint E): + * minBoundary from E = origBoundary - rightMediaIn = 200 - 100 = 100 + * So leftward rolls up to frame 100 are allowed by media bounds. + * + * Optional: override to test specific media-bound clamping. + */ +function makeState({ + leftMediaIn = 0, leftMediaOut = 200, + rightMediaIn = 100, rightMediaOut = 300, + rightStart = 200, + gap = 0, +}: { + leftMediaIn?: number; leftMediaOut?: number; + rightMediaIn?: number; rightMediaOut?: number; + rightStart?: number; gap?: number; +} = {}): TimelineState { + const clipLeft = createClip({ + id: 'clip-left', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(0), timelineEnd: toFrame(200), + mediaIn: toFrame(leftMediaIn), mediaOut: toFrame(leftMediaOut), + }); + const clipRight = createClip({ + id: 'clip-right', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(rightStart + gap), timelineEnd: toFrame(rightStart + gap + 200), + mediaIn: toFrame(rightMediaIn), mediaOut: toFrame(rightMediaOut), + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clipLeft, clipRight] }); + const timeline = createTimeline({ + id: 'tl', name: 'Roll Test', fps: frameRate(30), + duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[ASSET_ID, makeAsset()]]) }); +} + +function makeCtx(state: TimelineState, overrides: Partial = {}): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: 10, // 1 frame = 10px, EDGE_ZONE_PX=8 → 0.8 frames + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / 10)), + trackAtY: (_y) => TRACK_ID, + snap: (frame, _excl?) => frame, + ...overrides, + }; +} + +function makeEv(overrides: { + frame?: TimelineFrame; trackId?: TrackId | null; clipId?: ClipId | null; + x?: number; y?: number; +} = {}): TimelinePointerEvent { + return { + frame: overrides.frame ?? toFrame(0), + trackId: overrides.trackId ?? TRACK_ID, + clipId: overrides.clipId ?? null, + x: overrides.x ?? 0, + y: overrides.y ?? 24, + buttons: 1, + shiftKey: false, altKey: false, metaKey: false, + }; +} + +/** Grab the cut point at frame 200 to start a roll trim drag. */ +function grabCut(tool: RollTrimTool, state: TimelineState) { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(200) }), ctx); +} + +function applyAndCheck(state: TimelineState, tx: ReturnType) { + expect(tx).not.toBeNull(); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toHaveLength(0); + } + return result.accepted ? result.nextState : state; +} + +// ── Suite 1: Roll right (boundary moves right) ──────────────────────────────── + +describe('RollTrimTool — roll RIGHT: left shorter, right longer', () => { + let tool: RollTrimTool; + let state: TimelineState; + + beforeEach(() => { tool = new RollTrimTool(); state = makeState(); }); + + it('Transaction has 2× RESIZE_CLIP with identical newFrame', () => { + grabCut(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(250) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(2); + expect(tx!.operations[0]!.type).toBe('RESIZE_CLIP'); + expect(tx!.operations[1]!.type).toBe('RESIZE_CLIP'); + + const op0 = tx!.operations[0]!; + const op1 = tx!.operations[1]!; + if (op0.type === 'RESIZE_CLIP' && op1.type === 'RESIZE_CLIP') { + expect(op0.newFrame).toBe(toFrame(250)); + expect(op1.newFrame).toBe(toFrame(250)); // ← identical + expect(op0.edge).toBe('end'); + expect(op1.edge).toBe('start'); + expect(op0.clipId).toBe(LEFT_ID); + expect(op1.clipId).toBe(RIGHT_ID); + } + }); + + it('combined duration unchanged after roll right', () => { + grabCut(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(250) }), ctx); + const nextState = applyAndCheck(state, tx); + + const track = nextState.timeline.tracks[0]!; + const newLeft = track.clips.find(c => c.id === LEFT_ID)!; + const newRight = track.clips.find(c => c.id === RIGHT_ID)!; + const origLeft = state.timeline.tracks[0]!.clips.find(c => c.id === LEFT_ID)!; + const origRight = state.timeline.tracks[0]!.clips.find(c => c.id === RIGHT_ID)!; + + const origCombined = (origLeft.timelineEnd - origLeft.timelineStart) + + (origRight.timelineEnd - origRight.timelineStart); + const newCombined = (newLeft.timelineEnd - newLeft.timelineStart) + + (newRight.timelineEnd - newRight.timelineStart); + + // THE defining invariant of roll trim + expect(newCombined).toBe(origCombined); + + // Sanity: left shorter, right longer + expect(newLeft.timelineEnd - newLeft.timelineStart).toBe(250); // was 200 + expect(newRight.timelineEnd - newRight.timelineStart).toBe(150); // was 200 + }); + + it('passes checkInvariants', () => { + grabCut(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(250) }), ctx); + applyAndCheck(state, tx); + }); +}); + +// ── Suite 2: Roll left (boundary moves left) ────────────────────────────────── + +describe('RollTrimTool — roll LEFT: left longer, right shorter', () => { + let tool: RollTrimTool; + let state: TimelineState; + + beforeEach(() => { tool = new RollTrimTool(); state = makeState(); }); + + it('combined duration unchanged after roll left', () => { + grabCut(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(150) }), ctx); + const nextState = applyAndCheck(state, tx); + + const track = nextState.timeline.tracks[0]!; + const newLeft = track.clips.find(c => c.id === LEFT_ID)!; + const newRight = track.clips.find(c => c.id === RIGHT_ID)!; + + expect(newLeft.timelineEnd - newLeft.timelineStart).toBe(150); // was 200 + expect(newRight.timelineEnd - newRight.timelineStart).toBe(250); // was 200 + + const combined = (newLeft.timelineEnd - newLeft.timelineStart) + + (newRight.timelineEnd - newRight.timelineStart); + expect(combined).toBe(400); // 200 + 200 = 400 unchanged + }); + + it('passes checkInvariants', () => { + grabCut(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(150) }), ctx); + applyAndCheck(state, tx); + }); +}); + +// ── Suite 3: Clamp — left media bound ──────────────────────────────────────── + +describe('RollTrimTool — clamp: left media bound (B\' rightward)', () => { + it('cannot roll right past left clip intrinsicDuration', () => { + // Constraint B': origBoundary + (intrinsicDuration - leftMediaOut) = 200 + (300 - 200) = 300 + // i.e. leftClip has 100 frames of rightward media supply + // Rolling to frame 350 → clamped to 300 + // Use asset with intrinsicDuration=300, leftClip.mediaOut=200 → 100 frames ahead + // rightClip also needs enough room: rightTimelineEnd must be > 300, and rightMediaIn + // must cover rolling left (rightMediaIn >= delta_max). Use rightMediaIn=200. + const leftAsset = createAsset({ + id: 'asset-1', name: 'T', mediaType: 'video', filePath: '/t.mp4', + intrinsicDuration: toFrame(300), // only 300 frames total → B' clamp at 300 + nativeFps: 30, sourceTimecodeOffset: toFrame(0), status: 'online', + }); + const rightAsset = createAsset({ + id: 'asset-2', name: 'T2', mediaType: 'video', filePath: '/t2.mp4', + intrinsicDuration: toFrame(1000), // plenty of media — not the binding constraint + nativeFps: 30, sourceTimecodeOffset: toFrame(0), status: 'online', + }); + // LEFT: [0,200), mediaIn=0, mediaOut=200 → rightward headroom = 300-200 = 100 frames + const clipLeft = createClip({ + id: 'clip-left', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(0), timelineEnd: toFrame(200), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }); + // RIGHT: [200,500), mediaIn=200, mediaOut=500 (duration=300, uses asset-2) + // rightMediaIn=200 → constraint E: 200-200=0 (can roll all the way left to 0, not binding) + const clipRight = createClip({ + id: 'clip-right', assetId: 'asset-2', trackId: 'track-1', + timelineStart: toFrame(200), timelineEnd: toFrame(500), + mediaIn: toFrame(200), mediaOut: toFrame(500), + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clipLeft, clipRight] }); + const tl = createTimeline({ id: 'tl', name: 'T', fps: frameRate(30), duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), tracks: [track] }); + const assetRegistry = new Map>([ + [toAssetId('asset-1'), leftAsset], + [toAssetId('asset-2'), rightAsset], + ]); + const state = createTimelineState({ timeline: tl, assetRegistry }); + + const tool = new RollTrimTool(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(350) }), ctx); + + expect(tx).not.toBeNull(); + const op0 = tx!.operations[0]!; + // maxBoundary = min(rightClip.timelineEnd-1=499, origBoundary+(intrinsicDuration-leftMediaOut)) = min(499, 200+100) = 300 + if (op0.type === 'RESIZE_CLIP') expect(op0.newFrame).toBe(toFrame(300)); + + applyAndCheck(state, tx); + }); +}); + +// ── Suite 4: Clamp — right media bound ─────────────────────────────────────── + +describe('RollTrimTool — clamp: right media bound (E leftward)', () => { + it('cannot roll left past right clip mediaIn=0 (constraint E)', () => { + // RIGHT: mediaIn=50, mediaOut=250 → constraint E: origBoundary - 50 = 150 + // Rolling to frame 50 → clamped to 150 (by constraint E) + // LEFT: same as default, intrinsicDuration=1000 → constraint B' not binding at 150 + // LEFT: [0,200), mediaIn=0, mediaOut=200; intrinsicDuration=1000 + // RIGHT: [200,400), mediaIn=50, mediaOut=250 (duration=200 to satisfy invariant) + const clipLeft = createClip({ + id: 'clip-left', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(0), timelineEnd: toFrame(200), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }); + const clipRight = createClip({ + id: 'clip-right', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(200), timelineEnd: toFrame(400), + mediaIn: toFrame(50), mediaOut: toFrame(250), // duration=200, mediaIn=50 + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clipLeft, clipRight] }); + const tl = createTimeline({ id: 'tl', name: 'T', fps: frameRate(30), duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), tracks: [track] }); + const state = createTimelineState({ timeline: tl, assetRegistry: new Map([[ASSET_ID, makeAsset()]]) }); + + const tool = new RollTrimTool(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(50) }), ctx); + + expect(tx).not.toBeNull(); + const op0 = tx!.operations[0]!; + // minBoundary = max(0+1=1, 200-50=150) = 150 ← E is binding here + if (op0.type === 'RESIZE_CLIP') expect(op0.newFrame).toBe(toFrame(150)); + + applyAndCheck(state, tx); + }); +}); + +// ── Suite 5: Clamp — min duration ───────────────────────────────────────────── + +describe('RollTrimTool — clamp: min-duration (1 frame)', () => { + it('left clip cannot be rolled to 0 frames — clamped to frame 1 (constraint A)', () => { + // LEFT: timelineStart=0 → minBoundary from A = 0+1 = 1 + // Constraint E: origBoundary - rightMediaIn = 200 - 199 = 1 (matches A exactly) + // Rolling to frame 0 → clamped to 1 + // RIGHT: mediaIn=199, mediaOut=399 (duration=200 satisfies invariant) + const clipLeft = createClip({ + id: 'clip-left', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(0), timelineEnd: toFrame(200), + mediaIn: toFrame(0), mediaOut: toFrame(200), + }); + const clipRight = createClip({ + id: 'clip-right', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(200), timelineEnd: toFrame(400), + mediaIn: toFrame(199), mediaOut: toFrame(399), // mediaIn=199 → E: 200-199=1 = A + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clipLeft, clipRight] }); + const tl = createTimeline({ id: 'tl', name: 'T', fps: frameRate(30), duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), tracks: [track] }); + const state = createTimelineState({ timeline: tl, assetRegistry: new Map([[ASSET_ID, makeAsset()]]) }); + + const tool = new RollTrimTool(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(0) }), ctx); + + expect(tx).not.toBeNull(); + const op0 = tx!.operations[0]!; + if (op0.type === 'RESIZE_CLIP') expect(op0.newFrame).toBe(toFrame(1)); + + applyAndCheck(state, tx); + }); + + it('right clip cannot be rolled to 0 frames — clamped at rightEnd - 1', () => { + // RIGHT: timelineEnd=400. Max boundary = 400 - 1 = 399 + // Rolling to frame 9000 → clamped to 399 + const state = makeState(); + const tool = new RollTrimTool(); + grabCut(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(9000) }), ctx); + + expect(tx).not.toBeNull(); + const op0 = tx!.operations[0]!; + if (op0.type === 'RESIZE_CLIP') expect(op0.newFrame).toBe(toFrame(399)); + + applyAndCheck(state, tx); + }); +}); + +// ── Suite 6: No-op ──────────────────────────────────────────────────────────── + +describe('RollTrimTool — no-op: pointerUp at original boundary', () => { + it('returns null when released at frame 200 (original boundary)', () => { + const state = makeState(); + const tool = new RollTrimTool(); + grabCut(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(200) }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 7: No roll target (gap between clips) ─────────────────────────────── + +describe('RollTrimTool — no roll target: gap between clips', () => { + it('gap=50: clicking frame 200 does not activate roll → null on pointerUp', () => { + const state = makeState({ gap: 50 }); // RIGHT starts at 250, not 200 + const tool = new RollTrimTool(); + const ctx = makeCtx(state); + + // Frame 200 is within zone of leftClip.timelineEnd=200, but rightClip.timelineStart=250 ≠ 200 + tool.onPointerDown(makeEv({ frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(250) }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 8: No roll target (single clip edge only) ─────────────────────────── + +describe('RollTrimTool — no roll target: only one clip edge nearby', () => { + it('clicking frame 50 (inside left clip, no cut) → null', () => { + const state = makeState(); + const tool = new RollTrimTool(); + const ctx = makeCtx(state); + + // Frame 50 — no clip has its end or start near here (left ends at 200, right starts at 200) + tool.onPointerDown(makeEv({ frame: toFrame(50) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(150) }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 9: minBoundary > maxBoundary (no room to roll) ───────────────────── + +describe('RollTrimTool — no-op: minBoundary > maxBoundary', () => { + it('when both clips are at their media limits, onPointerDown is a no-op', () => { + // LEFT: mediaIn=199, mediaOut=200 (only 1 frame of media on left side) + // min = max(0+1, 200-(200-199-1)) = max(1, 200-0) = max(1, 200) = 200 + // RIGHT: mediaIn=0, mediaOut=1 (only 1 frame of media on right side) + // max = min(400-1, 200+(1-0-1)) = min(399, 200) = 200 + // minBoundary=200, maxBoundary=200 → still equal (just barely valid) + // Let's make it tighter: + // LEFT: mediaIn=200, mediaOut=200 → range 0, making mediaOut-mediaIn-1 = -1 + // min = max(1, 200-(-1)) = max(1, 201) = 201 + // RIGHT: mediaIn=0, mediaOut=1 → max = min(399, 200+0) = 200 + // minBoundary=201 > maxBoundary=200 → no-op ✓ + const state = makeState({ leftMediaIn: 200, leftMediaOut: 200, rightMediaIn: 0, rightMediaOut: 1 }); + const tool = new RollTrimTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ frame: toFrame(200) }), ctx); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(190) }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 10: ProvisionalState ghost ───────────────────────────────────────── + +describe('RollTrimTool — ProvisionalState ghost', () => { + it('ghost has exactly 2 clips — left and right', () => { + const state = makeState(); + const tool = new RollTrimTool(); + grabCut(tool, state); + const ctx = makeCtx(state); + + const ghost = tool.onPointerMove(makeEv({ frame: toFrame(250) }), ctx); + expect(ghost).not.toBeNull(); + expect(ghost!.isProvisional).toBe(true); + expect(ghost!.clips).toHaveLength(2); + }); + + it('ghost left clip has new timelineEnd, right clip has new timelineStart = same frame', () => { + const state = makeState(); + const tool = new RollTrimTool(); + grabCut(tool, state); + const ctx = makeCtx(state); + + const ghost = tool.onPointerMove(makeEv({ frame: toFrame(250) }), ctx); + const ghostLeft = ghost!.clips.find(c => c.id === LEFT_ID)!; + const ghostRight = ghost!.clips.find(c => c.id === RIGHT_ID)!; + + expect(ghostLeft.timelineEnd).toBe(toFrame(250)); + expect(ghostRight.timelineStart).toBe(toFrame(250)); + expect(ghostLeft.timelineEnd).toBe(ghostRight.timelineStart); // always equal + }); + + it('not mid-drag → onPointerMove returns null', () => { + const state = makeState(); + const tool = new RollTrimTool(); + const ctx = makeCtx(state); + + // No pointerDown + const ghost = tool.onPointerMove(makeEv({ frame: toFrame(250) }), ctx); + expect(ghost).toBeNull(); + }); +}); + +// ── Suite 11: onCancel and structural ──────────────────────────────────────── + +describe('RollTrimTool — onCancel and structural', () => { + it('onCancel resets state — subsequent pointerUp returns null', () => { + const state = makeState(); + const tool = new RollTrimTool(); + grabCut(tool, state); + tool.onCancel(); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ frame: toFrame(250) }), ctx); + expect(tx).toBeNull(); + }); + + it('getCursor returns ew-resize when mid-drag', () => { + const state = makeState(); + const tool = new RollTrimTool(); + grabCut(tool, state); + const ctx = makeCtx(state); + expect(tool.getCursor(ctx)).toBe('ew-resize'); + }); + + it('getCursor returns default when idle', () => { + const state = makeState(); + const tool = new RollTrimTool(); + const ctx = makeCtx(state); + expect(tool.getCursor(ctx)).toBe('default'); + }); + + it('has correct id and shortcutKey', () => { + const tool = new RollTrimTool(); + expect(tool.id).toBe('roll-trim'); + expect(tool.shortcutKey).toBe('t'); + }); +}); diff --git a/packages/core/src/__tests__/tools/selection.test.ts b/packages/core/src/__tests__/tools/selection.test.ts new file mode 100644 index 0000000..53f931d --- /dev/null +++ b/packages/core/src/__tests__/tools/selection.test.ts @@ -0,0 +1,529 @@ +/** + * SelectionTool Tests — Phase 2 + * + * Tests the four interaction modes with no React / no engine / no router. + * Every Transaction produced is verified with checkInvariants() — no exceptions. + * + * Test locations follow the convention: + * packages/core/src/__tests__/tools/ ← unit tests, zero React + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { SelectionTool } from '../../tools/selection'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset, toAssetId } from '../../types/asset'; +import { toFrame, toTimecode, frameRate } from '../../types/frame'; +import { buildSnapIndex } from '../../snap-index'; +import type { + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, +} from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TimelineFrame } from '../../types/frame'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const ASSET_ID = toAssetId('asset-1'); +const TRACK_ID = toTrackId('track-1'); +const CLIP_A_ID = toClipId('clip-a'); // [0, 100) +const CLIP_B_ID = toClipId('clip-b'); // [200, 300) + +function makeState(): TimelineState { + const asset = createAsset({ + id: 'asset-1', + name: 'Test Asset', + mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + status: 'online', + }); + + const clipA = createClip({ + id: 'clip-a', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const clipB = createClip({ + id: 'clip-b', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const track = createTrack({ + id: 'track-1', + name: 'V1', + type: 'video', + clips: [clipA, clipB], + }); + + const timeline = createTimeline({ + id: 'tl', + name: 'Selection Test', + fps: frameRate(30), + duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + + return createTimelineState({ + timeline, + assetRegistry: new Map([[ASSET_ID, asset]]), + }); +} + +/** Build a minimal ToolContext. snap() is a no-op (identity) by default. */ +function makeCtx( + state: TimelineState, + overrides: Partial = {}, +): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: 10, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / 10)), + trackAtY: (_y) => TRACK_ID, + snap: (frame, _exclude?) => frame, // identity — no snap in unit tests + ...overrides, + }; +} + +/** Minimal PointerEvent builder */ +function makeEv(overrides: { + frame?: TimelineFrame; + trackId?: TrackId | null; + clipId?: ClipId | null; + x?: number; + y?: number; + buttons?: number; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} = {}): TimelinePointerEvent { + return { + frame: overrides.frame ?? toFrame(0), + trackId: overrides.trackId ?? TRACK_ID, + clipId: overrides.clipId ?? null, + x: overrides.x ?? 0, + y: overrides.y ?? 24, + buttons: overrides.buttons ?? 1, + shiftKey: overrides.shiftKey ?? false, + altKey: overrides.altKey ?? false, + metaKey: overrides.metaKey ?? false, + }; +} + +/** Apply a Transaction and assert zero invariant violations. */ +function applyAndCheck(state: TimelineState, tx: ReturnType) { + expect(tx).not.toBeNull(); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toHaveLength(0); + } + return result.accepted ? result.nextState : state; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('SelectionTool — MODE 1: single click', () => { + let tool: SelectionTool; + let state: TimelineState; + + beforeEach(() => { + tool = new SelectionTool(); + state = makeState(); + }); + + it('click on clip selects it', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); // < 4px + tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); + + expect(tool.getSelection().has(CLIP_A_ID)).toBe(true); + expect(tool.getSelection().size).toBe(1); + }); + + it('click on different clip replaces selection', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); + + tool.onPointerDown(makeEv({ clipId: CLIP_B_ID, x: 200 }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_B_ID, x: 201 }), ctx); + + expect(tool.getSelection().has(CLIP_B_ID)).toBe(true); + expect(tool.getSelection().has(CLIP_A_ID)).toBe(false); + expect(tool.getSelection().size).toBe(1); + }); + + it('shift-click toggles clip into selection', () => { + const ctx = makeCtx(state); + // Select A first + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); + + // Shift-click B + tool.onPointerDown(makeEv({ clipId: CLIP_B_ID, x: 200, shiftKey: true }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_B_ID, x: 201, shiftKey: true }), ctx); + + expect(tool.getSelection().has(CLIP_A_ID)).toBe(true); + expect(tool.getSelection().has(CLIP_B_ID)).toBe(true); + }); + + it('shift-click on already-selected clip deselects it', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50, shiftKey: true }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51, shiftKey: true }), ctx); + + expect(tool.getSelection().has(CLIP_A_ID)).toBe(false); + }); + + it('click on empty space clears selection', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); + + // Click empty space + tool.onPointerDown(makeEv({ clipId: null, x: 150 }), ctx); + tool.onPointerUp(makeEv({ clipId: null, x: 151 }), ctx); + + expect(tool.getSelection().size).toBe(0); + }); + + it('click returns null Transaction (selection is not in history)', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); + expect(tx).toBeNull(); + }); +}); + +describe('SelectionTool — MODE 2: single clip drag', () => { + let tool: SelectionTool; + let state: TimelineState; + + beforeEach(() => { + tool = new SelectionTool(); + state = makeState(); + }); + + it('drag clip A by 50 frames — provisional ghost appears during move', () => { + const ctx = makeCtx(state); + // Down at frame 10 (clientX=100 with ppf=10) + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(10), x: 100 }), ctx); + + // Move to frame 60 — sufficient drag (60-10=50 frames, clientX=600 vs 100 = 500px >> 4px) + const provisional = tool.onPointerMove( + makeEv({ clipId: CLIP_A_ID, frame: toFrame(60), x: 600 }), + ctx, + ); + + expect(provisional).not.toBeNull(); + expect(provisional!.isProvisional).toBe(true); + expect(provisional!.clips).toHaveLength(1); + // clip-a starts at 0, dragStartFrame=10, delta=+50 → new start = 50 + expect(provisional!.clips[0]!.timelineStart).toBe(toFrame(50)); + expect(provisional!.clips[0]!.timelineEnd).toBe(toFrame(150)); + }); + + it('ghost uses LIVE clip data from ctx.state (not a stored snapshot)', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(10), x: 100 }), ctx); + + const provisional = tool.onPointerMove( + makeEv({ clipId: CLIP_A_ID, frame: toFrame(60), x: 600 }), + ctx, + ); + // Ghost is a spread of the live clip — same id, trackId, assetId + expect(provisional!.clips[0]!.id).toBe(CLIP_A_ID); + expect(provisional!.clips[0]!.trackId).toBe(TRACK_ID); + expect(provisional!.clips[0]!.assetId).toBe(ASSET_ID); + }); + + it('drag produces MOVE_CLIP Transaction with correct newTimelineStart', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(0), x: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), ctx); + const tx = tool.onPointerUp( + makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), + ctx, + ); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(1); + const op = tx!.operations[0]!; + expect(op.type).toBe('MOVE_CLIP'); + if (op.type === 'MOVE_CLIP') { + expect(op.clipId).toBe(CLIP_A_ID); + expect(op.newTimelineStart).toBe(toFrame(50)); + } + }); + + it('MOVE_CLIP Transaction passes checkInvariants', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(0), x: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), ctx); + applyAndCheck(state, tx); + }); + + it('no-op drag (clip returned to start) returns null', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(0), x: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(10), x: 100 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(0), x: 0 }), ctx); + expect(tx).toBeNull(); + }); + + it('onPointerMove returns null below 4px click threshold', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 100 }), ctx); + // Move only 2px — below threshold + const provisional = tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, x: 102 }), ctx); + expect(provisional).toBeNull(); + }); +}); + +describe('SelectionTool — MODE 3: multi-clip drag', () => { + let tool: SelectionTool; + let state: TimelineState; + + beforeEach(() => { + tool = new SelectionTool(); + state = makeState(); + }); + + /** Helper: select both clips via clicks, then drag both */ + function selectBoth(ctx: ToolContext) { + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); + + tool.onPointerDown(makeEv({ clipId: CLIP_B_ID, x: 200, shiftKey: true }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_B_ID, x: 201, shiftKey: true }), ctx); + + expect(tool.getSelection().size).toBe(2); + } + + it('dragging a selected clip when multiple are selected moves all', () => { + const ctx = makeCtx(state); + selectBoth(ctx); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(0), x: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), ctx); + const provisional = tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 501 }), ctx); + + expect(provisional).not.toBeNull(); + expect(provisional!.clips).toHaveLength(2); // both clips in ghost + }); + + it('all selected clips move by identical delta', () => { + const ctx = makeCtx(state); + selectBoth(ctx); + + // clip-a=[0,100), clip-b=[200,300). Drag clip-a by +50 frames + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(0), x: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(2); + + const opA = tx!.operations.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_A_ID); + const opB = tx!.operations.find(o => o.type === 'MOVE_CLIP' && o.clipId === CLIP_B_ID); + + expect(opA).toBeDefined(); + expect(opB).toBeDefined(); + + if (opA?.type === 'MOVE_CLIP') expect(opA.newTimelineStart).toBe(toFrame(50)); // 0 + 50 + if (opB?.type === 'MOVE_CLIP') expect(opB.newTimelineStart).toBe(toFrame(250)); // 200 + 50 + }); + + it('multi-clip MOVE_CLIP Transaction passes checkInvariants', () => { + const ctx = makeCtx(state); + selectBoth(ctx); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(0), x: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, frame: toFrame(50), x: 500 }), ctx); + + applyAndCheck(state, tx); + }); + + it('uniform delta — both clips shift by same amount (not independently snapped)', () => { + const ctx = makeCtx(state); + selectBoth(ctx); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(10), x: 100 }), ctx); + const provisional = tool.onPointerMove( + makeEv({ clipId: CLIP_A_ID, frame: toFrame(30), x: 300 }), // delta=+20 + ctx, + ); + + expect(provisional!.clips).toHaveLength(2); + const ghostA = provisional!.clips.find(c => c.id === CLIP_A_ID)!; + const ghostB = provisional!.clips.find(c => c.id === CLIP_B_ID)!; + + // Both moved by +20 frames + expect(ghostA.timelineStart).toBe(toFrame(20)); // 0 + 20 + expect(ghostB.timelineStart).toBe(toFrame(220)); // 200 + 20 + }); +}); + +describe('SelectionTool — MODE 4: rubber-band select', () => { + let tool: SelectionTool; + let state: TimelineState; + + beforeEach(() => { + tool = new SelectionTool(); + state = makeState(); + }); + + it('rubber-band drag returns ProvisionalState with rubberBand region', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: null, frame: toFrame(0), y: 10 }), ctx); + const provisional = tool.onPointerMove( + makeEv({ clipId: null, frame: toFrame(150), y: 40 }), + ctx, + ); + + expect(provisional).not.toBeNull(); + expect(provisional!.clips).toHaveLength(0); + expect(provisional!.rubberBand).toBeDefined(); + expect(provisional!.rubberBand!.startFrame).toBe(toFrame(0)); + expect(provisional!.rubberBand!.endFrame).toBe(toFrame(150)); + expect(provisional!.rubberBand!.startY).toBe(10); + expect(provisional!.rubberBand!.endY).toBe(40); + }); + + it('rubber-band selects clips whose frame range overlaps the band', () => { + const ctx = makeCtx(state); + // Sweep from frame 50 to frame 250 — intersects both clip-a [0,100) and clip-b [200,300) + tool.onPointerDown(makeEv({ clipId: null, frame: toFrame(50), x: 0, y: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: null, frame: toFrame(250), x: 500, y: 48 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: null, frame: toFrame(250), x: 500, y: 48 }), ctx); + + expect(tx).toBeNull(); // no Transaction + expect(tool.getSelection().has(CLIP_A_ID)).toBe(true); + expect(tool.getSelection().has(CLIP_B_ID)).toBe(true); + }); + + it('rubber-band selects only clips within the swept region', () => { + const ctx = makeCtx(state); + // Sweep from frame 0 to frame 50 — only intersects clip-a [0,100) + tool.onPointerDown(makeEv({ clipId: null, frame: toFrame(0), x: 0, y: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: null, frame: toFrame(50), x: 500, y: 48 }), ctx); + tool.onPointerUp(makeEv({ clipId: null, frame: toFrame(50), x: 500, y: 48 }), ctx); + + expect(tool.getSelection().has(CLIP_A_ID)).toBe(true); + expect(tool.getSelection().has(CLIP_B_ID)).toBe(false); + }); + + it('rubber-band returns null Transaction', () => { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: null, frame: toFrame(0), x: 0, y: 0 }), ctx); + tool.onPointerMove(makeEv({ clipId: null, frame: toFrame(250), x: 500, y: 48 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: null, frame: toFrame(250), x: 500, y: 48 }), ctx); + expect(tx).toBeNull(); + }); +}); + +describe('SelectionTool — onCancel', () => { + it('resets ALL instance state including selection', () => { + const state = makeState(); + const ctx = makeCtx(state); + const tool = new SelectionTool(); + + // Create some state + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP_A_ID, x: 51 }), ctx); // select A + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, frame: toFrame(10), x: 100 }), ctx); // start drag + + // Cancel mid-drag + tool.onCancel(); + + expect(tool.getSelection().size).toBe(0); + // After cancel, getCursor() should return 'default' + expect(tool.getCursor(ctx)).toBe('default'); + }); +}); + +describe('SelectionTool — getCursor', () => { + it('returns "default" when idle', () => { + const state = makeState(); + const ctx = makeCtx(state); + const tool = new SelectionTool(); + expect(tool.getCursor(ctx)).toBe('default'); + }); + + it('returns "grab" when hovering a clip', () => { + const state = makeState(); + const ctx = makeCtx(state); + const tool = new SelectionTool(); + + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, x: 50 }), ctx); + expect(tool.getCursor(ctx)).toBe('grab'); + }); + + it('returns "grabbing" during drag', () => { + const state = makeState(); + const ctx = makeCtx(state); + const tool = new SelectionTool(); + + tool.onPointerDown(makeEv({ clipId: CLIP_A_ID, x: 50, frame: toFrame(5) }), ctx); + tool.onPointerMove(makeEv({ clipId: CLIP_A_ID, x: 550, frame: toFrame(55) }), ctx); + expect(tool.getCursor(ctx)).toBe('grabbing'); + }); + + it('returns "crosshair" during rubber-band', () => { + const state = makeState(); + const ctx = makeCtx(state); + const tool = new SelectionTool(); + + tool.onPointerDown(makeEv({ clipId: null, x: 150 }), ctx); + tool.onPointerMove(makeEv({ clipId: null, x: 200 }), ctx); + expect(tool.getCursor(ctx)).toBe('crosshair'); + }); +}); + +describe('SelectionTool — ITool interface: getSnapCandidateTypes', () => { + it('returns ClipStart, ClipEnd, Playhead snap types', () => { + const tool = new SelectionTool(); + const types = tool.getSnapCandidateTypes(); + expect(types).toContain('ClipStart'); + expect(types).toContain('ClipEnd'); + expect(types).toContain('Playhead'); + }); +}); + +describe('SelectionTool — no React imports (structural)', () => { + it('SelectionTool has correct id and shortcutKey', () => { + const tool = new SelectionTool(); + expect(tool.id).toBe('selection'); + expect(tool.shortcutKey).toBe('v'); + }); +}); diff --git a/packages/core/src/__tests__/tools/slide-tool.test.ts b/packages/core/src/__tests__/tools/slide-tool.test.ts new file mode 100644 index 0000000..07c85c6 --- /dev/null +++ b/packages/core/src/__tests__/tools/slide-tool.test.ts @@ -0,0 +1,290 @@ +/** + * SlideTool Tests — Phase 7 Step 5 + * + * Fixture: one video track, three clips — clip1 [0,200), clip2 [300,500), clip3 [500,700]. + * Slide range for clip2: start 200..300 (left neighbor ends at 200, right starts at 500). + * + * 1. onPointerDown on clip2 sets drag state (provisional on move) + * 2. onPointerDown on empty: no drag + * 3. onPointerMove shows provisional at new position + * 4. onPointerMove clamps to left neighbor boundary + * 5. onPointerMove clamps to right neighbor boundary + * 6. onPointerUp dispatches MOVE_CLIP for clip2 + * 7. onPointerUp with no movement: no dispatch + * 8. onPointerUp includes neighbor adjustments in transaction + * 9. onCancel resets state + * 10. capture-before-reset: no drag after onPointerUp + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { SlideTool } from '../../tools/slide-tool'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset, toAssetId } from '../../types/asset'; +import { toFrame, toTimecode, frameRate } from '../../types/frame'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +const TRACK_ID = toTrackId('track-1'); +const ASSET_ID = toAssetId('asset-1'); +const CLIP1_ID = toClipId('clip1'); +const CLIP2_ID = toClipId('clip2'); +const CLIP3_ID = toClipId('clip3'); + +const PX_PER_FRAME = 10; + +function makeState(): TimelineState { + const asset = createAsset({ + id: 'asset-1', + name: 'Test', + mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(2000), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + status: 'online', + }); + const clip1 = createClip({ + id: 'clip1', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const clip2 = createClip({ + id: 'clip2', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(300), + timelineEnd: toFrame(500), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const clip3 = createClip({ + id: 'clip3', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(500), + timelineEnd: toFrame(700), + mediaIn: toFrame(0), + mediaOut: toFrame(200), + }); + const track = createTrack({ + id: 'track-1', + name: 'V1', + type: 'video', + clips: [clip1, clip2, clip3], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'Slide Test', + fps: frameRate(30), + duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ + timeline, + assetRegistry: new Map([[ASSET_ID, asset]]), + }); +} + +function makeCtx(state: TimelineState, overrides: Partial = {}): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: PX_PER_FRAME, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.round(x / PX_PER_FRAME)), + trackAtY: () => TRACK_ID, + snap: (frame) => frame, + ...overrides, + }; +} + +function makeEv(overrides: { + frame?: number; + trackId?: TrackId | null; + clipId?: ClipId | null; + x?: number; + y?: number; +} = {}): TimelinePointerEvent { + const frame = overrides.frame ?? 0; + return { + frame: toFrame(frame), + trackId: overrides.trackId ?? TRACK_ID, + clipId: overrides.clipId ?? null, + x: overrides.x ?? frame * PX_PER_FRAME, + y: overrides.y ?? 24, + buttons: 1, + shiftKey: false, + altKey: false, + metaKey: false, + }; +} + +describe('SlideTool — onPointerDown on clip2 sets draggingClipId', () => { + it('after pointerDown on clip2, pointerMove returns provisional', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3500, frame: 350 }), ctx); + const prov = tool.onPointerMove(makeEv({ clipId: CLIP2_ID, x: 3600, frame: 360 }), ctx); + expect(prov).not.toBeNull(); + expect(prov!.clips).toHaveLength(1); + expect(prov!.clips[0]!.id).toBe(CLIP2_ID); + expect(prov!.isProvisional).toBe(true); + }); +}); + +describe('SlideTool — onPointerDown on empty space: no drag', () => { + it('pointerDown with clipId null then pointerMove returns null', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: null, x: 100 }), ctx); + const prov = tool.onPointerMove(makeEv({ clipId: null, x: 200 }), ctx); + expect(prov).toBeNull(); + }); +}); + +describe('SlideTool — onPointerMove shows provisional at new position', () => { + it('ghost clip has timelineStart moved by delta frames', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000, frame: 300 }), ctx); + const prov = tool.onPointerMove(makeEv({ clipId: CLIP2_ID, x: 2500, frame: 250 }), ctx); + expect(prov).not.toBeNull(); + const ghost = prov!.clips[0]!; + expect((ghost.timelineStart as number)).toBe(250); + expect((ghost.timelineEnd as number)).toBe(450); + }); +}); + +describe('SlideTool — onPointerMove clamps to left neighbor boundary', () => { + it('slide left past left neighbor: clamped to left.timelineEnd', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000, frame: 300 }), ctx); + const prov = tool.onPointerMove(makeEv({ clipId: CLIP2_ID, x: 1000, frame: 100 }), ctx); + expect(prov).not.toBeNull(); + const ghost = prov!.clips[0]!; + expect((ghost.timelineStart as number)).toBe(200); + expect((ghost.timelineEnd as number)).toBe(400); + }); +}); + +describe('SlideTool — onPointerMove clamps to right neighbor boundary', () => { + it('slide right past right neighbor: clamped so clip end does not pass right start', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000, frame: 300 }), ctx); + const prov = tool.onPointerMove(makeEv({ clipId: CLIP2_ID, x: 4000, frame: 400 }), ctx); + expect(prov).not.toBeNull(); + const ghost = prov!.clips[0]!; + expect((ghost.timelineStart as number)).toBe(300); + expect((ghost.timelineEnd as number)).toBe(500); + }); +}); + +describe('SlideTool — onPointerUp dispatches MOVE_CLIP for clip2', () => { + it('transaction contains MOVE_CLIP for the slid clip', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000, frame: 300 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP2_ID, x: 2500, frame: 250 }), ctx); + expect(tx).not.toBeNull(); + const moveOps = tx!.operations.filter((op) => op.type === 'MOVE_CLIP' && op.clipId === CLIP2_ID); + expect(moveOps.length).toBeGreaterThanOrEqual(1); + expect(moveOps[0]!.type).toBe('MOVE_CLIP'); + if (moveOps[0]!.type === 'MOVE_CLIP') { + expect((moveOps[0].newTimelineStart as number)).toBe(250); + } + }); +}); + +describe('SlideTool — onPointerUp with no movement: no dispatch', () => { + it('pointerUp at same x as pointerDown returns null', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000, frame: 300 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP2_ID, x: 3000, frame: 300 }), ctx); + expect(tx).toBeNull(); + }); +}); + +describe('SlideTool — onPointerUp includes neighbor adjustments in transaction', () => { + it('transaction includes RESIZE_CLIP for left and MOVE_CLIP+RESIZE_CLIP for right', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000, frame: 300 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP2_ID, x: 2500, frame: 250 }), ctx); + expect(tx).not.toBeNull(); + const resizeLeft = tx!.operations.filter( + (op) => op.type === 'RESIZE_CLIP' && op.clipId === CLIP1_ID, + ); + const moveRight = tx!.operations.filter( + (op) => op.type === 'MOVE_CLIP' && op.clipId === CLIP3_ID, + ); + const resizeRight = tx!.operations.filter( + (op) => op.type === 'RESIZE_CLIP' && op.clipId === CLIP3_ID, + ); + expect(resizeLeft.length).toBe(1); + expect(moveRight.length).toBe(1); + expect(resizeRight.length).toBe(1); + }); + + it('applied transaction passes checkInvariants', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000, frame: 300 }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP2_ID, x: 2500, frame: 250 }), ctx); + expect(tx).not.toBeNull(); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toHaveLength(0); + } + }); +}); + +describe('SlideTool — onCancel resets state and clears provisional', () => { + it('after onCancel, pointerMove returns null', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000 }), ctx); + tool.onCancel(); + const prov = tool.onPointerMove(makeEv({ clipId: CLIP2_ID, x: 2500 }), ctx); + expect(prov).toBeNull(); + }); +}); + +describe('SlideTool — capture-before-reset: draggingClipId null after onPointerUp', () => { + it('after pointerUp, subsequent pointerMove returns null (no drag)', () => { + const tool = new SlideTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP2_ID, x: 3000 }), ctx); + tool.onPointerUp(makeEv({ clipId: CLIP2_ID, x: 2500 }), ctx); + const prov = tool.onPointerMove(makeEv({ clipId: CLIP2_ID, x: 2000 }), ctx); + expect(prov).toBeNull(); + }); +}); diff --git a/packages/core/src/__tests__/tools/slip.test.ts b/packages/core/src/__tests__/tools/slip.test.ts new file mode 100644 index 0000000..3ccebf4 --- /dev/null +++ b/packages/core/src/__tests__/tools/slip.test.ts @@ -0,0 +1,432 @@ +/** + * SlipTool Tests — Phase 2 Step 5 + * + * Covers all items from the approved test plan: + * □ Slip right: mediaIn and mediaOut both increase by delta + * □ Slip left: mediaIn and mediaOut both decrease by delta + * □ timelineStart/End identical before and after (explicit assertion) + * □ Clamp at mediaIn floor: delta clamped when mediaIn would go below 0 + * □ Clamp at mediaOut ceiling: delta clamped when mediaOut would exceed intrinsicDuration + * □ No-op: clampedDelta === 0 → null Transaction + * □ Empty space click: no drag, onPointerUp null + * □ Ghost: mediaIn/Out shifted, timelineStart/End unchanged + * □ checkInvariants + dispatch.accepted on every Transaction + * □ operations.length === 1 on every Transaction (SET_MEDIA_BOUNDS only) + * + * Zero React imports. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; + +import { SlipTool } from '../../tools/slip'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset, toAssetId } from '../../types/asset'; +import { toFrame, toTimecode, frameRate } from '../../types/frame'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TimelineFrame } from '../../types/frame'; +import type { TrackId } from '../../types/track'; +import type { ClipId } from '../../types/clip'; + +// ── Constants ───────────────────────────────────────────────────────────────── + +const TRACK_ID = toTrackId('track-1'); +const ASSET_ID = toAssetId('asset-1'); +const CLIP_ID = toClipId('clip-a'); + +// ── Fixture builders ────────────────────────────────────────────────────────── + +/** + * Default clip: + * timeline: [100, 300) — 200 frames on timeline + * media: mediaIn=50, mediaOut=250 — 200 frames of media, centred in asset + * asset: intrinsicDuration=500 + * + * Leftward headroom (minDelta): -50 (mediaIn can fall to 0) + * Rightward headroom (maxDelta): +250 (mediaOut can rise to 500) + * + * This gives ample room to test both clamp directions and free slip. + */ +function makeState(): TimelineState { + const asset = createAsset({ + id: 'asset-1', name: 'Test', mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(500), + nativeFps: 30, sourceTimecodeOffset: toFrame(0), status: 'online', + }); + const clip = createClip({ + id: 'clip-a', assetId: 'asset-1', trackId: 'track-1', + timelineStart: toFrame(100), timelineEnd: toFrame(300), + mediaIn: toFrame(50), mediaOut: toFrame(250), + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clip] }); + const timeline = createTimeline({ + id: 'tl', name: 'Slip Test', fps: frameRate(30), + duration: toFrame(9000), startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[ASSET_ID, asset]]) }); +} + +function makeCtx(state: TimelineState, overrides: Partial = {}): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: 10, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / 10)), + trackAtY: (_y) => TRACK_ID, + snap: (frame, _excl?) => frame, + ...overrides, + }; +} + +function makeEv(overrides: { + frame?: TimelineFrame; trackId?: TrackId | null; clipId?: ClipId | null; + x?: number; y?: number; +} = {}): TimelinePointerEvent { + return { + frame: overrides.frame ?? toFrame(0), + trackId: overrides.trackId ?? TRACK_ID, + clipId: overrides.clipId ?? null, + x: overrides.x ?? 0, + y: overrides.y ?? 24, + buttons: 1, + shiftKey: false, altKey: false, metaKey: false, + }; +} + +/** Initiate a drag on CLIP_ID starting at frame 200 (middle of timeline clip). */ +function startDrag(tool: SlipTool, state: TimelineState) { + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ clipId: CLIP_ID, frame: toFrame(200) }), ctx); +} + +function applyAndCheck(state: TimelineState, tx: ReturnType): TimelineState { + expect(tx).not.toBeNull(); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toHaveLength(0); + } + return result.accepted ? result.nextState : state; +} + +// ── Suite 1: Slip right ─────────────────────────────────────────────────────── + +describe('SlipTool — slip RIGHT: mediaIn and mediaOut increase by delta', () => { + let tool: SlipTool; + let state: TimelineState; + + beforeEach(() => { tool = new SlipTool(); state = makeState(); }); + + it('single SET_MEDIA_BOUNDS — operations.length === 1', () => { + startDrag(tool, state); + const ctx = makeCtx(state); + // Drag right by 30 frames: start=200, end=230 → delta=+30 + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(230) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(1); // ← defining assertion + expect(tx!.operations[0]!.type).toBe('SET_MEDIA_BOUNDS'); + }); + + it('mediaIn and mediaOut both increase by +30', () => { + startDrag(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(230) }), ctx); + + const op = tx!.operations[0]!; + if (op.type === 'SET_MEDIA_BOUNDS') { + expect(op.mediaIn).toBe(toFrame(80)); // 50 + 30 + expect(op.mediaOut).toBe(toFrame(280)); // 250 + 30 + } + }); + + it('timelineStart and timelineEnd are unchanged after dispatch', () => { + startDrag(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(230) }), ctx); + const nextState = applyAndCheck(state, tx); + + const clip = nextState.timeline.tracks[0]!.clips.find(c => c.id === CLIP_ID)!; + const origClip = state.timeline.tracks[0]!.clips.find(c => c.id === CLIP_ID)!; + + // The defining characteristic of slip: position does NOT change + expect(clip.timelineStart).toBe(origClip.timelineStart); + expect(clip.timelineEnd).toBe(origClip.timelineEnd); + }); + + it('passes checkInvariants', () => { + startDrag(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(230) }), ctx); + applyAndCheck(state, tx); + }); +}); + +// ── Suite 2: Slip left ──────────────────────────────────────────────────────── + +describe('SlipTool — slip LEFT: mediaIn and mediaOut decrease by delta', () => { + let tool: SlipTool; + let state: TimelineState; + + beforeEach(() => { tool = new SlipTool(); state = makeState(); }); + + it('mediaIn and mediaOut both decrease by 20 (delta = -20)', () => { + startDrag(tool, state); + const ctx = makeCtx(state); + // Drag left: start=200, end=180 → delta=-20 + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(180) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(1); + + const op = tx!.operations[0]!; + if (op.type === 'SET_MEDIA_BOUNDS') { + expect(op.mediaIn).toBe(toFrame(30)); // 50 - 20 + expect(op.mediaOut).toBe(toFrame(230)); // 250 - 20 + } + }); + + it('timelineStart and timelineEnd unchanged after left slip', () => { + startDrag(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(180) }), ctx); + const nextState = applyAndCheck(state, tx); + + const clip = nextState.timeline.tracks[0]!.clips.find(c => c.id === CLIP_ID)!; + const origClip = state.timeline.tracks[0]!.clips.find(c => c.id === CLIP_ID)!; + + expect(clip.timelineStart).toBe(origClip.timelineStart); + expect(clip.timelineEnd).toBe(origClip.timelineEnd); + }); + + it('passes checkInvariants', () => { + startDrag(tool, state); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(180) }), ctx); + applyAndCheck(state, tx); + }); +}); + +// ── Suite 3: Clamp at mediaIn floor ────────────────────────────────────────── + +describe('SlipTool — clamp: mediaIn floor (cannot go below 0)', () => { + it('delta clamped at -50 when dragging far left (mediaIn=50, minDelta=-50)', () => { + // Asset starts at 0. mediaIn=50 → minDelta=-50. Dragging to -1000 → clamped to -50. + const state = makeState(); + const tool = new SlipTool(); + startDrag(tool, state); + const ctx = makeCtx(state); + + // drag far left: want delta=-1000, clamped to -50 + // dragStart=200, dragEnd=200-1000=-800 (below timeline, but frames can be any integer) + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(-800) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(1); + const op = tx!.operations[0]!; + if (op.type === 'SET_MEDIA_BOUNDS') { + expect(op.mediaIn).toBe(toFrame(0)); // 50 + (-50) = 0 — floor + expect(op.mediaOut).toBe(toFrame(200)); // 250 + (-50) = 200 + } + applyAndCheck(state, tx); + }); + + it('exact clamp: dragging exactly to minDelta is allowed (not null)', () => { + const state = makeState(); + const tool = new SlipTool(); + startDrag(tool, state); + const ctx = makeCtx(state); + + // delta = -50 exactly → mediaIn becomes 0 + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(150) }), ctx); + + expect(tx).not.toBeNull(); + const op = tx!.operations[0]!; + if (op.type === 'SET_MEDIA_BOUNDS') { + expect(op.mediaIn).toBe(toFrame(0)); + } + applyAndCheck(state, tx); + }); +}); + +// ── Suite 4: Clamp at mediaOut ceiling ──────────────────────────────────────── + +describe('SlipTool — clamp: mediaOut ceiling (cannot exceed intrinsicDuration)', () => { + it('delta clamped at +250 when dragging far right (mediaOut=250, maxDelta=250)', () => { + // intrinsicDuration=500, mediaOut=250 → maxDelta=250. Dragging +1000 → clamped to +250. + const state = makeState(); + const tool = new SlipTool(); + startDrag(tool, state); + const ctx = makeCtx(state); + + // dragStart=200, dragEnd=200+1000=1200 → delta=+1000, clamped to +250 + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(1200) }), ctx); + + expect(tx).not.toBeNull(); + expect(tx!.operations).toHaveLength(1); + const op = tx!.operations[0]!; + if (op.type === 'SET_MEDIA_BOUNDS') { + expect(op.mediaIn).toBe(toFrame(300)); // 50 + 250 + expect(op.mediaOut).toBe(toFrame(500)); // 250 + 250 = 500 = intrinsicDuration + } + applyAndCheck(state, tx); + }); + + it('exact clamp: dragging to maxDelta is allowed (not null)', () => { + const state = makeState(); + const tool = new SlipTool(); + startDrag(tool, state); + const ctx = makeCtx(state); + + // delta = +250 exactly → mediaOut becomes 500 = intrinsicDuration + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(450) }), ctx); + + expect(tx).not.toBeNull(); + const op = tx!.operations[0]!; + if (op.type === 'SET_MEDIA_BOUNDS') { + expect(op.mediaOut).toBe(toFrame(500)); + } + applyAndCheck(state, tx); + }); +}); + +// ── Suite 5: No-op ──────────────────────────────────────────────────────────── + +describe('SlipTool — no-op: clampedDelta === 0 → null', () => { + it('releasing at same frame as press → null', () => { + const state = makeState(); + const tool = new SlipTool(); + startDrag(tool, state); + const ctx = makeCtx(state); + + // dragEnd = dragStart = 200 → delta = 0 + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(200) }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 6: Empty space click ──────────────────────────────────────────────── + +describe('SlipTool — empty space: no drag initiated', () => { + it('clicking empty space (clipId=null) → onPointerUp returns null', () => { + const state = makeState(); + const tool = new SlipTool(); + const ctx = makeCtx(state); + + tool.onPointerDown(makeEv({ clipId: null, frame: toFrame(50) }), ctx); + const tx = tool.onPointerUp(makeEv({ clipId: null, frame: toFrame(150) }), ctx); + expect(tx).toBeNull(); + }); +}); + +// ── Suite 7: ProvisionalState ghost ────────────────────────────────────────── + +describe('SlipTool — ProvisionalState ghost', () => { + it('ghost has 1 clip — only the slipped clip', () => { + const state = makeState(); + const tool = new SlipTool(); + startDrag(tool, state); + const ctx = makeCtx(state); + + const ghost = tool.onPointerMove( + makeEv({ clipId: CLIP_ID, frame: toFrame(220) }), ctx, + ); + + expect(ghost).not.toBeNull(); + expect(ghost!.isProvisional).toBe(true); + expect(ghost!.clips).toHaveLength(1); + }); + + it('ghost mediaIn/Out shifted, timelineStart/End unchanged', () => { + const state = makeState(); + const origClip = state.timeline.tracks[0]!.clips.find(c => c.id === CLIP_ID)!; + const tool = new SlipTool(); + startDrag(tool, state); + const ctx = makeCtx(state); + + // delta = 220 - 200 = +20 + const ghost = tool.onPointerMove( + makeEv({ clipId: CLIP_ID, frame: toFrame(220) }), ctx, + ); + + const ghostClip = ghost!.clips.find(c => c.id === CLIP_ID)!; + + // ← media shifted + expect(ghostClip.mediaIn).toBe(toFrame(70)); // 50 + 20 + expect(ghostClip.mediaOut).toBe(toFrame(270)); // 250 + 20 + + // ← timeline position unchanged + expect(ghostClip.timelineStart).toBe(origClip.timelineStart); + expect(ghostClip.timelineEnd).toBe(origClip.timelineEnd); + }); + + it('not mid-drag → onPointerMove returns null', () => { + const state = makeState(); + const tool = new SlipTool(); + const ctx = makeCtx(state); + + // No pointerDown + const ghost = tool.onPointerMove(makeEv({ clipId: CLIP_ID, frame: toFrame(220) }), ctx); + expect(ghost).toBeNull(); + }); +}); + +// ── Suite 8: onCancel and structural ───────────────────────────────────────── + +describe('SlipTool — onCancel and structural', () => { + it('onCancel resets drag state — subsequent pointerUp returns null', () => { + const state = makeState(); + const tool = new SlipTool(); + startDrag(tool, state); + tool.onCancel(); + const ctx = makeCtx(state); + const tx = tool.onPointerUp(makeEv({ clipId: CLIP_ID, frame: toFrame(250) }), ctx); + expect(tx).toBeNull(); + }); + + it('getCursor returns ew-resize mid-drag', () => { + const state = makeState(); + const tool = new SlipTool(); + startDrag(tool, state); + const ctx = makeCtx(state); + expect(tool.getCursor(ctx)).toBe('ew-resize'); + }); + + it('getCursor returns grab when hovering clip', () => { + const state = makeState(); + const tool = new SlipTool(); + const ctx = makeCtx(state); + // Stage hover + tool.onPointerMove(makeEv({ clipId: CLIP_ID, frame: toFrame(200) }), ctx); + expect(tool.getCursor(ctx)).toBe('grab'); + }); + + it('getCursor returns default when idle and not hovering', () => { + const state = makeState(); + const tool = new SlipTool(); + const ctx = makeCtx(state); + // Move over empty space to stage isHoveringClip=false + tool.onPointerMove(makeEv({ clipId: null, frame: toFrame(50) }), ctx); + expect(tool.getCursor(ctx)).toBe('default'); + }); + + it('getSnapCandidateTypes returns empty array', () => { + const tool = new SlipTool(); + expect(tool.getSnapCandidateTypes()).toHaveLength(0); + }); + + it('has correct id and shortcutKey', () => { + const tool = new SlipTool(); + expect(tool.id).toBe('slip'); + expect(tool.shortcutKey).toBe('y'); + }); +}); diff --git a/packages/core/src/__tests__/tools/transition-tool.test.ts b/packages/core/src/__tests__/tools/transition-tool.test.ts new file mode 100644 index 0000000..08e6317 --- /dev/null +++ b/packages/core/src/__tests__/tools/transition-tool.test.ts @@ -0,0 +1,234 @@ +/** + * TransitionTool Tests — Phase 4 Step 4 + * + * Fixture: one timeline, one video track, two adjacent clips. + * Zero React. Mock ToolContext, dispatch for transactions. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { TransitionTool } from '../../tools/transition-tool'; +import { checkInvariants } from '../../validation/invariants'; +import { dispatch } from '../../engine/dispatcher'; +import { applyOperation } from '../../engine/apply'; +import { createTimelineState } from '../../types/state'; +import { createTimeline } from '../../types/timeline'; +import { createTrack, toTrackId } from '../../types/track'; +import { createClip, toClipId } from '../../types/clip'; +import { createAsset } from '../../types/asset'; +import { toFrame, toTimecode } from '../../types/frame'; +import { createTransition, toTransitionId } from '../../types/transition'; +import { buildSnapIndex } from '../../snap-index'; +import type { ToolContext, TimelinePointerEvent } from '../../tools/types'; +import type { TimelineState } from '../../types/state'; +import type { TrackId } from '../../types/track'; + +const TRACK_ID = toTrackId('track-1'); +const CLIP_A_ID = toClipId('clip-a'); +const CLIP_B_ID = toClipId('clip-b'); +const PIXELS_PER_FRAME = 10; // frame 100 → x=1000 + +function makeState(): TimelineState { + const asset = createAsset({ + id: 'asset-1', + name: 'V1', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + }); + const clipA = createClip({ + id: 'clip-a', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clipB = createClip({ + id: 'clip-b', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(100), + timelineEnd: toFrame(200), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ id: 'track-1', name: 'V1', type: 'video', clips: [clipA, clipB] }); + const timeline = createTimeline({ + id: 'tl', + name: 'T', + fps: 30, + duration: toFrame(1000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map([[asset.id, asset]]) }); +} + +function makeCtx(state: TimelineState, overrides: Partial = {}): ToolContext { + return { + state, + snapIndex: buildSnapIndex(state, toFrame(0)), + pixelsPerFrame: PIXELS_PER_FRAME, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: (x) => toFrame(Math.floor(x / PIXELS_PER_FRAME)), + trackAtY: (_y) => TRACK_ID, + snap: (frame) => frame, + ...overrides, + }; +} + +function makeEv(overrides: Partial & { x?: number } = {}): TimelinePointerEvent { + const x = overrides.x ?? 0; + return { + frame: toFrame(Math.floor(x / PIXELS_PER_FRAME)), + trackId: TRACK_ID, + clipId: null, + x, + y: 24, + buttons: overrides.buttons ?? 1, + shiftKey: overrides.shiftKey ?? false, + altKey: overrides.altKey ?? false, + metaKey: overrides.metaKey ?? false, + ...overrides, + }; +} + +describe('TransitionTool — onPointerDown near right edge sets pendingClipId', () => { + it('onPointerDown near right edge sets pendingClipId', () => { + const tool = new TransitionTool(); + const state = makeState(); + const ctx = makeCtx(state); + const rightEdgeX = 100 * PIXELS_PER_FRAME; // clip-a ends at frame 100 + tool.onPointerDown(makeEv({ x: rightEdgeX }), ctx); + const tx = tool.onPointerUp(makeEv({ x: rightEdgeX + 50 }), ctx); + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('ADD_TRANSITION'); + }); + + it('onPointerDown away from edge: no pendingClipId', () => { + const tool = new TransitionTool(); + const state = makeState(); + const ctx = makeCtx(state); + tool.onPointerDown(makeEv({ x: 500 }), ctx); + const tx = tool.onPointerUp(makeEv({ x: 500 }), ctx); + expect(tx).toBeNull(); + }); +}); + +describe('TransitionTool — onPointerMove', () => { + it('onPointerMove with pendingClipId sets provisional', () => { + const tool = new TransitionTool(); + const state = makeState(); + const ctx = makeCtx(state); + const rightEdgeX = 100 * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: rightEdgeX }), ctx); + const provisional = tool.onPointerMove(makeEv({ x: rightEdgeX + 80 }), ctx); + expect(provisional).not.toBeNull(); + expect(provisional!.isProvisional).toBe(true); + expect(provisional!.clips).toHaveLength(1); + expect(provisional!.clips[0]!.transition).toBeDefined(); + expect(provisional!.clips[0]!.transition!.durationFrames).toBe(8); + }); + + it('onPointerMove without pendingClipId: no provisional', () => { + const tool = new TransitionTool(); + const state = makeState(); + const ctx = makeCtx(state); + const provisional = tool.onPointerMove(makeEv({ x: 100 }), ctx); + expect(provisional).toBeNull(); + }); +}); + +describe('TransitionTool — onPointerUp', () => { + it('onPointerUp dispatches ADD_TRANSITION (new)', () => { + const tool = new TransitionTool(); + const state = makeState(); + const ctx = makeCtx(state); + const rightEdgeX = 100 * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: rightEdgeX }), ctx); + const tx = tool.onPointerUp(makeEv({ x: rightEdgeX + 60 }), ctx); + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('ADD_TRANSITION'); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) expect(checkInvariants(result.nextState)).toEqual([]); + }); + + it('onPointerUp dispatches SET_TRANSITION_DURATION when transition already exists', () => { + const tool = new TransitionTool(); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 10); + let state = makeState(); + state = applyOperation(state, { type: 'ADD_TRANSITION', clipId: CLIP_A_ID, transition: trans }); + const ctx = makeCtx(state); + const rightEdgeX = 100 * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: rightEdgeX }), ctx); + const tx = tool.onPointerUp(makeEv({ x: rightEdgeX + 100 }), ctx); + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('SET_TRANSITION_DURATION'); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) expect(checkInvariants(result.nextState)).toEqual([]); + }); + + it('onPointerUp with durationFrames < 1: no dispatch', () => { + const tool = new TransitionTool(); + const state = makeState(); + const ctx = makeCtx(state); + const rightEdgeX = 100 * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: rightEdgeX }), ctx); + const tx = tool.onPointerUp(makeEv({ x: rightEdgeX - 5 }), ctx); + expect(tx).toBeNull(); + }); + + it('onPointerDown on existing transition area dispatches DELETE_TRANSITION on up', () => { + const tool = new TransitionTool(); + const trans = createTransition(toTransitionId('tr-1'), 'dissolve', 15); + let state = makeState(); + state = applyOperation(state, { type: 'ADD_TRANSITION', clipId: CLIP_A_ID, transition: trans }); + const ctx = makeCtx(state); + const rightEdgeX = 100 * PIXELS_PER_FRAME; + const inTransitionZoneX = rightEdgeX - 10 * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: inTransitionZoneX }), ctx); + const tx = tool.onPointerUp(makeEv({ x: inTransitionZoneX }), ctx); + expect(tx).not.toBeNull(); + expect(tx!.operations[0]!.type).toBe('DELETE_TRANSITION'); + const result = dispatch(state, tx!); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(checkInvariants(result.nextState)).toEqual([]); + expect(result.nextState.timeline.tracks[0]!.clips[0]!.transition).toBeUndefined(); + } + }); +}); + +describe('TransitionTool — onCancel', () => { + it('onCancel resets state and clears provisional', () => { + const tool = new TransitionTool(); + const state = makeState(); + const ctx = makeCtx(state); + const rightEdgeX = 100 * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: rightEdgeX }), ctx); + tool.onCancel(); + const provisional = tool.onPointerMove(makeEv({ x: rightEdgeX + 50 }), ctx); + expect(provisional).toBeNull(); + const tx = tool.onPointerUp(makeEv({ x: rightEdgeX + 50 }), ctx); + expect(tx).toBeNull(); + }); +}); + +describe('TransitionTool — capture-before-reset', () => { + it('onPointerUp resets BEFORE dispatch does not cause stale-closure bug', () => { + const tool = new TransitionTool(); + const state = makeState(); + const ctx = makeCtx(state); + const rightEdgeX = 100 * PIXELS_PER_FRAME; + tool.onPointerDown(makeEv({ x: rightEdgeX }), ctx); + const tx = tool.onPointerUp(makeEv({ x: rightEdgeX + 40 }), ctx); + expect(tx).not.toBeNull(); + const tx2 = tool.onPointerUp(makeEv({ x: rightEdgeX + 60 }), ctx); + expect(tx2).toBeNull(); + }); +}); diff --git a/packages/core/src/__tests__/tools/zoom-tool.test.ts b/packages/core/src/__tests__/tools/zoom-tool.test.ts new file mode 100644 index 0000000..b769829 --- /dev/null +++ b/packages/core/src/__tests__/tools/zoom-tool.test.ts @@ -0,0 +1,203 @@ +/** + * ZoomTool Tests — Phase 7 Step 5 + * + * 11. onPointerDown captures dragStartX and dragStartZoom + * 12. onPointerMove right calls onZoomChange with increased zoom + * 13. onPointerMove left calls onZoomChange with decreased zoom + * 14. onZoomChange value is clamped to max + * 15. onZoomChange value is clamped to min + * 16. onPointerUp resets drag state + * 17. '+' key calls onZoomChange with 1.25x current + * 18. '-' key calls onZoomChange with 0.8x current + * 19. '0' key resets to initialPixelsPerFrame + * 20. onCancel resets drag state + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { ZoomTool, createZoomTool } from '../../tools/zoom-tool'; +import type { ToolContext, TimelinePointerEvent, TimelineKeyEvent } from '../../tools/types'; + +function makeCtx(pixelsPerFrame: number): ToolContext { + return { + state: {} as ToolContext['state'], + snapIndex: {} as ToolContext['snapIndex'], + pixelsPerFrame, + modifiers: { shift: false, alt: false, ctrl: false, meta: false }, + frameAtX: () => ({} as ToolContext['frameAtX'] extends (x: number) => infer R ? R : never), + trackAtY: () => null, + snap: (f) => f, + }; +} + +function makePointerEv(x: number): TimelinePointerEvent { + return { + frame: {} as TimelinePointerEvent['frame'], + trackId: null, + clipId: null, + x, + y: 0, + buttons: 1, + shiftKey: false, + altKey: false, + metaKey: false, + }; +} + +function makeKeyEv(key: string): TimelineKeyEvent { + return { + key, + code: key === '=' ? 'Equal' : key === '0' ? 'Digit0' : key, + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + }; +} + +describe('ZoomTool — onPointerDown captures dragStartX and dragStartZoom', () => { + it('after pointerDown, pointerMove right increases zoom from start value', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ onZoomChange, initialPixelsPerFrame: 10 }); + const ctx = makeCtx(10); + tool.onPointerDown(makePointerEv(100), ctx); + tool.onPointerMove(makePointerEv(150), ctx); + expect(onZoomChange).toHaveBeenCalled(); + const lastCall = onZoomChange.mock.calls[onZoomChange.mock.calls.length - 1]![0]; + expect(lastCall).toBeGreaterThan(10); + }); +}); + +describe('ZoomTool — onPointerMove right calls onZoomChange with increased zoom', () => { + it('drag right yields zoom > dragStartZoom', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ onZoomChange }); + const ctx = makeCtx(10); + tool.onPointerDown(makePointerEv(0), ctx); + tool.onPointerMove(makePointerEv(50), ctx); + expect(onZoomChange).toHaveBeenCalledWith(expect.any(Number)); + expect(onZoomChange.mock.calls[0]![0]).toBeGreaterThan(10); + }); +}); + +describe('ZoomTool — onPointerMove left calls onZoomChange with decreased zoom', () => { + it('drag left yields zoom < dragStartZoom', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ onZoomChange }); + const ctx = makeCtx(10); + tool.onPointerDown(makePointerEv(100), ctx); + tool.onPointerMove(makePointerEv(50), ctx); + expect(onZoomChange).toHaveBeenCalledWith(expect.any(Number)); + expect(onZoomChange.mock.calls[0]![0]).toBeLessThan(10); + }); +}); + +describe('ZoomTool — onZoomChange value is clamped to max', () => { + it('drag far right does not exceed maxPixelsPerFrame', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ + onZoomChange, + maxPixelsPerFrame: 20, + initialPixelsPerFrame: 10, + }); + const ctx = makeCtx(10); + tool.onPointerDown(makePointerEv(0), ctx); + tool.onPointerMove(makePointerEv(500), ctx); + const calls = onZoomChange.mock.calls; + expect(calls.length).toBeGreaterThan(0); + calls.forEach(([value]) => expect(value).toBeLessThanOrEqual(20)); + }); +}); + +describe('ZoomTool — onZoomChange value is clamped to min', () => { + it('drag far left does not go below minPixelsPerFrame', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ + onZoomChange, + minPixelsPerFrame: 2, + initialPixelsPerFrame: 10, + }); + const ctx = makeCtx(10); + tool.onPointerDown(makePointerEv(0), ctx); + tool.onPointerMove(makePointerEv(-500), ctx); + const calls = onZoomChange.mock.calls; + expect(calls.length).toBeGreaterThan(0); + calls.forEach(([value]) => expect(value).toBeGreaterThanOrEqual(2)); + }); +}); + +describe('ZoomTool — onPointerUp resets drag state', () => { + it('after pointerUp, pointerMove uses new ctx.pixelsPerFrame (no accumulated delta)', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ onZoomChange }); + const ctx10 = makeCtx(10); + const ctx20 = makeCtx(20); + tool.onPointerDown(makePointerEv(0), ctx10); + tool.onPointerUp(makePointerEv(100), ctx10); + tool.onPointerDown(makePointerEv(0), ctx20); + tool.onPointerMove(makePointerEv(10), ctx20); + const lastCall = onZoomChange.mock.calls[onZoomChange.mock.calls.length - 1]![0]; + expect(lastCall).toBeGreaterThan(20); + }); +}); + +describe('ZoomTool — "+" key calls onZoomChange with 1.25x current', () => { + it('key "+" zooms in by 1.25x', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ onZoomChange }); + const ctx = makeCtx(10); + tool.onKeyDown(makeKeyEv('+'), ctx); + expect(onZoomChange).toHaveBeenCalledWith(12.5); + }); +}); + +describe('ZoomTool — "-" key calls onZoomChange with 0.8x current', () => { + it('key "-" zooms out by 0.8x', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ onZoomChange }); + const ctx = makeCtx(10); + tool.onKeyDown(makeKeyEv('-'), ctx); + expect(onZoomChange).toHaveBeenCalledWith(8); + }); +}); + +describe('ZoomTool — "0" key resets to initialPixelsPerFrame', () => { + it('key "0" calls onZoomChange with initialPixelsPerFrame', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ + onZoomChange, + initialPixelsPerFrame: 15, + }); + const ctx = makeCtx(10); + tool.onKeyDown(makeKeyEv('0'), ctx); + expect(onZoomChange).toHaveBeenCalledWith(15); + }); +}); + +describe('ZoomTool — onCancel resets drag state', () => { + it('after onCancel, next pointerDown starts fresh', () => { + const onZoomChange = vi.fn(); + const tool = new ZoomTool({ onZoomChange }); + const ctx = makeCtx(10); + tool.onPointerDown(makePointerEv(0), ctx); + tool.onCancel(); + tool.onPointerDown(makePointerEv(0), ctx); + tool.onPointerMove(makePointerEv(10), ctx); + expect(onZoomChange).toHaveBeenCalled(); + }); +}); + +describe('ZoomTool — createZoomTool returns ITool', () => { + it('createZoomTool(options) returns object with id and shortcutKey', () => { + const onZoomChange = vi.fn(); + const tool = createZoomTool({ onZoomChange }); + expect(tool.id).toBe('zoom'); + expect(tool.shortcutKey).toBe('Z'); + expect(typeof tool.onPointerDown).toBe('function'); + expect(typeof tool.onPointerMove).toBe('function'); + expect(typeof tool.onPointerUp).toBe('function'); + expect(typeof tool.onKeyDown).toBe('function'); + expect(typeof tool.onKeyUp).toBe('function'); + expect(typeof tool.onCancel).toBe('function'); + }); +}); diff --git a/packages/core/src/engine/aaf-export.ts b/packages/core/src/engine/aaf-export.ts new file mode 100644 index 0000000..49266e7 --- /dev/null +++ b/packages/core/src/engine/aaf-export.ts @@ -0,0 +1,119 @@ +/** + * AAF XML export — Phase 5 Step 4 + * + * Simplified AAF XML representation for Avid interchange. + * Pure function, returns string. No IO. + */ + +import type { TimelineState } from '../types/state'; +import type { Clip } from '../types/clip'; +import type { Track } from '../types/track'; +import type { Asset, FileAsset, GeneratorAsset } from '../types/asset'; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export type AAFExportOptions = { + /** Default: timeline name */ + projectName?: string; + /** Default: derived from state (e.g. 30 → "30/1") */ + frameRate?: string; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function xmlEscape(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function clipDurationFrames(clip: Clip): number { + return (clip.timelineEnd - clip.timelineStart) as number; +} + +function sourceRefForClip(state: TimelineState, clip: Clip): string { + const asset = state.assetRegistry.get(clip.assetId); + if (!asset) return 'missing'; + if (asset.kind === 'file') return (asset as FileAsset).filePath; + return (asset as GeneratorAsset).generatorDef.type; +} + +function dataDefinition(track: Track): string { + return track.type === 'video' ? 'Picture' : track.type === 'audio' ? 'Sound' : 'Picture'; +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +export function exportToAAF( + state: TimelineState, + options?: AAFExportOptions, +): string { + const projectName = xmlEscape(options?.projectName ?? state.timeline.name ?? 'Untitled'); + const fps = state.timeline.fps as number; + const editRate = options?.frameRate ?? `${fps}/1`; + const timelineName = xmlEscape(state.timeline.name ?? 'Timeline'); + + const lines: string[] = []; + const indent = (n: number) => ' '.repeat(n); + + lines.push(''); + lines.push(``); + lines.push(`${indent(1)}`); + lines.push(`${indent(1)}`); + + // One MasterMob per clip (all tracks) + const clipTrackPairs: { clip: Clip; track: Track }[] = []; + for (const track of state.timeline.tracks) { + for (const clip of track.clips) clipTrackPairs.push({ clip, track }); + } + for (const { clip, track } of clipTrackPairs) { + const clipId = xmlEscape(clip.id); + const len = clipDurationFrames(clip); + const ref = xmlEscape(sourceRefForClip(state, clip)); + const def = dataDefinition(track); + lines.push(`${indent(2)}`); + lines.push(`${indent(3)}`); + lines.push(`${indent(4)}`); + lines.push(`${indent(5)}`); + lines.push(`${indent(4)}`); + lines.push(`${indent(3)}`); + lines.push(`${indent(2)}`); + } + + // CompositionMob: one TimelineMobSlot per track + lines.push(`${indent(2)}`); + state.timeline.tracks.forEach((track, slotIndex) => { + const def = dataDefinition(track); + lines.push(`${indent(3)}`); + lines.push(`${indent(4)}`); + + let cursor = 0; + for (const clip of track.clips) { + const start = clip.timelineStart as number; + const gapFrames = start - cursor; + if (gapFrames > 0) { + lines.push(`${indent(5)}`); + } + const len = clipDurationFrames(clip); + const clipId = xmlEscape(clip.id); + lines.push(`${indent(5)}`); + cursor = (clip.timelineEnd as number); + } + lines.push(`${indent(4)}`); + lines.push(`${indent(3)}`); + }); + lines.push(`${indent(2)}`); + lines.push(`${indent(1)}`); + lines.push(''); + + return lines.join('\n'); +} diff --git a/packages/core/src/engine/apply.ts b/packages/core/src/engine/apply.ts new file mode 100644 index 0000000..67723b7 --- /dev/null +++ b/packages/core/src/engine/apply.ts @@ -0,0 +1,637 @@ +/** + * OPERATION APPLIER — Phase 0 compliant + * + * Pure functions. No validation here — validation lives in validators.ts. + * Apply is dumb. Validate is smart. + * + * RULE: Every case returns a NEW TimelineState. Never mutates. + * RULE: No imports from React, DOM, or any UI framework. + */ + +import type { TimelineState } from '../types/state'; +import type { OperationPrimitive } from '../types/operations'; +import { sortTrackClips } from '../types/track'; +import type { Clip } from '../types/clip'; +import { createClip } from '../types/clip'; +import type { Track } from '../types/track'; +import { createGeneratorAsset } from '../types/asset'; +import type { TimelineFrame } from '../types/frame'; +import type { Marker } from '../types/marker'; +import type { Caption } from '../types/caption'; +import type { Effect, EffectParam } from '../types/effect'; +import type { Keyframe } from '../types/keyframe'; +import type { ClipTransform } from '../types/clip-transform'; +import { DEFAULT_CLIP_TRANSFORM } from '../types/clip-transform'; +import type { AudioProperties } from '../types/audio-properties'; +import { DEFAULT_AUDIO_PROPERTIES } from '../types/audio-properties'; +import type { Transition } from '../types/transition'; +import { defaultCaptionStyle } from './subtitle-import'; + +// --------------------------------------------------------------------------- +// applyOperation +// --------------------------------------------------------------------------- + +export function applyOperation( + state: TimelineState, + op: OperationPrimitive, +): TimelineState { + switch (op.type) { + + // — Clip operations —————————————————————————————————————————————————— + + case 'INSERT_CLIP': { + return updateTrack(state, op.trackId, (track) => + sortTrackClips({ ...track, clips: [...track.clips, op.clip] }), + ); + } + + case 'DELETE_CLIP': { + return updateTrackOfClip(state, op.clipId, (track) => ({ + ...track, + clips: track.clips.filter((c) => c.id !== op.clipId), + })); + } + + case 'MOVE_CLIP': { + const targetTrackId = op.targetTrackId; + + // Find the clip first so we know where it currently lives + let foundClip: Clip | undefined; + for (const track of state.timeline.tracks) { + const c = track.clips.find((c) => c.id === op.clipId); + if (c) { foundClip = c; break; } + } + if (!foundClip) return state; + + const delta = op.newTimelineStart - foundClip.timelineStart; + const movedClip: Clip = { + ...foundClip, + trackId: (targetTrackId ?? foundClip.trackId) as typeof foundClip.trackId, + timelineStart: op.newTimelineStart, + timelineEnd: (foundClip.timelineEnd + delta) as typeof foundClip.timelineEnd, + }; + + const effectiveTargetTrackId = targetTrackId ?? foundClip.trackId; + const isCrossTrack = effectiveTargetTrackId !== foundClip.trackId; + + let stateWithMovedClip: TimelineState; + if (!isCrossTrack) { + stateWithMovedClip = updateClip(state, op.clipId, () => movedClip); + } else { + const newTracks = state.timeline.tracks.map((track) => { + if (track.id === foundClip!.trackId) { + return { ...track, clips: track.clips.filter((c) => c.id !== op.clipId) }; + } + if (track.id === effectiveTargetTrackId) { + return sortTrackClips({ ...track, clips: [...track.clips, movedClip] }); + } + return track; + }); + stateWithMovedClip = { ...state, timeline: { ...state.timeline, tracks: newTracks } }; + } + + // Shift clip-linked markers by the same delta (Part 2) + const shiftedMarkers = shiftLinkedMarkers( + stateWithMovedClip.timeline.markers, + op.clipId, + delta, + ); + return { + ...stateWithMovedClip, + timeline: { ...stateWithMovedClip.timeline, markers: shiftedMarkers }, + }; + } + + + case 'RESIZE_CLIP': { + return updateClip(state, op.clipId, (clip) => { + if (op.edge === 'start') { + const delta = op.newFrame - clip.timelineStart; + return { + ...clip, + timelineStart: op.newFrame, + mediaIn: (clip.mediaIn + delta) as typeof clip.mediaIn, + }; + } else { + const delta = op.newFrame - clip.timelineEnd; + return { + ...clip, + timelineEnd: op.newFrame, + mediaOut: (clip.mediaOut + delta) as typeof clip.mediaOut, + }; + } + }); + } + + case 'SLICE_CLIP': { + // SLICE_CLIP is always wrapped in a Transaction with DELETE_CLIP + INSERT_CLIP×2. + // If called in isolation, it's a no-op — slicing is a compound operation. + return state; + } + + case 'SET_MEDIA_BOUNDS': { + return updateClip(state, op.clipId, (clip) => ({ + ...clip, + mediaIn: op.mediaIn, + mediaOut: op.mediaOut, + })); + } + + case 'SET_CLIP_ENABLED': { + return updateClip(state, op.clipId, (clip) => ({ ...clip, enabled: op.enabled })); + } + + case 'SET_CLIP_REVERSED': { + return updateClip(state, op.clipId, (clip) => ({ ...clip, reversed: op.reversed })); + } + + case 'SET_CLIP_SPEED': { + return updateClip(state, op.clipId, (clip) => ({ ...clip, speed: op.speed })); + } + + case 'SET_CLIP_COLOR': { + return updateClip(state, op.clipId, (clip) => ({ ...clip, color: op.color })); + } + + case 'SET_CLIP_NAME': { + return updateClip(state, op.clipId, (clip) => ({ ...clip, name: op.name })); + } + + // — Track operations —————————————————————————————————————————————————— + + case 'ADD_TRACK': { + return { + ...state, + timeline: { + ...state.timeline, + tracks: [...state.timeline.tracks, op.track], + }, + }; + } + + case 'DELETE_TRACK': { + return { + ...state, + timeline: { + ...state.timeline, + tracks: state.timeline.tracks.filter((t) => t.id !== op.trackId), + }, + }; + } + + case 'REORDER_TRACK': { + const tracks = [...state.timeline.tracks]; + const idx = tracks.findIndex((t) => t.id === op.trackId); + if (idx === -1) return state; + const [track] = tracks.splice(idx, 1); + if (!track) return state; + tracks.splice(op.newIndex, 0, track); + return { ...state, timeline: { ...state.timeline, tracks } }; + } + + case 'SET_TRACK_HEIGHT': { + return updateTrack(state, op.trackId, (t) => ({ ...t, height: op.height })); + } + + case 'SET_TRACK_NAME': { + return updateTrack(state, op.trackId, (t) => ({ ...t, name: op.name })); + } + + // — Asset operations —————————————————————————————————————————————————— + + case 'REGISTER_ASSET': { + const next = new Map(state.assetRegistry); + next.set(op.asset.id, op.asset); + return { ...state, assetRegistry: next }; + } + + case 'UNREGISTER_ASSET': { + const next = new Map(state.assetRegistry); + next.delete(op.assetId); + return { ...state, assetRegistry: next }; + } + + case 'SET_ASSET_STATUS': { + const asset = state.assetRegistry.get(op.assetId); + if (!asset) return state; + const next = new Map(state.assetRegistry); + next.set(op.assetId, { ...asset, status: op.status }); + return { ...state, assetRegistry: next }; + } + + // — Timeline operations ——————————————————————————————————————————————— + + case 'RENAME_TIMELINE': { + return { ...state, timeline: { ...state.timeline, name: op.name } }; + } + + case 'SET_TIMELINE_DURATION': { + return { ...state, timeline: { ...state.timeline, duration: op.duration } }; + } + + case 'SET_TIMELINE_START_TC': { + return { ...state, timeline: { ...state.timeline, startTimecode: op.startTimecode } }; + } + + case 'SET_SEQUENCE_SETTINGS': { + return { + ...state, + timeline: { + ...state.timeline, + sequenceSettings: { ...state.timeline.sequenceSettings, ...op.settings }, + }, + }; + } + + // — Phase 3: Marker operations ———————————————————————————————————————— + + case 'ADD_MARKER': { + const markers = [...state.timeline.markers, op.marker].sort(sortMarkersByAnchor); + return { ...state, timeline: { ...state.timeline, markers } }; + } + + case 'MOVE_MARKER': { + const marker = state.timeline.markers.find((m) => m.id === op.markerId); + if (!marker) return state; + const updated = + marker.type === 'point' + ? { ...marker, frame: op.newFrame } + : { + ...marker, + frameStart: op.newFrame, + frameEnd: (op.newFrame + (marker.frameEnd - marker.frameStart)) as TimelineFrame, + }; + const markers = state.timeline.markers + .map((m) => (m.id === op.markerId ? updated : m)) + .sort(sortMarkersByAnchor); + return { ...state, timeline: { ...state.timeline, markers } }; + } + + case 'DELETE_MARKER': { + const markers = state.timeline.markers.filter((m) => m.id !== op.markerId); + return { ...state, timeline: { ...state.timeline, markers } }; + } + + case 'SET_IN_POINT': { + return { ...state, timeline: { ...state.timeline, inPoint: op.frame } }; + } + + case 'SET_OUT_POINT': { + return { ...state, timeline: { ...state.timeline, outPoint: op.frame } }; + } + + case 'ADD_BEAT_GRID': { + return { ...state, timeline: { ...state.timeline, beatGrid: op.beatGrid } }; + } + + case 'REMOVE_BEAT_GRID': { + return { ...state, timeline: { ...state.timeline, beatGrid: null } }; + } + + case 'INSERT_GENERATOR': { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return state; + const genAsset = createGeneratorAsset({ + id: op.generator.id as unknown as string, + name: op.generator.name, + mediaType: track.type, + generatorDef: op.generator, + nativeFps: state.timeline.fps, + }); + const clip = createClip({ + id: `gen-clip-${op.generator.id}`, + assetId: genAsset.id as unknown as string, + trackId: op.trackId as unknown as string, + timelineStart: op.atFrame, + timelineEnd: (op.atFrame + op.generator.duration) as TimelineFrame, + mediaIn: 0 as TimelineFrame, + mediaOut: op.generator.duration, + }); + const nextRegistry = new Map(state.assetRegistry); + nextRegistry.set(genAsset.id, genAsset); + return updateTrack( + { ...state, assetRegistry: nextRegistry }, + op.trackId, + (t) => sortTrackClips({ ...t, clips: [...t.clips, clip] }), + ); + } + + case 'ADD_CAPTION': { + const captionToAdd: Caption = { + ...op.caption, + style: op.caption.style ?? defaultCaptionStyle, + }; + return updateTrack(state, op.trackId, (track) => { + const captions = [...track.captions, captionToAdd].sort( + (a, b) => a.startFrame - b.startFrame, + ); + return { ...track, captions }; + }); + } + + case 'EDIT_CAPTION': { + return updateTrack(state, op.trackId, (track) => { + const cap = track.captions.find((c) => c.id === op.captionId); + if (!cap) return track; + const updated = { + ...cap, + ...(op.text !== undefined && { text: op.text }), + ...(op.language !== undefined && { language: op.language }), + ...(op.style !== undefined && { style: { ...cap.style, ...op.style } }), + ...(op.burnIn !== undefined && { burnIn: op.burnIn }), + ...(op.startFrame !== undefined && { startFrame: op.startFrame }), + ...(op.endFrame !== undefined && { endFrame: op.endFrame }), + }; + const captions = track.captions + .map((c) => (c.id === op.captionId ? updated : c)) + .sort((a, b) => a.startFrame - b.startFrame); + return { ...track, captions }; + }); + } + + case 'DELETE_CAPTION': { + return updateTrack(state, op.trackId, (track) => ({ + ...track, + captions: track.captions.filter((c) => c.id !== op.captionId), + })); + } + + // — Phase 4: Effect & Keyframe ———————————————————————————————————————— + + case 'ADD_EFFECT': { + return updateClipEffects(state, op.clipId, (effects) => [...effects, op.effect]); + } + + case 'REMOVE_EFFECT': { + return updateClipEffects(state, op.clipId, (effects) => + effects.filter((e) => e.id !== op.effectId), + ); + } + + case 'REORDER_EFFECT': { + return updateClipEffects(state, op.clipId, (effects) => { + const idx = effects.findIndex((e) => e.id === op.effectId); + if (idx < 0) return effects; + const arr = [...effects]; + const [removed] = arr.splice(idx, 1); + if (!removed) return effects; + arr.splice(op.newIndex, 0, removed); + return arr; + }); + } + + case 'SET_EFFECT_ENABLED': { + return updateClipEffects(state, op.clipId, (effects) => + effects.map((e) => (e.id === op.effectId ? { ...e, enabled: op.enabled } : e)), + ); + } + + case 'SET_EFFECT_PARAM': { + return updateClipEffects(state, op.clipId, (effects) => + effects.map((e) => { + if (e.id !== op.effectId) return e; + const idx = e.params.findIndex((p) => p.key === op.key); + const newParams: EffectParam[] = + idx >= 0 + ? e.params.map((p, i) => (i === idx ? { key: op.key, value: op.value } : p)) + : [...e.params, { key: op.key, value: op.value }]; + return { ...e, params: newParams }; + }), + ); + } + + case 'ADD_KEYFRAME': { + return updateClipEffects(state, op.clipId, (effects) => + effects.map((e) => { + if (e.id !== op.effectId) return e; + const keyframes = [...e.keyframes, op.keyframe].sort((a, b) => a.frame - b.frame); + return { ...e, keyframes }; + }), + ); + } + + case 'MOVE_KEYFRAME': { + return updateClipEffects(state, op.clipId, (effects) => + effects.map((e) => { + if (e.id !== op.effectId) return e; + const keyframes = e.keyframes + .map((k) => (k.id === op.keyframeId ? { ...k, frame: op.newFrame } as Keyframe : k)) + .sort((a, b) => a.frame - b.frame); + return { ...e, keyframes }; + }), + ); + } + + case 'DELETE_KEYFRAME': { + return updateClipEffects(state, op.clipId, (effects) => + effects.map((e) => + e.id === op.effectId + ? { ...e, keyframes: e.keyframes.filter((k) => k.id !== op.keyframeId) } + : e, + ), + ); + } + + case 'SET_KEYFRAME_EASING': { + return updateClipEffects(state, op.clipId, (effects) => + effects.map((e) => ({ + ...e, + keyframes: e.keyframes.map((k) => + k.id === op.keyframeId ? { ...k, easing: op.easing } : k, + ), + })), + ); + } + + // — Phase 4 Step 3: Transform, Audio, Transitions, Groups ——————————————— + + case 'SET_CLIP_TRANSFORM': { + return updateClip(state, op.clipId, (clip) => { + const base = clip.transform ?? DEFAULT_CLIP_TRANSFORM; + const p = op.transform; + const merged: ClipTransform = { + positionX: p.positionX ?? base.positionX, + positionY: p.positionY ?? base.positionY, + scaleX: p.scaleX ?? base.scaleX, + scaleY: p.scaleY ?? base.scaleY, + rotation: p.rotation ?? base.rotation, + opacity: p.opacity ?? base.opacity, + anchorX: p.anchorX ?? base.anchorX, + anchorY: p.anchorY ?? base.anchorY, + }; + return { ...clip, transform: merged }; + }); + } + + case 'SET_AUDIO_PROPERTIES': { + return updateClip(state, op.clipId, (clip) => { + const base = clip.audio ?? DEFAULT_AUDIO_PROPERTIES; + const merged: AudioProperties = { ...base, ...op.properties }; + return { ...clip, audio: merged }; + }); + } + + case 'ADD_TRANSITION': { + return updateClip(state, op.clipId, (clip) => ({ ...clip, transition: op.transition })); + } + + case 'DELETE_TRANSITION': { + return updateClip(state, op.clipId, (clip) => { + const { transition: _, ...rest } = clip; + return rest as Clip; + }); + } + + case 'SET_TRANSITION_DURATION': { + return updateClip(state, op.clipId, (clip) => { + if (!clip.transition) return clip; + return { ...clip, transition: { ...clip.transition, durationFrames: op.durationFrames } }; + }); + } + + case 'SET_TRANSITION_ALIGNMENT': { + return updateClip(state, op.clipId, (clip) => { + if (!clip.transition) return clip; + return { ...clip, transition: { ...clip.transition, alignment: op.alignment } }; + }); + } + + case 'LINK_CLIPS': { + const linkGroups = [...(state.timeline.linkGroups ?? []), op.linkGroup]; + return { ...state, timeline: { ...state.timeline, linkGroups } }; + } + + case 'UNLINK_CLIPS': { + const linkGroups = (state.timeline.linkGroups ?? []).filter((g) => g.id !== op.linkGroupId); + return { ...state, timeline: { ...state.timeline, linkGroups } }; + } + + case 'ADD_TRACK_GROUP': { + const trackGroups = [...(state.timeline.trackGroups ?? []), op.trackGroup]; + const tracks = state.timeline.tracks.map((t) => + op.trackGroup.trackIds.some((id) => id === t.id) + ? { ...t, groupId: op.trackGroup.id } + : t, + ); + return { + ...state, + timeline: { ...state.timeline, trackGroups, tracks }, + }; + } + + case 'DELETE_TRACK_GROUP': { + const trackGroups = (state.timeline.trackGroups ?? []).filter((g) => g.id !== op.trackGroupId); + const tracks = state.timeline.tracks.map((t) => { + if (t.groupId !== op.trackGroupId) return t; + const { groupId: _, ...rest } = t; + return rest as Track; + }); + return { + ...state, + timeline: { ...state.timeline, trackGroups, tracks }, + }; + } + + case 'SET_TRACK_BLEND_MODE': { + return updateTrack(state, op.trackId, (t) => ({ ...t, blendMode: op.blendMode })); + } + + case 'SET_TRACK_OPACITY': { + return updateTrack(state, op.trackId, (t) => ({ ...t, opacity: op.opacity })); + } + } +} + +function shiftLinkedMarkers( + markers: readonly Marker[], + clipId: string, + delta: number, +): Marker[] { + const shifted = markers.map((m) => { + if (m.clipId !== clipId) return m; + if (m.type === 'point') { + return { ...m, frame: (m.frame + delta) as TimelineFrame }; + } + return { + ...m, + frameStart: (m.frameStart + delta) as TimelineFrame, + frameEnd: (m.frameEnd + delta) as TimelineFrame, + }; + }); + return [...shifted].sort(sortMarkersByAnchor); +} + +function sortMarkersByAnchor( + a: { type: 'point'; frame: TimelineFrame } | { type: 'range'; frameStart: TimelineFrame }, + b: { type: 'point'; frame: TimelineFrame } | { type: 'range'; frameStart: TimelineFrame }, +): number { + const anchorA = a.type === 'point' ? a.frame : a.frameStart; + const anchorB = b.type === 'point' ? b.frame : b.frameStart; + return anchorA - anchorB; +} + +// --------------------------------------------------------------------------- +// Internal helpers — keep these private to this file +// --------------------------------------------------------------------------- + +function updateClipEffects( + state: TimelineState, + clipId: string, + fn: (effects: readonly Effect[]) => readonly Effect[], +): TimelineState { + return updateClip(state, clipId, (clip) => ({ + ...clip, + effects: fn(clip.effects ?? []), + })); +} + +function updateTrack( + state: TimelineState, + trackId: string, + fn: (track: Track) => Track, +): TimelineState { + return { + ...state, + timeline: { + ...state.timeline, + tracks: state.timeline.tracks.map((t) => (t.id === trackId ? fn(t) : t)), + }, + }; +} + +function updateTrackOfClip( + state: TimelineState, + clipId: string, + fn: (track: Track) => Track, +): TimelineState { + return { + ...state, + timeline: { + ...state.timeline, + tracks: state.timeline.tracks.map((t) => + t.clips.some((c) => c.id === clipId) ? fn(t) : t, + ), + }, + }; +} + +function updateClip( + state: TimelineState, + clipId: string, + fn: (clip: Clip) => Clip, +): TimelineState { + return { + ...state, + timeline: { + ...state.timeline, + tracks: state.timeline.tracks.map((track) => { + if (!track.clips.some((c) => c.id === clipId)) return track; + const updatedTrack = { + ...track, + clips: track.clips.map((c) => (c.id === clipId ? fn(c) : c)), + }; + return sortTrackClips(updatedTrack); + }), + }, + }; +} diff --git a/packages/core/src/engine/clock.ts b/packages/core/src/engine/clock.ts new file mode 100644 index 0000000..1825e18 --- /dev/null +++ b/packages/core/src/engine/clock.ts @@ -0,0 +1,68 @@ +/** + * Clock abstraction — Phase 6 Step 1 + * + * Allows PlayheadController to run without real rAF (swapped for mock in tests). + */ + +export type ClockCallback = (timestamp: number) => void; + +export type Clock = { + requestFrame: (cb: ClockCallback) => number; + cancelFrame: (id: number) => void; + now: () => number; // ms, like performance.now() +}; + +export const browserClock: Clock = { + requestFrame: (cb) => requestAnimationFrame(cb), + cancelFrame: (id) => cancelAnimationFrame(id), + now: () => performance.now(), +}; + +export const nodeClock: Clock = { + requestFrame: (cb) => { + const id = setTimeout(() => cb(Date.now()), 16); + return id as unknown as number; + }, + cancelFrame: (id) => clearTimeout(id as unknown as ReturnType), + now: () => Date.now(), +}; + +// --------------------------------------------------------------------------- +// Test clock +// --------------------------------------------------------------------------- + +export function createTestClock(): { + clock: Clock; + tick: (ms: number) => void; + getCallbacks: () => ClockCallback[]; +} { + const pending: Array< { id: number; cb: ClockCallback }> = []; + let idCounter = 0; + let currentTime = 0; + + const clock: Clock = { + requestFrame: (cb) => { + const id = ++idCounter; + pending.push({ id, cb }); + return id; + }, + cancelFrame: (id) => { + const idx = pending.findIndex((p) => p.id === id); + if (idx !== -1) pending.splice(idx, 1); + }, + now: () => currentTime, + }; + + function tick(ms: number): void { + currentTime += ms; + const toRun = [...pending]; + pending.length = 0; + toRun.forEach((p) => p.cb(currentTime)); + } + + function getCallbacks(): ClockCallback[] { + return pending.map((p) => p.cb); + } + + return { clock, tick, getCallbacks }; +} diff --git a/packages/core/src/engine/dispatcher.ts b/packages/core/src/engine/dispatcher.ts index 7fe9115..a20ff3e 100644 --- a/packages/core/src/engine/dispatcher.ts +++ b/packages/core/src/engine/dispatcher.ts @@ -1,144 +1,71 @@ /** - * DISPATCH ORCHESTRATION - * - * Coordinates operation execution, validation, and history management. - * - * WHAT IS THE DISPATCHER? - * - Executes operations on state - * - Validates the resulting state - * - Commits valid states to history - * - Rejects invalid states with errors - * - * WHY A DISPATCHER? - * - Enforces validation before mutation - * - Prevents invalid states from entering history - * - Provides consistent error handling - * - Separates concerns (operations vs validation vs history) - * - * CRITICAL FLOW: - * 1. Execute operation (pure function) - * 2. Validate resulting state - * 3. If valid: push to history, return success - * 4. If invalid: return errors, state unchanged - * - * USAGE: - * ```typescript - * const result = dispatch(history, (state) => addClip(state, trackId, clip)); - * if (result.success) { - * history = result.history; - * } else { - * console.error('Operation failed:', result.errors); - * } - * ``` + * DISPATCHER — Phase 0 compliant + * + * The ONLY entry point for mutating TimelineState. + * Validates first, applies atomically, checks invariants. + * + * Algorithm: + * 1. For each operation: run per-primitive validator → reject immediately on failure + * 2. Apply all operations sequentially to get proposedState + * 3. Run checkInvariants(proposedState) → reject on any violation + * 4. Bump timeline.version by 1 and return accepted + * + * RULE: If one primitive fails, zero primitives are applied. */ -import { HistoryState, pushHistory, getCurrentState } from './history'; -import { TimelineState } from '../types/state'; -import { ValidationError } from '../types/validation'; -import { validateTimeline } from '../systems/validation'; +import type { TimelineState } from '../types/state'; +import type { + Transaction, + DispatchResult, +} from '../types/operations'; +import { applyOperation } from './apply'; +import { checkInvariants } from '../validation/invariants'; +import { validateOperation } from '../validation/validators'; -/** - * Operation - A pure function that transforms timeline state - */ -export type Operation = (state: TimelineState) => TimelineState; - -/** - * DispatchResult - The result of a dispatch operation - * - * Contains: - * - success: Whether the operation succeeded - * - history: Updated history (if success) or unchanged (if failure) - * - errors: Validation errors (if failure) - */ -export interface DispatchResult { - /** Whether the operation succeeded */ - success: boolean; - - /** Updated history state */ - history: HistoryState; - - /** Validation errors (if operation failed) */ - errors?: ValidationError[]; -} +// --------------------------------------------------------------------------- +// dispatch +// --------------------------------------------------------------------------- -/** - * Dispatch an operation - * - * Executes the operation, validates the result, and commits to history - * if valid. Returns errors if validation fails. - * - * @param history - Current history state - * @param operation - Operation to execute - * @returns Dispatch result with success status and updated history - */ -export function dispatch(history: HistoryState, operation: Operation): DispatchResult { - // Get current state - const currentState = getCurrentState(history); - - let newState: TimelineState; - - try { - // Execute operation - newState = operation(currentState); - } catch (error) { - // Operation threw an error, convert to validation error - const errorMessage = error instanceof Error ? error.message : String(error); - return { - success: false, - history, // Return unchanged history - errors: [{ - code: 'OPERATION_ERROR', - message: errorMessage, - }], - }; +export function dispatch( + state: TimelineState, + transaction: Transaction, +): DispatchResult { + // Step 1 + Step 2: Validate each operation against the rolling state, then apply it. + // Validating against rolling state is necessary for compound transactions like + // [ DELETE_CLIP, INSERT_CLIP(left), INSERT_CLIP(right) ] + // where INSERT_CLIP validation must see the post-DELETE state (original clip gone). + // If any op fails validation, we return immediately — zero ops have been committed. + let proposedState = state; + for (const op of transaction.operations) { + const rejection = validateOperation(proposedState, op); + if (rejection) { + return { + accepted: false, + reason: rejection.reason, + message: rejection.message, + }; + } + proposedState = applyOperation(proposedState, op); } - - // Validate resulting state - const validationResult = validateTimeline(newState); - - if (!validationResult.valid) { - // Validation failed, return errors + + // Step 3: Run InvariantChecker on the full proposed state + const violations = checkInvariants(proposedState); + if (violations.length > 0) { return { - success: false, - history, // Return unchanged history - errors: validationResult.errors, + accepted: false, + reason: 'INVARIANT_VIOLATED', + message: violations.map((v) => v.message).join('; '), }; } - - // Validation passed, commit to history - const newHistory = pushHistory(history, newState); - - return { - success: true, - history: newHistory, - }; -} -/** - * Dispatch multiple operations as a batch - * - * Executes all operations in sequence. If any operation fails validation, - * the entire batch is rejected and state remains unchanged. - * - * This is useful for atomic operations that must all succeed or all fail. - * - * @param history - Current history state - * @param operations - Array of operations to execute - * @returns Dispatch result with success status and updated history - */ -export function dispatchBatch( - history: HistoryState, - operations: Operation[] -): DispatchResult { - // Compose all operations into a single operation - const composedOperation: Operation = (state) => { - let currentState = state; - for (const operation of operations) { - currentState = operation(currentState); - } - return currentState; + // Step 4: Commit — bump version + const nextState: TimelineState = { + ...proposedState, + timeline: { + ...proposedState.timeline, + version: state.timeline.version + 1, + }, }; - - // Dispatch the composed operation - return dispatch(history, composedOperation); + + return { accepted: true, nextState }; } diff --git a/packages/core/src/engine/edl-export.ts b/packages/core/src/engine/edl-export.ts new file mode 100644 index 0000000..921d559 --- /dev/null +++ b/packages/core/src/engine/edl-export.ts @@ -0,0 +1,179 @@ +/** + * CMX3600 EDL export — Phase 5 Step 3 + * + * Single video track only. Pure function, returns string. + * No IO. + */ + +import type { TimelineState } from '../types/state'; +import type { Clip } from '../types/clip'; +import type { Asset, FileAsset, GeneratorAsset } from '../types/asset'; +import type { Track } from '../types/track'; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export type EDLExportOptions = { + /** Default: state.timeline.name */ + title?: string; + /** Default: false (non-drop). True = 29.97 drop frame only. */ + dropFrame?: boolean; + /** Which video track to export. Default: 0 (first video track). */ + trackIndex?: number; +}; + +// --------------------------------------------------------------------------- +// Frame → Timecode +// --------------------------------------------------------------------------- + +function pad2(n: number): string { + return String(n).padStart(2, '0'); +} + +/** + * Non-drop: HH:MM:SS:FF from frame count at given fps. + */ +function frameToTimecodeNonDrop(frame: number, fps: number): string { + const totalSeconds = Math.floor(frame / fps); + const ff = Math.floor(frame % fps); + const ss = totalSeconds % 60; + const mm = Math.floor(totalSeconds / 60) % 60; + const hh = Math.floor(totalSeconds / 3600); + return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`; +} + +/** SMPTE drop-frame for 29.97fps only. */ +function frameToTimecodeDropFrame29_97(frame: number): string { + const FRAMES_PER_MIN = 1798; + const FRAMES_PER_10_MIN = 17982; + const d = Math.floor(frame / FRAMES_PER_10_MIN); + const m = Math.floor((frame % FRAMES_PER_10_MIN) / FRAMES_PER_MIN); + const totalMinutes = 10 * d + m; + const r = frame % FRAMES_PER_MIN; + const ss = Math.floor(r / 30); + const ff = r % 30; + const hh = Math.floor(totalMinutes / 60); + const mm = totalMinutes % 60; + return `${pad2(hh)}:${pad2(mm)}:${pad2(ss)}:${pad2(ff)}`; +} + +/** + * Convert frame count to timecode string. + * dropFrame true: only 29.97fps uses real drop-frame; others fall back to non-drop. + */ +export function frameToTimecode( + frame: number, + fps: number, + dropFrame: boolean, +): string { + if (!dropFrame || fps !== 29.97) { + return frameToTimecodeNonDrop(frame, fps); + } + return frameToTimecodeDropFrame29_97(frame); +} + +// --------------------------------------------------------------------------- +// Reel name +// --------------------------------------------------------------------------- + +/** + * FileAsset: filename without extension, truncate 8 chars, uppercase. + * GeneratorAsset or undefined: "AX". + */ +export function reelName(asset: Asset | undefined): string { + let raw: string; + if (!asset || asset.kind === 'generator') raw = 'AX'; + else { + const fa = asset as FileAsset; + const path = fa.filePath; + const base = path.split('/').pop() ?? path; + const noExt = base.includes('.') ? base.slice(0, base.lastIndexOf('.')) : base; + raw = noExt.toUpperCase().replace(/[^A-Z0-9_-]/g, '_').slice(0, 8); + } + return raw.padEnd(8).slice(0, 8); +} + +// --------------------------------------------------------------------------- +// Transition type +// --------------------------------------------------------------------------- + +function transitionCode(clip: Clip): 'C' | 'D' { + const t = clip.transition; + if (t && t.type === 'dissolve') return 'D'; + return 'C'; +} + +// --------------------------------------------------------------------------- +// Comment line: FROM CLIP NAME +// --------------------------------------------------------------------------- + +function clipDisplayName(asset: Asset | undefined, clipName: string | null): string { + if (!asset) return clipName ?? 'unknown'; + if (asset.kind === 'file') return (asset as FileAsset).filePath.split('/').pop() ?? (asset as FileAsset).filePath; + return (asset as GeneratorAsset).generatorDef?.type ?? (asset as GeneratorAsset).name ?? 'generator'; +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +function clipDurationFrames(clip: Clip): number { + return (clip.timelineEnd - clip.timelineStart) as number; +} + +/** + * Export a single video track to CMX3600 EDL string. + * trackIndex selects which video track (default 0). + */ +export function exportToEDL( + state: TimelineState, + options?: EDLExportOptions, +): string { + const title = options?.title ?? state.timeline.name ?? 'Untitled'; + const dropFrame = options?.dropFrame ?? false; + const trackIndex = options?.trackIndex ?? 0; + + const videoTracks = state.timeline.tracks.filter((t) => t.type === 'video'); + const track: Track | undefined = videoTracks[trackIndex]; + if (!track) { + return `TITLE: ${title}\nFCM: ${dropFrame ? 'DROP FRAME' : 'NON-DROP FRAME'}\n`; + } + + const fps = state.timeline.fps as number; + const useDropFrame = dropFrame && fps === 29.97; + let headerComment = ''; + if (dropFrame && fps !== 29.97) { + headerComment = '* DROP FRAME NOT SUPPORTED FOR THIS FRAME RATE\n\n'; + } + + const lines: string[] = []; + lines.push(`TITLE: ${title}`); + lines.push(`FCM: ${useDropFrame ? 'DROP FRAME' : 'NON-DROP FRAME'}`); + if (headerComment) lines.push(headerComment.trim()); + + const clips = track.clips; + for (let i = 0; i < clips.length; i++) { + const clip = clips[i]!; + const asset = state.assetRegistry.get(clip.assetId); + const eventNum = String(i + 1).padStart(3, '0'); + const reel = reelName(asset); + const channel = 'V'; + const trans = transitionCode(clip); + const startFrame = clip.timelineStart as number; + const dur = clipDurationFrames(clip); + const mediaStart = clip.mediaIn as number; + + const srcIn = frameToTimecode(mediaStart, fps, useDropFrame); + const srcOut = frameToTimecode(mediaStart + dur, fps, useDropFrame); + const recIn = frameToTimecode(startFrame, fps, useDropFrame); + const recOut = frameToTimecode(startFrame + dur, fps, useDropFrame); + + const eventLine = `${eventNum} ${reel} ${channel} ${trans} ${srcIn} ${srcOut} ${recIn} ${recOut}`; + lines.push(eventLine); + lines.push(`* FROM CLIP NAME: ${clipDisplayName(asset, clip.name)}`); + if (i < clips.length - 1) lines.push(''); + } + + return lines.join('\n'); +} diff --git a/packages/core/src/engine/fcpxml-export.ts b/packages/core/src/engine/fcpxml-export.ts new file mode 100644 index 0000000..dadadf7 --- /dev/null +++ b/packages/core/src/engine/fcpxml-export.ts @@ -0,0 +1,171 @@ +/** + * FCP XML (FCPX) export — Phase 5 Step 4 + * + * Final Cut Pro XML 1.10 interchange. Pure function, returns string. No IO. + */ + +import type { TimelineState } from '../types/state'; +import type { Clip } from '../types/clip'; +import type { Track } from '../types/track'; +import type { Asset, FileAsset, GeneratorAsset } from '../types/asset'; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export type FCPXMLExportOptions = { + libraryName?: string; + eventName?: string; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function xmlEscape(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** + * FCPXML rational time: "0s" or "{frames}/{fps}s". + */ +export function toFCPTime(frames: number, fps: number): string { + if (frames === 0) return '0s'; + return `${frames}/${fps}s`; +} + +function clipDurationFrames(clip: Clip): number { + return (clip.timelineEnd - clip.timelineStart) as number; +} + +const DEFAULT_WIDTH = 1920; +const DEFAULT_HEIGHT = 1080; + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +export function exportToFCPXML( + state: TimelineState, + options?: FCPXMLExportOptions, +): string { + const libraryName = xmlEscape(options?.libraryName ?? 'Library'); + const eventName = xmlEscape(options?.eventName ?? state.timeline.name ?? 'Event'); + const timelineName = xmlEscape(state.timeline.name ?? 'Project'); + const fps = state.timeline.fps as number; + + const lines: string[] = []; + const indent = (n: number) => ' '.repeat(n); + + lines.push(''); + lines.push(''); + lines.push(''); + lines.push(`${indent(1)}`); + + // Format + const frameDuration = fps > 0 ? `1/${fps}s` : '1/30s'; + lines.push(`${indent(2)}`); + + // One per FileAsset + const fileAssets = Array.from(state.assetRegistry.values()).filter((a) => a.kind === 'file'); + for (const asset of fileAssets) { + const fa = asset as FileAsset; + const id = xmlEscape(fa.id); + const name = xmlEscape(fa.name); + const src = 'file://' + xmlEscape(fa.filePath); + const duration = toFCPTime(fa.intrinsicDuration as number, fps); + const hasVideo = fa.mediaType === 'video' ? '1' : '0'; + const hasAudio = fa.mediaType === 'audio' ? '1' : '0'; + lines.push(`${indent(2)}`); + } + + // One per GeneratorAsset + const genAssets = Array.from(state.assetRegistry.values()).filter((a) => a.kind === 'generator'); + for (const asset of genAssets) { + const ga = asset as GeneratorAsset; + const id = xmlEscape(ga.id); + const genType = xmlEscape(ga.generatorDef.type); + const uid = `.../Generators.localized/${genType}`; + lines.push(`${indent(2)}`); + } + + lines.push(`${indent(1)}`); + lines.push(`${indent(1)}`); + lines.push(`${indent(2)}`); + lines.push(`${indent(3)}`); + + // Total duration from last clip end on first track + let totalDurationFrames = 0; + const firstTrack = state.timeline.tracks[0]; + if (firstTrack?.clips.length) { + const last = firstTrack.clips[firstTrack.clips.length - 1]!; + totalDurationFrames = last.timelineEnd as number; + } + const totalDuration = toFCPTime(totalDurationFrames, fps); + lines.push(`${indent(4)}`); + lines.push(`${indent(5)}`); + + // Primary video track: clip and gap elements + const videoTrack = state.timeline.tracks.find((t) => t.type === 'video') ?? state.timeline.tracks[0]; + if (videoTrack) { + let cursor = 0; + for (const clip of videoTrack.clips) { + const start = clip.timelineStart as number; + const gapFrames = start - cursor; + if (gapFrames > 0) { + const gapOffset = toFCPTime(start, fps); + const gapDur = toFCPTime(gapFrames, fps); + lines.push(`${indent(6)}`); + } + const asset = state.assetRegistry.get(clip.assetId); + const dur = clipDurationFrames(clip); + const offset = toFCPTime(start, fps); + const durationStr = toFCPTime(dur, fps); + const mediaStart = toFCPTime(clip.mediaIn as number, fps); + const clipId = xmlEscape(clip.id); + + if (asset?.kind === 'generator') { + const ga = asset as GeneratorAsset; + const ref = xmlEscape(ga.id); + lines.push(`${indent(6)}`); + lines.push(`${indent(7)}`); + lines.push(`${indent(8)}`); + lines.push(`${indent(7)}`); + lines.push(`${indent(6)}`); + } else { + const ref = asset ? xmlEscape(asset.id) : 'missing'; + lines.push(`${indent(6)}`); + lines.push(`${indent(7)}`); + } + cursor = clip.timelineEnd as number; + } + } + + // Audio tracks: asset-clip with role="dialogue" on spine (simplified) + for (const track of state.timeline.tracks) { + if (track.type !== 'audio') continue; + for (const clip of track.clips) { + const dur = clipDurationFrames(clip); + const start = clip.timelineStart as number; + const offset = toFCPTime(start, fps); + const durationStr = toFCPTime(dur, fps); + const asset = state.assetRegistry.get(clip.assetId); + const ref = asset ? xmlEscape(asset.id) : 'missing'; + const clipId = xmlEscape(clip.id); + lines.push(`${indent(6)}`); + } + } + + lines.push(`${indent(5)}`); + lines.push(`${indent(4)}`); + lines.push(`${indent(3)}`); + lines.push(`${indent(2)}`); + lines.push(`${indent(1)}`); + lines.push(''); + + return lines.join('\n'); +} diff --git a/packages/core/src/engine/frame-resolver.ts b/packages/core/src/engine/frame-resolver.ts new file mode 100644 index 0000000..d3ab52e --- /dev/null +++ b/packages/core/src/engine/frame-resolver.ts @@ -0,0 +1,197 @@ +/** + * Frame resolver — Phase 6 Step 2 + Step 3 + * + * Given a TimelineFrame, resolves which clips are visible and builds + * the composite request. Pure function — no async, no decoding. + */ + +import { toFrame } from '../types/frame'; +import type { TimelineFrame } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { Clip, ClipId } from '../types/clip'; +import type { Track } from '../types/track'; +import type { PlaybackQuality } from '../types/playhead'; +import type { ResolvedCompositeRequest, ResolvedLayer } from '../types/pipeline'; +import type { Marker } from '../types/marker'; +import { DEFAULT_CLIP_TRANSFORM } from '../types/clip-transform'; +import type { TrackIndex } from './track-index'; + +/** Anchor frame for seek: point → frame, range → frameStart. */ +export function getMarkerAnchor(marker: Marker): TimelineFrame { + return marker.type === 'point' ? marker.frame : marker.frameStart; +} + +/** + * Returns the media-space frame for a clip at the given timeline frame. + * mediaFrame = timelineFrame - clip.timelineStart + clip.mediaIn + */ +export function mediaFrameForClip( + clip: Clip, + timelineFrame: TimelineFrame, +): TimelineFrame { + const t = timelineFrame as number; + const start = clip.timelineStart as number; + const mediaIn = (clip.mediaIn ?? toFrame(0)) as number; + return toFrame(mediaIn + (t - start)); +} + +/** + * Returns all clips visible at the given timeline frame, with their + * parent track and track index (z-order). + * If index is provided and built, uses O(log n + k) lookup; else linear scan. + */ +export function getClipsAtFrame( + state: TimelineState, + timelineFrame: TimelineFrame, + index?: TrackIndex, +): Array<{ clip: Clip; track: Track; trackIndex: number }> { + const t = timelineFrame as number; + if (index?.isBuilt) { + return index.query(t); + } + const out: Array<{ clip: Clip; track: Track; trackIndex: number }> = []; + const tracks = state.timeline.tracks; + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]!; + for (const clip of track.clips) { + const start = clip.timelineStart as number; + const end = clip.timelineEnd as number; + if (t >= start && t < end) { + out.push({ clip, track, trackIndex: i }); + } + } + } + return out; +} + +/** + * Resolves the composite request for a timeline frame: which layers are + * visible and their transform/opacity/blend/effects. Does not decode. + * Pass optional index for O(log n + k) clip lookup. + */ +export function resolveFrame( + state: TimelineState, + timelineFrame: TimelineFrame, + quality: PlaybackQuality, + dimensions: { width: number; height: number }, + index?: TrackIndex, +): ResolvedCompositeRequest { + const pairs = getClipsAtFrame(state, timelineFrame, index); + const layers: ResolvedLayer[] = pairs.map(({ clip, track, trackIndex }) => ({ + clipId: clip.id, + trackId: track.id, + trackIndex, + mediaFrame: mediaFrameForClip(clip, timelineFrame), + transform: clip.transform ?? DEFAULT_CLIP_TRANSFORM, + opacity: track.opacity ?? 1, + blendMode: track.blendMode ?? 'normal', + effects: clip.effects ?? [], + })); + return { + timelineFrame, + layers, + width: dimensions.width, + height: dimensions.height, + quality, + }; +} + +/** + * Returns the nearest frame strictly after fromFrame where any clip + * starts or ends on any track. Returns null if none. + */ +export function findNextClipBoundary( + state: TimelineState, + fromFrame: TimelineFrame, +): TimelineFrame | null { + const from = fromFrame as number; + let min: number | null = null; + for (const track of state.timeline.tracks) { + for (const clip of track.clips) { + const start = clip.timelineStart as number; + const end = clip.timelineEnd as number; + if (start > from && (min === null || start < min)) min = start; + if (end > from && (min === null || end < min)) min = end; + } + } + return min !== null ? toFrame(min) : null; +} + +/** + * Returns the nearest frame strictly before fromFrame where any clip + * starts or ends on any track. Returns null if none. + */ +export function findPrevClipBoundary( + state: TimelineState, + fromFrame: TimelineFrame, +): TimelineFrame | null { + const from = fromFrame as number; + let max: number | null = null; + for (const track of state.timeline.tracks) { + for (const clip of track.clips) { + const start = clip.timelineStart as number; + const end = clip.timelineEnd as number; + if (start < from && (max === null || start > max)) max = start; + if (end < from && (max === null || end > max)) max = end; + } + } + return max !== null ? toFrame(max) : null; +} + +/** + * Returns the marker with the smallest anchor strictly after fromFrame. + * Point markers use .frame; range markers use .frameStart as anchor. + */ +export function findNextMarker( + state: TimelineState, + fromFrame: TimelineFrame, +): Marker | null { + const from = fromFrame as number; + let best: Marker | null = null; + let bestAnchor = Infinity; + for (const m of state.timeline.markers) { + const anchor = getMarkerAnchor(m) as number; + if (anchor > from && anchor < bestAnchor) { + bestAnchor = anchor; + best = m; + } + } + return best; +} + +/** + * Returns the marker with the largest anchor strictly before fromFrame. + */ +export function findPrevMarker( + state: TimelineState, + fromFrame: TimelineFrame, +): Marker | null { + const from = fromFrame as number; + let best: Marker | null = null; + let bestAnchor = -Infinity; + for (const m of state.timeline.markers) { + const anchor = getMarkerAnchor(m) as number; + if (anchor < from && anchor > bestAnchor) { + bestAnchor = anchor; + best = m; + } + } + return best; +} + +/** + * Searches all tracks for a clip with the given id. + * Returns clip + parent track + trackIndex, or null if not found. + */ +export function findClipById( + state: TimelineState, + clipId: ClipId, +): { clip: Clip; track: Track; trackIndex: number } | null { + const tracks = state.timeline.tracks; + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]!; + const clip = track.clips.find((c) => c.id === clipId); + if (clip) return { clip, track, trackIndex: i }; + } + return null; +} diff --git a/packages/core/src/engine/history.ts b/packages/core/src/engine/history.ts index 7cc502e..dd52610 100644 --- a/packages/core/src/engine/history.ts +++ b/packages/core/src/engine/history.ts @@ -1,39 +1,20 @@ /** * HISTORY ENGINE - * + * * Snapshot-based undo/redo system for timeline state. - * - * WHAT IS THE HISTORY ENGINE? - * - Stores immutable snapshots of timeline state - * - Provides undo/redo functionality - * - Prevents state corruption - * - * HOW IT WORKS: - * - past: Array of previous states - * - present: Current state - * - future: Array of states that can be redone - * - * WHY SNAPSHOTS? - * - Simple and reliable (no complex diffing) - * - Guaranteed to restore exact state - * - No risk of partial corruption - * - Easy to implement and test - * - * USAGE: - * ```typescript - * let history = createHistory(initialState); - * history = pushHistory(history, newState); - * history = undo(history); - * history = redo(history); - * ``` - * - * ALL FUNCTIONS ARE PURE: - * - Take history as input - * - Return new history as output - * - Never mutate input + * + * Two APIs: + * - HistoryState + pure functions (createHistory, pushHistory, undo, redo) + * - HistoryStack class with compression, checkpoints, and persistence */ import { TimelineState } from '../types/state'; +import type { Transaction } from '../types/operations'; +import type { CompressionPolicy } from '../types/compression'; +import { DEFAULT_COMPRESSION_POLICY } from '../types/compression'; +import { TransactionCompressor } from './transaction-compressor'; +import { serializeTimeline, deserializeTimeline } from './serializer'; +import { SerializationError } from './serialization-error'; /** * HistoryState - The history container @@ -196,3 +177,165 @@ export function clearHistory(history: HistoryState): HistoryState { future: [], }; } + +// --------------------------------------------------------------------------- +// HistoryStack — Phase 7 Step 3 (compression, checkpoints, persistence) +// --------------------------------------------------------------------------- + +export type HistoryEntry = { + readonly state: TimelineState; + readonly transaction: Transaction; +}; + +export class HistoryStack { + private entries: HistoryEntry[] = []; + private undoIndex = -1; + private limit: number; + private compressor: TransactionCompressor; + private clock: () => number; + private checkpoints: Map = new Map(); + + constructor( + limit: number = 100, + policy: CompressionPolicy = DEFAULT_COMPRESSION_POLICY, + clock: () => number = Date.now, + ) { + this.limit = limit; + this.clock = clock; + this.compressor = new TransactionCompressor(policy, clock); + } + + push(entry: HistoryEntry): void { + if (this.entries.length === 0) { + this.entries.push(entry); + this.undoIndex = 0; + return; + } + this.entries.push(entry); + if (this.entries.length > this.limit) { + this.entries.shift(); + this.undoIndex = this.entries.length - 1; + } else { + this.undoIndex = this.entries.length - 1; + } + } + + pushWithCompression(entry: HistoryEntry, transaction: Transaction): void { + const now = this.clock(); + if (this.compressor.shouldCompress(transaction, now)) { + if (this.entries.length > 0) { + this.entries[this.entries.length - 1] = entry; + this.compressor.record(transaction, now); + return; + } + } + this.push(entry); + this.compressor.record(transaction, now); + } + + resetCompression(): void { + this.compressor.reset(); + } + + undo(): TimelineState | null { + if (this.undoIndex <= 0) return null; + this.undoIndex--; + return this.entries[this.undoIndex]!.state; + } + + redo(): TimelineState | null { + if (this.undoIndex >= this.entries.length - 1) return null; + this.undoIndex++; + return this.entries[this.undoIndex]!.state; + } + + getCurrentState(): TimelineState | null { + if (this.entries.length === 0) return null; + return this.entries[this.undoIndex]!.state; + } + + canUndo(): boolean { + return this.undoIndex > 0; + } + + canRedo(): boolean { + return this.undoIndex < this.entries.length - 1; + } + + saveCheckpoint(name: string): void { + this.checkpoints.set(name, this.undoIndex); + } + + restoreCheckpoint(name: string): HistoryEntry | null { + const idx = this.checkpoints.get(name); + if (idx === undefined) return null; + if (idx >= this.entries.length) return null; + return this.entries[idx] ?? null; + } + + listCheckpoints(): string[] { + return [...this.checkpoints.keys()]; + } + + clearCheckpoint(name: string): void { + this.checkpoints.delete(name); + } + + serialize(): string { + const payload = { + version: 1, + undoIndex: this.undoIndex, + entries: this.entries.map((e) => ({ + state: serializeTimeline(e.state), + transaction: e.transaction, + })), + }; + return JSON.stringify(payload); + } + + static deserialize( + raw: string, + limit?: number, + policy: CompressionPolicy = DEFAULT_COMPRESSION_POLICY, + clock: () => number = Date.now, + ): HistoryStack { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid JSON'; + throw new SerializationError(msg); + } + if (!parsed || typeof parsed !== 'object' || !('version' in parsed)) { + throw new SerializationError('Invalid history structure'); + } + const obj = parsed as { version: number; undoIndex: number; entries: Array<{ state: string; transaction: Transaction }> }; + if (obj.version !== 1) { + throw new SerializationError(`Unknown history version: ${obj.version}`); + } + if (!Array.isArray(obj.entries)) { + throw new SerializationError('Missing entries'); + } + const entries: HistoryEntry[] = []; + for (const e of obj.entries) { + if (typeof e.state !== 'string' || !e.transaction) { + throw new SerializationError('Invalid entry'); + } + entries.push({ + state: deserializeTimeline(e.state), + transaction: e.transaction, + }); + } + const stack = new HistoryStack(limit ?? entries.length + 50, policy, clock); + stack.entries = entries; + stack.undoIndex = + entries.length === 0 + ? -1 + : Math.min(Math.max(0, obj.undoIndex), entries.length - 1); + return stack; + } + + softLimitWarning(): boolean { + return this.entries.length >= this.limit * 0.8; + } +} diff --git a/packages/core/src/engine/interval-tree.ts b/packages/core/src/engine/interval-tree.ts new file mode 100644 index 0000000..32ca219 --- /dev/null +++ b/packages/core/src/engine/interval-tree.ts @@ -0,0 +1,85 @@ +/** + * Centered interval tree — Phase 7 Step 1 + * + * Stores intervals [start, end) and answers query(point): + * all intervals containing point. O(log n + k). + */ + +export type Interval = { + readonly start: number; // inclusive + readonly end: number; // exclusive + readonly data: T; +}; + +type INode = { + center: number; + intervals: Interval[]; + left: INode | null; + right: INode | null; +}; + +function medianMidpoints(intervals: Interval[]): number { + const midpoints = intervals + .map((i) => (i.start + i.end) / 2) + .slice() + .sort((a, b) => a - b); + const len = midpoints.length; + if (len === 0) return 0; + return len % 2 === 1 + ? midpoints[(len - 1) / 2]! + : (midpoints[len / 2 - 1]! + midpoints[len / 2]!) / 2; +} + +function buildNode(intervals: Interval[], _depth: number): INode | null { + if (intervals.length === 0) return null; + const center = medianMidpoints(intervals); + const left_intervals: Interval[] = []; + const right_intervals: Interval[] = []; + const crossing: Interval[] = []; + for (const i of intervals) { + if (i.end <= center) left_intervals.push(i); + else if (i.start > center) right_intervals.push(i); + else crossing.push(i); + } + crossing.sort((a, b) => a.start - b.start); + return { + center, + intervals: crossing, + left: buildNode(left_intervals, _depth + 1), + right: buildNode(right_intervals, _depth + 1), + }; +} + +function queryNode(node: INode | null, point: number): T[] { + if (node === null) return []; + const results: T[] = []; + for (const interval of node.intervals) { + if (interval.start <= point && point < interval.end) { + results.push(interval.data); + } + } + if (point < node.center) { + results.push(...queryNode(node.left, point)); + } else { + results.push(...queryNode(node.right, point)); + } + return results; +} + +export class IntervalTree { + private root: INode | null = null; + private _size = 0; + + build(intervals: Interval[]): void { + this._size = intervals.length; + this.root = buildNode(intervals, 0); + } + + query(point: number): T[] { + return queryNode(this.root, point); + } + + size(): number { + return this._size; + } +} diff --git a/packages/core/src/engine/keyboard-handler.ts b/packages/core/src/engine/keyboard-handler.ts new file mode 100644 index 0000000..7526714 --- /dev/null +++ b/packages/core/src/engine/keyboard-handler.ts @@ -0,0 +1,173 @@ +/** + * KeyboardHandler — Phase 6 Step 4 + * + * J/K/L jog-shuttle and keyboard contract. Zero DOM deps; + * accepts TimelineKeyEvent (host maps from KeyboardEvent). + */ + +import { toFrame } from '../types/frame'; +import type { TimelineFrame } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { TimelineKeyEvent } from '../tools/types'; +import type { KeyBinding, KeyboardHandlerOptions } from '../types/keyboard'; +import { DEFAULT_KEY_BINDINGS } from '../types/keyboard'; +import type { PlaybackRate } from '../types/playhead'; +import { PlaybackEngine } from './playback-engine'; + +function jogLevelToRate(level: number): PlaybackRate { + switch (level) { + case -3: + return -4.0; + case -2: + return -2.0; + case -1: + return -1.0; + case 0: + return 0.0; + case 1: + return 1.0; + case 2: + return 2.0; + case 3: + return 4.0; + default: + return level <= -4 ? -4.0 : 4.0; + } +} + +function matchBinding(binding: KeyBinding, event: TimelineKeyEvent): boolean { + if (binding.code !== event.code) return false; + if (binding.shift !== undefined && binding.shift !== event.shiftKey) return false; + if (binding.alt !== undefined && binding.alt !== event.altKey) return false; + if (binding.meta !== undefined && binding.meta !== event.metaKey) return false; + if (binding.ctrl !== undefined && binding.ctrl !== event.ctrlKey) return false; + if (binding.repeat === false && event.repeat === true) return false; + return true; +} + +/** Count how many modifier keys are specified (prefer more specific binding). */ +function bindingSpecificity(b: KeyBinding): number { + let n = 0; + if (b.shift !== undefined) n++; + if (b.alt !== undefined) n++; + if (b.meta !== undefined) n++; + if (b.ctrl !== undefined) n++; + return n; +} + +export class KeyboardHandler { + private bindings: KeyBinding[]; + private engine: PlaybackEngine; + private jogLevel = 0; + private onMarkIn: ((frame: TimelineFrame) => void) | undefined; + private onMarkOut: ((frame: TimelineFrame) => void) | undefined; + private getTimelineState: (() => TimelineState) | undefined; + + constructor( + engine: PlaybackEngine, + options?: KeyboardHandlerOptions, + ) { + this.engine = engine; + this.bindings = options?.bindings ?? DEFAULT_KEY_BINDINGS; + this.onMarkIn = options?.onMarkIn; + this.onMarkOut = options?.onMarkOut; + this.getTimelineState = options?.getTimelineState; + } + + handleKeyDown(event: TimelineKeyEvent): boolean { + const matches = this.bindings.filter((b) => matchBinding(b, event)); + if (matches.length === 0) return false; + const binding = matches.sort((a, b) => bindingSpecificity(b) - bindingSpecificity(a))[0]!; + this.dispatchAction(binding.action); + return true; + } + + private dispatchAction(action: KeyBinding['action']): void { + switch (action) { + case 'play-pause': + if (this.engine.getState().isPlaying) this.engine.pause(); + else this.engine.play(); + break; + case 'stop': + this.engine.pause(); + this.engine.seekTo(toFrame(0)); + break; + case 'jog-forward': { + this.jogLevel = Math.min(this.jogLevel + 1, 3); + const rate = jogLevelToRate(this.jogLevel); + this.engine.setPlaybackRate(rate); + if (!this.engine.getState().isPlaying) this.engine.play(); + break; + } + case 'jog-backward': { + this.jogLevel = Math.max(this.jogLevel - 1, -3); + const rate = jogLevelToRate(this.jogLevel); + this.engine.setPlaybackRate(rate); + if (!this.engine.getState().isPlaying) this.engine.play(); + break; + } + case 'jog-stop': + this.jogLevel = 0; + this.engine.pause(); + this.engine.setPlaybackRate(1.0); + break; + case 'step-forward': { + this.engine.pause(); + const s = this.engine.getState(); + const f = s.currentFrame as number; + const max = s.durationFrames - 1; + this.engine.seekTo(toFrame(Math.min(f + 1, max))); + break; + } + case 'step-backward': { + this.engine.pause(); + const f = (this.engine.getState().currentFrame as number) - 1; + this.engine.seekTo(toFrame(Math.max(f, 0))); + break; + } + case 'seek-start': + this.engine.seekToStart(); + break; + case 'seek-end': + this.engine.seekToEnd(); + break; + case 'next-clip': + this.engine.seekToNextClipBoundary(); + break; + case 'prev-clip': + this.engine.seekToPrevClipBoundary(); + break; + case 'next-marker': + this.engine.seekToNextMarker(); + break; + case 'prev-marker': + this.engine.seekToPrevMarker(); + break; + case 'mark-in': + if (this.onMarkIn) + this.onMarkIn(this.engine.getState().currentFrame); + break; + case 'mark-out': + if (this.onMarkOut) + this.onMarkOut(this.engine.getState().currentFrame); + break; + case 'toggle-loop': { + const current = this.engine.getState().loopRegion; + if (current !== null) { + this.engine.setLoopRegion(null); + } else if (this.getTimelineState) { + const state = this.getTimelineState(); + const inPt = state.timeline.inPoint; + const outPt = state.timeline.outPoint; + if (inPt != null && outPt != null) { + this.engine.setLoopRegion({ + startFrame: inPt, + endFrame: outPt, + }); + } + } + break; + } + } + } +} diff --git a/packages/core/src/engine/marker-search.ts b/packages/core/src/engine/marker-search.ts new file mode 100644 index 0000000..8975732 --- /dev/null +++ b/packages/core/src/engine/marker-search.ts @@ -0,0 +1,31 @@ +/** + * MARKER SEARCH API — Phase 3 Step 2 + * + * Pure functions. Search state.timeline.markers only. + */ + +import type { TimelineState } from '../types/state'; +import type { Marker } from '../types/marker'; + +/** + * Returns markers whose color exactly matches the given string. + */ +export function findMarkersByColor( + state: TimelineState, + color: string, +): Marker[] { + return state.timeline.markers.filter((m) => m.color === color); +} + +/** + * Returns markers whose label contains the given string (case-insensitive). + */ +export function findMarkersByLabel( + state: TimelineState, + label: string, +): Marker[] { + const lower = label.toLowerCase(); + return state.timeline.markers.filter((m) => + m.label.toLowerCase().includes(lower), + ); +} diff --git a/packages/core/src/engine/migrator.ts b/packages/core/src/engine/migrator.ts new file mode 100644 index 0000000..bb49ee7 --- /dev/null +++ b/packages/core/src/engine/migrator.ts @@ -0,0 +1,61 @@ +/** + * Schema migrator — Phase 5 Step 1 / Addendum + * + * Pure functions. Brings parsed JSON to current schema version. + * checkInvariants() is run by deserializeTimeline after migrate. + */ + +import { CURRENT_SCHEMA_VERSION } from '../types/state'; +import type { TimelineState } from '../types/state'; +import type { AssetId, Asset } from '../types/asset'; +import { SerializationError } from './serialization-error'; + +export { CURRENT_SCHEMA_VERSION }; + +/** V1 → V2: no structural change; bump schemaVersion only. */ +function migrateV1toV2(raw: unknown): unknown { + return { + ...(raw as Record), + schemaVersion: 2, + }; +} + +/** + * Migrate parsed JSON to current schema version. + * Runs migration chain (v1→v2, future v2→v3, …). Converts assetRegistry + * from plain object to Map when needed. + * Throws SerializationError on invalid structure or unknown version. + */ +export function migrate(raw: unknown): TimelineState { + if (typeof raw !== 'object' || raw === null) { + throw new SerializationError('Invalid JSON structure'); + } + + const obj = raw as Record; + const version = obj.schemaVersion; + if (typeof version !== 'number') { + throw new SerializationError('Missing schemaVersion'); + } + if (version > CURRENT_SCHEMA_VERSION) { + throw new SerializationError(`Unknown schema version: ${version}`); + } + + let current: unknown = raw; + if (version < 2) current = migrateV1toV2(current); + // future: if (version < 3) current = migrateV2toV3(current) + // future: if (version < 4) current = migrateV3toV4(current) + + const curr = current as Record; + const registry = curr.assetRegistry; + const assetRegistry = + registry instanceof Map + ? registry + : new Map( + Object.entries(registry as Record).map(([k, v]) => [k as AssetId, v]), + ); + return { + schemaVersion: curr.schemaVersion as number, + timeline: curr.timeline as TimelineState['timeline'], + assetRegistry, + } as TimelineState; +} diff --git a/packages/core/src/engine/otio-export.ts b/packages/core/src/engine/otio-export.ts new file mode 100644 index 0000000..2f69cbe --- /dev/null +++ b/packages/core/src/engine/otio-export.ts @@ -0,0 +1,244 @@ +/** + * OTIO export — Phase 5 Step 2 + * + * Pure function. Produces OTIO JSON-serializable document from TimelineState. + * No external OTIO library. Hand-rolled mapping. + */ + +import type { TimelineState } from '../types/state'; +import type { Clip } from '../types/clip'; +import type { Track } from '../types/track'; +import type { FileAsset, GeneratorAsset } from '../types/asset'; +import type { Marker } from '../types/marker'; +import type { Effect } from '../types/effect'; + +// --------------------------------------------------------------------------- +// Internal OTIO types (not exported) +// --------------------------------------------------------------------------- + +type OTIORationalTime = { value: number; rate: number }; +type OTIOTimeRange = { + OTIO_SCHEMA: string; + start_time: OTIORationalTime; + duration: OTIORationalTime; +}; + +type OTIOExternalReference = { + OTIO_SCHEMA: string; + target_url: string; + available_range: OTIOTimeRange; +}; +type OTIOGeneratorReference = { + OTIO_SCHEMA: string; + generator_kind: string; +}; +type OTIOMissingReference = { OTIO_SCHEMA: string }; + +type OTIOClip = { + OTIO_SCHEMA: string; + name: string; + source_range: OTIOTimeRange; + media_reference: OTIOExternalReference | OTIOGeneratorReference | OTIOMissingReference; + effects?: OTIOEffect[]; +}; +type OTIOGap = { + OTIO_SCHEMA: string; + source_range: OTIOTimeRange; +}; +type OTIOEffect = { + OTIO_SCHEMA: string; + name: string; + effect_name: string; + enabled: boolean; + metadata: { params: readonly { key: string; value: number | string | boolean }[] }; +}; +type OTIOTrack = { + OTIO_SCHEMA: string; + kind: string; + children: (OTIOClip | OTIOGap)[]; +}; +type OTIOStack = { + OTIO_SCHEMA: string; + children: OTIOTrack[]; +}; +type OTIOMarker = { + OTIO_SCHEMA: string; + name: string; + color: string; + marked_range: OTIOTimeRange; +}; +export type OTIODocument = { + OTIO_SCHEMA: string; + name: string; + global_start_time: OTIORationalTime; + tracks: OTIOStack; + markers: OTIOMarker[]; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function rationalTime(value: number, rate: number): OTIORationalTime { + return { value, rate }; +} + +function timeRange(start: number, duration: number, rate: number): OTIOTimeRange { + return { + OTIO_SCHEMA: 'TimeRange.1', + start_time: rationalTime(start, rate), + duration: rationalTime(duration, rate), + }; +} + +function fpsFromTimeline(state: TimelineState): number { + return state.timeline.fps as number; +} + +function clipDurationFrames(clip: Clip): number { + return (clip.timelineEnd - clip.timelineStart) as number; +} + +// --------------------------------------------------------------------------- +// Media reference +// --------------------------------------------------------------------------- + +function mediaReferenceForClip( + state: TimelineState, + clip: Clip, + fps: number, +): OTIOExternalReference | OTIOGeneratorReference | OTIOMissingReference { + const asset = state.assetRegistry.get(clip.assetId); + if (!asset) { + return { OTIO_SCHEMA: 'MissingReference.1' }; + } + if (asset.kind === 'file') { + const fa = asset as FileAsset; + const dur = fa.intrinsicDuration as number; + return { + OTIO_SCHEMA: 'ExternalReference.1', + target_url: fa.filePath, + available_range: timeRange(0, dur, fps), + }; + } + const ga = asset as GeneratorAsset; + return { + OTIO_SCHEMA: 'GeneratorReference.1', + generator_kind: ga.generatorDef.type, + }; +} + +// --------------------------------------------------------------------------- +// Effects +// --------------------------------------------------------------------------- + +function effectToOTIO(e: Effect): OTIOEffect { + return { + OTIO_SCHEMA: 'Effect.1', + name: e.effectType, + effect_name: e.effectType, + enabled: e.enabled, + metadata: { params: e.params ?? [] }, + }; +} + +// --------------------------------------------------------------------------- +// Clip to OTIO (single clip, no gap) +// --------------------------------------------------------------------------- + +function clipToOTIO(state: TimelineState, clip: Clip, fps: number): OTIOClip { + const durationFrames = clipDurationFrames(clip); + const mediaStart = clip.mediaIn as number; + const otioClip: OTIOClip = { + OTIO_SCHEMA: 'Clip.1', + name: clip.name ?? clip.id, + source_range: { + OTIO_SCHEMA: 'TimeRange.1', + start_time: rationalTime(mediaStart, fps), + duration: rationalTime(durationFrames, fps), + }, + media_reference: mediaReferenceForClip(state, clip, fps), + }; + const effects = clip.effects; + if (effects && effects.length > 0) { + otioClip.effects = effects.map(effectToOTIO); + } + return otioClip; +} + +// --------------------------------------------------------------------------- +// Track to OTIO (clips + gaps) +// --------------------------------------------------------------------------- + +function trackToOTIO(state: TimelineState, track: Track, fps: number): OTIOTrack { + const children: (OTIOClip | OTIOGap)[] = []; + const clips = track.clips; + for (let i = 0; i < clips.length; i++) { + const clip = clips[i]!; + const clipStart = clip.timelineStart as number; + const prevEnd = i === 0 ? 0 : (clips[i - 1]!.timelineEnd as number); + const gapFrames = clipStart - prevEnd; + if (gapFrames > 0) { + children.push({ + OTIO_SCHEMA: 'Gap.1', + source_range: timeRange(0, gapFrames, fps), + }); + } + children.push(clipToOTIO(state, clip, fps)); + } + const kind = track.type === 'video' ? 'Video' : track.type === 'audio' ? 'Audio' : 'Video'; + return { + OTIO_SCHEMA: 'Track.1', + kind, + children, + }; +} + +// --------------------------------------------------------------------------- +// Markers (timeline-level; export all timeline.markers) +// --------------------------------------------------------------------------- + +function markerToOTIO(marker: Marker, fps: number): OTIOMarker { + if (marker.type === 'point') { + const frame = marker.frame as number; + return { + OTIO_SCHEMA: 'Marker.1', + name: marker.label ?? '', + color: marker.color ?? 'RED', + marked_range: timeRange(frame, 0, fps), + }; + } + const start = marker.frameStart as number; + const duration = (marker.frameEnd - marker.frameStart) as number; + return { + OTIO_SCHEMA: 'Marker.1', + name: marker.label ?? '', + color: marker.color ?? 'RED', + marked_range: timeRange(start, duration, fps), + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Export TimelineState to an OTIO document (plain object). + * Caller can JSON.stringify the result. + */ +export function exportToOTIO(state: TimelineState): OTIODocument { + const fps = fpsFromTimeline(state); + const timeline = state.timeline; + const tracks: OTIOTrack[] = timeline.tracks.map((t) => trackToOTIO(state, t, fps)); + const markers: OTIOMarker[] = (timeline.markers ?? []).map((m) => markerToOTIO(m, fps)); + return { + OTIO_SCHEMA: 'Timeline.1', + name: timeline.name ?? 'Untitled', + global_start_time: rationalTime(0, fps), + tracks: { + OTIO_SCHEMA: 'Stack.1', + children: tracks, + }, + markers, + }; +} diff --git a/packages/core/src/engine/otio-import.ts b/packages/core/src/engine/otio-import.ts new file mode 100644 index 0000000..f97bf23 --- /dev/null +++ b/packages/core/src/engine/otio-import.ts @@ -0,0 +1,308 @@ +/** + * OTIO import — Phase 5 Step 2 + * + * Pure function. Builds TimelineState from OTIO document. + * Throws SerializationError on invalid doc or invariant violations. + */ + +import { SerializationError } from './serialization-error'; +import { checkInvariants } from '../validation/invariants'; +import { createTimelineState } from '../types/state'; +import { createTimeline } from '../types/timeline'; +import { createTrack, toTrackId } from '../types/track'; +import { createClip, toClipId } from '../types/clip'; +import { createAsset, createGeneratorAsset, toAssetId } from '../types/asset'; +import { createEffect, toEffectId } from '../types/effect'; +import { toFrame, frameRate } from '../types/frame'; +import { toMarkerId } from '../types/marker'; +import { toGeneratorId, type GeneratorType } from '../types/generator'; +import type { TimelineState, AssetRegistry } from '../types/state'; +import type { Clip } from '../types/clip'; +import type { Marker } from '../types/marker'; +import type { Asset, AssetId } from '../types/asset'; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export type OTIOImportOptions = { + /** Override fps; default: from doc global_start_time.rate or first clip rate, fallback 30 */ + fps?: number; + /** Override timeline name */ + name?: string; +}; + +// --------------------------------------------------------------------------- +// OTIO shape (minimal for parsing) +// --------------------------------------------------------------------------- + +type OTIORationalTime = { value: number; rate: number }; +type OTIOTimeRange = { + start_time?: OTIORationalTime; + duration?: OTIORationalTime; +}; +type OTIOMediaRef = { + OTIO_SCHEMA?: string; + target_url?: string; + available_range?: OTIOTimeRange; + generator_kind?: string; +}; +type OTIOItem = { + OTIO_SCHEMA?: string; + name?: string; + source_range?: OTIOTimeRange; + media_reference?: OTIOMediaRef; + effects?: Array<{ name?: string; effect_name?: string; enabled?: boolean; metadata?: { params?: unknown[] } }>; +}; +type OTIOTrack = { OTIO_SCHEMA?: string; kind?: string; children?: OTIOItem[] }; +type OTIODoc = { + OTIO_SCHEMA?: string; + name?: string; + global_start_time?: OTIORationalTime; + tracks?: { children?: OTIOTrack[] }; + markers?: Array<{ + name?: string; + color?: string; + marked_range?: OTIOTimeRange; + }>; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let clipIdCounter = 0; +function generateClipId(): string { + return `clip-${++clipIdCounter}`; +} + +function parseFps(doc: OTIODoc, options?: OTIOImportOptions): number { + if (options?.fps != null) return options.fps; + const global = doc.global_start_time; + if (global?.rate != null) return global.rate; + const tracks = doc.tracks?.children; + if (tracks) { + for (const track of tracks) { + const children = track?.children ?? []; + for (const item of children) { + const sr = item?.source_range; + if (sr?.duration?.rate != null) return sr.duration.rate; + } + } + } + return 30; +} + +function rationalTimeToFrames(rt: OTIORationalTime | undefined, targetFps: number): number { + if (!rt) return 0; + const value = rt.value ?? 0; + const rate = rt.rate ?? targetFps; + return Math.round(value * (targetFps / rate)); +} + +function ensureFrameRate(fps: number): import('../types/frame').FrameRate { + const valid = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60]; + if (!valid.includes(fps)) return 30 as import('../types/frame').FrameRate; + return frameRate(fps); +} + +// --------------------------------------------------------------------------- +// Import +// --------------------------------------------------------------------------- + +export function importFromOTIO(doc: unknown, options?: OTIOImportOptions): TimelineState { + if (typeof doc !== 'object' || doc === null) { + throw new SerializationError('Invalid OTIO document'); + } + const d = doc as OTIODoc; + const schema = d.OTIO_SCHEMA; + if (typeof schema !== 'string' || !schema.startsWith('Timeline')) { + throw new SerializationError('Invalid OTIO document: OTIO_SCHEMA must be Timeline'); + } + + const targetFps = parseFps(d, options); + const fps = ensureFrameRate(targetFps); + const timelineName = options?.name ?? d.name ?? 'Untitled'; + + const assetRegistry = new Map(); + const tracks: import('../types/track').Track[] = []; + const trackList = d.tracks?.children ?? []; + + for (let ti = 0; ti < trackList.length; ti++) { + const otioTrack = trackList[ti]!; + const kind = otioTrack.kind ?? 'Video'; + const trackType = kind === 'Audio' ? 'audio' : 'video'; + const trackId = toTrackId(`track-${ti + 1}`); + const clips: Clip[] = []; + let cursorFrames = 0; + const children = otioTrack.children ?? []; + + for (const item of children) { + const itemSchema = item?.OTIO_SCHEMA ?? ''; + if (itemSchema === 'Gap.1') { + const dur = item?.source_range?.duration; + const gapFrames = rationalTimeToFrames(dur, targetFps); + cursorFrames += gapFrames; + continue; + } + if (itemSchema === 'Clip.1') { + const sr = item.source_range; + const durationFrames = rationalTimeToFrames(sr?.duration, targetFps); + const mediaStartFrames = rationalTimeToFrames(sr?.start_time, targetFps); + const clipName = item.name ?? generateClipId(); + const clipId = toClipId(clipName); + const mediaRef = item.media_reference; + let assetId: AssetId; + if (mediaRef?.OTIO_SCHEMA === 'GeneratorReference.1') { + const genKind = mediaRef.generator_kind ?? 'solid'; + const assetIdStr = `gen-${ti}-${clips.length}`; + assetId = toAssetId(assetIdStr); + if (!assetRegistry.has(assetId)) { + const genAsset = createGeneratorAsset({ + id: assetIdStr, + name: genKind, + mediaType: trackType, + generatorDef: { + id: toGeneratorId(assetIdStr), + type: (['solid', 'bars', 'countdown', 'noise', 'text'].includes(genKind) ? genKind : 'solid') as GeneratorType, + params: {}, + duration: toFrame(Math.max(1, durationFrames)), + name: genKind, + }, + nativeFps: fps, + }); + assetRegistry.set(assetId, genAsset); + } + } else if (mediaRef?.OTIO_SCHEMA === 'ExternalReference.1' && mediaRef.target_url != null) { + const url = mediaRef.target_url; + const avail = mediaRef.available_range; + const intrinsicDuration = rationalTimeToFrames(avail?.duration, targetFps) || 1; + const aidStr = `asset-${url}-${intrinsicDuration}`; + assetId = toAssetId(aidStr); + if (!assetRegistry.has(assetId)) { + const fileAsset = createAsset({ + id: aidStr, + name: url.split('/').pop() ?? 'media', + mediaType: trackType, + filePath: url, + intrinsicDuration: toFrame(Math.max(1, intrinsicDuration)), + nativeFps: fps, + sourceTimecodeOffset: toFrame(0), + }); + assetRegistry.set(assetId, fileAsset); + } + } else { + const aidStr = `missing-${ti}-${clips.length}`; + assetId = toAssetId(aidStr); + if (!assetRegistry.has(assetId)) { + const fileAsset = createAsset({ + id: aidStr, + name: 'Missing', + mediaType: trackType, + filePath: '', + intrinsicDuration: toFrame(Math.max(1, durationFrames)), + nativeFps: fps, + sourceTimecodeOffset: toFrame(0), + status: 'missing', + }); + assetRegistry.set(assetId, fileAsset); + } + } + + const timelineStart = toFrame(cursorFrames); + const timelineEnd = toFrame(cursorFrames + durationFrames); + const mediaIn = toFrame(mediaStartFrames); + const mediaOut = toFrame(mediaStartFrames + durationFrames); + + let effects: Clip['effects']; + const otioEffects = item.effects; + if (otioEffects && otioEffects.length > 0) { + effects = otioEffects.map((e, idx) => { + const effectType = e.effect_name ?? e.name ?? 'effect'; + return createEffect( + toEffectId(`eff-${clipId}-${idx}`), + effectType, + 'preComposite', + (e.metadata?.params as { key: string; value: number | string | boolean }[]) ?? [], + ); + }); + } + + const clipParams: Parameters[0] = { + id: clipId, + assetId: assetId as unknown as string, + trackId, + timelineStart, + timelineEnd, + mediaIn, + mediaOut, + }; + if (effects?.length) clipParams.effects = effects; + const clip = createClip(clipParams); + clips.push(clip); + cursorFrames += durationFrames; + } + } + + tracks.push( + createTrack({ + id: trackId, + name: kind === 'Audio' ? `Audio ${ti + 1}` : `Video ${ti + 1}`, + type: trackType, + clips, + }), + ); + } + + const markers: Marker[] = []; + const otioMarkers = d.markers ?? []; + for (let i = 0; i < otioMarkers.length; i++) { + const m = otioMarkers[i]!; + const range = m.marked_range; + const startFrames = rationalTimeToFrames(range?.start_time, targetFps); + const durationFrames = rationalTimeToFrames(range?.duration, targetFps); + if (durationFrames <= 0) { + markers.push({ + type: 'point', + id: toMarkerId(`m${i + 1}`), + frame: toFrame(startFrames), + label: m.name ?? '', + color: m.color ?? 'RED', + scope: 'global', + linkedClipId: null, + }); + } else { + markers.push({ + type: 'range', + id: toMarkerId(`m${i + 1}`), + frameStart: toFrame(startFrames), + frameEnd: toFrame(startFrames + durationFrames), + label: m.name ?? '', + color: m.color ?? 'RED', + scope: 'global', + linkedClipId: null, + }); + } + } + + const timeline = createTimeline({ + id: 'tl', + name: timelineName, + fps, + duration: toFrame(Math.max(1, 86400)), + startTimecode: '00:00:00:00' as import('../types/frame').Timecode, + tracks, + markers, + }); + + const state = createTimelineState({ + timeline, + assetRegistry: assetRegistry as AssetRegistry, + }); + + const violations = checkInvariants(state); + if (violations.length > 0) { + throw new SerializationError('OTIO import produced invalid state', violations); + } + return state; +} diff --git a/packages/core/src/engine/playback-engine.ts b/packages/core/src/engine/playback-engine.ts new file mode 100644 index 0000000..d574f66 --- /dev/null +++ b/packages/core/src/engine/playback-engine.ts @@ -0,0 +1,195 @@ +/** + * PlaybackEngine — Phase 6 Step 2 + * + * Orchestrates PlayheadController + pipeline contracts. + * Host app instantiates this with its PipelineConfig. + */ + +import type { TimelineFrame } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { PlaybackRate, PlaybackQuality, LoopRegion } from '../types/playhead'; +import type { PlayheadListener } from '../types/playhead'; +import type { + PipelineConfig, + CompositeRequest, + CompositeResult, + VideoFrameRequest, +} from '../types/pipeline'; +import type { Clock } from './clock'; +import { nodeClock } from './clock'; +import { toFrame } from '../types/frame'; +import { PlayheadController } from './playhead-controller'; +import { + resolveFrame, + findNextClipBoundary, + findPrevClipBoundary, + findNextMarker, + findPrevMarker, + getMarkerAnchor, +} from './frame-resolver'; +import { TrackIndex } from './track-index'; +import { SnapIndexManager } from './snap-index-manager'; + +export class PlaybackEngine { + private controller: PlayheadController; + private pipeline: PipelineConfig; + private state: TimelineState; + private dimensions: { width: number; height: number }; + private trackIndex: TrackIndex = new TrackIndex(); + private snapManager: SnapIndexManager = new SnapIndexManager(); + private unsubscribe: (() => void) | null = null; + + constructor( + state: TimelineState, + pipeline: PipelineConfig, + dimensions: { width: number; height: number }, + clock?: Clock, + ) { + this.state = state; + this.pipeline = pipeline; + this.dimensions = dimensions; + this.trackIndex.build(state); + this.snapManager.rebuildSync(state); + const durationFrames = (state.timeline.duration as number) ?? 0; + const fps = (state.timeline.fps as number) || 30; + this.controller = new PlayheadController( + { durationFrames, fps }, + clock ?? nodeClock, + ); + } + + updateState(state: TimelineState): void { + this.state = state; + this.trackIndex.build(state); + this.snapManager.scheduleRebuild(state); + const durationFrames = (state.timeline.duration as number) ?? 0; + this.controller.setDuration(durationFrames); + } + + play(): void { + this.controller.play(); + } + + pause(): void { + this.controller.pause(); + } + + seekTo(frame: TimelineFrame): void { + this.controller.seekTo(frame); + } + + seekToNextClipBoundary(): void { + const next = findNextClipBoundary( + this.state, + this.controller.getState().currentFrame, + ); + if (next !== null) this.controller.seekTo(next); + } + + seekToPrevClipBoundary(): void { + const prev = findPrevClipBoundary( + this.state, + this.controller.getState().currentFrame, + ); + if (prev !== null) this.controller.seekTo(prev); + } + + seekToNextMarker(): void { + const marker = findNextMarker( + this.state, + this.controller.getState().currentFrame, + ); + if (marker !== null) this.controller.seekTo(getMarkerAnchor(marker)); + } + + seekToPrevMarker(): void { + const marker = findPrevMarker( + this.state, + this.controller.getState().currentFrame, + ); + if (marker !== null) this.controller.seekTo(getMarkerAnchor(marker)); + } + + seekToStart(): void { + this.controller.seekTo(toFrame(0)); + } + + seekToEnd(): void { + const duration = this.state.timeline.duration as number; + this.controller.seekTo(toFrame(Math.max(0, duration - 1))); + } + + setPlaybackRate(rate: PlaybackRate): void { + this.controller.setPlaybackRate(rate); + } + + setQuality(quality: PlaybackQuality): void { + this.controller.setQuality(quality); + } + + setLoopRegion(region: LoopRegion | null): void { + this.controller.setLoopRegion(region); + } + + setPreroll(frames: number): void { + this.controller.setPreroll(frames); + } + + setPostroll(frames: number): void { + this.controller.setPostroll(frames); + } + + getState() { + return this.controller.getState(); + } + + getCurrentTimelineState(): TimelineState { + return this.state; + } + + on(listener: PlayheadListener): () => void { + return this.controller.on(listener); + } + + async renderFrame(timelineFrame: TimelineFrame): Promise { + const resolved = resolveFrame( + this.state, + timelineFrame, + this.controller.getState().quality, + this.dimensions, + this.trackIndex, + ); + const decoded = await Promise.all( + resolved.layers.map((layer) => { + const req: VideoFrameRequest = { + clipId: layer.clipId, + mediaFrame: layer.mediaFrame, + quality: resolved.quality, + }; + return this.pipeline.videoDecoder(req); + }), + ); + const layers = resolved.layers.map((layer, i) => ({ + clipId: layer.clipId, + trackId: layer.trackId, + trackIndex: layer.trackIndex, + frame: decoded[i]!, + transform: layer.transform, + opacity: layer.opacity, + blendMode: layer.blendMode, + effects: layer.effects, + })); + const request: CompositeRequest = { + timelineFrame: resolved.timelineFrame, + layers, + width: resolved.width, + height: resolved.height, + quality: resolved.quality, + }; + return this.pipeline.compositor(request); + } + + destroy(): void { + this.controller.destroy(); + } +} diff --git a/packages/core/src/engine/playhead-controller.ts b/packages/core/src/engine/playhead-controller.ts new file mode 100644 index 0000000..1393e56 --- /dev/null +++ b/packages/core/src/engine/playhead-controller.ts @@ -0,0 +1,226 @@ +/** + * PlayheadController — Phase 6 Step 1 + * + * Manages playback position only. Decoupled from Dispatcher and TimelineState. + * Never calls dispatch(). Uses clock abstraction for testability. + */ + +import { toFrame } from '../types/frame'; +import type { TimelineFrame } from '../types/frame'; +import type { + PlayheadState, + PlaybackRate, + PlaybackQuality, + PlayheadEventType, + PlayheadEvent, + PlayheadListener, + PlayheadUnsubscribe, + LoopRegion, +} from '../types/playhead'; +import type { Clock } from './clock'; +import { nodeClock } from './clock'; + +export class PlayheadController { + private state: PlayheadState; + private listeners: Set = new Set(); + private rafId: number | null = null; + private lastTimestamp: number | null = null; + private frameAccum = 0; + private clock: Clock; + + constructor( + initialState: Pick, + clock: Clock = nodeClock, + ) { + this.clock = clock; + this.state = { + currentFrame: toFrame(0), + isPlaying: false, + playbackRate: 1.0, + quality: 'full', + durationFrames: initialState.durationFrames, + fps: initialState.fps, + loopRegion: null, + prerollFrames: 0, + postrollFrames: 0, + }; + } + + getState(): PlayheadState { + return this.state; + } + + play(): void { + if (this.state.isPlaying) return; + this.lastTimestamp = null; + if ( + this.state.loopRegion !== null && + this.state.prerollFrames > 0 + ) { + const start = this.state.loopRegion.startFrame as number; + const seekFrame = Math.max(0, start - this.state.prerollFrames); + this.state = { ...this.state, currentFrame: toFrame(seekFrame) }; + } + this.state = { ...this.state, isPlaying: true }; + this.scheduleFrame(); + this.emit('play', this.state.currentFrame); + } + + pause(): void { + if (!this.state.isPlaying) return; + this.state = { ...this.state, isPlaying: false }; + if (this.rafId !== null) { + this.clock.cancelFrame(this.rafId); + this.rafId = null; + } + this.lastTimestamp = null; + this.emit('pause', this.state.currentFrame); + } + + seekTo(frame: TimelineFrame): void { + const n = Math.max(0, Math.min(this.state.durationFrames - 1, frame as number)); + const clamped = toFrame(n); + this.state = { ...this.state, currentFrame: clamped }; + this.emit('seek', clamped); + } + + setPlaybackRate(rate: PlaybackRate): void { + this.lastTimestamp = null; + this.state = { ...this.state, playbackRate: rate }; + this.emit('state', this.state.currentFrame); + } + + setQuality(quality: PlaybackQuality): void { + this.state = { ...this.state, quality }; + this.emit('state', this.state.currentFrame); + } + + setDuration(durationFrames: number): void { + let currentFrame = this.state.currentFrame; + const cur = currentFrame as number; + if (cur >= durationFrames) { + currentFrame = toFrame(Math.max(0, durationFrames - 1)); + } + this.state = { + ...this.state, + durationFrames, + currentFrame, + }; + } + + setLoopRegion(region: LoopRegion | null): void { + if (region !== null) { + const start = region.startFrame as number; + const end = region.endFrame as number; + if (start >= end) throw new Error('LoopRegion startFrame must be < endFrame'); + } + this.state = { ...this.state, loopRegion: region }; + this.emit('state', this.state.currentFrame); + } + + setPreroll(frames: number): void { + if (frames < 0) throw new Error('prerollFrames must be >= 0'); + this.state = { ...this.state, prerollFrames: frames }; + this.emit('state', this.state.currentFrame); + } + + setPostroll(frames: number): void { + if (frames < 0) throw new Error('postrollFrames must be >= 0'); + this.state = { ...this.state, postrollFrames: frames }; + this.emit('state', this.state.currentFrame); + } + + private scheduleFrame(): void { + this.rafId = this.clock.requestFrame(this.onFrame.bind(this)); + } + + private onFrame(timestamp: number): void { + if (!this.state.isPlaying) return; + + if (this.lastTimestamp === null) { + this.lastTimestamp = timestamp; + this.scheduleFrame(); + return; + } + + const elapsed = timestamp - this.lastTimestamp; + this.lastTimestamp = timestamp; + + const frameAdvance = (elapsed * (this.state.fps * this.state.playbackRate)) / 1000; + this.frameAccum += frameAdvance; + + const wholeFrames = Math.floor(Math.abs(this.frameAccum)); + if (wholeFrames === 0) { + this.scheduleFrame(); + return; + } + + let advanceBy = wholeFrames; + if (wholeFrames > 2) { + this.emit('frame-dropped', this.state.currentFrame, { dropped: wholeFrames - 1 }); + advanceBy = 1; + } + + const direction = this.state.playbackRate >= 0 ? 1 : -1; + const currentN = this.state.currentFrame as number; + let newFrame = currentN + direction * advanceBy; + this.frameAccum -= direction * advanceBy; + + if (this.state.loopRegion !== null && direction > 0) { + const region = this.state.loopRegion; + const effectiveEnd = (region.endFrame as number) + this.state.postrollFrames; + if (newFrame >= effectiveEnd) { + let wrapFrame = (region.startFrame as number) - this.state.prerollFrames; + if (wrapFrame < 0) wrapFrame = 0; + this.state = { ...this.state, currentFrame: toFrame(wrapFrame) }; + this.emit('loop-point', this.state.currentFrame); + this.scheduleFrame(); + return; + } + } + + if (newFrame >= this.state.durationFrames) { + this.state = { + ...this.state, + currentFrame: toFrame(this.state.durationFrames - 1), + isPlaying: false, + }; + this.rafId = null; + this.lastTimestamp = null; + this.frameAccum = 0; + this.emit('ended', this.state.currentFrame); + return; + } + + if (newFrame < 0) { + this.state = { + ...this.state, + currentFrame: toFrame(0), + isPlaying: false, + }; + this.rafId = null; + this.lastTimestamp = null; + this.frameAccum = 0; + this.emit('ended', this.state.currentFrame); + return; + } + + this.state = { ...this.state, currentFrame: toFrame(newFrame) }; + this.scheduleFrame(); + } + + on(listener: PlayheadListener): PlayheadUnsubscribe { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + private emit(type: PlayheadEventType, frame: TimelineFrame, data?: unknown): void { + const event: PlayheadEvent = { type, frame, data }; + this.listeners.forEach((fn) => fn(event)); + } + + destroy(): void { + if (this.state.isPlaying) this.pause(); + this.listeners.clear(); + } +} diff --git a/packages/core/src/engine/project-ops.ts b/packages/core/src/engine/project-ops.ts new file mode 100644 index 0000000..f1b2f94 --- /dev/null +++ b/packages/core/src/engine/project-ops.ts @@ -0,0 +1,123 @@ +/** + * Project operations — Phase 5 Step 5 + * + * Pure functions that transform Project immutably. + * Projects are not managed by the Dispatcher. + */ + +import type { Project, Bin, BinId, BinItem } from '../types/project'; + +function withUpdatedAt(project: Project, updatedAt: number = Date.now()): Project { + return { ...project, updatedAt }; +} + +function itemsEqual(a: BinItem, b: BinItem): boolean { + if (a.kind !== b.kind) return false; + switch (a.kind) { + case 'asset': + return a.assetId === (b as typeof a).assetId; + case 'sequence': + return a.timelineId === (b as typeof a).timelineId; + case 'bin': + return a.binId === (b as typeof a).binId; + } +} + +export function addTimeline(project: Project, state: import('../types/state').TimelineState): Project { + return withUpdatedAt({ + ...project, + timelines: [...project.timelines, state], + }); +} + +export function removeTimeline(project: Project, timelineId: string): Project { + const nextTimelines = project.timelines.filter((t) => t.timeline.id !== timelineId); + if (nextTimelines.length === project.timelines.length) return project; + return withUpdatedAt({ ...project, timelines: nextTimelines }); +} + +export function addBin(project: Project, bin: Bin): Project { + const nextBins = [...project.bins, bin]; + const nextRoot = + bin.parentId === null ? [...project.rootBinIds, bin.id] : project.rootBinIds; + return withUpdatedAt({ ...project, bins: nextBins, rootBinIds: nextRoot }); +} + +export function removeBin(project: Project, binId: BinId): Project { + // Collect all descendant bin IDs (including the root binId) + const toRemove = new Set(); + const byParent = new Map(); + for (const b of project.bins) { + const key = b.parentId; + const arr = byParent.get(key) ?? []; + arr.push(b); + byParent.set(key, arr); + } + + const stack: BinId[] = [binId]; + while (stack.length) { + const id = stack.pop()!; + if (toRemove.has(id)) continue; + toRemove.add(id); + const children = byParent.get(id) ?? []; + for (const child of children) stack.push(child.id); + } + + if (toRemove.size === 1 && !project.bins.some((b) => b.id === binId)) { + return project; + } + + const nextBins = project.bins.filter((b) => !toRemove.has(b.id)); + const nextRoot = project.rootBinIds.filter((id) => !toRemove.has(id)); + return withUpdatedAt({ ...project, bins: nextBins, rootBinIds: nextRoot }); +} + +export function addItemToBin(project: Project, binId: BinId, item: BinItem): Project { + const idx = project.bins.findIndex((b) => b.id === binId); + if (idx < 0) throw new Error(`Bin not found: ${binId}`); + + const target = project.bins[idx]!; + const updated: Bin = { ...target, items: [...target.items, item] }; + const nextBins = [...project.bins]; + nextBins[idx] = updated; + return withUpdatedAt({ ...project, bins: nextBins }); +} + +export function removeItemFromBin(project: Project, binId: BinId, item: BinItem): Project { + const idx = project.bins.findIndex((b) => b.id === binId); + if (idx < 0) throw new Error(`Bin not found: ${binId}`); + + const target = project.bins[idx]!; + const nextItems = target.items.filter((i) => !itemsEqual(i, item)); + if (nextItems.length === target.items.length) return project; + + const updated: Bin = { ...target, items: nextItems }; + const nextBins = [...project.bins]; + nextBins[idx] = updated; + return withUpdatedAt({ ...project, bins: nextBins }); +} + +export function moveItemBetweenBins( + project: Project, + fromBinId: BinId, + toBinId: BinId, + item: BinItem, +): Project { + const fromIdx = project.bins.findIndex((b) => b.id === fromBinId); + const toIdx = project.bins.findIndex((b) => b.id === toBinId); + if (fromIdx < 0) throw new Error(`Bin not found: ${fromBinId}`); + if (toIdx < 0) throw new Error(`Bin not found: ${toBinId}`); + + const fromBin = project.bins[fromIdx]!; + const toBin = project.bins[toIdx]!; + + const nextFromItems = fromBin.items.filter((i) => !itemsEqual(i, item)); + const nextToItems = [...toBin.items, item]; + + const nextBins = [...project.bins]; + nextBins[fromIdx] = { ...fromBin, items: nextFromItems }; + nextBins[toIdx] = { ...toBin, items: nextToItems }; + + return withUpdatedAt({ ...project, bins: nextBins }); +} + diff --git a/packages/core/src/engine/project-serializer.ts b/packages/core/src/engine/project-serializer.ts new file mode 100644 index 0000000..a73c52c --- /dev/null +++ b/packages/core/src/engine/project-serializer.ts @@ -0,0 +1,130 @@ +/** + * Project serialization — Phase 5 Step 5 + * + * Pure functions. No IO. Uses timeline migrate() + checkInvariants(). + */ + +import type { Project, Bin, BinId, BinItem } from '../types/project'; +import type { TimelineState } from '../types/state'; +import { CURRENT_SCHEMA_VERSION } from '../types/state'; +import { migrate } from './migrator'; +import { checkInvariants } from '../validation/invariants'; +import { SerializationError } from './serialization-error'; + +function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null; +} + +function validateBinItem(item: unknown): asserts item is BinItem { + if (!isObject(item)) throw new SerializationError('Invalid bin item'); + const kind = item.kind; + if (kind === 'asset') { + if (typeof item.assetId !== 'string') throw new SerializationError('Invalid bin item'); + return; + } + if (kind === 'sequence') { + if (typeof item.timelineId !== 'string') throw new SerializationError('Invalid bin item'); + return; + } + if (kind === 'bin') { + if (typeof item.binId !== 'string') throw new SerializationError('Invalid bin item'); + return; + } + throw new SerializationError('Invalid bin item'); +} + +function validateBin(bin: unknown): asserts bin is Bin { + if (!isObject(bin)) throw new SerializationError('Invalid bin'); + if (typeof bin.id !== 'string') throw new SerializationError('Invalid bin'); + if (typeof bin.label !== 'string') throw new SerializationError('Invalid bin'); + if (!(typeof bin.parentId === 'string' || bin.parentId === null)) throw new SerializationError('Invalid bin'); + if (!Array.isArray(bin.items)) throw new SerializationError('Invalid bin'); + for (const item of bin.items) validateBinItem(item); + if (bin.color !== undefined && typeof bin.color !== 'string') throw new SerializationError('Invalid bin'); +} + +function toPlainTimeline(state: TimelineState): unknown { + return { + schemaVersion: state.schemaVersion, + timeline: state.timeline, + assetRegistry: Object.fromEntries(state.assetRegistry), + }; +} + +export function serializeProject(project: Project): string { + // Emit stable key order for deterministic JSON output + const plain = { + id: project.id, + name: project.name, + timelines: project.timelines.map(toPlainTimeline), + bins: project.bins, + rootBinIds: project.rootBinIds, + createdAt: project.createdAt, + updatedAt: project.updatedAt, + schemaVersion: project.schemaVersion, + }; + return JSON.stringify(plain, null, 2); +} + +export function deserializeProject(raw: string): Project { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid JSON'; + throw new SerializationError(msg); + } + + if (!isObject(parsed)) throw new SerializationError('Invalid JSON structure'); + const obj = parsed as Record; + + const schemaVersion = obj.schemaVersion; + if (typeof schemaVersion !== 'number') throw new SerializationError('Missing schemaVersion'); + if (schemaVersion > CURRENT_SCHEMA_VERSION) { + throw new SerializationError(`Unknown project schema version: ${schemaVersion}`); + } + + if (!Array.isArray(obj.timelines)) throw new SerializationError('Missing timelines'); + + const timelines: TimelineState[] = []; + for (const t of obj.timelines) { + const state = migrate(t); + const violations = checkInvariants(state); + if (violations.length > 0) { + throw new SerializationError('Timeline failed invariant checks', violations); + } + timelines.push(state); + } + + const binsRaw = obj.bins; + if (binsRaw !== undefined && !Array.isArray(binsRaw)) throw new SerializationError('Invalid bins'); + const bins: Bin[] = (binsRaw ?? []) as Bin[]; + for (const b of bins) validateBin(b); + + const rootBinIdsRaw = obj.rootBinIds; + if (rootBinIdsRaw !== undefined && !Array.isArray(rootBinIdsRaw)) { + throw new SerializationError('Invalid rootBinIds'); + } + const rootBinIds = (rootBinIdsRaw ?? []) as BinId[]; + for (const id of rootBinIds) { + if (typeof id !== 'string') throw new SerializationError('Invalid rootBinIds'); + } + + // Minimal required fields; others are pass-through + if (typeof obj.id !== 'string') throw new SerializationError('Invalid project id'); + if (typeof obj.name !== 'string') throw new SerializationError('Invalid project name'); + if (typeof obj.createdAt !== 'number') throw new SerializationError('Invalid createdAt'); + if (typeof obj.updatedAt !== 'number') throw new SerializationError('Invalid updatedAt'); + + return { + id: obj.id as Project['id'], + name: obj.name, + schemaVersion, + createdAt: obj.createdAt, + updatedAt: obj.updatedAt, + timelines, + bins, + rootBinIds, + }; +} + diff --git a/packages/core/src/engine/serialization-error.ts b/packages/core/src/engine/serialization-error.ts new file mode 100644 index 0000000..37a71a9 --- /dev/null +++ b/packages/core/src/engine/serialization-error.ts @@ -0,0 +1,17 @@ +/** + * SerializationError — Phase 5 Step 1 + * + * Thrown when deserialization or migration fails. + */ + +import type { InvariantViolation } from '../types/operations'; + +export class SerializationError extends Error { + constructor( + message: string, + public readonly violations?: InvariantViolation[], + ) { + super(message); + this.name = 'SerializationError'; + } +} diff --git a/packages/core/src/engine/serializer.ts b/packages/core/src/engine/serializer.ts new file mode 100644 index 0000000..0103e0b --- /dev/null +++ b/packages/core/src/engine/serializer.ts @@ -0,0 +1,128 @@ +/** + * Timeline serialization — Phase 5 Step 1 + * + * Pure functions. No IO. No DOM. No external deps. + * serializeTimeline / deserializeTimeline round-trip TimelineState. + */ + +import type { TimelineState } from '../types/state'; +import type { FileAsset, Asset, AssetId } from '../types/asset'; +import type { ClipId } from '../types/clip'; +import { checkInvariants } from '../validation/invariants'; +import { migrate } from './migrator'; +import { SerializationError } from './serialization-error'; + +export { SerializationError } from './serialization-error'; + +// --------------------------------------------------------------------------- +// serializeTimeline +// --------------------------------------------------------------------------- + +/** + * Serialize state to JSON string. + * Converts assetRegistry Map to plain object for JSON compatibility. + */ +export function serializeTimeline(state: TimelineState): string { + const plain = { + schemaVersion: state.schemaVersion, + timeline: state.timeline, + assetRegistry: Object.fromEntries(state.assetRegistry), + }; + return JSON.stringify(plain, null, 2); +} + +// --------------------------------------------------------------------------- +// deserializeTimeline +// --------------------------------------------------------------------------- + +/** + * Parse JSON string, migrate to current schema, validate invariants. + * Throws SerializationError on invalid JSON, missing schemaVersion, + * unknown version, or invariant violations. + */ +export function deserializeTimeline(raw: string): TimelineState { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid JSON'; + throw new SerializationError(msg); + } + + let state: TimelineState; + try { + state = migrate(parsed); + } catch (e) { + if (e instanceof SerializationError) throw e; + throw new SerializationError(e instanceof Error ? e.message : 'Migration failed'); + } + + const violations = checkInvariants(state); + if (violations.length > 0) { + throw new SerializationError('State failed invariant checks', violations); + } + + return state; +} + +// --------------------------------------------------------------------------- +// Asset path remapper +// --------------------------------------------------------------------------- + +export type AssetRemapCallback = (asset: FileAsset) => FileAsset; + +/** + * Walk assetRegistry; for each FileAsset replace with remap(asset). + * GeneratorAssets unchanged. Returns new state (immutable). + */ +export function remapAssetPaths( + state: TimelineState, + remap: AssetRemapCallback, +): TimelineState { + const next = new Map(); + for (const [id, asset] of state.assetRegistry) { + if (asset.kind === 'file') { + next.set(id, remap(asset)); + } else { + next.set(id, asset); + } + } + return { ...state, assetRegistry: next }; +} + +// --------------------------------------------------------------------------- +// Offline asset detection +// --------------------------------------------------------------------------- + +export type OfflineAsset = { + readonly assetId: AssetId; + readonly path: string; + readonly clipIds: readonly ClipId[]; +}; + +/** + * For each FileAsset where isOnline(asset) === false, collect clip IDs + * that reference it. Host provides isOnline; core does not do filesystem checks. + */ +export function findOfflineAssets( + state: TimelineState, + isOnline: (asset: FileAsset) => boolean, +): OfflineAsset[] { + const result: OfflineAsset[] = []; + for (const asset of state.assetRegistry.values()) { + if (asset.kind !== 'file') continue; + if (isOnline(asset)) continue; + const clipIds: ClipId[] = []; + for (const track of state.timeline.tracks) { + for (const clip of track.clips) { + if (clip.assetId === asset.id) clipIds.push(clip.id); + } + } + result.push({ + assetId: asset.id, + path: asset.filePath, + clipIds, + }); + } + return result; +} diff --git a/packages/core/src/engine/snap-index-manager.ts b/packages/core/src/engine/snap-index-manager.ts new file mode 100644 index 0000000..71eaee1 --- /dev/null +++ b/packages/core/src/engine/snap-index-manager.ts @@ -0,0 +1,44 @@ +/** + * SnapIndexManager — Phase 7 Step 2 + * + * Debounces SnapIndex rebuilds using queueMicrotask. + * Multiple scheduleRebuild() calls in one turn → single rebuild. + */ + +import { toFrame } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { SnapIndex } from '../snap-index'; +import { buildSnapIndex } from '../snap-index'; + +export class SnapIndexManager { + private index: SnapIndex | null = null; + private state: TimelineState | null = null; + private pending = false; + + getIndex(): SnapIndex | null { + return this.index; + } + + scheduleRebuild(state: TimelineState): void { + this.state = state; + if (this.pending) return; + this.pending = true; + queueMicrotask(() => { + this.pending = false; + const s = this.state; + if (s !== null) { + this.index = buildSnapIndex(s, toFrame(0)); + } + }); + } + + rebuildSync(state: TimelineState): void { + this.index = buildSnapIndex(state, toFrame(0)); + this.state = state; + this.pending = false; + } + + get isPending(): boolean { + return this.pending; + } +} diff --git a/packages/core/src/engine/subtitle-import.ts b/packages/core/src/engine/subtitle-import.ts new file mode 100644 index 0000000..e259e81 --- /dev/null +++ b/packages/core/src/engine/subtitle-import.ts @@ -0,0 +1,241 @@ +/** + * SUBTITLE IMPORT — Phase 3 Step 3 + * + * Pure functions for parsing SRT/VTT into Caption[]. + * No file IO. No DOM. No external deps. + */ + +import { toFrame } from '../types/frame'; +import type { TimelineFrame } from '../types/frame'; +import { toCaptionId } from '../types/caption'; +import type { Caption, CaptionStyle } from '../types/caption'; +import type { TrackId } from '../types/track'; +import type { OperationPrimitive } from '../types/operations'; + +// --------------------------------------------------------------------------- +// Default style (exported) +// --------------------------------------------------------------------------- + +export const defaultCaptionStyle: CaptionStyle = { + fontFamily: 'sans-serif', + fontSize: 16, + color: '#ffffff', + backgroundColor: 'rgba(0,0,0,0.75)', + hAlign: 'center', + vAlign: 'bottom', +}; + +// --------------------------------------------------------------------------- +// Options types +// --------------------------------------------------------------------------- + +export type SRTParseOptions = { + language?: string; + burnIn?: boolean; + defaultStyle?: Partial; +}; + +export type VTTParseOptions = SRTParseOptions; + +// --------------------------------------------------------------------------- +// timecodeToFrame (internal) +// --------------------------------------------------------------------------- + +/** + * Accepts SRT "HH:MM:SS,mmm" and VTT "HH:MM:SS.mmm" or "MM:SS.mmm". + * Returns frame index at given fps. + */ +function timecodeToFrame(tc: string, fps: number): TimelineFrame { + const normalized = tc.trim().replace(',', '.'); + const parts = normalized.split(':'); + let h = 0; + let m: number; + let s: number; + let ms: number; + if (parts.length === 3) { + h = parseInt(parts[0]!, 10) || 0; + m = parseInt(parts[1]!, 10) || 0; + const sMs = parts[2]!.split('.'); + s = parseInt(sMs[0]!, 10) || 0; + ms = parseInt(sMs[1] ?? '0', 10) || 0; + } else if (parts.length === 2) { + m = parseInt(parts[0]!, 10) || 0; + const sMs = parts[1]!.split('.'); + s = parseInt(sMs[0]!, 10) || 0; + ms = parseInt(sMs[1] ?? '0', 10) || 0; + } else { + return toFrame(0); + } + const totalSeconds = h * 3600 + m * 60 + s + ms / 1000; + return toFrame(Math.round(totalSeconds * fps)); +} + +// --------------------------------------------------------------------------- +// SRT/VTT tag stripping +// --------------------------------------------------------------------------- + +/** Strip SRT/VTT formatting tags, keep inner text. */ +function stripTags(text: string): string { + return text + .replace(/\s*<\/b>/gi, '') + .replace(/\s*<\/i>/gi, '') + .replace(/\s*<\/u>/gi, '') + .replace(//gi, '') + .replace(/<\/b>/gi, '') + .replace(//gi, '') + .replace(/<\/i>/gi, '') + .replace(//gi, '') + .replace(/<\/u>/gi, '') + .replace(/]*>/gi, '') + .replace(/<\/font>/gi, '') + .replace(/\s*<\/ruby>/gi, '') + .replace(/\s*<\/rt>/gi, '') + .replace(//gi, '') + .replace(/<\/ruby>/gi, '') + .replace(//gi, '') + .replace(/<\/rt>/gi, '') + .replace(/]*>/gi, '') + .replace(/<\/lang>/gi, '') + .replace(/<[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}>/g, ''); // VTT timestamp tags +} + +// --------------------------------------------------------------------------- +// Timecode line regexes +// --------------------------------------------------------------------------- + +// SRT / VTT full: HH:MM:SS,mmm --> HH:MM:SS.mmm +const FULL_TIMECODE_RE = /^(\d{1,2}:\d{1,2}:\d{1,2}[,.]\d{3})\s*-->\s*(\d{1,2}:\d{1,2}:\d{1,2}[,.]\d{3})/; +// VTT short: MM:SS.mmm --> MM:SS.mmm (hours optional) +const SHORT_TIMECODE_RE = /^(\d{1,2}:\d{1,2}[,.]\d{3})\s*-->\s*(\d{1,2}:\d{1,2}[,.]\d{3})/; + +function matchTimecodeLine(line: string): { start: string; end: string } | null { + const full = line.match(FULL_TIMECODE_RE); + if (full) return { start: full[1]!, end: full[2]! }; + const short = line.match(SHORT_TIMECODE_RE); + if (short) return { start: short[1]!, end: short[2]! }; + return null; +} + +// --------------------------------------------------------------------------- +// parseSRT +// --------------------------------------------------------------------------- + +export function parseSRT( + raw: string, + fps: number, + options?: SRTParseOptions, +): Caption[] { + const language = options?.language ?? 'en-US'; + const burnIn = options?.burnIn ?? false; + const style: CaptionStyle = { ...defaultCaptionStyle, ...options?.defaultStyle }; + + const blocks = raw.split(/\r?\n\r?\n/).filter((b) => b.trim().length > 0); + const captions: Caption[] = []; + + for (const block of blocks) { + const lines = block.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + if (lines.length < 2) continue; + + const indexStr = lines[0]!; + const timecodeLine = lines[1]!; + const matched = matchTimecodeLine(timecodeLine); + if (!matched) continue; + + const startTc = matched.start.replace(',', '.'); + const endTc = matched.end.replace(',', '.'); + const textLines = lines.slice(2); + const text = stripTags(textLines.join('\n')); + + const index = indexStr.replace(/\D/g, '') || indexStr; + captions.push({ + id: toCaptionId(`srt-${index}`), + text, + startFrame: timecodeToFrame(startTc, fps), + endFrame: timecodeToFrame(endTc, fps), + language, + style, + burnIn, + }); + } + + return captions; +} + +// --------------------------------------------------------------------------- +// parseVTT +// --------------------------------------------------------------------------- + +export function parseVTT( + raw: string, + fps: number, + options?: VTTParseOptions, +): Caption[] { + const lines = raw.split(/\r?\n/); + if (lines.length === 0 || !lines[0]!.trim().startsWith('WEBVTT')) { + return []; + } + + const language = options?.language ?? 'en-US'; + const burnIn = options?.burnIn ?? false; + const style: CaptionStyle = { ...defaultCaptionStyle, ...options?.defaultStyle }; + + const captions: Caption[] = []; + let cueIndex = 0; + const blocks = raw.split(/\r?\n\r?\n/); + + for (const block of blocks) { + const blockLines = block.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + if (blockLines.length === 0) continue; + + const first = blockLines[0]!; + if (first.startsWith('NOTE') || first.startsWith('STYLE') || first.startsWith('REGION')) continue; + + let timecodeLine: string; + let textLines: string[]; + if (first.includes('-->')) { + timecodeLine = first; + textLines = blockLines.slice(1); + } else if (blockLines.length >= 2 && blockLines[1]!.includes('-->')) { + timecodeLine = blockLines[1]!; + textLines = blockLines.slice(2); + } else { + continue; + } + + const matched = matchTimecodeLine(timecodeLine); + if (!matched) continue; + + cueIndex++; + const startTc = matched.start.replace(',', '.'); + const endTc = matched.end.replace(',', '.'); + + const text = stripTags(textLines.join('\n')); + + captions.push({ + id: toCaptionId(`vtt-${cueIndex}`), + text, + startFrame: timecodeToFrame(startTc, fps), + endFrame: timecodeToFrame(endTc, fps), + language, + style, + burnIn, + }); + } + + return captions; +} + +// --------------------------------------------------------------------------- +// subtitleImportToOps +// --------------------------------------------------------------------------- + +export function subtitleImportToOps( + captions: Caption[], + trackId: TrackId, +): OperationPrimitive[] { + return captions.map((caption) => ({ + type: 'ADD_CAPTION', + caption, + trackId, + })); +} diff --git a/packages/core/src/engine/thumbnail-cache.ts b/packages/core/src/engine/thumbnail-cache.ts new file mode 100644 index 0000000..08e4090 --- /dev/null +++ b/packages/core/src/engine/thumbnail-cache.ts @@ -0,0 +1,74 @@ +/** + * ThumbnailCache — Phase 7 Step 4 + * + * In-memory LRU cache for thumbnail results. + * No Worker — sits between pipeline and host's thumbnail provider. + */ + +import type { ThumbnailRequest, ThumbnailResult } from '../types/pipeline'; +import type { ClipId } from '../types/clip'; + +export class ThumbnailCache { + private cache: Map = new Map(); + private order: string[] = []; + private maxSize: number; + + constructor(maxSize: number = 200) { + this.maxSize = maxSize; + } + + private key(request: ThumbnailRequest): string { + return `${request.clipId}:${request.mediaFrame}:${request.width}x${request.height}`; + } + + get(request: ThumbnailRequest): ThumbnailResult | null { + const k = this.key(request); + if (!this.cache.has(k)) return null; + const idx = this.order.indexOf(k); + if (idx >= 0) { + this.order.splice(idx, 1); + this.order.push(k); + } + return this.cache.get(k) ?? null; + } + + set(request: ThumbnailRequest, result: ThumbnailResult): void { + const k = this.key(request); + if (this.cache.has(k)) { + this.cache.set(k, result); + const idx = this.order.indexOf(k); + if (idx >= 0) { + this.order.splice(idx, 1); + this.order.push(k); + } + return; + } + this.cache.set(k, result); + this.order.push(k); + if (this.cache.size > this.maxSize && this.order.length > 0) { + const oldest = this.order.shift()!; + this.cache.delete(oldest); + } + } + + has(request: ThumbnailRequest): boolean { + return this.cache.has(this.key(request)); + } + + invalidateClip(clipId: ClipId): void { + const prefix = `${clipId}:`; + for (const k of this.order) { + if (k.startsWith(prefix)) this.cache.delete(k); + } + this.order = this.order.filter((k) => !k.startsWith(prefix)); + } + + clear(): void { + this.cache.clear(); + this.order = []; + } + + get size(): number { + return this.cache.size; + } +} diff --git a/packages/core/src/engine/thumbnail-queue.ts b/packages/core/src/engine/thumbnail-queue.ts new file mode 100644 index 0000000..dbbe0ed --- /dev/null +++ b/packages/core/src/engine/thumbnail-queue.ts @@ -0,0 +1,98 @@ +/** + * ThumbnailQueue — Phase 7 Step 4 + * + * Priority queue for thumbnail requests. + * Visible clips get 'high', off-screen get 'low'. + */ + +import type { ThumbnailRequest } from '../types/pipeline'; +import type { TimelineFrame } from '../types/frame'; +import type { ClipId } from '../types/clip'; +import type { ThumbnailPriority, ThumbnailQueueEntry } from '../types/worker-contracts'; + +function priorityValue(p: ThumbnailPriority): number { + switch (p) { + case 'high': + return 2; + case 'normal': + return 1; + case 'low': + return 0; + default: + return 1; + } +} + +function sameRequest(a: ThumbnailRequest, b: ThumbnailRequest): boolean { + return a.clipId === b.clipId && (a.mediaFrame as number) === (b.mediaFrame as number); +} + +export class ThumbnailQueue { + private entries: ThumbnailQueueEntry[] = []; + + enqueue( + request: ThumbnailRequest, + priority: ThumbnailPriority = 'normal', + ): void { + const existing = this.entries.find((e) => sameRequest(e.request, request)); + if (existing) { + if (priorityValue(priority) > priorityValue(existing.priority)) { + this.entries = this.entries.map((e) => + sameRequest(e.request, request) ? { ...e, priority } : e, + ); + } + return; + } + this.entries.push({ + request, + priority, + addedAt: Date.now(), + }); + } + + dequeue(): ThumbnailQueueEntry | null { + if (this.entries.length === 0) return null; + this.entries.sort((a, b) => { + const p = priorityValue(b.priority) - priorityValue(a.priority); + if (p !== 0) return p; + return a.addedAt - b.addedAt; + }); + return this.entries.shift() ?? null; + } + + cancel(clipId: ClipId): void { + this.entries = this.entries.filter((e) => e.request.clipId !== clipId); + } + + setPriority( + clipId: ClipId, + mediaFrame: TimelineFrame, + priority: ThumbnailPriority, + ): void { + const m = mediaFrame as number; + this.entries = this.entries.map((e) => { + if (e.request.clipId === clipId && (e.request.mediaFrame as number) === m) { + return { ...e, priority }; + } + return e; + }); + } + + get length(): number { + return this.entries.length; + } + + peek(): ThumbnailQueueEntry | null { + if (this.entries.length === 0) return null; + const sorted = [...this.entries].sort((a, b) => { + const p = priorityValue(b.priority) - priorityValue(a.priority); + if (p !== 0) return p; + return a.addedAt - b.addedAt; + }); + return sorted[0] ?? null; + } + + clear(): void { + this.entries = []; + } +} diff --git a/packages/core/src/engine/timeline-engine.ts b/packages/core/src/engine/timeline-engine.ts index a155175..0f980f1 100644 --- a/packages/core/src/engine/timeline-engine.ts +++ b/packages/core/src/engine/timeline-engine.ts @@ -44,18 +44,41 @@ import { TimelineState } from '../types/state'; import { Clip } from '../types/clip'; import { Track } from '../types/track'; import { Asset } from '../types/asset'; -import { Frame } from '../types/frame'; -import { TimelineMarker, ClipMarker, RegionMarker, WorkArea } from '../types/marker'; -import { HistoryState, createHistory, undo as undoHistory, redo as redoHistory, canUndo as canUndoHistory, canRedo as canRedoHistory, getCurrentState } from './history'; -import { dispatch, DispatchResult } from './dispatcher'; +import { TimelineFrame } from '../types/frame'; +import { HistoryState, createHistory, pushHistory, undo as undoHistory, redo as redoHistory, canUndo as canUndoHistory, canRedo as canRedoHistory, getCurrentState } from './history'; +import { dispatch } from './dispatcher'; +import type { DispatchResult, OperationPrimitive } from '../types/operations'; import * as ClipOps from '../operations/clip-operations'; import * as TrackOps from '../operations/track-operations'; import * as TimelineOps from '../operations/timeline-operations'; import * as RippleOps from '../operations/ripple'; -import * as MarkerOps from '../operations/marker-operations'; import * as AssetRegistry from '../systems/asset-registry'; import * as Queries from '../systems/queries'; +// --------------------------------------------------------------------------- +// Legacy shim: wraps old callback-style operations into the new dispatch API. +// This is a TEMPORARY compatibility bridge. Phase 1 replaces this class +// entirely with direct dispatch(state, transaction) calls from the adapter. +// --------------------------------------------------------------------------- +type LegacyOperation = (state: TimelineState) => TimelineState; + +function legacyDispatch( + history: HistoryState, + operation: LegacyOperation +): { accepted: boolean; history?: HistoryState; errors?: { code: string; message: string }[] } { + const currentState = getCurrentState(history); + let newState: TimelineState; + try { + newState = operation(currentState); + } catch (err) { + return { accepted: false, errors: [{ code: 'OPERATION_ERROR', message: String(err) }] }; + } + // pushHistory is already imported at the top of this file + const newHistory = pushHistory(history, newState); + return { accepted: true, history: newHistory }; +} + + /** * TimelineEngine - The main timeline editing engine * @@ -135,11 +158,11 @@ export class TimelineEngine { * @param asset - Asset to register * @returns Dispatch result */ - registerAsset(asset: Asset): DispatchResult { - const result = dispatch(this.history, (state) => + registerAsset(asset: Asset): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => AssetRegistry.registerAsset(state, asset) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -165,11 +188,11 @@ export class TimelineEngine { * @param clip - Clip to add * @returns Dispatch result */ - addClip(trackId: string, clip: Clip): DispatchResult { - const result = dispatch(this.history, (state) => + addClip(trackId: string, clip: Clip): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => ClipOps.addClip(state, trackId, clip) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -182,11 +205,11 @@ export class TimelineEngine { * @param clipId - ID of the clip to remove * @returns Dispatch result */ - removeClip(clipId: string): DispatchResult { - const result = dispatch(this.history, (state) => + removeClip(clipId: string): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => ClipOps.removeClip(state, clipId) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -200,11 +223,11 @@ export class TimelineEngine { * @param newStart - New timeline start frame * @returns Dispatch result */ - moveClip(clipId: string, newStart: Frame): DispatchResult { - const result = dispatch(this.history, (state) => + moveClip(clipId: string, newStart: TimelineFrame): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => ClipOps.moveClip(state, clipId, newStart) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -219,11 +242,11 @@ export class TimelineEngine { * @param newEnd - New timeline end frame * @returns Dispatch result */ - resizeClip(clipId: string, newStart: Frame, newEnd: Frame): DispatchResult { - const result = dispatch(this.history, (state) => + resizeClip(clipId: string, newStart: TimelineFrame, newEnd: TimelineFrame): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => ClipOps.resizeClip(state, clipId, newStart, newEnd) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -238,11 +261,11 @@ export class TimelineEngine { * @param newMediaOut - New media out frame * @returns Dispatch result */ - trimClip(clipId: string, newMediaIn: Frame, newMediaOut: Frame): DispatchResult { - const result = dispatch(this.history, (state) => + trimClip(clipId: string, newMediaIn: TimelineFrame, newMediaOut: TimelineFrame): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => ClipOps.trimClip(state, clipId, newMediaIn, newMediaOut) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -256,11 +279,11 @@ export class TimelineEngine { * @param targetTrackId - ID of the target track * @returns Dispatch result */ - moveClipToTrack(clipId: string, targetTrackId: string): DispatchResult { - const result = dispatch(this.history, (state) => + moveClipToTrack(clipId: string, targetTrackId: string): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => ClipOps.moveClipToTrack(state, clipId, targetTrackId) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -275,11 +298,11 @@ export class TimelineEngine { * @param track - Track to add * @returns Dispatch result */ - addTrack(track: Track): DispatchResult { - const result = dispatch(this.history, (state) => + addTrack(track: Track): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TrackOps.addTrack(state, track) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -292,11 +315,11 @@ export class TimelineEngine { * @param trackId - ID of the track to remove * @returns Dispatch result */ - removeTrack(trackId: string): DispatchResult { - const result = dispatch(this.history, (state) => + removeTrack(trackId: string): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TrackOps.removeTrack(state, trackId) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -310,11 +333,11 @@ export class TimelineEngine { * @param newIndex - New index position * @returns Dispatch result */ - moveTrack(trackId: string, newIndex: number): DispatchResult { - const result = dispatch(this.history, (state) => + moveTrack(trackId: string, newIndex: number): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TrackOps.moveTrack(state, trackId, newIndex) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -327,11 +350,11 @@ export class TimelineEngine { * @param trackId - ID of the track * @returns Dispatch result */ - toggleTrackMute(trackId: string): DispatchResult { - const result = dispatch(this.history, (state) => + toggleTrackMute(trackId: string): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TrackOps.toggleTrackMute(state, trackId) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -344,11 +367,11 @@ export class TimelineEngine { * @param trackId - ID of the track * @returns Dispatch result */ - toggleTrackLock(trackId: string): DispatchResult { - const result = dispatch(this.history, (state) => + toggleTrackLock(trackId: string): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TrackOps.toggleTrackLock(state, trackId) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -361,11 +384,11 @@ export class TimelineEngine { * @param trackId - ID of the track * @returns Dispatch result */ - toggleTrackSolo(trackId: string): DispatchResult { - const result = dispatch(this.history, (state) => + toggleTrackSolo(trackId: string): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TrackOps.toggleTrackSolo(state, trackId) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -379,11 +402,11 @@ export class TimelineEngine { * @param height - New height in pixels * @returns Dispatch result */ - setTrackHeight(trackId: string, height: number): DispatchResult { - const result = dispatch(this.history, (state) => + setTrackHeight(trackId: string, height: number): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TrackOps.setTrackHeight(state, trackId, height) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -398,11 +421,11 @@ export class TimelineEngine { * @param duration - New duration in frames * @returns Dispatch result */ - setTimelineDuration(duration: Frame): DispatchResult { - const result = dispatch(this.history, (state) => + setTimelineDuration(duration: TimelineFrame): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TimelineOps.setTimelineDuration(state, duration) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -415,11 +438,11 @@ export class TimelineEngine { * @param name - New timeline name * @returns Dispatch result */ - setTimelineName(name: string): DispatchResult { - const result = dispatch(this.history, (state) => + setTimelineName(name: string): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => TimelineOps.setTimelineName(state, name) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -512,8 +535,8 @@ export class TimelineEngine { * @param frame - Frame to check * @returns Array of clips at that frame */ - getClipsAtFrame(frame: Frame): Clip[] { - return Queries.getClipsAtFrame(this.getState(), frame); + getClipsAtFrame(f: TimelineFrame): Clip[] { + return Queries.getClipsAtFrame(this.getState(), f); } /** @@ -523,7 +546,7 @@ export class TimelineEngine { * @param end - End frame * @returns Array of clips in the range */ - getClipsInRange(start: Frame, end: Frame): Clip[] { + getClipsInRange(start: TimelineFrame, end: TimelineFrame): Clip[] { return Queries.getClipsInRange(this.getState(), start, end); } @@ -541,7 +564,7 @@ export class TimelineEngine { * * @returns Array of all tracks */ - getAllTracks(): Track[] { + getAllTracks(): readonly Track[] { return Queries.getAllTracks(this.getState()); } @@ -553,11 +576,11 @@ export class TimelineEngine { * @param clipId - ID of the clip to delete * @returns Dispatch result */ - rippleDelete(clipId: string): DispatchResult { - const result = dispatch(this.history, (state) => + rippleDelete(clipId: string): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => RippleOps.rippleDelete(state, clipId) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -571,11 +594,11 @@ export class TimelineEngine { * @param newEnd - New end frame for the clip * @returns Dispatch result */ - rippleTrim(clipId: string, newEnd: Frame): DispatchResult { - const result = dispatch(this.history, (state) => + rippleTrim(clipId: string, newEnd: TimelineFrame): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => RippleOps.rippleTrim(state, clipId, newEnd) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -590,64 +613,11 @@ export class TimelineEngine { * @param atFrame - Frame to insert at * @returns Dispatch result */ - insertEdit(trackId: string, clip: Clip, atFrame: Frame): DispatchResult { - const result = dispatch(this.history, (state) => + insertEdit(trackId: string, clip: Clip, atFrame: TimelineFrame): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => RippleOps.insertEdit(state, trackId, clip, atFrame) ); - if (result.success) { - this.history = result.history; - this.notify(); - } - return result; - } - - // ===== MARKER OPERATIONS ===== - - /** - * Add a timeline marker - * - * @param marker - Timeline marker to add - * @returns Dispatch result - */ - addTimelineMarker(marker: TimelineMarker): DispatchResult { - const result = dispatch(this.history, (state) => - MarkerOps.addTimelineMarker(state, marker) - ); - if (result.success) { - this.history = result.history; - this.notify(); - } - return result; - } - - /** - * Add a clip marker - * - * @param marker - Clip marker to add - * @returns Dispatch result - */ - addClipMarker(marker: ClipMarker): DispatchResult { - const result = dispatch(this.history, (state) => - MarkerOps.addClipMarker(state, marker) - ); - if (result.success) { - this.history = result.history; - this.notify(); - } - return result; - } - - /** - * Add a region marker - * - * @param marker - Region marker to add - * @returns Dispatch result - */ - addRegionMarker(marker: RegionMarker): DispatchResult { - const result = dispatch(this.history, (state) => - MarkerOps.addRegionMarker(state, marker) - ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); } @@ -655,97 +625,56 @@ export class TimelineEngine { } /** - * Remove a marker by ID + * Ripple move - move clip and shift surrounding clips to accommodate * - * @param markerId - ID of the marker to remove - * @returns Dispatch result - */ - removeMarker(markerId: string): DispatchResult { - const result = dispatch(this.history, (state) => - MarkerOps.removeMarker(state, markerId) - ); - if (result.success) { - this.history = result.history; - this.notify(); - } - return result; - } - - /** - * Update a timeline marker + * This moves a clip to a new position while maintaining timeline continuity: + * - Closes the gap at the source position + * - Makes space at the destination position + * - All operations are atomic (single undo entry) * - * @param markerId - ID of the marker to update - * @param updates - Partial marker updates + * @param clipId - ID of the clip to move + * @param newStart - New start frame for the clip * @returns Dispatch result */ - updateTimelineMarker( - markerId: string, - updates: Partial> - ): DispatchResult { - const result = dispatch(this.history, (state) => - MarkerOps.updateTimelineMarker(state, markerId, updates) + rippleMove(clipId: string, newStart: TimelineFrame): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => + RippleOps.rippleMove(state, clipId, newStart) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); + } else if (!result.accepted && result.errors?.[0]?.code === 'OPERATION_ERROR') { + throw new Error(result.errors[0].message); } return result; } /** - * Update a region marker + * Insert move - move clip and shift destination clips right * - * @param markerId - ID of the marker to update - * @param updates - Partial marker updates - * @returns Dispatch result - */ - updateRegionMarker( - markerId: string, - updates: Partial> - ): DispatchResult { - const result = dispatch(this.history, (state) => - MarkerOps.updateRegionMarker(state, markerId, updates) - ); - if (result.success) { - this.history = result.history; - this.notify(); - } - return result; - } - - // ===== WORK AREA OPERATIONS ===== - - /** - * Set work area + * This moves a clip to a new position without closing the gap at source: + * - Leaves gap at the source position + * - Pushes all clips at destination right to make space + * - All operations are atomic (single undo entry) * - * @param start - Start frame - * @param end - End frame + * @param clipId - ID of the clip to move + * @param newStart - New start frame for the clip * @returns Dispatch result */ - setWorkArea(start: Frame, end: Frame): DispatchResult { - const result = dispatch(this.history, (state) => - MarkerOps.setWorkArea(state, { startFrame: start, endFrame: end }) + insertMove(clipId: string, newStart: TimelineFrame): { accepted: boolean; errors?: { code: string; message: string }[] } { + const result = legacyDispatch(this.history, (state) => + RippleOps.insertMove(state, clipId, newStart) ); - if (result.success) { + if (result.accepted && result.history) { this.history = result.history; this.notify(); + } else if (!result.accepted && result.errors?.[0]?.code === 'OPERATION_ERROR') { + throw new Error(result.errors[0].message); } return result; } - /** - * Clear work area - * - * @returns Dispatch result - */ - clearWorkArea(): DispatchResult { - const result = dispatch(this.history, (state) => - MarkerOps.clearWorkArea(state) - ); - if (result.success) { - this.history = result.history; - this.notify(); - } - return result; - } + // Phase 2: Marker and WorkArea operations are gated to Phase 2. + // They are intentionally omitted here to keep Phase 0 clean. } + diff --git a/packages/core/src/engine/track-index.ts b/packages/core/src/engine/track-index.ts new file mode 100644 index 0000000..aeb4b63 --- /dev/null +++ b/packages/core/src/engine/track-index.ts @@ -0,0 +1,55 @@ +/** + * TrackIndex — Phase 7 Step 1 + * + * Wraps IntervalTree per track for O(log n + k) getClipsAtFrame. + */ + +import type { Clip } from '../types/clip'; +import type { Track } from '../types/track'; +import type { TimelineState } from '../types/state'; +import { IntervalTree } from './interval-tree'; + +export type ClipEntry = { + clip: Clip; + track: Track; + trackIndex: number; +}; + +export class TrackIndex { + private tree: IntervalTree = new IntervalTree(); + private built = false; + + build(state: TimelineState): void { + const intervals: Array<{ start: number; end: number; data: ClipEntry }> = []; + const tracks = state.timeline.tracks; + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]!; + for (const clip of track.clips) { + const start = clip.timelineStart as number; + const end = clip.timelineEnd as number; + intervals.push({ + start, + end, + data: { clip, track, trackIndex: i }, + }); + } + } + this.tree.build(intervals); + this.built = true; + } + + query(frame: number): ClipEntry[] { + if (!this.built) { + throw new Error('TrackIndex not built'); + } + return this.tree.query(frame).sort((a, b) => a.trackIndex - b.trackIndex); + } + + get isBuilt(): boolean { + return this.built; + } + + invalidate(): void { + this.built = false; + } +} diff --git a/packages/core/src/engine/transaction-compressor.ts b/packages/core/src/engine/transaction-compressor.ts new file mode 100644 index 0000000..b5963cd --- /dev/null +++ b/packages/core/src/engine/transaction-compressor.ts @@ -0,0 +1,50 @@ +/** + * TransactionCompressor — Phase 7 Step 3 + * + * Decides whether a transaction should be merged into the previous + * history entry (same op type within window). + */ + +import type { Transaction } from '../types/operations'; +import type { CompressionPolicy } from '../types/compression'; +import { DEFAULT_COMPRESSION_POLICY } from '../types/compression'; +import { isCompressibleOpType } from '../types/compression'; + +export class TransactionCompressor { + private lastOpType: string | null = null; + private lastTime = 0; + private policy: CompressionPolicy; + private clock: () => number; + + constructor( + policy: CompressionPolicy = DEFAULT_COMPRESSION_POLICY, + clock: () => number = Date.now, + ) { + this.policy = policy; + this.clock = clock; + } + + shouldCompress(transaction: Transaction, now: number): boolean { + if (this.policy.kind === 'none') return false; + if (transaction.operations.length !== 1) return false; + const opType = transaction.operations[0]!.type; + if (!isCompressibleOpType(opType)) return false; + if ( + opType === this.lastOpType && + now - this.lastTime <= (this.policy as { windowMs: number }).windowMs + ) { + return true; + } + return false; + } + + record(transaction: Transaction, now: number): void { + this.lastOpType = transaction.operations[0]?.type ?? null; + this.lastTime = now; + } + + reset(): void { + this.lastOpType = null; + this.lastTime = 0; + } +} diff --git a/packages/core/src/engine/virtual-window.ts b/packages/core/src/engine/virtual-window.ts new file mode 100644 index 0000000..279dd0e --- /dev/null +++ b/packages/core/src/engine/virtual-window.ts @@ -0,0 +1,84 @@ +/** + * Virtual rendering contract — Phase 7 Step 2 + * + * Defines what is "visible" so the React layer can mount + * only visible clip components. + */ + +import { toFrame } from '../types/frame'; +import type { TimelineFrame } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { Clip } from '../types/clip'; +import type { Track } from '../types/track'; + +export type VirtualWindow = { + readonly startFrame: TimelineFrame; + readonly endFrame: TimelineFrame; + readonly pixelsPerFrame: number; +}; + +export type VirtualClipEntry = { + readonly clip: Clip; + readonly track: Track; + readonly trackIndex: number; + readonly isVisible: boolean; + readonly left: number; + readonly width: number; +}; + +/** + * Returns all clips with visibility and layout (left, width). + * Sorted by trackIndex ascending, then by clip timelineStart ascending. + */ +export function getVisibleClips( + state: TimelineState, + window: VirtualWindow, +): VirtualClipEntry[] { + const startN = window.startFrame as number; + const endN = window.endFrame as number; + const ppf = window.pixelsPerFrame; + const result: VirtualClipEntry[] = []; + const tracks = state.timeline.tracks; + for (let i = 0; i < tracks.length; i++) { + const track = tracks[i]!; + const sortedClips = [...track.clips].sort( + (a, b) => (a.timelineStart as number) - (b.timelineStart as number), + ); + for (const clip of sortedClips) { + const clipStart = clip.timelineStart as number; + const clipEnd = clip.timelineEnd as number; + const durationFrames = clipEnd - clipStart; + const isVisible = clipEnd > startN && clipStart < endN; + const left = (clipStart - startN) * ppf; + const width = durationFrames * ppf; + result.push({ + clip, + track, + trackIndex: i, + isVisible, + left, + width, + }); + } + } + return result; +} + +/** + * Builds a VirtualWindow from viewport dimensions and scroll. + */ +export function getVisibleFrameRange( + viewportWidth: number, + scrollLeft: number, + pixelsPerFrame: number, +): VirtualWindow { + const startFrame = toFrame(Math.floor(scrollLeft / pixelsPerFrame)); + const endFrame = toFrame( + Math.ceil((scrollLeft + viewportWidth) / pixelsPerFrame), + ); + return { + startFrame, + endFrame, + pixelsPerFrame, + }; +} diff --git a/packages/core/src/example.ts b/packages/core/src/example.ts deleted file mode 100644 index a2d9e15..0000000 --- a/packages/core/src/example.ts +++ /dev/null @@ -1,503 +0,0 @@ -/** - * PHASE 1 COMPREHENSIVE TEST SUITE - * - * This script validates the deterministic kernel with: - * - Basic operations (add, move, resize, trim) - * - Validation edge cases (overlaps, bounds, asset references) - * - Undo/redo integrity - * - State immutability - * - Collision detection - * - History isolation - */ - -// Public API imports -import { - TimelineEngine, - createTimeline, - createTimelineState, - createTrack, - createClip, - createAsset, - frame, - frameRate, - framesToTimecode, -} from './index'; - -// Internal utilities for testing -import { - generateTimelineId, - generateTrackId, - generateClipId, - generateAssetId, -} from './internal'; - -console.log('\n=== PHASE 1 KERNEL TEST SUITE ===\n'); - -const fps = frameRate(30); -let testsPassed = 0; -let testsFailed = 0; - -function test(name: string, fn: () => boolean) { - try { - const result = fn(); - if (result) { - console.log(`✓ ${name}`); - testsPassed++; - } else { - console.error(`✗ ${name}`); - testsFailed++; - } - } catch (error) { - console.error(`✗ ${name} - Exception: ${error}`); - testsFailed++; - } -} - -// Setup -const timeline = createTimeline({ - id: generateTimelineId(), - name: 'Test Timeline', - fps, - duration: frame(3000), -}); - -const engine = new TimelineEngine( - createTimelineState({ - timeline, - assets: new Map(), - }) -); - -const asset = createAsset({ - id: generateAssetId(), - type: 'video', - duration: frame(600), // 20 seconds at 30fps - sourceUrl: 'test.mp4', -}); - -engine.registerAsset(asset); - -const track = createTrack({ - id: generateTrackId(), - name: 'Video Track', - type: 'video', -}); - -engine.addTrack(track); - -console.log('Setup complete\n'); - -// ======================================== -// BASIC OPERATIONS -// ======================================== -console.log('--- Basic Operations ---\n'); - -test('Add valid clip', () => { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const result = engine.addClip(track.id, clip); - return result.success === true; -}); - -const clipA = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(200), - timelineEnd: frame(400), - mediaIn: frame(0), - mediaOut: frame(200), -}); - -engine.addClip(track.id, clipA); - -test('Move clip to new position', () => { - const result = engine.moveClip(clipA.id, frame(500)); - if (!result.success) return false; - - const movedClip = engine.findClipById(clipA.id); - return movedClip?.timelineStart === 500; -}); - -test('Resize clip (must maintain duration match in Phase 1)', () => { - // In Phase 1, timeline duration must equal media duration - // clipA has media duration of 200 (mediaOut - mediaIn) - const result = engine.resizeClip(clipA.id, frame(500), frame(700)); // 200 frame duration - if (!result.success) return false; - - const resizedClip = engine.findClipById(clipA.id); - return resizedClip?.timelineStart === 500 && resizedClip?.timelineEnd === 700; -}); - -test('Trim clip media bounds (must adjust timeline to match)', () => { - // Trimming changes media duration, so timeline must also change - // New media duration: 150 - 50 = 100 frames - // So timeline must also be 100 frames - const result = engine.trimClip(clipA.id, frame(50), frame(250)); // 200 frame media duration - if (!result.success) return false; - - const trimmedClip = engine.findClipById(clipA.id); - return trimmedClip?.mediaIn === 50 && trimmedClip?.mediaOut === 250; -}); - -test('Remove clip', () => { - const clips = engine.getAllClips(); - const clipToRemove = clips[0]; - if (!clipToRemove) return false; - - const result = engine.removeClip(clipToRemove.id); - if (!result.success) return false; - - const removedClip = engine.findClipById(clipToRemove.id); - return removedClip === undefined; -}); - -console.log(); - -// ======================================== -// VALIDATION TESTS -// ======================================== -console.log('--- Validation Tests ---\n'); - -test('Reject clip with invalid bounds (start >= end)', () => { - const invalidClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(100), - timelineEnd: frame(100), // Invalid: start === end - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const result = engine.addClip(track.id, invalidClip); - return result.success === false; -}); - -test('Reject clip exceeding asset duration', () => { - const outOfBoundsClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(0), - timelineEnd: frame(100), - mediaIn: frame(0), - mediaOut: frame(700), // Exceeds asset duration of 600 - }); - - const result = engine.addClip(track.id, outOfBoundsClip); - return result.success === false; -}); - -test('Reject overlapping clips on same track', () => { - // clipA is at 500-600 - const overlapClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(550), // Overlaps with clipA - timelineEnd: frame(650), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const result = engine.addClip(track.id, overlapClip); - return result.success === false; -}); - -test('Allow boundary-touching clips (no overlap)', () => { - // clipA is now at 500-700 after resize - const touchingClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(700), // Exactly at clipA's end - timelineEnd: frame(800), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const result = engine.addClip(track.id, touchingClip); - return result.success === true; -}); - -test('Reject clip with non-existent asset', () => { - const invalidAssetClip = createClip({ - id: generateClipId(), - assetId: 'non_existent_asset', - trackId: track.id, - timelineStart: frame(800), - timelineEnd: frame(900), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - const result = engine.addClip(track.id, invalidAssetClip); - return result.success === false; -}); - -test('Reject move that would cause overlap', () => { - // Try to move clipA to overlap with the touching clip at 700-800 - const result = engine.moveClip(clipA.id, frame(750)); // Would overlap 750-950 with 700-800 - return result.success === false; -}); - -console.log(); - -// ======================================== -// UNDO/REDO TESTS -// ======================================== -console.log('--- Undo/Redo Tests ---\\n'); - -test('Can undo after successful operation', () => { - return engine.canUndo() === true; -}); - -test('Undo restores previous state', () => { - // First, do a successful operation we can undo - const clipBeforeMove = engine.findClipById(clipA.id); - if (!clipBeforeMove) return false; - const originalPosition = clipBeforeMove.timelineStart; - - // Move to a new position (this should succeed) - engine.moveClip(clipA.id, frame(900)); - - const clipAfterMove = engine.findClipById(clipA.id); - if (!clipAfterMove || clipAfterMove.timelineStart === originalPosition) return false; - - // Now undo - engine.undo(); - - const clipAfterUndo = engine.findClipById(clipA.id); - if (!clipAfterUndo) return false; - - // After undo, should be back at original position - return clipAfterUndo.timelineStart === originalPosition; -}); - -test('Can redo after undo', () => { - return engine.canRedo() === true; -}); - -test('Redo restores undone state', () => { - const clipBeforeRedo = engine.findClipById(clipA.id); - if (!clipBeforeRedo) return false; - const positionAfterUndo = clipBeforeRedo.timelineStart; - - // Redo should restore the moved position - engine.redo(); - - const clipAfterRedo = engine.findClipById(clipA.id); - if (!clipAfterRedo) return false; - - // After redo, position should be different (back to moved position) - return clipAfterRedo.timelineStart !== positionAfterUndo && clipAfterRedo.timelineStart === 900; -}); - -test('Failed operations do not affect history', () => { - const canUndoBefore = engine.canUndo(); - - // Try invalid operation - const invalidClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(550), - timelineEnd: frame(650), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, invalidClip); // Should fail - - const canUndoAfter = engine.canUndo(); - - // History should be unchanged - return canUndoBefore === canUndoAfter; -}); - -console.log(); - -// ======================================== -// STATE IMMUTABILITY TESTS -// ======================================== -console.log('--- Immutability Tests ---\n'); - -test('State snapshots are independent', () => { - const state1 = engine.getState(); - const state1Str = JSON.stringify(state1); - - // Make a change - const newClip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(2000), - timelineEnd: frame(2100), - mediaIn: frame(0), - mediaOut: frame(100), - }); - - engine.addClip(track.id, newClip); - - const state2 = engine.getState(); - const state2Str = JSON.stringify(state2); - - // States should be different objects and have different content - return state1 !== state2 && state1Str !== state2Str; -}); - -test('Undo/redo preserves state integrity', () => { - const stateBefore = JSON.stringify(engine.getState()); - - engine.undo(); - engine.redo(); - - const stateAfter = JSON.stringify(engine.getState()); - - return stateBefore === stateAfter; -}); - -console.log(); - -// ======================================== -// QUERY TESTS -// ======================================== -console.log('--- Query Tests ---\n'); - -test('Find clip by ID', () => { - const clip = engine.findClipById(clipA.id); - return clip !== undefined && clip.id === clipA.id; -}); - -test('Get all clips', () => { - const clips = engine.getAllClips(); - return clips.length > 0; -}); - -test('Find track by ID', () => { - const foundTrack = engine.findTrackById(track.id); - return foundTrack !== undefined && foundTrack.id === track.id; -}); - -test('Get clips on track', () => { - const clips = engine.getClipsOnTrack(track.id); - return clips.length > 0; -}); - -test('Get clips at frame', () => { - const clips = engine.getClipsAtFrame(frame(550)); - return clips.length > 0; // clipA should be at this frame -}); - -console.log(); - -// ======================================== -// TRACK OPERATIONS -// ======================================== -console.log('--- Track Operations ---\n'); - -const track2 = createTrack({ - id: generateTrackId(), - name: 'Audio Track', - type: 'audio', -}); - -test('Add second track', () => { - const result = engine.addTrack(track2); - return result.success === true; -}); - -test('Move track position', () => { - const result = engine.moveTrack(track2.id, 0); - if (!result.success) return false; - - const state = engine.getState(); - return state.timeline.tracks[0]?.id === track2.id; -}); - -test('Toggle track mute', () => { - const result = engine.toggleTrackMute(track.id); - if (!result.success) return false; - - const mutedTrack = engine.findTrackById(track.id); - return mutedTrack?.muted === true; -}); - -test('Toggle track lock', () => { - const result = engine.toggleTrackLock(track.id); - if (!result.success) return false; - - const lockedTrack = engine.findTrackById(track.id); - return lockedTrack?.locked === true; -}); - -test('Remove empty track', () => { - const result = engine.removeTrack(track2.id); - return result.success === true; -}); - -console.log(); - -// ======================================== -// STRESS TEST -// ======================================== -console.log('--- Stress Test ---\n'); - -test('Add 50 clips without collision', () => { - let successCount = 0; - - for (let i = 0; i < 50; i++) { - const clip = createClip({ - id: generateClipId(), - assetId: asset.id, - trackId: track.id, - timelineStart: frame(2200 + i * 20), // Start after immutability test clip - timelineEnd: frame(2210 + i * 20), - mediaIn: frame(0), - mediaOut: frame(10), - }); - - const result = engine.addClip(track.id, clip); - if (result.success) successCount++; - } - - return successCount === 50; -}); - -test('Query performance with many clips', () => { - const start = Date.now(); - const clips = engine.getAllClips(); - const duration = Date.now() - start; - - console.log(` (Found ${clips.length} clips in ${duration}ms)`); - return duration < 100; // Should be fast -}); - -console.log(); - -// ======================================== -// RESULTS -// ======================================== -console.log('='.repeat(50)); -console.log(`Tests Passed: ${testsPassed}`); -console.log(`Tests Failed: ${testsFailed}`); -console.log(`Total Tests: ${testsPassed + testsFailed}`); -console.log('='.repeat(50)); - -if (testsFailed === 0) { - console.log('\n✓ ALL TESTS PASSED!\n'); - console.log('Phase 1 kernel is stable and deterministic.'); -} else { - console.log(`\n✗ ${testsFailed} test(s) failed.\n`); - throw new Error(`${testsFailed} test(s) failed`); -} - diff --git a/packages/core/src/index-backup.ts b/packages/core/src/index-backup.ts deleted file mode 100644 index 276f2f3..0000000 --- a/packages/core/src/index-backup.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * @timeline/core - * - * A deterministic, frame-based timeline editing kernel. - * - * This is the foundational layer for professional timeline editors. - * It provides: - * - Frame-based time representation (no floating-point drift) - * - Immutable state management (predictable, testable) - * - Validation before mutation (prevents invalid states) - * - Snapshot-based undo/redo (reliable history) - * - Pure operations (no side effects) - * - * DESIGN PHILOSOPHY: - * - Clarity over cleverness - * - Determinism over convenience - * - Stability over speed - * - * USAGE: - * ```typescript - * import { TimelineEngine, createTimeline, createTrack, createClip, frame, frameRate } from '@timeline/core'; - * - * // Create initial state - * const timeline = createTimeline({ - * id: 'timeline_1', - * name: 'My Project', - * fps: frameRate(30), - * duration: frame(9000), - * tracks: [], - * }); - * - * const state = createTimelineState({ timeline, assets: new Map() }); - * - * // Create engine - * const engine = new TimelineEngine(state); - * - * // Use the engine - * const result = engine.addClip(trackId, clip); - * if (!result.success) { - * console.error('Failed:', result.errors); - * } - * ``` - */ - -// ===== CORE TYPES ===== -export type { Frame, FrameRate } from './types/frame'; -export type { Asset, AssetType } from './types/asset'; -export type { Clip } from './types/clip'; -export type { Track, TrackType } from './types/track'; -export type { Timeline } from './types/timeline'; -export type { TimelineState } from './types/state'; -export type { ValidationError, ValidationResult } from './types/validation'; - -// ===== FACTORY FUNCTIONS ===== -export { frame, frameRate, isValidFrame, isValidFrameRate } from './types/frame'; -export { createAsset } from './types/asset'; -export { createClip, getClipDuration, getClipMediaDuration, clipContainsFrame, clipsOverlap } from './types/clip'; -export { createTrack, sortTrackClips } from './types/track'; -export { createTimeline } from './types/timeline'; -export { createTimelineState } from './types/state'; -export { validResult, invalidResult, invalidResults, combineResults } from './types/validation'; - -// ===== FRAME UTILITIES ===== -export { - framesToSeconds, - secondsToFrames, - framesToTimecode, - framesToMinutesSeconds, - clampFrame, - addFrames, - subtractFrames, - frameDuration, -} from './utils/frame'; - -// ===== ID UTILITIES ===== -export { - generateId, - generateClipId, - generateTrackId, - generateTimelineId, - generateAssetId, - resetIdCounter, -} from './utils/id'; - -// ===== TIMELINE ENGINE ===== -export { TimelineEngine } from './engine/timeline-engine'; -export type { DispatchResult } from './engine/dispatcher'; - -// ===== QUERY FUNCTIONS (for advanced users) ===== -export { - findClipById, - findTrackById, - getClipsOnTrack, - getClipsAtFrame, - getClipsInRange, - getAllClips, - getAllTracks, - findTrackIndex, -} from './systems/queries'; - -// ===== ASSET REGISTRY (for advanced users) ===== -export { - registerAsset, - getAsset, - hasAsset, - getAllAssets, - unregisterAsset, -} from './systems/asset-registry'; - -// ===== VALIDATION (for advanced users) ===== -export { - validateClip, - validateTrack, - validateTimeline, - validateNoOverlap, -} from './systems/validation'; - -// ===== OPERATIONS (for advanced users) ===== -export { - addClip, - removeClip, - moveClip, - resizeClip, - trimClip, - updateClip, - moveClipToTrack, -} from './operations/clip-operations'; - -export { - addTrack, - removeTrack, - moveTrack, - updateTrack, - toggleTrackMute, - toggleTrackLock, -} from './operations/track-operations'; - -export { - setTimelineDuration, - setTimelineName, - updateTimelineMetadata, -} from './operations/timeline-operations'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 671a3a2..9b4bce5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ /** - * @timeline/core + * @webpacked-timeline/core * * A deterministic, frame-based timeline editing kernel with professional editing intelligence. * @@ -12,7 +12,7 @@ * Internal systems are not exported and may change without notice. * * For internal access (tests, advanced integrations), use: - * import { ... } from '@timeline/core/internal' + * import { ... } from '@webpacked-timeline/core/internal' */ export * from './public-api'; diff --git a/packages/core/src/internal.ts b/packages/core/src/internal.ts index bf6bb4f..8eeb701 100644 --- a/packages/core/src/internal.ts +++ b/packages/core/src/internal.ts @@ -1,5 +1,5 @@ /** - * @timeline/core - Internal API + * @webpacked-timeline/core - Internal API * * This file exports internal systems, operations, and utilities. * @@ -58,54 +58,11 @@ export { unregisterAsset, } from './systems/asset-registry'; -// Snapping -export type { SnapTarget, SnapResult } from './systems/snapping'; -export { - findSnapTargets, - calculateSnap, - calculateSnapExcluding, - findSnapTargetsForTrack, -} from './systems/snapping'; - -// Linking -export { - createLinkGroup, - breakLinkGroup, - getLinkedClips, - isClipLinked, - getLinkGroup, - addClipToLinkGroup, - removeClipFromLinkGroup, -} from './systems/linking'; - -// Grouping -export { - createGroup, - ungroupClips, - getGroupClips, - isClipGrouped, - getGroup, - addClipToGroup, - removeClipFromGroup, - renameGroup, - getChildGroups, -} from './systems/grouping'; - -// Clipboard -export type { ClipboardData } from './systems/clipboard'; -export { - copyClips, - cutClips, - pasteClips, - duplicateClips, -} from './systems/clipboard'; - -// Drag State -export type { DragState } from './systems/drag-state'; -export { - calculateDragPreview, - calculateResizeDragPreview, -} from './systems/drag-state'; +// Snapping — Phase 2 (snap-index.ts replaced this) +// Linking — Phase 4 +// Grouping — Phase 4 +// Clipboard — Phase 4 +// DragState — Phase 1 adapter (lives in packages/react) // ======================================== // INTERNAL OPERATIONS @@ -136,34 +93,18 @@ export { export { setTimelineDuration, setTimelineName, - updateTimelineMetadata, } from './operations/timeline-operations'; -// Marker Operations -export { - addTimelineMarker, - addClipMarker, - addRegionMarker, - removeMarker, - removeClipMarkers, - setWorkArea, - clearWorkArea, - updateTimelineMarker, - updateRegionMarker, -} from './operations/marker-operations'; - -// Linked Operations -export { - moveLinkedClips, - deleteLinkedClips, - offsetLinkedClips, -} from './operations/linked-operations'; +// Marker Operations — Phase 3 +// Linked Operations — Phase 4 // Ripple Operations export { rippleDelete, rippleTrim, insertEdit, + rippleMove, + insertMove, } from './operations/ripple'; // ======================================== @@ -197,9 +138,12 @@ export { export { isValidFrame, - isValidFrameRate, + isDropFrame, } from './types/frame'; +// Frame type alias for test backward compat (tests import `type Frame`) +export type { TimelineFrame as Frame, TimelineFrame } from './types/frame'; + // Clip Utilities export { getClipDuration, @@ -225,15 +169,11 @@ export { // INTERNAL ENGINE COMPONENTS // ======================================== -// Transactions -export type { Operation, TransactionContext, TransactionResult } from './engine/transactions'; -export { - beginTransaction, - applyOperation, - commitTransaction, - rollbackTransaction, - getOperationCount, -} from './engine/transactions'; - -// Dispatcher -export type { DispatchResult } from './engine/dispatcher'; +// Dispatcher & Operations +export type { DispatchResult, Transaction, OperationPrimitive, InvariantViolation, ViolationType, RejectionReason } from './types/operations'; +export { dispatch } from './engine/dispatcher'; +export { checkInvariants } from './validation/invariants'; + +// TimelineEngine — for test files +export { TimelineEngine } from './engine/timeline-engine'; + diff --git a/packages/core/src/operations/clip-operations.ts b/packages/core/src/operations/clip-operations.ts index ad87eca..7262f78 100644 --- a/packages/core/src/operations/clip-operations.ts +++ b/packages/core/src/operations/clip-operations.ts @@ -29,7 +29,9 @@ import { TimelineState } from '../types/state'; import { Clip } from '../types/clip'; -import { Frame } from '../types/frame'; +import type { TimelineFrame } from '../types/frame'; +import type { TrackId } from '../types/track'; +type Frame = TimelineFrame; // local alias for readability import { findTrackById, findClipById } from '../systems/queries'; import { sortTrackClips } from '../types/track'; import { validateTrackTypeMatch } from '../systems/validation'; @@ -296,7 +298,7 @@ export function moveClipToTrack( let newState = removeClip(state, clipId); // Update clip's trackId and add to new track - const updatedClip = { ...clip, trackId: targetTrackId }; + const updatedClip: Clip = { ...clip, trackId: targetTrackId as TrackId }; newState = addClip(newState, targetTrackId, updatedClip); return newState; diff --git a/packages/core/src/operations/linked-operations.ts b/packages/core/src/operations/linked-operations.ts deleted file mode 100644 index e0dfd42..0000000 --- a/packages/core/src/operations/linked-operations.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * LINKED OPERATIONS - * - * Operations that affect all clips in a link group together. - * These compose basic operations using transactions. - * - * DESIGN: - * - Use transactions to batch multiple operations - * - Preserve relative positions/timing - * - Respect Phase 1 collision rules - * - Pure functions - no mutations - */ - -import { TimelineState } from '../types/state'; -import { Frame } from '../types/frame'; -import { getLinkedClips } from '../systems/linking'; -import { moveClip, removeClip } from './clip-operations'; -import { beginTransaction, applyOperation, commitTransaction } from '../engine/transactions'; - -/** - * Move all clips in a link group together - * - * Maintains relative positions between linked clips. - * - * @param state - Timeline state - * @param clipId - Any clip in the link group - * @param newStart - New start position for the specified clip - * @returns New state with all linked clips moved - */ -export function moveLinkedClips( - state: TimelineState, - clipId: string, - newStart: Frame -): TimelineState { - const linkedClips = getLinkedClips(state, clipId); - - if (linkedClips.length === 0) { - throw new Error(`Clip not found: ${clipId}`); - } - - // If only one clip (not linked), use basic move - if (linkedClips.length === 1) { - return moveClip(state, clipId, newStart); - } - - // Find the reference clip - const referenceClip = linkedClips.find(c => c.id === clipId); - if (!referenceClip) { - throw new Error(`Clip not found: ${clipId}`); - } - - // Calculate delta - const delta = (newStart - referenceClip.timelineStart) as Frame; - - // Use transaction to move all clips - let tx = beginTransaction(state); - - for (const clip of linkedClips) { - const newClipStart = (clip.timelineStart + delta) as Frame; - tx = applyOperation(tx, s => moveClip(s, clip.id, newClipStart)); - } - - return commitTransaction(tx); -} - -/** - * Delete all clips in a link group together - * - * @param state - Timeline state - * @param clipId - Any clip in the link group - * @returns New state with all linked clips deleted - */ -export function deleteLinkedClips( - state: TimelineState, - clipId: string -): TimelineState { - const linkedClips = getLinkedClips(state, clipId); - - if (linkedClips.length === 0) { - throw new Error(`Clip not found: ${clipId}`); - } - - // If only one clip (not linked), use basic remove - if (linkedClips.length === 1) { - return removeClip(state, clipId); - } - - // Use transaction to delete all clips - let tx = beginTransaction(state); - - for (const clip of linkedClips) { - tx = applyOperation(tx, s => removeClip(s, clip.id)); - } - - return commitTransaction(tx); -} - -/** - * Offset all clips in a link group by a delta - * - * Useful for nudging linked clips together. - * - * @param state - Timeline state - * @param clipId - Any clip in the link group - * @param deltaFrames - Frames to offset by (can be negative) - * @returns New state with all linked clips offset - */ -export function offsetLinkedClips( - state: TimelineState, - clipId: string, - deltaFrames: Frame -): TimelineState { - const linkedClips = getLinkedClips(state, clipId); - - if (linkedClips.length === 0) { - throw new Error(`Clip not found: ${clipId}`); - } - - // Use transaction to offset all clips - let tx = beginTransaction(state); - - for (const clip of linkedClips) { - const newStart = (clip.timelineStart + deltaFrames) as Frame; - tx = applyOperation(tx, s => moveClip(s, clip.id, newStart)); - } - - return commitTransaction(tx); -} diff --git a/packages/core/src/operations/marker-operations.ts b/packages/core/src/operations/marker-operations.ts deleted file mode 100644 index acc3dbe..0000000 --- a/packages/core/src/operations/marker-operations.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * MARKER OPERATIONS - * - * Operations for adding/removing markers. - * Markers are pure metadata - they don't affect clip timing or validation. - * - * DESIGN: - * - Markers stored in TimelineState.markers - * - Pure functions - no mutations - * - Three types: timeline, clip, region - * - Work area is separate from markers - */ - -import { TimelineState } from '../types/state'; -import { TimelineMarker, ClipMarker, RegionMarker, WorkArea } from '../types/marker'; -import { findClipById } from '../systems/queries'; - -/** - * Add a timeline marker - * - * @param state - Timeline state - * @param marker - Timeline marker to add - * @returns New state with marker added - */ -export function addTimelineMarker( - state: TimelineState, - marker: TimelineMarker -): TimelineState { - return { - ...state, - markers: { - ...state.markers, - timeline: [...state.markers.timeline, marker], - }, - }; -} - -/** - * Add a clip marker - * - * @param state - Timeline state - * @param marker - Clip marker to add - * @returns New state with marker added - */ -export function addClipMarker( - state: TimelineState, - marker: ClipMarker -): TimelineState { - // Verify clip exists - const clip = findClipById(state, marker.clipId); - if (!clip) { - throw new Error(`Clip not found: ${marker.clipId}`); - } - - return { - ...state, - markers: { - ...state.markers, - clips: [...state.markers.clips, marker], - }, - }; -} - -/** - * Add a region marker - * - * @param state - Timeline state - * @param marker - Region marker to add - * @returns New state with marker added - */ -export function addRegionMarker( - state: TimelineState, - marker: RegionMarker -): TimelineState { - if (marker.startFrame >= marker.endFrame) { - throw new Error('Region marker start must be before end'); - } - - return { - ...state, - markers: { - ...state.markers, - regions: [...state.markers.regions, marker], - }, - }; -} - -/** - * Remove a marker by ID - * - * Searches all marker types and removes the first match. - * - * @param state - Timeline state - * @param markerId - Marker ID to remove - * @returns New state with marker removed - */ -export function removeMarker( - state: TimelineState, - markerId: string -): TimelineState { - return { - ...state, - markers: { - timeline: state.markers.timeline.filter(m => m.id !== markerId), - clips: state.markers.clips.filter(m => m.id !== markerId), - regions: state.markers.regions.filter(m => m.id !== markerId), - }, - }; -} - -/** - * Remove all markers for a specific clip - * - * Useful when deleting a clip. - * - * @param state - Timeline state - * @param clipId - Clip ID - * @returns New state with clip markers removed - */ -export function removeClipMarkers( - state: TimelineState, - clipId: string -): TimelineState { - return { - ...state, - markers: { - ...state.markers, - clips: state.markers.clips.filter(m => m.clipId !== clipId), - }, - }; -} - -/** - * Set work area - * - * @param state - Timeline state - * @param workArea - Work area to set - * @returns New state with work area set - */ -export function setWorkArea( - state: TimelineState, - workArea: WorkArea -): TimelineState { - if (workArea.startFrame >= workArea.endFrame) { - throw new Error('Work area start must be before end'); - } - - return { - ...state, - workArea, - }; -} - -/** - * Clear work area - * - * @param state - Timeline state - * @returns New state with work area cleared - */ -export function clearWorkArea( - state: TimelineState -): TimelineState { - const { workArea: _, ...stateWithoutWorkArea } = state; - return stateWithoutWorkArea as TimelineState; -} - -/** - * Update timeline marker - * - * @param state - Timeline state - * @param markerId - Marker ID to update - * @param updates - Partial marker updates - * @returns New state with marker updated - */ -export function updateTimelineMarker( - state: TimelineState, - markerId: string, - updates: Partial> -): TimelineState { - return { - ...state, - markers: { - ...state.markers, - timeline: state.markers.timeline.map(m => - m.id === markerId ? { ...m, ...updates } : m - ), - }, - }; -} - -/** - * Update region marker - * - * @param state - Timeline state - * @param markerId - Marker ID to update - * @param updates - Partial marker updates - * @returns New state with marker updated - */ -export function updateRegionMarker( - state: TimelineState, - markerId: string, - updates: Partial> -): TimelineState { - return { - ...state, - markers: { - ...state.markers, - regions: state.markers.regions.map(m => - m.id === markerId ? { ...m, ...updates } : m - ), - }, - }; -} diff --git a/packages/core/src/operations/ripple.ts b/packages/core/src/operations/ripple.ts index 8c38601..103e598 100644 --- a/packages/core/src/operations/ripple.ts +++ b/packages/core/src/operations/ripple.ts @@ -12,7 +12,8 @@ */ import { TimelineState } from '../types/state'; -import { Frame } from '../types/frame'; +import type { TimelineFrame } from '../types/frame'; +type Frame = TimelineFrame; import { Clip, getClipDuration } from '../types/clip'; import { findClipById, findTrackById } from '../systems/queries'; import { removeClip, moveClip, resizeClip, addClip } from './clip-operations'; @@ -159,3 +160,193 @@ export function insertEdit( return commitTransaction(tx); } + +/** + * Ripple move - move clip to new position with automatic gap/shift handling + * + * SEMANTICS (based on test contract): + * + * Moving RIGHT (newStart > originalStart): + * - Leaves gap at source + * - All clips at/after originalEnd shift RIGHT by clipDuration + * - Target clip moves to newStart + * + * Moving LEFT (newStart < originalStart): + * - Closes gap at source + * - All clips at/after originalEnd shift LEFT by clipDuration + * - Target clip moves to newStart + * + * @param state - Timeline state + * @param clipId - Clip ID to move + * @param newStart - New start frame for the clip + * @returns New state with clip moved and surrounding clips adjusted + */ +export function rippleMove( + state: TimelineState, + clipId: string, + newStart: Frame +): TimelineState { + const clip = findClipById(state, clipId); + if (!clip) { + throw new Error(`Clip not found: ${clipId}`); + } + + const track = findTrackById(state, clip.trackId); + if (!track) { + throw new Error(`Track not found: ${clip.trackId}`); + } + + const clipDuration = getClipDuration(clip); + const newEnd = (newStart + clipDuration) as Frame; + + // Validate bounds + if (newStart < 0) { + throw new Error('Cannot move clip before timeline start (frame 0)'); + } + + if (newEnd > state.timeline.duration) { + throw new Error(`Cannot move clip beyond timeline duration (${state.timeline.duration} frames)`); + } + + const originalStart = clip.timelineStart; + const originalEnd = clip.timelineEnd; + + if (newStart === originalStart) { + return state; // No-op + } + + // Start transaction + let tx = beginTransaction(state); + + if (newStart > originalStart) { + // Moving RIGHT — collapse-then-insert ("ripple swap") algorithm: + // + // Step 1 — collapse gap at source: shift all clips that start at/after originalEnd + // leftward by clipDuration (as if clip was removed). + const afterSource = track.clips + .filter(c => c.id !== clipId && c.timelineStart >= originalEnd) + .sort((a, b) => a.timelineStart - b.timelineStart); // ascending – left first + + for (const other of afterSource) { + const s = (other.timelineStart - clipDuration) as Frame; + tx = applyOperation(tx, st => moveClip(st, other.id, s)); + } + + // Step 2 — compute destination in the collapsed timeline. + // If clips existed between originalEnd and newStart in the original track, they + // have all shifted left by clipDuration so the target frame also shifts left by + // clipDuration → collapsedDest = newStart - clipDuration. + // If NO clips existed there, the empty space is unchanged and the target frame is + // still newStart → collapsedDest = newStart. + const anyClipBetween = afterSource.some(c => c.timelineStart < newStart); + const collapsedDest = anyClipBetween + ? (newStart - clipDuration) as Frame + : newStart; + + // Step 3 — make room at destination: shift all clips that start at/after collapsedDest + // rightward by clipDuration. Read from the current transaction state so we + // have the positions after Step 1. Process right-to-left to avoid chasing. + const currentTrack = tx.currentState.timeline.tracks.find(t => t.id === track.id)!; + const atDest = currentTrack.clips + .filter(c => c.id !== clipId && c.timelineStart >= collapsedDest) + .sort((a, b) => b.timelineStart - a.timelineStart); // descending – right first + + for (const other of atDest) { + const s = (other.timelineStart + clipDuration) as Frame; + tx = applyOperation(tx, st => moveClip(st, other.id, s)); + } + + // Step 4 — place the clip at its destination in the collapsed timeline. + tx = applyOperation(tx, st => moveClip(st, clipId, collapsedDest)); + } else { + // Moving LEFT: + // Close the gap left behind: shift clips at/after originalEnd leftward by clipDuration. + const afterSource = track.clips + .filter(c => c.id !== clipId && c.timelineStart >= originalEnd) + .sort((a, b) => a.timelineStart - b.timelineStart); // ascending – left first + + for (const other of afterSource) { + const s = (other.timelineStart - clipDuration) as Frame; + tx = applyOperation(tx, st => moveClip(st, other.id, s)); + } + + // Place clip at requested position. + tx = applyOperation(tx, st => moveClip(st, clipId, newStart)); + } + + return commitTransaction(tx); +} + +/** + * Insert move - move clip to new position and shift destination clips right + * + * This differs from ripple move: + * - Ripple move: closes gap at source, swaps with intermediate clips + * - Insert move: leaves gap at source, pushes all destination clips right + * + * This is useful for "inserting" a clip into a specific position without + * affecting the source timeline structure. + * + * All operations are atomic via transaction. + * + * @param state - Timeline state + * @param clipId - Clip ID to move + * @param newStart - New start frame for the clip + * @returns New state with clip moved and destination clips shifted + * + * @example + * // Timeline before: [A][B*][C]__[D] + * // Insert move B to position after D + * // Timeline after: [A]__[C][D][B*] (gap remains at A) + */ +export function insertMove( + state: TimelineState, + clipId: string, + newStart: Frame +): TimelineState { + const clip = findClipById(state, clipId); + if (!clip) { + throw new Error(`Clip not found: ${clipId}`); + } + + const track = findTrackById(state, clip.trackId); + if (!track) { + throw new Error(`Track not found: ${clip.trackId}`); + } + + // Validate new position is within timeline bounds + const clipDuration = getClipDuration(clip); + const newEnd = (newStart + clipDuration) as Frame; + + if (newStart < 0) { + throw new Error('Cannot move clip before timeline start (frame 0)'); + } + + if (newEnd > state.timeline.duration) { + throw new Error(`Cannot move clip beyond timeline duration (${state.timeline.duration} frames)`); + } + + // If moving to the same position, no-op + if (newStart === clip.timelineStart) { + return state; + } + + // Use transaction for atomicity + let tx = beginTransaction(state); + + // Find all clips at or after the new position (excluding the clip being moved) + const clipsToShift = track.clips.filter(c => + c.id !== clipId && c.timelineStart >= newStart + ); + + // Shift destination clips right to make space + for (const clipToShift of clipsToShift) { + const shiftedStart = (clipToShift.timelineStart + clipDuration) as Frame; + tx = applyOperation(tx, s => moveClip(s, clipToShift.id, shiftedStart)); + } + + // Move the clip to its new position + tx = applyOperation(tx, s => moveClip(s, clipId, newStart)); + + return commitTransaction(tx); +} diff --git a/packages/core/src/operations/timeline-operations.ts b/packages/core/src/operations/timeline-operations.ts index 07dc0d4..53e1d2d 100644 --- a/packages/core/src/operations/timeline-operations.ts +++ b/packages/core/src/operations/timeline-operations.ts @@ -21,7 +21,8 @@ */ import { TimelineState } from '../types/state'; -import { Frame } from '../types/frame'; +import type { TimelineFrame } from '../types/frame'; +type Frame = TimelineFrame; /** * Set the timeline duration @@ -57,25 +58,6 @@ export function setTimelineName(state: TimelineState, name: string): TimelineSta }; } -/** - * Update timeline metadata - * - * @param state - Current timeline state - * @param metadata - Metadata to merge with existing metadata - * @returns New timeline state with updated metadata - */ -export function updateTimelineMetadata( - state: TimelineState, - metadata: Record -): TimelineState { - return { - ...state, - timeline: { - ...state.timeline, - metadata: { - ...state.timeline.metadata, - ...metadata, - }, - }, - }; -} +// updateTimelineMetadata removed — Timeline has no metadata field in Phase 0. +// Will be re-added in Phase 3 (project metadata). + diff --git a/packages/core/src/public-api.ts b/packages/core/src/public-api.ts index a728507..cd8dfbe 100644 --- a/packages/core/src/public-api.ts +++ b/packages/core/src/public-api.ts @@ -1,150 +1,328 @@ /** - * @timeline/core - Public API - * - * This is the stable, public API surface for the timeline editing kernel. - * - * PHILOSOPHY: - * - Public API = Contract (stable, minimal, intentional) - * - Internal code = Implementation (free to refactor) - * - * This file exports ONLY what consumers need to use the timeline engine. - * Internal systems, operations, and utilities are NOT exported here. + * @webpacked-timeline/core — Public API (Phase 0) + * + * This is the stable contract surface. Everything here is intentional and minimal. + * Internal files are not exported and may change without notice. */ -// ======================================== -// CORE ENGINE -// ======================================== +// ── Core factories ───────────────────────────────────────────────────────── +export { createTimeline } from './types/timeline'; +export { createTrack } from './types/track'; +export { createClip } from './types/clip'; +export { createAsset } from './types/asset'; +export { createTimelineState, CURRENT_SCHEMA_VERSION } from './types/state'; -/** - * The main timeline engine providing high-level editing operations. - * - * This is the primary interface for interacting with the timeline. - * All editing operations go through this engine. - */ -export { TimelineEngine } from './engine/timeline-engine'; +// ── Frame utilities ──────────────────────────────────────────────────────── +export { frame, frameRate, toFrame, toTimecode, FrameRates, isDropFrame } from './types/frame'; +export { framesToTimecode, framesToSeconds, secondsToFrames } from './utils/frame'; +export type { TimelineFrame as Frame } from './types/frame'; // backward compat alias -// ======================================== -// FACTORY FUNCTIONS -// ======================================== +// ── High-level engine class ─────────────────────────────────────────────── +export { TimelineEngine } from './engine/timeline-engine'; -/** - * Factory functions for creating core timeline entities. - * These are the primary way to construct timeline objects. - */ -export { createTimeline } from './types/timeline'; -export { createTrack } from './types/track'; -export { createClip } from './types/clip'; -export { createAsset } from './types/asset'; -export { createTimelineState } from './types/state'; +// ── Dispatcher (the ONLY way to mutate state) ───────────────────────────── +export { dispatch } from './engine/dispatcher'; -// ======================================== -// FRAME UTILITIES -// ======================================== +// ── Invariant checker (run in every test after every mutation) ──────────── +export { checkInvariants } from './validation/invariants'; -/** - * Frame-based time utilities. - * - * The timeline uses frame-based time for deterministic editing. - * These utilities help convert between frames and other time formats. - */ -export { - frame, - frameRate, -} from './types/frame'; +// ── History ──────────────────────────────────────────────────────────────── +export { + createHistory, + pushHistory, + undo, + redo, + canUndo, + canRedo, + getCurrentState, +} from './engine/history'; +export type { HistoryState, HistoryEntry } from './engine/history'; +export { HistoryStack } from './engine/history'; +export type { CompressionPolicy, CompressibleOpType } from './types/compression'; +export { + DEFAULT_COMPRESSION_POLICY, + NO_COMPRESSION, +} from './types/compression'; +export { TransactionCompressor } from './engine/transaction-compressor'; -export { - framesToTimecode, - framesToSeconds, - secondsToFrames, -} from './utils/frame'; +// ── Public types ─────────────────────────────────────────────────────────── -// ======================================== -// PUBLIC TYPES -// ======================================== +// Time types +export type { TimelineFrame, FrameRate, RationalTime, Timecode, TimeRange } from './types/frame'; -/** - * Core type definitions. - * - * These types define the shape of timeline data structures. - * Consumers use these for type safety when working with the engine. - */ +// Branded IDs +export type { AssetId } from './types/asset'; +export { toAssetId } from './types/asset'; +export type { ClipId } from './types/clip'; +export { toClipId } from './types/clip'; +export type { TrackId } from './types/track'; +export { toTrackId } from './types/track'; +export type { MarkerId } from './types/marker'; +export { toMarkerId } from './types/marker'; -// Frame types -export type { Frame, FrameRate } from './types/frame'; +// Entity types +export type { Asset, AssetStatus } from './types/asset'; +export type { Clip } from './types/clip'; +export type { Track, TrackType } from './types/track'; +export type { Timeline, SequenceSettings } from './types/timeline'; +export type { TimelineState, AssetRegistry } from './types/state'; -// Core entity types -export type { Timeline } from './types/timeline'; -export type { Track, TrackType } from './types/track'; -export type { Clip } from './types/clip'; -export type { Asset, AssetType } from './types/asset'; -export type { TimelineState } from './types/state'; +// Operation types +export type { + OperationPrimitive, + Transaction, + DispatchResult, + RejectionReason, + InvariantViolation, + ViolationType, +} from './types/operations'; -// Validation types (returned by engine methods) -export type { ValidationResult, ValidationError } from './types/validation'; +// ── Phase 1 exports ──────────────────────────────────────────────────────── -// Phase 2: Marker types -export type { - TimelineMarker, - ClipMarker, - RegionMarker, - WorkArea, - Marker -} from './types/marker'; +// Snap index +export { + buildSnapIndex, + nearest, + toggleSnap, +} from './snap-index'; +export type { + SnapPointType, + SnapPoint, + SnapIndex, +} from './snap-index'; +export { SnapIndexManager } from './engine/snap-index-manager'; -// Phase 2: Linking types -export type { LinkGroup } from './types/linking'; +// Tool system +export type { + ToolId, + Modifiers, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, + RubberBandRegion, + ToolContext, + ITool, +} from './tools/types'; +export { toToolId } from './tools/types'; -// Phase 2: Grouping types -export type { Group } from './types/grouping'; +// Tool registry +export { + createRegistry, + activateTool as activateToolInRegistry, + getActiveTool, + registerTool, + NoOpTool, +} from './tools/registry'; +export type { ToolRegistry } from './tools/registry'; -// ======================================== -// NOTES FOR CONSUMERS -// ======================================== +// Also export activateTool unaliased for direct use +export { activateTool } from './tools/registry'; + +// Provisional manager +export { + createProvisionalManager, + setProvisional, + clearProvisional, + resolveClip, +} from './tools/provisional'; +export type { ProvisionalManager } from './tools/provisional'; + +// Marker search (Phase 3) +export { findMarkersByColor, findMarkersByLabel } from './engine/marker-search'; + +// Subtitle import (Phase 3 Step 3) +export { + parseSRT, + parseVTT, + defaultCaptionStyle, + subtitleImportToOps, +} from './engine/subtitle-import'; +export type { SRTParseOptions, VTTParseOptions } from './engine/subtitle-import'; + +// ── Phase 4: Easing, Keyframes, Effects, Transform, Audio, Transitions, Groups ─ + +export type { EasingCurve } from './types/easing'; +export { LINEAR_EASING, HOLD_EASING } from './types/easing'; + +export type { KeyframeId, Keyframe } from './types/keyframe'; +export { toKeyframeId } from './types/keyframe'; + +export type { + EffectId, + EffectType, + RenderStage, + EffectParam, + Effect, +} from './types/effect'; +export { toEffectId, createEffect } from './types/effect'; + +export type { AnimatableProperty, ClipTransform } from './types/clip-transform'; +export { + createAnimatableProperty, + DEFAULT_CLIP_TRANSFORM, +} from './types/clip-transform'; + +export type { ChannelRouting, AudioProperties } from './types/audio-properties'; +export { DEFAULT_AUDIO_PROPERTIES } from './types/audio-properties'; + +export type { + TransitionId, + TransitionType, + TransitionAlignment, + TransitionParam, + Transition, +} from './types/transition'; +export { toTransitionId, createTransition } from './types/transition'; + +export type { TrackGroupId, TrackGroup } from './types/track-group'; +export { toTrackGroupId, createTrackGroup } from './types/track-group'; + +export type { LinkGroupId, LinkGroup } from './types/link-group'; +export { toLinkGroupId, createLinkGroup } from './types/link-group'; + +// Phase 2 tools (default set for React TimelineEngine) +export { SelectionTool } from './tools/selection'; +export { RazorTool } from './tools/razor'; +export { RippleTrimTool } from './tools/ripple-trim'; +export { RollTrimTool } from './tools/roll-trim'; +export { SlipTool } from './tools/slip'; +export { RippleDeleteTool } from './tools/ripple-delete'; +export { RippleInsertTool } from './tools/ripple-insert'; +export { HandTool } from './tools/hand'; + +// Phase 4 Step 4: Transition and Keyframe tools (register via createRegistry / registerTool) +export { TransitionTool } from './tools/transition-tool'; +export { KeyframeTool } from './tools/keyframe-tool'; + +// Phase 7 Step 5: Slide and Zoom tools +export { SlideTool } from './tools/slide-tool'; +export { ZoomTool, createZoomTool } from './tools/zoom-tool'; +export type { ZoomToolOptions } from './tools/zoom-tool'; + +// Phase 5 Step 1: Serialization +export { + SerializationError, + serializeTimeline, + deserializeTimeline, + remapAssetPaths, + findOfflineAssets, +} from './engine/serializer'; +export type { AssetRemapCallback, OfflineAsset } from './engine/serializer'; + +// Phase 5 Step 2: OTIO interchange +export { exportToOTIO } from './engine/otio-export'; +export { importFromOTIO } from './engine/otio-import'; +export type { OTIODocument } from './engine/otio-export'; +export type { OTIOImportOptions } from './engine/otio-import'; + +// Phase 5 Step 3: EDL export +export { exportToEDL, frameToTimecode, reelName } from './engine/edl-export'; +export type { EDLExportOptions } from './engine/edl-export'; + +// Phase 5 Step 4: AAF and FCP XML export +export { exportToAAF } from './engine/aaf-export'; +export type { AAFExportOptions } from './engine/aaf-export'; +export { exportToFCPXML, toFCPTime } from './engine/fcpxml-export'; +export type { FCPXMLExportOptions } from './engine/fcpxml-export'; + +// Phase 5 Step 5: Project model + bins +export type { + ProjectId, + BinId, + BinItem, + Bin, + Project, +} from './types/project'; +export { + toProjectId, + toBinId, + createBin, + createProject, +} from './types/project'; +export { + addTimeline, + removeTimeline, + addBin, + removeBin, + addItemToBin, + removeItemFromBin, + moveItemBetweenBins, +} from './engine/project-ops'; +export { serializeProject, deserializeProject } from './engine/project-serializer'; + +// Phase 6 Step 1: Playhead +export { PlayheadController } from './engine/playhead-controller'; +export type { + PlayheadState, + PlayheadEvent, + PlayheadEventType, + PlayheadListener, + PlayheadUnsubscribe, + PlaybackRate, + PlaybackQuality, + LoopRegion, +} from './types/playhead'; +export type { Clock } from './engine/clock'; +export { browserClock, nodeClock, createTestClock } from './engine/clock'; + +// Phase 6 Step 2: Pipeline contracts +export type { + VideoFrameRequest, + AudioChunkRequest, + VideoDecoder, + AudioDecoder, + VideoFrameResult, + AudioChunkResult, + CompositeLayer, + CompositeRequest, + CompositeResult, + Compositor, + ThumbnailRequest, + ThumbnailResult, + ThumbnailProvider, + PipelineConfig, +} from './types/pipeline'; +export { + resolveFrame, + getClipsAtFrame, + mediaFrameForClip, + findNextClipBoundary, + findPrevClipBoundary, + findNextMarker, + findPrevMarker, + findClipById, +} from './engine/frame-resolver'; +export { IntervalTree } from './engine/interval-tree'; +export type { Interval } from './engine/interval-tree'; +export { TrackIndex } from './engine/track-index'; +export type { ClipEntry } from './engine/track-index'; +export { PlaybackEngine } from './engine/playback-engine'; +export { getVisibleClips, getVisibleFrameRange } from './engine/virtual-window'; +export type { VirtualWindow, VirtualClipEntry } from './engine/virtual-window'; +export { diffStates, EMPTY_STATE_CHANGE } from './types/state-change'; +export type { StateChange } from './types/state-change'; + +// Phase 7 Step 4: Worker contracts, thumbnail cache/queue +export type { + WaveformRequest, + WaveformPeak, + WaveformResult, + WaveformWorkerMessage, + WaveformWorkerResponse, + ThumbnailPriority, + ThumbnailQueueEntry, + ThumbnailWorkerMessage, + ThumbnailWorkerResponse, +} from './types/worker-contracts'; +export { ThumbnailCache } from './engine/thumbnail-cache'; +export { ThumbnailQueue } from './engine/thumbnail-queue'; + +// Phase 6 Step 4: Keyboard (J/K/L jog-shuttle) +export type { + TimelineKeyAction, + KeyBinding, + KeyboardHandlerOptions, +} from './types/keyboard'; +export { DEFAULT_KEY_BINDINGS } from './types/keyboard'; +export { KeyboardHandler } from './engine/keyboard-handler'; -/** - * USAGE PATTERN: - * - * ```typescript - * import { - * TimelineEngine, - * createTimeline, - * createTrack, - * createClip, - * createAsset, - * frame, - * frameRate - * } from '@timeline/core'; - * - * // Create timeline - * const timeline = createTimeline({ - * id: 'timeline-1', - * name: 'My Timeline', - * fps: frameRate(30), - * duration: frame(3000), - * tracks: [] - * }); - * - * // Create engine - * const engine = new TimelineEngine(createTimelineState({ timeline })); - * - * // Use high-level operations - * engine.addTrack(createTrack({ ... })); - * engine.addClip(trackId, createClip({ ... })); - * engine.moveClip(clipId, frame(100)); - * - * // Access state - * const state = engine.getState(); - * ``` - * - * WHAT'S NOT EXPORTED: - * - Internal operations (addClip, moveClip, etc. - use TimelineEngine methods) - * - Internal systems (validation, queries, snapping, linking, grouping) - * - Internal utilities (ID generation, low-level helpers) - * - Transaction primitives (use TimelineEngine transaction methods) - * - * WHY: - * - Keeps API surface small and stable - * - Allows internal refactoring without breaking changes - * - Encourages use of high-level TimelineEngine API - * - Reduces cognitive overhead for consumers - */ diff --git a/packages/core/src/snap-index.ts b/packages/core/src/snap-index.ts new file mode 100644 index 0000000..fe33321 --- /dev/null +++ b/packages/core/src/snap-index.ts @@ -0,0 +1,208 @@ +/** + * SNAP INDEX — Phase 1 + * + * Pure functions. Zero React/DOM imports. Zero mutation. + * + * Phase 1 snap sources: ClipStart, ClipEnd, Playhead. + * Phase 2 will add: Marker, InPoint, OutPoint. + * Phase 3 will add: BeatGrid. + * + * Priority table (do not change values): + * Marker: 100 + * InPoint: 90 + * OutPoint: 90 + * ClipStart: 80 + * ClipEnd: 80 + * Playhead: 70 + * BeatGrid: 50 + */ + +import type { TimelineFrame } from './types/frame'; +import type { TrackId } from './types/track'; +import type { TimelineState } from './types/state'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * All snap point sources across phases. + * Defined in full now so SnapPoint & allowedTypes filters are stable. + */ +export type SnapPointType = + | 'ClipStart' // Phase 1 + | 'ClipEnd' // Phase 1 + | 'Playhead' // Phase 1 + | 'Marker' // Phase 2 + | 'InPoint' // Phase 2 + | 'OutPoint' // Phase 2 + | 'BeatGrid'; // Phase 3 + +export type SnapPoint = { + readonly frame: TimelineFrame; + readonly type: SnapPointType; + readonly priority: number; + readonly trackId: TrackId | null; // null = timeline-wide (playhead, markers) + readonly sourceId: string; // clipId, markerId — used for exclusion list +}; + +export type SnapIndex = { + readonly points: readonly SnapPoint[]; // sorted ascending by frame + readonly builtAt: number; // Date.now() + readonly enabled: boolean; +}; + +// --------------------------------------------------------------------------- +// Priority Table +// --------------------------------------------------------------------------- + +const PRIORITIES: Record = { + Marker: 100, + InPoint: 90, + OutPoint: 90, + ClipStart: 80, + ClipEnd: 80, + Playhead: 70, + BeatGrid: 50, +}; + +// --------------------------------------------------------------------------- +// buildSnapIndex +// --------------------------------------------------------------------------- + +/** + * Build a SnapIndex from committed state + playhead position. + * + * RULE: Call via queueMicrotask after accepted dispatch. + * Never call during a drag (pointer move). + * + * Phase 1 sources pulled (in order): + * 1. ClipStart + ClipEnd from every clip on every track + * 2. Playhead position (trackId = null) + */ +export function buildSnapIndex( + state: TimelineState, + playheadFrame: TimelineFrame, + enabled = true, +): SnapIndex { + const points: SnapPoint[] = []; + + // 1. Clip boundaries from all tracks + for (const track of state.timeline.tracks) { + for (const clip of track.clips) { + points.push({ + frame: clip.timelineStart, + type: 'ClipStart', + priority: PRIORITIES.ClipStart, + trackId: track.id, + sourceId: clip.id, + }); + points.push({ + frame: clip.timelineEnd, + type: 'ClipEnd', + priority: PRIORITIES.ClipEnd, + trackId: track.id, + sourceId: clip.id, + }); + } + } + + // 2. Playhead (timeline-wide) + points.push({ + frame: playheadFrame, + type: 'Playhead', + priority: PRIORITIES.Playhead, + trackId: null, + sourceId: '__playhead__', + }); + + // 3. BeatGrid (Phase 3) — beat frames when beatGrid is set + const beatGrid = state.timeline.beatGrid; + const dur = state.timeline.duration; + if (beatGrid !== null) { + const fps = state.timeline.fps as number; + const beatDurationFrames = Math.round((60 / beatGrid.bpm) * fps); + let f: TimelineFrame = beatGrid.offset; + while (f < dur) { + points.push({ + frame: f, + type: 'BeatGrid', + priority: PRIORITIES.BeatGrid, + trackId: null, + sourceId: `__beat_${f}__`, + }); + f = (f + beatDurationFrames) as TimelineFrame; + } + } + + // Sort ascending by frame + points.sort((a, b) => a.frame - b.frame); + + return { points, builtAt: Date.now(), enabled }; +} + +// --------------------------------------------------------------------------- +// nearest +// --------------------------------------------------------------------------- + +/** + * Find the highest-priority snap candidate within radiusFrames. + * + * Returns null when: + * - index.enabled is false + * - no point is within radiusFrames of frame + * + * Tiebreak (equidistant candidates): highest priority wins. + * Second tiebreak (equal priority): first in sorted order. + * + * @param exclude sourceIds to skip (e.g. the clip being dragged) + * @param allowedTypes if provided, only consider points of these types + */ +export function nearest( + index: SnapIndex, + frame: TimelineFrame, + radiusFrames: number, + exclude?: readonly string[], + allowedTypes?: readonly SnapPointType[], +): SnapPoint | null { + if (!index.enabled) return null; + + const excludeSet = exclude ? new Set(exclude) : null; + + let best: SnapPoint | null = null; + let bestDist = Infinity; + + for (const point of index.points) { + // Apply exclusion list + if (excludeSet && excludeSet.has(point.sourceId)) continue; + + // Apply type filter + if (allowedTypes && !allowedTypes.includes(point.type)) continue; + + const dist = Math.abs(point.frame - frame); + if (dist > radiusFrames) continue; + + // Prefer closer, then higher priority (higher wins) + if ( + dist < bestDist || + (dist === bestDist && best !== null && point.priority > best.priority) + ) { + best = point; + bestDist = dist; + } + } + + return best; +} + +// --------------------------------------------------------------------------- +// toggleSnap +// --------------------------------------------------------------------------- + +/** + * Return a new SnapIndex with enabled toggled. + * Does NOT rebuild points — pure field update. + */ +export function toggleSnap(index: SnapIndex, enabled: boolean): SnapIndex { + return { ...index, enabled }; +} diff --git a/packages/core/src/systems/asset-registry.ts b/packages/core/src/systems/asset-registry.ts index 95bd097..60a79e3 100644 --- a/packages/core/src/systems/asset-registry.ts +++ b/packages/core/src/systems/asset-registry.ts @@ -1,102 +1,33 @@ /** - * ASSET REGISTRY SYSTEM - * + * ASSET REGISTRY SYSTEM — Phase 0 compliant + * * Pure functions for managing assets in the timeline state. - * - * WHAT IS THE ASSET REGISTRY? - * - A Map of asset ID -> Asset - * - Stores immutable metadata about media files - * - Provides lookup functions for assets - * - * WHY A REGISTRY? - * - Centralized asset management - * - Multiple clips can reference the same asset - * - Asset duration is the source of truth for validation - * - * USAGE: - * ```typescript - * let state = createTimelineState({ timeline, assets: new Map() }); - * state = registerAsset(state, asset); - * const asset = getAsset(state, 'asset_1'); - * ``` - * - * ALL FUNCTIONS ARE PURE: - * - Take state as input - * - Return new state as output - * - Never mutate the input state + * Uses state.assetRegistry (ReadonlyMap). */ import { TimelineState } from '../types/state'; -import { Asset } from '../types/asset'; +import { Asset, AssetId, toAssetId } from '../types/asset'; -/** - * Register a new asset in the state - * - * Creates a new state with the asset added to the registry. - * If an asset with the same ID already exists, it will be replaced. - * - * @param state - Current timeline state - * @param asset - Asset to register - * @returns New timeline state with the asset registered - */ export function registerAsset(state: TimelineState, asset: Asset): TimelineState { - const newAssets = new Map(state.assets); - newAssets.set(asset.id, asset); - - return { - ...state, - assets: newAssets, - }; + const next = new Map(state.assetRegistry); + next.set(asset.id, asset); + return { ...state, assetRegistry: next }; } -/** - * Get an asset by ID - * - * @param state - Current timeline state - * @param assetId - ID of the asset to get - * @returns The asset, or undefined if not found - */ export function getAsset(state: TimelineState, assetId: string): Asset | undefined { - return state.assets.get(assetId); + return state.assetRegistry.get(toAssetId(assetId)); } -/** - * Check if an asset exists in the registry - * - * @param state - Current timeline state - * @param assetId - ID of the asset to check - * @returns true if the asset exists - */ export function hasAsset(state: TimelineState, assetId: string): boolean { - return state.assets.has(assetId); + return state.assetRegistry.has(toAssetId(assetId)); } -/** - * Get all assets in the registry - * - * @param state - Current timeline state - * @returns Array of all assets - */ export function getAllAssets(state: TimelineState): Asset[] { - return Array.from(state.assets.values()); + return Array.from(state.assetRegistry.values()); } -/** - * Remove an asset from the registry - * - * WARNING: This does not check if any clips reference this asset. - * You should validate that no clips reference this asset before removing it. - * - * @param state - Current timeline state - * @param assetId - ID of the asset to remove - * @returns New timeline state with the asset removed - */ export function unregisterAsset(state: TimelineState, assetId: string): TimelineState { - const newAssets = new Map(state.assets); - newAssets.delete(assetId); - - return { - ...state, - assets: newAssets, - }; + const next = new Map(state.assetRegistry); + next.delete(toAssetId(assetId)); + return { ...state, assetRegistry: next }; } diff --git a/packages/core/src/systems/clipboard.ts b/packages/core/src/systems/clipboard.ts deleted file mode 100644 index 0a01686..0000000 --- a/packages/core/src/systems/clipboard.ts +++ /dev/null @@ -1,193 +0,0 @@ -/** - * CLIPBOARD SYSTEM - * - * Copy/cut/paste operations with ID regeneration. - * - * DESIGN: - * - Clipboard data is external to timeline state - * - Paste generates new IDs for all clips - * - Preserves relative positions - * - Respects collision rules - * - Uses transactions for atomicity - */ - -import { TimelineState } from '../types/state'; -import { Clip } from '../types/clip'; -import { Frame } from '../types/frame'; -import { generateClipId } from '../utils/id'; -import { findClipById } from '../systems/queries'; -import { removeClip, addClip } from '../operations/clip-operations'; -import { beginTransaction, applyOperation, commitTransaction } from '../engine/transactions'; - -/** - * Clipboard data structure - */ -export interface ClipboardData { - /** Clips in the clipboard */ - clips: Clip[]; - - /** Relative positions (offset from first clip) */ - relativePositions: Frame[]; - - /** Timestamp when copied */ - timestamp: number; -} - -/** - * Copy clips to clipboard - * - * @param state - Timeline state - * @param clipIds - Clip IDs to copy - * @returns Clipboard data - */ -export function copyClips( - state: TimelineState, - clipIds: string[] -): ClipboardData { - if (clipIds.length === 0) { - throw new Error('No clips to copy'); - } - - // Find all clips - const clips: Clip[] = []; - for (const clipId of clipIds) { - const clip = findClipById(state, clipId); - if (!clip) { - throw new Error(`Clip not found: ${clipId}`); - } - clips.push(clip); - } - - // Sort by timeline start - clips.sort((a, b) => a.timelineStart - b.timelineStart); - - // Calculate relative positions from first clip - const firstClipStart = clips[0]!.timelineStart; - const relativePositions = clips.map(c => (c.timelineStart - firstClipStart) as Frame); - - return { - clips, - relativePositions, - timestamp: Date.now(), - }; -} - -/** - * Cut clips to clipboard - * - * Copies clips and removes them from timeline. - * - * @param state - Timeline state - * @param clipIds - Clip IDs to cut - * @returns New state and clipboard data - */ -export function cutClips( - state: TimelineState, - clipIds: string[] -): { state: TimelineState; clipboard: ClipboardData } { - // Copy first - const clipboard = copyClips(state, clipIds); - - // Remove clips using transaction - let tx = beginTransaction(state); - - for (const clipId of clipIds) { - tx = applyOperation(tx, s => removeClip(s, clipId)); - } - - const newState = commitTransaction(tx); - - return { state: newState, clipboard }; -} - -/** - * Paste clips from clipboard - * - * Generates new IDs for all clips and respects collision rules. - * - * @param state - Timeline state - * @param trackId - Track ID to paste into - * @param atFrame - Frame to paste at - * @param clipboard - Clipboard data - * @returns New state with clips pasted - */ -export function pasteClips( - state: TimelineState, - trackId: string, - atFrame: Frame, - clipboard: ClipboardData -): TimelineState { - if (clipboard.clips.length === 0) { - throw new Error('Clipboard is empty'); - } - - // Use transaction to paste all clips - let tx = beginTransaction(state); - - for (let i = 0; i < clipboard.clips.length; i++) { - const originalClip = clipboard.clips[i]!; - const relativePosition = clipboard.relativePositions[i]!; - - // Calculate new position - const newStart = (atFrame + relativePosition) as Frame; - const duration = originalClip.timelineEnd - originalClip.timelineStart; - const newEnd = (newStart + duration) as Frame; - - // Create new clip with new ID (omit linking/grouping) - const { linkGroupId: _, groupId: __, ...clipWithoutGroups } = originalClip; - const newClip: Clip = { - ...clipWithoutGroups, - id: generateClipId(), - trackId, - timelineStart: newStart, - timelineEnd: newEnd, - }; - - tx = applyOperation(tx, s => addClip(s, trackId, newClip)); - } - - return commitTransaction(tx); -} - -/** - * Duplicate clips - * - * Creates copies of clips with new IDs at an offset position. - * - * @param state - Timeline state - * @param clipIds - Clip IDs to duplicate - * @param offset - Frame offset for duplicates - * @returns New state with clips duplicated - */ -export function duplicateClips( - state: TimelineState, - clipIds: string[], - offset: Frame -): TimelineState { - if (clipIds.length === 0) { - throw new Error('No clips to duplicate'); - } - - // Use transaction to duplicate all clips - let tx = beginTransaction(state); - - for (const clipId of clipIds) { - const clip = findClipById(state, clipId); - if (!clip) { - throw new Error(`Clip not found: ${clipId}`); - } - - // Create duplicate with new ID and offset position (omit linking/grouping) - const { linkGroupId: _, groupId: __, ...clipWithoutGroups } = clip; - const duplicate: Clip = { - ...clipWithoutGroups, - id: generateClipId(), - timelineStart: (clip.timelineStart + offset) as Frame, - timelineEnd: (clip.timelineEnd + offset) as Frame, - }; - - tx = applyOperation(tx, s => addClip(s, clip.trackId, duplicate)); - } - - return commitTransaction(tx); -} diff --git a/packages/core/src/systems/drag-state.ts b/packages/core/src/systems/drag-state.ts deleted file mode 100644 index 066488b..0000000 --- a/packages/core/src/systems/drag-state.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * DRAG STATE MODEL - * - * Ephemeral drag preview calculations. - * Never stored in timeline state - lives in UI layer only. - * - * DESIGN: - * - Drag state is isolated from timeline state - * - Integrates snapping automatically - * - Pre-validates before commit - * - Pure calculations - no mutations - */ - -import { TimelineState } from '../types/state'; -import { Frame } from '../types/frame'; -import { Clip } from '../types/clip'; -import { SnapTarget, SnapResult, calculateSnapExcluding, findSnapTargets } from '../systems/snapping'; -import { findClipById } from '../systems/queries'; -import { validateClip, validateTrack } from '../systems/validation'; - -/** - * Drag state - ephemeral preview - */ -export interface DragState { - /** Clip being dragged */ - clipId: string; - - /** Original position before drag */ - originalStart: Frame; - originalEnd: Frame; - - /** Proposed position during drag */ - proposedStart: Frame; - proposedEnd: Frame; - - /** Whether snapping occurred */ - snapped: boolean; - - /** Snap target if snapped */ - snapTarget?: SnapTarget; - - /** Whether the proposed position is valid */ - valid: boolean; - - /** Validation errors if invalid */ - validationErrors?: string[]; -} - -/** - * Calculate drag preview - * - * Pure function - does not mutate state. - * - * @param state - Timeline state - * @param clipId - Clip being dragged - * @param proposedStart - Proposed start frame - * @param snapThreshold - Snap threshold in frames - * @param playhead - Optional playhead position for snapping - * @returns Drag state preview - */ -export function calculateDragPreview( - state: TimelineState, - clipId: string, - proposedStart: Frame, - snapThreshold: Frame, - playhead?: Frame -): DragState { - const clip = findClipById(state, clipId); - if (!clip) { - throw new Error(`Clip not found: ${clipId}`); - } - - const clipDuration = clip.timelineEnd - clip.timelineStart; - - // Find snap targets (excluding the clip being dragged) - const snapTargets = findSnapTargets(state, playhead); - - // Calculate snap for proposed start - const snapResult = calculateSnapExcluding( - proposedStart, - snapTargets, - snapThreshold, - [clipId] - ); - - // Use snapped position if snapping occurred - const finalStart = snapResult.snapped ? snapResult.snappedFrame : proposedStart; - const finalEnd = (finalStart + clipDuration) as Frame; - - // Create preview clip for validation - const previewClip: Clip = { - ...clip, - timelineStart: finalStart, - timelineEnd: finalEnd, - }; - - // Validate the proposed position - const validationErrors: string[] = []; - - // Check clip bounds - const clipResult = validateClip(state, previewClip); - if (!clipResult.valid) { - validationErrors.push(...clipResult.errors.map((e: any) => e.message)); - } - - // Check track collisions (create temporary state for validation) - const tempState: TimelineState = { - ...state, - timeline: { - ...state.timeline, - tracks: state.timeline.tracks.map(track => { - if (track.id === clip.trackId) { - return { - ...track, - clips: track.clips.map(c => c.id === clipId ? previewClip : c), - }; - } - return track; - }), - }, - }; - - // Find the track for validation - const track = tempState.timeline.tracks.find(t => t.id === clip.trackId); - if (track) { - const trackResult = validateTrack(tempState, track); - if (!trackResult.valid) { - validationErrors.push(...trackResult.errors.map((e: any) => e.message)); - } - } - - const result: DragState = { - clipId, - originalStart: clip.timelineStart, - originalEnd: clip.timelineEnd, - proposedStart: finalStart, - proposedEnd: finalEnd, - snapped: snapResult.snapped, - valid: validationErrors.length === 0, - }; - - // Only add optional properties if defined - if (snapResult.target !== undefined) { - result.snapTarget = snapResult.target; - } - if (validationErrors.length > 0) { - result.validationErrors = validationErrors; - } - - return result; -} - -/** - * Calculate drag preview for resize - * - * @param state - Timeline state - * @param clipId - Clip being resized - * @param proposedEnd - Proposed end frame - * @param snapThreshold - Snap threshold in frames - * @param playhead - Optional playhead position - * @returns Drag state preview - */ -export function calculateResizeDragPreview( - state: TimelineState, - clipId: string, - proposedEnd: Frame, - snapThreshold: Frame, - playhead?: Frame -): DragState { - const clip = findClipById(state, clipId); - if (!clip) { - throw new Error(`Clip not found: ${clipId}`); - } - - // Find snap targets - const snapTargets = findSnapTargets(state, playhead); - - // Calculate snap for proposed end - const snapResult = calculateSnapExcluding( - proposedEnd, - snapTargets, - snapThreshold, - [clipId] - ); - - const finalEnd = snapResult.snapped ? snapResult.snappedFrame : proposedEnd; - - // Create preview clip - const previewClip: Clip = { - ...clip, - timelineEnd: finalEnd, - }; - - // Validate - const validationErrors: string[] = []; - - const clipResult = validateClip(state, previewClip); - if (!clipResult.valid) { - validationErrors.push(...clipResult.errors.map((e: any) => e.message)); - } - - const result: DragState = { - clipId, - originalStart: clip.timelineStart, - originalEnd: clip.timelineEnd, - proposedStart: clip.timelineStart, - proposedEnd: finalEnd, - snapped: snapResult.snapped, - valid: validationErrors.length === 0, - }; - - // Only add optional properties if defined - if (snapResult.target !== undefined) { - result.snapTarget = snapResult.target; - } - if (validationErrors.length > 0) { - result.validationErrors = validationErrors; - } - - return result; -} diff --git a/packages/core/src/systems/grouping.ts b/packages/core/src/systems/grouping.ts deleted file mode 100644 index 7988093..0000000 --- a/packages/core/src/systems/grouping.ts +++ /dev/null @@ -1,372 +0,0 @@ -/** - * GROUPING SYSTEM - * - * Groups organize clips visually without affecting edit behavior. - * Unlike link groups, groups are for organization only. - * - * DESIGN: - * - Groups stored in TimelineState.groups - * - Clips reference group via groupId - * - Supports nested groups via parentGroupId - * - Pure functions - no mutations - * - * USAGE: - * ```typescript - * // Create group - * state = createGroup(state, ['clip1', 'clip2'], 'Scene 1'); - * - * // Get grouped clips - * const clips = getGroupClips(state, groupId); - * - * // Ungroup - * state = ungroupClips(state, groupId); - * ``` - */ - -import { TimelineState } from '../types/state'; -import { Group } from '../types/grouping'; -import { Clip } from '../types/clip'; -import { generateGroupId } from '../utils/id-phase2'; -import { findClipById, getAllClips } from './queries'; - -/** - * Create a group from multiple clips - * - * @param state - Timeline state - * @param clipIds - Clip IDs to group together - * @param name - Group name - * @param options - Optional group options - * @returns New state with group created - */ -export function createGroup( - state: TimelineState, - clipIds: string[], - name: string, - options?: { - parentGroupId?: string; - color?: string; - collapsed?: boolean; - } -): TimelineState { - if (clipIds.length < 1) { - throw new Error('Group must contain at least 1 clip'); - } - - // Verify all clips exist - for (const clipId of clipIds) { - const clip = findClipById(state, clipId); - if (!clip) { - throw new Error(`Clip not found: ${clipId}`); - } - } - - // Verify parent group exists if specified - if (options?.parentGroupId) { - const parentGroup = state.groups.get(options.parentGroupId); - if (!parentGroup) { - throw new Error(`Parent group not found: ${options.parentGroupId}`); - } - } - - // Generate group ID - const groupId = generateGroupId(); - - // Create group - const group: Group = { - id: groupId, - name, - clipIds: [...clipIds], - }; - - // Add optional properties only if defined - if (options?.parentGroupId !== undefined) { - group.parentGroupId = options.parentGroupId; - } - if (options?.color !== undefined) { - group.color = options.color; - } - if (options?.collapsed !== undefined) { - group.collapsed = options.collapsed; - } - - // Add group to state - const newGroups = new Map(state.groups); - newGroups.set(groupId, group); - - // Update clips with groupId - const newTracks = state.timeline.tracks.map(track => ({ - ...track, - clips: track.clips.map(clip => { - if (clipIds.includes(clip.id)) { - return { ...clip, groupId }; - } - return clip; - }), - })); - - return { - ...state, - groups: newGroups, - timeline: { - ...state.timeline, - tracks: newTracks, - }, - }; -} - -/** - * Ungroup clips - * - * Removes the group and clears groupId from all clips. - * - * @param state - Timeline state - * @param groupId - Group ID to ungroup - * @returns New state with group removed - */ -export function ungroupClips( - state: TimelineState, - groupId: string -): TimelineState { - const group = state.groups.get(groupId); - if (!group) { - throw new Error(`Group not found: ${groupId}`); - } - - // Remove group - const newGroups = new Map(state.groups); - newGroups.delete(groupId); - - // Also remove any child groups - for (const [childGroupId, childGroup] of state.groups) { - if (childGroup.parentGroupId === groupId) { - newGroups.delete(childGroupId); - } - } - - // Clear groupId from clips - const newTracks = state.timeline.tracks.map(track => ({ - ...track, - clips: track.clips.map(clip => { - if (clip.groupId === groupId) { - const { groupId: _, ...clipWithoutGroup } = clip; - return clipWithoutGroup as Clip; - } - return clip; - }), - })); - - return { - ...state, - groups: newGroups, - timeline: { - ...state.timeline, - tracks: newTracks, - }, - }; -} - -/** - * Get all clips in a group - * - * @param state - Timeline state - * @param groupId - Group ID - * @returns Array of clips in the group - */ -export function getGroupClips( - state: TimelineState, - groupId: string -): Clip[] { - const group = state.groups.get(groupId); - if (!group) { - return []; - } - - const allClips = getAllClips(state); - return allClips.filter(c => c.groupId === groupId); -} - -/** - * Check if a clip is grouped - * - * @param state - Timeline state - * @param clipId - Clip ID to check - * @returns True if clip is in a group - */ -export function isClipGrouped( - state: TimelineState, - clipId: string -): boolean { - const clip = findClipById(state, clipId); - return clip?.groupId !== undefined; -} - -/** - * Get group by ID - * - * @param state - Timeline state - * @param groupId - Group ID - * @returns Group or undefined - */ -export function getGroup( - state: TimelineState, - groupId: string -): Group | undefined { - return state.groups.get(groupId); -} - -/** - * Add clip to existing group - * - * @param state - Timeline state - * @param clipId - Clip ID to add - * @param groupId - Group ID to add to - * @returns New state with clip added to group - */ -export function addClipToGroup( - state: TimelineState, - clipId: string, - groupId: string -): TimelineState { - const clip = findClipById(state, clipId); - if (!clip) { - throw new Error(`Clip not found: ${clipId}`); - } - - const group = state.groups.get(groupId); - if (!group) { - throw new Error(`Group not found: ${groupId}`); - } - - // Update group - const newGroup: Group = { - ...group, - clipIds: [...group.clipIds, clipId], - }; - - const newGroups = new Map(state.groups); - newGroups.set(groupId, newGroup); - - // Update clip - const newTracks = state.timeline.tracks.map(track => ({ - ...track, - clips: track.clips.map(c => { - if (c.id === clipId) { - return { ...c, groupId }; - } - return c; - }), - })); - - return { - ...state, - groups: newGroups, - timeline: { - ...state.timeline, - tracks: newTracks, - }, - }; -} - -/** - * Remove clip from group - * - * @param state - Timeline state - * @param clipId - Clip ID to remove - * @returns New state with clip removed from group - */ -export function removeClipFromGroup( - state: TimelineState, - clipId: string -): TimelineState { - const clip = findClipById(state, clipId); - if (!clip || !clip.groupId) { - return state; // Clip not grouped - } - - const group = state.groups.get(clip.groupId); - if (!group) { - return state; // Group doesn't exist - } - - // Remove clip from group - const newClipIds = group.clipIds.filter(id => id !== clipId); - - const newGroups = new Map(state.groups); - newGroups.set(clip.groupId, { - ...group, - clipIds: newClipIds, - }); - - // Clear groupId from clip - const newTracks = state.timeline.tracks.map(track => ({ - ...track, - clips: track.clips.map(c => { - if (c.id === clipId) { - const { groupId: _, ...clipWithoutGroup } = c; - return clipWithoutGroup as Clip; - } - return c; - }), - })); - - return { - ...state, - groups: newGroups, - timeline: { - ...state.timeline, - tracks: newTracks, - }, - }; -} - -/** - * Rename a group - * - * @param state - Timeline state - * @param groupId - Group ID - * @param newName - New group name - * @returns New state with group renamed - */ -export function renameGroup( - state: TimelineState, - groupId: string, - newName: string -): TimelineState { - const group = state.groups.get(groupId); - if (!group) { - throw new Error(`Group not found: ${groupId}`); - } - - const newGroups = new Map(state.groups); - newGroups.set(groupId, { - ...group, - name: newName, - }); - - return { - ...state, - groups: newGroups, - }; -} - -/** - * Get all child groups of a parent group - * - * @param state - Timeline state - * @param parentGroupId - Parent group ID - * @returns Array of child groups - */ -export function getChildGroups( - state: TimelineState, - parentGroupId: string -): Group[] { - const childGroups: Group[] = []; - - for (const group of state.groups.values()) { - if (group.parentGroupId === parentGroupId) { - childGroups.push(group); - } - } - - return childGroups; -} diff --git a/packages/core/src/systems/linking.ts b/packages/core/src/systems/linking.ts deleted file mode 100644 index caa8ee9..0000000 --- a/packages/core/src/systems/linking.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * LINKING SYSTEM - * - * Link groups synchronize edits across multiple clips. - * When one clip in a link group is moved/deleted, all linked clips are affected. - * - * DESIGN: - * - Link groups stored in TimelineState.linkGroups - * - Clips reference group via linkGroupId - * - Pure functions - no mutations - * - Linked operations use transactions - * - * USAGE: - * ```typescript - * // Create link group - * state = createLinkGroup(state, ['clip1', 'clip2']); - * - * // Get linked clips - * const linked = getLinkedClips(state, 'clip1'); - * - * // Break link - * state = breakLinkGroup(state, linkGroupId); - * ``` - */ - -import { TimelineState } from '../types/state'; -import { LinkGroup } from '../types/linking'; -import { Clip } from '../types/clip'; -import { generateLinkGroupId } from '../utils/id-phase2'; -import { findClipById, getAllClips } from './queries'; - -/** - * Create a link group from multiple clips - * - * @param state - Timeline state - * @param clipIds - Clip IDs to link together - * @returns New state with link group created - */ -export function createLinkGroup( - state: TimelineState, - clipIds: string[] -): TimelineState { - if (clipIds.length < 2) { - throw new Error('Link group must contain at least 2 clips'); - } - - // Verify all clips exist - for (const clipId of clipIds) { - const clip = findClipById(state, clipId); - if (!clip) { - throw new Error(`Clip not found: ${clipId}`); - } - } - - // Generate link group ID - const linkGroupId = generateLinkGroupId(); - - // Create link group - const linkGroup: LinkGroup = { - id: linkGroupId, - clipIds: [...clipIds], - createdAt: Date.now(), - }; - - // Add link group to state - const newLinkGroups = new Map(state.linkGroups); - newLinkGroups.set(linkGroupId, linkGroup); - - // Update clips with linkGroupId - const newTracks = state.timeline.tracks.map(track => ({ - ...track, - clips: track.clips.map(clip => { - if (clipIds.includes(clip.id)) { - return { ...clip, linkGroupId }; - } - return clip; - }), - })); - - return { - ...state, - linkGroups: newLinkGroups, - timeline: { - ...state.timeline, - tracks: newTracks, - }, - }; -} - -/** - * Break a link group - * - * Removes the link group and clears linkGroupId from all clips. - * - * @param state - Timeline state - * @param linkGroupId - Link group ID to break - * @returns New state with link group removed - */ -export function breakLinkGroup( - state: TimelineState, - linkGroupId: string -): TimelineState { - const linkGroup = state.linkGroups.get(linkGroupId); - if (!linkGroup) { - throw new Error(`Link group not found: ${linkGroupId}`); - } - - // Remove link group - const newLinkGroups = new Map(state.linkGroups); - newLinkGroups.delete(linkGroupId); - - // Clear linkGroupId from clips - const newTracks = state.timeline.tracks.map(track => ({ - ...track, - clips: track.clips.map(clip => { - if (clip.linkGroupId === linkGroupId) { - const { linkGroupId: _, ...clipWithoutLink } = clip; - return clipWithoutLink as Clip; - } - return clip; - }), - })); - - return { - ...state, - linkGroups: newLinkGroups, - timeline: { - ...state.timeline, - tracks: newTracks, - }, - }; -} - -/** - * Get all clips in the same link group as the given clip - * - * @param state - Timeline state - * @param clipId - Clip ID to find linked clips for - * @returns Array of linked clips (including the original clip) - */ -export function getLinkedClips( - state: TimelineState, - clipId: string -): Clip[] { - const clip = findClipById(state, clipId); - if (!clip || !clip.linkGroupId) { - return clip ? [clip] : []; - } - - const linkGroup = state.linkGroups.get(clip.linkGroupId); - if (!linkGroup) { - return [clip]; - } - - // Find all clips in the link group - const allClips = getAllClips(state); - return allClips.filter(c => c.linkGroupId === clip.linkGroupId); -} - -/** - * Check if a clip is linked - * - * @param state - Timeline state - * @param clipId - Clip ID to check - * @returns True if clip is in a link group - */ -export function isClipLinked( - state: TimelineState, - clipId: string -): boolean { - const clip = findClipById(state, clipId); - return clip?.linkGroupId !== undefined; -} - -/** - * Get link group by ID - * - * @param state - Timeline state - * @param linkGroupId - Link group ID - * @returns Link group or undefined - */ -export function getLinkGroup( - state: TimelineState, - linkGroupId: string -): LinkGroup | undefined { - return state.linkGroups.get(linkGroupId); -} - -/** - * Add clip to existing link group - * - * @param state - Timeline state - * @param clipId - Clip ID to add - * @param linkGroupId - Link group ID to add to - * @returns New state with clip added to link group - */ -export function addClipToLinkGroup( - state: TimelineState, - clipId: string, - linkGroupId: string -): TimelineState { - const clip = findClipById(state, clipId); - if (!clip) { - throw new Error(`Clip not found: ${clipId}`); - } - - const linkGroup = state.linkGroups.get(linkGroupId); - if (!linkGroup) { - throw new Error(`Link group not found: ${linkGroupId}`); - } - - // Update link group - const newLinkGroup: LinkGroup = { - ...linkGroup, - clipIds: [...linkGroup.clipIds, clipId], - }; - - const newLinkGroups = new Map(state.linkGroups); - newLinkGroups.set(linkGroupId, newLinkGroup); - - // Update clip - const newTracks = state.timeline.tracks.map(track => ({ - ...track, - clips: track.clips.map(c => { - if (c.id === clipId) { - return { ...c, linkGroupId }; - } - return c; - }), - })); - - return { - ...state, - linkGroups: newLinkGroups, - timeline: { - ...state.timeline, - tracks: newTracks, - }, - }; -} - -/** - * Remove clip from link group - * - * If this leaves the link group with < 2 clips, the group is deleted. - * - * @param state - Timeline state - * @param clipId - Clip ID to remove - * @returns New state with clip removed from link group - */ -export function removeClipFromLinkGroup( - state: TimelineState, - clipId: string -): TimelineState { - const clip = findClipById(state, clipId); - if (!clip || !clip.linkGroupId) { - return state; // Clip not linked - } - - const linkGroup = state.linkGroups.get(clip.linkGroupId); - if (!linkGroup) { - return state; // Link group doesn't exist - } - - // Remove clip from link group - const newClipIds = linkGroup.clipIds.filter(id => id !== clipId); - - let newLinkGroups = new Map(state.linkGroups); - - // If link group has < 2 clips, delete it - if (newClipIds.length < 2) { - newLinkGroups.delete(clip.linkGroupId); - } else { - newLinkGroups.set(clip.linkGroupId, { - ...linkGroup, - clipIds: newClipIds, - }); - } - - // Clear linkGroupId from clip - const newTracks = state.timeline.tracks.map(track => ({ - ...track, - clips: track.clips.map(c => { - if (c.id === clipId) { - const { linkGroupId: _, ...clipWithoutLink } = c; - return clipWithoutLink as Clip; - } - return c; - }), - })); - - return { - ...state, - linkGroups: newLinkGroups, - timeline: { - ...state.timeline, - tracks: newTracks, - }, - }; -} diff --git a/packages/core/src/systems/queries.ts b/packages/core/src/systems/queries.ts index 0136615..6accd34 100644 --- a/packages/core/src/systems/queries.ts +++ b/packages/core/src/systems/queries.ts @@ -29,7 +29,7 @@ import { TimelineState } from '../types/state'; import { Clip, clipContainsFrame } from '../types/clip'; import { Track } from '../types/track'; -import { Frame } from '../types/frame'; +import type { TimelineFrame as Frame } from '../types/frame'; /** * Find a clip by ID @@ -70,7 +70,7 @@ export function findTrackById(state: TimelineState, trackId: string): Track | un */ export function getClipsOnTrack(state: TimelineState, trackId: string): Clip[] { const track = findTrackById(state, trackId); - return track ? track.clips : []; + return track ? Array.from(track.clips) : []; } /** @@ -143,7 +143,7 @@ export function getAllClips(state: TimelineState): Clip[] { * @param state - Current timeline state * @returns Array of all tracks */ -export function getAllTracks(state: TimelineState): Track[] { +export function getAllTracks(state: TimelineState): readonly Track[] { return state.timeline.tracks; } diff --git a/packages/core/src/systems/snapping.ts b/packages/core/src/systems/snapping.ts deleted file mode 100644 index c491141..0000000 --- a/packages/core/src/systems/snapping.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * SNAPPING SYSTEM - * - * Pure snapping calculations for professional editing feel. - * - * DESIGN PRINCIPLES: - * - Snapping is READ-ONLY (never mutates state) - * - Returns proposed snapped frame - * - Caller decides whether to use snapped value - * - Threshold in frames (not pixels) - * - Viewport conversion happens outside this system - * - * SNAP TARGETS: - * - Clip start/end positions - * - Timeline markers - * - Playhead position - * - Work area boundaries - * - * USAGE: - * ```typescript - * const targets = findSnapTargets(state, playhead); - * const result = calculateSnap(proposedFrame, targets, frame(5)); - * const finalFrame = result.snapped ? result.snappedFrame : result.originalFrame; - * ``` - */ - -import { TimelineState } from '../types/state'; -import { Frame } from '../types/frame'; -import { getAllClips } from './queries'; - -/** - * Snap target types - */ -export type SnapTarget = - | { type: 'clip-start'; clipId: string; frame: Frame } - | { type: 'clip-end'; clipId: string; frame: Frame } - | { type: 'timeline-marker'; markerId: string; frame: Frame } - | { type: 'playhead'; frame: Frame } - | { type: 'work-area-start'; frame: Frame } - | { type: 'work-area-end'; frame: Frame }; - -/** - * Snap result - */ -export interface SnapResult { - /** Whether snapping occurred */ - snapped: boolean; - - /** Original frame before snapping */ - originalFrame: Frame; - - /** Snapped frame (same as original if not snapped) */ - snappedFrame: Frame; - - /** Target that was snapped to (if snapped) */ - target?: SnapTarget; - - /** Distance to snap target in frames */ - distance: number; -} - -/** - * Find all snap targets in the timeline - * - * @param state - Timeline state - * @param playhead - Optional playhead position to include as target - * @returns Array of snap targets - */ -export function findSnapTargets( - state: TimelineState, - playhead?: Frame -): SnapTarget[] { - const targets: SnapTarget[] = []; - - // Add clip start/end positions - const clips = getAllClips(state); - for (const clip of clips) { - targets.push({ - type: 'clip-start', - clipId: clip.id, - frame: clip.timelineStart, - }); - - targets.push({ - type: 'clip-end', - clipId: clip.id, - frame: clip.timelineEnd, - }); - } - - // Add timeline markers - for (const marker of state.markers.timeline) { - targets.push({ - type: 'timeline-marker', - markerId: marker.id, - frame: marker.frame, - }); - } - - // Add playhead if provided - if (playhead !== undefined) { - targets.push({ - type: 'playhead', - frame: playhead, - }); - } - - // Add work area boundaries if defined - if (state.workArea) { - targets.push({ - type: 'work-area-start', - frame: state.workArea.startFrame, - }); - - targets.push({ - type: 'work-area-end', - frame: state.workArea.endFrame, - }); - } - - return targets; -} - -/** - * Calculate snap for a given frame - * - * Pure function - does not mutate state. - * - * @param frame - Frame to snap - * @param targets - Available snap targets - * @param threshold - Snap threshold in frames - * @returns Snap result - */ -export function calculateSnap( - frame: Frame, - targets: SnapTarget[], - threshold: Frame -): SnapResult { - let closestTarget: SnapTarget | undefined; - let closestDistance = Infinity; - - // Find closest target within threshold - for (const target of targets) { - const distance = Math.abs(target.frame - frame); - - if (distance <= threshold && distance < closestDistance) { - closestTarget = target; - closestDistance = distance; - } - } - - // Return snap result - if (closestTarget) { - return { - snapped: true, - originalFrame: frame, - snappedFrame: closestTarget.frame, - target: closestTarget, - distance: closestDistance, - }; - } - - // No snap - return { - snapped: false, - originalFrame: frame, - snappedFrame: frame, - distance: 0, - }; -} - -/** - * Calculate snap excluding specific clips - * - * Useful when dragging a clip - don't snap to itself. - * - * @param frame - Frame to snap - * @param targets - Available snap targets - * @param threshold - Snap threshold in frames - * @param excludeClipIds - Clip IDs to exclude from snapping - * @returns Snap result - */ -export function calculateSnapExcluding( - frame: Frame, - targets: SnapTarget[], - threshold: Frame, - excludeClipIds: string[] -): SnapResult { - // Filter out excluded clips - const filteredTargets = targets.filter(target => { - if (target.type === 'clip-start' || target.type === 'clip-end') { - return !excludeClipIds.includes(target.clipId); - } - return true; - }); - - return calculateSnap(frame, filteredTargets, threshold); -} - -/** - * Find snap targets for a specific track - * - * Useful for track-specific snapping. - * - * @param state - Timeline state - * @param trackId - Track ID to find targets for - * @param playhead - Optional playhead position - * @returns Array of snap targets - */ -export function findSnapTargetsForTrack( - state: TimelineState, - trackId: string, - playhead?: Frame -): SnapTarget[] { - const targets: SnapTarget[] = []; - - // Find track - const track = state.timeline.tracks.find(t => t.id === trackId); - if (!track) return targets; - - // Add clip start/end positions from this track only - for (const clip of track.clips) { - targets.push({ - type: 'clip-start', - clipId: clip.id, - frame: clip.timelineStart, - }); - - targets.push({ - type: 'clip-end', - clipId: clip.id, - frame: clip.timelineEnd, - }); - } - - // Add timeline markers (always relevant) - for (const marker of state.markers.timeline) { - targets.push({ - type: 'timeline-marker', - markerId: marker.id, - frame: marker.frame, - }); - } - - // Add playhead if provided - if (playhead !== undefined) { - targets.push({ - type: 'playhead', - frame: playhead, - }); - } - - // Add work area boundaries if defined - if (state.workArea) { - targets.push({ - type: 'work-area-start', - frame: state.workArea.startFrame, - }); - - targets.push({ - type: 'work-area-end', - frame: state.workArea.endFrame, - }); - } - - return targets; -} diff --git a/packages/core/src/systems/validation.ts b/packages/core/src/systems/validation.ts index 2d73320..327565f 100644 --- a/packages/core/src/systems/validation.ts +++ b/packages/core/src/systems/validation.ts @@ -19,7 +19,7 @@ * - Asset must exist in registry * - timelineEnd > timelineStart * - mediaOut > mediaIn - * - mediaOut <= asset.duration + * - mediaOut <= asset.intrinsicDuration * - Timeline duration === media duration (Phase 1, no speed) * * TRACK VALIDATION: @@ -103,11 +103,11 @@ export function validateClip(state: TimelineState, clip: Clip): ValidationResult } // Check media bounds don't exceed asset duration - if (clip.mediaOut > asset.duration) { + if (clip.mediaOut > asset.intrinsicDuration) { errors.push(invalidResult( 'MEDIA_EXCEEDS_ASSET', - `Clip media out (${clip.mediaOut}) exceeds asset duration (${asset.duration})`, - { clipId: clip.id, mediaOut: clip.mediaOut, assetDuration: asset.duration } + `Clip media out (${clip.mediaOut}) exceeds asset duration (${asset.intrinsicDuration})`, + { clipId: clip.id, mediaOut: clip.mediaOut, assetDuration: asset.intrinsicDuration } )); } @@ -286,44 +286,33 @@ export function validateTrackTypeMatch( // Check if asset type matches track type // Note: 'image' assets can go on 'video' tracks - if (asset.type === 'video' && targetTrack.type !== 'video') { + if (asset.mediaType === 'video' && targetTrack.type !== 'video') { return invalidResult( 'TRACK_TYPE_MISMATCH', `Cannot place video clip '${clip.id}' on ${targetTrack.type} track '${targetTrack.id}'`, { clipId: clip.id, - assetType: asset.type, + assetType: asset.mediaType, trackType: targetTrack.type, trackId: targetTrack.id, } ); } - if (asset.type === 'audio' && targetTrack.type !== 'audio') { + if (asset.mediaType === 'audio' && targetTrack.type !== 'audio') { return invalidResult( 'TRACK_TYPE_MISMATCH', `Cannot place audio clip '${clip.id}' on ${targetTrack.type} track '${targetTrack.id}'`, { clipId: clip.id, - assetType: asset.type, + assetType: asset.mediaType, trackType: targetTrack.type, trackId: targetTrack.id, } ); } - if (asset.type === 'image' && targetTrack.type !== 'video') { - return invalidResult( - 'TRACK_TYPE_MISMATCH', - `Cannot place image clip '${clip.id}' on ${targetTrack.type} track '${targetTrack.id}'`, - { - clipId: clip.id, - assetType: asset.type, - trackType: targetTrack.type, - trackId: targetTrack.id, - } - ); - } + // 'image' asset support: Phase 3. Treat as video-track compatible for now. return validResult(); } diff --git a/packages/core/src/tools/hand.ts b/packages/core/src/tools/hand.ts new file mode 100644 index 0000000..fa6eeb3 --- /dev/null +++ b/packages/core/src/tools/hand.ts @@ -0,0 +1,137 @@ +/** + * HandTool — Phase 2 Step 8 + * + * Scroll/pan the timeline viewport by dragging. + * This tool has ZERO effect on TimelineState. + * + * - Never produces a Transaction (onPointerUp returns null always) + * - Never calls dispatch + * - Never returns ProvisionalState (onPointerMove returns null always) + * - Never creates ClipIds (_setIdGenerator not needed) + * + * THE SCROLL CALLBACK: + * The UI registers a callback via setScrollCallback(). + * On every onPointerMove during drag, HandTool fires: + * scrollCallback(event.x - lastX) ← pixel delta, not frame delta + * The UI handles scrollLeft adjustment. HandTool has no DOM access. + * + * The callback is optional — drag tracking activates regardless. + * If no callback is registered, delta is computed but discarded. + * This allows testing drag tracking without a live callback. + * + * RULES: + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui + * - Every instance variable appears in onCancel() + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { + toToolId, + type ToolId, + type SnapPointType, +} from './types'; +import type { Transaction } from '../types/operations'; + +// --------------------------------------------------------------------------- +// HandTool +// --------------------------------------------------------------------------- + +export class HandTool implements ITool { + readonly id: ToolId = toToolId('hand'); + readonly shortcutKey: string = 'h'; // standard NLE convention + + // ── Scroll callback ──────────────────────────────────────────────────────── + /** + * Registered by the UI layer once at mount. Persists across drags and + * cancels — not per-drag state. Pass null to unregister. + */ + private scrollCallback: ((deltaX: number) => void) | null = null; + + // ── Drag-tracking ────────────────────────────────────────────────────────── + /** Gates delta computation and cursor. */ + private isDragging: boolean = false; + + /** + * X position (pixels) at the last pointer event. + * Delta is event-to-event (not from start): deltaX = event.x - lastX. + * Incremental delta is what UI scroll handlers expect (scrollLeft += deltaX). + */ + private lastX: number = 0; + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Register the UI's scroll handler. Called once at mount, not per-drag. + * @param cb Receives pixel deltaX per move event. Pass null to unregister. + */ + setScrollCallback(cb: ((deltaX: number) => void) | null): void { + this.scrollCallback = cb; + } + + // ── ITool: getCursor ────────────────────────────────────────────────────── + + getCursor(_ctx: ToolContext): string { + return this.isDragging ? 'grabbing' : 'grab'; + // No 'default' — HandTool always shows grab intent + } + + // ── ITool: getSnapCandidateTypes ───────────────────────────────────────── + + getSnapCandidateTypes(): readonly SnapPointType[] { + return []; // pure scroll, no snapping + } + + // ── ITool: onPointerDown ────────────────────────────────────────────────── + + onPointerDown(event: TimelinePointerEvent, _ctx: ToolContext): void { + this.isDragging = true; + this.lastX = event.x; + // scrollCallback may be null — drag tracking still activates. + // Delta will be computed but discarded if no callback is registered. + } + + // ── ITool: onPointerMove ────────────────────────────────────────────────── + + onPointerMove(event: TimelinePointerEvent, _ctx: ToolContext): ProvisionalState | null { + if (!this.isDragging) return null; + + const deltaX = event.x - this.lastX; + this.scrollCallback?.(deltaX); // no-op if null + this.lastX = event.x; + + return null; // scroll is not a ProvisionalState concern + } + + // ── ITool: onPointerUp ──────────────────────────────────────────────────── + + onPointerUp(_event: TimelinePointerEvent, _ctx: ToolContext): Transaction | null { + this.isDragging = false; + this.lastX = 0; + return null; // always — HandTool never produces a Transaction + } + + // ── ITool: onKeyDown / onKeyUp ──────────────────────────────────────────── + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; // always — no keyboard interactions + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + // ── ITool: onCancel ─────────────────────────────────────────────────────── + /** + * Resets per-drag state only. + * scrollCallback is NOT cleared — it persists across cancels. + * Re-registering on every cancelled drag would be unnecessary UI burden. + */ + onCancel(): void { + this.isDragging = false; + this.lastX = 0; + } +} diff --git a/packages/core/src/tools/keyframe-tool.ts b/packages/core/src/tools/keyframe-tool.ts new file mode 100644 index 0000000..4c030b6 --- /dev/null +++ b/packages/core/src/tools/keyframe-tool.ts @@ -0,0 +1,272 @@ +/** + * KeyframeTool (Pen tool) — Phase 4 Step 4 + * + * Click on a clip's effect lane to add a keyframe. + * Click an existing keyframe to delete (via Delete key). Drag keyframe to move. + * + * RULES: + * - onPointerMove never dispatches; returns ProvisionalState for preview + * - onPointerUp never mutates instance state (capture-before-reset) + * - Every instance variable reset in onCancel() + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { toToolId, type ToolId, type SnapPointType } from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { EffectId } from '../types/effect'; +import type { KeyframeId, Keyframe } from '../types/keyframe'; +import type { TimelineState } from '../types/state'; +import type { Transaction } from '../types/operations'; +import type { TimelineFrame } from '../types/frame'; +import { toFrame } from '../types/frame'; +import { toKeyframeId } from '../types/keyframe'; +import { LINEAR_EASING } from '../types/easing'; +import { applyOperation } from '../engine/apply'; +import { nearest } from '../snap-index'; + +const KEYFRAME_HIT_RADIUS_PX = 6; +const SNAP_RADIUS_FRAMES = 5; + +function findClip(state: TimelineState, clipId: ClipId): Clip | undefined { + for (const track of state.timeline.tracks) { + const c = track.clips.find((c) => c.id === clipId); + if (c) return c; + } + return undefined; +} + +function findKeyframeAt( + clip: Clip, + x: number, + pixelsPerFrame: number, +): { effectId: EffectId; keyframe: Keyframe } | null { + const effects = clip.effects ?? []; + for (const effect of effects) { + for (const kf of effect.keyframes) { + const kfPx = kf.frame * pixelsPerFrame; + if (Math.abs(x - kfPx) <= KEYFRAME_HIT_RADIUS_PX) { + return { effectId: effect.id, keyframe: kf }; + } + } + } + return null; +} + +let _txSeq = 0; +function txId(): string { + return `keyframe-tx-${++_txSeq}`; +} + +export class KeyframeTool implements ITool { + readonly id: ToolId = toToolId('keyframe'); + readonly shortcutKey: string = 'P'; + + private draggingKeyframe: { + clipId: ClipId; + effectId: EffectId; + keyframeId: KeyframeId; + startX: number; + startFrame: TimelineFrame; + } | null = null; + private activeClipId: ClipId | null = null; + private activeEffectId: EffectId | null = null; + private pendingAddKeyframe: { + clipId: ClipId; + effectId: EffectId; + targetFrame: TimelineFrame; + } | null = null; + + getCursor(_ctx: ToolContext): string { + return 'crosshair'; + } + + getSnapCandidateTypes(): readonly SnapPointType[] { + return ['ClipStart', 'ClipEnd', 'Marker', 'BeatGrid']; + } + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void { + if (event.clipId === null) return; + + const clip = findClip(ctx.state, event.clipId); + if (!clip) return; + + const effects = clip.effects ?? []; + if (effects.length === 0) return; + + const hitKf = findKeyframeAt(clip, event.x, ctx.pixelsPerFrame); + if (hitKf) { + this.draggingKeyframe = { + clipId: clip.id, + effectId: hitKf.effectId, + keyframeId: hitKf.keyframe.id, + startX: event.x, + startFrame: hitKf.keyframe.frame, + }; + return; + } + + const firstEffect = effects[0]!; + let targetFrame = ctx.frameAtX(event.x) as TimelineFrame; + if (ctx.snapIndex.enabled) { + const snapPoint = nearest( + ctx.snapIndex, + targetFrame, + SNAP_RADIUS_FRAMES, + undefined, + ['ClipStart', 'ClipEnd', 'Marker', 'BeatGrid'], + ); + if (snapPoint) targetFrame = snapPoint.frame as TimelineFrame; + } + this.activeClipId = clip.id; + this.activeEffectId = firstEffect.id; + this.pendingAddKeyframe = { + clipId: clip.id, + effectId: firstEffect.id, + targetFrame, + }; + } + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null { + if (this.draggingKeyframe === null) return null; + + const clip = findClip(ctx.state, this.draggingKeyframe.clipId); + if (!clip) return null; + + const dragDeltaX = event.x - this.draggingKeyframe.startX; + const deltaFrames = Math.round(dragDeltaX / ctx.pixelsPerFrame); + let newFrame = Math.max(0, this.draggingKeyframe.startFrame + deltaFrames) as TimelineFrame; + if (ctx.snapIndex.enabled) { + const snapPoint = nearest( + ctx.snapIndex, + newFrame, + SNAP_RADIUS_FRAMES, + undefined, + ['ClipStart', 'ClipEnd', 'Marker', 'BeatGrid'], + ); + if (snapPoint) newFrame = snapPoint.frame as TimelineFrame; + } + + const nextState = applyOperation(ctx.state, { + type: 'MOVE_KEYFRAME', + clipId: this.draggingKeyframe.clipId, + effectId: this.draggingKeyframe.effectId, + keyframeId: this.draggingKeyframe.keyframeId, + newFrame, + }); + + const updatedClip = nextState.timeline.tracks + .flatMap((t) => t.clips) + .find((c) => c.id === this.draggingKeyframe!.clipId); + if (!updatedClip) return null; + + return { + clips: [updatedClip], + isProvisional: true, + }; + } + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + const dragging = this.draggingKeyframe; + const pendingAdd = this.pendingAddKeyframe; + + this.draggingKeyframe = null; + this.activeClipId = null; + this.activeEffectId = null; + this.pendingAddKeyframe = null; + + if (pendingAdd !== null) { + return { + id: txId(), + label: 'Add keyframe', + timestamp: Date.now(), + operations: [ + { + type: 'ADD_KEYFRAME', + clipId: pendingAdd.clipId, + effectId: pendingAdd.effectId, + keyframe: { + id: toKeyframeId(`kf-${Date.now()}`), + frame: pendingAdd.targetFrame, + value: 1.0, + easing: LINEAR_EASING, + }, + }, + ], + }; + } + + if (dragging === null) return null; + + const dragDeltaX = event.x - dragging.startX; + const deltaFrames = Math.round(dragDeltaX / ctx.pixelsPerFrame); + let newFrame = Math.max(0, dragging.startFrame + deltaFrames) as TimelineFrame; + if (ctx.snapIndex.enabled) { + const snapPoint = nearest( + ctx.snapIndex, + newFrame, + SNAP_RADIUS_FRAMES, + undefined, + ['ClipStart', 'ClipEnd', 'Marker', 'BeatGrid'], + ); + if (snapPoint) newFrame = snapPoint.frame as TimelineFrame; + } + + if (newFrame === dragging.startFrame) return null; + + return { + id: txId(), + label: 'Move keyframe', + timestamp: Date.now(), + operations: [ + { + type: 'MOVE_KEYFRAME', + clipId: dragging.clipId, + effectId: dragging.effectId, + keyframeId: dragging.keyframeId, + newFrame, + }, + ], + }; + } + + onKeyDown(event: TimelineKeyEvent, ctx: ToolContext): Transaction | null { + if (event.key !== 'Delete' && event.key !== 'Backspace') return null; + + const dragging = this.draggingKeyframe; + this.draggingKeyframe = null; + this.activeClipId = null; + this.activeEffectId = null; + this.pendingAddKeyframe = null; + + if (dragging === null) return null; + + return { + id: txId(), + label: 'Delete keyframe', + timestamp: Date.now(), + operations: [ + { + type: 'DELETE_KEYFRAME', + clipId: dragging.clipId, + effectId: dragging.effectId, + keyframeId: dragging.keyframeId, + }, + ], + }; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + onCancel(): void { + this.draggingKeyframe = null; + this.activeClipId = null; + this.activeEffectId = null; + this.pendingAddKeyframe = null; + } +} diff --git a/packages/core/src/tools/provisional.ts b/packages/core/src/tools/provisional.ts new file mode 100644 index 0000000..cbaab50 --- /dev/null +++ b/packages/core/src/tools/provisional.ts @@ -0,0 +1,89 @@ +/** + * PROVISIONAL MANAGER — Phase 1 + * + * Manages ghost state during pointer drags. + * + * RULES (from ITOOL_CONTRACT.md): + * - setProvisional / clearProvisional return NEW objects — never mutate + * - resolveClip checks provisional first, then committed state + * - The engine calls clearProvisional() BEFORE dispatching onPointerUp's tx + * - Provisional updates trigger notify() so ghosts render immediately + * + * resolveClip priority: + * 1. provisional.clips has a clip with this id → return ghost version + * 2. clip exists in committed state → return committed + * 3. clip absent from both (deleted mid-drag) → return undefined + */ + +import type { ClipId, Clip } from '../types/clip'; +import type { TimelineState } from '../types/state'; +import type { ProvisionalState } from './types'; + +// --------------------------------------------------------------------------- +// ProvisionalManager +// --------------------------------------------------------------------------- + +export type ProvisionalManager = { + readonly current: ProvisionalState | null; +}; + +// --------------------------------------------------------------------------- +// Functions +// --------------------------------------------------------------------------- + +/** Create an empty provisional manager (current = null). */ +export function createProvisionalManager(): ProvisionalManager { + return { current: null }; +} + +/** Return a new manager with current set to state. + * Pure — never mutates the original manager. */ +export function setProvisional( + _manager: ProvisionalManager, + state: ProvisionalState, +): ProvisionalManager { + return { current: state }; +} + +/** Return a new manager with current set to null. + * Pure — never mutates the original manager. */ +export function clearProvisional(_manager: ProvisionalManager): ProvisionalManager { + return { current: null }; +} + +/** + * Resolve which version of a clip to render. + * + * Priority: + * 1. If manager.current has a clip with this id → return provisional (ghost) + * 2. Otherwise → search committed state + * 3. If absent from both (clip deleted mid-drag) → return undefined + * + * Returns undefined if the clip has been deleted from committed state + * and is not in provisional. Components must handle this: + * const clip = useClip(id) + * if (!clip) return null ← required, not optional + * + * Call site in useClip selector: + * () => resolveClip(id, engine.getSnapshot(), engine.getProvisionalManager()) + */ +export function resolveClip( + clipId: ClipId, + state: TimelineState, + manager: ProvisionalManager, +): Clip | undefined { + // Priority 1 — provisional ghost + if (manager.current !== null) { + const ghost = manager.current.clips.find(c => c.id === clipId); + if (ghost) return ghost; + } + + // Priority 2 — committed state + for (const track of state.timeline.tracks) { + const clip = track.clips.find(c => c.id === clipId); + if (clip) return clip; + } + + // Priority 3 — absent from both + return undefined; +} diff --git a/packages/core/src/tools/razor.ts b/packages/core/src/tools/razor.ts new file mode 100644 index 0000000..9319f4d --- /dev/null +++ b/packages/core/src/tools/razor.ts @@ -0,0 +1,263 @@ +/** + * RazorTool — Phase 2 Step 2 + * + * Click on a clip at any frame → split it into two clips at that frame. + * Shift+click (ctx.modifiers.shift) → split ALL clips at that frame across ALL tracks. + * + * CONTRACT: + * - No drag, no provisional ghost, no rubber-band + * - onPointerMove always returns null + * - Every Transaction is DELETE_CLIP + INSERT_CLIP(left) + INSERT_CLIP(right), per clip + * - New ClipIds are generated by generateId() — replaceable in tests via _setIdGenerator() + * + * RULES: + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui + * - ctx.modifiers.shift is the authoritative source for shift detection (not event.shiftKey) + * - Both halves need strictly positive duration — reject if atFrame is at clip boundary + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { + toToolId, + type ToolId, + type SnapPointType, +} from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { TrackId } from '../types/track'; +import type { TimelineFrame } from '../types/frame'; +import type { Transaction } from '../types/operations'; + +// --------------------------------------------------------------------------- +// ID generator (replaceable in tests — see _setIdGenerator below) +// --------------------------------------------------------------------------- + +/** Production default: crypto.randomUUID() */ +let generateId: () => string = () => crypto.randomUUID(); + +/** + * Override the ID generator in tests for deterministic IDs. + * Not part of the public API — exported only for test use. + * + * @example + * ```typescript + * let counter = 0; + * beforeEach(() => { counter = 0; _setIdGenerator(() => `test-id-${++counter}`); }); + * afterEach(() => { _setIdGenerator(() => crypto.randomUUID()); }); + * ``` + */ +export function _setIdGenerator(fn: () => string): void { + generateId = fn; +} + +// --------------------------------------------------------------------------- +// computeSlice — pure module-level helper, not exported +// --------------------------------------------------------------------------- + +/** + * Compute the two clip halves produced by slicing `clip` at `atFrame`. + * + * Returns `null` if `atFrame` is not strictly inside the clip's timeline bounds + * (i.e. atFrame <= timelineStart or atFrame >= timelineEnd), which would produce + * a zero-duration half. + * + * The caller is responsible for asserting checkInvariants on the resulting state. + */ +function computeSlice( + clip: Clip, + atFrame: TimelineFrame, +): { left: Clip; right: Clip } | null { + // Guard: atFrame must be strictly inside the clip + if (atFrame <= clip.timelineStart || atFrame >= clip.timelineEnd) { + return null; + } + + const offset = (atFrame - clip.timelineStart) as TimelineFrame; + const splitMediaPoint = (clip.mediaIn + offset) as TimelineFrame; + + const left: Clip = { + ...clip, + id: generateId() as ClipId, + timelineEnd: atFrame, + mediaOut: splitMediaPoint, + }; + + const right: Clip = { + ...clip, + id: generateId() as ClipId, + timelineStart: atFrame, + mediaIn: splitMediaPoint, + }; + + // left.mediaOut === right.mediaIn === splitMediaPoint ✓ + return { left, right }; +} + +// --------------------------------------------------------------------------- +// Transaction ID counter +// --------------------------------------------------------------------------- + +let _txSeq = 0; +function txId(): string { return `razor-tx-${++_txSeq}`; } + +// --------------------------------------------------------------------------- +// RazorTool +// --------------------------------------------------------------------------- + +export class RazorTool implements ITool { + readonly id: ToolId = toToolId('razor'); + readonly shortcutKey: string = 'b'; // 'b' for blade — standard NLE shortcut + + // ── Instance variables ──────────────────────────────────────────────────── + // Two vars only. The slice happens at pointerUp, not pointerDown. + // We store the snapped frame at down-time so we don't re-snap at up-time. + + /** Snapped slice frame captured at onPointerDown. */ + private pendingFrame: TimelineFrame | null = null; + + /** ClipId hit at onPointerDown. null if clicking empty space or for shift+all. */ + private pendingClipId: ClipId | null = null; + + // ── ITool: getCursor ────────────────────────────────────────────────────── + + getCursor(_ctx: ToolContext): string { + return 'crosshair'; // always — no state-dependent cursor changes + } + + // ── ITool: getSnapCandidateTypes ───────────────────────────────────────── + + getSnapCandidateTypes(): readonly SnapPointType[] { + return ['ClipStart', 'ClipEnd', 'Playhead', 'Marker']; + } + + // ── ITool: onPointerDown ────────────────────────────────────────────────── + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void { + // Snap the frame at click time, excluding the clicked clip's own edges + // (snapping to its own start/end would produce a zero-duration half) + const exclusion = event.clipId !== null ? [event.clipId] : []; + this.pendingFrame = ctx.snap(event.frame, exclusion); + this.pendingClipId = event.clipId; + } + + // ── ITool: onPointerMove ────────────────────────────────────────────────── + + onPointerMove(_event: TimelinePointerEvent, _ctx: ToolContext): ProvisionalState | null { + // The cursor line (blade preview) is a UI rendering concern — not ProvisionalState. + // Tools never manage cursor-line rendering directly. + return null; + } + + // ── ITool: onPointerUp ──────────────────────────────────────────────────── + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + // Capture instance state before resetting (pattern from SelectionTool) + const atFrame = this.pendingFrame; + const clipId = this.pendingClipId; + + // Reset immediately — onPointerUp must never leave stale state + this.pendingFrame = null; + this.pendingClipId = null; + + if (atFrame === null) return null; + + // ── Shift+click: slice ALL clips at atFrame across ALL tracks ────────── + if (ctx.modifiers.shift) { + return this._sliceAllTracks(atFrame, ctx); + } + + // ── Single clip slice ────────────────────────────────────────────────── + if (clipId === null) return null; // clicked empty space, no shift + + const clip = this._findClip(clipId, ctx); + if (!clip) return null; + + const sliced = computeSlice(clip, atFrame); + if (!sliced) return null; // atFrame at boundary — would produce zero-duration half + + return { + id: txId(), + label: 'Razor', + timestamp: Date.now(), + operations: [ + { type: 'DELETE_CLIP', clipId: clip.id }, + { type: 'INSERT_CLIP', clip: sliced.left, trackId: clip.trackId }, + { type: 'INSERT_CLIP', clip: sliced.right, trackId: clip.trackId }, + ], + }; + } + + // ── ITool: onKeyDown / onKeyUp ──────────────────────────────────────────── + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + // ── ITool: onCancel ─────────────────────────────────────────────────────── + /** + * Reset ALL instance state. + * Every instance variable must appear here. + */ + onCancel(): void { + this.pendingFrame = null; + this.pendingClipId = null; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private _findClip(clipId: ClipId, ctx: ToolContext): Clip | undefined { + for (const track of ctx.state.timeline.tracks) { + const clip = track.clips.find(c => c.id === clipId); + if (clip) return clip; + } + return undefined; + } + + /** + * Slice every clip that contains `atFrame` across all tracks. + * Groups operations per-clip: DELETE then INSERT left then INSERT right. + * Tracks where no clip spans `atFrame` contribute zero operations. + */ + private _sliceAllTracks( + atFrame: TimelineFrame, + ctx: ToolContext, + ): Transaction | null { + type Op = + | { type: 'DELETE_CLIP'; clipId: ClipId } + | { type: 'INSERT_CLIP'; clip: Clip; trackId: TrackId }; + + const operations: Op[] = []; + + for (const track of ctx.state.timeline.tracks) { + for (const clip of track.clips) { + // Only slice clips that strictly contain atFrame + const sliced = computeSlice(clip, atFrame); + if (!sliced) continue; // atFrame outside this clip's bounds — skip + + operations.push( + { type: 'DELETE_CLIP', clipId: clip.id }, + { type: 'INSERT_CLIP', clip: sliced.left, trackId: track.id }, + { type: 'INSERT_CLIP', clip: sliced.right, trackId: track.id }, + ); + } + } + + if (operations.length === 0) return null; // nothing was sliced + + const clipCount = operations.length / 3; // always a multiple of 3 + + return { + id: txId(), + label: `Razor — All Tracks (${clipCount} clip${clipCount === 1 ? '' : 's'})`, + timestamp: Date.now(), + operations, + }; + } +} diff --git a/packages/core/src/tools/registry.ts b/packages/core/src/tools/registry.ts new file mode 100644 index 0000000..e621757 --- /dev/null +++ b/packages/core/src/tools/registry.ts @@ -0,0 +1,170 @@ +/** + * TOOL REGISTRY — Phase 1 + * + * Pure functions. No classes. No React. No state mutation. + * + * ToolRegistry is immutable data — activateTool returns a NEW registry. + * The active tool lives here, not on TimelineEngine, keeping the engine thin. + * + * RULES: + * - activateTool calls outgoing.onCancel() before switching + * - activateTool throws on unknown id (programmer error, never user error) + * - NoOpTool is the canonical do-nothing ITool (test double + startup default) + */ + +import type { + ITool, + ToolId, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, + SnapPointType, +} from './types'; +import { toToolId } from './types'; + +// --------------------------------------------------------------------------- +// ToolRegistry +// --------------------------------------------------------------------------- + +export type ToolRegistry = { + readonly tools: ReadonlyMap; + readonly activeToolId: ToolId; +}; + +// --------------------------------------------------------------------------- +// Pure functions +// --------------------------------------------------------------------------- + +/** + * Create an initial registry from an array of tools. + * + * @throws if defaultId is not present in the tools array + */ +export function createRegistry( + tools: readonly ITool[], + defaultId: ToolId, +): ToolRegistry { + const map = new Map(); + for (const tool of tools) { + map.set(tool.id, tool); + } + if (!map.has(defaultId)) { + throw new Error( + `createRegistry: defaultId "${defaultId}" not found in tools. ` + + `Available: [${[...map.keys()].join(', ')}]`, + ); + } + return { tools: map, activeToolId: defaultId }; +} + +/** + * Activate a new tool. + * + * Steps (must run in order): + * 1. Call outgoing tool's onCancel() — cleans up any in-progress drag state + * 2. Validate that the new id exists in the registry + * 3. Return a new ToolRegistry with activeToolId updated + * + * @throws if id is not registered + */ +export function activateTool( + registry: ToolRegistry, + id: ToolId, +): ToolRegistry { + // Step 1 — notify outgoing tool (idempotent, always safe to call) + getActiveTool(registry).onCancel(); + + // Step 2 — validate + if (!registry.tools.has(id)) { + throw new Error( + `activateTool: unknown toolId "${id}". ` + + `Registered: [${[...registry.tools.keys()].join(', ')}]`, + ); + } + + // Step 3 — return updated registry (new object, no mutation) + return { ...registry, activeToolId: id }; +} + +/** + * Return the currently active ITool. + * Never returns undefined — registry invariant guarantees activeToolId is registered. + */ +export function getActiveTool(registry: ToolRegistry): ITool { + const tool = registry.tools.get(registry.activeToolId); + if (!tool) { + // This should never happen if createRegistry and activateTool are used correctly. + throw new Error( + `getActiveTool: activeToolId "${registry.activeToolId}" is not registered. ` + + `Registry is corrupt.`, + ); + } + return tool; +} + +/** + * Return a new registry with the tool added. + * If a tool with the same id already exists, it is replaced. + * activeToolId is unchanged. + */ +export function registerTool( + registry: ToolRegistry, + tool: ITool, +): ToolRegistry { + const next = new Map(registry.tools); + next.set(tool.id, tool); + return { ...registry, tools: next }; +} + +// --------------------------------------------------------------------------- +// NoOpTool — canonical do-nothing ITool +// --------------------------------------------------------------------------- + +/** + * Satisfies ITool with no side effects. + * + * Use for: + * - Test doubles (spread and override only the methods you need) + * - Default active tool on engine startup + * - ToolRouter smoke tests + * + * onCancel() is a deliberate no-op: NoOpTool has no drag state to clean up. + * Real tools will clear instance variables there. + */ +export const NoOpTool: ITool = { + id: toToolId('noop'), + shortcutKey: '', + + getCursor(_ctx: ToolContext): string { + return 'default'; + }, + + getSnapCandidateTypes(): readonly SnapPointType[] { + return []; + }, + + onPointerDown(_evt: TimelinePointerEvent, _ctx: ToolContext): void { + // intentional no-op + }, + + onPointerMove(_evt: TimelinePointerEvent, _ctx: ToolContext): ProvisionalState | null { + return null; + }, + + onPointerUp(_evt: TimelinePointerEvent, _ctx: ToolContext): null { + return null; + }, + + onKeyDown(_evt: TimelineKeyEvent, _ctx: ToolContext): null { + return null; + }, + + onKeyUp(_evt: TimelineKeyEvent, _ctx: ToolContext): void { + // intentional no-op + }, + + onCancel(): void { + // intentional no-op — NoOpTool has no drag state + }, +}; diff --git a/packages/core/src/tools/ripple-delete.ts b/packages/core/src/tools/ripple-delete.ts new file mode 100644 index 0000000..6bf9864 --- /dev/null +++ b/packages/core/src/tools/ripple-delete.ts @@ -0,0 +1,185 @@ +/** + * RippleDeleteTool — Phase 2 Step 6 + * + * Click a clip to delete it. All clips to the right on the same track + * shift left by the deleted clip's duration. No drag. No provisional state. + * + * TRANSACTION: + * DELETE_CLIP { clipId } + * MOVE_CLIP×N — one per downstream clip, sorted LEFT-TO-RIGHT + * + * MOVE_CLIP ordering rule (OPERATIONS.md: delta is negative → left-to-right): + * Leftmost clip moves first into space vacated by DELETE_CLIP. + * Each subsequent clip moves into space vacated by the one before it. + * Wrong order → OVERLAP rejection from rolling-state validator. + * + * ACTIVATION: RippleDeleteTool is activated programmatically (e.g. when Delete + * is pressed while a clip is selected in SelectionTool). shortcutKey is empty + * because 'delete' is not a single-char tool-activation key. + * + * RULES: + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui + * - onPointerMove never dispatches (returns null always) + * - onPointerUp never mutates instance state + * - Every instance variable appears in onCancel() + * - Capture-before-reset pattern applied in onPointerUp + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { + toToolId, + type ToolId, + type SnapPointType, +} from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { TimelineFrame } from '../types/frame'; +import type { OperationPrimitive, Transaction } from '../types/operations'; +import type { TimelineState } from '../types/state'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function findClip(state: TimelineState, clipId: ClipId): Clip | undefined { + for (const track of state.timeline.tracks) { + const c = track.clips.find(c => c.id === clipId); + if (c) return c; + } + return undefined; +} + +let _txSeq = 0; +function txId(): string { return `ripple-delete-tx-${++_txSeq}`; } + +// --------------------------------------------------------------------------- +// computeRippleDeleteOps — pure function, module scope, not exported +// --------------------------------------------------------------------------- + +/** + * Returns the operations array for a ripple-delete of `clip`: + * [DELETE_CLIP, ...MOVE_CLIP×N (left-to-right)] + * + * MOVE_CLIP sort: LEFT-TO-RIGHT (ascending timelineStart). + * Reason: delta is negative (clips shift left). Per OPERATIONS.md: + * -delta → sort left-to-right so each clip moves into already-vacated space. + */ +function computeRippleDeleteOps( + clip: Clip, + state: TimelineState, +): OperationPrimitive[] { + const deletedDuration = (clip.timelineEnd - clip.timelineStart) as number; + + const track = state.timeline.tracks.find(t => t.id === clip.trackId); + + // Clips strictly to the right: those whose start >= clip's end + const rightClips = (track?.clips ?? []) + .filter(c => c.timelineStart >= clip.timelineEnd) + .sort((a, b) => a.timelineStart - b.timelineStart); // LEFT-TO-RIGHT — -delta rule + + return [ + { type: 'DELETE_CLIP', clipId: clip.id }, + ...rightClips.map(c => ({ + type: 'MOVE_CLIP' as const, + clipId: c.id, + newTimelineStart: (c.timelineStart - deletedDuration) as TimelineFrame, + })), + ]; +} + +// --------------------------------------------------------------------------- +// RippleDeleteTool +// --------------------------------------------------------------------------- + +export class RippleDeleteTool implements ITool { + readonly id: ToolId = toToolId('ripple-delete'); + readonly shortcutKey: string = ''; // not a single-char activation key — activated programmatically + + // ── 1 click-recording var ───────────────────────────────────────────────── + /** + * Clip targeted at onPointerDown. Read and cleared at onPointerUp. + * No drag, no delta, no edge — this tool is click-only. + */ + private pendingClipId: ClipId | null = null; + + // ── 1 cursor-staging var ────────────────────────────────────────────────── + /** Staged by onPointerMove — getCursor() has no event parameter. */ + private isHoveringClip: boolean = false; + + // ── ITool: getCursor ────────────────────────────────────────────────────── + + getCursor(_ctx: ToolContext): string { + if (this.isHoveringClip) return 'pointer'; // "click to delete this" + return 'default'; + } + + // ── ITool: getSnapCandidateTypes ───────────────────────────────────────── + + getSnapCandidateTypes(): readonly SnapPointType[] { + return []; // no drag, no snap + } + + // ── ITool: onPointerDown ────────────────────────────────────────────────── + + onPointerDown(event: TimelinePointerEvent, _ctx: ToolContext): void { + if (event.clipId === null) return; // clicked empty space + this.pendingClipId = event.clipId; + } + + // ── ITool: onPointerMove ────────────────────────────────────────────────── + + onPointerMove(event: TimelinePointerEvent, _ctx: ToolContext): ProvisionalState | null { + this.isHoveringClip = event.clipId !== null; + return null; // no ghost — delete is instantaneous, no preview needed + } + + // ── ITool: onPointerUp ──────────────────────────────────────────────────── + + onPointerUp(_event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + // Capture-before-reset pattern + const clipId = this.pendingClipId; + this._resetState(); + + if (!clipId) return null; // empty-space click + + // Read clip from ctx.state (committed, current) + const liveClip = findClip(ctx.state, clipId); + if (!liveClip) return null; // defensive: clip may have already been removed + + const operations = computeRippleDeleteOps(liveClip, ctx.state); + + return { + id: txId(), + label: 'Ripple Delete', + timestamp: Date.now(), + operations, + }; + } + + // ── ITool: onKeyDown / onKeyUp ──────────────────────────────────────────── + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + // ── ITool: onCancel ─────────────────────────────────────────────────────── + /** Reset ALL instance state. Every variable must appear here. */ + onCancel(): void { + this.pendingClipId = null; + this.isHoveringClip = false; + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private _resetState(): void { + this.pendingClipId = null; + // isHoveringClip intentionally NOT reset — it is a cursor-staging var, not click state + } +} diff --git a/packages/core/src/tools/ripple-insert.ts b/packages/core/src/tools/ripple-insert.ts new file mode 100644 index 0000000..d44d6e8 --- /dev/null +++ b/packages/core/src/tools/ripple-insert.ts @@ -0,0 +1,311 @@ +/** + * RippleInsertTool — Phase 2 Step 7 + * + * Drag a clip from an asset and drop it onto a track. + * Clips at or after the drop point shift RIGHT by insertDuration. + * The inserted clip lands exactly at the drop point. + * + * TRANSACTION ORDER — critical: + * MOVE_CLIPs first (RIGHT-TO-LEFT — +delta rule) + * INSERT_CLIP last (gap is now open after all MOVE_CLIPs) + * + * INSTANCE VARIABLE GROUPS: + * Group A (pending-insert): set by setPendingInsert(), preserved across drops, + * cleared only by onCancel(). Cannot be changed mid-drag (guard in setPendingInsert). + * Group B (drag-tracking): set by onPointerDown(), cleared by onPointerUp() and onCancel(). + * + * PROVISIONAL STATE: + * Ghost inserted clip (sentinel id 'provisional-insert') + all shifted right-clips. + * Ghost id is NEVER written to committed state — real clip gets new id at onPointerUp. + * + * RULES: + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui + * - onPointerMove never dispatches + * - onPointerUp never mutates instance state (capture-before-reset) + * - Every instance variable appears in onCancel() + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { + toToolId, + type ToolId, + type SnapPointType, +} from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { TrackId } from '../types/track'; +import type { TimelineFrame } from '../types/frame'; +import type { OperationPrimitive, Transaction } from '../types/operations'; +import type { TimelineState } from '../types/state'; +import type { Asset } from '../types/asset'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** + * Sentinel ClipId used for the ghost inserted clip during drag. + * This id is NEVER written to committed state. + * The real clip gets a new id from _generateId() at onPointerUp time. + */ +const PROVISIONAL_INSERT_ID = 'provisional-insert' as ClipId; + +// --------------------------------------------------------------------------- +// ID generator — replaceable in tests via _setIdGenerator() +// --------------------------------------------------------------------------- + +/** Production default: crypto.randomUUID() */ +let generateId: () => string = () => crypto.randomUUID(); + +/** + * Replace the ID generator for deterministic IDs in tests. + * @example + * let counter = 0; + * beforeEach(() => { counter = 0; _setIdGenerator(() => `insert-${++counter}`); }); + * afterEach(() => { _setIdGenerator(() => crypto.randomUUID()); }); + */ +export function _setIdGenerator(fn: () => string): void { + generateId = fn; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function findTrack(state: TimelineState, trackId: TrackId) { + return state.timeline.tracks.find(t => t.id === trackId) ?? null; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +let _txSeq = 0; +function txId(): string { return `ripple-insert-tx-${++_txSeq}`; } + +// --------------------------------------------------------------------------- +// computeRippleInsertOps — pure function, module scope, not exported +// --------------------------------------------------------------------------- + +/** + * Returns the operation array for a ripple-insert: + * [MOVE_CLIP×N (right-to-left), INSERT_CLIP] + * + * MOVE_CLIP sort: RIGHT-TO-LEFT (descending timelineStart). + * Reason: delta is positive (+insertDuration). Per OPERATIONS.md: + * +delta → sort right-to-left so rightmost clip moves into empty space first. + * + * INSERT_CLIP LAST — after all MOVE_CLIPs, [dropFrame, dropFrame+insertDuration) is vacant. + */ +function computeRippleInsertOps( + dropFrame: TimelineFrame, + insertClip: Clip, + targetTrackId: TrackId, + state: TimelineState, +): OperationPrimitive[] { + const insertDuration = (insertClip.timelineEnd - insertClip.timelineStart) as number; + + const track = findTrack(state, targetTrackId); + + // Clips whose timelineStart >= dropFrame are pushed right + const rightClips = (track?.clips ?? []) + .filter(c => c.timelineStart >= dropFrame) + .sort((a, b) => b.timelineStart - a.timelineStart); // RIGHT-TO-LEFT — +delta rule + + return [ + // MOVE_CLIPs first — rightmost moves into empty space first + ...rightClips.map(c => ({ + type: 'MOVE_CLIP' as const, + clipId: c.id, + newTimelineStart: (c.timelineStart + insertDuration) as TimelineFrame, + })), + // INSERT_CLIP last — [dropFrame, dropFrame+insertDuration) is now vacant + { type: 'INSERT_CLIP' as const, clip: insertClip, trackId: targetTrackId }, + ]; +} + +// --------------------------------------------------------------------------- +// RippleInsertTool +// --------------------------------------------------------------------------- + +export class RippleInsertTool implements ITool { + readonly id: ToolId = toToolId('ripple-insert'); + readonly shortcutKey: string = ''; // activated programmatically + + // ── GROUP A: Pending-insert state ───────────────────────────────────────── + // Set by setPendingInsert(). Preserved across drops. Cleared by onCancel(). + // Cannot be changed mid-drag (guard in setPendingInsert). + private pendingAsset: Asset | null = null; + private pendingMediaIn: TimelineFrame | null = null; + private pendingMediaOut: TimelineFrame | null = null; + + // ── GROUP B: Drag-tracking state ────────────────────────────────────────── + // Set by onPointerDown(). Reset by onPointerUp() (Group B only) and onCancel() (all). + private isDragging: boolean = false; + + // ── Public API ──────────────────────────────────────────────────────────── + + /** + * Configure what clip will be inserted on the next drag. + * Preserves across drops — can be called once per asset, then drag many times. + * + * Guard: ignored if a drag is in progress — prevents ghost/Transaction mismatch + * from async React state updates firing setPendingInsert mid-drag. + */ + setPendingInsert( + asset: Asset, + mediaIn: TimelineFrame, + mediaOut: TimelineFrame, + ): void { + if (this.isDragging) return; // mid-drag guard: silent ignore + this.pendingAsset = asset; + this.pendingMediaIn = mediaIn; + this.pendingMediaOut = mediaOut; + } + + // ── ITool: getCursor ────────────────────────────────────────────────────── + + getCursor(_ctx: ToolContext): string { + if (this.pendingAsset !== null) return 'copy'; // pending insert configured + return 'default'; + } + + // ── ITool: getSnapCandidateTypes ───────────────────────────────────────── + + getSnapCandidateTypes(): readonly SnapPointType[] { + return ['ClipStart', 'ClipEnd', 'Playhead', 'Marker']; + } + + // ── ITool: onPointerDown ────────────────────────────────────────────────── + + onPointerDown(event: TimelinePointerEvent, _ctx: ToolContext): void { + if (this.pendingAsset === null) return; // no clip configured + if (event.trackId === null) return; // not over a track + this.isDragging = true; + } + + // ── ITool: onPointerMove ────────────────────────────────────────────────── + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null { + if (!this.isDragging || this.pendingAsset === null) return null; + if (event.trackId === null) return null; + + const insertDuration = (this.pendingMediaOut! - this.pendingMediaIn!) as number; + const timelineDuration = ctx.state.timeline.duration as number; + + const snapped = ctx.snap(event.frame) as TimelineFrame; + const dropFrame = clamp(snapped, 0, timelineDuration - insertDuration) as TimelineFrame; + + const track = findTrack(ctx.state, event.trackId); + const rightClips = (track?.clips ?? []) + .filter(c => c.timelineStart >= dropFrame); + + // Ghost inserted clip — sentinel id, NEVER committed + const ghostInserted: Clip = { + id: PROVISIONAL_INSERT_ID, + assetId: this.pendingAsset.id, + trackId: event.trackId, + timelineStart: dropFrame, + timelineEnd: (dropFrame + insertDuration) as TimelineFrame, + mediaIn: this.pendingMediaIn!, + mediaOut: this.pendingMediaOut!, + speed: 1.0, + enabled: true, + reversed: false, + name: null, + color: null, + metadata: {}, + }; + + // Ghost shifted clips — all right-side clips moved right + const ghostsShifted: Clip[] = rightClips.map(c => ({ + ...c, + timelineStart: (c.timelineStart + insertDuration) as TimelineFrame, + timelineEnd: (c.timelineEnd + insertDuration) as TimelineFrame, + })); + + return { + clips: [ghostInserted, ...ghostsShifted], + isProvisional: true, + }; + } + + // ── ITool: onPointerUp ──────────────────────────────────────────────────── + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + // Capture-before-reset pattern: + // All capture must happen before _resetDragState() clears isDragging. + const wasDragging = this.isDragging; + const asset = this.pendingAsset; + const mediaIn = this.pendingMediaIn; + const mediaOut = this.pendingMediaOut; + const trackId = event.trackId; + + // Reset Group B only — Group A (pendingAsset etc.) preserved for re-use + this._resetDragState(); + + if (!wasDragging || !asset || mediaIn === null || mediaOut === null) return null; + if (trackId === null) return null; + + const insertDuration = (mediaOut - mediaIn) as number; + const timelineDuration = ctx.state.timeline.duration as number; + + const snapped = ctx.snap(event.frame) as TimelineFrame; + const dropFrame = clamp(snapped, 0, timelineDuration - insertDuration) as TimelineFrame; + + // Build the real inserted clip — new id from _generateId(), NOT the sentinel + const newClip: Clip = { + id: generateId() as ClipId, + assetId: asset.id, + trackId, + timelineStart: dropFrame, + timelineEnd: (dropFrame + insertDuration) as TimelineFrame, + mediaIn, + mediaOut, + speed: 1.0, + enabled: true, + reversed: false, + name: null, + color: null, + metadata: {}, + }; + + const operations = computeRippleInsertOps(dropFrame, newClip, trackId, ctx.state); + + return { + id: txId(), + label: 'Ripple Insert', + timestamp: Date.now(), + operations, + }; + } + + // ── ITool: onKeyDown / onKeyUp ──────────────────────────────────────────── + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + // ── ITool: onCancel ─────────────────────────────────────────────────────── + /** Reset ALL instance state. Every variable must appear here. */ + onCancel(): void { + this.pendingAsset = null; + this.pendingMediaIn = null; + this.pendingMediaOut = null; + this.isDragging = false; + } + + // ── Private ─────────────────────────────────────────────────────────────── + + /** Reset Group B only. Group A (pending-insert) is preserved for re-use. */ + private _resetDragState(): void { + this.isDragging = false; + } +} diff --git a/packages/core/src/tools/ripple-trim.ts b/packages/core/src/tools/ripple-trim.ts new file mode 100644 index 0000000..98c051b --- /dev/null +++ b/packages/core/src/tools/ripple-trim.ts @@ -0,0 +1,436 @@ +/** + * RippleTrimTool — Phase 2 Step 3 + * + * Drag a clip edge (start or end). The dragged edge moves. + * All clips downstream of the edit point shift by the same delta. + * + * DOWNSTREAM DEFINITION: + * END edge trim: clips with timelineStart >= original.timelineEnd (to the right) + * START edge trim: clips with timelineEnd <= original.timelineStart (to the left) + * + * START EDGE SEMANTICS: + * When the start edge moves right (+delta), left clips also shift right (+delta). + * When the start edge moves left (-delta), left clips also shift left (-delta). + * This is standard NLE ripple trim behavior (Premiere / Resolve convention). + * + * TRANSACTION ORDER: + * RESIZE_CLIP first, then N× MOVE_CLIP. + * Rolling-state validation means MOVE_CLIPs validate after RESIZE is applied. + * + * CLAMPING (applied before ghost and Transaction): + * 1. Min duration: clip must remain ≥ 1 frame + * 2. Media bounds: mediaIn must stay < mediaOut - 1 (START); mediaOut > mediaIn + 1 (END) + * 3. Frame-0: for START trim, leftward shift must not push any left-clip below frame 0 + * + * RULES: + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui + * - onPointerMove never dispatches + * - onPointerUp never mutates instance state + * - Every instance variable appears in onCancel() + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { + toToolId, + type ToolId, + type SnapPointType, +} from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { TrackId } from '../types/track'; +import type { TimelineFrame } from '../types/frame'; +import type { Transaction } from '../types/operations'; +import type { TimelineState } from '../types/state'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Pixel width on each side of a clip edge that counts as "grabbing" the edge. */ +const EDGE_HIT_ZONE_PX = 8; + +/** Minimum allowed clip duration in frames. */ +const MIN_DURATION_FRAMES = 1; + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +type TrimEdge = 'start' | 'end'; + +type DownstreamPosition = { + readonly timelineStart: TimelineFrame; + readonly timelineEnd: TimelineFrame; + readonly trackId: TrackId; +}; + +// --------------------------------------------------------------------------- +// computeDownstreamClips — pure module-level helper, not exported +// --------------------------------------------------------------------------- + +/** + * Return the clips that ripple when `clip`'s `edge` is trimmed. + * + * END edge: clips on same track with timelineStart >= clip.timelineEnd (to the right) + * START edge: clips on same track with timelineEnd <= clip.timelineStart (to the left) + * + * The dragged clip itself is always excluded. + */ +function computeDownstreamClips( + clip: Clip, + edge: TrimEdge, + state: TimelineState, +): Clip[] { + const track = state.timeline.tracks.find(t => t.id === clip.trackId); + if (!track) return []; + + if (edge === 'end') { + return track.clips.filter( + c => c.id !== clip.id && c.timelineStart >= clip.timelineEnd, + ); + } else { + return track.clips.filter( + c => c.id !== clip.id && c.timelineEnd <= clip.timelineStart, + ); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Find a clip by id across all tracks. */ +function findClip(state: TimelineState, clipId: ClipId): Clip | undefined { + for (const track of state.timeline.tracks) { + const c = track.clips.find(c => c.id === clipId); + if (c) return c; + } + return undefined; +} + +/** Clamp a value between min and max (inclusive). */ +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +let _txSeq = 0; +function txId(): string { return `ripple-trim-tx-${++_txSeq}`; } + +// --------------------------------------------------------------------------- +// RippleTrimTool +// --------------------------------------------------------------------------- + +export class RippleTrimTool implements ITool { + readonly id: ToolId = toToolId('ripple-trim'); + readonly shortcutKey: string = 'r'; + + // ── Per-drag tracking ───────────────────────────────────────────────────── + private dragClipId: ClipId | null = null; + private dragEdge: TrimEdge | null = null; + + /** Original clip bounds — frame values only, never a stale Clip object. */ + private dragOrigStart: TimelineFrame | null = null; + private dragOrigEnd: TimelineFrame | null = null; + private dragOrigMediaIn: TimelineFrame | null = null; // for START media clamp + private dragOrigMediaOut: TimelineFrame | null = null; // for END media clamp + + /** + * Original positions of downstream clips. + * Keyed by ClipId. Both timelineStart and timelineEnd stored — ghost needs + * both to render, MOVE_CLIP only needs start (but duration is end - start). + */ + private originalDownstream: Map = new Map(); + + // ── getCursor() state ───────────────────────────────────────────────────── + private lastHitEdge: TrimEdge | null = null; + private lastHoveredClipId: ClipId | null = null; + + // ── ITool: getCursor ────────────────────────────────────────────────────── + + getCursor(_ctx: ToolContext): string { + if (this.dragEdge !== null) return 'ew-resize'; // mid-drag + if (this.lastHitEdge !== null) return 'ew-resize'; // hovering near edge + return 'default'; + } + + // ── ITool: getSnapCandidateTypes ───────────────────────────────────────── + + getSnapCandidateTypes(): readonly SnapPointType[] { + return ['ClipStart', 'ClipEnd', 'Playhead', 'Marker']; + } + + // ── ITool: onPointerDown ────────────────────────────────────────────────── + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void { + if (event.clipId === null) return; + + const clip = findClip(ctx.state, event.clipId); + if (!clip) return; + + // Determine which edge was grabbed (8px hit zone, converted to frames) + const hitZoneFrames = (EDGE_HIT_ZONE_PX / ctx.pixelsPerFrame) as TimelineFrame; + const distToStart = Math.abs(event.frame - clip.timelineStart) as TimelineFrame; + const distToEnd = Math.abs(event.frame - clip.timelineEnd) as TimelineFrame; + + let edge: TrimEdge | null = null; + if (distToEnd <= hitZoneFrames) edge = 'end'; + if (distToStart <= hitZoneFrames) edge = 'start'; // start wins if equidistant + + if (edge === null) return; // not close enough to an edge + + // Populate downstream clip positions ONCE at drag-start + const downstream = computeDownstreamClips(clip, edge, ctx.state); + + this.dragClipId = event.clipId; + this.dragEdge = edge; + this.dragOrigStart = clip.timelineStart; + this.dragOrigEnd = clip.timelineEnd; + this.dragOrigMediaIn = clip.mediaIn; + this.dragOrigMediaOut = clip.mediaOut; + + this.originalDownstream.clear(); + for (const dc of downstream) { + this.originalDownstream.set(dc.id, { + timelineStart: dc.timelineStart, + timelineEnd: dc.timelineEnd, + trackId: dc.trackId, + }); + } + } + + // ── ITool: onPointerMove ────────────────────────────────────────────────── + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null { + // Update getCursor() state + this.lastHoveredClipId = event.clipId; + if (event.clipId !== null) { + const c = findClip(ctx.state, event.clipId); + if (c) { + const hitZoneFrames = (EDGE_HIT_ZONE_PX / ctx.pixelsPerFrame) as TimelineFrame; + const distToStart = Math.abs(event.frame - c.timelineStart) as TimelineFrame; + const distToEnd = Math.abs(event.frame - c.timelineEnd) as TimelineFrame; + if (distToEnd <= hitZoneFrames) this.lastHitEdge = 'end'; + else if (distToStart <= hitZoneFrames) this.lastHitEdge = 'start'; + else this.lastHitEdge = null; + } else { + this.lastHitEdge = null; + } + } else { + this.lastHitEdge = null; + this.lastHoveredClipId = null; + } + + // Not mid-drag + if (this.dragClipId === null || this.dragEdge === null) return null; + if (this.dragOrigStart === null || this.dragOrigEnd === null) return null; + if (this.dragOrigMediaIn === null || this.dragOrigMediaOut === null) return null; + + // Snap, excluding the dragged clip's own edges + const rawFrame = event.frame; + const snapped = ctx.snap(rawFrame, [this.dragClipId]) as TimelineFrame; + const newFrame = this._clampFrame(snapped); + if (newFrame === null) return null; + + return this._buildGhost(newFrame, ctx.state); + } + + // ── ITool: onPointerUp ──────────────────────────────────────────────────── + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + // Compute snapped + clamped frame BEFORE resetting instance state. + // _clampFrame() reads dragEdge, dragOrigStart/End, dragOrigMediaIn/Out, + // and originalDownstream — all of which _resetDragState() clears. + const rawFrame = event.frame; + const snapped = ctx.snap(rawFrame, this.dragClipId ? [this.dragClipId] : []) as TimelineFrame; + const newFrame = this._clampFrame(snapped); + + // Capture what we need, then reset + const clipId = this.dragClipId; + const edge = this.dragEdge; + const origStart = this.dragOrigStart; + const origEnd = this.dragOrigEnd; + const downstream = new Map(this.originalDownstream); + + this._resetDragState(); + + if (clipId === null || edge === null || origStart === null || origEnd === null) return null; + if (newFrame === null) return null; + + // No-op: edge didn't move + const originalEdge = edge === 'end' ? origEnd : origStart; + if (newFrame === originalEdge) return null; + + const delta = (newFrame - originalEdge) as TimelineFrame; + + // Sort downstream entries so MOVE_CLIPs are applied in safe order. + // When delta > 0 (clips shift right): move the RIGHTMOST clip first so each + // clip's destination is already clear in the rolling state when validated. + // When delta < 0 (clips shift left): move the LEFTMOST clip first. + const sortedDownstream = [...downstream.entries()].sort(([, a], [, b]) => + delta >= 0 + ? b.timelineStart - a.timelineStart // right-to-left (descending) + : a.timelineStart - b.timelineStart, // left-to-right (ascending) + ); + + const operations: Transaction['operations'][number][] = [ + // RESIZE first — rolling-state means MOVE_CLIPs validate after RESIZE is applied + { type: 'RESIZE_CLIP', clipId, edge, newFrame }, + // MOVE_CLIPs in safe order (no inter-clip transient overlap) + ...sortedDownstream.map(([dcId, orig]) => ({ + type: 'MOVE_CLIP' as const, + clipId: dcId, + newTimelineStart: (orig.timelineStart + delta) as TimelineFrame, + })), + ]; + + return { + id: txId(), + label: `Ripple Trim (${edge})`, + timestamp: Date.now(), + operations, + }; + } + + // ── ITool: onKeyDown / onKeyUp ──────────────────────────────────────────── + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + // ── ITool: onCancel ─────────────────────────────────────────────────────── + /** Reset ALL instance state. Every variable must appear here. */ + onCancel(): void { + this.dragClipId = null; + this.dragEdge = null; + this.dragOrigStart = null; + this.dragOrigEnd = null; + this.dragOrigMediaIn = null; + this.dragOrigMediaOut = null; + this.originalDownstream.clear(); + this.lastHitEdge = null; + this.lastHoveredClipId = null; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** + * Apply all clamping rules to the candidate newFrame. + * Returns null if the resulting trim would produce a zero-or-negative-duration clip. + */ + private _clampFrame(candidate: TimelineFrame): TimelineFrame | null { + if (this.dragEdge === null || this.dragOrigStart === null || this.dragOrigEnd === null || + this.dragOrigMediaIn === null || this.dragOrigMediaOut === null) return null; + + let frame = candidate as number; + + if (this.dragEdge === 'end') { + // ── END edge clamping ──────────────────────────────────────────────── + // Min duration: newFrame must be > timelineStart + MIN_DURATION_FRAMES - 1 + const minEnd = this.dragOrigStart + MIN_DURATION_FRAMES; + // Media bounds: mediaOut = dragOrigMediaOut + (frame - dragOrigEnd) + // → must stay >= dragOrigMediaIn + 1 + // → frame >= dragOrigEnd - (dragOrigMediaOut - dragOrigMediaIn - 1) + const minEndForMedia = this.dragOrigEnd - (this.dragOrigMediaOut - this.dragOrigMediaIn - 1); + frame = Math.max(frame, minEnd, minEndForMedia); + + } else { + // ── START edge clamping ────────────────────────────────────────────── + // Min duration: newFrame must be < timelineEnd - MIN_DURATION_FRAMES + 1 + const maxStart = this.dragOrigEnd - MIN_DURATION_FRAMES; + // Media bounds (rightward / making clip shorter at front): + // mediaIn = dragOrigMediaIn + (frame - dragOrigStart) + // → must stay <= dragOrigMediaOut - 1 + // → frame <= dragOrigStart + (dragOrigMediaOut - dragOrigMediaIn - 1) + const maxStartForMedia = this.dragOrigStart + (this.dragOrigMediaOut - this.dragOrigMediaIn - 1); + frame = Math.min(frame, maxStart, maxStartForMedia); + + // Media bounds (leftward / making clip longer at front): + // mediaIn = dragOrigMediaIn + (frame - dragOrigStart) + // → must stay >= 0 + // → frame >= dragOrigStart - dragOrigMediaIn + const minStartForMedia = this.dragOrigStart - this.dragOrigMediaIn; + frame = Math.max(frame, minStartForMedia); + + // Frame-0 clamp: leftward trim (delta < 0) must not push any left-clip below 0 + const delta = frame - this.dragOrigStart; + if (delta < 0 && this.originalDownstream.size > 0) { + // Find leftmost start among downstream (left-side) clips + let minDownstreamStart = Infinity; + for (const pos of this.originalDownstream.values()) { + if (pos.timelineStart < minDownstreamStart) { + minDownstreamStart = pos.timelineStart; + } + } + if (minDownstreamStart !== Infinity) { + // leftmost.timelineStart + delta >= 0 → delta >= -minDownstreamStart + // → frame >= dragOrigStart - minDownstreamStart + const minFrameForLeftClips = this.dragOrigStart - minDownstreamStart; + frame = Math.max(frame, minFrameForLeftClips); + } + } + } + + // After all clamping: verify clip would still have positive duration + if (this.dragEdge === 'end' && frame <= this.dragOrigStart) return null; + if (this.dragEdge === 'start' && frame >= this.dragOrigEnd) return null; + + return frame as TimelineFrame; + } + + /** + * Build the ProvisionalState showing trimmed clip + all shifted downstream clips. + * Always reads live clip data from ctx.state — never spreads stored clip objects. + */ + private _buildGhost(newFrame: TimelineFrame, state: TimelineState): ProvisionalState | null { + if (this.dragClipId === null || this.dragEdge === null || this.dragOrigEnd === null || + this.dragOrigStart === null) return null; + + const liveClip = findClip(state, this.dragClipId); + if (!liveClip) return null; + + // Delta from the original edge position + const originalEdge = this.dragEdge === 'end' ? this.dragOrigEnd : this.dragOrigStart; + const delta = (newFrame - originalEdge) as TimelineFrame; + + // Trimmed clip ghost + const trimmedGhost: Clip = + this.dragEdge === 'end' + ? { ...liveClip, timelineEnd: newFrame } + : { ...liveClip, timelineStart: newFrame }; + + // Downstream ghosts — all shifted by uniform delta + const downstreamGhosts: Clip[] = []; + for (const [dcId, orig] of this.originalDownstream) { + const liveDc = findClip(state, dcId); + if (!liveDc) continue; + downstreamGhosts.push({ + ...liveDc, + timelineStart: (orig.timelineStart + delta) as TimelineFrame, + timelineEnd: (orig.timelineEnd + delta) as TimelineFrame, + }); + } + + return { + clips: [trimmedGhost, ...downstreamGhosts], + isProvisional: true, + }; + } + + /** Reset per-drag instance state. Does NOT touch getCursor vars. */ + private _resetDragState(): void { + this.dragClipId = null; + this.dragEdge = null; + this.dragOrigStart = null; + this.dragOrigEnd = null; + this.dragOrigMediaIn = null; + this.dragOrigMediaOut = null; + this.originalDownstream.clear(); + } +} diff --git a/packages/core/src/tools/roll-trim.ts b/packages/core/src/tools/roll-trim.ts new file mode 100644 index 0000000..7dab73c --- /dev/null +++ b/packages/core/src/tools/roll-trim.ts @@ -0,0 +1,313 @@ +/** + * RollTrimTool — Phase 2 Step 4 + * + * Drag the boundary between two adjacent clips. + * Left clip's end and right clip's start move together to the same frame. + * Combined duration of both clips is unchanged. + * No downstream ripple. No upstream ripple. + * + * TRANSACTION: 2× RESIZE_CLIP with identical newFrame. One history entry. + * + * CLAMP (precomputed at onPointerDown — only 5 instance vars needed): + * minBoundary = max(leftOrig.timelineStart + 1, + * origBoundary - (leftOrig.mediaOut - leftOrig.mediaIn - 1)) + * maxBoundary = min(rightOrig.timelineEnd - 1, + * origBoundary + (rightOrig.mediaOut - rightOrig.mediaIn - 1)) + * + * ORIGBOUNDARY AT onPointerUp: + * Read from ctx.state (committed, not yet changed) — avoids a 6th instance var. + * + * RULES: + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui + * - onPointerMove never dispatches + * - onPointerUp never mutates instance state + * - Every instance variable appears in onCancel() + * - Capture-before-reset pattern: compute clamp BEFORE _resetDragState() + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { + toToolId, + type ToolId, + type SnapPointType, +} from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { Track } from '../types/track'; +import type { TimelineFrame } from '../types/frame'; +import type { Transaction } from '../types/operations'; +import type { TimelineState } from '../types/state'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Pixel distance on each side of a cut point that activates roll trim. */ +const EDGE_ZONE_PX = 8; + +/** Minimum clip duration in frames. */ +const MIN_DURATION = 1; + +// --------------------------------------------------------------------------- +// findRollTarget — pure module-level helper, not exported +// --------------------------------------------------------------------------- + +/** + * Find the two adjacent clips that form a cut point near `frame`. + * + * Returns null if: + * - No clip's end edge is within `zonePx` px of `frame`, OR + * - No clip's start edge is within `zonePx` px of `frame`, OR + * - The same clip was found for both (very short clip), OR + * - There is a gap between leftClip.timelineEnd and rightClip.timelineStart + * (a gap means it is not a true cut — roll trim requires adjacency) + */ +function findRollTarget( + frame: TimelineFrame, + track: Track, + zonePx: number, + ppf: number, +): { leftClip: Clip; rightClip: Clip } | null { + const zoneFrames = zonePx / ppf; + + const leftClip = track.clips.find(c => Math.abs(c.timelineEnd - frame) <= zoneFrames); + const rightClip = track.clips.find(c => Math.abs(c.timelineStart - frame) <= zoneFrames); + + if (!leftClip || !rightClip) return null; + if (leftClip.id === rightClip.id) return null; // same clip (degenerate) + if (leftClip.timelineEnd !== rightClip.timelineStart) return null; // gap — not a cut + + return { leftClip, rightClip }; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function findClip(state: TimelineState, clipId: ClipId): Clip | undefined { + for (const track of state.timeline.tracks) { + const c = track.clips.find(c => c.id === clipId); + if (c) return c; + } + return undefined; +} + +function findTrack(state: TimelineState, trackId: string) { + return state.timeline.tracks.find(t => t.id === trackId) ?? null; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +let _txSeq = 0; +function txId(): string { return `roll-trim-tx-${++_txSeq}`; } + +// --------------------------------------------------------------------------- +// RollTrimTool +// --------------------------------------------------------------------------- + +export class RollTrimTool implements ITool { + readonly id: ToolId = toToolId('roll-trim'); + readonly shortcutKey: string = 't'; // 'T' for trim — standard in DaVinci Resolve + + // ── Per-drag tracking ───────────────────────────────────────────────────── + private leftClipId: ClipId | null = null; + private rightClipId: ClipId | null = null; + + /** + * Precomputed clamp bounds — computed once at onPointerDown from all 4 constraints. + * Avoids storing all 8 original clip bounds as separate instance vars. + */ + private minBoundary: TimelineFrame | null = null; + private maxBoundary: TimelineFrame | null = null; + + // ── getCursor() staging ─────────────────────────────────────────────────── + /** True when the pointer is hovering a valid cut point (within EDGE_ZONE). */ + private isHoveringCut: boolean = false; + + // ── ITool: getCursor ────────────────────────────────────────────────────── + + getCursor(_ctx: ToolContext): string { + if (this.leftClipId !== null) return 'ew-resize'; // mid-drag + if (this.isHoveringCut) return 'ew-resize'; // hovering cut + return 'default'; + } + + // ── ITool: getSnapCandidateTypes ───────────────────────────────────────── + + getSnapCandidateTypes(): readonly SnapPointType[] { + return ['ClipStart', 'ClipEnd', 'Playhead', 'Marker']; + } + + // ── ITool: onPointerDown ────────────────────────────────────────────────── + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void { + if (event.trackId === null) return; + + const track = findTrack(ctx.state, event.trackId); + if (!track) return; + + const target = findRollTarget(event.frame, track, EDGE_ZONE_PX, ctx.pixelsPerFrame); + if (!target) return; + + const { leftClip, rightClip } = target; + const origBoundary = leftClip.timelineEnd; // === rightClip.timelineStart + + // Precompute [minBoundary, maxBoundary] from all valid constraints. + // + // KEY INSIGHT: The invariant (mediaOut - mediaIn = timelineEnd - timelineStart) + // means some constraints are redundant with duration-based ones: + // + // Working constraints: + // (A) Left min-duration: origBoundary >= leftClip.timelineStart + 1 + // (C) Right min-duration: origBoundary <= rightClip.timelineEnd - 1 + // (E) Right media leftward: rightClip.mediaIn + delta >= 0 + // → origBoundary - rightClip.mediaIn (binding when rolling LEFT far enough) + // (B') Left media rightward: leftClip.mediaOut + delta <= leftAsset.intrinsicDuration + // → origBoundary + (leftAsset.intrinsicDuration - leftClip.mediaOut) + // (binding when rolling RIGHT past left clip's media supply) + // + // Redundant (proven identical to A/C by the invariant): + // (B) leftClip.mediaOut + delta >= leftClip.mediaIn + 1 ← same as (A) + // (D) rightClip.mediaIn + delta <= rightClip.mediaOut - 1 ← same as (C) + // + + // Look up left clip's asset for intrinsicDuration (needed for B') + const leftAsset = ctx.state.assetRegistry.get(leftClip.assetId); + const leftIntrinsicDuration = leftAsset ? leftAsset.intrinsicDuration : (Infinity as TimelineFrame); + + const minBoundary = Math.max( + leftClip.timelineStart + MIN_DURATION, // (A) left min-duration + origBoundary - rightClip.mediaIn, // (E) right media leftward bound + ) as TimelineFrame; + + const maxBoundary = Math.min( + rightClip.timelineEnd - MIN_DURATION, // (C) right min-duration + origBoundary + (leftIntrinsicDuration - leftClip.mediaOut), // (B') left media rightward bound + ) as TimelineFrame; + + // If there's no valid roll range (clips already at media limits), abort + if (minBoundary > maxBoundary) return; + + this.leftClipId = leftClip.id; + this.rightClipId = rightClip.id; + this.minBoundary = minBoundary; + this.maxBoundary = maxBoundary; + } + + // ── ITool: onPointerMove ────────────────────────────────────────────────── + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null { + // Update isHoveringCut for getCursor() + if (event.trackId !== null) { + const track = findTrack(ctx.state, event.trackId); + this.isHoveringCut = track !== null + && findRollTarget(event.frame, track, EDGE_ZONE_PX, ctx.pixelsPerFrame) !== null; + } else { + this.isHoveringCut = false; + } + + // Not mid-drag + if (this.leftClipId === null || this.rightClipId === null || + this.minBoundary === null || this.maxBoundary === null) { + return null; + } + + const snapped = ctx.snap(event.frame, [this.leftClipId, this.rightClipId]) as TimelineFrame; + const boundaryFrame = clamp(snapped, this.minBoundary, this.maxBoundary) as TimelineFrame; + + return this._buildGhost(boundaryFrame, ctx.state); + } + + // ── ITool: onPointerUp ──────────────────────────────────────────────────── + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + // STEP 1: Compute clamped boundary BEFORE reset (capture-before-reset pattern). + // _resetDragState() clears minBoundary/maxBoundary — must clamp first. + if (this.minBoundary === null || this.maxBoundary === null) { + this._resetDragState(); + return null; + } + const snapped = ctx.snap(event.frame, [ + ...(this.leftClipId ? [this.leftClipId] : []), + ...(this.rightClipId ? [this.rightClipId] : []), + ]) as TimelineFrame; + const boundaryFrame = clamp(snapped, this.minBoundary, this.maxBoundary) as TimelineFrame; + + // STEP 2: Capture, then reset + const leftId = this.leftClipId; + const rightId = this.rightClipId; + this._resetDragState(); + + if (!leftId || !rightId) return null; + + // STEP 3: Read origBoundary from ctx.state (committed — not yet changed) + // Option B: avoids a 6th instance variable; ctx.state is safe here. + const liveLeft = findClip(ctx.state, leftId); + const origBoundary = liveLeft?.timelineEnd ?? null; + if (origBoundary === null) return null; + + // No-op: boundary didn't move + if (boundaryFrame === origBoundary) return null; + + return { + id: txId(), + label: 'Roll Trim', + timestamp: Date.now(), + operations: [ + { type: 'RESIZE_CLIP', clipId: leftId, edge: 'end', newFrame: boundaryFrame }, + { type: 'RESIZE_CLIP', clipId: rightId, edge: 'start', newFrame: boundaryFrame }, + ], + }; + } + + // ── ITool: onKeyDown / onKeyUp ──────────────────────────────────────────── + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + // ── ITool: onCancel ─────────────────────────────────────────────────────── + /** Reset ALL instance state. Every variable must appear here. */ + onCancel(): void { + this.leftClipId = null; + this.rightClipId = null; + this.minBoundary = null; + this.maxBoundary = null; + this.isHoveringCut = false; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private _buildGhost(boundaryFrame: TimelineFrame, state: TimelineState): ProvisionalState | null { + if (!this.leftClipId || !this.rightClipId) return null; + + const liveLeft = findClip(state, this.leftClipId); + const liveRight = findClip(state, this.rightClipId); + if (!liveLeft || !liveRight) return null; + + const ghostLeft: Clip = { ...liveLeft, timelineEnd: boundaryFrame }; + const ghostRight: Clip = { ...liveRight, timelineStart: boundaryFrame }; + + return { + clips: [ghostLeft, ghostRight], + isProvisional: true, + }; + } + + private _resetDragState(): void { + this.leftClipId = null; + this.rightClipId = null; + this.minBoundary = null; + this.maxBoundary = null; + // isHoveringCut is NOT reset here — it is a hover-state var, not drag-state + } +} diff --git a/packages/core/src/tools/selection.ts b/packages/core/src/tools/selection.ts new file mode 100644 index 0000000..465ca53 --- /dev/null +++ b/packages/core/src/tools/selection.ts @@ -0,0 +1,470 @@ +/** + * SelectionTool — Phase 2 + * + * The most complex tool. Handles four interaction modes: + * MODE 1: Single click → select/deselect clip (no drag) + * MODE 2: Single drag → move one clip, produce MOVE_CLIP Transaction + * MODE 3: Multi drag → move all selected clips by uniform delta, N× MOVE_CLIP + * MODE 4: Rubber-band → marquee select clips, no Transaction + * + * SELECTION CONTRACT: + * Selection lives on this instance as Set. + * It is NOT in TimelineState. It is NOT undoable. + * onCancel() resets all instance state, including selection. + * + * GHOST CLIP CONTRACT (corrected in design review): + * Ghost clips are ALWAYS built by reading the live clip from ctx.state + * then overriding position fields. Never spread a stored clip snapshot. + * originalPositions is ONLY used in onPointerUp for MOVE_CLIP delta math. + * + * RULES: + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui + * - onPointerMove must never call dispatch + * - onPointerUp must never mutate instance state + * - Every instance variable appears in onCancel() — no exceptions + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { + toToolId, + type ToolId, + type SnapPointType, +} from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { TrackId } from '../types/track'; +import type { TimelineFrame } from '../types/frame'; +import type { Transaction } from '../types/operations'; +import type { TimelineState } from '../types/state'; +import { findClipById } from '../systems/queries'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +/** Minimum pixel distance before a pointerdown becomes a drag (click tolerance). */ +const DRAG_THRESHOLD_PX = 4; + +/** Pixel width on each side of a clip edge that triggers 'ew-resize' cursor. */ +const EDGE_HIT_ZONE_PX = 8; + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +type DragMode = 'idle' | 'drag-clip' | 'rubber-band'; + +type OriginalPosition = { + readonly timelineStart: TimelineFrame; + readonly timelineEnd: TimelineFrame; + readonly trackId: TrackId; +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Find a clip by id and assert it exists. Returns undefined if missing. */ +function liveClip(state: TimelineState, id: ClipId): Clip | undefined { + return findClipById(state, id); +} + +/** Clip edge check — returns 'start', 'end', or null. */ +function hitEdge( + clip: Clip, + clientX: number, + ppf: number, + originX: number, +): 'start' | 'end' | null { + const startPx = clip.timelineStart * ppf + originX; + const endPx = clip.timelineEnd * ppf + originX; + if (Math.abs(clientX - startPx) <= EDGE_HIT_ZONE_PX) return 'start'; + if (Math.abs(clientX - endPx) <= EDGE_HIT_ZONE_PX) return 'end'; + return null; +} + +/** Collect clips from state that belong to the given Set of ids. */ +function collectClips(state: TimelineState, ids: ReadonlySet): Clip[] { + const result: Clip[] = []; + for (const id of ids) { + const c = liveClip(state, id); + if (c) result.push(c); + } + return result; +} + +/** Make a unique transaction id. */ +let _txSeq = 0; +function txId(): string { return `selection-tx-${++_txSeq}`; } + +// --------------------------------------------------------------------------- +// SelectionTool +// --------------------------------------------------------------------------- + +export class SelectionTool implements ITool { + readonly id: ToolId = toToolId('selection'); + readonly shortcutKey: string = 'v'; + + // ── Selection state (persists across gestures) ─────────────────────────── + private readonly selected: Set = new Set(); + + // ── Per-gesture tracking ────────────────────────────────────────────────── + private mode: DragMode = 'idle'; + + // drag-clip mode + private dragStartFrame: TimelineFrame | null = null; + private dragStartX: number | null = null; + private dragStartY: number | null = null; + private dragClipId: ClipId | null = null; + private isMultiDrag: boolean = false; + /** Frame values only — no Clip objects. Used only in onPointerUp for delta math. */ + private originalPositions: Map = new Map(); + + // rubber-band mode + private rubberBandStartFrame: TimelineFrame | null = null; + private rubberBandStartY: number | null = null; + + // getCursor() state (updated on move/down, read in getCursor) + private lastClientX: number | null = null; + private lastHitEdge: 'start'|'end' | null = null; + + // ── Public read access ──────────────────────────────────────────────────── + + getSelection(): ReadonlySet { + return this.selected; + } + + clearSelection(): void { + this.selected.clear(); + } + + // ── ITool: getCursor ────────────────────────────────────────────────────── + + getCursor(_ctx: ToolContext): string { + if (this.mode === 'drag-clip') return 'grabbing'; + if (this.mode === 'rubber-band') return 'crosshair'; + if (this.lastHitEdge !== null) return 'ew-resize'; + // lastClientX non-null → we've had at least one move event with a clip under cursor. + // The actual clip-hover check happens in onPointerMove where we update lastHitEdge. + // If lastHitEdge is null but we're hovering a clip, use 'grab'. + // The presence of lastClientX alone doesn't mean hovering clip — we'd need the + // last event's clipId. Store it: + if (this._lastHoveredClipId !== null) return 'grab'; + return 'default'; + } + + private _lastHoveredClipId: ClipId | null = null; + + // ── ITool: getSnapCandidateTypes ───────────────────────────────────────── + + getSnapCandidateTypes(): readonly SnapPointType[] { + return ['ClipStart', 'ClipEnd', 'Playhead']; + } + + // ── ITool: onPointerDown ────────────────────────────────────────────────── + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void { + this.lastClientX = event.x; + this._lastHoveredClipId = event.clipId; + + if (event.clipId !== null) { + // Hitting a clip — start potential drag + const clip = liveClip(ctx.state, event.clipId); + if (!clip) return; + + this.mode = 'drag-clip'; + this.dragStartX = event.x; + this.dragStartY = event.y; + this.dragStartFrame = event.frame; + this.dragClipId = event.clipId; + + // Determine if this is a multi-clip drag + this.isMultiDrag = this.selected.size > 1 && this.selected.has(event.clipId); + + // Snapshot original positions for all clips that will move + this.originalPositions.clear(); + const clipsToRecord = this.isMultiDrag + ? [...this.selected] + : [event.clipId]; + + for (const id of clipsToRecord) { + const c = liveClip(ctx.state, id); + if (c) { + this.originalPositions.set(id, { + timelineStart: c.timelineStart, + timelineEnd: c.timelineEnd, + trackId: c.trackId, + }); + } + } + } else { + // Hitting empty space — start rubber-band + this.mode = 'rubber-band'; + this.dragStartX = event.x; // needed for click-threshold check in onPointerUp + this.rubberBandStartFrame = event.frame; + this.rubberBandStartY = event.y; + } + } + + // ── ITool: onPointerMove ────────────────────────────────────────────────── + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null { + this.lastClientX = event.x; + this._lastHoveredClipId = event.clipId; + + // Update edge-hover state for getCursor() + if (event.clipId !== null) { + const c = liveClip(ctx.state, event.clipId); + this.lastHitEdge = c + ? hitEdge(c, event.x, ctx.pixelsPerFrame, 0) + : null; + } else { + this.lastHitEdge = null; + this._lastHoveredClipId = null; + } + + // ── MODE 4: rubber-band ─────────────────────────────────────────────── + if (this.mode === 'rubber-band') { + if (this.rubberBandStartFrame === null || this.rubberBandStartY === null) return null; + return { + clips: [], + rubberBand: { + startFrame: this.rubberBandStartFrame, + endFrame: event.frame, + startY: this.rubberBandStartY, + endY: event.y, + }, + isProvisional: true, + }; + } + + // ── MODE 1: click (below drag threshold) ────────────────────────────── + if (this.mode === 'drag-clip' && this.dragStartX !== null) { + const dxPx = Math.abs(event.x - this.dragStartX); + if (dxPx < DRAG_THRESHOLD_PX) return null; + } + + // ── MODE 2: single clip drag ────────────────────────────────────────── + if (this.mode === 'drag-clip' && !this.isMultiDrag && this.dragClipId !== null) { + const clip = liveClip(ctx.state, this.dragClipId); + if (!clip || this.dragStartFrame === null) return null; + + const frameDelta = event.frame - this.dragStartFrame; + const orig = this.originalPositions.get(this.dragClipId); + if (!orig) return null; + + const rawTarget = (orig.timelineStart + frameDelta) as TimelineFrame; + const snappedStart = ctx.snap(rawTarget, [this.dragClipId]); + const duration = (clip.timelineEnd - clip.timelineStart) as TimelineFrame; + + return { + clips: [{ + ...clip, // read fresh from state + timelineStart: snappedStart, + timelineEnd: (snappedStart + duration) as TimelineFrame, + }], + isProvisional: true, + }; + } + + // ── MODE 3: multi-clip drag ──────────────────────────────────────────── + if (this.mode === 'drag-clip' && this.isMultiDrag && this.dragClipId !== null) { + if (this.dragStartFrame === null) return null; + + const frameDelta = event.frame - this.dragStartFrame; + const anchorOrig = this.originalPositions.get(this.dragClipId); + if (!anchorOrig) return null; + + // Snap the anchor clip's new start, then derive uniform delta + const rawAnchor = (anchorOrig.timelineStart + frameDelta) as TimelineFrame; + const snappedAnchor = ctx.snap(rawAnchor, [...this.selected]); + const snappedDelta = (snappedAnchor - anchorOrig.timelineStart) as TimelineFrame; + + const ghosts: Clip[] = []; + for (const id of this.selected) { + const c = liveClip(ctx.state, id); + if (!c) continue; + const orig = this.originalPositions.get(id); + if (!orig) continue; + + ghosts.push({ + ...c, // fresh from state + timelineStart: (orig.timelineStart + snappedDelta) as TimelineFrame, + timelineEnd: (orig.timelineEnd + snappedDelta) as TimelineFrame, + }); + } + + return { clips: ghosts, isProvisional: true }; + } + + return null; + } + + // ── ITool: onPointerUp ──────────────────────────────────────────────────── + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + // ── Capture all instance state before resetting ──────────────────────── + // _resetDragState() clears dragStartFrame, dragClipId, etc. + // Everything we need MUST be saved to locals first. + const previousMode = this.mode; + const savedDragClipId = this.dragClipId; + const savedDragStartFrame = this.dragStartFrame; + const savedDragStartX = this.dragStartX; + const savedIsMultiDrag = this.isMultiDrag; + const savedOrigPositions = new Map(this.originalPositions); + const savedRbStartFrame = this.rubberBandStartFrame; + const savedSelected = new Set(this.selected); // snapshot for rubber-band calc + + // Reset drag state (preserves this.selected for click path) + this._resetDragState(); + + // ── MODE 4: rubber-band complete ────────────────────────────────────── + if (previousMode === 'rubber-band') { + // Check if this was just a click on empty space (< 4px delta) + const dxPx = savedDragStartX !== null ? Math.abs(event.x - savedDragStartX) : 0; + if (dxPx < DRAG_THRESHOLD_PX) { + // Click on empty space — clear selection + this.selected.clear(); + return null; + } + + if (savedRbStartFrame === null) return null; + const minFrame = Math.min(savedRbStartFrame, event.frame) as TimelineFrame; + const maxFrame = Math.max(savedRbStartFrame, event.frame) as TimelineFrame; + + for (const track of ctx.state.timeline.tracks) { + for (const clip of track.clips) { + if (clip.timelineStart < maxFrame && clip.timelineEnd > minFrame) { + this.selected.add(clip.id); + } + } + } + return null; // rubber-band produces no Transaction + } + + if (previousMode !== 'drag-clip') return null; + + // ── MODE 1: click (no drag — delta below threshold) ──────────────────── + const dxPx = savedDragStartX !== null ? Math.abs(event.x - savedDragStartX) : 0; + if (dxPx < DRAG_THRESHOLD_PX) { + if (event.clipId !== null) { + if (event.shiftKey) { + if (this.selected.has(event.clipId)) this.selected.delete(event.clipId); + else this.selected.add(event.clipId); + } else { + this.selected.clear(); + this.selected.add(event.clipId); + } + } else { + this.selected.clear(); + } + return null; + } + + if (savedDragClipId === null) return null; + + // ── MODE 2: single clip drag ────────────────────────────────────────── + if (!savedIsMultiDrag) { + const orig = savedOrigPositions.get(savedDragClipId); + if (!orig) return null; + + const frameDelta = (event.frame - (savedDragStartFrame ?? event.frame)) as TimelineFrame; + const rawTarget = (orig.timelineStart + frameDelta) as TimelineFrame; + const snapped = ctx.snap(rawTarget, [savedDragClipId]); + + if (snapped === orig.timelineStart) return null; // no-op + + return { + id: txId(), + label: 'Move Clip', + timestamp: Date.now(), + operations: [{ + type: 'MOVE_CLIP', + clipId: savedDragClipId, + newTimelineStart: snapped, + }], + }; + } + + // ── MODE 3: multi-clip drag ──────────────────────────────────────────── + const anchorOrig = savedOrigPositions.get(savedDragClipId); + if (!anchorOrig) return null; + + const frameDelta = (event.frame - (savedDragStartFrame ?? event.frame)) as TimelineFrame; + const rawAnchor = (anchorOrig.timelineStart + frameDelta) as TimelineFrame; + const snappedAnchor = ctx.snap(rawAnchor, [...savedSelected]); + const snappedDelta = (snappedAnchor - anchorOrig.timelineStart) as TimelineFrame; + + if (snappedDelta === 0) return null; + + const operations = [...savedSelected].flatMap(id => { + const orig = savedOrigPositions.get(id); + if (!orig) return []; + return [{ + type: 'MOVE_CLIP' as const, + clipId: id, + newTimelineStart: (orig.timelineStart + snappedDelta) as TimelineFrame, + }]; + }); + + if (operations.length === 0) return null; + + return { + id: txId(), + label: `Move ${operations.length} Clips`, + timestamp: Date.now(), + operations, + }; + } + + // ── ITool: onKeyDown / onKeyUp ──────────────────────────────────────────── + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + // ── ITool: onCancel ─────────────────────────────────────────────────────── + /** + * Reset ALL instance state. + * Every instance variable must appear here. + * If a new variable is added to the class, it MUST be added here too. + */ + onCancel(): void { + this.selected.clear(); + this.mode = 'idle'; + this.dragStartFrame = null; + this.dragStartX = null; + this.dragStartY = null; + this.dragClipId = null; + this.isMultiDrag = false; + this.originalPositions.clear(); + this.rubberBandStartFrame = null; + this.rubberBandStartY = null; + this.lastClientX = null; + this.lastHitEdge = null; + this._lastHoveredClipId = null; + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + /** Reset per-gesture drag state WITHOUT clearing selection. */ + private _resetDragState(): void { + // Preserve mode/ids for the caller (onPointerUp reads them first, then calls this) + // So only clear after reading: + this.mode = 'idle'; + this.dragStartFrame = null; + this.dragStartX = null; + this.dragStartY = null; + this.isMultiDrag = false; + this.originalPositions.clear(); + this.rubberBandStartFrame = null; + this.rubberBandStartY = null; + } +} diff --git a/packages/core/src/tools/slide-tool.ts b/packages/core/src/tools/slide-tool.ts new file mode 100644 index 0000000..26a9eb5 --- /dev/null +++ b/packages/core/src/tools/slide-tool.ts @@ -0,0 +1,195 @@ +/** + * SlideTool — Phase 7 Step 5 + * + * Moves a clip left/right on the timeline. Neighbors trim to fill: + * left neighbor's end resizes to abut; right neighbor moves and resizes. + * No ripple — total duration unchanged. + * + * Uses: MOVE_CLIP, RESIZE_CLIP (edge 'start' | 'end'). + * Capture-before-reset in onPointerUp. + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { toToolId, type ToolId, type SnapPointType } from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { Track } from '../types/track'; +import type { TimelineFrame } from '../types/frame'; +import type { TimelineState } from '../types/state'; +import type { Transaction, OperationPrimitive } from '../types/operations'; +import { toFrame } from '../types/frame'; +import { findClipById } from '../engine/frame-resolver'; + +function findClipAndTrack( + state: TimelineState, + clipId: ClipId, +): { clip: Clip; track: Track } | null { + const found = findClipById(state, clipId); + return found ? { clip: found.clip, track: found.track } : null; +} + +function findLeftNeighbor(track: Track, clip: Clip): Clip | null { + const clipStart = clip.timelineStart as number; + let best: Clip | null = null; + let bestStart = -1; + for (const c of track.clips) { + const s = c.timelineStart as number; + if (s < clipStart && s > bestStart) { + bestStart = s; + best = c; + } + } + return best; +} + +function findRightNeighbor(track: Track, clip: Clip): Clip | null { + const clipStart = clip.timelineStart as number; + let best: Clip | null = null; + let bestStart = Infinity; + for (const c of track.clips) { + const s = c.timelineStart as number; + if (s > clipStart && s < bestStart) { + bestStart = s; + best = c; + } + } + return best; +} + +let _txSeq = 0; +function txId(): string { + return `slide-tx-${++_txSeq}`; +} + +export class SlideTool implements ITool { + readonly id: ToolId = toToolId('slide'); + readonly shortcutKey = 'Y'; + + private draggingClipId: ClipId | null = null; + private dragStartX = 0; + private originalStart: TimelineFrame = toFrame(0); + + getCursor(_ctx: ToolContext): string { + if (this.draggingClipId !== null) return 'ew-resize'; + return 'grab'; + } + + getSnapCandidateTypes(): readonly SnapPointType[] { + return ['ClipStart', 'ClipEnd']; + } + + onPointerDown(event: TimelinePointerEvent, _ctx: ToolContext): void { + if (event.clipId === null) return; + const found = findClipAndTrack(_ctx.state, event.clipId); + if (!found) return; + this.draggingClipId = event.clipId; + this.dragStartX = event.x; + this.originalStart = found.clip.timelineStart; + } + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null { + if (this.draggingClipId === null) return null; + const found = findClipAndTrack(ctx.state, this.draggingClipId); + if (!found) return null; + const { clip, track } = found; + const left = findLeftNeighbor(track, clip); + const right = findRightNeighbor(track, clip); + const durationFrames = (clip.timelineEnd as number) - (clip.timelineStart as number); + const deltaFrames = Math.round((event.x - this.dragStartX) / ctx.pixelsPerFrame); + let newStartN = (this.originalStart as number) + deltaFrames; + const minStart = left + ? (left.timelineEnd as number) + : 0; + const maxStart = right + ? (right.timelineStart as number) - durationFrames + : (ctx.state.timeline.duration as number) - durationFrames; + newStartN = Math.max(minStart, Math.min(maxStart, newStartN)); + const newStart = toFrame(newStartN); + const ghostClip: Clip = { + ...clip, + timelineStart: newStart, + timelineEnd: toFrame(newStartN + durationFrames), + }; + return { clips: [ghostClip], isProvisional: true }; + } + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + const clipId = this.draggingClipId; + const startX = this.dragStartX; + const origStart = this.originalStart; + this.draggingClipId = null; + this.dragStartX = 0; + this.originalStart = toFrame(0); + + if (clipId === null) return null; + + const found = findClipAndTrack(ctx.state, clipId); + if (!found) return null; + const { clip, track } = found; + const left = findLeftNeighbor(track, clip); + const right = findRightNeighbor(track, clip); + const durationFrames = (clip.timelineEnd as number) - (clip.timelineStart as number); + const deltaFrames = Math.round((event.x - startX) / ctx.pixelsPerFrame); + let newStartN = (origStart as number) + deltaFrames; + const minStart = left ? (left.timelineEnd as number) : 0; + const maxStart = right + ? (right.timelineStart as number) - durationFrames + : (ctx.state.timeline.duration as number) - durationFrames; + newStartN = Math.max(minStart, Math.min(maxStart, newStartN)); + const newStart = toFrame(newStartN); + if (newStartN === (origStart as number)) return null; + + const operations: OperationPrimitive[] = []; + if (left) { + operations.push({ + type: 'RESIZE_CLIP', + clipId: left.id, + edge: 'end', + newFrame: newStart, + }); + } + operations.push({ + type: 'MOVE_CLIP', + clipId, + newTimelineStart: newStart, + }); + if (right) { + const newRightStart = newStartN + durationFrames; + operations.push({ + type: 'MOVE_CLIP', + clipId: right.id, + newTimelineStart: toFrame(newRightStart), + }); + operations.push({ + type: 'RESIZE_CLIP', + clipId: right.id, + edge: 'end', + newFrame: toFrame((right.timelineEnd as number)), + }); + } + + return { + id: txId(), + label: 'Slide', + timestamp: Date.now(), + operations, + }; + } + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + onCancel(): void { + this.draggingClipId = null; + this.dragStartX = 0; + this.originalStart = toFrame(0); + } +} diff --git a/packages/core/src/tools/slip.ts b/packages/core/src/tools/slip.ts new file mode 100644 index 0000000..cb42f21 --- /dev/null +++ b/packages/core/src/tools/slip.ts @@ -0,0 +1,196 @@ +/** + * SlipTool — Phase 2 Step 5 + * + * Drag a clip to shift its media window. The clip's timeline position is + * unchanged — only mediaIn and mediaOut move together by the same delta. + * + * OPERATION: Single SET_MEDIA_BOUNDS. No MOVE_CLIP. No RESIZE_CLIP. Nothing else. + * + * DELTA: rawDelta = event.frame - dragStartFrame (no snapping — slip is in media space) + * + * CLAMP: + * minDelta = -clip.mediaIn → mediaIn + delta >= 0 + * maxDelta = asset.intrinsicDuration - clip.mediaOut → mediaOut + delta <= intrinsicDuration + * clampedDelta = clamp(rawDelta, minDelta, maxDelta) + * + * SNAP: none. getSnapCandidateTypes returns []. + * + * RULES: + * - Zero imports from React, DOM, @webpacked-timeline/react, @webpacked-timeline/ui + * - onPointerMove never dispatches + * - onPointerUp never mutates instance state + * - Every instance variable appears in onCancel() + * - Capture-before-reset: compute delta BEFORE _resetDragState() + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { + toToolId, + type ToolId, + type SnapPointType, +} from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { TimelineFrame } from '../types/frame'; +import type { Transaction } from '../types/operations'; +import type { TimelineState } from '../types/state'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function findClip(state: TimelineState, clipId: ClipId): Clip | undefined { + for (const track of state.timeline.tracks) { + const c = track.clips.find(c => c.id === clipId); + if (c) return c; + } + return undefined; +} + +let _txSeq = 0; +function txId(): string { return `slip-tx-${++_txSeq}`; } + +// --------------------------------------------------------------------------- +// SlipTool +// --------------------------------------------------------------------------- + +export class SlipTool implements ITool { + readonly id: ToolId = toToolId('slip'); + readonly shortcutKey: string = 'y'; // Premiere convention for slip + + // ── 2 drag-tracking vars ────────────────────────────────────────────────── + /** Clip being slipped. Null when idle. */ + private dragClipId: ClipId | null = null; + /** Frame at which pointer went down. Delta = currentFrame - dragStartFrame. */ + private dragStartFrame: TimelineFrame | null = null; + + // ── 1 cursor-staging var ────────────────────────────────────────────────── + /** Staged by onPointerMove — getCursor() has no event parameter. */ + private isHoveringClip: boolean = false; + + // ── ITool: getCursor ────────────────────────────────────────────────────── + + getCursor(_ctx: ToolContext): string { + if (this.dragClipId !== null) return 'ew-resize'; // mid-drag: resize cursor + if (this.isHoveringClip) return 'grab'; // hovering: grab cursor + return 'default'; + } + + // ── ITool: getSnapCandidateTypes ───────────────────────────────────────── + + getSnapCandidateTypes(): readonly SnapPointType[] { + return []; // slip is in media space — no timeline snapping + } + + // ── ITool: onPointerDown ────────────────────────────────────────────────── + + onPointerDown(event: TimelinePointerEvent, _ctx: ToolContext): void { + if (event.clipId === null) return; // clicked empty space + this.dragClipId = event.clipId; + this.dragStartFrame = event.frame; + } + + // ── ITool: onPointerMove ────────────────────────────────────────────────── + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null { + // Stage hover for getCursor() + this.isHoveringClip = event.clipId !== null; + + // Not mid-drag + if (this.dragClipId === null || this.dragStartFrame === null) return null; + + const liveClip = findClip(ctx.state, this.dragClipId); + if (!liveClip) return null; + + const asset = ctx.state.assetRegistry.get(liveClip.assetId); + if (!asset) return null; + + const rawDelta = (event.frame - this.dragStartFrame) as number; + const minDelta = -liveClip.mediaIn as number; + const maxDelta = (asset.intrinsicDuration - liveClip.mediaOut) as number; + const clampedDelta = Math.max(minDelta, Math.min(maxDelta, rawDelta)); + + // Ghost: same timeline bounds, shifted media window + const ghostClip: Clip = { + ...liveClip, + // timelineStart and timelineEnd intentionally NOT overridden — clip stays put + mediaIn: (liveClip.mediaIn + clampedDelta) as TimelineFrame, + mediaOut: (liveClip.mediaOut + clampedDelta) as TimelineFrame, + }; + + return { clips: [ghostClip], isProvisional: true }; + } + + // ── ITool: onPointerUp ──────────────────────────────────────────────────── + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + // Capture-before-reset: compute rawDelta BEFORE calling _resetDragState(), + // because rawDelta uses this.dragStartFrame which reset clears. + if (this.dragClipId === null || this.dragStartFrame === null) { + this._resetDragState(); + return null; + } + + const rawDelta = (event.frame - this.dragStartFrame) as number; + + // Capture, then reset + const clipId = this.dragClipId; + this._resetDragState(); + + const liveClip = findClip(ctx.state, clipId); + if (!liveClip) return null; + + const asset = ctx.state.assetRegistry.get(liveClip.assetId); + if (!asset) return null; + + const minDelta = -liveClip.mediaIn as number; + const maxDelta = (asset.intrinsicDuration - liveClip.mediaOut) as number; + const clampedDelta = Math.max(minDelta, Math.min(maxDelta, rawDelta)); + + // No-op: media window didn't shift + if (clampedDelta === 0) return null; + + return { + id: txId(), + label: 'Slip', + timestamp: Date.now(), + operations: [ + { + type: 'SET_MEDIA_BOUNDS', + clipId, + mediaIn: (liveClip.mediaIn + clampedDelta) as TimelineFrame, + mediaOut: (liveClip.mediaOut + clampedDelta) as TimelineFrame, + }, + ], + }; + } + + // ── ITool: onKeyDown / onKeyUp ──────────────────────────────────────────── + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + // ── ITool: onCancel ─────────────────────────────────────────────────────── + /** Reset ALL instance state. Every variable must appear here. */ + onCancel(): void { + this.dragClipId = null; + this.dragStartFrame = null; + this.isHoveringClip = false; + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private _resetDragState(): void { + this.dragClipId = null; + this.dragStartFrame = null; + // isHoveringClip intentionally NOT reset here — it is a cursor-staging var + } +} diff --git a/packages/core/src/tools/transition-tool.ts b/packages/core/src/tools/transition-tool.ts new file mode 100644 index 0000000..eb7d7e7 --- /dev/null +++ b/packages/core/src/tools/transition-tool.ts @@ -0,0 +1,230 @@ +/** + * TransitionTool — Phase 4 Step 4 + * + * Drag from a clip's right edge to create or resize a transition. + * Click on an existing transition area to delete it. + * + * RULES: + * - onPointerMove never dispatches; returns ProvisionalState for preview + * - onPointerUp never mutates instance state (capture-before-reset) + * - Every instance variable reset in onCancel() + */ + +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + ProvisionalState, +} from './types'; +import { toToolId, type ToolId, type SnapPointType } from './types'; +import type { ClipId, Clip } from '../types/clip'; +import type { TimelineState } from '../types/state'; +import type { Transaction } from '../types/operations'; +import type { TimelineFrame } from '../types/frame'; +import { createTransition, toTransitionId } from '../types/transition'; +import { LINEAR_EASING } from '../types/easing'; +import { applyOperation } from '../engine/apply'; + +const TRANSITION_EDGE_THRESHOLD_PX = 8; + +function findClip(state: TimelineState, clipId: ClipId): Clip | undefined { + for (const track of state.timeline.tracks) { + const c = track.clips.find((c) => c.id === clipId); + if (c) return c; + } + return undefined; +} + +function findClipAtRightEdge( + state: TimelineState, + x: number, + pixelsPerFrame: number, +): { clip: Clip; trackIndex: number } | null { + for (let ti = 0; ti < state.timeline.tracks.length; ti++) { + const track = state.timeline.tracks[ti]!; + for (const clip of track.clips) { + const rightEdgePx = clip.timelineEnd * pixelsPerFrame; + if (Math.abs(x - rightEdgePx) <= TRANSITION_EDGE_THRESHOLD_PX) { + return { clip, trackIndex: ti }; + } + } + } + return null; +} + +function findClipInTransitionZone( + state: TimelineState, + x: number, + pixelsPerFrame: number, +): Clip | null { + for (const track of state.timeline.tracks) { + for (const clip of track.clips) { + if (!clip.transition) continue; + const rightEdgePx = clip.timelineEnd * pixelsPerFrame; + const leftOfTransitionPx = rightEdgePx - clip.transition.durationFrames * pixelsPerFrame; + if (x >= leftOfTransitionPx && x <= rightEdgePx) return clip; + } + } + return null; +} + +let _txSeq = 0; +function txId(): string { + return `transition-tx-${++_txSeq}`; +} + +export class TransitionTool implements ITool { + readonly id: ToolId = toToolId('transition'); + readonly shortcutKey: string = 'T'; + + private pendingClipId: ClipId | null = null; + private dragStartX: number = 0; + private pendingDeleteTransitionClipId: ClipId | null = null; + + getCursor(_ctx: ToolContext): string { + return 'ew-resize'; + } + + getSnapCandidateTypes(): readonly SnapPointType[] { + return ['ClipStart', 'ClipEnd', 'Marker', 'BeatGrid']; + } + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void { + const { state, pixelsPerFrame } = ctx; + const x = event.x; + + // Prefer starting a drag from the right edge over deleting the transition + const atEdge = findClipAtRightEdge(state, x, pixelsPerFrame); + if (atEdge) { + this.pendingClipId = atEdge.clip.id; + this.dragStartX = x; + return; + } + + const inTransition = findClipInTransitionZone(state, x, pixelsPerFrame); + if (inTransition) { + this.pendingDeleteTransitionClipId = inTransition.id; + } + } + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null { + if (this.pendingClipId === null) return null; + + const clip = findClip(ctx.state, this.pendingClipId); + if (!clip) return null; + + const dragDeltaX = event.x - this.dragStartX; + const deltaFrames = Math.round(dragDeltaX / ctx.pixelsPerFrame); + const durationFrames = Math.max(1, deltaFrames); + + const transition = createTransition( + toTransitionId(`tr-${clip.id}-preview`), + 'dissolve', + durationFrames, + 'centerOnCut', + LINEAR_EASING, + ); + + let nextState: TimelineState; + if (clip.transition) { + nextState = applyOperation(ctx.state, { + type: 'SET_TRANSITION_DURATION', + clipId: clip.id, + durationFrames, + }); + } else { + nextState = applyOperation(ctx.state, { + type: 'ADD_TRANSITION', + clipId: clip.id, + transition, + }); + } + + const updatedClip = nextState.timeline.tracks + .flatMap((t) => t.clips) + .find((c) => c.id === this.pendingClipId); + if (!updatedClip) return null; + + return { + clips: [updatedClip], + isProvisional: true, + }; + } + + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null { + const pendingClipId = this.pendingClipId; + const dragStartX = this.dragStartX; + const pendingDelete = this.pendingDeleteTransitionClipId; + + this.pendingClipId = null; + this.dragStartX = 0; + this.pendingDeleteTransitionClipId = null; + + if (pendingDelete !== null) { + return { + id: txId(), + label: 'Delete transition', + timestamp: Date.now(), + operations: [{ type: 'DELETE_TRANSITION', clipId: pendingDelete }], + }; + } + + if (pendingClipId === null) return null; + + const clip = findClip(ctx.state, pendingClipId); + if (!clip) return null; + + const dragDeltaX = event.x - dragStartX; + const deltaFrames = Math.round(dragDeltaX / ctx.pixelsPerFrame); + const durationFrames = Math.max(1, deltaFrames); + if (deltaFrames < 1) return null; + + if (clip.transition) { + return { + id: txId(), + label: 'Set transition duration', + timestamp: Date.now(), + operations: [ + { + type: 'SET_TRANSITION_DURATION', + clipId: pendingClipId, + durationFrames, + }, + ], + }; + } + + const transition = createTransition( + toTransitionId(`tr-${clip.id}-${Date.now()}`), + 'dissolve', + durationFrames, + 'centerOnCut', + LINEAR_EASING, + ); + return { + id: txId(), + label: 'Add transition', + timestamp: Date.now(), + operations: [ + { + type: 'ADD_TRANSITION', + clipId: pendingClipId, + transition, + }, + ], + }; + } + + onKeyDown(_event: TimelineKeyEvent, _ctx: ToolContext): Transaction | null { + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + onCancel(): void { + this.pendingClipId = null; + this.dragStartX = 0; + this.pendingDeleteTransitionClipId = null; + } +} diff --git a/packages/core/src/tools/types.ts b/packages/core/src/tools/types.ts new file mode 100644 index 0000000..1d55506 --- /dev/null +++ b/packages/core/src/tools/types.ts @@ -0,0 +1,163 @@ +/** + * TOOL CONTRACT TYPES — Phase 1 + * + * Zero implementation. Zero imports from React or DOM. + * Every ITool must satisfy this interface exactly. + * + * RULES (from ITOOL_CONTRACT.md): + * - onPointerMove NEVER calls dispatch + * - onPointerUp NEVER mutates instance state + * - onKeyDown, onKeyUp, onCancel are REQUIRED — implement as no-ops if unused + */ + +import type { TimelineFrame } from '../types/frame'; +import type { TrackId } from '../types/track'; +import type { ClipId, Clip } from '../types/clip'; +import type { TimelineState } from '../types/state'; +import type { Transaction } from '../types/operations'; +import type { SnapIndex } from '../snap-index'; +import type { SnapPointType } from '../snap-index'; +export type { SnapPointType } from '../snap-index'; + +// --------------------------------------------------------------------------- +// Branded ID +// --------------------------------------------------------------------------- + +export type ToolId = string & { readonly __brand: 'ToolId' }; + +export function toToolId(s: string): ToolId { + return s as ToolId; +} + +// --------------------------------------------------------------------------- +// Modifiers +// --------------------------------------------------------------------------- + +/** Keyboard modifier state — available on ToolContext so getCursor() can + * react to held keys even when no pointer event is firing. */ +export type Modifiers = { + readonly shift: boolean; + readonly alt: boolean; + readonly ctrl: boolean; + readonly meta: boolean; +}; + +// --------------------------------------------------------------------------- +// Event types +// --------------------------------------------------------------------------- + +/** Normalised pointer event in frame-space. + * ToolRouter populates clipId/trackId via hit-test — tools never recompute it. + * Optional edge is set when click is within clip left/right hit zone (e.g. for trim). */ +export type TimelinePointerEvent = { + readonly frame: TimelineFrame; + readonly trackId: TrackId | null; + readonly clipId: ClipId | null; // clip under cursor at event time, if any + readonly x: number; // client pixels (for snap radius math) + readonly y: number; + readonly buttons: number; // same as PointerEvent.buttons + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + /** When over a clip: 'left' | 'right' if within edge hit zone, else 'none'. */ + readonly edge?: 'left' | 'right' | 'none'; +}; + +export type TimelineKeyEvent = { + readonly key: string; + readonly code: string; + readonly shiftKey: boolean; + readonly altKey: boolean; + readonly metaKey: boolean; + readonly ctrlKey: boolean; + /** True when key is held and OS is firing repeated keydowns. */ + readonly repeat?: boolean; +}; + +// --------------------------------------------------------------------------- +// ProvisionalState +// --------------------------------------------------------------------------- + +/** Pixel + frame region swept by a rubber-band (marquee) selection drag. + * Populated by SelectionTool during rubber-band drags. */ +export type RubberBandRegion = { + readonly startFrame: TimelineFrame; + readonly endFrame: TimelineFrame; + readonly startY: number; // clientY of drag origin + readonly endY: number; // clientY of current cursor position +}; + +/** Ghost state produced by onPointerMove. + * isProvisional: true is a compile-time discriminant so resolveClip() + * can distinguish provisional from committed Clip[] arrays. */ +export type ProvisionalState = { + readonly clips: readonly Clip[]; + readonly rubberBand?: RubberBandRegion; // populated during rubber-band select drag + readonly isProvisional: true; +}; + +// --------------------------------------------------------------------------- +// ToolContext +// --------------------------------------------------------------------------- + +/** Injected by TimelineEngine on every event call. + * Tools never import TimelineEngine. They never call dispatch() directly. */ +export type ToolContext = { + readonly state: TimelineState; + readonly snapIndex: SnapIndex; + readonly pixelsPerFrame: number; + /** Current modifier key state — updates on every pointer/key event. */ + readonly modifiers: Modifiers; + /** Convert a client-pixel x-position to a TimelineFrame. */ + readonly frameAtX: (x: number) => TimelineFrame; + /** Return the TrackId whose row contains client-pixel y, or null. */ + readonly trackAtY: (y: number) => TrackId | null; + /** Query snap and return the snapped frame (or original if no hit). + * Handles enabled/disabled, radius, exclusion, and type filter internally. + * Tools never see radiusFrames or the enabled flag. */ + readonly snap: ( + frame: TimelineFrame, + exclude?: readonly string[], + allowedTypes?: readonly SnapPointType[], + ) => TimelineFrame; +}; + +// --------------------------------------------------------------------------- +// ITool interface +// --------------------------------------------------------------------------- + +export interface ITool { + readonly id: ToolId; + /** Single-character keyboard shortcut, e.g. 'v', 'b', 'r'. Empty string = no shortcut. */ + readonly shortcutKey: string; + + /** Return the CSS cursor string for the current tool + modifier state. + * Called on every pointermove — must be cheap. */ + getCursor(ctx: ToolContext): string; + + /** Return the SnapPointType categories this tool snaps to. + * Used by ctx.snap() to filter the snap index automatically. */ + getSnapCandidateTypes(): readonly SnapPointType[]; + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void; + + /** Return ProvisionalState for ghost rendering. + * MUST NOT call dispatch. MUST NOT call engine methods. */ + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): ProvisionalState | null; + + /** Return a Transaction to commit, or null if this gesture produces no edit. + * MUST NOT mutate any instance state. */ + onPointerUp(event: TimelinePointerEvent, ctx: ToolContext): Transaction | null; + + /** Handle a keydown — return a Transaction or null. + * Required — implement as `return null` if unused. */ + onKeyDown(event: TimelineKeyEvent, ctx: ToolContext): Transaction | null; + + /** Handle a keyup — no return value. + * Required — implement as no-op if unused. */ + onKeyUp(event: TimelineKeyEvent, ctx: ToolContext): void; + + /** Called when a gesture is interrupted (Escape, tool switch mid-drag). + * Required — implement as no-op if unused. */ + onCancel(): void; +} diff --git a/packages/core/src/tools/zoom-tool.ts b/packages/core/src/tools/zoom-tool.ts new file mode 100644 index 0000000..47023d3 --- /dev/null +++ b/packages/core/src/tools/zoom-tool.ts @@ -0,0 +1,99 @@ +/** + * ZoomTool — Phase 7 Step 5 + * + * Adjusts pixelsPerFrame (zoom level) via callback only. + * Does NOT dispatch any operations; pixelsPerFrame is UI state. + */ + +import type { ITool, ToolContext, TimelinePointerEvent, TimelineKeyEvent } from './types'; +import { toToolId, type ToolId, type SnapPointType } from './types'; + +export type ZoomToolOptions = { + onZoomChange: (pixelsPerFrame: number) => void; + minPixelsPerFrame?: number; + maxPixelsPerFrame?: number; + initialPixelsPerFrame?: number; +}; + +const DEFAULT_MIN = 0.5; +const DEFAULT_MAX = 200; +const DEFAULT_INITIAL = 10; + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export class ZoomTool implements ITool { + readonly id: ToolId = toToolId('zoom'); + readonly shortcutKey = 'Z'; + + private dragStartX = 0; + private dragStartZoom = 0; + + private readonly options: Required; + + constructor(options: ZoomToolOptions) { + this.options = { + onZoomChange: options.onZoomChange, + minPixelsPerFrame: options.minPixelsPerFrame ?? DEFAULT_MIN, + maxPixelsPerFrame: options.maxPixelsPerFrame ?? DEFAULT_MAX, + initialPixelsPerFrame: options.initialPixelsPerFrame ?? DEFAULT_INITIAL, + }; + } + + getCursor(_ctx: ToolContext): string { + return 'zoom-in'; + } + + getSnapCandidateTypes(): readonly SnapPointType[] { + return []; + } + + onPointerDown(event: TimelinePointerEvent, ctx: ToolContext): void { + this.dragStartX = event.x; + this.dragStartZoom = ctx.pixelsPerFrame; + } + + onPointerMove(event: TimelinePointerEvent, ctx: ToolContext): null { + const deltaX = event.x - this.dragStartX; + const factor = Math.pow(1.01, deltaX); + const newZoom = clamp( + this.dragStartZoom * factor, + this.options.minPixelsPerFrame, + this.options.maxPixelsPerFrame, + ); + this.options.onZoomChange(newZoom); + return null; + } + + onPointerUp(_event: TimelinePointerEvent, _ctx: ToolContext): null { + this.dragStartX = 0; + this.dragStartZoom = 0; + return null; + } + + onKeyDown(event: TimelineKeyEvent, ctx: ToolContext): null { + const current = ctx.pixelsPerFrame; + const { minPixelsPerFrame, maxPixelsPerFrame, initialPixelsPerFrame, onZoomChange } = this.options; + if (event.key === '+' || event.key === '=') { + onZoomChange(clamp(current * 1.25, minPixelsPerFrame, maxPixelsPerFrame)); + } else if (event.key === '-') { + onZoomChange(clamp(current * 0.8, minPixelsPerFrame, maxPixelsPerFrame)); + } else if (event.key === '0') { + onZoomChange(clamp(initialPixelsPerFrame, minPixelsPerFrame, maxPixelsPerFrame)); + } + return null; + } + + onKeyUp(_event: TimelineKeyEvent, _ctx: ToolContext): void {} + + onCancel(): void { + this.dragStartX = 0; + this.dragStartZoom = 0; + } +} + +/** Returns an ITool that wraps ZoomTool with the given options (for host registration). */ +export function createZoomTool(options: ZoomToolOptions): ITool { + return new ZoomTool(options); +} diff --git a/packages/core/src/types/asset.ts b/packages/core/src/types/asset.ts index 117b3ec..caab591 100644 --- a/packages/core/src/types/asset.ts +++ b/packages/core/src/types/asset.ts @@ -1,84 +1,110 @@ /** - * ASSET MODEL - * - * An Asset represents immutable metadata about a media file. - * - * WHAT IS AN ASSET? - * - A reference to source media (video, audio, image) - * - Contains metadata like duration and type - * - Immutable once registered (duration never changes) - * - * WHY SEPARATE ASSETS FROM CLIPS? - * - Multiple clips can reference the same asset - * - Asset duration is the source of truth - * - Clips can trim/slice the asset without modifying it - * - * EXAMPLE: - * ```typescript - * const asset: Asset = { - * id: 'asset_1', - * type: 'video', - * duration: frame(3600), // 2 minutes at 30fps - * sourceUrl: 'https://example.com/video.mp4', - * }; - * ``` - * - * INVARIANTS: - * - Asset ID must be unique - * - Duration must be positive - * - Duration is immutable after registration + * ASSET MODEL — Phase 0 + Phase 3 + * + * Asset is FileAsset | GeneratorAsset. Multiple Clips can reference the same Asset. + * Assets never change their intrinsicDuration after registration. */ -import type { Frame } from './frame'; +import type { TimelineFrame, FrameRate } from './frame'; +import type { TrackType } from './track'; +import type { Generator } from './generator'; -/** - * AssetType - The kind of media this asset represents - */ -export type AssetType = 'video' | 'audio' | 'image'; +// --------------------------------------------------------------------------- +// Branded IDs +// --------------------------------------------------------------------------- -/** - * Asset - Immutable metadata about a media file - */ -export interface Asset { - /** Unique identifier */ +export type AssetId = string & { readonly __brand: 'AssetId' }; + +export const toAssetId = (s: string): AssetId => s as AssetId; + +// --------------------------------------------------------------------------- +// Asset status +// --------------------------------------------------------------------------- + +export type AssetStatus = 'online' | 'offline' | 'proxy-only' | 'missing'; + +// --------------------------------------------------------------------------- +// FileAsset — media file on disk +// --------------------------------------------------------------------------- + +export type FileAsset = { + readonly kind: 'file'; + readonly id: AssetId; + readonly name: string; + readonly mediaType: TrackType; + readonly filePath: string; + readonly intrinsicDuration: TimelineFrame; + readonly nativeFps: FrameRate; + readonly sourceTimecodeOffset: TimelineFrame; + readonly status: AssetStatus; +}; + +// --------------------------------------------------------------------------- +// GeneratorAsset — synthetic asset (no filePath) +// --------------------------------------------------------------------------- + +export type GeneratorAsset = { + readonly kind: 'generator'; + readonly id: AssetId; + readonly name: string; + readonly mediaType: TrackType; + readonly intrinsicDuration: TimelineFrame; + readonly nativeFps: FrameRate; + readonly sourceTimecodeOffset: TimelineFrame; + readonly status: AssetStatus; + readonly generatorDef: Generator; +}; + +// --------------------------------------------------------------------------- +// Asset — discriminated union +// --------------------------------------------------------------------------- + +export type Asset = FileAsset | GeneratorAsset; + +// --------------------------------------------------------------------------- +// Factories +// --------------------------------------------------------------------------- + +export function createAsset(params: { id: string; - - /** Type of media */ - type: AssetType; - - /** Total duration of the asset in frames */ - duration: Frame; - - /** Source URL or file path */ - sourceUrl: string; - - /** Optional metadata for custom use cases */ - metadata?: Record; + name: string; + mediaType: TrackType; + filePath: string; + intrinsicDuration: TimelineFrame; + nativeFps: FrameRate; + sourceTimecodeOffset: TimelineFrame; + status?: AssetStatus; +}): FileAsset { + return { + kind: 'file', + id: params.id as AssetId, + name: params.name, + mediaType: params.mediaType, + filePath: params.filePath, + intrinsicDuration: params.intrinsicDuration, + nativeFps: params.nativeFps, + sourceTimecodeOffset: params.sourceTimecodeOffset, + status: params.status ?? 'online', + }; } -/** - * Create a new asset - * - * @param params - Asset parameters - * @returns A new Asset object - */ -export function createAsset(params: { +export function createGeneratorAsset(params: { id: string; - type: AssetType; - duration: Frame; - sourceUrl: string; - metadata?: Record; -}): Asset { - const asset: Asset = { - id: params.id, - type: params.type, - duration: params.duration, - sourceUrl: params.sourceUrl, + name: string; + mediaType: TrackType; + generatorDef: Generator; + nativeFps: FrameRate; + status?: AssetStatus; +}): GeneratorAsset { + return { + kind: 'generator', + id: params.id as AssetId, + name: params.name, + mediaType: params.mediaType, + intrinsicDuration: params.generatorDef.duration, + nativeFps: params.nativeFps, + sourceTimecodeOffset: 0 as TimelineFrame, + status: params.status ?? 'online', + generatorDef: params.generatorDef, }; - - if (params.metadata !== undefined) { - asset.metadata = params.metadata; - } - - return asset; } diff --git a/packages/core/src/types/audio-properties.ts b/packages/core/src/types/audio-properties.ts new file mode 100644 index 0000000..cce0240 --- /dev/null +++ b/packages/core/src/types/audio-properties.ts @@ -0,0 +1,28 @@ +/** + * AUDIO PROPERTIES — Phase 4 + * + * Per-clip audio: gain, pan, mute, channel routing. + */ + +import { + createAnimatableProperty, + type AnimatableProperty, +} from './clip-transform'; + +export type ChannelRouting = 'stereo' | 'mono' | 'left' | 'right'; + +export type AudioProperties = { + readonly gain: AnimatableProperty; // dB, default 0 + readonly pan: AnimatableProperty; // -1 to 1, default 0 + readonly mute: boolean; // default false + readonly channelRouting: ChannelRouting; // default 'stereo' + readonly normalizationGain: number; // dB, default 0 +}; + +export const DEFAULT_AUDIO_PROPERTIES: AudioProperties = { + gain: createAnimatableProperty(0), + pan: createAnimatableProperty(0), + mute: false, + channelRouting: 'stereo', + normalizationGain: 0, +}; diff --git a/packages/core/src/types/caption.ts b/packages/core/src/types/caption.ts new file mode 100644 index 0000000..e2e0a2f --- /dev/null +++ b/packages/core/src/types/caption.ts @@ -0,0 +1,41 @@ +/** + * CAPTION MODEL — Phase 3 + * + * Captions live on Track.captions[]. Used for SRT/VTT and burn-in. + */ + +import type { TimelineFrame } from './frame'; + +// --------------------------------------------------------------------------- +// Branded ID +// --------------------------------------------------------------------------- + +export type CaptionId = string & { readonly __brand: 'CaptionId' }; +export const toCaptionId = (s: string): CaptionId => s as CaptionId; + +// --------------------------------------------------------------------------- +// CaptionStyle +// --------------------------------------------------------------------------- + +export type CaptionStyle = { + readonly fontFamily: string; + readonly fontSize: number; + readonly color: string; + readonly backgroundColor: string; + readonly hAlign: 'left' | 'center' | 'right'; + readonly vAlign: 'top' | 'center' | 'bottom'; +}; + +// --------------------------------------------------------------------------- +// Caption +// --------------------------------------------------------------------------- + +export type Caption = { + readonly id: CaptionId; + readonly text: string; + readonly startFrame: TimelineFrame; + readonly endFrame: TimelineFrame; + readonly language: string; // BCP-47: 'en-US', 'fr-FR' + readonly style: CaptionStyle; + readonly burnIn: boolean; +}; diff --git a/packages/core/src/types/clip-transform.ts b/packages/core/src/types/clip-transform.ts new file mode 100644 index 0000000..1ff8f09 --- /dev/null +++ b/packages/core/src/types/clip-transform.ts @@ -0,0 +1,39 @@ +/** + * CLIP TRANSFORM — Phase 4 + * + * Animatable position, scale, rotation, opacity, anchor. + * Each property: base value + optional keyframes. + */ + +import type { Keyframe } from './keyframe'; + +export type AnimatableProperty = { + readonly value: number; + readonly keyframes: readonly Keyframe[]; +}; + +export function createAnimatableProperty(value: number): AnimatableProperty { + return { value, keyframes: [] }; +} + +export type ClipTransform = { + readonly positionX: AnimatableProperty; // pixels, default 0 + readonly positionY: AnimatableProperty; // pixels, default 0 + readonly scaleX: AnimatableProperty; // multiplier, default 1 + readonly scaleY: AnimatableProperty; // multiplier, default 1 + readonly rotation: AnimatableProperty; // degrees, default 0 + readonly opacity: AnimatableProperty; // 0–1, default 1 + readonly anchorX: AnimatableProperty; // pixels, default 0 + readonly anchorY: AnimatableProperty; // pixels, default 0 +}; + +export const DEFAULT_CLIP_TRANSFORM: ClipTransform = { + positionX: createAnimatableProperty(0), + positionY: createAnimatableProperty(0), + scaleX: createAnimatableProperty(1), + scaleY: createAnimatableProperty(1), + rotation: createAnimatableProperty(0), + opacity: createAnimatableProperty(1), + anchorX: createAnimatableProperty(0), + anchorY: createAnimatableProperty(0), +}; diff --git a/packages/core/src/types/clip.ts b/packages/core/src/types/clip.ts index 6992a17..f830723 100644 --- a/packages/core/src/types/clip.ts +++ b/packages/core/src/types/clip.ts @@ -1,176 +1,130 @@ /** - * CLIP MODEL - * - * A Clip represents a time-bound reference to an Asset on a Track. - * - * WHAT IS A CLIP? - * - A piece of media placed at a specific time on the timeline - * - References an Asset (the source media) - * - Defines WHEN it appears (timeline bounds) - * - Defines WHAT portion of the asset to play (media bounds) - * - * KEY CONCEPTS: - * - * 1. TIMELINE BOUNDS: - * - timelineStart: When the clip appears on the timeline - * - timelineEnd: When the clip ends on the timeline - * - Duration = timelineEnd - timelineStart - * - * 2. MEDIA BOUNDS: - * - mediaIn: Start frame in the source asset - * - mediaOut: End frame in the source asset - * - Defines which portion of the asset to play - * - * EXAMPLE: - * ```typescript - * // A 10-second video asset - * const asset = { id: 'asset_1', duration: frame(300) }; // 300 frames at 30fps - * - * // A clip that shows 3 seconds of the video (frames 60-150) - * // starting at 5 seconds on the timeline - * const clip: Clip = { - * id: 'clip_1', - * assetId: 'asset_1', - * trackId: 'track_1', - * timelineStart: frame(150), // 5 seconds * 30fps - * timelineEnd: frame(240), // 8 seconds * 30fps - * mediaIn: frame(60), // Start at 2 seconds into the asset - * mediaOut: frame(150), // End at 5 seconds into the asset - * }; - * ``` - * - * INVARIANTS (Phase 1 - No Speed Remapping): - * - timelineEnd > timelineStart - * - mediaOut > mediaIn - * - timelineEnd - timelineStart === mediaOut - mediaIn (same duration) - * - mediaOut <= asset.duration (can't exceed asset bounds) - * - All frame values must be non-negative - * - * FUTURE: When speed remapping is added, the duration constraint will change. + * CLIP MODEL — Phase 0 compliant + * + * A Clip is a time-bound reference to an Asset placed on a Track. + * All fields are readonly. Never mutate — always return a new object. */ -import { Frame } from './frame'; +import type { TimelineFrame } from './frame'; +import type { AssetId } from './asset'; +import type { TrackId } from './track'; +import type { Effect } from './effect'; +import type { ClipTransform } from './clip-transform'; +import type { AudioProperties } from './audio-properties'; +import type { Transition } from './transition'; -/** - * Clip - A time-bound reference to an Asset - */ -export interface Clip { - /** Unique identifier */ - id: string; - - /** Reference to the asset this clip uses */ - assetId: string; - - /** Reference to the track this clip belongs to */ - trackId: string; - - // === TIMELINE BOUNDS (when the clip appears) === - - /** Start frame on the timeline */ - timelineStart: Frame; - - /** End frame on the timeline */ - timelineEnd: Frame; - - // === MEDIA BOUNDS (which part of the asset to play) === - - /** Start frame in the source asset */ - mediaIn: Frame; - - /** End frame in the source asset */ - mediaOut: Frame; - - /** Optional label for the clip */ - label?: string; - - /** Optional metadata for custom use cases */ - metadata?: Record; - - // === PHASE 2: LINKING & GROUPING === - - /** Optional link group ID - clips in same group move/delete together */ - linkGroupId?: string; - - /** Optional group ID - for visual organization */ - groupId?: string; -} +// --------------------------------------------------------------------------- +// Branded ID +// --------------------------------------------------------------------------- + +export type ClipId = string & { readonly __brand: 'ClipId' }; +export const toClipId = (s: string): ClipId => s as ClipId; + +// --------------------------------------------------------------------------- +// Clip +// --------------------------------------------------------------------------- /** - * Create a new clip - * - * @param params - Clip parameters - * @returns A new Clip object + * Clip — a time-bound viewport into an Asset on a Track. + * + * TIMELINE BOUNDS: timelineStart / timelineEnd — where it sits on the track. + * MEDIA BOUNDS: mediaIn / mediaOut — which portion of the asset plays. + * + * INVARIANTS (Phase 0, speed=1.0): + * timelineEnd > timelineStart + * mediaOut > mediaIn + * (mediaOut - mediaIn) === (timelineEnd - timelineStart) + * mediaIn >= 0 + * mediaOut <= asset.intrinsicDuration + * timelineEnd <= timeline.duration + * speed > 0 */ +export type Clip = { + readonly id: ClipId; + readonly assetId: AssetId; + readonly trackId: TrackId; + + // — Timeline bounds — + readonly timelineStart: TimelineFrame; + readonly timelineEnd: TimelineFrame; + + // — Media bounds — + readonly mediaIn: TimelineFrame; + readonly mediaOut: TimelineFrame; + + readonly speed: number; // 1.0 = normal, > 0 always + readonly enabled: boolean; + readonly reversed: boolean; + readonly name: string | null; + readonly color: string | null; + readonly metadata: Record; + // — Phase 4 — + readonly effects?: readonly Effect[]; + readonly transform?: ClipTransform; + readonly audio?: AudioProperties; + readonly transition?: Transition; +}; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + export function createClip(params: { id: string; assetId: string; trackId: string; - timelineStart: Frame; - timelineEnd: Frame; - mediaIn: Frame; - mediaOut: Frame; - label?: string; - metadata?: Record; + timelineStart: TimelineFrame; + timelineEnd: TimelineFrame; + mediaIn: TimelineFrame; + mediaOut: TimelineFrame; + speed?: number; + enabled?: boolean; + reversed?: boolean; + name?: string | null; + color?: string | null; + metadata?: Record; + effects?: readonly Effect[]; + transform?: ClipTransform; + audio?: AudioProperties; + transition?: Transition; }): Clip { - const clip: Clip = { - id: params.id, - assetId: params.assetId, - trackId: params.trackId, + return { + id: params.id as ClipId, + assetId: params.assetId as AssetId, + trackId: params.trackId as TrackId, timelineStart: params.timelineStart, timelineEnd: params.timelineEnd, mediaIn: params.mediaIn, mediaOut: params.mediaOut, + speed: params.speed ?? 1.0, + enabled: params.enabled ?? true, + reversed: params.reversed ?? false, + name: params.name ?? null, + color: params.color ?? null, + metadata: params.metadata ?? {}, + ...(params.effects !== undefined && { effects: params.effects }), + ...(params.transform !== undefined && { transform: params.transform }), + ...(params.audio !== undefined && { audio: params.audio }), + ...(params.transition !== undefined && { transition: params.transition }), }; - - if (params.label !== undefined) { - clip.label = params.label; - } - - if (params.metadata !== undefined) { - clip.metadata = params.metadata; - } - - return clip; } -/** - * Get the timeline duration of a clip - * - * @param clip - The clip - * @returns Duration in frames - */ -export function getClipDuration(clip: Clip): Frame { - return (clip.timelineEnd - clip.timelineStart) as Frame; +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +export function getClipDuration(clip: Clip): TimelineFrame { + return (clip.timelineEnd - clip.timelineStart) as TimelineFrame; } -/** - * Get the media duration of a clip - * - * @param clip - The clip - * @returns Duration in frames - */ -export function getClipMediaDuration(clip: Clip): Frame { - return (clip.mediaOut - clip.mediaIn) as Frame; +export function getClipMediaDuration(clip: Clip): TimelineFrame { + return (clip.mediaOut - clip.mediaIn) as TimelineFrame; } -/** - * Check if a clip contains a specific frame on the timeline - * - * @param clip - The clip - * @param frame - The frame to check - * @returns true if the frame is within the clip's timeline bounds - */ -export function clipContainsFrame(clip: Clip, frame: Frame): boolean { - return frame >= clip.timelineStart && frame < clip.timelineEnd; +export function clipContainsFrame(clip: Clip, f: TimelineFrame): boolean { + return f >= clip.timelineStart && f < clip.timelineEnd; } -/** - * Check if two clips overlap on the timeline - * - * @param clip1 - First clip - * @param clip2 - Second clip - * @returns true if the clips overlap - */ -export function clipsOverlap(clip1: Clip, clip2: Clip): boolean { - return clip1.timelineStart < clip2.timelineEnd && clip2.timelineStart < clip1.timelineEnd; +export function clipsOverlap(a: Clip, b: Clip): boolean { + return a.timelineStart < b.timelineEnd && b.timelineStart < a.timelineEnd; } diff --git a/packages/core/src/types/compression.ts b/packages/core/src/types/compression.ts new file mode 100644 index 0000000..eb09b82 --- /dev/null +++ b/packages/core/src/types/compression.ts @@ -0,0 +1,51 @@ +/** + * Transaction compression policy — Phase 7 Step 3 + * + * Rapid same-type ops within a time window can be merged + * into a single history entry (last-write-wins). + */ + +export type CompressionPolicy = + | { readonly kind: 'none' } + | { + readonly kind: 'last-write-wins'; + readonly windowMs: number; + }; + +export type CompressibleOpType = + | 'MOVE_CLIP' + | 'SET_CLIP_TRANSFORM' + | 'SET_AUDIO_PROPERTIES' + | 'SET_EFFECT_PARAM' + | 'MOVE_KEYFRAME' + | 'SET_TRANSITION_DURATION' + | 'MOVE_MARKER' + | 'SET_IN_POINT' + | 'SET_OUT_POINT' + | 'SET_TRACK_OPACITY'; + +const COMPRESSIBLE_OP_TYPES: ReadonlySet = new Set([ + 'MOVE_CLIP', + 'SET_CLIP_TRANSFORM', + 'SET_AUDIO_PROPERTIES', + 'SET_EFFECT_PARAM', + 'MOVE_KEYFRAME', + 'SET_TRANSITION_DURATION', + 'MOVE_MARKER', + 'SET_IN_POINT', + 'SET_OUT_POINT', + 'SET_TRACK_OPACITY', +]); + +export function isCompressibleOpType(type: string): type is CompressibleOpType { + return COMPRESSIBLE_OP_TYPES.has(type as CompressibleOpType); +} + +export const DEFAULT_COMPRESSION_POLICY: CompressionPolicy = { + kind: 'last-write-wins', + windowMs: 300, +}; + +export const NO_COMPRESSION: CompressionPolicy = { + kind: 'none', +}; diff --git a/packages/core/src/types/easing.ts b/packages/core/src/types/easing.ts new file mode 100644 index 0000000..f0f8451 --- /dev/null +++ b/packages/core/src/types/easing.ts @@ -0,0 +1,22 @@ +/** + * EASING CURVES — Phase 4 + * + * Discriminated union for keyframe/interpolation easing. + */ + +export type EasingCurve = + | { readonly kind: 'Linear' } + | { readonly kind: 'Hold' } + | { readonly kind: 'EaseIn'; readonly power: number } + | { readonly kind: 'EaseOut'; readonly power: number } + | { readonly kind: 'EaseBoth'; readonly power: number } + | { + readonly kind: 'BezierCurve'; + readonly p1x: number; + readonly p1y: number; + readonly p2x: number; + readonly p2y: number; + }; + +export const LINEAR_EASING: EasingCurve = { kind: 'Linear' }; +export const HOLD_EASING: EasingCurve = { kind: 'Hold' }; diff --git a/packages/core/src/types/effect.ts b/packages/core/src/types/effect.ts new file mode 100644 index 0000000..1827b5b --- /dev/null +++ b/packages/core/src/types/effect.ts @@ -0,0 +1,43 @@ +/** + * EFFECT MODEL — Phase 4 + * + * Effect applied to a clip (blur, LUT, color correct, host-defined). + */ + +import type { Keyframe } from './keyframe'; + +export type EffectId = string & { readonly __brand: 'EffectId' }; +export function toEffectId(s: string): EffectId { + return s as EffectId; +} + +/** Open string: 'blur', 'lut', 'colorCorrect', host-defined. */ +export type EffectType = string; + +export type RenderStage = + | 'preComposite' // applied before track composite + | 'postComposite' // applied after track composite + | 'output'; // applied to final output + +export type EffectParam = { + readonly key: string; + readonly value: number | string | boolean; +}; + +export type Effect = { + readonly id: EffectId; + readonly effectType: EffectType; + readonly enabled: boolean; + readonly renderStage: RenderStage; + readonly params: readonly EffectParam[]; + readonly keyframes: readonly Keyframe[]; +}; + +export function createEffect( + id: EffectId, + effectType: EffectType, + renderStage: RenderStage = 'preComposite', + params: readonly EffectParam[] = [], +): Effect { + return { id, effectType, enabled: true, renderStage, params, keyframes: [] }; +} diff --git a/packages/core/src/types/frame.ts b/packages/core/src/types/frame.ts index 2e5f22e..7cfb436 100644 --- a/packages/core/src/types/frame.ts +++ b/packages/core/src/types/frame.ts @@ -1,95 +1,118 @@ /** * FRAME-BASED TIME REPRESENTATION - * - * This file defines the foundational time system for the entire timeline engine. - * - * WHY FRAMES INSTEAD OF MILLISECONDS? - * - Frames are discrete, integer values (no floating-point drift) - * - Frames are deterministic (same input always produces same output) - * - Frames match how video editors actually work (frame-accurate editing) - * - Frames prevent rounding errors that accumulate over time - * - * CRITICAL RULE: - * All time values in the timeline state MUST be stored as frames. - * Never store time as seconds or milliseconds in state. - * - * USAGE: - * ```typescript - * const fps = 30 as FrameRate; - * const frame = 150 as Frame; // 5 seconds at 30fps - * ``` + * + * Phase 0 compliant. All time values in state are TimelineFrame branded integers. + * FrameRate is a discriminated literal union — never a raw float. + * + * THREE INVIOLABLE RULES: + * 1. Core has ZERO UI framework imports. + * 2. Every function that changes state returns a NEW object. + * 3. Every frame value is a branded TimelineFrame integer — never a raw number. */ -/** - * Frame - A discrete point in time, measured in frames - * - * This is a "branded type" - it's just a number at runtime, - * but TypeScript treats it as a distinct type to prevent mixing - * frames with regular numbers accidentally. - * - * INVARIANT: Frame values must be non-negative integers - */ -export type Frame = number & { readonly __brand: 'Frame' }; +// --------------------------------------------------------------------------- +// TimelineFrame +// --------------------------------------------------------------------------- /** - * FrameRate - Frames per second (FPS) - * - * Common values: 24, 25, 30, 60 - * - * INVARIANT: FrameRate must be a positive number + * TimelineFrame — A discrete, non-negative integer point in time measured in frames. + * + * Branded so TypeScript prevents raw numbers from sneaking into frame positions. + * The ONLY way to create one is via toFrame(). */ -export type FrameRate = number & { readonly __brand: 'FrameRate' }; +export type TimelineFrame = number & { readonly __brand: "TimelineFrame" }; + +/** The canonical factory. Use this everywhere instead of casting. */ +export const toFrame = (n: number): TimelineFrame => n as TimelineFrame; /** - * Create a Frame value from a number - * - * This function validates and rounds the input to ensure it's a valid frame number. - * - * @param value - The frame number (will be rounded to nearest integer) - * @returns A valid Frame value - * @throws Error if value is negative + * Legacy alias kept for backward-compat during transition. + * Prefer toFrame() for new code. */ -export function frame(value: number): Frame { +export function frame(value: number): TimelineFrame { const rounded = Math.round(value); - if (rounded < 0) { - throw new Error(`Frame value must be non-negative, got: ${value}`); + throw new Error(`TimelineFrame must be non-negative, got: ${value}`); } - - return rounded as Frame; + return rounded as TimelineFrame; } +// --------------------------------------------------------------------------- +// FrameRate — Discriminated literal union, never a raw float +// --------------------------------------------------------------------------- + /** - * Create a FrameRate value from a number - * - * @param value - The frames per second - * @returns A valid FrameRate value - * @throws Error if value is not positive + * FrameRate — The exact set of supported frame rates. + * + * RULE: Never pass 29.97 as a plain number. Use the literal type. + * This is a discriminated union — TypeScript enforces membership at compile time. + */ +export type FrameRate = 23.976 | 24 | 25 | 29.97 | 30 | 50 | 59.94 | 60; + +/** + * Named constants for the most common rates. + * Prefer these over raw literals where possible. + */ +export const FrameRates = { + CINEMA: 24 as FrameRate, + PAL: 25 as FrameRate, + NTSC_DF: 29.97 as FrameRate, + NTSC: 30 as FrameRate, + PAL_HFR: 50 as FrameRate, + NTSC_HFR: 59.94 as FrameRate, + HFR: 60 as FrameRate, +} as const; + +/** + * Legacy factory — kept for backward-compat with existing tests. + * This now validates that the value is a member of the FrameRate union. + * @throws if the value is not a recognised frame rate. */ export function frameRate(value: number): FrameRate { - if (value <= 0) { - throw new Error(`FrameRate must be positive, got: ${value}`); + const valid: FrameRate[] = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60]; + if (!valid.includes(value as FrameRate)) { + throw new Error( + `FrameRate must be one of ${valid.join(", ")}, got: ${value}`, + ); } - return value as FrameRate; } +// --------------------------------------------------------------------------- +// Derived time types +// --------------------------------------------------------------------------- + /** - * Check if a value is a valid frame number - * - * @param value - The value to check - * @returns true if the value is a non-negative integer + * RationalTime — a frame count at a specific rate. Used only at + * ingest/export boundaries. Never stored in TimelineState. */ +export type RationalTime = { + readonly value: number; + readonly rate: FrameRate; +}; + +/** + * Timecode — SMPTE timecode string, display-only. Never use for arithmetic. + */ +export type Timecode = string & { readonly __brand: "Timecode" }; +export const toTimecode = (s: string): Timecode => s as Timecode; + +/** + * TimeRange — a start + duration pair, both in TimelineFrame units. + */ +export type TimeRange = { + readonly startFrame: TimelineFrame; + readonly duration: TimelineFrame; +}; + +// --------------------------------------------------------------------------- +// Guard helpers +// --------------------------------------------------------------------------- + export function isValidFrame(value: number): boolean { return Number.isInteger(value) && value >= 0; } -/** - * Check if a value is a valid frame rate - * - * @param value - The value to check - * @returns true if the value is positive - */ -export function isValidFrameRate(value: number): boolean { - return value > 0; +export function isDropFrame(fps: FrameRate): boolean { + return fps === 29.97 || fps === 59.94; } diff --git a/packages/core/src/types/generator.ts b/packages/core/src/types/generator.ts new file mode 100644 index 0000000..3e5a10d --- /dev/null +++ b/packages/core/src/types/generator.ts @@ -0,0 +1,29 @@ +/** + * GENERATOR MODEL — Phase 3 + * + * Generators are synthetic "assets" (solid, bars, countdown, etc.) + * registered in AssetRegistry as GeneratorAsset. No filePath. + */ + +import type { TimelineFrame } from './frame'; + +// --------------------------------------------------------------------------- +// Branded ID +// --------------------------------------------------------------------------- + +export type GeneratorId = string & { readonly __brand: 'GeneratorId' }; +export const toGeneratorId = (s: string): GeneratorId => s as GeneratorId; + +// --------------------------------------------------------------------------- +// Generator type +// --------------------------------------------------------------------------- + +export type GeneratorType = 'solid' | 'bars' | 'countdown' | 'noise' | 'text'; + +export type Generator = { + readonly id: GeneratorId; + readonly type: GeneratorType; + readonly params: Record; + readonly duration: TimelineFrame; + readonly name: string; +}; diff --git a/packages/core/src/types/keyboard.ts b/packages/core/src/types/keyboard.ts new file mode 100644 index 0000000..d656c22 --- /dev/null +++ b/packages/core/src/types/keyboard.ts @@ -0,0 +1,61 @@ +/** + * Keyboard contract — Phase 6 Step 4 + Step 5 + * + * Key bindings and actions for J/K/L jog-shuttle and timeline navigation. + */ + +import type { TimelineFrame } from './frame'; +import type { TimelineState } from './state'; + +export type TimelineKeyAction = + | 'play-pause' + | 'stop' + | 'jog-forward' + | 'jog-backward' + | 'jog-stop' + | 'step-forward' + | 'step-backward' + | 'seek-start' + | 'seek-end' + | 'next-clip' + | 'prev-clip' + | 'next-marker' + | 'prev-marker' + | 'mark-in' + | 'mark-out' + | 'toggle-loop'; + +export type KeyBinding = { + readonly code: string; + readonly shift?: boolean; + readonly alt?: boolean; + readonly meta?: boolean; + readonly ctrl?: boolean; + readonly action: TimelineKeyAction; + readonly repeat?: boolean; +}; + +export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [ + { code: 'Space', action: 'play-pause' }, + { code: 'KeyK', action: 'jog-stop' }, + { code: 'KeyJ', action: 'jog-backward' }, + { code: 'KeyL', action: 'jog-forward' }, + { code: 'ArrowRight', action: 'step-forward', repeat: true }, + { code: 'ArrowLeft', action: 'step-backward', repeat: true }, + { code: 'Home', action: 'seek-start' }, + { code: 'End', action: 'seek-end' }, + { code: 'ArrowRight', shift: true, action: 'next-clip' }, + { code: 'ArrowLeft', shift: true, action: 'prev-clip' }, + { code: 'ArrowRight', alt: true, action: 'next-marker' }, + { code: 'ArrowLeft', alt: true, action: 'prev-marker' }, + { code: 'KeyI', action: 'mark-in' }, + { code: 'KeyO', action: 'mark-out' }, + { code: 'KeyQ', action: 'toggle-loop' }, +]; + +export type KeyboardHandlerOptions = { + bindings?: KeyBinding[]; + onMarkIn?: (frame: TimelineFrame) => void; + onMarkOut?: (frame: TimelineFrame) => void; + getTimelineState?: () => TimelineState; +}; diff --git a/packages/core/src/types/keyframe.ts b/packages/core/src/types/keyframe.ts new file mode 100644 index 0000000..0aacdf7 --- /dev/null +++ b/packages/core/src/types/keyframe.ts @@ -0,0 +1,22 @@ +/** + * KEYFRAME MODEL — Phase 4 + * + * Single keyframe for animatable values (effect params, transform, audio). + */ + +import type { TimelineFrame } from './frame'; +import type { EasingCurve } from './easing'; + +export type KeyframeId = string & { readonly __brand: 'KeyframeId' }; +export function toKeyframeId(s: string): KeyframeId { + return s as KeyframeId; +} + +/** Value is a plain number (opacity, scale, rotation, gain, etc.). */ +export type Keyframe = { + readonly id: KeyframeId; + readonly frame: TimelineFrame; + readonly value: number; + /** Easing out of this keyframe. */ + readonly easing: EasingCurve; +}; diff --git a/packages/core/src/types/link-group.ts b/packages/core/src/types/link-group.ts new file mode 100644 index 0000000..6928c0b --- /dev/null +++ b/packages/core/src/types/link-group.ts @@ -0,0 +1,25 @@ +/** + * LINK GROUP — Phase 4 + * + * Locks A/V clips in sync; when one moves, all move together. + */ + +import type { ClipId } from './clip'; + +export type LinkGroupId = string & { readonly __brand: 'LinkGroupId' }; +export function toLinkGroupId(s: string): LinkGroupId { + return s as LinkGroupId; +} + +export type LinkGroup = { + readonly id: LinkGroupId; + /** Min 2 clips. */ + readonly clipIds: readonly ClipId[]; +}; + +export function createLinkGroup( + id: LinkGroupId, + clipIds: readonly ClipId[], +): LinkGroup { + return { id, clipIds }; +} diff --git a/packages/core/src/types/marker.ts b/packages/core/src/types/marker.ts index 69a2f4b..7e183e8 100644 --- a/packages/core/src/types/marker.ts +++ b/packages/core/src/types/marker.ts @@ -1,56 +1,59 @@ /** - * MARKER TYPES - * - * Pure metadata for timeline navigation and organization. - * Markers do not affect clip timing or validation. + * MARKER TYPES — Phase 3 + * + * Discriminated union: point (single frame) or range (frameStart..frameEnd). + * Markers live on Timeline.markers[]. linkedClipId moves with clip on ripple. */ -import { Frame } from './frame'; +import type { TimelineFrame } from './frame'; +import type { ClipId } from './clip'; -/** - * Timeline marker - marks a specific frame on the timeline - */ -export interface TimelineMarker { - id: string; - type: 'timeline'; - frame: Frame; - label: string; - color?: string; -} +// --------------------------------------------------------------------------- +// Branded ID +// --------------------------------------------------------------------------- -/** - * Clip marker - marks a frame relative to clip start - */ -export interface ClipMarker { - id: string; - type: 'clip'; - clipId: string; - frame: Frame; // Relative to clip's timelineStart - label: string; - color?: string; -} +export type MarkerId = string & { readonly __brand: 'MarkerId' }; +export const toMarkerId = (s: string): MarkerId => s as MarkerId; -/** - * Region marker - marks a frame range - */ -export interface RegionMarker { - id: string; - type: 'region'; - startFrame: Frame; - endFrame: Frame; - label: string; - color?: string; -} +// --------------------------------------------------------------------------- +// MarkerScope +// --------------------------------------------------------------------------- -/** - * Work area - defines the active editing region - */ -export interface WorkArea { - startFrame: Frame; - endFrame: Frame; -} +export type MarkerScope = 'global' | 'personal' | 'export'; -/** - * Union type for all marker types - */ -export type Marker = TimelineMarker | ClipMarker | RegionMarker; +// --------------------------------------------------------------------------- +// Marker — discriminated union (Option A) +// --------------------------------------------------------------------------- + +export type Marker = + | { + readonly type: 'point'; + readonly id: MarkerId; + readonly frame: TimelineFrame; + readonly label: string; + readonly color: string; + readonly scope: MarkerScope; + readonly linkedClipId: ClipId | null; + readonly clipId?: ClipId; + } + | { + readonly type: 'range'; + readonly id: MarkerId; + readonly frameStart: TimelineFrame; + readonly frameEnd: TimelineFrame; + readonly label: string; + readonly color: string; + readonly scope: MarkerScope; + readonly linkedClipId: ClipId | null; + readonly clipId?: ClipId; + }; + +// --------------------------------------------------------------------------- +// BeatGrid — timeline-level, generates snap points +// --------------------------------------------------------------------------- + +export type BeatGrid = { + readonly bpm: number; + readonly timeSignature: readonly [number, number]; + readonly offset: TimelineFrame; +}; diff --git a/packages/core/src/types/operations.ts b/packages/core/src/types/operations.ts new file mode 100644 index 0000000..1207038 --- /dev/null +++ b/packages/core/src/types/operations.ts @@ -0,0 +1,191 @@ +/** + * OPERATION PRIMITIVES — Phase 0 compliant + * + * The ONLY way to express a mutation in the engine. + * All mutations flow through: OperationPrimitive[] → Transaction → Dispatcher. + * + * RULE: Never add a new mutation function. + * Add a new type to OperationPrimitive, handle it in the Dispatcher switch, + * update the InvariantChecker, and update OPERATIONS.md. + * + * RULE: Transactions are all-or-nothing. + * If any primitive fails validation, the entire Transaction is rejected. + */ + +import type { TimelineFrame, Timecode } from './frame'; +import type { AssetId, Asset, AssetStatus } from './asset'; +import type { ClipId, Clip } from './clip'; +import type { TrackId, Track, TrackType } from './track'; +import type { SequenceSettings, Timeline } from './timeline'; +import type { TimelineState } from './state'; +import type { MarkerId, Marker, BeatGrid } from './marker'; +import type { Generator } from './generator'; +import type { CaptionId, Caption, CaptionStyle } from './caption'; +import type { Effect, EffectId } from './effect'; +import type { Keyframe, KeyframeId } from './keyframe'; +import type { EasingCurve } from './easing'; +import type { ClipTransform } from './clip-transform'; +import type { AudioProperties } from './audio-properties'; +import type { Transition, TransitionAlignment } from './transition'; +import type { LinkGroup, LinkGroupId } from './link-group'; +import type { TrackGroup, TrackGroupId } from './track-group'; + +// --------------------------------------------------------------------------- +// OperationPrimitive — the complete, versioned discriminated union +// --------------------------------------------------------------------------- + +export type OperationPrimitive = + // — Clip operations — + | { type: 'MOVE_CLIP'; clipId: ClipId; newTimelineStart: TimelineFrame; targetTrackId?: TrackId } + | { type: 'RESIZE_CLIP'; clipId: ClipId; edge: 'start' | 'end'; newFrame: TimelineFrame } + | { type: 'SLICE_CLIP'; clipId: ClipId; atFrame: TimelineFrame } + | { type: 'DELETE_CLIP'; clipId: ClipId } + | { type: 'INSERT_CLIP'; clip: Clip; trackId: TrackId } + | { type: 'SET_MEDIA_BOUNDS'; clipId: ClipId; mediaIn: TimelineFrame; mediaOut: TimelineFrame } + | { type: 'SET_CLIP_ENABLED'; clipId: ClipId; enabled: boolean } + | { type: 'SET_CLIP_REVERSED'; clipId: ClipId; reversed: boolean } + | { type: 'SET_CLIP_SPEED'; clipId: ClipId; speed: number } + | { type: 'SET_CLIP_COLOR'; clipId: ClipId; color: string | null } + | { type: 'SET_CLIP_NAME'; clipId: ClipId; name: string | null } + // — Track operations — + | { type: 'ADD_TRACK'; track: Track } + | { type: 'DELETE_TRACK'; trackId: TrackId } + | { type: 'REORDER_TRACK'; trackId: TrackId; newIndex: number } + | { type: 'SET_TRACK_HEIGHT'; trackId: TrackId; height: number } + | { type: 'SET_TRACK_NAME'; trackId: TrackId; name: string } + // — Asset operations — + | { type: 'REGISTER_ASSET'; asset: Asset } + | { type: 'UNREGISTER_ASSET'; assetId: AssetId } + | { type: 'SET_ASSET_STATUS'; assetId: AssetId; status: AssetStatus } + // — Timeline operations — + | { type: 'RENAME_TIMELINE'; name: string } + | { type: 'SET_TIMELINE_DURATION'; duration: TimelineFrame } + | { type: 'SET_TIMELINE_START_TC'; startTimecode: Timecode } + | { type: 'SET_SEQUENCE_SETTINGS'; settings: Partial } + // — Phase 3: Marker operations — + | { type: 'ADD_MARKER'; marker: Marker } + | { type: 'MOVE_MARKER'; markerId: MarkerId; newFrame: TimelineFrame } + | { type: 'DELETE_MARKER'; markerId: MarkerId } + // — Phase 3: In/Out — + | { type: 'SET_IN_POINT'; frame: TimelineFrame | null } + | { type: 'SET_OUT_POINT'; frame: TimelineFrame | null } + // — Phase 3: Beat grid — + | { type: 'ADD_BEAT_GRID'; beatGrid: BeatGrid } + | { type: 'REMOVE_BEAT_GRID' } + // — Phase 3: Generator — + | { type: 'INSERT_GENERATOR'; generator: Generator; trackId: TrackId; atFrame: TimelineFrame } + // — Phase 3: Caption — + | { type: 'ADD_CAPTION'; caption: Omit & { style?: CaptionStyle }; trackId: TrackId } + | { type: 'EDIT_CAPTION'; captionId: CaptionId; trackId: TrackId; text?: string; language?: string; style?: Partial; burnIn?: boolean; startFrame?: TimelineFrame; endFrame?: TimelineFrame } + | { type: 'DELETE_CAPTION'; captionId: CaptionId; trackId: TrackId } + // — Phase 4: Effect & Keyframe — + | { type: 'ADD_EFFECT'; clipId: ClipId; effect: Effect } + | { type: 'REMOVE_EFFECT'; clipId: ClipId; effectId: EffectId } + | { type: 'REORDER_EFFECT'; clipId: ClipId; effectId: EffectId; newIndex: number } + | { type: 'SET_EFFECT_ENABLED'; clipId: ClipId; effectId: EffectId; enabled: boolean } + | { type: 'SET_EFFECT_PARAM'; clipId: ClipId; effectId: EffectId; key: string; value: number | string | boolean } + | { type: 'ADD_KEYFRAME'; clipId: ClipId; effectId: EffectId; keyframe: Keyframe } + | { type: 'MOVE_KEYFRAME'; clipId: ClipId; effectId: EffectId; keyframeId: KeyframeId; newFrame: TimelineFrame } + | { type: 'DELETE_KEYFRAME'; clipId: ClipId; effectId: EffectId; keyframeId: KeyframeId } + | { type: 'SET_KEYFRAME_EASING'; clipId: ClipId; effectId: EffectId; keyframeId: KeyframeId; easing: EasingCurve } + // — Phase 4 Step 3: Transform, Audio, Transitions, Groups — + | { type: 'SET_CLIP_TRANSFORM'; clipId: ClipId; transform: Partial } + | { type: 'SET_AUDIO_PROPERTIES'; clipId: ClipId; properties: Partial } + | { type: 'ADD_TRANSITION'; clipId: ClipId; transition: Transition } + | { type: 'DELETE_TRANSITION'; clipId: ClipId } + | { type: 'SET_TRANSITION_DURATION'; clipId: ClipId; durationFrames: number } + | { type: 'SET_TRANSITION_ALIGNMENT'; clipId: ClipId; alignment: TransitionAlignment } + | { type: 'LINK_CLIPS'; linkGroup: LinkGroup } + | { type: 'UNLINK_CLIPS'; linkGroupId: LinkGroupId } + | { type: 'ADD_TRACK_GROUP'; trackGroup: TrackGroup } + | { type: 'DELETE_TRACK_GROUP'; trackGroupId: TrackGroupId } + | { type: 'SET_TRACK_BLEND_MODE'; trackId: TrackId; blendMode: string } + | { type: 'SET_TRACK_OPACITY'; trackId: TrackId; opacity: number }; + +// --------------------------------------------------------------------------- +// Transaction +// --------------------------------------------------------------------------- + +/** + * Transaction — an atomic, labeled batch of OperationPrimitives. + * + * All primitives in a Transaction are validated before any are applied. + * If one fails, none are applied. This is the all-or-nothing rule. + */ +export type Transaction = { + readonly id: string; + readonly label: string; + readonly timestamp: number; + readonly operations: readonly OperationPrimitive[]; +}; + +// --------------------------------------------------------------------------- +// DispatchResult +// --------------------------------------------------------------------------- + +export type RejectionReason = + | 'OVERLAP' + | 'LOCKED_TRACK' + | 'ASSET_MISSING' + | 'TYPE_MISMATCH' + | 'OUT_OF_BOUNDS' + | 'MEDIA_BOUNDS_INVALID' + | 'ASSET_IN_USE' + | 'TRACK_NOT_EMPTY' + | 'SPEED_INVALID' + | 'INVARIANT_VIOLATED' + | 'NOT_FOUND' + | 'BEAT_GRID_EXISTS' + | 'CLIP_NOT_FOUND' + | 'DUPLICATE_EFFECT_ID' + | 'EFFECT_NOT_FOUND' + | 'EFFECT_INDEX_OUT_OF_RANGE' + | 'KEYFRAME_NOT_FOUND' + | 'DUPLICATE_KEYFRAME_ID' + | 'INVALID_RANGE' + | 'TRANSITION_NOT_FOUND' + | 'LINK_GROUP_NOT_FOUND' + | 'TRACK_GROUP_NOT_FOUND' + | 'DUPLICATE_LINK_GROUP_ID' + | 'DUPLICATE_TRACK_GROUP_ID' + | 'INVALID_OPACITY' + | 'TRACK_NOT_FOUND'; + +export type DispatchResult = + | { accepted: true; nextState: TimelineState } + | { accepted: false; reason: RejectionReason; message: string }; + +// --------------------------------------------------------------------------- +// InvariantViolation (co-located for import convenience) +// --------------------------------------------------------------------------- + +export type ViolationType = + | 'OVERLAP' + | 'MEDIA_BOUNDS_INVALID' + | 'ASSET_MISSING' + | 'TRACK_TYPE_MISMATCH' + | 'CLIP_BEYOND_TIMELINE' + | 'TRACK_NOT_SORTED' + | 'DURATION_MISMATCH' + | 'SPEED_INVALID' + | 'SCHEMA_VERSION_MISMATCH' + | 'MARKER_OUT_OF_BOUNDS' + | 'IN_OUT_INVALID' + | 'BEAT_GRID_INVALID' + | 'CAPTION_OUT_OF_BOUNDS' + | 'CAPTION_OVERLAP' + | 'EFFECT_NOT_FOUND' + | 'KEYFRAME_NOT_FOUND' + | 'KEYFRAME_ORDER_VIOLATION' + | 'EFFECT_INDEX_OUT_OF_RANGE' + | 'INVALID_RENDER_STAGE' + | 'TRACK_GROUP_NOT_FOUND' + | 'INVALID_OPACITY' + | 'INVALID_RANGE' + | 'LINK_GROUP_NOT_FOUND'; + +export type InvariantViolation = { + readonly type: ViolationType; + readonly entityId: string; + readonly message: string; +}; diff --git a/packages/core/src/types/pipeline.ts b/packages/core/src/types/pipeline.ts new file mode 100644 index 0000000..27ffb35 --- /dev/null +++ b/packages/core/src/types/pipeline.ts @@ -0,0 +1,139 @@ +/** + * Pipeline contracts — Phase 6 Step 2 + * + * Core defines the CONTRACT (types + interfaces). + * Host app provides the IMPLEMENTATION. + * Core never does actual decoding or compositing. + */ + +import type { ClipId } from './clip'; +import type { TrackId } from './track'; +import type { TimelineFrame } from './frame'; +import type { PlaybackQuality } from './playhead'; +import type { ClipTransform } from './clip-transform'; +import type { Effect } from './effect'; + +// --------------------------------------------------------------------------- +// Decode contract +// --------------------------------------------------------------------------- + +export type VideoFrameRequest = { + readonly clipId: ClipId; + readonly mediaFrame: TimelineFrame; + readonly quality: PlaybackQuality; +}; + +export type AudioChunkRequest = { + readonly clipId: ClipId; + readonly mediaFrame: TimelineFrame; + readonly durationFrames: number; + readonly sampleRate: number; +}; + +export type VideoFrameResult = { + readonly clipId: ClipId; + readonly mediaFrame: TimelineFrame; + readonly width: number; + readonly height: number; + readonly bitmap: unknown; +}; + +export type AudioChunkResult = { + readonly clipId: ClipId; + readonly mediaFrame: TimelineFrame; + readonly samples: unknown; + readonly sampleRate: number; +}; + +export type VideoDecoder = ( + request: VideoFrameRequest, +) => Promise; + +export type AudioDecoder = ( + request: AudioChunkRequest, +) => Promise; + +// --------------------------------------------------------------------------- +// Composite contract +// --------------------------------------------------------------------------- + +export type CompositeLayer = { + readonly clipId: ClipId; + readonly trackId: TrackId; + readonly trackIndex: number; + readonly frame: VideoFrameResult; + readonly transform: ClipTransform; + readonly opacity: number; + readonly blendMode: string; + readonly effects: readonly Effect[]; +}; + +/** Layer spec from resolveFrame (no decoded frame yet). */ +export type ResolvedLayer = { + readonly clipId: ClipId; + readonly trackId: TrackId; + readonly trackIndex: number; + readonly mediaFrame: TimelineFrame; + readonly transform: ClipTransform; + readonly opacity: number; + readonly blendMode: string; + readonly effects: readonly Effect[]; +}; + +export type CompositeRequest = { + readonly timelineFrame: TimelineFrame; + readonly layers: readonly CompositeLayer[]; + readonly width: number; + readonly height: number; + readonly quality: PlaybackQuality; +}; + +export type CompositeResult = { + readonly timelineFrame: TimelineFrame; + readonly bitmap: unknown; +}; + +/** Result of resolveFrame (layers have mediaFrame, not decoded frame). */ +export type ResolvedCompositeRequest = { + readonly timelineFrame: TimelineFrame; + readonly layers: readonly ResolvedLayer[]; + readonly width: number; + readonly height: number; + readonly quality: PlaybackQuality; +}; + +export type Compositor = ( + request: CompositeRequest, +) => Promise; + +// --------------------------------------------------------------------------- +// Thumbnail contract +// --------------------------------------------------------------------------- + +export type ThumbnailRequest = { + readonly clipId: ClipId; + readonly mediaFrame: TimelineFrame; + readonly width: number; + readonly height: number; +}; + +export type ThumbnailResult = { + readonly clipId: ClipId; + readonly mediaFrame: TimelineFrame; + readonly bitmap: unknown; +}; + +export type ThumbnailProvider = ( + request: ThumbnailRequest, +) => Promise; + +// --------------------------------------------------------------------------- +// Pipeline registry +// --------------------------------------------------------------------------- + +export type PipelineConfig = { + readonly videoDecoder: VideoDecoder; + readonly audioDecoder?: AudioDecoder; + readonly compositor: Compositor; + readonly thumbnailProvider?: ThumbnailProvider; +}; diff --git a/packages/core/src/types/playhead.ts b/packages/core/src/types/playhead.ts new file mode 100644 index 0000000..9492126 --- /dev/null +++ b/packages/core/src/types/playhead.ts @@ -0,0 +1,51 @@ +/** + * Playhead types — Phase 6 Step 1 + Step 5 + * + * Playback position and quality. No DOM deps. + */ + +import type { TimelineFrame } from './frame'; + +export type PlaybackRate = number; +// 1.0 = normal, 0.5 = half speed, 2.0 = double, +// -1.0 = reverse, 0 = paused + +export type PlaybackQuality = 'full' | 'half' | 'quarter' | 'proxy'; + +export type LoopRegion = { + readonly startFrame: TimelineFrame; + readonly endFrame: TimelineFrame; // exclusive +}; + +export type PlayheadState = { + readonly currentFrame: TimelineFrame; + readonly isPlaying: boolean; + readonly playbackRate: PlaybackRate; + readonly quality: PlaybackQuality; + readonly durationFrames: number; + readonly fps: number; + readonly loopRegion: LoopRegion | null; + readonly prerollFrames: number; + readonly postrollFrames: number; +}; + +export type PlayheadEventType = + | 'play' + | 'pause' + | 'seek' + | 'loop' + | 'frame-dropped' + | 'ended' + | 'loop-point' + | 'state'; + +export type PlayheadEvent = { + readonly type: PlayheadEventType; + readonly frame: TimelineFrame; + readonly data?: unknown; +}; + +export type PlayheadListener = (event: PlayheadEvent) => void; + +/** Return type of PlayheadController.on() — call to unsubscribe. */ +export type PlayheadUnsubscribe = () => void; diff --git a/packages/core/src/types/project.ts b/packages/core/src/types/project.ts new file mode 100644 index 0000000..f51c76e --- /dev/null +++ b/packages/core/src/types/project.ts @@ -0,0 +1,82 @@ +/** + * PROJECT MODEL — Phase 5 Step 5 + * + * A Project is a multi-timeline container with a shared bin hierarchy. + * Pure types + factories only. No IO. + */ + +import type { AssetId } from './asset'; +import type { TimelineState } from './state'; +import { CURRENT_SCHEMA_VERSION } from './state'; + +// --------------------------------------------------------------------------- +// Branded IDs +// --------------------------------------------------------------------------- + +export type ProjectId = string & { readonly __brand: 'ProjectId' }; +export function toProjectId(s: string): ProjectId { + return s as ProjectId; +} + +export type BinId = string & { readonly __brand: 'BinId' }; +export function toBinId(s: string): BinId { + return s as BinId; +} + +// --------------------------------------------------------------------------- +// Bin model +// --------------------------------------------------------------------------- + +export type BinItem = + | { readonly kind: 'asset'; readonly assetId: AssetId } + | { readonly kind: 'sequence'; readonly timelineId: string } + | { readonly kind: 'bin'; readonly binId: BinId }; + +export type Bin = { + readonly id: BinId; + readonly label: string; + readonly parentId: BinId | null; // null = root + readonly items: readonly BinItem[]; + readonly color?: string; +}; + +export function createBin( + id: BinId, + label: string, + parentId: BinId | null = null, +): Bin { + return { id, label, parentId, items: [] }; +} + +// --------------------------------------------------------------------------- +// Project +// --------------------------------------------------------------------------- + +export type Project = { + readonly id: ProjectId; + readonly name: string; + readonly timelines: readonly TimelineState[]; + readonly bins: readonly Bin[]; + readonly rootBinIds: readonly BinId[]; + readonly createdAt: number; + readonly updatedAt: number; + readonly schemaVersion: number; // = CURRENT_SCHEMA_VERSION +}; + +export function createProject( + id: ProjectId, + name: string, + timelines: readonly TimelineState[] = [], +): Project { + const now = Date.now(); + return { + id, + name, + timelines, + bins: [], + rootBinIds: [], + createdAt: now, + updatedAt: now, + schemaVersion: CURRENT_SCHEMA_VERSION, + }; +} diff --git a/packages/core/src/types/state-change.ts b/packages/core/src/types/state-change.ts new file mode 100644 index 0000000..cabdfe3 --- /dev/null +++ b/packages/core/src/types/state-change.ts @@ -0,0 +1,78 @@ +/** + * StateChange diff — Phase 7 Step 2 + * + * Lightweight diff for hook optimization: compare prev vs next + * by reference so hooks can skip re-render when nothing relevant changed. + */ + +import type { TimelineState } from './state'; +import type { ClipId } from './clip'; + +export type StateChange = { + readonly trackIds: boolean; + readonly clipIds: ReadonlySet; + readonly markers: boolean; + readonly timeline: boolean; + readonly playhead: boolean; +}; + +export const EMPTY_STATE_CHANGE: StateChange = { + trackIds: false, + clipIds: new Set(), + markers: false, + timeline: false, + playhead: false, +}; + +/** + * Diffs prev and next state by reference. + * clipIds: set of clip ids whose clip reference changed or were added/removed. + */ +export function diffStates( + prev: TimelineState, + next: TimelineState, +): StateChange { + const clipIds = new Set(); + + // trackIds: track array reference changed + const trackIds = prev.timeline.tracks !== next.timeline.tracks; + + // markers + const markers = prev.timeline.markers !== next.timeline.markers; + + // timeline (fps, duration, etc.) + const timeline = prev.timeline !== next.timeline; + + // clipIds: collect where clip reference changed or clip added/removed + const prevTracks = prev.timeline.tracks; + const nextTracks = next.timeline.tracks; + for (let i = 0; i < nextTracks.length; i++) { + const nextTrack = nextTracks[i]!; + const prevTrack = prevTracks.find((t) => t.id === nextTrack.id); + for (const nextClip of nextTrack.clips) { + const prevClip = prevTrack?.clips.find((c) => c.id === nextClip.id); + if (prevClip !== nextClip) { + clipIds.add(nextClip.id); + } + } + } + // Removed clips: in prev but not in next + for (let i = 0; i < prevTracks.length; i++) { + const prevTrack = prevTracks[i]!; + const nextTrack = nextTracks.find((t) => t.id === prevTrack.id); + for (const prevClip of prevTrack.clips) { + const stillPresent = nextTrack?.clips.some((c) => c.id === prevClip.id); + if (!stillPresent) { + clipIds.add(prevClip.id); + } + } + } + + return { + trackIds, + clipIds, + markers, + timeline, + playhead: false, + }; +} diff --git a/packages/core/src/types/state.ts b/packages/core/src/types/state.ts index a8d7c55..16715bd 100644 --- a/packages/core/src/types/state.ts +++ b/packages/core/src/types/state.ts @@ -1,114 +1,65 @@ /** - * TIMELINE STATE - * - * This is the complete state shape for the timeline engine. - * - * WHAT IS TIMELINE STATE? - * - The root state object that contains everything - * - Timeline (tracks and clips) - * - Assets (media metadata) - * - * WHY SEPARATE STATE? - * - Clear separation between timeline structure and asset registry - * - Assets can be shared across multiple clips - * - State is the single source of truth - * - * EXAMPLE: - * ```typescript - * const state: TimelineState = { - * timeline: { - * id: 'timeline_1', - * name: 'My Project', - * fps: frameRate(30), - * duration: frame(9000), - * tracks: [], - * }, - * assets: new Map([ - * ['asset_1', { id: 'asset_1', type: 'video', duration: frame(3600), sourceUrl: '...' }], - * ]), - * }; - * ``` - * - * IMMUTABILITY: - * All operations on TimelineState must return a NEW state object. + * TIMELINE STATE — Phase 0 compliant + * + * TimelineState is the single source of truth for the engine. + * Phase 0 only: timeline + assetRegistry. No Phase 2 fields. + * + * RULE: Every function that changes state returns a NEW TimelineState. * Never mutate the existing state. */ -import { Timeline } from './timeline'; -import { Asset } from './asset'; -import { LinkGroup } from './linking'; -import { Group } from './grouping'; -import { TimelineMarker, ClipMarker, RegionMarker, WorkArea } from './marker'; +import type { Timeline } from './timeline'; +import type { Asset, AssetId } from './asset'; -/** - * TimelineState - The complete state for the timeline engine - * - * Phase 2 additions: - * - linkGroups: Synchronized editing groups - * - groups: Visual organization groups - * - markers: Timeline/clip/region markers - * - workArea: Active editing region - */ -export interface TimelineState { - /** The timeline (tracks and clips) */ - timeline: Timeline; - - /** Asset registry (media metadata) */ - assets: Map; - - // === PHASE 2: EDITING INTELLIGENCE === - - /** Link groups for synchronized editing */ - linkGroups: Map; - - /** Groups for visual organization */ - groups: Map; - - /** Markers for navigation and annotation */ - markers: { - timeline: TimelineMarker[]; - clips: ClipMarker[]; - regions: RegionMarker[]; - }; - - /** Optional work area definition */ - workArea?: WorkArea; -} +// --------------------------------------------------------------------------- +// AssetRegistry — ReadonlyMap is the invariant boundary +// --------------------------------------------------------------------------- + +export type AssetRegistry = ReadonlyMap; + +// --------------------------------------------------------------------------- +// Schema versioning +// --------------------------------------------------------------------------- /** - * Create a new timeline state - * - * @param params - State parameters - * @returns A new TimelineState object + * Increment this whenever TimelineState gains a new required field or + * a field's semantics change in a breaking way. + * + * The schemaVersion invariant check rejects loading a future schema + * into an older engine (prevents silent data corruption on downgrade). */ +export const CURRENT_SCHEMA_VERSION = 2 as const; + +// --------------------------------------------------------------------------- +// TimelineState +// --------------------------------------------------------------------------- + +export type TimelineState = { + readonly schemaVersion: number; // must equal CURRENT_SCHEMA_VERSION + readonly timeline: Timeline; + readonly assetRegistry: AssetRegistry; +}; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + export function createTimelineState(params: { - timeline: Timeline; - assets?: Map; - linkGroups?: Map; - groups?: Map; - markers?: { - timeline: TimelineMarker[]; - clips: ClipMarker[]; - regions: RegionMarker[]; - }; - workArea?: WorkArea; + timeline: Timeline; + assetRegistry?: AssetRegistry; + /** @deprecated use assetRegistry. Kept for test backward-compat only. */ + assets?: Map; }): TimelineState { - const state: TimelineState = { - timeline: params.timeline, - assets: params.assets ?? new Map(), - linkGroups: params.linkGroups ?? new Map(), - groups: params.groups ?? new Map(), - markers: params.markers ?? { - timeline: [], - clips: [], - regions: [], - }, + // Support legacy 'assets' param during test migration + const registry: AssetRegistry = + params.assetRegistry ?? + (params.assets + ? (params.assets as unknown as AssetRegistry) + : new Map()); + + return { + schemaVersion: CURRENT_SCHEMA_VERSION, + timeline: params.timeline, + assetRegistry: registry, }; - - // Only add workArea if explicitly provided - if (params.workArea !== undefined) { - state.workArea = params.workArea; - } - - return state; } diff --git a/packages/core/src/types/timeline.ts b/packages/core/src/types/timeline.ts index fe11209..a5788ed 100644 --- a/packages/core/src/types/timeline.ts +++ b/packages/core/src/types/timeline.ts @@ -1,87 +1,93 @@ /** - * TIMELINE MODEL - * - * The Timeline is the root container for the entire editing project. - * - * WHAT IS A TIMELINE? - * - The top-level data structure for a project - * - Contains all tracks (which contain all clips) - * - Defines the frame rate (FPS) for the entire project - * - Defines the total duration of the project - * - * WHY A TIMELINE? - * - Single source of truth for all timeline data - * - Defines the temporal bounds of the project - * - Provides a consistent frame rate for all time calculations - * - * EXAMPLE: - * ```typescript - * const timeline: Timeline = { - * id: 'timeline_1', - * name: 'My Project', - * fps: frameRate(30), - * duration: frame(9000), // 5 minutes at 30fps - * tracks: [], - * }; - * ``` - * - * INVARIANTS: - * - FPS is immutable after timeline creation - * - Duration must be positive - * - All tracks must have unique IDs + * TIMELINE MODEL — Phase 0 + Phase 3 */ -import { Frame, FrameRate } from './frame'; -import { Track } from './track'; +import type { TimelineFrame, FrameRate, Timecode } from './frame'; +import type { Track } from './track'; +import type { Marker, BeatGrid } from './marker'; +import type { TrackGroup } from './track-group'; +import type { LinkGroup } from './link-group'; -/** - * Timeline - The root container for a timeline project - */ -export interface Timeline { - /** Unique identifier */ - id: string; - - /** Human-readable name */ - name: string; - - /** Frames per second (immutable after creation) */ - fps: FrameRate; - - /** Total duration of the timeline in frames */ - duration: Frame; - - /** Tracks in the timeline (ordered bottom-to-top) */ - tracks: Track[]; - - /** Optional metadata for custom use cases */ - metadata?: Record; -} +// --------------------------------------------------------------------------- +// SequenceSettings +// --------------------------------------------------------------------------- + +export type SequenceSettings = { + readonly pixelAspectRatio: number; + readonly fieldOrder: 'progressive' | 'upper' | 'lower'; + readonly colorSpace: string; + readonly audioSampleRate: number; + readonly audioChannelCount: number; +}; + +// --------------------------------------------------------------------------- +// Timeline +// --------------------------------------------------------------------------- + +export type Timeline = { + readonly id: string; + readonly name: string; + readonly fps: FrameRate; + readonly duration: TimelineFrame; + readonly startTimecode: Timecode; + readonly tracks: readonly Track[]; + readonly sequenceSettings: SequenceSettings; + /** + * Increments by 1 on every successfully committed Transaction. + * Use this to detect stale references without deep equality checks. + */ + readonly version: number; + // — Phase 3 — + readonly markers: readonly Marker[]; + readonly beatGrid: BeatGrid | null; + readonly inPoint: TimelineFrame | null; + readonly outPoint: TimelineFrame | null; + // — Phase 4 — + readonly trackGroups?: readonly TrackGroup[]; + readonly linkGroups?: readonly LinkGroup[]; +}; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +const DEFAULT_SEQUENCE_SETTINGS: SequenceSettings = { + pixelAspectRatio: 1, + fieldOrder: 'progressive', + colorSpace: 'sRGB', + audioSampleRate: 48000, + audioChannelCount: 2, +}; -/** - * Create a new timeline - * - * @param params - Timeline parameters - * @returns A new Timeline object - */ export function createTimeline(params: { id: string; name: string; fps: FrameRate; - duration: Frame; - tracks?: Track[]; - metadata?: Record; + duration: TimelineFrame; + startTimecode?: Timecode; + tracks?: readonly Track[]; + sequenceSettings?: Partial; + markers?: readonly Marker[]; + beatGrid?: BeatGrid | null; + inPoint?: TimelineFrame | null; + outPoint?: TimelineFrame | null; + trackGroups?: readonly TrackGroup[]; + linkGroups?: readonly LinkGroup[]; }): Timeline { - const timeline: Timeline = { + return { id: params.id, name: params.name, fps: params.fps, duration: params.duration, + startTimecode: params.startTimecode ?? ('00:00:00:00' as Timecode), tracks: params.tracks ?? [], + sequenceSettings: { ...DEFAULT_SEQUENCE_SETTINGS, ...params.sequenceSettings }, + version: 0, + markers: params.markers ?? [], + beatGrid: params.beatGrid ?? null, + inPoint: params.inPoint ?? null, + outPoint: params.outPoint ?? null, + ...(params.trackGroups !== undefined && { trackGroups: params.trackGroups }), + ...(params.linkGroups !== undefined && { linkGroups: params.linkGroups }), }; - - if (params.metadata !== undefined) { - timeline.metadata = params.metadata; - } - - return timeline; } diff --git a/packages/core/src/types/track-group.ts b/packages/core/src/types/track-group.ts new file mode 100644 index 0000000..65acd4c --- /dev/null +++ b/packages/core/src/types/track-group.ts @@ -0,0 +1,27 @@ +/** + * TRACK GROUP — Phase 4 + * + * Logical grouping of tracks (e.g. for nesting or UI collapse). + */ + +import type { TrackId } from './track'; + +export type TrackGroupId = string & { readonly __brand: 'TrackGroupId' }; +export function toTrackGroupId(s: string): TrackGroupId { + return s as TrackGroupId; +} + +export type TrackGroup = { + readonly id: TrackGroupId; + readonly label: string; + readonly trackIds: readonly TrackId[]; + readonly collapsed: boolean; +}; + +export function createTrackGroup( + id: TrackGroupId, + label: string, + trackIds: readonly TrackId[] = [], +): TrackGroup { + return { id, label, trackIds, collapsed: false }; +} diff --git a/packages/core/src/types/track.ts b/packages/core/src/types/track.ts index b461935..a3aab2c 100644 --- a/packages/core/src/types/track.ts +++ b/packages/core/src/types/track.ts @@ -1,125 +1,88 @@ /** - * TRACK MODEL - * - * A Track is a horizontal container for Clips. - * - * WHAT IS A TRACK? - * - A layer that holds clips (like a layer in Photoshop) - * - Provides organization and isolation for clips - * - Has a type (video or audio) that clips must match - * - * WHY TRACKS? - * - Organize clips into layers - * - Enable stacking and compositing (track order matters) - * - Provide track-level controls (mute, lock) - * - Isolate editing operations - * - * TRACK ORDER: - * Tracks are rendered bottom-to-top: - * - tracks[0] = bottom layer (rendered first) - * - tracks[n] = top layer (rendered last, appears on top) - * - * EXAMPLE: - * ```typescript - * const track: Track = { - * id: 'track_1', - * name: 'Video Track 1', - * type: 'video', - * clips: [], - * locked: false, - * muted: false, - * }; - * ``` - * - * INVARIANTS: - * - Clips on a track must not overlap - * - All clips must match the track type - * - Clips array should be sorted by timelineStart (for performance) + * TRACK MODEL — Phase 0 + Phase 3 + * + * A Track is a horizontal container for Clips, always sorted by timelineStart. + * Phase 3: captions[] for subtitle/caption items. */ -import { Clip } from './clip'; +import type { Clip } from './clip'; +import type { Caption } from './caption'; +import type { TrackGroupId } from './track-group'; -/** - * TrackType - The kind of content this track holds - */ -export type TrackType = 'video' | 'audio'; +// --------------------------------------------------------------------------- +// Branded ID +// --------------------------------------------------------------------------- -/** - * Track - A container for clips - */ -export interface Track { - /** Unique identifier */ - id: string; - - /** Human-readable name */ - name: string; - - /** Type of content this track holds */ - type: TrackType; - - /** Clips on this track (should be sorted by timelineStart) */ - clips: Clip[]; - - /** Whether the track is locked (prevents editing) */ - locked: boolean; - - /** Whether the track is muted (affects playback) */ - muted: boolean; - - /** Whether the track is soloed (mutes all other tracks) */ - solo: boolean; - - /** Track height in pixels (for UI rendering) */ - height: number; - - /** Optional metadata for custom use cases */ - metadata?: Record; -} +export type TrackId = string & { readonly __brand: 'TrackId' }; +export const toTrackId = (s: string): TrackId => s as TrackId; + +// --------------------------------------------------------------------------- +// TrackType — must match Asset.mediaType for any clip placed on the track +// --------------------------------------------------------------------------- + +export type TrackType = 'video' | 'audio' | 'subtitle' | 'title'; + +// --------------------------------------------------------------------------- +// Track +// --------------------------------------------------------------------------- + +export type Track = { + readonly id: TrackId; + readonly name: string; + readonly type: TrackType; + readonly locked: boolean; + readonly muted: boolean; + readonly solo: boolean; + readonly height: number; + /** Always sorted ascending by timelineStart — invariant enforced by checkInvariants. */ + readonly clips: readonly Clip[]; + /** Phase 3: captions on this track (e.g. subtitle/title). */ + readonly captions: readonly Caption[]; + // — Phase 4 — + readonly blendMode?: string; + readonly opacity?: number; // 0–1, default 1 + readonly groupId?: TrackGroupId; +}; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- -/** - * Create a new track - * - * @param params - Track parameters - * @returns A new Track object - */ export function createTrack(params: { id: string; name: string; type: TrackType; - clips?: Clip[]; + clips?: readonly Clip[]; + captions?: readonly Caption[]; locked?: boolean; muted?: boolean; solo?: boolean; height?: number; - metadata?: Record; + blendMode?: string; + opacity?: number; + groupId?: TrackGroupId; }): Track { - const track: Track = { - id: params.id, + return { + id: params.id as TrackId, name: params.name, type: params.type, clips: params.clips ?? [], + captions: params.captions ?? [], locked: params.locked ?? false, muted: params.muted ?? false, solo: params.solo ?? false, height: params.height ?? 56, + ...(params.blendMode !== undefined && { blendMode: params.blendMode }), + ...(params.opacity !== undefined && { opacity: params.opacity }), + ...(params.groupId !== undefined && { groupId: params.groupId }), }; - - if (params.metadata !== undefined) { - track.metadata = params.metadata; - } - - return track; } -/** - * Sort clips on a track by timeline start frame - * - * This is useful for maintaining clip order and improving - * query performance. - * - * @param track - The track to sort - * @returns A new track with sorted clips - */ +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Returns a new track with clips sorted ascending by timelineStart. */ export function sortTrackClips(track: Track): Track { return { ...track, diff --git a/packages/core/src/types/transition.ts b/packages/core/src/types/transition.ts new file mode 100644 index 0000000..4809b73 --- /dev/null +++ b/packages/core/src/types/transition.ts @@ -0,0 +1,46 @@ +/** + * TRANSITION MODEL — Phase 4 + * + * Outgoing transition between clips (dissolve, wipe, etc.). + */ + +import type { EasingCurve } from './easing'; +import { LINEAR_EASING } from './easing'; + +export type TransitionId = string & { readonly __brand: 'TransitionId' }; +export function toTransitionId(s: string): TransitionId { + return s as TransitionId; +} + +/** 'dissolve' | 'wipe' | 'dip' | host-defined. */ +export type TransitionType = string; + +export type TransitionAlignment = + | 'centerOnCut' // straddles the cut point equally + | 'endAtCut' // ends exactly at the cut point + | 'startAtCut'; // starts exactly at the cut point + +export type TransitionParam = { + readonly key: string; + readonly value: number | string | boolean; +}; + +export type Transition = { + readonly id: TransitionId; + readonly type: TransitionType; + readonly durationFrames: number; + readonly alignment: TransitionAlignment; + readonly easing: EasingCurve; + readonly params: readonly TransitionParam[]; +}; + +export function createTransition( + id: TransitionId, + type: TransitionType, + durationFrames: number, + alignment: TransitionAlignment = 'centerOnCut', + easing: EasingCurve = LINEAR_EASING, + params: readonly TransitionParam[] = [], +): Transition { + return { id, type, durationFrames, alignment, easing, params }; +} diff --git a/packages/core/src/types/worker-contracts.ts b/packages/core/src/types/worker-contracts.ts new file mode 100644 index 0000000..276ff66 --- /dev/null +++ b/packages/core/src/types/worker-contracts.ts @@ -0,0 +1,68 @@ +/** + * Worker contracts — Phase 7 Step 4 + * + * Core defines message/response types only. + * No Worker instantiation — host responsibility. + */ + +import type { AssetId } from './asset'; +import type { ClipId } from './clip'; +import type { TimelineFrame } from './frame'; +import type { ThumbnailRequest, ThumbnailResult } from './pipeline'; + +// --------------------------------------------------------------------------- +// Waveform worker contract +// --------------------------------------------------------------------------- + +export type WaveformRequest = { + readonly requestId: string; + readonly assetId: AssetId; + readonly channel: number; + readonly startFrame: TimelineFrame; + readonly endFrame: TimelineFrame; + readonly buckets: number; + readonly sampleRate: number; +}; + +export type WaveformPeak = { + readonly min: number; + readonly max: number; + readonly rms: number; +}; + +export type WaveformResult = { + readonly requestId: string; + readonly assetId: AssetId; + readonly peaks: readonly WaveformPeak[]; + readonly error?: string; +}; + +export type WaveformWorkerMessage = + | { type: 'request'; payload: WaveformRequest } + | { type: 'cancel'; requestId: string }; + +export type WaveformWorkerResponse = + | { type: 'result'; payload: WaveformResult } + | { type: 'progress'; requestId: string; progress: number } + | { type: 'error'; requestId: string; message: string }; + +// --------------------------------------------------------------------------- +// Thumbnail queue contract +// --------------------------------------------------------------------------- + +export type ThumbnailPriority = 'high' | 'normal' | 'low'; + +export type ThumbnailQueueEntry = { + readonly request: ThumbnailRequest; + readonly priority: ThumbnailPriority; + readonly addedAt: number; +}; + +export type ThumbnailWorkerMessage = + | { type: 'request'; payload: ThumbnailQueueEntry } + | { type: 'cancel'; requestId: string } + | { type: 'set-priority'; requestId: string; priority: ThumbnailPriority }; + +export type ThumbnailWorkerResponse = + | { type: 'result'; payload: ThumbnailResult } + | { type: 'error'; requestId: string; message: string }; diff --git a/packages/core/src/utils/frame.ts b/packages/core/src/utils/frame.ts index f301b9a..9fdfa58 100644 --- a/packages/core/src/utils/frame.ts +++ b/packages/core/src/utils/frame.ts @@ -22,8 +22,8 @@ * ``` */ -import type { Frame, FrameRate } from '../types/frame'; -import { frame } from '../types/frame'; +import type { TimelineFrame as Frame, FrameRate } from '../types/frame'; +import { toFrame as frame } from '../types/frame'; /** * Convert frames to seconds diff --git a/packages/core/src/validation/invariants.ts b/packages/core/src/validation/invariants.ts new file mode 100644 index 0000000..f99aa2d --- /dev/null +++ b/packages/core/src/validation/invariants.ts @@ -0,0 +1,489 @@ +/** + * INVARIANT CHECKER — Phase 0 compliant + * + * The most critical file in the engine. + * checkInvariants() runs after every proposed state change inside the Dispatcher. + * Zero violations is the only acceptable result in tests and at commit time. + * + * RULE: Run checkInvariants in EVERY test after every state mutation. + */ + +import type { TimelineState } from '../types/state'; +import { CURRENT_SCHEMA_VERSION } from '../types/state'; +import type { Track } from '../types/track'; +import type { Clip } from '../types/clip'; +import { clipsOverlap } from '../types/clip'; +import type { InvariantViolation } from '../types/operations'; + +// --------------------------------------------------------------------------- +// checkInvariants — the 9 checks from the spec, in order +// --------------------------------------------------------------------------- + +export function checkInvariants(state: TimelineState): InvariantViolation[] { + const violations: InvariantViolation[] = []; + + // —— Schema version check (runs first — a version mismatch invalidates everything) —— + if (state.schemaVersion !== CURRENT_SCHEMA_VERSION) { + violations.push({ + type: 'SCHEMA_VERSION_MISMATCH', + entityId: 'timeline', + message: `Expected schema v${CURRENT_SCHEMA_VERSION}, got v${state.schemaVersion}`, + }); + return violations; + } + + for (const track of state.timeline.tracks) { + checkTrack(state, track, violations); + } + + // —— Phase 3: Marker bounds ————————————————————————————————————————————— + checkMarkerBounds(state, violations); + // —— Phase 3: In/Out points —————————————————————————————————────────————— + checkInOutPoints(state, violations); + // —— Phase 3: Beat grid —————————————————————————————————————————————————— + checkBeatGrid(state, violations); + // —— Phase 4 Step 3: Link groups, Track groups —————————————————────────—— + checkLinkGroups(state, violations); + checkTrackGroups(state, violations); + + return violations; +} + +// --------------------------------------------------------------------------- +// Per-track checks +// --------------------------------------------------------------------------- + +function checkTrack( + state: TimelineState, + track: Track, + violations: InvariantViolation[], +): void { + const clips = track.clips; + + // —— Check 8: clips[] must be sorted ascending by timelineStart —————————— + for (let i = 1; i < clips.length; i++) { + const prev = clips[i - 1]!; + const curr = clips[i]!; + if (prev.timelineStart > curr.timelineStart) { + violations.push({ + type: 'TRACK_NOT_SORTED', + entityId: track.id, + message: + `Track '${track.id}': clip '${curr.id}' (start=${curr.timelineStart}) ` + + `appears after clip '${prev.id}' (start=${prev.timelineStart}) — not sorted.`, + }); + } + } + + // —— Check 1: No two clips share any frame (OVERLAP) ———————————————————— + for (let i = 0; i < clips.length; i++) { + for (let j = i + 1; j < clips.length; j++) { + const a = clips[i]!; + const b = clips[j]!; + if (clipsOverlap(a, b)) { + violations.push({ + type: 'OVERLAP', + entityId: a.id, + message: + `Track '${track.id}': clips '${a.id}' [${a.timelineStart}-${a.timelineEnd}) ` + + `and '${b.id}' [${b.timelineStart}-${b.timelineEnd}) overlap.`, + }); + } + } + } + + // Per-clip checks + for (const clip of clips) { + checkClip(state, track, clip, violations); + } + + // —— Phase 3: Caption bounds (per track) ————————————————————————————————— + checkCaptionBounds(state, track, violations); +} + +// --------------------------------------------------------------------------- +// Per-clip checks +// --------------------------------------------------------------------------- + +function checkClip( + state: TimelineState, + track: Track, + clip: Clip, + violations: InvariantViolation[], +): void { + // —— Check 2: assetId must exist in assetRegistry (ASSET_MISSING) ————————— + const asset = state.assetRegistry.get(clip.assetId); + if (!asset) { + violations.push({ + type: 'ASSET_MISSING', + entityId: clip.id, + message: `Clip '${clip.id}' references asset '${clip.assetId}' which is not in the registry.`, + }); + // Cannot run media-bounds checks without the asset — skip remaining checks + return; + } + + // —— Check 3: mediaType must match track type (TRACK_TYPE_MISMATCH) ———— + if (asset.mediaType !== track.type) { + violations.push({ + type: 'TRACK_TYPE_MISMATCH', + entityId: clip.id, + message: + `Clip '${clip.id}' has asset mediaType '${asset.mediaType}' ` + + `but is on a '${track.type}' track '${track.id}'.`, + }); + } + + // —— Check 4: mediaIn >= 0 (MEDIA_BOUNDS_INVALID) ———————————————————— + if (clip.mediaIn < 0) { + violations.push({ + type: 'MEDIA_BOUNDS_INVALID', + entityId: clip.id, + message: `Clip '${clip.id}': mediaIn (${clip.mediaIn}) must be >= 0.`, + }); + } + + // —— Check 5: mediaOut <= asset.intrinsicDuration (MEDIA_BOUNDS_INVALID) — + if (clip.mediaOut > asset.intrinsicDuration) { + violations.push({ + type: 'MEDIA_BOUNDS_INVALID', + entityId: clip.id, + message: + `Clip '${clip.id}': mediaOut (${clip.mediaOut}) exceeds ` + + `asset intrinsicDuration (${asset.intrinsicDuration}).`, + }); + } + + // —— Check 6: (mediaOut - mediaIn) === (timelineEnd - timelineStart) / speed —— + const mediaDuration = clip.mediaOut - clip.mediaIn; + const timelineDuration = clip.timelineEnd - clip.timelineStart; + const expectedMediaDuration = timelineDuration / clip.speed; + if (Math.abs(mediaDuration - expectedMediaDuration) > 0.5) { + violations.push({ + type: 'DURATION_MISMATCH', + entityId: clip.id, + message: + `Clip '${clip.id}': mediaDuration (${mediaDuration}) ≠ ` + + `timelineDuration/speed (${expectedMediaDuration.toFixed(2)}).`, + }); + } + + // —— Check 7: timelineEnd <= timeline.duration (CLIP_BEYOND_TIMELINE) ———— + if (clip.timelineEnd > state.timeline.duration) { + violations.push({ + type: 'CLIP_BEYOND_TIMELINE', + entityId: clip.id, + message: + `Clip '${clip.id}': timelineEnd (${clip.timelineEnd}) exceeds ` + + `timeline duration (${state.timeline.duration}).`, + }); + } + + // —— Check 9: speed > 0 (SPEED_INVALID) ———————————————————————————— + if (clip.speed <= 0) { + violations.push({ + type: 'SPEED_INVALID', + entityId: clip.id, + message: `Clip '${clip.id}': speed (${clip.speed}) must be > 0.`, + }); + } + + // —— Phase 4: Effects (keyframe order, renderStage) ——————————————————— + checkEffects(clip, violations); + // —— Phase 4 Step 3: Transition ———————————————————————————————————————— + checkTransitions(clip, state, violations); +} + +function checkEffects( + clip: Clip, + violations: InvariantViolation[], +): void { + const effects = clip.effects ?? []; + const validStages = ['preComposite', 'postComposite', 'output'] as const; + for (const effect of effects) { + if (!validStages.includes(effect.renderStage)) { + violations.push({ + type: 'INVALID_RENDER_STAGE', + entityId: effect.id, + message: `Effect '${effect.id}': renderStage '${effect.renderStage}' is invalid.`, + }); + } + const kfs = effect.keyframes; + for (let i = 1; i < kfs.length; i++) { + const prev = kfs[i - 1]!; + const curr = kfs[i]!; + if (prev.frame > curr.frame || prev.frame === curr.frame) { + violations.push({ + type: 'KEYFRAME_ORDER_VIOLATION', + entityId: effect.id, + message: `Effect '${effect.id}': keyframes must be sorted ascending by frame with no duplicates.`, + }); + break; + } + } + } +} + +// --------------------------------------------------------------------------- +// Phase 3: Marker bounds +// --------------------------------------------------------------------------- + +function checkMarkerBounds( + state: TimelineState, + violations: InvariantViolation[], +): void { + const dur = state.timeline.duration; + for (const m of state.timeline.markers) { + if (m.type === 'point') { + if (m.frame < 0) { + violations.push({ + type: 'MARKER_OUT_OF_BOUNDS', + entityId: m.id, + message: `Point marker '${m.id}' frame (${m.frame}) must be >= 0.`, + }); + } + if (m.frame >= dur) { + violations.push({ + type: 'MARKER_OUT_OF_BOUNDS', + entityId: m.id, + message: `Point marker '${m.id}' frame (${m.frame}) must be < timeline duration (${dur}).`, + }); + } + } else { + if (m.frameStart < 0) { + violations.push({ + type: 'MARKER_OUT_OF_BOUNDS', + entityId: m.id, + message: `Range marker '${m.id}' frameStart (${m.frameStart}) must be >= 0.`, + }); + } + if (m.frameStart >= dur) { + violations.push({ + type: 'MARKER_OUT_OF_BOUNDS', + entityId: m.id, + message: `Range marker '${m.id}' frameStart (${m.frameStart}) must be < timeline duration (${dur}).`, + }); + } + if (m.frameEnd > dur) { + violations.push({ + type: 'MARKER_OUT_OF_BOUNDS', + entityId: m.id, + message: `Range marker '${m.id}' frameEnd (${m.frameEnd}) exceeds timeline duration (${dur}).`, + }); + } + if (m.frameEnd <= m.frameStart) { + violations.push({ + type: 'MARKER_OUT_OF_BOUNDS', + entityId: m.id, + message: `Range marker '${m.id}' frameEnd must be > frameStart.`, + }); + } + } + } +} + +// --------------------------------------------------------------------------- +// Phase 3: In/Out points +// --------------------------------------------------------------------------- + +function checkInOutPoints( + state: TimelineState, + violations: InvariantViolation[], +): void { + const dur = state.timeline.duration; + const inPt = state.timeline.inPoint; + const outPt = state.timeline.outPoint; + if (inPt !== null && inPt < 0) { + violations.push({ + type: 'IN_OUT_INVALID', + entityId: 'timeline', + message: `In point (${inPt}) must be >= 0.`, + }); + } + if (outPt !== null && outPt > dur) { + violations.push({ + type: 'IN_OUT_INVALID', + entityId: 'timeline', + message: `Out point (${outPt}) must be <= timeline duration (${dur}).`, + }); + } + if (inPt !== null && outPt !== null && inPt >= outPt) { + violations.push({ + type: 'IN_OUT_INVALID', + entityId: 'timeline', + message: `In point (${inPt}) must be < out point (${outPt}).`, + }); + } +} + +// --------------------------------------------------------------------------- +// Phase 3: Beat grid +// --------------------------------------------------------------------------- + +function checkBeatGrid( + state: TimelineState, + violations: InvariantViolation[], +): void { + const bg = state.timeline.beatGrid; + if (bg === null) return; + if (bg.bpm <= 0) { + violations.push({ + type: 'BEAT_GRID_INVALID', + entityId: 'timeline', + message: `Beat grid bpm (${bg.bpm}) must be > 0.`, + }); + } + if (bg.timeSignature[0] <= 0 || bg.timeSignature[1] <= 0) { + violations.push({ + type: 'BEAT_GRID_INVALID', + entityId: 'timeline', + message: `Beat grid timeSignature must be positive.`, + }); + } +} + +// --------------------------------------------------------------------------- +// Phase 3: Caption bounds (per track) +// --------------------------------------------------------------------------- + +function checkCaptionBounds( + state: TimelineState, + track: Track, + violations: InvariantViolation[], +): void { + const dur = state.timeline.duration; + for (const cap of track.captions) { + if (cap.endFrame > dur) { + violations.push({ + type: 'CAPTION_OUT_OF_BOUNDS', + entityId: cap.id, + message: `Caption '${cap.id}' endFrame (${cap.endFrame}) exceeds timeline duration (${dur}).`, + }); + } + if (cap.endFrame <= cap.startFrame) { + violations.push({ + type: 'CAPTION_OUT_OF_BOUNDS', + entityId: cap.id, + message: `Caption '${cap.id}' endFrame must be > startFrame.`, + }); + } + } + const byStart = [...track.captions].sort((a, b) => a.startFrame - b.startFrame); + for (let i = 0; i < byStart.length - 1; i++) { + const a = byStart[i]!; + const b = byStart[i + 1]!; + if (a.endFrame > b.startFrame) { + violations.push({ + type: 'CAPTION_OVERLAP', + entityId: track.id, + message: `Captions '${a.id}' and '${b.id}' overlap on track '${track.id}'.`, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Phase 4 Step 3: Transitions (per clip) +// --------------------------------------------------------------------------- + +const VALID_TRANSITION_ALIGNMENTS = ['centerOnCut', 'endAtCut', 'startAtCut'] as const; + +function checkTransitions( + clip: Clip, + state: TimelineState, + violations: InvariantViolation[], +): void { + const trans = clip.transition; + if (!trans) return; + if (trans.durationFrames <= 0) { + violations.push({ + type: 'INVALID_RANGE', + entityId: clip.id, + message: `Clip '${clip.id}' transition durationFrames (${trans.durationFrames}) must be > 0.`, + }); + } + if (!VALID_TRANSITION_ALIGNMENTS.includes(trans.alignment as typeof VALID_TRANSITION_ALIGNMENTS[number])) { + violations.push({ + type: 'INVALID_RANGE', + entityId: clip.id, + message: `Clip '${clip.id}' transition alignment '${trans.alignment}' is invalid.`, + }); + } +} + +// --------------------------------------------------------------------------- +// Phase 4 Step 3: Link groups +// --------------------------------------------------------------------------- + +function checkLinkGroups(state: TimelineState, violations: InvariantViolation[]): void { + const groups = state.timeline.linkGroups ?? []; + const clipIdsInGroups = new Map(); + for (const g of groups) { + if (g.clipIds.length < 2) { + violations.push({ + type: 'INVALID_RANGE', + entityId: g.id, + message: `Link group '${g.id}' must have at least 2 clipIds.`, + }); + } + for (const cid of g.clipIds) { + const clip = findClipInState(state, cid); + if (!clip) { + violations.push({ + type: 'LINK_GROUP_NOT_FOUND', + entityId: g.id, + message: `Link group '${g.id}' references non-existent clip '${cid}'.`, + }); + } + const count = (clipIdsInGroups.get(cid) ?? 0) + 1; + clipIdsInGroups.set(cid, count); + } + } + for (const [cid, count] of clipIdsInGroups) { + if (count > 1) { + violations.push({ + type: 'INVALID_RANGE', + entityId: cid, + message: `Clip '${cid}' appears in more than one link group.`, + }); + } + } +} + +// --------------------------------------------------------------------------- +// Phase 4 Step 3: Track groups +// --------------------------------------------------------------------------- + +function checkTrackGroups(state: TimelineState, violations: InvariantViolation[]): void { + const groups = state.timeline.trackGroups ?? []; + const groupIds = new Set(groups.map((g) => g.id)); + for (const g of groups) { + for (const tid of g.trackIds) { + if (!state.timeline.tracks.some((t) => t.id === tid)) { + violations.push({ + type: 'TRACK_GROUP_NOT_FOUND', + entityId: g.id, + message: `Track group '${g.id}' references non-existent track '${tid}'.`, + }); + } + } + } + for (const track of state.timeline.tracks) { + const gid = track.groupId; + if (gid !== undefined && !groupIds.has(gid)) { + violations.push({ + type: 'TRACK_GROUP_NOT_FOUND', + entityId: track.id, + message: `Track '${track.id}' has groupId '${gid}' which does not exist.`, + }); + } + } +} + +function findClipInState(state: TimelineState, clipId: string): Clip | undefined { + for (const track of state.timeline.tracks) { + const clip = track.clips.find((c) => c.id === clipId); + if (clip) return clip; + } + return undefined; +} diff --git a/packages/core/src/validation/validators.ts b/packages/core/src/validation/validators.ts new file mode 100644 index 0000000..b19f937 --- /dev/null +++ b/packages/core/src/validation/validators.ts @@ -0,0 +1,752 @@ +/** + * PER-PRIMITIVE VALIDATORS — Phase 0 compliant + * + * Runs BEFORE applying an operation. Each function checks whether the + * operation can legally be applied to the current state. + * + * Returns null if valid, or a { reason, message } rejection if not. + * The Dispatcher calls these in order and stops at the first failure. + * + * RULE: No mutation here. These are pure read-only checks. + */ + +import type { TimelineState } from '../types/state'; +import type { OperationPrimitive, RejectionReason } from '../types/operations'; +import type { Clip } from '../types/clip'; +import type { Effect, EffectId } from '../types/effect'; + +type Rejection = { reason: RejectionReason; message: string }; + +// --------------------------------------------------------------------------- +// validateOperation — dispatcher interface +// --------------------------------------------------------------------------- + +export function validateOperation( + state: TimelineState, + op: OperationPrimitive, +): Rejection | null { + switch (op.type) { + + case 'MOVE_CLIP': return validateMoveClip(state, op); + case 'RESIZE_CLIP': return validateResizeClip(state, op); + case 'SLICE_CLIP': return validateSliceClip(state, op); + case 'DELETE_CLIP': return validateDeleteClip(state, op); + case 'INSERT_CLIP': return validateInsertClip(state, op); + case 'SET_MEDIA_BOUNDS': return validateSetMediaBounds(state, op); + case 'SET_CLIP_SPEED': return validateSetClipSpeed(state, op); + + case 'ADD_TRACK': return validateAddTrack(state, op); + case 'DELETE_TRACK': return validateDeleteTrack(state, op); + case 'UNREGISTER_ASSET': return validateUnregisterAsset(state, op); + + case 'ADD_MARKER': return validateAddMarker(state, op); + case 'MOVE_MARKER': return validateMoveMarker(state, op); + case 'DELETE_MARKER': return validateDeleteMarker(state, op); + case 'SET_IN_POINT': return validateSetInPoint(state, op); + case 'SET_OUT_POINT': return validateSetOutPoint(state, op); + case 'ADD_BEAT_GRID': return validateAddBeatGrid(state, op); + case 'REMOVE_BEAT_GRID': return validateRemoveBeatGrid(state, op); + case 'INSERT_GENERATOR': return validateInsertGenerator(state, op); + case 'ADD_CAPTION': return validateAddCaption(state, op); + case 'EDIT_CAPTION': return validateEditCaption(state, op); + case 'DELETE_CAPTION': return validateDeleteCaption(state, op); + + case 'ADD_EFFECT': return validateAddEffect(state, op); + case 'REMOVE_EFFECT': return validateRemoveEffect(state, op); + case 'REORDER_EFFECT': return validateReorderEffect(state, op); + case 'SET_EFFECT_ENABLED': return validateSetEffectEnabled(state, op); + case 'SET_EFFECT_PARAM': return validateSetEffectParam(state, op); + case 'ADD_KEYFRAME': return validateAddKeyframe(state, op); + case 'MOVE_KEYFRAME': return validateMoveKeyframe(state, op); + case 'DELETE_KEYFRAME': return validateDeleteKeyframe(state, op); + case 'SET_KEYFRAME_EASING': return validateSetKeyframeEasing(state, op); + + case 'SET_CLIP_TRANSFORM': return validateSetClipTransform(state, op); + case 'SET_AUDIO_PROPERTIES': return validateSetAudioProperties(state, op); + case 'ADD_TRANSITION': return validateAddTransition(state, op); + case 'DELETE_TRANSITION': return validateDeleteTransition(state, op); + case 'SET_TRANSITION_DURATION': return validateSetTransitionDuration(state, op); + case 'SET_TRANSITION_ALIGNMENT': return validateSetTransitionAlignment(state, op); + case 'LINK_CLIPS': return validateLinkClips(state, op); + case 'UNLINK_CLIPS': return validateUnlinkClips(state, op); + case 'ADD_TRACK_GROUP': return validateAddTrackGroup(state, op); + case 'DELETE_TRACK_GROUP': return validateDeleteTrackGroup(state, op); + case 'SET_TRACK_BLEND_MODE': return validateSetTrackBlendMode(state, op); + case 'SET_TRACK_OPACITY': return validateSetTrackOpacity(state, op); + + default: return null; + } +} + +// --------------------------------------------------------------------------- +// Clip validators +// --------------------------------------------------------------------------- + +function validateMoveClip( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'ASSET_MISSING', message: `Clip '${op.clipId}' not found.` }; + + const targetTrackId = op.targetTrackId ?? clip.trackId; + const track = state.timeline.tracks.find((t) => t.id === targetTrackId); + if (!track) return { reason: 'OUT_OF_BOUNDS', message: `Track '${targetTrackId}' not found.` }; + if (track.locked) return { reason: 'LOCKED_TRACK', message: `Track '${targetTrackId}' is locked.` }; + + const duration = clip.timelineEnd - clip.timelineStart; + const newEnd = op.newTimelineStart + duration; + + if (op.newTimelineStart < 0 || newEnd > state.timeline.duration) { + return { reason: 'OUT_OF_BOUNDS', message: `MOVE_CLIP would place clip '${op.clipId}' outside timeline bounds.` }; + } + + // Overlap check against target track + for (const existing of track.clips) { + if (existing.id === op.clipId) continue; // skip self + const overlaps = op.newTimelineStart < existing.timelineEnd && newEnd > existing.timelineStart; + if (overlaps) { + return { reason: 'OVERLAP', message: `Clip '${op.clipId}' would overlap '${existing.id}' on track '${targetTrackId}'.` }; + } + } + return null; +} + +function validateResizeClip( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'ASSET_MISSING', message: `Clip '${op.clipId}' not found.` }; + if (op.edge === 'start' && op.newFrame >= clip.timelineEnd) { + return { reason: 'OUT_OF_BOUNDS', message: `RESIZE_CLIP start edge must be < timelineEnd.` }; + } + if (op.edge === 'end' && op.newFrame <= clip.timelineStart) { + return { reason: 'OUT_OF_BOUNDS', message: `RESIZE_CLIP end edge must be > timelineStart.` }; + } + return null; +} + +function validateSliceClip( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'ASSET_MISSING', message: `Clip '${op.clipId}' not found.` }; + if (op.atFrame <= clip.timelineStart || op.atFrame >= clip.timelineEnd) { + return { reason: 'OUT_OF_BOUNDS', message: `SLICE_CLIP atFrame must be strictly inside the clip bounds.` }; + } + return null; +} + +function validateDeleteClip( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'ASSET_MISSING', message: `Clip '${op.clipId}' not found.` }; + const track = state.timeline.tracks.find((t) => t.id === clip.trackId); + if (track?.locked) return { reason: 'LOCKED_TRACK', message: `Track '${clip.trackId}' is locked.` }; + return null; +} + +function validateInsertClip( + state: TimelineState, + op: Extract, +): Rejection | null { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return { reason: 'OUT_OF_BOUNDS', message: `Track '${op.trackId}' not found.` }; + if (track.locked) return { reason: 'LOCKED_TRACK', message: `Track '${op.trackId}' is locked.` }; + + const asset = state.assetRegistry.get(op.clip.assetId); + if (!asset) return { reason: 'ASSET_MISSING', message: `Asset '${op.clip.assetId}' not in registry.` }; + if (asset.mediaType !== track.type) return { reason: 'TYPE_MISMATCH', message: `Asset mediaType '${asset.mediaType}' ≠ track type '${track.type}'.` }; + + for (const existing of track.clips) { + const overlaps = op.clip.timelineStart < existing.timelineEnd && op.clip.timelineEnd > existing.timelineStart; + if (overlaps) { + return { reason: 'OVERLAP', message: `INSERT_CLIP would overlap '${existing.id}'.` }; + } + } + return null; +} + +function validateSetMediaBounds( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'ASSET_MISSING', message: `Clip '${op.clipId}' not found.` }; + const asset = state.assetRegistry.get(clip.assetId); + if (!asset) return { reason: 'ASSET_MISSING', message: `Asset '${clip.assetId}' not found.` }; + if (op.mediaIn < 0) return { reason: 'MEDIA_BOUNDS_INVALID', message: `mediaIn must be >= 0.` }; + if (op.mediaOut > asset.intrinsicDuration) { + return { reason: 'MEDIA_BOUNDS_INVALID', message: `mediaOut (${op.mediaOut}) exceeds asset intrinsicDuration (${asset.intrinsicDuration}).` }; + } + return null; +} + +function validateSetClipSpeed( + _state: TimelineState, + op: Extract, +): Rejection | null { + if (op.speed <= 0) return { reason: 'SPEED_INVALID', message: `speed must be > 0, got ${op.speed}.` }; + return null; +} + +// --------------------------------------------------------------------------- +// Track validators +// --------------------------------------------------------------------------- + +function validateAddTrack( + state: TimelineState, + op: Extract, +): Rejection | null { + if (state.timeline.tracks.some((t) => t.id === op.track.id)) { + return { reason: 'OVERLAP', message: `Track '${op.track.id}' already exists.` }; + } + return null; +} + +function validateDeleteTrack( + state: TimelineState, + op: Extract, +): Rejection | null { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return { reason: 'OUT_OF_BOUNDS', message: `Track '${op.trackId}' not found.` }; + if (track.clips.length > 0) return { reason: 'TRACK_NOT_EMPTY', message: `Cannot delete track '${op.trackId}': it has ${track.clips.length} clips. Delete all clips first.` }; + return null; +} + +// --------------------------------------------------------------------------- +// Asset validators +// --------------------------------------------------------------------------- + +function validateUnregisterAsset( + state: TimelineState, + op: Extract, +): Rejection | null { + for (const track of state.timeline.tracks) { + for (const clip of track.clips) { + if (clip.assetId === op.assetId) { + return { reason: 'ASSET_IN_USE', message: `Asset '${op.assetId}' is referenced by clip '${clip.id}'.` }; + } + } + } + return null; +} + +// --------------------------------------------------------------------------- +// Phase 3: Marker validators +// --------------------------------------------------------------------------- + +function validateAddMarker( + state: TimelineState, + op: Extract, +): Rejection | null { + const { marker } = op; + if (state.timeline.markers.some((m) => m.id === marker.id)) { + return { reason: 'OUT_OF_BOUNDS', message: `Marker '${marker.id}' already exists.` }; + } + if (marker.clipId != null) { + const clip = findClip(state, marker.clipId); + if (!clip) { + return { reason: 'NOT_FOUND', message: `Clip '${marker.clipId}' not found.` }; + } + } + const dur = state.timeline.duration; + if (marker.type === 'point') { + if (marker.frame < 0 || marker.frame > dur) { + return { reason: 'OUT_OF_BOUNDS', message: `Point marker frame (${marker.frame}) must be in [0, ${dur}].` }; + } + } else { + if (marker.frameStart >= marker.frameEnd) { + return { reason: 'OUT_OF_BOUNDS', message: `Range marker frameStart must be < frameEnd.` }; + } + if (marker.frameEnd > dur) { + return { reason: 'OUT_OF_BOUNDS', message: `Range marker frameEnd (${marker.frameEnd}) exceeds timeline duration (${dur}).` }; + } + } + return null; +} + +function validateMoveMarker( + state: TimelineState, + op: Extract, +): Rejection | null { + const marker = findMarker(state, op.markerId); + if (!marker) return { reason: 'NOT_FOUND', message: `Marker '${op.markerId}' not found.` }; + const dur = state.timeline.duration; + if (marker.type === 'point') { + if (op.newFrame < 0 || op.newFrame > dur) { + return { reason: 'OUT_OF_BOUNDS', message: `newFrame (${op.newFrame}) must be in [0, ${dur}].` }; + } + } else { + const duration = marker.frameEnd - marker.frameStart; + const newEnd = op.newFrame + duration; + if (op.newFrame < 0 || newEnd > dur) { + return { reason: 'OUT_OF_BOUNDS', message: `MOVE_MARKER would place range marker outside timeline.` }; + } + } + return null; +} + +function validateDeleteMarker( + state: TimelineState, + op: Extract, +): Rejection | null { + if (!findMarker(state, op.markerId)) { + return { reason: 'NOT_FOUND', message: `Marker '${op.markerId}' not found.` }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Phase 3: In/Out validators +// --------------------------------------------------------------------------- + +function validateSetInPoint( + state: TimelineState, + op: Extract, +): Rejection | null { + if (op.frame === null) return null; + if (op.frame < 0) return { reason: 'OUT_OF_BOUNDS', message: `In point frame must be >= 0.` }; + const out = state.timeline.outPoint; + if (out !== null && op.frame >= out) { + return { reason: 'OUT_OF_BOUNDS', message: `In point must be < out point (${out}).` }; + } + return null; +} + +function validateSetOutPoint( + state: TimelineState, + op: Extract, +): Rejection | null { + if (op.frame === null) return null; + if (op.frame < 0) return { reason: 'OUT_OF_BOUNDS', message: `Out point frame must be >= 0.` }; + const inPt = state.timeline.inPoint; + if (inPt !== null && op.frame <= inPt) { + return { reason: 'OUT_OF_BOUNDS', message: `Out point must be > in point (${inPt}).` }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Phase 3: Beat grid validators +// --------------------------------------------------------------------------- + +function validateAddBeatGrid( + state: TimelineState, + op: Extract, +): Rejection | null { + if (state.timeline.beatGrid !== null) { + return { reason: 'BEAT_GRID_EXISTS', message: `Timeline already has a beat grid.` }; + } + const { beatGrid } = op; + if (beatGrid.bpm <= 0) return { reason: 'OUT_OF_BOUNDS', message: `Beat grid bpm must be > 0.` }; + if (beatGrid.timeSignature[0] <= 0 || beatGrid.timeSignature[1] <= 0) { + return { reason: 'OUT_OF_BOUNDS', message: `Beat grid timeSignature must be positive.` }; + } + return null; +} + +function validateRemoveBeatGrid( + _state: TimelineState, + _op: Extract, +): Rejection | null { + return null; +} + +// --------------------------------------------------------------------------- +// Phase 3: Generator validator +// --------------------------------------------------------------------------- + +function validateInsertGenerator( + state: TimelineState, + op: Extract, +): Rejection | null { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return { reason: 'OUT_OF_BOUNDS', message: `Track '${op.trackId}' not found.` }; + if (track.locked) return { reason: 'LOCKED_TRACK', message: `Track '${op.trackId}' is locked.` }; + if (track.type !== 'video' && track.type !== 'audio') { + return { reason: 'TYPE_MISMATCH', message: `INSERT_GENERATOR requires video or audio track.` }; + } + const dur = state.timeline.duration; + if (op.atFrame < 0 || op.atFrame + op.generator.duration > dur) { + return { reason: 'OUT_OF_BOUNDS', message: `INSERT_GENERATOR would place clip outside timeline.` }; + } + for (const c of track.clips) { + const overlaps = op.atFrame < c.timelineEnd && op.atFrame + op.generator.duration > c.timelineStart; + if (overlaps) return { reason: 'OVERLAP', message: `INSERT_GENERATOR would overlap clip '${c.id}'.` }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Phase 3: Caption validators +// --------------------------------------------------------------------------- + +function validateAddCaption( + state: TimelineState, + op: Extract, +): Rejection | null { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return { reason: 'OUT_OF_BOUNDS', message: `Track '${op.trackId}' not found.` }; + if (track.locked) return { reason: 'LOCKED_TRACK', message: `Track '${op.trackId}' is locked.` }; + const { caption } = op; + if (caption.startFrame >= caption.endFrame) { + return { reason: 'OUT_OF_BOUNDS', message: `Caption startFrame must be < endFrame.` }; + } + if (caption.endFrame > state.timeline.duration) { + return { reason: 'OUT_OF_BOUNDS', message: `Caption endFrame exceeds timeline duration.` }; + } + if (track.captions.some((c) => c.id === caption.id)) { + return { reason: 'OUT_OF_BOUNDS', message: `Caption '${caption.id}' already on track.` }; + } + const overlaps = track.captions.some( + (c) => caption.startFrame < c.endFrame && caption.endFrame > c.startFrame, + ); + if (overlaps) { + return { reason: 'OVERLAP', message: `Caption overlaps an existing caption on track '${op.trackId}'.` }; + } + return null; +} + +function validateEditCaption( + state: TimelineState, + op: Extract, +): Rejection | null { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return { reason: 'NOT_FOUND', message: `Track '${op.trackId}' not found.` }; + const caption = track.captions.find((c) => c.id === op.captionId); + if (!caption) return { reason: 'NOT_FOUND', message: `Caption '${op.captionId}' not found on track.` }; + if (op.startFrame !== undefined && op.endFrame !== undefined) { + if (op.startFrame >= op.endFrame) return { reason: 'OUT_OF_BOUNDS', message: `startFrame must be < endFrame.` }; + if (op.endFrame > state.timeline.duration) return { reason: 'OUT_OF_BOUNDS', message: `endFrame exceeds timeline duration.` }; + } else if (op.startFrame !== undefined) { + if (op.startFrame >= caption.endFrame) return { reason: 'OUT_OF_BOUNDS', message: `startFrame must be < endFrame.` }; + } else if (op.endFrame !== undefined) { + if (caption.startFrame >= op.endFrame) return { reason: 'OUT_OF_BOUNDS', message: `endFrame must be > startFrame.` }; + if (op.endFrame > state.timeline.duration) return { reason: 'OUT_OF_BOUNDS', message: `endFrame exceeds timeline duration.` }; + } + return null; +} + +function validateDeleteCaption( + state: TimelineState, + op: Extract, +): Rejection | null { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return { reason: 'NOT_FOUND', message: `Track '${op.trackId}' not found.` }; + if (!track.captions.some((c) => c.id === op.captionId)) { + return { reason: 'NOT_FOUND', message: `Caption '${op.captionId}' not found on track.` }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Phase 4: Effect & Keyframe validators +// --------------------------------------------------------------------------- + +function validateAddEffect( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effects = clip.effects ?? []; + if (effects.some((e) => e.id === op.effect.id)) { + return { reason: 'DUPLICATE_EFFECT_ID', message: `Effect '${op.effect.id}' already exists on clip '${op.clipId}'.` }; + } + return null; +} + +function validateRemoveEffect( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effect = findEffect(clip, op.effectId); + if (!effect) return { reason: 'EFFECT_NOT_FOUND', message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` }; + return null; +} + +function validateReorderEffect( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effect = findEffect(clip, op.effectId); + if (!effect) return { reason: 'EFFECT_NOT_FOUND', message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` }; + const effects = clip.effects ?? []; + if (op.newIndex < 0 || op.newIndex >= effects.length) { + return { reason: 'EFFECT_INDEX_OUT_OF_RANGE', message: `newIndex ${op.newIndex} out of range [0, ${effects.length - 1}].` }; + } + return null; +} + +function validateSetEffectEnabled( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effect = findEffect(clip, op.effectId); + if (!effect) return { reason: 'EFFECT_NOT_FOUND', message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` }; + return null; +} + +function validateSetEffectParam( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effect = findEffect(clip, op.effectId); + if (!effect) return { reason: 'EFFECT_NOT_FOUND', message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` }; + return null; +} + +function validateAddKeyframe( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effect = findEffect(clip, op.effectId); + if (!effect) return { reason: 'EFFECT_NOT_FOUND', message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` }; + if (effect.keyframes.some((k) => k.id === op.keyframe.id)) { + return { reason: 'DUPLICATE_KEYFRAME_ID', message: `Keyframe '${op.keyframe.id}' already exists on effect '${op.effectId}'.` }; + } + if (op.keyframe.frame < 0) { + return { reason: 'INVALID_RANGE', message: `Keyframe frame (${op.keyframe.frame}) must be >= 0.` }; + } + return null; +} + +function validateMoveKeyframe( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effect = findEffect(clip, op.effectId); + if (!effect) return { reason: 'EFFECT_NOT_FOUND', message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` }; + const kf = effect.keyframes.find((k) => k.id === op.keyframeId); + if (!kf) return { reason: 'KEYFRAME_NOT_FOUND', message: `Keyframe '${op.keyframeId}' not found on effect '${op.effectId}'.` }; + if (op.newFrame < 0) { + return { reason: 'INVALID_RANGE', message: `newFrame (${op.newFrame}) must be >= 0.` }; + } + return null; +} + +function validateDeleteKeyframe( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effect = findEffect(clip, op.effectId); + if (!effect) return { reason: 'EFFECT_NOT_FOUND', message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` }; + const kf = effect.keyframes.find((k) => k.id === op.keyframeId); + if (!kf) return { reason: 'KEYFRAME_NOT_FOUND', message: `Keyframe '${op.keyframeId}' not found on effect '${op.effectId}'.` }; + return null; +} + +function validateSetKeyframeEasing( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const effect = findEffect(clip, op.effectId); + if (!effect) return { reason: 'EFFECT_NOT_FOUND', message: `Effect '${op.effectId}' not found on clip '${op.clipId}'.` }; + const kf = effect.keyframes.find((k) => k.id === op.keyframeId); + if (!kf) return { reason: 'KEYFRAME_NOT_FOUND', message: `Keyframe '${op.keyframeId}' not found on effect '${op.effectId}'.` }; + return null; +} + +// --------------------------------------------------------------------------- +// Phase 4 Step 3: Transform, Audio, Transitions, LinkGroups, TrackGroups +// --------------------------------------------------------------------------- + +function validateSetClipTransform( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + return null; +} + +function validateSetAudioProperties( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + const p = op.properties; + if (p.pan !== undefined && typeof p.pan === 'object' && p.pan !== null && 'value' in p.pan) { + const v = (p.pan as { value: number }).value; + if (v < -1 || v > 1) { + return { reason: 'INVALID_RANGE', message: `pan must be in [-1, 1].` }; + } + } + if (p.normalizationGain !== undefined && p.normalizationGain < 0) { + return { reason: 'INVALID_RANGE', message: `normalizationGain must be >= 0.` }; + } + return null; +} + +function validateAddTransition( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + if (op.transition.durationFrames <= 0) { + return { reason: 'INVALID_RANGE', message: `transition.durationFrames must be > 0.` }; + } + return null; +} + +function validateDeleteTransition( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + if (!clip.transition) { + return { reason: 'TRANSITION_NOT_FOUND', message: `Clip '${op.clipId}' has no transition to delete.` }; + } + return null; +} + +function validateSetTransitionDuration( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + if (!clip.transition) { + return { reason: 'TRANSITION_NOT_FOUND', message: `Clip '${op.clipId}' has no transition.` }; + } + if (op.durationFrames <= 0) { + return { reason: 'INVALID_RANGE', message: `durationFrames must be > 0.` }; + } + return null; +} + +function validateSetTransitionAlignment( + state: TimelineState, + op: Extract, +): Rejection | null { + const clip = findClip(state, op.clipId); + if (!clip) return { reason: 'CLIP_NOT_FOUND', message: `Clip '${op.clipId}' not found.` }; + if (!clip.transition) { + return { reason: 'TRANSITION_NOT_FOUND', message: `Clip '${op.clipId}' has no transition.` }; + } + return null; +} + +function validateLinkClips( + state: TimelineState, + op: Extract, +): Rejection | null { + const linkGroup = op.linkGroup; + if (!linkGroup.clipIds.length || linkGroup.clipIds.length < 2) { + return { reason: 'INVALID_RANGE', message: `linkGroup.clipIds must have length >= 2.` }; + } + for (const cid of linkGroup.clipIds) { + if (!findClip(state, cid)) { + return { reason: 'CLIP_NOT_FOUND', message: `Clip '${cid}' not found.` }; + } + } + const existing = state.timeline.linkGroups ?? []; + if (existing.some((g) => g.id === linkGroup.id)) { + return { reason: 'DUPLICATE_LINK_GROUP_ID', message: `Link group '${linkGroup.id}' already exists.` }; + } + return null; +} + +function validateUnlinkClips( + state: TimelineState, + op: Extract, +): Rejection | null { + const groups = state.timeline.linkGroups ?? []; + if (!groups.some((g) => g.id === op.linkGroupId)) { + return { reason: 'LINK_GROUP_NOT_FOUND', message: `Link group '${op.linkGroupId}' not found.` }; + } + return null; +} + +function validateAddTrackGroup( + state: TimelineState, + op: Extract, +): Rejection | null { + const groups = state.timeline.trackGroups ?? []; + if (groups.some((g) => g.id === op.trackGroup.id)) { + return { reason: 'DUPLICATE_TRACK_GROUP_ID', message: `Track group '${op.trackGroup.id}' already exists.` }; + } + for (const tid of op.trackGroup.trackIds) { + if (!state.timeline.tracks.some((t) => t.id === tid)) { + return { reason: 'TRACK_NOT_FOUND', message: `Track '${tid}' not found.` }; + } + } + return null; +} + +function validateDeleteTrackGroup( + state: TimelineState, + op: Extract, +): Rejection | null { + const groups = state.timeline.trackGroups ?? []; + if (!groups.some((g) => g.id === op.trackGroupId)) { + return { reason: 'TRACK_GROUP_NOT_FOUND', message: `Track group '${op.trackGroupId}' not found.` }; + } + return null; +} + +function validateSetTrackBlendMode( + state: TimelineState, + op: Extract, +): Rejection | null { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return { reason: 'TRACK_NOT_FOUND', message: `Track '${op.trackId}' not found.` }; + return null; +} + +function validateSetTrackOpacity( + state: TimelineState, + op: Extract, +): Rejection | null { + const track = state.timeline.tracks.find((t) => t.id === op.trackId); + if (!track) return { reason: 'TRACK_NOT_FOUND', message: `Track '${op.trackId}' not found.` }; + if (op.opacity < 0 || op.opacity > 1) { + return { reason: 'INVALID_OPACITY', message: `opacity must be in [0, 1].` }; + } + return null; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function findEffect(clip: Clip, effectId: EffectId): Effect | undefined { + const effects = clip.effects ?? []; + return effects.find((e) => e.id === effectId); +} + +function findClip(state: TimelineState, clipId: string) { + for (const track of state.timeline.tracks) { + const clip = track.clips.find((c) => c.id === clipId); + if (clip) return clip; + } + return undefined; +} + +function findMarker(state: TimelineState, markerId: string) { + return state.timeline.markers.find((m) => m.id === markerId); +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000..0f9487e --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '**/*.d.ts', + '**/*.config.*', + '**/mockData', + ], + }, + }, +}); diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md new file mode 100644 index 0000000..6457e5e --- /dev/null +++ b/packages/react/CHANGELOG.md @@ -0,0 +1,22 @@ +# Changelog + +All notable changes to `@webpacked-timeline/react` are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.0-beta.1] - 2026-03-07 + +### Added +- `TimelineEngine` orchestrator class — wires core's dispatcher, `HistoryStack`, `PlaybackEngine`, `SnapIndexManager`, `TrackIndex`, `KeyboardHandler`, and all 12 built-in tools +- `TimelineProvider` context + `TimelineContext` for React tree +- 20+ hooks with `useSyncExternalStore` for granular re-renders: + - `useEngine`, `useTimeline`, `useTrackIds`, `useTrack`, `useClip`, `useClips` + - `useMarkers`, `useHistory`, `useCanUndo`, `useCanRedo` + - `usePlayheadFrame`, `useIsPlaying`, `usePlaybackEngine`, `usePlayhead`, `usePlayheadEvent` + - `useActiveToolId`, `useActiveTool`, `useCursor` + - `useProvisional`, `useSelectedClipIds`, `useChange` +- Engine-first hook variants: `useTimelineWithEngine`, `useTrackIdsWithEngine`, `useTrackWithEngine`, `useClipWithEngine`, `useProvisionalWithEngine` +- `useVirtualWindow` and `useVisibleClips` for viewport-aware rendering +- `createToolRouter` adapter and `useToolRouter` hook for pointer/keyboard event wiring +- `EngineSnapshot` type for stable external store contract +- `DEFAULT_PLAYHEAD_STATE` constant +- 187 tests passing diff --git a/packages/react/README.md b/packages/react/README.md index d2e7761..72e8d49 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -1,73 +1,121 @@ -# React + TypeScript + Vite +# @webpacked-timeline/react -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React adapter for `@webpacked-timeline/core`. Provides `TimelineEngine`, hooks, context, and tool routing for building timeline editors. -Currently, two official plugins are available: +## Install -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +```bash +npm install @webpacked-timeline/core @webpacked-timeline/react +``` + +Both packages are required. `@webpacked-timeline/core` is a peer dependency. + +## Quick Start + +```tsx +import { TimelineEngine, TimelineProvider, useTrackIds, usePlayheadFrame } from '@webpacked-timeline/react'; +import { createTimelineState, createTimeline, toFrame, frameRate } from '@webpacked-timeline/core'; + +const engine = new TimelineEngine({ + initialState: createTimelineState({ + timeline: createTimeline({ + id: 'tl-1', + name: 'My Timeline', + fps: frameRate(30), + duration: toFrame(9000), + }), + }), +}); + +function App() { + return ( + + + + ); +} + +function TimelineView() { + const trackIds = useTrackIds(); + const frame = usePlayheadFrame(); + return ( +
+

Frame: {frame as number}

+

{trackIds.length} tracks

+
+ ); +} +``` -## React Compiler +## Hooks -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +All hooks accept an optional `engine` argument for use outside `TimelineProvider`. Inside the provider, they read from context automatically. -## Expanding the ESLint configuration +| Hook | Returns | Re-renders when | +|------|---------|-----------------| +| `useEngine()` | `TimelineEngine` | — (stable ref) | +| `useTimeline(engine?)` | `Timeline` | timeline metadata changes | +| `useTrackIds(engine?)` | `string[]` | tracks added/removed/reordered | +| `useTrack(engine?, trackId)` | `Track \| null` | that track changes | +| `useClip(engine?, clipId)` | `Clip \| null` | that clip changes | +| `useClips(engine?, trackId)` | `Clip[]` | any clip on track changes | +| `useMarkers(engine?)` | `Marker[]` | markers change | +| `useHistory(engine?)` | `{ canUndo, canRedo }` | history changes | +| `usePlayheadFrame(engine?)` | `TimelineFrame` | every frame tick | +| `useIsPlaying(engine?)` | `boolean` | play/pause toggle | +| `useActiveToolId(engine?)` | `string` | tool switch | +| `useActiveTool(engine?)` | `ITool` | tool switch | +| `useProvisional(engine?)` | `ProvisionalState \| null` | drag preview | +| `useSelectedClipIds(engine?)` | `ReadonlySet` | selection changes | +| `useCursor(engine?)` | `string` | cursor style changes | +| `useCanUndo(engine?)` | `boolean` | undo availability | +| `useCanRedo(engine?)` | `boolean` | redo availability | +| `useChange(engine?)` | `StateChange` | any state diff | +| `usePlaybackEngine(engine?)` | `PlaybackEngine \| null` | — | +| `usePlayhead(engine?)` | `UsePlayheadResult` | playhead state | +| `useVirtualWindow(engine, vpWidth, scrollLeft, ppf)` | `VirtualWindow` | viewport changes | +| `useVisibleClips(engine, window)` | `VirtualClipEntry[]` | visible clips change | -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +### Engine-first variants -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +For use without context: `useTimelineWithEngine(engine)`, `useTrackIdsWithEngine(engine)`, `useTrackWithEngine(engine, id)`, `useClipWithEngine(engine, id)`, `useProvisionalWithEngine(engine)`. - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, +## Tool Routing - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```tsx +import { useToolRouter } from '@webpacked-timeline/react'; + +const handlers = useToolRouter(engine, { + getPixelsPerFrame: () => ppf, +}); + +return ( +
+ {/* timeline content */} +
+); ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +## TimelineEngine + +`TimelineEngine` is the main orchestrator class. It wires together the core dispatcher, history, tools, playback, snap system, and keyboard handler. + +```typescript +const engine = new TimelineEngine({ + initialState, // TimelineState (required) + pipeline, // PipelineConfig (optional) + clock, // Clock (optional, defaults to browserClock) + getPixelsPerFrame, // () => number (optional) + onZoomChange, // (ppf: number) => void (optional) + historyLimit, // number (optional, default 100) + compression, // CompressionPolicy (optional) + tools, // ITool[] (optional, overrides defaults) + defaultToolId, // string (optional, default 'selection') +}); ``` + +Key methods: `dispatch()`, `undo()`, `redo()`, `seekTo()`, `activateTool()`, `getState()`, `getSnapshot()`, `subscribe()`. + +## License + +MIT diff --git a/packages/react/examples/basic-usage.tsx b/packages/react/examples/basic-usage.tsx index 6a5f4c3..0ef14a1 100644 --- a/packages/react/examples/basic-usage.tsx +++ b/packages/react/examples/basic-usage.tsx @@ -1,5 +1,5 @@ /** - * @timeline/react - Basic Usage Example + * @webpacked-timeline/react - Basic Usage Example * * This example demonstrates how to use the React adapter with the timeline core. * @@ -21,14 +21,14 @@ import { createAsset, frame, frameRate, -} from '@timeline/core'; +} from '@webpacked-timeline/core'; import { TimelineProvider, useTimeline, useTrack, useClip, useEngine, -} from '@timeline/react'; +} from '@webpacked-timeline/react'; // Import internal utilities for example import { @@ -36,7 +36,7 @@ import { generateTrackId, generateClipId, generateAssetId, -} from '@timeline/core/internal'; +} from '@webpacked-timeline/core/internal'; // ===== CREATE ENGINE ===== diff --git a/packages/react/package.json b/packages/react/package.json index 26aaf59..ca7f799 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -1,10 +1,10 @@ { - "name": "@timeline/react", - "version": "0.1.0", - "description": "React adapter for @timeline/core - thin integration layer providing hooks and context", + "name": "@webpacked-timeline/react", + "version": "1.0.0-beta.1", + "description": "React adapter for @webpacked-timeline/core. Hooks, context, and tool routing for building timeline editors.", "type": "module", - "main": "./dist/index.js", - "module": "./dist/index.mjs", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { @@ -13,33 +13,42 @@ "require": "./dist/index.cjs" } }, - "files": [ - "dist" - ], + "files": ["dist", "README.md", "CHANGELOG.md"], "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "peerDependencies": { + "@webpacked-timeline/core": ">=1.0.0-beta.1", "react": "^18.0.0 || ^19.0.0" }, "dependencies": { - "@timeline/core": "workspace:*" + "@webpacked-timeline/core": "workspace:*" }, "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.1.0", "@types/react": "^18.2.0", - "react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitest/coverage-v8": "^2.1.8", + "jsdom": "^25.0.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", "tsup": "^8.0.0", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "vitest": "^2.1.8" }, - "keywords": [ - "timeline", - "react", - "hooks", - "video-editing", - "framework-adapter" - ], + "keywords": ["timeline", "react", "nle", "video-editor", "hooks", "typescript"], "author": "", - "license": "MIT" + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/manas-timeline/timeline", + "directory": "packages/react" + }, + "homepage": "https://github.com/manas-timeline/timeline/tree/main/packages/react#readme" } diff --git a/packages/react/src/App.tsx b/packages/react/src/App.tsx index 3d7ded3..7b14074 100644 --- a/packages/react/src/App.tsx +++ b/packages/react/src/App.tsx @@ -1,35 +1,90 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +/** + * Minimal Timeline Example + * + * Uses Phase R TimelineEngine with options-based constructor. + * For a full demo with @webpacked-timeline/ui, see apps/demo/ + */ -function App() { - const [count, setCount] = useState(0) +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + frameRate, +} from '@webpacked-timeline/core'; +import { TimelineEngine } from './engine'; +import { TimelineProvider } from './index'; + +const timeline = createTimeline({ + id: 'tl-1', + name: 'Example Timeline', + fps: frameRate(30), + duration: toFrame(3000), + tracks: [], +}); + +const videoTrack = createTrack({ + id: 'track-v1', + name: 'Video Track 1', + type: 'video', + locked: false, + clips: [], +}); + +const videoAsset = createAsset({ + id: 'asset-v1', + name: 'Sample Video', + mediaType: 'video', + filePath: '/sample.mp4', + intrinsicDuration: toFrame(300), + nativeFps: frameRate(30), + sourceTimecodeOffset: toFrame(0), +}); + +const videoClip = createClip({ + id: 'clip-1', + assetId: videoAsset.id, + trackId: videoTrack.id, + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + effects: [], +}); +const timelineWithTrack = createTimeline({ + ...timeline, + tracks: [ + createTrack({ + ...videoTrack, + clips: [videoClip], + }), + ], +}); + +const initialState = createTimelineState({ + timeline: timelineWithTrack, + assetRegistry: new Map([[videoAsset.id, videoAsset]]), +}); + +const engine = new TimelineEngine({ initialState }); + +function App() { return ( - <> - -

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

+ +
+
+

Timeline Example

+

+ Phase R engine — for full UI use apps/demo with @webpacked-timeline/ui +

+
+
-

- Click on the Vite and React logos to learn more -

- - ) +
+ ); } -export default App +export default App; diff --git a/packages/react/src/TimelineProvider.tsx b/packages/react/src/TimelineProvider.tsx index ff21faa..2c563ee 100644 --- a/packages/react/src/TimelineProvider.tsx +++ b/packages/react/src/TimelineProvider.tsx @@ -1,19 +1,18 @@ /** - * @timeline/react - TimelineProvider - * - * React context provider for the timeline engine. - * - * This component provides the TimelineEngine instance to all child components - * via React context. Use the hooks (useEngine, useTimeline, etc.) to access - * the engine and subscribe to state changes. - * + * @webpacked-timeline/react - TimelineProvider + * + * React context provider for the Phase 1 TimelineEngine. + * + * Provides the engine to all child components via React context. + * All hooks read from this context via useTimelineContext() in hooks.ts. + * * @example * ```tsx - * import { TimelineEngine } from '@timeline/core'; - * import { TimelineProvider } from '@timeline/react'; - * + * import { TimelineEngine } from '@webpacked-timeline/react'; + * import { TimelineProvider } from '@webpacked-timeline/react'; + * * const engine = new TimelineEngine(initialState); - * + * * function App() { * return ( * @@ -25,34 +24,20 @@ */ import { createContext, ReactNode } from 'react'; -import { TimelineEngine } from '@timeline/core'; +import type { TimelineEngine } from './engine'; /** - * Timeline context - * - * Provides the TimelineEngine instance to child components. - * Use the hooks to access this context. + * React context that holds the Phase 1 TimelineEngine instance. + * Use via hooks — never access this directly in components. */ export const TimelineContext = createContext(null); -/** - * Timeline provider props - */ export interface TimelineProviderProps { - /** The timeline engine instance */ - engine: TimelineEngine; - /** Child components */ + /** The Phase 1 timeline engine instance */ + engine: TimelineEngine; children: ReactNode; } -/** - * Timeline provider component - * - * Wraps your application to provide the timeline engine to all child components. - * - * @param props - Provider props - * @returns Provider component - */ export function TimelineProvider({ engine, children }: TimelineProviderProps) { return ( diff --git a/packages/react/src/__tests__/engine.test.ts b/packages/react/src/__tests__/engine.test.ts new file mode 100644 index 0000000..21ec27c --- /dev/null +++ b/packages/react/src/__tests__/engine.test.ts @@ -0,0 +1,631 @@ +/** + * TimelineEngine — Phase 1 Tests + * + * 27 tests covering the full engine contract: + * subscribe / getSnapshot — useSyncExternalStore interface + * dispatch() — accept / reject + * undo() / redo() — history + * provisional state — handlePointerMove / Up + * activateTool() — cursor + onCancel + * handlePointerDown — cursor notify + * handleKeyDown — tx dispatch + null no-notify + * setPixelsPerFrame — no notify (ppf not in snapshot) + * setPlayheadFrame — notify + * NoOpTool default — engine default + * notify() storm guard — exactly ONE notify per handlePointerMove + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TimelineEngine, type EngineSnapshot } from '../engine'; +import { + createTimelineState, + createTimeline, + createTrack, + toFrame, + toTimecode, + NoOpTool, + toToolId, + createTestClock, +} from '@webpacked-timeline/core'; +import type { + ITool, + ToolContext, + TimelinePointerEvent, + TimelineKeyEvent, + Modifiers, + ProvisionalState, + Transaction, + TimelineState, +} from '@webpacked-timeline/core'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +function makeState(): TimelineState { + return createTimelineState({ + timeline: createTimeline({ + id: 'tl-1', + name: 'Test Timeline', + fps: 30, + duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [], + }), + }); +} + +const noModifiers: Modifiers = { shift: false, alt: false, ctrl: false, meta: false }; + +function makePointerEvent(frame = 0): TimelinePointerEvent { + return { + frame: toFrame(frame), + trackId: null, + clipId: null, + x: 0, + y: 0, + buttons: 1, + shiftKey: false, + altKey: false, + metaKey: false, + }; +} + +function makeKeyEvent(key = 'x'): TimelineKeyEvent { + return { + key, + code: key === ' ' ? 'Space' : `Key${key.toUpperCase()}`, + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + }; +} + +/** Minimal valid transaction — rename the timeline. */ +function makeRenameTx(name: string): Transaction { + return { + id: `rename-${name}`, + label: `Rename to ${name}`, + timestamp: 0, + operations: [{ type: 'RENAME_TIMELINE', name }], + }; +} + +/** Transaction that validators reject — MOVE_CLIP on a non-existent clipId. */ +function makeRejectTx(): Transaction { + return { + id: 'reject-tx', + label: 'Invalid move', + timestamp: 0, + operations: [{ type: 'MOVE_CLIP', clipId: 'ghost-id' as any, newTimelineStart: toFrame(0) }], + }; +} + +/** A fake ProvisionalState with isProvisional: true. */ +function makeProvisional(): ProvisionalState { + return { clips: [], isProvisional: true }; +} + +/** Helper to build a custom tool that overrides specific handlers. */ +function makeTool(id: string, overrides: Partial): ITool { + return { ...NoOpTool, id: toToolId(id), ...overrides }; +} + +// ── Engine factory: starts with two registered tools ──────────────────────── + +function makeEngine() { + const state = makeState(); + const altTool = makeTool('alt', {}); + return new TimelineEngine({ + initialState: state, + tools: [NoOpTool, altTool], + defaultToolId: 'noop', + }); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('subscribe / getSnapshot', () => { + it('getSnapshot() returns the same object reference until notify() fires', () => { + const engine = makeEngine(); + const snap1 = engine.getSnapshot(); + const snap2 = engine.getSnapshot(); + expect(snap1).toBe(snap2); // stable ref before any change + }); + + it('subscribe() returns an unsubscribe function that stops future notifications', () => { + const engine = makeEngine(); + const listener = vi.fn(); + const unsub = engine.subscribe(listener); + unsub(); + engine.dispatch(makeRenameTx('after-unsub')); + expect(listener).not.toHaveBeenCalled(); + }); + + it('listeners are called once per accepted dispatch()', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + engine.dispatch(makeRenameTx('renamed')); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('listeners are NOT called when dispatch() rejects', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + // MOVE_CLIP on a non-existent clipId — validators reject this before applying + engine.dispatch(makeRejectTx()); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe('dispatch()', () => { + it('accepted tx updates snapshot.state and snapshot.canUndo', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('NewName')); + const snap = engine.getSnapshot(); + expect(snap.state.timeline.name).toBe('NewName'); + expect(snap.canUndo).toBe(true); + }); + + it('rejected tx leaves snapshot unchanged', () => { + const engine = makeEngine(); + const snapBefore = engine.getSnapshot(); + // MOVE_CLIP on a non-existent clipId — validators reject before any state change + engine.dispatch(makeRejectTx()); + // Same object reference — rejected dispatch must not call buildSnapshot() + expect(engine.getSnapshot()).toBe(snapBefore); + }); + + it('dispatch() returns the DispatchResult from core', () => { + const engine = makeEngine(); + const result = engine.dispatch(makeRenameTx('x')); + expect(result.accepted).toBe(true); + if (result.accepted) { + expect(result.nextState.timeline.name).toBe('x'); + } + }); +}); + +describe('undo() / redo()', () => { + it('undo() reverts accepted tx and canUndo becomes false', () => { + const engine = makeEngine(); + const originalName = engine.getSnapshot().state.timeline.name; + engine.dispatch(makeRenameTx('Changed')); + engine.undo(); + expect(engine.getSnapshot().state.timeline.name).toBe(originalName); + expect(engine.getSnapshot().canUndo).toBe(false); + }); + + it('redo() re-applies undone tx and canRedo becomes false', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('Changed')); + engine.undo(); + engine.redo(); + expect(engine.getSnapshot().state.timeline.name).toBe('Changed'); + expect(engine.getSnapshot().canRedo).toBe(false); + }); + + it('undo() is a no-op when canUndo is false', () => { + const engine = makeEngine(); + const nameBefore = engine.getSnapshot().state.timeline.name; + const undone = engine.undo(); + expect(undone).toBe(false); + expect(engine.getSnapshot().state.timeline.name).toBe(nameBefore); + }); +}); + +describe('provisional state', () => { + it('handlePointerMove with a tool returning ProvisionalState sets snapshot.provisional', () => { + const ghost = makeProvisional(); + const moveTool = makeTool('move', { + onPointerMove: () => ghost, + }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'move', + }); + engine.handlePointerMove(makePointerEvent(), noModifiers); + expect(engine.getSnapshot().provisional).toBe(ghost); + }); + + it('handlePointerMove with a tool returning null clears snapshot.provisional', () => { + const ghost = makeProvisional(); + let callCount = 0; + const moveTool = makeTool('move', { + onPointerMove: () => callCount++ === 0 ? ghost : null, + }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'move', + }); + engine.handlePointerMove(makePointerEvent(), noModifiers); // sets provisional + engine.handlePointerMove(makePointerEvent(), noModifiers); // clears provisional + expect(engine.getSnapshot().provisional).toBeNull(); + }); + + it('handlePointerUp clears provisional before dispatching tx', () => { + const ghost = makeProvisional(); + const upTool = makeTool('up', { + onPointerMove: () => ghost, + onPointerUp: () => makeRenameTx('committed'), + }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, upTool], + defaultToolId: 'up', + }); + engine.handlePointerMove(makePointerEvent(), noModifiers); // sets provisional + engine.handlePointerUp(makePointerEvent(), noModifiers); // clears, dispatches + expect(engine.getSnapshot().provisional).toBeNull(); + expect(engine.getSnapshot().state.timeline.name).toBe('committed'); + }); + + it('snapshot.provisional is null after handlePointerUp even when tool returns null tx', () => { + const ghost = makeProvisional(); + const upTool = makeTool('up', { + onPointerMove: () => ghost, + onPointerUp: () => null, // no commit + }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, upTool], + defaultToolId: 'up', + }); + engine.handlePointerMove(makePointerEvent(), noModifiers); + engine.handlePointerUp(makePointerEvent(), noModifiers); + expect(engine.getSnapshot().provisional).toBeNull(); + }); +}); + +describe('activateTool()', () => { + it('snapshot.activeToolId changes after activateTool()', () => { + const engine = makeEngine(); + expect(engine.getSnapshot().activeToolId).toBe('noop'); + engine.activateTool('alt'); + expect(engine.getSnapshot().activeToolId).toBe('alt'); + }); + + it('activateTool() calls onCancel() on the outgoing tool', () => { + const cancelSpy = vi.fn(); + const cancelTool = makeTool('cancel', { onCancel: cancelSpy }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [cancelTool, NoOpTool], + defaultToolId: 'cancel', + }); + engine.activateTool(toToolId('noop')); + expect(cancelSpy).toHaveBeenCalledOnce(); + }); + + it('activateTool() throws on unknown id — snapshot unchanged', () => { + const engine = makeEngine(); + expect(() => engine.activateTool('ghost')).toThrow(); + expect(engine.getSnapshot().activeToolId).toBe('noop'); + }); +}); + +describe('handlePointerDown', () => { + it('notifies once per call — cursor update', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + engine.handlePointerDown(makePointerEvent(), noModifiers); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('delegates to getActiveTool().onPointerDown', () => { + const downSpy = vi.fn(); + const downTool = makeTool('down', { onPointerDown: downSpy }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, downTool], + defaultToolId: 'down', + }); + const event = makePointerEvent(10); + engine.handlePointerDown(event, noModifiers); + expect(downSpy).toHaveBeenCalledOnce(); + expect(downSpy.mock.calls[0]![0]).toBe(event); + }); +}); + +describe('handleKeyDown', () => { + it('does NOT notify when tool returns null — no re-render on no-op keystrokes', () => { + const engine = makeEngine(); // NoOpTool.onKeyDown returns null + const listener = vi.fn(); + engine.subscribe(listener); + engine.handleKeyDown(makeKeyEvent('z'), noModifiers); + expect(listener).not.toHaveBeenCalled(); + }); + + it('notifies when handleKeyDown tool returns an accepted Transaction (21b)', () => { + const keyTool = makeTool('keytool', { + onKeyDown: (_e: TimelineKeyEvent, _ctx: ToolContext) => makeRenameTx('from-key'), + }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, keyTool], + defaultToolId: 'keytool', + }); + const listener = vi.fn(); + engine.subscribe(listener); + + engine.handleKeyDown(makeKeyEvent('x'), noModifiers); + expect(listener).toHaveBeenCalledOnce(); + expect(engine.getSnapshot().state.timeline.name).toBe('from-key'); + }); +}); + +describe('NoOpTool default', () => { + it('engine with defaultToolId "noop" has activeToolId "noop"', () => { + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool], + defaultToolId: 'noop', + }); + expect(engine.getSnapshot().activeToolId).toBe('noop'); + // Confirm all pointer/key methods run without throwing + expect(() => { + engine.handlePointerDown(makePointerEvent(), noModifiers); + engine.handlePointerMove(makePointerEvent(), noModifiers); + engine.handlePointerUp(makePointerEvent(), noModifiers); + engine.handleKeyDown(makeKeyEvent(), noModifiers); + engine.handleKeyUp(makeKeyEvent(), noModifiers); + }).not.toThrow(); + }); +}); + +describe('notify() storm guard', () => { + it('handlePointerMove calls notify() exactly ONCE regardless of tool return (25)', () => { + const moveTool = makeTool('move', { + onPointerMove: () => makeProvisional(), + }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'move', + }); + const listener = vi.fn(); + engine.subscribe(listener); + engine.handlePointerMove(makePointerEvent(), noModifiers); + expect(listener).toHaveBeenCalledOnce(); + }); + + it('snapshot.provisional reflects the tool return value after handlePointerMove (25b)', () => { + const ghost = makeProvisional(); + const moveTool = makeTool('movetool', { + onPointerMove: () => ghost, + }); + const engine = new TimelineEngine({ + initialState: makeState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'movetool', + }); + engine.handlePointerMove(makePointerEvent(), noModifiers); + // toBe not toEqual — confirms no unnecessary copying + expect(engine.getSnapshot().provisional).toBe(ghost); + }); +}); + +// ── Phase R Step 1 — Full orchestrator (23 new tests) ──────────────────────── + +describe('Phase R Step 1 — Construction', () => { + it('1. Engine constructs without pipeline (edit-only)', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + expect(engine.playbackEngine).toBeNull(); + expect(engine.getSnapshot().playhead.isPlaying).toBe(false); + }); + + it('2. Engine constructs with pipeline', () => { + const mockPipeline = { + videoDecoder: vi.fn().mockResolvedValue({}), + compositor: vi.fn().mockResolvedValue({}), + } as any; + const { clock } = createTestClock(); + const engine = new TimelineEngine({ + initialState: makeState(), + pipeline: mockPipeline, + clock, + }); + expect(engine.playbackEngine).not.toBeNull(); + }); + + it('3. getSnapshot() returns valid EngineSnapshot', () => { + const engine = makeEngine(); + const snap = engine.getSnapshot(); + expect(snap).toHaveProperty('state'); + expect(snap).toHaveProperty('provisional'); + expect(snap).toHaveProperty('activeToolId'); + expect(snap).toHaveProperty('canUndo'); + expect(snap).toHaveProperty('canRedo'); + expect(snap).toHaveProperty('trackIds'); + expect(snap).toHaveProperty('cursor'); + expect(snap).toHaveProperty('playhead'); + expect(snap).toHaveProperty('change'); + }); + + it('4. Initial snapshot has correct trackIds', () => { + const state = makeState(); + const engine = new TimelineEngine({ initialState: state }); + const expected = state.timeline.tracks.map((t) => t.id); + expect(engine.getSnapshot().trackIds).toEqual(expected); + }); + + it('5. Initial canUndo: false, canRedo: false', () => { + const engine = makeEngine(); + expect(engine.getSnapshot().canUndo).toBe(false); + expect(engine.getSnapshot().canRedo).toBe(false); + }); +}); + +describe('Phase R Step 1 — dispatch', () => { + it('6. dispatch updates snapshot.state', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('NewName')); + expect(engine.getSnapshot().state.timeline.name).toBe('NewName'); + }); + + it('7. dispatch updates canUndo: true', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('X')); + expect(engine.getSnapshot().canUndo).toBe(true); + }); + + it('8. dispatch notifies subscribers', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + engine.dispatch(makeRenameTx('Y')); + expect(listener).toHaveBeenCalled(); + }); + + it('9. Failed dispatch does not notify subscribers', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + engine.dispatch(makeRejectTx()); + expect(listener).not.toHaveBeenCalled(); + }); + + it('10. dispatch updates stableTrackIds when track list changes', () => { + const state = makeState(); + const engine = new TimelineEngine({ initialState: state }); + const idsBefore = engine.getSnapshot().trackIds; + const newTrack = createTrack({ + id: 'track-new', + name: 'New', + type: 'video', + clips: [], + }); + engine.dispatch({ + id: 'add', + label: 'Add track', + timestamp: 0, + operations: [{ type: 'ADD_TRACK', track: newTrack }], + }); + const idsAfter = engine.getSnapshot().trackIds; + expect(idsAfter.length).toBe(idsBefore.length + 1); + expect(idsAfter).toContain('track-new'); + }); +}); + +describe('Phase R Step 1 — undo/redo', () => { + it('11. undo() restores previous state', () => { + const engine = makeEngine(); + const nameBefore = engine.getSnapshot().state.timeline.name; + engine.dispatch(makeRenameTx('After')); + engine.undo(); + expect(engine.getSnapshot().state.timeline.name).toBe(nameBefore); + }); + + it('12. undo() updates canUndo/canRedo flags', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('A')); + expect(engine.getSnapshot().canUndo).toBe(true); + expect(engine.getSnapshot().canRedo).toBe(false); + engine.undo(); + expect(engine.getSnapshot().canUndo).toBe(false); + expect(engine.getSnapshot().canRedo).toBe(true); + }); + + it('13. redo() after undo restores forward state', () => { + const engine = makeEngine(); + engine.dispatch(makeRenameTx('Redone')); + engine.undo(); + engine.redo(); + expect(engine.getSnapshot().state.timeline.name).toBe('Redone'); + }); + + it('14. undo() returns false when nothing to undo', () => { + const engine = makeEngine(); + expect(engine.undo()).toBe(false); + }); +}); + +describe('Phase R Step 1 — Tools', () => { + it('15. activateTool changes activeToolId in snapshot', () => { + const engine = makeEngine(); + engine.activateTool('alt'); + expect(engine.getSnapshot().activeToolId).toBe('alt'); + }); + + it('16. getActiveToolId returns correct id', () => { + const engine = makeEngine(); + expect(engine.getActiveToolId()).toBe('noop'); + engine.activateTool('alt'); + expect(engine.getActiveToolId()).toBe('alt'); + }); + + it('17. handleKeyDown returns true for Space key when playback engine present', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engine = new TimelineEngine({ + initialState: makeState(), + pipeline: mockPipeline, + clock, + }); + const result = engine.handleKeyDown( + { key: ' ', code: 'Space', shiftKey: false, altKey: false, metaKey: false, ctrlKey: false }, + noModifiers, + ); + expect(result).toBe(true); + }); +}); + +describe('Phase R Step 1 — Snapshot stability and subscribers', () => { + it('18. dispatch calls subscriber exactly once per dispatch', () => { + const engine = makeEngine(); + const listener = vi.fn(); + engine.subscribe(listener); + engine.dispatch(makeRenameTx('Once')); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('19. subscribe returns unsubscribe function', () => { + const engine = makeEngine(); + const unsub = engine.subscribe(vi.fn()); + expect(typeof unsub).toBe('function'); + unsub(); + }); + + it('20. After unsubscribe, callback not called', () => { + const engine = makeEngine(); + const listener = vi.fn(); + const unsub = engine.subscribe(listener); + unsub(); + engine.dispatch(makeRenameTx('NoNotify')); + expect(listener).not.toHaveBeenCalled(); + }); + + it('21. Multiple subscribers all notified', () => { + const engine = makeEngine(); + const a = vi.fn(); + const b = vi.fn(); + engine.subscribe(a); + engine.subscribe(b); + engine.dispatch(makeRenameTx('Both')); + expect(a).toHaveBeenCalled(); + expect(b).toHaveBeenCalled(); + }); +}); + +describe('Phase R Step 1 — Playback integration', () => { + it('22. playbackEngine is null without pipeline', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + expect(engine.playbackEngine).toBeNull(); + }); + + it('23. playbackEngine is PlaybackEngine with pipeline', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engine = new TimelineEngine({ + initialState: makeState(), + pipeline: mockPipeline, + clock, + }); + expect(engine.playbackEngine).not.toBeNull(); + expect(typeof (engine.playbackEngine as any)?.getState).toBe('function'); + }); +}); diff --git a/packages/react/src/__tests__/hooks-r2.test.tsx b/packages/react/src/__tests__/hooks-r2.test.tsx new file mode 100644 index 0000000..9119fda --- /dev/null +++ b/packages/react/src/__tests__/hooks-r2.test.tsx @@ -0,0 +1,571 @@ +/** + * Phase R Step 2 — Full hook set tests + * + * Fixture: engine with 2 tracks, 3 clips total, 2 markers, 30fps. + * All hooks take engine as first arg. Selector isolation proven with toBe. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + toTimecode, + frameRate, + toTrackId, + toClipId, + toAssetId, + toToolId, + createTestClock, + NoOpTool, +} from '@webpacked-timeline/core'; +import type { Transaction } from '@webpacked-timeline/core'; + +import { TimelineEngine } from '../engine'; +import { + useTimeline, + useTrackIds, + useTrack, + useClip, + useClips, + useMarkers, + useHistory, + useActiveToolId, + useCursor, + useProvisional, + usePlayheadFrame, + useIsPlaying, + useChange, +} from '../hooks/index'; + +// ── Fixture: 2 tracks, 3 clips, 2 markers, 30fps ───────────────────────────── + +const TRACK_1 = toTrackId('track-1'); +const TRACK_2 = toTrackId('track-2'); +const CLIP_A = toClipId('clip-a'); +const CLIP_B = toClipId('clip-b'); +const CLIP_C = toClipId('clip-c'); + +function makeFixtureState() { + const asset = createAsset({ + id: 'asset-1', + name: 'Asset', + mediaType: 'video', + filePath: '/a.mp4', + intrinsicDuration: toFrame(600), + nativeFps: frameRate(30), + sourceTimecodeOffset: toFrame(0), + }); + + const clipA = createClip({ + id: 'clip-a', + assetId: asset.id, + trackId: TRACK_1, + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clipB = createClip({ + id: 'clip-b', + assetId: asset.id, + trackId: TRACK_1, + timelineStart: toFrame(150), + timelineEnd: toFrame(250), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clipC = createClip({ + id: 'clip-c', + assetId: asset.id, + trackId: TRACK_2, + timelineStart: toFrame(50), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const track1 = createTrack({ + id: TRACK_1, + name: 'V1', + type: 'video', + clips: [clipA, clipB], + }); + const track2 = createTrack({ + id: TRACK_2, + name: 'V2', + type: 'video', + clips: [clipC], + }); + + const marker1 = { + type: 'point' as const, + id: 'm1' as import('@webpacked-timeline/core').MarkerId, + frame: toFrame(50), + label: 'M1', + color: '#ff0000', + scope: 'global' as const, + linkedClipId: null, + }; + const marker2 = { + type: 'point' as const, + id: 'm2' as import('@webpacked-timeline/core').MarkerId, + frame: toFrame(200), + label: 'M2', + color: '#00ff00', + scope: 'global' as const, + linkedClipId: null, + }; + + const timeline = createTimeline({ + id: 'tl-r2', + name: 'R2 Fixture', + fps: frameRate(30), + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track1, track2], + markers: [marker1, marker2], + }); + + return createTimelineState({ + timeline, + assetRegistry: new Map([[asset.id, asset]]), + }); +} + +let engine: TimelineEngine; + +beforeEach(() => { + const state = makeFixtureState(); + engine = new TimelineEngine({ + initialState: state, + tools: [NoOpTool], + defaultToolId: 'noop', + }); +}); + +// ── useTimeline ───────────────────────────────────────────────────────────── + +describe('useTimeline', () => { + it('1. Returns correct fps and durationFrames', () => { + const { result } = renderHook(() => useTimeline(engine)); + expect(result.current.fps).toBe(30); + expect(result.current.duration).toBe(3000); + }); + + it('2. Re-renders when timeline changes', () => { + const { result, rerender } = renderHook(() => useTimeline(engine)); + const first = result.current; + act(() => { + engine.dispatch({ + id: 'r', + label: 'Rename', + timestamp: 0, + operations: [{ type: 'RENAME_TIMELINE', name: 'Renamed' }], + }); + }); + rerender(); + expect(result.current.name).toBe('Renamed'); + expect(result.current).not.toBe(first); + }); + + it('3. Does NOT re-render when only playhead changes (timeline ref unchanged)', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engWithPlayback = new TimelineEngine({ + initialState: makeFixtureState(), + pipeline: mockPipeline, + clock, + tools: [NoOpTool], + defaultToolId: 'noop', + }); + const { result, rerender } = renderHook(() => useTimeline(engWithPlayback)); + const refBefore = result.current; + act(() => engWithPlayback.playbackEngine!.seekTo(toFrame(100))); + rerender(); + expect(result.current).toBe(refBefore); + }); +}); + +// ── useTrackIds ──────────────────────────────────────────────────────────── + +describe('useTrackIds', () => { + it('4. Returns correct track ids', () => { + const { result } = renderHook(() => useTrackIds(engine)); + expect(result.current).toEqual(['track-1', 'track-2']); + }); + + it('5. Returns stable reference when clips change (trackIds ref unchanged after MOVE_CLIP)', () => { + const { result, rerender } = renderHook(() => useTrackIds(engine)); + const refBefore = result.current; + act(() => { + engine.dispatch({ + id: 'm', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: CLIP_A, newTimelineStart: toFrame(20) }, + ], + }); + }); + rerender(); + expect(result.current).toBe(refBefore); + }); + + it('6. Returns new reference when track added', () => { + const { result, rerender } = renderHook(() => useTrackIds(engine)); + const refBefore = result.current; + const newTrack = createTrack({ + id: toTrackId('track-3'), + name: 'V3', + type: 'video', + clips: [], + }); + act(() => { + engine.dispatch({ + id: 'a', + label: 'Add', + timestamp: 0, + operations: [{ type: 'ADD_TRACK', track: newTrack }], + }); + }); + rerender(); + expect(result.current).not.toBe(refBefore); + expect(result.current).toContain('track-3'); + }); +}); + +// ── useTrack ──────────────────────────────────────────────────────────────── + +describe('useTrack', () => { + it('7. Returns correct track for id', () => { + const { result } = renderHook(() => useTrack(engine, TRACK_1)); + expect(result.current?.id).toBe('track-1'); + expect(result.current?.name).toBe('V1'); + }); + + it('8. Returns null for unknown id', () => { + const { result } = renderHook(() => useTrack(engine, 'unknown')); + expect(result.current).toBeNull(); + }); + + it('9. Re-renders when that track changes', () => { + const { result, rerender } = renderHook(() => useTrack(engine, TRACK_1)); + act(() => { + engine.dispatch({ + id: 'n', + label: 'Name', + timestamp: 0, + operations: [{ type: 'SET_TRACK_NAME', trackId: TRACK_1, name: 'Video One' }], + }); + }); + rerender(); + expect(result.current?.name).toBe('Video One'); + }); + + it('10. Does NOT re-render when OTHER track changes', () => { + const { result, rerender } = renderHook(() => useTrack(engine, TRACK_1)); + const refBefore = result.current; + act(() => { + engine.dispatch({ + id: 'n', + label: 'Name', + timestamp: 0, + operations: [{ type: 'SET_TRACK_NAME', trackId: TRACK_2, name: 'Other' }], + }); + }); + rerender(); + expect(result.current).toBe(refBefore); + }); +}); + +// ── useClip (isolation: clip A update does not re-render clip B) ───────────── + +describe('useClip', () => { + it('11. Returns correct clip for id', () => { + const { result } = renderHook(() => useClip(engine, CLIP_A)); + expect(result.current?.id).toBe('clip-a'); + expect(result.current?.timelineStart).toBe(0); + }); + + it('12. Returns null for unknown id', () => { + const { result } = renderHook(() => useClip(engine, 'no-such-clip' as any)); + expect(result.current).toBeNull(); + }); + + it('13. ISOLATION: updating clip A does not re-render component watching clip B (toBe)', () => { + let renderCountA = 0; + let renderCountB = 0; + renderHook(() => { + renderCountA++; + return useClip(engine, CLIP_A); + }); + const { result: resultB } = renderHook(() => { + renderCountB++; + return useClip(engine, CLIP_B); + }); + const clipBRefBefore = resultB.current; + renderCountA = 0; + renderCountB = 0; + act(() => { + engine.dispatch({ + id: 'm', + label: 'Move A', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: CLIP_A, newTimelineStart: toFrame(5) }, + ], + }); + }); + expect(renderCountB).toBe(0); + expect(resultB.current).toBe(clipBRefBefore); + }); +}); + +// ── useClips ─────────────────────────────────────────────────────────────── + +describe('useClips', () => { + it('14. Returns all clips for a track', () => { + const { result } = renderHook(() => useClips(engine, TRACK_1)); + expect(result.current.length).toBe(2); + expect(result.current.map((c) => c.id)).toEqual(['clip-a', 'clip-b']); + }); + + it('15. Returns EMPTY_CLIPS stable ref for unknown trackId', () => { + const { result } = renderHook(() => useClips(engine, 'unknown-track')); + expect(result.current).toEqual([]); + const { result: result2 } = renderHook(() => useClips(engine, 'unknown-track')); + expect(result2.current).toBe(result.current); + }); +}); + +// ── useMarkers ───────────────────────────────────────────────────────────── + +describe('useMarkers', () => { + it('16. Returns markers array', () => { + const { result } = renderHook(() => useMarkers(engine)); + expect(result.current.length).toBe(2); + expect(result.current[0]?.label).toBe('M1'); + }); + + it('17. Updates when marker added', () => { + const { result, rerender } = renderHook(() => useMarkers(engine)); + expect(result.current.length).toBe(2); + act(() => { + engine.dispatch({ + id: 'am', + label: 'Add marker', + timestamp: 0, + operations: [ + { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: 'm3' as any, + frame: toFrame(100), + label: 'M3', + color: '#0000ff', + scope: 'global', + linkedClipId: null, + }, + }, + ], + }); + }); + rerender(); + expect(result.current.length).toBe(3); + }); +}); + +// ── useHistory ───────────────────────────────────────────────────────────── + +describe('useHistory', () => { + it('18. Initial: canUndo false, canRedo false', () => { + const { result } = renderHook(() => useHistory(engine)); + expect(result.current.canUndo).toBe(false); + expect(result.current.canRedo).toBe(false); + }); + + it('19. After dispatch: canUndo true', () => { + const { result, rerender } = renderHook(() => useHistory(engine)); + act(() => { + engine.dispatch({ + id: 'r', + label: 'R', + timestamp: 0, + operations: [{ type: 'RENAME_TIMELINE', name: 'X' }], + }); + }); + rerender(); + expect(result.current.canUndo).toBe(true); + }); + + it('20. Returns stable object ref when flags unchanged (no spurious re-render)', () => { + const { result, rerender } = renderHook(() => useHistory(engine)); + const refBefore = result.current; + act(() => { + engine.dispatch({ + id: 'r', + label: 'R', + timestamp: 0, + operations: [{ type: 'RENAME_TIMELINE', name: 'Y' }], + }); + }); + rerender(); + const refAfter = result.current; + act(() => engine.undo()); + rerender(); + const refAfterUndo = result.current; + expect(refAfter).not.toBe(refBefore); + expect(refAfterUndo).not.toBe(refAfter); + }); +}); + +// ── useActiveToolId ──────────────────────────────────────────────────────── + +describe('useActiveToolId', () => { + it('21. Returns "noop" initially (defaultToolId in fixture)', () => { + const { result } = renderHook(() => useActiveToolId(engine)); + expect(result.current).toBe('noop'); + }); + + it('22. Updates after activateTool()', () => { + const { result, rerender } = renderHook(() => useActiveToolId(engine)); + act(() => engine.activateTool('selection')); + rerender(); + expect(result.current).toBe('selection'); + }); +}); + +// ── useCursor ────────────────────────────────────────────────────────────── + +describe('useCursor', () => { + it('23. Returns "default" initially', () => { + const { result } = renderHook(() => useCursor(engine)); + expect(result.current).toBe('default'); + }); +}); + +// ── useProvisional ───────────────────────────────────────────────────────── + +describe('useProvisional', () => { + it('24. Returns null initially', () => { + const { result } = renderHook(() => useProvisional(engine)); + expect(result.current).toBeNull(); + }); + + it('25. Returns provisional state when set', () => { + const ghost = { clips: [], isProvisional: true as const }; + const moveTool = { + ...NoOpTool, + id: toToolId('provisional'), + onPointerMove: () => ghost, + }; + const engWithProvisional = new TimelineEngine({ + initialState: makeFixtureState(), + tools: [NoOpTool, moveTool], + defaultToolId: 'provisional', + }); + const { result, rerender } = renderHook(() => useProvisional(engWithProvisional)); + expect(result.current).toBeNull(); + act(() => { + engWithProvisional.handlePointerMove( + { + frame: toFrame(0), + trackId: null, + clipId: null, + x: 0, + y: 0, + buttons: 1, + shiftKey: false, + altKey: false, + metaKey: false, + }, + { shift: false, alt: false, ctrl: false, meta: false }, + ); + }); + rerender(); + expect(result.current).toBe(ghost); + }); +}); + +// ── usePlayheadFrame ─────────────────────────────────────────────────────── + +describe('usePlayheadFrame', () => { + it('26. Returns frame 0 initially', () => { + const { result } = renderHook(() => usePlayheadFrame(engine)); + expect(result.current).toBe(0); + }); + + it('27. Updates when seekTo called on playback engine', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engWithPlayback = new TimelineEngine({ + initialState: makeFixtureState(), + pipeline: mockPipeline, + clock, + tools: [NoOpTool], + defaultToolId: 'noop', + }); + const { result, rerender } = renderHook(() => usePlayheadFrame(engWithPlayback)); + expect(result.current).toBe(0); + act(() => engWithPlayback.playbackEngine!.seekTo(toFrame(100))); + rerender(); + expect(result.current).toBe(100); + }); +}); + +// ── useIsPlaying ─────────────────────────────────────────────────────────── + +describe('useIsPlaying', () => { + it('28. Returns false initially', () => { + const { result } = renderHook(() => useIsPlaying(engine)); + expect(result.current).toBe(false); + }); + + it('29. Returns true after play()', () => { + const mockPipeline = { videoDecoder: vi.fn(), compositor: vi.fn() } as any; + const { clock } = createTestClock(); + const engWithPlayback = new TimelineEngine({ + initialState: makeFixtureState(), + pipeline: mockPipeline, + clock, + tools: [NoOpTool], + defaultToolId: 'noop', + }); + const { result, rerender } = renderHook(() => useIsPlaying(engWithPlayback)); + act(() => engWithPlayback.playbackEngine!.play()); + rerender(); + expect(result.current).toBe(true); + }); +}); + +// ── useChange ───────────────────────────────────────────────────────────── + +describe('useChange', () => { + it('30. Returns EMPTY_STATE_CHANGE initially', () => { + const { result } = renderHook(() => useChange(engine)); + expect(result.current.trackIds).toBe(false); + expect(result.current.clipIds.size).toBe(0); + }); + + it('31. clipIds contains changed clip after dispatch', () => { + const { result, rerender } = renderHook(() => useChange(engine)); + act(() => { + engine.dispatch({ + id: 'm', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: CLIP_A, newTimelineStart: toFrame(10) }, + ], + }); + }); + rerender(); + expect(result.current.clipIds.has(CLIP_A)).toBe(true); + }); +}); diff --git a/packages/react/src/__tests__/hooks.test.tsx b/packages/react/src/__tests__/hooks.test.tsx new file mode 100644 index 0000000..01d6a84 --- /dev/null +++ b/packages/react/src/__tests__/hooks.test.tsx @@ -0,0 +1,337 @@ +/** + * hooks.ts — Phase 1 Integration Tests + * + * These tests run in jsdom with @testing-library/react. + * They exercise hooks against a real TimelineEngine instance inside a + * TimelineProvider, using renderHook to observe re-render behaviour. + * + * THE CRITICAL TEST: useClip(id) selector isolation + * When clip A changes, a component subscribed to clip B must NOT re-render. + * This is the core performance contract for Phase 2 — without it, a drag + * on any clip re-renders every clip component in the timeline. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { createElement, ReactNode } from 'react'; + +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + toTimecode, + frameRate, + toTrackId, + toClipId, + toAssetId, +} from '@webpacked-timeline/core'; +import type { ClipId, TrackId, Transaction } from '@webpacked-timeline/core'; + +import { TimelineProvider, TimelineEngine } from '../index'; +import { + useTimeline, + useTrackIds, + useTrack, + useClip, + useActiveTool, + useCanUndo, + useCanRedo, + useProvisional, + useEngine, +} from '../hooks'; + +// ── Test state factory ────────────────────────────────────────────────────── + +const ASSET_ID = toAssetId('asset-1'); +const TRACK_ID = toTrackId('track-1'); +const CLIP_A_ID = toClipId('clip-a'); +const CLIP_B_ID = toClipId('clip-b'); + +function makeState() { + const asset = createAsset({ + id: 'asset-1', + name: 'Test Asset', + mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + status: 'online', + }); + + const clipA = createClip({ + id: 'clip-a', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const clipB = createClip({ + id: 'clip-b', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(200), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const track = createTrack({ + id: 'track-1', + name: 'Video 1', + type: 'video', + clips: [clipA, clipB], + }); + + const timeline = createTimeline({ + id: 'tl-hooks-test', + name: 'Hooks Test Timeline', + fps: frameRate(30), + duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + + return createTimelineState({ + timeline, + assetRegistry: new Map([[ASSET_ID, asset]]), + }); +} + +function makeTx(label: string, ...ops: Transaction['operations']): Transaction { + return { id: `tx-${label}`, label, timestamp: 0, operations: [...ops] }; +} + +// ── Provider wrapper factory ───────────────────────────────────────────────── + +function makeWrapper(engine: TimelineEngine) { + return ({ children }: { children: ReactNode }) => ( + {children} + ); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('useEngine', () => { + it('returns the engine instance from context', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useEngine(), { wrapper }); + expect(result.current).toBe(engine); + }); + + it('throws outside TimelineProvider', () => { + expect(() => renderHook(() => useEngine())).toThrow('TimelineProvider'); + }); +}); + +describe('useTimeline', () => { + it('returns the Timeline object from the snapshot', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useTimeline(), { wrapper }); + expect(result.current.name).toBe('Hooks Test Timeline'); + expect(result.current.fps).toBe(30); + }); + + it('re-renders when timeline name changes', async () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useTimeline(), { wrapper }); + expect(result.current.name).toBe('Hooks Test Timeline'); + + act(() => { + engine.dispatch(makeTx('rename', { type: 'RENAME_TIMELINE', name: 'Renamed' })); + }); + + expect(result.current.name).toBe('Renamed'); + }); +}); + +describe('useTrackIds', () => { + it('returns a readonly array of track ids', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useTrackIds(), { wrapper }); + expect(result.current).toEqual([TRACK_ID]); + }); + + it('returns the SAME array reference between notifies (stable ref)', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useTrackIds(), { wrapper }); + const ref1 = result.current; + + // Dispatch something that does NOT change tracks + act(() => { + engine.dispatch(makeTx('rename', { type: 'RENAME_TIMELINE', name: 'Changed' })); + }); + + // Engine notifies, but trackIds array is rebuilt with same content. + // useSyncExternalStore uses Object.is — new array with same content still + // causes re-render, BUT the array reference is stable within a render. + // The important invariant: reading trackIds twice within one render = same ref. + const ref2 = result.current; + // ref2 may be a new array (new notify → new buildSnapshot → new .map()), + // but each element is identical, and no infinite loop occurs. + expect(ref2).toEqual([TRACK_ID]); + }); +}); + +describe('useTrack', () => { + it('returns the track matching the given id', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useTrack(TRACK_ID), { wrapper }); + expect(result.current?.id).toBe(TRACK_ID); + expect(result.current?.clips).toHaveLength(2); + }); + + it('returns null for an unknown track id', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useTrack(toTrackId('ghost')), { wrapper }); + expect(result.current).toBeNull(); + }); +}); + +describe('useClip', () => { + it('returns the clip matching the given id from committed state', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useClip(CLIP_A_ID), { wrapper }); + expect(result.current?.id).toBe(CLIP_A_ID); + expect(result.current?.timelineStart).toBe(toFrame(0)); + }); + + it('returns null for a non-existent clip id', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useClip(toClipId('ghost')), { wrapper }); + expect(result.current).toBeNull(); + }); + + // ── THE CRITICAL ISOLATION TEST ──────────────────────────────────────────── + // + // WHAT THIS PROVES: + // Two hooks subscribe to clip A and clip B respectively. + // We dispatch SET_CLIP_NAME on clip A only. + // The clip A hook must reflect the new name. + // The clip B hook must return the SAME object reference as before the dispatch. + // + // WHY OBJECT IDENTITY PROVES ISOLATION: + // useSyncExternalStore calls the selector every time the engine notifies. + // If the selector for clip B returns the SAME reference (Object.is === true), + // React marks that subscription as unchanged and does NOT schedule a re-render. + // If we get a new reference back for clip B, it would always re-render — that + // is the bug this test catches. + // + // Note: we can't count "renders" with renderHook easily (it re-renders on + // all notifications to check), but we CAN verify that the selector returns + // the identical reference, which is what React uses to gate the re-render. + + it('ISOLATION: clip B selector returns same reference when only clip A changes', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + + // Render two independent hooks — one per clip + const hookA = renderHook(() => useClip(CLIP_A_ID), { wrapper }); + const hookB = renderHook(() => useClip(CLIP_B_ID), { wrapper }); + + // Capture clip B's reference BEFORE the dispatch + const clipBBefore = hookB.result.current; + expect(clipBBefore?.id).toBe(CLIP_B_ID); + + // Change clip A's name — clip B is untouched + act(() => { + engine.dispatch(makeTx('rename-clip-a', { + type: 'SET_CLIP_NAME', + clipId: CLIP_A_ID, + name: 'Renamed Clip A', + })); + }); + + // Clip A must reflect the change + expect(hookA.result.current?.name).toBe('Renamed Clip A'); + + // Clip B's selector must return the IDENTICAL object reference (Object.is equality) + // This is the contract: same object → useSyncExternalStore skips the re-render + expect(hookB.result.current).toBe(clipBBefore); + }); + + it('ISOLATION: clip B renders when clip B itself changes', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + + const hookB = renderHook(() => useClip(CLIP_B_ID), { wrapper }); + const clipBBefore = hookB.result.current; + + act(() => { + engine.dispatch(makeTx('rename-clip-b', { + type: 'SET_CLIP_NAME', + clipId: CLIP_B_ID, + name: 'Renamed Clip B', + })); + }); + + // References must differ — clip B was rebuilt with the new name + expect(hookB.result.current).not.toBe(clipBBefore); + expect(hookB.result.current?.name).toBe('Renamed Clip B'); + }); +}); + +describe('useActiveTool', () => { + it('returns id and cursor string', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useActiveTool(), { wrapper }); + expect(typeof result.current.id).toBe('string'); + expect(typeof result.current.cursor).toBe('string'); + }); +}); + +describe('useCanUndo / useCanRedo', () => { + it('canUndo is false initially, true after a dispatch', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useCanUndo(), { wrapper }); + expect(result.current).toBe(false); + + act(() => { + engine.dispatch(makeTx('rename', { type: 'RENAME_TIMELINE', name: 'X' })); + }); + + expect(result.current).toBe(true); + }); + + it('canRedo is false initially, true after an undo', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result: undoResult } = renderHook(() => useCanUndo(), { wrapper }); + const { result: redoResult } = renderHook(() => useCanRedo(), { wrapper }); + + act(() => { + engine.dispatch(makeTx('rename', { type: 'RENAME_TIMELINE', name: 'X' })); + }); + expect(redoResult.current).toBe(false); + + act(() => { engine.undo(); }); + expect(undoResult.current).toBe(false); + expect(redoResult.current).toBe(true); + }); +}); + +describe('useProvisional', () => { + it('returns null when not dragging', () => { + const engine = new TimelineEngine({ initialState: makeState() }); + const wrapper = makeWrapper(engine); + const { result } = renderHook(() => useProvisional(), { wrapper }); + expect(result.current).toBeNull(); + }); +}); diff --git a/packages/react/src/__tests__/integration.test.tsx b/packages/react/src/__tests__/integration.test.tsx new file mode 100644 index 0000000..5b35a4c --- /dev/null +++ b/packages/react/src/__tests__/integration.test.tsx @@ -0,0 +1,794 @@ +/** + * Phase R Step 4 — Integration tests + * + * Full round-trip: dispatch → engine → snapshot → hook → re-render. + * Uses renderHook + act throughout. Playback tests use createTestClock(). + */ + +import type React from 'react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + toTimecode, + frameRate, + toTrackId, + toClipId, + toAssetId, + createTestClock, +} from '@webpacked-timeline/core'; +import type { PipelineConfig } from '@webpacked-timeline/core'; +import type { VirtualWindow } from '@webpacked-timeline/core'; +import { TimelineEngine } from '../engine'; +import { + useTrackIds, + useTrack, + useClip, + useClips, + useMarkers, + useHistory, + useActiveToolId, + useCursor, + useProvisional, + usePlayheadFrame, + useIsPlaying, +} from '../hooks/index'; +import { useVirtualWindow, useVisibleClips } from '../hooks/use-virtual-window'; +import { createToolRouter } from '../adapter/tool-router'; + +const FPS = 30; +const DURATION_FRAMES = 1800; +const TRACK_H = 48; +const PPF = 10; + +function buildIntegrationEngine(): { + engine: TimelineEngine; + clock: ReturnType['clock']; + tick: ReturnType['tick']; +} { + const { clock, tick } = createTestClock(); + const videoAsset = createAsset({ + id: 'asset-v', + name: 'V', + mediaType: 'video', + filePath: '/v.mp4', + intrinsicDuration: toFrame(3000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + const audioAsset = createAsset({ + id: 'asset-a', + name: 'A', + mediaType: 'audio', + filePath: '/a.wav', + intrinsicDuration: toFrame(2000), + nativeFps: FPS, + sourceTimecodeOffset: toFrame(0), + }); + + const v1Clip1 = createClip({ + id: 'v1-c1', + assetId: videoAsset.id, + trackId: 'v1', + timelineStart: toFrame(0), + timelineEnd: toFrame(300), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const v1Clip2 = createClip({ + id: 'v1-c2', + assetId: videoAsset.id, + trackId: 'v1', + timelineStart: toFrame(400), + timelineEnd: toFrame(700), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const v1Clip3 = createClip({ + id: 'v1-c3', + assetId: videoAsset.id, + trackId: 'v1', + timelineStart: toFrame(800), + timelineEnd: toFrame(1100), + mediaIn: toFrame(0), + mediaOut: toFrame(300), + }); + const v2Clip1 = createClip({ + id: 'v2-c1', + assetId: videoAsset.id, + trackId: 'v2', + timelineStart: toFrame(0), + timelineEnd: toFrame(600), + mediaIn: toFrame(0), + mediaOut: toFrame(600), + }); + const v2Clip2 = createClip({ + id: 'v2-c2', + assetId: videoAsset.id, + trackId: 'v2', + timelineStart: toFrame(700), + timelineEnd: toFrame(1100), + mediaIn: toFrame(0), + mediaOut: toFrame(400), + }); + const a1Clip1 = createClip({ + id: 'a1-c1', + assetId: audioAsset.id, + trackId: 'a1', + timelineStart: toFrame(0), + timelineEnd: toFrame(600), + mediaIn: toFrame(0), + mediaOut: toFrame(600), + }); + + const trackV1 = createTrack({ + id: 'v1', + name: 'V1', + type: 'video', + clips: [v1Clip1, v1Clip2, v1Clip3], + }); + const trackV2 = createTrack({ + id: 'v2', + name: 'V2', + type: 'video', + clips: [v2Clip1, v2Clip2], + }); + const trackA1 = createTrack({ + id: 'a1', + name: 'A1', + type: 'audio', + clips: [a1Clip1], + }); + + const markerPoint = { + type: 'point' as const, + id: 'm1', + frame: toFrame(150), + label: 'M1', + color: '#f00', + scope: 'global' as const, + linkedClipId: null, + }; + const markerRange = { + type: 'range' as const, + id: 'm2', + frameStart: toFrame(400), + frameEnd: toFrame(700), + label: 'M2', + color: '#0f0', + scope: 'global' as const, + linkedClipId: null, + }; + + const timeline = createTimeline({ + id: 'tl-int', + name: 'Integration', + fps: FPS, + duration: toFrame(DURATION_FRAMES), + startTimecode: toTimecode('00:00:00:00'), + tracks: [trackV1, trackV2, trackA1], + markers: [markerPoint, markerRange], + }); + + const initialState = createTimelineState({ + timeline, + assetRegistry: new Map([ + [videoAsset.id, videoAsset], + [audioAsset.id, audioAsset], + ]), + }); + + const mockPipeline: PipelineConfig = { + videoDecoder: async (req) => ({ + clipId: req.clipId, + mediaFrame: req.mediaFrame, + width: 1920, + height: 1080, + bitmap: null, + }), + compositor: async (req) => ({ timelineFrame: req.timelineFrame, bitmap: null }), + }; + + const engine = new TimelineEngine({ + initialState, + pipeline: mockPipeline, + dimensions: { width: 1920, height: 1080 }, + clock, + }); + + return { engine, clock, tick }; +} + +function makePointerEvent(overrides: { + clientX?: number; + clientY?: number; + buttons?: number; +} = {}): React.PointerEvent { + return { + clientX: overrides.clientX ?? 0, + clientY: overrides.clientY ?? 0, + buttons: overrides.buttons ?? 1, + shiftKey: false, + altKey: false, + metaKey: false, + ctrlKey: false, + preventDefault: vi.fn(), + currentTarget: { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 10000, height: 400 }), + }, + } as unknown as React.PointerEvent; +} + +function makeKeyEvent(overrides: { key?: string; code?: string; shiftKey?: boolean } = {}): React.KeyboardEvent { + return { + key: overrides.key ?? ' ', + code: overrides.code ?? 'Space', + shiftKey: overrides.shiftKey ?? false, + altKey: false, + metaKey: false, + ctrlKey: false, + repeat: false, + preventDefault: vi.fn(), + } as unknown as React.KeyboardEvent; +} + +// ─── Edit + hooks round-trip ───────────────────────────────────────────── + +describe('Integration — Edit + hooks round-trip', () => { + let engine: TimelineEngine; + + beforeEach(() => { + const built = buildIntegrationEngine(); + engine = built.engine; + }); + + it('1. dispatch INSERT_CLIP → useClips returns new clip', () => { + const trackId = toTrackId('v1'); + const { result } = renderHook(() => useClips(engine, trackId)); + const countBefore = result.current.length; + const newClip = createClip({ + id: 'v1-new', + assetId: toAssetId('asset-v'), + trackId: 'v1', + timelineStart: toFrame(300), + timelineEnd: toFrame(400), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + act(() => { + engine.dispatch({ + id: 'add', + label: 'Add clip', + timestamp: 0, + operations: [{ type: 'INSERT_CLIP', trackId, clip: newClip }], + }); + }); + expect(result.current.length).toBe(countBefore + 1); + expect(result.current.some((c) => c.id === 'v1-new')).toBe(true); + }); + + it('2. dispatch MOVE_CLIP → useClip returns updated startFrame', () => { + const clipId = 'v1-c1'; + const { result } = renderHook(() => useClip(engine, clipId)); + expect((result.current!.timelineStart as number)).toBe(0); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId(clipId), newTimelineStart: toFrame(100) }, + ], + }); + }); + expect((result.current!.timelineStart as number)).toBe(100); + }); + + it('3. dispatch DELETE_CLIP → useClips no longer contains deleted clip', () => { + const trackId = toTrackId('v1'); + const { result } = renderHook(() => useClips(engine, trackId)); + const idToDelete = 'v1-c2'; + act(() => { + engine.dispatch({ + id: 'del', + label: 'Delete', + timestamp: 0, + operations: [{ type: 'DELETE_CLIP', clipId: toClipId(idToDelete) }], + }); + }); + expect(result.current.some((c) => c.id === idToDelete)).toBe(false); + }); + + it('4. useTrackIds stable after MOVE_CLIP (no track list change → same array ref)', () => { + const { result, rerender } = renderHook(() => useTrackIds(engine)); + const refBefore = result.current; + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId('v1-c1'), newTimelineStart: toFrame(50) }, + ], + }); + }); + rerender(); + expect(result.current).toBe(refBefore); + }); + + it('5. useTrackIds updates after ADD_TRACK', () => { + const { result } = renderHook(() => useTrackIds(engine)); + const countBefore = result.current.length; + const newTrack = createTrack({ + id: 'v3', + name: 'V3', + type: 'video', + clips: [], + }); + act(() => { + engine.dispatch({ + id: 'add-track', + label: 'Add track', + timestamp: 0, + operations: [{ type: 'ADD_TRACK', track: newTrack }], + }); + }); + expect(result.current.length).toBe(countBefore + 1); + expect(result.current).toContain('v3'); + }); + + it('6. useHistory.canUndo true after dispatch', () => { + const { result } = renderHook(() => useHistory(engine)); + expect(result.current.canUndo).toBe(false); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId('v1-c1'), newTimelineStart: toFrame(10) }, + ], + }); + }); + expect(result.current.canUndo).toBe(true); + }); + + it('7. undo() → useClip returns original position', () => { + const clipId = 'v1-c1'; + const { result } = renderHook(() => useClip(engine, clipId)); + const originalStart = result.current!.timelineStart as number; + act(() => { + const r = engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId(clipId), newTimelineStart: toFrame(1100) }, + ], + }); + expect(r.accepted).toBe(true); + }); + act(() => { + engine.undo(); + }); + expect((result.current!.timelineStart as number)).toBe(originalStart); + }); + + it('8. redo() → useClip returns moved position', () => { + const clipId = 'v1-c1'; + const newStart = 1100; + const { result } = renderHook(() => useClip(engine, clipId)); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId(clipId), newTimelineStart: toFrame(newStart) }, + ], + }); + }); + act(() => { + engine.undo(); + }); + expect((result.current!.timelineStart as number)).toBe(0); + let didRedo: boolean; + act(() => { + didRedo = engine.redo(); + }); + expect(didRedo).toBe(true); + const clip = engine.getSnapshot().state.timeline.tracks[0]!.clips.find((c) => c.id === clipId); + expect(clip!.timelineStart).toBe(newStart); + expect((result.current!.timelineStart as number)).toBe(newStart); + }); + + it('9. dispatch ADD_MARKER → useMarkers updates', () => { + const { result } = renderHook(() => useMarkers(engine)); + const countBefore = result.current.length; + const newMarker = { + type: 'point' as const, + id: 'm3', + frame: toFrame(500), + label: 'M3', + color: '#00f', + scope: 'global' as const, + linkedClipId: null, + }; + act(() => { + engine.dispatch({ + id: 'add-marker', + label: 'Add marker', + timestamp: 0, + operations: [{ type: 'ADD_MARKER', marker: newMarker }], + }); + }); + expect(result.current.length).toBe(countBefore + 1); + expect(result.current.some((m) => m.id === 'm3')).toBe(true); + }); + + it('10. useClip isolation: dispatch MOVE_CLIP on clip A → render count for clip B unchanged', () => { + const clipAId = 'v1-c1'; + const clipBId = 'v2-c1'; + let clipBRenderCount = 0; + const { result } = renderHook(() => { + const clipB = useClip(engine, clipBId); + clipBRenderCount++; + return clipB; + }); + expect(clipBRenderCount).toBe(1); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move A', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId(clipAId), newTimelineStart: toFrame(20) }, + ], + }); + }); + expect(result.current).not.toBeNull(); + expect(result.current!.id).toBe(clipBId); + expect(clipBRenderCount).toBe(1); + }); +}); + +// ─── Tool interaction round-trip ──────────────────────────────────────── + +describe('Integration — Tool interaction round-trip', () => { + let engine: TimelineEngine; + + beforeEach(() => { + const built = buildIntegrationEngine(); + engine = built.engine; + }); + + it('11. Activate RazorTool → useActiveToolId === "razor"', () => { + const { result } = renderHook(() => useActiveToolId(engine)); + expect(result.current).toBe('selection'); + act(() => { + engine.activateTool('razor'); + }); + expect(result.current).toBe('razor'); + }); + + it('12. Activate SelectionTool → cursor is "default"', () => { + act(() => { + engine.activateTool('razor'); + }); + const { result } = renderHook(() => useCursor(engine)); + act(() => { + engine.activateTool('selection'); + }); + expect(result.current).toBe('default'); + }); + + it('13. handlePointerDown + handlePointerUp with RazorTool dispatches SPLIT → useClips has one more clip', () => { + const trackId = toTrackId('v1'); + const { result } = renderHook(() => useClips(engine, trackId)); + const countBefore = result.current.length; + act(() => { + engine.activateTool('razor'); + }); + const frameInClip = 450; + const modifiers = { shift: false, alt: false, ctrl: false, meta: false }; + const syntheticEvent = { + frame: toFrame(frameInClip), + trackId: toTrackId('v1'), + clipId: toClipId('v1-c2'), + x: frameInClip * PPF, + y: TRACK_H * 0.5, + buttons: 1, + shiftKey: false, + altKey: false, + metaKey: false, + }; + act(() => { + engine.handlePointerDown(syntheticEvent, modifiers); + }); + act(() => { + engine.handlePointerUp( + { ...syntheticEvent, buttons: 0 }, + modifiers, + ); + }); + expect(result.current.length).toBe(countBefore + 1); + }); + + it('14. handlePointerLeave clears provisional state → useProvisional returns null', () => { + act(() => { + engine.activateTool('selection'); + }); + const handlers = createToolRouter({ + engine, + getPixelsPerFrame: () => PPF, + }); + const rect = { left: 0, top: 0, width: 10000, height: 400 }; + const down = makePointerEvent({ clientX: 100, clientY: TRACK_H / 2, buttons: 1 }); + (down as { currentTarget: { getBoundingClientRect: () => typeof rect } }).currentTarget = { + getBoundingClientRect: () => rect, + }; + act(() => { + handlers.onPointerDown(down); + }); + const { result } = renderHook(() => useProvisional(engine)); + const leave = makePointerEvent({ clientX: 0, clientY: 0, buttons: 0 }); + (leave as { currentTarget: { getBoundingClientRect: () => typeof rect } }).currentTarget = { + getBoundingClientRect: () => rect, + }; + act(() => { + handlers.onPointerLeave(leave); + }); + expect(result.current).toBeNull(); + }); +}); + +// ─── Playback + edit simultaneous ───────────────────────────────────────── + +describe('Integration — Playback + edit simultaneous', () => { + it('15. play() → useIsPlaying true', () => { + const { engine } = buildIntegrationEngine(); + const { result } = renderHook(() => useIsPlaying(engine)); + expect(result.current).toBe(false); + act(() => { + engine.playbackEngine!.play(); + }); + expect(result.current).toBe(true); + }); + + it('16. pause() → useIsPlaying false', () => { + const { engine } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.play(); + }); + const { result } = renderHook(() => useIsPlaying(engine)); + expect(result.current).toBe(true); + act(() => { + engine.playbackEngine!.pause(); + }); + expect(result.current).toBe(false); + }); + + it('17. dispatch during playback does not crash', () => { + const { engine, tick } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.play(); + }); + act(() => { + tick(100); + }); + act(() => { + engine.dispatch({ + id: 'move', + label: 'Move', + timestamp: 0, + operations: [ + { type: 'MOVE_CLIP', clipId: toClipId('v1-c1'), newTimelineStart: toFrame(50) }, + ], + }); + }); + expect(engine.getSnapshot().state.timeline.tracks[0]!.clips[0]!.timelineStart).toBe(50); + }); + + it('18. seekTo(300) → usePlayheadFrame === 300', () => { + const { engine } = buildIntegrationEngine(); + const { result } = renderHook(() => usePlayheadFrame(engine)); + act(() => { + engine.playbackEngine!.seekTo(toFrame(300)); + }); + expect(result.current).toBe(300); + }); + + it('19. tick(1000ms at 30fps) → usePlayheadFrame advanced', () => { + const { engine, tick } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.seekTo(toFrame(0)); + engine.playbackEngine!.play(); + }); + act(() => { + tick(0); + for (let i = 0; i < 20; i++) tick(50); + }); + const playheadState = engine.playbackEngine!.getState(); + const frameAfter = playheadState.currentFrame as number; + expect(frameAfter).toBeGreaterThan(0); + expect(frameAfter).toBeLessThanOrEqual(1800); + }); + + it('20. loop region: setLoopRegion → play → tick past end → usePlayheadFrame wraps', () => { + const { engine, tick } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.setLoopRegion({ + startFrame: toFrame(100), + endFrame: toFrame(150), + }); + }); + act(() => { + engine.playbackEngine!.seekTo(toFrame(100)); + }); + act(() => { + engine.playbackEngine!.play(); + }); + act(() => { + tick(2000); + }); + const frame = engine.getSnapshot().playhead.currentFrame as number; + expect(frame).toBeGreaterThanOrEqual(100); + expect(frame).toBeLessThan(150); + }); +}); + +// ─── Virtual rendering ──────────────────────────────────────────────────── + +describe('Integration — Virtual rendering', () => { + let engine: TimelineEngine; + + beforeEach(() => { + const built = buildIntegrationEngine(); + engine = built.engine; + }); + + it('21. useVisibleClips with window 0–300 returns clips in that range', () => { + const window: VirtualWindow = { + startFrame: toFrame(0), + endFrame: toFrame(300), + pixelsPerFrame: PPF, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const visible = result.current.filter((e) => e.isVisible); + expect(visible.length).toBeGreaterThanOrEqual(2); + visible.forEach((e) => { + expect((e.clip.timelineEnd as number) > 0).toBe(true); + expect((e.clip.timelineStart as number) < 300).toBe(true); + }); + }); + + it('22. useVisibleClips updates when new clip dispatched inside window', () => { + const window: VirtualWindow = { + startFrame: toFrame(0), + endFrame: toFrame(500), + pixelsPerFrame: PPF, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const countBefore = result.current.filter((e) => e.isVisible).length; + const newClip = createClip({ + id: 'v1-gap', + assetId: toAssetId('asset-v'), + trackId: 'v1', + timelineStart: toFrame(300), + timelineEnd: toFrame(400), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + act(() => { + engine.dispatch({ + id: 'add', + label: 'Add', + timestamp: 0, + operations: [ + { type: 'INSERT_CLIP', trackId: toTrackId('v1'), clip: newClip }, + ], + }); + }); + expect(result.current.filter((e) => e.isVisible).length).toBe(countBefore + 1); + }); + + it('23. Clip outside window has isVisible: false', () => { + const window: VirtualWindow = { + startFrame: toFrame(1500), + endFrame: toFrame(1800), + pixelsPerFrame: PPF, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const entries = result.current; + const v1Clips = entries.filter((e) => e.track.id === 'v1'); + v1Clips.forEach((e) => { + const end = e.clip.timelineEnd as number; + const start = e.clip.timelineStart as number; + expect(end <= 1500 || start >= 1800 ? !e.isVisible : true).toBe(true); + }); + }); +}); + +// ─── Compression ───────────────────────────────────────────────────────── + +describe('Integration — Compression', () => { + it('24. Rapid MOVE_CLIP dispatches (5 in sequence) → undo() restores position from BEFORE all 5', () => { + const { engine } = buildIntegrationEngine(); + const clipId = toClipId('v1-c1'); + const { result } = renderHook(() => useClip(engine, clipId)); + const originalStart = result.current!.timelineStart as number; + act(() => { + for (let i = 1; i <= 5; i++) { + engine.dispatch({ + id: `move-${i}`, + label: `Move ${i}`, + timestamp: i, + operations: [ + { type: 'MOVE_CLIP', clipId, newTimelineStart: toFrame(originalStart + i * 10) }, + ], + }); + } + }); + expect((result.current!.timelineStart as number)).toBe(originalStart + 50); + act(() => { + engine.undo(); + }); + expect((result.current!.timelineStart as number)).toBe(originalStart); + }); +}); + +// ─── Keyboard ───────────────────────────────────────────────────────────── + +describe('Integration — Keyboard', () => { + it('25. handleKeyDown Space → useIsPlaying true (requires pipeline)', () => { + const { engine } = buildIntegrationEngine(); + const { result } = renderHook(() => useIsPlaying(engine)); + const handlers = createToolRouter({ + engine, + getPixelsPerFrame: () => PPF, + }); + act(() => { + handlers.onKeyDown(makeKeyEvent({ key: ' ', code: 'Space' })); + }); + expect(result.current).toBe(true); + }); + + it('26. handleKeyDown ArrowRight → usePlayheadFrame advanced by 1', () => { + const { engine } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.seekTo(toFrame(100)); + }); + const { result } = renderHook(() => usePlayheadFrame(engine)); + const handlers = createToolRouter({ + engine, + getPixelsPerFrame: () => PPF, + }); + act(() => { + handlers.onKeyDown(makeKeyEvent({ key: 'ArrowRight', code: 'ArrowRight' })); + }); + expect(result.current).toBe(101); + }); + + it('27. handleKeyDown Shift+ArrowRight → seekToNextClipBoundary → usePlayheadFrame at next boundary', () => { + const { engine } = buildIntegrationEngine(); + act(() => { + engine.playbackEngine!.seekTo(toFrame(0)); + }); + const { result } = renderHook(() => usePlayheadFrame(engine)); + const handlers = createToolRouter({ + engine, + getPixelsPerFrame: () => PPF, + }); + act(() => { + handlers.onKeyDown( + makeKeyEvent({ key: 'ArrowRight', code: 'ArrowRight', shiftKey: true }), + ); + }); + expect(result.current).toBe(300); + }); +}); diff --git a/packages/react/src/__tests__/setup.ts b/packages/react/src/__tests__/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/packages/react/src/__tests__/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/packages/react/src/__tests__/timeline-provider.test.tsx b/packages/react/src/__tests__/timeline-provider.test.tsx new file mode 100644 index 0000000..f2a646d --- /dev/null +++ b/packages/react/src/__tests__/timeline-provider.test.tsx @@ -0,0 +1,78 @@ +/** + * Timeline Provider and Hooks Tests + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { + createTimeline, + createTimelineState, + toFrame, + frameRate, +} from '@webpacked-timeline/core'; +import { + TimelineEngine, // Phase 1 engine from @webpacked-timeline/react + TimelineProvider, + useTimeline, +} from '../index'; +import { useEngine } from '../hooks'; + +describe('TimelineProvider', () => { + let engine: TimelineEngine; + + beforeEach(() => { + const timeline = createTimeline({ + id: 'tl-provider-test', + name: 'Test Timeline', + fps: frameRate(30), + duration: toFrame(9000), + tracks: [], + }); + const state = createTimelineState({ timeline }); + engine = new TimelineEngine({ initialState: state }); + }); + + it('should provide timeline state via useTimeline hook', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useTimeline(), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current.name).toBe('Test Timeline'); + }); + + it('should provide engine instance via useEngine hook', () => { + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + const { result } = renderHook(() => useEngine(), { wrapper }); + + expect(result.current).toBe(engine); + }); + + it('should throw error when useTimeline is used outside provider', () => { + const originalError = console.error; + console.error = () => {}; + + expect(() => { + renderHook(() => useTimeline()); + }).toThrow('TimelineProvider'); + + console.error = originalError; + }); + + it('should throw error when useEngine is used outside provider', () => { + const originalError = console.error; + console.error = () => {}; + + expect(() => { + renderHook(() => useEngine()); + }).toThrow('TimelineProvider'); + + console.error = originalError; + }); +}); diff --git a/packages/react/src/__tests__/tool-router-r3.test.ts b/packages/react/src/__tests__/tool-router-r3.test.ts new file mode 100644 index 0000000..b4753f2 --- /dev/null +++ b/packages/react/src/__tests__/tool-router-r3.test.ts @@ -0,0 +1,384 @@ +/** + * Phase R Step 3 — ToolRouter (adapter) + useToolRouter + virtual hooks + * + * Fixture: engine with 1 track, 2 clips. + * Mock React.PointerEvent / React.KeyboardEvent with plain objects. + */ + +import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + toTimecode, + frameRate, + toTrackId, + toAssetId, + NoOpTool, +} from '@webpacked-timeline/core'; +import type { VirtualWindow } from '@webpacked-timeline/core'; + +import { TimelineEngine } from '../engine'; +import { createToolRouter } from '../adapter/tool-router'; +import { useToolRouter } from '../hooks/use-tool-router'; +import { useVirtualWindow } from '../hooks/use-virtual-window'; +import { useVisibleClips } from '../hooks/use-virtual-window'; + +// ── Fixture: 1 track, 2 clips ─────────────────────────────────────────────── + +function makeFixtureState() { + const asset = createAsset({ + id: 'asset-1', + name: 'Asset', + mediaType: 'video', + filePath: '/a.mp4', + intrinsicDuration: toFrame(600), + nativeFps: frameRate(30), + sourceTimecodeOffset: toFrame(0), + }); + const clipA = createClip({ + id: 'clip-a', + assetId: asset.id, + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const clipB = createClip({ + id: 'clip-b', + assetId: asset.id, + trackId: 'track-1', + timelineStart: toFrame(150), + timelineEnd: toFrame(250), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + const track = createTrack({ + id: 'track-1', + name: 'V1', + type: 'video', + clips: [clipA, clipB], + }); + const timeline = createTimeline({ + id: 'tl-r3', + name: 'R3', + fps: frameRate(30), + duration: toFrame(3000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ + timeline, + assetRegistry: new Map([[asset.id, asset]]), + }); +} + +let engine: TimelineEngine; +const ppf = 10; +let scrollLeft = 0; + +function makeReactPointerEvent(overrides: Partial<{ + clientX: number; + clientY: number; + buttons: number; + shiftKey: boolean; + ctrlKey: boolean; +}> = {}): React.PointerEvent { + return { + clientX: overrides.clientX ?? 50, + clientY: overrides.clientY ?? 24, + buttons: overrides.buttons ?? 1, + shiftKey: overrides.shiftKey ?? false, + altKey: false, + metaKey: false, + ctrlKey: overrides.ctrlKey ?? false, + preventDefault: vi.fn(), + currentTarget: { + getBoundingClientRect: () => ({ left: 0, top: 0, width: 1000, height: 400 }), + }, + } as unknown as React.PointerEvent; +} + +function makeReactKeyEvent(overrides: Partial<{ + key: string; + code: string; + shiftKey: boolean; + preventDefault: () => void; +}> = {}): React.KeyboardEvent { + return { + key: overrides.key ?? 'x', + code: overrides.code ?? 'KeyX', + shiftKey: overrides.shiftKey ?? false, + altKey: false, + metaKey: false, + ctrlKey: false, + repeat: false, + preventDefault: overrides.preventDefault ?? vi.fn(), + } as unknown as React.KeyboardEvent; +} + +beforeEach(() => { + engine = new TimelineEngine({ + initialState: makeFixtureState(), + tools: [NoOpTool], + defaultToolId: 'noop', + }); + scrollLeft = 0; +}); + +// ── createToolRouter ──────────────────────────────────────────────────────── + +describe('createToolRouter', () => { + it('1. Returns object with 5 handlers', () => { + const router = createToolRouter({ + engine, + getPixelsPerFrame: () => ppf, + getScrollLeft: () => scrollLeft, + }); + expect(router).toHaveProperty('onPointerDown'); + expect(router).toHaveProperty('onPointerMove'); + expect(router).toHaveProperty('onPointerUp'); + expect(router).toHaveProperty('onPointerLeave'); + expect(router).toHaveProperty('onKeyDown'); + expect(typeof router.onPointerDown).toBe('function'); + expect(typeof router.onPointerMove).toBe('function'); + expect(typeof router.onPointerUp).toBe('function'); + expect(typeof router.onPointerLeave).toBe('function'); + expect(typeof router.onKeyDown).toBe('function'); + }); + + it('2. onPointerDown calls engine.handlePointerDown', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handlePointerDown'); + const e = makeReactPointerEvent(); + router.onPointerDown(e); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0]).toMatchObject({ frame: 5, x: 50, y: 24 }); + }); + + it('3. onPointerUp calls engine.handlePointerUp', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handlePointerUp'); + router.onPointerUp(makeReactPointerEvent()); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('4. onPointerMove is rAF-throttled: 3 sync calls → 1 engine.handlePointerMove after flush', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handlePointerMove'); + let rafCallback: FrameRequestCallback | null = null; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafCallback = cb; + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', () => { + rafCallback = null; + }); + + router.onPointerMove(makeReactPointerEvent({ clientX: 10 })); + router.onPointerMove(makeReactPointerEvent({ clientX: 20 })); + router.onPointerMove(makeReactPointerEvent({ clientX: 30 })); + expect(spy).toHaveBeenCalledTimes(0); + expect(rafCallback).not.toBeNull(); + rafCallback!(0); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![0].x).toBe(30); + + vi.unstubAllGlobals(); + }); + + it('5. onPointerLeave calls engine.handlePointerUp AND engine.handlePointerLeave', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const upSpy = vi.spyOn(engine, 'handlePointerUp'); + const leaveSpy = vi.spyOn(engine, 'handlePointerLeave'); + router.onPointerLeave(makeReactPointerEvent()); + expect(upSpy).toHaveBeenCalledTimes(1); + expect(leaveSpy).toHaveBeenCalledTimes(1); + }); + + it('6. onKeyDown calls engine.handleKeyDown', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handleKeyDown'); + router.onKeyDown(makeReactKeyEvent({ key: 'z', code: 'KeyZ' })); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('7. onKeyDown calls e.preventDefault() when handler returns true', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const e = makeReactKeyEvent({ key: ' ', code: 'Space' }); + vi.spyOn(engine, 'handleKeyDown').mockReturnValue(true); + router.onKeyDown(e); + expect(e.preventDefault).toHaveBeenCalled(); + }); + + it('8. onKeyDown does NOT call e.preventDefault() when handler returns false', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const e = makeReactKeyEvent({ key: 'z' }); + vi.spyOn(engine, 'handleKeyDown').mockReturnValue(false); + router.onKeyDown(e); + expect(e.preventDefault).not.toHaveBeenCalled(); + }); + + it('9. convertPointerEvent includes scrollLeft offset in x coordinate', () => { + scrollLeft = 100; + const router = createToolRouter({ + engine, + getPixelsPerFrame: () => ppf, + getScrollLeft: () => scrollLeft, + }); + const spy = vi.spyOn(engine, 'handlePointerDown'); + const e = makeReactPointerEvent({ clientX: 50 }); + router.onPointerDown(e); + expect(spy.mock.calls[0]![0].x).toBe(150); + expect(spy.mock.calls[0]![0].frame).toBe(15); + }); + + it('10. Modifiers (shiftKey, ctrlKey) passed through', () => { + const router = createToolRouter({ engine, getPixelsPerFrame: () => ppf }); + const spy = vi.spyOn(engine, 'handlePointerDown'); + router.onPointerDown( + makeReactPointerEvent({ shiftKey: true, ctrlKey: true }), + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0]![1]).toEqual({ + shift: true, + alt: false, + ctrl: true, + meta: false, + }); + }); +}); + +// ── useToolRouter ────────────────────────────────────────────────────────── + +describe('useToolRouter', () => { + it('11. Returns stable handlers reference across re-renders (same engine)', () => { + const { result, rerender } = renderHook( + () => + useToolRouter(engine, { + getPixelsPerFrame: () => ppf, + }), + ); + const first = result.current; + rerender(); + expect(result.current).toBe(first); + }); + + it('12. Returns new handlers if engine changes', () => { + const engine2 = new TimelineEngine({ + initialState: makeFixtureState(), + tools: [NoOpTool], + defaultToolId: 'noop', + }); + const { result, rerender } = renderHook( + ({ eng }) => + useToolRouter(eng, { getPixelsPerFrame: () => ppf }), + { initialProps: { eng: engine } }, + ); + const first = result.current; + rerender({ eng: engine2 }); + expect(result.current).not.toBe(first); + }); +}); + +// ── useVirtualWindow ──────────────────────────────────────────────────────── + +describe('useVirtualWindow', () => { + it('13. Returns correct startFrame and endFrame for given viewport/scroll/ppf', () => { + const { result } = renderHook(() => + useVirtualWindow(engine, 100, 0, 10), + ); + expect(result.current.startFrame).toBe(0); + expect(result.current.endFrame).toBe(10); + expect(result.current.pixelsPerFrame).toBe(10); + }); + + it('14. Returns new window when scrollLeft changes', () => { + const { result, rerender } = renderHook( + ({ scroll }: { scroll: number }) => + useVirtualWindow(engine, 100, scroll, 10), + { initialProps: { scroll: 0 } }, + ); + const w0 = result.current; + rerender({ scroll: 50 }); + expect(result.current).not.toBe(w0); + expect(result.current.startFrame).toBe(5); + }); + + it('15. Returns same reference when nothing changes', () => { + const { result, rerender } = renderHook(() => + useVirtualWindow(engine, 100, 0, 10), + ); + const w0 = result.current; + rerender(); + expect(result.current).toBe(w0); + }); +}); + +// ── useVisibleClips ───────────────────────────────────────────────────────── + +describe('useVisibleClips', () => { + it('16. Returns only visible clips for current window', () => { + const window: VirtualWindow = { + startFrame: toFrame(0), + endFrame: toFrame(200), + pixelsPerFrame: 10, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const entries = result.current; + expect(entries.length).toBeGreaterThanOrEqual(2); + const visible = entries.filter((e) => e.isVisible); + expect(visible.length).toBe(2); + }); + + it('17. Updates when engine state changes (new clip added inside window)', () => { + const window: VirtualWindow = { + startFrame: toFrame(0), + endFrame: toFrame(500), + pixelsPerFrame: 10, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const countBefore = result.current.filter((e) => e.isVisible).length; + const newClip = createClip({ + id: 'clip-c', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(100), + timelineEnd: toFrame(150), + mediaIn: toFrame(0), + mediaOut: toFrame(50), + }); + let dispatched: { accepted: boolean }; + act(() => { + dispatched = engine.dispatch({ + id: 'add', + label: 'Add clip', + timestamp: 0, + operations: [ + { type: 'INSERT_CLIP', trackId: toTrackId('track-1'), clip: newClip }, + ], + }); + }); + expect(dispatched!.accepted).toBe(true); + const countAfter = result.current.filter((e) => e.isVisible).length; + expect(countAfter).toBe(countBefore + 1); + }); + + it('18. isVisible false for clips outside window', () => { + const window: VirtualWindow = { + startFrame: toFrame(1000), + endFrame: toFrame(1100), + pixelsPerFrame: 10, + }; + const { result } = renderHook(() => useVisibleClips(engine, window)); + const entries = result.current; + const visible = entries.filter((e) => e.isVisible); + expect(visible.length).toBe(0); + }); +}); diff --git a/packages/react/src/__tests__/tool-router.test.ts b/packages/react/src/__tests__/tool-router.test.ts new file mode 100644 index 0000000..f826ad1 --- /dev/null +++ b/packages/react/src/__tests__/tool-router.test.ts @@ -0,0 +1,440 @@ +/** + * tool-router.test.ts + * + * Tests for createToolRouter() and its coordinate conversion utilities. + * + * KEY TESTS: + * - onPointerMove is rAF-throttled: multiple rapid calls → one engine call + * - onPointerLeave resets rAF state AND calls handlePointerUp (Option Y) + * - Coordinate conversion: frameAtX, trackAtY, clipAtFrame + * - Handlers are synchronous for down/up, deferred for move + * + * VITEST FAKE TIMERS: + * We use vi.useFakeTimers() to control requestAnimationFrame. + * flushRaf() flushes pending rAF callbacks. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + createToolRouter, + frameAtX, + trackAtY, + clipAtFrame, + extractModifiers, +} from '../tool-router'; +import type { TrackLayout, RouterLayout } from '../tool-router'; + +import { + createTimeline, + createTimelineState, + createTrack, + createClip, + createAsset, + toFrame, + toTimecode, + frameRate, + toTrackId, + toClipId, + toAssetId, +} from '@webpacked-timeline/core'; +import type { TimelinePointerEvent, TimelineKeyEvent, Modifiers } from '@webpacked-timeline/core'; + +import { TimelineEngine } from '../index'; + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const ASSET_ID = toAssetId('asset-1'); +const TRACK_ID = toTrackId('track-1'); +const CLIP_A_ID = toClipId('clip-a'); + +function makeState() { + const asset = createAsset({ + id: 'asset-1', + name: 'Test Asset', + mediaType: 'video', + filePath: '/media/test.mp4', + intrinsicDuration: toFrame(600), + nativeFps: 30, + sourceTimecodeOffset: toFrame(0), + status: 'online', + }); + + const clipA = createClip({ + id: 'clip-a', + assetId: 'asset-1', + trackId: 'track-1', + timelineStart: toFrame(0), + timelineEnd: toFrame(100), + mediaIn: toFrame(0), + mediaOut: toFrame(100), + }); + + const track = createTrack({ + id: 'track-1', + name: 'Video 1', + type: 'video', + clips: [clipA], + }); + + const timeline = createTimeline({ + id: 'tl-router-test', + name: 'Router Test Timeline', + fps: frameRate(30), + duration: toFrame(9000), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + + return createTimelineState({ + timeline, + assetRegistry: new Map([[ASSET_ID, asset]]), + }); +} + +/** Minimal PointerEvent-like object for tests */ +function makePointerEvent(overrides: Partial<{ + clientX: number; + clientY: number; + buttons: number; + shiftKey: boolean; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; +}> = {}): PointerEvent { + return { + clientX: overrides.clientX ?? 100, + clientY: overrides.clientY ?? 24, + buttons: overrides.buttons ?? 1, + shiftKey: overrides.shiftKey ?? false, + altKey: overrides.altKey ?? false, + ctrlKey: overrides.ctrlKey ?? false, + metaKey: overrides.metaKey ?? false, + } as unknown as PointerEvent; +} + +/** Minimal KeyboardEvent-like object for tests */ +function makeKeyEvent(key: string, overrides: Partial<{ + code: string; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; + ctrlKey: boolean; +}> = {}): KeyboardEvent { + return { + key, + code: overrides.code ?? `Key${key.toUpperCase()}`, + shiftKey: overrides.shiftKey ?? false, + altKey: overrides.altKey ?? false, + metaKey: overrides.metaKey ?? false, + ctrlKey: overrides.ctrlKey ?? false, + } as unknown as KeyboardEvent; +} + +/** Standard RouterLayout used in most tests: origin=0, ppf=10, one 48px track */ +const DEFAULT_TRACK_LAYOUTS: TrackLayout[] = [ + { trackId: TRACK_ID, top: 0, height: 48 }, +]; + +const DEFAULT_LAYOUT: RouterLayout = { + timelineOriginX: 0, + pixelsPerFrame: 10, + trackLayouts: DEFAULT_TRACK_LAYOUTS, +}; + +// ── UNIT TESTS: coordinate conversion ────────────────────────────────────── + +describe('frameAtX', () => { + it('converts clientX to frame at ppf=10, origin=0', () => { + expect(frameAtX(100, 0, 10)).toBe(10); + expect(frameAtX(0, 0, 10)).toBe(0); + expect(frameAtX(99, 0, 10)).toBe(9); // floor: 9.9 → 9 + }); + + it('accounts for non-zero timelineOriginX', () => { + expect(frameAtX(200, 100, 10)).toBe(10); // (200-100)/10 = 10 + expect(frameAtX(100, 100, 10)).toBe(0); + }); + + it('negative clientX before origin → negative frame (clamping is caller responsibility)', () => { + expect(frameAtX(0, 50, 10)).toBe(-5); // (0-50)/10 = -5 + }); +}); + +describe('trackAtY', () => { + const layouts: TrackLayout[] = [ + { trackId: toTrackId('t1'), top: 0, height: 48 }, + { trackId: toTrackId('t2'), top: 48, height: 48 }, + { trackId: toTrackId('t3'), top: 96, height: 32 }, + ]; + + it('returns track containing clientY', () => { + expect(trackAtY(0, layouts)).toBe(toTrackId('t1')); + expect(trackAtY(47, layouts)).toBe(toTrackId('t1')); + expect(trackAtY(48, layouts)).toBe(toTrackId('t2')); + expect(trackAtY(96, layouts)).toBe(toTrackId('t3')); + expect(trackAtY(127, layouts)).toBe(toTrackId('t3')); + }); + + it('returns null when below all tracks', () => { + expect(trackAtY(200, layouts)).toBeNull(); + }); + + it('returns null for empty layout', () => { + expect(trackAtY(50, [])).toBeNull(); + }); + + it('top boundary is inclusive, bottom boundary is exclusive', () => { + expect(trackAtY(48, layouts)).toBe(toTrackId('t2')); // exactly at t2 top + expect(trackAtY(47, layouts)).toBe(toTrackId('t1')); // last px of t1 + }); +}); + +describe('clipAtFrame', () => { + const state = makeState(); + + it('returns clipId when frame is within clip bounds', () => { + // clip-a: [0, 100) + expect(clipAtFrame(toFrame(0), TRACK_ID, state)).toBe(CLIP_A_ID); + expect(clipAtFrame(toFrame(99), TRACK_ID, state)).toBe(CLIP_A_ID); + }); + + it('returns null at timelineEnd (exclusive bound)', () => { + expect(clipAtFrame(toFrame(100), TRACK_ID, state)).toBeNull(); + }); + + it('returns null when trackId is null', () => { + expect(clipAtFrame(toFrame(50), null, state)).toBeNull(); + }); + + it('returns null for unknown trackId', () => { + expect(clipAtFrame(toFrame(50), toTrackId('ghost'), state)).toBeNull(); + }); + + it('returns null in gap between clips', () => { + expect(clipAtFrame(toFrame(150), TRACK_ID, state)).toBeNull(); + }); +}); + +describe('extractModifiers', () => { + it('extracts all modifier keys from a PointerEvent', () => { + const e = makePointerEvent({ shiftKey: true, altKey: false, ctrlKey: true, metaKey: false }); + expect(extractModifiers(e)).toEqual({ shift: true, alt: false, ctrl: true, meta: false }); + }); + + it('extracts all modifiers false when none held', () => { + expect(extractModifiers(makePointerEvent())).toEqual({ + shift: false, alt: false, ctrl: false, meta: false, + }); + }); +}); + +// ── INTEGRATION TESTS: createToolRouter ──────────────────────────────────── + +/** + * requestAnimationFrame stub. + * Captures pending callbacks and lets tests flush them via flushRaf(). + * Vitest's flushRaf() does not flush rAF in jsdom — this is the + * standard workaround. + */ +let rafCallbacks: FrameRequestCallback[] = []; + +function flushRaf(): void { + const callbacks = rafCallbacks.slice(); + rafCallbacks = []; + for (const cb of callbacks) cb(performance.now()); +} + +describe('createToolRouter', () => { + let engine: TimelineEngine; + let router: ReturnType; + + beforeEach(() => { + // Stub rAF to capture callbacks synchronously + rafCallbacks = []; + vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + + engine = new TimelineEngine({ initialState: makeState() }); + router = createToolRouter(engine, () => DEFAULT_LAYOUT); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + rafCallbacks = []; + }); + + // ── onPointerDown ───────────────────────────────────────────────────────── + + describe('onPointerDown', () => { + it('calls engine.handlePointerDown() synchronously', () => { + const spy = vi.spyOn(engine, 'handlePointerDown'); + router.onPointerDown(makePointerEvent({ clientX: 50, clientY: 24 })); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('passes correct TimelinePointerEvent (frame derived from clientX, trackId from clientY)', () => { + const spy = vi.spyOn(engine, 'handlePointerDown'); + // clientX=50, ppf=10, origin=0 → frame 5. clientY=24 → track-1 (0-48) + router.onPointerDown(makePointerEvent({ clientX: 50, clientY: 24 })); + const evt = spy.mock.calls[0]![0] as TimelinePointerEvent; + expect(evt.frame).toBe(toFrame(5)); + expect(evt.trackId).toBe(TRACK_ID); + }); + + it('populates clipId via hit-test (cursor over clip-a at frame 5)', () => { + const spy = vi.spyOn(engine, 'handlePointerDown'); + router.onPointerDown(makePointerEvent({ clientX: 50, clientY: 24 })); // frame 5 + const evt = spy.mock.calls[0]![0] as TimelinePointerEvent; + expect(evt.clipId).toBe(CLIP_A_ID); + }); + + it('populates clipId as null when cursor is in a gap (frame 150)', () => { + const spy = vi.spyOn(engine, 'handlePointerDown'); + router.onPointerDown(makePointerEvent({ clientX: 1500, clientY: 24 })); // frame 150 + const evt = spy.mock.calls[0]![0] as TimelinePointerEvent; + expect(evt.clipId).toBeNull(); + }); + }); + + // ── onPointerMove (rAF throttle) ────────────────────────────────────────── + + describe('onPointerMove — rAF throttle', () => { + it('does NOT call engine.handlePointerMove() synchronously', () => { + const spy = vi.spyOn(engine, 'handlePointerMove'); + router.onPointerMove(makePointerEvent()); + expect(spy).toHaveBeenCalledTimes(0); // deferred to rAF + }); + + it('calls engine.handlePointerMove() exactly once after rAF flushes', () => { + const spy = vi.spyOn(engine, 'handlePointerMove'); + router.onPointerMove(makePointerEvent({ clientX: 100 })); + flushRaf(); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('coalesces multiple rapid moves into one engine call (rAF throttle)', () => { + const spy = vi.spyOn(engine, 'handlePointerMove'); + // 10 rapid move events before the rAF fires + for (let i = 0; i < 10; i++) { + router.onPointerMove(makePointerEvent({ clientX: i * 10 })); + } + flushRaf(); + // Only ONE engine call — rAF coalesced all intermediate events + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('uses the MOST RECENT event, not the first (coalescing semantics)', () => { + const spy = vi.spyOn(engine, 'handlePointerMove'); + router.onPointerMove(makePointerEvent({ clientX: 10 })); // frame 1 + router.onPointerMove(makePointerEvent({ clientX: 200 })); // frame 20 — this wins + router.onPointerMove(makePointerEvent({ clientX: 500 })); // frame 50 — this wins + flushRaf(); + const evt = spy.mock.calls[0]![0] as TimelinePointerEvent; + // clientX=500, ppf=10, origin=0 → frame 50 + expect(evt.frame).toBe(toFrame(50)); + }); + + it('allows a second rAF after the first fires (reset)', () => { + const spy = vi.spyOn(engine, 'handlePointerMove'); + router.onPointerMove(makePointerEvent({ clientX: 100 })); + flushRaf(); // first rAF fires + router.onPointerMove(makePointerEvent({ clientX: 200 })); + flushRaf(); // second rAF fires + expect(spy).toHaveBeenCalledTimes(2); + }); + }); + + // ── onPointerUp ─────────────────────────────────────────────────────────── + + describe('onPointerUp', () => { + it('calls engine.handlePointerUp() synchronously', () => { + const spy = vi.spyOn(engine, 'handlePointerUp'); + router.onPointerUp(makePointerEvent()); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + // ── onPointerLeave (Option Y) ───────────────────────────────────────────── + + describe('onPointerLeave — Option Y', () => { + it('calls engine.handlePointerUp() to clear provisional state', () => { + const spy = vi.spyOn(engine, 'handlePointerUp'); + router.onPointerLeave(makePointerEvent()); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('cancels the pending rAF — the queued callback becomes a no-op', () => { + const spy = vi.spyOn(engine, 'handlePointerMove'); + // Schedule a rAF by calling move + router.onPointerMove(makePointerEvent({ clientX: 100 })); + // Leave before rAF fires + router.onPointerLeave(makePointerEvent({ clientX: 200 })); + // Flush rAF — should be a no-op because lastMoveEvent was nulled + flushRaf(); + expect(spy).toHaveBeenCalledTimes(0); + }); + + it('resets rAF state so next move starts clean', () => { + const moveSpy = vi.spyOn(engine, 'handlePointerMove'); + router.onPointerMove(makePointerEvent()); + router.onPointerLeave(makePointerEvent()); + flushRaf(); // flush cancelled rAF + + // New move after leave — should schedule a fresh rAF + router.onPointerMove(makePointerEvent({ clientX: 100 })); + flushRaf(); + expect(moveSpy).toHaveBeenCalledTimes(1); // only the post-leave move + }); + }); + + // ── onKeyDown / onKeyUp ─────────────────────────────────────────────────── + + describe('onKeyDown', () => { + it('calls engine.handleKeyDown() synchronously', () => { + const spy = vi.spyOn(engine, 'handleKeyDown'); + router.onKeyDown(makeKeyEvent('z', { ctrlKey: true })); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it('passes correct TimelineKeyEvent', () => { + const spy = vi.spyOn(engine, 'handleKeyDown'); + router.onKeyDown(makeKeyEvent('Escape')); + const evt = spy.mock.calls[0]![0] as TimelineKeyEvent; + expect(evt.key).toBe('Escape'); + }); + }); + + describe('onKeyUp', () => { + it('calls engine.handleKeyUp() synchronously', () => { + const spy = vi.spyOn(engine, 'handleKeyUp'); + router.onKeyUp(makeKeyEvent('Shift')); + expect(spy).toHaveBeenCalledTimes(1); + }); + }); + + // ── getLayout() called fresh on every event ─────────────────────────────── + + describe('getLayout() freshness', () => { + it('uses updated ppf for coordinate conversion without recreating the router', () => { + let ppf = 10; + const router2 = createToolRouter(engine, () => ({ + timelineOriginX: 0, + pixelsPerFrame: ppf, + trackLayouts: DEFAULT_TRACK_LAYOUTS, + })); + + const spy = vi.spyOn(engine, 'handlePointerDown'); + + // At ppf=10, clientX=100 → frame 10 + router2.onPointerDown(makePointerEvent({ clientX: 100 })); + expect((spy.mock.calls[0]![0] as TimelinePointerEvent).frame).toBe(toFrame(10)); + + // Zoom in: ppf=20. No need to recreate the router. + ppf = 20; + router2.onPointerDown(makePointerEvent({ clientX: 100 })); + expect((spy.mock.calls[1]![0] as TimelinePointerEvent).frame).toBe(toFrame(5)); + }); + }); +}); diff --git a/packages/react/src/__tests__/usePlayhead.test.ts b/packages/react/src/__tests__/usePlayhead.test.ts new file mode 100644 index 0000000..3fd5140 --- /dev/null +++ b/packages/react/src/__tests__/usePlayhead.test.ts @@ -0,0 +1,189 @@ +/** + * usePlayhead / usePlayheadEvent — Phase 6 Step 6 + * + * Uses @testing-library/react renderHook + act. + * Fixture: minimal TimelineState (one track, no clips, 900 frames, 30fps), + * mock PipelineConfig, PlaybackEngine with createTestClock(). + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { + createTimelineState, + createTimeline, + createTrack, + toFrame, + toTimecode, + frameRate, + toTrackId, + createTestClock, + PlaybackEngine, +} from '@webpacked-timeline/core'; +import type { TimelineState } from '@webpacked-timeline/core'; +import type { PipelineConfig } from '@webpacked-timeline/core'; +import { usePlayhead, usePlayheadEvent } from '../hooks'; + +const FPS = 30; +const DURATION = 900; +const DIMS = { width: 1920, height: 1080 }; + +function makeMinimalState(): TimelineState { + const track = createTrack({ + id: toTrackId('v1'), + name: 'V1', + type: 'video', + clips: [], + }); + const timeline = createTimeline({ + id: 'tl', + name: 'Playhead Test', + fps: frameRate(FPS), + duration: toFrame(DURATION), + startTimecode: toTimecode('00:00:00:00'), + tracks: [track], + }); + return createTimelineState({ timeline, assetRegistry: new Map() }); +} + +const mockPipeline: PipelineConfig = { + videoDecoder: vi.fn().mockResolvedValue({ + clipId: 'x', + mediaFrame: toFrame(0), + width: DIMS.width, + height: DIMS.height, + bitmap: null, + }), + compositor: vi.fn().mockResolvedValue({ + timelineFrame: toFrame(0), + bitmap: null, + }), +}; + +function makeEngine() { + const { clock } = createTestClock(); + return new PlaybackEngine(makeMinimalState(), mockPipeline, DIMS, clock); +} + +describe('usePlayhead — state', () => { + it('1. initial state: frame 0, not playing, rate 1.0', () => { + const engine = makeEngine(); + const { result } = renderHook(() => usePlayhead(engine)); + expect(result.current.currentFrame).toEqual(toFrame(0)); + expect(result.current.isPlaying).toBe(false); + expect(result.current.playbackRate).toBe(1.0); + expect(result.current.durationFrames).toBe(DURATION); + expect(result.current.fps).toBe(FPS); + }); + + it('2. play() via hook updates isPlaying to true after act()', () => { + const engine = makeEngine(); + const { result } = renderHook(() => usePlayhead(engine)); + expect(result.current.isPlaying).toBe(false); + act(() => { + result.current.play(); + }); + expect(result.current.isPlaying).toBe(true); + }); + + it('3. pause() via hook updates isPlaying to false', () => { + const engine = makeEngine(); + const { result } = renderHook(() => usePlayhead(engine)); + act(() => result.current.play()); + expect(result.current.isPlaying).toBe(true); + act(() => result.current.pause()); + expect(result.current.isPlaying).toBe(false); + }); + + it('4. seekTo(300) updates currentFrame to 300', () => { + const engine = makeEngine(); + const { result } = renderHook(() => usePlayhead(engine)); + act(() => result.current.seekTo(toFrame(300))); + expect(result.current.currentFrame).toEqual(toFrame(300)); + }); + + it('5. setPlaybackRate(2.0) updates playbackRate', () => { + const engine = makeEngine(); + const { result } = renderHook(() => usePlayhead(engine)); + act(() => result.current.setPlaybackRate(2.0)); + expect(result.current.playbackRate).toBe(2.0); + }); + + it('6. setLoopRegion updates loopRegion', () => { + const engine = makeEngine(); + const { result } = renderHook(() => usePlayhead(engine)); + const region = { startFrame: toFrame(100), endFrame: toFrame(200) }; + act(() => result.current.setLoopRegion(region)); + expect(result.current.loopRegion).toEqual(region); + }); + + it('7. toggle() plays when paused', () => { + const engine = makeEngine(); + const { result } = renderHook(() => usePlayhead(engine)); + expect(result.current.isPlaying).toBe(false); + act(() => result.current.toggle()); + expect(result.current.isPlaying).toBe(true); + }); + + it('8. toggle() pauses when playing', () => { + const engine = makeEngine(); + const { result } = renderHook(() => usePlayhead(engine)); + act(() => result.current.play()); + expect(result.current.isPlaying).toBe(true); + act(() => result.current.toggle()); + expect(result.current.isPlaying).toBe(false); + }); +}); + +describe('usePlayhead — referential stability', () => { + it('9. play callback reference is stable across re-renders', () => { + const engine = makeEngine(); + const { result, rerender } = renderHook(() => usePlayhead(engine)); + const playRef1 = result.current.play; + act(() => result.current.play()); + rerender(); + const playRef2 = result.current.play; + expect(playRef2).toBe(playRef1); + }); + + it('10. seekTo callback reference is stable across re-renders', () => { + const engine = makeEngine(); + const { result, rerender } = renderHook(() => usePlayhead(engine)); + const seekToRef1 = result.current.seekTo; + act(() => result.current.seekTo(toFrame(100))); + rerender(); + const seekToRef2 = result.current.seekTo; + expect(seekToRef2).toBe(seekToRef1); + }); +}); + +describe('usePlayheadEvent', () => { + it('11. handler called when matching event type fires', () => { + const engine = makeEngine(); + const handler = vi.fn(); + renderHook(() => usePlayheadEvent(engine, 'seek', handler)); + act(() => engine.seekTo(toFrame(50))); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0][0].type).toBe('seek'); + expect(handler.mock.calls[0][0].frame).toEqual(toFrame(50)); + }); + + it('12. handler NOT called for non-matching event type', () => { + const engine = makeEngine(); + const handler = vi.fn(); + renderHook(() => usePlayheadEvent(engine, 'loop-point', handler)); + act(() => engine.seekTo(toFrame(50))); + expect(handler).not.toHaveBeenCalled(); + }); + + it('13. unsubscribes on unmount (no memory leak)', () => { + const engine = makeEngine(); + const handler = vi.fn(); + const { unmount } = renderHook(() => usePlayheadEvent(engine, 'seek', handler)); + act(() => engine.seekTo(toFrame(10))); + expect(handler).toHaveBeenCalledTimes(1); + unmount(); + handler.mockClear(); + act(() => engine.seekTo(toFrame(20))); + expect(handler).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react/src/adapter/tool-router.ts b/packages/react/src/adapter/tool-router.ts new file mode 100644 index 0000000..6af6150 --- /dev/null +++ b/packages/react/src/adapter/tool-router.ts @@ -0,0 +1,171 @@ +/** + * ToolRouter — Phase R Step 3 + * + * Converts React pointer/keyboard events into engine events. + * rAF throttle only on onPointerMove. Option Y: onPointerLeave + * calls handlePointerUp then handlePointerLeave. + */ + +import type { PointerEvent as ReactPointerEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'; +import type { TimelineEngine } from '../engine'; +import type { TimelinePointerEvent, TimelineKeyEvent, Modifiers, ClipId, TrackId } from '@webpacked-timeline/core'; +import type { TimelineFrame } from '@webpacked-timeline/core'; + +export type ToolRouterOptions = { + engine: TimelineEngine; + getPixelsPerFrame: () => number; + getScrollLeft?: () => number; +}; + +export type ToolRouterHandlers = { + onPointerDown: (e: ReactPointerEvent) => void; + onPointerMove: (e: ReactPointerEvent) => void; + onPointerUp: (e: ReactPointerEvent) => void; + onPointerLeave: (e: ReactPointerEvent) => void; + onKeyDown: (e: ReactKeyboardEvent) => void; +}; + +function getScrollLeftDefault(): number { + return 0; +} + +const EDGE_HIT_PX = 8; + +function convertPointerEvent( + e: ReactPointerEvent, + getPixelsPerFrame: () => number, + getScrollLeft: () => number, +): TimelinePointerEvent { + const ppf = getPixelsPerFrame(); + const sl = getScrollLeft(); + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + + // x is in timeline coordinate space (scroll-adjusted, from timeline origin) + const x = e.clientX - rect.left + sl; + const y = e.clientY - rect.top; + + // Frame from x position + const frame = Math.max(0, Math.round(x / ppf)) as TimelineFrame; + + // DOM hit-test: walk up from target + let clipId: string | undefined; + let trackId: string | undefined; + let edge: 'left' | 'right' | 'none' = 'none'; + let clipEl: HTMLElement | null = null; + + let el: HTMLElement | null = e.target as HTMLElement | null; + while (el && el !== e.currentTarget) { + if (!clipId && el.dataset.clipId) { + clipId = el.dataset.clipId; + clipEl = el; + } + if (!trackId && el.dataset.trackId) { + trackId = el.dataset.trackId; + } + if (clipId && trackId) break; + el = el.parentElement; + } + + // Edge detection + if (clipId && clipEl) { + const cr = clipEl.getBoundingClientRect(); + const localX = e.clientX - cr.left; + const thresh = Math.min(EDGE_HIT_PX, cr.width * 0.15); + if (localX <= thresh) edge = 'left'; + else if (localX >= cr.width - thresh) edge = 'right'; + } + + return { + frame, + trackId: (trackId as TrackId) ?? null, + clipId: (clipId as ClipId) ?? null, + x, + y, + buttons: e.buttons, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + edge, + }; +} + +function extractModifiers(e: ReactPointerEvent | ReactKeyboardEvent): Modifiers { + return { + shift: e.shiftKey, + alt: e.altKey, + ctrl: e.ctrlKey, + meta: e.metaKey, + }; +} + +function convertKeyEvent(e: ReactKeyboardEvent): TimelineKeyEvent { + return { + code: e.code, + key: e.key, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + repeat: e.repeat, + }; +} + +export function createToolRouter(options: ToolRouterOptions): ToolRouterHandlers { + const { engine } = options; + const getScrollLeft = options.getScrollLeft ?? getScrollLeftDefault; + const getPixelsPerFrame = options.getPixelsPerFrame; + + let rafId: number | null = null; + let lastMoveEvent: ReactPointerEvent | null = null; + let lastModifiers: Modifiers | null = null; + + return { + onPointerDown(e: ReactPointerEvent): void { + const converted = convertPointerEvent(e, getPixelsPerFrame, getScrollLeft); + engine.handlePointerDown(converted, extractModifiers(e)); + }, + + onPointerMove(e: ReactPointerEvent): void { + e.preventDefault(); + lastMoveEvent = e; + lastModifiers = extractModifiers(e); + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + rafId = requestAnimationFrame(() => { + rafId = null; + if (lastMoveEvent !== null && lastModifiers !== null) { + const converted = convertPointerEvent( + lastMoveEvent, + getPixelsPerFrame, + getScrollLeft, + ); + engine.handlePointerMove(converted, lastModifiers); + } + }); + }, + + onPointerUp(e: ReactPointerEvent): void { + const converted = convertPointerEvent(e, getPixelsPerFrame, getScrollLeft); + engine.handlePointerUp(converted, extractModifiers(e)); + }, + + onPointerLeave(e: ReactPointerEvent): void { + if (rafId !== null) { + cancelAnimationFrame(rafId); + rafId = null; + } + lastMoveEvent = null; + lastModifiers = null; + const converted = convertPointerEvent(e, getPixelsPerFrame, getScrollLeft); + engine.handlePointerUp(converted, extractModifiers(e)); + engine.handlePointerLeave(converted); + }, + + onKeyDown(e: ReactKeyboardEvent): void { + const converted = convertKeyEvent(e); + const handled = engine.handleKeyDown(converted, extractModifiers(e)); + if (handled) e.preventDefault(); + }, + }; +} diff --git a/packages/react/src/engine.ts b/packages/react/src/engine.ts new file mode 100644 index 0000000..6f2609a --- /dev/null +++ b/packages/react/src/engine.ts @@ -0,0 +1,529 @@ +/** + * TimelineEngine — Phase R Step 1 (full orchestrator) + * + * Wires @webpacked-timeline/core: HistoryStack, PlaybackEngine, SnapIndexManager, + * TrackIndex, KeyboardHandler, dispatch, diffStates. Exposes EngineSnapshot + * for useSyncExternalStore. + * + * No DOM dependencies — clock and getPixelsPerFrame are injected. + */ + +import { + dispatch as coreDispatch, + HistoryStack, + DEFAULT_COMPRESSION_POLICY, + diffStates, + EMPTY_STATE_CHANGE, + createProvisionalManager, + setProvisional, + clearProvisional, + createRegistry, + activateTool as registryActivateTool, + getActiveTool, + buildSnapIndex, + nearest, + SnapIndexManager, + TrackIndex, + PlaybackEngine, + browserClock, + KeyboardHandler, + SelectionTool, + RazorTool, + RippleTrimTool, + RollTrimTool, + SlipTool, + SlideTool, + RippleDeleteTool, + RippleInsertTool, + HandTool, + TransitionTool, + KeyframeTool, + createZoomTool, + toFrame, + toToolId, +} from '@webpacked-timeline/core'; + +import type { + TimelineState, + Transaction, + DispatchResult, + TimelineFrame, + TrackId, + ITool, + ToolId, + ToolContext, + Modifiers, + TimelinePointerEvent, + TimelineKeyEvent, + ToolRegistry, + ProvisionalManager, + StateChange, + SnapPointType, + SnapIndex, +} from '@webpacked-timeline/core'; + +import type { EngineSnapshot, TimelineEngineOptions } from './types/engine-snapshot'; +import { DEFAULT_PLAYHEAD_STATE } from './types/engine-snapshot'; + +export type { EngineSnapshot, TimelineEngineOptions } from './types/engine-snapshot'; +export { DEFAULT_PLAYHEAD_STATE } from './types/engine-snapshot'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const SNAP_RADIUS_PX = 8; +const DEFAULT_TRACK_H = 48; +const DEFAULT_PPF = 10; + +/** No-op transaction for initial history entry */ +const NOOP_TRANSACTION: Transaction = { + id: 'initial', + label: 'initial', + timestamp: 0, + operations: [], +}; + +/** Idle modifiers for cursor computation */ +const IDLE_MODIFIERS: Modifiers = { shift: false, alt: false, ctrl: false, meta: false }; + +// --------------------------------------------------------------------------- +// TimelineEngine +// --------------------------------------------------------------------------- + +export class TimelineEngine { + private currentState: TimelineState; + private history: HistoryStack; + private provisional: ProvisionalManager; + private toolRegistry: ToolRegistry; + private keyboardHandler: KeyboardHandler; + private playback: PlaybackEngine | null; + private snapManager: SnapIndexManager; + private trackIndex: TrackIndex; + private subscribers: Set<() => void>; + private snapshot: EngineSnapshot; + private prevState: TimelineState; + private stableTrackIds: readonly string[]; + private options: TimelineEngineOptions; + /** Cached for stable snapshot.history ref — only updated when canUndo/canRedo change. */ + private historyFlags: { canUndo: boolean; canRedo: boolean }; + private playbackUnsubscribe: (() => void) | null = null; + /** Lightweight playhead frame when no PlaybackEngine is present. */ + private _playheadFrame: TimelineFrame = toFrame(0); + /** Selection state (set of clip IDs). */ + private _selectedClipIds: ReadonlySet = new Set(); + + constructor(options: TimelineEngineOptions) { + this.options = options; + this.currentState = options.initialState; + + this.history = new HistoryStack( + options.historyLimit ?? 100, + options.compression ?? DEFAULT_COMPRESSION_POLICY, + ); + this.history.push({ + state: options.initialState, + transaction: NOOP_TRANSACTION, + }); + + this.provisional = createProvisionalManager(); + + const ppf = options.getPixelsPerFrame?.() ?? DEFAULT_PPF; + const defaultTools: ITool[] = [ + new SelectionTool(), + new RazorTool(), + new RippleTrimTool(), + new RollTrimTool(), + new SlipTool(), + new SlideTool(), + new RippleDeleteTool(), + new RippleInsertTool(), + new HandTool(), + new TransitionTool(), + new KeyframeTool(), + createZoomTool({ + onZoomChange: options.onZoomChange ?? (() => {}), + initialPixelsPerFrame: ppf, + }), + ]; + const toolMap = new Map(defaultTools.map((t) => [t.id, t])); + for (const t of options.tools ?? []) { + toolMap.set(t.id, t); + } + const allTools = [...toolMap.values()]; + const defaultToolId = options.defaultToolId ?? 'selection'; + this.toolRegistry = createRegistry(allTools, toToolId(defaultToolId)); + + this.snapManager = new SnapIndexManager(); + this.snapManager.rebuildSync(options.initialState); + + this.trackIndex = new TrackIndex(); + this.trackIndex.build(options.initialState); + + if (options.pipeline) { + this.playback = new PlaybackEngine( + options.initialState, + options.pipeline, + options.dimensions ?? { width: 1920, height: 1080 }, + options.clock ?? browserClock, + ); + this.playbackUnsubscribe = this.playback.on(() => { + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + }); + } else { + this.playback = null; + } + + this.keyboardHandler = new KeyboardHandler(this.playback ?? ({} as PlaybackEngine), { + onMarkIn: options.onMarkIn, + onMarkOut: options.onMarkOut, + getTimelineState: () => this.currentState, + }); + + this.subscribers = new Set(); + this.stableTrackIds = options.initialState.timeline.tracks.map((t) => t.id); + this.prevState = options.initialState; + const u0 = this.history.canUndo(); + const r0 = this.history.canRedo(); + this.historyFlags = { canUndo: u0, canRedo: r0 }; + this.snapshot = this.buildSnapshot(EMPTY_STATE_CHANGE); + + this.subscribe = this.subscribe.bind(this); + this.getSnapshot = this.getSnapshot.bind(this); + this.dispatch = this.dispatch.bind(this); + } + + dispatch(transaction: Transaction): DispatchResult { + const result = coreDispatch(this.currentState, transaction); + if (!result.accepted) { + return result; + } + + const change = diffStates(this.currentState, result.nextState); + this.currentState = result.nextState; + this.history.pushWithCompression( + { state: result.nextState, transaction }, + transaction, + ); + + this.trackIndex.build(result.nextState); + this.snapManager.scheduleRebuild(result.nextState); + this.playback?.updateState(result.nextState); + + const nextIds = result.nextState.timeline.tracks.map((t) => t.id); + const idsChanged = + this.stableTrackIds.length !== nextIds.length || + this.stableTrackIds.some((id, i) => id !== nextIds[i]); + if (idsChanged) { + this.stableTrackIds = nextIds; + } + + this.rebuildSnapshot(change); + this.notify(); + return result; + } + + undo(): boolean { + const state = this.history.undo(); + if (state === null) return false; + this.currentState = state; + this.applyStateChange(state); + return true; + } + + redo(): boolean { + const state = this.history.redo(); + if (state === null) return false; + this.currentState = state; + this.applyStateChange(state); + return true; + } + + private applyStateChange(state: TimelineState): void { + const change = diffStates(this.prevState, state); + this.prevState = state; + this.trackIndex.build(state); + this.snapManager.scheduleRebuild(state); + this.playback?.updateState(state); + const nextIds = state.timeline.tracks.map((t) => t.id); + const idsChanged = + this.stableTrackIds.length !== nextIds.length || + this.stableTrackIds.some((id, i) => id !== nextIds[i]); + if (idsChanged) { + this.stableTrackIds = nextIds; + } + this.rebuildSnapshot(change); + this.notify(); + } + + activateTool(toolId: string): void { + const id = toToolId(toolId); + this.toolRegistry = registryActivateTool(this.toolRegistry, id); + this._syncSelectionFromTool(); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + + getActiveToolId(): string { + return this.toolRegistry.activeToolId as string; + } + + /** + * Read selection from the active tool (if it exposes getSelection) and + * sync to this._selectedClipIds. Called after every pointer event so that + * SelectionTool's internal Set is always mirrored in the engine snapshot. + */ + private _syncSelectionFromTool(): void { + const tool = getActiveTool(this.toolRegistry) as unknown as { + getSelection?: () => ReadonlySet; + }; + if (typeof tool.getSelection === 'function') { + this._selectedClipIds = tool.getSelection(); + } + } + + handlePointerDown(event: TimelinePointerEvent, modifiers: Modifiers): void { + const ctx = this.buildToolContext(modifiers); + getActiveTool(this.toolRegistry).onPointerDown(event, ctx); + this._syncSelectionFromTool(); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + + handlePointerMove(event: TimelinePointerEvent, modifiers: Modifiers): void { + const ctx = this.buildToolContext(modifiers); + const provisional = getActiveTool(this.toolRegistry).onPointerMove(event, ctx); + this.provisional = + provisional !== null + ? setProvisional(this.provisional, provisional) + : clearProvisional(this.provisional); + // NOTE: Don't sync selection on move — selection only changes on up/down. + // Rebuild snapshot with current state (doesn't re-allocate state object). + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notifyProvisional(); + } + + handlePointerUp(event: TimelinePointerEvent, modifiers: Modifiers): void { + this.provisional = clearProvisional(this.provisional); + const ctx = this.buildToolContext(modifiers); + const tx = getActiveTool(this.toolRegistry).onPointerUp(event, ctx); + this._syncSelectionFromTool(); + if (tx !== null) { + this.dispatch(tx); + } else { + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + } + + /** Option Y: cursor left timeline mid-drag — cancel tool gesture and clear provisional. */ + handlePointerLeave(_event: TimelinePointerEvent): void { + getActiveTool(this.toolRegistry).onCancel(); + this._syncSelectionFromTool(); + this.provisional = clearProvisional(this.provisional); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + + handleKeyDown(event: TimelineKeyEvent, modifiers: Modifiers): boolean { + if (this.keyboardHandler.handleKeyDown(event)) { + return true; + } + const ctx = this.buildToolContext(modifiers); + const tx = getActiveTool(this.toolRegistry).onKeyDown(event, ctx); + if (tx !== null) { + this.dispatch(tx); + return true; + } + return false; + } + + handleKeyUp(event: TimelineKeyEvent, modifiers: Modifiers): void { + const ctx = this.buildToolContext(modifiers); + getActiveTool(this.toolRegistry).onKeyUp(event, ctx); + } + + private buildToolContext(modifiers: Modifiers): ToolContext { + const state = this.currentState; + const ppf = this.options.getPixelsPerFrame?.() ?? DEFAULT_PPF; + let idx = this.snapManager.getIndex(); + if (idx === null) { + this.snapManager.rebuildSync(state); + idx = this.snapManager.getIndex(); + } + const snapIndex = idx ?? buildSnapIndex(state, toFrame(0)); + + return { + state, + snapIndex, + pixelsPerFrame: ppf, + modifiers, + frameAtX: (x: number): TimelineFrame => Math.round(x / ppf) as TimelineFrame, + trackAtY: (y: number): TrackId | null => { + const i = Math.floor(y / DEFAULT_TRACK_H); + const t = state.timeline.tracks[i]; + return t ? t.id : null; + }, + snap: ( + frame: TimelineFrame, + exclude?: readonly string[], + allowedTypes?: readonly SnapPointType[], + ): TimelineFrame => { + const radius = SNAP_RADIUS_PX / ppf; + const hit = nearest(snapIndex, frame, radius, exclude, allowedTypes); + return hit ? hit.frame : frame; + }, + }; + } + + private buildSnapshot(change: StateChange): EngineSnapshot { + const activeTool = getActiveTool(this.toolRegistry); + const cursor = activeTool?.getCursor(this.buildToolContext(IDLE_MODIFIERS)) ?? 'default'; + const u = this.history.canUndo(); + const r = this.history.canRedo(); + if (u !== this.historyFlags.canUndo || r !== this.historyFlags.canRedo) { + this.historyFlags = { canUndo: u, canRedo: r }; + } + const playheadState = this.playback?.getState() ?? { + ...DEFAULT_PLAYHEAD_STATE, + currentFrame: this._playheadFrame, + durationFrames: (this.currentState.timeline.duration as number) ?? 0, + fps: (this.currentState.timeline.fps as number) || 30, + }; + return { + state: this.currentState, + provisional: this.provisional.current, + activeToolId: this.toolRegistry.activeToolId as string, + canUndo: u, + canRedo: r, + history: this.historyFlags, + trackIds: this.stableTrackIds, + cursor, + playhead: playheadState, + change, + selectedClipIds: this._selectedClipIds, + }; + } + + private rebuildSnapshot(change: StateChange): void { + this.snapshot = this.buildSnapshot(change); + } + + subscribe(callback: () => void): () => void { + this.subscribers.add(callback); + return () => this.subscribers.delete(callback); + } + + getSnapshot(): EngineSnapshot { + return this.snapshot; + } + + getSnapIndex(): SnapIndex | null { + return this.snapManager.getIndex(); + } + + private notify(): void { + this.subscribers.forEach((cb) => cb()); + } + + /** + * Notify only on provisional changes (during drag / pointer move). + * Currently calls notify() — future optimization: use a separate lightweight + * provisional subscriber channel so that state-only hooks (useTrackIds, + * useHistory, useMarkers) skip their selector checks during drag. + */ + private notifyProvisional(): void { + this.subscribers.forEach((cb) => cb()); + } + + get playbackEngine(): PlaybackEngine | null { + return this.playback; + } + + getState(): TimelineState { + return this.currentState; + } + + /** + * Seek the playhead to a specific frame. + * Works with or without a PlaybackEngine. + */ + seekTo(frame: TimelineFrame): void { + if (this.playback) { + this.playback.seekTo(frame); + } else { + const maxFrame = (this.currentState.timeline.duration as number) - 1; + this._playheadFrame = Math.max(0, Math.min(frame as number, maxFrame)) as TimelineFrame; + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + } + + /** Get the current playhead frame. */ + getPlayheadFrame(): TimelineFrame { + if (this.playback) { + return this.playback.getState().currentFrame; + } + return this._playheadFrame; + } + + /** + * Write a selection set back to the active tool (if SelectionTool). + * Keeps the tool's internal Set in sync with external API calls. + */ + private _writeSelectionToTool(ids: ReadonlySet): void { + const tool = getActiveTool(this.toolRegistry) as unknown as { + clearSelection?: () => void; + getSelection?: () => ReadonlySet; + }; + if (typeof tool.clearSelection === 'function') { + tool.clearSelection(); + // SelectionTool.clearSelection() clears the set; we can't add back via public API + // so we just clear — the engine's _selectedClipIds drives rendering. + } + this._selectedClipIds = ids; + } + + /** Get the current selection (clip IDs). */ + getSelectedClipIds(): ReadonlySet { + return this._selectedClipIds; + } + + /** Set the selection (clip IDs). */ + setSelectedClipIds(ids: ReadonlySet): void { + this._writeSelectionToTool(ids); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + + /** Toggle a clip in/out of the selection. */ + toggleClipSelection(clipId: string, multi: boolean): void { + const next = new Set(this._selectedClipIds); + if (multi) { + if (next.has(clipId)) next.delete(clipId); + else next.add(clipId); + } else { + next.clear(); + next.add(clipId); + } + this._writeSelectionToTool(next); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + + /** Clear selection. */ + clearSelection(): void { + if (this._selectedClipIds.size === 0) return; + this._writeSelectionToTool(new Set()); + this.rebuildSnapshot(EMPTY_STATE_CHANGE); + this.notify(); + } + + destroy(): void { + this.playbackUnsubscribe?.(); + this.playbackUnsubscribe = null; + this.playback?.destroy(); + this.subscribers.clear(); + } +} diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts new file mode 100644 index 0000000..77dac59 --- /dev/null +++ b/packages/react/src/hooks.ts @@ -0,0 +1,122 @@ +/** + * @webpacked-timeline/react — hooks + * + * Phase R Step 2: All hooks use useSyncExternalStore. Engine-as-first-arg hooks + * live in hooks/index.ts. This file provides useEngine() from context and + * context-based wrappers so existing code (useTimeline(), useTrackIds(), etc.) + * continues to work without passing engine. + */ + +import { useContext } from 'react'; +import { TimelineContext } from './TimelineProvider'; +import type { TimelineEngine } from './engine'; +import type { TrackId, ClipId, ProvisionalState } from '@webpacked-timeline/core'; + +// Re-export all engine-as-first-arg hooks from hooks/index +export { + useTimeline as useTimelineWithEngine, + useTrackIds as useTrackIdsWithEngine, + useTrack as useTrackWithEngine, + useClip as useClipWithEngine, + useClips, + useMarkers, + useHistory, + useActiveToolId, + useCursor, + useProvisional as useProvisionalWithEngine, + usePlayheadFrame, + useIsPlaying, + useChange, + usePlaybackEngine, + useSelectedClipIds, +} from './hooks/index'; + +// --------------------------------------------------------------------------- +// useEngine — from context (no subscription) +// --------------------------------------------------------------------------- + +function useTimelineContext(): TimelineEngine { + const engine = useContext(TimelineContext); + if (!engine) { + throw new Error( + 'Timeline hooks must be used within a . ' + + 'Wrap your component tree with .', + ); + } + return engine; +} + +/** + * Returns the TimelineEngine instance from context. + * Use with Phase R hooks: useTimeline(useEngine()), etc. + * + * @throws If used outside TimelineProvider. + */ +export function useEngine(): TimelineEngine { + return useTimelineContext(); +} + +// --------------------------------------------------------------------------- +// Context-based wrappers (engine from context, then delegate to hooks/index) +// --------------------------------------------------------------------------- + +import { + useTimeline as useTimelineFromIndex, + useTrackIds as useTrackIdsFromIndex, + useTrack as useTrackFromIndex, + useClip as useClipFromIndex, + useActiveToolId as useActiveToolIdFromIndex, + useCursor as useCursorFromIndex, + useProvisional as useProvisionalFromIndex, + useHistory as useHistoryFromIndex, +} from './hooks/index'; + +export function useTimeline(): ReturnType { + return useTimelineFromIndex(useTimelineContext()); +} + +export function useTrackIds(): ReturnType { + return useTrackIdsFromIndex(useTimelineContext()); +} + +export function useTrack(id: TrackId | string): ReturnType { + return useTrackFromIndex(useTimelineContext(), id); +} + +export function useClip(id: ClipId | string): ReturnType { + return useClipFromIndex(useTimelineContext(), id); +} + +/** Returns { id, cursor }. Use useActiveToolId(engine) / useCursor(engine) for separate subs. */ +export function useActiveTool(): { readonly id: string; readonly cursor: string } { + const engine = useTimelineContext(); + const id = useActiveToolIdFromIndex(engine); + const cursor = useCursorFromIndex(engine); + return { id, cursor }; +} + +export function useCanUndo(): boolean { + return useHistoryFromIndex(useTimelineContext()).canUndo; +} + +export function useCanRedo(): boolean { + return useHistoryFromIndex(useTimelineContext()).canRedo; +} + +export function useProvisional(): ProvisionalState | null { + return useProvisionalFromIndex(useTimelineContext()); +} + +// --------------------------------------------------------------------------- +// Phase 6 PlaybackEngine hooks (take PlaybackEngine, not TimelineEngine) +// --------------------------------------------------------------------------- + +export { usePlayhead } from './hooks/usePlayhead'; +export type { UsePlayheadResult } from './hooks/usePlayhead'; +export { usePlayheadEvent } from './hooks/usePlayheadEvent'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type { EngineSnapshot } from './engine'; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts new file mode 100644 index 0000000..b6029f9 --- /dev/null +++ b/packages/react/src/hooks/index.ts @@ -0,0 +1,246 @@ +/** + * Phase R Step 2 — Full hook set + * + * All hooks take engine: TimelineEngine as first arg and use useSyncExternalStore + * with a pure selector. Referential stability of the returned value controls re-renders. + */ + +import { useSyncExternalStore } from 'react'; +import type { TimelineEngine } from '../engine'; +import type { EngineSnapshot } from '../types/engine-snapshot'; +import type { Timeline, Track, Clip } from '@webpacked-timeline/core'; +import type { TrackId, ClipId } from '@webpacked-timeline/core'; +import type { TimelineFrame, ProvisionalState, StateChange } from '@webpacked-timeline/core'; + +type Marker = Timeline['markers'][number]; + +const EMPTY_CLIPS: readonly Clip[] = []; +const EMPTY_MARKERS: readonly Marker[] = []; + +function getServerSnapshot(engine: TimelineEngine, selector: (snap: EngineSnapshot) => T): T { + return selector(engine.getSnapshot()); +} + +// --------------------------------------------------------------------------- +// useTimeline +// --------------------------------------------------------------------------- + +export function useTimeline(engine: TimelineEngine): Timeline { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().state.timeline, + () => getServerSnapshot(engine, (snap) => snap.state.timeline), + ); +} + +// --------------------------------------------------------------------------- +// useTrackIds +// --------------------------------------------------------------------------- + +export function useTrackIds(engine: TimelineEngine): readonly string[] { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().trackIds, + () => getServerSnapshot(engine, (snap) => snap.trackIds), + ); +} + +// --------------------------------------------------------------------------- +// useTrack +// --------------------------------------------------------------------------- + +export function useTrack(engine: TimelineEngine, trackId: TrackId | string): Track | null { + const id = typeof trackId === 'string' ? trackId : (trackId as string); + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().state.timeline.tracks.find((t) => t.id === id) ?? null, + () => + getServerSnapshot( + engine, + (snap) => snap.state.timeline.tracks.find((t) => t.id === id) ?? null, + ), + ); +} + +// --------------------------------------------------------------------------- +// useClip — provisional-aware; isolation: clip A change does not re-render clip B +// --------------------------------------------------------------------------- + +export function useClip(engine: TimelineEngine, clipId: ClipId | string): Clip | null { + const id = typeof clipId === 'string' ? clipId : (clipId as string); + return useSyncExternalStore( + engine.subscribe, + () => { + const snap = engine.getSnapshot(); + if (snap.provisional !== null) { + const ghost = snap.provisional.clips.find((c) => c.id === id); + if (ghost) return ghost; + } + for (const track of snap.state.timeline.tracks) { + const clip = track.clips.find((c) => c.id === id); + if (clip) return clip; + } + return null; + }, + () => { + const snap = engine.getSnapshot(); + if (snap.provisional !== null) { + const ghost = snap.provisional.clips.find((c) => c.id === id); + if (ghost) return ghost; + } + for (const track of snap.state.timeline.tracks) { + const clip = track.clips.find((c) => c.id === id); + if (clip) return clip; + } + return null; + }, + ); +} + +// --------------------------------------------------------------------------- +// useClips +// --------------------------------------------------------------------------- + +export function useClips( + engine: TimelineEngine, + trackId: TrackId | string, +): readonly Clip[] { + const id = typeof trackId === 'string' ? trackId : (trackId as string); + return useSyncExternalStore( + engine.subscribe, + () => + engine.getSnapshot().state.timeline.tracks.find((t) => t.id === id)?.clips ?? EMPTY_CLIPS, + () => + getServerSnapshot( + engine, + (snap) => snap.state.timeline.tracks.find((t) => t.id === id)?.clips ?? EMPTY_CLIPS, + ), + ); +} + +// --------------------------------------------------------------------------- +// useMarkers +// --------------------------------------------------------------------------- + +export function useMarkers(engine: TimelineEngine): readonly Marker[] { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().state.timeline.markers ?? EMPTY_MARKERS, + () => + getServerSnapshot( + engine, + (snap) => snap.state.timeline.markers ?? EMPTY_MARKERS, + ), + ); +} + +// --------------------------------------------------------------------------- +// useHistory — stable object ref when canUndo/canRedo unchanged +// --------------------------------------------------------------------------- + +export function useHistory(engine: TimelineEngine): { + canUndo: boolean; + canRedo: boolean; +} { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().history, + () => getServerSnapshot(engine, (snap) => snap.history), + ); +} + +// --------------------------------------------------------------------------- +// useActiveToolId +// --------------------------------------------------------------------------- + +export function useActiveToolId(engine: TimelineEngine): string { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().activeToolId, + () => getServerSnapshot(engine, (snap) => snap.activeToolId), + ); +} + +// --------------------------------------------------------------------------- +// useCursor +// --------------------------------------------------------------------------- + +export function useCursor(engine: TimelineEngine): string { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().cursor, + () => getServerSnapshot(engine, (snap) => snap.cursor), + ); +} + +// --------------------------------------------------------------------------- +// useProvisional +// --------------------------------------------------------------------------- + +export function useProvisional(engine: TimelineEngine): ProvisionalState | null { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().provisional, + () => getServerSnapshot(engine, (snap) => snap.provisional), + ); +} + +// --------------------------------------------------------------------------- +// usePlayheadFrame — re-renders every frame during playback +// --------------------------------------------------------------------------- + +export function usePlayheadFrame(engine: TimelineEngine): TimelineFrame { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().playhead.currentFrame, + () => getServerSnapshot(engine, (snap) => snap.playhead.currentFrame), + ); +} + +// --------------------------------------------------------------------------- +// useIsPlaying +// --------------------------------------------------------------------------- + +export function useIsPlaying(engine: TimelineEngine): boolean { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().playhead.isPlaying, + () => getServerSnapshot(engine, (snap) => snap.playhead.isPlaying), + ); +} + +// --------------------------------------------------------------------------- +// useChange — advanced: subscribe to diff for custom bailout +// --------------------------------------------------------------------------- + +export function useChange(engine: TimelineEngine): StateChange { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().change, + () => getServerSnapshot(engine, (snap) => snap.change), + ); +} + +// --------------------------------------------------------------------------- +// usePlaybackEngine +// --------------------------------------------------------------------------- + +import type { PlaybackEngine } from '@webpacked-timeline/core'; + +export function usePlaybackEngine(engine: TimelineEngine): PlaybackEngine | null { + return engine.playbackEngine; +} + +// --------------------------------------------------------------------------- +// useSelectedClipIds — re-renders when selection changes +// --------------------------------------------------------------------------- + +const EMPTY_SELECTION: ReadonlySet = new Set(); + +export function useSelectedClipIds(engine: TimelineEngine): ReadonlySet { + return useSyncExternalStore( + engine.subscribe, + () => engine.getSnapshot().selectedClipIds ?? EMPTY_SELECTION, + () => getServerSnapshot(engine, (snap) => snap.selectedClipIds ?? EMPTY_SELECTION), + ); +} diff --git a/packages/react/src/hooks/use-tool-router.ts b/packages/react/src/hooks/use-tool-router.ts new file mode 100644 index 0000000..67fc7a8 --- /dev/null +++ b/packages/react/src/hooks/use-tool-router.ts @@ -0,0 +1,29 @@ +/** + * useToolRouter — Phase R Step 3 + * + * Returns stable ToolRouterHandlers from createToolRouter. + * Recreate only when engine changes. + */ + +import { useMemo } from 'react'; +import type { TimelineEngine } from '../engine'; +import { createToolRouter } from '../adapter/tool-router'; +import type { ToolRouterHandlers } from '../adapter/tool-router'; + +export function useToolRouter( + engine: TimelineEngine, + options: { + getPixelsPerFrame: () => number; + getScrollLeft?: () => number; + }, +): ToolRouterHandlers { + return useMemo( + () => + createToolRouter({ + engine, + getPixelsPerFrame: options.getPixelsPerFrame, + getScrollLeft: options.getScrollLeft, + }), + [engine], + ); +} diff --git a/packages/react/src/hooks/use-virtual-window.ts b/packages/react/src/hooks/use-virtual-window.ts new file mode 100644 index 0000000..7d0ccb6 --- /dev/null +++ b/packages/react/src/hooks/use-virtual-window.ts @@ -0,0 +1,62 @@ +/** + * Virtual rendering hooks — Phase R Step 3 + * + * useVirtualWindow: derived from viewport/scroll/zoom (useMemo). + * useVisibleClips: subscribes to engine and returns clips in window (useSyncExternalStore). + * Result is cached so same (state, window) returns same reference (avoids infinite loop). + */ + +import { useMemo, useSyncExternalStore, useRef } from 'react'; +import type { TimelineEngine } from '../engine'; +import type { VirtualWindow, VirtualClipEntry, TimelineState } from '@webpacked-timeline/core'; +import { getVisibleFrameRange, getVisibleClips } from '@webpacked-timeline/core'; + +export function useVirtualWindow( + _engine: TimelineEngine, + viewportWidth: number, + scrollLeft: number, + pixelsPerFrame: number, +): VirtualWindow { + return useMemo( + () => getVisibleFrameRange(viewportWidth, scrollLeft, pixelsPerFrame), + [viewportWidth, scrollLeft, pixelsPerFrame], + ); +} + +function getVisibleClipsCached( + state: TimelineState, + window: VirtualWindow, + cache: { state: TimelineState | null; window: VirtualWindow | null; result: VirtualClipEntry[] }, +): VirtualClipEntry[] { + if (cache.state === state && cache.window === window) return cache.result; + cache.state = state; + cache.window = window; + cache.result = getVisibleClips(state, window); + return cache.result; +} + +export function useVisibleClips( + engine: TimelineEngine, + window: VirtualWindow, +): VirtualClipEntry[] { + const cache = useRef<{ + state: TimelineState | null; + window: VirtualWindow | null; + result: VirtualClipEntry[]; + }>({ state: null, window: null, result: [] }); + return useSyncExternalStore( + engine.subscribe, + () => + getVisibleClipsCached( + engine.getSnapshot().state, + window, + cache.current, + ), + () => + getVisibleClipsCached( + engine.getSnapshot().state, + window, + cache.current, + ), + ); +} diff --git a/packages/react/src/hooks/useClip.ts b/packages/react/src/hooks/useClip.ts index 57e2751..a2594cd 100644 --- a/packages/react/src/hooks/useClip.ts +++ b/packages/react/src/hooks/useClip.ts @@ -1,84 +1,2 @@ -/** - * @timeline/react - useClip hook - * - * Subscribe to a specific clip by ID. - * - * This hook subscribes to state changes and returns the clip with the - * specified ID. It searches across all tracks to find the clip. - * Re-renders whenever the timeline state changes (currently not optimized - * with selectors). - * - * @example - * ```tsx - * function ClipView({ clipId }: { clipId: string }) { - * const clip = useClip(clipId); - * const engine = useEngine(); - * - * if (!clip) { - * return
Clip not found
; - * } - * - * const handleMove = (newStart: Frame) => { - * engine.moveClip(clipId, newStart); - * }; - * - * return ( - *
- *
Start: {clip.timelineStart}
- *
End: {clip.timelineEnd}
- * - *
- * ); - * } - * ``` - */ - -import { useState, useEffect } from 'react'; -import type { Clip } from '@timeline/core'; -import { useEngine } from './useEngine'; - -/** - * Subscribe to a specific clip - * - * Searches across all tracks to find the clip with the specified ID. - * Re-renders whenever the timeline state changes. - * Returns undefined if the clip is not found. - * - * @param clipId - ID of the clip to subscribe to - * @returns The clip, or undefined if not found - */ -export function useClip(clipId: string): Clip | undefined { - const engine = useEngine(); - - const [clip, setClip] = useState(() => { - const state = engine.getState(); - // Search across all tracks - for (const track of state.timeline.tracks) { - const found = track.clips.find(c => c.id === clipId); - if (found) return found; - } - return undefined; - }); - - useEffect(() => { - // Subscribe to state changes - const unsubscribe = engine.subscribe((state) => { - // Search across all tracks - for (const track of state.timeline.tracks) { - const found = track.clips.find(c => c.id === clipId); - if (found) { - setClip(found); - return; - } - } - setClip(undefined); - }); - - // Cleanup subscription on unmount - return unsubscribe; - }, [engine, clipId]); - - return clip; -} +// Forwarding shim — implementation moved to hooks.ts +export { useClip } from '../hooks'; diff --git a/packages/react/src/hooks/useEngine.ts b/packages/react/src/hooks/useEngine.ts index 937ce9c..77e043b 100644 --- a/packages/react/src/hooks/useEngine.ts +++ b/packages/react/src/hooks/useEngine.ts @@ -1,45 +1,2 @@ -/** - * @timeline/react - useEngine hook - * - * Access the TimelineEngine instance from context. - * - * This hook provides direct access to the engine instance without subscribing - * to state changes. Use this when you need to call engine methods but don't - * need to re-render on state changes. - * - * @example - * ```tsx - * function MyComponent() { - * const engine = useEngine(); - * - * const handleAddClip = () => { - * engine.addClip(trackId, clip); - * }; - * - * return ; - * } - * ``` - */ - -import { useContext } from 'react'; -import { TimelineEngine } from '@timeline/core'; -import { TimelineContext } from '../TimelineProvider'; - -/** - * Access the timeline engine instance - * - * @returns The timeline engine instance - * @throws Error if used outside TimelineProvider - */ -export function useEngine(): TimelineEngine { - const engine = useContext(TimelineContext); - - if (!engine) { - throw new Error( - 'useEngine must be used within a TimelineProvider. ' + - 'Wrap your component tree with .' - ); - } - - return engine; -} +// Forwarding shim — implementation moved to hooks.ts +export { useEngine } from '../hooks'; diff --git a/packages/react/src/hooks/usePlayhead.ts b/packages/react/src/hooks/usePlayhead.ts new file mode 100644 index 0000000..e6efa1a --- /dev/null +++ b/packages/react/src/hooks/usePlayhead.ts @@ -0,0 +1,98 @@ +/** + * usePlayhead — Phase 6 Step 6 + * + * Subscribes to PlaybackEngine playhead state via useSyncExternalStore. + * Returns state + stable action callbacks. + */ + +import { useSyncExternalStore, useCallback } from 'react'; +import type { + PlaybackEngine, + PlayheadState, + PlayheadListener, + TimelineFrame, + PlaybackRate, + PlaybackQuality, + LoopRegion, +} from '@webpacked-timeline/core'; + +export type UsePlayheadResult = { + currentFrame: TimelineFrame; + isPlaying: boolean; + playbackRate: PlaybackRate; + quality: PlaybackQuality; + durationFrames: number; + fps: number; + loopRegion: LoopRegion | null; + prerollFrames: number; + postrollFrames: number; + play: () => void; + pause: () => void; + seekTo: (frame: TimelineFrame) => void; + setPlaybackRate: (rate: PlaybackRate) => void; + setQuality: (quality: PlaybackQuality) => void; + setLoopRegion: (region: LoopRegion | null) => void; + setPreroll: (frames: number) => void; + setPostroll: (frames: number) => void; + seekToStart: () => void; + seekToEnd: () => void; + seekToNextClipBoundary: () => void; + seekToPrevClipBoundary: () => void; + seekToNextMarker: () => void; + seekToPrevMarker: () => void; + toggle: () => void; +}; + +function subscribe(engine: PlaybackEngine, onStoreChange: () => void): () => void { + return engine.on((() => onStoreChange()) as PlayheadListener); +} + +function getSnapshot(engine: PlaybackEngine): PlayheadState { + return engine.getState(); +} + +export function usePlayhead(engine: PlaybackEngine): UsePlayheadResult { + const state = useSyncExternalStore( + (onStoreChange) => subscribe(engine, onStoreChange), + () => getSnapshot(engine), + () => getSnapshot(engine), + ); + + const play = useCallback(() => engine.play(), [engine]); + const pause = useCallback(() => engine.pause(), [engine]); + const seekTo = useCallback((frame: TimelineFrame) => engine.seekTo(frame), [engine]); + const setPlaybackRate = useCallback((rate: PlaybackRate) => engine.setPlaybackRate(rate), [engine]); + const setQuality = useCallback((quality: PlaybackQuality) => engine.setQuality(quality), [engine]); + const setLoopRegion = useCallback((region: LoopRegion | null) => engine.setLoopRegion(region), [engine]); + const setPreroll = useCallback((frames: number) => engine.setPreroll(frames), [engine]); + const setPostroll = useCallback((frames: number) => engine.setPostroll(frames), [engine]); + const seekToStart = useCallback(() => engine.seekToStart(), [engine]); + const seekToEnd = useCallback(() => engine.seekToEnd(), [engine]); + const seekToNextClipBoundary = useCallback(() => engine.seekToNextClipBoundary(), [engine]); + const seekToPrevClipBoundary = useCallback(() => engine.seekToPrevClipBoundary(), [engine]); + const seekToNextMarker = useCallback(() => engine.seekToNextMarker(), [engine]); + const seekToPrevMarker = useCallback(() => engine.seekToPrevMarker(), [engine]); + const toggle = useCallback(() => { + if (engine.getState().isPlaying) engine.pause(); + else engine.play(); + }, [engine]); + + return { + ...state, + play, + pause, + seekTo, + setPlaybackRate, + setQuality, + setLoopRegion, + setPreroll, + setPostroll, + seekToStart, + seekToEnd, + seekToNextClipBoundary, + seekToPrevClipBoundary, + seekToNextMarker, + seekToPrevMarker, + toggle, + }; +} diff --git a/packages/react/src/hooks/usePlayheadEvent.ts b/packages/react/src/hooks/usePlayheadEvent.ts new file mode 100644 index 0000000..a27b6f8 --- /dev/null +++ b/packages/react/src/hooks/usePlayheadEvent.ts @@ -0,0 +1,29 @@ +/** + * usePlayheadEvent — Phase 6 Step 6 + * + * Subscribe to specific playhead events without causing re-renders on every frame. + * Handler is called only when event type matches. Exclude handler from deps — + * use useCallback at call site if needed. + */ + +import { useEffect } from 'react'; +import type { + PlaybackEngine, + PlayheadEventType, + PlayheadListener, + PlayheadEvent, +} from '@webpacked-timeline/core'; + +export function usePlayheadEvent( + engine: PlaybackEngine, + eventType: PlayheadEventType | PlayheadEventType[], + handler: PlayheadListener, +): void { + useEffect(() => { + const types = Array.isArray(eventType) ? eventType : [eventType]; + const unsub = engine.on((event: PlayheadEvent) => { + if (types.includes(event.type)) handler(event); + }); + return unsub; + }, [engine]); +} diff --git a/packages/react/src/hooks/useTimeline.ts b/packages/react/src/hooks/useTimeline.ts index 3884fd6..718b30b 100644 --- a/packages/react/src/hooks/useTimeline.ts +++ b/packages/react/src/hooks/useTimeline.ts @@ -1,66 +1,6 @@ -/** - * @timeline/react - useTimeline hook - * - * Subscribe to the entire timeline state. - * - * This hook subscribes to all state changes and re-renders whenever the - * timeline state changes. Use this for components that need access to the - * full state or need to respond to any state change. - * - * @example - * ```tsx - * function TimelineView() { - * const { state, engine } = useTimeline(); - * - * return ( - *
- *

{state.timeline.name}

- * - * - * - * {state.timeline.tracks.map(track => ( - * - * ))} - *
- * ); - * } - * ``` - */ - -import { useState, useEffect } from 'react'; -import { TimelineState } from '@timeline/core'; -import { useEngine } from './useEngine'; - -/** - * Timeline hook return value - */ -export interface UseTimelineResult { - /** Current timeline state */ - state: TimelineState; - /** Timeline engine instance */ - engine: ReturnType; -} - -/** - * Subscribe to the entire timeline state - * - * Re-renders whenever any part of the timeline state changes. - * - * @returns Current state and engine instance - */ -export function useTimeline(): UseTimelineResult { - const engine = useEngine(); - const [state, setState] = useState(() => engine.getState()); - - useEffect(() => { - // Subscribe to state changes - const unsubscribe = engine.subscribe((newState) => { - setState(newState); - }); - - // Cleanup subscription on unmount - return unsubscribe; - }, [engine]); - - return { state, engine }; -} +// Forwarding shim — implementation moved to hooks.ts +export { useTimeline } from '../hooks'; +// Legacy export: UseTimelineResult was the old shape that returned { state, engine }. +// useTimeline() now returns Timeline directly (breaking change — intentional). +// Keeping this export to avoid unused-export errors; consumers should update. +export type UseTimelineResult = never; diff --git a/packages/react/src/hooks/useTrack.ts b/packages/react/src/hooks/useTrack.ts index e57fd10..6ff73ac 100644 --- a/packages/react/src/hooks/useTrack.ts +++ b/packages/react/src/hooks/useTrack.ts @@ -1,66 +1,2 @@ -/** - * @timeline/react - useTrack hook - * - * Subscribe to a specific track by ID. - * - * This hook subscribes to state changes and returns the track with the - * specified ID. Re-renders whenever the timeline state changes (currently - * not optimized with selectors). - * - * @example - * ```tsx - * function TrackView({ trackId }: { trackId: string }) { - * const track = useTrack(trackId); - * - * if (!track) { - * return
Track not found
; - * } - * - * return ( - *
- *

{track.name}

- *
Clips: {track.clips.length}
- * - * {track.clips.map(clip => ( - * - * ))} - *
- * ); - * } - * ``` - */ - -import { useState, useEffect } from 'react'; -import type { Track } from '@timeline/core'; -import { useEngine } from './useEngine'; - -/** - * Subscribe to a specific track - * - * Re-renders whenever the timeline state changes. - * Returns undefined if the track is not found. - * - * @param trackId - ID of the track to subscribe to - * @returns The track, or undefined if not found - */ -export function useTrack(trackId: string): Track | undefined { - const engine = useEngine(); - - const [track, setTrack] = useState(() => { - const state = engine.getState(); - return state.timeline.tracks.find(t => t.id === trackId); - }); - - useEffect(() => { - // Subscribe to state changes - const unsubscribe = engine.subscribe((state) => { - const newTrack = state.timeline.tracks.find(t => t.id === trackId); - setTrack(newTrack); - }); - - // Cleanup subscription on unmount - return unsubscribe; - }, [engine, trackId]); - - return track; -} +// Forwarding shim — implementation moved to hooks.ts +export { useTrack } from '../hooks'; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 58239f4..615a720 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,49 +1,26 @@ /** - * @timeline/react - * - * React adapter for @timeline/core - * - * This package provides a thin integration layer between the framework-agnostic - * timeline core and React applications. It includes: - * - * - TimelineProvider: Context provider for the engine - * - useEngine: Access the engine instance - * - useTimeline: Subscribe to full timeline state - * - useTrack: Subscribe to a specific track - * - useClip: Subscribe to a specific clip - * + * @webpacked-timeline/react + * + * React adapter for @webpacked-timeline/core. Provides the TimelineProvider context + * and all Phase 1 hooks for subscribing to engine state. + * * @example * ```tsx - * import { TimelineEngine, createTimeline, createTimelineState } from '@timeline/core'; - * import { TimelineProvider, useTimeline } from '@timeline/react'; - * - * // Create engine + * import { TimelineEngine, createTimeline, createTimelineState, toFrame, frameRate } from '@webpacked-timeline/core'; + * import { TimelineProvider, useTimeline, useTrackIds } from '@webpacked-timeline/react'; + * * const engine = new TimelineEngine(createTimelineState({ - * timeline: createTimeline({ ... }) + * timeline: createTimeline({ id: 'tl-1', name: 'My Timeline', fps: frameRate(30), duration: toFrame(9000) }), * })); - * - * // Wrap app in provider + * * function App() { - * return ( - * - * - * - * ); + * return ; * } - * - * // Use hooks in components + * * function TimelineView() { - * const { state, engine } = useTimeline(); - * - * return ( - *
- *

{state.timeline.name}

- * - * {state.timeline.tracks.map(track => ( - * - * ))} - *
- * ); + * const timeline = useTimeline(); + * const trackIds = useTrackIds(); + * return

{timeline.name} — {trackIds.length} tracks

; * } * ``` */ @@ -52,9 +29,43 @@ export { TimelineProvider, TimelineContext } from './TimelineProvider'; export type { TimelineProviderProps } from './TimelineProvider'; -// Hooks -export { useEngine } from './hooks/useEngine'; -export { useTimeline } from './hooks/useTimeline'; -export type { UseTimelineResult } from './hooks/useTimeline'; -export { useTrack } from './hooks/useTrack'; -export { useClip } from './hooks/useClip'; +// Engine class + snapshot type +export { TimelineEngine, DEFAULT_PLAYHEAD_STATE } from './engine'; +export type { EngineSnapshot, TimelineEngineOptions } from './engine'; + +// Phase R Step 2: all hooks (context-based wrappers + engine-as-first-arg from hooks/index) +export { + useEngine, + useTimeline, + useTrackIds, + useTrack, + useClip, + useClips, + useMarkers, + useHistory, + useActiveTool, + useActiveToolId, + useCanUndo, + useCanRedo, + useCursor, + useProvisional, + usePlayheadFrame, + useIsPlaying, + useChange, + usePlaybackEngine, + usePlayhead, + usePlayheadEvent, + useTimelineWithEngine, + useTrackIdsWithEngine, + useTrackWithEngine, + useClipWithEngine, + useProvisionalWithEngine, + useSelectedClipIds, +} from './hooks'; +export type { UsePlayheadResult } from './hooks'; + +// Phase R Step 3: ToolRouter (adapter) + virtual hooks +export { createToolRouter } from './adapter/tool-router'; +export type { ToolRouterOptions, ToolRouterHandlers } from './adapter/tool-router'; +export { useToolRouter } from './hooks/use-tool-router'; +export { useVirtualWindow, useVisibleClips } from './hooks/use-virtual-window'; diff --git a/packages/react/src/tool-router.ts b/packages/react/src/tool-router.ts new file mode 100644 index 0000000..34d84d3 --- /dev/null +++ b/packages/react/src/tool-router.ts @@ -0,0 +1,300 @@ +/** + * tool-router.ts — Phase 1 + * + * Converts raw DOM PointerEvents / KeyboardEvents into TimelinePointerEvents + * and routes them to the appropriate engine handler methods. + * + * RULES: + * - requestAnimationFrame appears ONLY here — do not move it to engine.ts + * - No React imports + * - No dispatch() calls — engine handles all mutations + * - No provisional state management — engine owns that entirely + * - getLayout() is called fresh on every event — never cached + * + * POINTER LEAVE CONTRACT: + * If the cursor leaves the timeline mid-drag, onPointerLeave resets the rAF + * AND calls engine.handlePointerUp() with a synthetic event. This clears + * provisional state (ghost disappears) and cancels the drag cleanly. + */ + +import type { TimelineEngine } from './engine'; +import type { + TimelinePointerEvent, + TimelineKeyEvent, + Modifiers, + TrackId, + ClipId, + TimelineFrame, + TimelineState, +} from '@webpacked-timeline/core'; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * Describes one track's pixel position in the rendered timeline. + * Provided by the UI layer on each event — the router never computes this. + */ +export type TrackLayout = { + readonly trackId: TrackId; + readonly top: number; // clientY of the track's top edge (px) + readonly height: number; // track height (px) +}; + +/** + * The layout state the router needs to convert coordinates. + * Returned fresh by getLayout() on every event — always reflects current zoom/scroll. + */ +export type RouterLayout = { + readonly timelineOriginX: number; // clientX where frame 0 starts + readonly pixelsPerFrame: number; // px per frame at current zoom + readonly trackLayouts: readonly TrackLayout[]; +}; + +/** + * Stable DOM event handler references returned by createToolRouter(). + * Attach these to the timeline container element once — they never need + * re-attaching on zoom, scroll, or tool switches. + */ +export type ToolRouterHandlers = { + readonly onPointerDown: (e: PointerEvent) => void; + readonly onPointerMove: (e: PointerEvent) => void; + readonly onPointerUp: (e: PointerEvent) => void; + /** Resets rAF and calls handlePointerUp to clear provisional on cursor leave. */ + readonly onPointerLeave: (e: PointerEvent) => void; + readonly onKeyDown: (e: KeyboardEvent) => void; + readonly onKeyUp: (e: KeyboardEvent) => void; +}; + +// --------------------------------------------------------------------------- +// Coordinate conversion utilities (module-level, not exported) +// --------------------------------------------------------------------------- + +/** + * Convert clientX to a TimelineFrame. + * Uses Math.floor — frames are integers, left edge of each frame cell. + */ +export function frameAtX( + clientX: number, + timelineOriginX: number, + ppf: number, +): TimelineFrame { + return Math.floor((clientX - timelineOriginX) / ppf) as TimelineFrame; +} + +/** + * Convert clientY to the TrackId of the track under the cursor, or null. + * Iterates track layouts linearly — timeline track counts are small (< 100). + */ +export function trackAtY( + clientY: number, + trackLayouts: readonly TrackLayout[], +): TrackId | null { + for (const layout of trackLayouts) { + if (clientY >= layout.top && clientY < layout.top + layout.height) { + return layout.trackId; + } + } + return null; +} + +/** + * Hit-test: find the ClipId under the cursor at the given frame on a track. + * Returns null if no clip covers that frame, or if trackId is null. + * + * Clips are sorted by timelineStart (invariant) — linear scan is fine for + * typical clip counts per track (< 200). + */ +export function clipAtFrame( + frame: TimelineFrame, + trackId: TrackId | null, + state: TimelineState, +): ClipId | null { + if (trackId === null) return null; + const track = state.timeline.tracks.find(t => t.id === trackId); + if (!track) return null; + const clip = track.clips.find( + c => frame >= c.timelineStart && frame < c.timelineEnd, + ); + return clip ? clip.id : null; +} + +/** Extract Modifiers from any PointerEvent or KeyboardEvent. */ +export function extractModifiers(e: PointerEvent | KeyboardEvent): Modifiers { + return { + shift: e.shiftKey, + alt: e.altKey, + ctrl: e.ctrlKey, + meta: e.metaKey, + }; +} + +/** + * Convert a raw PointerEvent into a TimelinePointerEvent. + * Populates clipId via hit-test against the current committed state. + */ +function convertPointerEvent( + e: PointerEvent, + layout: RouterLayout, + state: TimelineState, +): TimelinePointerEvent { + const frame = frameAtX(e.clientX, layout.timelineOriginX, layout.pixelsPerFrame); + const trackId = trackAtY(e.clientY, layout.trackLayouts); + const clipId = clipAtFrame(frame, trackId, state); + + return { + frame, + trackId, + clipId, + x: e.clientX, + y: e.clientY, + buttons: e.buttons, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + }; +} + +/** Convert a raw KeyboardEvent into a TimelineKeyEvent. */ +function convertKeyEvent(e: KeyboardEvent): TimelineKeyEvent { + return { + key: e.key, + code: e.code, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + repeat: e.repeat, + }; +} + +// --------------------------------------------------------------------------- +// createToolRouter — public factory +// --------------------------------------------------------------------------- + +/** + * Creates stable DOM event handlers that wire raw browser events to the engine. + * + * @param engine - The Phase 1 TimelineEngine instance. + * @param getLayout - Called on every event to get current zoom/scroll state. + * Never cached — always reflects the latest UI layout. + * + * @returns Stable handler references. Safe to attach as DOM event listeners + * without re-attaching on zoom, scroll, or tool changes. + * + * @example + * ```tsx + * const router = createToolRouter(engine, () => ({ + * timelineOriginX: containerRef.current.getBoundingClientRect().left, + * pixelsPerFrame: ppf, + * trackLayouts: computeTrackLayouts(tracks), + * })); + * + *
+ * ``` + */ +export function createToolRouter( + engine: TimelineEngine, + getLayout: () => RouterLayout, +): ToolRouterHandlers { + + // ── Closure state ───────────────────────────────────────────────────────── + // Mutable — deliberately not frozen. These are an implementation detail + // of the rAF throttle and leave-cancel logic. + + /** True while a requestAnimationFrame callback is outstanding. */ + let rafPending: boolean = false; + + /** The most-recent pointermove event — overwritten on every move call. */ + let lastMoveEvent: PointerEvent | null = null; + + /** Modifiers captured at the same time as lastMoveEvent. */ + let lastMoveModifiers: Modifiers | null = null; + + // ── rAF throttle ────────────────────────────────────────────────────────── + + /** + * Called on every raw pointermove. Always captures the most-recent event, + * but schedules at most ONE requestAnimationFrame per vsync frame. + * Intermediate events are dropped — the rAF callback always processes + * the latest position, not a queued history. + * + * This is the ONLY place requestAnimationFrame appears in the codebase. + */ + function throttledPointerMove(e: PointerEvent): void { + // Always capture most-recent — even if rAF is already pending + lastMoveEvent = e; + lastMoveModifiers = extractModifiers(e); + + if (rafPending) return; // rAF already scheduled — drop this schedule request + rafPending = true; + + requestAnimationFrame(() => { + rafPending = false; + + // onPointerLeave may have nulled these after the rAF was queued + if (!lastMoveEvent || !lastMoveModifiers) return; + + const layout = getLayout(); + const state = engine.getSnapshot().state; + const converted = convertPointerEvent(lastMoveEvent, layout, state); + engine.handlePointerMove(converted, lastMoveModifiers); + }); + } + + // ── Handler object ───────────────────────────────────────────────────────── + + return { + onPointerDown(e: PointerEvent): void { + const layout = getLayout(); + const state = engine.getSnapshot().state; + const converted = convertPointerEvent(e, layout, state); + engine.handlePointerDown(converted, extractModifiers(e)); + }, + + onPointerMove(e: PointerEvent): void { + throttledPointerMove(e); + }, + + onPointerUp(e: PointerEvent): void { + const layout = getLayout(); + const state = engine.getSnapshot().state; + const converted = convertPointerEvent(e, layout, state); + engine.handlePointerUp(converted, extractModifiers(e)); + }, + + /** + * Cursor left the timeline mid-drag. + * + * Reset the rAF (kill queued callback via null-guard) then call + * handlePointerUp to clear provisional state. Without this, the ghost + * clip would render indefinitely — pointerup fires on a different element. + */ + onPointerLeave(e: PointerEvent): void { + // Kill outstanding rAF — its null-guard prevents a stale engine call + rafPending = false; + lastMoveEvent = null; + lastMoveModifiers = null; + + // Synthetic pointerup — clears provisional, tool.onPointerUp returns null + const layout = getLayout(); + const state = engine.getSnapshot().state; + const converted = convertPointerEvent(e, layout, state); + engine.handlePointerUp(converted, extractModifiers(e)); + }, + + onKeyDown(e: KeyboardEvent): void { + engine.handleKeyDown(convertKeyEvent(e), extractModifiers(e)); + }, + + onKeyUp(e: KeyboardEvent): void { + engine.handleKeyUp(convertKeyEvent(e), extractModifiers(e)); + }, + }; +} diff --git a/packages/react/src/types/engine-snapshot.ts b/packages/react/src/types/engine-snapshot.ts new file mode 100644 index 0000000..f349fb4 --- /dev/null +++ b/packages/react/src/types/engine-snapshot.ts @@ -0,0 +1,64 @@ +/** + * EngineSnapshot — Phase R Step 1 + * + * The single object useSyncExternalStore reads. + * Includes edit state, playback state (merged), and change for hook optimization. + */ + +import type { + TimelineState, + ProvisionalState, + StateChange, + PlayheadState, + PipelineConfig, + Clock, + CompressionPolicy, + ITool, + TimelineFrame, +} from '@webpacked-timeline/core'; +import { toFrame } from '@webpacked-timeline/core'; + +export type TimelineEngineOptions = { + initialState: TimelineState; + pipeline?: PipelineConfig; + dimensions?: { width: number; height: number }; + clock?: Clock; + historyLimit?: number; + compression?: CompressionPolicy; + /** Merged with defaults; duplicate ids override. */ + tools?: ITool[]; + /** Default active tool id (default: 'selection'). */ + defaultToolId?: string; + onMarkIn?: (frame: TimelineFrame) => void; + onMarkOut?: (frame: TimelineFrame) => void; + onZoomChange?: (pixelsPerFrame: number) => void; + getPixelsPerFrame?: () => number; +}; + +export type EngineSnapshot = { + readonly state: TimelineState; + readonly provisional: ProvisionalState | null; + readonly activeToolId: string; + readonly canUndo: boolean; + readonly canRedo: boolean; + /** Stable ref for useHistory — only changes when canUndo/canRedo change. */ + readonly history: { readonly canUndo: boolean; readonly canRedo: boolean }; + readonly trackIds: readonly string[]; + readonly cursor: string; + readonly playhead: PlayheadState; + readonly change: StateChange; + /** Currently selected clip IDs. */ + readonly selectedClipIds: ReadonlySet; +}; + +export const DEFAULT_PLAYHEAD_STATE: PlayheadState = { + currentFrame: toFrame(0), + isPlaying: false, + playbackRate: 1.0, + quality: 'full', + durationFrames: 0, + fps: 30, + loopRegion: null, + prerollFrames: 0, + postrollFrames: 0, +}; diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json index c372e21..675b788 100644 --- a/packages/react/tsconfig.json +++ b/packages/react/tsconfig.json @@ -27,5 +27,5 @@ "rootDir": "./src" }, "include": ["src"], - "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx", "examples"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx", "examples", "**/App.tsx"] } diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 0000000..eacbfa7 --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/__tests__/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '**/*.d.ts', + '**/*.config.*', + '**/__tests__/setup.ts', + ], + }, + }, +}); diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md new file mode 100644 index 0000000..3c6ce45 --- /dev/null +++ b/packages/ui/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to `@webpacked-timeline/ui` are documented here. +Format: [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [1.0.0-beta.1] - 2026-03-07 + +### Added +- DaVinci Resolve-style preset with 6 components: + - `DaVinciEditor` — full-layout editor (toolbar + ruler + tracks + clips + playhead) + - `DaVinciToolbar` — tool buttons, zoom controls, undo/redo, play/pause + - `DaVinciRuler` — timecode ruler with major/minor tick marks + - `DaVinciTrack` — track label row with name, type badge, lock/visibility, solo/mute (audio), clip count + - `DaVinciClip` — clip block with waveform visualization, label, trim handles, accent strip + - `DaVinciPlayhead` — red playhead line +- `TimelineProvider` context and `useTimelineContext` / `useEngine` for custom layouts +- Shared utilities: `frameToPx`, `pxToFrame`, `frameToTimecode`, `rulerTickInterval`, `clamp`, `cn` +- CSS variable theming system with ~50 tokens in `tokens.css` +- DaVinci dark theme override in `davinci.css` +- Style entry points: `@webpacked-timeline/ui/styles/davinci` and `@webpacked-timeline/ui/styles/tokens` +- Full keyboard shortcut support (V/C/T/R/S/Y/H for tools, Space for play, arrow keys for scrubbing, Cmd+Z for undo) +- Track resize (drag handle between tracks) +- Clip selection, multi-select (Cmd+A), and deletion (Delete/Backspace) +- Virtual windowing for clips outside viewport +- Snap indicator lines during drag operations +- Add/delete tracks from the label column +- Add clips to tracks +- Zoom slider with logarithmic scale +- Playhead auto-scroll during playback +- Hand tool for panning +- Tabler-based SVG icon set diff --git a/packages/ui/README.md b/packages/ui/README.md index 9681195..35f5045 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -1,38 +1,138 @@ -# @timeline/ui +# @webpacked-timeline/ui -Installable UI components for timeline infrastructure. +DaVinci-style React timeline editor. One import. Full professional timeline. -## Installation +## Install ```bash -npm install @timeline/ui +npm install @webpacked-timeline/ui @webpacked-timeline/react @webpacked-timeline/core ``` +## Quick Start (30 seconds) + +```tsx +import { DaVinciEditor } from '@webpacked-timeline/ui'; +import '@webpacked-timeline/ui/styles/davinci'; +import { TimelineEngine } from '@webpacked-timeline/react'; +import { createTimelineState, createTimeline, toFrame, frameRate } from '@webpacked-timeline/core'; + +const engine = new TimelineEngine({ + initialState: createTimelineState({ + timeline: createTimeline({ + id: 'tl-1', + name: 'My Timeline', + fps: frameRate(30), + duration: toFrame(9000), + }), + }), +}); + +export default function App() { + return ; +} +``` + +That's it — a full DaVinci Resolve-style timeline editor with toolbar, ruler, tracks, clips, playhead, undo/redo, and keyboard shortcuts. + ## Components -- `Timeline` - Root timeline container -- `Track` - Single track row -- `Clip` - Individual clip block -- `TimeRuler` - Timeline ruler with frame markers +All components are exported from the package root: + +| Component | Description | +|-----------|-------------| +| `DaVinciEditor` | Full-layout editor (toolbar + ruler + tracks + playhead) | +| `DaVinciToolbar` | Tool buttons, zoom controls, transport (undo/redo/play) | +| `DaVinciRuler` | Timecode ruler with major/minor ticks | +| `DaVinciTrack` | Track label row (name, type badge, lock, solo/mute) | +| `DaVinciClip` | Clip block with waveform, label, trim handles | +| `DaVinciPlayhead` | Red playhead line | + +### DaVinciEditor Props + +```typescript +interface DaVinciEditorProps { + engine: TimelineEngine; // from @webpacked-timeline/react + initialPpf?: number; // initial pixels per frame (default: 4) + onPpfChange?: (ppf: number) => void; + registerZoomHandler?: (handler: (ppf: number) => void) => void; + className?: string; + style?: React.CSSProperties; +} +``` + +### Context & Utilities -## Usage +For custom layouts, use the context directly: ```tsx -import { Timeline } from "@timeline/ui"; -import { TimelineProvider } from "@timeline/react"; - -function App() { - return ( - - - - ); +import { TimelineProvider, useTimelineContext, useEngine } from '@webpacked-timeline/ui'; +import { frameToPx, pxToFrame, frameToTimecode } from '@webpacked-timeline/ui'; +``` + +## Theming + +All visual properties are controlled by CSS custom properties. Import the DaVinci theme: + +```css +@import '@webpacked-timeline/ui/styles/davinci'; +``` + +Override any token in your CSS: + +```css +:root { + --tl-clip-video-bg: hsl(270 70% 50%); + --tl-track-height: 60px; + --tl-playhead-color: hsl(120 60% 50%); } ``` -## Styling +### Key Tokens + +| Token | Default | Description | +|-------|---------|-------------| +| `--tl-app-bg` | `hsl(220 13% 9%)` | App background | +| `--tl-panel-bg` | `hsl(220 13% 11%)` | Panel background | +| `--tl-toolbar-bg` | `hsl(220 13% 11%)` | Toolbar background | +| `--tl-toolbar-height` | `40px` | Toolbar height | +| `--tl-ruler-height` | `32px` | Ruler height | +| `--tl-track-height` | `80px` | Track row height | +| `--tl-track-bg-video` | `#28282E` | Video track background | +| `--tl-track-bg-audio` | `#28282E` | Audio track background | +| `--tl-clip-video-bg` | `#2E77A5` | Video clip fill | +| `--tl-clip-audio-bg` | `#179160` | Audio clip fill | +| `--tl-clip-radius` | `2px` | Clip border radius | +| `--tl-clip-text` | `hsl(0 0% 92%)` | Clip label color | +| `--tl-playhead-color` | `#ff3b30` | Playhead line color | +| `--tl-timecode-color` | `hsl(0 0% 88%)` | Timecode text color | +| `--tl-label-width` | `200px` | Track label column width | +| `--tl-snap-color` | `hsl(45 90% 60%)` | Snap indicator color | + +See [tokens.css](src/tokens.css) for the full list of ~50 tokens. All colors controlled by CSS variables — no hardcoded colors in components. + +## Keyboard Shortcuts + +| Key | Action | +|-----|--------| +| `V` | Selection tool | +| `C` | Razor tool | +| `T` | Ripple Trim | +| `R` | Roll Trim | +| `S` | Slip | +| `Y` | Slide | +| `H` | Hand (pan) | +| `Space` | Play/Pause | +| `←` / `→` | Step 1 frame | +| `Shift+←/→` | Step 10 frames | +| `Cmd+Z` | Undo | +| `Cmd+Shift+Z` | Redo | +| `Delete` | Delete selected clips | +| `Cmd+A` | Select all | +| `Escape` | Clear selection | + +## Presets -Components use Tailwind CSS classes. You can override styles via the `className` prop. +The DaVinci preset ships with `@webpacked-timeline/ui`. More presets are planned. ## License diff --git a/packages/ui/docs/ARCHITECTURE.md b/packages/ui/docs/ARCHITECTURE.md new file mode 100644 index 0000000..3ae89f0 --- /dev/null +++ b/packages/ui/docs/ARCHITECTURE.md @@ -0,0 +1,245 @@ +We are starting Phase U. +Before any component code, create this file: + +packages/ui/ARCHITECTURE.md + +Content: + +════════════════════════════════════════════════════════ +@webpacked-timeline/ui — Architecture & Contract +════════════════════════════════════════════════════════ + +## Philosophy + +@webpacked-timeline/ui is not a component library. +It is a component distribution system. + +When you run `npx @webpacked-timeline/ui add clip`, a clip.tsx +lands in YOUR project. You own it. Edit every pixel, +every animation, every class. There are no version +conflicts, no !important fights, no "the library +doesn't support that prop." + +The only things you import from @timeline packages +are @webpacked-timeline/core (types + engine) and +@webpacked-timeline/react (hooks). Never the UI. + +## What gets copied into your project + +components/timeline/ + _shared/ ← copied first, once + time.ts ← timeToPx, pxToTime, frameToTimecode + geometry.ts ← rect math, hit testing + interaction.ts ← drag state, pointer capture + use-drag.ts ← useDrag() hook + use-snap.ts ← useSnap() hook + timeline.css ← CSS variable tokens + timeline-root.tsx ← Tier 1 + track.tsx + clip.tsx + playhead.tsx + ruler.tsx + toolbar.tsx ← Tier 2 + zoom-bar.tsx + thumbnail-strip.tsx ← Tier 3 + waveform.tsx + clip-label.tsx + effect-lane.tsx ← Tier 4 + keyframe-diamond.tsx + transition-handle.tsx + marker-pin.tsx ← Tier 5 + marker-range.tsx + in-out-handles.tsx + +## Provider pattern + +timeline-root.tsx is the ONLY component that +takes engine as a prop. It wraps children in +TimelineProvider: + + + {children} + + +All other components read engine from context: + + const engine = useTimelineEngine() + +This is not global magic. Engine is explicit at +the root. One place. Clear ownership. + +Override: any component can accept an optional +engine prop to bypass context: + + + +## Rendering contract + +Components MAY: + - Read engine state via hooks + - Call engine commands (dispatch, activateTool, + play, pause, seekTo, undo, redo) + - Render provisional state from useProvisional() + - Subscribe to playhead via usePlayheadFrame() + +Components MUST NOT: + - Modify engine state directly (no engine.state.x = y) + - Store canonical engine values in local useState + (no const [frame, setFrame] = useState(0) when + frame comes from the engine) + - Embed tool logic (tools live in core) + - Import from other copied components + (import only from _shared/ and @webpacked-timeline/*) + +## Tool system + +Tool state is managed entirely by @webpacked-timeline/core. +Components respect tool mode — they do not define it. + +engine.activateTool('razor') // switch tool +engine.getActiveToolId() // read active tool +useActiveToolId(engine) // React hook + +The timeline-root component attaches the ToolRouter +(from @webpacked-timeline/react) to its DOM container. +All pointer events flow: DOM → ToolRouter → ITool. +Components never handle pointer events directly +for tool purposes. + +## CSS variable tokens + +All visual values are CSS variables defined in +timeline.css. No hardcoded colors anywhere. + +Token naming: --tl-{component}-{property}-{state} + +Examples: + --tl-clip-bg + --tl-clip-bg-selected + --tl-clip-bg-provisional + --tl-track-height + --tl-playhead-color + --tl-ruler-height + --tl-waveform-color + --tl-keyframe-color + +Override any token: + :root { --tl-clip-bg: hsl(142 71% 45%); } + +## Themes + +Themes are CSS files that override token values. +Install: npx @webpacked-timeline/ui add theme --theme=dark-pro + +Available themes: + dark-pro (default, DaVinci-inspired) + light (Final Cut Pro-inspired) + high-contrast + +## Presets + +Presets are convenience installers. Not locked +bundles. Fully editable after install. + + npx @webpacked-timeline/ui add --preset=minimal + Installs: timeline-root, track, clip, + playhead, ruler + + npx @webpacked-timeline/ui add --preset=video-editor + Installs: all Tier 1–4 components + + npx @webpacked-timeline/ui add --preset=audio-editor + Installs: timeline-root, track, clip, + waveform, playhead, ruler, toolbar + +## Versioning and updates + +Copied files belong to you. The CLI never +silently overwrites them. + + npx @webpacked-timeline/ui diff clip + Shows what changed between your version + and the latest registry version. + + npx @webpacked-timeline/ui update clip + Shows diff first, then asks for confirmation. + Use --force to skip confirmation. + + npx @webpacked-timeline/ui update clip --force + Overwrites without confirmation. + +## Performance policy + +Components mount only visible clips. +useVisibleClips(engine, window) from @webpacked-timeline/react +returns VirtualClipEntry[] with isVisible flags. + +Timeline renders ALL tracks but only mounts clip +components where isVisible === true. + +Future (Phase P): full windowed virtualization +using @webpacked-timeline/core TrackIndex for O(log n) lookup. + +## Accessibility policy + +Minimum viable accessibility shipped with v0.1: + - Keyboard scrubbing (arrow keys via KeyboardHandler) + - Focus ring tokens (--tl-focus-ring) + - ARIA role="region" on timeline root + - ARIA role="listitem" on tracks + - aria-label on playhead + +Full accessibility (WCAG 2.1 AA) is a roadmap item. + +## Non-goals for v0.x + +These are explicitly out of scope: + - Built-in video rendering pipeline + - Cloud export or storage + - Asset management backend + - Real-time collaboration layer + - Mobile touch support (pointer events only) + - Accessibility beyond minimum viable + - Server-side rendering support + +## Component anatomy + +Every component follows this structure: + + 1. Imports (@webpacked-timeline/react, _shared/, react) + 2. Props interface (documented with JSDoc) + 3. Component function + 4. Internal sub-components (if needed, in same file) + 5. Export + +Props interface minimum: + className?: string + style?: React.CSSProperties + +Render prop escape hatches where content varies: + renderLabel?: (clip: Clip) => React.ReactNode + renderThumbnail?: (clip: Clip) => React.ReactNode + renderOverlay?: (clip: Clip) => React.ReactNode + +## Testing policy + +CLI: unit tests for registry, file copier, + dependency resolver, diff engine + +Registry: integrity test — every registered + component's files exist and compile + +Components: render tests using + @testing-library/react + +Integration: example project in + packages/ui/examples/ + that installs components and renders + +════════════════════════════════════════════════════════ +ACCEPTANCE +════════════════════════════════════════════════════════ +- Create packages/ui/ARCHITECTURE.md +- No code changes +- No test changes +- pnpm exec tsc --noEmit → 0 errors \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index b31da1a..4a724f0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,36 +1,51 @@ { - "name": "@timeline/ui", - "version": "0.1.0", - "description": "Installable UI components for timeline infrastructure", + "name": "@webpacked-timeline/ui", + "version": "1.0.0-beta.1", + "description": "DaVinci-style React timeline UI. Drop-in components built on @webpacked-timeline/core and @webpacked-timeline/react.", "type": "module", - "main": "./dist/index.js", + "main": "./dist/index.cjs", + "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", + "require": "./dist/index.cjs", "types": "./dist/index.d.ts" - } + }, + "./styles/davinci": "./dist/davinci.css", + "./styles/tokens": "./dist/tokens.css" }, - "files": [ - "dist", - "src" - ], + "files": ["dist", "README.md", "CHANGELOG.md"], "scripts": { - "build": "tsc", - "dev": "tsc --watch", + "build": "vite build && tsc --emitDeclarationOnly --outDir dist && cp src/tokens.css src/davinci.css dist/", + "dev": "vite build --watch", + "typecheck": "tsc --noEmit", "clean": "rm -rf dist" }, - "dependencies": { - "@timeline/core": "workspace:*", - "@timeline/react": "workspace:*" - }, "peerDependencies": { - "react": "^18.0.0", - "react-dom": "^18.0.0" + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0", + "@webpacked-timeline/core": ">=1.0.0-beta.1", + "@webpacked-timeline/react": ">=1.0.0-beta.1" }, "devDependencies": { + "@webpacked-timeline/core": "workspace:*", + "@webpacked-timeline/react": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "typescript": "^5.0.0" - } + "@types/node": "^20.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0" + }, + "keywords": ["timeline", "react", "nle", "davinci", "video-editor", "ui", "components", "typescript"], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/manas-timeline/timeline", + "directory": "packages/ui" + }, + "homepage": "https://github.com/manas-timeline/timeline/tree/main/packages/ui#readme" } diff --git a/packages/ui/src/context/TimelineUIContext.tsx b/packages/ui/src/context/TimelineUIContext.tsx deleted file mode 100644 index 7caaf7a..0000000 --- a/packages/ui/src/context/TimelineUIContext.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { createContext, useContext, useState, ReactNode } from 'react'; -import { frame, type Frame } from '@timeline/core'; - -/** - * UI Interaction State - * - * Separate from engine state - this is purely for UI interactions. - * Does NOT integrate with undo/redo. - */ -export interface TimelineUIState { - /** Current playhead position */ - playhead: Frame; - - /** Zoom level (pixels per frame) */ - zoom: number; - - /** Whether snapping is enabled */ - snappingEnabled: boolean; - - /** Current editing mode */ - editingMode: 'normal' | 'ripple' | 'insert'; - - /** Set of selected clip IDs */ - selectedClipIds: Set; -} - -/** - * UI Context Actions - */ -export interface TimelineUIActions { - setPlayhead: (playhead: Frame) => void; - setZoom: (zoom: number) => void; - setSnappingEnabled: (enabled: boolean) => void; - setEditingMode: (mode: 'normal' | 'ripple' | 'insert') => void; - setSelectedClipIds: (clipIds: Set) => void; - toggleClipSelection: (clipId: string) => void; - clearSelection: () => void; -} - -/** - * Combined UI Context - */ -export interface TimelineUIContextValue { - state: TimelineUIState; - actions: TimelineUIActions; -} - -const TimelineUIContext = createContext(null); - -/** - * TimelineUI Provider Props - */ -export interface TimelineUIProviderProps { - children: ReactNode; - initialPlayhead?: Frame; - initialZoom?: number; - initialSnappingEnabled?: boolean; - initialEditingMode?: 'normal' | 'ripple' | 'insert'; -} - -/** - * TimelineUI Provider - * - * Provides UI interaction state to all timeline components. - * Separate from engine state. - */ -export function TimelineUIProvider({ - children, - initialPlayhead = frame(0), - initialZoom = 1, - initialSnappingEnabled = true, - initialEditingMode = 'normal', -}: TimelineUIProviderProps) { - const [playhead, setPlayhead] = useState(initialPlayhead); - const [zoom, setZoom] = useState(initialZoom); - const [snappingEnabled, setSnappingEnabled] = useState(initialSnappingEnabled); - const [editingMode, setEditingMode] = useState<'normal' | 'ripple' | 'insert'>(initialEditingMode); - const [selectedClipIds, setSelectedClipIds] = useState>(new Set()); - - const toggleClipSelection = (clipId: string) => { - setSelectedClipIds((prev) => { - const next = new Set(prev); - if (next.has(clipId)) { - next.delete(clipId); - } else { - next.add(clipId); - } - return next; - }); - }; - - const clearSelection = () => { - setSelectedClipIds(new Set()); - }; - - const value: TimelineUIContextValue = { - state: { - playhead, - zoom, - snappingEnabled, - editingMode, - selectedClipIds, - }, - actions: { - setPlayhead, - setZoom, - setSnappingEnabled, - setEditingMode, - setSelectedClipIds, - toggleClipSelection, - clearSelection, - }, - }; - - return ( - - {children} - - ); -} - -/** - * Hook to access TimelineUI context - * - * @throws Error if used outside TimelineUIProvider - */ -export function useTimelineUI(): TimelineUIContextValue { - const context = useContext(TimelineUIContext); - - if (!context) { - throw new Error('useTimelineUI must be used within TimelineUIProvider'); - } - - return context; -} diff --git a/packages/ui/src/context/index.ts b/packages/ui/src/context/index.ts deleted file mode 100644 index 0011494..0000000 --- a/packages/ui/src/context/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { TimelineUIProvider, useTimelineUI } from './TimelineUIContext'; -export type { TimelineUIState, TimelineUIActions, TimelineUIContextValue } from './TimelineUIContext'; diff --git a/packages/ui/src/context/timeline-context.tsx b/packages/ui/src/context/timeline-context.tsx new file mode 100644 index 0000000..85f2b90 --- /dev/null +++ b/packages/ui/src/context/timeline-context.tsx @@ -0,0 +1,109 @@ +/** + * Timeline Context — coordination hub for all timeline components. + * + * Provides engine, zoom/scroll state, and layout constants to all + * child components via React context. No prop drilling needed. + */ +import React, { createContext, useContext, useState, useCallback, useRef } from 'react'; +import type { TimelineEngine } from '@webpacked-timeline/react'; + +// ── Types ────────────────────────────────────────────────────────────────── + +export interface TimelineContextValue { + engine: TimelineEngine; + ppf: number; + ppfRef: React.MutableRefObject; + setPpf: (v: number) => void; + scrollLeft: number; + scrollRef: React.MutableRefObject; + setScrollLeft: (v: number) => void; + vpWidth: number; + setVpWidth: (v: number) => void; + labelWidth: number; + rulerHeight: number; + toolbarHeight: number; +} + +export interface TimelineProviderProps { + engine: TimelineEngine; + children: React.ReactNode; + initialPpf?: number; + onPpfChange?: (ppf: number) => void; + labelWidth?: number; + rulerHeight?: number; + toolbarHeight?: number; +} + +// ── Context ──────────────────────────────────────────────────────────────── + +const TimelineCtx = createContext(null); + +// ── Provider ─────────────────────────────────────────────────────────────── + +export function TimelineProvider({ + engine, + children, + initialPpf = 4, + onPpfChange, + labelWidth = 200, + rulerHeight = 32, + toolbarHeight = 40, +}: TimelineProviderProps) { + // ── PPF (pixels per frame) ── + const [ppf, setPpfState] = useState(initialPpf); + const ppfRef = useRef(initialPpf); + + const setPpf = useCallback( + (v: number) => { + const clamped = Math.max(0.5, Math.min(100, v)); + ppfRef.current = clamped; + setPpfState(clamped); + onPpfChange?.(clamped); + }, + [onPpfChange], + ); + + // ── Scroll ── + const [scrollLeft, setScrollState] = useState(0); + const scrollRef = useRef(0); + + const setScrollLeft = useCallback((v: number) => { + const clamped = Math.max(0, v); + scrollRef.current = clamped; + setScrollState(clamped); + }, []); + + // ── Viewport width ── + const [vpWidth, setVpWidth] = useState(1200); + + const value: TimelineContextValue = { + engine, + ppf, + ppfRef, + setPpf, + scrollLeft, + scrollRef, + setScrollLeft, + vpWidth, + setVpWidth, + labelWidth, + rulerHeight, + toolbarHeight, + }; + + return {children}; +} + +// ── Hooks ────────────────────────────────────────────────────────────────── + +export function useTimelineContext(): TimelineContextValue { + const ctx = useContext(TimelineCtx); + if (!ctx) { + throw new Error('useTimelineContext must be used within a '); + } + return ctx; +} + +export function useEngine(): TimelineEngine { + return useTimelineContext().engine; +} diff --git a/packages/ui/src/davinci.css b/packages/ui/src/davinci.css new file mode 100644 index 0000000..9ace613 --- /dev/null +++ b/packages/ui/src/davinci.css @@ -0,0 +1,45 @@ +/* ═══════════════════════════════════════════════════════════ + @webpacked-timeline/ui — DaVinci Resolve Theme + + Imports base tokens and applies DaVinci-specific overrides. + Import this file to get the full DaVinci look: + + import '@webpacked-timeline/ui/styles/davinci'; + ═══════════════════════════════════════════════════════════ */ + +@import './tokens.css'; + +:root { + /* DaVinci-specific overrides */ + --tl-track-height: 80px; + --tl-track-height-audio: 80px; + --tl-timecode-color: hsl(0 0% 88%); + --tl-label-width: 200px; + --tl-clip-video-accent: hsl(195 85% 72%); + --tl-clip-video-text: hsl(195 85% 15%); + --tl-clip-audio-text: hsl(142 70% 12%); +} + +/* ── Scrollbar styling ── */ +.tl-scroll-area::-webkit-scrollbar { + height: 10px; + width: 10px; +} +.tl-scroll-area::-webkit-scrollbar-track { + background: var(--tl-scrollbar-track); +} +.tl-scroll-area::-webkit-scrollbar-thumb { + background: var(--tl-scrollbar-thumb); + border-radius: 5px; +} +.tl-scroll-area::-webkit-scrollbar-thumb:hover { + background: var(--tl-scrollbar-thumb-hover); +} +.tl-scroll-area::-webkit-scrollbar-corner { + background: var(--tl-scrollbar-track); +} + +/* ── Button hover ── */ +.tl-btn:hover { + background: var(--tl-btn-bg-hover) !important; +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dceae50..ed53ef6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,2 +1,51 @@ -export * from './timeline'; -export * from './context'; +/** + * @webpacked-timeline/ui — Public API + * + * DaVinci Resolve–style timeline editor components. + * + * Quick start: + * import { DaVinciEditor } from '@webpacked-timeline/ui'; + * import '@webpacked-timeline/ui/styles/davinci'; + * + * + */ + +// ── DaVinci Preset (the main thing most users want) ──────────────────────── +export { + DaVinciEditor, + DaVinciToolbar, + DaVinciRuler, + DaVinciTrack, + DaVinciClip, + DaVinciPlayhead, +} from './presets/davinci'; +export type { + DaVinciEditorProps, + DaVinciRulerProps, + DaVinciTrackProps, + DaVinciClipProps, + DaVinciPlayheadProps, +} from './presets/davinci'; + +// ── Context (for custom layouts) ─────────────────────────────────────────── +export { + TimelineProvider, + useTimelineContext, + useEngine, +} from './context/timeline-context'; +export type { + TimelineContextValue, + TimelineProviderProps, +} from './context/timeline-context'; + +// ── Shared utilities ─────────────────────────────────────────────────────── +export { + frameToPx, + pxToFrame, + frameToTimecode, + rulerTickInterval, +} from './shared/time'; + +export { useTimelineRefs } from './shared/use-refs'; +export { clamp } from './shared/geometry'; +export { cn } from './shared/cn'; diff --git a/packages/ui/src/presets/davinci/davinci-clip.tsx b/packages/ui/src/presets/davinci/davinci-clip.tsx new file mode 100644 index 0000000..de6eca6 --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-clip.tsx @@ -0,0 +1,287 @@ +/** + * DaVinciClip — renders a single clip block. + * + * Purely presentational: receives all data as props. + * Positioned absolutely within its parent track clip row. + * Uses CSS variables for theming. + */ +import React, { useState, useRef, useMemo, useEffect } from 'react'; +import type { Clip, ProvisionalState } from '@webpacked-timeline/core'; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function getDisplayClip(clip: Clip, provisional: ProvisionalState | null): Clip { + if (!provisional?.clips) return clip; + return provisional.clips.find((c) => c.id === clip.id) ?? clip; +} + +function seededRandom(seed: string) { + let h = 0; + for (let i = 0; i < seed.length; i++) { + h = (Math.imul(31, h) + seed.charCodeAt(i)) | 0; + } + return () => { + h ^= h << 13; + h ^= h >> 17; + h ^= h << 5; + return (h >>> 0) / 0xffffffff; + }; +} + +// ── ClipWaveform ─────────────────────────────────────────────────────────── + +function ClipWaveform({ clipId, width, height }: { clipId: string; width: number; height: number }) { + const canvasRef = useRef(null); + const samples = useMemo(() => { + const rand = seededRandom(clipId); + const w = Math.max(1, Math.round(width)); + return Array.from({ length: w }, () => rand() * 2 - 1); + }, [clipId, width]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const w = Math.round(width); + const h = height; + canvas.width = w; + canvas.height = h; + ctx.clearRect(0, 0, w, h); + const mid = h / 2; + + // Subtle center line + ctx.strokeStyle = 'rgba(255,255,255,0.15)'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, mid); + ctx.lineTo(w, mid); + ctx.stroke(); + + // Continuous waveform line + ctx.strokeStyle = 'rgba(255,255,255,0.7)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let x = 0; x < w; x++) { + const sampleIndex = Math.floor(x / w * samples.length); + const sample = samples[sampleIndex]!; + const y = mid + sample * mid * 0.6; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + }, [samples, width, height]); + + return ( + + ); +} + +// ── Props ────────────────────────────────────────────────────────────────── + +export interface DaVinciClipProps { + clip: Clip; + provisional: ProvisionalState | null; + trackId: string; + isAudio: boolean; + ppf: number; + height: number; + isSelected: boolean; + toolId: string; + fps: number; + startFrame: number; + endFrame: number; +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function DaVinciClip({ + clip, + provisional, + trackId, + isAudio, + ppf, + height, + isSelected, + toolId, + fps, + startFrame, + endFrame, +}: DaVinciClipProps) { + const [isHovered, setIsHovered] = useState(false); + + const dc = getDisplayClip(clip, provisional); + const start = dc.timelineStart as number; + const dur = (dc.timelineEnd as number) - start; + const left = start * ppf; + const width = dur * ppf; + + // Virtual windowing: skip clips entirely outside visible range + if (start + dur <= startFrame || start >= endFrame) return null; + if (width < 1) return null; + + const isProvisional = !!provisional?.clips?.find((c) => c.id === clip.id); + const showClipDetail = ppf >= 3; + const showClipFull = ppf >= 8; + const showDuration = ppf >= 5; + const isThin = width < 4; + const durationSec = (dur / fps).toFixed(1); + + return ( +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + style={{ + position: 'absolute', + left, + top: 0, + width: Math.max(width, 2), + height, + background: isThin + ? isAudio ? 'var(--tl-clip-audio-accent)' : 'var(--tl-clip-video-accent)' + : isProvisional + ? 'var(--tl-clip-provisional)' + : isAudio + ? 'linear-gradient(to bottom, var(--tl-clip-audio-top), var(--tl-clip-audio-bg))' + : 'linear-gradient(to bottom, var(--tl-clip-video-top), var(--tl-clip-video-bg))', + border: isThin ? 'none' : '1px solid', + borderColor: isSelected ? 'var(--tl-clip-border-sel)' : 'var(--tl-clip-border)', + borderRadius: isThin ? 0 : 'var(--tl-clip-radius)', + overflow: 'hidden', + cursor: + toolId === 'razor' + ? 'crosshair' + : toolId === 'hand' + ? 'grab' + : 'pointer', + userSelect: 'none', + filter: isSelected ? 'brightness(1.3)' : isHovered ? 'brightness(1.1)' : undefined, + }} + > + {!isThin && ( + <> + {/* Top accent strip — video clips only */} + {!isAudio && ( +
+ )} + + {/* Clip label */} + {showClipDetail && width > 30 && ( + + 🔗 {clip.name ?? clip.id} + + )} + + {/* Type icon — bottom-left */} + {showClipFull && width > 80 && ( + + {isAudio ? '♪' : '▶'} + + )} + + {/* Duration label — bottom-right */} + {showDuration && width > 60 && ( + + {durationSec}s + + )} + + {/* Audio waveform */} + {isAudio && width > 40 && ( + + )} + + {/* Trim handles on hover */} + {showClipFull && isHovered && ( + <> +
+
+ + )} + + )} +
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-editor.tsx b/packages/ui/src/presets/davinci/davinci-editor.tsx new file mode 100644 index 0000000..f28228b --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-editor.tsx @@ -0,0 +1,917 @@ +/** + * DaVinciEditor — flagship timeline editor component. + * + * Composes all DaVinci preset components into a complete + * DaVinci Resolve–style timeline editor. Owns all layout + * coordination, pointer/keyboard handling, and scroll sync. + * + * Usage: + * import { DaVinciEditor } from '@webpacked-timeline/ui'; + * import '@webpacked-timeline/ui/styles/davinci'; + * + * + */ +import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; +import { + useTrackIdsWithEngine, + useTimelineWithEngine, + usePlayheadFrame, + useIsPlaying, + useActiveToolId, + useHistory, + useProvisionalWithEngine, + useSelectedClipIds, + useCursor, + useVirtualWindow, + useMarkers, + useTrackWithEngine, + useClips, +} from '@webpacked-timeline/react'; +import type { TimelineEngine } from '@webpacked-timeline/react'; +import type { + TimelinePointerEvent, + TimelineKeyEvent, + Modifiers, + ClipId, + TrackId, + ProvisionalState, +} from '@webpacked-timeline/core'; +import { toFrame, createTrack, toTrackId, createClip, toAssetId, createAsset, frameRate } from '@webpacked-timeline/core'; +import { TimelineProvider, useTimelineContext } from '../../context/timeline-context'; +import { DaVinciToolbar } from './davinci-toolbar'; +import { DaVinciRuler } from './davinci-ruler'; +import { DaVinciTrack } from './davinci-track'; +import { DaVinciClip } from './davinci-clip'; +import { DaVinciPlayhead } from './davinci-playhead'; + +// ── Constants ────────────────────────────────────────────────────────────── + +const DEFAULT_TRACK_HEIGHT_VIDEO = 80; +const DEFAULT_TRACK_HEIGHT_AUDIO = 80; +const MIN_TRACK_HEIGHT = 32; +const MAX_TRACK_HEIGHT = 125; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +let _txSeq = 0; +const txId = () => `ui-tx-${++_txSeq}`; + +function extractModifiers(e: React.PointerEvent | React.KeyboardEvent): Modifiers { + return { shift: e.shiftKey, alt: e.altKey, ctrl: e.ctrlKey, meta: e.metaKey }; +} + +function getShortTrackId( + trackId: string, + type: string, + allTrackIds: readonly string[], + trackTypes: Map, +): string { + const prefix = type === 'video' ? 'V' : type === 'audio' ? 'A' : type === 'subtitle' ? 'S' : 'T'; + let idx = 1; + for (const tid of allTrackIds) { + if (tid === trackId) break; + if (trackTypes.get(tid) === type) idx++; + } + return `${prefix}${idx}`; +} + +// ── ClipRow (internal) ───────────────────────────────────────────────────── + +function ClipRow({ + trackId, + ppf, + provisional, + selection, + toolId, + height, + fps, + startFrame, + endFrame, +}: { + trackId: string; + ppf: number; + provisional: ProvisionalState | null; + selection: ReadonlySet; + toolId: string; + height: number; + fps: number; + startFrame: number; + endFrame: number; +}) { + const { engine } = useTimelineContext(); + const track = useTrackWithEngine(engine, trackId); + const clips = useClips(engine, trackId); + const [trackHovered, setTrackHovered] = useState(false); + + if (!track) return null; + const isAudio = track.type === 'audio'; + + return ( +
setTrackHovered(true)} + onMouseLeave={() => setTrackHovered(false)} + > + {/* Empty track indicator */} + {clips.length === 0 && ( + <> +
+ {trackHovered && ( + + Drop clips here or click + + + )} + + )} + + {clips.map((clip) => ( + + ))} +
+ ); +} + +// ── Public Props ─────────────────────────────────────────────────────────── + +export interface DaVinciEditorProps { + engine: TimelineEngine; + initialPpf?: number; + /** Called when ppf changes (sync to engine's getPixelsPerFrame) */ + onPpfChange?: (ppf: number) => void; + /** Editor calls this on mount with its setPpf function (for engine → editor zoom) */ + registerZoomHandler?: (handler: (ppf: number) => void) => void; + className?: string; + style?: React.CSSProperties; +} + +// ── Public Component ─────────────────────────────────────────────────────── + +export function DaVinciEditor({ + engine, + initialPpf = 4, + onPpfChange, + registerZoomHandler, + className, + style, +}: DaVinciEditorProps) { + return ( + + + + ); +} + +// ── Inner Editor (reads from context) ────────────────────────────────────── + +function EditorInner({ + registerZoomHandler, + className, + style, +}: { + registerZoomHandler?: (handler: (ppf: number) => void) => void; + className?: string; + style?: React.CSSProperties; +}) { + const { + engine, + ppf, + ppfRef, + setPpf, + scrollLeft, + scrollRef, + setScrollLeft, + vpWidth, + setVpWidth, + labelWidth, + rulerHeight, + toolbarHeight, + } = useTimelineContext(); + + // ── Force re-render ── + const [, forceUpdate] = useState(0); + const triggerUpdate = useCallback(() => forceUpdate((n) => n + 1), []); + + // ── Refs ── + const trackAreaRef = useRef(null); + const scrollContainerRef = useRef(null); + const labelColumnRef = useRef(null); + const rulerContentRef = useRef(null); + const handDragRef = useRef<{ startX: number; startScroll: number } | null>(null); + const resizeDragRef = useRef<{ trackId: string; startY: number; startHeight: number } | null>(null); + + // ── Track height state ── + const [trackHeights, setTrackHeights] = useState>({}); + + // ── Engine data ── + const trackIds = useTrackIdsWithEngine(engine); + const timeline = useTimelineWithEngine(engine); + const frame = usePlayheadFrame(engine); + const isPlaying = useIsPlaying(engine); + const toolId = useActiveToolId(engine); + const provisional = useProvisionalWithEngine(engine); + const selection = useSelectedClipIds(engine); + const cursor = useCursor(engine); + const virtualWindow = useVirtualWindow(engine, vpWidth, scrollLeft, ppf); + + const fps = timeline.fps as number; + const durationFrames = timeline.duration as number; + + // ── Snap indicator frames ── + const snapFrames = useMemo(() => { + if (!provisional?.clips?.length) return []; + const state = engine.getState(); + const committedEdges = new Set(); + const committedClipMap = new Map(); + for (const track of state.timeline.tracks) { + for (const clip of track.clips) { + const s = clip.timelineStart as number; + const e = clip.timelineEnd as number; + committedEdges.add(s); + committedEdges.add(e); + committedClipMap.set(clip.id as string, { start: s, end: e }); + } + } + committedEdges.add(frame as number); + const snapped = new Set(); + for (const pc of provisional.clips) { + const committed = committedClipMap.get(pc.id as string); + if (!committed) continue; + const ps = pc.timelineStart as number; + const pe = pc.timelineEnd as number; + if (ps !== committed.start && committedEdges.has(ps)) snapped.add(ps); + if (pe !== committed.end && committedEdges.has(pe)) snapped.add(pe); + } + return Array.from(snapped); + }, [provisional, frame, engine]); + + // ── Build track type map ── + const trackTypesMap = useMemo(() => { + const map = new Map(); + const state = engine.getState(); + for (const t of state.timeline.tracks) { + map.set(t.id as string, t.type); + } + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trackIds]); + + // ── Get height for a track ── + const getTrackHeight = useCallback( + (trackId: string) => { + if (trackHeights[trackId] !== undefined) return trackHeights[trackId]; + const type = trackTypesMap.get(trackId); + return type === 'video' ? DEFAULT_TRACK_HEIGHT_VIDEO : DEFAULT_TRACK_HEIGHT_AUDIO; + }, + [trackHeights, trackTypesMap], + ); + + const totalTrackHeight = useMemo( + () => trackIds.reduce((sum, tid) => sum + getTrackHeight(tid), 0), + [trackIds, getTrackHeight], + ); + + // ── Track add / delete ── + const addTrack = useCallback( + (type: 'video' | 'audio') => { + const id = `track-${Date.now()}`; + const name = + type === 'video' + ? `Video ${trackIds.filter((t) => trackTypesMap.get(t) === 'video').length + 1}` + : `Audio ${trackIds.filter((t) => trackTypesMap.get(t) === 'audio').length + 1}`; + engine.dispatch({ + id: txId(), + label: `Add ${type} track`, + timestamp: Date.now(), + operations: [{ type: 'ADD_TRACK', track: createTrack({ id, name, type }) }] as any, + }); + triggerUpdate(); + }, + [engine, trackIds, trackTypesMap, triggerUpdate], + ); + + const deleteTrack = useCallback( + (trackId: string) => { + engine.dispatch({ + id: txId(), + label: 'Delete track', + timestamp: Date.now(), + operations: [{ type: 'DELETE_TRACK', trackId }] as any, + }); + triggerUpdate(); + }, + [engine, triggerUpdate], + ); + + // ── Add clip to track ── + const addClip = useCallback( + (trackId: string) => { + const state = engine.getState(); + const track = state.timeline.tracks.find((t) => (t.id as string) === trackId); + const lastClip = track?.clips.reduce( + (latest: any, c: any) => + (c.timelineEnd as number) > ((latest?.timelineEnd as number) ?? 0) ? c : latest, + null as any, + ); + const startFrame = lastClip ? (lastClip.timelineEnd as number) + 30 : 0; + const dur = 90; + const clipId = `clip-${Date.now()}`; + const assetId = `asset-${clipId}`; + const trackType = track?.type ?? 'video'; + const mediaType = trackType === 'audio' ? 'audio' : 'video'; + + const asset = createAsset({ + id: assetId, + name: `New ${mediaType} clip`, + mediaType: mediaType as any, + filePath: `generator://${assetId}`, + intrinsicDuration: toFrame(dur), + nativeFps: frameRate(fps), + sourceTimecodeOffset: toFrame(0), + }); + + const clip = createClip({ + id: clipId, + assetId, + trackId, + timelineStart: toFrame(startFrame), + timelineEnd: toFrame(startFrame + dur), + mediaIn: toFrame(0), + mediaOut: toFrame(dur), + name: `New ${mediaType} clip`, + }); + + engine.dispatch({ + id: txId(), + label: 'Add Clip', + timestamp: Date.now(), + operations: [{ type: 'INSERT_CLIP', clip, trackId }] as any, + }); + triggerUpdate(); + }, + [engine, fps, triggerUpdate], + ); + + // ── Clip counts per track ── + const clipCounts = useMemo(() => { + const map = new Map(); + const state = engine.getState(); + for (const t of state.timeline.tracks) { + map.set(t.id as string, t.clips.length); + } + return map; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [trackIds, provisional]); + + // ── Observe viewport width ── + useEffect(() => { + const el = scrollContainerRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + setVpWidth(entry.contentRect.width); + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, [setVpWidth]); + + // ── Wire zoom callback on mount ── + useEffect(() => { + registerZoomHandler?.(setPpf); + }, [registerZoomHandler, setPpf]); + + // ── Playhead auto-scroll during playback ── + useEffect(() => { + if (!isPlaying) return; + const playheadX = (frame as number) * ppfRef.current; + const viewStart = scrollRef.current; + const viewEnd = viewStart + vpWidth; + if (playheadX > viewEnd - 80 || playheadX < viewStart + 20) { + const newScroll = Math.max(0, playheadX - vpWidth * 0.2); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollLeft = newScroll; + } + setScrollLeft(newScroll); + } + }, [frame, isPlaying, vpWidth, setScrollLeft, ppfRef, scrollRef]); + + // ── Track resize: document-level listeners ── + useEffect(() => { + const onMove = (e: PointerEvent) => { + if (!resizeDragRef.current) return; + const dy = e.clientY - resizeDragRef.current.startY; + const newH = Math.max(MIN_TRACK_HEIGHT, Math.min(MAX_TRACK_HEIGHT, resizeDragRef.current.startHeight + dy)); + setTrackHeights((prev) => ({ ...prev, [resizeDragRef.current!.trackId]: newH })); + }; + const onUp = () => { + resizeDragRef.current = null; + }; + document.addEventListener('pointermove', onMove); + document.addEventListener('pointerup', onUp); + return () => { + document.removeEventListener('pointermove', onMove); + document.removeEventListener('pointerup', onUp); + }; + }, []); + + // ── Pointer event converter ── + const convertEvent = useCallback( + (e: React.PointerEvent): TimelinePointerEvent => { + const currentPpf = ppfRef.current; + const rect = trackAreaRef.current!.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + const f = Math.max(0, Math.round(x / currentPpf)); + + let clipId: string | null = null; + let trackId: string | null = null; + let clipEl: HTMLElement | null = null; + + let el = e.target as HTMLElement | null; + while (el && el !== trackAreaRef.current) { + if (!clipId && el.dataset.clipId) { + clipId = el.dataset.clipId; + clipEl = el; + } + if (!trackId && el.dataset.trackId) { + trackId = el.dataset.trackId; + } + if (clipId && trackId) break; + el = el.parentElement; + } + + let edge: 'left' | 'right' | 'none' = 'none'; + if (clipEl) { + const cr = clipEl.getBoundingClientRect(); + const lx = e.clientX - cr.left; + const thresh = Math.min(8, cr.width * 0.2); + edge = lx <= thresh ? 'left' : lx >= cr.width - thresh ? 'right' : 'none'; + } + + return { + x, + y, + frame: toFrame(f), + buttons: e.buttons, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + clipId: clipId as ClipId | null, + trackId: trackId as TrackId | null, + edge, + }; + }, + [ppfRef], + ); + + // ── Pointer handlers ── + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + const currentToolId = engine.getActiveToolId(); + if (currentToolId === 'hand') { + handDragRef.current = { startX: e.clientX, startScroll: scrollRef.current }; + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + return; + } + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + engine.handlePointerDown(convertEvent(e), extractModifiers(e)); + triggerUpdate(); + }, + [engine, convertEvent, triggerUpdate, scrollRef], + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + const currentToolId = engine.getActiveToolId(); + if (currentToolId === 'hand' && handDragRef.current && e.buttons & 1) { + const dx = e.clientX - handDragRef.current.startX; + const newScroll = Math.max(0, handDragRef.current.startScroll - dx); + setScrollLeft(newScroll); + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollLeft = newScroll; + } + return; + } + if (!(e.buttons & 1)) return; + engine.handlePointerMove(convertEvent(e), extractModifiers(e)); + triggerUpdate(); + }, + [engine, convertEvent, triggerUpdate, setScrollLeft], + ); + + const onPointerUp = useCallback( + (e: React.PointerEvent) => { + const currentToolId = engine.getActiveToolId(); + if (currentToolId === 'hand') { + handDragRef.current = null; + return; + } + engine.handlePointerUp(convertEvent(e), extractModifiers(e)); + triggerUpdate(); + }, + [engine, convertEvent, triggerUpdate], + ); + + const onPointerLeave = useCallback( + (e: React.PointerEvent) => { + const currentToolId = engine.getActiveToolId(); + if (currentToolId === 'hand') { + handDragRef.current = null; + return; + } + const evt = convertEvent(e); + engine.handlePointerUp(evt, extractModifiers(e)); + engine.handlePointerLeave(evt); + }, + [engine, convertEvent], + ); + + // ── Keyboard handler ── + const onKeyDown = useCallback( + (e: React.KeyboardEvent) => { + const toolKeys: Record = { + v: 'selection', + c: 'razor', + t: 'ripple-trim', + r: 'roll-trim', + s: 'slip', + y: 'slide', + h: 'hand', + }; + if (!e.metaKey && !e.ctrlKey && !e.altKey && toolKeys[e.key.toLowerCase()]) { + e.preventDefault(); + engine.activateTool(toolKeys[e.key.toLowerCase()]); + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'z') { + e.preventDefault(); + e.shiftKey ? engine.redo() : engine.undo(); + return; + } + + if (e.key === 'Delete' || e.key === 'Backspace') { + const sel = engine.getSnapshot().selectedClipIds; + if (sel.size > 0) { + e.preventDefault(); + const ops: Array<{ type: 'DELETE_CLIP'; clipId: ClipId; trackId: TrackId }> = []; + for (const cid of sel) { + for (const trk of engine.getState().timeline.tracks) { + const c = trk.clips.find((cl) => cl.id === cid); + if (c) { + ops.push({ type: 'DELETE_CLIP', clipId: c.id, trackId: trk.id }); + break; + } + } + } + if (ops.length > 0) { + engine.dispatch({ + id: `delete-${Date.now()}`, + label: 'Delete clips', + timestamp: Date.now(), + operations: ops as any, + }); + engine.clearSelection(); + } + } + return; + } + + if (e.key === 'ArrowLeft') { + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + engine.seekTo(toFrame(Math.max(0, (frame as number) - step))); + return; + } + if (e.key === 'ArrowRight') { + e.preventDefault(); + const step = e.shiftKey ? 10 : 1; + engine.seekTo(toFrame(Math.min(durationFrames - 1, (frame as number) + step))); + return; + } + + if (e.key === ' ') { + e.preventDefault(); + isPlaying ? engine.playbackEngine?.pause() : engine.playbackEngine?.play(); + return; + } + + if (e.key === 'Home') { + e.preventDefault(); + engine.seekTo(toFrame(0)); + return; + } + if (e.key === 'End') { + e.preventDefault(); + engine.seekTo(toFrame(durationFrames - 1)); + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + engine.clearSelection(); + return; + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a') { + e.preventDefault(); + const allIds = new Set(); + for (const trk of engine.getState().timeline.tracks) { + for (const c of trk.clips) allIds.add(c.id as string); + } + engine.setSelectedClipIds(allIds); + return; + } + + const keyEvt: TimelineKeyEvent = { + code: e.code, + key: e.key, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + ctrlKey: e.ctrlKey, + repeat: e.repeat, + }; + const handled = engine.handleKeyDown(keyEvt, extractModifiers(e)); + if (handled) e.preventDefault(); + }, + [engine, frame, durationFrames, isPlaying], + ); + + // ── Scroll handler ── + const onScroll = useCallback( + (e: React.UIEvent) => { + const sl = e.currentTarget.scrollLeft; + const st = e.currentTarget.scrollTop; + setScrollLeft(sl); + if (labelColumnRef.current) { + labelColumnRef.current.scrollTop = st; + } + if (rulerContentRef.current) { + rulerContentRef.current.scrollLeft = sl; + } + }, + [setScrollLeft], + ); + + // ── Computed ── + const timelineWidth = durationFrames * ppf; + const firstAudioIdx = trackIds.findIndex((tid) => trackTypesMap.get(tid) === 'audio'); + + // ── Render ─────────────────────────────────────────────────────────────── + + return ( +
+ {/* ═══ TOOLBAR ═══ */} + + + {/* ═══ RULER ROW ═══ */} + + + {/* ═══ TRACK AREA ═══ */} +
+ {/* ── Left: track labels ── */} +
+ {/* Add track buttons header */} +
+ + +
+ {trackIds.map((tid, i) => { + const h = getTrackHeight(tid); + const type = trackTypesMap.get(tid) ?? 'video'; + const shortId = getShortTrackId(tid, type, trackIds, trackTypesMap); + const isSep = firstAudioIdx > 0 && i === firstAudioIdx; + return ( +
+ {isSep &&
} + + {/* Resize handle */} +
{ + e.stopPropagation(); + resizeDragRef.current = { trackId: tid, startY: e.clientY, startHeight: h }; + }} + onMouseEnter={(ev) => { + (ev.currentTarget as HTMLElement).style.background = 'var(--tl-resize-handle)'; + }} + onMouseLeave={(ev) => { + (ev.currentTarget as HTMLElement).style.background = 'transparent'; + }} + /> +
+ ); + })} +
+ + {/* ── Right: clip scroll area ── */} +
+
+ {/* Track clip rows */} + {trackIds.map((tid, i) => { + const isSep = firstAudioIdx > 0 && i === firstAudioIdx; + return ( + + {isSep &&
} + + + ); + })} + + {/* Snap indicator lines */} + {snapFrames.map((sf) => ( +
+ ))} + + {/* Playhead */} + +
+
+
+
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-playhead.tsx b/packages/ui/src/presets/davinci/davinci-playhead.tsx new file mode 100644 index 0000000..418014b --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-playhead.tsx @@ -0,0 +1,37 @@ +/** + * DaVinciPlayhead — the red playhead line + head triangle. + * + * Reads frame from engine via hook, ppf from context. + * Rendered as an absolute-positioned overlay. + */ +import React from 'react'; +import { usePlayheadFrame } from '@webpacked-timeline/react'; +import { useTimelineContext } from '../../context/timeline-context'; + +export interface DaVinciPlayheadProps { + /** Total height of the track area (line extends full height) */ + totalHeight: number; + /** Extra top offset (e.g. for add-track header alignment) */ + topOffset?: number; +} + +export function DaVinciPlayhead({ totalHeight, topOffset = 0 }: DaVinciPlayheadProps) { + const { engine, ppf } = useTimelineContext(); + const frame = usePlayheadFrame(engine); + + return ( +
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-ruler.tsx b/packages/ui/src/presets/davinci/davinci-ruler.tsx new file mode 100644 index 0000000..8045dd4 --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-ruler.tsx @@ -0,0 +1,232 @@ +/** + * DaVinciRuler — timecode display + tick marks. + * + * Left section: current timecode (fixed label-width area). + * Right section: scrollable tick marks with labels, markers, playhead triangle. + */ +import React, { useMemo, useCallback } from 'react'; +import { + usePlayheadFrame, + useTimelineWithEngine, + useMarkers, +} from '@webpacked-timeline/react'; +import { toFrame, toMarkerId } from '@webpacked-timeline/core'; +import { useTimelineContext } from '../../context/timeline-context'; +import { frameToTimecode, rulerTickInterval } from '../../shared/time'; + +// ── Transaction ID helper ────────────────────────────────────────────────── + +let _rulerTxSeq = 0; +const txId = () => `ruler-tx-${++_rulerTxSeq}`; + +// ── Props ────────────────────────────────────────────────────────────────── + +export interface DaVinciRulerProps { + /** Ref attached to the tick content div — needed for scroll sync from parent */ + contentRef?: React.RefObject; +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function DaVinciRuler({ contentRef }: DaVinciRulerProps) { + const { engine, ppf, scrollLeft, vpWidth, labelWidth, rulerHeight } = useTimelineContext(); + const frame = usePlayheadFrame(engine); + const timeline = useTimelineWithEngine(engine); + const markers = useMarkers(engine); + + const fps = timeline.fps as number; + const durationFrames = timeline.duration as number; + const timelineWidth = durationFrames * ppf; + + // ── Timecode ── + const timecode = useMemo( + () => frameToTimecode(frame as number, fps), + [frame, fps], + ); + + // ── Ruler ticks (visible only) ── + const rulerTicks = useMemo(() => { + const startF = Math.floor(scrollLeft / ppf); + const endF = startF + Math.ceil(vpWidth / ppf) + 1; + const { major, minor } = rulerTickInterval(ppf, fps); + + const ticks: Array<{ frame: number; isMajor: boolean; x: number }> = []; + const first = Math.floor(startF / minor) * minor; + for (let f = first; f <= endF; f += minor) { + ticks.push({ frame: f, isMajor: f % major === 0, x: f * ppf }); + } + return ticks; + }, [scrollLeft, ppf, vpWidth, fps]); + + // ── Click handler (seek or add marker) ── + const [, forceUpdate] = React.useState(0); + const triggerUpdate = useCallback(() => forceUpdate((n) => n + 1), []); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX - rect.left + (contentRef?.current?.scrollLeft ?? 0); + const f = Math.max(0, Math.round(x / ppf)); + + if (e.altKey) { + engine.dispatch({ + id: txId(), + label: 'Add Marker', + timestamp: Date.now(), + operations: [ + { + type: 'ADD_MARKER', + marker: { + type: 'point', + id: toMarkerId(`marker-${Date.now()}`), + frame: toFrame(f), + label: `M ${f}`, + color: 'hsl(45 90% 60%)', + scope: 'global', + linkedClipId: null, + }, + }, + ] as any, + }); + triggerUpdate(); + } else { + engine.seekTo(toFrame(f)); + triggerUpdate(); + } + }, + [engine, ppf, contentRef, triggerUpdate], + ); + + return ( +
+ {/* ── Timecode (left, label-width, aligned with track labels) ── */} +
+ + {timecode} + +
+ + {/* ── Ruler ticks (right, flex:1, overflow hidden) ── */} +
} + style={{ + flex: 1, + position: 'relative', + overflowX: 'hidden', + overflowY: 'hidden', + cursor: 'pointer', + borderBottom: '1px solid var(--tl-track-border)', + }} + onClick={handleClick} + > +
+ {rulerTicks.map((tick) => ( +
+
+ {tick.isMajor && tick.x > 4 && ( + + {frameToTimecode(tick.frame, fps)} + + )} +
+ ))} + + {/* Marker triangles */} + {markers.map((m) => { + if (m.type !== 'point') return null; + const mx = (m.frame as number) * ppf; + return ( +
+ ); + })} + + {/* Playhead triangle */} +
+
+
+
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-toolbar.tsx b/packages/ui/src/presets/davinci/davinci-toolbar.tsx new file mode 100644 index 0000000..d9b3577 --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-toolbar.tsx @@ -0,0 +1,179 @@ +/** + * DaVinciToolbar — tool buttons, zoom controls, transport. + * + * Three groups: [tools] | [zoom] | [undo/redo + play] + * Reads all state from context + @webpacked-timeline/react hooks. + */ +import React from 'react'; +import { + useActiveToolId, + useIsPlaying, + useHistory, + useSelectedClipIds, +} from '@webpacked-timeline/react'; +import { useTimelineContext } from '../../context/timeline-context'; +import { + TOOL_ICONS, + IconZoomOut, + IconZoomIn, + IconUndo, + IconRedo, + IconPlayerPlay, + IconPlayerPause, +} from './icons'; + +// ── Constants ────────────────────────────────────────────────────────────── + +const TOOLS = [ + { id: 'selection', label: 'Select', key: 'V' }, + { id: 'razor', label: 'Razor', key: 'C' }, + { id: 'ripple-trim', label: 'Trim', key: 'T' }, + { id: 'roll-trim', label: 'Roll', key: 'R' }, + { id: 'slip', label: 'Slip', key: 'S' }, + { id: 'slide', label: 'Slide', key: 'Y' }, + { id: 'hand', label: 'Hand', key: 'H' }, +] as const; + +const zoomBtnStyle: React.CSSProperties = { + padding: '3px 6px', + background: 'var(--tl-btn-bg)', + color: 'var(--tl-btn-text)', + border: '1px solid var(--tl-btn-border)', + borderRadius: 3, + cursor: 'pointer', + fontSize: 13, + fontFamily: 'monospace', + lineHeight: 1, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', +}; + +// ── Component ────────────────────────────────────────────────────────────── + +export function DaVinciToolbar() { + const { engine, ppf, setPpf, toolbarHeight } = useTimelineContext(); + const toolId = useActiveToolId(engine); + const isPlaying = useIsPlaying(engine); + const history = useHistory(engine); + const selection = useSelectedClipIds(engine); + + return ( +
+ {/* ── Left group: tool buttons ── */} +
+ {TOOLS.map((tool) => { + const Icon = TOOL_ICONS[tool.id]; + const isActive = toolId === tool.id; + return ( + + ); + })} +
+ + {/* ── Center group: zoom ── */} +
+ + setPpf(Math.exp(parseFloat(e.target.value)))} + style={{ width: 80, cursor: 'pointer' }} + /> + + + {ppf.toFixed(1)}px/f + +
+ + {/* ── Right group: undo/redo + play ── */} +
+ + + + {/* Separator */} +
+ + + + {selection.size > 0 && ( + + {selection.size} sel + + )} +
+
+ ); +} diff --git a/packages/ui/src/presets/davinci/davinci-track.tsx b/packages/ui/src/presets/davinci/davinci-track.tsx new file mode 100644 index 0000000..6e2e13b --- /dev/null +++ b/packages/ui/src/presets/davinci/davinci-track.tsx @@ -0,0 +1,246 @@ +/** + * DaVinciTrack — renders a track label row. + * + * Displays track name, type badge, lock/visibility icons, + * solo/mute buttons (audio), clip count, and action buttons. + * Uses CSS variables for theming. + */ +import React, { useState } from 'react'; +import { useTrackWithEngine } from '@webpacked-timeline/react'; +import { useTimelineContext } from '../../context/timeline-context'; + +// ── Props ────────────────────────────────────────────────────────────────── + +export interface DaVinciTrackProps { + trackId: string; + shortId: string; + height: number; + clipCount: number; + onDelete: (trackId: string) => void; + onAddClip: (trackId: string) => void; +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function DaVinciTrack({ + trackId, + shortId, + height, + clipCount, + onDelete, + onAddClip, +}: DaVinciTrackProps) { + const { engine } = useTimelineContext(); + const track = useTrackWithEngine(engine, trackId); + const [soloActive, setSoloActive] = useState(false); + const [muteActive, setMuteActive] = useState(false); + const [lockActive, setLockActive] = useState(false); + const [visActive, setVisActive] = useState(true); + + if (!track) return null; + + const isAudio = track.type === 'audio'; + const typeVar = + track.type === 'video' + ? 'var(--tl-type-video)' + : track.type === 'audio' + ? 'var(--tl-type-audio)' + : track.type === 'subtitle' + ? 'var(--tl-type-subtitle)' + : 'var(--tl-type-title)'; + + return ( +
+ {/* Type color bar */} +
+ + {/* Row 1: short id + name + icons */} +
+ + {shortId} + + + {track.name ?? shortId} + +
+
setLockActive(!lockActive)} + style={{ + width: 16, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + color: lockActive ? 'var(--tl-btn-text-active)' : 'var(--tl-label-text)', + background: lockActive ? 'hsl(220 13% 20%)' : 'transparent', + borderRadius: 2, + cursor: 'pointer', + }} + > + 🔒 +
+
setVisActive(!visActive)} + style={{ + width: 16, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + color: visActive ? 'var(--tl-label-text)' : 'hsl(0 0% 40%)', + background: !visActive ? 'hsl(220 13% 20%)' : 'transparent', + borderRadius: 2, + cursor: 'pointer', + }} + > + 👁 +
+
+
+ + {/* Delete track button — top-right */} +
{ + ev.stopPropagation(); + onDelete(trackId); + }} + style={{ + position: 'absolute', + top: 2, + right: 6, + width: 16, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 11, + color: 'var(--tl-label-text-dim)', + cursor: 'pointer', + borderRadius: 2, + background: 'transparent', + }} + onMouseEnter={(ev) => { + (ev.currentTarget as HTMLElement).style.background = 'hsl(0 60% 40%)'; + (ev.currentTarget as HTMLElement).style.color = '#fff'; + }} + onMouseLeave={(ev) => { + (ev.currentTarget as HTMLElement).style.background = 'transparent'; + (ev.currentTarget as HTMLElement).style.color = 'var(--tl-label-text-dim)'; + }} + title="Delete track" + > + × +
+ + {/* Row 2: clip count + add clip + S/M buttons for audio */} +
+ + {clipCount} Clip{clipCount !== 1 ? 's' : ''} + +
{ + ev.stopPropagation(); + onAddClip(trackId); + }} + style={{ + fontSize: 9, + color: 'hsl(220 10% 55%)', + cursor: 'pointer', + padding: '0 3px', + borderRadius: 2, + background: 'transparent', + }} + onMouseEnter={(ev) => { + (ev.currentTarget as HTMLElement).style.color = 'hsl(213 70% 55%)'; + }} + onMouseLeave={(ev) => { + (ev.currentTarget as HTMLElement).style.color = 'hsl(220 10% 55%)'; + }} + title="Add clip to this track" + > + + Clip +
+
+ {isAudio && ( +
+
setSoloActive(!soloActive)} + style={{ + width: 20, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + fontFamily: 'monospace', + fontWeight: 700, + color: soloActive ? 'hsl(0 0% 10%)' : 'var(--tl-label-text)', + background: soloActive ? 'var(--tl-solo-active)' : 'hsl(220 13% 16%)', + borderRadius: 8, + cursor: 'pointer', + }} + > + S +
+
setMuteActive(!muteActive)} + style={{ + width: 20, + height: 16, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + fontSize: 9, + fontFamily: 'monospace', + fontWeight: 700, + color: muteActive ? 'hsl(0 0% 100%)' : 'var(--tl-label-text)', + background: muteActive ? 'var(--tl-mute-active)' : 'hsl(220 13% 16%)', + borderRadius: 8, + cursor: 'pointer', + }} + > + M +
+
+ )} +
+
+ ); +} diff --git a/packages/ui/src/presets/davinci/icons.tsx b/packages/ui/src/presets/davinci/icons.tsx new file mode 100644 index 0000000..51032fc --- /dev/null +++ b/packages/ui/src/presets/davinci/icons.tsx @@ -0,0 +1,200 @@ +/** + * Tabler-based SVG icon components for the DaVinci preset. + * + * Sources: Tabler Icons (outline style, 24×24, stroke-1.5, round-cap). + * Missing icons (playback, zoom, trim) are hand-drawn in the same style. + */ +import React from 'react'; + +type IconProps = { size?: number; color?: string; strokeWidth?: number }; + +const defaults = { size: 16, color: 'currentColor', strokeWidth: 1.5 }; + +function svgProps(p: IconProps) { + const s = p.size ?? defaults.size; + const c = p.color ?? defaults.color; + const sw = p.strokeWidth ?? defaults.strokeWidth; + return { + xmlns: 'http://www.w3.org/2000/svg', + width: s, + height: s, + viewBox: '0 0 24 24', + fill: 'none', + stroke: c, + strokeWidth: sw, + strokeLinecap: 'round' as const, + strokeLinejoin: 'round' as const, + style: { display: 'block' } as React.CSSProperties, + }; +} + +// ── Tool icons ───────────────────────────────────────────────────────────── + +export function IconPointer(p: IconProps = {}) { + return ( + + + + + ); +} + +export function IconScissors(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconRippleTrim(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconRollTrim(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconSlip(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconSlide(p: IconProps = {}) { + const s = p.size ?? defaults.size; + const c = p.color ?? defaults.color; + return ( + + + + + + ); +} + +export function IconHand(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +// ── Playback icons ───────────────────────────────────────────────────── + +export function IconPlayerPlay(p: IconProps = {}) { + return ( + + + + + ); +} + +export function IconPlayerPause(p: IconProps = {}) { + return ( + + + + + + ); +} + +// ── Edit icons ───────────────────────────────────────────────────────── + +export function IconUndo(p: IconProps = {}) { + return ( + + + + + + ); +} + +export function IconRedo(p: IconProps = {}) { + return ( + + + + + + ); +} + +// ── Zoom icons ───────────────────────────────────────────────────────── + +export function IconZoomIn(p: IconProps = {}) { + return ( + + + + + + + + ); +} + +export function IconZoomOut(p: IconProps = {}) { + return ( + + + + + + + ); +} + +// ── Exports (all icons keyed by tool id) ─────────────────────────────── + +export const TOOL_ICONS: Record React.JSX.Element> = { + selection: IconPointer, + razor: IconScissors, + 'ripple-trim': IconRippleTrim, + 'roll-trim': IconRollTrim, + slip: IconSlip, + slide: IconSlide, + hand: IconHand, +}; diff --git a/packages/ui/src/presets/davinci/index.ts b/packages/ui/src/presets/davinci/index.ts new file mode 100644 index 0000000..bbbb58c --- /dev/null +++ b/packages/ui/src/presets/davinci/index.ts @@ -0,0 +1,12 @@ +// DaVinci Resolve preset — all components +export { DaVinciEditor } from './davinci-editor'; +export type { DaVinciEditorProps } from './davinci-editor'; +export { DaVinciToolbar } from './davinci-toolbar'; +export { DaVinciRuler } from './davinci-ruler'; +export type { DaVinciRulerProps } from './davinci-ruler'; +export { DaVinciTrack } from './davinci-track'; +export type { DaVinciTrackProps } from './davinci-track'; +export { DaVinciClip } from './davinci-clip'; +export type { DaVinciClipProps } from './davinci-clip'; +export { DaVinciPlayhead } from './davinci-playhead'; +export type { DaVinciPlayheadProps } from './davinci-playhead'; diff --git a/packages/ui/src/shared/cn.ts b/packages/ui/src/shared/cn.ts new file mode 100644 index 0000000..ebfb082 --- /dev/null +++ b/packages/ui/src/shared/cn.ts @@ -0,0 +1,7 @@ +/** + * Simple class-name merge utility. + * Filters falsy values and joins with space. + */ +export function cn(...classes: (string | undefined | null | false)[]): string { + return classes.filter(Boolean).join(' '); +} diff --git a/packages/ui/src/shared/geometry.ts b/packages/ui/src/shared/geometry.ts new file mode 100644 index 0000000..a59d21b --- /dev/null +++ b/packages/ui/src/shared/geometry.ts @@ -0,0 +1,7 @@ +/** + * Geometry and math utilities. + */ + +export function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} diff --git a/packages/ui/src/shared/time.ts b/packages/ui/src/shared/time.ts new file mode 100644 index 0000000..95e39d0 --- /dev/null +++ b/packages/ui/src/shared/time.ts @@ -0,0 +1,54 @@ +/** + * Frame / pixel / timecode math utilities. + * Extracted from proven demo logic. + */ + +export function frameToPx(frame: number, ppf: number, scrollLeft = 0): number { + return frame * ppf - scrollLeft; +} + +export function pxToFrame(x: number, ppf: number, scrollLeft = 0): number { + return Math.max(0, Math.round((x + scrollLeft) / ppf)); +} + +export function frameToTimecode(frame: number, fps: number): string { + const totalSeconds = Math.floor(frame / fps); + const f = Math.round(frame % fps); + const s = totalSeconds % 60; + const m = Math.floor(totalSeconds / 60) % 60; + const h = Math.floor(totalSeconds / 3600); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(h)}:${pad(m)}:${pad(s)}:${pad(f)}`; +} + +export function rulerTickInterval( + pixelsPerFrame: number, + fps: number, +): { major: number; minor: number } { + const LABEL_MIN_GAP = 120; + const TICK_MIN_GAP = 6; + + const candidates = [ + 1, 2, 5, 10, 15, 30, + fps, fps * 2, fps * 5, fps * 10, fps * 30, fps * 60, fps * 300, + fps * 600, fps * 1800, fps * 3600, + ]; + + const major = + candidates.find((c) => c * pixelsPerFrame >= LABEL_MIN_GAP) ?? + candidates[candidates.length - 1]!; + + let minor: number; + if (major >= fps) { + minor = major / (fps === 30 ? 5 : 4); + } else { + minor = Math.max(1, major / 5); + } + minor = Math.round(minor); + + while (minor * pixelsPerFrame < TICK_MIN_GAP && minor < major) { + minor *= 2; + } + + return { major, minor }; +} diff --git a/packages/ui/src/shared/use-refs.ts b/packages/ui/src/shared/use-refs.ts new file mode 100644 index 0000000..fefdc74 --- /dev/null +++ b/packages/ui/src/shared/use-refs.ts @@ -0,0 +1,37 @@ +/** + * Hook providing the proven ppfRef + scrollRef pattern. + * + * Keeps both a reactive state value and a mutable ref in sync. + * Pointer handlers read from the ref (never stale in closures), + * while React re-renders on state changes. + */ +import { useState, useRef, useCallback } from 'react'; + +export function useTimelineRefs(initialPpf = 4, initialScroll = 0) { + const [ppf, setPpfState] = useState(initialPpf); + const ppfRef = useRef(initialPpf); + + const [scrollLeft, setScrollState] = useState(initialScroll); + const scrollRef = useRef(initialScroll); + + const setPpf = useCallback((v: number) => { + const clamped = Math.max(0.5, Math.min(100, v)); + ppfRef.current = clamped; + setPpfState(clamped); + }, []); + + const setScrollLeft = useCallback((v: number) => { + const clamped = Math.max(0, v); + scrollRef.current = clamped; + setScrollState(clamped); + }, []); + + return { + ppf, + ppfRef, + setPpf, + scrollLeft, + scrollRef, + setScrollLeft, + }; +} diff --git a/packages/ui/src/timeline/Clip.tsx b/packages/ui/src/timeline/Clip.tsx deleted file mode 100644 index 186653b..0000000 --- a/packages/ui/src/timeline/Clip.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { useClip, useEngine } from '@timeline/react'; -import { frame, type Frame } from '@timeline/core'; -import { - findSnapTargets, - calculateSnapExcluding, - type SnapResult -} from '@timeline/core/internal'; -import { useState, useRef, useEffect } from 'react'; - -type DragMode = 'move' | 'resize-left' | 'resize-right'; - -interface ClipProps { - clipId: string; - trackId: string; - pixelsPerFrame: number; - snappingEnabled?: boolean; - editingMode?: 'normal' | 'ripple' | 'insert'; - onSnapIndicator?: (frame: Frame | null) => void; - playhead?: Frame; - className?: string; - onDragStart?: (clipId: string) => void; - onDrag?: (clipId: string, deltaFrames: number) => void; - onDragEnd?: (clipId: string) => void; - isSelected?: boolean; - onClipClick?: (clipId: string, multiSelect: boolean) => void; - isLocked?: boolean; - onRequestTrackAtY?: (clientY: number, clipId: string) => { trackId: string; valid: boolean } | null; -} - -export function Clip({ - clipId, - trackId, - pixelsPerFrame, - snappingEnabled = false, - editingMode = 'normal', - onSnapIndicator, - playhead, - className = '', - onDragStart, - onDrag, - onDragEnd, - isSelected = false, - onClipClick, - isLocked = false, - onRequestTrackAtY, -}: ClipProps) { - const clip = useClip(clipId); - const engine = useEngine(); - const [isDragging, setIsDragging] = useState(false); - const [dragMode, setDragMode] = useState('move'); - const [targetTrackInfo, setTargetTrackInfo] = useState<{ trackId: string; valid: boolean } | null>(null); - const dragStartX = useRef(0); - const dragStartY = useRef(0); - const dragStartFrame = useRef(frame(0)); - const dragStartEnd = useRef(frame(0)); - const dragStartTrackId = useRef(''); - - if (!clip) { - return null; - } - - const width = (clip.timelineEnd - clip.timelineStart) * pixelsPerFrame; - const left = clip.timelineStart * pixelsPerFrame; - - const applySnapping = (proposedFrame: Frame): Frame => { - if (!snappingEnabled) { - onSnapIndicator?.(null); - return proposedFrame; - } - - const state = engine.getState(); - const targets = findSnapTargets(state, playhead); - const snapResult = calculateSnapExcluding( - proposedFrame, - targets, - frame(5), // 5 frame snap threshold - [clipId] - ); - - if (snapResult.snapped) { - onSnapIndicator?.(snapResult.snappedFrame); - return snapResult.snappedFrame; - } else { - onSnapIndicator?.(null); - return proposedFrame; - } - }; - - const handleMouseDown = (e: React.MouseEvent, mode: DragMode) => { - e.preventDefault(); - e.stopPropagation(); - - // Prevent dragging/resizing if track is locked - if (isLocked) { - return; - } - - // Handle selection on click - if (mode === 'move') { - onClipClick?.(clipId, e.shiftKey || e.metaKey || e.ctrlKey); - } - - setIsDragging(true); - setDragMode(mode); - dragStartX.current = e.clientX; - dragStartY.current = e.clientY; - dragStartFrame.current = clip.timelineStart; - dragStartEnd.current = clip.timelineEnd; - dragStartTrackId.current = trackId; - onDragStart?.(clipId); - }; - - useEffect(() => { - if (!isDragging) return; - - let lastValidStart = dragStartFrame.current; - let lastValidEnd = dragStartEnd.current; - - const handleMouseMove = (e: MouseEvent) => { - const deltaX = e.clientX - dragStartX.current; - const deltaY = e.clientY - dragStartY.current; - const deltaFrames = Math.round(deltaX / pixelsPerFrame); - - try { - if (dragMode === 'resize-left') { - // Resize from left: adjust timelineStart - const proposedStart = frame(dragStartFrame.current + deltaFrames); - const snappedStart = applySnapping(proposedStart); - - // Only update if different from last valid state - if (snappedStart !== lastValidStart) { - engine.resizeClip(clipId, snappedStart, clip.timelineEnd); - lastValidStart = snappedStart; - } - } else if (dragMode === 'resize-right') { - // Resize from right: adjust timelineEnd - const proposedEnd = frame(dragStartEnd.current + deltaFrames); - const snappedEnd = applySnapping(proposedEnd); - - // Only update if different from last valid state - if (snappedEnd !== lastValidEnd) { - engine.resizeClip(clipId, clip.timelineStart, snappedEnd); - lastValidEnd = snappedEnd; - } - } else { - // Move clip - check for vertical movement - const isVerticalDrag = Math.abs(deltaY) > 10; // Threshold for vertical drag - - if (isVerticalDrag && onRequestTrackAtY) { - // Vertical drag - check target track - const trackInfo = onRequestTrackAtY(e.clientY, clipId); - setTargetTrackInfo(trackInfo); - - // Don't perform horizontal movement during vertical drag - return; - } else { - // Horizontal drag only - setTargetTrackInfo(null); - - const proposedStart = frame(dragStartFrame.current + deltaFrames); - const snappedStart = applySnapping(proposedStart); - - // Only update if different from last valid state - if (snappedStart !== lastValidStart) { - onDrag?.(clipId, snappedStart - dragStartFrame.current); - - // Use appropriate move method based on editing mode - if (editingMode === 'normal') { - engine.moveClip(clipId, snappedStart); - } - // TODO: Implement ripple and insert modes when engine methods are available - - lastValidStart = snappedStart; - } - } - } - } catch (error) { - // Validation error - clip can't be moved/resized there - console.warn('Cannot perform operation:', error); - } - }; - - const handleMouseUp = (e: MouseEvent) => { - // Check if we were doing a vertical drag - const deltaY = e.clientY - dragStartY.current; - const isVerticalDrag = Math.abs(deltaY) > 10; - - if (isVerticalDrag && dragMode === 'move' && onRequestTrackAtY) { - const trackInfo = onRequestTrackAtY(e.clientY, clipId); - - if (trackInfo && trackInfo.valid && trackInfo.trackId !== dragStartTrackId.current) { - // Attempt to move clip to new track - const result = engine.moveClipToTrack(clipId, trackInfo.trackId); - - if (!result.success) { - console.warn('Failed to move clip to track:', result.errors); - } - } - } - - setIsDragging(false); - setTargetTrackInfo(null); - onSnapIndicator?.(null); - onDragEnd?.(clipId); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isDragging, dragMode, clipId, pixelsPerFrame, clip, engine, snappingEnabled, editingMode, playhead, onDrag, onDragEnd, onSnapIndicator, isLocked, onRequestTrackAtY]); - - return ( -
- {/* Left resize handle */} - {!isLocked && ( -
handleMouseDown(e, 'resize-left')} - title="Resize left" - > - {/* Larger hit area */} -
-
- )} - - {/* Clip content */} -
handleMouseDown(e, 'move')} - > -
- Clip {clipId.slice(0, 8)} -
-
- - {/* Right resize handle */} - {!isLocked && ( -
handleMouseDown(e, 'resize-right')} - title="Resize right" - > - {/* Larger hit area */} -
-
- )} -
- ); -} diff --git a/packages/ui/src/timeline/TimeRuler.tsx b/packages/ui/src/timeline/TimeRuler.tsx deleted file mode 100644 index f4d2ca5..0000000 --- a/packages/ui/src/timeline/TimeRuler.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { frame, type Frame, type FrameRate } from '@timeline/core'; - -interface TimeRulerProps { - duration: Frame; - fps: FrameRate; - pixelsPerFrame: number; - className?: string; - onRulerClick?: (frame: Frame) => void; -} - -export function TimeRuler({ duration, fps, pixelsPerFrame, className = '', onRulerClick }: TimeRulerProps) { - const totalWidth = duration * pixelsPerFrame; - - // Calculate tick interval based on zoom level - const getTickInterval = (): number => { - if (pixelsPerFrame >= 2) return fps; // Every second when zoomed in - if (pixelsPerFrame >= 0.5) return fps * 5; // Every 5 seconds - return fps * 10; // Every 10 seconds when zoomed out - }; - - const tickInterval = getTickInterval(); - const ticks: number[] = []; - - for (let f = 0; f <= duration; f += tickInterval) { - ticks.push(f); - } - - const formatTime = (f: number): string => { - const totalSeconds = Math.floor(f / fps); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${seconds.toString().padStart(2, '0')}`; - }; - - // Handle click to move playhead - const handleClick = (e: React.MouseEvent) => { - if (!onRulerClick) return; - - const rect = e.currentTarget.getBoundingClientRect(); - const clickX = e.clientX - rect.left; - const clickedFrame = frame(Math.round(clickX / pixelsPerFrame)); - - // Clamp to valid range - const clampedFrame = frame(Math.max(0, Math.min(duration, clickedFrame))); - onRulerClick(clampedFrame); - }; - - return ( -
- {/* Left spacer to align with track labels */} -
- - {/* Time ruler content */} -
- {ticks.map((f) => ( -
- - {formatTime(f)} - -
- ))} -
-
- ); -} diff --git a/packages/ui/src/timeline/Timeline.tsx b/packages/ui/src/timeline/Timeline.tsx deleted file mode 100644 index ac40b46..0000000 --- a/packages/ui/src/timeline/Timeline.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import { useTimeline, useEngine } from '@timeline/react'; -import { TimeRuler } from './TimeRuler'; -import { Track } from './Track'; -import { frame, type Frame } from '@timeline/core'; -import { useState, useEffect, useRef } from 'react'; - -interface TimelineProps { - className?: string; - onClipMove?: (clipId: string, newStart: Frame) => void; - onClipResize?: (clipId: string, newStart: Frame, newEnd: Frame) => void; -} - -export function Timeline({ className = '', onClipMove, onClipResize }: TimelineProps) { - const { state } = useTimeline(); - const engine = useEngine(); - const [pixelsPerFrame, setPixelsPerFrame] = useState(1); - const [snappingEnabled, setSnappingEnabled] = useState(true); - const [editingMode, setEditingMode] = useState<'normal' | 'ripple' | 'insert'>('normal'); - const [snapIndicator, setSnapIndicator] = useState(null); - const [playhead, setPlayhead] = useState(frame(0)); - const [selectedClipIds, setSelectedClipIds] = useState>(new Set()); - const [copiedClips, setCopiedClips] = useState>([]); - const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false); - const timelineRef = useRef(null); - - const { timeline } = state; - - // Keyboard handler for playhead movement, clip selection, deletion, copy/paste, and undo/redo - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - // Undo/Redo work globally - if ((e.metaKey || e.ctrlKey) && e.key === 'z') { - e.preventDefault(); - if (e.shiftKey) { - engine.redo(); - } else { - engine.undo(); - } - return; - } - - // Only handle other keys when timeline is focused - if (!timelineRef.current?.contains(document.activeElement)) { - return; - } - - switch (e.key) { - case 'ArrowLeft': - e.preventDefault(); - // Jump by 10 frames with Shift, 1 frame otherwise - const leftAmount = e.shiftKey ? 10 : 1; - setPlayhead(prev => frame(Math.max(0, prev - leftAmount))); - break; - case 'ArrowRight': - e.preventDefault(); - // Jump by 10 frames with Shift, 1 frame otherwise - const rightAmount = e.shiftKey ? 10 : 1; - setPlayhead(prev => frame(Math.min(timeline.duration, prev + rightAmount))); - break; - case 'Home': - e.preventDefault(); - setPlayhead(frame(0)); - break; - case 'End': - e.preventDefault(); - setPlayhead(frame(timeline.duration)); - break; - case 'Delete': - case 'Backspace': - e.preventDefault(); - // Delete selected clips (ripple mode if enabled) - selectedClipIds.forEach(clipId => { - try { - if (editingMode === 'ripple') { - engine.rippleDelete(clipId); - } else { - engine.removeClip(clipId); - } - } catch (error) { - console.warn('Failed to delete clip:', error); - } - }); - setSelectedClipIds(new Set()); - break; - case 'c': - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - // Copy selected clips - const clipsToCopy: Array<{clipId: string, trackId: string}> = []; - selectedClipIds.forEach(clipId => { - const clip = engine.findClipById(clipId); - if (clip) { - clipsToCopy.push({ clipId, trackId: clip.trackId }); - } - }); - setCopiedClips(clipsToCopy); - } - break; - case 'v': - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - // Paste clips at playhead - copiedClips.forEach(({ clipId, trackId }) => { - try { - const originalClip = engine.findClipById(clipId); - if (originalClip) { - const duration = originalClip.timelineEnd - originalClip.timelineStart; - const newClip = { - ...originalClip, - id: `clip-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - timelineStart: playhead, - timelineEnd: frame(playhead + duration), - }; - if (editingMode === 'insert') { - engine.insertEdit(trackId, newClip, playhead); - } else { - engine.addClip(trackId, newClip); - } - } - } catch (error) { - console.warn('Failed to paste clip:', error); - } - }); - } - break; - case 'a': - if (e.metaKey || e.ctrlKey) { - e.preventDefault(); - // Select all clips - const allClipIds = new Set(); - state.timeline.tracks.forEach(track => { - track.clips.forEach(clip => { - allClipIds.add(clip.id); - }); - }); - setSelectedClipIds(allClipIds); - } - break; - case 'Escape': - e.preventDefault(); - // Deselect all - setSelectedClipIds(new Set()); - break; - case 'm': - case 'M': - e.preventDefault(); - // Add marker at playhead - { - const marker = { - id: `marker-${Date.now()}`, - type: 'timeline' as const, - frame: playhead, - label: `Mark ${playhead}`, - color: '#10b981', - }; - engine.addTimelineMarker(marker); - } - break; - } - }; - - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [timeline.duration, selectedClipIds, copiedClips, playhead, engine, state.timeline.tracks]); - - // Handle TimeRuler click to move playhead - const handleRulerClick = (clickedFrame: Frame) => { - setPlayhead(clickedFrame); - }; - - // Handle playhead drag start - const handlePlayheadMouseDown = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDraggingPlayhead(true); - }; - - // Handle playhead drag - useEffect(() => { - if (!isDraggingPlayhead) return; - - const handleMouseMove = (e: MouseEvent) => { - if (!timelineRef.current) return; - - const rect = timelineRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left - 128; // Offset by track label width - const newFrame = Math.round(x / pixelsPerFrame); - const clampedFrame = Math.max(0, Math.min(timeline.duration, newFrame)); - setPlayhead(frame(clampedFrame)); - }; - - const handleMouseUp = () => { - setIsDraggingPlayhead(false); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isDraggingPlayhead, pixelsPerFrame, timeline.duration]); - - // Handle clip selection - const handleClipClick = (clipId: string, multiSelect: boolean) => { - if (multiSelect) { - // Toggle selection - setSelectedClipIds(prev => { - const newSet = new Set(prev); - if (newSet.has(clipId)) { - newSet.delete(clipId); - } else { - newSet.add(clipId); - } - return newSet; - }); - } else { - // Single selection - setSelectedClipIds(new Set([clipId])); - } - }; - - // Handle track lookup at Y position with validation - const handleRequestTrackAtY = (clientY: number, clipId: string): { trackId: string; valid: boolean } | null => { - if (!timelineRef.current) return null; - - // Find the clip to check its asset type - const clip = engine.findClipById(clipId); - if (!clip) return null; - - const asset = engine.getAsset(clip.assetId); - if (!asset) return null; - - // Find all track elements - const trackElements = timelineRef.current.querySelectorAll('[data-track-id]'); - - for (const element of Array.from(trackElements)) { - const rect = element.getBoundingClientRect(); - - if (clientY >= rect.top && clientY <= rect.bottom) { - const targetTrackId = element.getAttribute('data-track-id'); - - if (!targetTrackId) continue; - - const targetTrack = state.timeline.tracks.find(t => t.id === targetTrackId); - - if (!targetTrack) continue; - - // Validate track type match - let valid = !targetTrack.locked; - - if (valid) { - // Check type compatibility - if (asset.type === 'video' && targetTrack.type !== 'video') { - valid = false; - } else if (asset.type === 'audio' && targetTrack.type !== 'audio') { - valid = false; - } else if (asset.type === 'image' && targetTrack.type !== 'video') { - valid = false; - } - } - - return { - trackId: targetTrackId, - valid, - }; - } - } - - return null; - }; - - return ( -
- {/* Controls */} -
- {/* Undo/Redo buttons */} -
- - -
- - {/* Zoom controls */} -
- Zoom: - - - {pixelsPerFrame.toFixed(1)}x - - -
- - {/* Snapping toggle */} -
- Snap: - -
- - {/* Editing mode selector */} -
- Mode: - - {editingMode === 'ripple' && ( - - ⚡ Ripple - - )} - {editingMode === 'insert' && ( - - ➕ Insert - - )} -
- - {/* Marker controls */} -
- - {state.workArea ? ( - - ) : ( - - )} -
-
- - {/* Timeline content */} -
- {/* Time ruler */} -
- -
- - {/* Tracks */} -
- {timeline.tracks.map((track) => ( - - ))} - - {/* Work Area */} - {state.workArea && ( -
-
- Work Area -
-
- )} - - {/* Timeline Markers */} - {state.markers.timeline.map((marker) => ( -
-
-
-
- {marker.label} -
-
- ))} - - {/* Region Markers */} - {state.markers.regions.map((marker) => ( -
-
- {marker.label} -
-
- ))} - - {/* Snap indicator */} - {snapIndicator !== null && ( -
- )} - - {/* Playhead */} -
- {/* Playhead handle for easier grabbing */} -
-
-
- - {/* Empty state */} - {timeline.tracks.length === 0 && ( -
- No tracks yet -
- )} -
-
- ); -} diff --git a/packages/ui/src/timeline/Track.tsx b/packages/ui/src/timeline/Track.tsx deleted file mode 100644 index f4e5621..0000000 --- a/packages/ui/src/timeline/Track.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useTrack, useEngine } from '@timeline/react'; -import { Clip } from './Clip'; -import type { Frame } from '@timeline/core'; -import { useState, useEffect } from 'react'; - -interface TrackProps { - trackId: string; - pixelsPerFrame: number; - snappingEnabled?: boolean; - editingMode?: 'normal' | 'ripple' | 'insert'; - onSnapIndicator?: (frame: Frame | null) => void; - playhead?: Frame; - className?: string; - selectedClipIds?: Set; - onClipClick?: (clipId: string, multiSelect: boolean) => void; - onRequestTrackAtY?: (clientY: number, clipId: string) => { trackId: string; valid: boolean } | null; -} - -export function Track({ - trackId, - pixelsPerFrame, - snappingEnabled, - editingMode, - onSnapIndicator, - playhead, - className = '', - selectedClipIds = new Set(), - onClipClick, - onRequestTrackAtY, -}: TrackProps) { - const track = useTrack(trackId); - const engine = useEngine(); - const [isResizing, setIsResizing] = useState(false); - const [startY, setStartY] = useState(0); - const [startHeight, setStartHeight] = useState(0); - - if (!track) { - return null; - } - - const handleToggleMute = () => { - engine.toggleTrackMute(trackId); - }; - - const handleToggleLock = () => { - engine.toggleTrackLock(trackId); - }; - - const handleToggleSolo = () => { - engine.toggleTrackSolo(trackId); - }; - - const handleDeleteTrack = () => { - if (window.confirm(`Delete track "${track.name}"?`)) { - engine.removeTrack(trackId); - } - }; - - const handleResizeStart = (e: React.MouseEvent) => { - e.preventDefault(); - setIsResizing(true); - setStartY(e.clientY); - setStartHeight(track.height); - }; - - useEffect(() => { - if (!isResizing) return; - - const handleMouseMove = (e: MouseEvent) => { - const deltaY = e.clientY - startY; - const newHeight = Math.max(40, Math.min(200, startHeight + deltaY)); - engine.setTrackHeight(trackId, newHeight); - }; - - const handleMouseUp = () => { - setIsResizing(false); - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - }, [isResizing, startY, startHeight, trackId, engine]); - - return ( -
- {/* Track label */} -
-
-
- - {track.type === 'video' ? '📹' : '🎵'} - - {track.name} -
- -
-
- - - -
-
- - {/* Track content area */} -
- {/* Background grid */} -
- {/* Grid lines could go here if needed */} -
- - {/* Clips */} - {track.clips.map((clip) => ( - - ))} -
- - {/* Draggable resize handle at bottom */} -
-
- ); -} diff --git a/packages/ui/src/timeline/index.ts b/packages/ui/src/timeline/index.ts deleted file mode 100644 index 2374975..0000000 --- a/packages/ui/src/timeline/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { Timeline } from './Timeline'; -export { Track } from './Track'; -export { Clip } from './Clip'; -export { TimeRuler } from './TimeRuler'; diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css new file mode 100644 index 0000000..eb848be --- /dev/null +++ b/packages/ui/src/tokens.css @@ -0,0 +1,92 @@ +/* ═══════════════════════════════════════════════════════════ + @webpacked-timeline/ui — Base CSS Variable Tokens + + Every color, size, and spacing used by components references + these variables. Override any variable to create a custom theme. + ═══════════════════════════════════════════════════════════ */ + +:root { + /* ── App chrome ── */ + --tl-app-bg: hsl(220 13% 9%); + --tl-panel-bg: hsl(220 13% 11%); + --tl-toolbar-bg: hsl(220 13% 11%); + --tl-toolbar-border: hsl(220 13% 18%); + --tl-toolbar-height: 40px; + + /* ── Ruler ── */ + --tl-ruler-bg: hsl(220 13% 8%); + --tl-ruler-height: 32px; + --tl-ruler-tick: hsl(220 13% 30%); + --tl-ruler-tick-maj: hsl(220 13% 48%); + --tl-ruler-text: hsl(220 10% 52%); + + /* ── Timecode ── */ + --tl-timecode-color: hsl(0 0% 88%); + --tl-timecode-size: 15px; + + /* ── Track ── */ + --tl-track-bg: hsl(220 13% 13%); + --tl-track-bg-video: #28282E; + --tl-track-bg-audio: #28282E; + --tl-track-border: hsl(220 13% 19%); + --tl-track-height: 80px; + --tl-track-height-audio: 80px; + + /* ── Label ── */ + --tl-label-width: 200px; + --tl-label-bg: hsl(220 13% 10%); + --tl-label-text: hsl(220 10% 62%); + --tl-label-text-dim: hsl(220 10% 45%); + --tl-label-border: hsl(220 13% 19%); + + /* ── Clips ── */ + --tl-clip-video-bg: #2E77A5; + --tl-clip-video-top: #2E77A5; + --tl-clip-audio-bg: #179160; + --tl-clip-audio-top: #179160; + --tl-clip-selected: #3da2ff; + --tl-clip-provisional:rgba(46,119,165,0.5); + --tl-clip-border: rgba(255,255,255,0.15); + --tl-clip-border-sel: #6fbfff; + --tl-clip-text: hsl(0 0% 92%); + --tl-clip-text-dim: hsl(0 0% 65%); + --tl-clip-video-text: hsl(195 85% 15%); + --tl-clip-audio-text: hsl(142 70% 12%); + --tl-clip-radius: 2px; + --tl-clip-video-accent: hsl(195 85% 72%); + --tl-clip-audio-accent:#179160; + + /* ── Playhead ── */ + --tl-playhead-color: #ff3b30; + --tl-playhead-width: 1px; + + /* ── Snap ── */ + --tl-snap-color: hsl(45 90% 60%); + + /* ── Toolbar buttons ── */ + --tl-btn-bg: transparent; + --tl-btn-bg-hover: hsl(220 13% 20%); + --tl-btn-bg-active: hsl(213 45% 32%); + --tl-btn-border: hsl(220 13% 22%); + --tl-btn-border-active:#3da2ff; + --tl-btn-text: hsl(220 10% 68%); + --tl-btn-text-active: #ffffff; + + /* ── Type colors ── */ + --tl-type-video: #2E77A5; + --tl-type-audio: #179160; + --tl-type-subtitle: #d4a62a; + --tl-type-title: #9a58d4; + + /* ── Solo / Mute ── */ + --tl-solo-active: #ffd54f; + --tl-mute-active: #ff5252; + + /* ── Resize ── */ + --tl-resize-handle: hsl(220 13% 26%); + + /* ── Scrollbar ── */ + --tl-scrollbar-track: hsl(220 13% 9%); + --tl-scrollbar-thumb: hsl(220 13% 24%); + --tl-scrollbar-thumb-hover: hsl(220 13% 32%); +} diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index 5bebdf1..542a7a9 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -2,20 +2,21 @@ "compilerOptions": { "target": "ES2020", "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "moduleResolution": "bundler", "jsx": "react-jsx", + "strict": true, "declaration": true, "declarationMap": true, - "outDir": "./dist", - "rootDir": "./src", - "strict": true, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", "esModuleInterop": true, "skipLibCheck": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], "forceConsistentCasingInFileNames": true, - "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true }, - "include": ["src"], + "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts new file mode 100644 index 0000000..043331f --- /dev/null +++ b/packages/ui/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + name: 'TimelineUI', + formats: ['es', 'cjs'], + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), + }, + rollupOptions: { + external: [ + 'react', + 'react-dom', + 'react/jsx-runtime', + '@webpacked-timeline/core', + '@webpacked-timeline/react', + ], + output: { + globals: { + react: 'React', + 'react-dom': 'ReactDOM', + }, + }, + }, + cssCodeSplit: false, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 721eb73..858109f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,19 +8,22 @@ importers: .: devDependencies: + '@changesets/cli': + specifier: ^2.29.8 + version: 2.29.8(@types/node@24.10.11) turbo: specifier: ^2.8.3 version: 2.8.3 apps/demo: dependencies: - '@timeline/core': + '@webpacked-timeline/core': specifier: workspace:* version: link:../../packages/core - '@timeline/react': + '@webpacked-timeline/react': specifier: workspace:* version: link:../../packages/react - '@timeline/ui': + '@webpacked-timeline/ui': specifier: workspace:* version: link:../../packages/ui react: @@ -30,6 +33,9 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.1(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1)) '@types/react': specifier: ^18.2.0 version: 18.3.28 @@ -38,83 +44,25 @@ importers: version: 18.3.7(@types/react@18.3.28) '@vitejs/plugin-react': specifier: ^4.2.0 - version: 4.7.0(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.30.2)) - autoprefixer: - specifier: ^10.4.16 - version: 10.4.24(postcss@8.5.6) - postcss: - specifier: ^8.4.32 - version: 8.5.6 + version: 4.7.0(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1)) tailwindcss: - specifier: ^3.4.0 - version: 3.4.4 + specifier: ^4.0.0 + version: 4.2.1 typescript: specifier: ^5.3.0 version: 5.9.3 vite: specifier: ^5.0.0 - version: 5.4.21(@types/node@24.10.11)(lightningcss@1.30.2) - - apps/docs: - dependencies: - '@mdx-js/loader': - specifier: ^3.1.1 - version: 3.1.1 - '@mdx-js/react': - specifier: ^3.1.1 - version: 3.1.1(@types/react@19.2.13)(react@19.2.3) - '@next/mdx': - specifier: ^16.1.6 - version: 16.1.6(@mdx-js/loader@3.1.1)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@19.2.3)) - next: - specifier: 16.1.6 - version: 16.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - react: - specifier: 19.2.3 - version: 19.2.3 - react-dom: - specifier: 19.2.3 - version: 19.2.3(react@19.2.3) - rehype-autolink-headings: - specifier: ^7.1.0 - version: 7.1.0 - rehype-highlight: - specifier: ^7.0.2 - version: 7.0.2 - rehype-slug: - specifier: ^6.0.0 - version: 6.0.0 - devDependencies: - '@tailwindcss/postcss': - specifier: ^4 - version: 4.1.18 - '@types/node': - specifier: ^20 - version: 20.19.33 - '@types/react': - specifier: ^19 - version: 19.2.13 - '@types/react-dom': - specifier: ^19 - version: 19.2.3(@types/react@19.2.13) - eslint: - specifier: ^9 - version: 9.39.2(jiti@2.6.1) - eslint-config-next: - specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - tailwindcss: - specifier: ^4 - version: 4.1.18 - typescript: - specifier: ^5 - version: 5.9.3 + version: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) packages/core: devDependencies: '@types/node': specifier: ^24.10.11 version: 24.10.11 + '@vitest/coverage-v8': + specifier: ^2.1.8 + version: 2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1)) tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) @@ -124,56 +72,91 @@ importers: typescript: specifier: ~5.9.3 version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1) packages/react: dependencies: - '@timeline/core': + '@webpacked-timeline/core': specifier: workspace:* version: link:../core devDependencies: + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.1.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@types/react': specifier: ^18.2.0 version: 18.3.28 - react: + '@types/react-dom': specifier: ^18.2.0 + version: 18.3.7(@types/react@18.3.28) + '@vitest/coverage-v8': + specifier: ^2.1.8 + version: 2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1)) + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + react: + specifier: ^18.3.1 version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) tsup: specifier: ^8.0.0 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) typescript: specifier: ^5.3.0 version: 5.9.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1) packages/ui: - dependencies: - '@timeline/core': + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.35 + '@types/react': + specifier: ^18.0.0 + version: 18.3.28 + '@types/react-dom': + specifier: ^18.0.0 + version: 18.3.7(@types/react@18.3.28) + '@webpacked-timeline/core': specifier: workspace:* version: link:../core - '@timeline/react': + '@webpacked-timeline/react': specifier: workspace:* version: link:../react react: - specifier: ^18.0.0 + specifier: ^18.3.1 version: 18.3.1 react-dom: - specifier: ^18.0.0 + specifier: ^18.3.1 version: 18.3.1(react@18.3.1) - devDependencies: - '@types/react': - specifier: ^18.0.0 - version: 18.3.28 - '@types/react-dom': - specifier: ^18.0.0 - version: 18.3.7(@types/react@18.3.28) typescript: specifier: ^5.0.0 version: 5.9.3 + vite: + specifier: ^5.0.0 + version: 5.4.21(@types/node@20.19.35)(lightningcss@1.31.1) packages: - '@alloc/quick-lru@5.2.0': - resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} - engines: {node: '>=10'} + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} @@ -246,6 +229,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -258,14 +245,91 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@emnapi/core@1.8.1': - resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + + '@changesets/apply-release-plan@7.0.14': + resolution: {integrity: sha512-ddBvf9PHdy2YY0OUiEl3TV78mH9sckndJR14QAt87KLEbIov81XO0q0QAmvooBxXlqRRP8I9B7XOzZwQG7JkWA==} + + '@changesets/assemble-release-plan@6.0.9': + resolution: {integrity: sha512-tPgeeqCHIwNo8sypKlS3gOPmsS3wP0zHt67JDuL20P4QcXiw/O4Hl7oXiuLnP9yg+rXLQ2sScdV1Kkzde61iSQ==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.29.8': + resolution: {integrity: sha512-1weuGZpP63YWUYjay/E84qqwcnt5yJMM0tep10Up7Q5cS/DGe2IZ0Uj3HNMxGhCINZuR7aO9WBMdKnPit5ZDPA==} + hasBin: true + + '@changesets/config@3.1.2': + resolution: {integrity: sha512-CYiRhA4bWKemdYi/uwImjPxqWNpqGPNbEBdX1BdONALFIDK7MCUj6FPkzD+z9gJcvDFUQJn9aDVf4UG7OT6Kog==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.3': + resolution: {integrity: sha512-gphr+v0mv2I3Oxt19VdWRRUxq3sseyUpX9DaHpTUmLj92Y10AGy+XOtV+kbM6L/fDcpx7/ISDFK6T8A/P3lOdQ==} + + '@changesets/get-release-plan@4.0.14': + resolution: {integrity: sha512-yjZMHpUHgl4Xl5gRlolVuxDkm4HgSJqT93Ri1Uz8kGrQb+5iJ8dkXJ20M2j/Y4iV5QzS2c5SeTxVSKX+2eMI0g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.2': + resolution: {integrity: sha512-Uo5MC5mfg4OM0jU3up66fmSn6/NE9INK+8/Vn/7sMVcdWg46zfbvvUSjD9EMonVqPi9fbrJH9SXHn48Tr1f2yA==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.6': + resolution: {integrity: sha512-P5QaN9hJSQQKJShzzpBT13FzOSPyHbqdoIBUd2DJdgvnECCyO6LmAOWSV+O8se2TaZJVwSXjL+v9yhb+a9JeJg==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 - '@emnapi/runtime@1.8.1': - resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} @@ -561,212 +625,22 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.1': - resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/config-array@0.21.1': - resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.4.2': - resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.17.0': - resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.3': - resolution: {integrity: sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.39.2': - resolution: {integrity: sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.7': - resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.4.1': - resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true - '@img/sharp-darwin-arm64@0.34.5': - resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [darwin] - - '@img/sharp-darwin-x64@0.34.5': - resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-darwin-arm64@1.2.4': - resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} - cpu: [arm64] - os: [darwin] - - '@img/sharp-libvips-darwin-x64@1.2.4': - resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} - cpu: [x64] - os: [darwin] - - '@img/sharp-libvips-linux-arm64@1.2.4': - resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-arm@1.2.4': - resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-ppc64@1.2.4': - resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-riscv64@1.2.4': - resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-s390x@1.2.4': - resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linux-x64@1.2.4': - resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-linux-arm64@0.34.5': - resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-arm@0.34.5': - resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-ppc64@0.34.5': - resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-riscv64@0.34.5': - resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-s390x@0.34.5': - resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@img/sharp-linux-x64@0.34.5': - resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@img/sharp-linuxmusl-arm64@0.34.5': - resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@img/sharp-linuxmusl-x64@0.34.5': - resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [linux] - libc: [musl] - - '@img/sharp-wasm32@0.34.5': - resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [wasm32] - - '@img/sharp-win32-arm64@0.34.5': - resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [arm64] - os: [win32] - - '@img/sharp-win32-ia32@0.34.5': - resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [ia32] - os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} - '@img/sharp-win32-x64@0.34.5': - resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - cpu: [x64] - os: [win32] + '@istanbuljs/schema@0.1.3': + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -784,94 +658,11 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@mdx-js/loader@3.1.1': - resolution: {integrity: sha512-0TTacJyZ9mDmY+VefuthVshaNIyCGZHJG2fMnGaDttCt8HmjUF7SizlHJpaCDoGnN635nK1wpzfpx/Xx5S4WnQ==} - peerDependencies: - webpack: '>=5' - peerDependenciesMeta: - webpack: - optional: true - - '@mdx-js/mdx@3.1.1': - resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} - - '@mdx-js/react@3.1.1': - resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} - peerDependencies: - '@types/react': '>=16' - react: '>=16' - - '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} - - '@next/env@16.1.6': - resolution: {integrity: sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ==} - - '@next/eslint-plugin-next@16.1.6': - resolution: {integrity: sha512-/Qq3PTagA6+nYVfryAtQ7/9FEr/6YVyvOtl6rZnGsbReGLf0jZU6gkpr1FuChAQpvV46a78p4cmHOVP8mbfSMQ==} - - '@next/mdx@16.1.6': - resolution: {integrity: sha512-PT5JR4WPPYOls7WD6xEqUVVI9HDY8kY7XLQsNYB2lSZk5eJSXWu3ECtIYmfR0hZpx8Sg7BKZYKi2+u5OTSEx0w==} - peerDependencies: - '@mdx-js/loader': '>=0.15.0' - '@mdx-js/react': '>=0.15.0' - peerDependenciesMeta: - '@mdx-js/loader': - optional: true - '@mdx-js/react': - optional: true - - '@next/swc-darwin-arm64@16.1.6': - resolution: {integrity: sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@next/swc-darwin-x64@16.1.6': - resolution: {integrity: sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@next/swc-linux-arm64-gnu@16.1.6': - resolution: {integrity: sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-arm64-musl@16.1.6': - resolution: {integrity: sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@next/swc-linux-x64-gnu@16.1.6': - resolution: {integrity: sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@next/swc-linux-x64-musl@16.1.6': - resolution: {integrity: sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@next/swc-win32-arm64-msvc@16.1.6': - resolution: {integrity: sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} - '@next/swc-win32-x64-msvc@16.1.6': - resolution: {integrity: sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -885,9 +676,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@nolyfill/is-core-module@1.0.39': - resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} - engines: {node: '>=12.4.0'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1030,75 +821,69 @@ packages: cpu: [x64] os: [win32] - '@rtsao/scc@1.1.0': - resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - - '@swc/helpers@0.5.15': - resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} - '@tailwindcss/node@4.1.18': - resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==} - - '@tailwindcss/oxide-android-arm64@4.1.18': - resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} cpu: [arm64] os: [android] - '@tailwindcss/oxide-darwin-arm64@4.1.18': - resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} cpu: [arm64] os: [darwin] - '@tailwindcss/oxide-darwin-x64@4.1.18': - resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} cpu: [x64] os: [darwin] - '@tailwindcss/oxide-freebsd-x64@4.1.18': - resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} cpu: [x64] os: [freebsd] - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': - resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} cpu: [arm] os: [linux] - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': - resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': - resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} cpu: [arm64] os: [linux] libc: [musl] - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': - resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [glibc] - '@tailwindcss/oxide-linux-x64-musl@4.1.18': - resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} cpu: [x64] os: [linux] libc: [musl] - '@tailwindcss/oxide-wasm32-wasi@4.1.18': - resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} engines: {node: '>=14.0.0'} cpu: [wasm32] bundledDependencies: @@ -1109,30 +894,52 @@ packages: - '@emnapi/wasi-threads' - tslib - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': - resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} cpu: [arm64] os: [win32] - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': - resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==} - engines: {node: '>= 10'} + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} cpu: [x64] os: [win32] - '@tailwindcss/oxide@4.1.18': - resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==} - engines: {node: '>= 10'} + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} - '@tailwindcss/postcss@4.1.18': - resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} - '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true - '@types/acorn@4.0.6': - resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1146,35 +953,14 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} - '@types/debug@4.1.12': - resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} - - '@types/estree-jsx@1.0.5': - resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} - '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/hast@3.0.4': - resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@types/json5@0.0.29': - resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} - - '@types/mdx@2.0.13': - resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} - - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - - '@types/node@20.19.33': - resolution: {integrity: sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==} + '@types/node@20.19.35': + resolution: {integrity: sha512-Uarfe6J91b9HAUXxjvSOdiO2UPOKLm07Q1oh0JHxoZ1y8HoqxDAu3gVrsrOHeiio0kSsoVBt4wFrKOm0dKxVPQ==} '@types/node@24.10.11': resolution: {integrity: sha512-/Af7O8r1frCVgOz0I62jWUtMohJ0/ZQU/ZoketltOJPZpnb17yoNc9BSoVuV9qlaIXJiPNOpsfq4ByFajSArNQ==} @@ -1187,292 +973,112 @@ packages: peerDependencies: '@types/react': ^18.0.0 - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} - peerDependencies: - '@types/react': ^19.2.0 - '@types/react@18.3.28': resolution: {integrity: sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==} - '@types/react@19.2.13': - resolution: {integrity: sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==} + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@types/unist@2.0.11': - resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + '@vitest/coverage-v8@2.1.9': + resolution: {integrity: sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==} + peerDependencies: + '@vitest/browser': 2.1.9 + vitest: 2.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} - '@typescript-eslint/eslint-plugin@8.55.0': - resolution: {integrity: sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} peerDependencies: - '@typescript-eslint/parser': ^8.55.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true - '@typescript-eslint/parser@8.55.0': - resolution: {integrity: sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} - '@typescript-eslint/project-service@8.55.0': - resolution: {integrity: sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} - '@typescript-eslint/scope-manager@8.55.0': - resolution: {integrity: sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} - '@typescript-eslint/tsconfig-utils@8.55.0': - resolution: {integrity: sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} - '@typescript-eslint/type-utils@8.55.0': - resolution: {integrity: sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.55.0': - resolution: {integrity: sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.55.0': - resolution: {integrity: sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.55.0': - resolution: {integrity: sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.55.0': - resolution: {integrity: sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@ungap/structured-clone@1.3.0': - resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} - cpu: [arm] - os: [android] - - '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} - cpu: [arm64] - os: [android] - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} - cpu: [arm64] - os: [darwin] - - '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} - cpu: [x64] - os: [darwin] - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} - cpu: [x64] - os: [freebsd] - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} - cpu: [arm] - os: [linux] - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} - cpu: [ppc64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} - cpu: [riscv64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} - cpu: [riscv64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} - cpu: [s390x] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} - cpu: [arm64] - os: [win32] - - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} - cpu: [ia32] - os: [win32] - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} - cpu: [x64] - os: [win32] - - '@vitejs/plugin-react@4.7.0': - resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} hasBin: true - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - - arg@5.0.2: - resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} - array-buffer-byte-length@1.0.2: - resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} - engines: {node: '>= 0.4'} - - array-includes@3.1.9: - resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlast@1.2.5: - resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} - engines: {node: '>= 0.4'} - - array.prototype.findlastindex@1.2.6: - resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} - engines: {node: '>= 0.4'} - - array.prototype.flat@1.3.3: - resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} - engines: {node: '>= 0.4'} - - array.prototype.flatmap@1.3.3: - resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} - engines: {node: '>= 0.4'} - - array.prototype.tosorted@1.1.4: - resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} - engines: {node: '>= 0.4'} - - arraybuffer.prototype.slice@1.0.4: - resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} - engines: {node: '>= 0.4'} - - ast-types-flow@0.0.8: - resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - - astring@1.9.0: - resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} - hasBin: true - - async-function@1.0.0: - resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} - engines: {node: '>= 0.4'} - - autoprefixer@10.4.24: - resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} - engines: {node: ^10 || ^12 || >=14} - hasBin: true - peerDependencies: - postcss: ^8.1.0 - - available-typed-arrays@1.0.7: - resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} - engines: {node: '>= 0.4'} - - axe-core@4.11.1: - resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} - engines: {node: '>=4'} + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} - bail@2.0.2: - resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -1481,12 +1087,9 @@ packages: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -1514,57 +1117,27 @@ packages: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} - call-bind@1.0.8: - resolution: {integrity: sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==} - engines: {node: '>= 0.4'} - - call-bound@1.0.4: - resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} - engines: {node: '>= 0.4'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - camelcase-css@2.0.1: - resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} - engines: {node: '>= 6'} - caniuse-lite@1.0.30001769: resolution: {integrity: sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==} - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - character-entities-html4@2.1.0: - resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} - - character-entities-legacy@3.0.0: - resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} - - character-entities@2.0.2: - resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} - character-reference-invalid@2.0.1: - resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chardet@2.1.1: + resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} - client-only@0.0.1: - resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - - collapse-white-space@2.1.0: - resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -1573,16 +1146,14 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - comma-separated-tokens@2.0.3: - resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} @@ -1597,36 +1168,19 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} - damerau-levenshtein@1.0.8: - resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} - - data-view-buffer@1.0.2: - resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} - engines: {node: '>= 0.4'} - - data-view-byte-length@1.0.2: - resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} - engines: {node: '>= 0.4'} - - data-view-byte-offset@1.0.1: - resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} - engines: {node: '>= 0.4'} - - debug@3.2.7: - resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} @@ -1637,58 +1191,66 @@ packages: supports-color: optional: true - decode-named-character-reference@1.3.0: - resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} - define-data-property@1.1.4: - resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} - engines: {node: '>= 0.4'} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} - define-properties@1.2.1: - resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} - engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - didyoumean@1.2.2: - resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} - dlv@1.1.3: - resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} - doctrine@2.1.0: - resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} - engines: {node: '>=0.10.0'} + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.19.0: - resolution: {integrity: sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==} + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} engines: {node: '>=10.13.0'} - es-abstract@1.24.1: - resolution: {integrity: sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==} - engines: {node: '>= 0.4'} + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} @@ -1698,9 +1260,8 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} - es-iterator-helpers@1.2.2: - resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} - engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -1710,20 +1271,6 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-shim-unscopables@1.1.0: - resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} - engines: {node: '>= 0.4'} - - es-to-primitive@1.3.0: - resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} - engines: {node: '>= 0.4'} - - esast-util-from-estree@2.0.0: - resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} - - esast-util-from-js@2.0.1: - resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - esbuild@0.21.5: resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} engines: {node: '>=12'} @@ -1738,167 +1285,25 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-config-next@16.1.6: - resolution: {integrity: sha512-vKq40io2B0XtkkNDYyleATwblNt8xuh3FWp8SpSz3pt7P01OkBFlKsJZ2mWt5WsCySlDQLckb1zMY9yE9Qy0LA==} - peerDependencies: - eslint: '>=9.0.0' - typescript: '>=3.3.1' - peerDependenciesMeta: - typescript: - optional: true - - eslint-import-resolver-node@0.3.9: - resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - - eslint-import-resolver-typescript@3.10.1: - resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - eslint: '*' - eslint-plugin-import: '*' - eslint-plugin-import-x: '*' - peerDependenciesMeta: - eslint-plugin-import: - optional: true - eslint-plugin-import-x: - optional: true - - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - - eslint-plugin-import@2.32.0: - resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - - eslint-plugin-jsx-a11y@6.10.2: - resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} - engines: {node: '>=4.0'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 - - eslint-plugin-react-hooks@7.0.1: - resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 - - eslint-plugin-react@7.37.5: - resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} - engines: {node: '>=4'} - peerDependencies: - eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@9.39.2: - resolution: {integrity: sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - estree-util-attach-comments@3.0.0: - resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} - - estree-util-build-jsx@3.0.1: - resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} - - estree-util-is-identifier-name@3.0.0: - resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} - - estree-util-scope@1.0.0: - resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} - - estree-util-to-js@2.0.0: - resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} - - estree-util-visit@2.0.0: - resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - extend@3.0.2: - resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} - fast-glob@3.3.1: - resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} - engines: {node: '>=8.6.0'} + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -1911,34 +1316,32 @@ packages: picomatch: optional: true - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} - for-each@0.3.5: - resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} - engines: {node: '>= 0.4'} + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} - fraction.js@5.3.4: - resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -1948,17 +1351,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function.prototype.name@1.1.8: - resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} - engines: {node: '>= 0.4'} - - functions-have-names@1.2.3: - resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} - - generator-function@2.0.1: - resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} - engines: {node: '>= 0.4'} - gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1971,35 +1363,21 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-symbol-description@1.1.0: - resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} - engines: {node: '>= 0.4'} - get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} - github-slugger@2.0.0: - resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@16.4.0: - resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} - engines: {node: '>=18'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true - globalthis@1.0.4: - resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} - engines: {node: '>= 0.4'} + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} @@ -2008,21 +1386,10 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-bigints@1.1.0: - resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} - engines: {node: '>= 0.4'} - has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} - has-property-descriptors@1.0.2: - resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} - - has-proto@1.2.0: - resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} - engines: {node: '>= 0.4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2035,196 +1402,89 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hast-util-heading-rank@3.0.0: - resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} - - hast-util-is-element@3.0.0: - resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} - - hast-util-to-estree@3.1.3: - resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} - - hast-util-to-jsx-runtime@2.3.6: - resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} - hast-util-to-string@3.0.1: - resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} - hast-util-to-text@4.0.2: - resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} - hast-util-whitespace@3.0.0: - resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + human-id@4.1.3: + resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} + hasBin: true - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} - highlight.js@11.11.1: - resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} - engines: {node: '>=12.0.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - inline-style-parser@0.2.7: - resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} - - internal-slot@1.1.0: - resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} - engines: {node: '>= 0.4'} - - is-alphabetical@2.0.1: - resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} - - is-alphanumerical@2.0.1: - resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} - - is-array-buffer@3.0.5: - resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} - engines: {node: '>= 0.4'} - - is-async-function@2.1.1: - resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} - engines: {node: '>= 0.4'} - - is-bigint@1.1.0: - resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} - engines: {node: '>= 0.4'} - - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} - is-boolean-object@1.2.2: - resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} - engines: {node: '>= 0.4'} - - is-bun-module@2.0.0: - resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} - - is-callable@1.2.7: - resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} - engines: {node: '>= 0.4'} - - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - - is-data-view@1.0.2: - resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} - engines: {node: '>= 0.4'} - - is-date-object@1.1.0: - resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} - engines: {node: '>= 0.4'} - - is-decimal@2.0.1: - resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} - is-finalizationregistry@1.1.1: - resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} - engines: {node: '>= 0.4'} - - is-generator-function@1.1.2: - resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} - engines: {node: '>= 0.4'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-hexadecimal@2.0.1: - resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} - - is-map@2.0.3: - resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} - engines: {node: '>= 0.4'} - - is-negative-zero@2.0.3: - resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} - engines: {node: '>= 0.4'} - - is-number-object@1.1.1: - resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} - engines: {node: '>= 0.4'} - is-number@7.0.0: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} - is-plain-obj@4.1.0: - resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} - engines: {node: '>=12'} - - is-regex@1.2.1: - resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} - engines: {node: '>= 0.4'} - - is-set@2.0.3: - resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} - engines: {node: '>= 0.4'} - - is-shared-array-buffer@1.0.4: - resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} - engines: {node: '>= 0.4'} - - is-string@1.1.1: - resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} - engines: {node: '>= 0.4'} - - is-symbol@1.1.1: - resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} - engines: {node: '>= 0.4'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} - is-typed-array@1.1.15: - resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} - engines: {node: '>= 0.4'} + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} - is-weakmap@2.0.2: - resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} - engines: {node: '>= 0.4'} + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} - is-weakref@1.1.1: - resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} - engines: {node: '>= 0.4'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - is-weakset@2.0.4: - resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} - engines: {node: '>= 0.4'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} - isarray@2.0.5: - resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} - iterator.prototype@1.1.5: - resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} - engines: {node: '>= 0.4'} + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} - jiti@1.21.7: - resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} - hasBin: true + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} @@ -2237,129 +1497,110 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@3.14.2: + resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + hasBin: true + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} hasBin: true - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - json5@1.0.2: - resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} - hasBin: true - json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} hasBin: true - jsx-ast-utils@3.3.5: - resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} - engines: {node: '>=4.0'} + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - language-subtag-registry@0.3.23: - resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} - - language-tags@1.0.9: - resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} - engines: {node: '>=0.10'} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-android-arm64@1.30.2: - resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] - lightningcss-darwin-arm64@1.30.2: - resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] - lightningcss-darwin-x64@1.30.2: - resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] - lightningcss-freebsd-x64@1.30.2: - resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] - lightningcss-linux-arm-gnueabihf@1.30.2: - resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] - lightningcss-linux-arm64-gnu@1.30.2: - resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] - lightningcss-linux-arm64-musl@1.30.2: - resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] - lightningcss-linux-x64-gnu@1.30.2: - resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] - lightningcss-linux-x64-musl@1.30.2: - resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] - lightningcss-win32-arm64-msvc@1.30.2: - resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] - lightningcss-win32-x64-msvc@1.30.2: - resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] - lightningcss@1.30.2: - resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} engines: {node: '>= 12.0.0'} - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2371,169 +1612,79 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} - longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - lowlight@3.3.0: - resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - markdown-extensions@2.0.0: - resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} - engines: {node: '>=16'} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdast-util-from-markdown@2.0.2: - resolution: {integrity: sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==} - - mdast-util-mdx-expression@2.0.1: - resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} - - mdast-util-mdx-jsx@3.2.0: - resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} - - mdast-util-mdx@3.0.0: - resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} - - mdast-util-mdxjs-esm@2.0.1: - resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} - - mdast-util-phrasing@4.1.0: - resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - - mdast-util-to-hast@13.2.1: - resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} - - mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - - mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - - micromark-extension-mdx-expression@3.0.1: - resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} - - micromark-extension-mdx-jsx@3.0.1: - resolution: {integrity: sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg==} - - micromark-extension-mdx-md@2.0.0: - resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} - - micromark-extension-mdxjs-esm@3.0.0: - resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} - - micromark-extension-mdxjs@3.0.0: - resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} - - micromark-factory-destination@2.0.1: - resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - - micromark-factory-label@2.0.1: - resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - - micromark-factory-mdx-expression@2.0.3: - resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} - - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - - micromark-factory-title@2.0.1: - resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - - micromark-factory-whitespace@2.0.1: - resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-chunked@2.0.1: - resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - - micromark-util-classify-character@2.0.1: - resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - - micromark-util-combine-extensions@2.0.1: - resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - - micromark-util-decode-numeric-character-reference@2.0.2: - resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - - micromark-util-decode-string@2.0.1: - resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-events-to-acorn@2.0.3: - resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} - - micromark-util-html-tag-name@2.0.1: - resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - - micromark-util-normalize-identifier@2.0.1: - resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - - micromark-util-resolve-all@2.0.1: - resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-subtokenize@2.1.0: - resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - - micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} minimatch@9.0.5: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2545,100 +1696,47 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - napi-postinstall@0.3.4: - resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} - engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - next@16.1.6: - resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} - engines: {node: '>=20.9.0'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.1.0 - '@playwright/test': ^1.51.1 - babel-plugin-react-compiler: '*' - react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 - sass: ^1.3.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - '@playwright/test': - optional: true - babel-plugin-react-compiler: - optional: true - sass: - optional: true - node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} + nwsapi@2.2.23: + resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==} object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-hash@3.0.0: - resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} - engines: {node: '>= 6'} - - object-inspect@1.13.4: - resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} - engines: {node: '>= 0.4'} - - object-keys@1.1.1: - resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} - engines: {node: '>= 0.4'} - - object.assign@4.1.7: - resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} - engines: {node: '>= 0.4'} - - object.entries@1.1.9: - resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} - engines: {node: '>= 0.4'} - - object.fromentries@2.0.8: - resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} - engines: {node: '>= 0.4'} + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} - object.groupby@1.0.3: - resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} - engines: {node: '>= 0.4'} + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} - object.values@1.2.1: - resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} - engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} - own-keys@1.0.1: - resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} - engines: {node: '>= 0.4'} + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} - parse-entities@4.0.2: - resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} @@ -2648,12 +1746,24 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2665,9 +1775,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pify@2.3.0: - resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} - engines: {node: '>=0.10.0'} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} @@ -2676,34 +1786,6 @@ packages: pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} - possible-typed-array-names@1.1.0: - resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} - engines: {node: '>= 0.4'} - - postcss-import@15.1.0: - resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} - engines: {node: '>=14.0.0'} - peerDependencies: - postcss: ^8.0.0 - - postcss-js@4.1.0: - resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.4.21 - - postcss-load-config@4.0.2: - resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} - engines: {node: '>= 14'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - postcss-load-config@6.0.1: resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} engines: {node: '>= 18'} @@ -2722,41 +1804,26 @@ packages: yaml: optional: true - postcss-nested@6.2.0: - resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - - postcss-selector-parser@6.1.2: - resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} - engines: {node: '>=4'} - - postcss-value-parser@4.2.0: - resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true - property-information@7.1.0: - resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -2765,13 +1832,8 @@ packages: peerDependencies: react: ^18.3.1 - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} - peerDependencies: - react: ^19.2.3 - - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} react-refresh@0.17.0: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} @@ -2781,113 +1843,52 @@ packages: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} engines: {node: '>=0.10.0'} - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} - engines: {node: '>=0.10.0'} - - read-cache@1.0.0: - resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} - - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} readdirp@4.1.2: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - recma-build-jsx@1.0.0: - resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} - - recma-jsx@1.0.1: - resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} - recma-parse@1.0.0: - resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} - recma-stringify@1.0.0: - resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - reflect.getprototypeof@1.0.10: - resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} - engines: {node: '>= 0.4'} + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - regexp.prototype.flags@1.5.4: - resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} - engines: {node: '>= 0.4'} + rollup@4.57.1: + resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true - rehype-autolink-headings@7.1.0: - resolution: {integrity: sha512-rItO/pSdvnvsP4QRB1pmPiNHUskikqtPojZKJPPPAVx9Hj8i8TwMBhofrrAYRhYOOBZH9tgmG5lPqDLuIWPWmw==} + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} - rehype-highlight@7.0.2: - resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} - rehype-recma@1.0.0: - resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rehype-slug@6.0.0: - resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - remark-mdx@3.1.0: - resolution: {integrity: sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} - remark-parse@11.0.0: - resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} - - remark-rehype@11.1.1: - resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - resolve-from@5.0.0: - resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} - engines: {node: '>=8'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - - resolve@2.0.0-next.5: - resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} - hasBin: true - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rollup@4.57.1: - resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - safe-array-concat@1.1.3: - resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} - engines: {node: '>=0.4'} - - safe-push-apply@1.0.0: - resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} - engines: {node: '>= 0.4'} - - safe-regex-test@1.1.0: - resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} - engines: {node: '>= 0.4'} - - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - - scheduler@0.27.0: - resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} @@ -2898,22 +1899,6 @@ packages: engines: {node: '>=10'} hasBin: true - set-function-length@1.2.2: - resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} - engines: {node: '>= 0.4'} - - set-function-name@2.0.2: - resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} - engines: {node: '>= 0.4'} - - set-proto@1.0.0: - resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} - engines: {node: '>= 0.4'} - - sharp@0.34.5: - resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} - engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} - shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2922,21 +1907,16 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - side-channel-list@1.0.0: - resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} - engines: {node: '>= 0.4'} - - side-channel-map@1.0.1: - resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} - engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - side-channel-weakmap@1.0.2: - resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} - engines: {node: '>= 0.4'} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} - engines: {node: '>= 0.4'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -2946,69 +1926,42 @@ packages: resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} engines: {node: '>= 12'} - space-separated-tokens@2.0.2: - resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} - - stable-hash@0.0.5: - resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} - - stop-iteration-iterator@1.1.0: - resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} - engines: {node: '>= 0.4'} + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} - string.prototype.includes@2.0.1: - resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} - engines: {node: '>= 0.4'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - string.prototype.matchall@4.0.12: - resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} - engines: {node: '>= 0.4'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} - string.prototype.repeat@1.0.0: - resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - string.prototype.trim@1.2.10: - resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} - engines: {node: '>= 0.4'} + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} - string.prototype.trimend@1.0.9: - resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} - engines: {node: '>= 0.4'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} - string.prototype.trimstart@1.0.8: - resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} - engines: {node: '>= 0.4'} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} - stringify-entities@4.0.4: - resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} - style-to-js@1.1.21: - resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} - - style-to-object@1.0.14: - resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} - - styled-jsx@5.1.6: - resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} - engines: {node: '>= 12.0.0'} - peerDependencies: - '@babel/core': '*' - babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' - peerDependenciesMeta: - '@babel/core': - optional: true - babel-plugin-macros: - optional: true - sucrase@3.35.1: resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} engines: {node: '>=16 || 14 >=14.17'} @@ -3018,22 +1971,24 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - - tailwindcss@3.4.4: - resolution: {integrity: sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==} - engines: {node: '>=14.0.0'} - hasBin: true + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} - tailwindcss@4.1.18: - resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + test-exclude@7.0.1: + resolution: {integrity: sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==} + engines: {node: '>=18'} + thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -3041,6 +1996,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -3048,35 +2006,44 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true - trim-lines@3.0.1: - resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - - trough@2.2.0: - resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} - - ts-api-utils@2.4.0: - resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} - tsconfig-paths@3.15.0: - resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - - tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsup@8.5.1: resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} engines: {node: '>=18'} @@ -3135,33 +2102,6 @@ packages: resolution: {integrity: sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ==} hasBin: true - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typed-array-buffer@1.0.3: - resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} - engines: {node: '>= 0.4'} - - typed-array-byte-length@1.0.3: - resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} - engines: {node: '>= 0.4'} - - typed-array-byte-offset@1.0.4: - resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} - engines: {node: '>= 0.4'} - - typed-array-length@1.0.7: - resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} - engines: {node: '>= 0.4'} - - typescript-eslint@8.55.0: - resolution: {integrity: sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -3170,42 +2110,15 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} - unbox-primitive@1.1.0: - resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} - engines: {node: '>= 0.4'} - undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - unified@11.0.5: - resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} - - unist-util-find-after@5.0.0: - resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} - - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-position-from-estree@2.0.0: - resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} - - unist-util-position@5.0.0: - resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.0.0: - resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} - - unrs-resolver@1.11.1: - resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} @@ -3213,17 +2126,10 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vfile-message@4.0.3: - resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} - - vfile@6.0.3: - resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true vite@5.4.21: resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} @@ -3256,30 +2162,88 @@ packages: terser: optional: true - which-boxed-primitive@1.1.1: - resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} - engines: {node: '>= 0.4'} + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true - which-builtin-type@1.2.1: - resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} - engines: {node: '>= 0.4'} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} - which-collection@1.0.2: - resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} - engines: {node: '>= 0.4'} + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} - which-typed-array@1.1.20: - resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} - engines: {node: '>= 0.4'} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3289,25 +2253,22 @@ packages: engines: {node: '>= 14.6'} hasBin: true - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - - zod@4.3.6: - resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} +snapshots: - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + '@adobe/css-tools@4.4.4': {} -snapshots: + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 '@babel/code-frame@7.29.0': dependencies: @@ -3398,6 +2359,8 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-plugin-utils': 7.28.6 + '@babel/runtime@7.28.6': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -3421,21 +2384,171 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@emnapi/core@1.8.1': + '@bcoe/v8-coverage@0.2.3': {} + + '@changesets/apply-release-plan@7.0.14': + dependencies: + '@changesets/config': 3.1.2 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.7.4 + + '@changesets/assemble-release-plan@6.0.9': dependencies: - '@emnapi/wasi-threads': 1.1.0 - tslib: 2.8.1 - optional: true + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.7.4 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.29.8(@types/node@24.10.11)': + dependencies: + '@changesets/apply-release-plan': 7.0.14 + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.2 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/get-release-plan': 4.0.14 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.6 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3(@types/node@24.10.11) + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + ci-info: 3.9.0 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + p-limit: 2.3.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.7.4 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' - '@emnapi/runtime@1.8.1': + '@changesets/config@3.1.2': dependencies: - tslib: 2.8.1 - optional: true + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.3 + '@changesets/logger': 0.1.1 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 - '@emnapi/wasi-threads@1.1.0': + '@changesets/errors@0.2.0': dependencies: - tslib: 2.8.1 - optional: true + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.3': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.7.4 + + '@changesets/get-release-plan@4.0.14': + dependencies: + '@changesets/assemble-release-plan': 6.0.9 + '@changesets/config': 3.1.2 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.6 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.2': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.1.1 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.6': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.2 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.1.3 + prettier: 2.8.8 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} '@esbuild/aix-ppc64@0.21.5': optional: true @@ -3584,280 +2697,74 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': + '@inquirer/external-editor@1.0.3(@types/node@24.10.11)': dependencies: - eslint: 9.39.2(jiti@2.6.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} + chardet: 2.1.1 + iconv-lite: 0.7.2 + optionalDependencies: + '@types/node': 24.10.11 - '@eslint/config-array@0.21.1': + '@isaacs/cliui@8.0.2': dependencies: - '@eslint/object-schema': 2.1.7 - debug: 4.4.3 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 - '@eslint/config-helpers@0.4.2': - dependencies: - '@eslint/core': 0.17.0 + '@istanbuljs/schema@0.1.3': {} - '@eslint/core@0.17.0': + '@jridgewell/gen-mapping@0.3.13': dependencies: - '@types/json-schema': 7.0.15 + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 - '@eslint/eslintrc@3.3.3': + '@jridgewell/remapping@2.3.5': dependencies: - ajv: 6.12.6 - debug: 4.4.3 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.1 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@eslint/js@9.39.2': {} + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} - '@eslint/object-schema@2.1.7': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 - '@eslint/plugin-kit@0.4.1': + '@manypkg/find-root@1.1.0': dependencies: - '@eslint/core': 0.17.0 - levn: 0.4.1 + '@babel/runtime': 7.28.6 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 - '@humanfs/core@0.19.1': {} + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.28.6 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 - '@humanfs/node@0.16.7': + '@nodelib/fs.scandir@2.1.5': dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 - '@humanwhocodes/module-importer@1.0.1': {} + '@nodelib/fs.stat@2.0.5': {} - '@humanwhocodes/retry@0.4.3': {} + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 - '@img/colour@1.0.0': + '@pkgjs/parseargs@0.11.0': optional: true - '@img/sharp-darwin-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.2.4 - optional: true - - '@img/sharp-darwin-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.2.4 - optional: true - - '@img/sharp-libvips-darwin-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-darwin-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-arm@1.2.4': - optional: true - - '@img/sharp-libvips-linux-ppc64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-riscv64@1.2.4': - optional: true - - '@img/sharp-libvips-linux-s390x@1.2.4': - optional: true - - '@img/sharp-libvips-linux-x64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-arm64@1.2.4': - optional: true - - '@img/sharp-libvips-linuxmusl-x64@1.2.4': - optional: true - - '@img/sharp-linux-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.2.4 - optional: true - - '@img/sharp-linux-arm@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.2.4 - optional: true - - '@img/sharp-linux-ppc64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.2.4 - optional: true - - '@img/sharp-linux-riscv64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.2.4 - optional: true - - '@img/sharp-linux-s390x@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.2.4 - optional: true - - '@img/sharp-linux-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-arm64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - optional: true - - '@img/sharp-linuxmusl-x64@0.34.5': - optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - optional: true - - '@img/sharp-wasm32@0.34.5': - dependencies: - '@emnapi/runtime': 1.8.1 - optional: true - - '@img/sharp-win32-arm64@0.34.5': - optional: true - - '@img/sharp-win32-ia32@0.34.5': - optional: true - - '@img/sharp-win32-x64@0.34.5': - optional: true - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@mdx-js/loader@3.1.1': - dependencies: - '@mdx-js/mdx': 3.1.1 - source-map: 0.7.6 - transitivePeerDependencies: - - supports-color - - '@mdx-js/mdx@3.1.1': - dependencies: - '@types/estree': 1.0.8 - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdx': 2.0.13 - acorn: 8.15.0 - collapse-white-space: 2.1.0 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - estree-util-scope: 1.0.0 - estree-walker: 3.0.3 - hast-util-to-jsx-runtime: 2.3.6 - markdown-extensions: 2.0.0 - recma-build-jsx: 1.0.0 - recma-jsx: 1.0.1(acorn@8.15.0) - recma-stringify: 1.0.0 - rehype-recma: 1.0.0 - remark-mdx: 3.1.0 - remark-parse: 11.0.0 - remark-rehype: 11.1.1 - source-map: 0.7.6 - unified: 11.0.5 - unist-util-position-from-estree: 2.0.0 - unist-util-stringify-position: 4.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - transitivePeerDependencies: - - supports-color - - '@mdx-js/react@3.1.1(@types/react@19.2.13)(react@19.2.3)': - dependencies: - '@types/mdx': 2.0.13 - '@types/react': 19.2.13 - react: 19.2.3 - - '@napi-rs/wasm-runtime@0.2.12': - dependencies: - '@emnapi/core': 1.8.1 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 - optional: true - - '@next/env@16.1.6': {} - - '@next/eslint-plugin-next@16.1.6': - dependencies: - fast-glob: 3.3.1 - - '@next/mdx@16.1.6(@mdx-js/loader@3.1.1)(@mdx-js/react@3.1.1(@types/react@19.2.13)(react@19.2.3))': - dependencies: - source-map: 0.7.6 - optionalDependencies: - '@mdx-js/loader': 3.1.1 - '@mdx-js/react': 3.1.1(@types/react@19.2.13)(react@19.2.3) - - '@next/swc-darwin-arm64@16.1.6': - optional: true - - '@next/swc-darwin-x64@16.1.6': - optional: true - - '@next/swc-linux-arm64-gnu@16.1.6': - optional: true - - '@next/swc-linux-arm64-musl@16.1.6': - optional: true - - '@next/swc-linux-x64-gnu@16.1.6': - optional: true - - '@next/swc-linux-x64-musl@16.1.6': - optional: true - - '@next/swc-win32-arm64-msvc@16.1.6': - optional: true - - '@next/swc-win32-x64-msvc@16.1.6': - optional: true - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 - - '@nolyfill/is-core-module@1.0.39': {} - '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.57.1': @@ -3935,89 +2842,105 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.57.1': optional: true - '@rtsao/scc@1.1.0': {} - - '@swc/helpers@0.5.15': - dependencies: - tslib: 2.8.1 - - '@tailwindcss/node@4.1.18': + '@tailwindcss/node@4.2.1': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.19.0 + enhanced-resolve: 5.20.0 jiti: 2.6.1 - lightningcss: 1.30.2 + lightningcss: 1.31.1 magic-string: 0.30.21 source-map-js: 1.2.1 - tailwindcss: 4.1.18 + tailwindcss: 4.2.1 - '@tailwindcss/oxide-android-arm64@4.1.18': + '@tailwindcss/oxide-android-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-arm64@4.1.18': + '@tailwindcss/oxide-darwin-arm64@4.2.1': optional: true - '@tailwindcss/oxide-darwin-x64@4.1.18': + '@tailwindcss/oxide-darwin-x64@4.2.1': optional: true - '@tailwindcss/oxide-freebsd-x64@4.1.18': + '@tailwindcss/oxide-freebsd-x64@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18': + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-gnu@4.1.18': + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-arm64-musl@4.1.18': + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-gnu@4.1.18': + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': optional: true - '@tailwindcss/oxide-linux-x64-musl@4.1.18': + '@tailwindcss/oxide-linux-x64-musl@4.2.1': optional: true - '@tailwindcss/oxide-wasm32-wasi@4.1.18': + '@tailwindcss/oxide-wasm32-wasi@4.2.1': optional: true - '@tailwindcss/oxide-win32-arm64-msvc@4.1.18': + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': optional: true - '@tailwindcss/oxide-win32-x64-msvc@4.1.18': + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': optional: true - '@tailwindcss/oxide@4.1.18': + '@tailwindcss/oxide@4.2.1': optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-arm64': 4.1.18 - '@tailwindcss/oxide-darwin-x64': 4.1.18 - '@tailwindcss/oxide-freebsd-x64': 4.1.18 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.18 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.18 - '@tailwindcss/oxide-linux-x64-musl': 4.1.18 - '@tailwindcss/oxide-wasm32-wasi': 4.1.18 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.18 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.18 - - '@tailwindcss/postcss@4.1.18': - dependencies: - '@alloc/quick-lru': 5.2.0 - '@tailwindcss/node': 4.1.18 - '@tailwindcss/oxide': 4.1.18 - postcss: 8.5.6 - tailwindcss: 4.1.18 + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.28.6 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 - '@tybys/wasm-util@0.10.1': + '@testing-library/jest-dom@6.9.1': dependencies: - tslib: 2.8.1 - optional: true + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 - '@types/acorn@4.0.6': + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: - '@types/estree': 1.0.8 + '@babel/runtime': 7.28.6 + '@testing-library/dom': 10.4.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.28 + '@types/react-dom': 18.3.7(@types/react@18.3.28) + + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': dependencies: @@ -4040,33 +2963,11 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@types/debug@4.1.12': - dependencies: - '@types/ms': 2.1.0 - - '@types/estree-jsx@1.0.5': - dependencies: - '@types/estree': 1.0.8 - '@types/estree@1.0.8': {} - '@types/hast@3.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/json-schema@7.0.15': {} - - '@types/json5@0.0.29': {} + '@types/node@12.20.55': {} - '@types/mdast@4.0.4': - dependencies: - '@types/unist': 3.0.3 - - '@types/mdx@2.0.13': {} - - '@types/ms@2.1.0': {} - - '@types/node@20.19.33': + '@types/node@20.19.35': dependencies: undici-types: 6.21.0 @@ -4080,319 +2981,126 @@ snapshots: dependencies: '@types/react': 18.3.28 - '@types/react-dom@19.2.3(@types/react@19.2.13)': - dependencies: - '@types/react': 19.2.13 - '@types/react@18.3.28': dependencies: '@types/prop-types': 15.7.15 csstype: 3.2.3 - '@types/react@19.2.13': - dependencies: - csstype: 3.2.3 - - '@types/unist@2.0.11': {} - - '@types/unist@3.0.3': {} - - '@typescript-eslint/eslint-plugin@8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/type-utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 - eslint: 9.39.2(jiti@2.6.1) - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1))': dependencies: - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - typescript: 5.9.3 + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.55.0(typescript@5.9.3)': + '@vitest/coverage-v8@2.1.9(vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1))': dependencies: - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 debug: 4.4.3 - typescript: 5.9.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.1 + tinyrainbow: 1.2.0 + vitest: 2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1) transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.55.0': + '@vitest/expect@2.1.9': dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 - '@typescript-eslint/tsconfig-utils@8.55.0(typescript@5.9.3)': + '@vitest/mocker@2.1.9(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1))': dependencies: - typescript: 5.9.3 + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) - '@typescript-eslint/type-utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@vitest/pretty-format@2.1.9': dependencies: - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + tinyrainbow: 1.2.0 - '@typescript-eslint/types@8.55.0': {} - - '@typescript-eslint/typescript-estree@8.55.0(typescript@5.9.3)': + '@vitest/runner@2.1.9': dependencies: - '@typescript-eslint/project-service': 8.55.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.55.0(typescript@5.9.3) - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/visitor-keys': 8.55.0 - debug: 4.4.3 - minimatch: 9.0.5 - semver: 7.7.4 - tinyglobby: 0.2.15 - ts-api-utils: 2.4.0(typescript@5.9.3) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@vitest/utils': 2.1.9 + pathe: 1.1.2 - '@typescript-eslint/utils@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)': + '@vitest/snapshot@2.1.9': dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@typescript-eslint/scope-manager': 8.55.0 - '@typescript-eslint/types': 8.55.0 - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 - '@typescript-eslint/visitor-keys@8.55.0': + '@vitest/spy@2.1.9': dependencies: - '@typescript-eslint/types': 8.55.0 - eslint-visitor-keys: 4.2.1 - - '@ungap/structured-clone@1.3.0': {} + tinyspy: 3.0.2 - '@unrs/resolver-binding-android-arm-eabi@1.11.1': - optional: true - - '@unrs/resolver-binding-android-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-arm64@1.11.1': - optional: true - - '@unrs/resolver-binding-darwin-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-freebsd-x64@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - optional: true - - '@unrs/resolver-binding-linux-x64-musl@1.11.1': - optional: true - - '@unrs/resolver-binding-wasm32-wasi@1.11.1': + '@vitest/utils@2.1.9': dependencies: - '@napi-rs/wasm-runtime': 0.2.12 - optional: true - - '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - optional: true + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 - '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - optional: true - - '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - optional: true + acorn@8.15.0: {} - '@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.30.2))': - dependencies: - '@babel/core': 7.29.0 - '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) - '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) - '@rolldown/pluginutils': 1.0.0-beta.27 - '@types/babel__core': 7.20.5 - react-refresh: 0.17.0 - vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.30.2) - transitivePeerDependencies: - - supports-color + agent-base@7.1.4: {} - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 + ansi-colors@4.1.3: {} - acorn@8.15.0: {} + ansi-regex@5.0.1: {} - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 + ansi-regex@6.2.2: {} ansi-styles@4.3.0: dependencies: color-convert: 2.0.1 - any-promise@1.3.0: {} - - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.1 - - arg@5.0.2: {} - - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - array-buffer-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - is-array-buffer: 3.0.5 - - array-includes@3.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - is-string: 1.1.1 - math-intrinsics: 1.1.0 - - array.prototype.findlast@1.2.5: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 + ansi-styles@5.2.0: {} - array.prototype.findlastindex@1.2.6: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-shim-unscopables: 1.1.0 + ansi-styles@6.2.3: {} - array.prototype.flat@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.flatmap@1.3.3: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-shim-unscopables: 1.1.0 - - array.prototype.tosorted@1.1.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-shim-unscopables: 1.1.0 + any-promise@1.3.0: {} - arraybuffer.prototype.slice@1.0.4: + argparse@1.0.10: dependencies: - array-buffer-byte-length: 1.0.2 - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - is-array-buffer: 3.0.5 - - ast-types-flow@0.0.8: {} + sprintf-js: 1.0.3 - astring@1.9.0: {} - - async-function@1.0.0: {} + argparse@2.0.1: {} - autoprefixer@10.4.24(postcss@8.5.6): + aria-query@5.3.0: dependencies: - browserslist: 4.28.1 - caniuse-lite: 1.0.30001769 - fraction.js: 5.3.4 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-value-parser: 4.2.0 + dequal: 2.0.3 - available-typed-arrays@1.0.7: - dependencies: - possible-typed-array-names: 1.1.0 + aria-query@5.3.2: {} - axe-core@4.11.1: {} + array-union@2.1.0: {} - axobject-query@4.1.0: {} + assertion-error@2.0.1: {} - bail@2.0.2: {} + asynckit@0.4.0: {} balanced-match@1.0.2: {} baseline-browser-mapping@2.9.19: {} - binary-extensions@2.3.0: {} - - brace-expansion@1.1.12: + better-path-resolve@1.0.0: dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 + is-windows: 1.0.2 brace-expansion@2.0.2: dependencies: @@ -4422,58 +3130,25 @@ snapshots: es-errors: 1.3.0 function-bind: 1.1.2 - call-bind@1.0.8: - dependencies: - call-bind-apply-helpers: 1.0.2 - es-define-property: 1.0.1 - get-intrinsic: 1.3.0 - set-function-length: 1.2.2 - - call-bound@1.0.4: - dependencies: - call-bind-apply-helpers: 1.0.2 - get-intrinsic: 1.3.0 - - callsites@3.1.0: {} - - camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001769: {} - ccount@2.0.1: {} - - chalk@4.1.2: + chai@5.3.3: dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - character-entities-html4@2.1.0: {} - - character-entities-legacy@3.0.0: {} - - character-entities@2.0.2: {} + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 - character-reference-invalid@2.0.1: {} + chardet@2.1.1: {} - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 + check-error@2.1.3: {} chokidar@4.0.3: dependencies: readdirp: 4.1.2 - client-only@0.0.1: {} - - collapse-white-space@2.1.0: {} + ci-info@3.9.0: {} color-convert@2.0.1: dependencies: @@ -4481,12 +3156,12 @@ snapshots: color-name@1.1.4: {} - comma-separated-tokens@2.0.3: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 commander@4.1.1: {} - concat-map@0.0.1: {} - confbox@0.1.8: {} consola@3.4.2: {} @@ -4499,71 +3174,43 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - cssesc@3.0.0: {} - - csstype@3.2.3: {} - - damerau-levenshtein@1.0.8: {} + css.escape@1.5.1: {} - data-view-buffer@1.0.2: + cssstyle@4.6.0: dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 - data-view-byte-length@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 - - data-view-byte-offset@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-data-view: 1.0.2 + csstype@3.2.3: {} - debug@3.2.7: + data-urls@5.0.0: dependencies: - ms: 2.1.3 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 debug@4.4.3: dependencies: ms: 2.1.3 - decode-named-character-reference@1.3.0: - dependencies: - character-entities: 2.0.2 + decimal.js@10.6.0: {} - deep-is@0.1.4: {} + deep-eql@5.0.2: {} - define-data-property@1.1.4: - dependencies: - es-define-property: 1.0.1 - es-errors: 1.3.0 - gopd: 1.2.0 - - define-properties@1.2.1: - dependencies: - define-data-property: 1.1.4 - has-property-descriptors: 1.0.2 - object-keys: 1.1.1 + delayed-stream@1.0.0: {} dequal@2.0.3: {} + detect-indent@6.1.0: {} + detect-libc@2.1.2: {} - devlop@1.1.0: + dir-glob@3.0.1: dependencies: - dequal: 2.0.3 + path-type: 4.0.0 - didyoumean@1.2.2: {} + dom-accessibility-api@0.5.16: {} - dlv@1.1.3: {} - - doctrine@2.1.0: - dependencies: - esutils: 2.0.3 + dom-accessibility-api@0.6.3: {} dunder-proto@1.0.1: dependencies: @@ -4571,94 +3218,31 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + electron-to-chromium@1.5.286: {} + emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} - enhanced-resolve@5.19.0: + enhanced-resolve@5.20.0: dependencies: graceful-fs: 4.2.11 tapable: 2.3.0 - es-abstract@1.24.1: + enquirer@2.4.1: dependencies: - array-buffer-byte-length: 1.0.2 - arraybuffer.prototype.slice: 1.0.4 - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - data-view-buffer: 1.0.2 - data-view-byte-length: 1.0.2 - data-view-byte-offset: 1.0.1 - es-define-property: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - es-set-tostringtag: 2.1.0 - es-to-primitive: 1.3.0 - function.prototype.name: 1.1.8 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - get-symbol-description: 1.1.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - hasown: 2.0.2 - internal-slot: 1.1.0 - is-array-buffer: 3.0.5 - is-callable: 1.2.7 - is-data-view: 1.0.2 - is-negative-zero: 2.0.3 - is-regex: 1.2.1 - is-set: 2.0.3 - is-shared-array-buffer: 1.0.4 - is-string: 1.1.1 - is-typed-array: 1.1.15 - is-weakref: 1.1.1 - math-intrinsics: 1.1.0 - object-inspect: 1.13.4 - object-keys: 1.1.1 - object.assign: 4.1.7 - own-keys: 1.0.1 - regexp.prototype.flags: 1.5.4 - safe-array-concat: 1.1.3 - safe-push-apply: 1.0.0 - safe-regex-test: 1.1.0 - set-proto: 1.0.0 - stop-iteration-iterator: 1.1.0 - string.prototype.trim: 1.2.10 - string.prototype.trimend: 1.0.9 - string.prototype.trimstart: 1.0.8 - typed-array-buffer: 1.0.3 - typed-array-byte-length: 1.0.3 - typed-array-byte-offset: 1.0.4 - typed-array-length: 1.0.7 - unbox-primitive: 1.1.0 - which-typed-array: 1.1.20 + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@6.0.1: {} es-define-property@1.0.1: {} es-errors@1.3.0: {} - es-iterator-helpers@1.2.2: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-set-tostringtag: 2.1.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - globalthis: 1.0.4 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - has-proto: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - iterator.prototype: 1.1.5 - safe-array-concat: 1.1.3 + es-module-lexer@1.7.0: {} es-object-atoms@1.1.1: dependencies: @@ -4671,30 +3255,6 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-shim-unscopables@1.1.0: - dependencies: - hasown: 2.0.2 - - es-to-primitive@1.3.0: - dependencies: - is-callable: 1.2.7 - is-date-object: 1.1.0 - is-symbol: 1.1.1 - - esast-util-from-estree@2.0.0: - dependencies: - '@types/estree-jsx': 1.0.5 - devlop: 1.1.0 - estree-util-visit: 2.0.0 - unist-util-position-from-estree: 2.0.0 - - esast-util-from-js@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - acorn: 8.15.0 - esast-util-from-estree: 2.0.0 - vfile-message: 4.0.3 - esbuild@0.21.5: optionalDependencies: '@esbuild/aix-ppc64': 0.21.5 @@ -4752,255 +3312,15 @@ snapshots: escalade@3.2.0: {} - escape-string-regexp@4.0.0: {} - - eslint-config-next@16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@next/eslint-plugin-next': 16.1.6 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) - globals: 16.4.0 - typescript-eslint: 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - optionalDependencies: - typescript: 5.9.3 - transitivePeerDependencies: - - '@typescript-eslint/parser' - - eslint-import-resolver-webpack - - eslint-plugin-import-x - - supports-color - - eslint-import-resolver-node@0.3.9: - dependencies: - debug: 3.2.7 - is-core-module: 2.16.1 - resolve: 1.22.11 - transitivePeerDependencies: - - supports-color - - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 - eslint: 9.39.2(jiti@2.6.1) - get-tsconfig: 4.13.6 - is-bun-module: 2.0.0 - stable-hash: 0.0.5 - tinyglobby: 0.2.15 - unrs-resolver: 1.11.1 - optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): - dependencies: - debug: 3.2.7 - optionalDependencies: - '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@2.6.1)) - transitivePeerDependencies: - - supports-color - - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@rtsao/scc': 1.1.0 - array-includes: 3.1.9 - array.prototype.findlastindex: 1.2.6 - array.prototype.flat: 1.3.3 - array.prototype.flatmap: 1.3.3 - debug: 3.2.7 - doctrine: 2.1.0 - eslint: 9.39.2(jiti@2.6.1) - eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) - hasown: 2.0.2 - is-core-module: 2.16.1 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - object.groupby: 1.0.3 - object.values: 1.2.1 - semver: 6.3.1 - string.prototype.trimend: 1.0.9 - tsconfig-paths: 3.15.0 - optionalDependencies: - '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.2(jiti@2.6.1)): - dependencies: - aria-query: 5.3.2 - array-includes: 3.1.9 - array.prototype.flatmap: 1.3.3 - ast-types-flow: 0.0.8 - axe-core: 4.11.1 - axobject-query: 4.1.0 - damerau-levenshtein: 1.0.8 - emoji-regex: 9.2.2 - eslint: 9.39.2(jiti@2.6.1) - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - language-tags: 1.0.9 - minimatch: 3.1.2 - object.fromentries: 2.0.8 - safe-regex-test: 1.1.0 - string.prototype.includes: 2.0.1 - - eslint-plugin-react-hooks@7.0.1(eslint@9.39.2(jiti@2.6.1)): - dependencies: - '@babel/core': 7.29.0 - '@babel/parser': 7.29.0 - eslint: 9.39.2(jiti@2.6.1) - hermes-parser: 0.25.1 - zod: 4.3.6 - zod-validation-error: 4.0.2(zod@4.3.6) - transitivePeerDependencies: - - supports-color - - eslint-plugin-react@7.37.5(eslint@9.39.2(jiti@2.6.1)): - dependencies: - array-includes: 3.1.9 - array.prototype.findlast: 1.2.5 - array.prototype.flatmap: 1.3.3 - array.prototype.tosorted: 1.1.4 - doctrine: 2.1.0 - es-iterator-helpers: 1.2.2 - eslint: 9.39.2(jiti@2.6.1) - estraverse: 5.3.0 - hasown: 2.0.2 - jsx-ast-utils: 3.3.5 - minimatch: 3.1.2 - object.entries: 1.1.9 - object.fromentries: 2.0.8 - object.values: 1.2.1 - prop-types: 15.8.1 - resolve: 2.0.0-next.5 - semver: 6.3.1 - string.prototype.matchall: 4.0.12 - string.prototype.repeat: 1.0.0 - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint@9.39.2(jiti@2.6.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@2.6.1)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.1 - '@eslint/config-helpers': 0.4.2 - '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.3 - '@eslint/js': 9.39.2 - '@eslint/plugin-kit': 0.4.1 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.6.1 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - estree-util-attach-comments@3.0.0: - dependencies: - '@types/estree': 1.0.8 - - estree-util-build-jsx@3.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - estree-walker: 3.0.3 - - estree-util-is-identifier-name@3.0.0: {} - - estree-util-scope@1.0.0: - dependencies: - '@types/estree': 1.0.8 - devlop: 1.1.0 - - estree-util-to-js@2.0.0: - dependencies: - '@types/estree-jsx': 1.0.5 - astring: 1.9.0 - source-map: 0.7.6 - - estree-util-visit@2.0.0: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/unist': 3.0.3 + esprima@4.0.1: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 - esutils@2.0.3: {} + expect-type@1.3.0: {} - extend@3.0.2: {} - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.1: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 + extendable-error@0.1.7: {} fast-glob@3.3.3: dependencies: @@ -5010,10 +3330,6 @@ snapshots: merge2: 1.4.1 micromatch: 4.0.8 - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5022,17 +3338,13 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - find-up@5.0.0: + find-up@4.1.0: dependencies: - locate-path: 6.0.0 + locate-path: 5.0.0 path-exists: 4.0.0 fix-dts-default-cjs-exports@1.0.1: @@ -5041,37 +3353,36 @@ snapshots: mlly: 1.8.0 rollup: 4.57.1 - flat-cache@4.0.1: + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fs-extra@7.0.1: dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - - flatted@3.3.3: {} + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 - for-each@0.3.5: + fs-extra@8.1.0: dependencies: - is-callable: 1.2.7 - - fraction.js@5.3.4: {} + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 fsevents@2.3.3: optional: true function-bind@1.1.2: {} - function.prototype.name@1.1.8: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - functions-have-names: 1.2.3 - hasown: 2.0.2 - is-callable: 1.2.7 - - functions-have-names@1.2.3: {} - - generator-function@2.0.1: {} - gensync@1.0.0-beta.2: {} get-intrinsic@1.3.0: @@ -5092,51 +3403,38 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 - get-symbol-description@1.1.0: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - get-tsconfig@4.13.6: dependencies: resolve-pkg-maps: 1.0.0 - github-slugger@2.0.0: {} - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: + glob@10.5.0: dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - globals@16.4.0: {} + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 - globalthis@1.0.4: + globby@11.1.0: dependencies: - define-properties: 1.2.1 - gopd: 1.2.0 + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 gopd@1.2.0: {} graceful-fs@4.2.11: {} - has-bigints@1.1.0: {} - has-flag@4.0.0: {} - has-property-descriptors@1.0.2: - dependencies: - es-define-property: 1.0.1 - - has-proto@1.2.0: - dependencies: - dunder-proto: 1.0.1 - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -5147,240 +3445,86 @@ snapshots: dependencies: function-bind: 1.1.2 - hast-util-heading-rank@3.0.0: + html-encoding-sniffer@4.0.0: dependencies: - '@types/hast': 3.0.4 + whatwg-encoding: 3.1.1 - hast-util-is-element@3.0.0: - dependencies: - '@types/hast': 3.0.4 + html-escaper@2.0.2: {} - hast-util-to-estree@3.1.3: + http-proxy-agent@7.0.2: dependencies: - '@types/estree': 1.0.8 - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-attach-comments: 3.0.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - style-to-js: 1.1.21 - unist-util-position: 5.0.0 - zwitch: 2.0.4 + agent-base: 7.1.4 + debug: 4.4.3 transitivePeerDependencies: - supports-color - hast-util-to-jsx-runtime@2.3.6: + https-proxy-agent@7.0.6: dependencies: - '@types/estree': 1.0.8 - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - comma-separated-tokens: 2.0.3 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - hast-util-whitespace: 3.0.0 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - property-information: 7.1.0 - space-separated-tokens: 2.0.2 - style-to-js: 1.1.21 - unist-util-position: 5.0.0 - vfile-message: 4.0.3 + agent-base: 7.1.4 + debug: 4.4.3 transitivePeerDependencies: - supports-color - hast-util-to-string@3.0.1: - dependencies: - '@types/hast': 3.0.4 - - hast-util-to-text@4.0.2: - dependencies: - '@types/hast': 3.0.4 - '@types/unist': 3.0.3 - hast-util-is-element: 3.0.0 - unist-util-find-after: 5.0.0 + human-id@4.1.3: {} - hast-util-whitespace@3.0.0: + iconv-lite@0.6.3: dependencies: - '@types/hast': 3.0.4 - - hermes-estree@0.25.1: {} + safer-buffer: 2.1.2 - hermes-parser@0.25.1: + iconv-lite@0.7.2: dependencies: - hermes-estree: 0.25.1 - - highlight.js@11.11.1: {} + safer-buffer: 2.1.2 ignore@5.3.2: {} - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - inline-style-parser@0.2.7: {} - - internal-slot@1.1.0: - dependencies: - es-errors: 1.3.0 - hasown: 2.0.2 - side-channel: 1.1.0 - - is-alphabetical@2.0.1: {} - - is-alphanumerical@2.0.1: - dependencies: - is-alphabetical: 2.0.1 - is-decimal: 2.0.1 - - is-array-buffer@3.0.5: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - is-async-function@2.1.1: - dependencies: - async-function: 1.0.0 - call-bound: 1.0.4 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 - - is-bigint@1.1.0: - dependencies: - has-bigints: 1.1.0 - - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-boolean-object@1.2.2: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-bun-module@2.0.0: - dependencies: - semver: 7.7.4 - - is-callable@1.2.7: {} - - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - - is-data-view@1.0.2: - dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - is-typed-array: 1.1.15 - - is-date-object@1.1.0: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - - is-decimal@2.0.1: {} + indent-string@4.0.0: {} is-extglob@2.1.1: {} - is-finalizationregistry@1.1.1: - dependencies: - call-bound: 1.0.4 - - is-generator-function@1.1.2: - dependencies: - call-bound: 1.0.4 - generator-function: 2.0.1 - get-proto: 1.0.1 - has-tostringtag: 1.0.2 - safe-regex-test: 1.1.0 + is-fullwidth-code-point@3.0.0: {} is-glob@4.0.3: dependencies: is-extglob: 2.1.1 - is-hexadecimal@2.0.1: {} - - is-map@2.0.3: {} - - is-negative-zero@2.0.3: {} - - is-number-object@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 - is-number@7.0.0: {} - is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} - is-regex@1.2.1: + is-subdir@1.2.0: dependencies: - call-bound: 1.0.4 - gopd: 1.2.0 - has-tostringtag: 1.0.2 - hasown: 2.0.2 + better-path-resolve: 1.0.0 - is-set@2.0.3: {} + is-windows@1.0.2: {} - is-shared-array-buffer@1.0.4: - dependencies: - call-bound: 1.0.4 - - is-string@1.1.1: - dependencies: - call-bound: 1.0.4 - has-tostringtag: 1.0.2 + isexe@2.0.0: {} - is-symbol@1.1.1: - dependencies: - call-bound: 1.0.4 - has-symbols: 1.1.0 - safe-regex-test: 1.1.0 + istanbul-lib-coverage@3.2.2: {} - is-typed-array@1.1.15: + istanbul-lib-report@3.0.1: dependencies: - which-typed-array: 1.1.20 - - is-weakmap@2.0.2: {} + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 - is-weakref@1.1.1: + istanbul-lib-source-maps@5.0.6: dependencies: - call-bound: 1.0.4 + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color - is-weakset@2.0.4: + istanbul-reports@3.2.0: dependencies: - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - - isarray@2.0.5: {} - - isexe@2.0.0: {} + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 - iterator.prototype@1.1.5: + jackspeak@3.4.3: dependencies: - define-data-property: 1.1.4 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - has-symbols: 1.1.0 - set-function-name: 2.0.2 - - jiti@1.21.7: {} + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 jiti@2.6.1: {} @@ -5388,96 +3532,99 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@3.14.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.1: dependencies: argparse: 2.0.1 - jsesc@3.1.0: {} - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.5 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.23 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.19.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate - json5@1.0.2: - dependencies: - minimist: 1.2.8 + jsesc@3.1.0: {} json5@2.2.3: {} - jsx-ast-utils@3.3.5: - dependencies: - array-includes: 3.1.9 - array.prototype.flat: 1.3.3 - object.assign: 4.1.7 - object.values: 1.2.1 - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - language-subtag-registry@0.3.23: {} - - language-tags@1.0.9: - dependencies: - language-subtag-registry: 0.3.23 - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 - lightningcss-android-arm64@1.30.2: + lightningcss-android-arm64@1.31.1: optional: true - lightningcss-darwin-arm64@1.30.2: + lightningcss-darwin-arm64@1.31.1: optional: true - lightningcss-darwin-x64@1.30.2: + lightningcss-darwin-x64@1.31.1: optional: true - lightningcss-freebsd-x64@1.30.2: + lightningcss-freebsd-x64@1.31.1: optional: true - lightningcss-linux-arm-gnueabihf@1.30.2: + lightningcss-linux-arm-gnueabihf@1.31.1: optional: true - lightningcss-linux-arm64-gnu@1.30.2: + lightningcss-linux-arm64-gnu@1.31.1: optional: true - lightningcss-linux-arm64-musl@1.30.2: + lightningcss-linux-arm64-musl@1.31.1: optional: true - lightningcss-linux-x64-gnu@1.30.2: + lightningcss-linux-x64-gnu@1.31.1: optional: true - lightningcss-linux-x64-musl@1.30.2: + lightningcss-linux-x64-musl@1.31.1: optional: true - lightningcss-win32-arm64-msvc@1.30.2: + lightningcss-win32-arm64-msvc@1.31.1: optional: true - lightningcss-win32-x64-msvc@1.30.2: + lightningcss-win32-x64-msvc@1.31.1: optional: true - lightningcss@1.30.2: + lightningcss@1.31.1: dependencies: detect-libc: 2.1.2 optionalDependencies: - lightningcss-android-arm64: 1.30.2 - lightningcss-darwin-arm64: 1.30.2 - lightningcss-darwin-x64: 1.30.2 - lightningcss-freebsd-x64: 1.30.2 - lightningcss-linux-arm-gnueabihf: 1.30.2 - lightningcss-linux-arm64-gnu: 1.30.2 - lightningcss-linux-arm64-musl: 1.30.2 - lightningcss-linux-x64-gnu: 1.30.2 - lightningcss-linux-x64-musl: 1.30.2 - lightningcss-win32-arm64-msvc: 1.30.2 - lightningcss-win32-x64-msvc: 1.30.2 - - lilconfig@2.1.0: {} + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 lilconfig@3.1.3: {} @@ -5485,358 +3632,62 @@ snapshots: load-tsconfig@0.2.5: {} - locate-path@6.0.0: + locate-path@5.0.0: dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} + p-locate: 4.1.0 - longest-streak@3.1.0: {} + lodash.startcase@4.4.0: {} loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 - lowlight@3.3.0: - dependencies: - '@types/hast': 3.0.4 - devlop: 1.1.0 - highlight.js: 11.11.1 + loupe@3.2.1: {} + + lru-cache@10.4.3: {} lru-cache@5.1.1: dependencies: yallist: 3.1.1 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - markdown-extensions@2.0.0: {} - - math-intrinsics@1.1.0: {} - - mdast-util-from-markdown@2.0.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-expression@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx-jsx@3.2.0: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - parse-entities: 4.0.2 - stringify-entities: 4.0.4 - unist-util-stringify-position: 4.0.0 - vfile-message: 4.0.3 - transitivePeerDependencies: - - supports-color - - mdast-util-mdx@3.0.0: - dependencies: - mdast-util-from-markdown: 2.0.2 - mdast-util-mdx-expression: 2.0.1 - mdast-util-mdx-jsx: 3.2.0 - mdast-util-mdxjs-esm: 2.0.1 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-mdxjs-esm@2.0.1: - dependencies: - '@types/estree-jsx': 1.0.5 - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.2 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@4.1.0: + magicast@0.3.5: dependencies: - '@types/mdast': 4.0.4 - unist-util-is: 6.0.1 - - mdast-util-to-hast@13.2.1: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - '@ungap/structured-clone': 1.3.0 - devlop: 1.1.0 - micromark-util-sanitize-uri: 2.0.1 - trim-lines: 3.0.1 - unist-util-position: 5.0.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + source-map-js: 1.2.1 - mdast-util-to-markdown@2.1.2: + make-dir@4.0.0: dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.0.0 - zwitch: 2.0.4 + semver: 7.7.4 - mdast-util-to-string@4.0.0: - dependencies: - '@types/mdast': 4.0.4 + math-intrinsics@1.1.0: {} merge2@1.4.1: {} - micromark-core-commonmark@2.0.3: - dependencies: - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-mdx-expression@3.0.1: - dependencies: - '@types/estree': 1.0.8 - devlop: 1.1.0 - micromark-factory-mdx-expression: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-events-to-acorn: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-mdx-jsx@3.0.1: - dependencies: - '@types/acorn': 4.0.6 - '@types/estree': 1.0.8 - devlop: 1.1.0 - estree-util-is-identifier-name: 3.0.0 - micromark-factory-mdx-expression: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-events-to-acorn: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - vfile-message: 4.0.3 - - micromark-extension-mdx-md@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-mdxjs-esm@3.0.0: - dependencies: - '@types/estree': 1.0.8 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-util-character: 2.1.1 - micromark-util-events-to-acorn: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-position-from-estree: 2.0.0 - vfile-message: 4.0.3 - - micromark-extension-mdxjs@3.0.0: - dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - micromark-extension-mdx-expression: 3.0.1 - micromark-extension-mdx-jsx: 3.0.1 - micromark-extension-mdx-md: 2.0.0 - micromark-extension-mdxjs-esm: 3.0.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-destination@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-label@2.0.1: - dependencies: - devlop: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-mdx-expression@2.0.3: - dependencies: - '@types/estree': 1.0.8 - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-events-to-acorn: 2.0.3 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-position-from-estree: 2.0.0 - vfile-message: 4.0.3 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 - - micromark-factory-title@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-whitespace@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-chunked@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-classify-character@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-combine-extensions@2.0.1: - dependencies: - micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-decode-numeric-character-reference@2.0.2: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-decode-string@2.0.1: - dependencies: - decode-named-character-reference: 1.3.0 - micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-symbol: 2.0.1 - - micromark-util-encode@2.0.1: {} - - micromark-util-events-to-acorn@2.0.3: - dependencies: - '@types/estree': 1.0.8 - '@types/unist': 3.0.3 - devlop: 1.1.0 - estree-util-visit: 2.0.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - vfile-message: 4.0.3 - - micromark-util-html-tag-name@2.0.1: {} - - micromark-util-normalize-identifier@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-resolve-all@2.0.1: - dependencies: - micromark-util-types: 2.0.2 - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-subtokenize@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.12 - debug: 4.4.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 - minimatch@3.1.2: + mime-db@1.52.0: {} + + mime-types@2.1.35: dependencies: - brace-expansion: 1.1.12 + mime-db: 1.52.0 + + min-indent@1.0.1: {} minimatch@9.0.5: dependencies: brace-expansion: 2.0.2 - minimist@1.2.8: {} + minipass@7.1.3: {} mlly@1.8.0: dependencies: @@ -5845,6 +3696,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.3 + mri@1.2.0: {} + ms@2.1.3: {} mz@2.7.0: @@ -5855,134 +3708,64 @@ snapshots: nanoid@3.3.11: {} - napi-postinstall@0.3.4: {} - - natural-compare@1.4.0: {} - - next@16.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - '@next/env': 16.1.6 - '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.9.19 - caniuse-lite: 1.0.30001769 - postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(react@19.2.3) - optionalDependencies: - '@next/swc-darwin-arm64': 16.1.6 - '@next/swc-darwin-x64': 16.1.6 - '@next/swc-linux-arm64-gnu': 16.1.6 - '@next/swc-linux-arm64-musl': 16.1.6 - '@next/swc-linux-x64-gnu': 16.1.6 - '@next/swc-linux-x64-musl': 16.1.6 - '@next/swc-win32-arm64-msvc': 16.1.6 - '@next/swc-win32-x64-msvc': 16.1.6 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - node-releases@2.0.27: {} - normalize-path@3.0.0: {} + nwsapi@2.2.23: {} object-assign@4.1.1: {} - object-hash@3.0.0: {} - - object-inspect@1.13.4: {} - - object-keys@1.1.1: {} + outdent@0.5.0: {} - object.assign@4.1.7: + p-filter@2.1.0: dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 - has-symbols: 1.1.0 - object-keys: 1.1.1 - - object.entries@1.1.9: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + p-map: 2.1.0 - object.fromentries@2.0.8: + p-limit@2.3.0: dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 + p-try: 2.2.0 - object.groupby@1.0.3: + p-locate@4.1.0: dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 + p-limit: 2.3.0 - object.values@1.2.1: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + p-map@2.1.0: {} - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 + p-try@2.2.0: {} - own-keys@1.0.1: - dependencies: - get-intrinsic: 1.3.0 - object-keys: 1.1.1 - safe-push-apply: 1.0.0 + package-json-from-dist@1.0.1: {} - p-limit@3.1.0: + package-manager-detector@0.2.11: dependencies: - yocto-queue: 0.1.0 + quansync: 0.2.11 - p-locate@5.0.0: + parse5@7.3.0: dependencies: - p-limit: 3.1.0 + entities: 6.0.1 - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 + path-exists@4.0.0: {} - parse-entities@4.0.2: - dependencies: - '@types/unist': 2.0.11 - character-entities-legacy: 3.0.0 - character-reference-invalid: 2.0.1 - decode-named-character-reference: 1.3.0 - is-alphanumerical: 2.0.1 - is-decimal: 2.0.1 - is-hexadecimal: 2.0.1 + path-key@3.1.1: {} - path-exists@4.0.0: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 - path-key@3.1.1: {} + path-type@4.0.0: {} - path-parse@1.0.7: {} + pathe@1.1.2: {} pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} picomatch@4.0.3: {} - pify@2.3.0: {} + pify@4.0.1: {} pirates@4.0.7: {} @@ -5992,27 +3775,6 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 - possible-typed-array-names@1.1.0: {} - - postcss-import@15.1.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.11 - - postcss-js@4.1.0(postcss@8.5.6): - dependencies: - camelcase-css: 2.0.1 - postcss: 8.5.6 - - postcss-load-config@4.0.2(postcss@8.5.6): - dependencies: - lilconfig: 3.1.3 - yaml: 2.8.2 - optionalDependencies: - postcss: 8.5.6 - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2): dependencies: lilconfig: 3.1.3 @@ -6022,42 +3784,24 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - postcss-nested@6.2.0(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - postcss-selector-parser: 6.1.2 - - postcss-selector-parser@6.1.2: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-value-parser@4.2.0: {} - - postcss@8.4.31: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.6: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 - prelude-ls@1.2.1: {} + prettier@2.8.8: {} - prop-types@15.8.1: + pretty-format@27.5.1: dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - - property-information@7.1.0: {} + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 punycode@2.3.1: {} + quansync@0.2.11: {} + queue-microtask@1.2.3: {} react-dom@18.3.1(react@18.3.1): @@ -6066,12 +3810,7 @@ snapshots: react: 18.3.1 scheduler: 0.23.2 - react-dom@19.2.3(react@19.2.3): - dependencies: - react: 19.2.3 - scheduler: 0.27.0 - - react-is@16.13.1: {} + react-is@17.0.2: {} react-refresh@0.17.0: {} @@ -6079,142 +3818,24 @@ snapshots: dependencies: loose-envify: 1.4.0 - react@19.2.3: {} - - read-cache@1.0.0: - dependencies: - pify: 2.3.0 - - readdirp@3.6.0: + read-yaml-file@1.1.0: dependencies: - picomatch: 2.3.1 + graceful-fs: 4.2.11 + js-yaml: 3.14.2 + pify: 4.0.1 + strip-bom: 3.0.0 readdirp@4.1.2: {} - recma-build-jsx@1.0.0: - dependencies: - '@types/estree': 1.0.8 - estree-util-build-jsx: 3.0.1 - vfile: 6.0.3 - - recma-jsx@1.0.1(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - estree-util-to-js: 2.0.0 - recma-parse: 1.0.0 - recma-stringify: 1.0.0 - unified: 11.0.5 - - recma-parse@1.0.0: - dependencies: - '@types/estree': 1.0.8 - esast-util-from-js: 2.0.1 - unified: 11.0.5 - vfile: 6.0.3 - - recma-stringify@1.0.0: - dependencies: - '@types/estree': 1.0.8 - estree-util-to-js: 2.0.0 - unified: 11.0.5 - vfile: 6.0.3 - - reflect.getprototypeof@1.0.10: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - get-proto: 1.0.1 - which-builtin-type: 1.2.1 - - regexp.prototype.flags@1.5.4: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-errors: 1.3.0 - get-proto: 1.0.1 - gopd: 1.2.0 - set-function-name: 2.0.2 - - rehype-autolink-headings@7.1.0: - dependencies: - '@types/hast': 3.0.4 - '@ungap/structured-clone': 1.3.0 - hast-util-heading-rank: 3.0.0 - hast-util-is-element: 3.0.0 - unified: 11.0.5 - unist-util-visit: 5.0.0 - - rehype-highlight@7.0.2: + redent@3.0.0: dependencies: - '@types/hast': 3.0.4 - hast-util-to-text: 4.0.2 - lowlight: 3.3.0 - unist-util-visit: 5.0.0 - vfile: 6.0.3 - - rehype-recma@1.0.0: - dependencies: - '@types/estree': 1.0.8 - '@types/hast': 3.0.4 - hast-util-to-estree: 3.1.3 - transitivePeerDependencies: - - supports-color - - rehype-slug@6.0.0: - dependencies: - '@types/hast': 3.0.4 - github-slugger: 2.0.0 - hast-util-heading-rank: 3.0.0 - hast-util-to-string: 3.0.1 - unist-util-visit: 5.0.0 - - remark-mdx@3.1.0: - dependencies: - mdast-util-mdx: 3.0.0 - micromark-extension-mdxjs: 3.0.0 - transitivePeerDependencies: - - supports-color - - remark-parse@11.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.2 - micromark-util-types: 2.0.2 - unified: 11.0.5 - transitivePeerDependencies: - - supports-color - - remark-rehype@11.1.1: - dependencies: - '@types/hast': 3.0.4 - '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.1 - unified: 11.0.5 - vfile: 6.0.3 - - resolve-from@4.0.0: {} + indent-string: 4.0.0 + strip-indent: 3.0.0 resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - - resolve@2.0.0-next.5: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - reusify@1.1.0: {} rollup@4.57.1: @@ -6248,211 +3869,80 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 - safe-array-concat@1.1.3: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - get-intrinsic: 1.3.0 - has-symbols: 1.1.0 - isarray: 2.0.5 - - safe-push-apply@1.0.0: - dependencies: - es-errors: 1.3.0 - isarray: 2.0.5 + safer-buffer@2.1.2: {} - safe-regex-test@1.1.0: + saxes@6.0.0: dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-regex: 1.2.1 + xmlchars: 2.2.0 scheduler@0.23.2: dependencies: loose-envify: 1.4.0 - scheduler@0.27.0: {} - semver@6.3.1: {} semver@7.7.4: {} - set-function-length@1.2.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - function-bind: 1.1.2 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-property-descriptors: 1.0.2 - - set-function-name@2.0.2: - dependencies: - define-data-property: 1.1.4 - es-errors: 1.3.0 - functions-have-names: 1.2.3 - has-property-descriptors: 1.0.2 - - set-proto@1.0.0: - dependencies: - dunder-proto: 1.0.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - - sharp@0.34.5: - dependencies: - '@img/colour': 1.0.0 - detect-libc: 2.1.2 - semver: 7.7.4 - optionalDependencies: - '@img/sharp-darwin-arm64': 0.34.5 - '@img/sharp-darwin-x64': 0.34.5 - '@img/sharp-libvips-darwin-arm64': 1.2.4 - '@img/sharp-libvips-darwin-x64': 1.2.4 - '@img/sharp-libvips-linux-arm': 1.2.4 - '@img/sharp-libvips-linux-arm64': 1.2.4 - '@img/sharp-libvips-linux-ppc64': 1.2.4 - '@img/sharp-libvips-linux-riscv64': 1.2.4 - '@img/sharp-libvips-linux-s390x': 1.2.4 - '@img/sharp-libvips-linux-x64': 1.2.4 - '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 - '@img/sharp-libvips-linuxmusl-x64': 1.2.4 - '@img/sharp-linux-arm': 0.34.5 - '@img/sharp-linux-arm64': 0.34.5 - '@img/sharp-linux-ppc64': 0.34.5 - '@img/sharp-linux-riscv64': 0.34.5 - '@img/sharp-linux-s390x': 0.34.5 - '@img/sharp-linux-x64': 0.34.5 - '@img/sharp-linuxmusl-arm64': 0.34.5 - '@img/sharp-linuxmusl-x64': 0.34.5 - '@img/sharp-wasm32': 0.34.5 - '@img/sharp-win32-arm64': 0.34.5 - '@img/sharp-win32-ia32': 0.34.5 - '@img/sharp-win32-x64': 0.34.5 - optional: true - shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} - side-channel-list@1.0.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - - side-channel-map@1.0.1: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 + siginfo@2.0.0: {} - side-channel-weakmap@1.0.2: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - get-intrinsic: 1.3.0 - object-inspect: 1.13.4 - side-channel-map: 1.0.1 + signal-exit@4.1.0: {} - side-channel@1.1.0: - dependencies: - es-errors: 1.3.0 - object-inspect: 1.13.4 - side-channel-list: 1.0.0 - side-channel-map: 1.0.1 - side-channel-weakmap: 1.0.2 + slash@3.0.0: {} source-map-js@1.2.1: {} source-map@0.7.6: {} - space-separated-tokens@2.0.2: {} - - stable-hash@0.0.5: {} - - stop-iteration-iterator@1.1.0: + spawndamnit@3.0.1: dependencies: - es-errors: 1.3.0 - internal-slot: 1.1.0 + cross-spawn: 7.0.6 + signal-exit: 4.1.0 - string.prototype.includes@2.0.1: - dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-abstract: 1.24.1 + sprintf-js@1.0.3: {} - string.prototype.matchall@4.0.12: - dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-errors: 1.3.0 - es-object-atoms: 1.1.1 - get-intrinsic: 1.3.0 - gopd: 1.2.0 - has-symbols: 1.1.0 - internal-slot: 1.1.0 - regexp.prototype.flags: 1.5.4 - set-function-name: 2.0.2 - side-channel: 1.1.0 + stackback@0.0.2: {} - string.prototype.repeat@1.0.0: - dependencies: - define-properties: 1.2.1 - es-abstract: 1.24.1 + std-env@3.10.0: {} - string.prototype.trim@1.2.10: + string-width@4.2.3: dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-data-property: 1.1.4 - define-properties: 1.2.1 - es-abstract: 1.24.1 - es-object-atoms: 1.1.1 - has-property-descriptors: 1.0.2 + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 - string.prototype.trimend@1.0.9: + string-width@5.1.2: dependencies: - call-bind: 1.0.8 - call-bound: 1.0.4 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 - string.prototype.trimstart@1.0.8: + strip-ansi@6.0.1: dependencies: - call-bind: 1.0.8 - define-properties: 1.2.1 - es-object-atoms: 1.1.1 + ansi-regex: 5.0.1 - stringify-entities@4.0.4: + strip-ansi@7.1.2: dependencies: - character-entities-html4: 2.1.0 - character-entities-legacy: 3.0.0 + ansi-regex: 6.2.2 strip-bom@3.0.0: {} - strip-json-comments@3.1.1: {} - - style-to-js@1.1.21: + strip-indent@3.0.0: dependencies: - style-to-object: 1.0.14 - - style-to-object@1.0.14: - dependencies: - inline-style-parser: 0.2.7 - - styled-jsx@5.1.6(react@19.2.3): - dependencies: - client-only: 0.0.1 - react: 19.2.3 + min-indent: 1.0.1 sucrase@3.35.1: dependencies: @@ -6468,39 +3958,20 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-preserve-symlinks-flag@1.0.0: {} - - tailwindcss@3.4.4: - dependencies: - '@alloc/quick-lru': 5.2.0 - arg: 5.0.2 - chokidar: 3.6.0 - didyoumean: 1.2.2 - dlv: 1.1.3 - fast-glob: 3.3.3 - glob-parent: 6.0.2 - is-glob: 4.0.3 - jiti: 1.21.7 - lilconfig: 2.1.0 - micromatch: 4.0.8 - normalize-path: 3.0.0 - object-hash: 3.0.0 - picocolors: 1.1.1 - postcss: 8.5.6 - postcss-import: 15.1.0(postcss@8.5.6) - postcss-js: 4.1.0(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6) - postcss-nested: 6.2.0(postcss@8.5.6) - postcss-selector-parser: 6.1.2 - resolve: 1.22.11 - sucrase: 3.35.1 - transitivePeerDependencies: - - ts-node + symbol-tree@3.2.4: {} - tailwindcss@4.1.18: {} + tailwindcss@4.2.1: {} tapable@2.3.0: {} + term-size@2.2.1: {} + + test-exclude@7.0.1: + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 10.5.0 + minimatch: 9.0.5 + thenify-all@1.6.0: dependencies: thenify: 3.3.1 @@ -6509,6 +3980,8 @@ snapshots: dependencies: any-promise: 1.3.0 + tinybench@2.9.0: {} + tinyexec@0.3.2: {} tinyglobby@0.2.15: @@ -6516,30 +3989,33 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 + tinypool@1.1.1: {} - tree-kill@1.2.2: {} + tinyrainbow@1.2.0: {} - trim-lines@3.0.1: {} + tinyspy@3.0.2: {} - trough@2.2.0: {} + tldts-core@6.1.86: {} - ts-api-utils@2.4.0(typescript@5.9.3): + tldts@6.1.86: dependencies: - typescript: 5.9.3 + tldts-core: 6.1.86 - ts-interface-checker@0.1.13: {} + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 - tsconfig-paths@3.15.0: + tough-cookie@5.1.2: dependencies: - '@types/json5': 0.0.29 - json5: 1.0.2 - minimist: 1.2.8 - strip-bom: 3.0.0 + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} - tslib@2.8.1: {} + ts-interface-checker@0.1.13: {} tsup@8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: @@ -6603,134 +4079,15 @@ snapshots: turbo-windows-64: 2.8.3 turbo-windows-arm64: 2.8.3 - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typed-array-buffer@1.0.3: - dependencies: - call-bound: 1.0.4 - es-errors: 1.3.0 - is-typed-array: 1.1.15 - - typed-array-byte-length@1.0.3: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - - typed-array-byte-offset@1.0.4: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - has-proto: 1.2.0 - is-typed-array: 1.1.15 - reflect.getprototypeof: 1.0.10 - - typed-array-length@1.0.7: - dependencies: - call-bind: 1.0.8 - for-each: 0.3.5 - gopd: 1.2.0 - is-typed-array: 1.1.15 - possible-typed-array-names: 1.1.0 - reflect.getprototypeof: 1.0.10 - - typescript-eslint@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3): - dependencies: - '@typescript-eslint/eslint-plugin': 8.55.0(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/parser': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.55.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - eslint: 9.39.2(jiti@2.6.1) - typescript: 5.9.3 - transitivePeerDependencies: - - supports-color - typescript@5.9.3: {} ufo@1.6.3: {} - unbox-primitive@1.1.0: - dependencies: - call-bound: 1.0.4 - has-bigints: 1.1.0 - has-symbols: 1.1.0 - which-boxed-primitive: 1.1.1 - undici-types@6.21.0: {} undici-types@7.16.0: {} - unified@11.0.5: - dependencies: - '@types/unist': 3.0.3 - bail: 2.0.2 - devlop: 1.1.0 - extend: 3.0.2 - is-plain-obj: 4.1.0 - trough: 2.2.0 - vfile: 6.0.3 - - unist-util-find-after@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position-from-estree@2.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - unrs-resolver@1.11.1: - dependencies: - napi-postinstall: 0.3.4 - optionalDependencies: - '@unrs/resolver-binding-android-arm-eabi': 1.11.1 - '@unrs/resolver-binding-android-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-arm64': 1.11.1 - '@unrs/resolver-binding-darwin-x64': 1.11.1 - '@unrs/resolver-binding-freebsd-x64': 1.11.1 - '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 - '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 - '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 - '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 - '@unrs/resolver-binding-linux-x64-musl': 1.11.1 - '@unrs/resolver-binding-wasm32-wasi': 1.11.1 - '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 - '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 - '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + universalify@0.1.2: {} update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: @@ -6738,23 +4095,35 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - util-deprecate@1.0.2: {} - - vfile-message@4.0.3: + vite-node@2.1.9(@types/node@24.10.11)(lightningcss@1.31.1): dependencies: - '@types/unist': 3.0.3 - unist-util-stringify-position: 4.0.0 + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser - vfile@6.0.3: + vite@5.4.21(@types/node@20.19.35)(lightningcss@1.31.1): dependencies: - '@types/unist': 3.0.3 - vfile-message: 4.0.3 + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.57.1 + optionalDependencies: + '@types/node': 20.19.35 + fsevents: 2.3.3 + lightningcss: 1.31.1 - vite@5.4.21(@types/node@24.10.11)(lightningcss@1.30.2): + vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1): dependencies: esbuild: 0.21.5 postcss: 8.5.6 @@ -6762,65 +4131,89 @@ snapshots: optionalDependencies: '@types/node': 24.10.11 fsevents: 2.3.3 - lightningcss: 1.30.2 + lightningcss: 1.31.1 + + vitest@2.1.9(@types/node@24.10.11)(jsdom@25.0.1)(lightningcss@1.31.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21(@types/node@24.10.11)(lightningcss@1.31.1)) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21(@types/node@24.10.11)(lightningcss@1.31.1) + vite-node: 2.1.9(@types/node@24.10.11)(lightningcss@1.31.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.11 + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser - which-boxed-primitive@1.1.1: + w3c-xmlserializer@5.0.0: dependencies: - is-bigint: 1.1.0 - is-boolean-object: 1.2.2 - is-number-object: 1.1.1 - is-string: 1.1.1 - is-symbol: 1.1.1 + xml-name-validator: 5.0.0 - which-builtin-type@1.2.1: + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: dependencies: - call-bound: 1.0.4 - function.prototype.name: 1.1.8 - has-tostringtag: 1.0.2 - is-async-function: 2.1.1 - is-date-object: 1.1.0 - is-finalizationregistry: 1.1.1 - is-generator-function: 1.1.2 - is-regex: 1.2.1 - is-weakref: 1.1.1 - isarray: 2.0.5 - which-boxed-primitive: 1.1.1 - which-collection: 1.0.2 - which-typed-array: 1.1.20 - - which-collection@1.0.2: - dependencies: - is-map: 2.0.3 - is-set: 2.0.3 - is-weakmap: 2.0.2 - is-weakset: 2.0.4 - - which-typed-array@1.1.20: - dependencies: - available-typed-arrays: 1.0.7 - call-bind: 1.0.8 - call-bound: 1.0.4 - for-each: 0.3.5 - get-proto: 1.0.1 - gopd: 1.2.0 - has-tostringtag: 1.0.2 + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 which@2.0.2: dependencies: isexe: 2.0.0 - word-wrap@1.2.5: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 - yallist@3.1.1: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 - yaml@2.8.2: {} + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 - yocto-queue@0.1.0: {} + ws@8.19.0: {} - zod-validation-error@4.0.2(zod@4.3.6): - dependencies: - zod: 4.3.6 + xml-name-validator@5.0.0: {} - zod@4.3.6: {} + xmlchars@2.2.0: {} - zwitch@2.0.4: {} + yallist@3.1.1: {} + + yaml@2.8.2: + optional: true