Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 7 additions & 0 deletions .changeset/add-phase-1-complete.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/add-phase-2-tools.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -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": []
}
7 changes: 7 additions & 0 deletions .changeset/phase-3-complete.md
Original file line number Diff line number Diff line change
@@ -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).
7 changes: 7 additions & 0 deletions .changeset/phase-4-complete.md
Original file line number Diff line number Diff line change
@@ -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).
6 changes: 6 additions & 0 deletions .changeset/phase-6-complete.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/phase-7-complete.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 6 additions & 0 deletions .changeset/phase-r-complete.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/phase-u-complete.md
Original file line number Diff line number Diff line change
@@ -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.
86 changes: 86 additions & 0 deletions .claude/skills/ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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`
172 changes: 172 additions & 0 deletions .claude/skills/adapter/HOOKS.md
Original file line number Diff line number Diff line change
@@ -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<Clip>();
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<Timeline, 'id' | 'name' | 'fps' | 'duration'> & { 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
Loading