diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..69a855fa
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+# Google Fonts API Key
+# Get your key from: https://developers.google.com/fonts/docs/developer_api
+GOOGLE_FONTS_API_KEY=
diff --git a/.eslintrc b/.eslintrc
index 659a434c..31902575 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -6,7 +6,7 @@
"project": ["./tsconfig.json", "./tsconfig.test.json"]
},
"plugins": ["@typescript-eslint"],
- "ignorePatterns": ["**/*.d.ts", "*.js", "**/*.js"],
+ "ignorePatterns": ["**/*.d.ts", "*.js", "**/*.js", "scripts/**"],
"overrides": [
{
"files": ["src/**/*.ts"],
diff --git a/.gitignore b/.gitignore
index f48a51bf..43094f21 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,4 +23,8 @@ dist-ssr
# AI
.taskmaster/
.claude/
-.cursor/
\ No newline at end of file
+.cursor/
+
+# Internal Shotstack files
+shotstack.html
+src/shotstack-main.ts
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
index 85aee5a5..2bd5a0a9 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v20
\ No newline at end of file
+22
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ccc90f05..3952db9d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,139 @@
All notable changes to this project will be documented in this file.
+## [2.0.0] - 2026-02-17
+
+### Added
+
+**Timeline**
+
+- **HTML/CSS timeline rebuild** - Replaced the 1.x timeline with a fully rewritten HTML/CSS implementation
+ - Asset type icons with variable track heights based on content type
+ - Playhead ghost hover preview
+ - Home/End key navigation (replaces meta+arrow keys)
+- **Copy-paste** - Duplicate clips on the timeline using standard keyboard shortcuts
+- **Collision detection** - Automatic clip pushing during drag operations to prevent overlapping
+- **Thumbnail rendering** - Video and image thumbnails displayed directly on timeline clips
+- **Soundtrack support** - Integrated audio player for soundtrack assets on the timeline
+- **Graceful error handling** - Visual error indicators on clips that fail to load
+- **Seconds-based timing** - Unified all time values to a branded `Seconds` type throughout the timeline
+
+**UI Toolbars**
+
+- **Rich text toolbar** - Full formatting toolbar with font picker, text shadow, padding, background color, animation presets, and strikethrough
+- **Media toolbar** - Editing controls for video and image clips including audio, fade effects, zoom/slide transitions, and effects
+- **SVG toolbar** - Fill color, corner radius, and clip-level controls (opacity, scale, transition, effect)
+- **Canvas toolbar** - Resolution, FPS, and background color controls in a vertical layout on the right edge
+- **Asset toolbar** - Quick text and media insertion controls on the canvas
+- **Text-to-Speech toolbar** - Voice selection and audio generation controls
+- **Text-to-image toolbar** - Prompt editing and AI image generation controls
+- **Draggable toolbars** - All toolbars support drag repositioning with reset capability
+- **Extensible toolbar buttons** - Registry system for adding custom toolbar buttons
+
+**AI-Powered Assets**
+
+- **AI asset players** - Aurora loading animations for text-to-image and text-to-speech assets during generation
+- **AI asset overlays** - Asset number badges and prompt preview overlays on AI-generated content
+
+**Merge Fields**
+
+- **Merge field system** - Template variable substitution across Edit properties
+ - Redesigned merge field popup with scroll architecture
+
+**Font System**
+
+- **Font Picker** - Virtual scrolling font selector with recent fonts tracking
+- **Font color picker** - Color and highlight controls with gradient presets
+- **Font weight selector** - Dropdown with full weight range, replacing the simple bold toggle
+- **OpenType integration** - Font family name extraction using `opentype.js` for accurate metadata
+- **CDN font hosting** - Migrated default font assets to CDN with expanded weight and style variants
+
+**Rotation, Resize & Alignment**
+
+- **Rotation** - Full rotation support for canvas objects with snapping to fixed angles
+- **8-point resize handles** - Corner and edge resize with proper dimension handling for SVG and rich text assets
+- **Alignment guides** - Drag snapping to alignment guides integrated into the canvas
+- **Dimension labels** - Live dimension display during resize operations
+- **Drag time tooltips** - Time position display during clip drag and resize on the timeline
+
+**Luma Mask**
+
+- **Luma mask system** - Attach luma masks to clips using Alt+drag with automatic asset type transformation
+
+**Output & Resolution**
+
+- **Output configuration commands** - Commands for setting format, resolution, aspect ratio, and destinations
+- **Resolution presets** - Predefined resolution options with rollback handling
+- **Multi-provider destinations** - Output schema supports multiple render destinations
+
+**Canvas & Rendering**
+
+- **Responsive canvas zoom** - Automatic zoom scaling to fit the container
+- **Alpha channel support** - Correct rendering for WebM VP9 transparent videos
+
+**Clip Management**
+
+- **Clip reconciliation** - Unidirectional data flow with unified resolution for predictable clip tracking across undo/redo
+- **Clip timing controls** - Asset/clip mode toggle for timing configuration
+- **Smart loadEdit** - Structural diffing with unified event dispatch when reloading an edit
+- **Drag-to-create track** - Drop zone indicators for creating new tracks during clip drag
+- **Alias resolution** - Clip reference system using aliases
+- **Caption rendering** - Subtitle player with VTT/SRT parser for caption assets
+
+**Keyframes & Animations**
+
+- **Layered animation composition** - Effects and transitions composed as independent animation layers
+- **Skew transform** - New skew property for clip transforms with animation builder support
+- **Smooth easing** - New easing curve for improved carousel and slide transitions
+
+**SDK Architecture**
+
+- **EditDocument** - Pure data layer for edit configuration management, separating data from rendering
+- **Composite UI components** - Reusable panels for effects, transitions, spacing, and style controls
+- **SVG asset support** - Full SVG asset rendering, editing, and shared SVG utilities
+- **Keyboard arrow positioning** - Arrow keys move selected clips on the canvas
+
+### Changed
+
+- Migrated to `@shotstack/schemas` as canonical type source
+- Upgraded `@shotstack/shotstack-canvas` from ^1.6.5 to ^1.9.6
+- Upgraded `pixi.js` from ^8.5.2 to ^8.15.0
+- Upgraded `zod` from ^3.23.8 to ^4.0.0
+- Requires Node.js 22 (previously unconstrained)
+- Timeline API simplified — feature toggles and constructor options removed
+- Default preview framerate updated to 25fps
+- Switched to light theme as default
+
+### Fixed
+
+- Corrected playback time unit conversions across all player types
+- Fixed memory leaks from event listener accumulation and recursive handlers
+- Resolved race conditions in render updates during clip selection changes
+- Fixed audio stuttering caused by excessive video sync checks (now rate-limited)
+- Added cache invalidation for clip and track mutations
+- Corrected track layer calculation and z-index sorting for multi-track compositions
+- Performance and rendering improvements
+- Fixed crop scaling logic to use max ratio consistently
+- Deferred audio volume keyframe initialization until timing is resolved
+- Fixed left-edge clip resize to correctly adjust start position and duration
+- Added track existence validation before clip reorder on same-track moves
+- Corrected carousel offset calculation logic
+- Normalized z-index hierarchy across UI layers
+- Improved `.mov` video error messaging
+- Included opacity in clip keyframe detection
+- Prevented toolbar container duplication on remount
+- Fixed SVG icons intercepting pointer events on toolbar buttons
+- Fixed clip mask offset to account for centered border strokes
+
+### Removed
+
+- `./schema` package export — schemas now provided by the `@shotstack/schemas` package
+- `TimelineOptions` — Timeline constructor options removed in favour of simplified API
+- `fast-deep-equal` dependency — replaced by internal structural diffing
+- Schema build configuration (`vite.config.schema.ts`)
+- Internal UI component exports from the public API surface
+- `.webm` browser support check
+
## [1.10.1] - 2025-11-27
### Changed
diff --git a/index.html b/index.html
index 8780ea72..cec8974b 100644
--- a/index.html
+++ b/index.html
@@ -3,8 +3,9 @@
+
- Shotstack Canvas
+ Shotstack Studio
diff --git a/jest.config.js b/jest.config.js
index fb6c4485..c342c940 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -4,7 +4,23 @@ export default {
extensionsToTreatAsEsm: [".ts"],
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
- "^@shotstack/shotstack-studio/schema$": "/dist/schema/index.cjs"
+ "\\.css\\?inline$": "/tests/__mocks__/css-inline.js",
+ "^@shotstack/shotstack-studio/schema$": "/dist/schema/index.cjs",
+ "^@core/(.*)$": "/src/core/$1",
+ "^@canvas/(.*)$": "/src/components/canvas/$1",
+ "^@timeline/(.*)$": "/src/components/timeline/$1",
+ "^@shared/(.*)$": "/src/core/shared/$1",
+ "^@schemas$": "/src/core/schemas/index.ts",
+ "^@schemas/(.*)$": "/src/core/schemas/$1",
+ "^@timing/(.*)$": "/src/core/timing/$1",
+ "^@layouts/(.*)$": "/src/core/layouts/$1",
+ "^@animations/(.*)$": "/src/core/animations/$1",
+ "^@events/(.*)$": "/src/core/events/$1",
+ "^@inputs/(.*)$": "/src/core/inputs/$1",
+ "^@loaders/(.*)$": "/src/core/loaders/$1",
+ "^@export/(.*)$": "/src/core/export/$1",
+ "^@styles/(.*)$": "/src/styles/$1",
+ "^@templates/(.*)$": "/src/templates/$1"
},
testPathIgnorePatterns: ["/node_modules/", "/dist/"],
transform: {
diff --git a/package.json b/package.json
index 55a4225a..cdf9f8ad 100644
--- a/package.json
+++ b/package.json
@@ -3,9 +3,10 @@
"author": "Shotstack ",
"contributors": [
"cpuccino",
- "dazzatron"
+ "dazzatron",
+ "kratos2k7"
],
- "version": "1.10.1",
+ "version": "2.0.0",
"description": "A video editing library for creating and editing videos with Shotstack",
"type": "module",
"main": "dist/shotstack-studio.umd.js",
@@ -18,17 +19,18 @@
"require": "./dist/shotstack-studio.umd.js",
"default": "./dist/shotstack-studio.es.js"
},
- "./schema": {
- "types": "./dist/schema/index.d.ts",
- "import": "./dist/schema/index.mjs",
- "require": "./dist/schema/index.cjs",
- "default": "./dist/schema/index.mjs"
+ "./internal": {
+ "types": "./dist/internal.d.ts",
+ "import": "./dist/internal.es.js",
+ "require": "./dist/internal.umd.js",
+ "default": "./dist/internal.es.js"
}
},
"files": [
"dist/shotstack-studio.umd.js",
"dist/shotstack-studio.es.js",
- "dist/schema/**",
+ "dist/internal.umd.js",
+ "dist/internal.es.js",
"dist/**/*.d.ts"
],
"keywords": [
@@ -38,30 +40,37 @@
"ffmpeg"
],
"license": "PolyForm Shield License 1.0.0",
+ "engines": {
+ "node": ">=22 <23"
+ },
"repository": {
"type": "git",
"url": "https://github.com/shotstack/shotstack-studio-sdk"
},
"scripts": {
"dev": "vite",
+ "dev:shotstack": "vite --open /shotstack.html",
"start": "npm run build && vite preview",
- "build": "npm run build:main && npm run build:schema",
+ "build": "npm run build:main && npm run build:internal",
"build:main": "vite build",
- "build:schema": "vite build --config vite.config.schema.ts",
- "test": "npm run build && jest",
+ "build:internal": "node scripts/build-internal.mjs",
+ "test": "jest",
"test:watch": "jest --watch",
"test:package": "node test-package.js",
"typecheck": "tsc --noEmit && tsc --project tsconfig.test.json --noEmit",
"lint": "eslint --ignore-path .gitignore .",
"lint:fix": "eslint --ignore-path .gitignore --fix .",
"format": "prettier --ignore-path .gitignore --write .",
- "prepublishOnly": "npm run build && npm run test && npm run test:package"
+ "verify:ci": "npm run lint && npm run typecheck && npm run test && npm run build && npm run test:package",
+ "release:check": "npm run verify:ci && npm pack --dry-run --json",
+ "prepublishOnly": "npm run release:check",
+ "generate:fonts": "npx tsx scripts/fetch-google-fonts.ts"
},
"devDependencies": {
- "@jest/globals": "^30.2.0",
"@types/howler": "^2.2.12",
"@types/jest": "^30.0.0",
"@types/node": "^22.9.0",
+ "@types/opentype.js": "^1.3.8",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"eslint": "^8.57.1",
@@ -70,6 +79,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.31.0",
"jest": "^30.2.0",
+ "jest-environment-jsdom": "^30.2.0",
"prettier": "^3.6.2",
"ts-jest": "^29.4.5",
"typescript": "^5.6.2",
@@ -77,12 +87,13 @@
"vite-plugin-dts": "^4.5.4"
},
"dependencies": {
- "@shotstack/shotstack-canvas": "^1.6.5",
- "fast-deep-equal": "^3.1.3",
+ "@shotstack/schemas": "1.7.0",
+ "@shotstack/shotstack-canvas": "^1.9.6",
"howler": "^2.2.4",
"mediabunny": "^1.11.2",
+ "opentype.js": "^1.3.4",
"pixi-filters": "^6.0.5",
- "pixi.js": "^8.5.2",
- "zod": "^3.23.8"
+ "pixi.js": "^8.15.0",
+ "zod": "^4.0.0"
}
}
diff --git a/public/assets/fonts/Montserrat.ttf b/public/assets/fonts/Montserrat.ttf
new file mode 100644
index 00000000..451e6928
Binary files /dev/null and b/public/assets/fonts/Montserrat.ttf differ
diff --git a/public/assets/fonts/OpenSans.ttf b/public/assets/fonts/OpenSans.ttf
new file mode 100644
index 00000000..9c57fbdb
Binary files /dev/null and b/public/assets/fonts/OpenSans.ttf differ
diff --git a/public/assets/fonts/WorkSans.ttf b/public/assets/fonts/WorkSans.ttf
new file mode 100644
index 00000000..16293f14
Binary files /dev/null and b/public/assets/fonts/WorkSans.ttf differ
diff --git a/readme.md b/readme.md
index 1813a8a8..6585fb09 100644
--- a/readme.md
+++ b/readme.md
@@ -4,7 +4,7 @@
[](https://polyformproject.org/licenses/shield/1.0.0/)
[](https://www.typescriptlang.org/)
-A JavaScript library for creating and editing videos in the browser.
+A JavaScript SDK for browser-based video editing with timeline, canvas preview, and export.
## Interactive Examples
@@ -18,12 +18,11 @@ Try Shotstack Studio in your preferred framework:
## Features
-- Create video compositions with multiple tracks and clips
-- Visual timeline interface
-- WYSIWYG text editing
-- Multi-track, drag-and-drop clip manipulation with snap-to-grid
-- Use in conjunction with the [Shotstack Edit API](https://shotstack.io/docs/guide/getting-started/hello-world-using-curl/) to render video
-- Export to video via the browser
+- Template-driven editing with undo/redo command model
+- Canvas preview rendering
+- Visual timeline with drag, resize, selection, and snapping
+- Extensible UI via `UIController` button API
+- Browser export pipeline via `VideoExporter`
## Installation
@@ -38,33 +37,58 @@ yarn add @shotstack/shotstack-studio
## Quick Start
```typescript
-import { Edit, Canvas, Controls, Timeline } from "@shotstack/shotstack-studio";
+import { Edit, Canvas, Controls, Timeline, UIController, VERSION } from "@shotstack/shotstack-studio";
+import type { EditConfig, UIControllerOptions, ToolbarButtonConfig } from "@shotstack/shotstack-studio";
-// 1. Load a template
+// 1) Load a template
const response = await fetch("https://shotstack-assets.s3.amazonaws.com/templates/hello-world/hello.json");
const template = await response.json();
-// 2. Initialize the edit
-const edit = new Edit(template.output.size, template.timeline.background);
+// 2) Create core components
+const edit = new Edit(template);
+const canvas = new Canvas(edit);
+const ui = UIController.create(edit, canvas);
+
+// 3) Load runtime
+await canvas.load();
await edit.load();
-// 3. Create a canvas to display the edit
-const canvas = new Canvas(template.output.size, edit);
-await canvas.load(); // Renders to [data-shotstack-studio] element
+// 4) Add one custom UI button
+ui.registerButton({
+ id: "text",
+ icon: ``,
+ tooltip: "Add Text"
+});
-// 4. Load the template
-await edit.loadEdit(template);
+ui.on("button:text", ({ position }) => {
+ edit.addTrack(0, {
+ clips: [
+ {
+ asset: {
+ type: "rich-text",
+ text: "Title",
+ font: { family: "Work Sans", size: 72, weight: 600, color: "#ffffff", opacity: 1 },
+ align: { horizontal: "center", vertical: "middle" }
+ },
+ start: position,
+ length: 5,
+ width: 500,
+ height: 200
+ }
+ ]
+ });
+});
-// 5. Initialize the Timeline
-const timeline = new Timeline(edit, { width: 1280, height: 300 });
-await timeline.load(); // Renders to [data-shotstack-timeline] element
+// 5) Timeline + controls
+const timelineContainer = document.querySelector("[data-shotstack-timeline]") as HTMLElement;
+const timeline = new Timeline(edit, timelineContainer);
+await timeline.load();
-// 6. Add keyboard controls
const controls = new Controls(edit);
await controls.load();
```
-Your HTML should include containers for both the canvas and timeline:
+Your HTML must include both containers:
```html
@@ -75,286 +99,219 @@ Your HTML should include containers for both the canvas and timeline:
### Edit
-The Edit class represents a video project with its timeline, clips, and properties.
+`Edit` is the runtime editing session and source of truth for document mutations.
```typescript
import { Edit } from "@shotstack/shotstack-studio";
-// For schema validation only (e.g., in tests):
-import { EditSchema, ClipSchema } from "@shotstack/shotstack-studio/schema";
-// Create an edit with dimensions and background
-const edit = new Edit({ width: 1280, height: 720 }, "#000000");
+const edit = new Edit(templateJson);
await edit.load();
-// Load from template
-await edit.loadEdit(templateJson);
+await edit.loadEdit(nextTemplateJson);
-// Playback controls
+// Playback (seconds)
edit.play();
edit.pause();
-edit.seek(2000); // Seek to 2 seconds (in milliseconds)
-edit.stop(); // Stop and return to beginning
-
-// Editing functions
-edit.addClip(0, {
- asset: {
- type: "image",
- src: "https://example.com/image.jpg"
- },
+edit.seek(2);
+edit.stop();
+
+// Mutations
+await edit.addTrack(0, { clips: [] });
+await edit.addClip(0, {
+ asset: { type: "image", src: "https://example.com/image.jpg" },
start: 0,
length: 5
});
-
-edit.addTrack(1, { clips: [] });
-edit.deleteClip(0, 0);
-edit.deleteTrack(1);
-
-// Undo/Redo
-edit.undo();
-edit.redo();
-
-// Get edit information
+await edit.updateClip(0, 0, { length: 6 });
+await edit.deleteClip(0, 0);
+
+// History
+await edit.undo();
+await edit.redo();
+
+// Clip operations
+await edit.deleteTrack(0);
+
+// Output settings
+await edit.setOutputSize(1920, 1080);
+await edit.setOutputFps(30);
+await edit.setOutputFormat("mp4");
+await edit.setOutputResolution("hd");
+await edit.setOutputAspectRatio("16:9");
+await edit.setTimelineBackground("#000000");
+
+// Read state
+const time = edit.playbackTime;
+const playing = edit.isPlaying;
const clip = edit.getClip(0, 0);
const track = edit.getTrack(0);
-const editJson = edit.getEdit();
-const duration = edit.totalDuration; // in milliseconds
+const snapshot = edit.getEdit();
+const durationSeconds = edit.totalDuration;
```
#### Events
-The Edit class provides an event system to listen for specific actions:
+Listen using string event names:
```typescript
-// Listen for clip selection events
-edit.events.on("clip:selected", data => {
- console.log("Clip selected:", data.clip);
- console.log("Track index:", data.trackIndex);
- console.log("Clip index:", data.clipIndex);
+const unsubscribeClipSelected = edit.events.on("clip:selected", data => {
+ console.log("Selected clip", data.trackIndex, data.clipIndex);
});
-// Listen for clip update events
edit.events.on("clip:updated", data => {
- console.log("Previous state:", data.previous); // { clip, trackIndex, clipIndex }
- console.log("Current state:", data.current); // { clip, trackIndex, clipIndex }
+ console.log("Updated from", data.previous, "to", data.current);
+});
+
+edit.events.on("playback:play", () => {
+ console.log("Playback started");
});
+
+// Unsubscribe when no longer needed
+unsubscribeClipSelected();
```
-Available events:
+Available event names:
-- `clip:selected` - Emitted when a clip is initially selected, providing data about the clip, its track index, and clip index.
-- `clip:updated` - Emitted when a clip's properties are modified, providing both previous and current states.
+- Playback: `playback:play`, `playback:pause`
+- Timeline: `timeline:updated`, `timeline:backgroundChanged`
+- Clip lifecycle: `clip:added`, `clip:selected`, `clip:updated`, `clip:deleted`, `clip:restored`, `clip:copied`, `clip:loadFailed`, `clip:unresolved`
+- Selection: `selection:cleared`
+- Edit state: `edit:changed`, `edit:undo`, `edit:redo`
+- Track: `track:added`, `track:removed`
+- Duration: `duration:changed`
+- Output: `output:resized`, `output:resolutionChanged`, `output:aspectRatioChanged`, `output:fpsChanged`, `output:formatChanged`, `output:destinationsChanged`
+- Merge fields: `mergefield:changed`
### Canvas
-The Canvas class provides the visual rendering of the edit.
+`Canvas` renders the current edit.
```typescript
-// Create and load the canvas
-const canvas = new Canvas(edit.size, edit);
+import { Canvas } from "@shotstack/shotstack-studio";
+
+const canvas = new Canvas(edit);
await canvas.load();
-// Zoom and positioning
canvas.centerEdit();
canvas.zoomToFit();
-canvas.setZoom(1.5); // 1.0 is 100%, 0.5 is 50%, etc.
-canvas.dispose(); // Clean up resources when done
+canvas.setZoom(1.25);
+canvas.resize();
+const zoom = canvas.getZoom();
+canvas.dispose();
```
-### Controls
+### UIController
-The Controls class adds keyboard controls for playback.
+`UIController` manages built-in UI wiring and extensible button events.
```typescript
-const controls = new Controls(edit);
-await controls.load();
+import { UIController } from "@shotstack/shotstack-studio";
+
+const ui = UIController.create(edit, canvas, { mergeFields: true });
+
+ui.registerButton({
+ id: "add-title",
+ icon: ``,
+ tooltip: "Add Title"
+});
+
+const unsubscribe = ui.on("button:add-title", ({ position }) => {
+ console.log("Button clicked at", position, "seconds");
+});
-// Available keyboard controls:
-// Space - Play/Pause
-// J - Stop
-// K - Pause
-// L - Play
-// Left Arrow - Seek backward
-// Right Arrow - Seek forward
-// Shift + Arrow - Seek larger amount
-// Comma - Step backward one frame
-// Period - Step forward one frame
-// Cmd/Ctrl + Z - Undo
-// Cmd/Ctrl + Shift + Z - Redo
-// Cmd/Ctrl + E - Export/download video
+ui.unregisterButton("add-title");
+unsubscribe();
+ui.dispose();
```
### Timeline
-The Timeline class provides a visual timeline interface for editing.
+`Timeline` provides visual clip editing.
```typescript
import { Timeline } from "@shotstack/shotstack-studio";
-const timeline = new Timeline(edit, { width: 1280, height: 300 });
-await timeline.load();
+const container = document.querySelector("[data-shotstack-timeline]") as HTMLElement;
+const timeline = new Timeline(edit, container);
-// Timeline features:
-// - Visual track and clip representation
-// - Drag-and-drop clip manipulation
-// - Clip resizing with edge detection
-// - Playhead control for navigation
-// - Snap-to-grid functionality
-// - Zoom and scroll controls
+await timeline.load();
+timeline.zoomIn();
+timeline.zoomOut();
+timeline.dispose();
```
-### VideoExporter
+### Controls
-The VideoExporter class exports the Edit to a MP4 video file encoded in h264 and AAC.
+`Controls` enables keyboard playback/edit shortcuts.
```typescript
-const exporter = new VideoExporter(edit, canvas);
-await exporter.export("my-video.mp4", 25); // filename, fps
-```
+import { Controls } from "@shotstack/shotstack-studio";
-## Theming
-
-Shotstack Studio supports theming for visual components. Currently, theming is available for the Timeline component, with Canvas theming coming in a future releases.
+const controls = new Controls(edit);
+await controls.load();
+```
-### Built-in Themes
+### VideoExporter
-The library includes pre-designed themes that you can use immediately:
+`VideoExporter` exports a timeline render from the browser runtime.
```typescript
-import { Timeline } from "@shotstack/shotstack-studio";
-import darkTheme from "@shotstack/shotstack-studio/themes/dark.json";
-import minimalTheme from "@shotstack/shotstack-studio/themes/minimal.json";
+import { VideoExporter } from "@shotstack/shotstack-studio";
-// Apply a theme when creating the timeline
-const timeline = new Timeline(edit, { width: 1280, height: 300 }, { theme: darkTheme });
+const exporter = new VideoExporter(edit, canvas);
+await exporter.export("my-video.mp4", 25);
```
-### Custom Themes
+## Merge Fields
-Create your own theme by defining colors and dimensions for each component:
+Merge fields are template placeholders, typically in the form `{{ FIELD_NAME }}`.
-```typescript
-const customTheme = {
- timeline: {
- // Main timeline colors
- background: "#1e1e1e",
- divider: "#1a1a1a",
- playhead: "#ff4444",
- snapGuide: "#888888",
- dropZone: "#00ff00",
- trackInsertion: "#00ff00",
-
- // Toolbar styling
- toolbar: {
- background: "#1a1a1a",
- surface: "#2a2a2a", // Button backgrounds
- hover: "#3a3a3a", // Button hover state
- active: "#007acc", // Button active state
- divider: "#3a3a3a", // Separator lines
- icon: "#888888", // Icon colors
- text: "#ffffff", // Text color
- height: 36 // Toolbar height in pixels
- },
-
- // Ruler styling
- ruler: {
- background: "#404040",
- text: "#ffffff", // Time labels
- markers: "#666666", // Time marker dots
- height: 40 // Ruler height in pixels
- },
-
- // Track styling
- tracks: {
- surface: "#2d2d2d", // Primary track color
- surfaceAlt: "#252525", // Alternating track color
- border: "#3a3a3a", // Track borders
- height: 60 // Track height in pixels
- },
-
- // Clip colors by asset type
- clips: {
- video: "#4a9eff",
- audio: "#00d4aa",
- image: "#f5a623",
- text: "#d0021b",
- shape: "#9013fe",
- html: "#50e3c2",
- luma: "#b8e986",
- default: "#8e8e93", // Unknown asset types
- selected: "#007acc", // Selection border
- radius: 4 // Corner radius in pixels
- }
+```json
+{
+ "asset": {
+ "type": "text",
+ "text": "{{ TITLE }}"
}
- // Canvas theming will be available in future releases
- // canvas: { ... }
-};
-
-const timeline = new Timeline(edit, { width: 1280, height: 300 }, { theme: customTheme });
+}
```
-### Theme Structure
+When merge-field-aware UI is required, enable it via `UIController` options:
-Themes are organized by component, making it intuitive to customize specific parts of the interface:
+```typescript
+const ui = UIController.create(edit, canvas, { mergeFields: true });
+```
-- **Timeline**: Controls the appearance of the timeline interface
- - `toolbar`: Playback controls and buttons
- - `ruler`: Time markers and labels
- - `tracks`: Track backgrounds and borders
- - `clips`: Asset-specific colors and selection states
- - Global timeline properties (background, playhead, etc.)
+You can also subscribe to merge field events when integrations update merge data:
-- **Canvas** (coming soon): Will control the appearance of the video preview area
+```typescript
+edit.events.on("mergefield:changed", ({ fields }) => {
+ console.log("Merge fields updated:", fields.length);
+});
+```
-## Template Format
+## Custom UI Buttons
-Templates use a JSON format with the following structure:
+Use `UIController` to register and handle custom button actions.
```typescript
-{
- timeline: {
- background: "#000000",
- fonts: [
- { src: "https://example.com/font.ttf" }
- ],
- tracks: [
- {
- clips: [
- {
- asset: {
- type: "image", // image, video, text, shape, audio
- src: "https://example.com/image.jpg",
- // Other asset properties depend on type
- },
- start: 0, // Start time in seconds
- length: 5, // Duration in seconds
- transition: { // Optional transitions
- in: "fade",
- out: "fade"
- },
- position: "center", // Positioning
- scale: 1, // Scale factor
- offset: {
- x: 0.1, // X-axis offset relative to position
- y: 0 // Y-axis offset relative to position
- }
- }
- ]
- }
- ]
- },
- output: {
- format: "mp4",
- size: {
- width: 1280,
- height: 720
- }
- }
-}
+ui.registerButton({
+ id: "text",
+ icon: ``,
+ tooltip: "Add Text",
+ dividerBefore: true
+});
+
+ui.on("button:text", ({ position, selectedClip }) => {
+ console.log("Current time (seconds):", position);
+ console.log("Current selection:", selectedClip);
+});
+
+ui.unregisterButton("text");
```
## API Reference
-For complete schema and type definitions, see the [Shotstack API Reference](https://shotstack.io/docs/api/#tocs_edit).
+For schema-level details and type definitions, see the [Shotstack API Reference](https://shotstack.io/docs/api/#tocs_edit).
## License
diff --git a/scripts/build-internal.mjs b/scripts/build-internal.mjs
new file mode 100644
index 00000000..d0412b32
--- /dev/null
+++ b/scripts/build-internal.mjs
@@ -0,0 +1,64 @@
+import { existsSync, renameSync, rmSync } from "fs";
+import { spawnSync } from "child_process";
+import { dirname, resolve } from "path";
+import { fileURLToPath } from "url";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+
+const projectRoot = resolve(__dirname, "..");
+const distDir = resolve(projectRoot, "dist");
+const publicTypesPath = resolve(distDir, "index.d.ts");
+const internalTypesPath = resolve(distDir, "internal.d.ts");
+const backupTypesPath = resolve(distDir, "index.d.ts.internal-backup");
+
+const restorePublicTypes = hadBackup => {
+ if (hadBackup && existsSync(backupTypesPath)) {
+ rmSync(publicTypesPath, { force: true });
+ renameSync(backupTypesPath, publicTypesPath);
+ return;
+ }
+
+ rmSync(publicTypesPath, { force: true });
+};
+
+let hadBackup = false;
+
+try {
+ rmSync(backupTypesPath, { force: true });
+
+ if (existsSync(publicTypesPath)) {
+ renameSync(publicTypesPath, backupTypesPath);
+ hadBackup = true;
+ }
+
+ rmSync(internalTypesPath, { force: true });
+
+ const result = spawnSync("vite", ["build", "--config", "vite.config.internal.ts"], {
+ cwd: projectRoot,
+ stdio: "inherit",
+ shell: process.platform === "win32"
+ });
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ if (result.status !== 0) {
+ throw new Error(`Internal build failed with exit code ${result.status ?? "unknown"}.`);
+ }
+
+ if (!existsSync(internalTypesPath)) {
+ throw new Error("Missing dist/internal.d.ts after internal build.");
+ }
+
+ restorePublicTypes(hadBackup);
+ rmSync(backupTypesPath, { force: true });
+} catch (error) {
+ restorePublicTypes(hadBackup);
+ rmSync(backupTypesPath, { force: true });
+
+ const message = error instanceof Error ? error.message : String(error);
+ console.error(`[build:internal] ${message}`);
+ process.exit(1);
+}
diff --git a/scripts/fetch-google-fonts.ts b/scripts/fetch-google-fonts.ts
new file mode 100644
index 00000000..567b8e2b
--- /dev/null
+++ b/scripts/fetch-google-fonts.ts
@@ -0,0 +1,248 @@
+/**
+ * Build Script: Fetch Google Fonts Metadata
+ *
+ * Fetches Google Fonts and generates a TypeScript file with font metadata.
+ * Uses direct TTF URLs from the Google Fonts API (required by shotstack-canvas).
+ *
+ * Usage:
+ * GOOGLE_FONTS_API_KEY=xxx npx tsx scripts/fetch-google-fonts.ts
+ *
+ * Get an API key from: https://developers.google.com/fonts/docs/developer_api
+ */
+
+import { writeFileSync } from "fs";
+import { resolve, dirname } from "path";
+import { fileURLToPath } from "url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+const API_KEY = process.env.GOOGLE_FONTS_API_KEY;
+const GOOGLE_FONTS_API = "https://www.googleapis.com/webfonts/v1/webfonts";
+const OUTPUT_PATH = resolve(__dirname, "../src/core/fonts/google-fonts.ts");
+
+/** Icon/symbol fonts that don't render as text - exclude from the bundle */
+const EXCLUDED_FONTS = new Set([
+ "Material Icons",
+ "Material Symbols",
+ "Material Symbols Outlined",
+ "Material Symbols Rounded",
+ "Material Symbols Sharp",
+ "Noto Color Emoji",
+ "Noto Emoji"
+]);
+
+interface GoogleFontsApiItem {
+ family: string;
+ variants: string[];
+ subsets: string[];
+ category: string;
+ files: Record; // variant -> TTF URL (e.g., "regular" -> "https://fonts.gstatic.com/s/.../file.ttf")
+ axes?: Array<{ tag: string; start: number; end: number }>; // Variable font axes (e.g., wght: 100-900)
+}
+
+interface GoogleFontsApiResponse {
+ items: GoogleFontsApiItem[];
+}
+
+interface FontInfo {
+ displayName: string;
+ filename: string;
+ category: string;
+ url: string;
+ weight: number;
+ isVariable: boolean; // True if font supports variable weights (100-900)
+}
+
+/**
+ * Extract filename from gstatic URL
+ * e.g., https://fonts.gstatic.com/s/inter/v13/UcCO3FwrK3iLTcviYwYZ8UA3.ttf
+ * → UcCO3FwrK3iLTcviYwYZ8UA3
+ */
+function extractFilename(url: string): string {
+ const match = url.match(/\/([^/]+)\.ttf$/);
+ return match ? match[1] : "";
+}
+
+/**
+ * Get the best URL for regular (400) weight from the files object
+ */
+function getRegularUrl(files: Record): string | null {
+ // Try these keys in order of preference
+ const keys = ["regular", "400", "300", "500"];
+ for (const key of keys) {
+ if (files[key]) {
+ return files[key];
+ }
+ }
+ // Fall back to first available
+ const firstKey = Object.keys(files)[0];
+ return firstKey ? files[firstKey] : null;
+}
+
+/**
+ * Get the weight value from a variant key
+ */
+function getWeightFromVariant(variant: string): number {
+ if (variant === "regular" || variant === "italic") return 400;
+ const num = parseInt(variant, 10);
+ return isNaN(num) ? 400 : num;
+}
+
+/**
+ * Check if a font has variable weight support (wght axis)
+ */
+function hasVariableWeight(item: GoogleFontsApiItem): boolean {
+ return item.axes?.some(axis => axis.tag === "wght") ?? false;
+}
+
+/**
+ * Get the best font URL, preferring variable fonts when available.
+ * Variable fonts contain all weights in a single file.
+ *
+ * Returns { url, isVariable } or null if no suitable URL found.
+ */
+function getBestFontUrl(item: GoogleFontsApiItem): { url: string; isVariable: boolean } | null {
+ const isVariable = hasVariableWeight(item);
+
+ if (isVariable) {
+ // Variable fonts have special variant keys like "wght" or ranges like "100..900"
+ // Look for variant keys that indicate variable font files
+ for (const [variant, url] of Object.entries(item.files)) {
+ // Variable fonts may have variant keys with ranges or axis tags
+ if (variant.includes("..") || variant === "wght") {
+ if (url.endsWith(".ttf")) {
+ return { url, isVariable: true };
+ }
+ }
+ }
+
+ // Some variable fonts use standard variant names but are still variable
+ // (the axes data tells us it's variable, so any file should work)
+ const regularUrl = getRegularUrl(item.files);
+ if (regularUrl?.endsWith(".ttf")) {
+ return { url: regularUrl, isVariable: true };
+ }
+ }
+
+ // Fall back to static font (regular weight)
+ const staticUrl = getRegularUrl(item.files);
+ if (staticUrl?.endsWith(".ttf")) {
+ return { url: staticUrl, isVariable: false };
+ }
+
+ return null;
+}
+
+async function main() {
+ if (!API_KEY) {
+ console.error("Error: GOOGLE_FONTS_API_KEY environment variable is required");
+ console.error("Get an API key from: https://developers.google.com/fonts/docs/developer_api");
+ process.exit(1);
+ }
+
+ console.log("Fetching Google Fonts API (with variable font support)...");
+ // capability=VF enables variable font data (axes field) in the response
+ const response = await fetch(`${GOOGLE_FONTS_API}?key=${API_KEY}&sort=popularity&capability=VF`);
+
+ if (!response.ok) {
+ console.error(`API request failed: ${response.status} ${response.statusText}`);
+ process.exit(1);
+ }
+
+ const data = (await response.json()) as GoogleFontsApiResponse;
+ console.log(`Found ${data.items.length} fonts from API`);
+
+ const fonts: FontInfo[] = [];
+ let skipped = 0;
+
+ let variableCount = 0;
+
+ for (const item of data.items) {
+ // Skip icon/symbol fonts that don't render as text
+ if (EXCLUDED_FONTS.has(item.family)) {
+ skipped++;
+ continue;
+ }
+
+ // Get the best font URL (preferring variable fonts)
+ const result = getBestFontUrl(item);
+ if (!result) {
+ skipped++;
+ continue;
+ }
+
+ const { url, isVariable } = result;
+
+ const filename = extractFilename(url);
+ if (!filename) {
+ skipped++;
+ continue;
+ }
+
+ // Determine weight from the variant we got
+ const variantKey = Object.keys(item.files).find(k => item.files[k] === url) ?? "regular";
+ const weight = getWeightFromVariant(variantKey);
+
+ if (isVariable) variableCount++;
+
+ fonts.push({
+ displayName: item.family,
+ filename,
+ category: item.category,
+ url,
+ weight,
+ isVariable
+ });
+ }
+
+ console.log(`Successfully processed ${fonts.length} fonts (${variableCount} variable, ${fonts.length - variableCount} static, ${skipped} skipped)`);
+
+ // Generate TypeScript file
+ const output = `/**
+ * Google Fonts Metadata
+ *
+ * Auto-generated by scripts/fetch-google-fonts.ts
+ * DO NOT EDIT MANUALLY
+ *
+ * Contains ${fonts.length} fonts from Google Fonts, sorted by popularity.
+ * Generated: ${new Date().toISOString()}
+ */
+
+export interface FontInfo {
+ /** Human-readable font name (e.g., "Inter") */
+ displayName: string;
+ /** Filename hash from gstatic URL - stored in asset.font.family */
+ filename: string;
+ /** Font category for filtering */
+ category: "sans-serif" | "serif" | "display" | "handwriting" | "monospace";
+ /** Full gstatic URL (TTF format) - stored in timeline.fonts */
+ url: string;
+ /** Primary weight for this font file */
+ weight: number;
+ /** True if font supports variable weights (100-900 range) */
+ isVariable: boolean;
+}
+
+export const GOOGLE_FONTS: FontInfo[] = ${JSON.stringify(fonts, null, "\t")};
+
+/** Map from filename to font for reverse lookup */
+export const GOOGLE_FONTS_BY_FILENAME = new Map(
+ GOOGLE_FONTS.map((font) => [font.filename, font])
+);
+
+/** Map from display name to font */
+export const GOOGLE_FONTS_BY_NAME = new Map(
+ GOOGLE_FONTS.map((font) => [font.displayName, font])
+);
+
+/** Font categories */
+export const GOOGLE_FONT_CATEGORIES = ["sans-serif", "serif", "display", "handwriting", "monospace"] as const;
+export type GoogleFontCategory = (typeof GOOGLE_FONT_CATEGORIES)[number];
+`;
+
+ writeFileSync(OUTPUT_PATH, output, "utf-8");
+ console.log(`\nGenerated: ${OUTPUT_PATH}`);
+ console.log(`File size: ${(output.length / 1024).toFixed(1)} KB`);
+}
+
+main().catch(console.error);
diff --git a/src/components/canvas/index.ts b/src/components/canvas/index.ts
index b239f821..b44c7f77 100644
--- a/src/components/canvas/index.ts
+++ b/src/components/canvas/index.ts
@@ -9,6 +9,7 @@ export { RichTextPlayer } from "./players/rich-text-player";
export { ImagePlayer } from "./players/image-player";
export { LumaPlayer } from "./players/luma-player";
export { ShapePlayer } from "./players/shape-player";
+export { SvgPlayer } from "./players/svg-player";
export { TextPlayer } from "./players/text-player";
export { VideoPlayer } from "./players/video-player";
@@ -18,5 +19,5 @@ export { TextEditor } from "./text/text-editor";
export { TextInputHandler } from "./text/text-input-handler";
// System management
-export { Edit } from "@core/edit";
+export { Edit } from "@core/edit-session";
export { Inspector } from "./system/inspector";
diff --git a/src/components/canvas/players/ai-icons.ts b/src/components/canvas/players/ai-icons.ts
new file mode 100644
index 00000000..881aaec4
--- /dev/null
+++ b/src/components/canvas/players/ai-icons.ts
@@ -0,0 +1,25 @@
+export type AiIconType = "image" | "video" | "mic";
+
+/** Fill variant paths — used by canvas overlay badge (24×24 viewBox). */
+export const AI_ICON_FILL_PATHS: Record = {
+ image:
+ "M20.7134 8.12811L20.4668 8.69379C20.2864 9.10792 19.7136 9.10792 19.5331 8.69379L19.2866 8.12811C18.8471 7.11947 18.0555 6.31641 17.0677 5.87708L16.308 5.53922C15.8973 5.35653 15.8973 4.75881 16.308 4.57612L17.0252 4.25714C18.0384 3.80651 18.8442 2.97373 19.2761 1.93083L19.5293 1.31953C19.7058 0.893489 20.2942 0.893489 20.4706 1.31953L20.7238 1.93083C21.1558 2.97373 21.9616 3.80651 22.9748 4.25714L23.6919 4.57612C24.1027 4.75881 24.1027 5.35653 23.6919 5.53922L22.9323 5.87708C21.9445 6.31641 21.1529 7.11947 20.7134 8.12811ZM2.9918 3H14V5H4V19L13.2923 9.70649C13.6828 9.31595 14.3159 9.31591 14.7065 9.70641L20 15.0104V11H22V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z",
+ video:
+ "M19.7134 8.12811L19.4668 8.69379C19.2864 9.10792 18.7136 9.10792 18.5331 8.69379L18.2866 8.12811C17.8471 7.11947 17.0555 6.31641 16.0677 5.87708L15.308 5.53922C14.8973 5.35653 14.8973 4.75881 15.308 4.57612L16.0252 4.25714C17.0384 3.80651 17.8442 2.97373 18.2761 1.93083L18.5293 1.31953C18.7058 0.893489 19.2942 0.893489 19.4706 1.31953L19.7238 1.93083C20.1558 2.97373 20.9616 3.80651 21.9748 4.25714L22.6919 4.57612C23.1027 4.75881 23.1027 5.35653 22.6919 5.53922L21.9323 5.87708C20.9445 6.31641 20.1529 7.11947 19.7134 8.12811ZM19 11C19.7013 11 20.3744 10.8797 21 10.6586V20.0066C21 20.5552 20.5551 21 20.0066 21H3.9934C3.44476 21 3 20.5551 3 20.0066V3.9934C3 3.44476 3.44495 3 3.9934 3H13.3414C13.1203 3.62556 13 4.29873 13 5C13 8.31371 15.6863 11 19 11ZM10.6219 8.41459C10.5562 8.37078 10.479 8.34741 10.4 8.34741C10.1791 8.34741 10 8.52649 10 8.74741V15.2526C10 15.3316 10.0234 15.4088 10.0672 15.4745C10.1897 15.6583 10.4381 15.708 10.6219 15.5854L15.5008 12.3328C15.5447 12.3035 15.5824 12.2658 15.6117 12.2219C15.7343 12.0381 15.6846 11.7897 15.5008 11.6672L10.6219 8.41459Z",
+ mic: "M20.4668 7.69379L20.7134 7.12811C21.1529 6.11947 21.9445 5.31641 22.9323 4.87708L23.6919 4.53922C24.1027 4.35653 24.1027 3.75881 23.6919 3.57612L22.9748 3.25714C21.9616 2.80651 21.1558 1.97373 20.7238 0.930828L20.4706 0.319534C20.2942 -0.106511 19.7058 -0.106511 19.5293 0.319534L19.2761 0.930828C18.8442 1.97373 18.0384 2.80651 17.0252 3.25714L16.308 3.57612C15.8973 3.75881 15.8973 4.35653 16.308 4.53922L17.0677 4.87708C18.0555 5.31641 18.8471 6.11947 19.2866 7.12811L19.5331 7.69379C19.7136 8.10792 20.2864 8.10792 20.4668 7.69379ZM14.3869 5.33879C14.661 5.77254 15.0357 6.09305 15.5111 6.30032L16.0764 6.54679C16.4565 6.71249 16.7643 6.94524 16.9998 7.24503V10C16.9998 12.7614 14.7612 15 11.9998 15C9.23833 15 6.99976 12.7614 6.99976 10V6C6.99976 3.23858 9.23833 1 11.9998 1C13.1238 1 14.1613 1.37094 14.9964 1.99709C14.7563 2.17678 14.5531 2.39813 14.3869 2.66114C14.129 3.06938 14 3.51566 14 3.99997C14 4.48428 14.129 4.93056 14.3869 5.33879ZM3.05469 11H5.07065C5.55588 14.3923 8.47329 17 11.9998 17C15.5262 17 18.4436 14.3923 18.9289 11H20.9448C20.4837 15.1716 17.1714 18.4839 12.9998 18.9451V23H10.9998V18.9451C6.82814 18.4839 3.51584 15.1716 3.05469 11Z"
+};
+
+/** Line variant paths — used by timeline clip icons (24×24 viewBox). */
+export const AI_ICON_LINE_PATHS: Record = {
+ image:
+ "M20.7134 8.12811L20.4668 8.69379C20.2864 9.10792 19.7136 9.10792 19.5331 8.69379L19.2866 8.12811C18.8471 7.11947 18.0555 6.31641 17.0677 5.87708L16.308 5.53922C15.8973 5.35653 15.8973 4.75881 16.308 4.57612L17.0252 4.25714C18.0384 3.80651 18.8442 2.97373 19.2761 1.93083L19.5293 1.31953C19.7058 0.893489 20.2942 0.893489 20.4706 1.31953L20.7238 1.93083C21.1558 2.97373 21.9616 3.80651 22.9748 4.25714L23.6919 4.57612C24.1027 4.75881 24.1027 5.35653 23.6919 5.53922L22.9323 5.87708C21.9445 6.31641 21.1529 7.11947 20.7134 8.12811ZM2.9918 3H14V5H4V19L14 9L20 15V11H22V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934C2 3.44476 2.45531 3 2.9918 3ZM20 17.8284L14 11.8284L6.82843 19H20V17.8284ZM8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9C10 10.1046 9.10457 11 8 11Z",
+ video:
+ "M19.7134 8.12811L19.4668 8.69379C19.2864 9.10792 18.7136 9.10792 18.5331 8.69379L18.2866 8.12811C17.8471 7.11947 17.0555 6.31641 16.0677 5.87708L15.308 5.53922C14.8973 5.35653 14.8973 4.75881 15.308 4.57612L16.0252 4.25714C17.0384 3.80651 17.8442 2.97373 18.2761 1.93083L18.5293 1.31953C18.7058 0.893489 19.2942 0.893489 19.4706 1.31953L19.7238 1.93083C20.1558 2.97373 20.9616 3.80651 21.9748 4.25714L22.6919 4.57612C23.1027 4.75881 23.1027 5.35653 22.6919 5.53922L21.9323 5.87708C20.9445 6.31641 20.1529 7.11947 19.7134 8.12811ZM3.9934 3H13V5H5V19H19V11H21V20.0066C21 20.5552 20.5551 21 20.0066 21H3.9934C3.44476 21 3 20.5551 3 20.0066V3.9934C3 3.44476 3.44495 3 3.9934 3ZM10.6219 8.41459L15.5008 11.6672C15.6846 11.7897 15.7343 12.0381 15.6117 12.2219C15.5824 12.2658 15.5447 12.3035 15.5008 12.3328L10.6219 15.5854C10.4381 15.708 10.1897 15.6583 10.0672 15.4745C10.0234 15.4088 10 15.3316 10 15.2526V8.74741C10 8.52649 10.1791 8.34741 10.4 8.34741C10.479 8.34741 10.5562 8.37078 10.6219 8.41459Z",
+ mic: "M20.4668 7.69379L20.7134 7.12811C21.1529 6.11947 21.9445 5.31641 22.9323 4.87708L23.6919 4.53922C24.1027 4.35653 24.1027 3.75881 23.6919 3.57612L22.9748 3.25714C21.9616 2.80651 21.1558 1.97373 20.7238 0.930828L20.4706 0.319534C20.2942 -0.106511 19.7058 -0.106511 19.5293 0.319534L19.2761 0.930828C18.8442 1.97373 18.0384 2.80651 17.0252 3.25714L16.308 3.57612C15.8973 3.75881 15.8973 4.35653 16.308 4.53922L17.0677 4.87708C18.0555 5.31641 18.8471 6.11947 19.2866 7.12811L19.5331 7.69379C19.7136 8.10792 20.2864 8.10792 20.4668 7.69379ZM3.05469 11H5.07065C5.55588 14.3923 8.47329 17 11.9998 17C15.5262 17 18.4436 14.3923 18.9289 11H20.9448C20.4837 15.1716 17.1714 18.4839 12.9998 18.9451V23H10.9998V18.9451C6.82814 18.4839 3.51584 15.1716 3.05469 11ZM12 1C9.23858 1 7 3.23858 7 6V10C7 12.7614 9.23858 15 12 15C14.7614 15 17 12.7614 17 10V7H15V10C15 11.6569 13.6569 13 12 13C10.3431 13 9 11.6569 9 10V6C9 4.34315 10.3431 3 12 3C12.5972 3 13.1509 3.17349 13.617 3.47248L14.6969 1.7891C13.9182 1.28957 12.9914 1 12 1Z"
+};
+
+export const AI_ASSET_ICON_MAP: Record = {
+ "text-to-image": "image",
+ "image-to-video": "video",
+ "text-to-speech": "mic"
+};
diff --git a/src/components/canvas/players/ai-pending-overlay.ts b/src/components/canvas/players/ai-pending-overlay.ts
new file mode 100644
index 00000000..3f667235
--- /dev/null
+++ b/src/components/canvas/players/ai-pending-overlay.ts
@@ -0,0 +1,399 @@
+import { getAuroraHues, hslToHex, truncatePrompt, getAiAssetTypeLabel } from "@core/shared/ai-asset-utils";
+import * as pixi from "pixi.js";
+
+import { type AiIconType, AI_ICON_FILL_PATHS } from "./ai-icons";
+
+export type { AiIconType };
+
+export interface AiPendingOverlayOptions {
+ mode: "badge" | "panel";
+ icon: AiIconType;
+ width: number;
+ height: number;
+ assetNumber?: number;
+ prompt?: string;
+ assetType?: string;
+}
+
+/**
+ * Aurora curtain layer definition.
+ */
+interface AuroraLayer {
+ color: number;
+ baseAlpha: number;
+ baseY: number;
+ rayHeight: number;
+ phase: number;
+ scrollSpeed: number;
+ waves: [number, number, number][];
+ graphics: pixi.Graphics;
+}
+
+function layeredSine(x: number, t: number, phase: number, waves: [number, number, number][]): number {
+ let sum = 0;
+ for (const [freq, amp, speed] of waves) {
+ sum += Math.sin(x * freq + t * speed + phase) * amp;
+ }
+ return sum;
+}
+
+function generateLayerSpecs(assetNumber?: number): Omit[] {
+ if (assetNumber === undefined) {
+ return [
+ {
+ color: 0x06b6d4,
+ baseAlpha: 0.12,
+ baseY: 0.35,
+ rayHeight: 0.45,
+ phase: 0,
+ scrollSpeed: 0.08,
+ waves: [
+ [2.0, 0.08, 0.15],
+ [4.5, 0.04, 0.25],
+ [9.0, 0.015, 0.4]
+ ]
+ },
+ {
+ color: 0x10b981,
+ baseAlpha: 0.18,
+ baseY: 0.3,
+ rayHeight: 0.5,
+ phase: 1.2,
+ scrollSpeed: 0.12,
+ waves: [
+ [1.5, 0.1, 0.18],
+ [3.8, 0.05, 0.3],
+ [8.0, 0.02, 0.5],
+ [14.0, 0.008, 0.7]
+ ]
+ },
+ {
+ color: 0x34d399,
+ baseAlpha: 0.15,
+ baseY: 0.28,
+ rayHeight: 0.35,
+ phase: 2.8,
+ scrollSpeed: 0.1,
+ waves: [
+ [2.2, 0.07, 0.2],
+ [5.5, 0.035, 0.35],
+ [11.0, 0.012, 0.55]
+ ]
+ },
+ {
+ color: 0x7c3aed,
+ baseAlpha: 0.14,
+ baseY: 0.45,
+ rayHeight: 0.4,
+ phase: 4.1,
+ scrollSpeed: 0.09,
+ waves: [
+ [1.8, 0.09, 0.12],
+ [4.2, 0.045, 0.28],
+ [9.5, 0.018, 0.45]
+ ]
+ },
+ {
+ color: 0xec4899,
+ baseAlpha: 0.1,
+ baseY: 0.5,
+ rayHeight: 0.3,
+ phase: 5.5,
+ scrollSpeed: 0.14,
+ waves: [
+ [2.5, 0.06, 0.22],
+ [6.0, 0.03, 0.38],
+ [12.0, 0.01, 0.6]
+ ]
+ }
+ ];
+ }
+
+ // Generate custom colors based on asset number using golden angle
+ const hues = getAuroraHues(assetNumber);
+
+ return [
+ {
+ color: hslToHex(hues[0], 70, 50),
+ baseAlpha: 0.12,
+ baseY: 0.35,
+ rayHeight: 0.45,
+ phase: 0,
+ scrollSpeed: 0.08,
+ waves: [
+ [2.0, 0.08, 0.15],
+ [4.5, 0.04, 0.25],
+ [9.0, 0.015, 0.4]
+ ]
+ },
+ {
+ color: hslToHex(hues[1], 70, 50),
+ baseAlpha: 0.18,
+ baseY: 0.3,
+ rayHeight: 0.5,
+ phase: 1.2,
+ scrollSpeed: 0.12,
+ waves: [
+ [1.5, 0.1, 0.18],
+ [3.8, 0.05, 0.3],
+ [8.0, 0.02, 0.5],
+ [14.0, 0.008, 0.7]
+ ]
+ },
+ {
+ color: hslToHex(hues[2], 70, 50),
+ baseAlpha: 0.15,
+ baseY: 0.28,
+ rayHeight: 0.35,
+ phase: 2.8,
+ scrollSpeed: 0.1,
+ waves: [
+ [2.2, 0.07, 0.2],
+ [5.5, 0.035, 0.35],
+ [11.0, 0.012, 0.55]
+ ]
+ },
+ {
+ color: hslToHex(hues[3], 70, 50),
+ baseAlpha: 0.14,
+ baseY: 0.45,
+ rayHeight: 0.4,
+ phase: 4.1,
+ scrollSpeed: 0.09,
+ waves: [
+ [1.8, 0.09, 0.12],
+ [4.2, 0.045, 0.28],
+ [9.5, 0.018, 0.45]
+ ]
+ },
+ {
+ color: hslToHex(hues[4], 70, 50),
+ baseAlpha: 0.1,
+ baseY: 0.5,
+ rayHeight: 0.3,
+ phase: 5.5,
+ scrollSpeed: 0.14,
+ waves: [
+ [2.5, 0.06, 0.22],
+ [6.0, 0.03, 0.38],
+ [12.0, 0.01, 0.6]
+ ]
+ }
+ ];
+}
+
+const COLUMN_COUNT = 64;
+
+const BADGE_SIZE = 72;
+const BADGE_ICON_SIZE = 42;
+
+/**
+ * Visual overlay indicating an AI asset is awaiting generation.
+ */
+export class AiPendingOverlay {
+ private container: pixi.Container;
+ private layers: AuroraLayer[] = [];
+ private auroraLayer!: pixi.Container;
+ private time = 0;
+ private rafId: number | null = null;
+ private lastTime: number | null = null;
+
+ constructor(private options: AiPendingOverlayOptions) {
+ this.container = new pixi.Container();
+ this.build();
+ this.startAnimation();
+ }
+
+ getContainer(): pixi.Container {
+ return this.container;
+ }
+
+ resize(width: number, height: number): void {
+ if (width === this.options.width && height === this.options.height) return;
+ this.options.width = width;
+ this.options.height = height;
+ this.rebuild();
+ }
+
+ updatePrompt(prompt: string): void {
+ if (prompt === this.options.prompt) return;
+ this.options.prompt = prompt;
+ this.rebuild();
+ }
+
+ dispose(): void {
+ this.stopAnimation();
+ this.container.destroy({ children: true });
+ }
+
+ // ── private ──────────────────────────────────────────
+
+ private rebuild(): void {
+ this.container.removeChildren();
+ this.layers = [];
+ this.build();
+ }
+
+ private startAnimation(): void {
+ const tick = (now: number) => {
+ if (this.lastTime !== null) {
+ const deltaSec = (now - this.lastTime) / 1000;
+ this.time += deltaSec;
+ this.drawAurora();
+ }
+ this.lastTime = now;
+ this.rafId = requestAnimationFrame(tick);
+ };
+ this.rafId = requestAnimationFrame(tick);
+ }
+
+ private stopAnimation(): void {
+ if (this.rafId !== null) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = null;
+ }
+ }
+
+ private build(): void {
+ const { mode, width, height } = this.options;
+
+ if (mode === "panel") {
+ const bg = new pixi.Graphics();
+ bg.roundRect(0, 0, width, height, 4);
+ bg.fill({ color: "#1E1B2E", alpha: 1 });
+ this.container.addChild(bg);
+ } else {
+ const scrim = new pixi.Graphics();
+ scrim.rect(0, 0, width, height);
+ scrim.fill({ color: "#000000", alpha: 0.25 });
+ this.container.addChild(scrim);
+ }
+
+ this.auroraLayer = new pixi.Container();
+
+ this.layers = generateLayerSpecs(this.options.assetNumber).map(spec => {
+ const graphics = new pixi.Graphics();
+ this.auroraLayer.addChild(graphics);
+ return { ...spec, graphics };
+ });
+
+ const strength = Math.min(Math.max(Math.min(width, height) * 0.04, 12), 40);
+ const blurFilter = new pixi.BlurFilter({ strength, quality: 4 });
+ this.auroraLayer.filters = [blurFilter];
+
+ const mask = new pixi.Graphics();
+ if (mode === "panel") {
+ mask.roundRect(0, 0, width, height, 4);
+ } else {
+ mask.rect(0, 0, width, height);
+ }
+ mask.fill({ color: "#ffffff" });
+ this.auroraLayer.addChild(mask);
+ this.auroraLayer.mask = mask;
+
+ this.container.addChild(this.auroraLayer);
+ this.drawAurora();
+
+ this.buildBadge();
+ }
+
+ /**
+ * Render all aurora layers as vertical strip columns.
+ */
+ private drawAurora(): void {
+ const { width, height } = this.options;
+ const t = this.time;
+ const colWidth = width / COLUMN_COUNT;
+
+ for (const layer of this.layers) {
+ layer.graphics.clear();
+
+ const breath = Math.sin(t * 0.3 + layer.phase * 1.7) * 0.5 + 0.5;
+ const layerAlpha = layer.baseAlpha * (0.7 + 0.3 * breath);
+
+ const scrollOffset = t * layer.scrollSpeed;
+
+ for (let i = 0; i < COLUMN_COUNT; i += 1) {
+ const xNorm = i / COLUMN_COUNT;
+ const xWave = xNorm * Math.PI * 2 + scrollOffset;
+
+ const waveOffset = layeredSine(xWave, t, layer.phase, layer.waves);
+ const topY = (layer.baseY + waveOffset) * height;
+
+ const heightMod = 0.6 + 0.4 * Math.sin(xWave * 3.7 + t * 0.2 + layer.phase * 2.3);
+ const rayH = layer.rayHeight * height * heightMod;
+
+ const rayIntensity = 0.4 + 0.6 * (Math.sin(xWave * 5.0 + t * 0.15 + layer.phase * 3.1) * 0.5 + 0.5) ** 0.7;
+
+ const alpha = layerAlpha * rayIntensity;
+ const x = i * colWidth;
+
+ layer.graphics.rect(x, topY, colWidth + 1, rayH);
+ layer.graphics.fill({ color: layer.color, alpha });
+ }
+ }
+ }
+
+ private buildBadge(): void {
+ const { width, height, icon, assetNumber, prompt, assetType } = this.options;
+
+ const badge = new pixi.Container();
+ badge.position.set(width / 2 - BADGE_SIZE / 2, height / 2 - BADGE_SIZE / 2);
+
+ const bg = new pixi.Graphics();
+ bg.circle(BADGE_SIZE / 2, BADGE_SIZE / 2, BADGE_SIZE / 2);
+ bg.fill({ color: "#000000", alpha: 0.5 });
+ badge.addChild(bg);
+
+ const iconGraphics = new pixi.Graphics();
+ iconGraphics.svg(``);
+ const scale = BADGE_ICON_SIZE / 24;
+ const offset = (BADGE_SIZE - BADGE_ICON_SIZE) / 2;
+ iconGraphics.scale.set(scale, scale);
+ iconGraphics.position.set(offset, offset);
+ badge.addChild(iconGraphics);
+
+ // Number badge with type prefix (if provided)
+ if (assetNumber !== undefined) {
+ // Generate label: "Image 6", "Video 3", etc.
+ const typeLabel = assetType ? getAiAssetTypeLabel(assetType) : "";
+ const labelText = typeLabel ? `${typeLabel} ${assetNumber}` : String(assetNumber);
+
+ const numberText = new pixi.Text({
+ text: labelText,
+ style: {
+ fontFamily: "Arial",
+ fontSize: 18,
+ fontWeight: "bold",
+ fill: "#ffffff"
+ }
+ });
+ numberText.anchor.set(0.5, 0.5);
+ numberText.position.set(BADGE_SIZE / 2, BADGE_SIZE + 15);
+ badge.addChild(numberText);
+ }
+
+ // Prompt text (if provided)
+ if (prompt) {
+ const truncated = truncatePrompt(prompt, 60);
+ const promptText = new pixi.Text({
+ text: truncated,
+ style: {
+ fontFamily: "Arial",
+ fontSize: 16,
+ fontWeight: "normal",
+ fill: "#ffffff",
+ align: "center",
+ wordWrap: true,
+ wordWrapWidth: width * 0.8,
+ lineHeight: 20
+ }
+ });
+ promptText.anchor.set(0.5, 0);
+ promptText.position.set(BADGE_SIZE / 2, BADGE_SIZE + 40);
+ badge.addChild(promptText);
+ }
+
+ this.container.addChild(badge);
+ }
+}
diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts
index dcb3d141..31573cc3 100644
--- a/src/components/canvas/players/audio-player.ts
+++ b/src/components/canvas/players/audio-player.ts
@@ -1,37 +1,26 @@
import { KeyframeBuilder } from "@animations/keyframe-builder";
-import type { Edit } from "@core/edit";
+import type { Edit } from "@core/edit-session";
import { type Size } from "@layouts/geometry";
import { AudioLoadParser } from "@loaders/audio-load-parser";
-import { type AudioAsset } from "@schemas/audio-asset";
-import { type Clip } from "@schemas/clip";
-import { type Keyframe } from "@schemas/keyframe";
+import { type AudioAsset, type ResolvedClip, type Keyframe } from "@schemas";
import * as howler from "howler";
import * as pixi from "pixi.js";
-import { Player } from "./player";
+import { Player, PlayerType } from "./player";
export class AudioPlayer extends Player {
private audioResource: howler.Howl | null;
private isPlaying: boolean;
- private volumeKeyframeBuilder: KeyframeBuilder;
+ private volumeKeyframeBuilder!: KeyframeBuilder;
private syncTimer: number;
- constructor(edit: Edit, clipConfiguration: Clip) {
- super(edit, clipConfiguration);
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.Audio);
this.audioResource = null;
this.isPlaying = false;
-
- const audioAsset = clipConfiguration.asset as AudioAsset;
- const baseVolume = typeof audioAsset.volume === "number" ? audioAsset.volume : 1;
-
- this.volumeKeyframeBuilder = new KeyframeBuilder(
- this.createVolumeKeyframes(audioAsset, baseVolume),
- this.getLength(),
- baseVolume
- );
this.syncTimer = 0;
}
@@ -41,7 +30,7 @@ export class AudioPlayer extends Player {
const audioClipConfiguration = this.clipConfiguration.asset as AudioAsset;
const identifier = audioClipConfiguration.src;
- const loadOptions: pixi.UnresolvedAsset = { src: identifier, loadParser: AudioLoadParser.Name };
+ const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: AudioLoadParser.Name };
const audioResource = await this.edit.assetLoader.load(identifier, loadOptions);
const isValidAudioSource = audioResource instanceof howler.Howl;
@@ -50,6 +39,11 @@ export class AudioPlayer extends Player {
}
this.audioResource = audioResource;
+
+ // Create volume keyframes after timing is resolved (not in constructor)
+ const baseVolume = typeof audioClipConfiguration.volume === "number" ? audioClipConfiguration.volume : 1;
+ this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioClipConfiguration, baseVolume), this.getLength(), baseVolume);
+
this.configureKeyframes();
}
@@ -67,13 +61,14 @@ export class AudioPlayer extends Player {
}
const shouldClipPlay = this.edit.isPlaying && this.isActive();
+ // getPlaybackTime() returns seconds
const playbackTime = this.getPlaybackTime();
if (shouldClipPlay) {
if (!this.isPlaying) {
this.isPlaying = true;
-
- this.audioResource.seek(playbackTime / 1000 + trim);
+ // playbackTime is already in seconds
+ this.audioResource.seek(playbackTime + trim);
this.audioResource.play();
}
@@ -81,11 +76,13 @@ export class AudioPlayer extends Player {
this.audioResource.volume(this.getVolume());
}
- const desyncThreshold = 100;
- const shouldSync = Math.abs((this.audioResource.seek() - trim) * 1000 - playbackTime) > desyncThreshold;
+ // Desync threshold: 0.1 seconds (100ms)
+ const desyncThreshold = 0.1;
+ // Both audioResource.seek() and playbackTime are in seconds
+ const shouldSync = Math.abs(this.audioResource.seek() - trim - playbackTime) > desyncThreshold;
if (shouldSync) {
- this.audioResource.seek(playbackTime / 1000 + trim);
+ this.audioResource.seek(playbackTime + trim);
}
}
@@ -94,22 +91,28 @@ export class AudioPlayer extends Player {
this.audioResource.pause();
}
+ // When paused, sync every 100ms for scrubbing
const shouldSync = this.syncTimer > 100;
if (!this.edit.isPlaying && this.isActive() && shouldSync) {
this.syncTimer = 0;
- this.audioResource.seek(playbackTime / 1000 + trim);
+ this.audioResource.seek(playbackTime + trim);
}
}
- public override draw(): void {
- super.draw();
- }
-
public override dispose(): void {
this.audioResource?.unload();
this.audioResource = null;
}
+ public override reconfigureAfterRestore(): void {
+ super.reconfigureAfterRestore();
+
+ // Rebuild volume keyframes with updated timing
+ const audioAsset = this.clipConfiguration.asset as AudioAsset;
+ const baseVolume = typeof audioAsset.volume === "number" ? audioAsset.volume : 1;
+ this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioAsset, baseVolume), this.getLength(), baseVolume);
+ }
+
public override getSize(): Size {
return { width: 0, height: 0 };
}
@@ -118,6 +121,15 @@ export class AudioPlayer extends Player {
return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime());
}
+ public getCurrentDrift(): number {
+ if (!this.audioResource) return 0;
+ const { trim = 0 } = this.clipConfiguration.asset as AudioAsset;
+ const audioTime = this.audioResource.seek() as number;
+ // getPlaybackTime() returns seconds, audioTime is also seconds
+ const playbackTime = this.getPlaybackTime();
+ return Math.abs(audioTime - trim - playbackTime);
+ }
+
private createVolumeKeyframes(asset: AudioAsset, baseVolume: number): Keyframe[] | number {
const { effect, volume } = asset;
diff --git a/src/components/canvas/players/caption-player.ts b/src/components/canvas/players/caption-player.ts
new file mode 100644
index 00000000..f8908d7e
--- /dev/null
+++ b/src/components/canvas/players/caption-player.ts
@@ -0,0 +1,252 @@
+import { Player, PlayerType } from "@canvas/players/player";
+import { type Cue, findActiveCue } from "@core/captions";
+import type { Edit } from "@core/edit-session";
+import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config";
+import { isAliasReference } from "@core/timing/types";
+import { type Size, type Vector } from "@layouts/geometry";
+import { SubtitleLoadParser, type SubtitleAsset } from "@loaders/subtitle-load-parser";
+import { type ExtendedCaptionAsset, type ResolvedClip } from "@schemas";
+import * as pixiFilters from "pixi-filters";
+import * as pixi from "pixi.js";
+
+const PLACEHOLDER_TEXT = "Captions will appear here";
+
+type CaptionState = { readonly kind: "loaded"; readonly cues: Cue[] } | { readonly kind: "placeholder" };
+
+/**
+ * CaptionPlayer renders timed subtitle cues from SRT/VTT files.
+ * Captions are shown/hidden based on the current playback time.
+ */
+export class CaptionPlayer extends Player {
+ private static loadedFonts = new Set();
+
+ private state: CaptionState = { kind: "loaded", cues: [] };
+ private currentCue: Cue | null = null;
+ private background: pixi.Graphics | null = null;
+ private text: pixi.Text | null = null;
+
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.Caption);
+ }
+
+ public override async load(): Promise {
+ await super.load();
+
+ const captionAsset = this.clipConfiguration.asset as ExtendedCaptionAsset;
+
+ const fontFamily = captionAsset.font?.family ?? "Open Sans";
+ await this.loadFont(fontFamily);
+
+ this.state = isAliasReference(captionAsset.src) ? { kind: "placeholder" } : await this.loadSubtitles(captionAsset.src);
+
+ this.background = new pixi.Graphics();
+ this.contentContainer.addChild(this.background);
+
+ this.text = new pixi.Text({ text: "", style: this.createTextStyle(captionAsset) });
+ this.text.visible = false;
+
+ if (captionAsset.stroke?.width && captionAsset.stroke.width > 0 && captionAsset.stroke.color) {
+ const strokeFilter = new pixiFilters.OutlineFilter({
+ thickness: captionAsset.stroke.width,
+ color: captionAsset.stroke.color
+ });
+ this.text.filters = [strokeFilter];
+ }
+
+ this.contentContainer.addChild(this.text);
+
+ if (this.state.kind === "placeholder") {
+ this.showPlaceholder(captionAsset);
+ }
+
+ this.configureKeyframes();
+ }
+
+ public override update(deltaTime: number, elapsed: number): void {
+ super.update(deltaTime, elapsed);
+
+ if (!this.text || this.state.kind === "placeholder") return;
+
+ const captionAsset = this.clipConfiguration.asset as ExtendedCaptionAsset;
+ const trim = captionAsset.trim ?? 0;
+
+ // getPlaybackTime() already returns seconds
+ const time = this.getPlaybackTime() + trim;
+
+ const activeCue = findActiveCue(this.state.cues, time);
+
+ if (activeCue !== this.currentCue) {
+ this.currentCue = activeCue;
+ this.updateDisplay(activeCue, captionAsset);
+ }
+ }
+
+ public override dispose(): void {
+ super.dispose();
+
+ this.background?.destroy();
+ this.background = null;
+
+ this.text?.destroy();
+ this.text = null;
+
+ this.state = { kind: "loaded", cues: [] };
+ this.currentCue = null;
+ }
+
+ public override getSize(): Size {
+ const captionAsset = this.clipConfiguration.asset as ExtendedCaptionAsset;
+
+ return {
+ width: this.clipConfiguration.width ?? captionAsset.width ?? this.edit.size.width,
+ height: this.clipConfiguration.height ?? captionAsset.height ?? this.edit.size.height
+ };
+ }
+
+ protected override getFitScale(): number {
+ return 1;
+ }
+
+ protected override getContainerScale(): Vector {
+ const scale = this.getScale();
+ return { x: scale, y: scale };
+ }
+
+ private async loadSubtitles(src: string): Promise {
+ try {
+ const loadOptions: pixi.UnresolvedAsset = {
+ src,
+ parser: SubtitleLoadParser.Name
+ };
+ const subtitle = await this.edit.assetLoader.load(src, loadOptions);
+
+ if (subtitle) {
+ return { kind: "loaded", cues: subtitle.cues };
+ }
+
+ console.warn("Failed to load subtitles");
+ return { kind: "placeholder" };
+ } catch (error) {
+ console.warn("Failed to load subtitles:", error);
+ return { kind: "placeholder" };
+ }
+ }
+
+ private showPlaceholder(captionAsset: ExtendedCaptionAsset): void {
+ const placeholderCue: Cue = { start: 0, end: Infinity, text: PLACEHOLDER_TEXT };
+ this.updateDisplay(placeholderCue, captionAsset);
+ }
+
+ private createTextStyle(captionAsset: ExtendedCaptionAsset): pixi.TextStyle {
+ const fontFamily = captionAsset.font?.family ?? "Open Sans";
+ const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily);
+ const fontSize = captionAsset.font?.size ?? 32;
+ const { width } = this.getSize();
+
+ return new pixi.TextStyle({
+ fontFamily: baseFontFamily,
+ fontSize,
+ fill: captionAsset.font?.color ?? "#ffffff",
+ fontWeight: fontWeight.toString() as pixi.TextStyleFontWeight,
+ wordWrap: true,
+ wordWrapWidth: width * 0.9,
+ lineHeight: (captionAsset.font?.lineHeight ?? 1.2) * fontSize,
+ align: captionAsset.alignment?.horizontal ?? "center"
+ });
+ }
+
+ private updateDisplay(cue: Cue | null, captionAsset: ExtendedCaptionAsset): void {
+ if (!this.text || !this.background) return;
+
+ if (!cue) {
+ this.text.visible = false;
+ this.background.clear();
+ return;
+ }
+
+ this.text.text = cue.text;
+ this.text.visible = true;
+
+ this.positionText(captionAsset);
+
+ this.drawBackground(captionAsset);
+ }
+
+ private positionText(captionAsset: ExtendedCaptionAsset): void {
+ if (!this.text) return;
+
+ const horizontalAlign = captionAsset.alignment?.horizontal ?? "center";
+ const verticalAlign = captionAsset.alignment?.vertical ?? "bottom";
+ const { width: containerWidth, height: containerHeight } = this.getSize();
+ const padding = captionAsset.background?.padding ?? 10;
+
+ let textX = containerWidth / 2 - this.text.width / 2;
+ if (horizontalAlign === "left") {
+ textX = padding;
+ } else if (horizontalAlign === "right") {
+ textX = containerWidth - this.text.width - padding;
+ }
+
+ let textY = containerHeight * 0.9;
+ if (verticalAlign === "top") {
+ textY = padding;
+ } else if (verticalAlign === "center") {
+ textY = containerHeight / 2 - this.text.height / 2;
+ }
+
+ this.text.position.set(textX, textY);
+ }
+
+ private drawBackground(captionAsset: ExtendedCaptionAsset): void {
+ if (!this.background || !this.text || !this.text.visible) {
+ this.background?.clear();
+ return;
+ }
+
+ const bgConfig = captionAsset.background;
+ if (!bgConfig?.color) {
+ this.background.clear();
+ return;
+ }
+
+ const padding = bgConfig.padding ?? 10;
+ const borderRadius = bgConfig.borderRadius ?? 4;
+
+ const bgX = this.text.x - padding;
+ const bgY = this.text.y - padding;
+ const bgWidth = this.text.width + padding * 2;
+ const bgHeight = this.text.height + padding * 2;
+
+ this.background.clear();
+ this.background.fillStyle = {
+ color: bgConfig.color,
+ alpha: bgConfig.opacity ?? 0.8
+ };
+
+ if (borderRadius > 0) {
+ this.background.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius);
+ } else {
+ this.background.rect(bgX, bgY, bgWidth, bgHeight);
+ }
+ this.background.fill();
+ }
+
+ private async loadFont(fontFamily: string): Promise {
+ const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily);
+ const cacheKey = `${baseFontFamily}-${fontWeight}`;
+
+ if (CaptionPlayer.loadedFonts.has(cacheKey)) {
+ return;
+ }
+
+ const fontPath = resolveFontPath(fontFamily);
+ if (fontPath) {
+ const fontFace = new FontFace(baseFontFamily, `url(${fontPath})`, {
+ weight: fontWeight.toString()
+ });
+ await fontFace.load();
+ document.fonts.add(fontFace);
+ CaptionPlayer.loadedFonts.add(cacheKey);
+ }
+ }
+}
diff --git a/src/components/canvas/players/html-player.ts b/src/components/canvas/players/html-player.ts
index cb527790..4be6fdbe 100644
--- a/src/components/canvas/players/html-player.ts
+++ b/src/components/canvas/players/html-player.ts
@@ -1,11 +1,10 @@
+import type { Edit } from "@core/edit-session";
import { type Size } from "@layouts/geometry";
-import { type Clip } from "@schemas/clip";
-import { type HtmlAsset, HtmlAssetPosition } from "@schemas/html-asset";
-import type { Edit } from "core/edit";
+import { type ResolvedClip, type HtmlAsset, HtmlAssetPosition } from "@schemas";
import * as pixiFilters from "pixi-filters";
import * as pixi from "pixi.js";
-import { Player } from "./player";
+import { Player, PlayerType } from "./player";
type HtmlDocumentFont = {
color?: string;
@@ -45,8 +44,8 @@ export class HtmlPlayer extends Player {
private background: pixi.Graphics | null;
private text: pixi.Text | null;
- constructor(timeline: Edit, clipConfiguration: Clip) {
- super(timeline, clipConfiguration);
+ constructor(timeline: Edit, clipConfiguration: ResolvedClip) {
+ super(timeline, clipConfiguration, PlayerType.Html);
this.background = null;
this.text = null;
@@ -134,10 +133,6 @@ export class HtmlPlayer extends Player {
super.update(deltaTime, elapsed);
}
- public override draw(): void {
- super.draw();
- }
-
public override dispose(): void {
super.dispose();
@@ -163,7 +158,7 @@ export class HtmlPlayer extends Player {
private async parseDocument(): Promise {
const htmlAsset = this.clipConfiguration.asset as HtmlAsset;
- const { html, css, position } = htmlAsset;
+ const { html, css = "", position } = htmlAsset;
if (!html.includes('data-html-type="text"')) {
console.warn("Unsupported html format.");
diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts
index f63173cc..32e674b2 100644
--- a/src/components/canvas/players/image-player.ts
+++ b/src/components/canvas/players/image-player.ts
@@ -1,51 +1,24 @@
-import type { Edit } from "@core/edit";
+import type { Edit } from "@core/edit-session";
import { type Size } from "@layouts/geometry";
-import { type Clip } from "@schemas/clip";
-import { type ImageAsset } from "@schemas/image-asset";
+import { type ResolvedClip, type ImageAsset } from "@schemas";
import * as pixi from "pixi.js";
-import { Player } from "./player";
+import { Player, PlayerType } from "./player";
export class ImagePlayer extends Player {
private texture: pixi.Texture | null;
private sprite: pixi.Sprite | null;
- private originalSize: Size | null;
- constructor(timeline: Edit, clipConfiguration: Clip) {
- super(timeline, clipConfiguration);
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.Image);
this.texture = null;
this.sprite = null;
- this.originalSize = null;
}
public override async load(): Promise {
await super.load();
-
- const imageAsset = this.clipConfiguration.asset as ImageAsset;
-
- const identifier = imageAsset.src;
- const loadOptions: pixi.UnresolvedAsset = {
- src: identifier,
- crossovern: "anonymous",
- data: {}
- };
- const texture = await this.edit.assetLoader.load>(identifier, loadOptions);
-
- const isValidImageSource = texture?.source instanceof pixi.ImageSource;
- if (!isValidImageSource) {
- throw new Error(`Invalid image source '${imageAsset.src}'.`);
- }
-
- this.texture = this.createCroppedTexture(texture);
- this.sprite = new pixi.Sprite(this.texture);
-
- this.contentContainer.addChild(this.sprite);
-
- if (this.clipConfiguration.width && this.clipConfiguration.height) {
- this.applyFixedDimensions();
- }
-
+ await this.loadTexture();
this.configureKeyframes();
}
@@ -53,20 +26,9 @@ export class ImagePlayer extends Player {
super.update(deltaTime, elapsed);
}
- public override draw(): void {
- super.draw();
- }
-
public override dispose(): void {
super.dispose();
-
- this.sprite?.destroy();
- this.sprite = null;
-
- this.texture?.destroy();
- this.texture = null;
-
- this.originalSize = null;
+ this.disposeTexture();
}
public override getSize(): Size {
@@ -80,6 +42,56 @@ export class ImagePlayer extends Player {
return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
}
+ public override getContentSize(): Size {
+ return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
+ }
+
+ /** Reload the image asset when asset.src changes (e.g., merge field update) */
+ public override async reloadAsset(): Promise {
+ this.disposeTexture();
+ await this.loadTexture();
+ }
+
+ private async loadTexture(): Promise {
+ const imageAsset = this.clipConfiguration.asset as ImageAsset;
+ const { src } = imageAsset;
+
+ const corsUrl = `${src}${src.includes("?") ? "&" : "?"}x-cors=1`;
+ const loadOptions: pixi.UnresolvedAsset = { src: corsUrl, crossorigin: "anonymous", data: {} };
+ const texture = await this.edit.assetLoader.load>(corsUrl, loadOptions);
+
+ if (!(texture?.source instanceof pixi.ImageSource)) {
+ if (texture) {
+ texture.destroy(true);
+ // Asset unloading handled by ref counting in edit-session.unloadClipAssets()
+ }
+ throw new Error(`Invalid image source '${src}'.`);
+ }
+
+ this.texture = this.createCroppedTexture(texture);
+ this.sprite = new pixi.Sprite(this.texture);
+ this.contentContainer.addChild(this.sprite);
+
+ if (this.clipConfiguration.width && this.clipConfiguration.height) {
+ this.applyFixedDimensions();
+ }
+ }
+
+ private disposeTexture(): void {
+ if (this.sprite) {
+ this.contentContainer.removeChild(this.sprite);
+ this.sprite.destroy();
+ this.sprite = null;
+ }
+ // DON'T destroy the texture - it's managed by Assets
+ // The unloadClipAssets() method handles proper cleanup via Assets.unload()
+ this.texture = null;
+ }
+
+ public override supportsEdgeResize(): boolean {
+ return true;
+ }
+
private createCroppedTexture(texture: pixi.Texture): pixi.Texture {
const imageAsset = this.clipConfiguration.asset as ImageAsset;
diff --git a/src/components/canvas/players/image-to-video-player.ts b/src/components/canvas/players/image-to-video-player.ts
new file mode 100644
index 00000000..bc1214ac
--- /dev/null
+++ b/src/components/canvas/players/image-to-video-player.ts
@@ -0,0 +1,147 @@
+import type { Edit } from "@core/edit-session";
+import { computeAiAssetNumber, isAiAsset } from "@core/shared/ai-asset-utils";
+import { type Size } from "@layouts/geometry";
+import { type ResolvedClip, type ImageToVideoAsset } from "@schemas";
+import * as pixi from "pixi.js";
+
+import { AiPendingOverlay } from "./ai-pending-overlay";
+import { createPlaceholderGraphic } from "./placeholder-graphic";
+import { Player, PlayerType } from "./player";
+
+export class ImageToVideoPlayer extends Player {
+ private sprite: pixi.Sprite | null = null;
+ private texture: pixi.Texture | null = null;
+ private placeholder: pixi.Graphics | null = null;
+ private aiOverlay: AiPendingOverlay | null = null;
+
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.ImageToVideo);
+ }
+
+ public override async load(): Promise {
+ await super.load();
+
+ const displaySize = this.getDisplaySize();
+
+ // Compute asset number from resolved state
+ const allClips = this.edit.getResolvedEdit()?.timeline.tracks.flatMap(t => t.clips) ?? [];
+ const assetNumber = computeAiAssetNumber(allClips, this.clipId ?? "");
+
+ // Extract resolved prompt and asset type
+ const { asset } = this.clipConfiguration;
+ const prompt = isAiAsset(asset) ? asset.prompt || "" : "";
+ const assetType = isAiAsset(asset) ? asset.type : "image-to-video";
+
+ try {
+ await this.loadTexture();
+ this.aiOverlay = new AiPendingOverlay({
+ mode: "badge",
+ icon: "video",
+ width: displaySize.width,
+ height: displaySize.height,
+ assetNumber: assetNumber ?? undefined,
+ prompt,
+ assetType
+ });
+ } catch {
+ this.placeholder = createPlaceholderGraphic(displaySize.width, displaySize.height);
+ this.contentContainer.addChild(this.placeholder);
+ this.aiOverlay = new AiPendingOverlay({
+ mode: "panel",
+ icon: "video",
+ width: displaySize.width,
+ height: displaySize.height,
+ assetNumber: assetNumber ?? undefined,
+ prompt,
+ assetType
+ });
+ }
+
+ this.contentContainer.addChild(this.aiOverlay.getContainer());
+ this.configureKeyframes();
+ }
+
+ public override update(deltaTime: number, elapsed: number): void {
+ super.update(deltaTime, elapsed);
+
+ const displaySize = this.getDisplaySize();
+ this.aiOverlay?.resize(displaySize.width, displaySize.height);
+
+ const overlayContainer = this.aiOverlay?.getContainer();
+ if (overlayContainer) {
+ const containerScale = this.getContainerScale();
+ const size = this.getSize();
+
+ // Counter-scale so overlay renders at screen-space pixels
+ overlayContainer.scale.set(1 / containerScale.x, 1 / containerScale.y);
+
+ // Position at the top-left of the visible content rect
+ overlayContainer.position.set(
+ size.width / 2 - displaySize.width / (2 * containerScale.x),
+ size.height / 2 - displaySize.height / (2 * containerScale.y)
+ );
+ }
+ }
+
+ public override getSize(): Size {
+ if (this.clipConfiguration.width && this.clipConfiguration.height) {
+ return {
+ width: this.clipConfiguration.width,
+ height: this.clipConfiguration.height
+ };
+ }
+
+ return {
+ width: this.sprite?.width || this.edit.size.width,
+ height: this.sprite?.height || this.edit.size.height
+ };
+ }
+
+ public override dispose(): void {
+ if (this.sprite) {
+ this.contentContainer.removeChild(this.sprite);
+ this.sprite.destroy();
+ this.sprite = null;
+ }
+ this.texture = null;
+
+ this.placeholder?.destroy();
+ this.placeholder = null;
+
+ this.aiOverlay?.dispose();
+ this.aiOverlay = null;
+
+ super.dispose();
+ }
+
+ private getDisplaySize(): Size {
+ return {
+ width: this.clipConfiguration.width ?? this.edit.size.width,
+ height: this.clipConfiguration.height ?? this.edit.size.height
+ };
+ }
+
+ private async loadTexture(): Promise {
+ const asset = this.clipConfiguration.asset as ImageToVideoAsset;
+ const { src } = asset;
+
+ const corsUrl = `${src}${src.includes("?") ? "&" : "?"}x-cors=1`;
+ const loadOptions: pixi.UnresolvedAsset = { src: corsUrl, crossorigin: "anonymous", data: {} };
+ const texture = await this.edit.assetLoader.load>(corsUrl, loadOptions);
+
+ if (!(texture?.source instanceof pixi.ImageSource)) {
+ if (texture) {
+ texture.destroy(true);
+ }
+ throw new Error(`Invalid image source '${src}'.`);
+ }
+
+ this.texture = texture;
+ this.sprite = new pixi.Sprite(this.texture);
+ this.contentContainer.addChild(this.sprite);
+
+ if (this.clipConfiguration.width && this.clipConfiguration.height) {
+ this.applyFixedDimensions();
+ }
+ }
+}
diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts
index 34c9be7e..b9df8c6e 100644
--- a/src/components/canvas/players/luma-player.ts
+++ b/src/components/canvas/players/luma-player.ts
@@ -1,10 +1,9 @@
-import type { Edit } from "@core/edit";
+import type { Edit } from "@core/edit-session";
import { type Size } from "@layouts/geometry";
-import { type Clip } from "@schemas/clip";
-import { type LumaAsset } from "@schemas/luma-asset";
+import { type ResolvedClip, type LumaAsset } from "@schemas";
import * as pixi from "pixi.js";
-import { Player } from "./player";
+import { Player, PlayerType } from "./player";
type LumaSource = pixi.ImageSource | pixi.VideoSource;
@@ -13,8 +12,8 @@ export class LumaPlayer extends Player {
private sprite: pixi.Sprite | null;
private isPlaying: boolean;
- constructor(edit: Edit, clipConfiguration: Clip) {
- super(edit, clipConfiguration);
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.Luma);
this.texture = null;
this.sprite = null;
@@ -32,9 +31,20 @@ export class LumaPlayer extends Player {
const isValidLumaSource = texture?.source instanceof pixi.ImageSource || texture?.source instanceof pixi.VideoSource;
if (!isValidLumaSource) {
+ // Clean up ref if texture loaded but has invalid source type
+ // (if texture was null, AssetLoader already decremented on failure)
+ if (texture) {
+ this.edit.assetLoader.decrementRef(identifier);
+ }
throw new Error(`Invalid luma source '${lumaAsset.src}'.`);
}
+ // Fix alpha channel rendering for WebM VP9 videos
+ // PixiJS 8's auto-detection is buggy, causing invisible rendering
+ if (texture.source instanceof pixi.VideoSource) {
+ texture.source.alphaMode = "no-premultiply-alpha";
+ }
+
this.texture = texture;
this.sprite = new pixi.Sprite(this.texture);
@@ -59,7 +69,7 @@ export class LumaPlayer extends Player {
if (shouldClipPlay) {
if (!this.isPlaying) {
this.isPlaying = true;
- this.texture.source.resource.currentTime = playbackTime / 1000;
+ this.texture.source.resource.currentTime = playbackTime;
this.texture.source.resource.play().catch(console.error);
}
@@ -67,11 +77,11 @@ export class LumaPlayer extends Player {
this.texture.source.resource.volume = this.getVolume();
}
- const desyncThreshold = 100;
- const shouldSync = Math.abs(this.texture.source.resource.currentTime * 1000 - playbackTime) > desyncThreshold;
+ const desyncThreshold = 0.1;
+ const shouldSync = Math.abs(this.texture.source.resource.currentTime - playbackTime) > desyncThreshold;
if (shouldSync) {
- this.texture.source.resource.currentTime = playbackTime / 1000;
+ this.texture.source.resource.currentTime = playbackTime;
}
}
@@ -81,25 +91,28 @@ export class LumaPlayer extends Player {
}
if (!this.edit.isPlaying && this.isActive()) {
- this.texture.source.resource.currentTime = playbackTime / 1000;
+ this.texture.source.resource.currentTime = playbackTime;
}
}
- public override draw(): void {
- super.draw();
- }
-
public override dispose(): void {
super.dispose();
this.sprite?.destroy();
this.sprite = null;
- this.texture?.destroy();
+ // DON'T destroy the texture - it's managed by Assets
+ // The unloadClipAssets() method in Edit already calls Assets.unload()
this.texture = null;
}
public override getSize(): Size {
+ if (this.clipConfiguration.width && this.clipConfiguration.height) {
+ return {
+ width: this.clipConfiguration.width,
+ height: this.clipConfiguration.height
+ };
+ }
return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
}
@@ -107,7 +120,18 @@ export class LumaPlayer extends Player {
return 0;
}
- public getMask(): pixi.Sprite | null {
+ public getSprite(): pixi.Sprite | null {
return this.sprite;
}
+
+ public isVideoSource(): boolean {
+ return this.texture?.source instanceof pixi.VideoSource;
+ }
+
+ public getVideoCurrentTime(): number {
+ if (this.texture?.source instanceof pixi.VideoSource) {
+ return this.texture.source.resource.currentTime;
+ }
+ return -1;
+ }
}
diff --git a/src/components/canvas/players/placeholder-graphic.ts b/src/components/canvas/players/placeholder-graphic.ts
new file mode 100644
index 00000000..fd43c3fb
--- /dev/null
+++ b/src/components/canvas/players/placeholder-graphic.ts
@@ -0,0 +1,20 @@
+import * as pixi from "pixi.js";
+
+/**
+ * Create a placeholder graphic for unresolved assets.
+ */
+export function createPlaceholderGraphic(width: number, height: number): pixi.Graphics {
+ const graphics = new pixi.Graphics();
+ graphics.fillStyle = { color: "#cccccc", alpha: 0.5 };
+ graphics.rect(0, 0, width, height);
+ graphics.fill();
+
+ graphics.strokeStyle = { color: "#999999", width: 2 };
+ graphics.moveTo(0, 0);
+ graphics.lineTo(width, height);
+ graphics.moveTo(width, 0);
+ graphics.lineTo(0, height);
+ graphics.stroke();
+
+ return graphics;
+}
diff --git a/src/components/canvas/players/player-factory.ts b/src/components/canvas/players/player-factory.ts
new file mode 100644
index 00000000..12ed797f
--- /dev/null
+++ b/src/components/canvas/players/player-factory.ts
@@ -0,0 +1,66 @@
+import type { Edit } from "@core/edit-session";
+import type { ResolvedClip } from "@schemas";
+
+import { AudioPlayer } from "./audio-player";
+import { CaptionPlayer } from "./caption-player";
+import { HtmlPlayer } from "./html-player";
+import { ImagePlayer } from "./image-player";
+import { ImageToVideoPlayer } from "./image-to-video-player";
+import { LumaPlayer } from "./luma-player";
+import type { Player } from "./player";
+import { RichTextPlayer } from "./rich-text-player";
+import { ShapePlayer } from "./shape-player";
+import { SvgPlayer } from "./svg-player";
+import { TextPlayer } from "./text-player";
+import { TextToImagePlayer } from "./text-to-image-player";
+import { TextToSpeechPlayer } from "./text-to-speech-player";
+import { VideoPlayer } from "./video-player";
+
+/**
+ * Factory for creating Player instances from clip configurations.
+ */
+export class PlayerFactory {
+ static create(edit: Edit, clipConfiguration: ResolvedClip): Player {
+ if (!clipConfiguration.asset?.type) {
+ throw new Error("Invalid clip configuration: missing asset type");
+ }
+
+ switch (clipConfiguration.asset.type) {
+ case "text":
+ return new TextPlayer(edit, clipConfiguration);
+ case "rich-text":
+ return new RichTextPlayer(edit, clipConfiguration);
+ case "shape":
+ return new ShapePlayer(edit, clipConfiguration);
+ case "html":
+ return new HtmlPlayer(edit, clipConfiguration);
+ case "image":
+ return new ImagePlayer(edit, clipConfiguration);
+ case "video":
+ return new VideoPlayer(edit, clipConfiguration);
+ case "audio":
+ return new AudioPlayer(edit, clipConfiguration);
+ case "luma":
+ return new LumaPlayer(edit, clipConfiguration);
+ case "caption":
+ return new CaptionPlayer(edit, clipConfiguration);
+ case "svg":
+ return new SvgPlayer(edit, clipConfiguration);
+ case "text-to-image":
+ return new TextToImagePlayer(edit, clipConfiguration);
+ case "image-to-video":
+ return new ImageToVideoPlayer(edit, clipConfiguration);
+ case "text-to-speech":
+ return new TextToSpeechPlayer(edit, clipConfiguration);
+ default:
+ throw new Error(`Unsupported asset type: ${(clipConfiguration.asset as { type: string }).type}`);
+ }
+ }
+
+ /**
+ * Reset static caches used by players.
+ */
+ static cleanup(): void {
+ TextPlayer.resetFontCache();
+ }
+}
diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts
index a38e23d8..da31cac7 100644
--- a/src/components/canvas/players/player.ts
+++ b/src/components/canvas/players/player.ts
@@ -1,286 +1,233 @@
+import { ComposedKeyframeBuilder } from "@animations/composed-keyframe-builder";
import { EffectPresetBuilder } from "@animations/effect-preset-builder";
import { KeyframeBuilder } from "@animations/keyframe-builder";
import { TransitionPresetBuilder } from "@animations/transition-preset-builder";
-import { type Edit } from "@core/edit";
-import { type ResolvedTiming, type TimingIntent } from "@core/timing/types";
+import { type Edit } from "@core/edit-session";
+import { InternalEvent } from "@core/events/edit-events";
+import { calculateContainerScale, calculateFitScale, calculateSpriteTransform, type FitMode } from "@core/layout/fit-system";
+import {
+ type AliasReference,
+ type ResolvedTiming,
+ type Seconds,
+ type TimingIntent,
+ type TimingValue,
+ isAliasReference,
+ sec
+} from "@core/timing/types";
import { Pointer } from "@inputs/pointer";
import { type Size, type Vector } from "@layouts/geometry";
import { PositionBuilder } from "@layouts/position-builder";
-import { type Clip, type ResolvedClipConfig } from "@schemas/clip";
-import { type Keyframe } from "@schemas/keyframe";
+import { type Clip, type ResolvedClip, type Keyframe } from "@schemas";
import * as pixi from "pixi.js";
import { Entity } from "../../../core/shared/entity";
/**
- * TODO: Move handles on UI level (screen space)
- * TODO: Handle overlapping frames - ex: length of a clip is 1.5s but there's an in (1s) and out (1s) transition
- * TODO: Scale X and Y needs to be implemented separately for getFitScale cover
- * TODO: Move animation effects and transitions out of player
- * TODO: On pointer down and custom keyframe, add a keyframe at the current time. Get current and time and push a keyframe into the state, and then reconfigure the keyframes.
- * TODO: Move bounding box to a separate entity
+ * Tracks a merge field binding for a specific property path.
+ * Used to restore placeholders on export for properties that haven't changed.
*/
+export interface MergeFieldBinding {
+ /** The original placeholder string, e.g., "{{ HERO_IMAGE }}" */
+ placeholder: string;
+ /** The resolved value at binding time, used for change detection */
+ resolvedValue: string;
+}
-export abstract class Player extends Entity {
- private static readonly SnapThreshold = 20;
-
- private static readonly DiscardedFrameCount = Math.ceil((1 / 30) * 1000);
-
- private static readonly ScaleHandleRadius = 10;
- private static readonly RotationHandleRadius = 10;
- private static readonly RotationHandleOffset = 50;
- private static readonly OutlineWidth = 5;
-
- private static readonly MinScale = 0.1;
- private static readonly MaxScale = 5;
-
- private static readonly EdgeHandleLength = 30;
- private static readonly EdgeHandleThickness = 8;
- private static readonly MinDimension = 50;
- private static readonly MaxDimension = 3840;
+export enum PlayerType {
+ Video = "video",
+ Image = "image",
+ Audio = "audio",
+ Text = "text",
+ RichText = "rich-text",
+ Luma = "luma",
+ Html = "html",
+ Shape = "shape",
+ Caption = "caption",
+ Svg = "svg",
+ TextToImage = "text-to-image",
+ ImageToVideo = "image-to-video",
+ TextToSpeech = "text-to-speech"
+}
- private static readonly RotationCursorSvg = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath d='M10 3a7 7 0 1 0 7 7' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3Cpath d='M10 3a7 7 0 1 0 7 7' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M14 3 L10 0 L10 6 Z' fill='black' stroke='white' stroke-width='0.75' stroke-linejoin='round'/%3E%3C/svg%3E") 10 10, auto`;
+/**
+ * Base class for all visual content players in the canvas.
+ *
+ * Player is responsible for rendering clip content (video, image, text, etc.)
+ * and applying keyframe animations.
+ *
+ */
+export abstract class Player extends Entity {
+ private static readonly DiscardedFrameCount = 0;
public layer: number;
public shouldDispose: boolean;
+ public readonly playerType: PlayerType;
+
+ /**
+ * Stable ID from the document for reconciliation.
+ * Used to track this Player across document changes.
+ */
+ public clipId: string | null = null;
protected edit: Edit;
- public clipConfiguration: Clip;
+ public clipConfiguration: ResolvedClip;
- private timingIntent: TimingIntent;
private resolvedTiming: ResolvedTiming;
private positionBuilder: PositionBuilder;
- private offsetXKeyframeBuilder?: KeyframeBuilder;
- private offsetYKeyframeBuilder?: KeyframeBuilder;
- private scaleKeyframeBuilder?: KeyframeBuilder;
- private opacityKeyframeBuilder?: KeyframeBuilder;
- private rotationKeyframeBuilder?: KeyframeBuilder;
-
- private outline: pixi.Graphics | null;
- private topLeftScaleHandle: pixi.Graphics | null;
- private topRightScaleHandle: pixi.Graphics | null;
- private bottomLeftScaleHandle: pixi.Graphics | null;
- private bottomRightScaleHandle: pixi.Graphics | null;
- private rotationHandle: pixi.Graphics | null;
-
- private leftEdgeHandle: pixi.Graphics | null;
- private rightEdgeHandle: pixi.Graphics | null;
- private topEdgeHandle: pixi.Graphics | null;
- private bottomEdgeHandle: pixi.Graphics | null;
-
- private isHovering: boolean;
- private isDragging: boolean;
- private dragOffset: Vector;
-
- private scaleDirection: "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | null;
- private scaleStart: number | null;
- private scaleOffset: Vector;
-
- private isRotating: boolean;
- private rotationStart: number | null;
- private rotationOffset: Vector;
-
- private edgeDragDirection: "left" | "right" | "top" | "bottom" | null;
- private edgeDragStart: Vector;
- private originalDimensions: { width: number; height: number; offsetX: number; offsetY: number } | null;
-
- private initialClipConfiguration: Clip | null;
+ private offsetXKeyframeBuilder?: ComposedKeyframeBuilder;
+ private offsetYKeyframeBuilder?: ComposedKeyframeBuilder;
+ private scaleKeyframeBuilder?: ComposedKeyframeBuilder;
+ private opacityKeyframeBuilder?: ComposedKeyframeBuilder;
+ private rotationKeyframeBuilder?: ComposedKeyframeBuilder;
+ private skewXKeyframeBuilder?: ComposedKeyframeBuilder;
+ private skewYKeyframeBuilder?: ComposedKeyframeBuilder;
+ private maskXKeyframeBuilder?: KeyframeBuilder;
+
+ private wipeMask: pixi.Graphics | null;
+ protected lumaWrapper: pixi.Container;
protected contentContainer: pixi.Container;
- constructor(edit: Edit, clipConfiguration: Clip) {
+ constructor(edit: Edit, clipConfiguration: ResolvedClip, playerType: PlayerType) {
super();
this.edit = edit;
this.layer = 0;
this.shouldDispose = false;
+ this.playerType = playerType;
this.clipConfiguration = clipConfiguration;
this.positionBuilder = new PositionBuilder(edit.size);
- this.timingIntent = {
- start: clipConfiguration.start,
- length: clipConfiguration.length
- };
-
- const startValue = typeof clipConfiguration.start === "number" ? clipConfiguration.start * 1000 : 0;
- const lengthValue = typeof clipConfiguration.length === "number" ? clipConfiguration.length * 1000 : 3000;
- this.resolvedTiming = { start: startValue, length: lengthValue };
-
- this.outline = null;
- this.topLeftScaleHandle = null;
- this.topRightScaleHandle = null;
- this.bottomRightScaleHandle = null;
- this.bottomLeftScaleHandle = null;
- this.rotationHandle = null;
+ this.resolvedTiming = { start: clipConfiguration.start, length: clipConfiguration.length };
- this.leftEdgeHandle = null;
- this.rightEdgeHandle = null;
- this.topEdgeHandle = null;
- this.bottomEdgeHandle = null;
-
- this.isHovering = false;
-
- this.isDragging = false;
- this.dragOffset = { x: 0, y: 0 };
-
- this.scaleDirection = null;
- this.scaleStart = null;
- this.scaleOffset = { x: 0, y: 0 };
-
- this.isRotating = false;
- this.rotationStart = null;
- this.rotationOffset = { x: 0, y: 0 };
-
- this.edgeDragDirection = null;
- this.edgeDragStart = { x: 0, y: 0 };
- this.originalDimensions = null;
-
- this.initialClipConfiguration = null;
+ this.wipeMask = null;
+ // TODO: Lazy-init lumaWrapper in getLumaWrapper() to avoid allocating per player when unused
+ this.lumaWrapper = new pixi.Container();
this.contentContainer = new pixi.Container();
- this.getContainer().addChild(this.contentContainer);
+ this.lumaWrapper.addChild(this.contentContainer);
+ this.getContainer().addChild(this.lumaWrapper);
}
public reconfigureAfterRestore(): void {
this.configureKeyframes();
}
- protected configureKeyframes() {
- this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset?.x ?? 0, this.getLength());
- this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset?.y ?? 0, this.getLength());
- this.scaleKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.scale ?? 1, this.getLength(), 1);
- this.opacityKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.opacity ?? 1, this.getLength(), 1);
- this.rotationKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.transform?.rotate?.angle ?? 0, this.getLength());
+ /**
+ * Reload the asset for this player (e.g., when asset.src changes).
+ * Override in subclasses that have loadable assets (image, video).
+ * Default implementation is a no-op.
+ */
+ public async reloadAsset(): Promise {
+ // Default: no-op. Override in ImagePlayer, VideoPlayer, etc.
+ }
+ protected configureKeyframes() {
+ const length = this.getLength();
+ const config = this.clipConfiguration;
+
+ // Extract base values from clip configuration
+ const baseOffsetX = typeof config.offset?.x === "number" ? config.offset.x : 0;
+ const baseOffsetY = typeof config.offset?.y === "number" ? config.offset.y : 0;
+ const baseScale = typeof config.scale === "number" ? config.scale : 1;
+ const baseOpacity = typeof config.opacity === "number" ? config.opacity : 1;
+ const baseRotation = typeof config.transform?.rotate?.angle === "number" ? config.transform.rotate.angle : 0;
+ const baseSkewX = typeof config.transform?.skew?.x === "number" ? config.transform.skew.x : 0;
+ const baseSkewY = typeof config.transform?.skew?.y === "number" ? config.transform.skew.y : 0;
+
+ // Create composed builders with base values
+ this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetX, length, "additive");
+ this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetY, length, "additive");
+ this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(baseScale, length, "multiplicative");
+ this.opacityKeyframeBuilder = new ComposedKeyframeBuilder(baseOpacity, length, "multiplicative", { min: 0, max: 1 });
+ this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(baseRotation, length, "additive");
+ this.skewXKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewX, length, "additive");
+ this.skewYKeyframeBuilder = new ComposedKeyframeBuilder(baseSkewY, length, "additive");
+
+ // If user has custom keyframes, add them and skip effect/transition layers
if (this.clipHasKeyframes()) {
+ if (Array.isArray(config.scale)) {
+ this.scaleKeyframeBuilder.addLayer(config.scale);
+ }
+ if (Array.isArray(config.opacity)) {
+ this.opacityKeyframeBuilder.addLayer(config.opacity);
+ }
+ if (Array.isArray(config.offset?.x)) {
+ this.offsetXKeyframeBuilder.addLayer(config.offset.x);
+ }
+ if (Array.isArray(config.offset?.y)) {
+ this.offsetYKeyframeBuilder.addLayer(config.offset.y);
+ }
+ if (Array.isArray(config.transform?.rotate?.angle)) {
+ this.rotationKeyframeBuilder.addLayer(config.transform.rotate.angle);
+ }
+ if (Array.isArray(config.transform?.skew?.x)) {
+ this.skewXKeyframeBuilder.addLayer(config.transform.skew.x);
+ }
+ if (Array.isArray(config.transform?.skew?.y)) {
+ this.skewYKeyframeBuilder.addLayer(config.transform.skew.y);
+ }
return;
}
- const offsetXKeyframes: Keyframe[] = [];
- const offsetYKeyframes: Keyframe[] = [];
- const opacityKeyframes: Keyframe[] = [];
- const scaleKeyframes: Keyframe[] = [];
- const rotationKeyframes: Keyframe[] = [];
-
- const resolvedClipConfig: ResolvedClipConfig = {
- ...this.clipConfiguration,
- start: this.getStart() / 1000,
- length: this.getLength() / 1000
+ // Build resolved clip config for preset builders
+ const resolvedClipConfig: ResolvedClip = {
+ ...config,
+ start: this.getStart(),
+ length
};
- const effectKeyframeSet = new EffectPresetBuilder(resolvedClipConfig).build(this.edit.size, this.getSize());
- offsetXKeyframes.push(...effectKeyframeSet.offsetXKeyframes);
- offsetYKeyframes.push(...effectKeyframeSet.offsetYKeyframes);
- opacityKeyframes.push(...effectKeyframeSet.opacityKeyframes);
- scaleKeyframes.push(...effectKeyframeSet.scaleKeyframes);
- rotationKeyframes.push(...effectKeyframeSet.rotationKeyframes);
-
- const transitionKeyframeSet = new TransitionPresetBuilder(resolvedClipConfig).build();
- offsetXKeyframes.push(...transitionKeyframeSet.offsetXKeyframes);
- offsetYKeyframes.push(...transitionKeyframeSet.offsetYKeyframes);
- opacityKeyframes.push(...transitionKeyframeSet.opacityKeyframes);
- scaleKeyframes.push(...transitionKeyframeSet.scaleKeyframes);
- rotationKeyframes.push(...transitionKeyframeSet.rotationKeyframes);
-
- if (offsetXKeyframes.length) {
- this.offsetXKeyframeBuilder = new KeyframeBuilder(offsetXKeyframes, this.getLength());
- }
+ // Build relative effect keyframes (factors/deltas)
+ const effectSet = new EffectPresetBuilder(resolvedClipConfig).buildRelative(this.edit.size, this.getSize());
- if (offsetYKeyframes.length) {
- this.offsetYKeyframeBuilder = new KeyframeBuilder(offsetYKeyframes, this.getLength());
- }
+ // Build relative transition keyframes (separate in/out sets)
+ const transitionSet = new TransitionPresetBuilder(resolvedClipConfig).buildRelative();
- if (opacityKeyframes.length) {
- this.opacityKeyframeBuilder = new KeyframeBuilder(opacityKeyframes, this.getLength(), 1);
- }
+ // Add effect layer
+ this.offsetXKeyframeBuilder.addLayer(effectSet.offsetXKeyframes);
+ this.offsetYKeyframeBuilder.addLayer(effectSet.offsetYKeyframes);
+ this.scaleKeyframeBuilder.addLayer(effectSet.scaleKeyframes);
+ this.opacityKeyframeBuilder.addLayer(effectSet.opacityKeyframes);
+ this.rotationKeyframeBuilder.addLayer(effectSet.rotationKeyframes);
- if (scaleKeyframes.length) {
- this.scaleKeyframeBuilder = new KeyframeBuilder(scaleKeyframes, this.getLength(), 1);
- }
+ // Add transition-in layer
+ this.offsetXKeyframeBuilder.addLayer(transitionSet.in.offsetXKeyframes);
+ this.offsetYKeyframeBuilder.addLayer(transitionSet.in.offsetYKeyframes);
+ this.scaleKeyframeBuilder.addLayer(transitionSet.in.scaleKeyframes);
+ this.opacityKeyframeBuilder.addLayer(transitionSet.in.opacityKeyframes);
+ this.rotationKeyframeBuilder.addLayer(transitionSet.in.rotationKeyframes);
+
+ // Add transition-out layer
+ this.offsetXKeyframeBuilder.addLayer(transitionSet.out.offsetXKeyframes);
+ this.offsetYKeyframeBuilder.addLayer(transitionSet.out.offsetYKeyframes);
+ this.scaleKeyframeBuilder.addLayer(transitionSet.out.scaleKeyframes);
+ this.opacityKeyframeBuilder.addLayer(transitionSet.out.opacityKeyframes);
+ this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes);
- if (rotationKeyframes.length) {
- this.rotationKeyframeBuilder = new KeyframeBuilder(rotationKeyframes, this.getLength());
+ // Mask keyframes (wipe/reveal effects)
+ const maskXKeyframes: Keyframe[] = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes];
+ if (maskXKeyframes.length) {
+ this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, length);
}
}
public override async load(): Promise {
+ if (this.lumaWrapper?.destroyed) {
+ this.lumaWrapper = new pixi.Container();
+ this.getContainer().addChild(this.lumaWrapper);
+ }
if (this.contentContainer?.destroyed) {
this.contentContainer = new pixi.Container();
- this.getContainer().addChild(this.contentContainer);
- }
-
- this.outline = new pixi.Graphics();
- this.getContainer().addChild(this.outline);
-
- // Only create corner scale handles for assets that don't use edge resize
- if (!this.supportsEdgeResize()) {
- this.topLeftScaleHandle = new pixi.Graphics();
- this.topRightScaleHandle = new pixi.Graphics();
- this.bottomRightScaleHandle = new pixi.Graphics();
- this.bottomLeftScaleHandle = new pixi.Graphics();
-
- this.topLeftScaleHandle.zIndex = 1000;
- this.topRightScaleHandle.zIndex = 1000;
- this.bottomRightScaleHandle.zIndex = 1000;
- this.bottomLeftScaleHandle.zIndex = 1000;
-
- this.getContainer().addChild(this.topLeftScaleHandle);
- this.getContainer().addChild(this.topRightScaleHandle);
- this.getContainer().addChild(this.bottomRightScaleHandle);
- this.getContainer().addChild(this.bottomLeftScaleHandle);
- }
-
- this.rotationHandle = new pixi.Graphics();
- this.rotationHandle.zIndex = 1000;
- this.rotationHandle.eventMode = "static";
- this.rotationHandle.cursor = Player.RotationCursorSvg;
- this.getContainer().addChild(this.rotationHandle);
-
- // Create edge handles for text/rich-text assets
- if (this.supportsEdgeResize()) {
- this.leftEdgeHandle = new pixi.Graphics();
- this.rightEdgeHandle = new pixi.Graphics();
- this.topEdgeHandle = new pixi.Graphics();
- this.bottomEdgeHandle = new pixi.Graphics();
-
- this.leftEdgeHandle.zIndex = 1000;
- this.rightEdgeHandle.zIndex = 1000;
- this.topEdgeHandle.zIndex = 1000;
- this.bottomEdgeHandle.zIndex = 1000;
-
- // Enable interactivity and set resize cursors
- this.leftEdgeHandle.eventMode = "static";
- this.leftEdgeHandle.cursor = "ew-resize";
-
- this.rightEdgeHandle.eventMode = "static";
- this.rightEdgeHandle.cursor = "ew-resize";
-
- this.topEdgeHandle.eventMode = "static";
- this.topEdgeHandle.cursor = "ns-resize";
-
- this.bottomEdgeHandle.eventMode = "static";
- this.bottomEdgeHandle.cursor = "ns-resize";
-
- this.getContainer().addChild(this.leftEdgeHandle);
- this.getContainer().addChild(this.rightEdgeHandle);
- this.getContainer().addChild(this.topEdgeHandle);
- this.getContainer().addChild(this.bottomEdgeHandle);
+ this.lumaWrapper.addChild(this.contentContainer);
}
this.getContainer().sortableChildren = true;
+ // Enable pointer events for click-to-select
this.getContainer().cursor = "pointer";
this.getContainer().eventMode = "static";
-
- this.getContainer().on("pointerdown", this.onPointerStart.bind(this));
- this.getContainer().on("pointermove", this.onPointerMove.bind(this));
- this.getContainer().on("globalpointermove", this.onPointerMove.bind(this));
- this.getContainer().on("pointerup", this.onPointerUp.bind(this));
- this.getContainer().on("pointerupoutside", this.onPointerUp.bind(this));
-
- this.getContainer().on("pointerover", this.onPointerOver.bind(this));
- this.getContainer().on("pointerout", this.onPointerOut.bind(this));
+ this.getContainer().on?.("pointerdown", this.onPointerDown.bind(this));
}
public override update(_: number, __: number): void {
@@ -303,194 +250,101 @@ export abstract class Player extends Entity {
this.contentContainer.alpha = this.getOpacity();
this.getContainer().angle = angle;
+ const skew = this.getSkew();
+ this.getContainer().skew?.set(skew.x * (Math.PI / 180), skew.y * (Math.PI / 180));
+
if (this.clipConfiguration.width && this.clipConfiguration.height) {
this.applyFixedDimensions();
}
+ // Update wipe/reveal mask animation
+ this.updateWipeMask();
+
if (this.shouldDiscardFrame()) {
this.contentContainer.alpha = 0;
}
}
- public override draw(): void {
- if (!this.outline) {
- return;
- }
-
- const isSelected = this.edit.isPlayerSelected(this);
-
- const isExporting = this.edit.isInExportMode();
-
- if (((!this.isActive() || !isSelected) && !this.isHovering) || isExporting) {
- this.outline.clear();
- this.topLeftScaleHandle?.clear();
- this.topRightScaleHandle?.clear();
- this.bottomRightScaleHandle?.clear();
- this.bottomLeftScaleHandle?.clear();
- this.rotationHandle?.clear();
- this.leftEdgeHandle?.clear();
- this.rightEdgeHandle?.clear();
- this.topEdgeHandle?.clear();
- this.bottomEdgeHandle?.clear();
+ private updateWipeMask(): void {
+ if (!this.maskXKeyframeBuilder) {
+ if (this.wipeMask) {
+ this.getContainer().mask = null;
+ this.wipeMask.destroy();
+ this.wipeMask = null;
+ }
return;
}
- const color = this.isHovering || this.isDragging ? 0x00ffff : 0xffffff;
+ const maskProgress = this.maskXKeyframeBuilder.getValue(this.getPlaybackTime());
const size = this.getSize();
- const scale = this.getScale();
-
- this.outline.clear();
- this.outline.strokeStyle = { width: Player.OutlineWidth / scale, color };
- this.outline.rect(0, 0, size.width, size.height);
- this.outline.stroke();
-
- if (!this.isActive() || !isSelected) {
- return;
+ // Create mask if it doesn't exist
+ if (!this.wipeMask) {
+ this.wipeMask = new pixi.Graphics();
+ this.getContainer().addChild(this.wipeMask);
+ this.getContainer().mask = this.wipeMask;
}
- // Draw corner scale handles (only for assets that don't support edge resize)
- if (
- this.topLeftScaleHandle &&
- this.topRightScaleHandle &&
- this.bottomRightScaleHandle &&
- this.bottomLeftScaleHandle
- ) {
- const handleSize = (Player.ScaleHandleRadius * 2) / scale;
-
- this.topLeftScaleHandle.fillStyle = { color };
- this.topLeftScaleHandle.clear();
- this.topLeftScaleHandle.rect(-handleSize / 2, -handleSize / 2, handleSize, handleSize);
- this.topLeftScaleHandle.fill();
-
- this.topRightScaleHandle.fillStyle = { color };
- this.topRightScaleHandle.clear();
- this.topRightScaleHandle.rect(size.width - handleSize / 2, -handleSize / 2, handleSize, handleSize);
- this.topRightScaleHandle.fill();
-
- this.bottomRightScaleHandle.fillStyle = { color };
- this.bottomRightScaleHandle.clear();
- this.bottomRightScaleHandle.rect(size.width - handleSize / 2, size.height - handleSize / 2, handleSize, handleSize);
- this.bottomRightScaleHandle.fill();
-
- this.bottomLeftScaleHandle.fillStyle = { color };
- this.bottomLeftScaleHandle.clear();
- this.bottomLeftScaleHandle.rect(-handleSize / 2, size.height - handleSize / 2, handleSize, handleSize);
- this.bottomLeftScaleHandle.fill();
- }
-
- // Draw rotation handle (for all asset types)
- if (this.rotationHandle) {
- const rotationHandleX = size.width / 2;
- const rotationHandleY = -Player.RotationHandleOffset / scale;
-
- this.rotationHandle.clear();
- this.rotationHandle.fillStyle = { color };
- this.rotationHandle.circle(rotationHandleX, rotationHandleY, Player.RotationHandleRadius / scale);
- this.rotationHandle.fill();
-
- this.outline.strokeStyle = { width: Player.OutlineWidth / scale, color };
- this.outline.moveTo(rotationHandleX, 0);
- this.outline.lineTo(rotationHandleX, rotationHandleY);
- this.outline.stroke();
- }
-
- // Draw edge handles for text/rich-text assets
- if (this.supportsEdgeResize()) {
- const edgeLength = Player.EdgeHandleLength / scale;
- const edgeThickness = Player.EdgeHandleThickness / scale;
-
- // Left edge handle (vertical bar on left edge, centered)
- if (this.leftEdgeHandle) {
- this.leftEdgeHandle.clear();
- this.leftEdgeHandle.fillStyle = { color };
- this.leftEdgeHandle.rect(-edgeThickness / 2, size.height / 2 - edgeLength / 2, edgeThickness, edgeLength);
- this.leftEdgeHandle.fill();
- }
-
- // Right edge handle (vertical bar on right edge, centered)
- if (this.rightEdgeHandle) {
- this.rightEdgeHandle.clear();
- this.rightEdgeHandle.fillStyle = { color };
- this.rightEdgeHandle.rect(size.width - edgeThickness / 2, size.height / 2 - edgeLength / 2, edgeThickness, edgeLength);
- this.rightEdgeHandle.fill();
- }
-
- // Top edge handle (horizontal bar on top edge, centered)
- if (this.topEdgeHandle) {
- this.topEdgeHandle.clear();
- this.topEdgeHandle.fillStyle = { color };
- this.topEdgeHandle.rect(size.width / 2 - edgeLength / 2, -edgeThickness / 2, edgeLength, edgeThickness);
- this.topEdgeHandle.fill();
- }
-
- // Bottom edge handle (horizontal bar on bottom edge, centered)
- if (this.bottomEdgeHandle) {
- this.bottomEdgeHandle.clear();
- this.bottomEdgeHandle.fillStyle = { color };
- this.bottomEdgeHandle.rect(size.width / 2 - edgeLength / 2, size.height - edgeThickness / 2, edgeLength, edgeThickness);
- this.bottomEdgeHandle.fill();
- }
- }
+ // Update mask to create wipe effect
+ this.wipeMask.clear();
+ this.wipeMask.rect(0, 0, size.width * maskProgress, size.height);
+ this.wipeMask.fill(0xffffff);
}
public override dispose(): void {
- this.outline?.destroy();
- this.outline = null;
-
- this.topLeftScaleHandle?.destroy();
- this.topLeftScaleHandle = null;
-
- this.topRightScaleHandle?.destroy();
- this.topRightScaleHandle = null;
-
- this.bottomLeftScaleHandle?.destroy();
- this.bottomLeftScaleHandle = null;
-
- this.bottomRightScaleHandle?.destroy();
- this.bottomRightScaleHandle = null;
-
- this.rotationHandle?.destroy();
- this.rotationHandle = null;
-
- this.leftEdgeHandle?.destroy();
- this.leftEdgeHandle = null;
-
- this.rightEdgeHandle?.destroy();
- this.rightEdgeHandle = null;
-
- this.topEdgeHandle?.destroy();
- this.topEdgeHandle = null;
-
- this.bottomEdgeHandle?.destroy();
- this.bottomEdgeHandle = null;
+ this.wipeMask?.destroy();
+ this.wipeMask = null;
this.contentContainer?.destroy();
+ this.lumaWrapper?.destroy();
}
- public getStart(): number {
+ public getStart(): Seconds {
return this.resolvedTiming.start;
}
- public getLength(): number {
+ public getLength(): Seconds {
return this.resolvedTiming.length;
}
- public getEnd(): number {
- return this.resolvedTiming.start + this.resolvedTiming.length;
+ public getEnd(): Seconds {
+ return sec(this.resolvedTiming.start + this.resolvedTiming.length);
}
+ /**
+ * Get timing intent from document (source of truth).
+ * Returns "auto"/"end"/"alias://x" strings as stored in the document, not resolved numeric values.
+ */
public getTimingIntent(): TimingIntent {
- return { ...this.timingIntent };
- }
+ if (this.clipId) {
+ const docClip = this.edit.getDocumentClipById(this.clipId);
+ if (docClip) {
+ let startIntent: Seconds | "auto" | AliasReference;
+ if (docClip.start === "auto") {
+ startIntent = "auto";
+ } else if (isAliasReference(docClip.start)) {
+ startIntent = docClip.start as AliasReference;
+ } else {
+ startIntent = docClip.start as Seconds;
+ }
- public setTimingIntent(intent: Partial): void {
- if (intent.start !== undefined) {
- this.timingIntent.start = intent.start;
- }
- if (intent.length !== undefined) {
- this.timingIntent.length = intent.length;
+ let lengthIntent: TimingValue;
+ if (docClip.length === "auto" || docClip.length === "end") {
+ lengthIntent = docClip.length;
+ } else if (isAliasReference(docClip.length)) {
+ lengthIntent = docClip.length as AliasReference;
+ } else {
+ lengthIntent = docClip.length as Seconds;
+ }
+
+ return { start: startIntent, length: lengthIntent };
+ }
}
+ // Fallback: use resolved timing from clipConfiguration
+ return {
+ start: this.clipConfiguration.start,
+ length: this.clipConfiguration.length
+ };
}
public getResolvedTiming(): ResolvedTiming {
@@ -499,15 +353,31 @@ export abstract class Player extends Entity {
public setResolvedTiming(timing: ResolvedTiming): void {
this.resolvedTiming = { ...timing };
+ this.clipConfiguration.start = timing.start;
+ this.clipConfiguration.length = timing.length;
}
- public convertToFixedTiming(): void {
- this.timingIntent = {
- start: this.resolvedTiming.start / 1000,
- length: this.resolvedTiming.length / 1000
- };
+ /**
+ * Get the clip configuration with timing intent applied.
+ * Note: Placeholder restoration is handled by document.toJSON() for export,
+ * or by EditSession.getTemplateClip() for API callers.
+ */
+ public getExportableClip(): Clip {
+ const exported = structuredClone(this.clipConfiguration) as Record;
+
+ // Apply timing intent (preserves "auto", "end" strings)
+ const intent = this.getTimingIntent();
+ exported["start"] = intent.start;
+ exported["length"] = intent.length;
+
+ return exported as Clip;
}
+ // ─────────────────────────────────────────────────────────────────────────────
+
+ /**
+ * Get the playback time relative to clip start, in seconds.
+ */
public getPlaybackTime(): number {
const clipTime = this.edit.playbackTime - this.getStart();
@@ -519,6 +389,23 @@ export abstract class Player extends Entity {
public abstract getSize(): Size;
+ /**
+ * Returns the source content dimensions (before fit scaling).
+ */
+ public getContentSize(): Size {
+ return this.getSize();
+ }
+
+ /** @internal */
+ public getContentContainer(): pixi.Container {
+ return this.contentContainer;
+ }
+
+ /** @internal */
+ public getLumaWrapper(): pixi.Container {
+ return this.lumaWrapper;
+ }
+
public getOpacity(): number {
return this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1;
}
@@ -537,26 +424,30 @@ export abstract class Player extends Entity {
return { x: size.width / 2, y: size.height / 2 };
}
+ /**
+ * Calculate the new offset position after moving by a pixel delta.
+ * Returns the new offset without mutating player state.
+ * Used for keyboard arrow key positioning.
+ * @internal
+ */
+ public calculateMoveOffset(deltaX: number, deltaY: number): { x: number; y: number } {
+ const currentPos = this.getPosition();
+ const newAbsolutePos = { x: currentPos.x + deltaX, y: currentPos.y + deltaY };
+
+ const relativePos = this.positionBuilder.absoluteToRelative(this.getSize(), this.clipConfiguration.position ?? "center", newAbsolutePos);
+
+ return { x: relativePos.x, y: relativePos.y };
+ }
+
protected getFitScale(): number {
- if (this.clipConfiguration.width && this.clipConfiguration.height) {
- return 1;
- }
+ const targetSize = {
+ width: this.clipConfiguration.width ?? this.edit.size.width,
+ height: this.clipConfiguration.height ?? this.edit.size.height
+ };
+ const contentSize = this.getContentSize();
+ const fit = (this.clipConfiguration.fit ?? "crop") as FitMode;
- switch (this.clipConfiguration.fit ?? "crop") {
- case "crop": {
- const ratioX = this.edit.size.width / this.getSize().width;
- const ratioY = this.edit.size.height / this.getSize().height;
- const isPortrait = this.edit.size.height >= this.edit.size.width;
- return isPortrait ? ratioY : ratioX;
- }
- case "cover":
- return Math.max(this.edit.size.width / this.getSize().width, this.edit.size.height / this.getSize().height);
- case "contain":
- return Math.min(this.edit.size.width / this.getSize().width, this.edit.size.height / this.getSize().height);
- case "none":
- default:
- return 1;
- }
+ return calculateFitScale(contentSize, targetSize, fit);
}
public getScale(): number {
@@ -564,45 +455,27 @@ export abstract class Player extends Entity {
}
protected getContainerScale(): Vector {
- if (this.clipConfiguration.width && this.clipConfiguration.height) {
- return { x: 1, y: 1 };
- }
-
const baseScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1;
- const size = this.getSize();
- const fit = this.clipConfiguration.fit ?? "crop";
+ const contentSize = this.getContentSize();
+ const fit = (this.clipConfiguration.fit ?? "crop") as FitMode;
+ const hasFixedDimensions = Boolean(this.clipConfiguration.width && this.clipConfiguration.height);
- if (size.width === 0 || size.height === 0) {
- return { x: baseScale, y: baseScale };
- }
-
- const ratioX = this.edit.size.width / size.width;
- const ratioY = this.edit.size.height / size.height;
-
- switch (fit) {
- case "contain": {
- const uniform = Math.min(ratioX, ratioY) * baseScale;
- return { x: uniform, y: uniform };
- }
- case "crop": {
- const isPortrait = this.edit.size.height >= this.edit.size.width;
- const uniform = (isPortrait ? ratioY : ratioX) * baseScale;
- return { x: uniform, y: uniform };
- }
- case "cover": {
- return { x: ratioX * baseScale, y: ratioY * baseScale };
- }
- case "none":
- default:
- return { x: baseScale, y: baseScale };
- }
+ return calculateContainerScale(contentSize, this.edit.size, fit, baseScale, hasFixedDimensions);
}
public getRotation(): number {
return this.rotationKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0;
}
+ public getSkew(): { x: number; y: number } {
+ return {
+ x: this.skewXKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0,
+ y: this.skewYKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0
+ };
+ }
+
public isActive(): boolean {
+ // playbackTime is in seconds, matching clip start/end
return this.edit.playbackTime >= this.getStart() && this.edit.playbackTime < this.getEnd();
}
@@ -610,454 +483,79 @@ export abstract class Player extends Entity {
return this.getPlaybackTime() < Player.DiscardedFrameCount;
}
- private onPointerStart(event: pixi.FederatedPointerEvent): void {
+ /**
+ * Handle pointer down - emit click event for selection handling.
+ * All drag/resize/rotate interaction is handled by SelectionHandles.
+ */
+ private onPointerDown(event: pixi.FederatedPointerEvent): void {
if (event.button !== Pointer.ButtonLeftClick) {
return;
}
- this.edit.events.emit("canvas:clip:clicked", { player: this });
-
- this.initialClipConfiguration = structuredClone(this.clipConfiguration);
-
- if (this.clipHasKeyframes()) {
- return;
- }
-
- this.scaleDirection = null;
-
- const isTopLeftScaling = this.topLeftScaleHandle?.getBounds().containsPoint(event.globalX, event.globalY);
- if (isTopLeftScaling) {
- this.scaleDirection = "topLeft";
- }
-
- const isTopRightScaling = this.topRightScaleHandle?.getBounds().containsPoint(event.globalX, event.globalY);
- if (isTopRightScaling) {
- this.scaleDirection = "topRight";
- }
-
- const isBottomRightScaling = this.bottomRightScaleHandle?.getBounds().containsPoint(event.globalX, event.globalY);
- if (isBottomRightScaling) {
- this.scaleDirection = "bottomRight";
- }
-
- const isBottomLeftScaling = this.bottomLeftScaleHandle?.getBounds().containsPoint(event.globalX, event.globalY);
- if (isBottomLeftScaling) {
- this.scaleDirection = "bottomLeft";
- }
-
- if (this.scaleDirection !== null) {
- this.scaleStart = this.getScale() / this.getFitScale();
-
- const timelinePoint = event.getLocalPosition(this.edit.getContainer());
- this.scaleOffset = timelinePoint;
-
- return;
- }
-
- const isRotating = this.rotationHandle?.getBounds().containsPoint(event.globalX, event.globalY);
- if (isRotating) {
- this.isRotating = true;
- this.rotationStart = this.getRotation();
-
- const timelinePoint = event.getLocalPosition(this.edit.getContainer());
- this.rotationOffset = timelinePoint;
-
- return;
- }
-
- // Check for edge handle interactions (for text/rich-text assets)
- if (this.supportsEdgeResize()) {
- this.edgeDragDirection = null;
-
- if (this.leftEdgeHandle?.getBounds().containsPoint(event.globalX, event.globalY)) {
- this.edgeDragDirection = "left";
- } else if (this.rightEdgeHandle?.getBounds().containsPoint(event.globalX, event.globalY)) {
- this.edgeDragDirection = "right";
- } else if (this.topEdgeHandle?.getBounds().containsPoint(event.globalX, event.globalY)) {
- this.edgeDragDirection = "top";
- } else if (this.bottomEdgeHandle?.getBounds().containsPoint(event.globalX, event.globalY)) {
- this.edgeDragDirection = "bottom";
- }
-
- if (this.edgeDragDirection !== null) {
- const timelinePoint = event.getLocalPosition(this.edit.getContainer());
- this.edgeDragStart = timelinePoint;
-
- const currentSize = this.getSize();
- // Get current offset values from keyframe builders (handles both numeric and keyframe array cases)
- const currentOffsetX = this.offsetXKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0;
- const currentOffsetY = this.offsetYKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0;
- this.originalDimensions = {
- width: currentSize.width,
- height: currentSize.height,
- offsetX: currentOffsetX,
- offsetY: currentOffsetY
- };
-
- return;
- }
- }
-
- this.isDragging = true;
-
- const timelinePoint = event.getLocalPosition(this.edit.getContainer());
- this.dragOffset = {
- x: timelinePoint.x - this.getContainer().position.x,
- y: timelinePoint.y - this.getContainer().position.y
- };
- }
-
- private onPointerMove(event: pixi.FederatedPointerEvent): void {
- if (this.scaleDirection !== null && this.scaleStart !== null) {
- const timelinePoint = event.getLocalPosition(this.edit.getContainer());
-
- const position = this.getPosition();
- const pivot = this.getPivot();
-
- const center: Vector = { x: position.x + pivot.x, y: position.y + pivot.y };
-
- const initialDistance = Math.sqrt((this.scaleOffset.x - center.x) ** 2 + (this.scaleOffset.y - center.y) ** 2);
- const currentDistance = Math.sqrt((timelinePoint.x - center.x) ** 2 + (timelinePoint.y - center.y) ** 2);
-
- const scaleRatio = currentDistance / initialDistance;
- const targetScale = this.scaleStart * scaleRatio;
-
- this.clipConfiguration.scale = Math.max(Player.MinScale, Math.min(targetScale, Player.MaxScale));
- this.scaleKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.scale, this.getLength(), 1);
-
- return;
- }
-
- if (this.isRotating && this.rotationStart !== null) {
- const timelinePoint = event.getLocalPosition(this.edit.getContainer());
-
- const position = this.getPosition();
- const pivot = this.getPivot();
-
- const center: Vector = { x: position.x + pivot.x, y: position.y + pivot.y };
-
- const initialAngle = Math.atan2(this.rotationOffset.y - center.y, this.rotationOffset.x - center.x);
- const currentAngle = Math.atan2(timelinePoint.y - center.y, timelinePoint.x - center.x);
-
- const angleDelta = (currentAngle - initialAngle) * (180 / Math.PI);
-
- let targetAngle = this.rotationStart + angleDelta;
- const snapAngle = 45;
- const angleModulo = targetAngle % snapAngle;
- const snapThreshold = 2;
-
- if (Math.abs(angleModulo) < snapThreshold) {
- targetAngle = Math.floor(targetAngle / snapAngle) * snapAngle;
- } else if (Math.abs(angleModulo - snapAngle) < snapThreshold) {
- targetAngle = Math.ceil(targetAngle / snapAngle) * snapAngle;
- }
-
- if (!this.clipConfiguration.transform) {
- this.clipConfiguration.transform = { rotate: { angle: 0 } };
- }
- if (!this.clipConfiguration.transform.rotate) {
- this.clipConfiguration.transform.rotate = { angle: 0 };
- }
- this.clipConfiguration.transform.rotate.angle = targetAngle;
- this.rotationKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.transform.rotate.angle, this.getLength());
-
- return;
- }
-
- // Handle edge resize dragging
- if (this.edgeDragDirection !== null && this.originalDimensions !== null) {
- const timelinePoint = event.getLocalPosition(this.edit.getContainer());
-
- const deltaX = timelinePoint.x - this.edgeDragStart.x;
- const deltaY = timelinePoint.y - this.edgeDragStart.y;
-
- let newWidth = this.originalDimensions.width;
- let newHeight = this.originalDimensions.height;
- let newOffsetX = this.originalDimensions.offsetX;
- let newOffsetY = this.originalDimensions.offsetY;
-
- switch (this.edgeDragDirection) {
- case "left":
- // Dragging left edge: width decreases, offset shifts right to keep right edge fixed
- newWidth = this.originalDimensions.width - deltaX;
- newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width;
- break;
- case "right":
- // Dragging right edge: width increases, offset shifts right to keep left edge fixed
- newWidth = this.originalDimensions.width + deltaX;
- newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width;
- break;
- case "top":
- // Dragging top edge: height decreases, offset shifts up to keep bottom edge fixed
- newHeight = this.originalDimensions.height - deltaY;
- newOffsetY = this.originalDimensions.offsetY - deltaY / 2 / this.edit.size.height;
- break;
- case "bottom":
- // Dragging bottom edge: height increases, offset shifts down to keep top edge fixed
- newHeight = this.originalDimensions.height + deltaY;
- newOffsetY = this.originalDimensions.offsetY - deltaY / 2 / this.edit.size.height;
- break;
- }
-
- // Clamp dimensions to valid bounds
- newWidth = Math.max(Player.MinDimension, Math.min(newWidth, Player.MaxDimension));
- newHeight = Math.max(Player.MinDimension, Math.min(newHeight, Player.MaxDimension));
-
- // Update clip configuration
- this.clipConfiguration.width = Math.round(newWidth);
- this.clipConfiguration.height = Math.round(newHeight);
-
- if (!this.clipConfiguration.offset) {
- this.clipConfiguration.offset = { x: 0, y: 0 };
- }
- this.clipConfiguration.offset.x = newOffsetX;
- this.clipConfiguration.offset.y = newOffsetY;
-
- // Update keyframe builders for position
- this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.x, this.getLength());
- this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.y, this.getLength());
-
- // Notify subclass about dimension change for re-rendering
- this.onDimensionsChanged();
-
- return;
- }
-
- if (this.isDragging) {
- const timelinePoint = event.getLocalPosition(this.edit.getContainer());
-
- const pivot = this.getPivot();
-
- const cursorPosition: Vector = { x: timelinePoint.x - this.dragOffset.x, y: timelinePoint.y - this.dragOffset.y };
- const updatedPosition: Vector = { x: cursorPosition.x - pivot.x, y: cursorPosition.y - pivot.y };
-
- const timelineCorners = [
- { x: 0, y: 0 },
- { x: this.edit.size.width, y: 0 },
- { x: 0, y: this.edit.size.height },
- { x: this.edit.size.width, y: this.edit.size.height }
- ];
- const timelineCenter = { x: this.edit.size.width / 2, y: this.edit.size.height / 2 };
- const timelineSnapPositions: Vector[] = [...timelineCorners, timelineCenter];
-
- const clipCorners = [
- { x: updatedPosition.x, y: updatedPosition.y },
- { x: updatedPosition.x + this.getSize().width, y: updatedPosition.y },
- { x: updatedPosition.x, y: updatedPosition.y + this.getSize().height },
- { x: updatedPosition.x + this.getSize().width, y: updatedPosition.y + this.getSize().height }
- ];
- const clipCenter = { x: updatedPosition.x + this.getSize().width / 2, y: updatedPosition.y + this.getSize().height / 2 };
- const clipSnapPositions: Vector[] = [...clipCorners, clipCenter];
-
- let closestDistanceX = Player.SnapThreshold;
- let closestDistanceY = Player.SnapThreshold;
-
- let snapPositionX: number | null = null;
- let snapPositionY: number | null = null;
-
- for (const clipSnapPosition of clipSnapPositions) {
- for (const timelineSnapPosition of timelineSnapPositions) {
- const distanceX = Math.abs(clipSnapPosition.x - timelineSnapPosition.x);
- if (distanceX < closestDistanceX) {
- closestDistanceX = distanceX;
- snapPositionX = updatedPosition.x + (timelineSnapPosition.x - clipSnapPosition.x);
- }
-
- const distanceY = Math.abs(clipSnapPosition.y - timelineSnapPosition.y);
- if (distanceY < closestDistanceY) {
- closestDistanceY = distanceY;
- snapPositionY = updatedPosition.y + (timelineSnapPosition.y - clipSnapPosition.y);
- }
- }
- }
-
- if (snapPositionX !== null) {
- updatedPosition.x = snapPositionX;
- }
-
- if (snapPositionY !== null) {
- updatedPosition.y = snapPositionY;
- }
-
- const updatedRelativePosition = this.positionBuilder.absoluteToRelative(
- this.getSize(),
- this.clipConfiguration.position ?? "center",
- updatedPosition
- );
-
- if (!this.clipConfiguration.offset) {
- this.clipConfiguration.offset = { x: 0, y: 0 };
- }
- this.clipConfiguration.offset.x = updatedRelativePosition.x;
- this.clipConfiguration.offset.y = updatedRelativePosition.y;
-
- this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.x, this.getLength());
- this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.y, this.getLength());
- }
- }
-
- private onPointerUp(): void {
- if ((this.isDragging || this.scaleDirection !== null || this.isRotating || this.edgeDragDirection !== null) && this.hasStateChanged()) {
- this.edit.setUpdatedClip(this, this.initialClipConfiguration, structuredClone(this.clipConfiguration));
- }
-
- this.isDragging = false;
- this.dragOffset = { x: 0, y: 0 };
-
- this.scaleDirection = null;
- this.scaleStart = null;
- this.scaleOffset = { x: 0, y: 0 };
-
- this.isRotating = false;
- this.rotationStart = null;
- this.rotationOffset = { x: 0, y: 0 };
-
- this.edgeDragDirection = null;
- this.edgeDragStart = { x: 0, y: 0 };
- this.originalDimensions = null;
-
- this.initialClipConfiguration = null;
- }
-
- private onPointerOver(): void {
- this.isHovering = true;
- }
-
- private onPointerOut(): void {
- this.isHovering = false;
- }
-
- private clipHasPresets(): boolean {
- return (
- Boolean(this.clipConfiguration.effect) || Boolean(this.clipConfiguration.transition?.in) || Boolean(this.clipConfiguration.transition?.out)
- );
+ this.edit.getInternalEvents().emit(InternalEvent.CanvasClipClicked, { player: this });
}
private clipHasKeyframes(): boolean {
return [
this.clipConfiguration.scale,
+ this.clipConfiguration.opacity,
this.clipConfiguration.offset?.x,
this.clipConfiguration.offset?.y,
- this.clipConfiguration.transform?.rotate?.angle
+ this.clipConfiguration.transform?.rotate?.angle,
+ this.clipConfiguration.transform?.skew?.x,
+ this.clipConfiguration.transform?.skew?.y
].some(property => property && typeof property !== "number");
}
- private hasStateChanged(): boolean {
- if (!this.initialClipConfiguration) return false;
-
- const currentOffsetX = this.clipConfiguration.offset?.x as number;
- const currentOffsetY = this.clipConfiguration.offset?.y as number;
- const currentScale = this.clipConfiguration.scale as number;
- const currentRotation = Number(this.clipConfiguration.transform?.rotate?.angle ?? 0);
- const currentWidth = this.clipConfiguration.width;
- const currentHeight = this.clipConfiguration.height;
-
- const initialOffsetX = this.initialClipConfiguration.offset?.x as number;
- const initialOffsetY = this.initialClipConfiguration.offset?.y as number;
- const initialScale = this.initialClipConfiguration.scale as number;
- const initialRotation = Number(this.initialClipConfiguration.transform?.rotate?.angle ?? 0);
- const initialWidth = this.initialClipConfiguration.width;
- const initialHeight = this.initialClipConfiguration.height;
-
- return (
- (initialOffsetX !== undefined && currentOffsetX !== initialOffsetX) ||
- (initialOffsetY !== undefined && currentOffsetY !== initialOffsetY) ||
- (initialScale !== undefined && currentScale !== initialScale) ||
- currentRotation !== initialRotation ||
- currentWidth !== initialWidth ||
- currentHeight !== initialHeight
- );
- }
-
protected applyFixedDimensions(): void {
const clipWidth = this.clipConfiguration.width;
const clipHeight = this.clipConfiguration.height;
if (!clipWidth || !clipHeight) return;
- const sprite = this.contentContainer.children[0] as pixi.Sprite;
- if (!sprite || !sprite.texture) return;
+ // Find sprite by type, not index (mask may be children[0] after refresh)
+ const sprite = this.contentContainer.children.find(child => child instanceof pixi.Sprite) as pixi.Sprite | undefined;
+ if (!sprite?.texture) return;
const nativeWidth = sprite.texture.width;
const nativeHeight = sprite.texture.height;
const fit = this.clipConfiguration.fit || "crop";
- if (!this.contentContainer.mask) {
- const clipMask = new pixi.Graphics();
- clipMask.rect(0, 0, clipWidth, clipHeight);
- clipMask.fill(0xffffff);
+ // Get or create the crop mask
+ const existingMask = this.contentContainer.mask;
+ let clipMask: pixi.Graphics;
+ if (existingMask instanceof pixi.Graphics) {
+ clipMask = existingMask;
+ } else if (!existingMask) {
+ clipMask = new pixi.Graphics();
this.contentContainer.addChild(clipMask);
this.contentContainer.mask = clipMask;
+ } else {
+ return;
}
- // keep animation code exactly as-is
- const currentUserScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1;
-
- sprite.anchor.set(0.5, 0.5);
-
- switch (fit) {
- // 🟢 cover → non-uniform stretch to exactly fill (distort)
- case "cover": {
- const scaleX = clipWidth / nativeWidth;
- const scaleY = clipHeight / nativeHeight;
+ // Expand mask to accommodate centered border strokes
+ // Canvas library renders borders centered on content boundary (half extends outward)
+ const { asset } = this.clipConfiguration;
+ const borderWidth = asset && "border" in asset && asset.border && typeof asset.border === "object" ? (asset.border.width ?? 0) : 0;
- // backend “cover” stretches image to fill without cropping
- sprite.scale.set(scaleX, scaleY);
- sprite.position.set(clipWidth / 2, clipHeight / 2);
- break;
- }
-
- // 🟢 crop → uniform fill but never downscale (only upscale if smaller)
- case "crop": {
- // Viewport (output) dimensions — same concept as backend "canvas"
- const outW = this.edit.size.width;
- const outH = this.edit.size.height;
-
- // 1) Pre-downscale to fit the viewport if the source is larger (preserve AR)
- let prescale = 1;
- if (nativeWidth > outW || nativeHeight > outH) {
- prescale = Math.min(outW / nativeWidth, outH / nativeHeight);
- }
+ const halfBorder = borderWidth / 2;
- // Adjusted (virtual) native after prescale
- const adjW = nativeWidth * prescale;
- const adjH = nativeHeight * prescale;
+ clipMask.clear();
+ clipMask.rect(-halfBorder, -halfBorder, clipWidth + borderWidth, clipHeight + borderWidth);
+ clipMask.fill(0xffffff);
- // 2) Uniform fill to cover the clip box (may overflow → mask crops)
- const fill = Math.max(clipWidth / adjW, clipHeight / adjH);
-
- // 3) Effective scale to apply to the *original* texture:
- // - Large images: prescale * fill (we normalized to viewport first)
- // - Small images: never downscale below native => clamp to >= 1
- const effective = prescale < 1 ? prescale * fill : Math.max(1, fill);
-
- // Apply base fit (animation is applied separately via contentContainer in your code)
- sprite.scale.set(effective, effective);
- sprite.anchor.set(0.5, 0.5);
- sprite.position.set(clipWidth / 2, clipHeight / 2);
-
- break;
- }
+ // keep animation code exactly as-is
+ const currentUserScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1;
- // 🟢 contain → uniform fit fully inside (may letterbox)
- case "contain": {
- const sx = clipWidth / nativeWidth;
- const sy = clipHeight / nativeHeight;
+ sprite.anchor.set(0.5, 0.5);
- const baseScale = Math.min(sx, sy);
+ // Use pure function for sprite transform calculation
+ const nativeSize = { width: nativeWidth, height: nativeHeight };
+ const targetSize = { width: clipWidth, height: clipHeight };
+ const transform = calculateSpriteTransform(nativeSize, targetSize, fit as FitMode);
- sprite.scale.set(baseScale, baseScale);
- sprite.position.set(clipWidth / 2, clipHeight / 2);
- break;
- }
-
- // 🟢 none → no fitting, use native size, cropped by mask
- case "none":
- default: {
- sprite.scale.set(1, 1);
- sprite.position.set(clipWidth / 2, clipHeight / 2);
- break;
- }
- }
+ sprite.scale.set(transform.scaleX, transform.scaleY);
+ sprite.position.set(transform.positionX, transform.positionY);
// 🟣 keep animation logic untouched
this.contentContainer.scale.set(currentUserScale, currentUserScale);
@@ -1102,7 +600,7 @@ export abstract class Player extends Entity {
* Override in subclasses to enable edge resize handles for dimension changes.
* When true, edge handles will be shown instead of corner scale handles.
*/
- protected supportsEdgeResize(): boolean {
+ public supportsEdgeResize(): boolean {
return false;
}
@@ -1112,4 +610,12 @@ export abstract class Player extends Entity {
protected onDimensionsChanged(): void {
// Default implementation does nothing - subclasses override this
}
+
+ /**
+ * Public wrapper for notifying dimension changes.
+ * Called by SelectionHandles after edge resize operations.
+ */
+ public notifyDimensionsChanged(): void {
+ this.onDimensionsChanged();
+ }
}
diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts
index 71c50d20..24b39f55 100644
--- a/src/components/canvas/players/rich-text-player.ts
+++ b/src/components/canvas/players/rich-text-player.ts
@@ -1,15 +1,15 @@
-import { Player } from "@canvas/players/player";
-import { type Size } from "@layouts/geometry";
-import { RichTextAssetSchema, type RichTextAsset } from "@schemas/rich-text-asset";
-import { createTextEngine } from "@shotstack/shotstack-canvas";
-import { TextEngine, TextRenderer, ValidatedRichTextAsset } from "@timeline/types";
+import { Player, PlayerType } from "@canvas/players/player";
+import { Edit } from "@core/edit-session";
+import { InternalEvent } from "@core/events/edit-events";
+import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config";
+import { type Size, type Vector } from "@layouts/geometry";
+import { RichTextAssetSchema, type RichTextAsset, type ResolvedClip } from "@schemas";
+import { createTextEngine, type CanvasRichTextAsset } from "@shotstack/shotstack-canvas";
+import * as opentype from "opentype.js";
import * as pixi from "pixi.js";
-interface CanvasRichTextPayload extends RichTextAsset {
- width: number;
- height: number;
- customFonts?: Array<{ src: string; family: string; weight: string }>;
-}
+// Derive TextEngine type from createTextEngine return type
+type TextEngine = Awaited>;
const extractFontNames = (url: string): { full: string; base: string } => {
const filename = url.split("/").pop() || "";
@@ -22,199 +22,302 @@ const extractFontNames = (url: string): { full: string; base: string } => {
};
};
+/** Check if a font URL is from Google Fonts CDN */
+const isGoogleFontUrl = (url: string): boolean => url.includes("fonts.gstatic.com");
+
export class RichTextPlayer extends Player {
+ private static readonly PREVIEW_FPS = 60;
+ private static readonly fontCapabilityCache = new Map>();
private textEngine: TextEngine | null = null;
- private renderer: TextRenderer | null = null;
+ private renderer: ReturnType | null = null;
private canvas: HTMLCanvasElement | null = null;
private texture: pixi.Texture | null = null;
private sprite: pixi.Sprite | null = null;
private lastRenderedTime: number = -1;
private cachedFrames = new Map();
private isRendering: boolean = false;
- private targetFPS: number = 30;
- private validatedAsset: ValidatedRichTextAsset | null = null;
+ private pendingRenderTime: number | null = null; // Stores time requested while rendering (race condition fix)
+ private validatedAsset: CanvasRichTextAsset | null = null;
+ private fontSupportsBold: boolean = false;
+ private loadComplete: boolean = false;
+ private readonly fontRegistrationCache = new Map>();
+
+ private static getFontSourceCacheKey(sourcePath: string): string {
+ const withoutHash = sourcePath.split("#", 1)[0];
+ return withoutHash.split("?", 1)[0];
+ }
- constructor(edit: any, clipConfiguration: any) {
- // Default fit to "cover" for rich-text assets if not provided
- if (!clipConfiguration.fit) {
- clipConfiguration.fit = "cover";
- }
- super(edit, clipConfiguration);
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ // Remove fit property for rich-text assets
+ // This aligns with @shotstack/schemas v1.5.6 which filters fit at track validation
+ const { fit, ...configWithoutFit } = clipConfiguration;
+ super(edit, configWithoutFit, PlayerType.RichText);
}
- private buildCanvasPayload(richTextAsset: RichTextAsset): any {
- const editData = this.edit.getEdit();
- const width = this.clipConfiguration.width || editData?.output?.size?.width || this.edit.size.width;
- const height = this.clipConfiguration.height || editData?.output?.size?.height || this.edit.size.height;
+ private resolveFontWeight(richTextAsset: RichTextAsset, fallbackWeight: number): number {
+ const explicitWeight = richTextAsset.font?.weight;
+ if (typeof explicitWeight === "string") {
+ return parseInt(explicitWeight, 10) || fallbackWeight;
+ }
+ if (typeof explicitWeight === "number") {
+ return explicitWeight;
+ }
- // Build customFonts array internally
- let customFonts: Array<{ src: string; family: string; weight: string }> | undefined;
- if (Array.isArray(editData?.timeline?.fonts) && editData.timeline.fonts.length > 0) {
- const requestedFamily = richTextAsset.font?.family;
- if (requestedFamily) {
- const matchingFont = editData.timeline.fonts?.find(font => {
+ return fallbackWeight;
+ }
+
+ private buildCanvasPayload(
+ richTextAsset: RichTextAsset,
+ fontInfo?: { baseFontFamily: string; fontWeight: number }
+ ): RichTextAsset & {
+ width: number;
+ height: number;
+ font?: RichTextAsset["font"] & { family: string; weight: number };
+ customFonts?: Array<{ src: string; family: string; weight: string }>;
+ } {
+ const width = this.clipConfiguration.width || this.edit.size.width;
+ const height = this.clipConfiguration.height || this.edit.size.height;
+
+ // Use provided font info or parse fresh (for reconfigure/updateTextContent calls)
+ const requestedFamily = richTextAsset.font?.family;
+ const { baseFontFamily, fontWeight: parsedWeight } =
+ fontInfo ?? (requestedFamily ? parseFontFamily(requestedFamily) : { baseFontFamily: requestedFamily, fontWeight: 400 });
+
+ // Use explicit font.weight if set, otherwise fall back to parsed weight from family name
+ const fontWeight = this.resolveFontWeight(richTextAsset, parsedWeight);
+
+ // Find matching timeline font for customFonts payload
+ const timelineFonts = this.edit.getTimelineFonts();
+ const matchingFont = requestedFamily
+ ? timelineFonts.find(font => {
const { full, base } = extractFontNames(font.src);
const requested = requestedFamily.toLowerCase();
return full.toLowerCase() === requested || base.toLowerCase() === requested;
- });
-
- if (matchingFont) {
- customFonts = [
- {
- src: matchingFont.src,
- family: requestedFamily,
- weight: richTextAsset.font?.weight?.toString() || "400"
- }
- ];
- }
+ })
+ : undefined;
+
+ // Build customFonts array for the text engine
+ // Match by URL filename first, then by binary font name from metadata
+ let customFonts: Array<{ src: string; family: string; weight: string }> | undefined;
+ if (matchingFont && requestedFamily) {
+ customFonts = [{ src: matchingFont.src, family: baseFontFamily || requestedFamily, weight: fontWeight.toString() }];
+ } else if (requestedFamily) {
+ // Filename matching failed — try matching by binary font name from metadata
+ const fontMetadata = this.edit.getFontMetadata();
+ const lowerRequested = (baseFontFamily || requestedFamily).toLowerCase();
+ const nonGoogleFonts = timelineFonts.filter(font => !isGoogleFontUrl(font.src));
+
+ const metadataMatch = nonGoogleFonts.find(font => {
+ const meta = fontMetadata.get(font.src);
+ return meta?.baseFamilyName.toLowerCase() === lowerRequested;
+ });
+ if (metadataMatch) {
+ customFonts = [{ src: metadataMatch.src, family: baseFontFamily || requestedFamily, weight: fontWeight.toString() }];
}
+ // No match → no customFonts → text engine falls back to default font
}
- // Build payload with stroke extracted from font and placed at root level for canvas compatibility
- const payload: any = {
+ // Determine the font family for the canvas payload:
+ // Use matched custom font name, or built-in font, or fall back to Roboto
+ const hasFontMatch = customFonts || (requestedFamily && resolveFontPath(requestedFamily));
+ const resolvedFamily = hasFontMatch ? baseFontFamily || requestedFamily : undefined;
+
+ return {
...richTextAsset,
width,
height,
- // Extract stroke from font property and place at root level for canvas compatibility
+ font: richTextAsset.font ? { ...richTextAsset.font, family: resolvedFamily || "Roboto", weight: fontWeight } : undefined,
stroke: richTextAsset.font?.stroke,
...(customFonts && { customFonts })
};
+ }
- return payload;
+ private async registerFont(
+ family: string,
+ weight: number,
+ source: { type: "url"; path: string } | { type: "file"; path: string }
+ ): Promise {
+ if (!this.textEngine) return false;
+ const normalizedPath = RichTextPlayer.getFontSourceCacheKey(source.path);
+ const cacheKey = `${source.type}:${normalizedPath}|${family}|${weight}`;
+ const cached = this.fontRegistrationCache.get(cacheKey);
+ if (cached) return cached;
+
+ const registrationPromise = (async (): Promise => {
+ try {
+ const fontDesc = { family, weight: weight.toString() };
+ if (source.type === "url") {
+ await this.textEngine!.registerFontFromUrl(source.path, fontDesc);
+ } else {
+ await this.textEngine!.registerFontFromFile(source.path, fontDesc);
+ }
+ return true;
+ } catch {
+ return false;
+ }
+ })();
+ this.fontRegistrationCache.set(cacheKey, registrationPromise);
+ return registrationPromise;
}
- private createFontMapping(): Map {
- const fontMap = new Map();
-
- fontMap.set("Arapey", "/assets/fonts/Arapey-Regular.ttf");
- fontMap.set("ClearSans", "/assets/fonts/ClearSans-Regular.ttf");
- fontMap.set("Clear Sans", "/assets/fonts/ClearSans-Regular.ttf");
- fontMap.set("DidactGothic", "/assets/fonts/DidactGothic-Regular.ttf");
- fontMap.set("Didact Gothic", "/assets/fonts/DidactGothic-Regular.ttf");
- fontMap.set("Montserrat", "/assets/fonts/Montserrat-SemiBold.ttf");
- fontMap.set("MovLette", "/assets/fonts/MovLette.ttf");
- fontMap.set("OpenSans", "/assets/fonts/OpenSans-Bold.ttf");
- fontMap.set("Open Sans", "/assets/fonts/OpenSans-Bold.ttf");
- fontMap.set("PermanentMarker", "/assets/fonts/PermanentMarker-Regular.ttf");
- fontMap.set("Permanent Marker", "/assets/fonts/PermanentMarker-Regular.ttf");
- fontMap.set("Roboto", "/assets/fonts/Roboto.ttf");
- fontMap.set("SueEllenFrancisco", "/assets/fonts/SueEllenFrancisco.ttf");
- fontMap.set("Sue Ellen Francisco", "/assets/fonts/SueEllenFrancisco.ttf");
- fontMap.set("UniNeue", "/assets/fonts/UniNeue-Bold.otf");
- fontMap.set("Uni Neue", "/assets/fonts/UniNeue-Bold.otf");
- fontMap.set("WorkSans", "/assets/fonts/WorkSans-Light.ttf");
- fontMap.set("Work Sans", "/assets/fonts/WorkSans-Light.ttf");
-
- return fontMap;
+ private createFontCapabilityCheckPromise(fontUrl: string): Promise {
+ return (async (): Promise => {
+ try {
+ const response = await fetch(fontUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch font: ${response.status}`);
+ }
+ const buffer = await response.arrayBuffer();
+ const font = opentype.parse(buffer);
+
+ // Check for fvar table (variable font) with weight axis
+ const fvar = font.tables["fvar"] as { axes?: Array<{ tag: string }> } | undefined;
+ return !!fvar?.axes?.find(axis => axis.tag === "wght");
+ } catch (error) {
+ console.warn("Failed to check font capabilities:", error);
+ return false;
+ }
+ })();
}
- public override reconfigureAfterRestore(): void {
- super.reconfigureAfterRestore();
+ private async prepareFontForAsset(richTextAsset: RichTextAsset, emitCapabilitiesEvent: boolean): Promise {
+ const fontUrl = await this.ensureFontRegistered(richTextAsset);
+ if (!fontUrl) {
+ return;
+ }
- for (const texture of this.cachedFrames.values()) {
- texture.destroy();
+ const cacheKey = RichTextPlayer.getFontSourceCacheKey(fontUrl);
+ const cachedCheck = RichTextPlayer.fontCapabilityCache.get(cacheKey);
+ const capabilityCheck = cachedCheck ?? this.createFontCapabilityCheckPromise(fontUrl);
+ if (!cachedCheck) {
+ RichTextPlayer.fontCapabilityCache.set(cacheKey, capabilityCheck);
}
- this.cachedFrames.clear();
- this.lastRenderedTime = -1;
- const richTextAsset = this.clipConfiguration.asset as RichTextAsset;
- if (this.textEngine) {
- const canvasPayload = this.buildCanvasPayload(richTextAsset);
- const { value: validated } = this.textEngine.validate(canvasPayload);
- this.validatedAsset = validated;
+ this.fontSupportsBold = await capabilityCheck;
+
+ if (emitCapabilitiesEvent) {
+ this.edit.getInternalEvents().emit(InternalEvent.FontCapabilitiesChanged, { supportsBold: this.fontSupportsBold });
}
+ }
- if (this.textEngine && this.renderer) {
- this.renderFrameSafe(this.getCurrentTime() / 1000);
+ public supportsBold(): boolean {
+ return this.fontSupportsBold;
+ }
+
+ private resolveFont(family: string): { url: string; baseFontFamily: string; fontWeight: number } | null {
+ const { baseFontFamily, fontWeight } = parseFontFamily(family);
+
+ // Check stored font metadata first (for template fonts with UUID-based URLs)
+ // Uses normalized base family + weight to match the correct font file
+ const metadataUrl = this.edit.getFontUrlByFamilyAndWeight(baseFontFamily, fontWeight);
+ if (metadataUrl) {
+ return { url: metadataUrl, baseFontFamily, fontWeight };
+ }
+
+ // Check timeline fonts by filename matching (legacy fallback)
+ const editData = this.edit.getEdit();
+ const timelineFonts = editData?.timeline?.fonts || [];
+ const matchingFont = timelineFonts.find(font => {
+ const { full, base } = extractFontNames(font.src);
+ const requested = family.toLowerCase();
+ return full.toLowerCase() === requested || base.toLowerCase() === requested;
+ });
+
+ if (matchingFont) {
+ return { url: matchingFont.src, baseFontFamily, fontWeight };
+ }
+
+ // Fall back to built-in fonts from FONT_PATHS
+ const builtInPath = resolveFontPath(family);
+ if (builtInPath) {
+ return { url: builtInPath, baseFontFamily, fontWeight };
+ }
+
+ return null;
+ }
+
+ public override reconfigureAfterRestore(): void {
+ super.reconfigureAfterRestore();
+ this.reconfigure(this.clipConfiguration.asset as RichTextAsset);
+ }
+
+ private async reconfigure(richTextAsset: RichTextAsset): Promise {
+ try {
+ await this.prepareFontForAsset(richTextAsset, true);
+
+ for (const texture of this.cachedFrames.values()) {
+ texture.destroy();
+ }
+ this.cachedFrames.clear();
+ this.lastRenderedTime = -1;
+
+ if (this.textEngine) {
+ const canvasPayload = this.buildCanvasPayload(richTextAsset);
+ const { value: validated } = this.textEngine.validate(canvasPayload);
+ this.validatedAsset = validated;
+ }
+
+ if (this.textEngine && this.renderer) {
+ this.renderFrameSafe(this.getPlaybackTime());
+ }
+ } catch {
+ // Validation or font loading failed (e.g., incompatible merge field value).
+ // Keep rendering the last valid state — don't update validatedAsset.
}
}
+ private async ensureFontRegistered(richTextAsset: RichTextAsset): Promise {
+ if (!this.textEngine) return null;
+
+ const family = richTextAsset.font?.family;
+ if (!family) return null;
+
+ const resolved = this.resolveFont(family);
+ if (!resolved) return null;
+
+ const fontWeight = this.resolveFontWeight(richTextAsset, resolved.fontWeight);
+ await this.registerFont(resolved.baseFontFamily, fontWeight, { type: "url", path: resolved.url });
+ return resolved.url;
+ }
+
public override async load(): Promise {
await super.load();
const richTextAsset = this.clipConfiguration.asset as RichTextAsset;
try {
- const editData = this.edit.getEdit();
- this.targetFPS = editData?.output?.fps || 30;
-
- // Validate the rich-text asset schema (without width, height, customFonts)
const validationResult = RichTextAssetSchema.safeParse(richTextAsset);
if (!validationResult.success) {
- console.error("Rich-text asset validation failed:", validationResult.error);
this.createFallbackText(richTextAsset);
return;
}
- // Build canvas payload with dimensions and customFonts
- const canvasPayload = this.buildCanvasPayload(richTextAsset);
+ // Parse font info once, reuse throughout
+ const requestedFamily = richTextAsset.font?.family;
+ const fontInfo = requestedFamily ? parseFontFamily(requestedFamily) : undefined;
+ const canvasPayload = this.buildCanvasPayload(richTextAsset, fontInfo);
this.textEngine = (await createTextEngine({
width: canvasPayload.width,
height: canvasPayload.height,
- fps: this.targetFPS
+ fps: RichTextPlayer.PREVIEW_FPS
})) as TextEngine;
const { value: validated } = this.textEngine!.validate(canvasPayload);
this.validatedAsset = validated;
- const fontMap = this.createFontMapping();
-
this.canvas = document.createElement("canvas");
this.canvas.width = canvasPayload.width;
this.canvas.height = canvasPayload.height;
this.renderer = this.textEngine!.createRenderer(this.canvas);
-
- const timelineFonts = editData?.timeline?.fonts || [];
-
- if (timelineFonts.length > 0) {
- const requestedFamily = richTextAsset.font?.family;
- if (requestedFamily) {
- const matchingFont = timelineFonts.find(font => {
- const { full, base } = extractFontNames(font.src);
- const requested = requestedFamily.toLowerCase();
- return full.toLowerCase() === requested || base.toLowerCase() === requested;
- });
-
- if (matchingFont) {
- try {
- const fontDesc = {
- family: requestedFamily,
- weight: richTextAsset.font?.weight?.toString() || "400"
- };
- await this.textEngine!.registerFontFromUrl(matchingFont.src, fontDesc);
- } catch (error) {
- console.warn(`Failed to load font ${requestedFamily}:`, error);
- }
- }
- }
- } else if (richTextAsset.font?.family) {
- const fontFamily = richTextAsset.font.family;
- const fontPath = fontMap.get(fontFamily);
-
- if (fontPath) {
- try {
- const fontDesc = {
- family: richTextAsset.font.family,
- weight: richTextAsset.font.weight || "400"
- };
- await this.textEngine!.registerFontFromFile(fontPath, fontDesc);
- } catch (error) {
- console.warn(`Failed to load local font: ${fontFamily}`, error);
- }
- } else {
- console.warn(`Font ${fontFamily} not found in local assets. Available fonts:`, Array.from(fontMap.keys()));
- }
- }
+ await this.prepareFontForAsset(richTextAsset, false);
await this.renderFrame(0);
this.configureKeyframes();
- } catch (error) {
- console.error("Failed to initialize rich text player:", error);
-
+ this.loadComplete = true;
+ } catch {
this.cleanupResources();
-
this.createFallbackText(richTextAsset);
}
}
@@ -237,7 +340,7 @@ export class RichTextPlayer extends Player {
private async renderFrame(timeSeconds: number): Promise {
if (!this.textEngine || !this.renderer || !this.canvas || !this.validatedAsset) return;
- const cacheKey = Math.floor(timeSeconds * this.targetFPS);
+ const cacheKey = Math.floor(timeSeconds * RichTextPlayer.PREVIEW_FPS);
if (this.cachedFrames.has(cacheKey)) {
const cachedTexture = this.cachedFrames.get(cacheKey)!;
@@ -249,7 +352,9 @@ export class RichTextPlayer extends Player {
}
try {
- const ops = await this.textEngine.renderFrame(this.validatedAsset, timeSeconds);
+ // Pass clip duration so animations can cap their length appropriately
+ const clipDuration = this.getLength();
+ const ops = await this.textEngine.renderFrame(this.validatedAsset, timeSeconds, clipDuration);
const ctx = this.canvas.getContext("2d");
if (ctx) ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
@@ -326,13 +431,33 @@ export class RichTextPlayer extends Player {
this.contentContainer.addChild(fallbackText);
}
private renderFrameSafe(timeSeconds: number): void {
- if (this.isRendering) return;
+ if (this.isRendering) {
+ // Store pending time to render after current render completes (race condition fix)
+ this.pendingRenderTime = timeSeconds;
+
+ // Show nearest cached frame instead of skipping entirely
+ const cacheKey = Math.floor(timeSeconds * RichTextPlayer.PREVIEW_FPS);
+ const cachedTexture = this.cachedFrames.get(cacheKey);
+ if (cachedTexture && this.sprite && this.sprite.texture !== cachedTexture) {
+ this.sprite.texture = cachedTexture;
+ }
+ return;
+ }
this.isRendering = true;
+ this.pendingRenderTime = null;
+
this.renderFrame(timeSeconds)
.catch(err => console.error("Failed to render rich text frame:", err))
.finally(() => {
this.isRendering = false;
+
+ // Check if a render was requested while we were busy
+ if (this.pendingRenderTime !== null && this.pendingRenderTime !== timeSeconds) {
+ const pending = this.pendingRenderTime;
+ this.pendingRenderTime = null;
+ this.renderFrameSafe(pending);
+ }
});
}
@@ -340,16 +465,24 @@ export class RichTextPlayer extends Player {
super.update(deltaTime, elapsed);
// Reset render state on seek to prevent race conditions
- if (elapsed === 101) {
+ if (elapsed === Edit.SEEK_ELAPSED_MARKER) {
this.isRendering = false;
+ this.pendingRenderTime = null;
this.lastRenderedTime = -1;
}
+ if (!this.isActive()) {
+ return;
+ }
+
+ // Guard against rendering before load() completes (font may not be registered yet)
+ if (!this.loadComplete) {
+ return;
+ }
+
if (this.textEngine && this.renderer && !this.isRendering) {
- const currentTimeSeconds = this.getCurrentTime() / 1000;
- const editData = this.edit.getEdit();
- const targetFPS = editData?.output?.fps || 30;
- const frameInterval = 1 / targetFPS;
+ const currentTimeSeconds = this.getPlaybackTime();
+ const frameInterval = 1 / 60; // Always render at 60fps for smooth preview
if (Math.abs(currentTimeSeconds - this.lastRenderedTime) > frameInterval) {
this.renderFrameSafe(currentTimeSeconds);
@@ -359,13 +492,14 @@ export class RichTextPlayer extends Player {
public override dispose(): void {
super.dispose();
+ this.loadComplete = false;
for (const texture of this.cachedFrames.values()) {
texture.destroy();
}
this.cachedFrames.clear();
- if (this.texture && !this.cachedFrames.has(Math.floor(this.lastRenderedTime * this.targetFPS))) {
+ if (this.texture && !this.cachedFrames.has(Math.floor(this.lastRenderedTime * RichTextPlayer.PREVIEW_FPS))) {
this.texture.destroy();
}
this.texture = null;
@@ -396,11 +530,24 @@ export class RichTextPlayer extends Player {
};
}
+ public override getContentSize(): Size {
+ return {
+ width: this.clipConfiguration.width || this.canvas?.width || this.edit.size.width,
+ height: this.clipConfiguration.height || this.canvas?.height || this.edit.size.height
+ };
+ }
+
protected override getFitScale(): number {
return 1;
}
- protected override supportsEdgeResize(): boolean {
+ protected override getContainerScale(): Vector {
+ // Rich text should not be fit-scaled - use only the user-defined scale
+ const scale = this.getScale();
+ return { x: scale, y: scale };
+ }
+
+ public override supportsEdgeResize(): boolean {
return true;
}
@@ -423,7 +570,7 @@ export class RichTextPlayer extends Player {
const { value: validated } = this.textEngine.validate(canvasPayload);
this.validatedAsset = validated;
- this.renderFrameSafe(this.getCurrentTime() / 1000);
+ this.renderFrameSafe(this.getPlaybackTime());
}
public updateTextContent(newText: string): void {
@@ -443,11 +590,11 @@ export class RichTextPlayer extends Player {
this.lastRenderedTime = -1;
if (this.textEngine && this.renderer) {
- this.renderFrameSafe(this.getCurrentTime() / 1000);
+ this.renderFrameSafe(this.getPlaybackTime());
}
}
- private getCurrentTime(): number {
- return this.edit.playbackTime;
+ public getCacheSize(): number {
+ return this.cachedFrames.size;
}
}
diff --git a/src/components/canvas/players/shape-player.ts b/src/components/canvas/players/shape-player.ts
index 51582d56..df24c927 100644
--- a/src/components/canvas/players/shape-player.ts
+++ b/src/components/canvas/players/shape-player.ts
@@ -1,18 +1,17 @@
-import type { Edit } from "@core/edit";
+import type { Edit } from "@core/edit-session";
import { type Size } from "@layouts/geometry";
-import { type Clip } from "@schemas/clip";
-import { type ShapeAsset } from "@schemas/shape-asset";
+import { type ResolvedClip, type ShapeAsset } from "@schemas";
import * as pixiFilters from "pixi-filters";
import * as pixi from "pixi.js";
-import { Player } from "./player";
+import { Player, PlayerType } from "./player";
export class ShapePlayer extends Player {
private shape: pixi.Graphics | null;
private shapeBackground: pixi.Graphics | null;
- constructor(timeline: Edit, clipConfiguration: Clip) {
- super(timeline, clipConfiguration);
+ constructor(timeline: Edit, clipConfiguration: ResolvedClip) {
+ super(timeline, clipConfiguration, PlayerType.Shape);
this.shape = null;
this.shapeBackground = null;
@@ -93,10 +92,6 @@ export class ShapePlayer extends Player {
super.update(deltaTime, elapsed);
}
- public override draw(): void {
- super.draw();
- }
-
public override dispose(): void {
super.dispose();
diff --git a/src/components/canvas/players/svg-player.ts b/src/components/canvas/players/svg-player.ts
new file mode 100644
index 00000000..da5b8b51
--- /dev/null
+++ b/src/components/canvas/players/svg-player.ts
@@ -0,0 +1,199 @@
+import { Player, PlayerType } from "@canvas/players/player";
+import type { Edit } from "@core/edit-session";
+import { type Size } from "@layouts/geometry";
+import { SvgAssetSchema, type ResolvedClip, type SvgAsset } from "@schemas";
+import { initResvg, renderSvgAssetToPng, type CanvasSvgAsset } from "@shotstack/shotstack-canvas";
+import * as pixi from "pixi.js";
+
+import { createPlaceholderGraphic } from "./placeholder-graphic";
+
+const RESVG_WASM_URL = "https://unpkg.com/@resvg/resvg-wasm@2.6.2/index_bg.wasm";
+
+export class SvgPlayer extends Player {
+ private static resvgInitialized: boolean = false;
+ private static resvgInitPromise: Promise | null = null;
+ private texture: pixi.Texture | null = null;
+ private sprite: pixi.Sprite | null = null;
+ private renderedWidth: number = 0;
+ private renderedHeight: number = 0;
+ private pendingRender: Promise | null = null;
+
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.Svg);
+ }
+
+ private static async initializeResvg(): Promise {
+ if (SvgPlayer.resvgInitialized) {
+ return;
+ }
+
+ if (SvgPlayer.resvgInitPromise) {
+ await SvgPlayer.resvgInitPromise;
+ return;
+ }
+
+ SvgPlayer.resvgInitPromise = (async () => {
+ const response = await fetch(RESVG_WASM_URL);
+ const wasmBytes = await response.arrayBuffer();
+ await initResvg(wasmBytes);
+ SvgPlayer.resvgInitialized = true;
+ })();
+
+ await SvgPlayer.resvgInitPromise;
+ }
+
+ public override async load(): Promise {
+ await super.load();
+
+ let svgAsset = this.clipConfiguration.asset as SvgAsset;
+
+ if (svgAsset.src) {
+ const resolvedSrc = this.edit.resolveMergeFields(svgAsset.src);
+ if (resolvedSrc !== svgAsset.src) {
+ svgAsset = { ...svgAsset, src: resolvedSrc };
+ }
+ }
+
+ try {
+ const validationResult = SvgAssetSchema.safeParse(svgAsset);
+ if (!validationResult.success) {
+ this.createFallbackGraphic();
+ return;
+ }
+
+ await SvgPlayer.initializeResvg();
+ await this.doRender();
+ this.configureKeyframes();
+ } catch (error) {
+ console.error("Failed to render SVG asset:", error);
+ this.createFallbackGraphic();
+ }
+ }
+
+ public override async reloadAsset(): Promise {
+ await this.rerenderAtCurrentDimensions();
+ }
+
+ private createFallbackGraphic(): void {
+ const width = this.clipConfiguration.width || this.edit.size.width;
+ const height = this.clipConfiguration.height || this.edit.size.height;
+
+ const graphics = createPlaceholderGraphic(width, height);
+
+ this.renderedWidth = width;
+ this.renderedHeight = height;
+ this.contentContainer.addChild(graphics);
+ this.configureKeyframes();
+ }
+
+ public override update(deltaTime: number, elapsed: number): void {
+ super.update(deltaTime, elapsed);
+ }
+
+ public override dispose(): void {
+ super.dispose();
+
+ this.pendingRender = null;
+
+ if (this.sprite) {
+ this.sprite.destroy();
+ this.sprite = null;
+ }
+
+ if (this.texture) {
+ this.texture.destroy(true);
+ this.texture = null;
+ }
+ }
+
+ public override getSize(): Size {
+ if (this.clipConfiguration.width && this.clipConfiguration.height) {
+ return {
+ width: this.clipConfiguration.width,
+ height: this.clipConfiguration.height
+ };
+ }
+
+ return {
+ width: this.renderedWidth || this.edit.size.width,
+ height: this.renderedHeight || this.edit.size.height
+ };
+ }
+
+ public override getContentSize(): Size {
+ return {
+ width: this.renderedWidth || this.edit.size.width,
+ height: this.renderedHeight || this.edit.size.height
+ };
+ }
+
+ protected override getFitScale(): number {
+ return 1;
+ }
+
+ public override supportsEdgeResize(): boolean {
+ return true;
+ }
+
+ protected override onDimensionsChanged(): void {
+ this.rerenderAtCurrentDimensions();
+ }
+
+ private async rerenderAtCurrentDimensions(): Promise {
+ // Wait for any pending render to complete
+ if (this.pendingRender) {
+ await this.pendingRender;
+ }
+
+ // Clean up old sprite/texture
+ if (this.sprite) {
+ this.contentContainer.removeChild(this.sprite);
+ this.sprite.destroy();
+ this.sprite = null;
+ }
+ if (this.texture) {
+ this.texture.destroy(true);
+ this.texture = null;
+ }
+
+ // Start new render
+ this.pendingRender = this.doRender();
+ await this.pendingRender;
+ this.pendingRender = null;
+ }
+
+ private async doRender(): Promise {
+ const svgAsset = this.clipConfiguration.asset as SvgAsset;
+ const width = this.clipConfiguration.width || this.edit.size.width;
+ const height = this.clipConfiguration.height || this.edit.size.height;
+
+ const result = await renderSvgAssetToPng(svgAsset as CanvasSvgAsset, {
+ defaultWidth: width,
+ defaultHeight: height
+ });
+
+ this.renderedWidth = result.width;
+ this.renderedHeight = result.height;
+
+ const blob = new Blob([result.png as BlobPart], { type: "image/png" });
+ const imageUrl = URL.createObjectURL(blob);
+
+ const image = new Image();
+ image.src = imageUrl;
+
+ await new Promise((resolve, reject) => {
+ image.onload = () => resolve();
+ image.onerror = () => reject(new Error("Failed to load SVG image"));
+ });
+
+ URL.revokeObjectURL(imageUrl);
+
+ this.texture = pixi.Texture.from(image);
+ this.sprite = new pixi.Sprite(this.texture);
+ this.contentContainer.addChild(this.sprite);
+
+ if (this.clipConfiguration.width && this.clipConfiguration.height) {
+ this.applyFixedDimensions();
+ }
+ }
+}
diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts
index 199e932e..4c1ec3af 100644
--- a/src/components/canvas/players/text-player.ts
+++ b/src/components/canvas/players/text-player.ts
@@ -1,8 +1,9 @@
-import { Player } from "@canvas/players/player";
+import { Player, PlayerType } from "@canvas/players/player";
import { TextEditor } from "@canvas/text/text-editor";
-import { type Size } from "@layouts/geometry";
-import { type Clip } from "@schemas/clip";
-import { type TextAsset } from "@schemas/text-asset";
+import type { Edit } from "@core/edit-session";
+import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config";
+import { type Size, type Vector } from "@layouts/geometry";
+import { type ResolvedClip, type TextAsset } from "@schemas";
import * as pixiFilters from "pixi-filters";
import * as pixi from "pixi.js";
@@ -10,35 +11,36 @@ import * as pixi from "pixi.js";
* TextPlayer renders and manages editable text elements in the canvas
*/
export class TextPlayer extends Player {
+ private static loadedFonts = new Set();
+
private background: pixi.Graphics | null = null;
private text: pixi.Text | null = null;
private textEditor: TextEditor | null = null;
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.Text);
+ }
+
public override async load(): Promise {
await super.load();
const textAsset = this.clipConfiguration.asset as TextAsset;
- // Create background if specified
+ // Load the font before rendering
+ const fontFamily = textAsset.font?.family ?? "Open Sans";
+ await this.loadFont(fontFamily);
+
this.background = new pixi.Graphics();
- if (textAsset.background) {
- this.background.fillStyle = {
- color: textAsset.background.color,
- alpha: textAsset.background.opacity
- };
-
- this.background.rect(0, 0, textAsset.width ?? this.edit.size.width, textAsset.height ?? this.edit.size.height);
- this.background.fill();
- }
+ this.drawBackground();
// Create and style text
- this.text = new pixi.Text(textAsset.text, this.createTextStyle(textAsset));
+ this.text = new pixi.Text({ text: textAsset.text ?? "", style: this.createTextStyle(textAsset) });
// Position text according to alignment
this.positionText(textAsset);
- // Apply stroke filter if specified
- if (textAsset.stroke) {
+ // Apply stroke filter if specified with a positive width and color
+ if (textAsset.stroke?.width && textAsset.stroke.width > 0 && textAsset.stroke.color) {
const textStrokeFilter = new pixiFilters.OutlineFilter({
thickness: textAsset.stroke.width,
color: textAsset.stroke.color
@@ -59,6 +61,42 @@ export class TextPlayer extends Player {
super.update(deltaTime, elapsed);
}
+ public override reconfigureAfterRestore(): void {
+ super.reconfigureAfterRestore();
+ this.reconfigure();
+ }
+
+ private async reconfigure(): Promise {
+ const textAsset = this.clipConfiguration.asset as TextAsset;
+
+ // Load font if changed
+ const fontFamily = textAsset.font?.family ?? "Open Sans";
+ await this.loadFont(fontFamily);
+
+ // Update background
+ this.drawBackground();
+
+ // Update text content and style
+ if (this.text) {
+ this.text.text = textAsset.text ?? "";
+ this.text.style = this.createTextStyle(textAsset);
+
+ // Update stroke filter
+ if (textAsset.stroke?.width && textAsset.stroke.width > 0 && textAsset.stroke.color) {
+ const textStrokeFilter = new pixiFilters.OutlineFilter({
+ thickness: textAsset.stroke.width,
+ color: textAsset.stroke.color
+ });
+ this.text.filters = [textStrokeFilter];
+ } else {
+ this.text.filters = [];
+ }
+
+ // Reposition text based on alignment
+ this.positionText(textAsset);
+ }
+ }
+
public override dispose(): void {
super.dispose();
@@ -76,8 +114,8 @@ export class TextPlayer extends Player {
const textAsset = this.clipConfiguration.asset as TextAsset;
return {
- width: textAsset.width ?? this.edit.size.width,
- height: textAsset.height ?? this.edit.size.height
+ width: this.clipConfiguration.width ?? textAsset.width ?? this.edit.size.width,
+ height: this.clipConfiguration.height ?? textAsset.height ?? this.edit.size.height
};
}
@@ -85,14 +123,44 @@ export class TextPlayer extends Player {
return 1;
}
+ protected override getContainerScale(): Vector {
+ // Text should not be fit-scaled - use only the user-defined scale
+ // getScale() returns keyframe scale * getFitScale(), and we override getFitScale() to return 1
+ const scale = this.getScale();
+ return { x: scale, y: scale };
+ }
+
+ public override supportsEdgeResize(): boolean {
+ return true;
+ }
+
+ protected override onDimensionsChanged(): void {
+ this.drawBackground();
+
+ if (this.text) {
+ const textAsset = this.clipConfiguration.asset as TextAsset;
+ this.text.style.wordWrapWidth = this.getSize().width;
+ this.positionText(textAsset);
+ }
+ }
+
+ protected override applyFixedDimensions(): void {
+ // No-op: base implementation expects a Sprite with texture for fit/crop.
+ // Text uses Graphics + Text objects that size themselves via getSize().
+ }
+
private createTextStyle(textAsset: TextAsset): pixi.TextStyle {
+ const fontFamily = textAsset.font?.family ?? "Open Sans";
+ const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily);
+ const { width } = this.getSize();
+
return new pixi.TextStyle({
- fontFamily: textAsset.font?.family ?? "Open Sans",
+ fontFamily: baseFontFamily,
fontSize: textAsset.font?.size ?? 32,
fill: textAsset.font?.color ?? "#ffffff",
- fontWeight: (textAsset.font?.weight ?? "400").toString() as pixi.TextStyleFontWeight,
+ fontWeight: fontWeight.toString() as pixi.TextStyleFontWeight,
wordWrap: true,
- wordWrapWidth: textAsset.width ?? this.edit.size.width,
+ wordWrapWidth: width,
lineHeight: (textAsset.font?.lineHeight ?? 1) * (textAsset.font?.size ?? 32),
align: textAsset.alignment?.horizontal ?? "center"
});
@@ -103,8 +171,7 @@ export class TextPlayer extends Player {
const textAlignmentHorizontal = textAsset.alignment?.horizontal ?? "center";
const textAlignmentVertical = textAsset.alignment?.vertical ?? "center";
- const containerWidth = textAsset.width ?? this.edit.size.width;
- const containerHeight = textAsset.height ?? this.edit.size.height;
+ const { width: containerWidth, height: containerHeight } = this.getSize();
let textX = containerWidth / 2 - this.text.width / 2;
let textY = containerHeight / 2 - this.text.height / 2;
@@ -124,7 +191,44 @@ export class TextPlayer extends Player {
this.text.position.set(textX, textY);
}
- public updateTextContent(newText: string, initialConfig: Clip): void {
+ private drawBackground(): void {
+ const textAsset = this.clipConfiguration.asset as TextAsset;
+ if (!this.background || !textAsset.background || !textAsset.background.color) return;
+
+ const { width, height } = this.getSize();
+ this.background.clear();
+ this.background.fillStyle = {
+ color: textAsset.background.color,
+ alpha: textAsset.background.opacity ?? 1
+ };
+ this.background.rect(0, 0, width, height);
+ this.background.fill();
+ }
+
+ public updateTextContent(newText: string, initialConfig: ResolvedClip): void {
this.edit.updateTextContent(this, newText, initialConfig);
}
+
+ private async loadFont(fontFamily: string): Promise {
+ const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily);
+ const cacheKey = `${baseFontFamily}-${fontWeight}`;
+
+ if (TextPlayer.loadedFonts.has(cacheKey)) {
+ return;
+ }
+
+ const fontPath = resolveFontPath(fontFamily);
+ if (fontPath) {
+ const fontFace = new FontFace(baseFontFamily, `url(${fontPath})`, {
+ weight: fontWeight.toString()
+ });
+ await fontFace.load();
+ document.fonts.add(fontFace);
+ TextPlayer.loadedFonts.add(cacheKey);
+ }
+ }
+
+ public static resetFontCache(): void {
+ TextPlayer.loadedFonts.clear();
+ }
}
diff --git a/src/components/canvas/players/text-to-image-player.ts b/src/components/canvas/players/text-to-image-player.ts
new file mode 100644
index 00000000..410123b1
--- /dev/null
+++ b/src/components/canvas/players/text-to-image-player.ts
@@ -0,0 +1,72 @@
+import type { Edit } from "@core/edit-session";
+import { computeAiAssetNumber, isAiAsset } from "@core/shared/ai-asset-utils";
+import { type Size } from "@layouts/geometry";
+import type { ResolvedClip } from "@schemas";
+
+import { AiPendingOverlay } from "./ai-pending-overlay";
+import { Player, PlayerType } from "./player";
+
+export class TextToImagePlayer extends Player {
+ private aiOverlay: AiPendingOverlay | null = null;
+ private lastPrompt = "";
+
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.TextToImage);
+ }
+
+ public override async load(): Promise {
+ await super.load();
+
+ const { width, height } = this.getSize();
+
+ // Compute asset number from resolved state
+ const allClips = this.edit.getResolvedEdit()?.timeline.tracks.flatMap(t => t.clips) ?? [];
+ const assetNumber = computeAiAssetNumber(allClips, this.clipId ?? "");
+
+ // Extract resolved prompt and asset type
+ const { asset } = this.clipConfiguration;
+ const prompt = isAiAsset(asset) ? asset.prompt || "" : "";
+ const assetType = isAiAsset(asset) ? asset.type : "text-to-image";
+
+ this.aiOverlay = new AiPendingOverlay({
+ mode: "panel",
+ icon: "image",
+ width,
+ height,
+ assetNumber: assetNumber ?? undefined,
+ prompt,
+ assetType
+ });
+ this.contentContainer.addChild(this.aiOverlay.getContainer());
+
+ this.configureKeyframes();
+ }
+
+ public override update(deltaTime: number, elapsed: number): void {
+ super.update(deltaTime, elapsed);
+ const { width, height } = this.getSize();
+ this.aiOverlay?.resize(width, height);
+
+ // Sync prompt text only when it actually changes (e.g. via toolbar editing)
+ const { asset } = this.clipConfiguration;
+ const currentPrompt = isAiAsset(asset) ? asset.prompt || "" : "";
+ if (currentPrompt !== this.lastPrompt) {
+ this.aiOverlay?.updatePrompt(currentPrompt);
+ this.lastPrompt = currentPrompt;
+ }
+ }
+
+ public override getSize(): Size {
+ const asset = this.clipConfiguration.asset as { width?: number; height?: number };
+ return {
+ width: this.clipConfiguration.width ?? asset.width ?? this.edit.size.width,
+ height: this.clipConfiguration.height ?? asset.height ?? this.edit.size.height
+ };
+ }
+
+ public override dispose(): void {
+ this.aiOverlay?.dispose();
+ this.aiOverlay = null;
+ super.dispose();
+ }
+}
diff --git a/src/components/canvas/players/text-to-speech-player.ts b/src/components/canvas/players/text-to-speech-player.ts
new file mode 100644
index 00000000..a26c23be
--- /dev/null
+++ b/src/components/canvas/players/text-to-speech-player.ts
@@ -0,0 +1,25 @@
+import type { Edit } from "@core/edit-session";
+import { type Size } from "@layouts/geometry";
+import type { ResolvedClip } from "@schemas";
+
+import { Player, PlayerType } from "./player";
+
+export class TextToSpeechPlayer extends Player {
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.TextToSpeech);
+ }
+
+ public override async load(): Promise {
+ await super.load();
+ this.configureKeyframes();
+ }
+
+ public override update(deltaTime: number, elapsed: number): void {
+ super.update(deltaTime, elapsed);
+ this.getContainer().alpha = 0;
+ }
+
+ public override getSize(): Size {
+ return { width: 0, height: 0 };
+ }
+}
diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts
index cd19751a..f416d662 100644
--- a/src/components/canvas/players/video-player.ts
+++ b/src/components/canvas/players/video-player.ts
@@ -1,69 +1,40 @@
import { KeyframeBuilder } from "@animations/keyframe-builder";
-import type { Edit } from "@core/edit";
+import type { Edit } from "@core/edit-session";
import { type Size } from "@layouts/geometry";
-import { type Clip } from "@schemas/clip";
-import { type VideoAsset } from "@schemas/video-asset";
+import { type ResolvedClip, type VideoAsset } from "@schemas";
import * as pixi from "pixi.js";
-import { Player } from "./player";
+import { Player, PlayerType } from "./player";
export class VideoPlayer extends Player {
private texture: pixi.Texture | null;
private sprite: pixi.Sprite | null;
private isPlaying: boolean;
- private originalSize: Size | null;
private volumeKeyframeBuilder: KeyframeBuilder;
private syncTimer: number;
+ private activeSyncTimer: number;
private skipVideoUpdate: boolean;
- constructor(edit: Edit, clipConfiguration: Clip) {
- super(edit, clipConfiguration);
+ constructor(edit: Edit, clipConfiguration: ResolvedClip) {
+ super(edit, clipConfiguration, PlayerType.Video);
this.texture = null;
this.sprite = null;
this.isPlaying = false;
- this.originalSize = null;
const videoAsset = this.clipConfiguration.asset as VideoAsset;
this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1, this.getLength());
this.syncTimer = 0;
+ this.activeSyncTimer = 0;
this.skipVideoUpdate = false;
}
- /**
- * TODO: Add support for .mov and .webm files
- */
public override async load(): Promise {
await super.load();
-
- const videoAsset = this.clipConfiguration.asset as VideoAsset;
-
- const identifier = videoAsset.src;
-
- if (identifier.endsWith(".mov") || identifier.endsWith(".webm")) {
- throw new Error(`Video source '${videoAsset.src}' is not supported. .mov and .webm files are currently not supported.`);
- }
-
- const loadOptions: pixi.UnresolvedAsset = { src: identifier, data: { autoPlay: false, muted: false } };
- const texture = await this.edit.assetLoader.load>(identifier, loadOptions);
-
- const isValidVideoSource = texture?.source instanceof pixi.VideoSource;
- if (!isValidVideoSource) {
- throw new Error(`Invalid video source '${videoAsset.src}'.`);
- }
-
- this.texture = this.createCroppedTexture(texture);
- this.sprite = new pixi.Sprite(this.texture);
-
- this.contentContainer.addChild(this.sprite);
-
- if (this.clipConfiguration.width && this.clipConfiguration.height) {
- this.applyFixedDimensions();
- }
-
+ await this.loadVideo();
this.configureKeyframes();
}
@@ -82,13 +53,16 @@ export class VideoPlayer extends Player {
return;
}
+ // getPlaybackTime() returns seconds
const playbackTime = this.getPlaybackTime();
const shouldClipPlay = this.edit.isPlaying && this.isActive();
if (shouldClipPlay) {
if (!this.isPlaying) {
this.isPlaying = true;
- this.texture.source.resource.currentTime = playbackTime / 1000 + trim;
+ this.activeSyncTimer = 0;
+ // playbackTime is already in seconds
+ this.texture.source.resource.currentTime = playbackTime + trim;
this.texture.source.resource.play().catch(console.error);
}
@@ -96,11 +70,17 @@ export class VideoPlayer extends Player {
this.texture.source.resource.volume = this.getVolume();
}
- const desyncThreshold = 100;
- const shouldSync = Math.abs((this.texture.source.resource.currentTime - trim) * 1000 - playbackTime) > desyncThreshold;
-
- if (shouldSync) {
- this.texture.source.resource.currentTime = playbackTime / 1000 + trim;
+ // Rate-limit sync checks to once per second to prevent audio stuttering
+ this.activeSyncTimer += elapsed;
+ if (this.activeSyncTimer > 1000) {
+ this.activeSyncTimer = 0;
+ // Desync threshold: 0.3 seconds (300ms)
+ const desyncThreshold = 0.3;
+ // Both currentTime and playbackTime are in seconds
+ const drift = Math.abs(this.texture.source.resource.currentTime - trim - playbackTime);
+ if (drift > desyncThreshold) {
+ this.texture.source.resource.currentTime = playbackTime + trim;
+ }
}
}
@@ -109,27 +89,17 @@ export class VideoPlayer extends Player {
this.texture.source.resource.pause();
}
+ // When paused, sync every 100ms for scrubbing
const shouldSync = this.syncTimer > 100;
if (!this.edit.isPlaying && this.isActive() && shouldSync) {
this.syncTimer = 0;
- this.texture.source.resource.currentTime = playbackTime / 1000 + trim;
+ this.texture.source.resource.currentTime = playbackTime + trim;
}
}
- public override draw(): void {
- super.draw();
- }
-
public override dispose(): void {
super.dispose();
-
- this.sprite?.destroy();
- this.sprite = null;
-
- this.texture?.destroy();
- this.texture = null;
-
- this.originalSize = null;
+ this.disposeVideo();
}
public override getSize(): Size {
@@ -143,10 +113,95 @@ export class VideoPlayer extends Player {
return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 };
}
+ public override supportsEdgeResize(): boolean {
+ return true;
+ }
+
+ /** Reload the video asset when asset.src changes (e.g., merge field update) */
+ public override async reloadAsset(): Promise {
+ this.skipVideoUpdate = true;
+ this.disposeVideo();
+ await this.loadVideo();
+ this.isPlaying = false;
+ this.syncTimer = 0;
+ this.activeSyncTimer = 0;
+ this.skipVideoUpdate = false;
+ }
+
+ private async loadVideo(): Promise {
+ const videoAsset = this.clipConfiguration.asset as VideoAsset;
+ const { src } = videoAsset;
+
+ if (src.endsWith(".mov")) {
+ throw new Error(`Video source '${src}' is not supported. .mov files cannot be played in the browser. Please convert to .webm or .mp4 first.`);
+ }
+
+ const corsUrl = `${src}${src.includes("?") ? "&" : "?"}x-cors=1`;
+ const loadOptions: pixi.UnresolvedAsset = { src: corsUrl, data: { autoPlay: false, muted: false } };
+
+ // Use unique loader to create independent video element per player
+ // This prevents conflicts when multiple clips use the same video source
+ const texture = await this.edit.assetLoader.loadVideoUnique(corsUrl, loadOptions);
+
+ if (!texture || !(texture.source instanceof pixi.VideoSource)) {
+ throw new Error(`Invalid video source '${src}'.`);
+ }
+
+ // Fix alpha channel rendering for WebM VP9 videos (PixiJS 8 auto-detection is buggy)
+ texture.source.alphaMode = "no-premultiply-alpha";
+
+ this.texture = this.createCroppedTexture(texture);
+
+ // Ensure the video has at least one decoded frame before adding to render tree
+ // This prevents WebGL errors when GPU tries to upload uninitialized texture data
+ const video = (this.texture.source as pixi.VideoSource).resource;
+ if (video instanceof HTMLVideoElement && video.readyState < 2) {
+ await new Promise(resolve => {
+ const onReady = () => {
+ video.removeEventListener("loadeddata", onReady);
+ resolve();
+ };
+ video.addEventListener("loadeddata", onReady);
+ if (video.readyState >= 2) resolve();
+ });
+ }
+
+ this.sprite = new pixi.Sprite(this.texture);
+ this.contentContainer.addChild(this.sprite);
+ }
+
+ private disposeVideo(): void {
+ if (this.texture?.source?.resource) {
+ this.texture.source.resource.pause();
+ // Release video resource - each player owns its own video element
+ this.texture.source.resource.src = "";
+ this.texture.source.resource.load();
+ }
+ if (this.sprite) {
+ this.contentContainer.removeChild(this.sprite);
+ this.sprite.destroy();
+ this.sprite = null;
+ }
+ // Destroy the texture since we own it (created via loadVideoUnique)
+ if (this.texture) {
+ this.texture.destroy(true);
+ this.texture = null;
+ }
+ }
+
public getVolume(): number {
return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime());
}
+ public getCurrentDrift(): number {
+ if (!this.texture?.source?.resource) return 0;
+ const { trim = 0 } = this.clipConfiguration.asset as VideoAsset;
+ const videoTime = this.texture.source.resource.currentTime;
+ // getPlaybackTime() returns seconds, videoTime is also seconds
+ const playbackTime = this.getPlaybackTime();
+ return Math.abs(videoTime - trim - playbackTime);
+ }
+
private createCroppedTexture(texture: pixi.Texture): pixi.Texture {
const videoAsset = this.clipConfiguration.asset as VideoAsset;
@@ -157,6 +212,11 @@ export class VideoPlayer extends Player {
const originalWidth = texture.width;
const originalHeight = texture.height;
+ // Guard against uninitialized textures - skip cropping until GPU upload completes
+ if (originalWidth <= 0 || originalHeight <= 0) {
+ return texture;
+ }
+
const left = Math.floor((videoAsset.crop?.left ?? 0) * originalWidth);
const right = Math.floor((videoAsset.crop?.right ?? 0) * originalWidth);
const top = Math.floor((videoAsset.crop?.top ?? 0) * originalHeight);
diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts
index 5956a66b..2b04a65e 100644
--- a/src/components/canvas/shotstack-canvas.ts
+++ b/src/components/canvas/shotstack-canvas.ts
@@ -1,11 +1,28 @@
-import { Inspector } from "@canvas/system/inspector";
-import { Edit } from "@core/edit";
+import type { Player } from "@canvas/players/player";
+import { AlignmentGuides } from "@canvas/system/alignment-guides";
+import { createWebGLErrorOverlay } from "@canvas/webgl-error-overlay";
+import { Edit } from "@core/edit-session";
+import { InternalEvent } from "@core/events/edit-events";
+import { ms } from "@core/timing/types";
+import type { UIController } from "@core/ui/ui-controller";
+import { checkWebGLSupport } from "@core/webgl-support";
import { type Size } from "@layouts/geometry";
import { AudioLoadParser } from "@loaders/audio-load-parser";
import { FontLoadParser } from "@loaders/font-load-parser";
+import { SubtitleLoadParser } from "@loaders/subtitle-load-parser";
+import type { Timeline } from "@timeline/index";
import * as pixi from "pixi.js";
-import type { Timeline } from "../timeline/timeline";
+import "pixi.js/app";
+import "pixi.js/events";
+import "pixi.js/graphics";
+import "pixi.js/text";
+import "pixi.js/text-html";
+import "pixi.js/sprite-tiling";
+import "pixi.js/filters";
+import "pixi.js/mesh";
+
+const TRACK_Z_INDEX_PADDING = 100;
export class Canvas {
/** @internal */
@@ -13,31 +30,51 @@ export class Canvas {
private static extensionsRegistered = false;
- private readonly size: Size;
+ private viewportSize: Size = { width: 0, height: 0 };
/** @internal */
public readonly application: pixi.Application;
private readonly edit: Edit;
- private readonly inspector: Inspector;
+ /** Container for interactive overlays (handles, guides). Renders above content. @internal */
+ public readonly overlayContainer: pixi.Container;
+
+ private viewportContainer?: pixi.Container;
+ private editBackground?: pixi.Graphics;
+ private viewportMask?: pixi.Graphics;
private container?: pixi.Container;
private background?: pixi.Graphics;
private timeline?: Timeline;
+ private uiController: UIController | null = null;
+ private alignmentGuides: AlignmentGuides | null = null;
private minZoom = 0.1;
private maxZoom = 4;
- private currentZoom = 0.8;
+ private currentZoom = 1;
private onTickBound: (ticker: pixi.Ticker) => void;
+ private onBackgroundClickBound: (event: pixi.FederatedPointerEvent) => void;
+ private onWheelBound: (e: WheelEvent) => void;
+ private canvasRoot: HTMLDivElement | null = null;
- constructor(size: Size, edit: Edit) {
- this.size = size;
+ constructor(edit: Edit) {
this.application = new pixi.Application();
-
this.edit = edit;
- this.inspector = new Inspector();
-
+ this.overlayContainer = new pixi.Container();
+ this.overlayContainer.sortableChildren = true;
this.onTickBound = this.onTick.bind(this);
+ this.onBackgroundClickBound = this.onBackgroundClick.bind(this);
+ this.onWheelBound = this.onWheel.bind(this);
+
+ edit.setCanvas(this);
+ }
+
+ /**
+ * Set the UIController for this canvas.
+ * @internal Called by UIController constructor for auto-registration.
+ */
+ setUIController(controller: UIController): void {
+ this.uiController = controller;
}
public async load(): Promise {
@@ -46,110 +83,411 @@ export class Canvas {
throw new Error(`Shotstack canvas root element '${Canvas.CanvasSelector}' not found.`);
}
+ // Check WebGL support before attempting PixiJS initialization
+ const webglSupport = checkWebGLSupport();
+ if (!webglSupport.supported) {
+ createWebGLErrorOverlay(root);
+ return;
+ }
+
+ const rect = root.getBoundingClientRect();
+ this.viewportSize =
+ rect.width > 0 && rect.height > 0 ? { width: rect.width, height: rect.height } : { width: this.edit.size.width, height: this.edit.size.height };
+
this.registerExtensions();
this.container = new pixi.Container();
+
this.background = new pixi.Graphics();
- this.background.fillStyle = { color: "#424242" };
- this.background.rect(0, 0, this.size.width, this.size.height);
+ this.background.fillStyle = { color: "#F0F1F5" };
+ this.background.rect(0, 0, this.viewportSize.width, this.viewportSize.height);
this.background.fill();
+ this.viewportContainer = new pixi.Container();
+ this.viewportContainer.sortableChildren = true;
+
+ this.editBackground = new pixi.Graphics();
+ this.editBackground.fillStyle = { color: this.edit.getTimelineBackground() };
+ this.editBackground.rect(0, 0, this.edit.size.width, this.edit.size.height);
+ this.editBackground.fill();
+ this.viewportContainer.addChild(this.editBackground);
+
+ this.viewportMask = new pixi.Graphics();
+ this.viewportMask.rect(0, 0, this.edit.size.width, this.edit.size.height);
+ this.viewportMask.fill(0xffffff);
+ this.viewportContainer.addChild(this.viewportMask);
+ this.viewportContainer.setMask({ mask: this.viewportMask });
+
+ this.alignmentGuides = new AlignmentGuides(this.viewportContainer, this.edit.size.width, this.edit.size.height);
+
+ this.subscribeToEditEvents();
+
await this.configureApplication();
this.configureStage();
+ const tracks = this.edit.getTracks();
+ for (let trackIndex = 0; trackIndex < tracks.length; trackIndex += 1) {
+ for (const player of tracks[trackIndex]) {
+ this.addPlayerToTrack(player, trackIndex);
+ }
+ }
+
this.setupTouchHandling(root);
- this.edit.getContainer().scale = this.currentZoom;
+ this.zoomToFit();
root.appendChild(this.application.canvas);
+
+ // Auto-mount UIController to canvas root (toolbars sit inside canvas)
+ // mount() handles deferred positioning via double rAF
+ this.uiController?.mount(root);
}
private setupTouchHandling(root: HTMLDivElement): void {
- const edit = this.edit.getContainer();
-
- root.addEventListener(
- "wheel",
- (e: WheelEvent) => {
- e.preventDefault();
- e.stopPropagation();
-
- if (e.ctrlKey) {
- const scaleFactor = Math.exp(-e.deltaY / 100);
- const newZoom = this.currentZoom * scaleFactor;
- const oldZoom = this.currentZoom;
- this.currentZoom = Math.min(Math.max(newZoom, this.minZoom), this.maxZoom);
-
- const stageCenter = {
- x: this.application.canvas.width / 2,
- y: this.application.canvas.height / 2
- };
-
- const distanceFromCenter = {
- x: edit.position.x - stageCenter.x,
- y: edit.position.y - stageCenter.y
- };
-
- const zoomRatio = this.currentZoom / oldZoom;
-
- edit.position.x = stageCenter.x + distanceFromCenter.x * zoomRatio;
- edit.position.y = stageCenter.y + distanceFromCenter.y * zoomRatio;
-
- edit.scale.x = this.currentZoom;
- edit.scale.y = this.currentZoom;
- }
- },
- {
- passive: false,
- capture: true
- }
- );
+ this.canvasRoot = root;
+ root.addEventListener("wheel", this.onWheelBound, { passive: false, capture: true });
+ }
+
+ private onWheel(e: WheelEvent): void {
+ // Allow native scrolling inside toolbar popups (font picker, TTS voice list, etc.).
+ const target = e.target as HTMLElement;
+ if (target.closest(".ss-toolbar-popup") || target.closest(".ss-media-toolbar-popup") || target.closest(".ss-canvas-toolbar-popup")) {
+ return;
+ }
+
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (e.ctrlKey && this.viewportContainer) {
+ const scaleFactor = Math.exp(-e.deltaY / 100);
+ const newZoom = this.currentZoom * scaleFactor;
+ const oldZoom = this.currentZoom;
+ this.currentZoom = Math.min(Math.max(newZoom, this.minZoom), this.maxZoom);
+
+ const stageCenter = {
+ x: this.application.canvas.width / 2,
+ y: this.application.canvas.height / 2
+ };
+
+ const distanceFromCenter = {
+ x: this.viewportContainer.position.x - stageCenter.x,
+ y: this.viewportContainer.position.y - stageCenter.y
+ };
+
+ const zoomRatio = this.currentZoom / oldZoom;
+
+ this.viewportContainer.position.x = stageCenter.x + distanceFromCenter.x * zoomRatio;
+ this.viewportContainer.position.y = stageCenter.y + distanceFromCenter.y * zoomRatio;
+
+ this.viewportContainer.scale.x = this.currentZoom;
+ this.viewportContainer.scale.y = this.currentZoom;
+
+ this.syncContentTransforms();
+ }
}
public centerEdit(): void {
- if (!this.edit) {
+ if (!this.viewportContainer) {
return;
}
- const edit = this.edit.getContainer();
- edit.position = {
+ this.viewportContainer.position = {
x: this.application.canvas.width / 2 - (this.edit.size.width * this.currentZoom) / 2,
y: this.application.canvas.height / 2 - (this.edit.size.height * this.currentZoom) / 2
};
+
+ this.syncContentTransforms();
}
- public zoomToFit(): void {
- if (!this.edit) {
+ public zoomToFit(padding: number = 40): void {
+ if (!this.viewportContainer) {
return;
}
- const widthRatio = this.application.canvas.width / this.edit.size.width;
- const heightRatio = this.application.canvas.height / this.edit.size.height;
+ const availableWidth = this.viewportSize.width - padding * 2;
+ const availableHeight = this.viewportSize.height - padding * 2;
+
+ const widthRatio = availableWidth / this.edit.size.width;
+ const heightRatio = availableHeight / this.edit.size.height;
const idealZoom = Math.min(widthRatio, heightRatio);
this.currentZoom = Math.min(Math.max(idealZoom, this.minZoom), this.maxZoom);
- const edit = this.edit.getContainer();
- edit.scale.x = this.currentZoom;
- edit.scale.y = this.currentZoom;
+ this.viewportContainer.scale.x = this.currentZoom;
+ this.viewportContainer.scale.y = this.currentZoom;
+
+ this.centerEdit(); // Also syncs overlay and toolbar positions
+ }
+
+ public resize(): void {
+ const root = document.querySelector(Canvas.CanvasSelector);
+ if (!root) return;
+
+ const rect = root.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return;
+
+ this.viewportSize = { width: rect.width, height: rect.height };
+
+ // Resize Pixi renderer
+ this.application.renderer.resize(rect.width, rect.height);
- this.centerEdit();
+ // Redraw background
+ if (this.background) {
+ this.background.clear();
+ this.background.rect(0, 0, this.viewportSize.width, this.viewportSize.height);
+ this.background.fill({ color: 0xf0f1f5 });
+ }
+
+ // Update stage hit area
+ this.application.stage.hitArea = new pixi.Rectangle(0, 0, this.viewportSize.width, this.viewportSize.height);
+
+ // Reposition content and UI elements
+ this.zoomToFit();
}
public setZoom(zoom: number): void {
+ if (!this.viewportContainer) return;
+
this.currentZoom = Math.min(Math.max(zoom, this.minZoom), this.maxZoom);
- const edit = this.edit.getContainer();
- edit.scale.x = this.currentZoom;
- edit.scale.y = this.currentZoom;
+ this.viewportContainer.scale.x = this.currentZoom;
+ this.viewportContainer.scale.y = this.currentZoom;
+
+ this.syncContentTransforms();
+ }
+
+ public getZoom(): number {
+ return this.currentZoom;
}
+ /**
+ * Sync overlay container and toolbar positions after content transforms change.
+ * Single point of update for all position-dependent UI elements.
+ */
+ private syncContentTransforms(): void {
+ if (!this.viewportContainer) return;
+
+ this.overlayContainer.scale.x = this.currentZoom;
+ this.overlayContainer.scale.y = this.currentZoom;
+ this.overlayContainer.position.x = this.viewportContainer.position.x;
+ this.overlayContainer.position.y = this.viewportContainer.position.y;
+ this.uiController?.updateToolbarPositions();
+ }
+
+ /**
+ * Get the pixel bounds of the canvas content (edit area) within the viewport.
+ * Used for positioning toolbars adjacent to the canvas content.
+ * @internal
+ */
+ public getContentBounds(): { left: number; right: number; top: number; bottom: number } {
+ const scaledWidth = this.edit.size.width * this.currentZoom;
+ const scaledHeight = this.edit.size.height * this.currentZoom;
+ const posX = this.viewportContainer?.position.x ?? 0;
+ const posY = this.viewportContainer?.position.y ?? 0;
+
+ return {
+ left: posX,
+ right: posX + scaledWidth,
+ top: posY,
+ bottom: posY + scaledHeight
+ };
+ }
+
+ /** @internal */
public registerTimeline(timeline: Timeline): void {
this.timeline = timeline;
}
+ /**
+ * Get the viewport container for coordinate transforms.
+ * Used by selection handles, export coordinator, and other components
+ * that need to convert between viewport and world coordinates.
+ * @internal
+ */
+ public getViewportContainer(): pixi.Container {
+ if (!this.viewportContainer) {
+ throw new Error("Viewport container not initialized. Call load() first.");
+ }
+ return this.viewportContainer;
+ }
+
+ // ─── Alignment Guides ─────────────────────────────────────────────────────────
+
+ /**
+ * Show an alignment guide line.
+ * @internal
+ */
+ public showAlignmentGuide(type: "canvas" | "clip", axis: "x" | "y", position: number, bounds?: { start: number; end: number }): void {
+ if (!this.alignmentGuides) return;
+
+ if (type === "canvas") {
+ this.alignmentGuides.drawCanvasGuide(axis, position);
+ } else if (bounds) {
+ this.alignmentGuides.drawClipGuide(axis, position, bounds.start, bounds.end);
+ }
+ }
+
+ /**
+ * Clear all alignment guides.
+ * @internal
+ */
+ public clearAlignmentGuides(): void {
+ this.alignmentGuides?.clear();
+ }
+
+ // ─── Player Container Management ─────────────────────────────────────────────
+
+ /**
+ * Add a player to the appropriate track container.
+ * @internal Used by PlayerReconciler
+ */
+ public addPlayerToTrack(player: Player, trackIndex: number): void {
+ if (!this.viewportContainer) return;
+
+ const zIndex = 100000 - (trackIndex + 1) * TRACK_Z_INDEX_PADDING;
+ const trackContainerKey = `shotstack-track-${zIndex}`;
+ let trackContainer = this.viewportContainer.getChildByLabel(trackContainerKey, false);
+
+ if (!trackContainer) {
+ trackContainer = new pixi.Container({ label: trackContainerKey, zIndex });
+ this.viewportContainer.addChild(trackContainer);
+ }
+
+ trackContainer.addChild(player.getContainer());
+ }
+
+ /**
+ * Move a player's container between track containers.
+ * @internal Used by PlayerReconciler
+ */
+ public movePlayerBetweenTracks(player: Player, fromTrackIdx: number, toTrackIdx: number): void {
+ if (!this.viewportContainer || fromTrackIdx === toTrackIdx) return;
+
+ const fromZIndex = 100000 - (fromTrackIdx + 1) * TRACK_Z_INDEX_PADDING;
+ const toZIndex = 100000 - (toTrackIdx + 1) * TRACK_Z_INDEX_PADDING;
+
+ const fromTrackContainerKey = `shotstack-track-${fromZIndex}`;
+ const toTrackContainerKey = `shotstack-track-${toZIndex}`;
+
+ const fromTrackContainer = this.viewportContainer.getChildByLabel(fromTrackContainerKey, false);
+ let toTrackContainer = this.viewportContainer.getChildByLabel(toTrackContainerKey, false);
+
+ // Create new track container if it doesn't exist
+ if (!toTrackContainer) {
+ toTrackContainer = new pixi.Container({ label: toTrackContainerKey, zIndex: toZIndex });
+ this.viewportContainer.addChild(toTrackContainer);
+ }
+
+ // Move player container
+ if (fromTrackContainer) {
+ fromTrackContainer.removeChild(player.getContainer());
+ }
+ toTrackContainer.addChild(player.getContainer());
+
+ // Force re-sort
+ this.viewportContainer.sortDirty = true;
+ }
+
+ /**
+ * Remove an empty track container.
+ * @internal Used by PlayerReconciler
+ */
+ public removeTrackContainer(trackIndex: number): void {
+ if (!this.viewportContainer) return;
+
+ const zIndex = 100000 - (trackIndex + 1) * TRACK_Z_INDEX_PADDING;
+ const trackContainerKey = `shotstack-track-${zIndex}`;
+ const trackContainer = this.viewportContainer.getChildByLabel(trackContainerKey, false);
+
+ if (trackContainer) {
+ this.viewportContainer.removeChild(trackContainer);
+ }
+ }
+
+ /**
+ * Update the edit background and viewport mask when size changes.
+ * Called from Edit when output size is changed.
+ * @internal
+ */
+ public updateViewportForSize(width: number, height: number, backgroundColor: string): void {
+ if (this.editBackground) {
+ this.editBackground.clear();
+ this.editBackground.fillStyle = { color: backgroundColor };
+ this.editBackground.rect(0, 0, width, height);
+ this.editBackground.fill();
+ }
+
+ if (this.viewportMask) {
+ this.viewportMask.clear();
+ this.viewportMask.rect(0, 0, width, height);
+ this.viewportMask.fill(0xffffff);
+ }
+
+ this.alignmentGuides?.updateSize(width, height);
+ }
+
+ // ─────────────────────────────────────────────────────────────
+ // Event Subscriptions (Edit → Canvas visual sync)
+ // ─────────────────────────────────────────────────────────────
+
+ /**
+ * Subscribe to Edit events for visual synchronization.
+ * Canvas reacts to these events to update PIXI visuals.
+ */
+ private subscribeToEditEvents(): void {
+ const internalEvents = this.edit.getInternalEvents();
+ internalEvents.on(InternalEvent.PlayerAddedToTrack, this.onPlayerAddedToTrack);
+ internalEvents.on(InternalEvent.PlayerMovedBetweenTracks, this.onPlayerMovedBetweenTracks);
+ internalEvents.on(InternalEvent.PlayerRemovedFromTrack, this.onPlayerRemovedFromTrack);
+ internalEvents.on(InternalEvent.TrackContainerRemoved, this.onTrackContainerRemoved);
+ internalEvents.on(InternalEvent.ViewportSizeChanged, this.onViewportSizeChanged);
+ internalEvents.on(InternalEvent.ViewportNeedsZoomToFit, this.onViewportNeedsZoomToFit);
+ }
+
+ private onPlayerAddedToTrack = ({ player, trackIndex }: { player: Player; trackIndex: number }): void => {
+ this.addPlayerToTrack(player, trackIndex);
+ };
+
+ private onPlayerMovedBetweenTracks = ({
+ player,
+ fromTrackIndex,
+ toTrackIndex
+ }: {
+ player: Player;
+ fromTrackIndex: number;
+ toTrackIndex: number;
+ }): void => {
+ this.movePlayerBetweenTracks(player, fromTrackIndex, toTrackIndex);
+ };
+
+ private onPlayerRemovedFromTrack = ({ player, trackIndex }: { player: Player; trackIndex: number }): void => {
+ if (!this.viewportContainer) return;
+
+ const zIndex = 100000 - (trackIndex + 1) * TRACK_Z_INDEX_PADDING;
+ const trackContainerKey = `shotstack-track-${zIndex}`;
+ const trackContainer = this.viewportContainer.getChildByLabel(trackContainerKey, false);
+
+ if (trackContainer) {
+ trackContainer.removeChild(player.getContainer());
+ }
+ };
+
+ private onTrackContainerRemoved = ({ trackIndex }: { trackIndex: number }): void => {
+ this.removeTrackContainer(trackIndex);
+ };
+
+ private onViewportSizeChanged = ({ width, height, backgroundColor }: { width: number; height: number; backgroundColor: string }): void => {
+ this.updateViewportForSize(width, height, backgroundColor);
+ };
+
+ private onViewportNeedsZoomToFit = (): void => {
+ this.zoomToFit();
+ };
+
private registerExtensions(): void {
if (!Canvas.extensionsRegistered) {
pixi.extensions.add(new AudioLoadParser());
pixi.extensions.add(new FontLoadParser());
+ pixi.extensions.add(new SubtitleLoadParser());
Canvas.extensionsRegistered = true;
}
}
@@ -157,9 +495,18 @@ export class Canvas {
private async configureApplication(): Promise {
const options: Partial = {
background: "#000000",
- width: this.size.width,
- height: this.size.height,
- antialias: true
+ width: this.viewportSize.width,
+ height: this.viewportSize.height,
+ antialias: true,
+ powerPreference: "high-performance",
+ eventFeatures: {
+ globalMove: true, // Required for drag handling in SelectionHandles
+ move: true,
+ click: true,
+ wheel: true
+ },
+ gcActive: false,
+ manageImports: false
};
await this.application.init(options);
@@ -170,81 +517,86 @@ export class Canvas {
}
private onTick(ticker: pixi.Ticker): void {
- this.edit.update(ticker.deltaTime, ticker.deltaMS);
- this.edit.draw();
-
- this.inspector.fps = Math.ceil(ticker.FPS);
- this.inspector.playbackTime = this.edit.playbackTime;
- this.inspector.playbackDuration = this.edit.totalDuration;
- this.inspector.isPlaying = this.edit.isPlaying;
+ this.edit.update(ticker.deltaTime, ms(ticker.deltaMS));
- this.inspector.update(ticker.deltaTime, ticker.deltaMS);
- this.inspector.draw();
+ // Update canvas overlays (selection handles, guides, etc.)
+ this.uiController?.updateOverlays(ticker.deltaTime, ticker.deltaMS);
if (this.timeline) {
- this.timeline.update(ticker.deltaTime, ticker.deltaMS);
this.timeline.draw();
}
}
private configureStage(): void {
- if (!this.container || !this.background) {
+ if (!this.container || !this.background || !this.viewportContainer) {
throw new Error("Shotstack canvas container not set up.");
}
this.container.addChild(this.background);
- this.container.addChild(this.edit.getContainer());
- this.container.addChild(this.inspector.getContainer());
+ this.container.addChild(this.viewportContainer);
+ this.container.addChild(this.overlayContainer); // Above content for handles/guides
this.application.stage.addChild(this.container);
this.application.stage.eventMode = "static";
- this.application.stage.hitArea = new pixi.Rectangle(0, 0, this.size.width, this.size.height);
+ this.application.stage.hitArea = new pixi.Rectangle(0, 0, this.viewportSize.width, this.viewportSize.height);
this.background.eventMode = "static";
- this.background.on("pointerdown", this.onBackgroundClick.bind(this));
-
- this.application.stage.on("click", this.onClick.bind(this));
-
- this.edit.getContainer().position = {
- x: this.application.canvas.width / 2 - (this.edit.size.width * this.currentZoom) / 2,
- y: this.application.canvas.height / 2 - (this.edit.size.height * this.currentZoom) / 2
- };
- }
-
- private onClick(): void {
- this.edit.pause();
+ this.background.on("pointerdown", this.onBackgroundClickBound);
}
private onBackgroundClick(event: pixi.FederatedPointerEvent): void {
if (event.target === this.background) {
- this.edit.events.emit("canvas:background:clicked", {});
+ this.edit.getInternalEvents().emit(InternalEvent.CanvasBackgroundClicked);
}
}
+ /** @internal */
public pauseTicker(): void {
this.application.ticker.remove(this.onTickBound);
}
+ /** @internal */
public resumeTicker(): void {
this.application.ticker.add(this.onTickBound);
}
public dispose(): void {
+ // Unsubscribe from Edit internal events
+ const internalEvents = this.edit.getInternalEvents();
+ internalEvents.off(InternalEvent.PlayerAddedToTrack, this.onPlayerAddedToTrack);
+ internalEvents.off(InternalEvent.PlayerMovedBetweenTracks, this.onPlayerMovedBetweenTracks);
+ internalEvents.off(InternalEvent.PlayerRemovedFromTrack, this.onPlayerRemovedFromTrack);
+ internalEvents.off(InternalEvent.TrackContainerRemoved, this.onTrackContainerRemoved);
+ internalEvents.off(InternalEvent.ViewportSizeChanged, this.onViewportSizeChanged);
+ internalEvents.off(InternalEvent.ViewportNeedsZoomToFit, this.onViewportNeedsZoomToFit);
+
const root = document.querySelector(Canvas.CanvasSelector);
if (root && root.contains(this.application.canvas)) {
root.removeChild(this.application.canvas);
}
this.application.ticker.remove(this.onTickBound);
- this.application.stage.off("click", this.onClick, this);
- this.background?.off("pointerdown", this.onBackgroundClick, this);
+ this.background?.off("pointerdown", this.onBackgroundClickBound);
+
+ // Remove wheel listener from canvas root
+ this.canvasRoot?.removeEventListener("wheel", this.onWheelBound, { capture: true });
+ this.canvasRoot = null;
+
+ // Clean up alignment guides
+ this.alignmentGuides?.dispose();
+ this.alignmentGuides = null;
+
+ // Clean up viewport container elements
+ this.editBackground?.destroy();
+ this.viewportMask?.destroy();
+ this.viewportContainer?.destroy();
this.background?.destroy();
+ this.overlayContainer.destroy();
this.container?.destroy();
- this.inspector.dispose();
-
+ this.uiController = null;
this.application.destroy(true, { children: true, texture: true });
}
}
diff --git a/src/components/canvas/system/alignment-guides.ts b/src/components/canvas/system/alignment-guides.ts
new file mode 100644
index 00000000..54431069
--- /dev/null
+++ b/src/components/canvas/system/alignment-guides.ts
@@ -0,0 +1,91 @@
+import * as pixi from "pixi.js";
+
+const GUIDE_COLOR = 0xff00ff; // Bright magenta
+const GUIDE_WIDTH = 2;
+const DASH_LENGTH = 6;
+const GAP_LENGTH = 4;
+
+export class AlignmentGuides {
+ private graphics: pixi.Graphics;
+ private canvasWidth: number;
+ private canvasHeight: number;
+
+ constructor(container: pixi.Container, canvasWidth: number, canvasHeight: number) {
+ this.canvasWidth = canvasWidth;
+ this.canvasHeight = canvasHeight;
+
+ this.graphics = new pixi.Graphics();
+ this.graphics.zIndex = 999999; // Above everything
+ container.addChild(this.graphics);
+ }
+
+ clear(): void {
+ this.graphics.clear();
+ }
+
+ /**
+ * Draw a solid guide line for canvas alignment (extends full canvas width/height)
+ */
+ drawCanvasGuide(axis: "x" | "y", position: number): void {
+ this.graphics.strokeStyle = { width: GUIDE_WIDTH, color: GUIDE_COLOR };
+
+ if (axis === "x") {
+ // Vertical line at x position
+ this.graphics.moveTo(position, 0);
+ this.graphics.lineTo(position, this.canvasHeight);
+ } else {
+ // Horizontal line at y position
+ this.graphics.moveTo(0, position);
+ this.graphics.lineTo(this.canvasWidth, position);
+ }
+ this.graphics.stroke();
+ }
+
+ /**
+ * Draw a dotted guide line for clip-to-clip alignment (bounded to clip area)
+ */
+ drawClipGuide(axis: "x" | "y", position: number, start: number, end: number): void {
+ if (axis === "x") {
+ // Vertical dotted line
+ this.drawDashedLine(position, start, position, end);
+ } else {
+ // Horizontal dotted line
+ this.drawDashedLine(start, position, end, position);
+ }
+ }
+
+ private drawDashedLine(x1: number, y1: number, x2: number, y2: number): void {
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+ const length = Math.sqrt(dx * dx + dy * dy);
+ const dashCount = Math.floor(length / (DASH_LENGTH + GAP_LENGTH));
+
+ const unitX = dx / length;
+ const unitY = dy / length;
+
+ this.graphics.strokeStyle = { width: GUIDE_WIDTH, color: GUIDE_COLOR };
+
+ for (let i = 0; i < dashCount; i += 1) {
+ const startOffset = i * (DASH_LENGTH + GAP_LENGTH);
+ const endOffset = startOffset + DASH_LENGTH;
+
+ const startX = x1 + unitX * startOffset;
+ const startY = y1 + unitY * startOffset;
+ const endX = x1 + unitX * Math.min(endOffset, length);
+ const endY = y1 + unitY * Math.min(endOffset, length);
+
+ this.graphics.moveTo(startX, startY);
+ this.graphics.lineTo(endX, endY);
+ }
+ this.graphics.stroke();
+ }
+
+ updateSize(width: number, height: number): void {
+ this.canvasWidth = width;
+ this.canvasHeight = height;
+ }
+
+ dispose(): void {
+ this.graphics.destroy();
+ }
+}
diff --git a/src/components/canvas/system/inspector.ts b/src/components/canvas/system/inspector.ts
index b695231c..2628a72c 100644
--- a/src/components/canvas/system/inspector.ts
+++ b/src/components/canvas/system/inspector.ts
@@ -1,5 +1,18 @@
-import { Entity } from "@core/shared/entity";
-import * as pixi from "pixi.js";
+import type { Edit } from "@core/edit-session";
+
+// Chrome-specific Performance Memory API
+// https://developer.mozilla.org/en-US/docs/Web/API/Performance/memory
+interface PerformanceMemory {
+ totalJSHeapSize: number;
+ usedJSHeapSize: number;
+ jsHeapSizeLimit: number;
+}
+
+declare global {
+ interface Performance {
+ memory?: PerformanceMemory;
+ }
+}
type MemoryInfo = {
totalHeapSize?: number;
@@ -7,97 +20,229 @@ type MemoryInfo = {
heapSizeLimit?: number;
};
-export class Inspector extends Entity {
- private static readonly Width = 250;
- private static readonly Height = 100;
-
- public fps: number;
- public playbackTime: number;
- public playbackDuration: number;
- public isPlaying: boolean;
+interface MemorySnapshot {
+ timestamp: number;
+ jsHeapUsed: number;
+}
- private background: pixi.Graphics | null;
- private text: pixi.Text | null;
+/**
+ * Inspector displays performance stats as an HTML overlay.
+ * Shows FPS, memory, playback health, and clip statistics.
+ */
+export class Inspector {
+ private container: HTMLDivElement | null = null;
+ private animationFrameId: number | null = null;
+ private lastFrameTime = 0;
+ private edit: Edit;
+
+ // Cached DOM elements for efficient updates (avoid innerHTML every frame)
+ private fpsEl: HTMLElement | null = null;
+ private playbackEl: HTMLElement | null = null;
+ private frameStatsEl: HTMLElement | null = null;
+ private frameSparklineEl: HTMLElement | null = null;
+ private jankEl: HTMLElement | null = null;
+ private heapEl: HTMLElement | null = null;
+ private heapSparklineEl: HTMLElement | null = null;
+ private clipsEl: HTMLElement | null = null;
+
+ // History tracking
+ private historySamples: MemorySnapshot[] = [];
+ private readonly maxSamples = 20;
+ private lastSampleTime = 0;
+ private readonly sampleInterval = 500;
+
+ // Frame timing tracking
+ private frameTimes: number[] = [];
+ private readonly frameTimeWindow = 60;
+ private readonly jankThreshold = 33;
+
+ constructor(edit: Edit) {
+ this.edit = edit;
+ }
- constructor() {
- super();
+ /**
+ * Mount the inspector to a parent element.
+ * @param parent - The parent element to append the inspector to
+ */
+ mount(parent: HTMLElement): void {
+ this.container = document.createElement("div");
+ this.container.style.cssText = `
+ position: fixed;
+ top: 10px;
+ left: 10px;
+ background: rgba(30, 30, 30, 0.9);
+ color: #fff;
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
+ font-size: 11px;
+ line-height: 1.4;
+ padding: 12px;
+ border-radius: 6px;
+ z-index: 19;
+ pointer-events: none;
+ min-width: 320px;
+ backdrop-filter: blur(4px);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ `;
+
+ // Build structure once (innerHTML only at mount, not every frame)
+ this.container.innerHTML = `
+
FPS:
+
+
Jank:
+
+
MEMORY
+
JS Heap:
+
+
SYSTEM
+
+ `;
+
+ // Cache element references for efficient per-frame updates
+ this.fpsEl = this.container.querySelector("[data-fps]");
+ this.playbackEl = this.container.querySelector("[data-playback]");
+ this.frameStatsEl = this.container.querySelector("[data-frame-stats]");
+ this.frameSparklineEl = this.container.querySelector("[data-frame-sparkline]");
+ this.jankEl = this.container.querySelector("[data-jank]");
+ this.heapEl = this.container.querySelector("[data-heap]");
+ this.heapSparklineEl = this.container.querySelector("[data-heap-sparkline]");
+ this.clipsEl = this.container.querySelector("[data-clips]");
+
+ parent.appendChild(this.container);
+
+ // Start the update loop
+ this.startUpdateLoop();
+ }
- this.background = null;
- this.text = null;
+ private startUpdateLoop(): void {
+ const loop = (timestamp: number) => {
+ const deltaMS = timestamp - this.lastFrameTime;
+ this.lastFrameTime = timestamp;
- this.fps = 0;
- this.playbackTime = 0;
- this.playbackDuration = 0;
- this.isPlaying = false;
+ this.update(deltaMS);
+ this.animationFrameId = requestAnimationFrame(loop);
+ };
+ this.animationFrameId = requestAnimationFrame(loop);
}
- public override async load(): Promise {
- const background = new pixi.Graphics();
- background.fillStyle = { color: "#424242", alpha: 0.5 };
- background.rect(0, 0, Inspector.Width, Inspector.Height);
- background.fill();
-
- this.getContainer().addChild(background);
- this.background = background;
-
- const text = new pixi.Text();
- text.text = "";
- text.style = {
- fontFamily: "monospace",
- fontSize: 14,
- fill: "#ffffff",
- wordWrap: true,
- wordWrapWidth: Inspector.Width
- };
+ private update(deltaMS: number): void {
+ if (!this.container) return;
+
+ // Track frame timing
+ this.trackFrameTime(deltaMS);
+
+ // Sample memory at interval
+ const now = performance.now();
+ if (now - this.lastSampleTime > this.sampleInterval) {
+ const memoryInfo = this.getMemoryInfo();
+ this.addHistorySample({
+ timestamp: now,
+ jsHeapUsed: memoryInfo.usedHeapSize ?? 0
+ });
+ this.lastSampleTime = now;
+ }
- this.getContainer().addChild(text);
- this.text = text;
+ this.render();
}
- public override update(_: number, __: number): void {
- if (!this.text) {
- return;
+ private trackFrameTime(deltaMS: number): void {
+ this.frameTimes.push(deltaMS);
+ if (this.frameTimes.length > this.frameTimeWindow) {
+ this.frameTimes.shift();
}
+ }
- const memoryInfo = this.getMemoryInfo();
+ private getFrameStats(): { avgFrameTime: number; maxFrameTime: number; jankCount: number } {
+ if (this.frameTimes.length === 0) {
+ return { avgFrameTime: 0, maxFrameTime: 0, jankCount: 0 };
+ }
+ const avg = this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
+ const max = Math.max(...this.frameTimes);
+ const jankCount = this.frameTimes.filter(t => t > this.jankThreshold).length;
+ return { avgFrameTime: avg, maxFrameTime: max, jankCount };
+ }
- const stats = [
- `FPS: ${this.fps}`,
- `Playback: ${(this.playbackTime / 1000).toFixed(2)}/${(this.playbackDuration / 1000).toFixed(2)}`,
- `Playing: ${this.isPlaying}`,
- `Total Heap Size: ${memoryInfo.totalHeapSize ? `${this.bytesToMegabytes(memoryInfo.totalHeapSize)}MB` : "N/A"}`,
- `Used Heap Size: ${memoryInfo.usedHeapSize ? `${this.bytesToMegabytes(memoryInfo.usedHeapSize)}MB` : "N/A"}`,
- `Heap Size Limit: ${memoryInfo.heapSizeLimit ? `${this.bytesToMegabytes(memoryInfo.heapSizeLimit)}MB` : "N/A"}`
- ];
+ private getFrameTimeSparkline(): string {
+ if (this.frameTimes.length === 0) return "";
+ const chars = "▁▂▃▄▅▆▇█";
+ const maxScale = 50;
+ return this.frameTimes
+ .slice(-20)
+ .map(t => chars[Math.min(7, Math.floor((t / maxScale) * 7))])
+ .join("");
+ }
- this.text.text = stats.join("\n");
+ private addHistorySample(snapshot: MemorySnapshot): void {
+ this.historySamples.push(snapshot);
+ if (this.historySamples.length > this.maxSamples) {
+ this.historySamples.shift();
+ }
}
- public override draw(): void {}
+ private getJsHeapSparkline(): string {
+ if (this.historySamples.length === 0) return "";
+ const chars = "▁▂▃▄▅▆▇█";
+ const values = this.historySamples.map(s => s.jsHeapUsed);
+ const max = Math.max(...values);
+ const min = Math.min(...values);
+ const range = max - min || 1;
+ return values.map(v => chars[Math.min(7, Math.floor(((v - min) / range) * 7))]).join("");
+ }
- public override dispose(): void {
- this.background?.destroy();
- this.background = null;
+ private render(): void {
+ if (!this.container) return;
- this.text?.destroy();
- this.text = null;
+ const memoryInfo = this.getMemoryInfo();
+ const jsSparkline = this.getJsHeapSparkline();
+ const jsHeapMB = memoryInfo.usedHeapSize ? this.bytesToMegabytes(memoryInfo.usedHeapSize) : 0;
+ const jsLimitMB = memoryInfo.heapSizeLimit ? this.bytesToMegabytes(memoryInfo.heapSizeLimit) : 0;
+
+ const frameStats = this.getFrameStats();
+ const frameSparkline = this.getFrameTimeSparkline();
+
+ // Calculate FPS from frame times
+ const fps = frameStats.avgFrameTime > 0 ? Math.round(1000 / frameStats.avgFrameTime) : 0;
+
+ // Get playback data from Edit
+ const { playbackTime, isPlaying } = this.edit;
+ const duration = this.edit.totalDuration;
+
+ // Count clips
+ const tracks = this.edit.getTracks();
+ const clipCount = tracks.reduce((sum, track) => sum + track.length, 0);
+ const trackCount = tracks.length;
+
+ // Update cached elements with textContent (no DOM tree recreation)
+ if (this.fpsEl) this.fpsEl.textContent = String(fps);
+ if (this.playbackEl) this.playbackEl.textContent = `${isPlaying ? "▶" : "⏸"} ${playbackTime.toFixed(1)}s / ${duration.toFixed(1)}s`;
+ if (this.frameStatsEl) this.frameStatsEl.textContent = `Frame: ${frameStats.avgFrameTime.toFixed(0)}/${frameStats.maxFrameTime.toFixed(0)}ms`;
+ if (this.frameSparklineEl) this.frameSparklineEl.textContent = frameSparkline;
+ if (this.jankEl) this.jankEl.textContent = String(frameStats.jankCount);
+ if (this.heapEl) this.heapEl.textContent = `${jsHeapMB}MB / ${jsLimitMB}MB`;
+ if (this.heapSparklineEl) this.heapSparklineEl.textContent = jsSparkline;
+ if (this.clipsEl) this.clipsEl.textContent = `Clips: ${clipCount} Tracks: ${trackCount}`;
}
private getMemoryInfo(): MemoryInfo {
const memoryInfo: MemoryInfo = {};
-
- if (!("memory" in performance)) {
+ if (!performance.memory) {
return memoryInfo;
}
-
- memoryInfo.totalHeapSize = (performance.memory as any).totalJSHeapSize;
- memoryInfo.usedHeapSize = (performance.memory as any).usedJSHeapSize;
- memoryInfo.heapSizeLimit = (performance.memory as any).jsHeapSizeLimit;
-
+ memoryInfo.totalHeapSize = performance.memory.totalJSHeapSize;
+ memoryInfo.usedHeapSize = performance.memory.usedJSHeapSize;
+ memoryInfo.heapSizeLimit = performance.memory.jsHeapSizeLimit;
return memoryInfo;
}
private bytesToMegabytes(bytes: number): number {
return Math.round(bytes / 1024 / 1024);
}
+
+ dispose(): void {
+ if (this.animationFrameId !== null) {
+ cancelAnimationFrame(this.animationFrameId);
+ this.animationFrameId = null;
+ }
+ this.container?.remove();
+ this.container = null;
+ }
}
diff --git a/src/components/canvas/text/text-cursor.ts b/src/components/canvas/text/text-cursor.ts
index bdcbe2a4..a4ccf859 100644
--- a/src/components/canvas/text/text-cursor.ts
+++ b/src/components/canvas/text/text-cursor.ts
@@ -1,5 +1,4 @@
-import { type Clip } from "@schemas/clip";
-import { type TextAsset } from "@schemas/text-asset";
+import { type Clip, type TextAsset } from "@schemas";
import * as pixi from "pixi.js";
type TextCursorOptions = {
diff --git a/src/components/canvas/text/text-editor.ts b/src/components/canvas/text/text-editor.ts
index 5b31b702..c456d0ca 100644
--- a/src/components/canvas/text/text-editor.ts
+++ b/src/components/canvas/text/text-editor.ts
@@ -1,6 +1,5 @@
import type { TextPlayer } from "@canvas/players/text-player";
-import { type Clip } from "@schemas/clip";
-import { type TextAsset } from "@schemas/text-asset";
+import { type ResolvedClip, type TextAsset } from "@schemas";
import * as pixi from "pixi.js";
import { TextCursor } from "./text-cursor";
@@ -26,7 +25,7 @@ export class TextEditor {
private parent: TextPlayer;
private targetText: pixi.Text;
- private clipConfig: Clip;
+ private clipConfig: ResolvedClip;
private isEditing: boolean = false;
private lastClickTime: number = 0;
@@ -36,7 +35,7 @@ export class TextEditor {
private textInputHandler: TextInputHandler | null = null;
private outsideClickHandler: ((e: MouseEvent) => void) | null = null;
- constructor(parent: TextPlayer, targetText: pixi.Text, clipConfig: Clip) {
+ constructor(parent: TextPlayer, targetText: pixi.Text, clipConfig: ResolvedClip) {
this.parent = parent;
this.targetText = targetText;
this.clipConfig = clipConfig;
@@ -70,7 +69,7 @@ export class TextEditor {
this.isEditing = true;
}
- private stopEditing(saveChanges = false, initialConfig?: Clip): void {
+ private stopEditing(saveChanges = false, initialConfig?: ResolvedClip): void {
if (!this.isEditing) return;
let newText = "";
@@ -116,7 +115,7 @@ export class TextEditor {
this.lastClickTime = currentTime;
};
- private setupOutsideClickHandler(initialConfig: Clip): void {
+ private setupOutsideClickHandler(initialConfig: ResolvedClip): void {
this.outsideClickHandler = (e: MouseEvent) => {
const container = this.parent.getContainer();
const bounds = container.getBounds();
diff --git a/src/components/canvas/text/text-input-handler.ts b/src/components/canvas/text/text-input-handler.ts
index 721f6f02..39520b92 100644
--- a/src/components/canvas/text/text-input-handler.ts
+++ b/src/components/canvas/text/text-input-handler.ts
@@ -134,7 +134,7 @@ export class TextInputHandler {
position: "absolute",
opacity: "0.01",
pointerEvents: "none",
- zIndex: "999",
+ zIndex: "18",
left: "0px",
top: "0px",
width: "1px",
diff --git a/src/components/canvas/webgl-error-overlay.ts b/src/components/canvas/webgl-error-overlay.ts
new file mode 100644
index 00000000..2c37b5ac
--- /dev/null
+++ b/src/components/canvas/webgl-error-overlay.ts
@@ -0,0 +1,56 @@
+/**
+ * WebGL Error Overlay
+ *
+ * Displays a user-friendly message when the browser doesn't support WebGL.
+ */
+
+export interface WebGLErrorOptions {
+ /** Custom title (default: "Browser Not Supported") */
+ title?: string;
+ /** Custom message */
+ message?: string;
+}
+
+const DEFAULT_TITLE = "Browser Not Supported";
+const DEFAULT_MESSAGE = "Please try a different browser or enable hardware acceleration in your browser settings.";
+
+/**
+ * Creates and displays a WebGL error overlay in the specified container.
+ */
+export function createWebGLErrorOverlay(container: HTMLElement, options?: WebGLErrorOptions): HTMLElement {
+ const title = options?.title ?? DEFAULT_TITLE;
+ const message = options?.message ?? DEFAULT_MESSAGE;
+
+ const overlay = document.createElement("div");
+ overlay.className = "ss-webgl-error-overlay";
+
+ const content = document.createElement("div");
+ content.className = "ss-webgl-error-content";
+
+ // Monitor icon - informative, not alarming
+ const icon = document.createElement("div");
+ icon.className = "ss-webgl-error-icon";
+ icon.innerHTML = ``;
+
+ // Title
+ const titleEl = document.createElement("h2");
+ titleEl.className = "ss-webgl-error-title";
+ titleEl.textContent = title;
+
+ // Message
+ const messageEl = document.createElement("p");
+ messageEl.className = "ss-webgl-error-message";
+ messageEl.textContent = message;
+
+ content.appendChild(icon);
+ content.appendChild(titleEl);
+ content.appendChild(messageEl);
+ overlay.appendChild(content);
+ container.appendChild(overlay);
+
+ return overlay;
+}
diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts
new file mode 100644
index 00000000..83190a32
--- /dev/null
+++ b/src/components/timeline/components/clip/clip-component.ts
@@ -0,0 +1,372 @@
+import { getAiAssetTypeLabel, isAiAsset, type ResolvedClipWithId } from "@core/shared/ai-asset-utils";
+import type { ResolvedClip } from "@schemas";
+
+import { AI_ICON_LINE_PATHS, AI_ASSET_ICON_MAP } from "../../../canvas/players/ai-icons";
+import { formatClipErrorMessage } from "../../error-messages";
+import type { ClipState, ClipRenderer } from "../../timeline.types";
+
+/** Reference to an attached luma clip */
+interface LumaRef {
+ trackIndex: number;
+ clipIndex: number;
+}
+
+export interface ClipComponentOptions {
+ showBadges: boolean;
+ onSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void;
+ getRenderer: (type: string) => ClipRenderer | undefined;
+ /** Get error state for a clip (if asset failed to load) */
+ getClipError?: (trackIndex: number, clipIndex: number) => { error: string; assetType: string } | null;
+ /** Reference to attached luma (if this clip has a mask) */
+ attachedLuma?: LumaRef;
+ /** Callback when mask badge is clicked - passes the CONTENT clip indices */
+ onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void;
+ /** Pre-computed AI asset numbers (map of clip ID to number) */
+ aiAssetNumbers: Map;
+}
+
+/** Renders a single clip element */
+export class ClipComponent {
+ public readonly element: HTMLElement;
+ private readonly options: ClipComponentOptions;
+ private currentState: ClipState | null = null;
+ private currentLumaRef: LumaRef | undefined = undefined;
+ private maskBadge: HTMLElement | null = null;
+ private errorBadge: HTMLElement | null = null;
+ private currentError: { error: string; assetType: string } | null = null;
+ private needsUpdate = true;
+
+ // Cached element references (avoid querySelector every frame)
+ private iconEl: HTMLElement | null = null;
+ private labelEl: HTMLElement | null = null;
+ private badgeEl: HTMLElement | null = null;
+
+ constructor(clip: ClipState, options: ClipComponentOptions) {
+ this.element = document.createElement("div");
+ this.element.className = "ss-clip";
+ this.options = options;
+ this.buildElement(clip);
+ this.currentState = clip;
+ this.element.dataset["clipId"] = clip.id;
+ }
+
+ private buildElement(clip: ClipState): void {
+ // Content container
+ const content = document.createElement("div");
+ content.className = "ss-clip-content";
+
+ // Icon for asset type (cache reference)
+ this.iconEl = document.createElement("span");
+ this.iconEl.className = "ss-clip-icon";
+ content.appendChild(this.iconEl);
+
+ // Label (cache reference)
+ this.labelEl = document.createElement("span");
+ this.labelEl.className = "ss-clip-label";
+ content.appendChild(this.labelEl);
+
+ this.element.appendChild(content);
+
+ // Timing badge (cache reference)
+ if (this.options.showBadges) {
+ this.badgeEl = document.createElement("div");
+ this.badgeEl.className = "ss-clip-badge";
+ this.element.appendChild(this.badgeEl);
+ }
+
+ // Resize handles
+ const leftHandle = document.createElement("div");
+ leftHandle.className = "ss-clip-resize-handle left";
+ this.element.appendChild(leftHandle);
+
+ const rightHandle = document.createElement("div");
+ rightHandle.className = "ss-clip-resize-handle right";
+ this.element.appendChild(rightHandle);
+
+ // Set up interaction handlers
+ this.setupInteraction(clip);
+ }
+
+ private setupInteraction(clip: ClipState): void {
+ this.element.addEventListener("pointerdown", e => {
+ // Check if clicking on resize handle
+ const target = e.target as HTMLElement;
+ if (target.classList.contains("ss-clip-resize-handle")) {
+ // Resize will be handled by InteractionController
+ return;
+ }
+
+ // Select clip
+ const addToSelection = e.shiftKey || e.ctrlKey || e.metaKey;
+ this.options.onSelect(clip.trackIndex, clip.clipIndex, addToSelection);
+ });
+ }
+
+ public draw(): void {
+ if (!this.needsUpdate || !this.currentState) return;
+ this.needsUpdate = false;
+
+ const clip = this.currentState;
+ const { config } = clip;
+ const assetType = this.getAssetType(config);
+
+ const prevAssetType = this.element.dataset["assetType"];
+ if (prevAssetType && prevAssetType !== assetType) {
+ this.clearRendererStyles();
+ }
+
+ // Update data attributes
+ this.element.dataset["assetType"] = assetType;
+ this.element.dataset["trackIndex"] = String(clip.trackIndex);
+ this.element.dataset["clipIndex"] = String(clip.clipIndex);
+
+ // Update CSS custom properties for positioning
+ this.element.style.setProperty("--clip-start", String(config.start));
+ this.element.style.setProperty("--clip-length", String(config.length));
+
+ // Update visual state classes
+ this.element.classList.toggle("selected", clip.visualState === "selected");
+ this.element.classList.toggle("dragging", clip.visualState === "dragging");
+ this.element.classList.toggle("resizing", clip.visualState === "resizing");
+
+ // Update icon (using cached reference)
+ if (this.iconEl && this.iconEl.dataset["assetType"] !== assetType) {
+ this.iconEl.dataset["assetType"] = assetType;
+ const aiIconType = AI_ASSET_ICON_MAP[assetType];
+ if (aiIconType) {
+ this.iconEl.innerHTML = ``;
+ } else {
+ this.iconEl.textContent = this.getAssetIcon(assetType);
+ }
+ }
+
+ // Update label (using cached reference)
+ if (this.labelEl) {
+ this.labelEl.textContent = this.getClipLabel(config);
+ }
+
+ // Update timing badge (using cached reference)
+ if (this.badgeEl) {
+ this.updateBadge(this.badgeEl, clip.timingIntent);
+ }
+
+ // Update mask badge (show if clip has attached luma)
+ this.updateMaskBadge();
+
+ // Update error state (show if asset failed to load)
+ this.updateErrorState();
+
+ // Apply custom renderer if available
+ const renderer = this.options.getRenderer(assetType);
+ if (renderer) {
+ renderer.render(config, this.element);
+ }
+ }
+
+ /** Show/hide mask badge based on attached luma */
+ private updateMaskBadge(): void {
+ if (this.currentLumaRef && this.currentState) {
+ // Create badge if it doesn't exist
+ if (!this.maskBadge) {
+ this.maskBadge = document.createElement("div");
+ this.maskBadge.className = "ss-clip-mask-badge";
+ this.maskBadge.textContent = "◐";
+ this.maskBadge.title = "Luma mask attached - click to detach";
+ this.maskBadge.addEventListener("click", e => {
+ e.stopPropagation();
+ // Pass the CONTENT clip indices (this clip), not the luma indices
+ if (this.currentState && this.options.onMaskClick) {
+ this.options.onMaskClick(this.currentState.trackIndex, this.currentState.clipIndex);
+ }
+ });
+ this.element.appendChild(this.maskBadge);
+ }
+ this.maskBadge.style.display = "flex";
+ } else if (this.maskBadge) {
+ // Hide badge if no luma attached
+ this.maskBadge.style.display = "none";
+ }
+ }
+
+ /** Show/hide error state based on clip error */
+ private updateErrorState(): void {
+ const error = this.currentState ? this.options.getClipError?.(this.currentState.trackIndex, this.currentState.clipIndex) : null;
+
+ // Compare by value, not reference (getClipError returns new object each call)
+ const hasNewError = error && (!this.currentError || error.error !== this.currentError.error || error.assetType !== this.currentError.assetType);
+
+ if (hasNewError) {
+ this.currentError = error;
+ this.element.classList.add("ss-clip--error");
+
+ // Create error badge if needed
+ if (!this.errorBadge) {
+ this.errorBadge = document.createElement("div");
+ this.errorBadge.className = "ss-clip-error-badge";
+ this.errorBadge.textContent = "⚠";
+ this.element.appendChild(this.errorBadge);
+ }
+
+ // User-friendly tooltip
+ this.errorBadge.title = formatClipErrorMessage(error.error, error.assetType);
+ } else if (!error && this.currentError) {
+ // Clear error state
+ this.currentError = null;
+ this.element.classList.remove("ss-clip--error");
+ this.errorBadge?.remove();
+ this.errorBadge = null;
+ }
+ }
+
+ public dispose(): void {
+ // Call dispose on custom renderer if exists
+ if (this.currentState) {
+ const assetType = this.getAssetType(this.currentState.config);
+ const renderer = this.options.getRenderer(assetType);
+ if (renderer?.dispose) {
+ renderer.dispose(this.element);
+ }
+ }
+
+ this.element.remove();
+ }
+
+ /** Clear any styles that renderers might have applied */
+ private clearRendererStyles(): void {
+ this.element.classList.remove("ss-clip--thumbnails", "ss-clip--loading-thumbnails");
+ this.element.style.backgroundImage = "";
+ this.element.style.backgroundPosition = "";
+ this.element.style.backgroundSize = "";
+ this.element.style.backgroundRepeat = "";
+ }
+
+ /** Update clip state and mark for re-render */
+ public updateClip(clip: ClipState, attachedLuma?: LumaRef): void {
+ // Only mark dirty if data actually changed (reference equality works due to TimelineStateManager caching)
+ const clipChanged = clip !== this.currentState;
+ const lumaChanged = attachedLuma !== this.currentLumaRef;
+
+ if (!clipChanged && !lumaChanged) {
+ return; // Nothing changed, skip update
+ }
+
+ this.currentState = clip;
+ this.currentLumaRef = attachedLuma;
+ this.needsUpdate = true;
+ }
+
+ private updateBadge(badge: HTMLElement, timingIntent: ClipState["timingIntent"]): void {
+ let icon = "";
+ let intent = "fixed";
+ let tooltip = "";
+
+ if (timingIntent.length === "auto") {
+ icon = "↔";
+ intent = "auto";
+ tooltip = "Auto length (from asset)";
+ } else if (timingIntent.length === "end") {
+ icon = "→";
+ intent = "end";
+ tooltip = "Extends to timeline end";
+ }
+
+ /* eslint-disable no-param-reassign -- Intentional DOM element mutation */
+ badge.textContent = icon;
+ badge.dataset["intent"] = intent;
+ badge.title = tooltip;
+ /* eslint-enable no-param-reassign */
+ }
+
+ private getAssetType(clip: ResolvedClip): string {
+ const { asset } = clip;
+ if (!asset) return "unknown";
+ return asset.type || "unknown";
+ }
+
+ private getAssetIcon(type: string): string {
+ const icons: Record = {
+ video: "▶",
+ image: "⛰",
+ audio: "♪",
+ text: "T",
+ "rich-text": "T",
+ shape: "◇",
+ caption: "≡",
+ html: "<>",
+ luma: "◐",
+ svg: "◇"
+ };
+ return icons[type] ?? "•";
+ }
+
+ private getClipLabel(clip: ResolvedClip): string {
+ const { asset } = clip;
+ if (!asset) return "Clip";
+
+ // Check if clip has ID (needed for AI assets)
+ const clipWithId = clip as ResolvedClipWithId;
+ const hasClipId = "id" in clip && typeof clipWithId.id === "string";
+
+ // AI assets get numbered labels with prompt preview
+ if (isAiAsset(asset)) {
+ const number = hasClipId ? (this.options.aiAssetNumbers.get(clipWithId.id) ?? null) : null;
+ const typeLabel = getAiAssetTypeLabel(asset.type);
+ const prompt = asset.prompt || "";
+
+ if (number && prompt) {
+ const truncatedPrompt = prompt.substring(0, 40);
+ return `${typeLabel} ${number}: ${truncatedPrompt}${prompt.length > 40 ? "..." : ""}`;
+ }
+ if (number) {
+ return `${typeLabel} ${number}`;
+ }
+ // Fallback if number computation fails
+ return `${typeLabel} Asset`;
+ }
+
+ // Get asset type for other checks
+ const assetType = "type" in asset && typeof (asset as { type: unknown }).type === "string" ? (asset as { type: string }).type : undefined;
+
+ // SVG special case
+ if (assetType === "svg") {
+ return "Shape";
+ }
+
+ if ("src" in asset && typeof asset.src === "string") {
+ const { src } = asset;
+ const filename = src.split("/").pop() || src;
+ return filename.split("?")[0];
+ }
+
+ if ("text" in asset && typeof asset.text === "string") {
+ return asset.text.substring(0, 20) + (asset.text.length > 20 ? "..." : "");
+ }
+
+ return assetType || "Clip";
+ }
+
+ public getState(): ClipState | null {
+ return this.currentState;
+ }
+
+ // ========== Luma Drop Target State ==========
+
+ /** Set this clip as a luma drop target (during luma drag) */
+ public setLumaDropTarget(active: boolean): void {
+ this.element.classList.toggle("ss-clip-luma-target", active);
+ }
+
+ /** Play attachment animation when luma is successfully attached */
+ public playLumaAttachAnimation(): void {
+ // Add animation class and remove after animation completes
+ this.element.classList.add("ss-clip-luma-attached");
+ setTimeout(() => {
+ this.element.classList.remove("ss-clip-luma-attached");
+ }, 600); // Match CSS animation duration
+ }
+
+ /** Check if this clip has an attached luma mask */
+ public hasLumaMask(): boolean {
+ return this.currentLumaRef !== undefined;
+ }
+}
diff --git a/src/components/timeline/components/playhead/playhead-component.ts b/src/components/timeline/components/playhead/playhead-component.ts
new file mode 100644
index 00000000..36fa1c41
--- /dev/null
+++ b/src/components/timeline/components/playhead/playhead-component.ts
@@ -0,0 +1,121 @@
+import { type Seconds, sec } from "@core/timing/types";
+
+export interface PlayheadOptions {
+ onSeek: (time: Seconds) => void;
+ getScrollX?: () => number;
+}
+
+/** Playhead indicator with drag support */
+export class PlayheadComponent {
+ public readonly element: HTMLElement;
+ private readonly options: PlayheadOptions;
+ private currentTime: Seconds = sec(0);
+ private pixelsPerSecond = 50;
+ private isDragging = false;
+ private containerRect: DOMRect | null = null;
+ private currentScrollX = 0;
+ private needsUpdate = true;
+
+ constructor(options: PlayheadOptions) {
+ this.element = document.createElement("div");
+ this.element.className = "ss-playhead";
+ this.options = options;
+ this.buildElement();
+ }
+
+ private buildElement(): void {
+ const line = document.createElement("div");
+ line.className = "ss-playhead-line";
+ this.element.appendChild(line);
+
+ const handle = document.createElement("div");
+ handle.className = "ss-playhead-handle";
+ this.element.appendChild(handle);
+
+ // Make playhead draggable
+ this.setupDrag(handle);
+ }
+
+ private setupDrag(handle: HTMLElement): void {
+ const onPointerDown = (e: PointerEvent) => {
+ this.isDragging = true;
+ handle.setPointerCapture(e.pointerId);
+ e.preventDefault();
+
+ // Cache container rect for drag calculation
+ const container = this.element.parentElement;
+ if (container) {
+ this.containerRect = container.getBoundingClientRect();
+ }
+ };
+
+ const onPointerMove = (e: PointerEvent) => {
+ if (!this.isDragging || !this.containerRect) return;
+
+ // Get current scroll from callback or stored value
+ const scrollX = this.options.getScrollX?.() ?? this.currentScrollX;
+ const x = e.clientX - this.containerRect.left + scrollX;
+ const time = sec(Math.max(0, x / this.pixelsPerSecond));
+
+ // Update position immediately for smooth feedback
+ this.setPosition(time);
+
+ // Emit seek event
+ this.options.onSeek(time);
+ };
+
+ const onPointerUp = (e: PointerEvent) => {
+ if (this.isDragging) {
+ this.isDragging = false;
+ handle.releasePointerCapture(e.pointerId);
+ this.containerRect = null;
+ }
+ };
+
+ handle.addEventListener("pointerdown", onPointerDown);
+ handle.addEventListener("pointermove", onPointerMove);
+ handle.addEventListener("pointerup", onPointerUp);
+ handle.addEventListener("pointercancel", onPointerUp);
+ }
+
+ public draw(): void {
+ if (!this.needsUpdate) return;
+ this.needsUpdate = false;
+
+ const x = this.currentTime * this.pixelsPerSecond;
+
+ this.element.style.setProperty("--playhead-time", String(this.currentTime));
+ this.element.style.left = `${x}px`;
+ }
+
+ public dispose(): void {
+ this.element.remove();
+ }
+
+ public setPixelsPerSecond(pps: number): void {
+ this.pixelsPerSecond = pps;
+ this.needsUpdate = true;
+ }
+
+ public setTime(time: Seconds): void {
+ if (this.isDragging) return; // Don't update during drag
+
+ this.currentTime = time;
+ this.needsUpdate = true;
+ }
+
+ private setPosition(time: Seconds): void {
+ this.currentTime = time;
+ this.needsUpdate = true;
+ // Immediate draw for responsive drag feedback
+ this.draw();
+ }
+
+ public getTime(): Seconds {
+ return this.currentTime;
+ }
+
+ public setScrollX(scrollX: number): void {
+ this.currentScrollX = scrollX;
+ }
+}
diff --git a/src/components/timeline/components/ruler/ruler-component.ts b/src/components/timeline/components/ruler/ruler-component.ts
new file mode 100644
index 00000000..972b4cac
--- /dev/null
+++ b/src/components/timeline/components/ruler/ruler-component.ts
@@ -0,0 +1,136 @@
+import { type Seconds, sec } from "@core/timing/types";
+
+interface RulerOptions {
+ onSeek?: (time: Seconds) => void;
+ onWheel?: (e: WheelEvent) => void;
+}
+
+/** Time ruler component for the timeline */
+export class RulerComponent {
+ public readonly element: HTMLElement;
+ private readonly contentElement: HTMLElement;
+ private readonly options: RulerOptions;
+ private currentPixelsPerSecond = 50;
+ private currentDuration: Seconds = sec(60);
+ private needsRender = true;
+ private scrollX = 0;
+
+ constructor(options: RulerOptions = {}) {
+ this.element = document.createElement("div");
+ this.element.className = "ss-timeline-ruler";
+ this.options = options;
+ this.contentElement = this.buildElement();
+ this.setupClickHandler();
+ }
+
+ private setupClickHandler(): void {
+ this.element.addEventListener("click", this.handleClick.bind(this));
+ this.element.addEventListener(
+ "wheel",
+ e => {
+ if (this.options.onWheel) {
+ this.options.onWheel(e);
+ }
+ },
+ { passive: true }
+ );
+ }
+
+ private handleClick(e: MouseEvent): void {
+ if (!this.options.onSeek) return;
+
+ const rect = this.element.getBoundingClientRect();
+ const x = e.clientX - rect.left + this.scrollX;
+ const time = Math.max(0, x / this.currentPixelsPerSecond);
+
+ this.options.onSeek(sec(time));
+ }
+
+ private buildElement(): HTMLElement {
+ const content = document.createElement("div");
+ content.className = "ss-ruler-content";
+ this.element.appendChild(content);
+ return content;
+ }
+
+ public draw(): void {
+ if (!this.needsRender) return;
+ this.needsRender = false;
+
+ const pps = this.currentPixelsPerSecond;
+ const duration = this.currentDuration;
+
+ // Calculate appropriate interval based on zoom level
+ let interval = 1; // seconds
+ if (pps < 20) interval = 10;
+ else if (pps < 40) interval = 5;
+ else if (pps < 80) interval = 2;
+ else if (pps > 150) interval = 0.5;
+
+ // Clear existing markers
+ this.contentElement.innerHTML = "";
+
+ // Generate markers
+ for (let t = 0; t <= duration; t += interval) {
+ const marker = document.createElement("div");
+ marker.className = "ss-ruler-marker";
+ marker.style.left = `${t * pps}px`;
+
+ const line = document.createElement("div");
+ line.className = "ss-ruler-marker-line";
+ marker.appendChild(line);
+
+ const label = document.createElement("div");
+ label.className = "ss-ruler-marker-label";
+ label.textContent = this.formatTime(t);
+ marker.appendChild(label);
+
+ this.contentElement.appendChild(marker);
+
+ // Add minor markers between major ones
+ if (interval >= 1) {
+ const minorInterval = interval / 4;
+ for (let mt = t + minorInterval; mt < t + interval && mt <= duration; mt += minorInterval) {
+ const minorMarker = document.createElement("div");
+ minorMarker.className = "ss-ruler-marker minor";
+ minorMarker.style.left = `${mt * pps}px`;
+
+ const minorLine = document.createElement("div");
+ minorLine.className = "ss-ruler-marker-line";
+ minorMarker.appendChild(minorLine);
+
+ this.contentElement.appendChild(minorMarker);
+ }
+ }
+ }
+ }
+
+ /** Update ruler parameters and mark for re-render */
+ public updateRuler(pixelsPerSecond: number, duration: Seconds): void {
+ if (pixelsPerSecond === this.currentPixelsPerSecond && duration === this.currentDuration) {
+ return;
+ }
+
+ this.currentPixelsPerSecond = pixelsPerSecond;
+ this.currentDuration = duration;
+ this.needsRender = true;
+ }
+
+ private formatTime(seconds: number): string {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ if (mins > 0) {
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
+ }
+ return `${secs}s`;
+ }
+
+ public syncScroll(scrollX: number): void {
+ this.scrollX = scrollX;
+ this.contentElement.style.transform = `translateX(${-scrollX}px)`;
+ }
+
+ public dispose(): void {
+ this.element.remove();
+ }
+}
diff --git a/src/components/timeline/components/toolbar/toolbar-component.ts b/src/components/timeline/components/toolbar/toolbar-component.ts
new file mode 100644
index 00000000..7e1d8842
--- /dev/null
+++ b/src/components/timeline/components/toolbar/toolbar-component.ts
@@ -0,0 +1,175 @@
+import { type Seconds, sec } from "@core/timing/types";
+
+export interface ToolbarOptions {
+ onPlay: () => void;
+ onPause: () => void;
+ onSkipBack: () => void;
+ onSkipForward: () => void;
+ onZoomChange: (pixelsPerSecond: number) => void;
+}
+
+/** Timeline toolbar with playback controls and zoom */
+export class ToolbarComponent {
+ public readonly element: HTMLElement;
+ private readonly options: ToolbarOptions;
+ private timeDisplayElement: HTMLElement | null = null;
+ private playButton: HTMLButtonElement | null = null;
+ private zoomSlider: HTMLInputElement | null = null;
+ private isPlaying = false;
+ private currentTime: Seconds = sec(0);
+ private duration: Seconds = sec(0);
+
+ constructor(options: ToolbarOptions, initialZoom: number = 50) {
+ this.element = document.createElement("div");
+ this.element.className = "ss-timeline-toolbar";
+ this.options = options;
+ this.buildElement(initialZoom);
+ }
+
+ public draw(): void {
+ // Update play button state
+ if (this.playButton) {
+ this.playButton.innerHTML = this.isPlaying ? this.getPauseIcon() : this.getPlayIcon();
+ }
+
+ // Update time display
+ if (this.timeDisplayElement) {
+ this.timeDisplayElement.textContent = `${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}`;
+ }
+ }
+
+ public dispose(): void {
+ this.element.remove();
+ }
+
+ private buildElement(initialZoom: number): void {
+ // Left section - empty for balance
+ const leftSection = document.createElement("div");
+ leftSection.className = "ss-toolbar-section";
+ this.element.appendChild(leftSection);
+
+ // Center section - playback controls + time display
+ const centerSection = document.createElement("div");
+ centerSection.className = "ss-toolbar-section ss-playback-controls";
+
+ // Skip back button
+ const skipBackBtn = this.createButton("skip-back", this.getSkipBackIcon(), () => {
+ this.options.onSkipBack();
+ });
+ centerSection.appendChild(skipBackBtn);
+
+ // Play/pause button (larger circular)
+ this.playButton = this.createButton("play", this.getPlayIcon(), () => {
+ if (this.isPlaying) {
+ this.options.onPause();
+ } else {
+ this.options.onPlay();
+ }
+ });
+ this.playButton.classList.add("ss-play-btn");
+ centerSection.appendChild(this.playButton);
+
+ // Skip forward button
+ const skipForwardBtn = this.createButton("skip-forward", this.getSkipForwardIcon(), () => {
+ this.options.onSkipForward();
+ });
+ centerSection.appendChild(skipForwardBtn);
+
+ // Time display
+ this.timeDisplayElement = document.createElement("span");
+ this.timeDisplayElement.className = "ss-time-display";
+ this.timeDisplayElement.textContent = "00:00.0 / 00:00.0";
+ centerSection.appendChild(this.timeDisplayElement);
+
+ this.element.appendChild(centerSection);
+
+ // Right section - zoom controls
+ const rightSection = document.createElement("div");
+ rightSection.className = "ss-toolbar-section";
+
+ const zoomOutBtn = this.createButton("zoom-out", this.getZoomOutIcon(), () => {
+ const current = parseInt(this.zoomSlider?.value || "50", 10);
+ const newZoom = Math.max(10, current / 1.2);
+ this.setZoom(newZoom);
+ this.options.onZoomChange(newZoom);
+ });
+ rightSection.appendChild(zoomOutBtn);
+
+ this.zoomSlider = document.createElement("input");
+ this.zoomSlider.type = "range";
+ this.zoomSlider.className = "ss-zoom-slider";
+ this.zoomSlider.min = "10";
+ this.zoomSlider.max = "200";
+ this.zoomSlider.value = String(initialZoom);
+ this.zoomSlider.addEventListener("input", () => {
+ const value = parseInt(this.zoomSlider?.value || "50", 10);
+ this.options.onZoomChange(value);
+ });
+ rightSection.appendChild(this.zoomSlider);
+
+ const zoomInBtn = this.createButton("zoom-in", this.getZoomInIcon(), () => {
+ const current = parseInt(this.zoomSlider?.value || "50", 10);
+ const newZoom = Math.min(200, current * 1.2);
+ this.setZoom(newZoom);
+ this.options.onZoomChange(newZoom);
+ });
+ rightSection.appendChild(zoomInBtn);
+
+ this.element.appendChild(rightSection);
+ }
+
+ private createButton(name: string, icon: string, onClick: () => void): HTMLButtonElement {
+ const btn = document.createElement("button");
+ btn.className = "ss-toolbar-btn";
+ btn.dataset["action"] = name;
+ btn.innerHTML = icon;
+ btn.addEventListener("click", onClick);
+ return btn;
+ }
+
+ public updatePlayState(isPlaying: boolean): void {
+ this.isPlaying = isPlaying;
+ }
+
+ public updateTimeDisplay(currentTime: Seconds, duration: Seconds): void {
+ this.currentTime = currentTime;
+ this.duration = duration;
+ }
+
+ public setZoom(pixelsPerSecond: number): void {
+ if (this.zoomSlider) {
+ this.zoomSlider.value = String(Math.round(pixelsPerSecond));
+ }
+ }
+
+ private formatTime(seconds: number): string {
+ const minutes = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${minutes.toString().padStart(2, "0")}:${secs.toFixed(1).padStart(4, "0")}`;
+ }
+
+ // Icon SVGs - pointer-events:none ensures clicks pass through to button
+ private getPlayIcon(): string {
+ return ``;
+ }
+
+ private getPauseIcon(): string {
+ return ``;
+ }
+
+ private getZoomInIcon(): string {
+ return ``;
+ }
+
+ private getZoomOutIcon(): string {
+ return ``;
+ }
+
+ private getSkipBackIcon(): string {
+ return ``;
+ }
+
+ private getSkipForwardIcon(): string {
+ return ``;
+ }
+}
diff --git a/src/components/timeline/components/track/track-component.ts b/src/components/timeline/components/track/track-component.ts
new file mode 100644
index 00000000..565a1acb
--- /dev/null
+++ b/src/components/timeline/components/track/track-component.ts
@@ -0,0 +1,190 @@
+import type { TrackState, ClipState, ClipRenderer } from "../../timeline.types";
+import { getTrackHeight } from "../../timeline.types";
+import { ClipComponent } from "../clip/clip-component";
+
+export interface TrackComponentOptions {
+ showBadges: boolean;
+ onClipSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void;
+ getClipRenderer: (type: string) => ClipRenderer | undefined;
+ /** Get error state for a clip (if asset failed to load) */
+ getClipError?: (trackIndex: number, clipIndex: number) => { error: string; assetType: string } | null;
+ /** Check if content clip has an attached luma (pure function) */
+ hasAttachedLuma?: (trackIndex: number, clipIndex: number) => boolean;
+ /** Find attached luma for a content clip via timing match (pure function) */
+ findAttachedLuma?: (trackIndex: number, clipIndex: number) => { trackIndex: number; clipIndex: number } | null;
+ /** Callback when mask badge is clicked on a content clip */
+ onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void;
+ /** Check if attached luma is currently visible for editing */
+ isLumaVisibleForEditing?: (contentTrackIndex: number, contentClipIndex: number) => boolean;
+ /** Find the content clip that a luma is attached to via timing match (pure function) */
+ findContentForLuma?: (lumaTrack: number, lumaClip: number) => { trackIndex: number; clipIndex: number } | null;
+ /** Pre-computed AI asset numbers (map of clip ID to number) */
+ aiAssetNumbers: Map;
+}
+
+/** Renders a single track with its clips */
+export class TrackComponent {
+ public readonly element: HTMLElement;
+ private readonly clipComponents = new Map();
+ private readonly options: TrackComponentOptions;
+ private trackIndex: number;
+
+ // Current state for draw
+ private currentTrack: TrackState | null = null;
+ private currentPixelsPerSecond = 50;
+ private needsUpdate = true;
+
+ constructor(trackIndex: number, options: TrackComponentOptions) {
+ this.element = document.createElement("div");
+ this.element.className = "ss-track";
+ this.trackIndex = trackIndex;
+ this.options = options;
+ this.element.dataset["trackIndex"] = String(trackIndex);
+ }
+
+ public draw(): void {
+ if (!this.needsUpdate || !this.currentTrack) {
+ return; // Nothing changed, skip entirely
+ }
+ this.needsUpdate = false;
+
+ const track = this.currentTrack;
+ this.trackIndex = track.index;
+ this.element.dataset["trackIndex"] = String(track.index);
+
+ const processedIds = new Set();
+
+ // Update or create clips
+ for (const clipState of track.clips) {
+ // Check if this is an attached luma clip (luma with matching content via timing)
+ const isLumaClip = clipState.config.asset?.type === "luma";
+ const contentClip = isLumaClip ? this.options.findContentForLuma?.(clipState.trackIndex, clipState.clipIndex) : null;
+ const isAttachedLuma = isLumaClip && contentClip !== null;
+
+ if (isAttachedLuma && contentClip) {
+ // Check if it should be visible for editing
+ const isVisibleForEditing = this.options.isLumaVisibleForEditing?.(contentClip.trackIndex, contentClip.clipIndex);
+
+ if (isVisibleForEditing) {
+ // Render the luma clip (it's visible for editing)
+ processedIds.add(clipState.id);
+
+ let clipComponent = this.clipComponents.get(clipState.id);
+ if (!clipComponent) {
+ clipComponent = new ClipComponent(clipState, {
+ showBadges: this.options.showBadges,
+ onSelect: this.options.onClipSelect,
+ getRenderer: this.options.getClipRenderer,
+ getClipError: this.options.getClipError,
+ aiAssetNumbers: this.options.aiAssetNumbers
+ });
+ this.clipComponents.set(clipState.id, clipComponent);
+ this.element.appendChild(clipComponent.element);
+ }
+ clipComponent.updateClip(clipState, undefined);
+ } else {
+ // Hide attached luma - remove clip component if it exists
+ const existingComponent = this.clipComponents.get(clipState.id);
+ if (existingComponent) {
+ existingComponent.dispose();
+ this.clipComponents.delete(clipState.id);
+ }
+ }
+ } else {
+ // Normal clip rendering (non-luma or unattached luma)
+ processedIds.add(clipState.id);
+
+ // Check if this content clip has an attached luma (for badge display)
+ const attachedLuma = this.options.findAttachedLuma?.(clipState.trackIndex, clipState.clipIndex);
+
+ let clipComponent = this.clipComponents.get(clipState.id);
+ if (!clipComponent) {
+ clipComponent = new ClipComponent(clipState, {
+ showBadges: this.options.showBadges,
+ onSelect: this.options.onClipSelect,
+ getRenderer: this.options.getClipRenderer,
+ getClipError: this.options.getClipError,
+ attachedLuma: attachedLuma ?? undefined,
+ onMaskClick: this.options.onMaskClick,
+ aiAssetNumbers: this.options.aiAssetNumbers
+ });
+ this.clipComponents.set(clipState.id, clipComponent);
+ this.element.appendChild(clipComponent.element);
+ }
+
+ clipComponent.updateClip(clipState, attachedLuma ?? undefined);
+ }
+ }
+
+ // Remove clips that no longer exist
+ for (const [id, component] of this.clipComponents) {
+ if (!processedIds.has(id)) {
+ component.dispose();
+ this.clipComponents.delete(id);
+ }
+ }
+
+ // Draw all clip components
+ for (const clipComponent of this.clipComponents.values()) {
+ clipComponent.draw();
+ }
+ }
+
+ public dispose(): void {
+ for (const component of this.clipComponents.values()) {
+ component.dispose();
+ }
+ this.clipComponents.clear();
+ this.element.remove();
+ }
+
+ /** Update track state and mark for re-render */
+ public updateTrack(track: TrackState, pixelsPerSecond: number): void {
+ // Only mark dirty if data actually changed (reference equality works due to TimelineStateManager caching)
+ const trackChanged = track !== this.currentTrack;
+ const ppsChanged = pixelsPerSecond !== this.currentPixelsPerSecond;
+
+ if (!trackChanged && !ppsChanged) {
+ return; // Nothing changed, skip update
+ }
+
+ // Only update height if asset type changed (not every frame)
+ const prevAssetType = this.currentTrack?.primaryAssetType;
+
+ this.currentTrack = track;
+ this.currentPixelsPerSecond = pixelsPerSecond;
+
+ // Set height only when asset type changes
+ if (track.primaryAssetType !== prevAssetType) {
+ const height = getTrackHeight(track.primaryAssetType);
+ this.element.style.height = `${height}px`;
+ this.element.dataset["assetType"] = track.primaryAssetType;
+ }
+
+ this.needsUpdate = true;
+ }
+
+ /** Get the current track state */
+ public getCurrentTrack(): TrackState | null {
+ return this.currentTrack;
+ }
+
+ public getClipComponent(clipId: string): ClipComponent | undefined {
+ return this.clipComponents.get(clipId);
+ }
+
+ public getClipAtPosition(x: number, pixelsPerSecond: number): ClipState | null {
+ for (const component of this.clipComponents.values()) {
+ const state = component.getState();
+ if (state) {
+ const clipStart = state.config.start * pixelsPerSecond;
+ const clipEnd = (state.config.start + state.config.length) * pixelsPerSecond;
+
+ if (x >= clipStart && x <= clipEnd) {
+ return state;
+ }
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/components/timeline/components/track/track-list.ts b/src/components/timeline/components/track/track-list.ts
new file mode 100644
index 00000000..f9c4a1da
--- /dev/null
+++ b/src/components/timeline/components/track/track-list.ts
@@ -0,0 +1,198 @@
+import type { TrackState, ClipState, ClipRenderer } from "../../timeline.types";
+import { getTrackHeight } from "../../timeline.types";
+
+import { TrackComponent } from "./track-component";
+
+export interface TrackListOptions {
+ showBadges: boolean;
+ onClipSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void;
+ getClipRenderer: (type: string) => ClipRenderer | undefined;
+ /** Get error state for a clip (if asset failed to load) */
+ getClipError?: (trackIndex: number, clipIndex: number) => { error: string; assetType: string } | null;
+ /** Check if content clip has an attached luma */
+ hasAttachedLuma?: (trackIndex: number, clipIndex: number) => boolean;
+ /** Find attached luma for a content clip via timing match */
+ findAttachedLuma?: (trackIndex: number, clipIndex: number) => { trackIndex: number; clipIndex: number } | null;
+ /** Callback when mask badge is clicked on a content clip */
+ onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void;
+ /** Check if attached luma is currently visible for editing */
+ isLumaVisibleForEditing?: (contentTrackIndex: number, contentClipIndex: number) => boolean;
+ /** Find the content clip that a luma is attached to via timing match */
+ findContentForLuma?: (lumaTrack: number, lumaClip: number) => { trackIndex: number; clipIndex: number } | null;
+ /** Pre-computed AI asset numbers (map of clip ID to number) */
+ aiAssetNumbers: Map;
+}
+
+/** Container for all track components with virtualization support */
+export class TrackListComponent {
+ public readonly element: HTMLElement;
+ public readonly contentElement: HTMLElement;
+ private readonly trackComponents: TrackComponent[] = [];
+ private readonly options: TrackListOptions;
+
+ // Current state for draw
+ private currentTracks: TrackState[] = [];
+ private currentTimelineWidth = 0;
+ private currentPixelsPerSecond = 50;
+ private needsUpdate = true;
+
+ // Scroll sync callback
+ private onScroll?: (scrollX: number, scrollY: number) => void;
+
+ constructor(options: TrackListOptions) {
+ this.element = document.createElement("div");
+ this.element.className = "ss-timeline-tracks";
+ this.options = options;
+ this.contentElement = this.buildElement();
+ }
+
+ private buildElement(): HTMLElement {
+ this.element.tabIndex = 0; // Make focusable for keyboard events
+
+ const content = document.createElement("div");
+ content.className = "ss-tracks-content";
+ this.element.appendChild(content);
+
+ // Set up scroll event
+ this.element.addEventListener("scroll", () => {
+ this.onScroll?.(this.element.scrollLeft, this.element.scrollTop);
+ });
+
+ return content;
+ }
+
+ public draw(): void {
+ if (!this.needsUpdate) {
+ return; // Nothing changed, skip entirely
+ }
+ this.needsUpdate = false;
+
+ const tracks = this.currentTracks;
+ const pixelsPerSecond = this.currentPixelsPerSecond;
+
+ // Set content width for scrolling
+ this.contentElement.style.width = `${this.currentTimelineWidth}px`;
+
+ // Add/remove track components as needed
+ while (this.trackComponents.length < tracks.length) {
+ const trackIndex = this.trackComponents.length;
+ const trackComponent = new TrackComponent(trackIndex, {
+ showBadges: this.options.showBadges,
+ onClipSelect: this.options.onClipSelect,
+ getClipRenderer: this.options.getClipRenderer,
+ getClipError: this.options.getClipError,
+ hasAttachedLuma: this.options.hasAttachedLuma,
+ findAttachedLuma: this.options.findAttachedLuma,
+ onMaskClick: this.options.onMaskClick,
+ isLumaVisibleForEditing: this.options.isLumaVisibleForEditing,
+ findContentForLuma: this.options.findContentForLuma,
+ aiAssetNumbers: this.options.aiAssetNumbers
+ });
+ this.trackComponents.push(trackComponent);
+ this.contentElement.appendChild(trackComponent.element);
+ }
+
+ while (this.trackComponents.length > tracks.length) {
+ const trackComponent = this.trackComponents.pop();
+ trackComponent?.dispose();
+ }
+
+ // Update each track and draw
+ tracks.forEach((track, index) => {
+ this.trackComponents[index].updateTrack(track, pixelsPerSecond);
+ this.trackComponents[index].draw();
+ });
+ }
+
+ public dispose(): void {
+ for (const track of this.trackComponents) {
+ track.dispose();
+ }
+ this.trackComponents.length = 0;
+ this.element.remove();
+ }
+
+ public setScrollHandler(handler: (scrollX: number, scrollY: number) => void): void {
+ this.onScroll = handler;
+ }
+
+ /** Update track list state and mark for re-render */
+ public updateTracks(tracks: TrackState[], timelineWidth: number, pixelsPerSecond: number): void {
+ this.currentTracks = tracks;
+ this.currentTimelineWidth = timelineWidth;
+ this.currentPixelsPerSecond = pixelsPerSecond;
+ this.needsUpdate = true;
+ }
+
+ public getTrackComponent(trackIndex: number): TrackComponent | undefined {
+ return this.trackComponents[trackIndex];
+ }
+
+ public findClipAtPosition(x: number, y: number, _trackHeight: number, pixelsPerSecond: number): ClipState | null {
+ const scrollY = this.element.scrollTop;
+ const relativeY = y + scrollY;
+
+ // Find track at y position using variable heights
+ let currentY = 0;
+ let trackIndex = -1;
+ for (let i = 0; i < this.trackComponents.length; i += 1) {
+ const track = this.trackComponents[i].getCurrentTrack();
+ const height = getTrackHeight(track?.primaryAssetType ?? "default");
+
+ if (relativeY >= currentY && relativeY < currentY + height) {
+ trackIndex = i;
+ break;
+ }
+ currentY += height;
+ }
+
+ if (trackIndex < 0 || trackIndex >= this.trackComponents.length) {
+ return null;
+ }
+
+ const scrollX = this.element.scrollLeft;
+ const relativeX = x + scrollX;
+
+ return this.trackComponents[trackIndex].getClipAtPosition(relativeX, pixelsPerSecond);
+ }
+
+ /** Get the track index at a given y position */
+ public getTrackIndexAtY(y: number): number {
+ const scrollY = this.element.scrollTop;
+ const relativeY = y + scrollY;
+
+ let currentY = 0;
+ for (let i = 0; i < this.trackComponents.length; i += 1) {
+ const track = this.trackComponents[i].getCurrentTrack();
+ const height = getTrackHeight(track?.primaryAssetType ?? "default");
+
+ if (relativeY >= currentY && relativeY < currentY + height) {
+ return i;
+ }
+ currentY += height;
+ }
+ return -1;
+ }
+
+ /** Get the Y position of a track by index */
+ public getTrackYPosition(trackIndex: number): number {
+ let y = 0;
+ for (let i = 0; i < trackIndex && i < this.trackComponents.length; i += 1) {
+ const track = this.trackComponents[i].getCurrentTrack();
+ y += getTrackHeight(track?.primaryAssetType ?? "default");
+ }
+ return y;
+ }
+
+ public getScrollPosition(): { scrollX: number; scrollY: number } {
+ return {
+ scrollX: this.element.scrollLeft,
+ scrollY: this.element.scrollTop
+ };
+ }
+
+ public setScrollPosition(scrollX: number, scrollY: number): void {
+ this.element.scrollLeft = scrollX;
+ this.element.scrollTop = scrollY;
+ }
+}
diff --git a/src/components/timeline/constants.ts b/src/components/timeline/constants.ts
deleted file mode 100644
index 63208990..00000000
--- a/src/components/timeline/constants.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * Shared constants for timeline components
- */
-
-// Visual constants for clips
-export const CLIP_CONSTANTS = {
- MIN_WIDTH: 50,
- PADDING: 4,
- DEFAULT_ALPHA: 1.0,
- DRAG_OPACITY: 0.6,
- RESIZE_OPACITY: 0.9,
- HOVER_OPACITY: 0.7,
- BORDER_WIDTH: 2,
- CORNER_RADIUS: 4,
- SELECTED_BORDER_MULTIPLIER: 2,
- TEXT_FONT_SIZE: 12,
- TEXT_TRUNCATE_SUFFIX_LENGTH: 3
-} as const;
-
-// Visual constants for tracks
-export const TRACK_CONSTANTS = {
- PADDING: 2,
- LABEL_PADDING: 8,
- DEFAULT_OPACITY: 0.8,
- BORDER_WIDTH: 1
-} as const;
-
-// Layout constants
-export const LAYOUT_CONSTANTS = {
- TOOLBAR_HEIGHT_RATIO: 0.12, // 12% of timeline height
- RULER_HEIGHT_RATIO: 0.133, // 13.3% of timeline height
- TOOLBAR_HEIGHT_DEFAULT: 36,
- RULER_HEIGHT_DEFAULT: 40,
- TRACK_HEIGHT_DEFAULT: 80,
- BORDER_WIDTH: 2,
- CORNER_RADIUS: 4,
- CLIP_PADDING: 4,
- LABEL_PADDING: 8,
- TRACK_PADDING: 2,
- MIN_CLIP_WIDTH: 50
-} as const;
-
-// Type for constants
-export type ClipConstants = typeof CLIP_CONSTANTS;
-export type TrackConstants = typeof TRACK_CONSTANTS;
-export type LayoutConstants = typeof LAYOUT_CONSTANTS;
diff --git a/src/components/timeline/error-messages.ts b/src/components/timeline/error-messages.ts
new file mode 100644
index 00000000..112899ac
--- /dev/null
+++ b/src/components/timeline/error-messages.ts
@@ -0,0 +1,77 @@
+/**
+ * User-friendly error message formatting for clip load failures.
+ * Detects file type mismatches and provides helpful suggestions.
+ */
+
+const VIDEO_EXTENSIONS = ["mp4", "mov", "webm", "avi", "mkv", "m4v"];
+const IMAGE_EXTENSIONS = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg"];
+const AUDIO_EXTENSIONS = ["mp3", "wav", "aac", "ogg", "m4a", "flac"];
+
+/**
+ * Extract filename from an error message containing a URL
+ */
+export function extractFilenameFromError(error: string): string | null {
+ const urlMatch = error.match(/['"]?(https?:\/\/[^'"]+)['"]?/);
+ if (urlMatch) {
+ const url = urlMatch[1];
+ return url.split("/").pop()?.split("?")[0] || null;
+ }
+ return null;
+}
+
+/**
+ * Format a user-friendly error message based on asset type and error details.
+ * Detects wrong file type scenarios and provides helpful suggestions.
+ */
+export function formatClipErrorMessage(error: string, assetType: string): string {
+ const filename = extractFilenameFromError(error);
+ const fileExt = filename?.split(".").pop()?.toLowerCase();
+
+ // Detect wrong file type scenarios
+ if (error.toLowerCase().includes("invalid") && error.toLowerCase().includes("source")) {
+ // Image clip with wrong file
+ if (assetType === "image") {
+ if (fileExt && VIDEO_EXTENSIONS.includes(fileExt)) {
+ return `⚠️ Wrong file type\n\nThis clip expects an image, but "${filename}" is a video file.\n\nTry using a .jpg, .png, or .gif instead.`;
+ }
+ if (fileExt && AUDIO_EXTENSIONS.includes(fileExt)) {
+ return `⚠️ Wrong file type\n\nThis clip expects an image, but "${filename}" is an audio file.\n\nTry using a .jpg, .png, or .gif instead.`;
+ }
+ }
+
+ // Video clip with wrong file
+ if (assetType === "video") {
+ if (fileExt && IMAGE_EXTENSIONS.includes(fileExt)) {
+ return `⚠️ Wrong file type\n\nThis clip expects a video, but "${filename}" is an image file.\n\nTry using a .mp4 or .mov instead.`;
+ }
+ if (fileExt && AUDIO_EXTENSIONS.includes(fileExt)) {
+ return `⚠️ Wrong file type\n\nThis clip expects a video, but "${filename}" is an audio file.\n\nTry using a .mp4 or .mov instead.`;
+ }
+ }
+
+ // Audio clip with wrong file
+ if (assetType === "audio") {
+ if (fileExt && VIDEO_EXTENSIONS.includes(fileExt)) {
+ return `⚠️ Wrong file type\n\nThis clip expects audio, but "${filename}" is a video file.\n\nTry using a .mp3 or .wav instead.`;
+ }
+ if (fileExt && IMAGE_EXTENSIONS.includes(fileExt)) {
+ return `⚠️ Wrong file type\n\nThis clip expects audio, but "${filename}" is an image file.\n\nTry using a .mp3 or .wav instead.`;
+ }
+ }
+
+ // Luma clip with wrong file
+ if (assetType === "luma") {
+ if (fileExt && AUDIO_EXTENSIONS.includes(fileExt)) {
+ return `⚠️ Wrong file type\n\nLuma masks require an image or video, but "${filename}" is an audio file.`;
+ }
+ }
+ }
+
+ // Generic file load error
+ if (filename) {
+ return `⚠️ Couldn't load file\n\n"${filename}" failed to load.\n\nCheck that the file exists and the link is correct.`;
+ }
+
+ // Fallback for unknown errors
+ return `⚠️ Something went wrong\n\nThis clip couldn't be loaded.\n\nPlease check your media files.`;
+}
diff --git a/src/components/timeline/features/index.ts b/src/components/timeline/features/index.ts
deleted file mode 100644
index ee982424..00000000
--- a/src/components/timeline/features/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-// Import the classes for the interface
-import type { PlayheadFeature as PlayheadFeatureType } from "./playhead-feature";
-import type { RulerFeature as RulerFeatureType } from "./ruler-feature";
-import type { ScrollManager as ScrollManagerType } from "./scroll-manager";
-
-export { RulerFeature } from "./ruler-feature";
-export { PlayheadFeature } from "./playhead-feature";
-export { ScrollManager } from "./scroll-manager";
-
-export type { TimelineFeatureEvents, RulerFeatureOptions, PlayheadFeatureOptions, ScrollManagerOptions, TimelineReference } from "./types";
-
-export { TIMELINE_CONSTANTS } from "./types";
-
-export interface TimelineFeatures {
- ruler: RulerFeatureType;
- playhead: PlayheadFeatureType;
- scroll: ScrollManagerType;
-}
diff --git a/src/components/timeline/features/playhead-feature.ts b/src/components/timeline/features/playhead-feature.ts
deleted file mode 100644
index e80c8dd1..00000000
--- a/src/components/timeline/features/playhead-feature.ts
+++ /dev/null
@@ -1,119 +0,0 @@
-import { EventEmitter } from "@core/events/event-emitter";
-import { Entity } from "@core/shared/entity";
-import * as PIXI from "pixi.js";
-
-import { TIMELINE_CONSTANTS, TimelineFeatureEvents, PlayheadFeatureOptions } from "./types";
-
-export class PlayheadFeature extends Entity {
- public events = new EventEmitter();
- private graphics: PIXI.Graphics;
- private currentTime = 0;
- private isDragging = false;
-
- constructor(private options: PlayheadFeatureOptions) {
- super();
- this.graphics = new PIXI.Graphics();
- }
-
- async load(): Promise {
- this.setupPlayhead();
- this.draw();
- }
-
- private setupPlayhead(): void {
- this.graphics.label = "playhead";
- this.graphics.eventMode = "static";
- this.graphics.cursor = "pointer";
-
- // Single set of event listeners
- this.graphics
- .on("pointerdown", this.onPointerDown.bind(this))
- .on("pointermove", this.onPointerMove.bind(this))
- .on("pointerup", this.onPointerUp.bind(this))
- .on("pointerupoutside", this.onPointerUp.bind(this));
-
- this.getContainer().addChild(this.graphics);
- }
-
- /** @internal */
- private drawPlayhead(): void {
- const x = this.currentTime * this.options.pixelsPerSecond;
- const playheadColor = this.options.theme?.timeline.playhead ?? 0xff4444;
- const lineWidth = TIMELINE_CONSTANTS.PLAYHEAD.LINE_WIDTH;
- const centerX = x + lineWidth / 2;
-
- this.graphics.clear();
- this.graphics.fill(playheadColor);
-
- // Draw line
- this.graphics.rect(x, 0, lineWidth, this.options.timelineHeight);
-
- // Draw triangle (centered on line)
- const triangleSize = 8;
- const triangleHeight = 10;
- this.graphics.moveTo(centerX, triangleHeight);
- this.graphics.lineTo(centerX - triangleSize, 0);
- this.graphics.lineTo(centerX + triangleSize, 0);
- this.graphics.closePath();
-
- this.graphics.fill();
- }
-
- /** @internal */
- private onPointerDown(event: PIXI.FederatedPointerEvent): void {
- this.isDragging = true;
- this.graphics.cursor = "grabbing";
- this.updateTimeFromPointer(event);
- }
-
- /** @internal */
- private onPointerMove(event: PIXI.FederatedPointerEvent): void {
- if (this.isDragging) {
- this.updateTimeFromPointer(event);
- }
- }
-
- /** @internal */
- private onPointerUp(): void {
- this.isDragging = false;
- this.graphics.cursor = "pointer";
- }
-
- /** @internal */
- private updateTimeFromPointer(event: PIXI.FederatedPointerEvent): void {
- if (!this.graphics.parent) return;
- const localPos = this.graphics.parent.toLocal(event.global);
- const newTime = Math.max(0, localPos.x / this.options.pixelsPerSecond);
- this.setTime(newTime);
- this.events.emit("playhead:seeked" as keyof TimelineFeatureEvents, { time: newTime });
- }
-
- public setTime(time: number): void {
- this.currentTime = time;
- this.draw();
- this.events.emit("playhead:timeChanged" as keyof TimelineFeatureEvents, { time });
- }
-
- public getTime(): number {
- return this.currentTime;
- }
-
- /** @internal */
- public updatePlayhead(pixelsPerSecond: number, timelineHeight: number): void {
- this.options.pixelsPerSecond = pixelsPerSecond;
- this.options.timelineHeight = timelineHeight;
- this.draw();
- }
-
- public update(): void {} // Event-driven, no frame updates needed
-
- /** @internal */
- public draw(): void {
- this.drawPlayhead();
- }
-
- public dispose(): void {
- this.graphics.removeAllListeners();
- this.events.clear("*");
- }
-}
diff --git a/src/components/timeline/features/ruler-feature.ts b/src/components/timeline/features/ruler-feature.ts
deleted file mode 100644
index a68e2e2e..00000000
--- a/src/components/timeline/features/ruler-feature.ts
+++ /dev/null
@@ -1,207 +0,0 @@
-import { EventEmitter } from "@core/events/event-emitter";
-import { Entity } from "@core/shared/entity";
-import * as PIXI from "pixi.js";
-
-import { TimelineTheme } from "../../../core/theme";
-
-import { TIMELINE_CONSTANTS, TimelineFeatureEvents, RulerFeatureOptions } from "./types";
-
-export class RulerFeature extends Entity {
- public events: EventEmitter;
- private rulerContainer: PIXI.Container;
- private rulerBackground: PIXI.Graphics;
- private timeMarkers: PIXI.Graphics;
- private timeLabels: PIXI.Container;
-
- private pixelsPerSecond: number;
- private timelineDuration: number;
- private rulerHeight: number;
- private theme?: TimelineTheme;
-
- constructor(options: RulerFeatureOptions) {
- super();
- this.events = new EventEmitter();
- this.pixelsPerSecond = options.pixelsPerSecond;
- this.timelineDuration = options.timelineDuration;
- this.rulerHeight = options.rulerHeight ?? TIMELINE_CONSTANTS.RULER.DEFAULT_HEIGHT;
- this.theme = options.theme;
-
- this.rulerContainer = new PIXI.Container();
- this.rulerBackground = new PIXI.Graphics();
- this.timeMarkers = new PIXI.Graphics();
- this.timeLabels = new PIXI.Container();
- }
-
- async load(): Promise {
- this.setupRuler();
- this.draw();
- }
-
- private setupRuler(): void {
- this.rulerContainer.label = "ruler";
- this.rulerContainer.addChild(this.rulerBackground);
- this.rulerContainer.addChild(this.timeMarkers);
- this.rulerContainer.addChild(this.timeLabels);
-
- // Make ruler interactive for click-to-seek
- this.rulerContainer.eventMode = "static";
- this.rulerContainer.cursor = "pointer";
-
- this.rulerContainer.on("pointerdown", this.onRulerPointerDown.bind(this));
-
- this.getContainer().addChild(this.rulerContainer);
- }
-
- private drawRulerBackground(): void {
- this.rulerBackground.clear();
- const rulerWidth = this.calculateRulerWidth();
-
- const rulerColor = this.theme?.timeline.ruler.background || 0x404040;
- const borderColor = this.theme?.timeline.tracks.border || 0x606060;
-
- this.rulerBackground.rect(0, 0, rulerWidth, this.rulerHeight);
- this.rulerBackground.fill(rulerColor);
- this.rulerBackground.rect(0, this.rulerHeight - 1, rulerWidth, 1);
- this.rulerBackground.fill(borderColor);
- }
-
- private drawTimeMarkers(): void {
- this.timeMarkers.clear();
-
- const interval = this.getTimeInterval();
- const visibleDuration = this.getVisibleDuration();
- const dotColor = this.theme?.timeline.ruler.markers || 0x666666;
- const dotY = this.rulerHeight * 0.5;
-
- // Determine number of dots between labels based on interval
- let dotsPerInterval = 4; // Default for most intervals
- if (interval === 10) dotsPerInterval = 9;
- else if (interval === 30 || interval === 60) dotsPerInterval = 5;
-
- const dotSpacing = interval / (dotsPerInterval + 1);
-
- // Draw dots between time labels
- for (let time = 0; time <= visibleDuration; time += interval) {
- // Draw dots after this time marker
- for (let i = 1; i <= dotsPerInterval; i += 1) {
- const dotTime = time + i * dotSpacing;
- if (dotTime <= visibleDuration) {
- const x = dotTime * this.pixelsPerSecond;
- this.timeMarkers.circle(x, dotY, 1.5);
- this.timeMarkers.fill(dotColor);
- }
- }
- }
- }
-
- private drawTimeLabels(): void {
- this.timeLabels.removeChildren();
-
- const interval = this.getTimeInterval();
- const visibleDuration = this.getVisibleDuration();
- const textColor = this.theme?.timeline.ruler.text || 0xffffff;
-
- // Create label style
- const labelStyle = {
- fontSize: TIMELINE_CONSTANTS.RULER.LABEL_FONT_SIZE,
- fill: textColor,
- fontFamily: "Arial"
- };
-
- // Draw time labels at intervals
- for (let seconds = 0; seconds <= visibleDuration; seconds += interval) {
- const label = new PIXI.Text({
- text: this.formatTime(seconds),
- style: labelStyle
- });
-
- // Position label
- const x = seconds * this.pixelsPerSecond;
- if (seconds === 0) {
- label.anchor.set(0, 0.5);
- label.x = x + TIMELINE_CONSTANTS.RULER.LABEL_PADDING_X;
- } else {
- label.anchor.set(0.5, 0.5);
- label.x = x;
- }
- label.y = this.rulerHeight * 0.5;
-
- this.timeLabels.addChild(label);
- }
- }
-
- private onRulerPointerDown(event: PIXI.FederatedPointerEvent): void {
- // Convert global to local coordinates within the ruler
- const localPos = this.rulerContainer.toLocal(event.global);
- const time = Math.max(0, localPos.x / this.pixelsPerSecond);
- this.events.emit("ruler:seeked" as keyof TimelineFeatureEvents, { time });
- }
-
- public updateRuler(pixelsPerSecond: number, timelineDuration: number): void {
- this.pixelsPerSecond = pixelsPerSecond;
- this.timelineDuration = timelineDuration;
- this.draw();
- }
-
- public update(_deltaTime: number, _elapsed: number): void {
- // Ruler is static unless parameters change
- }
-
- public draw(): void {
- this.drawRulerBackground();
- this.drawTimeMarkers();
- this.drawTimeLabels();
- }
-
- public dispose(): void {
- this.timeLabels.removeChildren();
- this.rulerContainer.removeChildren();
- this.events.clear("*");
- }
-
- private getViewportWidth(): number {
- return this.getContainer().parent?.width || 800;
- }
-
- private calculateRulerWidth(): number {
- const calculatedWidth = this.timelineDuration * this.pixelsPerSecond;
- return Math.max(calculatedWidth, this.getViewportWidth());
- }
-
- private getVisibleDuration(): number {
- return Math.max(this.timelineDuration, this.getViewportWidth() / this.pixelsPerSecond);
- }
-
- private getTimeInterval(): number {
- // Choose appropriate time interval based on zoom level
- const intervals = [1, 5, 10, 30, 60, 120, 300, 600];
- const minPixelSpacing = 80;
-
- for (const interval of intervals) {
- const pixelSpacing = interval * this.pixelsPerSecond;
- if (pixelSpacing >= minPixelSpacing) {
- return interval;
- }
- }
-
- // If extremely zoomed out, use larger intervals
- return Math.ceil(this.getVisibleDuration() / 10);
- }
-
- private formatTime(seconds: number): string {
- if (seconds === 0) return "0s";
-
- const minutes = Math.floor(seconds / 60);
- const remainingSeconds = seconds % 60;
-
- if (seconds < 60) {
- return `${seconds}s`;
- }
- if (remainingSeconds === 0) {
- return `${minutes}m`;
- }
- // Format as M:SS for times with seconds
- const formattedSeconds = remainingSeconds.toString().padStart(2, "0");
- return `${minutes}:${formattedSeconds}`;
- }
-}
diff --git a/src/components/timeline/features/scroll-manager.ts b/src/components/timeline/features/scroll-manager.ts
deleted file mode 100644
index f1c62e0f..00000000
--- a/src/components/timeline/features/scroll-manager.ts
+++ /dev/null
@@ -1,233 +0,0 @@
-import { EventEmitter } from "@core/events/event-emitter";
-
-import { TIMELINE_CONSTANTS, TimelineFeatureEvents, ScrollManagerOptions, TimelineReference } from "./types";
-
-export class ScrollManager {
- public events: EventEmitter;
- private timeline: TimelineReference;
- private abortController?: AbortController;
-
- // Scroll state
- private scrollX = 0;
- private scrollY = 0;
-
- constructor(options: ScrollManagerOptions) {
- this.events = new EventEmitter();
- this.timeline = options.timeline;
- }
-
- public async initialize(): Promise {
- this.setupEventListeners();
- }
-
- private setupEventListeners(): void {
- this.abortController = new AbortController();
-
- // Get the PIXI canvas element
- const { canvas } = this.timeline.getPixiApp();
-
- // Add wheel event listener for scrolling
- canvas.addEventListener("wheel", this.handleWheel.bind(this), {
- passive: false,
- signal: this.abortController.signal
- });
-
- // Keyboard navigation disabled for now
- // document.addEventListener('keydown', this.handleKeydown.bind(this), {
- // signal: this.abortController.signal
- // });
- }
-
- private handleWheel(event: WheelEvent): void {
- event.preventDefault();
-
- // Check for Ctrl/Cmd key for zoom
- if (event.ctrlKey || event.metaKey) {
- this.handleZoom(event);
- return;
- }
-
- this.handleScroll(event);
- }
-
- private handleZoom(event: WheelEvent): void {
- // Handle zoom
- const zoomDirection = event.deltaY > 0 ? "out" : "in";
-
- // Get playhead time position
- const playheadTime = this.timeline.getPlayheadTime();
-
- // Get actual edit duration (not extended duration)
- // The timeline.timeRange.endTime includes the 1.5x buffer, we need the actual duration
- const actualEditDuration = this.timeline.getActualEditDuration();
-
- // Perform zoom
- if (zoomDirection === "in") {
- this.timeline.zoomIn();
- } else {
- this.timeline.zoomOut();
- }
-
- // Get new pixels per second after zoom
- const newPixelsPerSecond = this.timeline.getOptions().pixelsPerSecond || 50;
-
- // Calculate playhead position in pixels after zoom
- const playheadXAfterZoom = playheadTime * newPixelsPerSecond;
-
- // Calculate viewport dimensions
- const viewportWidth = this.timeline.getOptions().width || 800;
-
- // Use the extended duration for content width (includes buffer space)
- const extendedDuration = this.timeline.timeRange.endTime;
- const contentWidth = extendedDuration * newPixelsPerSecond;
-
- // But ensure playhead doesn't go beyond actual edit duration
- const maxPlayheadX = actualEditDuration * newPixelsPerSecond;
-
- // Calculate the new scroll position to keep playhead in view
- const newScrollX = this.calculateZoomScrollPosition({
- playheadXAfterZoom,
- viewportWidth,
- contentWidth,
- maxPlayheadX,
- actualEditDuration,
- playheadTime
- });
-
- // Update scroll position
- this.scrollX = newScrollX;
- this.timeline.setScroll(this.scrollX, this.scrollY);
-
- // Emit zoom event with actual focus position
- const actualFocusX = playheadXAfterZoom - newScrollX;
- this.events.emit("zoom" as keyof TimelineFeatureEvents, {
- pixelsPerSecond: newPixelsPerSecond,
- focusX: actualFocusX,
- focusTime: playheadTime
- });
- }
-
- private handleScroll(event: WheelEvent): void {
- let { deltaX } = event;
- let { deltaY } = event;
-
- // Shift key converts vertical scroll to horizontal scroll
- if (event.shiftKey) {
- deltaX = deltaY;
- deltaY = 0;
- }
-
- // Different scroll speeds for horizontal vs vertical
- const horizontalScrollSpeed = TIMELINE_CONSTANTS.SCROLL.HORIZONTAL_SPEED;
- const verticalScrollSpeed = TIMELINE_CONSTANTS.SCROLL.VERTICAL_SPEED;
-
- // Update scroll position
- this.scrollX += deltaX * horizontalScrollSpeed;
- this.scrollY += deltaY * verticalScrollSpeed;
-
- // Apply bounds (prevent negative scrolling and limit based on content)
- this.scrollX = this.clampScrollX(this.scrollX);
- this.scrollY = this.clampScrollY(this.scrollY);
-
- // Update timeline viewport
- this.timeline.setScroll(this.scrollX, this.scrollY);
-
- // Emit scroll event
- this.events.emit("scroll" as keyof TimelineFeatureEvents, { x: this.scrollX, y: this.scrollY });
- }
-
- public setScroll(x: number, y: number): void {
- this.scrollX = this.clampScrollX(x);
- this.scrollY = this.clampScrollY(y);
- this.timeline.setScroll(this.scrollX, this.scrollY);
- this.events.emit("scroll" as keyof TimelineFeatureEvents, { x: this.scrollX, y: this.scrollY });
- }
-
- private clampScrollX(x: number): number {
- // Calculate max scroll based on extended content width
- const contentWidth = this.timeline.getExtendedTimelineWidth();
- const viewportWidth = this.timeline.getOptions().width || 0;
- const maxScroll = Math.max(0, contentWidth - viewportWidth);
-
- return Math.max(0, Math.min(x, maxScroll));
- }
-
- private clampScrollY(y: number): number {
- const layout = this.timeline.getLayout();
- const trackCount = this.timeline.getVisualTracks().length;
- const height = this.timeline.getOptions().height || 0;
- const maxScroll = Math.max(0, trackCount * layout.trackHeight - (height - layout.rulerHeight));
- return Math.max(0, Math.min(y, maxScroll));
- }
-
- public getScroll(): { x: number; y: number } {
- return { x: this.scrollX, y: this.scrollY };
- }
-
- private calculateZoomScrollPosition(params: {
- playheadXAfterZoom: number;
- viewportWidth: number;
- contentWidth: number;
- maxPlayheadX: number;
- actualEditDuration: number;
- playheadTime: number;
- }): number {
- const { playheadXAfterZoom, viewportWidth, contentWidth, maxPlayheadX, actualEditDuration, playheadTime } = params;
-
- // Calculate ideal scroll to center playhead
- const idealScrollX = playheadXAfterZoom - viewportWidth / 2;
-
- // Calculate scroll bounds
- const maxScroll = Math.max(0, contentWidth - viewportWidth);
-
- // Determine the best scroll position
- let newScrollX: number;
-
- // First, check if we're trying to show beyond the actual edit duration
- const rightEdgeOfViewport = idealScrollX + viewportWidth;
- const maxAllowedScroll = Math.max(0, maxPlayheadX - viewportWidth);
-
- if (contentWidth <= viewportWidth) {
- // Content fits in viewport, no scroll needed
- newScrollX = 0;
- } else if (idealScrollX < 0) {
- // Would scroll past start, align to start
- newScrollX = 0;
- } else if (rightEdgeOfViewport > maxPlayheadX && playheadTime <= actualEditDuration) {
- // Would show beyond actual edit duration, limit scroll
- // Position viewport so its right edge aligns with the actual edit end
- newScrollX = Math.min(maxAllowedScroll, maxScroll);
- } else if (idealScrollX > maxScroll) {
- // Would scroll past end of extended timeline
- newScrollX = maxScroll;
- } else {
- // Can center playhead normally
- newScrollX = idealScrollX;
- }
-
- // Double-check that playhead remains visible
- const playheadInViewport = playheadXAfterZoom - newScrollX;
- if (playheadInViewport < 0 || playheadInViewport > viewportWidth) {
- // This shouldn't happen, but if it does, adjust to keep playhead visible
- if (playheadXAfterZoom > contentWidth - viewportWidth) {
- // Playhead near end, show it at right edge of viewport
- newScrollX = Math.max(0, playheadXAfterZoom - viewportWidth + 50); // 50px padding from edge
- } else {
- // Show playhead with some padding from left edge
- newScrollX = Math.max(0, playheadXAfterZoom - 50);
- }
- // Re-clamp to valid bounds
- newScrollX = Math.max(0, Math.min(newScrollX, maxScroll));
- }
-
- return newScrollX;
- }
-
- public dispose(): void {
- if (this.abortController) {
- this.abortController.abort();
- this.abortController = undefined;
- }
- this.events.clear("*");
- }
-}
diff --git a/src/components/timeline/features/types.ts b/src/components/timeline/features/types.ts
deleted file mode 100644
index bfc6b690..00000000
--- a/src/components/timeline/features/types.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-import { TimelineTheme } from "../../../core/theme";
-import { VisualTrack } from "../visual/visual-track";
-
-// Constants for timeline features
-export const TIMELINE_CONSTANTS = {
- RULER: {
- DEFAULT_HEIGHT: 40,
- MAJOR_MARKER_HEIGHT_RATIO: 0.8,
- MINOR_MARKER_HEIGHT_RATIO: 0.6,
- MINOR_MARKER_TENTH_HEIGHT_RATIO: 0.3,
- LABEL_FONT_SIZE: 10,
- LABEL_PADDING_X: 2,
- LABEL_PADDING_Y: 2,
- MINOR_MARKER_ZOOM_THRESHOLD: 20,
- LABEL_INTERVAL_ZOOMED: 1,
- LABEL_INTERVAL_DEFAULT: 5,
- LABEL_ZOOM_THRESHOLD: 50
- },
- PLAYHEAD: {
- LINE_WIDTH: 2,
- HANDLE_WIDTH: 10,
- HANDLE_HEIGHT: 10,
- HANDLE_OFFSET_Y: -10,
- HANDLE_OFFSET_X: 5
- },
- SCROLL: {
- HORIZONTAL_SPEED: 2,
- VERTICAL_SPEED: 0.5
- }
-} as const;
-
-// Type-safe event definitions
-export interface TimelineFeatureEvents {
- "ruler:seeked": { time: number };
- "playhead:seeked": { time: number };
- "playhead:timeChanged": { time: number };
- scroll: { x: number; y: number };
- zoom: { pixelsPerSecond: number; focusX: number; focusTime: number };
-}
-
-// Parameter object interfaces
-export interface RulerFeatureOptions {
- pixelsPerSecond: number;
- timelineDuration: number;
- rulerHeight?: number;
- theme?: TimelineTheme;
-}
-
-export interface PlayheadFeatureOptions {
- pixelsPerSecond: number;
- timelineHeight: number;
- theme?: TimelineTheme;
-}
-
-// Interface to avoid circular dependency
-export interface TimelineReference {
- getTimeDisplay(): { updateTimeDisplay(): void };
- updateTime(time: number, emit?: boolean): void;
- timeRange: { startTime: number; endTime: number };
- viewportHeight: number;
- zoomLevelIndex: number;
- getPixiApp(): { canvas: HTMLCanvasElement };
- setScroll(x: number, y: number): void;
- getExtendedTimelineWidth(): number;
- getOptions(): { width?: number; height?: number; pixelsPerSecond?: number };
- getLayout(): { trackHeight: number; rulerHeight: number };
- getVisualTracks(): VisualTrack[];
- zoomIn(): void;
- zoomOut(): void;
- getPlayheadTime(): number;
- getActualEditDuration(): number;
-}
-
-export interface ScrollManagerOptions {
- timeline: TimelineReference;
-}
diff --git a/src/components/timeline/index.ts b/src/components/timeline/index.ts
index c61ceb95..21de6d61 100644
--- a/src/components/timeline/index.ts
+++ b/src/components/timeline/index.ts
@@ -1,21 +1,7 @@
-// Timeline v2 Core
-export { Timeline } from "./timeline";
+/** Timeline Component */
-// Timeline v2 Visual Components
-export { VisualClip } from "./visual/visual-clip";
-export { VisualTrack } from "./visual/visual-track";
+export { Timeline } from "@timeline/timeline";
-// Timeline v2 Types
-export type { EditType, TimelineOptions, ClipConfig, ClipInfo, DropPosition } from "./types";
+export type { ClipState, TrackState, ViewportState, PlaybackState, ClipInfo, ClipRenderer } from "@timeline/timeline.types";
-// Timeline v2 Features and Layout
-export { RulerFeature, PlayheadFeature, ScrollManager } from "./features";
-export { TimelineLayout } from "./timeline-layout";
-
-// Timeline v2 Interaction
-export { InteractionController } from "./interaction";
-
-// Note: Additional components will be exported as they are implemented
-// export { DragTool } from './drag-tool';
-// export { SelectionTool } from './selection-tool';
-// export { ResizeTool } from './resize-tool';
+export { DEFAULT_PIXELS_PER_SECOND, TRACK_HEIGHTS, getTrackHeight } from "@timeline/timeline.types";
diff --git a/src/components/timeline/interaction/collision-detector.ts b/src/components/timeline/interaction/collision-detector.ts
deleted file mode 100644
index cbeeae0f..00000000
--- a/src/components/timeline/interaction/collision-detector.ts
+++ /dev/null
@@ -1,105 +0,0 @@
-import { TimelineInterface } from "./types";
-
-interface ClipBounds {
- start: number;
- end: number;
-}
-
-export interface CollisionResult {
- validTime: number;
- wouldOverlap: boolean;
-}
-
-export class CollisionDetector {
- private timeline: TimelineInterface;
-
- constructor(timeline: TimelineInterface) {
- this.timeline = timeline;
- }
-
- public getValidDropPosition(time: number, duration: number, trackIndex: number, excludeClipIndex?: number): CollisionResult {
- const track = this.timeline.getVisualTracks()[trackIndex];
- if (!track) return { validTime: time, wouldOverlap: false };
-
- // Get all clips except the one being dragged
- const otherClips = this.getOtherClipBounds(track, excludeClipIndex);
-
- // Find the first overlap
- const dragEnd = time + duration;
- const overlap = otherClips.find(
- clip => !(dragEnd <= clip.start || time >= clip.end) // Not if completely before or after
- );
-
- if (!overlap) {
- return { validTime: time, wouldOverlap: false };
- }
-
- // Find nearest valid position
- const beforeGap = overlap.start - duration;
- const afterGap = overlap.end;
-
- // Choose position closest to original intent
- const validTime = Math.abs(time - beforeGap) < Math.abs(time - afterGap) && beforeGap >= 0 ? beforeGap : afterGap;
-
- // Recursively check if new position is valid
- const recursiveCheck = this.getValidDropPosition(validTime, duration, trackIndex, excludeClipIndex);
-
- return {
- validTime: recursiveCheck.validTime,
- wouldOverlap: true
- };
- }
-
- public checkOverlap(time: number, duration: number, trackIndex: number, excludeClipIndex?: number): boolean {
- const track = this.timeline.getVisualTracks()[trackIndex];
- if (!track) return false;
-
- const otherClips = this.getOtherClipBounds(track, excludeClipIndex);
- const clipEnd = time + duration;
-
- return otherClips.some(clip => !(clipEnd <= clip.start || time >= clip.end));
- }
-
- private getOtherClipBounds(track: import("./types").VisualTrack, excludeClipIndex?: number): ClipBounds[] {
- return track
- .getClips()
- .map((clip, index) => ({ clip, index }))
- .filter(({ index }: { index: number }) => index !== excludeClipIndex)
- .map(({ clip }) => {
- const config = clip.getClipConfig();
- if (!config) return null;
- const start = config.start;
- return {
- start,
- end: start + config.length
- };
- })
- .filter((clip: ClipBounds | null): clip is ClipBounds => clip !== null)
- .sort((a: ClipBounds, b: ClipBounds) => a.start - b.start);
- }
-
- public findAvailableGaps(trackIndex: number, minDuration: number): Array<{ start: number; end: number }> {
- const track = this.timeline.getVisualTracks()[trackIndex];
- if (!track) return [];
-
- const clips = this.getOtherClipBounds(track);
- const gaps: Array<{ start: number; end: number }> = [];
-
- // Check gap before first clip
- if (clips.length > 0 && clips[0].start >= minDuration) {
- gaps.push({ start: 0, end: clips[0].start });
- }
-
- // Check gaps between clips
- for (let i = 0; i < clips.length - 1; i += 1) {
- const gap = clips[i + 1].start - clips[i].end;
- if (gap >= minDuration) {
- gaps.push({ start: clips[i].end, end: clips[i + 1].start });
- }
- }
-
- // Note: We don't add a gap after the last clip as timeline extends infinitely
-
- return gaps;
- }
-}
diff --git a/src/components/timeline/interaction/drag-handler.ts b/src/components/timeline/interaction/drag-handler.ts
deleted file mode 100644
index 1efb17fb..00000000
--- a/src/components/timeline/interaction/drag-handler.ts
+++ /dev/null
@@ -1,286 +0,0 @@
-import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command";
-import { MoveClipCommand } from "@core/commands/move-clip-command";
-import * as PIXI from "pixi.js";
-
-import { CollisionDetector } from "./collision-detector";
-import { SnapManager } from "./snap-manager";
-import { TimelineInterface, DragInfo, Point, ClipInfo, DropZone, InteractionThresholds, InteractionHandler } from "./types";
-import { VisualFeedbackManager } from "./visual-feedback-manager";
-
-export class DragHandler implements InteractionHandler {
- private timeline: TimelineInterface;
- private thresholds: InteractionThresholds;
- private snapManager: SnapManager;
- private collisionDetector: CollisionDetector;
- private visualFeedback: VisualFeedbackManager;
-
- private dragInfo: DragInfo | null = null;
- private currentDropZone: DropZone | null = null;
-
- constructor(
- timeline: TimelineInterface,
- thresholds: InteractionThresholds,
- snapManager: SnapManager,
- collisionDetector: CollisionDetector,
- visualFeedback: VisualFeedbackManager
- ) {
- this.timeline = timeline;
- this.thresholds = thresholds;
- this.snapManager = snapManager;
- this.collisionDetector = collisionDetector;
- this.visualFeedback = visualFeedback;
- }
-
- public activate(): void {
- // Handler activation if needed
- }
-
- public deactivate(): void {
- this.endDrag();
- }
-
- public canStartDrag(startPos: Point, currentPos: Point): boolean {
- const distance = Math.sqrt((currentPos.x - startPos.x) ** 2 + (currentPos.y - startPos.y) ** 2);
-
- const { trackHeight } = this.timeline.getLayout();
- const threshold = trackHeight < 20 ? 2 : this.thresholds.drag.base;
-
- return distance > threshold;
- }
-
- public startDrag(clipInfo: ClipInfo, event: PIXI.FederatedPointerEvent): boolean {
- const clipData = this.timeline.getClipData(clipInfo.trackIndex, clipInfo.clipIndex);
- if (!clipData) {
- console.warn(`Clip data not found for track ${clipInfo.trackIndex}, clip ${clipInfo.clipIndex}`);
- return false;
- }
-
- // Calculate offset from clip start to mouse position
- const localPos = this.timeline.getContainer().toLocal(event.global);
- const layout = this.timeline.getLayout();
- const clipStart = clipData.start;
- const clipStartX = layout.getXAtTime(clipStart);
- // Use relative position within tracks area, not absolute position
- const clipStartY = clipInfo.trackIndex * layout.trackHeight;
-
- this.dragInfo = {
- trackIndex: clipInfo.trackIndex,
- clipIndex: clipInfo.clipIndex,
- startTime: clipStart,
- offsetX: localPos.x - clipStartX,
- offsetY: localPos.y - clipStartY
- };
-
- // Set cursor
- this.timeline.getPixiApp().canvas.style.cursor = "grabbing";
-
- // Emit drag started event
- this.timeline.getEdit().events.emit("drag:started", this.dragInfo);
-
- return true;
- }
-
- public updateDrag(event: PIXI.FederatedPointerEvent): void {
- if (!this.dragInfo) return;
-
- const position = this.calculateDragPosition(event);
- const dropZone = this.detectDropZone(position.y);
-
- if (dropZone) {
- this.handleDropZonePreview(dropZone, position);
- } else {
- this.handleNormalDragPreview(position);
- }
-
- this.emitDragUpdate(position, dropZone);
- }
-
- public completeDrag(event: PIXI.FederatedPointerEvent): void {
- if (!this.dragInfo) return;
-
- const dragInfo = { ...this.dragInfo };
- const position = this.calculateDragPosition(event);
- const dropZone = this.detectDropZone(position.y);
-
- // End drag to ensure visual cleanup happens first
- this.endDrag();
-
- if (dropZone) {
- this.executeDropZoneMove(dropZone, dragInfo, position);
- } else {
- this.executeNormalMove(dragInfo, position);
- }
- }
-
- private calculateDragPosition(event: PIXI.FederatedPointerEvent): { x: number; y: number; time: number; track: number; ghostY: number } {
- if (!this.dragInfo) throw new Error("No drag info available");
-
- const localPos = this.timeline.getContainer().toLocal(event.global);
- const layout = this.timeline.getLayout();
-
- const rawTime = Math.max(0, layout.getTimeAtX(localPos.x - this.dragInfo.offsetX));
- const dragY = localPos.y - this.dragInfo.offsetY;
-
- // Calculate which track the clip center is over
- const clipCenterY = dragY + layout.trackHeight / 2;
- const dragTrack = Math.max(0, Math.floor(clipCenterY / layout.trackHeight));
-
- // Ensure within bounds
- const maxTrackIndex = this.timeline.getVisualTracks().length - 1;
- const boundedTrack = Math.max(0, Math.min(maxTrackIndex, dragTrack));
-
- return {
- x: localPos.x,
- y: localPos.y + layout.viewportY, // For drop zone detection
- time: rawTime,
- track: boundedTrack,
- ghostY: dragY // Free Y position for ghost
- };
- }
-
- private detectDropZone(y: number): DropZone | null {
- const layout = this.timeline.getLayout();
- const tracks = this.timeline.getVisualTracks();
- const threshold = layout.trackHeight * this.thresholds.dropZone.ratio;
-
- // Check each potential insertion point
- for (let i = 0; i <= tracks.length; i += 1) {
- const boundaryY = layout.tracksY + i * layout.trackHeight;
- if (Math.abs(y - boundaryY) < threshold) {
- let type: "above" | "below" | "between";
- if (i === 0) {
- type = "above";
- } else if (i === tracks.length) {
- type = "below";
- } else {
- type = "between";
- }
- return {
- type,
- position: i
- };
- }
- }
-
- return null;
- }
-
- private handleDropZonePreview(dropZone: DropZone, _position: { time: number }): void {
- if (!this.currentDropZone || this.currentDropZone.type !== dropZone.type || this.currentDropZone.position !== dropZone.position) {
- this.currentDropZone = dropZone;
- this.visualFeedback.showDropZone(dropZone);
- }
- this.timeline.hideDragGhost();
- this.visualFeedback.hideSnapGuidelines();
- this.visualFeedback.hideTargetTrack();
- }
-
- private handleNormalDragPreview(position: { time: number; track: number; ghostY?: number }): void {
- if (!this.dragInfo) return;
-
- // Hide drop zone if showing
- if (this.currentDropZone) {
- this.visualFeedback.hideDropZone();
- this.currentDropZone = null;
- }
-
- // Get clip duration for calculations
- const clipConfig = this.timeline.getClipData(this.dragInfo.trackIndex, this.dragInfo.clipIndex);
- if (!clipConfig) return;
- const clipDuration = clipConfig.length;
-
- // Calculate final position with snapping and collision prevention
- const excludeIndex = position.track === this.dragInfo.trackIndex ? this.dragInfo.clipIndex : undefined;
- const finalTime = this.calculateFinalPosition(position.time, position.track, clipDuration, excludeIndex);
-
- // Update snap guidelines
- const alignments = this.snapManager.findAlignedElements(finalTime, clipDuration, position.track, excludeIndex);
- if (alignments.length > 0) {
- this.visualFeedback.showSnapGuidelines(alignments);
- } else {
- this.visualFeedback.hideSnapGuidelines();
- }
-
- // Show visual indicator for target track
- this.visualFeedback.showTargetTrack(position.track);
-
- // Show drag preview with free Y position
- this.timeline.showDragGhost(position.track, finalTime, position.ghostY);
- }
-
- private calculateFinalPosition(time: number, track: number, clipDuration: number, excludeIndex?: number, originalTrackIndex?: number): number {
- const snapResult = this.snapManager.calculateSnapPosition(time, track, clipDuration, excludeIndex);
-
- const sourceTrack = originalTrackIndex ?? this.dragInfo?.trackIndex;
- if (sourceTrack !== undefined && track === sourceTrack) {
- return Math.max(0, snapResult.time);
- }
-
- const validPosition = this.collisionDetector.getValidDropPosition(snapResult.time, clipDuration, track, excludeIndex);
-
- return validPosition.validTime;
- }
-
- private emitDragUpdate(position: { time: number; track: number }, dropZone: DropZone | null): void {
- if (!this.dragInfo) return;
-
- const clipConfig = this.timeline.getClipData(this.dragInfo.trackIndex, this.dragInfo.clipIndex);
- if (!clipConfig) return;
- const clipDuration = clipConfig.length;
-
- const finalTime = dropZone
- ? position.time
- : this.calculateFinalPosition(
- position.time,
- position.track,
- clipDuration,
- position.track === this.dragInfo.trackIndex ? this.dragInfo.clipIndex : undefined
- );
-
- this.timeline.getEdit().events.emit("drag:moved", {
- ...this.dragInfo,
- currentTime: finalTime,
- currentTrack: dropZone ? -1 : position.track
- });
- }
-
- private executeDropZoneMove(dropZone: DropZone, dragInfo: DragInfo, position: { time: number }): void {
- const command = new CreateTrackAndMoveClipCommand(dropZone.position, dragInfo.trackIndex, dragInfo.clipIndex, position.time);
- this.timeline.getEdit().executeEditCommand(command);
- }
-
- private executeNormalMove(dragInfo: DragInfo, position: { time: number; track: number }): void {
- const clipConfig = this.timeline.getClipData(dragInfo.trackIndex, dragInfo.clipIndex);
- if (!clipConfig) return;
-
- const clipDuration = clipConfig.length;
- const excludeIndex = position.track === dragInfo.trackIndex ? dragInfo.clipIndex : undefined;
- // Pass dragInfo.trackIndex as originalTrackIndex since this.dragInfo is cleared before this call
- const finalTime = this.calculateFinalPosition(position.time, position.track, clipDuration, excludeIndex, dragInfo.trackIndex);
-
- // Only execute if position changed
- const hasChanged = position.track !== dragInfo.trackIndex || Math.abs(finalTime - dragInfo.startTime) > 0.01;
-
- if (hasChanged) {
- const command = new MoveClipCommand(dragInfo.trackIndex, dragInfo.clipIndex, position.track, finalTime);
- this.timeline.getEdit().executeEditCommand(command);
- }
- }
-
- private endDrag(): void {
- this.dragInfo = null;
- this.currentDropZone = null;
-
- this.visualFeedback.hideAll();
- this.timeline.getPixiApp().canvas.style.cursor = "default";
- this.timeline.getEdit().events.emit("drag:ended", {});
- }
-
- public getDragInfo(): DragInfo | null {
- return this.dragInfo;
- }
-
- public dispose(): void {
- this.endDrag();
- }
-}
diff --git a/src/components/timeline/interaction/index.ts b/src/components/timeline/interaction/index.ts
deleted file mode 100644
index b52bb310..00000000
--- a/src/components/timeline/interaction/index.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-export { InteractionController } from "./interaction-controller";
-export { DragHandler } from "./drag-handler";
-export { ResizeHandler } from "./resize-handler";
-export { SnapManager } from "./snap-manager";
-export { CollisionDetector } from "./collision-detector";
-export { VisualFeedbackManager } from "./visual-feedback-manager";
-
-export type {
- InteractionState,
- Point,
- ClipInfo,
- DragInfo,
- ResizeInfo,
- DropZone,
- SnapPoint,
- SnapResult,
- AlignmentInfo,
- InteractionThresholds,
- InteractionEvents,
- InteractionHandler,
- TimelineInterface
-} from "./types";
diff --git a/src/components/timeline/interaction/interaction-calculations.ts b/src/components/timeline/interaction/interaction-calculations.ts
new file mode 100644
index 00000000..56f3ddfe
--- /dev/null
+++ b/src/components/timeline/interaction/interaction-calculations.ts
@@ -0,0 +1,424 @@
+import { type Seconds, sec } from "@core/timing/types";
+
+import type { ClipState, TrackState } from "../timeline.types";
+import { getTrackHeight } from "../timeline.types";
+
+// ─── Types ─────────────────────────────────────────────────────────────────
+
+export interface ClipRef {
+ readonly trackIndex: number;
+ readonly clipIndex: number;
+}
+
+export interface SnapPoint {
+ readonly time: Seconds;
+ readonly type: "clip-start" | "clip-end" | "playhead";
+}
+
+export interface CollisionResult {
+ readonly newStartTime: Seconds;
+ readonly pushOffset: Seconds;
+}
+
+export type DragTarget = { readonly type: "track"; readonly trackIndex: number } | { readonly type: "insert"; readonly insertionIndex: number };
+
+// ─── Drag Behavior ─────────────────────────────────────────────────────────
+
+export interface DetermineDragBehaviorInput {
+ readonly dragTarget: DragTarget;
+ readonly draggedAssetType: string | undefined;
+ readonly altKeyHeld: boolean;
+ readonly targetClip: ClipState | null;
+ readonly existingLumaRef: ClipRef | null;
+ readonly draggedClipRef: ClipRef;
+}
+
+export type DragBehavior =
+ | { readonly type: "luma-attach"; readonly targetClip: ClipState }
+ | { readonly type: "luma-blocked"; readonly reason: string }
+ | { readonly type: "luma-overlay" }
+ | { readonly type: "normal-collision" }
+ | { readonly type: "track-insert" };
+
+export function determineDragBehavior(input: DetermineDragBehaviorInput): DragBehavior {
+ const { dragTarget, draggedAssetType, altKeyHeld, targetClip, existingLumaRef, draggedClipRef } = input;
+
+ if (dragTarget.type === "insert") {
+ return { type: "track-insert" };
+ }
+
+ const canAttachAsLuma = draggedAssetType === "luma" || draggedAssetType === "image" || draggedAssetType === "video";
+ if (!canAttachAsLuma) {
+ return { type: "normal-collision" };
+ }
+
+ if (!altKeyHeld || !targetClip) {
+ return draggedAssetType === "luma" ? { type: "luma-overlay" } : { type: "normal-collision" };
+ }
+
+ const isDraggingSameLuma =
+ existingLumaRef && existingLumaRef.clipIndex === draggedClipRef.clipIndex && existingLumaRef.trackIndex === draggedClipRef.trackIndex;
+
+ if (existingLumaRef && !isDraggingSameLuma) {
+ return { type: "luma-blocked", reason: "Target already has a different luma" };
+ }
+
+ return { type: "luma-attach", targetClip };
+}
+
+// ─── Coordinate Transforms ─────────────────────────────────────────────────
+
+export function pixelsToSeconds(px: number, pixelsPerSecond: number): Seconds {
+ return sec(pixelsPerSecond === 0 ? 0 : px / pixelsPerSecond);
+}
+export function secondsToPixels(seconds: Seconds, pixelsPerSecond: number): number {
+ return seconds * pixelsPerSecond;
+}
+
+// ─── Time Formatting ───────────────────────────────────────────────────────
+
+export function formatDragTime(seconds: Seconds): string {
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ const tenths = Math.floor((seconds % 1) * 10);
+ return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${tenths}`;
+}
+
+// ─── Track Y Position Calculations ─────────────────────────────────────────
+
+export function buildTrackYPositions(tracks: readonly TrackState[]): number[] {
+ const positions: number[] = [];
+ let y = 0;
+ for (const track of tracks) {
+ positions.push(y);
+ y += getTrackHeight(track.primaryAssetType);
+ }
+ return positions;
+}
+
+export function getTrackYPosition(trackIndex: number, trackYPositions: readonly number[]): number {
+ return trackYPositions[trackIndex] ?? 0;
+}
+
+// ─── Drag Target Detection ─────────────────────────────────────────────────
+
+const INSERT_ZONE_SIZE = 12; // pixels at track edges for insert detection
+
+export function getDragTargetAtY(y: number, tracks: readonly TrackState[]): DragTarget {
+ // Top edge - insert above first track
+ if (y < INSERT_ZONE_SIZE / 2) {
+ return { type: "insert", insertionIndex: 0 };
+ }
+
+ let currentY = 0;
+ for (let i = 0; i < tracks.length; i += 1) {
+ const height = getTrackHeight(tracks[i].primaryAssetType);
+
+ // Top edge insert zone (between this track and previous)
+ if (i > 0 && y >= currentY - INSERT_ZONE_SIZE / 2 && y < currentY + INSERT_ZONE_SIZE / 2) {
+ return { type: "insert", insertionIndex: i };
+ }
+
+ // Inside track (not in edge zones)
+ if (y >= currentY + INSERT_ZONE_SIZE / 2 && y < currentY + height - INSERT_ZONE_SIZE / 2) {
+ return { type: "track", trackIndex: i };
+ }
+
+ currentY += height;
+ }
+
+ // Bottom edge - insert after last track
+ if (y >= currentY - INSERT_ZONE_SIZE / 2) {
+ return { type: "insert", insertionIndex: tracks.length };
+ }
+
+ // Default to last track
+ return { type: "track", trackIndex: Math.max(0, tracks.length - 1) };
+}
+
+// ─── Snap Point Logic ──────────────────────────────────────────────────────
+
+export interface BuildSnapPointsInput {
+ readonly tracks: readonly TrackState[];
+ readonly playheadTime: Seconds;
+ readonly excludeClip: ClipRef;
+}
+
+export function buildSnapPoints(input: BuildSnapPointsInput): SnapPoint[] {
+ const { tracks, playheadTime, excludeClip } = input;
+ const snapPoints: SnapPoint[] = [];
+
+ // Add playhead position
+ snapPoints.push({
+ time: playheadTime,
+ type: "playhead"
+ });
+
+ // Add clip edges from all tracks
+ for (const track of tracks) {
+ for (const clip of track.clips) {
+ // Skip the clip being dragged/resized
+ const isExcluded = clip.trackIndex === excludeClip.trackIndex && clip.clipIndex === excludeClip.clipIndex;
+ if (!isExcluded) {
+ snapPoints.push({
+ time: sec(clip.config.start),
+ type: "clip-start"
+ });
+ snapPoints.push({
+ time: sec(clip.config.start + clip.config.length),
+ type: "clip-end"
+ });
+ }
+ }
+ }
+
+ return snapPoints;
+}
+
+export interface ApplySnapInput {
+ readonly time: Seconds;
+ readonly snapPoints: readonly SnapPoint[];
+ readonly snapThresholdPx: number;
+ readonly pixelsPerSecond: number;
+}
+
+export function findNearestSnapPoint(input: ApplySnapInput): Seconds | null {
+ const { time, snapPoints, snapThresholdPx, pixelsPerSecond } = input;
+ const thresholdSeconds = snapThresholdPx / pixelsPerSecond;
+
+ for (const point of snapPoints) {
+ if (Math.abs(time - point.time) <= thresholdSeconds) {
+ return point.time;
+ }
+ }
+
+ return null;
+}
+
+// ─── Collision Detection ───────────────────────────────────────────────────
+
+export function getTrackClipsExcluding(track: TrackState, excludeClip: ClipRef): ClipState[] {
+ return track.clips
+ .filter(c => !(c.trackIndex === excludeClip.trackIndex && c.clipIndex === excludeClip.clipIndex))
+ .sort((a, b) => a.config.start - b.config.start);
+}
+
+export function findOverlappingClip(
+ clips: readonly ClipState[],
+ desiredStart: number,
+ clipLength: number
+): { clip: ClipState; index: number } | null {
+ const desiredEnd = desiredStart + clipLength;
+
+ for (let i = 0; i < clips.length; i += 1) {
+ const clip = clips[i];
+ const clipStart = clip.config.start;
+ const clipEnd = clipStart + clip.config.length;
+
+ if (desiredStart < clipEnd && desiredEnd > clipStart) {
+ return { clip, index: i };
+ }
+ }
+
+ return null;
+}
+
+export function resolveOverlapSnap(
+ targetClip: ClipState,
+ targetIndex: number,
+ desiredStart: number,
+ clipLength: number,
+ sortedClips: readonly ClipState[]
+): CollisionResult {
+ const targetStart = targetClip.config.start;
+ const targetEnd = targetStart + targetClip.config.length;
+
+ // Determine snap direction based on dragged clip center vs target clip center
+ const draggedCenter = desiredStart + clipLength / 2;
+ const targetCenter = targetStart + targetClip.config.length / 2;
+ const snapRight = draggedCenter >= targetCenter;
+
+ if (snapRight) {
+ // Snap to RIGHT of target clip
+ const newStartTime = sec(targetEnd);
+ const newEndTime = newStartTime + clipLength;
+ const nextClip = sortedClips[targetIndex + 1];
+
+ if (nextClip && newEndTime > nextClip.config.start) {
+ return { newStartTime, pushOffset: sec(newEndTime - nextClip.config.start) };
+ }
+ return { newStartTime, pushOffset: sec(0) };
+ }
+
+ // Snap to LEFT of target clip
+ const prevClipEnd = targetIndex > 0 ? sortedClips[targetIndex - 1].config.start + sortedClips[targetIndex - 1].config.length : 0;
+ const availableSpace = targetStart - prevClipEnd;
+
+ if (availableSpace >= clipLength) {
+ return { newStartTime: sec(targetStart - clipLength), pushOffset: sec(0) };
+ }
+
+ // No space on left - push target clip forward
+ const newStartTime = sec(prevClipEnd);
+ return { newStartTime, pushOffset: sec(newStartTime + clipLength - targetStart) };
+}
+
+export interface ResolveCollisionInput {
+ readonly track: TrackState;
+ readonly desiredStart: Seconds;
+ readonly clipLength: Seconds;
+ readonly excludeClip: ClipRef;
+}
+
+/** Default result when no collision detected */
+const NO_COLLISION: CollisionResult = { newStartTime: sec(0), pushOffset: sec(0) };
+
+export function resolveClipCollision(input: ResolveCollisionInput): CollisionResult {
+ const { track, desiredStart, clipLength, excludeClip } = input;
+
+ const clips = getTrackClipsExcluding(track, excludeClip);
+ if (clips.length === 0) {
+ return { ...NO_COLLISION, newStartTime: desiredStart };
+ }
+
+ const overlap = findOverlappingClip(clips, desiredStart, clipLength);
+ if (overlap) {
+ // Skip collision for luma assets - they should be overlayable
+ if (overlap.clip.config.asset?.type === "luma") {
+ return { newStartTime: desiredStart, pushOffset: sec(0) };
+ }
+ return resolveOverlapSnap(overlap.clip, overlap.index, desiredStart, clipLength, clips);
+ }
+
+ return { newStartTime: desiredStart, pushOffset: sec(0) };
+}
+
+// ─── Content Clip Detection ────────────────────────────────────────────────
+
+export interface FindContentClipInput {
+ readonly track: TrackState;
+ readonly time: Seconds;
+ readonly excludeClip?: ClipRef;
+}
+
+export function findContentClipAtPosition(input: FindContentClipInput): ClipState | null {
+ const { track, time, excludeClip } = input;
+
+ for (const clip of track.clips) {
+ // Skip excluded clip (can't attach to self)
+ const isExcluded = excludeClip && clip.trackIndex === excludeClip.trackIndex && clip.clipIndex === excludeClip.clipIndex;
+
+ // Only consider non-luma content clips that aren't excluded
+ if (!isExcluded && clip.config.asset?.type !== "luma") {
+ const clipStart = clip.config.start;
+ const clipEnd = clipStart + clip.config.length;
+
+ // Check if time falls within this clip
+ if (time >= clipStart && time < clipEnd) {
+ return clip;
+ }
+ }
+ }
+
+ return null;
+}
+
+// ─── Distance Calculations ─────────────────────────────────────────────────
+
+export function distance(dx: number, dy: number): number {
+ return Math.sqrt(dx * dx + dy * dy);
+}
+
+export function exceedsDragThreshold(dx: number, dy: number, threshold: number): boolean {
+ return distance(dx, dy) >= threshold;
+}
+
+// ─── Drop Action ────────────────────────────────────────────────────────────
+
+export type DropAction =
+ | { readonly type: "transform-and-attach"; readonly targetClip: ClipState }
+ | { readonly type: "reattach-luma"; readonly targetClip: ClipState }
+ | { readonly type: "detach-luma" }
+ | { readonly type: "insert-track"; readonly insertionIndex: number }
+ | { readonly type: "move-with-push"; readonly pushOffset: number }
+ | { readonly type: "simple-move" }
+ | { readonly type: "no-change" };
+
+export interface DetermineDropActionInput {
+ readonly dragTarget: DragTarget;
+ readonly draggedAssetType: string | undefined;
+ readonly altKeyHeld: boolean;
+ readonly targetClip: ClipState | null;
+ readonly existingLumaRef: ClipRef | null;
+ readonly draggedClipRef: ClipRef;
+ readonly startTime: number;
+ readonly newTime: number;
+ readonly originalTrack: number;
+ readonly pushOffset: number;
+}
+
+function determineNormalMove(startTime: number, newTime: number, originalTrack: number, targetTrack: number, pushOffset: number): DropAction {
+ if (pushOffset > 0) {
+ return { type: "move-with-push", pushOffset };
+ }
+ if (newTime !== startTime || targetTrack !== originalTrack) {
+ return { type: "simple-move" };
+ }
+ return { type: "no-change" };
+}
+
+export function determineDropAction(input: DetermineDropActionInput): DropAction {
+ const { dragTarget, draggedAssetType, altKeyHeld, targetClip, existingLumaRef, draggedClipRef, startTime, newTime, originalTrack, pushOffset } =
+ input;
+
+ // Insert target - always create new track
+ if (dragTarget.type === "insert") {
+ return { type: "insert-track", insertionIndex: dragTarget.insertionIndex };
+ }
+
+ const attachMode = altKeyHeld && targetClip;
+
+ // Image/video with Alt on target → transform to luma and attach
+ if ((draggedAssetType === "image" || draggedAssetType === "video") && attachMode) {
+ const targetHasLuma = existingLumaRef !== null;
+ if (targetHasLuma) {
+ // Fall through to normal move (warning handled separately in controller)
+ return determineNormalMove(startTime, newTime, originalTrack, dragTarget.trackIndex, pushOffset);
+ }
+ return { type: "transform-and-attach", targetClip };
+ }
+
+ // Luma handling
+ if (draggedAssetType === "luma") {
+ if (attachMode) {
+ const isDraggingSameLuma = existingLumaRef?.clipIndex === draggedClipRef.clipIndex && existingLumaRef?.trackIndex === draggedClipRef.trackIndex;
+
+ if (existingLumaRef && !isDraggingSameLuma) {
+ // Target has different luma - detach and move normally (warning handled in controller)
+ return { type: "detach-luma" };
+ }
+ return { type: "reattach-luma", targetClip };
+ }
+ // No Alt or no target - detach luma and move normally
+ return { type: "detach-luma" };
+ }
+
+ // Normal move for non-attachable assets
+ return determineNormalMove(startTime, newTime, originalTrack, dragTarget.trackIndex, pushOffset);
+}
+
+// ─── Utility Functions ─────────────────────────────────────────────────────
+
+/**
+ * Calculate the overlap duration between two time ranges.
+ * @param start1 Start time of first range
+ * @param end1 End time of first range
+ * @param start2 Start time of second range
+ * @param end2 End time of second range
+ * @returns The duration of overlap, or 0 if no overlap
+ */
+export function calculateOverlap(start1: number, end1: number, start2: number, end2: number): number {
+ const overlapStart = Math.max(start1, start2);
+ const overlapEnd = Math.min(end1, end2);
+ return Math.max(0, overlapEnd - overlapStart);
+}
diff --git a/src/components/timeline/interaction/interaction-controller.ts b/src/components/timeline/interaction/interaction-controller.ts
index 91fed2de..e2e3ef09 100644
--- a/src/components/timeline/interaction/interaction-controller.ts
+++ b/src/components/timeline/interaction/interaction-controller.ts
@@ -1,270 +1,871 @@
-import * as PIXI from "pixi.js";
-
-import { CollisionDetector } from "./collision-detector";
-import { DragHandler } from "./drag-handler";
-import { ResizeHandler } from "./resize-handler";
-import { SnapManager } from "./snap-manager";
-import { TimelineInterface, InteractionState, ClipInfo, InteractionThresholds } from "./types";
-import { VisualFeedbackManager } from "./visual-feedback-manager";
-
-export class InteractionController {
- private timeline: TimelineInterface;
- /** @internal */
- private state: InteractionState = { type: "idle" };
- private abortController?: AbortController;
-
- // Handlers
- private dragHandler: DragHandler;
- private resizeHandler: ResizeHandler;
- private snapManager: SnapManager;
- private collisionDetector: CollisionDetector;
- private visualFeedback: VisualFeedbackManager;
-
- // Default thresholds
- private thresholds: InteractionThresholds = {
- drag: {
- base: 3,
- small: 2
- },
- resize: {
- min: 12,
- max: 20,
- ratio: 0.4
- },
- dropZone: {
- ratio: 0.15
- },
- snap: {
- pixels: 10,
- time: 0.1
- }
- };
+import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command";
+import { DetachLumaCommand } from "@core/commands/detach-luma-command";
+import { MoveAndAttachLumaCommand } from "@core/commands/move-and-attach-luma-command";
+import { MoveClipCommand } from "@core/commands/move-clip-command";
+import { MoveClipWithPushCommand } from "@core/commands/move-clip-with-push-command";
+import { ResizeClipCommand } from "@core/commands/resize-clip-command";
+import type { Edit } from "@core/edit-session";
+import { inferAssetTypeFromUrl } from "@core/shared/asset-utils";
+import { type Seconds, sec } from "@core/timing/types";
+import type { ClipState } from "@timeline/timeline.types";
+import { getTrackHeight } from "@timeline/timeline.types";
+
+import { TimelineStateManager } from "../timeline-state";
+
+import {
+ type DragBehavior,
+ type DropAction,
+ type SnapPoint,
+ buildSnapPoints,
+ buildTrackYPositions,
+ determineDragBehavior,
+ determineDropAction,
+ exceedsDragThreshold,
+ findContentClipAtPosition,
+ findNearestSnapPoint,
+ getDragTargetAtY,
+ getTrackYPosition,
+ resolveClipCollision
+} from "./interaction-calculations";
+import {
+ type FeedbackConfig,
+ type FeedbackElements,
+ clearAllFeedback,
+ clearLumaFeedback,
+ createDragGhost,
+ createFeedbackElements,
+ disposeFeedbackElements,
+ getTracksOffsetInFeedbackLayer,
+ hideDragTimeTooltip,
+ hideDropZone,
+ hideLumaConnectionLine,
+ hideSnapLine,
+ restoreClipElementStyles,
+ showDragTimeTooltip,
+ showDropZone,
+ showSnapLine,
+ updateLumaTargetHighlight
+} from "./interaction-feedback";
+import {
+ type ClipRef,
+ type CollisionResult,
+ type DragTarget,
+ type DraggingState,
+ type InteractionState,
+ type PendingState,
+ type ResizingState,
+ IDLE_STATE,
+ createDraggingState,
+ createPendingState,
+ createResizingState,
+ updateDragState
+} from "./interaction-state";
+
+interface TimelineInteractionConfig {
+ dragThreshold?: number;
+ snapThreshold?: number;
+ resizeZone?: number;
+ onRequestRender?: () => void;
+}
- constructor(timeline: TimelineInterface, thresholds?: Partial) {
- this.timeline = timeline;
+/** Resolved config type - numeric properties required, callback optional */
+type ResolvedConfig = Required> & Pick;
+
+/** Configuration defaults */
+const DEFAULT_CONFIG: ResolvedConfig = {
+ dragThreshold: 3,
+ snapThreshold: 10,
+ resizeZone: 12
+};
+
+// ─── Lifecycle Interface ───────────────────────────────────────────────────
+
+/**
+ * Lifecycle interface for timeline interaction controllers.
+ */
+export interface TimelineInteractionRegistration {
+ mount(): void;
+ update(deltaTime: number): void;
+ draw(): void;
+ dispose(): void;
+}
- // Deep merge custom thresholds using structuredClone
- if (thresholds) {
- const merged = structuredClone(this.thresholds);
+// ─── Controller ────────────────────────────────────────────────────────────
+
+/** Controller for timeline interactions (drag, resize, selection) */
+export class InteractionController implements TimelineInteractionRegistration {
+ private state: InteractionState = IDLE_STATE;
+ private readonly config: ResolvedConfig;
+ private snapPoints: SnapPoint[] = [];
+
+ // DOM feedback elements (stateless management)
+ private feedbackElements: FeedbackElements;
+
+ private trackYCache: number[] | null = null;
+
+ // Bound handlers for cleanup
+ private readonly handlePointerDown: (e: PointerEvent) => void;
+ private readonly handlePointerMove: (e: PointerEvent) => void;
+ private readonly handlePointerUp: (e: PointerEvent) => void;
+
+ constructor(
+ private readonly edit: Edit,
+ private readonly stateManager: TimelineStateManager,
+ private readonly tracksContainer: HTMLElement,
+ feedbackLayer: HTMLElement,
+ config?: Partial
+ ) {
+ this.feedbackElements = createFeedbackElements(feedbackLayer);
+ this.config = { ...DEFAULT_CONFIG, ...config };
+
+ // Bind handlers (event setup deferred to mount())
+ this.handlePointerDown = this.onPointerDown.bind(this);
+ this.handlePointerMove = this.onPointerMove.bind(this);
+ this.handlePointerUp = this.onPointerUp.bind(this);
+ }
- // Manually merge each nested object
- if (thresholds.drag) {
- Object.assign(merged.drag, thresholds.drag);
- }
- if (thresholds.resize) {
- Object.assign(merged.resize, thresholds.resize);
- }
- if (thresholds.dropZone) {
- Object.assign(merged.dropZone, thresholds.dropZone);
- }
- if (thresholds.snap) {
- Object.assign(merged.snap, thresholds.snap);
- }
+ // ═══════════════════════════════════════════════════════════════════════════
+ // LIFECYCLE (TimelineInteractionRegistration)
+ // ═══════════════════════════════════════════════════════════════════════════
+
+ public mount(): void {
+ this.tracksContainer.addEventListener("pointerdown", this.handlePointerDown);
+ document.addEventListener("pointermove", this.handlePointerMove);
+ document.addEventListener("pointerup", this.handlePointerUp);
+ }
- this.thresholds = merged;
+ public update(_deltaTime: number): void {
+ // Lazy caching approach works well for DOM-based timeline.
+ // Future optimization: Could rebuild snap points here instead of on-demand.
+ }
+
+ public draw(): void {
+ // No frame-synced rendering needed for DOM-based timeline.
+ }
+
+ private onPointerDown(e: PointerEvent): void {
+ const target = e.target as HTMLElement;
+
+ // Find clip element
+ const clipEl = target.closest(".ss-clip") as HTMLElement;
+ if (!clipEl) {
+ // Click on empty space - clear selection
+ this.stateManager.clearSelection();
+ return;
}
- // Initialize managers
- this.snapManager = new SnapManager(timeline, this.thresholds);
- this.collisionDetector = new CollisionDetector(timeline);
- this.visualFeedback = new VisualFeedbackManager(timeline);
+ const trackIndex = parseInt(clipEl.dataset["trackIndex"] || "0", 10);
+ const clipIndex = parseInt(clipEl.dataset["clipIndex"] || "0", 10);
- // Initialize handlers
- this.dragHandler = new DragHandler(timeline, this.thresholds, this.snapManager, this.collisionDetector, this.visualFeedback);
+ // Check if clicking on resize handle
+ if (target.classList.contains("ss-clip-resize-handle")) {
+ const edge = target.classList.contains("left") ? "left" : "right";
+ this.startResize(e, { trackIndex, clipIndex }, edge);
+ return;
+ }
- this.resizeHandler = new ResizeHandler(timeline, this.thresholds);
+ // Start potential drag
+ this.startPending(e, { trackIndex, clipIndex });
}
- public activate(): void {
- this.abortController = new AbortController();
- this.setupEventListeners();
- this.dragHandler.activate();
- this.resizeHandler.activate();
+ private startPending(e: PointerEvent, clipRef: ClipRef): void {
+ const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex);
+ if (!clip) return;
+
+ this.state = createPendingState({ x: e.clientX, y: e.clientY }, clipRef, clip.config.start);
}
- public deactivate(): void {
- if (this.abortController) {
- this.abortController.abort();
- this.abortController = undefined;
+ private startResize(e: PointerEvent, clipRef: ClipRef, edge: "left" | "right"): void {
+ const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex);
+ if (!clip) return;
+
+ // Cache clip element to avoid querySelector on every mouse move
+ const clipElement = this.tracksContainer.querySelector(
+ `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]`
+ ) as HTMLElement | null;
+ if (!clipElement) return;
+
+ this.trackYCache = null;
+
+ this.state = createResizingState(clipRef, clipElement, edge, clip.config.start, clip.config.length);
+
+ this.buildSnapPointsForClip(clipRef);
+
+ e.preventDefault();
+ }
+
+ private onPointerMove(e: PointerEvent): void {
+ switch (this.state.type) {
+ case "pending":
+ this.handlePendingMove(e, this.state);
+ break;
+ case "dragging":
+ this.handleDragMove(e, this.state);
+ break;
+ case "resizing":
+ this.handleResizeMove(e, this.state);
+ break;
+ default:
+ break;
}
- this.resetState();
- this.dragHandler.deactivate();
- this.resizeHandler.deactivate();
}
- /** @internal */
- private setupEventListeners(): void {
- const pixiApp = this.timeline.getPixiApp();
+ private handlePendingMove(e: PointerEvent, state: PendingState): void {
+ const dx = e.clientX - state.startPoint.x;
+ const dy = e.clientY - state.startPoint.y;
- pixiApp.stage.interactive = true;
+ if (exceedsDragThreshold(dx, dy, this.config.dragThreshold)) {
+ this.transitionToDragging(e, state);
+ }
+ }
- pixiApp.stage.on("pointerdown", this.handlePointerDown.bind(this), {
- signal: this.abortController?.signal
- });
- pixiApp.stage.on("pointermove", this.handlePointerMove.bind(this), {
- signal: this.abortController?.signal
- });
- pixiApp.stage.on("pointerup", this.handlePointerUp.bind(this), {
- signal: this.abortController?.signal
- });
- pixiApp.stage.on("pointerupoutside", this.handlePointerUp.bind(this), {
- signal: this.abortController?.signal
+ private transitionToDragging(e: PointerEvent, state: PendingState): void {
+ this.trackYCache = null;
+
+ const { clipRef } = state;
+ const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex);
+ if (!clip) {
+ this.state = IDLE_STATE;
+ return;
+ }
+
+ // Find the actual clip DOM element
+ const clipElement = this.tracksContainer.querySelector(
+ `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]`
+ ) as HTMLElement | null;
+ if (!clipElement) {
+ this.state = IDLE_STATE;
+ return;
+ }
+
+ // Store original styles for restoration later
+ const originalStyles = {
+ position: clipElement.style.position,
+ left: clipElement.style.left,
+ top: clipElement.style.top,
+ zIndex: clipElement.style.zIndex,
+ pointerEvents: clipElement.style.pointerEvents
+ };
+
+ // Get clip element's current screen position
+ const clipRect = clipElement.getBoundingClientRect();
+
+ // Calculate drag offsets - distance from mouse to clip's top-left corner
+ const dragOffsetX = e.clientX - clipRect.left;
+ const dragOffsetY = e.clientY - clipRect.top;
+
+ // Make clip element follow mouse with position: fixed
+ clipElement.style.position = "fixed";
+ clipElement.style.left = `${clipRect.left}px`;
+ clipElement.style.top = `${clipRect.top}px`;
+ clipElement.style.width = `${clipRect.width}px`;
+ clipElement.style.height = `${clipRect.height}px`;
+ clipElement.style.zIndex = "18";
+ clipElement.style.pointerEvents = "none";
+
+ // Create ghost as drop preview (shows where clip will land)
+ const pps = this.stateManager.getViewport().pixelsPerSecond;
+ const track = this.stateManager.getTracks()[clipRef.trackIndex];
+ const clipAssetType = clip.config.asset?.type || "unknown";
+ const trackAssetType = track?.primaryAssetType ?? clipAssetType;
+ const ghost = createDragGhost(clip.config.length, clipAssetType, trackAssetType, pps);
+ this.feedbackElements.container.appendChild(ghost);
+
+ this.state = createDraggingState(state, clipElement, ghost, dragOffsetX, dragOffsetY, originalStyles, clip.config.length, e.altKey);
+
+ // Position ghost at current clip position initially
+ const tracksOffset = getTracksOffsetInFeedbackLayer(this.feedbackElements.container, this.tracksContainer);
+ ghost.style.left = `${clip.config.start * pps}px`;
+ ghost.style.top = `${this.getTrackYPositionCached(clipRef.trackIndex) + 4 + tracksOffset}px`;
+
+ this.buildSnapPointsForClip(clipRef);
+ }
+
+ private handleDragMove(e: PointerEvent, state: DraggingState): void {
+ // 1. Setup
+ const rect = this.tracksContainer.getBoundingClientRect();
+ const scrollX = this.tracksContainer.scrollLeft;
+ const pps = this.stateManager.getViewport().pixelsPerSecond;
+ const tracksOffset = getTracksOffsetInFeedbackLayer(this.feedbackElements.container, this.tracksContainer);
+ const feedbackConfig: FeedbackConfig = { pixelsPerSecond: pps, scrollLeft: scrollX, tracksOffset };
+
+ // 2. Move clip element with mouse
+ state.clipElement.style.left = `${e.clientX - state.dragOffsetX}px`; // eslint-disable-line no-param-reassign -- DOM manipulation
+ state.clipElement.style.top = `${e.clientY - state.dragOffsetY}px`; // eslint-disable-line no-param-reassign -- DOM manipulation
+
+ // 3. Calculate target position
+ const mouseX = e.clientX - rect.left + scrollX;
+ const mouseY = e.clientY - rect.top + this.tracksContainer.scrollTop;
+ const clipX = mouseX - state.dragOffsetX;
+ let clipTime: Seconds = sec(Math.max(0, clipX / pps));
+
+ // 4. Determine drag target and apply snapping
+ const dragTarget = this.getDragTargetAtYPosition(mouseY);
+ const updatedState = updateDragState(state, { dragTarget, altKeyHeld: e.altKey });
+ this.state = updatedState;
+ clipTime = this.applySnapAndShowLine(clipTime, feedbackConfig);
+
+ // 5. Determine behaviour
+ const draggedClip = this.stateManager.getClipAt(state.clipRef.trackIndex, state.clipRef.clipIndex);
+ const targetClip = dragTarget.type === "track" ? this.findContentClipAtPositionOnTrack(dragTarget.trackIndex, clipTime, state.clipRef) : null;
+ const existingLumaRef =
+ targetClip && dragTarget.type === "track" ? this.stateManager.findAttachedLuma(dragTarget.trackIndex, targetClip.clipIndex) : null;
+
+ const behavior = determineDragBehavior({
+ dragTarget,
+ draggedAssetType: draggedClip?.config.asset?.type,
+ altKeyHeld: e.altKey,
+ targetClip,
+ existingLumaRef,
+ draggedClipRef: state.clipRef
});
+
+ // 6. Apply behaviour
+ clipTime = this.applyDragBehavior(updatedState, behavior, clipTime, feedbackConfig);
+
+ // 7. Update ghost position
+ this.updateGhostPosition(updatedState, clipTime, feedbackConfig);
}
- /** @internal */
- private handlePointerDown(event: PIXI.FederatedPointerEvent): void {
- const target = event.target as PIXI.Container;
-
- // Check if clicked on a clip
- if (target.label) {
- const clipInfo = this.parseClipLabel(target.label);
- if (clipInfo) {
- // Check if clicking on resize edge
- if (this.resizeHandler.isOnClipRightEdge(clipInfo, event)) {
- if (this.resizeHandler.startResize(clipInfo, event)) {
- const resizeInfo = this.resizeHandler.getResizeInfo();
- if (resizeInfo) {
- this.state = { type: "resizing", resizeInfo };
- }
- }
- return;
- }
+ // ─── Drag Behavior Helpers ─────────────────────────────────────────────────
- // Start selection (potential drag)
- this.state = {
- type: "selecting",
- startPos: { x: event.global.x, y: event.global.y },
- clipInfo
- };
+ private applySnapAndShowLine(clipTime: Seconds, feedbackConfig: FeedbackConfig): Seconds {
+ const snappedTime = this.applySnap(clipTime);
+ if (snappedTime !== null) {
+ this.feedbackElements.snapLine = showSnapLine(this.feedbackElements, snappedTime, feedbackConfig);
+ return snappedTime;
+ }
+ hideSnapLine(this.feedbackElements.snapLine);
+ return clipTime;
+ }
+
+ private applyDragBehavior(state: DraggingState, behavior: DragBehavior, clipTime: Seconds, feedbackConfig: FeedbackConfig): Seconds {
+ switch (behavior.type) {
+ case "track-insert":
+ this.state = updateDragState(state, { collisionResult: { newStartTime: clipTime, pushOffset: sec(0) } });
+ clearLumaFeedback(this.feedbackElements, state.clipElement);
+ return clipTime;
+
+ case "luma-overlay":
+ clearLumaFeedback(this.feedbackElements, state.clipElement);
+ this.state = updateDragState(state, { collisionResult: { newStartTime: clipTime, pushOffset: sec(0) } });
+ return clipTime;
+
+ case "luma-blocked":
+ clearLumaFeedback(this.feedbackElements, state.clipElement);
+ return this.applyCollisionAndUpdateState(state, clipTime);
- // Set cursor to indicate draggable
- this.timeline.getPixiApp().canvas.style.cursor = "grab";
- return;
+ case "normal-collision":
+ clearLumaFeedback(this.feedbackElements, state.clipElement);
+ return this.applyCollisionAndUpdateState(state, clipTime);
+
+ case "luma-attach":
+ return this.applyLumaAttachmentFeedback(state, behavior.targetClip, feedbackConfig);
+
+ default: {
+ const exhaustiveCheck: never = behavior;
+ throw new Error(`Unhandled drag behavior: ${(exhaustiveCheck as DragBehavior).type}`);
}
}
+ }
+
+ private applyCollisionAndUpdateState(state: DraggingState, clipTime: Seconds): Seconds {
+ if (state.dragTarget.type !== "track") return clipTime;
+
+ const collisionResult = this.resolveClipCollisionOnTrack(state.dragTarget.trackIndex, clipTime, state.draggedClipLength, state.clipRef);
+ this.state = updateDragState(state, { collisionResult });
+ return collisionResult.newStartTime;
+ }
+
+ private applyLumaAttachmentFeedback(state: DraggingState, targetClip: ClipState, feedbackConfig: FeedbackConfig): Seconds {
+ if (state.dragTarget.type !== "track") return sec(targetClip.config.start);
+
+ const tracks = this.stateManager.getTracks();
+ const targetTrack = tracks[state.dragTarget.trackIndex];
+ if (!targetTrack) return sec(targetClip.config.start);
+
+ const targetTrackY = this.getTrackYPositionCached(state.dragTarget.trackIndex);
+ const targetTrackHeight = getTrackHeight(targetTrack.primaryAssetType);
+
+ const lumaResult = updateLumaTargetHighlight(
+ this.tracksContainer,
+ this.feedbackElements,
+ state.clipElement,
+ targetClip,
+ state.dragTarget.trackIndex,
+ targetTrackY,
+ targetTrackHeight,
+ feedbackConfig.tracksOffset,
+ feedbackConfig.pixelsPerSecond
+ );
+
+ this.feedbackElements.lumaTargetClipElement = lumaResult.targetClipElement;
+ this.feedbackElements.lumaConnectionLine = lumaResult.connectionLine;
+
+ const newTime = sec(targetClip.config.start);
+ this.state = updateDragState(state, { collisionResult: { newStartTime: newTime, pushOffset: sec(0) } });
+ return newTime;
+ }
+
+ private updateGhostPosition(state: DraggingState, clipTime: Seconds, feedbackConfig: FeedbackConfig): void {
+ const { ghost } = state;
+ if (state.dragTarget.type === "track") {
+ ghost.style.display = "block"; // eslint-disable-line no-param-reassign -- DOM manipulation
+ const tracks = this.stateManager.getTracks();
+ const targetTrack = tracks[state.dragTarget.trackIndex];
+ if (!targetTrack) return;
+
+ const targetTrackY = this.getTrackYPositionCached(state.dragTarget.trackIndex) + 4;
+ const targetHeight = getTrackHeight(targetTrack.primaryAssetType) - 8;
+
+ ghost.style.left = `${clipTime * feedbackConfig.pixelsPerSecond}px`; // eslint-disable-line no-param-reassign -- DOM manipulation
+ ghost.style.top = `${targetTrackY + feedbackConfig.tracksOffset}px`; // eslint-disable-line no-param-reassign -- DOM manipulation
+ ghost.style.height = `${targetHeight}px`; // eslint-disable-line no-param-reassign -- DOM manipulation
+
+ this.feedbackElements.dragTimeTooltip = showDragTimeTooltip(
+ this.feedbackElements,
+ clipTime,
+ clipTime * feedbackConfig.pixelsPerSecond,
+ targetTrackY + feedbackConfig.tracksOffset
+ );
+ hideDropZone(this.feedbackElements.dropZone);
+ } else {
+ ghost.style.display = "none"; // eslint-disable-line no-param-reassign -- DOM manipulation
+ const dropZoneY = this.getTrackYPositionCached(state.dragTarget.insertionIndex);
+ this.feedbackElements.dropZone = showDropZone(this.feedbackElements, dropZoneY, feedbackConfig.tracksOffset);
+ }
+ }
- // Clicked on empty space - clear selection
- this.timeline.getEdit().clearSelection();
+ // ─── Resize Handling ───────────────────────────────────────────────────────
+
+ private handleResizeMove(e: PointerEvent, state: ResizingState): void {
+ const rect = this.tracksContainer.getBoundingClientRect();
+ const scrollX = this.tracksContainer.scrollLeft;
+ const pps = this.stateManager.getViewport().pixelsPerSecond;
+ const tracksOffset = getTracksOffsetInFeedbackLayer(this.feedbackElements.container, this.tracksContainer);
+ const feedbackConfig = { pixelsPerSecond: pps, scrollLeft: scrollX, tracksOffset };
+
+ const x = e.clientX - rect.left + scrollX;
+ let time: Seconds = sec(Math.max(0, x / pps));
+
+ // Apply snapping
+ const snappedTime = this.applySnap(time);
+ if (snappedTime !== null) {
+ time = snappedTime;
+ this.feedbackElements.snapLine = showSnapLine(this.feedbackElements, time, feedbackConfig);
+ } else {
+ hideSnapLine(this.feedbackElements.snapLine);
+ }
+
+ // Calculate new dimensions based on edge
+ const { edge, originalStart, originalLength, clipElement } = state;
+
+ if (edge === "left") {
+ // Resize from left edge (keep end fixed, change start and length)
+ const originalEnd = originalStart + originalLength;
+ const newStart = sec(Math.max(0, Math.min(time, originalEnd - 0.1)));
+ const newLength = sec(originalEnd - newStart);
+
+ clipElement.style.setProperty("--clip-start", String(newStart));
+ clipElement.style.setProperty("--clip-length", String(newLength));
+ this.feedbackElements.dragTimeTooltip = showDragTimeTooltip(this.feedbackElements, newStart, e.clientX - rect.left, e.clientY - rect.top);
+ } else {
+ // Resize from right edge
+ const newLength = sec(Math.max(0.1, time - originalStart));
+
+ clipElement.style.setProperty("--clip-length", String(newLength));
+ this.feedbackElements.dragTimeTooltip = showDragTimeTooltip(
+ this.feedbackElements,
+ sec(originalStart + newLength),
+ e.clientX - rect.left,
+ e.clientY - rect.top
+ );
+ }
}
- /** @internal */
- private handlePointerMove(event: PIXI.FederatedPointerEvent): void {
+ private onPointerUp(e: PointerEvent): void {
switch (this.state.type) {
- case "selecting":
- this.handleSelectingMove(event);
+ case "pending":
+ // Was just a click, selection already handled
+ this.state = IDLE_STATE;
break;
case "dragging":
- this.timeline.getPixiApp().canvas.style.cursor = "grabbing";
- this.dragHandler.updateDrag(event);
+ this.completeDrag(e, this.state);
break;
case "resizing":
- this.timeline.getPixiApp().canvas.style.cursor = "ew-resize";
- this.resizeHandler.updateResize(event);
- break;
- case "idle":
- this.updateCursorForPosition(event);
+ this.completeResize(e, this.state);
break;
default:
- // No action needed for other states
break;
}
}
- /** @internal */
- private handlePointerUp(event: PIXI.FederatedPointerEvent): void {
- switch (this.state.type) {
- case "selecting":
- // Complete selection
- this.timeline.getEdit().selectClip(this.state.clipInfo.trackIndex, this.state.clipInfo.clipIndex);
+ private completeDrag(_e: PointerEvent, state: DraggingState): void {
+ const { clipRef, clipElement, ghost, originalStyles, dragTarget, collisionResult, altKeyHeld, startTime, originalTrack } = state;
+
+ // 1. Restore clip element styles
+ restoreClipElementStyles(clipElement, originalStyles);
+
+ // 2. Determine action
+ const draggedClip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex);
+ const targetClip =
+ dragTarget.type === "track" ? this.findContentClipAtPositionOnTrack(dragTarget.trackIndex, collisionResult.newStartTime, clipRef) : null;
+ const existingLumaRef =
+ targetClip && dragTarget.type === "track" ? this.stateManager.findAttachedLuma(targetClip.trackIndex, targetClip.clipIndex) : null;
+
+ const action = determineDropAction({
+ dragTarget,
+ draggedAssetType: draggedClip?.config.asset?.type,
+ altKeyHeld,
+ targetClip,
+ existingLumaRef,
+ draggedClipRef: clipRef,
+ startTime,
+ newTime: collisionResult.newStartTime,
+ originalTrack,
+ pushOffset: collisionResult.pushOffset
+ });
+
+ // 3. Execute action
+ this.executeDropAction(state, action, targetClip, existingLumaRef);
+
+ // 4. Cleanup
+ ghost.remove();
+ this.feedbackElements = clearAllFeedback(this.feedbackElements, clipElement);
+ this.state = IDLE_STATE;
+ }
+
+ private executeDropAction(state: DraggingState, action: DropAction, targetClip: ClipState | null, existingLumaRef: ClipRef | null): void {
+ switch (action.type) {
+ case "transform-and-attach":
+ if (existingLumaRef) {
+ console.warn("Cannot attach luma: target clip already has a luma mask");
+ this.executeNormalMove(state, { type: "simple-move" });
+ } else {
+ this.executeTransformAndAttach(state, action.targetClip);
+ }
break;
- case "dragging":
- this.dragHandler.completeDrag(event);
+
+ case "reattach-luma":
+ this.executeReattachLuma(state, action.targetClip);
break;
- case "resizing":
- this.resizeHandler.completeResize(event);
+
+ case "detach-luma":
+ if (existingLumaRef && targetClip) {
+ console.warn("Cannot attach luma: target clip already has a luma mask");
+ }
+ this.executeDetachLuma(state);
+ this.executeNormalMove(state, { type: "simple-move" });
break;
- default:
- // No action needed for other states
+
+ case "insert-track":
+ case "move-with-push":
+ case "simple-move":
+ case "no-change":
+ this.executeNormalMove(state, action);
break;
+
+ default: {
+ const exhaustiveCheck: never = action;
+ throw new Error(`Unhandled action type: ${(exhaustiveCheck as DropAction).type}`);
+ }
+ }
+ }
+
+ private executeTransformAndAttach(state: DraggingState, targetClip: ClipState): void {
+ const { clipRef, originalTrack, dragTarget } = state;
+ if (dragTarget.type !== "track") return;
+
+ const command = new MoveAndAttachLumaCommand(
+ originalTrack, // fromTrackIndex
+ clipRef.clipIndex, // fromClipIndex
+ dragTarget.trackIndex, // toTrackIndex
+ targetClip.trackIndex, // contentTrackIndex
+ targetClip.clipIndex, // contentClipIndex
+ sec(targetClip.config.start) // targetStart
+ );
+
+ this.edit.executeEditCommand(command);
+
+ this.playLumaAttachAnimation();
+ this.config.onRequestRender?.();
+ }
+
+ private executeReattachLuma(state: DraggingState, targetClip: ClipState): void {
+ const { clipRef, startTime, originalTrack, dragTarget } = state;
+ if (dragTarget.type !== "track") return;
+
+ const newTime = targetClip.config.start;
+
+ // Get Player references BEFORE move
+ const lumaPlayer = this.edit.getPlayerClip(clipRef.trackIndex, clipRef.clipIndex);
+ const contentPlayer = this.edit.getPlayerClip(targetClip.trackIndex, targetClip.clipIndex);
+
+ if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) {
+ const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, sec(newTime));
+ this.edit.executeEditCommand(command);
}
- this.resetState();
+ // Re-establish luma→content relationship using stable clip IDs
+ const lumaIndices = lumaPlayer ? this.edit.findClipIndices(lumaPlayer) : null;
+ if (lumaIndices && lumaPlayer?.clipId && contentPlayer?.clipId) {
+ // Update relationship
+ this.edit.setLumaContentRelationship(lumaPlayer.clipId, contentPlayer.clipId);
+
+ // Sync timing to match content
+ lumaPlayer.setResolvedTiming({
+ start: contentPlayer.getStart(),
+ length: contentPlayer.getLength()
+ });
+ lumaPlayer.reconfigureAfterRestore();
+
+ // Update document (bypassing command for this immediate sync)
+ this.edit.getDocument()?.updateClip(lumaIndices.trackIndex, lumaIndices.clipIndex, {
+ start: contentPlayer.getStart(),
+ length: contentPlayer.getLength()
+ });
+ }
+ }
+
+ private executeDetachLuma(state: DraggingState): void {
+ const { clipRef } = state;
+
+ // Get the clip's asset to infer original type
+ const clip = this.edit.getClip(clipRef.trackIndex, clipRef.clipIndex);
+ if (!clip?.asset) return;
+
+ const { src } = clip.asset as { src?: string };
+ if (!src) return;
+
+ // Infer original type from URL extension
+ const originalType = inferAssetTypeFromUrl(src);
+
+ const detachCmd = new DetachLumaCommand(clipRef.trackIndex, clipRef.clipIndex, originalType);
+ this.edit.executeEditCommand(detachCmd);
}
- /** @internal */
- private handleSelectingMove(event: PIXI.FederatedPointerEvent): void {
- if (this.state.type !== "selecting") return;
+ private executeNormalMove(state: DraggingState, action: DropAction): void {
+ const { clipRef, originalTrack, dragTarget, collisionResult, startTime } = state;
+ const newTime = collisionResult.newStartTime;
- const currentPos = { x: event.global.x, y: event.global.y };
+ // Get attached luma Player BEFORE move
+ const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex);
- if (this.dragHandler.canStartDrag(this.state.startPos, currentPos)) {
- if (this.dragHandler.startDrag(this.state.clipInfo, event)) {
- const dragInfo = this.dragHandler.getDragInfo();
- if (dragInfo) {
- this.state = {
- type: "dragging",
- dragInfo
- };
+ switch (action.type) {
+ case "insert-track": {
+ const command = new CreateTrackAndMoveClipCommand(action.insertionIndex, originalTrack, clipRef.clipIndex, sec(newTime));
+ this.edit.executeEditCommand(command);
+ this.moveLumaWithContent(lumaPlayer, action.insertionIndex, newTime);
+ break;
+ }
+ case "move-with-push": {
+ if (dragTarget.type !== "track") return;
+ const command = new MoveClipWithPushCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, sec(newTime), sec(action.pushOffset));
+ this.edit.executeEditCommand(command);
+ this.moveLumaWithContent(lumaPlayer, dragTarget.trackIndex, newTime);
+ break;
+ }
+ case "simple-move": {
+ if (dragTarget.type !== "track") return;
+ if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) {
+ const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, sec(newTime));
+ this.edit.executeEditCommand(command);
+ this.moveLumaWithContent(lumaPlayer, dragTarget.trackIndex, newTime);
}
+ break;
}
+ case "no-change":
+ // Nothing to do
+ break;
+ default:
+ // Other action types handled elsewhere
+ break;
}
}
- /** @internal */
- private updateCursorForPosition(event: PIXI.FederatedPointerEvent): void {
- const target = event.target as PIXI.Container;
+ private playLumaAttachAnimation(): void {
+ if (this.feedbackElements.lumaTargetClipElement) {
+ const targetElement = this.feedbackElements.lumaTargetClipElement;
+ targetElement.classList.remove("ss-clip-luma-target");
+ targetElement.classList.add("ss-clip-luma-attached");
+ setTimeout(() => targetElement.classList.remove("ss-clip-luma-attached"), 600);
+ this.feedbackElements.lumaTargetClipElement = null;
+ }
+ hideLumaConnectionLine(this.feedbackElements.lumaConnectionLine);
+ }
+
+ private completeResize(e: PointerEvent, state: ResizingState): void {
+ const { clipRef, edge, originalStart, originalLength } = state;
+
+ const rect = this.tracksContainer.getBoundingClientRect();
+ const scrollX = this.tracksContainer.scrollLeft;
+ const pps = this.stateManager.getViewport().pixelsPerSecond;
- if (target.label) {
- const clipInfo = this.parseClipLabel(target.label);
- if (clipInfo) {
- const resizeCursor = this.resizeHandler.getCursorForPosition(clipInfo, event);
- if (resizeCursor) {
- this.timeline.getPixiApp().canvas.style.cursor = resizeCursor;
- return;
+ const x = e.clientX - rect.left + scrollX;
+ let time: Seconds = sec(Math.max(0, x / pps));
+
+ // Apply snapping
+ const snappedTime = this.applySnap(time);
+ if (snappedTime !== null) {
+ time = snappedTime;
+ }
+
+ // Get attached luma Player reference BEFORE changes (stable across index changes)
+ const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex);
+
+ if (edge === "left") {
+ // Resize from left edge (keep end fixed, change start and length)
+ const originalEnd = originalStart + originalLength;
+ const newStart = Math.max(0, Math.min(time, originalEnd - 0.1));
+ const newLength = originalEnd - newStart;
+
+ if (newStart !== originalStart || newLength !== originalLength) {
+ // Move clip to new start position
+ if (newStart !== originalStart) {
+ const moveCommand = new MoveClipCommand(clipRef.trackIndex, clipRef.clipIndex, clipRef.trackIndex, sec(newStart));
+ this.edit.executeEditCommand(moveCommand);
+ }
+
+ // Resize clip to new length
+ if (newLength !== originalLength) {
+ const resizeCommand = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, sec(newLength));
+ this.edit.executeEditCommand(resizeCommand);
+ }
+
+ // Also update attached luma clip
+ if (lumaPlayer) {
+ const lumaIndices = this.edit.findClipIndices(lumaPlayer);
+ if (lumaIndices) {
+ if (newStart !== originalStart) {
+ const lumaMoveCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, lumaIndices.trackIndex, sec(newStart));
+ this.edit.executeEditCommand(lumaMoveCommand);
+ }
+ if (newLength !== originalLength) {
+ const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, sec(newLength));
+ this.edit.executeEditCommand(lumaResizeCommand);
+ }
+ }
+ }
+ }
+ } else {
+ // Resize from right edge (keep start fixed, change length)
+ const newLength = Math.max(0.1, time - originalStart);
+
+ if (newLength !== originalLength) {
+ const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, sec(newLength));
+ this.edit.executeEditCommand(command);
+
+ // Also resize attached luma to match
+ if (lumaPlayer) {
+ const lumaIndices = this.edit.findClipIndices(lumaPlayer);
+ if (lumaIndices) {
+ const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, sec(newLength));
+ this.edit.executeEditCommand(lumaResizeCommand);
+ }
}
- // Show grab cursor for draggable clips
- this.timeline.getPixiApp().canvas.style.cursor = "grab";
- return;
}
}
- // Default cursor
- this.timeline.getPixiApp().canvas.style.cursor = "default";
+ // Cleanup
+ hideSnapLine(this.feedbackElements.snapLine);
+ hideDragTimeTooltip(this.feedbackElements.dragTimeTooltip);
+ this.state = IDLE_STATE;
}
- /** @internal */
- private parseClipLabel(label: string): ClipInfo | null {
- if (!label?.startsWith("clip-")) {
- return null;
- }
+ private moveLumaWithContent(lumaPlayer: ReturnType, targetTrack: number, newTime: number): void {
+ if (!lumaPlayer) return;
+ const lumaIndices = this.edit.findClipIndices(lumaPlayer);
+ if (!lumaIndices) return;
+ const cmd = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, targetTrack, sec(newTime));
+ this.edit.executeEditCommand(cmd);
+ }
- const parts = label.split("-");
- if (parts.length !== 3) {
- return null;
+ /** Resolve clip collision based on clip boundaries (delegates to pure function) */
+ private resolveClipCollisionOnTrack(trackIndex: number, desiredStart: Seconds, clipLength: Seconds, excludeClip: ClipRef): CollisionResult {
+ const track = this.stateManager.getTracks()[trackIndex];
+ if (!track) {
+ return { newStartTime: desiredStart, pushOffset: sec(0) };
}
- const trackIndex = parseInt(parts[1], 10);
- const clipIndex = parseInt(parts[2], 10);
+ return resolveClipCollision({
+ track,
+ desiredStart,
+ clipLength,
+ excludeClip
+ });
+ }
+
+ /** Find a non-luma content clip at the given position on a track (delegates to pure function) */
+ private findContentClipAtPositionOnTrack(trackIndex: number, time: Seconds, excludeClipRef?: ClipRef): ClipState | null {
+ const track = this.stateManager.getTracks()[trackIndex];
+ if (!track) return null;
+
+ return findContentClipAtPosition({
+ track,
+ time,
+ excludeClip: excludeClipRef
+ });
+ }
+
+ private buildSnapPointsForClip(excludeClip: ClipRef): void {
+ const playback = this.stateManager.getPlayback();
+ const tracks = this.stateManager.getTracks();
- if (Number.isNaN(trackIndex) || Number.isNaN(clipIndex)) {
- return null;
+ this.snapPoints = buildSnapPoints({
+ tracks,
+ playheadTime: playback.time,
+ excludeClip
+ });
+ }
+
+ private applySnap(time: Seconds): Seconds | null {
+ const pps = this.stateManager.getViewport().pixelsPerSecond;
+
+ return findNearestSnapPoint({
+ time,
+ snapPoints: this.snapPoints,
+ snapThresholdPx: this.config.snapThreshold,
+ pixelsPerSecond: pps
+ });
+ }
+
+ /** Get drag target at Y position (delegates to pure function) */
+ private getDragTargetAtYPosition(y: number): DragTarget {
+ const tracks = this.stateManager.getTracks();
+ return getDragTargetAtY(y, tracks);
+ }
+
+ private ensureTrackYCache(): number[] {
+ if (!this.trackYCache) {
+ const tracks = this.stateManager.getTracks();
+ this.trackYCache = buildTrackYPositions(tracks);
}
+ return this.trackYCache;
+ }
- return { trackIndex, clipIndex };
+ private getTrackYPositionCached(trackIndex: number): number {
+ const cache = this.ensureTrackYCache();
+ return getTrackYPosition(trackIndex, cache);
}
- /** @internal */
- private resetState(): void {
- this.state = { type: "idle" };
- this.visualFeedback.hideAll();
- this.timeline.getPixiApp().canvas.style.cursor = "default";
+ // ========== Visual State Queries ==========
+
+ public isDragging(trackIndex: number, clipIndex: number): boolean {
+ if (this.state.type !== "dragging") return false;
+ return this.state.clipRef.trackIndex === trackIndex && this.state.clipRef.clipIndex === clipIndex;
+ }
+
+ public isResizing(trackIndex: number, clipIndex: number): boolean {
+ if (this.state.type !== "resizing") return false;
+ return this.state.clipRef.trackIndex === trackIndex && this.state.clipRef.clipIndex === clipIndex;
}
public dispose(): void {
- this.deactivate();
- this.dragHandler.dispose();
- this.resizeHandler.dispose();
- this.visualFeedback.dispose();
+ this.tracksContainer.removeEventListener("pointerdown", this.handlePointerDown);
+ document.removeEventListener("pointermove", this.handlePointerMove);
+ document.removeEventListener("pointerup", this.handlePointerUp);
+
+ // Dispose all feedback elements (stateless, idempotent)
+ disposeFeedbackElements(this.feedbackElements);
}
}
diff --git a/src/components/timeline/interaction/interaction-feedback.ts b/src/components/timeline/interaction/interaction-feedback.ts
new file mode 100644
index 00000000..15e94e61
--- /dev/null
+++ b/src/components/timeline/interaction/interaction-feedback.ts
@@ -0,0 +1,361 @@
+import type { Seconds } from "@core/timing/types";
+
+import type { ClipState } from "../timeline.types";
+import { getTrackHeight } from "../timeline.types";
+
+import { formatDragTime, secondsToPixels } from "./interaction-calculations";
+
+// ─── Types ─────────────────────────────────────────────────────────────────
+
+export interface FeedbackElements {
+ readonly container: HTMLElement;
+ snapLine: HTMLElement | null;
+ dropZone: HTMLElement | null;
+ dragTimeTooltip: HTMLElement | null;
+ lumaConnectionLine: HTMLElement | null;
+ lumaTargetClipElement: HTMLElement | null;
+}
+
+export interface FeedbackConfig {
+ readonly pixelsPerSecond: number;
+ readonly scrollLeft: number;
+ readonly tracksOffset: number;
+}
+
+export interface DragFeedbackInput {
+ readonly clipTime: Seconds;
+ readonly tooltipX: number;
+ readonly tooltipY: number;
+ readonly isSnapActive: boolean;
+ readonly showDropZone: boolean;
+ readonly dropZoneTrackY: number;
+ readonly lumaTarget: {
+ readonly clip: ClipState;
+ readonly trackIndex: number;
+ readonly trackYPosition: number;
+ readonly trackHeight: number;
+ } | null;
+ readonly draggingClipElement: HTMLElement | null;
+}
+
+export interface ResizeFeedbackInput {
+ readonly time: Seconds;
+ readonly isSnapActive: boolean;
+ readonly tooltipX: number;
+ readonly tooltipY: number;
+}
+
+// ─── Element Factory ───────────────────────────────────────────────────────
+
+export function getOrCreateElement(container: HTMLElement, existing: HTMLElement | null, className: string): HTMLElement {
+ if (existing) return existing;
+ const el = document.createElement("div");
+ el.className = className;
+ container.appendChild(el);
+ return el;
+}
+
+// ─── Snap Line ─────────────────────────────────────────────────────────────
+
+export function showSnapLine(elements: FeedbackElements, time: Seconds, config: FeedbackConfig): HTMLElement {
+ const snapLine = getOrCreateElement(elements.container, elements.snapLine, "ss-snap-line");
+ const x = secondsToPixels(time, config.pixelsPerSecond) - config.scrollLeft;
+ snapLine.style.left = `${x}px`;
+ snapLine.style.display = "block";
+ return snapLine;
+}
+
+export function hideSnapLine(element: HTMLElement | null): void {
+ if (!element) return;
+ element.style.display = "none"; // eslint-disable-line no-param-reassign -- DOM manipulation
+}
+
+// ─── Drop Zone ─────────────────────────────────────────────────────────────
+
+export function showDropZone(elements: FeedbackElements, trackY: number, tracksOffset: number): HTMLElement {
+ const dropZone = getOrCreateElement(elements.container, elements.dropZone, "ss-drop-zone");
+ dropZone.style.top = `${trackY - 2 + tracksOffset}px`;
+ dropZone.style.display = "block";
+ return dropZone;
+}
+
+export function hideDropZone(element: HTMLElement | null): void {
+ if (!element) return;
+ element.style.display = "none"; // eslint-disable-line no-param-reassign -- DOM manipulation
+}
+
+// ─── Drag Time Tooltip ─────────────────────────────────────────────────────
+
+export function showDragTimeTooltip(elements: FeedbackElements, time: Seconds, x: number, y: number): HTMLElement {
+ const tooltip = getOrCreateElement(elements.container, elements.dragTimeTooltip, "ss-drag-time-tooltip");
+ tooltip.textContent = formatDragTime(time);
+ tooltip.style.left = `${x}px`;
+ tooltip.style.top = `${y - 28}px`;
+ tooltip.style.display = "block";
+ return tooltip;
+}
+
+export function hideDragTimeTooltip(element: HTMLElement | null): void {
+ if (!element) return;
+ element.style.display = "none"; // eslint-disable-line no-param-reassign -- DOM manipulation
+}
+
+// ─── Luma Connection Line ──────────────────────────────────────────────────
+
+export function showLumaConnectionLine(
+ elements: FeedbackElements,
+ targetClip: ClipState,
+ trackYPosition: number,
+ trackHeight: number,
+ tracksOffset: number,
+ pixelsPerSecond: number
+): HTMLElement {
+ const line = getOrCreateElement(elements.container, elements.lumaConnectionLine, "ss-luma-connection-line");
+ const clipX = secondsToPixels(targetClip.config.start, pixelsPerSecond);
+ line.style.left = `${clipX}px`;
+ line.style.top = `${trackYPosition + tracksOffset}px`;
+ line.style.height = `${trackHeight}px`;
+ line.classList.add("active");
+ return line;
+}
+
+export function hideLumaConnectionLine(line: HTMLElement | null): void {
+ if (line) line.classList.remove("active");
+}
+
+// ─── Luma Target Highlight ─────────────────────────────────────────────────
+
+export interface LumaHighlightResult {
+ readonly targetClipElement: HTMLElement | null;
+ readonly connectionLine: HTMLElement | null;
+}
+
+export function updateLumaTargetHighlight(
+ tracksContainer: HTMLElement,
+ elements: FeedbackElements,
+ draggingClipElement: HTMLElement | null,
+ targetClip: ClipState | null,
+ trackIndex: number,
+ trackYPosition: number,
+ trackHeight: number,
+ tracksOffset: number,
+ pixelsPerSecond: number
+): LumaHighlightResult {
+ // Clear previous highlight
+ if (elements.lumaTargetClipElement) {
+ elements.lumaTargetClipElement.classList.remove("ss-clip-luma-target");
+ }
+
+ // Hide connection line and clear dragging clip indicator if no target
+ if (!targetClip) {
+ hideLumaConnectionLine(elements.lumaConnectionLine);
+ if (draggingClipElement) {
+ draggingClipElement.classList.remove("ss-clip-luma-has-target");
+ }
+ return { targetClipElement: null, connectionLine: elements.lumaConnectionLine };
+ }
+
+ // Find and highlight new target
+ const clipElement = tracksContainer.querySelector(
+ `[data-track-index="${trackIndex}"][data-clip-index="${targetClip.clipIndex}"]`
+ ) as HTMLElement | null;
+
+ if (clipElement) {
+ clipElement.classList.add("ss-clip-luma-target");
+
+ // Add indicator to dragging clip (shows mask icon via ::after)
+ if (draggingClipElement) {
+ draggingClipElement.classList.add("ss-clip-luma-has-target");
+ }
+
+ // Show connection line from ghost to target
+ const connectionLine = showLumaConnectionLine(elements, targetClip, trackYPosition, trackHeight, tracksOffset, pixelsPerSecond);
+
+ return { targetClipElement: clipElement, connectionLine };
+ }
+
+ return { targetClipElement: null, connectionLine: elements.lumaConnectionLine };
+}
+
+export function clearLumaFeedback(elements: FeedbackElements, draggingClipElement: HTMLElement | null): void {
+ if (elements.lumaTargetClipElement) {
+ elements.lumaTargetClipElement.classList.remove("ss-clip-luma-target");
+ }
+ if (draggingClipElement) {
+ draggingClipElement.classList.remove("ss-clip-luma-has-target");
+ }
+ hideLumaConnectionLine(elements.lumaConnectionLine);
+}
+
+// ─── Ghost Creation ────────────────────────────────────────────────────────
+
+export function createDragGhost(clipLength: Seconds, clipAssetType: string, trackAssetType: string, pixelsPerSecond: number): HTMLElement {
+ const ghost = document.createElement("div");
+ ghost.className = "ss-drag-ghost ss-clip";
+ ghost.dataset["assetType"] = clipAssetType;
+
+ const width = secondsToPixels(clipLength, pixelsPerSecond);
+ const trackHeight = getTrackHeight(trackAssetType);
+
+ ghost.style.width = `${width}px`;
+ ghost.style.height = `${trackHeight - 8}px`;
+ ghost.style.position = "absolute";
+ ghost.style.pointerEvents = "none";
+ ghost.style.opacity = "0.8";
+
+ return ghost;
+}
+
+// ─── Position Calculation ──────────────────────────────────────────────────
+
+export function getTracksOffsetInFeedbackLayer(feedbackLayer: HTMLElement, tracksContainer: HTMLElement): number {
+ const feedbackParent = feedbackLayer.parentElement;
+ if (!feedbackParent) return 0;
+
+ const parentRect = feedbackParent.getBoundingClientRect();
+ const tracksRect = tracksContainer.getBoundingClientRect();
+ return tracksRect.top - parentRect.top;
+}
+
+// ─── Aggregate Render Functions ────────────────────────────────────────────
+
+export function renderDragFeedback(
+ tracksContainer: HTMLElement,
+ elements: FeedbackElements,
+ input: DragFeedbackInput,
+ config: FeedbackConfig
+): FeedbackElements {
+ // Snap line
+ let { snapLine } = elements;
+ if (input.isSnapActive) {
+ snapLine = showSnapLine(elements, input.clipTime, config);
+ } else {
+ hideSnapLine(elements.snapLine);
+ }
+
+ // Drop zone
+ let { dropZone } = elements;
+ if (input.showDropZone) {
+ dropZone = showDropZone(elements, input.dropZoneTrackY, config.tracksOffset);
+ } else {
+ hideDropZone(elements.dropZone);
+ }
+
+ // Time tooltip
+ const dragTimeTooltip = showDragTimeTooltip(elements, input.clipTime, input.tooltipX, input.tooltipY);
+
+ // Luma feedback
+ let { lumaTargetClipElement, lumaConnectionLine } = elements;
+
+ if (input.lumaTarget) {
+ const lumaResult = updateLumaTargetHighlight(
+ tracksContainer,
+ elements,
+ input.draggingClipElement,
+ input.lumaTarget.clip,
+ input.lumaTarget.trackIndex,
+ input.lumaTarget.trackYPosition,
+ input.lumaTarget.trackHeight,
+ config.tracksOffset,
+ config.pixelsPerSecond
+ );
+ lumaTargetClipElement = lumaResult.targetClipElement;
+ lumaConnectionLine = lumaResult.connectionLine;
+ } else {
+ clearLumaFeedback(elements, input.draggingClipElement);
+ lumaTargetClipElement = null;
+ }
+
+ return {
+ container: elements.container,
+ snapLine,
+ dropZone,
+ dragTimeTooltip,
+ lumaConnectionLine,
+ lumaTargetClipElement
+ };
+}
+
+export function renderResizeFeedback(elements: FeedbackElements, input: ResizeFeedbackInput, config: FeedbackConfig): FeedbackElements {
+ // Snap line
+ let { snapLine } = elements;
+ if (input.isSnapActive) {
+ snapLine = showSnapLine(elements, input.time, config);
+ } else {
+ hideSnapLine(elements.snapLine);
+ }
+
+ // Time tooltip
+ const dragTimeTooltip = showDragTimeTooltip(elements, input.time, input.tooltipX, input.tooltipY);
+
+ return {
+ ...elements,
+ snapLine,
+ dragTimeTooltip
+ };
+}
+
+export function clearAllFeedback(elements: FeedbackElements, draggingClipElement: HTMLElement | null = null): FeedbackElements {
+ hideSnapLine(elements.snapLine);
+ hideDropZone(elements.dropZone);
+ hideDragTimeTooltip(elements.dragTimeTooltip);
+ clearLumaFeedback(elements, draggingClipElement);
+
+ return {
+ container: elements.container,
+ snapLine: elements.snapLine, // Keep pooled elements
+ dropZone: elements.dropZone,
+ dragTimeTooltip: elements.dragTimeTooltip,
+ lumaConnectionLine: elements.lumaConnectionLine,
+ lumaTargetClipElement: null
+ };
+}
+
+export function createFeedbackElements(container: HTMLElement): FeedbackElements {
+ return {
+ container,
+ snapLine: null,
+ dropZone: null,
+ dragTimeTooltip: null,
+ lumaConnectionLine: null,
+ lumaTargetClipElement: null
+ };
+}
+
+export function disposeFeedbackElements(elements: FeedbackElements): void {
+ if (elements.snapLine) {
+ elements.snapLine.remove();
+ }
+ if (elements.dropZone) {
+ elements.dropZone.remove();
+ }
+ if (elements.dragTimeTooltip) {
+ elements.dragTimeTooltip.remove();
+ }
+ if (elements.lumaConnectionLine) {
+ elements.lumaConnectionLine.remove();
+ }
+ if (elements.lumaTargetClipElement) {
+ elements.lumaTargetClipElement.classList.remove("ss-clip-luma-target");
+ }
+}
+
+// ─── Clip Element Style Restoration ────────────────────────────────────────
+
+export interface ClipOriginalStyles {
+ readonly position: string;
+ readonly left: string;
+ readonly top: string;
+ readonly zIndex: string;
+ readonly pointerEvents: string;
+}
+
+export function restoreClipElementStyles(clipElement: HTMLElement, originalStyles: ClipOriginalStyles): void {
+ clipElement.style.position = originalStyles.position; // eslint-disable-line no-param-reassign -- DOM manipulation
+ clipElement.style.left = originalStyles.left; // eslint-disable-line no-param-reassign -- DOM manipulation
+ clipElement.style.top = originalStyles.top; // eslint-disable-line no-param-reassign -- DOM manipulation
+ clipElement.style.zIndex = originalStyles.zIndex; // eslint-disable-line no-param-reassign -- DOM manipulation
+ clipElement.style.pointerEvents = originalStyles.pointerEvents; // eslint-disable-line no-param-reassign -- DOM manipulation
+ clipElement.style.width = ""; // eslint-disable-line no-param-reassign -- DOM manipulation
+ clipElement.style.height = ""; // eslint-disable-line no-param-reassign -- DOM manipulation
+}
diff --git a/src/components/timeline/interaction/interaction-state.ts b/src/components/timeline/interaction/interaction-state.ts
new file mode 100644
index 00000000..aba49f08
--- /dev/null
+++ b/src/components/timeline/interaction/interaction-state.ts
@@ -0,0 +1,160 @@
+import { type Seconds, sec } from "@core/timing/types";
+
+import type { ClipRef, CollisionResult, DragTarget } from "./interaction-calculations";
+
+export type {
+ ClipRef,
+ SnapPoint,
+ CollisionResult,
+ DragTarget,
+ DragBehavior,
+ DetermineDragBehaviorInput,
+ DropAction,
+ DetermineDropActionInput
+} from "./interaction-calculations";
+export { determineDropAction } from "./interaction-calculations";
+
+// ─── Point ─────────────────────────────────────────────────────────────────
+
+export interface Point {
+ readonly x: number;
+ readonly y: number;
+}
+
+// ─── Clip Original Styles ──────────────────────────────────────────────────
+
+export interface ClipOriginalStyles {
+ readonly position: string;
+ readonly left: string;
+ readonly top: string;
+ readonly zIndex: string;
+ readonly pointerEvents: string;
+}
+
+// ─── State Machine ─────────────────────────────────────────────────────────
+
+export type InteractionState = IdleState | PendingState | DraggingState | ResizingState;
+
+export interface IdleState {
+ readonly type: "idle";
+}
+
+export interface PendingState {
+ readonly type: "pending";
+ readonly startPoint: Point;
+ readonly clipRef: ClipRef;
+ readonly originalTime: Seconds;
+}
+
+export interface DraggingState {
+ readonly type: "dragging";
+ readonly clipRef: ClipRef;
+ readonly clipElement: HTMLElement;
+ readonly ghost: HTMLElement;
+ readonly startTime: Seconds;
+ readonly originalTrack: number;
+ readonly dragOffsetX: number;
+ readonly dragOffsetY: number;
+ readonly originalStyles: ClipOriginalStyles;
+ readonly draggedClipLength: Seconds;
+ // Ephemeral drag state (updated each frame)
+ readonly dragTarget: DragTarget;
+ readonly collisionResult: CollisionResult;
+ readonly altKeyHeld: boolean;
+}
+
+export interface ResizingState {
+ readonly type: "resizing";
+ readonly clipRef: ClipRef;
+ readonly clipElement: HTMLElement;
+ readonly edge: "left" | "right";
+ readonly originalStart: Seconds;
+ readonly originalLength: Seconds;
+}
+
+// ─── Constants ─────────────────────────────────────────────────────────────
+
+export const IDLE_STATE: IdleState = { type: "idle" };
+
+// ─── State Factory Functions ───────────────────────────────────────────────
+
+export function createPendingState(startPoint: Point, clipRef: ClipRef, originalTime: Seconds): PendingState {
+ return { type: "pending", startPoint, clipRef, originalTime };
+}
+
+export function createDraggingState(
+ pending: PendingState,
+ clipElement: HTMLElement,
+ ghost: HTMLElement,
+ dragOffsetX: number,
+ dragOffsetY: number,
+ originalStyles: ClipOriginalStyles,
+ clipLength: Seconds,
+ altKeyHeld: boolean
+): DraggingState {
+ return {
+ type: "dragging",
+ clipRef: pending.clipRef,
+ clipElement,
+ ghost,
+ startTime: pending.originalTime,
+ originalTrack: pending.clipRef.trackIndex,
+ dragOffsetX,
+ dragOffsetY,
+ originalStyles,
+ draggedClipLength: clipLength,
+ // Initial ephemeral state
+ dragTarget: { type: "track", trackIndex: pending.clipRef.trackIndex },
+ collisionResult: { newStartTime: pending.originalTime, pushOffset: sec(0) },
+ altKeyHeld
+ };
+}
+
+export function createResizingState(
+ clipRef: ClipRef,
+ clipElement: HTMLElement,
+ edge: "left" | "right",
+ originalStart: Seconds,
+ originalLength: Seconds
+): ResizingState {
+ return { type: "resizing", clipRef, clipElement, edge, originalStart, originalLength };
+}
+
+// ─── State Update Functions ────────────────────────────────────────────────
+
+export interface DragStateUpdates {
+ readonly dragTarget?: DragTarget;
+ readonly collisionResult?: CollisionResult;
+ readonly altKeyHeld?: boolean;
+}
+
+export function updateDragState(state: DraggingState, updates: DragStateUpdates): DraggingState {
+ return {
+ ...state,
+ dragTarget: updates.dragTarget ?? state.dragTarget,
+ collisionResult: updates.collisionResult ?? state.collisionResult,
+ altKeyHeld: updates.altKeyHeld ?? state.altKeyHeld
+ };
+}
+
+// ─── Type Guards ───────────────────────────────────────────────────────────
+
+export function isIdle(state: InteractionState): state is IdleState {
+ return state.type === "idle";
+}
+
+export function isPending(state: InteractionState): state is PendingState {
+ return state.type === "pending";
+}
+
+export function isDragging(state: InteractionState): state is DraggingState {
+ return state.type === "dragging";
+}
+
+export function isResizing(state: InteractionState): state is ResizingState {
+ return state.type === "resizing";
+}
+
+export function isActive(state: InteractionState): state is DraggingState | ResizingState {
+ return state.type === "dragging" || state.type === "resizing";
+}
diff --git a/src/components/timeline/interaction/resize-handler.ts b/src/components/timeline/interaction/resize-handler.ts
deleted file mode 100644
index bc841c05..00000000
--- a/src/components/timeline/interaction/resize-handler.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { ResizeClipCommand } from "@core/commands/resize-clip-command";
-import * as PIXI from "pixi.js";
-
-import { TimelineInterface, ResizeInfo, ClipInfo, InteractionThresholds, InteractionHandler } from "./types";
-
-export class ResizeHandler implements InteractionHandler {
- private timeline: TimelineInterface;
- private thresholds: InteractionThresholds;
- private resizeInfo: ResizeInfo | null = null;
-
- constructor(timeline: TimelineInterface, thresholds: InteractionThresholds) {
- this.timeline = timeline;
- this.thresholds = thresholds;
- }
-
- public activate(): void {
- // Handler activation if needed
- }
-
- public deactivate(): void {
- this.endResize();
- }
-
- public isOnClipRightEdge(clipInfo: ClipInfo, event: PIXI.FederatedPointerEvent): boolean {
- const track = this.timeline.getVisualTracks()[clipInfo.trackIndex];
- if (!track) return false;
-
- const clip = track.getClip(clipInfo.clipIndex);
- if (!clip) return false;
-
- // Get the clip's right edge position in global coordinates
- const clipContainer = clip.getContainer();
- const clipBounds = clipContainer.getBounds();
- const rightEdgeX = clipBounds.x + clipBounds.width;
-
- // Check if mouse is within threshold of right edge
- const distance = Math.abs(event.global.x - rightEdgeX);
- const threshold = this.getResizeThreshold();
-
- return distance <= threshold;
- }
-
- public startResize(clipInfo: ClipInfo, event: PIXI.FederatedPointerEvent): boolean {
- const clipData = this.timeline.getClipData(clipInfo.trackIndex, clipInfo.clipIndex);
- if (!clipData) return false;
-
- this.resizeInfo = {
- trackIndex: clipInfo.trackIndex,
- clipIndex: clipInfo.clipIndex,
- originalLength: clipData.length,
- startX: event.global.x
- };
-
- // Set cursor
- this.timeline.getPixiApp().canvas.style.cursor = "ew-resize";
-
- // Set visual feedback on the clip
- const track = this.timeline.getVisualTracks()[clipInfo.trackIndex];
- if (track) {
- const clip = track.getClip(clipInfo.clipIndex);
- if (clip) {
- clip.setResizing(true);
- }
- }
-
- this.timeline.getEdit().events.emit("resize:started", this.resizeInfo);
- return true;
- }
-
- public updateResize(event: PIXI.FederatedPointerEvent): void {
- if (!this.resizeInfo) return;
-
- // Calculate new duration based on mouse movement
- const deltaX = event.global.x - this.resizeInfo.startX;
- const pixelsPerSecond = this.timeline.getOptions().pixelsPerSecond || 50;
- const deltaTime = deltaX / pixelsPerSecond;
- const newLength = Math.max(0.1, this.resizeInfo.originalLength + deltaTime);
-
- // Update visual preview
- const track = this.timeline.getVisualTracks()[this.resizeInfo.trackIndex];
- if (track) {
- const clip = track.getClip(this.resizeInfo.clipIndex);
- if (clip) {
- const newWidth = newLength * pixelsPerSecond;
- clip.setPreviewWidth(newWidth);
-
- this.timeline.getEdit().events.emit("resize:updated", { width: newWidth });
- }
- }
- }
-
- public completeResize(event: PIXI.FederatedPointerEvent): void {
- if (!this.resizeInfo) return;
-
- // Calculate final duration
- const deltaX = event.global.x - this.resizeInfo.startX;
- const pixelsPerSecond = this.timeline.getOptions().pixelsPerSecond || 50;
- const deltaTime = deltaX / pixelsPerSecond;
- const newLength = Math.max(0.1, this.resizeInfo.originalLength + deltaTime);
-
- // Clear visual preview first
- const track = this.timeline.getVisualTracks()[this.resizeInfo.trackIndex];
- if (track) {
- const clip = track.getClip(this.resizeInfo.clipIndex);
- if (clip) {
- clip.setResizing(false);
- clip.setPreviewWidth(null);
- }
- }
-
- // Execute resize command if length changed significantly
- if (Math.abs(newLength - this.resizeInfo.originalLength) > 0.01) {
- const command = new ResizeClipCommand(this.resizeInfo.trackIndex, this.resizeInfo.clipIndex, newLength);
- this.timeline.getEdit().executeEditCommand(command);
-
- this.timeline.getEdit().events.emit("resize:ended", { newLength });
- }
-
- this.endResize();
- }
-
- public getCursorForPosition(clipInfo: ClipInfo | null, event: PIXI.FederatedPointerEvent): string {
- if (clipInfo && this.isOnClipRightEdge(clipInfo, event)) {
- return "ew-resize";
- }
- return "";
- }
-
- private getResizeThreshold(): number {
- const { trackHeight } = this.timeline.getLayout();
- // More generous scaling for smaller tracks
- return Math.max(this.thresholds.resize.min, Math.min(this.thresholds.resize.max, trackHeight * this.thresholds.resize.ratio));
- }
-
- private endResize(): void {
- this.resizeInfo = null;
- this.timeline.getPixiApp().canvas.style.cursor = "default";
- }
-
- public getResizeInfo(): ResizeInfo | null {
- return this.resizeInfo;
- }
-
- public dispose(): void {
- this.endResize();
- }
-}
diff --git a/src/components/timeline/interaction/snap-manager.ts b/src/components/timeline/interaction/snap-manager.ts
deleted file mode 100644
index f73987d8..00000000
--- a/src/components/timeline/interaction/snap-manager.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-import { TimelineInterface, SnapPoint, SnapResult, AlignmentInfo, InteractionThresholds } from "./types";
-
-export class SnapManager {
- private timeline: TimelineInterface;
- private thresholds: InteractionThresholds;
-
- constructor(timeline: TimelineInterface, thresholds: InteractionThresholds) {
- this.timeline = timeline;
- this.thresholds = thresholds;
- }
-
- public getAllSnapPoints(currentTrackIndex: number, excludeClipIndex?: number): SnapPoint[] {
- const snapPoints: SnapPoint[] = [];
-
- // Get clips from ALL tracks for cross-track alignment
- const tracks = this.timeline.getVisualTracks();
- tracks.forEach((track, trackIdx) => {
- const clips = track.getClips();
- clips.forEach((clip, clipIdx) => {
- // Skip the clip being dragged
- if (trackIdx === currentTrackIndex && clipIdx === excludeClipIndex) return;
-
- const clipConfig = clip.getClipConfig();
- if (clipConfig) {
- const start = clipConfig.start;
- snapPoints.push({
- time: start,
- type: "clip-start",
- trackIndex: trackIdx,
- clipIndex: clipIdx
- });
- snapPoints.push({
- time: start + clipConfig.length,
- type: "clip-end",
- trackIndex: trackIdx,
- clipIndex: clipIdx
- });
- }
- });
- });
-
- // Add playhead position
- const playheadTime = this.timeline.getPlayheadTime();
- snapPoints.push({ time: playheadTime, type: "playhead" });
-
- return snapPoints;
- }
-
- public getTrackSnapPoints(trackIndex: number, excludeClipIndex?: number): SnapPoint[] {
- return this.getAllSnapPoints(trackIndex, excludeClipIndex).filter(point => point.trackIndex === undefined || point.trackIndex === trackIndex);
- }
-
- public calculateSnapPosition(dragTime: number, dragTrack: number, clipDuration: number, excludeClipIndex?: number): SnapResult {
- const pixelsPerSecond = this.timeline.getOptions().pixelsPerSecond || 50;
- const snapThresholdTime = this.thresholds.snap.pixels / pixelsPerSecond;
-
- // Get potential snap points for this track
- const snapPoints = this.getTrackSnapPoints(dragTrack, excludeClipIndex);
-
- // Check snap points for both clip start and clip end
- let closestSnap: { time: number; type: SnapPoint["type"]; distance: number } | null = null;
-
- for (const snapPoint of snapPoints) {
- // Check snap for clip start
- const startDistance = Math.abs(dragTime - snapPoint.time);
- if (startDistance < snapThresholdTime) {
- if (!closestSnap || startDistance < closestSnap.distance) {
- closestSnap = { time: snapPoint.time, type: snapPoint.type, distance: startDistance };
- }
- }
-
- // Check snap for clip end
- const endDistance = Math.abs(dragTime + clipDuration - snapPoint.time);
- if (endDistance < snapThresholdTime) {
- if (!closestSnap || endDistance < closestSnap.distance) {
- // Adjust time so clip end aligns with snap point
- closestSnap = {
- time: snapPoint.time - clipDuration,
- type: snapPoint.type,
- distance: endDistance
- };
- }
- }
- }
-
- if (closestSnap) {
- return { time: closestSnap.time, snapped: true, snapType: closestSnap.type };
- }
-
- return { time: dragTime, snapped: false };
- }
-
- public findAlignedElements(clipStart: number, clipDuration: number, currentTrack: number, excludeClipIndex?: number): AlignmentInfo[] {
- const SNAP_THRESHOLD = 0.1;
- const clipEnd = clipStart + clipDuration;
- const alignments = new Map; isPlayhead: boolean }>();
-
- // Check all tracks for alignments
- this.timeline.getVisualTracks().forEach((track, trackIdx) => {
- track.getClips().forEach((clip, clipIdx) => {
- if (trackIdx === currentTrack && clipIdx === excludeClipIndex) return;
-
- const config = clip.getClipConfig();
- if (!config) return;
-
- const otherStart = config.start;
- const otherEnd = otherStart + config.length;
-
- // Check alignments
- [
- { time: otherStart, aligns: [clipStart, clipEnd] },
- { time: otherEnd, aligns: [clipStart, clipEnd] }
- ].forEach(({ time, aligns }) => {
- if (aligns.some(t => Math.abs(t - time) < SNAP_THRESHOLD)) {
- if (!alignments.has(time)) {
- alignments.set(time, { tracks: new Set(), isPlayhead: false });
- }
- alignments.get(time)!.tracks.add(trackIdx);
- }
- });
- });
- });
-
- // Check playhead alignment
- const playheadTime = this.timeline.getPlayheadTime();
- if (Math.abs(clipStart - playheadTime) < SNAP_THRESHOLD || Math.abs(clipEnd - playheadTime) < SNAP_THRESHOLD) {
- if (!alignments.has(playheadTime)) {
- alignments.set(playheadTime, { tracks: new Set(), isPlayhead: true });
- }
- alignments.get(playheadTime)!.isPlayhead = true;
- }
-
- // Convert to array format
- return Array.from(alignments.entries()).map(([time, data]) => ({
- time,
- tracks: Array.from(data.tracks).concat(currentTrack),
- isPlayhead: data.isPlayhead
- }));
- }
-}
diff --git a/src/components/timeline/interaction/types.ts b/src/components/timeline/interaction/types.ts
deleted file mode 100644
index aee05c44..00000000
--- a/src/components/timeline/interaction/types.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-import { EditCommand } from "@core/commands/types";
-import { EventEmitter } from "@core/events/event-emitter";
-import * as PIXI from "pixi.js";
-
-import { TimelineTheme } from "../../../core/theme";
-import { TimelineLayout } from "../timeline-layout";
-import { ResolvedClipConfig, TimelineOptions } from "../types/timeline";
-
-// Visual component interfaces
-export interface VisualClip {
- getContainer(): PIXI.Container;
- getClipConfig(): ResolvedClipConfig | null;
- setResizing(resizing: boolean): void;
- setPreviewWidth(width: number | null): void;
-}
-
-export interface VisualTrack {
- getClips(): VisualClip[];
- getClip(index: number): VisualClip | null;
-}
-
-// Edit interface
-export interface EditInterface {
- clearSelection(): void;
- selectClip(trackIndex: number, clipIndex: number): void;
- executeEditCommand(command: EditCommand): void;
- events: EventEmitter;
-}
-
-// ClipConfig is imported from ../types/timeline which uses Zod schema
-
-// Core interfaces for dependency injection
-export interface TimelineInterface {
- getPixiApp(): PIXI.Application;
- getLayout(): TimelineLayout;
- getTheme(): TimelineTheme;
- getOptions(): TimelineOptions;
- getVisualTracks(): VisualTrack[];
- getClipData(trackIndex: number, clipIndex: number): ResolvedClipConfig | null;
- getPlayheadTime(): number;
- getExtendedTimelineWidth(): number;
- getContainer(): PIXI.Container;
- getEdit(): EditInterface;
- showDragGhost(track: number, time: number, freeY?: number): void;
- hideDragGhost(): void;
-}
-
-// State types using discriminated unions
-export type InteractionState =
- | { type: "idle" }
- | { type: "selecting"; startPos: Point; clipInfo: ClipInfo }
- | { type: "dragging"; dragInfo: DragInfo }
- | { type: "resizing"; resizeInfo: ResizeInfo };
-
-export interface Point {
- x: number;
- y: number;
-}
-
-export interface ClipInfo {
- trackIndex: number;
- clipIndex: number;
-}
-
-export interface DragInfo {
- trackIndex: number;
- clipIndex: number;
- startTime: number;
- offsetX: number;
- offsetY: number;
-}
-
-export interface ResizeInfo {
- trackIndex: number;
- clipIndex: number;
- originalLength: number;
- startX: number;
-}
-
-export interface DropZone {
- type: "above" | "between" | "below";
- position: number;
-}
-
-// Snap-related types
-export interface SnapPoint {
- time: number;
- type: "clip-start" | "clip-end" | "playhead";
- trackIndex?: number;
- clipIndex?: number;
-}
-
-export interface SnapResult {
- time: number;
- snapped: boolean;
- snapType?: "clip-start" | "clip-end" | "playhead";
-}
-
-export interface AlignmentInfo {
- time: number;
- tracks: number[];
- isPlayhead: boolean;
-}
-
-// Threshold configuration
-export interface InteractionThresholds {
- drag: {
- base: number;
- small: number;
- };
- resize: {
- min: number;
- max: number;
- ratio: number;
- };
- dropZone: {
- ratio: number;
- };
- snap: {
- pixels: number;
- time: number;
- };
-}
-
-// Event types
-export interface InteractionEvents {
- "drag:started": DragInfo;
- "drag:moved": {
- trackIndex: number;
- clipIndex: number;
- startTime: number;
- offsetX: number;
- offsetY: number;
- currentTime: number;
- currentTrack: number;
- };
- "drag:ended": void;
- "resize:started": ResizeInfo;
- "resize:updated": { width: number };
- "resize:ended": { newLength: number };
-}
-
-// Handler interfaces
-export interface InteractionHandler {
- activate(): void;
- deactivate(): void;
- dispose(): void;
-}
diff --git a/src/components/timeline/interaction/visual-feedback-manager.ts b/src/components/timeline/interaction/visual-feedback-manager.ts
deleted file mode 100644
index afde0f17..00000000
--- a/src/components/timeline/interaction/visual-feedback-manager.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { TimelineInterface, DropZone, AlignmentInfo } from "./types";
-
-export class VisualFeedbackManager {
- private timeline: TimelineInterface;
- private graphics: Map = new Map();
-
- constructor(timeline: TimelineInterface) {
- this.timeline = timeline;
- }
-
- public showDropZone(dropZone: DropZone): void {
- this.hideDropZone(); // Clear existing
-
- const graphics = new PIXI.Graphics();
- const layout = this.timeline.getLayout();
- const width = this.timeline.getExtendedTimelineWidth();
- const y = dropZone.position * layout.trackHeight;
-
- const theme = this.timeline.getTheme();
- const color = theme.timeline.trackInsertion;
-
- // Draw highlighted line with thickness
- graphics.setStrokeStyle({ width: 4, color, alpha: 0.8 });
- graphics.moveTo(0, y);
- graphics.lineTo(width, y);
- graphics.stroke();
-
- // Add subtle glow effect
- graphics.setStrokeStyle({ width: 8, color, alpha: 0.3 });
- graphics.moveTo(0, y);
- graphics.lineTo(width, y);
- graphics.stroke();
-
- this.timeline.getContainer().addChild(graphics);
- this.graphics.set("dropZone", graphics);
- }
-
- public hideDropZone(): void {
- this.hideGraphics("dropZone");
- }
-
- public showSnapGuidelines(alignments: AlignmentInfo[]): void {
- this.hideSnapGuidelines(); // Clear existing
-
- const graphics = new PIXI.Graphics();
- const layout = this.timeline.getLayout();
- const theme = this.timeline.getTheme();
-
- alignments.forEach(({ time, tracks, isPlayhead }) => {
- const x = layout.getXAtTime(time);
- const minTrack = Math.min(...tracks);
- const maxTrack = Math.max(...tracks);
-
- const startY = minTrack * layout.trackHeight;
- const endY = (maxTrack + 1) * layout.trackHeight;
-
- const color = isPlayhead ? theme.timeline.playhead : theme.timeline.snapGuide;
-
- // Glow effect
- graphics.setStrokeStyle({ width: 3, color, alpha: 0.3 });
- graphics.moveTo(x, startY);
- graphics.lineTo(x, endY);
- graphics.stroke();
-
- // Core line
- graphics.setStrokeStyle({ width: 1, color, alpha: 0.8 });
- graphics.moveTo(x, startY);
- graphics.lineTo(x, endY);
- graphics.stroke();
- });
-
- this.timeline.getContainer().addChild(graphics);
- this.graphics.set("snapGuidelines", graphics);
- }
-
- public hideSnapGuidelines(): void {
- this.hideGraphics("snapGuidelines");
- }
-
- public showTargetTrack(trackIndex: number): void {
- this.hideTargetTrack(); // Clear existing
-
- const graphics = new PIXI.Graphics();
- const layout = this.timeline.getLayout();
- const width = this.timeline.getExtendedTimelineWidth();
- const y = trackIndex * layout.trackHeight;
- const height = layout.trackHeight;
-
- const theme = this.timeline.getTheme();
- const color = theme.timeline.dropZone;
-
- // Draw subtle highlight for target track
- graphics.rect(0, y, width, height);
- graphics.fill({ color, alpha: 0.1 });
-
- // Add subtle border
- graphics.setStrokeStyle({ width: 1, color, alpha: 0.3 });
- graphics.rect(0, y, width, height);
- graphics.stroke();
-
- this.timeline.getContainer().addChild(graphics);
- this.graphics.set("targetTrack", graphics);
- }
-
- public hideTargetTrack(): void {
- this.hideGraphics("targetTrack");
- }
-
- public hideAll(): void {
- this.graphics.forEach((_, key) => this.hideGraphics(key));
- }
-
- private hideGraphics(key: string): void {
- const graphics = this.graphics.get(key);
- if (graphics) {
- graphics.clear();
- if (graphics.parent) {
- graphics.parent.removeChild(graphics);
- }
- graphics.destroy();
- this.graphics.delete(key);
- }
- }
-
- public dispose(): void {
- this.hideAll();
- this.graphics.clear();
- }
-}
diff --git a/src/components/timeline/managers/drag-preview-manager.ts b/src/components/timeline/managers/drag-preview-manager.ts
deleted file mode 100644
index 1774e759..00000000
--- a/src/components/timeline/managers/drag-preview-manager.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { TimelineLayout } from "../timeline-layout";
-import { ResolvedClipConfig } from "../types/timeline";
-import { VisualTrack } from "../visual/visual-track";
-
-export interface DraggedClipInfo {
- trackIndex: number;
- clipIndex: number;
- clipConfig: ResolvedClipConfig;
-}
-
-export class DragPreviewManager {
- private dragPreviewContainer: PIXI.Container | null = null;
- private dragPreviewGraphics: PIXI.Graphics | null = null;
- private draggedClipInfo: DraggedClipInfo | null = null;
-
- constructor(
- private container: PIXI.Container,
- private layout: TimelineLayout,
- private getPixelsPerSecond: () => number,
- private getTrackHeight: () => number,
- private getVisualTracks: () => VisualTrack[]
- ) {}
-
- public showDragPreview(trackIndex: number, clipIndex: number, clipData: ResolvedClipConfig): void {
- if (!clipData) return;
-
- this.draggedClipInfo = { trackIndex, clipIndex, clipConfig: clipData };
-
- // Create drag preview
- this.dragPreviewContainer = new PIXI.Container();
- this.dragPreviewGraphics = new PIXI.Graphics();
- this.dragPreviewContainer.addChild(this.dragPreviewGraphics);
- this.container.addChild(this.dragPreviewContainer);
-
- // Set original clip to dragging state
- const visualTracks = this.getVisualTracks();
- visualTracks[trackIndex]?.getClip(clipIndex)?.setDragging(true);
-
- // Draw initial preview
- this.drawDragPreview(trackIndex, clipData.start);
- }
-
- /** @internal */
- public drawDragPreview(trackIndex: number, time: number): void {
- if (!this.dragPreviewContainer || !this.dragPreviewGraphics || !this.draggedClipInfo) return;
-
- const { clipConfig } = this.draggedClipInfo;
- const x = this.layout.getXAtTime(time);
- const y = trackIndex * this.layout.trackHeight;
- const width = clipConfig.length * this.getPixelsPerSecond();
-
- // Clear and redraw
- this.dragPreviewGraphics.clear();
- this.dragPreviewGraphics.roundRect(0, 0, width, this.getTrackHeight(), 4);
- this.dragPreviewGraphics.fill({ color: 0x8e8e93, alpha: 0.6 });
- this.dragPreviewGraphics.stroke({ width: 2, color: 0x00ff00 });
-
- // Position
- this.dragPreviewContainer.position.set(x, y);
- }
-
- /** @internal */
- private drawDragPreviewAtPosition(time: number, freeY: number, targetTrack: number): void {
- if (!this.dragPreviewContainer || !this.dragPreviewGraphics || !this.draggedClipInfo) return;
-
- const { clipConfig } = this.draggedClipInfo;
- const x = this.layout.getXAtTime(time);
- const width = clipConfig.length * this.getPixelsPerSecond();
- const height = this.getTrackHeight();
-
- // Clear and redraw
- this.dragPreviewGraphics.clear();
-
- // Draw the ghost preview with free positioning
- this.dragPreviewGraphics.roundRect(0, 0, width, height, 4);
- this.dragPreviewGraphics.fill({ color: 0x8e8e93, alpha: 0.6 });
-
- // Different border color to indicate target track
- const targetY = targetTrack * this.layout.trackHeight;
- const isAligned = Math.abs(freeY - targetY) < 5; // Within 5 pixels of target
- const borderColor = isAligned ? 0x00ff00 : 0xffaa00; // Green if aligned, orange if not
-
- this.dragPreviewGraphics.stroke({ width: 2, color: borderColor });
-
- // Position at free Y
- this.dragPreviewContainer.position.set(x, freeY);
- }
-
- public hideDragPreview(): void {
- if (this.dragPreviewContainer) {
- this.dragPreviewContainer.destroy({ children: true });
- this.dragPreviewContainer = null;
- this.dragPreviewGraphics = null;
- }
-
- // Reset original clip appearance
- if (this.draggedClipInfo) {
- const visualTracks = this.getVisualTracks();
- visualTracks[this.draggedClipInfo.trackIndex]?.getClip(this.draggedClipInfo.clipIndex)?.setDragging(false);
- this.draggedClipInfo = null;
- }
- }
-
- public hideDragGhost(): void {
- if (this.dragPreviewContainer) {
- this.dragPreviewContainer.visible = false;
- }
- }
-
- public showDragGhost(trackIndex: number, time: number, freeY?: number): void {
- if (!this.dragPreviewContainer || !this.draggedClipInfo) return;
- this.dragPreviewContainer.visible = true;
-
- if (freeY !== undefined) {
- // Use free Y position for ghost
- this.drawDragPreviewAtPosition(time, freeY, trackIndex);
- } else {
- // Use track-aligned position
- this.drawDragPreview(trackIndex, time);
- }
- }
-
- public getDraggedClipInfo(): DraggedClipInfo | null {
- return this.draggedClipInfo;
- }
-
- public hasActivePreview(): boolean {
- return this.dragPreviewContainer !== null;
- }
-
- public dispose(): void {
- this.hideDragPreview();
- }
-}
diff --git a/src/components/timeline/managers/index.ts b/src/components/timeline/managers/index.ts
deleted file mode 100644
index fafddb79..00000000
--- a/src/components/timeline/managers/index.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-export { DragPreviewManager } from "./drag-preview-manager";
-export type { DraggedClipInfo } from "./drag-preview-manager";
-
-export { ViewportManager } from "./viewport-manager";
-export type { ViewportState } from "./viewport-manager";
-
-export { VisualTrackManager } from "./visual-track-manager";
-
-export { TimelineEventHandler } from "./timeline-event-handler";
-export type { TimelineEventCallbacks } from "./timeline-event-handler";
-
-export { TimelineRenderer } from "./timeline-renderer";
-export type { RendererOptions } from "./timeline-renderer";
-
-export { TimelineFeatureManager } from "./timeline-feature-manager";
-export type { TimelineFeatures } from "./timeline-feature-manager";
-
-export { TimelineOptionsManager } from "./timeline-options-manager";
diff --git a/src/components/timeline/managers/selection-overlay-renderer.ts b/src/components/timeline/managers/selection-overlay-renderer.ts
deleted file mode 100644
index 4deb011d..00000000
--- a/src/components/timeline/managers/selection-overlay-renderer.ts
+++ /dev/null
@@ -1,82 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { TimelineTheme } from "../../../core/theme";
-import { CLIP_CONSTANTS } from "../constants";
-
-export interface SelectionBounds {
- x: number;
- y: number;
- width: number;
- height: number;
- cornerRadius: number;
- borderWidth: number;
-}
-
-export class SelectionOverlayRenderer {
- private selectionGraphics: Map = new Map();
-
- constructor(
- private overlay: PIXI.Container,
- private theme: TimelineTheme
- ) {}
-
- public renderSelection(clipId: string, bounds: SelectionBounds, isSelected: boolean): void {
- if (!isSelected) {
- this.clearSelection(clipId);
- return;
- }
-
- let graphics = this.selectionGraphics.get(clipId);
- if (!graphics) {
- graphics = new PIXI.Graphics();
- graphics.label = `selection-border-${clipId}`;
- this.selectionGraphics.set(clipId, graphics);
- this.overlay.addChild(graphics);
- }
-
- // Update position
- graphics.position.set(bounds.x, bounds.y);
-
- // Clear and redraw
- graphics.clear();
-
- // Draw selection border
- graphics.setStrokeStyle({
- width: bounds.borderWidth * CLIP_CONSTANTS.SELECTED_BORDER_MULTIPLIER,
- color: this.theme.timeline.clips.selected
- });
- graphics.roundRect(0, 0, bounds.width, bounds.height, bounds.cornerRadius);
- graphics.stroke();
- }
-
- public clearSelection(clipId: string): void {
- const graphics = this.selectionGraphics.get(clipId);
- if (graphics) {
- this.overlay.removeChild(graphics);
- graphics.destroy();
- this.selectionGraphics.delete(clipId);
- }
- }
-
- public clearAllSelections(): void {
- this.selectionGraphics.forEach((_graphics, clipId) => {
- this.clearSelection(clipId);
- });
- }
-
- public updateTheme(theme: TimelineTheme): void {
- this.theme = theme;
- // Force redraw of all selections with new theme
- this.selectionGraphics.forEach(graphics => {
- graphics.clear(); // Will be redrawn on next render
- });
- }
-
- public getOverlay(): PIXI.Container {
- return this.overlay;
- }
-
- public dispose(): void {
- this.clearAllSelections();
- }
-}
diff --git a/src/components/timeline/managers/timeline-event-handler.ts b/src/components/timeline/managers/timeline-event-handler.ts
deleted file mode 100644
index 2b3d4304..00000000
--- a/src/components/timeline/managers/timeline-event-handler.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-import { Edit } from "@core/edit";
-
-import { EditType, ClipConfig } from "../types/timeline";
-
-export interface TimelineEventCallbacks {
- onEditChange: (editType?: EditType) => Promise;
- onSeek: (time: number) => void;
- onClipSelected: (trackIndex: number, clipIndex: number) => void;
- onSelectionCleared: () => void;
- onDragStarted: (trackIndex: number, clipIndex: number) => void;
- onDragEnded: () => void;
-}
-
-export class TimelineEventHandler {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- private boundHandlers = new Map void>();
-
- constructor(
- private edit: Edit,
- private callbacks: TimelineEventCallbacks
- ) {}
-
- public setupEventListeners(): void {
- const events = [
- ["timeline:updated", this.handleTimelineUpdated],
- ["clip:updated", this.handleClipUpdated],
- ["clip:selected", this.handleClipSelected],
- ["selection:cleared", this.handleSelectionCleared],
- ["drag:started", this.handleDragStarted],
- ["drag:ended", this.handleDragEnded],
- ["track:created", this.handleTrackCreated]
- ] as const;
-
- for (const [event, handler] of events) {
- const bound = handler.bind(this);
- this.boundHandlers.set(event, bound);
- this.edit.events.on(event, bound);
- }
- }
-
- private async handleTimelineUpdated(event: { current: EditType }): Promise {
- await this.callbacks.onEditChange(event.current);
- }
-
- private async handleClipUpdated(): Promise {
- await this.callbacks.onEditChange();
- }
-
- private handleClipSelected(event: { clip: ClipConfig; trackIndex: number; clipIndex: number }): void {
- this.callbacks.onClipSelected(event.trackIndex, event.clipIndex);
- }
-
- private handleSelectionCleared(): void {
- this.callbacks.onSelectionCleared();
- }
-
- private handleDragStarted(event: { trackIndex: number; clipIndex: number; startTime: number; offsetX: number; offsetY: number }): void {
- this.callbacks.onDragStarted(event.trackIndex, event.clipIndex);
- }
-
- private handleDragEnded(): void {
- this.callbacks.onDragEnded();
- }
-
- private async handleTrackCreated(): Promise {
- await this.callbacks.onEditChange();
- }
-
- public handleSeek(event: { time: number }): void {
- // Convert timeline seconds to edit milliseconds
- this.callbacks.onSeek(event.time * 1000);
- }
-
- public dispose(): void {
- for (const [event, handler] of this.boundHandlers) {
- this.edit.events.off(event, handler);
- }
- this.boundHandlers.clear();
- }
-}
diff --git a/src/components/timeline/managers/timeline-feature-manager.ts b/src/components/timeline/managers/timeline-feature-manager.ts
deleted file mode 100644
index b4891d40..00000000
--- a/src/components/timeline/managers/timeline-feature-manager.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { Edit } from "@core/edit";
-
-import { TimelineTheme } from "../../../core/theme";
-import {
- RulerFeature,
- PlayheadFeature,
- ScrollManager,
- RulerFeatureOptions,
- PlayheadFeatureOptions,
- ScrollManagerOptions,
- TimelineReference
-} from "../features";
-import { TimelineLayout } from "../timeline-layout";
-import { TimelineToolbar } from "../timeline-toolbar";
-
-import { TimelineEventHandler } from "./timeline-event-handler";
-import { TimelineRenderer } from "./timeline-renderer";
-import { ViewportManager } from "./viewport-manager";
-
-export interface TimelineFeatures {
- toolbar: TimelineToolbar;
- ruler: RulerFeature;
- playhead: PlayheadFeature;
- scroll: ScrollManager;
-}
-
-export class TimelineFeatureManager {
- private toolbar!: TimelineToolbar;
- private ruler!: RulerFeature;
- private playhead!: PlayheadFeature;
- private scroll!: ScrollManager;
-
- constructor(
- private edit: Edit,
- private layout: TimelineLayout,
- private renderer: TimelineRenderer,
- private viewportManager: ViewportManager,
- private eventHandler: TimelineEventHandler,
- private getTimelineContext: () => TimelineReference // Reference back to timeline for scroll
- ) {}
-
- public async setupTimelineFeatures(
- theme: TimelineTheme,
- pixelsPerSecond: number,
- width: number,
- height: number,
- extendedDuration: number
- ): Promise {
- // Create toolbar
- this.toolbar = new TimelineToolbar(this.edit, theme, this.layout, width);
- this.renderer.getStage().addChild(this.toolbar);
-
- // Create ruler feature with extended duration for display
- const rulerOptions: RulerFeatureOptions = {
- pixelsPerSecond,
- timelineDuration: extendedDuration,
- rulerHeight: this.layout.rulerHeight,
- theme
- };
- this.ruler = new RulerFeature(rulerOptions);
- await this.ruler.load();
- this.ruler.getContainer().y = this.layout.rulerY;
- this.viewportManager.getRulerViewport().addChild(this.ruler.getContainer());
-
- // Connect ruler seek events
- this.ruler.events.on("ruler:seeked", this.eventHandler.handleSeek.bind(this.eventHandler));
-
- // Create playhead feature (should span full height including ruler)
- const playheadOptions: PlayheadFeatureOptions = {
- pixelsPerSecond,
- timelineHeight: height,
- theme
- };
- this.playhead = new PlayheadFeature(playheadOptions);
- await this.playhead.load();
- // Position playhead to start from top of ruler
- this.playhead.getContainer().y = this.layout.rulerY;
- // Add playhead to dedicated container that renders above ruler
- this.viewportManager.getPlayheadContainer().addChild(this.playhead.getContainer());
-
- // Connect playhead seek events
- this.playhead.events.on("playhead:seeked", this.eventHandler.handleSeek.bind(this.eventHandler));
-
- // Create scroll manager for handling scroll events
- const scrollOptions: ScrollManagerOptions = {
- timeline: this.getTimelineContext()
- };
- this.scroll = new ScrollManager(scrollOptions);
- await this.scroll.initialize();
-
- // Position viewport and apply initial transform
- this.viewportManager.updateViewportTransform();
- }
-
- public recreateTimelineFeatures(theme: TimelineTheme, pixelsPerSecond: number, height: number, extendedDuration: number): void {
- if (this.ruler) {
- this.ruler.dispose();
- const { rulerHeight } = this.layout;
- const rulerOptions: RulerFeatureOptions = {
- pixelsPerSecond,
- timelineDuration: extendedDuration,
- rulerHeight,
- theme
- };
- this.ruler = new RulerFeature(rulerOptions);
- this.ruler.load();
- this.ruler.getContainer().y = this.layout.rulerY;
- this.viewportManager.getRulerViewport().addChild(this.ruler.getContainer());
- this.ruler.events.on("ruler:seeked", this.eventHandler.handleSeek.bind(this.eventHandler));
- }
-
- if (this.playhead) {
- this.playhead.dispose();
- const playheadOptions: PlayheadFeatureOptions = {
- pixelsPerSecond,
- timelineHeight: height,
- theme
- };
- this.playhead = new PlayheadFeature(playheadOptions);
- this.playhead.load();
- // Position playhead to start from top of ruler
- this.playhead.getContainer().y = this.layout.rulerY;
- // Add playhead to dedicated container that renders above ruler
- this.viewportManager.getPlayheadContainer().addChild(this.playhead.getContainer());
- this.playhead.events.on("playhead:seeked", this.eventHandler.handleSeek.bind(this.eventHandler));
- }
- }
-
- public updateRuler(pixelsPerSecond: number, extendedDuration: number): void {
- this.ruler.updateRuler(pixelsPerSecond, extendedDuration);
- }
-
- public updatePlayhead(pixelsPerSecond: number, timelineHeight: number): void {
- if (this.playhead) {
- this.playhead.updatePlayhead(pixelsPerSecond, timelineHeight);
- }
- }
-
- public getFeatures(): TimelineFeatures {
- return {
- toolbar: this.toolbar,
- ruler: this.ruler,
- playhead: this.playhead,
- scroll: this.scroll
- };
- }
-
- public getToolbar(): TimelineToolbar {
- return this.toolbar;
- }
-
- public getPlayhead(): PlayheadFeature {
- return this.playhead;
- }
-
- public dispose(): void {
- if (this.toolbar) {
- this.toolbar.destroy();
- }
- if (this.ruler) {
- this.ruler.dispose();
- }
- if (this.playhead) {
- this.playhead.dispose();
- }
- if (this.scroll) {
- this.scroll.dispose();
- }
- }
-}
diff --git a/src/components/timeline/managers/timeline-options-manager.ts b/src/components/timeline/managers/timeline-options-manager.ts
deleted file mode 100644
index 565741c2..00000000
--- a/src/components/timeline/managers/timeline-options-manager.ts
+++ /dev/null
@@ -1,134 +0,0 @@
-import { TimelineTheme } from "../../../core/theme";
-import { TimelineLayout } from "../timeline-layout";
-import { TimelineOptions } from "../types/timeline";
-
-export class TimelineOptionsManager {
- private pixelsPerSecond: number;
- private trackHeight: number;
- private backgroundColor: number;
- private antialias: boolean;
- private resolution: number;
- private width: number;
- private height: number;
-
- // Zoom constraints
- private static readonly MIN_PIXELS_PER_SECOND = 10;
- private static readonly MAX_PIXELS_PER_SECOND = 500;
- private static readonly ZOOM_FACTOR = 1.1; // 10% zoom per step
-
- constructor(
- size: { width: number; height: number },
- theme: TimelineTheme,
- private layout: TimelineLayout,
- private onResize?: (width: number) => void
- ) {
- // Set dimensions from size parameter
- this.width = size.width;
- this.height = size.height;
-
- // Set default values for other properties (some from theme)
- this.pixelsPerSecond = 50;
- // Enforce minimum track height of 40px for usability
- const themeTrackHeight = theme.timeline.tracks.height || TimelineLayout.TRACK_HEIGHT_DEFAULT;
- this.trackHeight = Math.max(40, themeTrackHeight);
- this.backgroundColor = theme.timeline.background;
- this.antialias = true;
- this.resolution = window.devicePixelRatio || 1;
- }
-
- public getOptions(): TimelineOptions {
- return {
- width: this.width,
- height: this.height,
- pixelsPerSecond: this.pixelsPerSecond,
- trackHeight: this.trackHeight,
- backgroundColor: this.backgroundColor,
- antialias: this.antialias,
- resolution: this.resolution
- };
- }
-
- public setOptions(options: Partial): void {
- if (options.width !== undefined) {
- this.width = options.width;
- // Notify about width change
- if (this.onResize) {
- this.onResize(this.width);
- }
- }
- if (options.height !== undefined) this.height = options.height;
- if (options.pixelsPerSecond !== undefined) this.pixelsPerSecond = options.pixelsPerSecond;
- if (options.trackHeight !== undefined) this.trackHeight = options.trackHeight;
- if (options.backgroundColor !== undefined) this.backgroundColor = options.backgroundColor;
- if (options.antialias !== undefined) this.antialias = options.antialias;
- if (options.resolution !== undefined) this.resolution = options.resolution;
-
- // Update layout with new options
- this.layout.updateOptions(this.getOptions() as Required);
- }
-
- public updateFromTheme(theme: TimelineTheme): void {
- // Update backgroundColor from theme
- this.backgroundColor = theme.timeline.background;
-
- // Update trackHeight from theme (with minimum of 40px)
- const themeTrackHeight = theme.timeline.tracks.height || TimelineLayout.TRACK_HEIGHT_DEFAULT;
- this.trackHeight = Math.max(40, themeTrackHeight);
-
- // Update layout with new options and theme
- this.layout.updateOptions(this.getOptions() as Required, theme);
- }
-
- // Individual getters
- public getWidth(): number {
- return this.width;
- }
- public getHeight(): number {
- return this.height;
- }
- public getPixelsPerSecond(): number {
- return this.pixelsPerSecond;
- }
- public getTrackHeight(): number {
- return this.trackHeight;
- }
- public getBackgroundColor(): number {
- return this.backgroundColor;
- }
- public getAntialias(): boolean {
- return this.antialias;
- }
- public getResolution(): number {
- return this.resolution;
- }
-
- // Zoom methods
- public zoomIn(): void {
- const newPixelsPerSecond = Math.min(this.pixelsPerSecond * TimelineOptionsManager.ZOOM_FACTOR, TimelineOptionsManager.MAX_PIXELS_PER_SECOND);
- this.setPixelsPerSecond(newPixelsPerSecond);
- }
-
- public zoomOut(): void {
- const newPixelsPerSecond = Math.max(this.pixelsPerSecond / TimelineOptionsManager.ZOOM_FACTOR, TimelineOptionsManager.MIN_PIXELS_PER_SECOND);
- this.setPixelsPerSecond(newPixelsPerSecond);
- }
-
- public setPixelsPerSecond(pixelsPerSecond: number): void {
- // Clamp to valid range
- this.pixelsPerSecond = Math.max(
- TimelineOptionsManager.MIN_PIXELS_PER_SECOND,
- Math.min(TimelineOptionsManager.MAX_PIXELS_PER_SECOND, pixelsPerSecond)
- );
-
- // Update layout with new options
- this.layout.updateOptions(this.getOptions() as Required);
- }
-
- public canZoomIn(): boolean {
- return this.pixelsPerSecond < TimelineOptionsManager.MAX_PIXELS_PER_SECOND;
- }
-
- public canZoomOut(): boolean {
- return this.pixelsPerSecond > TimelineOptionsManager.MIN_PIXELS_PER_SECOND;
- }
-}
diff --git a/src/components/timeline/managers/timeline-renderer.ts b/src/components/timeline/managers/timeline-renderer.ts
deleted file mode 100644
index e1f72ecc..00000000
--- a/src/components/timeline/managers/timeline-renderer.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import * as PIXI from "pixi.js";
-
-export interface RendererOptions {
- width: number;
- height: number;
- backgroundColor: number;
- antialias: boolean;
- resolution: number;
-}
-
-export class TimelineRenderer {
- private app!: PIXI.Application;
- private trackLayer!: PIXI.Container;
- private overlayLayer!: PIXI.Container;
- private animationFrameId: number | null = null;
-
- constructor(
- private options: RendererOptions,
- private onUpdate: (deltaTime: number, elapsed: number) => void
- ) {}
-
- public async initializePixiApp(): Promise {
- this.app = new PIXI.Application();
-
- await this.app.init({
- width: this.options.width,
- height: this.options.height,
- backgroundColor: this.options.backgroundColor,
- antialias: this.options.antialias,
- resolution: this.options.resolution,
- autoDensity: true,
- preference: "webgl"
- });
-
- // Find timeline container element and attach canvas
- const timelineElement = document.querySelector("[data-shotstack-timeline]") as HTMLElement;
- if (!timelineElement) {
- throw new Error("Timeline container element [data-shotstack-timeline] not found");
- }
-
- timelineElement.appendChild(this.app.canvas);
- }
-
- public async setupRenderLayers(): Promise {
- // Create ordered layers for proper z-ordering
- this.trackLayer = new PIXI.Container();
- this.overlayLayer = new PIXI.Container();
-
- // Set up layer properties
- this.trackLayer.label = "track-layer";
- this.overlayLayer.label = "overlay-layer";
-
- // Add layers to stage in correct order
- this.app.stage.addChild(this.trackLayer);
- this.app.stage.addChild(this.overlayLayer);
- }
-
- /** @internal */
- public startAnimationLoop(): void {
- let lastTime = performance.now();
-
- const animate = (currentTime: number) => {
- const deltaMS = currentTime - lastTime;
- lastTime = currentTime;
-
- // Convert to PIXI-style deltaTime (frame-based)
- const deltaTime = deltaMS / 16.667;
-
- this.onUpdate(deltaTime, deltaMS);
- this.draw();
-
- this.animationFrameId = requestAnimationFrame(animate);
- };
-
- this.animationFrameId = requestAnimationFrame(animate);
- }
-
- /** @internal */
- public draw(): void {
- // Render the PIXI application
- this.app.render();
- }
-
- public render(): void {
- this.app.render();
- }
-
- public updateBackgroundColor(color: number): void {
- if (this.app) {
- this.app.renderer.background.color = color;
- }
- }
-
- public getApp(): PIXI.Application {
- return this.app;
- }
-
- public getStage(): PIXI.Container {
- return this.app.stage;
- }
-
- /** @internal */
- public getTrackLayer(): PIXI.Container {
- return this.trackLayer;
- }
-
- /** @internal */
- public getOverlayLayer(): PIXI.Container {
- return this.overlayLayer;
- }
-
- public dispose(): void {
- // Stop animation loop
- if (this.animationFrameId !== null) {
- cancelAnimationFrame(this.animationFrameId);
- this.animationFrameId = null;
- }
-
- // Destroy PIXI application
- if (this.app) {
- this.app.destroy(true);
- }
- }
-}
diff --git a/src/components/timeline/managers/viewport-manager.ts b/src/components/timeline/managers/viewport-manager.ts
deleted file mode 100644
index e6b3bb8f..00000000
--- a/src/components/timeline/managers/viewport-manager.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { TimelineLayout } from "../timeline-layout";
-
-export interface ViewportState {
- x: number;
- y: number;
- zoom: number;
-}
-
-export class ViewportManager {
- private scrollX = 0;
- private scrollY = 0;
- private zoomLevel = 1;
-
- private viewport!: PIXI.Container;
- private rulerViewport!: PIXI.Container;
- private playheadContainer!: PIXI.Container;
-
- constructor(
- private layout: TimelineLayout,
- private trackLayer: PIXI.Container,
- private overlayLayer: PIXI.Container,
- private entityContainer: PIXI.Container,
- private onRender: () => void
- ) {}
-
- public async setupViewport(): Promise {
- // Create ruler viewport for horizontal scrolling
- this.rulerViewport = new PIXI.Container();
- this.rulerViewport.label = "ruler-viewport";
- this.overlayLayer.addChild(this.rulerViewport);
-
- // Create playhead container in overlay layer (above ruler)
- this.playheadContainer = new PIXI.Container();
- this.playheadContainer.label = "playhead-container";
- this.overlayLayer.addChild(this.playheadContainer);
-
- // Create main viewport for tracks
- this.viewport = new PIXI.Container();
- this.viewport.label = "viewport";
-
- // Add viewport to track layer for scrolling
- this.trackLayer.addChild(this.viewport);
-
- // Add our Entity container to viewport (this is where visual tracks will go)
- this.viewport.addChild(this.entityContainer);
- }
-
- /** @internal */
- public updateViewportTransform(): void {
- // Apply scroll transform using layout calculations
- const position = this.layout.calculateViewportPosition(this.scrollX, this.scrollY);
- this.viewport.position.set(position.x, position.y);
- this.viewport.scale.set(this.zoomLevel, this.zoomLevel);
-
- // Sync ruler horizontal scroll (no vertical scroll for ruler)
- this.rulerViewport.position.x = position.x;
- this.rulerViewport.scale.x = this.zoomLevel;
-
- // Sync playhead horizontal scroll
- this.playheadContainer.position.x = position.x;
- this.playheadContainer.scale.x = this.zoomLevel;
- }
-
- public setScroll(x: number, y: number): void {
- this.scrollX = x;
- this.scrollY = y;
- this.updateViewportTransform();
- this.onRender();
- }
-
- public setZoom(zoom: number): void {
- this.zoomLevel = Math.max(0.1, Math.min(10, zoom));
- this.updateViewportTransform();
- this.onRender();
- }
-
- public getViewport(): ViewportState {
- return {
- x: this.scrollX,
- y: this.scrollY,
- zoom: this.zoomLevel
- };
- }
-
- /** @internal */
- public getMainViewport(): PIXI.Container {
- return this.viewport;
- }
-
- /** @internal */
- public getRulerViewport(): PIXI.Container {
- return this.rulerViewport;
- }
-
- /** @internal */
- public getPlayheadContainer(): PIXI.Container {
- return this.playheadContainer;
- }
-}
diff --git a/src/components/timeline/managers/visual-track-manager.ts b/src/components/timeline/managers/visual-track-manager.ts
deleted file mode 100644
index 4a170e87..00000000
--- a/src/components/timeline/managers/visual-track-manager.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { TimelineTheme } from "../../../core/theme";
-import { TimelineLayout } from "../timeline-layout";
-import { EditType, ClipInfo } from "../types/timeline";
-import { VisualTrack, VisualTrackOptions } from "../visual/visual-track";
-
-import { SelectionOverlayRenderer } from "./selection-overlay-renderer";
-
-export class VisualTrackManager {
- private visualTracks: VisualTrack[] = [];
- private selectionOverlay: PIXI.Container;
- private selectionRenderer: SelectionOverlayRenderer;
-
- constructor(
- private container: PIXI.Container,
- private layout: TimelineLayout,
- private theme: TimelineTheme,
- private getPixelsPerSecond: () => number,
- private getExtendedTimelineWidth: () => number
- ) {
- // Create selection overlay container
- this.selectionOverlay = new PIXI.Container();
- this.selectionOverlay.label = "selectionOverlay";
-
- // Add as last child to ensure it renders on top
- this.container.addChild(this.selectionOverlay);
-
- // Create selection renderer
- this.selectionRenderer = new SelectionOverlayRenderer(this.selectionOverlay, this.theme);
- }
-
- public async rebuildFromEdit(editType: EditType, pixelsPerSecond: number): Promise {
- // Create visual representation directly from event payload
- if (!editType?.timeline?.tracks) {
- return;
- }
-
- // Clear all existing visual tracks first to avoid stale event handlers
- this.clearAllVisualState();
-
- // Create visual tracks
- for (let trackIndex = 0; trackIndex < editType.timeline.tracks.length; trackIndex += 1) {
- const trackData = editType.timeline.tracks[trackIndex];
-
- const visualTrackOptions: VisualTrackOptions = {
- pixelsPerSecond,
- trackHeight: this.layout.trackHeight,
- trackIndex,
- width: this.getExtendedTimelineWidth(),
- theme: this.theme,
- selectionRenderer: this.selectionRenderer
- };
-
- const visualTrack = new VisualTrack(visualTrackOptions);
- await visualTrack.load();
-
- // Rebuild track with track data
- visualTrack.rebuildFromTrackData(trackData, pixelsPerSecond);
-
- // Add to container and track array
- this.container.addChild(visualTrack.getContainer());
- this.visualTracks.push(visualTrack);
- }
-
- // Ensure selection overlay stays on top
- this.container.setChildIndex(this.selectionOverlay, this.container.children.length - 1);
- }
-
- public clearAllVisualState(): void {
- // Dispose all visual tracks
- this.visualTracks.forEach(track => {
- this.container.removeChild(track.getContainer());
- track.dispose();
- });
-
- this.visualTracks = [];
-
- // Clear all selections
- this.selectionRenderer.clearAllSelections();
- }
-
- public updateVisualSelection(trackIndex: number, clipIndex: number): void {
- // Clear all existing selections first
- this.clearVisualSelection();
-
- // Set the specified clip as selected
- const track = this.visualTracks[trackIndex];
- if (track) {
- const clip = track.getClip(clipIndex);
- if (clip) {
- clip.setSelected(true);
- }
- }
- }
-
- public clearVisualSelection(): void {
- // Clear selection from all clips
- this.visualTracks.forEach(track => {
- const clips = track.getClips();
- clips.forEach(clip => {
- clip.setSelected(false);
- });
- });
- }
-
- public findClipAtPosition(x: number, y: number): ClipInfo | null {
- // Hit test using visual tracks for accurate positioning
- const trackIndex = Math.floor(y / this.layout.trackHeight);
-
- if (trackIndex < 0 || trackIndex >= this.visualTracks.length) {
- return null;
- }
-
- const visualTrack = this.visualTracks[trackIndex];
- const relativeY = y - trackIndex * this.layout.trackHeight;
-
- const result = visualTrack.findClipAtPosition(x, relativeY);
- if (result) {
- const clipConfig = result.clip.getClipConfig();
- return {
- trackIndex,
- clipIndex: result.clipIndex,
- clipConfig,
- x: clipConfig.start * this.getPixelsPerSecond(),
- y: trackIndex * this.layout.trackHeight,
- width: clipConfig.length * this.getPixelsPerSecond(),
- height: this.layout.trackHeight
- };
- }
-
- return null;
- }
-
- public updateTrackWidths(extendedWidth: number): void {
- this.visualTracks.forEach(track => {
- track.setWidth(extendedWidth);
- });
- }
-
- public getVisualTracks(): VisualTrack[] {
- return this.visualTracks;
- }
-
- public updatePixelsPerSecond(pixelsPerSecond: number): void {
- // Update pixels per second for all existing tracks without rebuilding
- this.visualTracks.forEach(track => {
- track.setPixelsPerSecond(pixelsPerSecond);
- });
- }
-
- public getSelectionOverlay(): PIXI.Container {
- return this.selectionOverlay;
- }
-
- public getSelectionRenderer(): SelectionOverlayRenderer {
- return this.selectionRenderer;
- }
-
- public dispose(): void {
- this.clearAllVisualState();
-
- // Clean up selection renderer and overlay
- if (this.selectionRenderer) {
- this.selectionRenderer.dispose();
- }
-
- if (this.selectionOverlay && this.container) {
- this.container.removeChild(this.selectionOverlay);
- this.selectionOverlay.destroy();
- }
- }
-}
diff --git a/src/components/timeline/media-thumbnail-renderer.ts b/src/components/timeline/media-thumbnail-renderer.ts
new file mode 100644
index 00000000..d0253d5b
--- /dev/null
+++ b/src/components/timeline/media-thumbnail-renderer.ts
@@ -0,0 +1,227 @@
+/**
+ * MediaThumbnailRenderer - Renders thumbnails for video and image clips
+ *
+ * Implements ClipRenderer interface to display visual previews in timeline clips
+ * similar to professional NLE software (Premiere, DaVinci, etc.)
+ *
+ * - Video: Extracts frame at trim point via ThumbnailGenerator
+ * - Image: Loads image directly and uses original URL
+ */
+
+import type { ResolvedClip, ImageAsset, VideoAsset } from "@schemas";
+
+import type { ThumbnailGenerator } from "./thumbnail-generator";
+import type { ClipRenderer } from "./timeline.types";
+
+interface ThumbnailState {
+ loading: boolean;
+ thumbnails: string[];
+ thumbnailWidth: number;
+ failed: boolean;
+}
+
+// Track height used for calculating thumbnail dimensions
+const THUMBNAIL_HEIGHT = 72;
+
+export class MediaThumbnailRenderer implements ClipRenderer {
+ private readonly generator: ThumbnailGenerator;
+ private readonly onRendered: () => void;
+
+ // Track state per clip identity (src|trim|start) - NOT by element reference
+ // This prevents state aliasing when elements are recycled or clips move
+ private clipStates = new Map();
+
+ private appliedToElement = new WeakMap();
+
+ constructor(generator: ThumbnailGenerator, onRendered: () => void = () => {}) {
+ this.generator = generator;
+ this.onRendered = onRendered;
+ }
+
+ /**
+ * Generate stable clip key from content identity.
+ * Key is based on asset source, trim point, and clip start time.
+ */
+ private getClipKey(clip: ResolvedClip): string {
+ const asset = clip.asset as { src?: string; trim?: number; type?: string };
+ return `${asset?.type ?? "unknown"}|${asset?.src ?? "none"}|${asset?.trim ?? 0}|${clip.start}`;
+ }
+
+ render(clip: ResolvedClip, element: HTMLElement): void {
+ const { asset } = clip;
+ const clipKey = this.getClipKey(clip);
+
+ if (this.appliedToElement.get(element) === clipKey) {
+ return;
+ }
+
+ this.clearThumbnailStyles(element);
+
+ if (!asset || !("src" in asset) || !asset.src) {
+ this.appliedToElement.set(element, clipKey);
+ return;
+ }
+
+ if (asset.type === "video") {
+ this.renderVideoThumbnail(element, asset as VideoAsset, clipKey);
+ } else if (asset.type === "image") {
+ this.renderImageThumbnail(element, asset as ImageAsset, clipKey);
+ } else {
+ this.appliedToElement.set(element, clipKey);
+ }
+ }
+
+ private clearThumbnailStyles(el: HTMLElement): void {
+ el.classList.remove("ss-clip--thumbnails", "ss-clip--loading-thumbnails");
+ // eslint-disable-next-line no-param-reassign -- Intentional DOM cleanup
+ el.style.backgroundImage = "";
+ // eslint-disable-next-line no-param-reassign -- Intentional DOM cleanup
+ el.style.backgroundPosition = "";
+ // eslint-disable-next-line no-param-reassign -- Intentional DOM cleanup
+ el.style.backgroundSize = "";
+ // eslint-disable-next-line no-param-reassign -- Intentional DOM cleanup
+ el.style.backgroundRepeat = "";
+ }
+
+ private renderVideoThumbnail(element: HTMLElement, asset: VideoAsset, clipKey: string): void {
+ // Check if we already have cached state for this clip
+ const cachedState = this.clipStates.get(clipKey);
+ if (cachedState && !cachedState.loading && (cachedState.thumbnails.length > 0 || cachedState.failed)) {
+ // Apply cached thumbnail to element (may be a new element for same clip)
+ if (cachedState.thumbnails.length > 0) {
+ this.applyThumbnail(element, cachedState.thumbnails[0], cachedState.thumbnailWidth);
+ }
+ this.appliedToElement.set(element, clipKey);
+ return;
+ }
+
+ this.showLoadingIfNeeded(element, clipKey);
+ this.generateAndApplyVideo(element, asset, clipKey);
+ }
+
+ private renderImageThumbnail(element: HTMLElement, asset: ImageAsset, clipKey: string): void {
+ // Check if we already have cached state for this clip
+ const cachedState = this.clipStates.get(clipKey);
+ if (cachedState && !cachedState.loading && (cachedState.thumbnails.length > 0 || cachedState.failed)) {
+ // Apply cached thumbnail to element (may be a new element for same clip)
+ if (cachedState.thumbnails.length > 0) {
+ this.applyThumbnail(element, cachedState.thumbnails[0], cachedState.thumbnailWidth);
+ }
+ this.appliedToElement.set(element, clipKey);
+ return;
+ }
+
+ this.showLoadingIfNeeded(element, clipKey);
+ this.generateAndApplyImage(element, asset, clipKey);
+ }
+
+ private showLoadingIfNeeded(element: HTMLElement, clipKey: string): void {
+ const state = this.clipStates.get(clipKey) ?? { loading: false, thumbnails: [], thumbnailWidth: 0, failed: false };
+ if (!state.loading && state.thumbnails.length === 0 && !state.failed) {
+ this.setLoadingState(element, true);
+ }
+ }
+
+ private async generateAndApplyVideo(element: HTMLElement, asset: VideoAsset, clipKey: string): Promise {
+ const state: ThumbnailState = { loading: true, thumbnails: [], thumbnailWidth: 0, failed: false };
+ this.clipStates.set(clipKey, state);
+
+ try {
+ const result = await this.generator.generateThumbnail(asset.src, asset.trim ?? 0);
+
+ // Check if element is still in DOM (might have been disposed)
+ if (!element.isConnected) return;
+
+ if (result) {
+ state.thumbnails = [result.dataUrl];
+ state.thumbnailWidth = result.thumbnailWidth;
+ this.applyThumbnail(element, result.dataUrl, result.thumbnailWidth);
+ } else {
+ state.failed = true;
+ }
+ } catch {
+ // Failed to generate thumbnails - fall back to solid color
+ state.failed = true;
+ this.setLoadingState(element, false);
+ } finally {
+ state.loading = false;
+ this.clipStates.set(clipKey, state);
+ this.appliedToElement.set(element, clipKey);
+ this.onRendered();
+ }
+ }
+
+ private async generateAndApplyImage(element: HTMLElement, asset: ImageAsset, clipKey: string): Promise {
+ const state: ThumbnailState = { loading: true, thumbnails: [], thumbnailWidth: 0, failed: false };
+ this.clipStates.set(clipKey, state);
+
+ try {
+ const result = await this.loadImageThumbnail(asset.src);
+
+ // Check if element is still in DOM (might have been disposed)
+ if (!element.isConnected) return;
+
+ if (result) {
+ state.thumbnails = [result.url];
+ state.thumbnailWidth = result.thumbnailWidth;
+ this.applyThumbnail(element, result.url, result.thumbnailWidth);
+ } else {
+ state.failed = true;
+ }
+ } catch {
+ // Failed to load image - fall back to solid color
+ state.failed = true;
+ this.setLoadingState(element, false);
+ } finally {
+ state.loading = false;
+ this.clipStates.set(clipKey, state);
+ this.appliedToElement.set(element, clipKey);
+ this.onRendered();
+ }
+ }
+
+ private loadImageThumbnail(src: string): Promise<{ url: string; thumbnailWidth: number } | null> {
+ return new Promise(resolve => {
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+ img.onload = () => {
+ const aspectRatio = img.naturalWidth / img.naturalHeight;
+ const thumbnailWidth = Math.round(THUMBNAIL_HEIGHT * aspectRatio);
+ resolve({ url: src, thumbnailWidth });
+ };
+ img.onerror = () => resolve(null);
+ img.src = src;
+ });
+ }
+
+ private applyThumbnail(el: HTMLElement, url: string, thumbnailWidth: number): void {
+ el.classList.add("ss-clip--thumbnails");
+ this.setLoadingState(el, false);
+
+ // Single thumbnail with CSS repeat-x tiles across clip width
+ // eslint-disable-next-line no-param-reassign -- Intentional DOM styling
+ el.style.backgroundImage = `url("${url}")`;
+ // eslint-disable-next-line no-param-reassign -- Intentional DOM styling
+ el.style.backgroundSize = `${thumbnailWidth}px 100%`;
+ // eslint-disable-next-line no-param-reassign -- Intentional DOM styling
+ el.style.backgroundRepeat = "repeat-x";
+ // eslint-disable-next-line no-param-reassign -- Intentional DOM styling
+ el.style.backgroundPosition = "left center";
+ }
+
+ private setLoadingState(element: HTMLElement, loading: boolean): void {
+ element.classList.toggle("ss-clip--loading-thumbnails", loading);
+ }
+
+ dispose(el: HTMLElement): void {
+ // Note: We keep clipStates cached - keyed by clip identity, not element.
+ // This allows reuse when same clip gets a new element after move/transform.
+ this.clearThumbnailStyles(el);
+ this.appliedToElement.delete(el);
+ }
+
+ /** Clear all cached thumbnail state */
+ clearCache(): void {
+ this.clipStates.clear();
+ }
+}
diff --git a/src/components/timeline/thumbnail-generator.ts b/src/components/timeline/thumbnail-generator.ts
new file mode 100644
index 00000000..5d5394c6
--- /dev/null
+++ b/src/components/timeline/thumbnail-generator.ts
@@ -0,0 +1,222 @@
+/**
+ * ThumbnailGenerator - Extracts video frames for timeline thumbnail strips
+ *
+ * Creates thumbnail strips by extracting frames at regular intervals from videos.
+ * Designed for timeline UI, not export quality - uses smaller dimensions for performance.
+ */
+
+interface CachedThumbnail {
+ dataUrl: string;
+ thumbnailWidth: number;
+}
+
+export class ThumbnailGenerator {
+ private cache = new Map();
+ private pendingRequests = new Map>();
+ private videoPool = new Map();
+ private extractionCanvas: HTMLCanvasElement | null = null;
+ private extractionContext: CanvasRenderingContext2D | null = null;
+
+ private readonly thumbnailHeight = 72; // Match video track height
+ private readonly maxCacheSize = 50;
+
+ constructor() {
+ this.initCanvas();
+ }
+
+ private initCanvas(): void {
+ this.extractionCanvas = document.createElement("canvas");
+ this.extractionCanvas.height = this.thumbnailHeight;
+ this.extractionContext = this.extractionCanvas.getContext("2d", {
+ willReadFrequently: false,
+ alpha: false
+ });
+ }
+
+ /** Generate a single thumbnail at the trim point */
+ async generateThumbnail(videoSrc: string, trim: number): Promise {
+ const cacheKey = `${videoSrc}|${trim}`;
+
+ const cached = this.cache.get(cacheKey);
+ if (cached) return cached;
+
+ const pending = this.pendingRequests.get(cacheKey);
+ if (pending) return pending;
+
+ const promise = this.extractThumbnail(videoSrc, trim, cacheKey);
+ this.pendingRequests.set(cacheKey, promise);
+
+ try {
+ return await promise;
+ } finally {
+ this.pendingRequests.delete(cacheKey);
+ }
+ }
+
+ private async extractThumbnail(videoSrc: string, trim: number, cacheKey: string): Promise {
+ try {
+ const video = await this.getOrLoadVideo(videoSrc);
+ if (!video) return null;
+
+ const aspectRatio = video.videoWidth / video.videoHeight;
+ const thumbnailWidth = Math.round(this.thumbnailHeight * aspectRatio);
+
+ if (this.extractionCanvas) {
+ this.extractionCanvas.width = thumbnailWidth;
+ this.extractionCanvas.height = this.thumbnailHeight;
+ }
+
+ const dataUrl = await this.extractFrame(video, trim, thumbnailWidth);
+ if (!dataUrl) return null;
+
+ const result: CachedThumbnail = { dataUrl, thumbnailWidth };
+
+ this.enforceMaxCacheSize();
+ this.cache.set(cacheKey, result);
+
+ return result;
+ } catch {
+ return null;
+ }
+ }
+
+ private async extractFrame(video: HTMLVideoElement, time: number, width: number): Promise {
+ if (!this.extractionContext || !this.extractionCanvas) return null;
+
+ try {
+ await this.seekToTime(video, time);
+
+ this.extractionContext.drawImage(video, 0, 0, width, this.thumbnailHeight);
+
+ // Use JPEG for smaller data URLs
+ return this.extractionCanvas.toDataURL("image/jpeg", 0.7);
+ } catch {
+ return null;
+ }
+ }
+
+ private seekToTime(video: HTMLVideoElement, time: number): Promise {
+ return new Promise((resolve, reject) => {
+ // Clamp to video duration
+ const clampedTime = Math.max(0, Math.min(time, video.duration || 0));
+
+ if (Math.abs(video.currentTime - clampedTime) < 0.05) {
+ resolve();
+ return;
+ }
+
+ let timeout: ReturnType;
+
+ const onSeeked = (): void => {
+ clearTimeout(timeout);
+ video.removeEventListener("seeked", onSeeked);
+ // Small delay for frame to render
+ setTimeout(resolve, 10);
+ };
+
+ timeout = setTimeout(() => {
+ video.removeEventListener("seeked", onSeeked);
+ reject(new Error("Seek timeout"));
+ }, 5000);
+
+ video.addEventListener("seeked", onSeeked);
+ // eslint-disable-next-line no-param-reassign -- Intentional video element seek
+ video.currentTime = clampedTime;
+ });
+ }
+
+ private async getOrLoadVideo(src: string): Promise {
+ // Return cached video element
+ const cached = this.videoPool.get(src);
+ if (cached && cached.readyState >= 2) {
+ return cached;
+ }
+
+ // Load new video
+ return new Promise(resolve => {
+ const video = document.createElement("video");
+ video.crossOrigin = "anonymous";
+ video.preload = "auto";
+ video.muted = true;
+ video.playsInline = true;
+
+ let timeout: ReturnType;
+ let cleanedUp = false;
+ let cleanup: () => void;
+
+ const onLoaded = (): void => {
+ if (cleanedUp) return;
+ cleanedUp = true;
+ cleanup();
+ this.videoPool.set(src, video);
+ resolve(video);
+ };
+
+ const onError = (): void => {
+ if (cleanedUp) return;
+ cleanedUp = true;
+ cleanup();
+ resolve(null);
+ };
+
+ // Define cleanup after handlers are declared to avoid use-before-define
+ cleanup = (): void => {
+ clearTimeout(timeout);
+ video.removeEventListener("loadeddata", onLoaded);
+ video.removeEventListener("error", onError);
+ };
+
+ // Timeout fallback for streaming videos that may not fire loadeddata quickly
+ timeout = setTimeout(() => {
+ if (cleanedUp) return;
+ cleanedUp = true;
+ cleanup();
+ if (video.readyState >= 2) {
+ this.videoPool.set(src, video);
+ resolve(video);
+ } else {
+ resolve(null);
+ }
+ }, 10000);
+
+ video.addEventListener("loadeddata", onLoaded);
+ video.addEventListener("error", onError);
+ video.src = src;
+ video.load();
+ });
+ }
+
+ private enforceMaxCacheSize(): void {
+ if (this.cache.size >= this.maxCacheSize) {
+ // Remove oldest entries (first 10)
+ const keys = Array.from(this.cache.keys());
+ for (let i = 0; i < 10 && i < keys.length; i += 1) {
+ this.cache.delete(keys[i]);
+ }
+ }
+ }
+
+ /** Clear cache for a specific video source */
+ clearCacheForSource(videoSrc: string): void {
+ for (const key of this.cache.keys()) {
+ if (key.startsWith(videoSrc)) {
+ this.cache.delete(key);
+ }
+ }
+ }
+
+ /** Clear all caches and release video elements */
+ dispose(): void {
+ this.cache.clear();
+ this.pendingRequests.clear();
+
+ for (const video of this.videoPool.values()) {
+ video.src = "";
+ video.load();
+ }
+ this.videoPool.clear();
+
+ this.extractionCanvas = null;
+ this.extractionContext = null;
+ }
+}
diff --git a/src/components/timeline/timeline-layout.ts b/src/components/timeline/timeline-layout.ts
deleted file mode 100644
index 5cdc3800..00000000
--- a/src/components/timeline/timeline-layout.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-import { TimelineTheme } from "../../core/theme";
-
-import { LAYOUT_CONSTANTS } from "./constants";
-import { TimelineOptions } from "./types/timeline";
-
-export interface TimelineLayoutConfig {
- toolbarHeight: number;
- rulerHeight: number;
- trackHeight: number;
- toolbarY: number;
- rulerY: number;
- tracksY: number;
- gridY: number;
- playheadY: number;
- viewportY: number;
-}
-
-export class TimelineLayout {
- // Use constants from centralized location
- public static readonly TOOLBAR_HEIGHT_RATIO = LAYOUT_CONSTANTS.TOOLBAR_HEIGHT_RATIO;
- public static readonly RULER_HEIGHT_RATIO = LAYOUT_CONSTANTS.RULER_HEIGHT_RATIO;
- public static readonly TOOLBAR_HEIGHT_DEFAULT = LAYOUT_CONSTANTS.TOOLBAR_HEIGHT_DEFAULT;
- public static readonly RULER_HEIGHT_DEFAULT = LAYOUT_CONSTANTS.RULER_HEIGHT_DEFAULT;
- public static readonly TRACK_HEIGHT_DEFAULT = LAYOUT_CONSTANTS.TRACK_HEIGHT_DEFAULT;
- public static readonly CLIP_PADDING = LAYOUT_CONSTANTS.CLIP_PADDING;
- public static readonly BORDER_WIDTH = LAYOUT_CONSTANTS.BORDER_WIDTH;
- public static readonly CORNER_RADIUS = LAYOUT_CONSTANTS.CORNER_RADIUS;
- public static readonly LABEL_PADDING = LAYOUT_CONSTANTS.LABEL_PADDING;
- public static readonly TRACK_PADDING = LAYOUT_CONSTANTS.TRACK_PADDING;
-
- private config: TimelineLayoutConfig;
-
- constructor(
- private options: Required,
- private theme?: TimelineTheme
- ) {
- this.config = this.calculateLayout();
- }
-
- private calculateLayout(): TimelineLayoutConfig {
- // Calculate proportional heights based on timeline height
- const timelineHeight = this.options.height;
-
- // Calculate toolbar and ruler heights proportionally
- // Use theme values if available, otherwise calculate from timeline height
- let toolbarHeight = this.theme?.timeline.toolbar.height || Math.round(timelineHeight * TimelineLayout.TOOLBAR_HEIGHT_RATIO);
- let rulerHeight = this.theme?.timeline.ruler.height || Math.round(timelineHeight * TimelineLayout.RULER_HEIGHT_RATIO);
-
- // Apply minimum heights to ensure usability
- toolbarHeight = Math.max(toolbarHeight, TimelineLayout.TOOLBAR_HEIGHT_DEFAULT);
- rulerHeight = Math.max(rulerHeight, TimelineLayout.RULER_HEIGHT_DEFAULT);
-
- // Track height from options (already validated in Timeline)
- const { trackHeight } = this.options;
-
- return {
- toolbarHeight,
- rulerHeight,
- trackHeight,
- toolbarY: 0,
- rulerY: toolbarHeight,
- tracksY: toolbarHeight + rulerHeight,
- gridY: toolbarHeight + rulerHeight,
- playheadY: toolbarHeight,
- viewportY: toolbarHeight + rulerHeight
- };
- }
-
- // Layout getters
- get toolbarHeight(): number {
- return this.config.toolbarHeight;
- }
-
- get toolbarY(): number {
- return this.config.toolbarY;
- }
-
- get rulerHeight(): number {
- return this.config.rulerHeight;
- }
-
- get trackHeight(): number {
- return this.config.trackHeight;
- }
-
- get rulerY(): number {
- return this.config.rulerY;
- }
-
- get tracksY(): number {
- return this.config.tracksY;
- }
-
- get gridY(): number {
- return this.config.gridY;
- }
-
- get playheadY(): number {
- return this.config.playheadY;
- }
-
- get viewportY(): number {
- return this.config.viewportY;
- }
-
- // Positioning methods
- public positionTrack(trackIndex: number): number {
- return trackIndex * this.trackHeight;
- }
-
- public positionClip(startTime: number): number {
- return startTime * this.options.pixelsPerSecond;
- }
-
- public calculateClipWidth(duration: number): number {
- return Math.max(LAYOUT_CONSTANTS.MIN_CLIP_WIDTH, duration * this.options.pixelsPerSecond);
- }
-
- public calculateDropPosition(globalX: number, globalY: number): { track: number; time: number; x: number; y: number } {
- // Adjust Y to account for ruler
- const adjustedY = globalY - this.tracksY;
-
- const trackIndex = Math.floor(adjustedY / this.trackHeight);
- const time = Math.max(0, globalX / this.options.pixelsPerSecond);
-
- return {
- track: Math.max(0, trackIndex),
- time,
- x: globalX,
- y: adjustedY
- };
- }
-
- public getTrackAtY(y: number): number {
- // Adjust Y to account for ruler
- const adjustedY = y - this.tracksY;
- return Math.floor(adjustedY / this.trackHeight);
- }
-
- public getTimeAtX(x: number): number {
- return x / this.options.pixelsPerSecond;
- }
-
- public getXAtTime(time: number): number {
- return time * this.options.pixelsPerSecond;
- }
-
- public getYAtTrack(trackIndex: number): number {
- return this.tracksY + trackIndex * this.trackHeight;
- }
-
- // Grid and ruler dimensions
- public getGridHeight(): number {
- return this.options.height - this.toolbarHeight - this.rulerHeight;
- }
-
- public getRulerWidth(): number {
- return this.options.width;
- }
-
- public getGridWidth(): number {
- return this.options.width;
- }
-
- // Viewport scroll calculations
- public calculateViewportPosition(scrollX: number, scrollY: number): { x: number; y: number } {
- return {
- x: -scrollX,
- y: this.viewportY - scrollY
- };
- }
-
- // Update layout when options or theme change
- public updateOptions(options: Required, theme?: TimelineTheme): void {
- this.options = options;
- this.theme = theme;
- this.config = this.calculateLayout();
- }
-
- // Utility methods
- public isPointInToolbar(_x: number, y: number): boolean {
- return y >= this.toolbarY && y <= this.toolbarY + this.toolbarHeight;
- }
-
- public isPointInRuler(_x: number, y: number): boolean {
- return y >= this.rulerY && y <= this.rulerY + this.rulerHeight;
- }
-
- public isPointInTracks(_x: number, y: number): boolean {
- return y >= this.tracksY && y <= this.options.height;
- }
-
- public getVisibleTrackRange(scrollY: number, viewportHeight: number): { start: number; end: number } {
- const adjustedScrollY = scrollY;
- const startTrack = Math.floor(adjustedScrollY / this.trackHeight);
- const endTrack = Math.ceil((adjustedScrollY + viewportHeight) / this.trackHeight);
-
- return {
- start: Math.max(0, startTrack),
- end: Math.max(0, endTrack)
- };
- }
-}
diff --git a/src/components/timeline/timeline-state.ts b/src/components/timeline/timeline-state.ts
new file mode 100644
index 00000000..e663d782
--- /dev/null
+++ b/src/components/timeline/timeline-state.ts
@@ -0,0 +1,257 @@
+import type { Player } from "@canvas/players/player";
+import type { Edit } from "@core/edit-session";
+import { EditEvent, InternalEvent } from "@core/events/edit-events";
+import { type Seconds, sec } from "@core/timing/types";
+import type { ResolvedClip, ResolvedTrack } from "@schemas";
+
+import type { TrackState, ClipState, ViewportState, PlaybackState, ClipVisualState, InteractionQuery } from "./timeline.types";
+
+export class TimelineStateManager {
+ private viewport: ViewportState;
+ private interactionQuery: InteractionQuery | null = null;
+ /** Track luma visibility by clip ID for stability across reconciliation */
+ private lumaEditingVisibleByClipId = new Set();
+ private cachedTracks: TrackState[] | null = null;
+
+ constructor(
+ private readonly edit: Edit,
+ initialViewport: Partial = {}
+ ) {
+ this.viewport = {
+ scrollX: 0,
+ scrollY: 0,
+ pixelsPerSecond: initialViewport.pixelsPerSecond ?? 50,
+ width: initialViewport.width ?? 800,
+ height: initialViewport.height ?? 400
+ };
+
+ // Document changes trigger Resolved event
+ this.edit.getInternalEvents().on(InternalEvent.Resolved, this.invalidateCache);
+
+ // Listen on clip/timeline events
+ this.edit.events.on(EditEvent.ClipUpdated, this.invalidateCache);
+ this.edit.events.on(EditEvent.TimelineUpdated, this.invalidateCache);
+
+ // Selection changes are UI state (not document mutations)
+ this.edit.events.on(EditEvent.ClipSelected, this.invalidateCache);
+ this.edit.events.on(EditEvent.SelectionCleared, this.invalidateCache);
+ }
+
+ private invalidateCache = (): void => {
+ this.cachedTracks = null;
+ };
+
+ // ========== Derived from Edit (memoized) ==========
+
+ public getTracks(): TrackState[] {
+ if (this.cachedTracks) {
+ return this.cachedTracks;
+ }
+ const resolvedEdit = this.edit.getResolvedEdit();
+ if (!resolvedEdit?.timeline?.tracks) return [];
+
+ this.cachedTracks = resolvedEdit.timeline.tracks.map((track: ResolvedTrack, trackIndex: number) => {
+ const clips = (track.clips || []).map((clip: ResolvedClip, clipIndex: number) => this.createClipState(clip, trackIndex, clipIndex));
+
+ const primaryAssetType = clips.length > 0 && clips[0].config.asset ? clips[0].config.asset.type || "unknown" : "empty";
+
+ return {
+ index: trackIndex,
+ clips,
+ primaryAssetType
+ };
+ });
+
+ return this.cachedTracks;
+ }
+
+ public getPlayback(): PlaybackState {
+ return {
+ time: this.edit.playbackTime as Seconds,
+ isPlaying: this.edit.isPlaying,
+ duration: this.edit.totalDuration as Seconds
+ };
+ }
+
+ public getClipAt(trackIndex: number, clipIndex: number): ClipState | undefined {
+ const tracks = this.getTracks();
+ return tracks[trackIndex]?.clips.find(c => c.clipIndex === clipIndex);
+ }
+
+ // ========== UI State ==========
+
+ public getViewport(): ViewportState {
+ return this.viewport;
+ }
+
+ public setViewport(updates: Partial): void {
+ this.viewport = { ...this.viewport, ...updates };
+ }
+
+ public setPixelsPerSecond(pps: number): void {
+ this.viewport.pixelsPerSecond = Math.max(10, Math.min(200, pps));
+ }
+
+ public setScroll(scrollX: number, scrollY: number): void {
+ this.viewport.scrollX = scrollX;
+ this.viewport.scrollY = scrollY;
+ }
+
+ public setInteractionQuery(query: InteractionQuery | null): void {
+ this.interactionQuery = query;
+ }
+
+ // ========== Selection (delegate to Edit) ==========
+
+ public selectClip(trackIndex: number, clipIndex: number, _addToSelection: boolean): void {
+ // Delegate to Edit - it owns selection state
+ this.edit.selectClip(trackIndex, clipIndex);
+ }
+
+ public clearSelection(): void {
+ this.edit.clearSelection();
+ }
+
+ public isClipSelected(trackIndex: number, clipIndex: number): boolean {
+ return this.edit.isClipSelected(trackIndex, clipIndex);
+ }
+
+ // ========== Utilities ==========
+
+ public getTimelineDuration(): Seconds {
+ return this.edit.totalDuration as Seconds;
+ }
+
+ public getExtendedDuration(): Seconds {
+ return sec(Math.max(60, this.getTimelineDuration() * 1.5));
+ }
+
+ public getTimelineWidth(): number {
+ return Math.max(this.getExtendedDuration() * this.viewport.pixelsPerSecond, this.viewport.width);
+ }
+
+ // ========== Luma Query Functions ==========
+
+ public findAttachedLuma(contentTrackIdx: number, contentClipIdx: number): { trackIndex: number; clipIndex: number } | null {
+ const tracks = this.getTracks();
+ const track = tracks[contentTrackIdx];
+ if (!track) return null;
+
+ const content = track.clips[contentClipIdx];
+ if (!content || content.config.asset?.type === "luma") return null;
+
+ const contentStart = content.config.start;
+ const contentLength = content.config.length;
+
+ const lumaIndex = track.clips.findIndex(
+ clip => clip.config.asset?.type === "luma" && clip.config.start === contentStart && clip.config.length === contentLength
+ );
+
+ return lumaIndex !== -1 ? { trackIndex: contentTrackIdx, clipIndex: lumaIndex } : null;
+ }
+
+ public findContentForLuma(lumaTrackIdx: number, lumaClipIdx: number): { trackIndex: number; clipIndex: number } | null {
+ const tracks = this.getTracks();
+ const track = tracks[lumaTrackIdx];
+ if (!track) return null;
+
+ const luma = track.clips[lumaClipIdx];
+ if (!luma || luma.config.asset?.type !== "luma") return null;
+
+ const lumaStart = luma.config.start;
+ const lumaLength = luma.config.length;
+
+ const contentIndex = track.clips.findIndex(
+ clip => clip.config.asset?.type !== "luma" && clip.config.start === lumaStart && clip.config.length === lumaLength
+ );
+
+ return contentIndex !== -1 ? { trackIndex: lumaTrackIdx, clipIndex: contentIndex } : null;
+ }
+
+ public hasAttachedLuma(contentTrackIdx: number, contentClipIdx: number): boolean {
+ return this.findAttachedLuma(contentTrackIdx, contentClipIdx) !== null;
+ }
+
+ public getAttachedLumaPlayer(trackIndex: number, clipIndex: number): Player | null {
+ const lumaRef = this.findAttachedLuma(trackIndex, clipIndex);
+ if (!lumaRef) return null;
+ return this.edit.getPlayerClip(lumaRef.trackIndex, lumaRef.clipIndex);
+ }
+
+ // ========== Luma UI State ==========
+
+ public toggleLumaVisibility(contentTrack: number, contentClip: number): boolean {
+ const contentPlayer = this.edit.getPlayerClip(contentTrack, contentClip);
+ if (!contentPlayer?.clipId) return false;
+
+ if (this.lumaEditingVisibleByClipId.has(contentPlayer.clipId)) {
+ this.lumaEditingVisibleByClipId.delete(contentPlayer.clipId);
+ return false; // Now hidden
+ }
+ this.lumaEditingVisibleByClipId.add(contentPlayer.clipId);
+ return true; // Now visible
+ }
+
+ public isLumaVisibleForEditing(contentTrack: number, contentClip: number): boolean {
+ const contentPlayer = this.edit.getPlayerClip(contentTrack, contentClip);
+ if (!contentPlayer?.clipId) return false;
+ return this.lumaEditingVisibleByClipId.has(contentPlayer.clipId);
+ }
+
+ public clearLumaVisibility(): void {
+ this.lumaEditingVisibleByClipId.clear();
+ }
+
+ public clearLumaVisibilityFor(contentPlayer: Player): void {
+ if (contentPlayer.clipId) {
+ this.lumaEditingVisibleByClipId.delete(contentPlayer.clipId);
+ }
+ }
+
+ public clearLumaVisibilityForClipId(clipId: string): void {
+ this.lumaEditingVisibleByClipId.delete(clipId);
+ }
+
+ public dispose(): void {
+ // Remove event listeners
+ this.edit.getInternalEvents().off(InternalEvent.Resolved, this.invalidateCache);
+ this.edit.events.off(EditEvent.ClipUpdated, this.invalidateCache);
+ this.edit.events.off(EditEvent.TimelineUpdated, this.invalidateCache);
+ this.edit.events.off(EditEvent.ClipSelected, this.invalidateCache);
+ this.edit.events.off(EditEvent.SelectionCleared, this.invalidateCache);
+
+ // Clear state
+ this.cachedTracks = null;
+ this.interactionQuery = null;
+ this.lumaEditingVisibleByClipId.clear();
+ }
+
+ // ========== Private ==========
+
+ private getComputedVisualState(trackIndex: number, clipIndex: number, isSelected: boolean): ClipVisualState {
+ if (this.interactionQuery?.isDragging(trackIndex, clipIndex)) return "dragging";
+ if (this.interactionQuery?.isResizing(trackIndex, clipIndex)) return "resizing";
+ if (isSelected) return "selected";
+ return "normal";
+ }
+
+ private createClipState(clip: ResolvedClip, trackIndex: number, clipIndex: number): ClipState {
+ const unresolvedEdit = this.edit.getEdit();
+ const unresolvedClip = unresolvedEdit?.timeline?.tracks?.[trackIndex]?.clips?.[clipIndex];
+
+ const isSelected = this.edit.isClipSelected(trackIndex, clipIndex);
+ const visualState = this.getComputedVisualState(trackIndex, clipIndex, isSelected);
+
+ return {
+ id: clip.id,
+ trackIndex,
+ clipIndex,
+ config: clip,
+ visualState,
+ timingIntent: {
+ start: unresolvedClip?.start === "auto" ? "auto" : clip.start,
+ length: unresolvedClip?.length === "auto" || unresolvedClip?.length === "end" ? unresolvedClip.length : clip.length
+ }
+ };
+ }
+}
diff --git a/src/components/timeline/timeline-toolbar.ts b/src/components/timeline/timeline-toolbar.ts
deleted file mode 100644
index 80af28b9..00000000
--- a/src/components/timeline/timeline-toolbar.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { Edit } from "../../core/edit";
-import { TimelineTheme } from "../../core/theme";
-
-import { TimelineLayout } from "./timeline-layout";
-import { TOOLBAR_CONSTANTS, PlaybackControls, TimeDisplay, EditControls, ToolbarLayout } from "./toolbar";
-
-export class TimelineToolbar extends PIXI.Container {
- private background!: PIXI.Graphics;
- private playbackControls!: PlaybackControls;
- private timeDisplay!: TimeDisplay;
- private editControls!: EditControls;
- private toolbarLayout!: ToolbarLayout;
-
- private toolbarWidth: number;
- private toolbarHeight: number;
-
- public override get width(): number {
- return this.toolbarWidth;
- }
-
- public override get height(): number {
- return this.toolbarHeight;
- }
-
- constructor(
- private edit: Edit,
- private theme: TimelineTheme,
- private layout: TimelineLayout,
- width: number
- ) {
- super();
- this.toolbarWidth = width;
- this.toolbarHeight = layout.toolbarHeight;
-
- // Position at top of timeline
- this.position.set(0, layout.toolbarY);
-
- // Initialize layout manager
- this.toolbarLayout = new ToolbarLayout(width, this.toolbarHeight);
-
- // Create components
- this.createBackground();
- this.createComponents();
- this.positionComponents();
-
- // Subscribe to edit events for updates
- this.subscribeToEditEvents();
- }
-
- private createBackground(): void {
- this.background = new PIXI.Graphics();
- this.drawBackground();
- this.addChild(this.background);
- }
-
- private drawBackground(): void {
- this.background.clear();
- this.background.rect(0, 0, this.toolbarWidth, this.toolbarHeight);
- this.background.fill({ color: this.theme.timeline.toolbar.background });
-
- // Add subtle bottom border to separate from ruler
- this.background.setStrokeStyle({
- width: 1,
- color: this.theme.timeline.toolbar.divider,
- alpha: TOOLBAR_CONSTANTS.DIVIDER_ALPHA
- });
- this.background.moveTo(0, this.toolbarHeight - 0.5);
- this.background.lineTo(this.toolbarWidth, this.toolbarHeight - 0.5);
- this.background.stroke();
- }
-
- private createComponents(): void {
- // Create playback controls
- this.playbackControls = new PlaybackControls(this.edit, this.theme, this.toolbarHeight);
- this.addChild(this.playbackControls);
-
- // Create time display
- this.timeDisplay = new TimeDisplay(this.edit, this.theme);
- this.addChild(this.timeDisplay);
-
- // Create edit controls
- this.editControls = new EditControls(this.edit, this.theme);
- this.addChild(this.editControls);
- }
-
- private positionComponents(): void {
- // Position playback controls
- const playbackPos = this.toolbarLayout.getPlaybackControlsPosition();
- this.playbackControls.position.set(playbackPos.x, playbackPos.y);
-
- // Position time display
- const timePos = this.toolbarLayout.getTimeDisplayPosition(this.playbackControls.getWidth());
- this.timeDisplay.position.set(timePos.x, timePos.y);
-
- // Position edit controls
- const editPos = this.toolbarLayout.getEditControlsPosition();
- this.editControls.position.set(editPos.x, editPos.y);
- }
-
- private subscribeToEditEvents(): void {
- // Listen for selection changes to update edit controls
- this.edit.events.on("clip:selected", this.updateEditControls);
- this.edit.events.on("selection:cleared", this.updateEditControls);
- }
-
- private updateEditControls = (): void => {
- this.editControls.update();
- };
-
- public resize(width: number): void {
- this.toolbarWidth = width;
-
- // Update layout
- this.toolbarLayout.updateWidth(width);
-
- // Redraw background
- this.drawBackground();
-
- // Reposition components
- this.positionComponents();
-
- // Notify components of resize
- this.playbackControls.resize(width);
- this.timeDisplay.resize(width);
- this.editControls.resize(width);
- }
-
- public updateTheme(theme: TimelineTheme): void {
- this.theme = theme;
-
- // Update background
- this.drawBackground();
-
- // Update all components
- this.playbackControls.updateTheme(theme);
- this.timeDisplay.updateTheme(theme);
- this.editControls.updateTheme(theme);
- }
-
- public updateTimeDisplay = (): void => {
- this.timeDisplay.update();
- };
-
- public override destroy(): void {
- // Unsubscribe from events
- this.edit.events.off("clip:selected", this.updateEditControls);
- this.edit.events.off("selection:cleared", this.updateEditControls);
-
- // Destroy components
- this.playbackControls.destroy();
- this.timeDisplay.destroy();
- this.editControls.destroy();
-
- super.destroy();
- }
-}
diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts
index 1e5f2551..fa9406bd 100644
--- a/src/components/timeline/timeline.ts
+++ b/src/components/timeline/timeline.ts
@@ -1,470 +1,542 @@
-import { Edit } from "@core/edit";
-import { Entity } from "@core/shared/entity";
-import * as PIXI from "pixi.js";
-
-import { TimelineTheme, TimelineThemeOptions, TimelineThemeResolver } from "../../core/theme";
-
-import { InteractionController } from "./interaction";
-import {
- DragPreviewManager,
- ViewportManager,
- VisualTrackManager,
- TimelineEventHandler,
- TimelineRenderer,
- TimelineFeatureManager,
- TimelineOptionsManager
-} from "./managers";
-import { TimelineLayout } from "./timeline-layout";
-import { EditType, TimelineOptions, ClipInfo, ResolvedClipConfig } from "./types/timeline";
-import { VisualTrack } from "./visual/visual-track";
-
-export class Timeline extends Entity {
- private currentEditType: EditType | null = null;
- private layout: TimelineLayout;
- private theme: TimelineTheme;
- private lastPlaybackTime = 0;
-
- // Timeline constants
- private static readonly TIMELINE_BUFFER_MULTIPLIER = 1.5; // 50% buffer for scrolling
-
- // Managers
- private interaction!: InteractionController;
- private dragPreviewManager!: DragPreviewManager;
- private viewportManager!: ViewportManager;
- private visualTrackManager!: VisualTrackManager;
- private eventHandler!: TimelineEventHandler;
- private renderer!: TimelineRenderer;
- private featureManager!: TimelineFeatureManager;
- private optionsManager!: TimelineOptionsManager;
+import { CreateTrackMoveAndDetachLumaCommand } from "@core/commands/create-track-move-and-detach-luma-command";
+import type { Edit } from "@core/edit-session";
+import { EditEvent } from "@core/events/edit-events";
+import { computeAiAssetNumber, type ResolvedClipWithId } from "@core/shared/ai-asset-utils";
+import { inferAssetTypeFromUrl } from "@core/shared/asset-utils";
+import { type Seconds, sec } from "@core/timing/types";
+import { injectShotstackStyles } from "@styles/inject";
+import { DEFAULT_PIXELS_PER_SECOND, type ClipRenderer, type ClipInfo } from "@timeline/timeline.types";
+
+import { PlayheadComponent } from "./components/playhead/playhead-component";
+import { RulerComponent } from "./components/ruler/ruler-component";
+import { ToolbarComponent } from "./components/toolbar/toolbar-component";
+import { TrackListComponent } from "./components/track/track-list";
+import { InteractionController } from "./interaction/interaction-controller";
+import { MediaThumbnailRenderer } from "./media-thumbnail-renderer";
+import { ThumbnailGenerator } from "./thumbnail-generator";
+import { TimelineStateManager } from "./timeline-state";
+
+const DEFAULT_SNAP_THRESHOLD = 10;
+
+export class Timeline {
+ public readonly element: HTMLElement;
+ private readonly container: HTMLElement;
+ private readonly stateManager: TimelineStateManager;
+
+ // Custom renderers
+ private clipRenderers = new Map();
+
+ // Media thumbnail generation (video and image)
+ private thumbnailGenerator: ThumbnailGenerator;
+ private mediaThumbnailRenderer: MediaThumbnailRenderer;
+
+ // Components (stored separately from children for typed access)
+ private toolbar: ToolbarComponent | null = null;
+ private rulerTracksWrapper: HTMLElement | null = null;
+ private ruler: RulerComponent | null = null;
+ private trackList: TrackListComponent | null = null;
+ private playhead: PlayheadComponent | null = null;
+ private playheadGhost: HTMLElement | null = null;
+ private feedbackLayer: HTMLElement | null = null;
+ private interactionController: InteractionController | null = null;
+
+ // Hybrid render loop state
+ private animationFrameId: number | null = null;
+ private isRenderLoopActive = false;
+ private lastFrameTime = 0;
+ private isInteracting = false;
+ private isLoaded = false;
+
+ private thumbnailRenderPending = false;
+
+ // Bound event handlers for cleanup
+ private readonly handleTimelineUpdated: () => void;
+ private readonly handlePlaybackPlay: () => void;
+ private readonly handlePlaybackPause: () => void;
+ private readonly handleClipSelected: () => void;
+ private readonly handleClipLoadFailed: () => void;
+ private readonly handleClipUpdated: () => void;
+ private readonly handleRulerMouseMove: (e: MouseEvent) => void;
constructor(
- private edit: Edit,
- size: { width: number; height: number },
- themeOptions?: TimelineThemeOptions
+ private readonly edit: Edit,
+ container: HTMLElement
) {
- super();
+ this.element = document.createElement("div");
+ this.element.className = "ss-html-timeline";
+ this.container = container;
+
+ // Configure root element to fill container
+ this.element.style.width = "100%";
+ this.element.style.height = "100%";
+
+ // Create state manager with placeholder size (will be updated in load())
+ this.stateManager = new TimelineStateManager(edit, {
+ width: 800, // placeholder, updated in load()
+ height: 300, // placeholder, updated in load()
+ pixelsPerSecond: DEFAULT_PIXELS_PER_SECOND
+ });
- // Resolve theme from options
- this.theme = TimelineThemeResolver.resolveTheme(themeOptions);
+ // Initialize media thumbnail generation (video and image)
+ this.thumbnailGenerator = new ThumbnailGenerator();
+ this.mediaThumbnailRenderer = new MediaThumbnailRenderer(this.thumbnailGenerator, () => {
+ if (!this.thumbnailRenderPending) {
+ this.thumbnailRenderPending = true;
+ requestAnimationFrame(() => {
+ this.thumbnailRenderPending = false;
+ this.requestRender();
+ });
+ }
+ });
+ this.clipRenderers.set("video", this.mediaThumbnailRenderer);
+ this.clipRenderers.set("image", this.mediaThumbnailRenderer);
- // Create layout first as it's needed by options manager
- this.layout = new TimelineLayout(
- {
- width: size.width,
- height: size.height,
- pixelsPerSecond: 50,
- trackHeight: Math.max(40, this.theme.timeline.tracks.height || TimelineLayout.TRACK_HEIGHT_DEFAULT),
- backgroundColor: this.theme.timeline.background,
- antialias: true,
- resolution: window.devicePixelRatio || 1
- },
- this.theme
- );
+ // Bind event handlers
+ this.handleTimelineUpdated = () => {
+ this.requestRender();
+ };
+ this.handlePlaybackPlay = () => this.startRenderLoop();
+ this.handlePlaybackPause = () => {
+ this.stopRenderLoop();
+ this.requestRender(); // Final render to update UI with paused state
+ };
+ this.handleClipSelected = () => this.requestRender();
+ this.handleClipLoadFailed = () => this.requestRender();
+ this.handleClipUpdated = () => this.requestRender();
+ this.handleRulerMouseMove = (e: MouseEvent) => {
+ if (!this.playheadGhost || !this.rulerTracksWrapper) return;
+ const rect = this.rulerTracksWrapper.getBoundingClientRect();
+ const scrollX = this.trackList?.element.scrollLeft ?? 0;
+ const x = e.clientX - rect.left + scrollX;
+ this.playheadGhost.style.left = `${x}px`;
+ };
+ }
- // Initialize options manager
- this.optionsManager = new TimelineOptionsManager(size, this.theme, this.layout, width => this.featureManager?.getToolbar()?.resize(width));
+ /** Initialize and mount the timeline */
+ public async load(): Promise {
+ if (this.isLoaded) return;
- this.initializeManagers();
- this.setupInteraction();
- }
+ // Inject styles
+ injectShotstackStyles();
- private initializeManagers(): void {
- const options = this.optionsManager.getOptions();
+ // Mount to container first so we can measure
+ this.container.appendChild(this.element);
- // Initialize renderer with required properties
- this.renderer = new TimelineRenderer(
- {
- width: options.width || 800,
- height: options.height || 600,
- backgroundColor: options.backgroundColor || 0x000000,
- antialias: options.antialias ?? true,
- resolution: options.resolution || window.devicePixelRatio || 1
- },
- (deltaTime, elapsed) => this.update(deltaTime, elapsed)
- );
+ // Get actual size from container
+ const rect = this.container.getBoundingClientRect();
+ const width = rect.width || 800;
+ const height = rect.height || 300;
+ this.stateManager.setViewport({ width, height });
- // Initialize event handler
- this.eventHandler = new TimelineEventHandler(this.edit, {
- onEditChange: this.handleEditChange.bind(this),
- onSeek: time => this.edit.seek(time),
- onClipSelected: (trackIndex, clipIndex) => this.visualTrackManager.updateVisualSelection(trackIndex, clipIndex),
- onSelectionCleared: () => this.visualTrackManager.clearVisualSelection(),
- onDragStarted: (trackIndex, clipIndex) => {
- const clipData = this.getClipData(trackIndex, clipIndex);
- if (clipData) {
- this.dragPreviewManager.showDragPreview(trackIndex, clipIndex, clipData);
- }
- },
- onDragEnded: () => this.dragPreviewManager.hideDragPreview()
- });
+ // Build component structure
+ this.buildComponents();
- this.eventHandler.setupEventListeners();
- }
+ // Set up event listeners for hybrid render loop
+ this.setupEventListeners();
- public async load(): Promise {
- await this.renderer.initializePixiApp();
- await this.renderer.setupRenderLayers();
- await this.setupViewport();
- await this.setupTimelineFeatures();
-
- // Activate interaction system after PIXI is ready
- this.interaction.activate();
-
- // Try to render initial state from Edit
- try {
- const currentEdit = this.edit.getResolvedEdit();
- if (currentEdit) {
- // Cache the initial state for tools to query
- this.currentEditType = currentEdit;
- await this.rebuildFromEdit(currentEdit);
- }
- } catch {
- // Silently handle error - timeline will show empty state
- }
+ // Initial render (data is derived from Edit on-demand)
+ this.draw();
- // Start animation loop for continuous rendering
- this.renderer.startAnimationLoop();
+ this.isLoaded = true;
}
- private async setupViewport(): Promise {
- // Initialize viewport manager
- this.viewportManager = new ViewportManager(this.layout, this.renderer.getTrackLayer(), this.renderer.getOverlayLayer(), this.getContainer(), () =>
- this.renderer.render()
- );
+ /** Render/draw component to DOM (called each frame after update). @internal */
+ public draw(): void {
+ // Derive state from Edit on-demand (single source of truth)
+ const viewport = this.stateManager.getViewport();
+ const playback = this.stateManager.getPlayback();
+ const tracks = this.stateManager.getTracks();
- await this.viewportManager.setupViewport();
+ // Update CSS variable for clip/playhead positioning
+ this.element.style.setProperty("--ss-timeline-pixels-per-second", String(viewport.pixelsPerSecond));
- // Initialize visual track manager
- this.visualTrackManager = new VisualTrackManager(
- this.getContainer(),
- this.layout,
- this.theme,
- () => this.optionsManager.getPixelsPerSecond(),
- () => this.getExtendedTimelineWidth()
- );
+ // Update toolbar
+ this.toolbar?.updatePlayState(playback.isPlaying);
+ this.toolbar?.updateTimeDisplay(playback.time, playback.duration);
+ this.toolbar?.draw();
- // Initialize drag preview manager
- this.dragPreviewManager = new DragPreviewManager(
- this.getContainer(),
- this.layout,
- () => this.optionsManager.getPixelsPerSecond(),
- () => this.optionsManager.getTrackHeight(),
- () => this.visualTrackManager.getVisualTracks()
- );
+ // Update ruler and draw
+ this.ruler?.updateRuler(viewport.pixelsPerSecond, this.stateManager.getExtendedDuration());
+ this.ruler?.draw();
- // Initialize feature manager
- this.featureManager = new TimelineFeatureManager(this.edit, this.layout, this.renderer, this.viewportManager, this.eventHandler, () => this);
+ // Update tracks and draw
+ this.trackList?.updateTracks(tracks, this.stateManager.getTimelineWidth(), viewport.pixelsPerSecond);
+ this.trackList?.draw();
- // Initial viewport positioning will be done in setupTimelineFeatures
- // after ruler height is known
+ // Update playhead
+ this.playhead?.setTime(playback.time);
+ this.playhead?.draw();
}
- private async setupTimelineFeatures(): Promise {
- const extendedDuration = this.getExtendedTimelineDuration();
+ /** Clean up and unmount the timeline */
+ public dispose(): void {
+ // Stop animation loop
+ this.stopRenderLoop();
- await this.featureManager.setupTimelineFeatures(
- this.theme,
- this.optionsManager.getPixelsPerSecond(),
- this.optionsManager.getWidth(),
- this.optionsManager.getHeight(),
- extendedDuration
- );
- }
+ // Remove event listeners
+ this.removeEventListeners();
- private recreateTimelineFeatures(): void {
- const extendedDuration = this.getExtendedTimelineDuration();
+ // Dispose state manager
+ this.stateManager.dispose();
- this.featureManager.recreateTimelineFeatures(
- this.theme,
- this.optionsManager.getPixelsPerSecond(),
- this.optionsManager.getHeight(),
- extendedDuration
- );
- }
+ // Dispose components
+ this.disposeComponents();
- // Viewport management methods for tools
- public setScroll(x: number, y: number): void {
- this.viewportManager.setScroll(x, y);
- }
+ // Clean up thumbnail generator
+ this.thumbnailGenerator.dispose();
- public setZoom(zoom: number): void {
- this.viewportManager.setZoom(zoom);
- }
+ // Clean up custom renderers
+ this.clipRenderers.clear();
- public getViewport(): { x: number; y: number; zoom: number } {
- return this.viewportManager.getViewport();
- }
+ // Remove DOM
+ this.element.remove();
- // Combined getter for PIXI resources
- /** @internal */
- public getPixiApp(): PIXI.Application {
- return this.renderer.getApp();
+ this.isLoaded = false;
}
- /** @internal */
- public getTrackLayer(): PIXI.Container {
- return this.renderer.getTrackLayer();
- }
+ // ========== Hybrid Render Loop ==========
- /** @internal */
- public getOverlayLayer(): PIXI.Container {
- return this.renderer.getOverlayLayer();
- }
+ /**
+ * Pre-compute AI asset numbers for all clips.
+ */
+ private computeAiAssetNumbers(): Map {
+ const numbers = new Map();
+ const allClips = this.edit.getResolvedEdit()?.timeline.tracks.flatMap(t => t.clips) ?? [];
- public getClipData(trackIndex: number, clipIndex: number): ResolvedClipConfig | null {
- if (!this.currentEditType?.timeline?.tracks) return null;
- const track = this.currentEditType.timeline.tracks[trackIndex];
- return (track?.clips?.[clipIndex] as ResolvedClipConfig) || null;
- }
+ // Compute number for each clip (only AI assets will get numbers)
+ for (const clip of allClips) {
+ const clipWithId = clip as ResolvedClipWithId;
+ if ("id" in clip && typeof clipWithId.id === "string") {
+ const number = computeAiAssetNumber(allClips, clipWithId.id);
+ if (number !== null) {
+ numbers.set(clipWithId.id, number);
+ }
+ }
+ }
- // Layout access for interactions
- public getLayout(): TimelineLayout {
- return this.layout;
+ return numbers;
}
- // Visual tracks access for interactions
- public getVisualTracks(): VisualTrack[] {
- return this.visualTrackManager.getVisualTracks();
- }
+ private setupEventListeners(): void {
+ // Listen for timeline data changes (single render when idle)
+ this.edit.events.on(EditEvent.TimelineUpdated, this.handleTimelineUpdated);
- // Edit access for interactions
- public getEdit(): Edit {
- return this.edit;
- }
+ // Listen for granular clip/track events
+ this.edit.events.on(EditEvent.ClipAdded, this.handleTimelineUpdated);
+ this.edit.events.on(EditEvent.ClipDeleted, this.handleTimelineUpdated);
+ this.edit.events.on(EditEvent.ClipRestored, this.handleTimelineUpdated);
+ this.edit.events.on(EditEvent.TrackAdded, this.handleTimelineUpdated);
+ this.edit.events.on(EditEvent.TrackRemoved, this.handleTimelineUpdated);
- // Extended timeline dimensions
- public getExtendedTimelineWidth(): number {
- const calculatedWidth = this.getExtendedTimelineDuration() * this.optionsManager.getPixelsPerSecond();
- const viewportWidth = this.optionsManager.getWidth();
- // Ensure width is at least as wide as the viewport
- return Math.max(calculatedWidth, viewportWidth);
- }
+ // Listen for playback state changes (start/stop render loop)
+ this.edit.events.on(EditEvent.PlaybackPlay, this.handlePlaybackPlay);
+ this.edit.events.on(EditEvent.PlaybackPause, this.handlePlaybackPause);
- // Drag ghost control methods for TimelineInteraction
- public hideDragGhost(): void {
- this.dragPreviewManager.hideDragGhost();
- }
+ // Listen for selection changes (from canvas or other sources)
+ this.edit.events.on(EditEvent.ClipSelected, this.handleClipSelected);
- public showDragGhost(trackIndex: number, time: number, freeY?: number): void {
- this.dragPreviewManager.showDragGhost(trackIndex, time, freeY);
- }
+ // Listen for clip updates
+ this.edit.events.on(EditEvent.ClipUpdated, this.handleClipUpdated);
- // Playhead control methods
- public setPlayheadTime(time: number): void {
- this.featureManager.getPlayhead().setTime(time);
+ // Listen for clip load failures (to show error badge on timeline)
+ this.edit.events.on(EditEvent.ClipLoadFailed, this.handleClipLoadFailed);
}
- public getPlayheadTime(): number {
- return this.featureManager.getPlayhead().getTime();
+ private removeEventListeners(): void {
+ this.edit.events.off(EditEvent.TimelineUpdated, this.handleTimelineUpdated);
+ this.edit.events.off(EditEvent.ClipAdded, this.handleTimelineUpdated);
+ this.edit.events.off(EditEvent.ClipDeleted, this.handleTimelineUpdated);
+ this.edit.events.off(EditEvent.ClipRestored, this.handleTimelineUpdated);
+ this.edit.events.off(EditEvent.TrackAdded, this.handleTimelineUpdated);
+ this.edit.events.off(EditEvent.TrackRemoved, this.handleTimelineUpdated);
+ this.edit.events.off(EditEvent.PlaybackPlay, this.handlePlaybackPlay);
+ this.edit.events.off(EditEvent.PlaybackPause, this.handlePlaybackPause);
+ this.edit.events.off(EditEvent.ClipSelected, this.handleClipSelected);
+ this.edit.events.off(EditEvent.ClipUpdated, this.handleClipUpdated);
+ this.edit.events.off(EditEvent.ClipLoadFailed, this.handleClipLoadFailed);
}
- public getActualEditDuration(): number {
- // Return the actual edit duration in seconds (without the 1.5x buffer)
- return this.edit.totalDuration / 1000 || 60;
+ /** Start continuous render loop (during playback or interaction) */
+ private startRenderLoop(): void {
+ if (this.isRenderLoopActive) return;
+ this.isRenderLoopActive = true;
+ this.lastFrameTime = performance.now();
+ this.tick();
}
- /** @internal */
- private setupInteraction(): void {
- this.interaction = new InteractionController(this);
-
- // Interaction will be activated in the load() method after PIXI is ready
+ /** Stop continuous render loop */
+ private stopRenderLoop(): void {
+ this.isRenderLoopActive = false;
+ if (this.animationFrameId !== null) {
+ cancelAnimationFrame(this.animationFrameId);
+ this.animationFrameId = null;
+ }
}
- /** @internal */
- private async handleEditChange(editType?: EditType): Promise {
- // Clean up drag preview before rebuilding
- this.dragPreviewManager.hideDragPreview();
+ /** Animation frame callback */
+ private tick = (): void => {
+ if (!this.isRenderLoopActive) return;
- // Get current edit state (always use resolved values for positioning)
- const currentEdit = editType || this.edit.getResolvedEdit();
- if (!currentEdit) return;
+ const now = performance.now();
+ this.lastFrameTime = now;
- // Cache current state
- this.currentEditType = currentEdit;
+ this.draw();
- // Update ruler with new timeline duration
- this.updateRulerDuration();
+ // Continue loop if playing or interacting
+ if (this.edit.isPlaying || this.isInteracting) {
+ this.animationFrameId = requestAnimationFrame(this.tick);
+ } else {
+ this.isRenderLoopActive = false;
+ this.animationFrameId = null;
+ }
+ };
- // Rebuild visuals from event data
- this.clearAllVisualState();
- await this.rebuildFromEdit(currentEdit);
+ /** Request a single render (used when idle and data changes) */
+ private requestRender(): void {
+ if (this.isRenderLoopActive) return; // Loop already running
+ this.draw();
}
- /** @internal */
- private getExtendedTimelineDuration(): number {
- const duration = this.edit.totalDuration / 1000 || 60;
- return Math.max(60, duration * Timeline.TIMELINE_BUFFER_MULTIPLIER);
+ /** Mark interaction as started (enables render loop). @internal */
+ public beginInteraction(): void {
+ this.isInteracting = true;
+ this.startRenderLoop();
}
- /** @internal */
- private updateRulerDuration(): void {
- const extendedDuration = this.getExtendedTimelineDuration();
- const extendedWidth = this.getExtendedTimelineWidth();
+ /** Mark interaction as ended (may stop render loop). @internal */
+ public endInteraction(): void {
+ this.isInteracting = false;
+ // Loop will stop on next tick if not playing
+ }
- // Update ruler with extended duration
- this.featureManager.updateRuler(this.optionsManager.getPixelsPerSecond(), extendedDuration);
+ // ========== Component Building ==========
- // Update track widths
- this.visualTrackManager.updateTrackWidths(extendedWidth);
- }
+ private buildComponents(): void {
+ // Clear existing content
+ this.element.innerHTML = "";
- /** @internal */
- private clearAllVisualState(): void {
- // Make sure drag preview is cleaned up
- this.dragPreviewManager.hideDragPreview();
+ const viewport = this.stateManager.getViewport();
- // Clear all visual timeline components
- this.visualTrackManager.clearAllVisualState();
- }
+ // Build toolbar
+ this.toolbar = new ToolbarComponent(
+ {
+ onPlay: () => this.edit.play(),
+ onPause: () => this.edit.pause(),
+ onSkipBack: () => this.edit.seek(sec(Math.max(0, this.edit.playbackTime - 1))),
+ onSkipForward: () => this.edit.seek(sec(this.edit.playbackTime + 1)),
+ onZoomChange: pps => this.setZoom(pps)
+ },
+ viewport.pixelsPerSecond
+ );
+ this.element.appendChild(this.toolbar.element);
+
+ // Create wrapper for ruler + tracks + playhead (so playhead can span both)
+ this.rulerTracksWrapper = document.createElement("div");
+ this.rulerTracksWrapper.className = "ss-ruler-tracks-wrapper";
+ this.element.appendChild(this.rulerTracksWrapper);
+
+ // Build ruler
+ this.ruler = new RulerComponent({
+ onSeek: timeSec => this.edit.seek(sec(timeSec)),
+ onWheel: e => {
+ if (this.trackList) {
+ this.trackList.element.scrollTop += e.deltaY;
+ this.trackList.element.scrollLeft += e.deltaX;
+ }
+ }
+ });
+ this.rulerTracksWrapper.appendChild(this.ruler.element);
+
+ // Build track list
+ this.trackList = new TrackListComponent({
+ showBadges: true,
+ onClipSelect: (trackIndex, clipIndex, addToSelection) => {
+ this.stateManager.selectClip(trackIndex, clipIndex, addToSelection);
+ this.requestRender();
+ },
+ getClipRenderer: type => this.clipRenderers.get(type),
+ getClipError: (trackIndex, clipIndex) => this.edit.getClipError(trackIndex, clipIndex),
+ hasAttachedLuma: (trackIndex, clipIndex) => this.stateManager.hasAttachedLuma(trackIndex, clipIndex),
+ findAttachedLuma: (trackIndex, clipIndex) => this.stateManager.findAttachedLuma(trackIndex, clipIndex),
+ onMaskClick: (contentTrackIndex, contentClipIndex) => {
+ const contentClip = this.edit.getResolvedClip(contentTrackIndex, contentClipIndex);
+ const clipId = this.edit.getClipId(contentTrackIndex, contentClipIndex);
+ const lumaRef = this.stateManager.findAttachedLuma(contentTrackIndex, contentClipIndex);
+ if (!lumaRef || !contentClip || !clipId) return;
+
+ const startTime = contentClip.start;
+ const newTrackIndex = contentTrackIndex + 1;
+
+ // Determine target asset type from URL
+ const lumaClip = this.edit.getResolvedClip(lumaRef.trackIndex, lumaRef.clipIndex);
+ const src = (lumaClip?.asset as { src?: string })?.src || "";
+ const targetType = inferAssetTypeFromUrl(src);
+
+ // Single compound command for atomic undo
+ const cmd = new CreateTrackMoveAndDetachLumaCommand(newTrackIndex, lumaRef.trackIndex, lumaRef.clipIndex, sec(startTime), targetType);
+ this.edit.executeEditCommand(cmd);
+
+ this.stateManager.clearLumaVisibilityForClipId(clipId);
+
+ this.requestRender();
+ },
+ isLumaVisibleForEditing: (contentTrackIndex, contentClipIndex) =>
+ this.stateManager.isLumaVisibleForEditing(contentTrackIndex, contentClipIndex),
+ findContentForLuma: (lumaTrack, lumaClip) => this.stateManager.findContentForLuma(lumaTrack, lumaClip),
+ aiAssetNumbers: this.computeAiAssetNumbers()
+ });
- /** @internal */
- private async rebuildFromEdit(editType: EditType): Promise {
- await this.visualTrackManager.rebuildFromEdit(editType, this.optionsManager.getPixelsPerSecond());
- // Force a render
- this.renderer.render();
- }
+ // Set up scroll sync (also sync playhead)
+ this.trackList.setScrollHandler((scrollX, scrollY) => {
+ this.stateManager.setScroll(scrollX, scrollY);
+ this.ruler?.syncScroll(scrollX);
+ // Sync playhead with track scroll
+ if (this.playhead) {
+ this.playhead.element.style.transform = `translateX(${-scrollX}px)`;
+ this.playhead.setScrollX(scrollX);
+ }
+ });
- // Public API for tools to query cached state
- public findClipAtPosition(x: number, y: number): ClipInfo | null {
- if (!this.currentEditType) return null;
- return this.visualTrackManager.findClipAtPosition(x, y);
- }
+ this.rulerTracksWrapper.appendChild(this.trackList.element);
- // Theme management methods
- public setTheme(themeOptions: TimelineThemeOptions): void {
- this.theme = TimelineThemeResolver.resolveTheme(themeOptions);
+ // Build playhead (at wrapper level so it spans ruler + tracks)
+ this.playhead = new PlayheadComponent({
+ onSeek: timeSec => this.edit.seek(sec(timeSec))
+ });
+ this.playhead.setPixelsPerSecond(viewport.pixelsPerSecond);
+ this.rulerTracksWrapper.appendChild(this.playhead.element);
- // Update options manager with new theme
- this.optionsManager.updateFromTheme(this.theme);
+ // Build playhead ghost (hover preview)
+ this.playheadGhost = document.createElement("div");
+ this.playheadGhost.className = "ss-playhead-ghost";
+ this.rulerTracksWrapper.appendChild(this.playheadGhost);
- // Update toolbar theme
- if (this.featureManager.getToolbar()) {
- this.featureManager.getToolbar().updateTheme(this.theme);
- }
+ this.rulerTracksWrapper.addEventListener("mousemove", this.handleRulerMouseMove);
- // Recreate timeline features with new theme and dimensions
- this.recreateTimelineFeatures();
+ // Build feedback layer (inside rulerTracksWrapper so coordinates align with tracks)
+ this.feedbackLayer = document.createElement("div");
+ this.feedbackLayer.className = "ss-feedback-layer";
+ this.rulerTracksWrapper.appendChild(this.feedbackLayer);
- // Rebuild visuals with new theme
- if (this.currentEditType) {
- this.clearAllVisualState();
- this.rebuildFromEdit(this.currentEditType);
- }
+ // Initialize interaction controller
+ this.interactionController = new InteractionController(this.edit, this.stateManager, this.trackList.element, this.feedbackLayer, {
+ snapThreshold: DEFAULT_SNAP_THRESHOLD,
+ onRequestRender: () => this.requestRender()
+ });
+ this.interactionController.mount();
- // Update PIXI app background
- this.renderer.updateBackgroundColor(this.optionsManager.getBackgroundColor());
- this.renderer.render();
+ this.stateManager.setInteractionQuery({
+ isDragging: (t, c) => this.interactionController?.isDragging(t, c) ?? false,
+ isResizing: (t, c) => this.interactionController?.isResizing(t, c) ?? false
+ });
}
- public getTheme(): TimelineTheme {
- return this.theme;
- }
+ private disposeComponents(): void {
+ this.interactionController?.dispose();
+ this.interactionController = null;
- // Getters for current state
- public getCurrentEditType(): EditType | null {
- return this.currentEditType;
- }
+ this.toolbar?.dispose();
+ this.toolbar = null;
- public getOptions(): TimelineOptions {
- return this.optionsManager.getOptions();
- }
+ this.ruler?.dispose();
+ this.ruler = null;
- public setOptions(options: Partial): void {
- this.optionsManager.setOptions(options);
- }
+ this.playhead?.dispose();
+ this.playhead = null;
- // Required Entity methods
- /** @internal */
- public update(_deltaTime: number, _elapsed: number): void {
- // Sync playhead with Edit playback time
- if (this.edit.isPlaying || this.lastPlaybackTime !== this.edit.playbackTime) {
- this.featureManager.getPlayhead().setTime(this.edit.playbackTime / 1000);
- this.lastPlaybackTime = this.edit.playbackTime;
-
- // Update toolbar time display
- if (this.featureManager.getToolbar()) {
- this.featureManager.getToolbar().updateTimeDisplay();
- }
- }
+ this.trackList?.dispose();
+ this.trackList = null;
+
+ // Remove mousemove listener before removing element
+ this.rulerTracksWrapper?.removeEventListener("mousemove", this.handleRulerMouseMove);
+ this.rulerTracksWrapper?.remove();
+ this.rulerTracksWrapper = null;
+
+ this.feedbackLayer?.remove();
+ this.feedbackLayer = null;
}
+ // ========== Public API ==========
+
/** @internal */
- public draw(): void {
- // Render the PIXI application
- this.renderer.draw();
+ public setZoom(pixelsPerSecond: number): void {
+ this.stateManager.setPixelsPerSecond(pixelsPerSecond);
+ this.toolbar?.setZoom(pixelsPerSecond);
+ this.playhead?.setPixelsPerSecond(pixelsPerSecond);
+ this.requestRender();
}
- // Methods for TimelineReference interface
- public getTimeDisplay(): { updateTimeDisplay(): void } {
- return this.featureManager.getToolbar();
+ public zoomIn(): void {
+ const current = this.stateManager.getViewport().pixelsPerSecond;
+ this.setZoom(Math.min(200, current * 1.2));
}
- public updateTime(time: number, emit?: boolean): void {
- this.setPlayheadTime(time);
- if (emit) {
- this.edit.seek(time * 1000); // Convert to milliseconds
- }
+ public zoomOut(): void {
+ const current = this.stateManager.getViewport().pixelsPerSecond;
+ this.setZoom(Math.max(10, current / 1.2));
}
- public get timeRange(): { startTime: number; endTime: number } {
- return {
- startTime: 0,
- endTime: this.getExtendedTimelineDuration()
- };
- }
+ /** @internal */
+ public scrollTo(time: Seconds): void {
+ if (!this.trackList) return;
- public get viewportHeight(): number {
- return this.optionsManager.getHeight();
+ const pps = this.stateManager.getViewport().pixelsPerSecond;
+ this.trackList.setScrollPosition(time * pps, 0);
}
- public get zoomLevelIndex(): number {
- // Convert zoom level to index (simplified - you may want to map this to actual zoom levels)
- const viewport = this.viewportManager.getViewport();
- return Math.round(Math.log2(viewport.zoom) + 5);
- }
+ /** Recalculate size from container and re-render. @internal */
+ public resize(): void {
+ const rect = this.container.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return;
- public zoomIn(): void {
- this.optionsManager.zoomIn();
- this.onZoomChanged();
+ this.stateManager.setViewport({ width: rect.width, height: rect.height });
+ this.requestRender();
}
- public zoomOut(): void {
- this.optionsManager.zoomOut();
- this.onZoomChanged();
+ /** @internal */
+ public selectClip(trackIndex: number, clipIndex: number): void {
+ this.stateManager.selectClip(trackIndex, clipIndex, false);
+ this.edit.selectClip(trackIndex, clipIndex);
+ this.requestRender();
}
- private onZoomChanged(): void {
- const pixelsPerSecond = this.optionsManager.getPixelsPerSecond();
-
- // Update visual tracks without rebuilding to preserve event handlers
- this.visualTrackManager.updatePixelsPerSecond(pixelsPerSecond);
-
- // Update track widths to match new zoom level
- const extendedWidth = this.getExtendedTimelineWidth();
- this.visualTrackManager.updateTrackWidths(extendedWidth);
+ /** @internal */
+ public clearSelection(): void {
+ this.stateManager.clearSelection();
+ this.edit.clearSelection();
+ this.requestRender();
+ }
- // Update timeline features
- this.featureManager.updateRuler(pixelsPerSecond, this.getExtendedTimelineDuration());
- this.featureManager.updatePlayhead(pixelsPerSecond, this.optionsManager.getHeight());
+ /** @internal */
+ public registerClipRenderer(type: string, renderer: ClipRenderer): void {
+ this.clipRenderers.set(type, renderer);
+ }
- // Force a render
- this.renderer.render();
+ /** @internal */
+ public getEdit(): Edit {
+ return this.edit;
}
/** @internal */
- public dispose(): void {
- // Clean up managers
- this.dragPreviewManager.dispose();
- this.visualTrackManager.dispose();
- this.eventHandler.dispose();
- this.featureManager.dispose();
-
- // Clean up interaction system
- if (this.interaction) {
- this.interaction.dispose();
+ public findClipAtPosition(x: number, y: number): ClipInfo | null {
+ if (!this.trackList) return null;
+
+ const rect = this.trackList.element.getBoundingClientRect();
+ const relativeX = x - rect.left;
+ const relativeY = y - rect.top;
+ const viewport = this.stateManager.getViewport();
+ const trackHeight = 64; // TODO: get from theme
+
+ const clipState = this.trackList.findClipAtPosition(relativeX, relativeY, trackHeight, viewport.pixelsPerSecond);
+
+ if (clipState) {
+ return {
+ trackIndex: clipState.trackIndex,
+ clipIndex: clipState.clipIndex,
+ config: clipState.config
+ };
}
- // Destroy renderer
- this.renderer.dispose();
+ return null;
}
}
diff --git a/src/components/timeline/timeline.types.ts b/src/components/timeline/timeline.types.ts
new file mode 100644
index 00000000..69a71b1a
--- /dev/null
+++ b/src/components/timeline/timeline.types.ts
@@ -0,0 +1,101 @@
+import type { Seconds } from "@core/timing/types";
+import type { ResolvedClip } from "@schemas";
+
+/** Visual state for clips */
+export type ClipVisualState = "normal" | "selected" | "dragging" | "resizing";
+
+/** Internal state for a clip */
+export interface ClipState {
+ /** Unique identifier for keyed updates */
+ id: string;
+ /** Track index */
+ trackIndex: number;
+ /** Clip index within track */
+ clipIndex: number;
+ /** Resolved clip configuration */
+ config: ResolvedClip;
+ /** Visual state */
+ visualState: ClipVisualState;
+ /** Original timing intent before resolution */
+ timingIntent: {
+ start: "auto" | number;
+ length: "auto" | "end" | number;
+ };
+}
+
+/** Internal state for a track */
+export interface TrackState {
+ /** Track index */
+ index: number;
+ /** Clips in this track */
+ clips: ClipState[];
+ /** Primary asset type (from first clip, determines track height) */
+ primaryAssetType: string;
+}
+
+/** Viewport state */
+export interface ViewportState {
+ /** Horizontal scroll position in pixels */
+ scrollX: number;
+ /** Vertical scroll position in pixels */
+ scrollY: number;
+ /** Zoom level (pixels per second) */
+ pixelsPerSecond: number;
+ /** Viewport width */
+ width: number;
+ /** Viewport height */
+ height: number;
+}
+
+/** Playback state */
+export interface PlaybackState {
+ /** Current playback time in seconds */
+ time: Seconds;
+ /** Whether playback is active */
+ isPlaying: boolean;
+ /** Total timeline duration in seconds */
+ duration: Seconds;
+}
+
+/** Clip info for interactions */
+export interface ClipInfo {
+ trackIndex: number;
+ clipIndex: number;
+ config: ResolvedClip;
+}
+
+/** Custom clip renderer interface */
+export interface ClipRenderer {
+ /** Render custom content inside clip element */
+ render(clip: ResolvedClip, element: HTMLElement): void;
+ /** Optional cleanup when clip is removed */
+ dispose?(element: HTMLElement): void;
+}
+
+export interface InteractionQuery {
+ isDragging(trackIndex: number, clipIndex: number): boolean;
+ isResizing(trackIndex: number, clipIndex: number): boolean;
+}
+
+/** Default timeline settings */
+export const DEFAULT_PIXELS_PER_SECOND = 50;
+
+/** Track heights by asset type */
+export const TRACK_HEIGHTS: Record = {
+ video: 72,
+ image: 72,
+ audio: 48,
+ text: 36,
+ "rich-text": 36,
+ shape: 36,
+ caption: 36,
+ html: 48,
+ luma: 72,
+ svg: 72,
+ default: 48
+};
+
+/** Get track height for an asset type */
+export function getTrackHeight(assetType: string): number {
+ return TRACK_HEIGHTS[assetType] ?? TRACK_HEIGHTS["default"];
+}
diff --git a/src/components/timeline/toolbar/components/edit-controls.ts b/src/components/timeline/toolbar/components/edit-controls.ts
deleted file mode 100644
index a9d1f1e4..00000000
--- a/src/components/timeline/toolbar/components/edit-controls.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { Edit } from "../../../../core/edit";
-import { TimelineTheme } from "../../../../core/theme";
-import { TOOLBAR_CONSTANTS } from "../constants";
-import { ToolbarComponent } from "../types";
-
-export class EditControls extends PIXI.Container implements ToolbarComponent {
- private edit: Edit;
- private theme: TimelineTheme;
- private cutButton!: PIXI.Container;
- private cutButtonBackground!: PIXI.Graphics;
- private cutButtonText!: PIXI.Text;
-
- constructor(edit: Edit, theme: TimelineTheme) {
- super();
-
- this.edit = edit;
- this.theme = theme;
-
- this.createCutButton();
- }
-
- private createCutButton(): void {
- this.cutButton = new PIXI.Container();
- this.cutButton.eventMode = "static";
- this.cutButton.cursor = "pointer";
-
- const { WIDTH, HEIGHT, FONT_SIZE } = TOOLBAR_CONSTANTS.CUT_BUTTON;
-
- // Create background
- this.cutButtonBackground = new PIXI.Graphics();
- this.cutButtonBackground.roundRect(0, 0, WIDTH, HEIGHT, TOOLBAR_CONSTANTS.BORDER_RADIUS);
- this.cutButtonBackground.fill({ color: this.theme.timeline.toolbar.surface || 0x444444 });
- this.cutButtonBackground.stroke({
- color: this.theme.timeline.tracks.border || 0x666666,
- width: 1
- });
- this.cutButton.addChild(this.cutButtonBackground);
-
- // Create text
- const textStyle = new PIXI.TextStyle({
- fontFamily: "Arial",
- fontSize: FONT_SIZE,
- fill: this.theme.timeline.toolbar.text || 0xffffff
- });
- this.cutButtonText = new PIXI.Text("SPLIT", textStyle);
- this.cutButtonText.anchor.set(0.5);
- this.cutButtonText.position.set(WIDTH / 2, HEIGHT / 2);
- this.cutButton.addChild(this.cutButtonText);
-
- // Add event listeners
- this.cutButton.on("click", this.handleCutClick, this);
- this.cutButton.on("pointerdown", this.handlePointerDown, this);
- this.cutButton.on("pointerover", this.handlePointerOver, this);
- this.cutButton.on("pointerout", this.handlePointerOut, this);
-
- this.addChild(this.cutButton);
- }
-
- private handleCutClick = (event: PIXI.FederatedPointerEvent): void => {
- event.stopPropagation();
- this.performCutClip();
- };
-
- private handlePointerDown = (event: PIXI.FederatedPointerEvent): void => {
- event.stopPropagation();
- this.updateButtonVisual(true, false);
- };
-
- private handlePointerOver = (): void => {
- this.updateButtonVisual(false, true);
- };
-
- private handlePointerOut = (): void => {
- this.updateButtonVisual(false, false);
- };
-
- private updateButtonVisual(pressed: boolean, hovering: boolean): void {
- this.cutButtonBackground.clear();
- this.cutButtonBackground.roundRect(
- 0,
- 0,
- TOOLBAR_CONSTANTS.CUT_BUTTON.WIDTH,
- TOOLBAR_CONSTANTS.CUT_BUTTON.HEIGHT,
- TOOLBAR_CONSTANTS.BORDER_RADIUS
- );
-
- let fillColor = this.theme.timeline.toolbar.surface || 0x444444;
- const alpha = 1;
-
- if (pressed) {
- fillColor = this.theme.timeline.toolbar.active || 0x333333;
- } else if (hovering) {
- fillColor = this.theme.timeline.toolbar.hover || 0x555555;
- }
-
- this.cutButtonBackground.fill({ color: fillColor, alpha });
- this.cutButtonBackground.stroke({
- color: this.theme.timeline.tracks.border || 0x666666,
- width: 1
- });
- }
-
- private performCutClip(): void {
- const selectedInfo = this.edit.getSelectedClipInfo();
- if (!selectedInfo) {
- return;
- }
-
- const { trackIndex, clipIndex } = selectedInfo;
- const playheadTime = this.edit.playbackTime / 1000;
-
- this.edit.splitClip(trackIndex, clipIndex, playheadTime);
- }
-
- public update(): void {
- // Update button state based on selection
- const hasSelection = this.edit.getSelectedClipInfo() !== null;
- this.cutButton.alpha = hasSelection ? 1 : 0.5;
- this.cutButton.eventMode = hasSelection ? "static" : "none";
- this.cutButton.cursor = hasSelection ? "pointer" : "default";
- }
-
- public resize(_width: number): void {
- // Edit controls maintain their size
- }
-
- public updateTheme(theme: TimelineTheme): void {
- this.theme = theme;
- this.updateButtonVisual(false, false);
- this.cutButtonText.style.fill = theme.timeline.toolbar.text || 0xffffff;
- }
-
- public override destroy(): void {
- this.cutButton.removeAllListeners();
- super.destroy();
- }
-
- public getWidth(): number {
- return TOOLBAR_CONSTANTS.CUT_BUTTON.WIDTH;
- }
-}
diff --git a/src/components/timeline/toolbar/components/playback-controls.ts b/src/components/timeline/toolbar/components/playback-controls.ts
deleted file mode 100644
index 986501b8..00000000
--- a/src/components/timeline/toolbar/components/playback-controls.ts
+++ /dev/null
@@ -1,126 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { Edit } from "../../../../core/edit";
-import { TimelineTheme } from "../../../../core/theme";
-import { TOOLBAR_CONSTANTS } from "../constants";
-import { ToolbarComponent, IconType } from "../types";
-
-import { ToolbarButton } from "./toolbar-button";
-
-export class PlaybackControls extends PIXI.Container implements ToolbarComponent {
- private edit: Edit;
- private theme: TimelineTheme;
- private toolbarHeight: number;
-
- private frameBackButton!: ToolbarButton;
- private playPauseButton!: ToolbarButton;
- private frameForwardButton!: ToolbarButton;
-
- constructor(edit: Edit, theme: TimelineTheme, toolbarHeight?: number) {
- super();
-
- this.edit = edit;
- this.theme = theme;
- this.toolbarHeight = toolbarHeight || 36; // Default height
-
- this.createButtons();
- this.subscribeToEditEvents();
- this.updatePlayPauseState();
- }
-
- private createButtons(): void {
- const sizes = this.calculateButtonSizes();
- const centerY = (sizes.playButton - sizes.regularButton) / 2;
-
- // Create buttons with their configurations
- const createButton = (iconType: IconType, onClick: () => void, tooltip: string, size: number) =>
- new ToolbarButton({ iconType, onClick, tooltip, theme: this.theme, size });
-
- // Frame back button
- this.frameBackButton = createButton("frame-back", () => this.handleFrameBack(), "Previous frame", sizes.regularButton);
- this.frameBackButton.position.set(0, centerY);
-
- // Play/Pause button
- this.playPauseButton = new ToolbarButton({
- iconType: "play",
- alternateIconType: "pause",
- onClick: () => this.handlePlayPause(),
- tooltip: "Play/Pause",
- theme: this.theme,
- size: sizes.playButton
- });
- this.playPauseButton.position.set(sizes.regularButton + sizes.spacing, 0);
-
- // Frame forward button
- this.frameForwardButton = createButton("frame-forward", () => this.handleFrameForward(), "Next frame", sizes.regularButton);
- this.frameForwardButton.position.set(sizes.regularButton + sizes.spacing + sizes.playButton + sizes.spacing, centerY);
-
- // Add all buttons
- this.addChild(this.frameBackButton, this.playPauseButton, this.frameForwardButton);
- }
-
- private calculateButtonSizes() {
- const regularButton = Math.round(this.toolbarHeight * 0.5);
- return {
- regularButton,
- playButton: Math.round(regularButton * 1.5),
- spacing: Math.round(this.toolbarHeight * 0.15)
- };
- }
-
- private handleFrameBack(): void {
- this.edit.seek(this.edit.playbackTime - TOOLBAR_CONSTANTS.FRAME_TIME_MS);
- }
-
- private handlePlayPause(): void {
- if (this.edit.isPlaying) {
- this.edit.pause();
- } else {
- this.edit.play();
- }
- }
-
- private handleFrameForward(): void {
- this.edit.seek(this.edit.playbackTime + TOOLBAR_CONSTANTS.FRAME_TIME_MS);
- }
-
- private subscribeToEditEvents(): void {
- this.edit.events.on("playback:play", this.updatePlayPauseState);
- this.edit.events.on("playback:pause", this.updatePlayPauseState);
- }
-
- private updatePlayPauseState = (): void => {
- this.playPauseButton.setActive(this.edit.isPlaying);
- };
-
- public update(): void {
- // Update any dynamic state if needed
- }
-
- public resize(_width: number): void {
- // Controls maintain fixed size, no resize needed
- }
-
- public updateTheme(theme: TimelineTheme): void {
- this.theme = theme;
- this.frameBackButton.updateTheme(theme);
- this.playPauseButton.updateTheme(theme);
- this.frameForwardButton.updateTheme(theme);
- }
-
- public override destroy(): void {
- this.edit.events.off("playback:play", this.updatePlayPauseState);
- this.edit.events.off("playback:pause", this.updatePlayPauseState);
-
- this.frameBackButton.destroy();
- this.playPauseButton.destroy();
- this.frameForwardButton.destroy();
-
- super.destroy();
- }
-
- public getWidth(): number {
- const sizes = this.calculateButtonSizes();
- return sizes.regularButton * 2 + sizes.playButton + sizes.spacing * 2;
- }
-}
diff --git a/src/components/timeline/toolbar/components/time-display.ts b/src/components/timeline/toolbar/components/time-display.ts
deleted file mode 100644
index 5d9735da..00000000
--- a/src/components/timeline/toolbar/components/time-display.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { Edit } from "../../../../core/edit";
-import { TimelineTheme } from "../../../../core/theme";
-import { TOOLBAR_CONSTANTS } from "../constants";
-import { ToolbarComponent, TimeFormatOptions } from "../types";
-
-export class TimeDisplay extends PIXI.Container implements ToolbarComponent {
- private edit: Edit;
- private theme: TimelineTheme;
- private timeText!: PIXI.Text;
- private formatOptions: TimeFormatOptions;
-
- constructor(edit: Edit, theme: TimelineTheme, formatOptions: TimeFormatOptions = {}) {
- super();
-
- this.edit = edit;
- this.theme = theme;
- this.formatOptions = {
- showMilliseconds: false,
- showHours: false,
- ...formatOptions
- };
-
- this.createDisplay();
- this.subscribeToEditEvents();
- this.updateTimeDisplay();
- }
-
- private createDisplay(): void {
- const textStyle = new PIXI.TextStyle({
- fontFamily: TOOLBAR_CONSTANTS.TIME_DISPLAY.FONT_FAMILY,
- fontSize: TOOLBAR_CONSTANTS.TIME_DISPLAY.FONT_SIZE,
- fill: this.theme.timeline.toolbar.text
- });
-
- this.timeText = new PIXI.Text("0:00 / 0:00", textStyle);
- this.timeText.anchor.set(0, 0.5);
- this.addChild(this.timeText);
- }
-
- private subscribeToEditEvents(): void {
- this.edit.events.on("playback:time", this.updateTimeDisplay);
- this.edit.events.on("duration:changed", this.updateTimeDisplay);
- }
-
- private updateTimeDisplay = (): void => {
- const currentTime = this.formatTime(this.edit.playbackTime / 1000);
- const duration = this.formatTime(this.edit.getTotalDuration() / 1000);
- this.timeText.text = `${currentTime} / ${duration}`;
- };
-
- private formatTime(seconds: number): string {
- const hours = Math.floor(seconds / 3600);
- const minutes = Math.floor((seconds % 3600) / 60);
- const secs = Math.floor(seconds % 60);
- const tenths = Math.floor((seconds % 1) * 10);
-
- let formatted = "";
-
- if (this.formatOptions.showHours || hours > 0) {
- formatted += `${hours}:${minutes.toString().padStart(2, "0")}`;
- } else {
- formatted += `${minutes}`;
- }
-
- formatted += `:${secs.toString().padStart(2, "0")}`;
-
- if (this.formatOptions.showMilliseconds) {
- formatted += `.${tenths}`;
- } else {
- // Default behavior from original - show tenths
- formatted += `.${tenths}`;
- }
-
- return formatted;
- }
-
- public update(): void {
- this.updateTimeDisplay();
- }
-
- public resize(_width: number): void {
- // Time display maintains its size
- }
-
- public updateTheme(theme: TimelineTheme): void {
- this.theme = theme;
- this.timeText.style.fill = theme.timeline.toolbar.text;
- }
-
- public override destroy(): void {
- this.edit.events.off("playback:time", this.updateTimeDisplay);
- this.edit.events.off("duration:changed", this.updateTimeDisplay);
-
- super.destroy();
- }
-
- public getWidth(): number {
- return this.timeText.width;
- }
-}
diff --git a/src/components/timeline/toolbar/components/toolbar-button.ts b/src/components/timeline/toolbar/components/toolbar-button.ts
deleted file mode 100644
index ce537dc2..00000000
--- a/src/components/timeline/toolbar/components/toolbar-button.ts
+++ /dev/null
@@ -1,193 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { TimelineTheme } from "../../../../core/theme";
-import { TOOLBAR_CONSTANTS } from "../constants";
-import { IconFactory } from "../icons/icon-factory";
-import { ButtonState, IconType } from "../types";
-
-export interface ToolbarButtonOptions {
- size?: number;
- onClick: () => void;
- tooltip?: string;
- iconType?: IconType;
- alternateIconType?: IconType;
- theme: TimelineTheme;
-}
-
-export class ToolbarButton extends PIXI.Container {
- private background: PIXI.Graphics;
- private hoverBackground: PIXI.Graphics;
- private icon?: PIXI.Graphics;
- private alternateIcon?: PIXI.Graphics;
- private state: ButtonState = {
- isHovering: false,
- isPressed: false,
- isActive: false
- };
-
- private size: number;
- private theme: TimelineTheme;
- private onClick: () => void;
-
- constructor(options: ToolbarButtonOptions) {
- super();
-
- this.size = options.size || TOOLBAR_CONSTANTS.BUTTON_SIZE;
- this.theme = options.theme;
- this.onClick = options.onClick;
-
- this.eventMode = "static";
- this.cursor = "pointer";
-
- // Create background
- this.background = new PIXI.Graphics();
- this.addChild(this.background);
-
- // Create hover background
- this.hoverBackground = new PIXI.Graphics();
- this.addChild(this.hoverBackground);
-
- // Create icon(s) - scaled to 60% of button size
- const iconScale = 0.6;
- const iconSize = this.size * iconScale;
- const iconOffset = (this.size - iconSize) / 2;
-
- if (options.iconType) {
- this.icon = IconFactory.createIcon(options.iconType, this.theme, iconSize);
- this.icon.position.set(iconOffset, iconOffset);
- this.addChild(this.icon);
- }
-
- if (options.alternateIconType) {
- this.alternateIcon = IconFactory.createIcon(options.alternateIconType, this.theme, iconSize);
- this.alternateIcon.position.set(iconOffset, iconOffset);
- this.alternateIcon.visible = false;
- this.addChild(this.alternateIcon);
- }
-
- // Set up event listeners
- this.setupEventListeners();
-
- // Initial render
- this.updateVisuals();
- }
-
- private setupEventListeners(): void {
- this.on("pointerdown", this.handlePointerDown, this);
- this.on("pointerup", this.handlePointerUp, this);
- this.on("pointerupoutside", this.handlePointerUp, this);
- this.on("pointerover", this.handlePointerOver, this);
- this.on("pointerout", this.handlePointerOut, this);
- }
-
- private handlePointerDown(): void {
- this.state.isPressed = true;
- this.updateVisuals();
- }
-
- private handlePointerUp(): void {
- if (this.state.isPressed) {
- this.onClick();
- }
- this.state.isPressed = false;
- this.updateVisuals();
- }
-
- private handlePointerOver(): void {
- this.state.isHovering = true;
- this.updateVisuals();
- }
-
- private handlePointerOut(): void {
- this.state.isHovering = false;
- this.state.isPressed = false;
- this.updateVisuals();
- }
-
- private updateVisuals(): void {
- const padding = TOOLBAR_CONSTANTS.BUTTON_HOVER_PADDING;
- const radius = this.size / 2;
-
- // Clear and redraw circular button background
- this.background.clear();
- this.background.circle(radius, radius, radius);
- this.background.fill({
- color: this.theme.timeline.toolbar.surface,
- alpha: 0.8
- });
-
- // Update hover background as a larger circle
- this.hoverBackground.clear();
- this.hoverBackground.circle(radius, radius, radius + padding);
-
- if (this.state.isPressed) {
- this.hoverBackground.fill({
- color: this.theme.timeline.toolbar.active,
- alpha: TOOLBAR_CONSTANTS.ACTIVE_ANIMATION_ALPHA
- });
- } else if (this.state.isHovering) {
- this.hoverBackground.fill({
- color: this.theme.timeline.toolbar.hover,
- alpha: TOOLBAR_CONSTANTS.HOVER_ANIMATION_ALPHA
- });
- } else {
- this.hoverBackground.fill({
- color: this.theme.timeline.toolbar.hover,
- alpha: 0
- });
- }
- }
-
- public setActive(active: boolean): void {
- this.state.isActive = active;
-
- // Toggle icon visibility if we have alternate icon
- if (this.icon && this.alternateIcon) {
- this.icon.visible = !active;
- this.alternateIcon.visible = active;
- }
- }
-
- public updateTheme(theme: TimelineTheme): void {
- this.theme = theme;
-
- // Recreate icons with new theme
- const iconScale = 0.6;
- const iconSize = this.size * iconScale;
- const iconOffset = (this.size - iconSize) / 2;
-
- if (this.icon) {
- const iconType = this.getIconType(this.icon);
- if (iconType) {
- this.removeChild(this.icon);
- this.icon = IconFactory.createIcon(iconType, theme, iconSize);
- this.icon.position.set(iconOffset, iconOffset);
- this.addChild(this.icon);
- }
- }
-
- if (this.alternateIcon) {
- const iconType = this.getIconType(this.alternateIcon);
- if (iconType) {
- this.removeChild(this.alternateIcon);
- this.alternateIcon = IconFactory.createIcon(iconType, theme, iconSize);
- this.alternateIcon.position.set(iconOffset, iconOffset);
- this.alternateIcon.visible = this.state.isActive;
- this.addChild(this.alternateIcon);
- }
- }
-
- this.updateVisuals();
- }
-
- private getIconType(_icon: PIXI.Graphics): IconType | null {
- // This is a simplified approach - in a real implementation,
- // we'd store the icon type as metadata on the Graphics object
- return null;
- }
-
- public override destroy(): void {
- this.removeAllListeners();
- super.destroy();
- }
-}
diff --git a/src/components/timeline/toolbar/constants.ts b/src/components/timeline/toolbar/constants.ts
deleted file mode 100644
index c67c7749..00000000
--- a/src/components/timeline/toolbar/constants.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-export const TOOLBAR_CONSTANTS = {
- // Layout
- BUTTON_SIZE: 24,
- BUTTON_SPACING: 8,
- BUTTON_HOVER_PADDING: 4,
- BORDER_RADIUS: 4,
- TEXT_SPACING: 16,
- EDGE_MARGIN: 10,
-
- // Playback
- FRAME_TIME_MS: 16.67, // milliseconds per frame
-
- // Icon dimensions
- ICON: {
- // Play icon (triangle)
- PLAY: {
- LEFT: 6,
- TOP: 4,
- RIGHT: 18,
- MIDDLE: 12,
- BOTTOM: 20
- },
- // Pause icon (two rectangles)
- PAUSE: {
- RECT1_X: 6,
- RECT2_X: 14,
- TOP: 4,
- WIDTH: 4,
- HEIGHT: 16
- },
- // Frame back/forward (double triangles)
- FRAME_STEP: {
- TRIANGLE1: {
- BACK: { LEFT: 11, RIGHT: 3, MIDDLE: 12 },
- FORWARD: { LEFT: 4, RIGHT: 12, MIDDLE: 12 }
- },
- TRIANGLE2: {
- BACK: { LEFT: 20, RIGHT: 12, MIDDLE: 12 },
- FORWARD: { LEFT: 13, RIGHT: 21, MIDDLE: 12 }
- },
- TOP: 4,
- BOTTOM: 20
- }
- },
-
- // Cut button
- CUT_BUTTON: {
- WIDTH: 60,
- HEIGHT: 24,
- FONT_SIZE: 12
- },
-
- // Time display
- TIME_DISPLAY: {
- FONT_SIZE: 14,
- FONT_FAMILY: "monospace"
- },
-
- // Animation
- HOVER_ANIMATION_ALPHA: 1,
- ACTIVE_ANIMATION_ALPHA: 0.3,
- DIVIDER_ALPHA: 0.5
-} as const;
-
-export type ToolbarConstants = typeof TOOLBAR_CONSTANTS;
diff --git a/src/components/timeline/toolbar/icons/icon-factory.ts b/src/components/timeline/toolbar/icons/icon-factory.ts
deleted file mode 100644
index e33e7c3f..00000000
--- a/src/components/timeline/toolbar/icons/icon-factory.ts
+++ /dev/null
@@ -1,106 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { TimelineTheme } from "../../../../core/theme";
-import { TOOLBAR_CONSTANTS } from "../constants";
-import { IconType } from "../types";
-
-export class IconFactory {
- static createIcon(type: IconType, theme: TimelineTheme, size?: number): PIXI.Graphics {
- const scale = size ? size / TOOLBAR_CONSTANTS.BUTTON_SIZE : 1;
-
- switch (type) {
- case "play":
- return this.createPlayIcon(theme, scale);
- case "pause":
- return this.createPauseIcon(theme, scale);
- case "frame-back":
- return this.createFrameBackIcon(theme, scale);
- case "frame-forward":
- return this.createFrameForwardIcon(theme, scale);
- default:
- throw new Error(`Unknown icon type: ${type}`);
- }
- }
-
- static createPlayIcon(theme: TimelineTheme, scale: number = 1): PIXI.Graphics {
- const icon = new PIXI.Graphics();
- const { PLAY } = TOOLBAR_CONSTANTS.ICON;
-
- icon.fill({ color: theme.timeline.toolbar.icon });
- icon.moveTo(PLAY.LEFT * scale, PLAY.TOP * scale);
- icon.lineTo(PLAY.RIGHT * scale, PLAY.MIDDLE * scale);
- icon.lineTo(PLAY.LEFT * scale, PLAY.BOTTOM * scale);
- icon.closePath();
- icon.fill();
-
- return icon;
- }
-
- static createPauseIcon(theme: TimelineTheme, scale: number = 1): PIXI.Graphics {
- const icon = new PIXI.Graphics();
- const { PAUSE } = TOOLBAR_CONSTANTS.ICON;
-
- icon.fill({ color: theme.timeline.toolbar.icon });
- icon.rect(PAUSE.RECT1_X * scale, PAUSE.TOP * scale, PAUSE.WIDTH * scale, PAUSE.HEIGHT * scale);
- icon.rect(PAUSE.RECT2_X * scale, PAUSE.TOP * scale, PAUSE.WIDTH * scale, PAUSE.HEIGHT * scale);
- icon.fill();
-
- return icon;
- }
-
- static createFrameBackIcon(theme: TimelineTheme, scale: number = 1): PIXI.Graphics {
- const icon = new PIXI.Graphics();
- const { FRAME_STEP } = TOOLBAR_CONSTANTS.ICON;
-
- icon.fill({ color: theme.timeline.toolbar.icon });
-
- // First triangle
- icon.moveTo(FRAME_STEP.TRIANGLE1.BACK.LEFT * scale, FRAME_STEP.TOP * scale);
- icon.lineTo(FRAME_STEP.TRIANGLE1.BACK.RIGHT * scale, FRAME_STEP.TRIANGLE1.BACK.MIDDLE * scale);
- icon.lineTo(FRAME_STEP.TRIANGLE1.BACK.LEFT * scale, FRAME_STEP.BOTTOM * scale);
- icon.closePath();
-
- // Second triangle
- icon.moveTo(FRAME_STEP.TRIANGLE2.BACK.LEFT * scale, FRAME_STEP.TOP * scale);
- icon.lineTo(FRAME_STEP.TRIANGLE2.BACK.RIGHT * scale, FRAME_STEP.TRIANGLE2.BACK.MIDDLE * scale);
- icon.lineTo(FRAME_STEP.TRIANGLE2.BACK.LEFT * scale, FRAME_STEP.BOTTOM * scale);
- icon.closePath();
-
- icon.fill();
-
- return icon;
- }
-
- static createFrameForwardIcon(theme: TimelineTheme, scale: number = 1): PIXI.Graphics {
- const icon = new PIXI.Graphics();
- const { FRAME_STEP } = TOOLBAR_CONSTANTS.ICON;
-
- icon.fill({ color: theme.timeline.toolbar.icon });
-
- // First triangle
- icon.moveTo(FRAME_STEP.TRIANGLE1.FORWARD.LEFT * scale, FRAME_STEP.TOP * scale);
- icon.lineTo(FRAME_STEP.TRIANGLE1.FORWARD.RIGHT * scale, FRAME_STEP.TRIANGLE1.FORWARD.MIDDLE * scale);
- icon.lineTo(FRAME_STEP.TRIANGLE1.FORWARD.LEFT * scale, FRAME_STEP.BOTTOM * scale);
- icon.closePath();
-
- // Second triangle
- icon.moveTo(FRAME_STEP.TRIANGLE2.FORWARD.LEFT * scale, FRAME_STEP.TOP * scale);
- icon.lineTo(FRAME_STEP.TRIANGLE2.FORWARD.RIGHT * scale, FRAME_STEP.TRIANGLE2.FORWARD.MIDDLE * scale);
- icon.lineTo(FRAME_STEP.TRIANGLE2.FORWARD.LEFT * scale, FRAME_STEP.BOTTOM * scale);
- icon.closePath();
-
- icon.fill();
-
- return icon;
- }
-
- static updateIconColor(icon: PIXI.Graphics, _theme: TimelineTheme): void {
- // Clear and redraw with new color
- const bounds = icon.getBounds();
- icon.clear();
- icon.position.set(bounds.x, bounds.y);
-
- // This is a simplified update - in practice, we'd need to store
- // the icon type and recreate it with the new theme
- }
-}
diff --git a/src/components/timeline/toolbar/index.ts b/src/components/timeline/toolbar/index.ts
deleted file mode 100644
index 407928ed..00000000
--- a/src/components/timeline/toolbar/index.ts
+++ /dev/null
@@ -1,9 +0,0 @@
-// Barrel exports for toolbar module
-export { TOOLBAR_CONSTANTS } from "./constants";
-export * from "./types";
-export { IconFactory } from "./icons/icon-factory";
-export { ToolbarButton } from "./components/toolbar-button";
-export { PlaybackControls } from "./components/playback-controls";
-export { TimeDisplay } from "./components/time-display";
-export { EditControls } from "./components/edit-controls";
-export { ToolbarLayout } from "./toolbar-layout";
diff --git a/src/components/timeline/toolbar/toolbar-layout.ts b/src/components/timeline/toolbar/toolbar-layout.ts
deleted file mode 100644
index 5b0912b5..00000000
--- a/src/components/timeline/toolbar/toolbar-layout.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { TOOLBAR_CONSTANTS } from "./constants";
-import { ComponentPosition, ToolbarLayoutConfig } from "./types";
-
-export class ToolbarLayout {
- private config: ToolbarLayoutConfig;
-
- constructor(width: number, height: number) {
- this.config = {
- width,
- height,
- buttonSize: Math.round(height * 0.5),
- buttonSpacing: Math.round(height * 0.15),
- edgeMargin: TOOLBAR_CONSTANTS.EDGE_MARGIN
- };
- }
-
- public getPlaybackControlsPosition(): ComponentPosition {
- // Center playback controls horizontally and vertically
- const controlsWidth = this.calculatePlaybackControlsWidth();
- const x = (this.config.width - controlsWidth) / 2;
- // Center the entire control group vertically
- const y = (this.config.height - this.getMaxButtonHeight()) / 2;
-
- return { x, y };
- }
-
- private getMaxButtonHeight(): number {
- // The play button is the tallest
- const regularButtonSize = this.config.buttonSize;
- const playButtonSize = Math.round(regularButtonSize * 1.5);
- return playButtonSize;
- }
-
- public getTimeDisplayPosition(playbackControlsWidth: number): ComponentPosition {
- // Position time display to the right of playback controls
- const playbackX = (this.config.width - playbackControlsWidth) / 2;
- const x = playbackX + playbackControlsWidth + TOOLBAR_CONSTANTS.TEXT_SPACING;
- const y = this.config.height / 2;
-
- return { x, y };
- }
-
- public getEditControlsPosition(): ComponentPosition {
- // Position edit controls on the right edge
- const x = this.config.width - TOOLBAR_CONSTANTS.CUT_BUTTON.WIDTH - this.config.edgeMargin;
- const y = (this.config.height - TOOLBAR_CONSTANTS.CUT_BUTTON.HEIGHT) / 2;
-
- return { x, y };
- }
-
- public calculatePlaybackControlsWidth(): number {
- // 2 regular buttons + 1 play button (50% larger) with 2 spaces between them
- const regularButtonSize = this.config.buttonSize;
- const playButtonSize = Math.round(regularButtonSize * 1.5);
- return regularButtonSize * 2 + playButtonSize + this.config.buttonSpacing * 2;
- }
-
- public updateWidth(width: number): void {
- this.config.width = width;
- }
-
- public getConfig(): ToolbarLayoutConfig {
- return { ...this.config };
- }
-}
diff --git a/src/components/timeline/toolbar/types.ts b/src/components/timeline/toolbar/types.ts
deleted file mode 100644
index 7ed5d47d..00000000
--- a/src/components/timeline/toolbar/types.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import * as PIXI from "pixi.js";
-
-import { Edit } from "../../../core/edit";
-import { TimelineTheme } from "../../../core/theme";
-import { TimelineLayout } from "../timeline-layout";
-
-// Button types
-export type ButtonType = "play-pause" | "frame-back" | "frame-forward" | "cut";
-
-export interface ButtonConfig {
- type: ButtonType;
- tooltip?: string;
- onClick: () => void;
- size?: number;
-}
-
-export interface IconButtonConfig extends ButtonConfig {
- getIcon: (theme: TimelineTheme) => PIXI.Graphics;
- getAlternateIcon?: (theme: TimelineTheme) => PIXI.Graphics;
-}
-
-export interface TextButtonConfig extends ButtonConfig {
- text: string;
- width: number;
- height: number;
-}
-
-// State types
-export type ToolbarState = { type: "idle" } | { type: "playing" } | { type: "paused" };
-
-export interface ButtonState {
- isHovering: boolean;
- isPressed: boolean;
- isActive: boolean;
-}
-
-// Component interfaces
-export interface ToolbarComponent {
- update(): void;
- resize(width: number): void;
- updateTheme(theme: TimelineTheme): void;
- destroy(): void;
-}
-
-export interface ToolbarOptions {
- edit: Edit;
- theme: TimelineTheme;
- layout: TimelineLayout;
- width: number;
-}
-
-// Layout types
-export interface ToolbarLayoutConfig {
- width: number;
- height: number;
- buttonSize: number;
- buttonSpacing: number;
- edgeMargin: number;
-}
-
-export interface ComponentPosition {
- x: number;
- y: number;
- width?: number;
- height?: number;
-}
-
-// Event types
-export interface ToolbarEventMap {
- "button:click": { button: ButtonType };
- "button:hover": { button: ButtonType; hovering: boolean };
- "state:change": { state: ToolbarState };
-}
-
-// Icon types
-export type IconType = "play" | "pause" | "frame-back" | "frame-forward" | "cut";
-
-export interface IconConfig {
- type: IconType;
- color: number;
- size?: number;
-}
-
-// Time formatting
-export interface TimeFormatOptions {
- showMilliseconds?: boolean;
- showHours?: boolean;
-}
diff --git a/src/components/timeline/types/assets.ts b/src/components/timeline/types/assets.ts
deleted file mode 100644
index bb64d821..00000000
--- a/src/components/timeline/types/assets.ts
+++ /dev/null
@@ -1,245 +0,0 @@
-/**
- * Type definitions for timeline assets
- */
-
-import { DrawOp } from "@shotstack/shotstack-canvas";
-
-// Volume keyframe for audio/video assets
-export interface VolumeKeyframe {
- from: number;
- to: number;
- start: number;
- length: number;
- interpolation?: "linear" | "bezier" | "constant";
- easing?: string;
-}
-
-// Video asset
-export interface VideoAsset {
- type: "video";
- src: string;
- trim?: number;
- volume?: number | VolumeKeyframe[];
-}
-
-export type TextRenderer = {
- render: (ops: DrawOp[]) => Promise;
-};
-
-export type TextEngine = {
- validate: (asset: unknown) => { value: ValidatedRichTextAsset; error?: unknown };
- renderFrame: (asset: ValidatedRichTextAsset, time: number) => Promise;
- createRenderer: (canvas: HTMLCanvasElement) => TextRenderer;
- registerFontFromUrl: (url: string, desc: FontDescriptor) => Promise;
- registerFontFromFile: (path: string, desc: FontDescriptor) => Promise;
- destroy: () => void;
-};
-
-export type FontDescriptor = {
- family: string;
- weight: string | number;
-};
-
-// Audio asset
-export interface AudioAsset {
- type: "audio";
- src: string;
- trim?: number;
- volume?: number | VolumeKeyframe[];
-}
-
-// Image asset
-export interface ImageAsset {
- type: "image";
- src: string;
-}
-
-// Text asset (basic)
-export interface TextAsset {
- type: "text";
- text: string;
- font?: {
- color?: string;
- family?: string;
- size?: number;
- weight?: number;
- lineHeight?: number;
- };
- alignment?: {
- horizontal?: "left" | "center" | "right";
- vertical?: "top" | "center" | "bottom";
- };
-}
-
-// Rich Text asset (advanced)
-export interface ValidatedRichTextAsset {
- type: "rich-text";
- text: string;
- width: number;
- height: number;
- font: {
- family: string;
- size: number;
- weight: string | number;
- color: string;
- opacity: number;
- };
- style: {
- letterSpacing: number;
- lineHeight: number;
- textTransform: "none" | "uppercase" | "lowercase" | "capitalize";
- textDecoration: "none" | "underline" | "line-through";
- gradient?: {
- type: "linear" | "radial";
- angle: number;
- stops: { offset: number; color: string }[];
- };
- };
- stroke: { width: number; color: string; opacity: number };
- shadow: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number };
- background: {
- color?: string;
- opacity: number;
- };
- border: { width: number; color: string; opacity: number; radius: number };
- padding?: number | { top: number; right: number; bottom: number; left: number };
- align: { horizontal: "left" | "center" | "right"; vertical: "top" | "middle" | "bottom" };
- animation: {
- preset: "fadeIn" | "slideIn" | "typewriter" | "shift" | "ascend" | "movingLetters";
- speed: number;
- duration?: number;
- style?: "character" | "word";
- direction?: "left" | "right" | "up" | "down";
- };
- customFonts: { src: string; family: string; weight?: string | number; originalFamily?: string }[];
-}
-
-// Shape asset
-export interface ShapeAsset {
- type: "shape";
- shape: "rectangle" | "ellipse" | "polygon" | "star";
- color?: string;
- borderColor?: string;
- borderWidth?: number;
-}
-
-// HTML asset
-export interface HtmlAsset {
- type: "html";
- html: string;
- css?: string;
-}
-
-// Rich text asset
-export interface RichTextAsset {
- type: "rich-text";
- text: string;
- width?: number;
- height?: number;
- font?: {
- family: string;
- size: number;
- weight: string | number;
- color: string;
- opacity: number;
- };
- style?: {
- bold: boolean;
- italic: boolean;
- underline: boolean;
- lineThrough: boolean;
- uppercase: boolean;
- letterSpacing: number;
- lineHeight: number;
- };
- stroke?: { width: number; color: string; opacity: number };
- shadow?: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number };
- background?: {
- color?: string;
- opacity: number;
- };
- border?: { width: number; color: string; opacity: number; radius: number };
- padding?: number | { top: number; right: number; bottom: number; left: number };
- align?: { horizontal: "left" | "center" | "right"; vertical: "top" | "middle" | "bottom" };
- animation?: {
- preset: "fadeIn" | "slideIn" | "typewriter" | "shift" | "ascend" | "movingLetters";
- speed: number;
- duration?: number;
- style?: "character" | "word";
- direction?: "left" | "right" | "up" | "down";
- };
- customFonts?: { src: string; family: string; weight?: string | number; originalFamily?: string }[];
-}
-
-// Luma asset
-export interface LumaAsset {
- type: "luma";
- src: string;
- trim?: number;
-}
-
-// Union type for all assets
-export type TimelineAsset = VideoAsset | AudioAsset | ImageAsset | TextAsset | RichTextAsset | ShapeAsset | HtmlAsset | LumaAsset;
-
-// Type guards
-export function isVideoAsset(asset: TimelineAsset): asset is VideoAsset {
- return asset.type === "video";
-}
-
-export function isAudioAsset(asset: TimelineAsset): asset is AudioAsset {
- return asset.type === "audio";
-}
-
-export function isImageAsset(asset: TimelineAsset): asset is ImageAsset {
- return asset.type === "image";
-}
-
-export function isTextAsset(asset: TimelineAsset): asset is TextAsset {
- return asset.type === "text";
-}
-
-export function isRichTextAsset(asset: TimelineAsset): asset is RichTextAsset {
- return asset.type === "rich-text";
-}
-
-export function isShapeAsset(asset: TimelineAsset): asset is ShapeAsset {
- return asset.type === "shape";
-}
-
-export function isHtmlAsset(asset: TimelineAsset): asset is HtmlAsset {
- return asset.type === "html";
-}
-
-export function isLumaAsset(asset: TimelineAsset): asset is LumaAsset {
- return asset.type === "luma";
-}
-
-// Helper to extract filename from path
-function getFilenameFromPath(path: string): string {
- const parts = path.split("/");
- return parts[parts.length - 1] || path;
-}
-
-// Helper to get display name for asset
-export function getAssetDisplayName(asset: TimelineAsset): string {
- switch (asset.type) {
- case "video":
- return asset.src ? getFilenameFromPath(asset.src) : "Video";
- case "audio":
- return asset.src ? getFilenameFromPath(asset.src) : "Audio";
- case "image":
- return asset.src ? getFilenameFromPath(asset.src) : "Image";
- case "text":
- return asset.text || "Text";
- case "rich-text":
- return asset.text || "Rich Text";
- case "shape":
- return asset.shape || "Shape";
- case "html":
- return "HTML";
- case "luma":
- return asset.src ? getFilenameFromPath(asset.src) : "Luma";
- default:
- return "Unknown Asset";
- }
-}
diff --git a/src/components/timeline/types/index.ts b/src/components/timeline/types/index.ts
deleted file mode 100644
index 458fe961..00000000
--- a/src/components/timeline/types/index.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-// Export all timeline types
-export * from "./timeline";
-export * from "./assets";
diff --git a/src/components/timeline/types/timeline.ts b/src/components/timeline/types/timeline.ts
deleted file mode 100644
index 5f61a8da..00000000
--- a/src/components/timeline/types/timeline.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { ClipSchema, type ResolvedClipConfig } from "@core/schemas/clip";
-import { EditSchema } from "@schemas/edit";
-import { z } from "zod";
-
-export type EditType = z.infer;
-export type ClipConfig = z.infer;
-
-export type { ResolvedClipConfig };
-
-export interface TimelineOptions {
- width?: number;
- height?: number;
- pixelsPerSecond?: number;
- trackHeight?: number;
- backgroundColor?: number;
- antialias?: boolean;
- resolution?: number;
-}
-
-export interface ClipInfo {
- trackIndex: number;
- clipIndex: number;
- clipConfig: ResolvedClipConfig;
- x: number;
- y: number;
- width: number;
- height: number;
-}
-
-export interface DropPosition {
- track: number;
- time: number;
- x: number;
- y: number;
-}
diff --git a/src/components/timeline/visual/visual-clip.ts b/src/components/timeline/visual/visual-clip.ts
deleted file mode 100644
index 71ba418d..00000000
--- a/src/components/timeline/visual/visual-clip.ts
+++ /dev/null
@@ -1,378 +0,0 @@
-import { Entity } from "@core/shared/entity";
-import * as PIXI from "pixi.js";
-
-import { TimelineTheme } from "../../../core/theme";
-import { CLIP_CONSTANTS } from "../constants";
-import { SelectionOverlayRenderer } from "../managers/selection-overlay-renderer";
-import { getAssetDisplayName, TimelineAsset } from "../types/assets";
-import { ResolvedClipConfig } from "../types/timeline";
-
-export interface VisualClipOptions {
- pixelsPerSecond: number;
- trackHeight: number;
- trackIndex: number;
- clipIndex: number;
- theme: TimelineTheme;
- selectionRenderer?: SelectionOverlayRenderer;
-}
-
-export class VisualClip extends Entity {
- private clipConfig: ResolvedClipConfig;
- private options: VisualClipOptions;
- private graphics: PIXI.Graphics;
- private background: PIXI.Graphics;
- private text: PIXI.Text;
- private selectionRenderer: SelectionOverlayRenderer | undefined;
- private lastGlobalX: number = -1;
- private lastGlobalY: number = -1;
- /** @internal */
- private visualState: {
- mode: "normal" | "selected" | "dragging" | "resizing";
- previewWidth?: number;
- } = { mode: "normal" };
-
- // Visual constants (some from theme)
- private readonly CLIP_PADDING = CLIP_CONSTANTS.PADDING;
- private readonly BORDER_WIDTH = CLIP_CONSTANTS.BORDER_WIDTH;
- private get CORNER_RADIUS() {
- return this.options.theme.timeline.clips.radius || CLIP_CONSTANTS.CORNER_RADIUS;
- }
-
- constructor(clipConfig: ResolvedClipConfig, options: VisualClipOptions) {
- super();
- this.clipConfig = clipConfig;
- this.options = options;
- this.selectionRenderer = options.selectionRenderer;
- this.graphics = new PIXI.Graphics();
- this.background = new PIXI.Graphics();
- this.text = new PIXI.Text();
-
- this.setupContainer();
- }
-
- public async load(): Promise {
- this.setupGraphics();
- this.updateVisualState();
- }
-
- private setupContainer(): void {
- const container = this.getContainer();
-
- // Set up container with label for later tool integration
- container.label = `clip-${this.options.trackIndex}-${this.options.clipIndex}`;
-
- // Make container interactive for click events
- container.interactive = true;
- container.cursor = "pointer";
-
- container.addChild(this.background);
- container.addChild(this.graphics);
- container.addChild(this.text);
- }
-
- private setupGraphics(): void {
- // Set up text style using theme colors
- this.text.style = new PIXI.TextStyle({
- fontSize: CLIP_CONSTANTS.TEXT_FONT_SIZE,
- fill: this.options.theme.timeline.toolbar.text,
- fontWeight: "bold",
- wordWrap: false,
- fontFamily: "Arial, sans-serif"
- });
-
- // Position text
- this.text.anchor.set(0, 0);
- this.text.x = this.CLIP_PADDING;
- this.text.y = this.CLIP_PADDING;
- }
-
- public updateFromConfig(newConfig: ResolvedClipConfig): void {
- this.clipConfig = newConfig;
- this.updateVisualState();
- }
-
- /** @internal */
- private updateVisualState(): void {
- this.updatePosition();
- this.updateAppearance();
- this.updateSize();
- this.updateText();
- }
-
- private setVisualState(updates: Partial): void {
- // Create new state object instead of mutating
- this.visualState = {
- ...this.visualState,
- ...updates
- };
- this.updateVisualState();
- }
-
- /** @internal */
- private updatePosition(): void {
- const container = this.getContainer();
- const startTime = this.clipConfig.start;
- container.x = startTime * this.options.pixelsPerSecond;
- // Clip should be positioned at y=0 relative to its parent track
- // The track itself handles the trackIndex positioning
- container.y = 0;
- }
-
- /** @internal */
- private updateSize(): void {
- const width = this.getEffectiveWidth();
- const height = this.options.trackHeight;
-
- this.drawClipBackground(width, height);
- this.drawClipBorder(width, height);
- }
-
- private getEffectiveWidth(): number {
- // Use preview width if available, otherwise calculate from duration
- if (this.visualState.previewWidth !== undefined) {
- return this.visualState.previewWidth;
- }
-
- const duration = this.clipConfig.length;
- const calculatedWidth = duration * this.options.pixelsPerSecond;
- return Math.max(CLIP_CONSTANTS.MIN_WIDTH, calculatedWidth);
- }
-
- private drawClipBackground(width: number, height: number): void {
- const color = this.getClipColor();
- const styles = this.getStateStyles();
-
- this.background.clear();
- this.background.roundRect(0, 0, width, height, this.CORNER_RADIUS);
- this.background.fill({ color, alpha: styles.alpha });
- }
-
- private drawClipBorder(width: number, height: number): void {
- const styles = this.getStateStyles();
-
- // Always draw the basic border in the clip container
- const borderWidth = this.BORDER_WIDTH;
- this.graphics.clear();
- this.graphics.roundRect(0, 0, width, height, this.CORNER_RADIUS);
- this.graphics.stroke({ width: borderWidth, color: styles.borderColor });
-
- // Handle selection highlight via renderer
- this.updateSelectionState(width, height);
- }
-
- private updateSelectionState(width: number, height: number): void {
- if (!this.selectionRenderer) return;
-
- const isSelected = this.visualState.mode === "selected";
- const clipId = this.getClipId();
-
- if (!isSelected) {
- this.selectionRenderer.clearSelection(clipId);
- return;
- }
-
- // Calculate global position only if position has changed
- const container = this.getContainer();
- const globalPos = container.toGlobal(new PIXI.Point(0, 0));
-
- // Convert to overlay coordinates
- const overlayContainer = this.selectionRenderer.getOverlay();
- const overlayPos = overlayContainer.toLocal(globalPos);
-
- // Update selection via renderer
- this.selectionRenderer.renderSelection(
- clipId,
- {
- x: overlayPos.x,
- y: overlayPos.y,
- width,
- height,
- cornerRadius: this.CORNER_RADIUS,
- borderWidth: this.BORDER_WIDTH
- },
- isSelected
- );
- }
-
- private getClipColor(): number {
- // Color based on asset type using theme
- const assetType = this.clipConfig.asset?.type;
- const themeClips = this.options.theme.timeline.clips;
-
- switch (assetType) {
- case "video":
- return themeClips.video;
- case "audio":
- return themeClips.audio;
- case "image":
- return themeClips.image;
- case "text":
- return themeClips.text;
- case "rich-text":
- return themeClips["rich-text"] || themeClips.text;
- case "shape":
- return themeClips.shape;
- case "html":
- return themeClips.html;
- case "luma":
- return themeClips.luma;
- default:
- return themeClips.default;
- }
- }
-
- /** @internal */
- private updateAppearance(): void {
- const container = this.getContainer();
-
- // Apply container-level opacity
- container.alpha = this.visualState.mode === "dragging" ? CLIP_CONSTANTS.DRAG_OPACITY : CLIP_CONSTANTS.DEFAULT_ALPHA;
- }
-
- /** @internal */
- private updateText(): void {
- // Get text content using type-safe helper
- const displayText = this.clipConfig.asset ? getAssetDisplayName(this.clipConfig.asset as TimelineAsset) : "Clip";
-
- this.text.text = displayText;
-
- // Ensure text fits within clip bounds
- const clipWidth = this.clipConfig.length * this.options.pixelsPerSecond;
- const maxTextWidth = clipWidth - this.CLIP_PADDING * 2;
-
- if (this.text.width > maxTextWidth) {
- // Truncate text if too long
- const ratio = maxTextWidth / this.text.width;
- const truncatedLength = Math.floor(displayText.length * ratio) - CLIP_CONSTANTS.TEXT_TRUNCATE_SUFFIX_LENGTH;
- this.text.text = `${displayText.substring(0, Math.max(1, truncatedLength))}...`;
- }
- }
-
- private getStateStyles() {
- const { theme } = this.options;
-
- switch (this.visualState.mode) {
- case "dragging":
- return { alpha: CLIP_CONSTANTS.DRAG_OPACITY, borderColor: theme.timeline.tracks.border };
- case "resizing":
- return { alpha: CLIP_CONSTANTS.RESIZE_OPACITY, borderColor: theme.timeline.dropZone };
- case "selected":
- return { alpha: CLIP_CONSTANTS.DEFAULT_ALPHA, borderColor: theme.timeline.clips.selected };
- default:
- return { alpha: CLIP_CONSTANTS.DEFAULT_ALPHA, borderColor: theme.timeline.tracks.border };
- }
- }
-
- // Public state management methods
- public setSelected(selected: boolean): void {
- this.setVisualState({ mode: selected ? "selected" : "normal" });
- }
-
- public setDragging(dragging: boolean): void {
- this.setVisualState({ mode: dragging ? "dragging" : "normal" });
- }
-
- public setResizing(resizing: boolean): void {
- this.setVisualState({
- mode: resizing ? "resizing" : "normal",
- ...(resizing ? {} : { previewWidth: undefined })
- });
- }
-
- public setPreviewWidth(width: number | null): void {
- this.setVisualState({ previewWidth: width || undefined });
- }
-
- public setPixelsPerSecond(pixelsPerSecond: number): void {
- this.updateOptions({ pixelsPerSecond });
-
- // Update selection state with new dimensions
- if (this.visualState.mode === "selected") {
- const width = this.getEffectiveWidth();
- const height = this.options.trackHeight;
- this.updateSelectionState(width, height);
- }
- }
-
- public updateOptions(updates: Partial): void {
- // Create new options object with updates
- this.options = {
- ...this.options,
- ...updates
- };
- this.updateVisualState();
- }
-
- // Getters
- public getClipConfig(): ResolvedClipConfig {
- return this.clipConfig;
- }
-
- public getOptions(): VisualClipOptions {
- // Return a defensive copy to prevent external mutations
- return { ...this.options };
- }
-
- public getVisualState(): { mode: "normal" | "selected" | "dragging" | "resizing"; previewWidth?: number } {
- // Return a defensive copy to prevent external mutations
- return { ...this.visualState };
- }
-
- public getSelected(): boolean {
- return this.visualState.mode === "selected";
- }
-
- public getClipId(): string {
- return `${this.options.trackIndex}-${this.options.clipIndex}`;
- }
-
- public getDragging(): boolean {
- return this.visualState.mode === "dragging";
- }
-
- public getRightEdgeX(): number {
- const width = this.getEffectiveWidth();
- const startTime = this.clipConfig.start;
- return startTime * this.options.pixelsPerSecond + width;
- }
-
- // Required Entity methods
- /** @internal */
- public update(_deltaTime: number, _elapsed: number): void {
- // Update selection position if selected and position has changed
- if (this.visualState.mode === "selected" && this.selectionRenderer) {
- const container = this.getContainer();
- const globalPos = container.toGlobal(new PIXI.Point(0, 0));
-
- // Check if position has actually changed to avoid unnecessary updates
- if (globalPos.x !== this.lastGlobalX || globalPos.y !== this.lastGlobalY) {
- this.lastGlobalX = globalPos.x;
- this.lastGlobalY = globalPos.y;
-
- const width = this.getEffectiveWidth();
- const height = this.options.trackHeight;
- this.updateSelectionState(width, height);
- }
- }
- }
-
- /** @internal */
- public draw(): void {
- // Draw is called by the Entity system
- // Currently empty as updates happen immediately via state changes
- // This prevents redundant drawing when draw() is called repeatedly
- }
-
- /** @internal */
- public dispose(): void {
- // Clean up selection via renderer
- if (this.selectionRenderer) {
- this.selectionRenderer.clearSelection(this.getClipId());
- }
-
- // Clean up graphics resources
- this.background.destroy();
- this.graphics.destroy();
- this.text.destroy();
- }
-}
diff --git a/src/components/timeline/visual/visual-track.ts b/src/components/timeline/visual/visual-track.ts
deleted file mode 100644
index 140b0fed..00000000
--- a/src/components/timeline/visual/visual-track.ts
+++ /dev/null
@@ -1,304 +0,0 @@
-import { TrackSchema } from "@core/schemas/track";
-import { Entity } from "@core/shared/entity";
-import * as PIXI from "pixi.js";
-import { z } from "zod";
-
-import { TimelineTheme } from "../../../core/theme";
-import { TRACK_CONSTANTS } from "../constants";
-import { SelectionOverlayRenderer } from "../managers/selection-overlay-renderer";
-import { ResolvedClipConfig } from "../types/timeline";
-
-import { VisualClip, VisualClipOptions } from "./visual-clip";
-
-type TrackType = z.infer;
-
-export interface VisualTrackOptions {
- pixelsPerSecond: number;
- trackHeight: number;
- trackIndex: number;
- width: number;
- theme: TimelineTheme;
- selectionRenderer?: SelectionOverlayRenderer;
-}
-
-export class VisualTrack extends Entity {
- private clips: VisualClip[] = [];
- private options: VisualTrackOptions;
- private background: PIXI.Graphics;
-
- // Visual constants
- private readonly TRACK_PADDING = TRACK_CONSTANTS.PADDING;
- private readonly LABEL_PADDING = TRACK_CONSTANTS.LABEL_PADDING;
-
- constructor(options: VisualTrackOptions) {
- super();
- this.options = options;
- this.background = new PIXI.Graphics();
-
- this.setupContainer();
- }
-
- public async load(): Promise {
- this.updateTrackAppearance();
- }
-
- private setupContainer(): void {
- const container = this.getContainer();
-
- // Set up container with label for later tool integration
- container.label = `track-${this.options.trackIndex}`;
-
- container.addChild(this.background);
- // Track labels removed - container.addChild(this.trackLabel);
-
- // Position track at correct vertical position
- container.y = this.options.trackIndex * this.options.trackHeight;
- }
-
- /** @internal */
- private updateTrackAppearance(): void {
- const { width } = this.options;
- const height = this.options.trackHeight;
- const { theme } = this.options;
-
- // Draw track background
- this.background.clear();
-
- // Alternating track colors using theme
- const bgColor = this.options.trackIndex % 2 === 0 ? theme.timeline.tracks.surface : theme.timeline.tracks.surfaceAlt;
-
- this.background.rect(0, 0, width, height);
- this.background.fill({ color: bgColor, alpha: TRACK_CONSTANTS.DEFAULT_OPACITY });
-
- // Draw track border using theme
- this.background.rect(0, 0, width, height);
- this.background.stroke({ width: TRACK_CONSTANTS.BORDER_WIDTH, color: theme.timeline.tracks.border });
-
- // Draw track separator line at bottom using theme
- this.background.moveTo(0, height - 1);
- this.background.lineTo(width, height - 1);
- this.background.stroke({ width: TRACK_CONSTANTS.BORDER_WIDTH, color: theme.timeline.divider });
- }
-
- public rebuildFromTrackData(trackData: TrackType, pixelsPerSecond: number): void {
- // Update options with new pixels per second
- this.options = {
- ...this.options,
- pixelsPerSecond
- };
-
- // Clear existing clips
- this.clearAllClips();
-
- // Create new clips from track data
- if (trackData.clips) {
- trackData.clips.forEach((clipConfig, clipIndex) => {
- const visualClipOptions: VisualClipOptions = {
- pixelsPerSecond: this.options.pixelsPerSecond,
- trackHeight: this.options.trackHeight,
- trackIndex: this.options.trackIndex,
- clipIndex,
- theme: this.options.theme,
- selectionRenderer: this.options.selectionRenderer
- };
-
- const visualClip = new VisualClip(clipConfig as ResolvedClipConfig, visualClipOptions);
- this.addClip(visualClip);
- });
- }
-
- // Update track appearance
- this.updateTrackAppearance();
- }
-
- private async addClip(visualClip: VisualClip): Promise {
- this.clips.push(visualClip);
- await visualClip.load();
-
- // Add clip to container
- const container = this.getContainer();
- container.addChild(visualClip.getContainer());
- }
-
- private clearAllClips(): void {
- // Remove all clips from container and dispose them
- const container = this.getContainer();
-
- for (const clip of this.clips) {
- container.removeChild(clip.getContainer());
- clip.dispose();
- }
-
- this.clips = [];
- }
-
- public removeClip(clipIndex: number): void {
- if (clipIndex >= 0 && clipIndex < this.clips.length) {
- const clip = this.clips[clipIndex];
- const container = this.getContainer();
-
- container.removeChild(clip.getContainer());
- clip.dispose();
-
- this.clips.splice(clipIndex, 1);
- }
- }
-
- public updateClip(clipIndex: number, newClipConfig: ResolvedClipConfig): void {
- if (clipIndex >= 0 && clipIndex < this.clips.length) {
- const clip = this.clips[clipIndex];
- clip.updateFromConfig(newClipConfig);
- }
- }
-
- public setPixelsPerSecond(pixelsPerSecond: number): void {
- // Create new options object instead of mutating
- this.options = {
- ...this.options,
- pixelsPerSecond
- };
-
- // Update all clips with new pixels per second
- this.clips.forEach(clip => {
- clip.setPixelsPerSecond(pixelsPerSecond);
- });
-
- // Don't update appearance here - it will be updated when setWidth is called
- }
-
- public setWidth(width: number): void {
- // Create new options object instead of mutating
- this.options = {
- ...this.options,
- width
- };
- this.updateTrackAppearance();
- }
-
- public setTrackIndex(trackIndex: number): void {
- // Create new options object instead of mutating
- this.options = {
- ...this.options,
- trackIndex
- };
-
- // Update container position
- const container = this.getContainer();
- container.y = trackIndex * this.options.trackHeight;
-
- // Track labels removed
- // this.trackLabel.text = `Track ${trackIndex + 1}`;
-
- // Update all clips with new track index
- this.clips.forEach((clip, _clipIndex) => {
- clip.updateOptions({ trackIndex });
- });
- }
-
- // Selection methods
- public selectClip(clipIndex: number): void {
- // Clear all selections first
- this.clearAllSelections();
-
- // Select the specified clip
- if (clipIndex >= 0 && clipIndex < this.clips.length) {
- this.clips[clipIndex].setSelected(true);
- }
- }
-
- public clearAllSelections(): void {
- this.clips.forEach(clip => {
- clip.setSelected(false);
- });
- }
-
- public getSelectedClip(): VisualClip | null {
- return this.clips.find(clip => clip.getSelected()) || null;
- }
-
- public getSelectedClipIndex(): number {
- return this.clips.findIndex(clip => clip.getSelected());
- }
-
- // Getters
- public getClips(): VisualClip[] {
- return [...this.clips];
- }
-
- public getClip(clipIndex: number): VisualClip | null {
- return this.clips[clipIndex] || null;
- }
-
- public getClipCount(): number {
- return this.clips.length;
- }
-
- public getTrackIndex(): number {
- return this.options.trackIndex;
- }
-
- public getTrackHeight(): number {
- return this.options.trackHeight;
- }
-
- public getOptions(): VisualTrackOptions {
- // Return a defensive copy to prevent external mutations
- return { ...this.options };
- }
-
- // Hit testing
- public findClipAtPosition(x: number, y: number): { clip: VisualClip; clipIndex: number } | null {
- // Check if y is within track bounds
- if (y < 0 || y > this.options.trackHeight) {
- return null;
- }
-
- // Convert x to time
- const time = x / this.options.pixelsPerSecond;
-
- // Find clip at this time
- for (let i = 0; i < this.clips.length; i += 1) {
- const clip = this.clips[i];
- const clipConfig = clip.getClipConfig();
- const clipStart = clipConfig.start;
- const clipEnd = clipStart + clipConfig.length;
-
- if (time >= clipStart && time <= clipEnd) {
- return { clip, clipIndex: i };
- }
- }
-
- return null;
- }
-
- // Required Entity methods
- /** @internal */
- public update(_deltaTime: number, _elapsed: number): void {
- // VisualTrack doesn't need frame-based updates
- // All updates are driven by state changes
-
- // Update all clips
- this.clips.forEach(clip => {
- clip.update(_deltaTime, _elapsed);
- });
- }
-
- /** @internal */
- public draw(): void {
- // Draw is called by the Entity system
- // Track appearance is updated when properties change
- // Only propagate draw to clips
- this.clips.forEach(clip => {
- clip.draw();
- });
- }
-
- /** @internal */
- public dispose(): void {
- // Clean up all clips
- this.clearAllClips();
-
- // Clean up graphics resources
- this.background.destroy();
- }
-}
diff --git a/src/core/animations/composed-keyframe-builder.ts b/src/core/animations/composed-keyframe-builder.ts
new file mode 100644
index 00000000..0fd009c0
--- /dev/null
+++ b/src/core/animations/composed-keyframe-builder.ts
@@ -0,0 +1,67 @@
+import { type Keyframe } from "@schemas";
+
+import { KeyframeBuilder } from "./keyframe-builder";
+
+type CompositionMode = "additive" | "multiplicative";
+
+/**
+ * Composes multiple keyframe layers into a single value using additive or multiplicative blending.
+ *
+ * - **Additive mode**: `base + Σ(layer deltas)` - used for offset and rotation
+ * - **Multiplicative mode**: `base × Π(layer factors)` - used for scale and opacity
+ *
+ * This enables effects and transitions to run simultaneously without conflicts.
+ */
+export class ComposedKeyframeBuilder {
+ private readonly baseValue: number;
+ private readonly mode: CompositionMode;
+ private readonly layers: KeyframeBuilder[] = [];
+ private readonly length: number;
+ private readonly clampRange?: { min: number; max: number };
+
+ constructor(baseValue: number, length: number, mode: CompositionMode, clampRange?: { min: number; max: number }) {
+ this.baseValue = baseValue;
+ this.length = length;
+ this.mode = mode;
+ this.clampRange = clampRange;
+ }
+
+ /**
+ * Add a keyframe layer to the composition.
+ * For additive mode, keyframes should represent deltas (e.g., 0 → 0.1 means "move by 0.1")
+ * For multiplicative mode, keyframes should represent factors (e.g., 1 → 1.3 means "scale by 1.3x")
+ */
+ addLayer(keyframes: Keyframe[]): void {
+ if (keyframes.length === 0) return;
+
+ const neutralValue = this.mode === "additive" ? 0 : 1;
+ this.layers.push(new KeyframeBuilder(keyframes, this.length, neutralValue));
+ }
+
+ /**
+ * Get the composed value at a specific time.
+ * Combines base value with all layer values using the composition mode.
+ */
+ getValue(time: number): number {
+ if (this.layers.length === 0) {
+ return this.baseValue;
+ }
+
+ if (this.mode === "additive") {
+ let result = this.baseValue;
+ for (const layer of this.layers) {
+ result += layer.getValue(time);
+ }
+ return result;
+ }
+ let result = this.baseValue;
+ for (const layer of this.layers) {
+ result *= layer.getValue(time);
+ }
+ // Clamp to range if specified (e.g., [0, 1] for opacity)
+ if (this.clampRange) {
+ result = Math.max(this.clampRange.min, Math.min(this.clampRange.max, result));
+ }
+ return result;
+ }
+}
diff --git a/src/core/animations/curve-interpolator.ts b/src/core/animations/curve-interpolator.ts
index 21e3de2f..2b9acaf8 100644
--- a/src/core/animations/curve-interpolator.ts
+++ b/src/core/animations/curve-interpolator.ts
@@ -7,6 +7,10 @@ export class CurveInterpolator {
private initializeCurves(): void {
this.curves = {
+ smooth: [
+ [0.5, 0.0],
+ [0.5, 1.0]
+ ],
ease: [
[0.25, 0.1],
[0.25, 1.0]
diff --git a/src/core/animations/effect-preset-builder.ts b/src/core/animations/effect-preset-builder.ts
index 4f63487e..cf45ebb7 100644
--- a/src/core/animations/effect-preset-builder.ts
+++ b/src/core/animations/effect-preset-builder.ts
@@ -1,6 +1,6 @@
+import { type ResolvedClip, type Keyframe } from "@schemas";
+
import { type Size } from "../layouts/geometry";
-import { type ResolvedClipConfig } from "../schemas/clip";
-import { type Keyframe } from "../schemas/keyframe";
export type EffectKeyframeSet = {
offsetXKeyframes: Keyframe[];
@@ -10,14 +10,22 @@ export type EffectKeyframeSet = {
rotationKeyframes: Keyframe[];
};
+export type RelativeEffectKeyframeSet = EffectKeyframeSet;
+
+type ParsedEffect = { name: string; speed: string | undefined };
+
export class EffectPresetBuilder {
- private clipConfiguration: ResolvedClipConfig;
+ private readonly clipConfiguration: ResolvedClip;
+ private readonly effectPreset: ParsedEffect;
- constructor(clipConfiguration: ResolvedClipConfig) {
+ constructor(clipConfiguration: ResolvedClip) {
this.clipConfiguration = clipConfiguration;
+
+ const [name, speed] = (clipConfiguration.effect ?? "").split(/(Slow|Fast)/);
+ this.effectPreset = { name, speed };
}
- public build(editSize: Size, clipSize: Size): EffectKeyframeSet {
+ public buildRelative(editSize: Size, clipSize: Size): RelativeEffectKeyframeSet {
const offsetXKeyframes: Keyframe[] = [];
const offsetYKeyframes: Keyframe[] = [];
const opacityKeyframes: Keyframe[] = [];
@@ -36,94 +44,40 @@ export class EffectPresetBuilder {
switch (effectName) {
case "zoomIn": {
const zoomSpeed = this.getZoomSpeed();
- const rawScale = this.clipConfiguration.scale;
- const scale = typeof rawScale === "number" ? rawScale : 1;
-
- const initialScale = 1 * scale;
- const targetScale = zoomSpeed * scale;
-
- scaleKeyframes.push({ from: initialScale, to: targetScale, start, length, interpolation: "linear" });
-
+ // Factor: starts at 1x, ends at zoomSpeed (e.g., 1.3x)
+ scaleKeyframes.push({ from: 1, to: zoomSpeed, start, length, interpolation: "linear" });
break;
}
case "zoomOut": {
const zoomSpeed = this.getZoomSpeed();
- const rawScale = this.clipConfiguration.scale;
- const scale = typeof rawScale === "number" ? rawScale : 1;
-
- const initialScale = zoomSpeed * scale;
- const targetScale = 1 * scale;
-
- scaleKeyframes.push({ from: initialScale, to: targetScale, start, length, interpolation: "linear" });
-
- break;
- }
- case "slideLeft": {
- const fittedSize = this.getFittedSize(editSize, clipSize);
- let targetOffsetX = this.getSlideStart();
-
- const minScaleWidth = editSize.width + editSize.width * targetOffsetX * 2;
-
- if (fittedSize.width < minScaleWidth) {
- const scaleFactorWidth = minScaleWidth / fittedSize.width;
- scaleKeyframes.push({ from: scaleFactorWidth, to: scaleFactorWidth, start, length, interpolation: "linear" });
- } else {
- targetOffsetX = (fittedSize.width - editSize.width) / 2 / editSize.width;
- }
-
- offsetXKeyframes.push({ from: targetOffsetX, to: -targetOffsetX, start, length });
-
- break;
- }
- case "slideRight": {
- const fittedSize = this.getFittedSize(editSize, clipSize);
- let targetOffsetX = this.getSlideStart();
-
- const minScaleWidth = editSize.width + editSize.width * targetOffsetX * 2;
-
- if (fittedSize.width < minScaleWidth) {
- const scaleFactorWidth = minScaleWidth / fittedSize.width;
- scaleKeyframes.push({ from: scaleFactorWidth, to: scaleFactorWidth, start, length, interpolation: "linear" });
- } else {
- targetOffsetX = (fittedSize.width - editSize.width) / 2 / editSize.width;
- }
-
- offsetXKeyframes.push({ from: -targetOffsetX, to: targetOffsetX, start, length });
-
- break;
- }
- case "slideUp": {
- const fittedSize = this.getFittedSize(editSize, clipSize);
- let targetOffsetY = this.getSlideStart();
-
- const minScaleHeight = editSize.height + editSize.height * targetOffsetY * 2;
-
- if (fittedSize.height < minScaleHeight) {
- const scaleFactorHeight = minScaleHeight / fittedSize.height;
- scaleKeyframes.push({ from: scaleFactorHeight, to: scaleFactorHeight, start, length, interpolation: "linear" });
- } else {
- targetOffsetY = (fittedSize.height - editSize.height) / 2 / editSize.height;
- }
-
- offsetYKeyframes.push({ from: targetOffsetY, to: -targetOffsetY, start, length });
-
+ // Factor: starts at zoomSpeed (e.g., 1.3x), ends at 1x
+ scaleKeyframes.push({ from: zoomSpeed, to: 1, start, length, interpolation: "linear" });
break;
}
+ case "slideLeft":
+ case "slideRight":
+ case "slideUp":
case "slideDown": {
+ const isHorizontal = effectName === "slideLeft" || effectName === "slideRight";
+ const startsPositive = effectName === "slideLeft" || effectName === "slideUp";
+
const fittedSize = this.getFittedSize(editSize, clipSize);
- let targetOffsetY = this.getSlideStart();
+ const editDimension = isHorizontal ? editSize.width : editSize.height;
+ const fittedDimension = isHorizontal ? fittedSize.width : fittedSize.height;
- const minScaleHeight = editSize.height + editSize.height * targetOffsetY * 2;
+ let targetOffset = this.getSlideStart();
+ const minScale = editDimension + editDimension * targetOffset * 2;
- if (fittedSize.height < minScaleHeight) {
- const scaleFactorHeight = minScaleHeight / fittedSize.height;
- scaleKeyframes.push({ from: scaleFactorHeight, to: scaleFactorHeight, start, length, interpolation: "linear" });
+ if (fittedDimension < minScale) {
+ const scaleFactor = minScale / fittedDimension;
+ scaleKeyframes.push({ from: scaleFactor, to: scaleFactor, start, length, interpolation: "linear" });
} else {
- targetOffsetY = (fittedSize.height - editSize.height) / 2 / editSize.height;
+ targetOffset = (fittedDimension - editDimension) / 2 / editDimension;
}
- offsetYKeyframes.push({ from: -targetOffsetY, to: targetOffsetY, start, length });
-
+ const [from, to] = startsPositive ? [targetOffset, -targetOffset] : [-targetOffset, targetOffset];
+ const targetKeyframes = isHorizontal ? offsetXKeyframes : offsetYKeyframes;
+ targetKeyframes.push({ from, to, start, length });
break;
}
default:
@@ -134,15 +88,14 @@ export class EffectPresetBuilder {
}
private getPresetName(): string {
- const [effectName] = (this.clipConfiguration.effect ?? "").split(/(Slow|Fast)/);
- return effectName;
+ return this.effectPreset.name;
}
private getZoomSpeed(): number {
- const [effectName, effectSpeed] = (this.clipConfiguration.effect ?? "").split(/(Slow|Fast)/);
+ const { name, speed } = this.effectPreset;
- if (effectName.startsWith("zoom")) {
- switch (effectSpeed) {
+ if (name.startsWith("zoom")) {
+ switch (speed) {
case "Slow":
return 1.1;
case "Fast":
@@ -156,10 +109,10 @@ export class EffectPresetBuilder {
}
private getSlideStart(): number {
- const [effectName, effectSpeed] = (this.clipConfiguration.effect ?? "").split(/(Slow|Fast)/);
+ const { name, speed } = this.effectPreset;
- if (effectName.startsWith("slide")) {
- switch (effectSpeed) {
+ if (name.startsWith("slide")) {
+ switch (speed) {
case "Slow":
return 0.03;
case "Fast":
diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts
index 7c856b6e..7d59225c 100644
--- a/src/core/animations/keyframe-builder.ts
+++ b/src/core/animations/keyframe-builder.ts
@@ -1,13 +1,15 @@
-import { type Keyframe } from "../schemas/keyframe";
+import { type Keyframe, type NumericKeyframe } from "@schemas";
import { CurveInterpolator } from "./curve-interpolator";
export class KeyframeBuilder {
- private readonly property: Keyframe[];
+ private readonly property: NumericKeyframe[];
private readonly length: number;
private readonly cubicBuilder: CurveInterpolator;
+ private cachedIndex = 0;
+
constructor(value: Keyframe[] | number, length: number, initialValue = 0) {
this.property = this.createKeyframes(value, length, initialValue);
this.length = length;
@@ -16,7 +18,7 @@ export class KeyframeBuilder {
}
public getValue(time: number): number {
- const keyframe = this.property.find(value => time >= value.start && time < value.start + value.length);
+ const keyframe = this.findKeyframe(time);
if (!keyframe) {
if (this.property.length > 0) {
if (time >= this.length) return this.property[this.property.length - 1].to;
@@ -37,7 +39,70 @@ export class KeyframeBuilder {
}
}
- private createKeyframes(value: Keyframe[] | number, length: number, initialValue = 0): Keyframe[] {
+ private findKeyframe(time: number): NumericKeyframe | undefined {
+ const props = this.property;
+ if (props.length === 0) return undefined;
+
+ const cached = props[this.cachedIndex];
+ if (cached) {
+ const cachedEnd = cached.start + cached.length;
+ if (Number.isFinite(cachedEnd) && time >= cached.start && time < cachedEnd) {
+ return cached;
+ }
+ }
+
+ const nextIdx = this.cachedIndex + 1;
+ if (nextIdx < props.length) {
+ const next = props[nextIdx];
+ const nextEnd = next.start + next.length;
+ if (Number.isFinite(nextEnd) && time >= next.start && time < nextEnd) {
+ this.cachedIndex = nextIdx;
+ return next;
+ }
+ }
+
+ const prevIdx = this.cachedIndex - 1;
+ if (prevIdx >= 0) {
+ const prev = props[prevIdx];
+ const prevEnd = prev.start + prev.length;
+ if (Number.isFinite(prevEnd) && time >= prev.start && time < prevEnd) {
+ this.cachedIndex = prevIdx;
+ return prev;
+ }
+ }
+
+ const idx = this.binarySearchKeyframe(time);
+ if (idx !== -1) {
+ this.cachedIndex = idx;
+ return props[idx];
+ }
+
+ return undefined;
+ }
+
+ private binarySearchKeyframe(time: number): number {
+ const props = this.property;
+ let low = 0;
+ let high = props.length - 1;
+
+ while (low <= high) {
+ const mid = Math.floor((low + high) / 2);
+ const kf = props[mid];
+ const end = kf.start + kf.length;
+
+ if (!Number.isFinite(end) || time >= end) {
+ low = mid + 1;
+ } else if (time < kf.start) {
+ high = mid - 1;
+ } else {
+ return mid;
+ }
+ }
+
+ return -1;
+ }
+
+ private createKeyframes(value: Keyframe[] | number, length: number, initialValue = 0): NumericKeyframe[] {
if (typeof value === "number") {
return [{ start: 0, length, from: value, to: value }];
}
@@ -48,22 +113,26 @@ export class KeyframeBuilder {
const normalizedKeyframes = this.createNormalizedKeyframes(value);
- try {
- this.validateKeyframes(normalizedKeyframes);
- } catch (error) {
- console.warn("Keyframe configuration issues detected:", error);
- }
+ this.validateKeyframes(normalizedKeyframes);
return this.insertFillerKeyframes(normalizedKeyframes, length, initialValue);
}
- private createNormalizedKeyframes(keyframes: Keyframe[]): Keyframe[] {
+ private createNormalizedKeyframes(keyframes: Keyframe[]): NumericKeyframe[] {
return keyframes
+ .filter((kf): kf is Keyframe & { start: number; length: number } => typeof kf.start === "number" && typeof kf.length === "number")
.toSorted((a, b) => a.start - b.start)
- .map(keyframe => ({ ...keyframe, start: keyframe.start * 1000, length: keyframe.length * 1000 }));
+ .map(keyframe => ({
+ start: keyframe.start,
+ length: keyframe.length,
+ from: typeof keyframe.from === "number" ? keyframe.from : 0,
+ to: typeof keyframe.to === "number" ? keyframe.to : 0,
+ interpolation: keyframe.interpolation,
+ easing: keyframe.easing
+ }));
}
- private validateKeyframes(keyframes: Keyframe[]): void {
+ private validateKeyframes(keyframes: NumericKeyframe[]): void {
for (let i = 0; i < keyframes.length; i += 1) {
const current = keyframes[i];
const next = keyframes[i + 1];
@@ -82,8 +151,8 @@ export class KeyframeBuilder {
}
}
- private insertFillerKeyframes(keyframes: Keyframe[], length: number, initialValue = 0): Keyframe[] {
- const updatedKeyframes: Keyframe[] = [];
+ private insertFillerKeyframes(keyframes: NumericKeyframe[], length: number, initialValue = 0): NumericKeyframe[] {
+ const updatedKeyframes: NumericKeyframe[] = [];
for (let i = 0; i < keyframes.length; i += 1) {
const current = keyframes[i];
@@ -91,7 +160,7 @@ export class KeyframeBuilder {
const shouldFillStart = i === 0 && current.start !== 0;
if (shouldFillStart) {
- const fillerKeyframe: Keyframe = { start: 0, length: current.start, from: initialValue, to: current.from };
+ const fillerKeyframe: NumericKeyframe = { start: 0, length: current.start, from: initialValue, to: current.from };
updatedKeyframes.push(fillerKeyframe);
}
@@ -101,7 +170,7 @@ export class KeyframeBuilder {
const shouldFillEnd = current.start + current.length < length;
if (shouldFillEnd) {
const currentStart = current.start + current.length;
- const fillerKeyframe: Keyframe = { start: currentStart, length: length - currentStart, from: current.to, to: current.to };
+ const fillerKeyframe: NumericKeyframe = { start: currentStart, length: length - currentStart, from: current.to, to: current.to };
updatedKeyframes.push(fillerKeyframe);
}
@@ -111,7 +180,9 @@ export class KeyframeBuilder {
const shouldFillMiddle = current.start + current.length !== next.start;
if (shouldFillMiddle) {
- const fillerKeyframe: Keyframe = { start: current.start + current.length, length: next.start, from: current.to, to: next.from };
+ const fillerStart = current.start + current.length;
+ const fillerLength = next.start - fillerStart;
+ const fillerKeyframe: NumericKeyframe = { start: fillerStart, length: fillerLength, from: current.to, to: next.from };
updatedKeyframes.push(fillerKeyframe);
}
}
diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts
index b0be0455..e44109e3 100644
--- a/src/core/animations/transition-preset-builder.ts
+++ b/src/core/animations/transition-preset-builder.ts
@@ -1,5 +1,4 @@
-import { type ResolvedClipConfig } from "../schemas/clip";
-import { type Keyframe } from "../schemas/keyframe";
+import { type ResolvedClip, type Keyframe } from "@schemas";
export type TransitionKeyframeSet = {
offsetXKeyframes: Keyframe[];
@@ -7,297 +6,154 @@ export type TransitionKeyframeSet = {
opacityKeyframes: Keyframe[];
scaleKeyframes: Keyframe[];
rotationKeyframes: Keyframe[];
+ maskXKeyframes: Keyframe[];
};
+export type RelativeTransitionKeyframeSet = {
+ in: TransitionKeyframeSet;
+ out: TransitionKeyframeSet;
+};
+
+type ParsedTransition = { name: string; speed: string | undefined };
+
export class TransitionPresetBuilder {
- private clipConfiguration: ResolvedClipConfig;
+ private readonly clipConfiguration: ResolvedClip;
+ private readonly inPreset: ParsedTransition;
+ private readonly outPreset: ParsedTransition;
- constructor(clipConfiguration: ResolvedClipConfig) {
+ constructor(clipConfiguration: ResolvedClip) {
this.clipConfiguration = clipConfiguration;
- }
-
- public build(): TransitionKeyframeSet {
- const offsetXKeyframes: Keyframe[] = [];
- const offsetYKeyframes: Keyframe[] = [];
- const opacityKeyframes: Keyframe[] = [];
- const scaleKeyframes: Keyframe[] = [];
- const rotationKeyframes: Keyframe[] = [];
- const inPresetKeyframeSet = this.buildInPreset();
- offsetXKeyframes.push(...inPresetKeyframeSet.offsetXKeyframes);
- offsetYKeyframes.push(...inPresetKeyframeSet.offsetYKeyframes);
- opacityKeyframes.push(...inPresetKeyframeSet.opacityKeyframes);
- scaleKeyframes.push(...inPresetKeyframeSet.scaleKeyframes);
- rotationKeyframes.push(...inPresetKeyframeSet.rotationKeyframes);
+ this.inPreset = this.parseTransition(clipConfiguration.transition?.in);
+ this.outPreset = this.parseTransition(clipConfiguration.transition?.out);
+ }
- const outPresetKeyframeSet = this.buildOutPreset();
+ private parseTransition(value: string | undefined): ParsedTransition {
+ const [name, speed] = (value ?? "").split(/(Slow|Fast|VeryFast)/);
+ return { name, speed };
+ }
- offsetXKeyframes.push(...outPresetKeyframeSet.offsetXKeyframes);
- offsetYKeyframes.push(...outPresetKeyframeSet.offsetYKeyframes);
- opacityKeyframes.push(...outPresetKeyframeSet.opacityKeyframes);
- scaleKeyframes.push(...outPresetKeyframeSet.scaleKeyframes);
- rotationKeyframes.push(...outPresetKeyframeSet.rotationKeyframes);
+ public buildRelative(): RelativeTransitionKeyframeSet {
+ return {
+ in: this.buildTransitionKeyframes("in"),
+ out: this.buildTransitionKeyframes("out")
+ };
+ }
- return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes };
+ private createEmptyKeyframeSet(): TransitionKeyframeSet {
+ return {
+ offsetXKeyframes: [],
+ offsetYKeyframes: [],
+ opacityKeyframes: [],
+ scaleKeyframes: [],
+ rotationKeyframes: [],
+ maskXKeyframes: []
+ };
}
- private buildInPreset(): TransitionKeyframeSet {
- const offsetXKeyframes: Keyframe[] = [];
- const offsetYKeyframes: Keyframe[] = [];
- const opacityKeyframes: Keyframe[] = [];
- const scaleKeyframes: Keyframe[] = [];
- const rotationKeyframes: Keyframe[] = [];
+ private buildTransitionKeyframes(direction: "in" | "out"): TransitionKeyframeSet {
+ const keyframes = this.createEmptyKeyframeSet();
+ const transitionValue = direction === "in" ? this.clipConfiguration.transition?.in : this.clipConfiguration.transition?.out;
- if (!this.clipConfiguration.transition?.in) {
- return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes };
- }
+ if (!transitionValue) return keyframes;
- const start = 0;
- const length = this.getInPresetLength();
- const transitionName = this.getInPresetName();
+ const length = this.getPresetLength(direction);
+ const start = direction === "in" ? 0 : this.clipConfiguration.length - length;
+ const transitionName = this.getPresetName(direction);
+ const isIn = direction === "in";
switch (transitionName) {
case "fade": {
- const initialOpacity = 0;
- const targetOpacity = Math.max(0, Math.min((this.clipConfiguration.opacity as number) ?? 1, 1));
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "linear" });
-
+ const [from, to] = isIn ? [0, 1] : [1, 0];
+ keyframes.opacityKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "ease" });
break;
}
case "zoom": {
- const zoomScaleDistance = 9;
- const rawScale = this.clipConfiguration.scale;
- const scale = typeof rawScale === "number" ? rawScale : 1;
-
- const initialScale = scale + zoomScaleDistance;
- const targetScale = scale;
- scaleKeyframes.push({ from: initialScale, to: targetScale, start, length, interpolation: "bezier", easing: "easeIn" });
-
- const initialOpacity = 0;
- const targetOpacity = 1;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" });
-
- break;
- }
- case "slideLeft": {
- const rawOffsetX = this.clipConfiguration.offset?.x;
- const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0;
- const initialOffsetX = offsetX + 0.025;
- const targetOffsetX = offsetX;
- offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "easeIn" });
-
- const initialOpacity = 0;
- const targetOpacity = 1;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" });
-
- break;
- }
- case "slideRight": {
- const rawOffsetX = this.clipConfiguration.offset?.x;
- const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0;
- const initialOffsetX = offsetX - 0.025;
- const targetOffsetX = offsetX;
- offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "easeIn" });
-
- const initialOpacity = 0;
- const targetOpacity = 1;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" });
-
- break;
- }
- case "slideUp": {
- const rawOffsetY = this.clipConfiguration.offset?.y;
- const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0;
- const initialOffsetY = offsetY + 0.025;
- const targetOffsetY = offsetY;
- offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "easeIn" });
-
- const initialOpacity = 0;
- const targetOpacity = 1;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" });
-
+ const [scaleFrom, scaleTo] = isIn ? [10, 1] : [1, 10];
+ const [opacityFrom, opacityTo] = isIn ? [0, 1] : [1, 0];
+ const easing = isIn ? "easeIn" : "easeOut";
+ keyframes.scaleKeyframes.push({ from: scaleFrom, to: scaleTo, start, length, interpolation: "bezier", easing });
+ keyframes.opacityKeyframes.push({ from: opacityFrom, to: opacityTo, start, length, interpolation: "bezier", easing });
break;
}
+ case "slideLeft":
+ case "slideRight":
+ case "slideUp":
case "slideDown": {
- const rawOffsetY = this.clipConfiguration.offset?.y;
- const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0;
- const initialOffsetY = offsetY - 0.025;
- const targetOffsetY = offsetY;
- offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "easeOut" });
-
- const initialOpacity = 0;
- const targetOpacity = 1;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeOut" });
-
+ const isHorizontal = transitionName === "slideLeft" || transitionName === "slideRight";
+ const isNegative = transitionName === "slideLeft" || transitionName === "slideUp";
+ const offset = 0.025;
+
+ const [offsetFrom, offsetTo] = isIn ? [isNegative ? offset : -offset, 0] : [0, isNegative ? -offset : offset];
+ const interpolation = isIn ? "linear" : "bezier";
+ const [opacityFrom, opacityTo] = isIn ? [0, 1] : [1, 0];
+
+ const targetKeyframes = isHorizontal ? keyframes.offsetXKeyframes : keyframes.offsetYKeyframes;
+ targetKeyframes.push(
+ isIn
+ ? { from: offsetFrom, to: offsetTo, start, length, interpolation }
+ : { from: offsetFrom, to: offsetTo, start, length, interpolation, easing: "ease" }
+ );
+ keyframes.opacityKeyframes.push({ from: opacityFrom, to: opacityTo, start, length, interpolation: "bezier", easing: "ease" });
break;
}
case "carouselLeft":
case "carouselRight":
case "carouselUp":
- case "carouselDown":
- case "shuffleTopRight":
- case "shuffleRightTop":
- case "shuffleRightBottom":
- case "shuffleBottomRight":
- case "shuffleBottomLeft":
- case "shuffleLeftBottom":
- case "shuffleLeftTop":
- case "shuffleTopLeft":
- default:
- console.warn(`Unimplemented transition:in preset "${this.clipConfiguration.transition.in}"`);
- break;
- }
-
- return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes };
- }
-
- private buildOutPreset(): TransitionKeyframeSet {
- const offsetXKeyframes: Keyframe[] = [];
- const offsetYKeyframes: Keyframe[] = [];
- const opacityKeyframes: Keyframe[] = [];
- const scaleKeyframes: Keyframe[] = [];
- const rotationKeyframes: Keyframe[] = [];
-
- if (!this.clipConfiguration.transition?.out) {
- return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes };
- }
-
- const length = this.getOutPresetLength();
- const start = this.clipConfiguration.length - length;
- const transitionName = this.getOutPresetName();
-
- switch (transitionName) {
- case "fade": {
- const initialOpacity = Math.max(0, Math.min((this.clipConfiguration.opacity as number) ?? 1, 1));
- const targetOpacity = 0;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "linear" });
-
- break;
- }
- case "zoom": {
- const zoomScaleDistance = 9;
- const rawScale = this.clipConfiguration.scale;
- const scale = typeof rawScale === "number" ? rawScale : 1;
-
- const initialScale = scale;
- const targetScale = scale + zoomScaleDistance;
- scaleKeyframes.push({ from: initialScale, to: targetScale, start, length, interpolation: "bezier", easing: "easeOut" });
-
- const initialOpacity = 1;
- const targetOpacity = 0;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeOut" });
-
+ case "carouselDown": {
+ const isHorizontal = transitionName === "carouselLeft" || transitionName === "carouselRight";
+ const isNegative = transitionName === "carouselLeft" || transitionName === "carouselUp";
+
+ // Carousel uses different offsets for in vs out, and different interpolation
+ const baseOffset = (isHorizontal && 1) || (isIn ? 1.05 : 1.1);
+ const [offsetFrom, offsetTo] = isIn ? [isNegative ? baseOffset : -baseOffset, 0] : [0, isNegative ? -baseOffset : baseOffset];
+
+ const targetKeyframes = isHorizontal ? keyframes.offsetXKeyframes : keyframes.offsetYKeyframes;
+ targetKeyframes.push(
+ isIn
+ ? { from: offsetFrom, to: offsetTo, start, length, interpolation: "linear" }
+ : { from: offsetFrom, to: offsetTo, start, length, interpolation: "bezier", easing: "ease" }
+ );
break;
}
- case "slideLeft": {
- const rawOffsetX = this.clipConfiguration.offset?.x;
- const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0;
- const initialOffsetX = offsetX;
- const targetOffsetX = offsetX - 0.025;
- offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "easeOut" });
-
- const initialOpacity = 1;
- const targetOpacity = 0;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeOut" });
-
- break;
- }
- case "slideRight": {
- const rawOffsetX = this.clipConfiguration.offset?.x;
- const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0;
- const initialOffsetX = offsetX;
- const targetOffsetX = offsetX + 0.025;
- offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "easeOut" });
-
- const initialOpacity = 1;
- const targetOpacity = 0;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeOut" });
-
- break;
- }
- case "slideUp": {
- const rawOffsetY = this.clipConfiguration.offset?.y;
- const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0;
- const initialOffsetY = offsetY;
- const targetOffsetY = offsetY - 0.025;
- offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "easeIn" });
-
- const initialOpacity = 1;
- const targetOpacity = 0;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" });
-
+ case "reveal":
+ case "wipeRight": {
+ const [from, to] = isIn ? [0, 1] : [1, 0];
+ keyframes.maskXKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "ease" });
break;
}
- case "slideDown": {
- const rawOffsetY = this.clipConfiguration.offset?.y;
- const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0;
- const initialOffsetY = offsetY;
- const targetOffsetY = offsetY + 0.025;
- offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "easeIn" });
-
- const initialOpacity = 1;
- const targetOpacity = 0;
- opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" });
-
+ case "wipeLeft": {
+ const [from, to] = isIn ? [1, 0] : [0, 1];
+ keyframes.maskXKeyframes.push({ from, to, start, length, interpolation: "bezier", easing: "ease" });
break;
}
- case "carouselLeft":
- case "carouselRight":
- case "carouselUp":
- case "carouselDown":
- case "shuffleTopRight":
- case "shuffleRightTop":
- case "shuffleRightBottom":
- case "shuffleBottomRight":
- case "shuffleBottomLeft":
- case "shuffleLeftBottom":
- case "shuffleLeftTop":
- case "shuffleTopLeft":
default:
- console.warn(`Unimplemented transition:out preset "${this.clipConfiguration.transition.out}"`);
break;
}
- return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes };
- }
-
- private getInPresetName(): string {
- const [transitionName] = (this.clipConfiguration.transition?.in ?? "").split(/(Slow|Fast|VeryFast)/);
- return transitionName;
+ return keyframes;
}
- private getOutPresetName(): string {
- const [transitionName] = (this.clipConfiguration.transition?.out ?? "").split(/(Slow|Fast|VeryFast)/);
- return transitionName;
+ private getPresetName(direction: "in" | "out"): string {
+ return direction === "in" ? this.inPreset.name : this.outPreset.name;
}
- private getInPresetLength(): number {
- const [, transitionSpeed] = (this.clipConfiguration.transition?.in ?? "").split(/(Slow|Fast|VeryFast)/);
-
- switch (transitionSpeed) {
- case "Slow":
- return 2;
- case "Fast":
- return 0.5;
- case "VeryFast":
- return 0.25;
- default:
- return 1;
- }
- }
+ private getPresetLength(direction: "in" | "out"): number {
+ const { name, speed } = direction === "in" ? this.inPreset : this.outPreset;
+ const isCarousel = name.startsWith("carousel");
+ const isSlide = name.startsWith("slide");
- private getOutPresetLength(): number {
- const [, transitionSpeed] = (this.clipConfiguration.transition?.out ?? "").split(/(Slow|Fast|VeryFast)/);
+ if (name === "zoom") return 0.4;
- switch (transitionSpeed) {
+ switch (speed) {
case "Slow":
return 2;
case "Fast":
- return 0.5;
+ return isCarousel || isSlide ? 0.25 : 0.5;
case "VeryFast":
return 0.25;
default:
- return 1;
+ return isCarousel || isSlide ? 0.5 : 1;
}
}
}
diff --git a/src/core/captions/index.ts b/src/core/captions/index.ts
new file mode 100644
index 00000000..ae197f3c
--- /dev/null
+++ b/src/core/captions/index.ts
@@ -0,0 +1 @@
+export { parseSubtitle, parseVTT, parseSRT, findActiveCue, getCuesDuration, type Cue } from "./parser";
diff --git a/src/core/captions/parser.ts b/src/core/captions/parser.ts
new file mode 100644
index 00000000..9dded7bd
--- /dev/null
+++ b/src/core/captions/parser.ts
@@ -0,0 +1,127 @@
+export interface Cue {
+ start: number;
+ end: number;
+ text: string;
+}
+
+function parseTimestamp(timestamp: string): number {
+ const normalized = timestamp.trim().replace(",", ".");
+ const parts = normalized.split(":");
+
+ if (parts.length === 3) {
+ const hours = parseInt(parts[0], 10);
+ const minutes = parseInt(parts[1], 10);
+ const seconds = parseFloat(parts[2]);
+ return hours * 3600 + minutes * 60 + seconds;
+ }
+
+ if (parts.length === 2) {
+ const minutes = parseInt(parts[0], 10);
+ const seconds = parseFloat(parts[1]);
+ return minutes * 60 + seconds;
+ }
+
+ return parseFloat(normalized) || 0;
+}
+
+export function parseVTT(content: string): Cue[] {
+ const cues: Cue[] = [];
+ const lines = content.split(/\r?\n/);
+
+ let i = 0;
+
+ while (i < lines.length && !lines[i].includes("-->")) {
+ i += 1;
+ }
+
+ while (i < lines.length) {
+ const line = lines[i].trim();
+
+ if (line.includes("-->")) {
+ const [startStr, endStr] = line.split("-->").map(s => s.trim().split(" ")[0]);
+ const start = parseTimestamp(startStr);
+ const end = parseTimestamp(endStr);
+
+ const textLines: string[] = [];
+ i += 1;
+
+ while (i < lines.length && lines[i].trim() !== "" && !lines[i].includes("-->")) {
+ const textLine = lines[i].trim();
+ if (!textLine.startsWith("NOTE")) {
+ textLines.push(textLine);
+ }
+ i += 1;
+ }
+
+ if (textLines.length > 0) {
+ cues.push({
+ start,
+ end,
+ text: textLines.join("\n")
+ });
+ }
+ } else {
+ i += 1;
+ }
+ }
+
+ return cues;
+}
+
+export function parseSRT(content: string): Cue[] {
+ const cues: Cue[] = [];
+ const lines = content.split(/\r?\n/);
+
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i].trim();
+
+ if (/^\d+$/.test(line) || line === "") {
+ i += 1;
+ } else if (line.includes("-->")) {
+ const [startStr, endStr] = line.split("-->").map(s => s.trim());
+ const start = parseTimestamp(startStr);
+ const end = parseTimestamp(endStr);
+
+ const textLines: string[] = [];
+ i += 1;
+
+ while (i < lines.length && lines[i].trim() !== "") {
+ textLines.push(lines[i].trim());
+ i += 1;
+ }
+
+ if (textLines.length > 0) {
+ cues.push({
+ start,
+ end,
+ text: textLines.join("\n")
+ });
+ }
+ } else {
+ i += 1;
+ }
+ }
+
+ return cues;
+}
+
+export function parseSubtitle(content: string): Cue[] {
+ const trimmed = content.trim();
+
+ if (trimmed.startsWith("WEBVTT")) {
+ return parseVTT(content);
+ }
+
+ return parseSRT(content);
+}
+
+export function findActiveCue(cues: Cue[], time: number): Cue | null {
+ return cues.find(cue => time >= cue.start && time <= cue.end) ?? null;
+}
+
+export function getCuesDuration(cues: Cue[]): number {
+ if (cues.length === 0) return 0;
+ return Math.max(...cues.map(cue => cue.end));
+}
diff --git a/src/core/commands/add-clip-command.ts b/src/core/commands/add-clip-command.ts
index 489e9af6..3c679cab 100644
--- a/src/core/commands/add-clip-command.ts
+++ b/src/core/commands/add-clip-command.ts
@@ -1,34 +1,100 @@
-import type { Player } from "@canvas/players/player";
-import { ClipSchema } from "@schemas/clip";
-import type { z } from "zod";
+import { EditEvent } from "@core/events/edit-events";
+import type { ResolvedClip } from "@schemas";
-import type { EditCommand, CommandContext } from "./types";
+import { type AliasReferenceMap, convertAliasReferencesToValues, restoreAliasReferences } from "./alias-reference-utils";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
-type ClipType = z.infer;
+type ClipType = ResolvedClip;
+/**
+ * Atomic command that adds a new clip to a track.
+ */
export class AddClipCommand implements EditCommand {
- name = "addClip";
- private addedPlayer?: Player;
+ readonly name = "addClip";
+ private addedClipId?: string;
+ private convertedReferences?: AliasReferenceMap;
constructor(
private trackIdx: number,
private clip: ClipType
) {}
- async execute(context?: CommandContext): Promise {
- if (!context) return; // For backward compatibility
- const validatedClip = ClipSchema.parse(this.clip);
- const clipPlayer = context.createPlayerFromAssetType(validatedClip);
- clipPlayer.layer = this.trackIdx + 1;
- await context.addPlayer(this.trackIdx, clipPlayer);
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("AddClipCommand.execute: context is required");
+
+ const document = context.getDocument();
+ if (!document) throw new Error("AddClipCommand.execute: no document");
+
+ // Document mutation only - reconciler creates the Player
+ const addedClip = context.documentAddClip(this.trackIdx, this.clip);
+
+ // Store clip ID for undo
+ this.addedClipId = (addedClip as { id?: string }).id;
+
+ // Restore alias references if this is a redo (after previous undo converted them)
+ if (this.convertedReferences && this.convertedReferences.size > 0) {
+ restoreAliasReferences(document, this.convertedReferences);
+ }
+
+ // Resolve triggers reconciler → creates Player (must happen before duration calc)
+ context.resolve();
+
context.updateDuration();
- this.addedPlayer = clipPlayer;
+ // Get clip index from document for event
+ const docTrack = context.getDocumentTrack(this.trackIdx);
+ const clips = docTrack?.clips as Array<{ id?: string }> | undefined;
+ const clipIndex = clips?.findIndex(c => c.id === this.addedClipId) ?? -1;
+
+ context.emitEvent(EditEvent.ClipAdded, {
+ trackIndex: this.trackIdx,
+ clipIndex
+ });
+
+ return CommandSuccess();
}
- async undo(context?: CommandContext): Promise {
- if (!context || !this.addedPlayer) return;
- context.queueDisposeClip(this.addedPlayer);
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("AddClipCommand.undo: context is required");
+ if (!this.addedClipId) return CommandNoop("No clip ID stored");
+
+ const document = context.getDocument();
+ if (!document) throw new Error("AddClipCommand.undo: no document");
+
+ // Find clip index by ID (position may have changed)
+ const docTrack = context.getDocumentTrack(this.trackIdx);
+ const clips = docTrack?.clips as Array<{ id?: string; alias?: string }> | undefined;
+ const clipIndex = clips?.findIndex(c => c.id === this.addedClipId) ?? -1;
+
+ if (clipIndex === -1) {
+ return CommandNoop(`Clip ${this.addedClipId} not found in track ${this.trackIdx}`);
+ }
+
+ // Convert alias references to resolved values before deletion
+ const clipAlias = clips?.[clipIndex]?.alias;
+ if (clipAlias) {
+ const skipIndices = new Set([`${this.trackIdx}:${clipIndex}`]);
+ this.convertedReferences = convertAliasReferencesToValues(document, context.getEditState(), clipAlias, skipIndices);
+ }
+
+ // Document mutation only - reconciler disposes the Player
+ context.documentRemoveClip(this.trackIdx, clipIndex);
+
+ // Resolve triggers reconciler → disposes orphaned Player (before duration calc)
+ context.resolve();
+
context.updateDuration();
+
+ context.emitEvent(EditEvent.ClipDeleted, {
+ trackIndex: this.trackIdx,
+ clipIndex
+ });
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.addedClipId = undefined;
+ this.convertedReferences = undefined;
}
}
diff --git a/src/core/commands/add-track-command.ts b/src/core/commands/add-track-command.ts
index 6dd5399f..d867c3a7 100644
--- a/src/core/commands/add-track-command.ts
+++ b/src/core/commands/add-track-command.ts
@@ -1,64 +1,55 @@
-import * as pixi from "pixi.js";
+import { EditEvent } from "@core/events/edit-events";
-import type { EditCommand, CommandContext } from "./types";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess } from "./types";
+/**
+ * Document-only command that adds a new empty track.
+ */
export class AddTrackCommand implements EditCommand {
- name = "addTrack";
+ readonly name = "addTrack";
constructor(private trackIdx: number) {}
- execute(context?: CommandContext): void {
- if (!context) return;
- const tracks = context.getTracks();
- const clips = context.getClips();
-
- tracks.splice(this.trackIdx, 0, []);
-
- // Update layers for all clips that are on tracks at or after the insertion point
- // Since we're inserting a track, all tracks at or after trackIdx shift down
- clips.forEach(clip => {
- if (clip.layer >= this.trackIdx) {
- // Remove from old container
- const oldZIndex = 100000 - clip.layer * 100;
- const oldContainer = context.getContainer().getChildByLabel(`shotstack-track-${oldZIndex}`, false);
- if (oldContainer) {
- oldContainer.removeChild(clip.getContainer());
- }
-
- // Update layer (track index + 1)
- // eslint-disable-next-line no-param-reassign
- clip.layer += 1;
-
- // Add to new container
- const newZIndex = 100000 - clip.layer * 100;
- let newContainer = context.getContainer().getChildByLabel(`shotstack-track-${newZIndex}`, false);
- if (!newContainer) {
- newContainer = new pixi.Container({ label: `shotstack-track-${newZIndex}`, zIndex: newZIndex });
- context.getContainer().addChild(newContainer);
- }
- newContainer.addChild(clip.getContainer());
- }
- });
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("AddTrackCommand.execute: context is required");
+
+ const doc = context.getDocument();
+ if (!doc) throw new Error("AddTrackCommand.execute: document is required");
+
+ // Document-only mutation
+ doc.addTrack(this.trackIdx);
+
+ // Reconciler handles track container creation and player layer updates
+ context.resolve();
+
context.updateDuration();
- // Emit track creation event to trigger timeline visual updates
- context.emitEvent("track:added", {
+ context.emitEvent(EditEvent.TrackAdded, {
trackIndex: this.trackIdx,
- totalTracks: tracks.length
+ totalTracks: doc.getTrackCount()
});
+
+ return CommandSuccess();
}
- undo(context?: CommandContext): void {
- if (!context) return;
- const tracks = context.getTracks();
- const clips = context.getClips();
- tracks.splice(this.trackIdx, 1);
- clips.forEach(clip => {
- if (clip.layer > this.trackIdx) {
- // eslint-disable-next-line no-param-reassign
- clip.layer -= 1;
- }
- });
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("AddTrackCommand.undo: context is required");
+
+ const doc = context.getDocument();
+ if (!doc) throw new Error("AddTrackCommand.undo: document is required");
+
+ // Document-only mutation
+ doc.removeTrack(this.trackIdx);
+
+ // Reconciler handles track container removal and player layer updates
+ context.resolve();
+
context.updateDuration();
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ // No resources to release
}
}
diff --git a/src/core/commands/alias-reference-utils.ts b/src/core/commands/alias-reference-utils.ts
new file mode 100644
index 00000000..c204302d
--- /dev/null
+++ b/src/core/commands/alias-reference-utils.ts
@@ -0,0 +1,137 @@
+import type { EditDocument } from "@core/edit-document";
+import type { Clip, ResolvedEdit } from "@schemas";
+
+/**
+ * Stores original alias reference values for a clip.
+ */
+export interface StoredAliasReference {
+ start?: string;
+ length?: string;
+}
+
+/**
+ * Map of clipId → original alias references
+ */
+export type AliasReferenceMap = Map;
+
+/**
+ * Find clips referencing the given alias and convert to resolved numeric values.
+ * Returns a map of clipId → original references for undo.
+ *
+ * @param document - The edit document
+ * @param resolved - The current resolved edit state
+ * @param aliasName - The alias being removed (e.g., "image" for "alias://image")
+ * @param skipClipIndices - Optional set of "trackIdx:clipIdx" to skip (the clips being deleted)
+ */
+export function convertAliasReferencesToValues(
+ document: EditDocument,
+ resolved: ResolvedEdit,
+ aliasName: string,
+ skipClipIndices?: Set
+): AliasReferenceMap {
+ const stored: AliasReferenceMap = new Map();
+ const aliasRef = `alias://${aliasName}`;
+
+ for (let t = 0; t < document.getTrackCount(); t += 1) {
+ const clips = document.getClipsInTrack(t);
+ for (let c = 0; c < clips.length; c += 1) {
+ // Process clips not in the skip set
+ if (!skipClipIndices?.has(`${t}:${c}`)) {
+ const docClip = clips[c];
+ const clipId = (docClip as { id?: string }).id;
+ const resolvedClip = resolved.timeline.tracks[t]?.clips[c];
+
+ let hasReference = false;
+ const original: StoredAliasReference = {};
+
+ if (docClip.start === aliasRef) {
+ original.start = docClip.start;
+ hasReference = true;
+ }
+ if (docClip.length === aliasRef) {
+ original.length = docClip.length;
+ hasReference = true;
+ }
+
+ if (hasReference && clipId && resolvedClip) {
+ stored.set(clipId, original);
+ // Update document with resolved numeric values
+ const updates: Partial = {};
+ if (original.start) updates.start = resolvedClip.start;
+ if (original.length) updates.length = resolvedClip.length;
+ document.updateClip(t, c, updates);
+ }
+ }
+ }
+ }
+
+ return stored;
+}
+
+/**
+ * Convert all alias references for multiple aliases at once.
+ * Used when deleting a track with multiple aliased clips.
+ *
+ * @param document - The edit document
+ * @param resolved - The current resolved edit state
+ * @param aliasNames - Array of alias names being removed
+ * @param skipClipIndices - Set of "trackIdx:clipIdx" to skip (the clips being deleted)
+ */
+export function convertMultipleAliasReferences(
+ document: EditDocument,
+ resolved: ResolvedEdit,
+ aliasNames: string[],
+ skipClipIndices: Set
+): AliasReferenceMap {
+ const combined: AliasReferenceMap = new Map();
+
+ for (const aliasName of aliasNames) {
+ const refs = convertAliasReferencesToValues(document, resolved, aliasName, skipClipIndices);
+ for (const [clipId, original] of refs) {
+ // Merge with existing (a clip might reference multiple aliases)
+ const existing = combined.get(clipId);
+ if (existing) {
+ combined.set(clipId, { ...existing, ...original });
+ } else {
+ combined.set(clipId, original);
+ }
+ }
+ }
+
+ return combined;
+}
+
+/**
+ * Restore original alias references after undoing deletion.
+ *
+ * @param document - The edit document
+ * @param convertedReferences - Map of clipId → original alias references
+ */
+export function restoreAliasReferences(document: EditDocument, convertedReferences: AliasReferenceMap): void {
+ for (const [clipId, original] of convertedReferences) {
+ const clipInfo = document.getClipById(clipId);
+ if (clipInfo) {
+ const updates: Partial = {};
+ if (original.start) updates.start = original.start;
+ if (original.length) updates.length = original.length;
+ document.updateClip(clipInfo.trackIndex, clipInfo.clipIndex, updates);
+ }
+ }
+}
+
+/**
+ * Extract alias names from an array of clips.
+ *
+ * @param clips - Array of clips to check for aliases
+ * @returns Array of alias names found
+ */
+export function extractAliasNames(clips: Clip[]): string[] {
+ const aliases: string[] = [];
+ for (const clip of clips) {
+ const { alias } = clip as { alias?: string };
+ if (alias) {
+ aliases.push(alias);
+ }
+ }
+ return aliases;
+}
diff --git a/src/core/commands/attach-luma-command.ts b/src/core/commands/attach-luma-command.ts
new file mode 100644
index 00000000..102ff11e
--- /dev/null
+++ b/src/core/commands/attach-luma-command.ts
@@ -0,0 +1,172 @@
+import { EditEvent } from "@core/events/edit-events";
+import { stripInternalProperties } from "@core/shared/clip-utils";
+import { type Seconds } from "@core/timing/types";
+
+import { TransformClipAssetCommand } from "./transform-clip-asset-command";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+/**
+ * Compound command that attaches a luma mask to a content clip atomically.
+ */
+export class AttachLumaCommand implements EditCommand {
+ readonly name = "attachLuma";
+
+ private transformCommand: TransformClipAssetCommand;
+ private wasExecuted = false;
+
+ // Store state for undo
+ private lumaClipId: string | null = null;
+ private contentClipId: string | null = null;
+ private originalLumaStart: Seconds | null = null;
+ private originalLumaLength: Seconds | null = null;
+
+ constructor(
+ private readonly lumaTrackIndex: number,
+ private readonly lumaClipIndex: number,
+ private readonly contentTrackIndex: number,
+ private readonly contentClipIndex: number
+ ) {
+ // Pre-create transform command
+ this.transformCommand = new TransformClipAssetCommand(lumaTrackIndex, lumaClipIndex, "luma");
+ }
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("AttachLumaCommand requires context");
+
+ // 1. Get references BEFORE transformation
+ const contentPlayer = context.getClipAt(this.contentTrackIndex, this.contentClipIndex);
+ const lumaPlayerBefore = context.getClipAt(this.lumaTrackIndex, this.lumaClipIndex);
+
+ if (!contentPlayer || !lumaPlayerBefore) {
+ return CommandNoop("Clips not found");
+ }
+
+ // Store for undo
+ this.contentClipId = contentPlayer.clipId;
+ this.originalLumaStart = lumaPlayerBefore.getStart();
+ this.originalLumaLength = lumaPlayerBefore.getLength();
+
+ // Capture document clip BEFORE mutations (for event emission)
+ const previousDocClip = structuredClone(context.getDocumentClip(this.lumaTrackIndex, this.lumaClipIndex));
+
+ // 2. Transform to luma type
+ this.transformCommand.execute(context);
+
+ // 3. Get NEW luma player after transformation (clipId changes during asset type transformation!)
+ const lumaPlayerAfter = context.getClipAt(this.lumaTrackIndex, this.lumaClipIndex);
+ if (!lumaPlayerAfter) {
+ throw new Error("Luma player not found after transformation");
+ }
+
+ this.lumaClipId = lumaPlayerAfter.clipId;
+
+ // 4. Establish relationship
+ if (!this.lumaClipId || !this.contentClipId) {
+ throw new Error("Failed to capture clip IDs for luma attachment");
+ }
+ const edit = context.getEditSession();
+ edit.setLumaContentRelationship(this.lumaClipId, this.contentClipId);
+
+ // 5. Sync timing: luma matches content
+ lumaPlayerAfter.setResolvedTiming({
+ start: contentPlayer.getStart(),
+ length: contentPlayer.getLength()
+ });
+ lumaPlayerAfter.reconfigureAfterRestore();
+
+ // 6. Update document
+ context.documentUpdateClip(this.lumaTrackIndex, this.lumaClipIndex, {
+ start: contentPlayer.getStart(),
+ length: contentPlayer.getLength()
+ });
+
+ // 7. Resolve to reconcile players and emit event
+ context.resolve();
+
+ // Emit ClipUpdated event for the luma clip (it changed from image/video → luma + synced timing)
+ const currentDocClip = context.getDocumentClip(this.lumaTrackIndex, this.lumaClipIndex);
+ if (previousDocClip && currentDocClip) {
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: {
+ trackIndex: this.lumaTrackIndex,
+ clipIndex: this.lumaClipIndex,
+ clip: stripInternalProperties(previousDocClip)
+ },
+ current: {
+ trackIndex: this.lumaTrackIndex,
+ clipIndex: this.lumaClipIndex,
+ clip: stripInternalProperties(currentDocClip)
+ }
+ });
+ }
+
+ this.wasExecuted = true;
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!this.wasExecuted || !context) {
+ return CommandNoop("Command was not executed");
+ }
+
+ // Capture document clip BEFORE undo (for event emission)
+ const previousDocClip = structuredClone(context.getDocumentClip(this.lumaTrackIndex, this.lumaClipIndex));
+
+ // 1. Clear relationship FIRST
+ if (this.lumaClipId) {
+ const edit = context.getEditSession();
+ edit.clearLumaContentRelationship(this.lumaClipId);
+ }
+
+ // 2. Restore original timing
+ if (this.originalLumaStart !== null && this.originalLumaLength !== null) {
+ const lumaPlayer = context.getClipAt(this.lumaTrackIndex, this.lumaClipIndex);
+ if (lumaPlayer) {
+ lumaPlayer.setResolvedTiming({
+ start: this.originalLumaStart,
+ length: this.originalLumaLength
+ });
+ lumaPlayer.reconfigureAfterRestore();
+ }
+
+ context.documentUpdateClip(this.lumaTrackIndex, this.lumaClipIndex, {
+ start: this.originalLumaStart,
+ length: this.originalLumaLength
+ });
+ }
+
+ // 3. Undo transformation (luma → original type)
+ this.transformCommand.undo(context);
+
+ // 4. Resolve
+ context.resolve();
+
+ // Emit ClipUpdated event for restored clip
+ const restoredDocClip = context.getDocumentClip(this.lumaTrackIndex, this.lumaClipIndex);
+ if (previousDocClip && restoredDocClip) {
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: {
+ trackIndex: this.lumaTrackIndex,
+ clipIndex: this.lumaClipIndex,
+ clip: stripInternalProperties(previousDocClip)
+ },
+ current: {
+ trackIndex: this.lumaTrackIndex,
+ clipIndex: this.lumaClipIndex,
+ clip: stripInternalProperties(restoredDocClip)
+ }
+ });
+ }
+
+ this.wasExecuted = false;
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.transformCommand.dispose?.();
+ this.lumaClipId = null;
+ this.contentClipId = null;
+ this.originalLumaStart = null;
+ this.originalLumaLength = null;
+ }
+}
diff --git a/src/core/commands/clear-selection-command.ts b/src/core/commands/clear-selection-command.ts
deleted file mode 100644
index 04770a0b..00000000
--- a/src/core/commands/clear-selection-command.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import type { Player } from "@canvas/players/player";
-
-import type { EditCommand, CommandContext } from "./types";
-
-export class ClearSelectionCommand implements EditCommand {
- public readonly name = "ClearSelection";
- private previousSelection: { player: Player; trackIndex: number; clipIndex: number } | null = null;
-
- public execute(context: CommandContext): void {
- // Find and store current selection for undo
- const currentSelection = context.getSelectedClip();
- if (currentSelection) {
- const indices = context.findClipIndices(currentSelection);
- if (indices) {
- this.previousSelection = {
- player: currentSelection,
- trackIndex: indices.trackIndex,
- clipIndex: indices.clipIndex
- };
- }
- }
-
- // Clear selection
- context.setSelectedClip(null);
-
- // Emit clear event
- context.emitEvent("selection:cleared", {});
- }
-
- public undo(context: CommandContext): void {
- if (this.previousSelection) {
- // Restore previous selection
- const player = context.getClipAt(this.previousSelection.trackIndex, this.previousSelection.clipIndex);
- if (player) {
- context.setSelectedClip(player);
- context.emitEvent("clip:selected", {
- clip: player.clipConfiguration,
- trackIndex: this.previousSelection.trackIndex,
- clipIndex: this.previousSelection.clipIndex
- });
- }
- }
- }
-}
diff --git a/src/core/commands/command-queue.ts b/src/core/commands/command-queue.ts
new file mode 100644
index 00000000..f0a1ed04
--- /dev/null
+++ b/src/core/commands/command-queue.ts
@@ -0,0 +1,46 @@
+/**
+ * CommandQueue - Ensures sequential command execution
+ */
+export class CommandQueue {
+ private queue: Array<() => Promise> = [];
+ private isProcessing = false;
+
+ /**
+ * Enqueue an operation for sequential execution.
+ */
+ async enqueue(operation: () => T | Promise): Promise {
+ return new Promise((resolve, reject) => {
+ this.queue.push(async () => {
+ try {
+ const result = await operation();
+ resolve(result);
+ } catch (error) {
+ reject(error);
+ }
+ });
+ this.processQueue();
+ });
+ }
+
+ /**
+ * Process queued operations one at a time.
+ */
+ private async processQueue(): Promise {
+ if (this.isProcessing) return;
+
+ this.isProcessing = true;
+
+ while (this.queue.length > 0) {
+ const operation = this.queue.shift();
+ if (operation) {
+ try {
+ await operation();
+ } catch {
+ // Error already handled in enqueue's try/catch
+ }
+ }
+ }
+
+ this.isProcessing = false;
+ }
+}
diff --git a/src/core/commands/create-track-and-move-clip-command.ts b/src/core/commands/create-track-and-move-clip-command.ts
index 3edb503c..1b34f1a2 100644
--- a/src/core/commands/create-track-and-move-clip-command.ts
+++ b/src/core/commands/create-track-and-move-clip-command.ts
@@ -1,23 +1,25 @@
+import { EditEvent } from "@core/events/edit-events";
+import { type Seconds } from "@core/timing/types";
+
import { AddTrackCommand } from "./add-track-command";
import { MoveClipCommand } from "./move-clip-command";
-import type { EditCommand, CommandContext } from "./types";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
/**
* Compound command that creates a new track and moves a clip to it atomically.
- * This ensures that both operations are treated as a single undo/redo action.
*/
export class CreateTrackAndMoveClipCommand implements EditCommand {
- name = "createTrackAndMoveClip";
+ readonly name = "createTrackAndMoveClip";
private addTrackCommand: AddTrackCommand;
private moveClipCommand: MoveClipCommand;
private wasExecuted = false;
constructor(
- private insertionIndex: number,
- private fromTrackIndex: number,
- private fromClipIndex: number,
- private newStart: number
+ private readonly insertionIndex: number,
+ private readonly fromTrackIndex: number,
+ private readonly fromClipIndex: number,
+ private readonly newStart: Seconds
) {
// Create the track at the insertion index
this.addTrackCommand = new AddTrackCommand(insertionIndex);
@@ -31,37 +33,55 @@ export class CreateTrackAndMoveClipCommand implements EditCommand {
this.moveClipCommand = new MoveClipCommand(adjustedFromTrackIndex, fromClipIndex, insertionIndex, newStart);
}
- async execute(context?: CommandContext): Promise {
- if (!context) return;
+ async execute(context?: CommandContext): Promise {
+ if (!context) throw new Error("CreateTrackAndMoveClipCommand.execute: context is required");
+
+ let addTrackExecuted = false;
try {
// Execute both commands in sequence
this.addTrackCommand.execute(context);
+ addTrackExecuted = true;
+
this.moveClipCommand.execute(context);
this.wasExecuted = true;
- } catch (error) {
- // Clean up on error
- if (this.wasExecuted) {
+ } catch (executeError) {
+ // Attempt partial rollback: only undo addTrack if it succeeded but moveClip failed
+ if (addTrackExecuted && !this.wasExecuted) {
try {
- this.undo(context);
- } catch {
- // Ignore undo errors
+ this.addTrackCommand.undo(context);
+ } catch (undoError) {
+ // If rollback fails, throw a compound error with both failures
+ throw new Error(
+ `CreateTrackAndMoveClipCommand: execute failed (${executeError instanceof Error ? executeError.message : String(executeError)}) ` +
+ `and rollback also failed (${undoError instanceof Error ? undoError.message : String(undoError)}). State may be corrupted.`
+ );
}
}
- throw error;
+ throw executeError;
}
+
+ return CommandSuccess();
}
- undo(context?: CommandContext): void {
- if (!context || !this.wasExecuted) return;
+ async undo(context?: CommandContext): Promise {
+ if (!context) throw new Error("CreateTrackAndMoveClipCommand.undo: context is required");
+ if (!this.wasExecuted) return CommandNoop("Command was not executed");
// Undo in reverse order
- this.moveClipCommand.undo(context);
+ await this.moveClipCommand.undo(context);
this.addTrackCommand.undo(context);
this.wasExecuted = false;
- context.emitEvent("track:created:undone", {
+ context.emitEvent(EditEvent.TrackRemoved, {
trackIndex: this.insertionIndex
});
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.addTrackCommand.dispose?.();
+ this.moveClipCommand.dispose?.();
}
}
diff --git a/src/core/commands/create-track-move-and-detach-luma-command.ts b/src/core/commands/create-track-move-and-detach-luma-command.ts
new file mode 100644
index 00000000..c4bbbac1
--- /dev/null
+++ b/src/core/commands/create-track-move-and-detach-luma-command.ts
@@ -0,0 +1,79 @@
+import { type Seconds } from "@core/timing/types";
+
+import { CreateTrackAndMoveClipCommand } from "./create-track-and-move-clip-command";
+import { DetachLumaCommand } from "./detach-luma-command";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+/**
+ * Compound command that creates a track, moves a luma clip to it, and detaches it atomically.
+ */
+export class CreateTrackMoveAndDetachLumaCommand implements EditCommand {
+ readonly name = "createTrackMoveAndDetachLuma";
+
+ private createTrackAndMoveCommand: CreateTrackAndMoveClipCommand;
+ private detachCommand: DetachLumaCommand;
+ private wasExecuted = false;
+
+ constructor(
+ private readonly insertionIndex: number,
+ private readonly fromTrackIndex: number,
+ private readonly fromClipIndex: number,
+ private readonly newStart: Seconds,
+ private readonly targetAssetType: "image" | "video"
+ ) {
+ // Create track and move (clip will end up at insertionIndex)
+ this.createTrackAndMoveCommand = new CreateTrackAndMoveClipCommand(insertionIndex, fromTrackIndex, fromClipIndex, newStart);
+
+ // After move, clip will be at index 0 on the new track (insertionIndex)
+ this.detachCommand = new DetachLumaCommand(insertionIndex, 0, targetAssetType);
+ }
+
+ async execute(context?: CommandContext): Promise {
+ if (!context) throw new Error("CreateTrackMoveAndDetachLumaCommand requires context");
+
+ let createMoveExecuted = false;
+
+ try {
+ // 1. Execute create track + move
+ await this.createTrackAndMoveCommand.execute(context);
+ createMoveExecuted = true;
+
+ // 2. Execute detach (transform back to original type)
+ await this.detachCommand.execute(context);
+
+ this.wasExecuted = true;
+ } catch (executeError) {
+ // Attempt partial rollback
+ if (createMoveExecuted && !this.wasExecuted) {
+ try {
+ await this.createTrackAndMoveCommand.undo(context);
+ } catch (undoError) {
+ throw new Error(
+ `CreateTrackMoveAndDetachLumaCommand: execute failed (${executeError instanceof Error ? executeError.message : String(executeError)}) ` +
+ `and rollback failed (${undoError instanceof Error ? undoError.message : String(undoError)})`
+ );
+ }
+ }
+ throw executeError;
+ }
+
+ return CommandSuccess();
+ }
+
+ async undo(context?: CommandContext): Promise {
+ if (!context) throw new Error("CreateTrackMoveAndDetachLumaCommand.undo: context is required");
+ if (!this.wasExecuted) return CommandNoop("Command was not executed");
+
+ // Undo in REVERSE order
+ await this.detachCommand.undo(context);
+ await this.createTrackAndMoveCommand.undo(context);
+
+ this.wasExecuted = false;
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.detachCommand.dispose?.();
+ this.createTrackAndMoveCommand.dispose?.();
+ }
+}
diff --git a/src/core/commands/delete-clip-command.ts b/src/core/commands/delete-clip-command.ts
index c3943cf7..e755d36c 100644
--- a/src/core/commands/delete-clip-command.ts
+++ b/src/core/commands/delete-clip-command.ts
@@ -1,38 +1,152 @@
-import type { Player } from "@canvas/players/player";
+import type { MergeFieldBinding } from "@core/edit-document";
+import { EditEvent } from "@core/events/edit-events";
+import type { Clip } from "@schemas";
-import type { EditCommand, CommandContext } from "./types";
+import { type AliasReferenceMap, convertAliasReferencesToValues, restoreAliasReferences } from "./alias-reference-utils";
+import { DeleteTrackCommand } from "./delete-track-command";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+/**
+ * Deletes a clip from a track.
+ */
export class DeleteClipCommand implements EditCommand {
- name = "deleteClip";
- private deletedClip?: Player;
+ readonly name = "deleteClip";
+ private deletedClipConfig?: Clip;
+ private deletedClipId?: string;
+ private deleteTrackCommand?: DeleteTrackCommand;
+ private trackWasDeleted = false;
+ private storedBindings?: Map;
+ private convertedReferences?: AliasReferenceMap;
constructor(
private trackIdx: number,
private clipIdx: number
) {}
- execute(context?: CommandContext): void {
- if (!context) return; // For backward compatibility
- const clips = context.getClips();
- const trackClips = clips.filter((c: Player) => c.layer === this.trackIdx + 1);
- this.deletedClip = trackClips[this.clipIdx];
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("DeleteClipCommand.execute: context is required");
- if (this.deletedClip) {
- context.queueDisposeClip(this.deletedClip);
- context.disposeClips();
- context.updateDuration();
+ const document = context.getDocument();
+ if (!document) throw new Error("DeleteClipCommand: no document");
- // Propagate timing changes to clips that were after the deleted clip
- // Use clipIdx - 1 because the clip at clipIdx no longer exists
- context.propagateTimingChanges(this.trackIdx, this.clipIdx - 1);
+ const clip = document.getClip(this.trackIdx, this.clipIdx);
+ if (!clip) {
+ return CommandNoop(`No clip at track ${this.trackIdx}, index ${this.clipIdx}`);
}
+
+ // Save config for undo
+ this.deletedClipConfig = structuredClone(clip);
+ this.deletedClipId = (clip as { id?: string }).id;
+
+ // Save merge field bindings for undo (from document)
+ if (this.deletedClipId) {
+ const bindings = context.getClipBindings(this.deletedClipId);
+ this.storedBindings = bindings ? new Map(bindings) : undefined;
+ // Clear bindings from document (will be recreated on undo)
+ document.clearClipBindings(this.deletedClipId);
+ }
+
+ // Convert alias references to resolved values before deletion
+ const clipAlias = (clip as { alias?: string }).alias;
+ if (clipAlias) {
+ const skipIndices = new Set([`${this.trackIdx}:${this.clipIdx}`]);
+ this.convertedReferences = convertAliasReferencesToValues(document, context.getEditState(), clipAlias, skipIndices);
+ }
+
+ // Clear any error associated with this clip before deletion
+ context.clearClipError(this.trackIdx, this.clipIdx);
+
+ // Clear selection if deleted clip was selected
+ const selectedClip = context.getSelectedClip();
+ if (selectedClip && this.deletedClipId && selectedClip.clipId === this.deletedClipId) {
+ context.setSelectedClip(null);
+ context.emitEvent(EditEvent.SelectionCleared);
+ }
+
+ // Document mutation
+ context.documentRemoveClip(this.trackIdx, this.clipIdx);
+
+ // Check if track is now empty - delete it
+ const track = document.getTrack(this.trackIdx);
+ if (track && track.clips.length === 0) {
+ this.deleteTrackCommand = new DeleteTrackCommand(this.trackIdx);
+ const result = this.deleteTrackCommand.execute(context);
+
+ // Only set trackWasDeleted if the command succeeded (not noop)
+ if (result.status === "success") {
+ this.trackWasDeleted = true;
+ }
+ }
+
+ // Resolve triggers reconciler → disposes orphaned Player
+ context.resolve();
+
+ context.updateDuration();
+
+ context.emitEvent(EditEvent.ClipDeleted, {
+ trackIndex: this.trackIdx,
+ clipIndex: this.clipIdx
+ });
+
+ return CommandSuccess();
}
- undo(context?: CommandContext): void {
- if (!context || !this.deletedClip) return;
- context.undeleteClip(this.trackIdx, this.deletedClip);
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("DeleteClipCommand.undo: context is required");
+ if (!this.deletedClipConfig) return CommandNoop("No deleted clip config");
+
+ const document = context.getDocument();
+ if (!document) throw new Error("DeleteClipCommand.undo: no document");
+
+ // Restore deleted track first if it was deleted
+ // NOTE: Don't use deleteTrackCommand.undo() as it calls resolve() with empty track
+ // which causes the reconciler to run in an incomplete state
+ if (this.trackWasDeleted) {
+ document.addTrack(this.trackIdx);
+ }
+
+ // Document mutation - add clip back at original position
+ const restoredClip = context.documentAddClip(this.trackIdx, this.deletedClipConfig, this.clipIdx);
+
+ // Restore merge field bindings to document (source of truth)
+ const restoredClipId = (restoredClip as { id?: string }).id;
+ if (restoredClipId && this.storedBindings && this.storedBindings.size > 0) {
+ document.setClipBindingsForClip(restoredClipId, this.storedBindings);
+ }
+
+ // Restore alias references that were converted to numeric values
+ if (this.convertedReferences && this.convertedReferences.size > 0) {
+ restoreAliasReferences(document, this.convertedReferences);
+ }
+
+ // Single resolve() with complete state (track + clip)
+ context.resolve();
+
+ context.updateDuration();
+
+ // Emit TrackAdded event if track was restored (same as DeleteTrackCommand.undo())
+ if (this.trackWasDeleted) {
+ context.emitEvent(EditEvent.TrackAdded, {
+ trackIndex: this.trackIdx,
+ totalTracks: document.getTrackCount()
+ });
+ this.trackWasDeleted = false;
+ }
+
+ // Emit event so luma masking can rebuild after restore
+ context.emitEvent(EditEvent.ClipRestored, {
+ trackIndex: this.trackIdx,
+ clipIndex: this.clipIdx
+ });
+
+ return CommandSuccess();
+ }
- // Propagate timing changes after restoring the clip
- context.propagateTimingChanges(this.trackIdx, this.clipIdx);
+ dispose(): void {
+ this.deletedClipConfig = undefined;
+ this.deletedClipId = undefined;
+ this.deleteTrackCommand = undefined;
+ this.storedBindings = undefined;
+ this.convertedReferences = undefined;
}
}
diff --git a/src/core/commands/delete-track-command.ts b/src/core/commands/delete-track-command.ts
index 5d28c455..35e860b9 100644
--- a/src/core/commands/delete-track-command.ts
+++ b/src/core/commands/delete-track-command.ts
@@ -1,73 +1,94 @@
-import type { ClipSchema } from "@schemas/clip";
-import * as pixi from "pixi.js";
-import type { z } from "zod";
+import { EditEvent } from "@core/events/edit-events";
+import type { Clip } from "@schemas";
-import type { EditCommand, CommandContext } from "./types";
-
-type ClipType = z.infer;
+import { type AliasReferenceMap, convertMultipleAliasReferences, extractAliasNames, restoreAliasReferences } from "./alias-reference-utils";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+/**
+ * Deletes a track and all its clips.
+ */
export class DeleteTrackCommand implements EditCommand {
- name = "deleteTrack";
- private deletedClips: Array<{ config: ClipType }> = [];
+ readonly name = "deleteTrack";
+ private deletedClips: Clip[] = [];
+ private convertedReferences?: AliasReferenceMap;
constructor(private trackIdx: number) {}
- execute(context?: CommandContext): void {
- if (!context) return;
- const clips = context.getClips();
- const tracks = context.getTracks();
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("DeleteTrackCommand.execute: context is required");
- this.deletedClips = clips.filter(c => c.layer === this.trackIdx + 1).map(c => ({ config: structuredClone(c.clipConfiguration) }));
+ const document = context.getDocument();
+ if (!document) throw new Error("DeleteTrackCommand: no document");
- clips.forEach((clip, index) => {
- if (clip.layer === this.trackIdx + 1) {
- clips[index].shouldDispose = true;
- }
- });
- context.disposeClips();
-
- tracks.splice(this.trackIdx, 1);
-
- const remainingClips = context.getClips();
- const container = context.getContainer();
-
- remainingClips.forEach((clip, index) => {
- if (clip.layer > this.trackIdx + 1) {
- const oldContainer = container.getChildByLabel(`shotstack-track-${100000 - clip.layer * 100}`, false);
- oldContainer?.removeChild(clip.getContainer());
- remainingClips[index].layer -= 1;
-
- const zIndex = 100000 - remainingClips[index].layer * 100;
- let trackContainer = container.getChildByLabel(`shotstack-track-${zIndex}`, false);
- if (!trackContainer) {
- trackContainer = new pixi.Container({ label: `shotstack-track-${zIndex}`, zIndex });
- container.addChild(trackContainer);
- }
- trackContainer.addChild(remainingClips[index].getContainer());
+ if (document.getTrackCount() <= 1) {
+ return CommandNoop("Cannot delete the last track");
+ }
+
+ // Save clips for undo
+ const track = document.getTrack(this.trackIdx);
+ if (track) {
+ this.deletedClips = track.clips.map(c => structuredClone(c));
+ }
+
+ // Convert alias references to resolved values before deletion
+ const aliasNames = extractAliasNames(this.deletedClips);
+ if (aliasNames.length > 0) {
+ // Build set of clips being deleted (all clips on this track)
+ const skipIndices = new Set();
+ for (let c = 0; c < this.deletedClips.length; c += 1) {
+ skipIndices.add(`${this.trackIdx}:${c}`);
}
- });
+ this.convertedReferences = convertMultipleAliasReferences(document, context.getEditState(), aliasNames, skipIndices);
+ }
+
+ // Document mutation - remove track (and all its clips)
+ document.removeTrack(this.trackIdx);
+
+ // Resolve triggers reconciler:
+ // - Disposes orphaned Players (clips on deleted track)
+ // - Updates remaining Players' layers (clips below move up)
+ context.resolve();
context.updateDuration();
+
+ context.emitEvent(EditEvent.TrackRemoved, { trackIndex: this.trackIdx });
+
+ return CommandSuccess();
}
- async undo(context?: CommandContext): Promise {
- if (!context || this.deletedClips.length === 0) return;
- const tracks = context.getTracks();
- const clips = context.getClips();
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("DeleteTrackCommand.undo: context is required");
- tracks.splice(this.trackIdx, 0, []);
+ const document = context.getDocument();
+ if (!document) throw new Error("DeleteTrackCommand.undo: no document");
- clips.forEach((clip, index) => {
- if (clip.layer >= this.trackIdx + 1) {
- clips[index].layer += 1;
- }
- });
+ // Document mutation - add track back at original position
+ document.addTrack(this.trackIdx);
- for (const { config } of this.deletedClips) {
- const player = context.createPlayerFromAssetType(config);
- player.layer = this.trackIdx + 1;
- await context.addPlayer(this.trackIdx, player);
+ // Add all clips back to the restored track
+ for (let i = 0; i < this.deletedClips.length; i += 1) {
+ document.addClip(this.trackIdx, this.deletedClips[i], i);
}
+
+ // Restore alias references that were converted to numeric values
+ if (this.convertedReferences && this.convertedReferences.size > 0) {
+ restoreAliasReferences(document, this.convertedReferences);
+ }
+
+ // Resolve triggers reconciler:
+ // - Creates Players for restored clips
+ // - Updates remaining Players' layers (clips below move down)
+ context.resolve();
+
context.updateDuration();
+
+ context.emitEvent(EditEvent.TrackAdded, { trackIndex: this.trackIdx, totalTracks: document.getTrackCount() });
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.deletedClips = [];
+ this.convertedReferences = undefined;
}
}
diff --git a/src/core/commands/detach-luma-command.ts b/src/core/commands/detach-luma-command.ts
new file mode 100644
index 00000000..a295ebb0
--- /dev/null
+++ b/src/core/commands/detach-luma-command.ts
@@ -0,0 +1,118 @@
+import { EditEvent } from "@core/events/edit-events";
+import { stripInternalProperties } from "@core/shared/clip-utils";
+
+import { TransformClipAssetCommand } from "./transform-clip-asset-command";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+/**
+ * Command that detaches a luma mask from a content clip.
+ */
+export class DetachLumaCommand implements EditCommand {
+ readonly name = "detachLuma";
+
+ private transformCommand: TransformClipAssetCommand;
+ private wasExecuted = false;
+ private storedContentClipId: string | null = null;
+
+ constructor(
+ private readonly lumaTrackIndex: number,
+ private readonly lumaClipIndex: number,
+ private readonly targetAssetType: "image" | "video"
+ ) {
+ this.transformCommand = new TransformClipAssetCommand(lumaTrackIndex, lumaClipIndex, targetAssetType);
+ }
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("DetachLumaCommand requires context");
+
+ const lumaPlayer = context.getClipAt(this.lumaTrackIndex, this.lumaClipIndex);
+ if (!lumaPlayer?.clipId) {
+ return CommandNoop("Luma clip not found");
+ }
+
+ // Capture document clip BEFORE mutations (for event emission)
+ const previousDocClip = structuredClone(context.getDocumentClip(this.lumaTrackIndex, this.lumaClipIndex));
+
+ // Store relationship for undo
+ const edit = context.getEditSession();
+ this.storedContentClipId = edit.getLumaContentRelationship(lumaPlayer.clipId) ?? null;
+
+ // Clear relationship
+ edit.clearLumaContentRelationship(lumaPlayer.clipId);
+
+ // Transform back to original type
+ this.transformCommand.execute(context);
+
+ // Resolve
+ context.resolve();
+
+ // Emit ClipUpdated event
+ const currentDocClip = context.getDocumentClip(this.lumaTrackIndex, this.lumaClipIndex);
+ if (previousDocClip && currentDocClip) {
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: {
+ trackIndex: this.lumaTrackIndex,
+ clipIndex: this.lumaClipIndex,
+ clip: stripInternalProperties(previousDocClip)
+ },
+ current: {
+ trackIndex: this.lumaTrackIndex,
+ clipIndex: this.lumaClipIndex,
+ clip: stripInternalProperties(currentDocClip)
+ }
+ });
+ }
+
+ this.wasExecuted = true;
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!this.wasExecuted || !context) {
+ return CommandNoop("Command was not executed");
+ }
+
+ // Capture document clip BEFORE undo
+ const previousDocClip = structuredClone(context.getDocumentClip(this.lumaTrackIndex, this.lumaClipIndex));
+
+ // Undo transformation (back to luma)
+ this.transformCommand.undo(context);
+
+ // Restore relationship
+ if (this.storedContentClipId) {
+ const lumaPlayer = context.getClipAt(this.lumaTrackIndex, this.lumaClipIndex);
+ if (lumaPlayer?.clipId) {
+ const edit = context.getEditSession();
+ edit.setLumaContentRelationship(lumaPlayer.clipId, this.storedContentClipId);
+ }
+ }
+
+ // Resolve
+ context.resolve();
+
+ // Emit ClipUpdated event
+ const restoredDocClip = context.getDocumentClip(this.lumaTrackIndex, this.lumaClipIndex);
+ if (previousDocClip && restoredDocClip) {
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: {
+ trackIndex: this.lumaTrackIndex,
+ clipIndex: this.lumaClipIndex,
+ clip: stripInternalProperties(previousDocClip)
+ },
+ current: {
+ trackIndex: this.lumaTrackIndex,
+ clipIndex: this.lumaClipIndex,
+ clip: stripInternalProperties(restoredDocClip)
+ }
+ });
+ }
+
+ this.wasExecuted = false;
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.transformCommand.dispose?.();
+ this.storedContentClipId = null;
+ }
+}
diff --git a/src/core/commands/export-command.ts b/src/core/commands/export-command.ts
index 90151158..23b47640 100644
--- a/src/core/commands/export-command.ts
+++ b/src/core/commands/export-command.ts
@@ -1,15 +1,16 @@
import type { Player } from "@canvas/players/player";
-import type { EditCommand, CommandContext } from "./types";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess } from "./types";
export class ExportCommand implements EditCommand {
readonly name = "export";
private clips: Player[] = [];
private tracks: Player[][] = [];
- execute(context: CommandContext): void {
+ execute(context: CommandContext): CommandResult {
this.clips = context.getClips();
this.tracks = context.getTracks();
+ return CommandSuccess();
}
getClips(): ReadonlyArray {
@@ -19,4 +20,9 @@ export class ExportCommand implements EditCommand {
getTracks(): ReadonlyArray> {
return this.tracks;
}
+
+ dispose(): void {
+ this.clips = [];
+ this.tracks = [];
+ }
}
diff --git a/src/core/commands/move-and-attach-luma-command.ts b/src/core/commands/move-and-attach-luma-command.ts
new file mode 100644
index 00000000..15d9c078
--- /dev/null
+++ b/src/core/commands/move-and-attach-luma-command.ts
@@ -0,0 +1,106 @@
+import { type Seconds } from "@core/timing/types";
+
+import { AttachLumaCommand } from "./attach-luma-command";
+import { MoveClipCommand } from "./move-clip-command";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+/**
+ * Compound command that moves a clip to a different track AND attaches it as a luma mask atomically.
+ */
+export class MoveAndAttachLumaCommand implements EditCommand {
+ readonly name = "moveAndAttachLuma";
+
+ private moveCommand?: MoveClipCommand;
+ private attachCommand?: AttachLumaCommand;
+ private wasExecuted = false;
+
+ constructor(
+ private readonly fromTrackIndex: number,
+ private readonly fromClipIndex: number,
+ private readonly toTrackIndex: number,
+ private readonly contentTrackIndex: number,
+ private readonly contentClipIndex: number,
+ private readonly targetStart: Seconds
+ ) {
+ // Create move command only if track changes
+ if (fromTrackIndex !== toTrackIndex) {
+ this.moveCommand = new MoveClipCommand(fromTrackIndex, fromClipIndex, toTrackIndex, targetStart);
+ }
+
+ // AttachLumaCommand will be created in execute() after we have correct indices
+ }
+
+ async execute(context?: CommandContext): Promise {
+ if (!context) throw new Error("MoveAndAttachLumaCommand requires context");
+
+ // 1. Get STABLE Player references BEFORE move
+ const lumaPlayer = context.getClipAt(this.fromTrackIndex, this.fromClipIndex);
+ const contentPlayer = context.getClipAt(this.contentTrackIndex, this.contentClipIndex);
+
+ if (!lumaPlayer || !contentPlayer) {
+ return CommandNoop("Clips not found");
+ }
+
+ let moveExecuted = false;
+
+ try {
+ // 2. Execute move if needed (changes track)
+ if (this.moveCommand) {
+ await this.moveCommand.execute(context);
+ moveExecuted = true;
+ }
+
+ // 3. Find NEW indices using stable Player references
+ const lumaIndices = context.findClipIndices(lumaPlayer);
+ const contentIndices = context.findClipIndices(contentPlayer);
+
+ if (!lumaIndices || !contentIndices) {
+ throw new Error("Failed to find clips after move");
+ }
+
+ // 4. Create attach command with CORRECT indices
+ this.attachCommand = new AttachLumaCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, contentIndices.trackIndex, contentIndices.clipIndex);
+
+ // 5. Execute attach
+ await this.attachCommand.execute(context);
+
+ this.wasExecuted = true;
+ } catch (executeError) {
+ // Attempt partial rollback: only undo move if it succeeded but attach failed
+ if (moveExecuted && !this.wasExecuted && this.moveCommand) {
+ try {
+ await this.moveCommand.undo(context);
+ } catch (undoError) {
+ // If rollback fails, throw a compound error with both failures
+ throw new Error(
+ `MoveAndAttachLumaCommand: execute failed (${executeError instanceof Error ? executeError.message : String(executeError)}) ` +
+ `and rollback also failed (${undoError instanceof Error ? undoError.message : String(undoError)}). State may be corrupted.`
+ );
+ }
+ }
+ throw executeError;
+ }
+
+ return CommandSuccess();
+ }
+
+ async undo(context?: CommandContext): Promise {
+ if (!context) throw new Error("MoveAndAttachLumaCommand.undo: context is required");
+ if (!this.wasExecuted || !this.attachCommand) return CommandNoop("Command was not executed");
+
+ // Undo in REVERSE order: attach first, then move
+ await this.attachCommand.undo(context);
+
+ if (this.moveCommand) {
+ await this.moveCommand.undo(context);
+ }
+
+ this.wasExecuted = false;
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.attachCommand?.dispose?.();
+ this.moveCommand?.dispose?.();
+ }
+}
diff --git a/src/core/commands/move-clip-command.ts b/src/core/commands/move-clip-command.ts
index 394717bf..f871f9a3 100644
--- a/src/core/commands/move-clip-command.ts
+++ b/src/core/commands/move-clip-command.ts
@@ -1,264 +1,206 @@
-import type { Player } from "@canvas/players/player";
-import type { TimingIntent } from "@core/timing/types";
+import { EditEvent } from "@core/events/edit-events";
+import { stripInternalProperties } from "@core/shared/clip-utils";
+import type { Seconds } from "@core/timing/types";
+import type { Clip } from "@schemas";
-import type { EditCommand, CommandContext } from "./types";
+import { DeleteTrackCommand } from "./delete-track-command";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+/**
+ * Document-only command that moves a clip to a different track and/or position.
+ */
export class MoveClipCommand implements EditCommand {
- name = "moveClip";
- private player?: Player;
- private originalTrackIndex: number;
- private originalClipIndex: number;
- private originalStart?: number | "auto";
- private originalTimingIntent?: TimingIntent;
+ readonly name = "moveClip";
+
+ private clipId: string | null = null;
+ private originalStart?: Clip["start"];
+ private previousDocClip?: Clip;
+ private deleteTrackCommand?: DeleteTrackCommand;
+ private sourceTrackWasDeleted = false;
+ /** Effective destination track index, adjusted if source track was deleted */
+ private effectiveToTrackIndex: number;
+ /** The final clip index after moving (for events and undo) */
+ private newClipIndex = 0;
constructor(
- private fromTrackIndex: number,
- private fromClipIndex: number,
- private toTrackIndex: number,
- private newStart: number
+ private readonly fromTrackIndex: number,
+ private readonly fromClipIndex: number,
+ private readonly toTrackIndex: number,
+ private readonly newStart: Seconds
) {
- this.originalTrackIndex = fromTrackIndex;
- this.originalClipIndex = fromClipIndex;
+ this.effectiveToTrackIndex = toTrackIndex;
}
- execute(context?: CommandContext): void {
- if (!context) return;
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("MoveClipCommand.execute: context is required");
- // Get the player by indices
- const tracks = context.getTracks();
+ const doc = context.getDocument();
+ if (!doc) throw new Error("MoveClipCommand.execute: document is required");
- if (this.fromTrackIndex < 0 || this.fromTrackIndex >= tracks.length) {
- console.warn(`Invalid source track index: ${this.fromTrackIndex}`);
- return;
+ // Get player for ID and timing intent
+ const player = context.getClipAt(this.fromTrackIndex, this.fromClipIndex);
+ if (!player) {
+ return CommandNoop(`Invalid clip at ${this.fromTrackIndex}/${this.fromClipIndex}`);
}
- const fromTrack = tracks[this.fromTrackIndex];
- if (this.fromClipIndex < 0 || this.fromClipIndex >= fromTrack.length) {
- console.warn(`Invalid clip index: ${this.fromClipIndex}`);
- return;
- }
-
- // Get the clip to move
- this.player = fromTrack[this.fromClipIndex];
- this.originalStart = this.player.clipConfiguration.start;
-
- // Store original timing intent for undo
- this.originalTimingIntent = this.player.getTimingIntent();
-
- // If moving to a different track
- if (this.fromTrackIndex !== this.toTrackIndex) {
- // Validate destination track
- if (this.toTrackIndex < 0 || this.toTrackIndex >= tracks.length) {
- console.warn(`Invalid destination track index: ${this.toTrackIndex}`);
- return;
- }
+ // Get document clip
+ const docClip = doc.getClip(this.fromTrackIndex, this.fromClipIndex);
+ if (!docClip) return CommandNoop(`Document clip not found at ${this.fromTrackIndex}/${this.fromClipIndex}`);
- // Remove from current track
- fromTrack.splice(this.fromClipIndex, 1);
+ // Store for undo and events
+ this.clipId = player.clipId;
+ this.previousDocClip = structuredClone(docClip);
+ this.originalStart = docClip.start;
- // Update the player's layer
- this.player.layer = this.toTrackIndex + 1;
+ // Determine effective destination track index
+ this.effectiveToTrackIndex = this.toTrackIndex;
- // Add to new track at the correct position (sorted by start time)
- const toTrack = tracks[this.toTrackIndex];
+ // Document-only mutations - always use moveClip since it handles reordering by start time
+ doc.moveClip(this.fromTrackIndex, this.fromClipIndex, this.toTrackIndex, { start: this.newStart });
- // Find the correct insertion point based on start time
- let insertIndex = 0;
- for (let i = 0; i < toTrack.length; i += 1) {
- const clip = toTrack[i];
- const clipStart = clip.getStart() / 1000; // Use resolved start time in seconds
- if (this.newStart < clipStart) {
- break;
- }
- insertIndex += 1;
- }
-
- // Insert at the correct position
- toTrack.splice(insertIndex, 0, this.player);
-
- // Store the new clip index for undo
- this.originalClipIndex = insertIndex;
- } else {
- // Same track - need to reorder if position changed
- const track = fromTrack;
-
- // Remove from current position
- track.splice(this.fromClipIndex, 1);
-
- // Find new insertion point based on start time
- let insertIndex = 0;
- for (let i = 0; i < track.length; i += 1) {
- const clip = track[i];
- const clipStart = clip.getStart() / 1000;
- if (this.newStart < clipStart) {
- break;
+ if (this.fromTrackIndex !== this.toTrackIndex) {
+ // Cross-track move: check if source track is now empty and should be deleted
+ const sourceTrackClips = doc.getClipsInTrack(this.fromTrackIndex);
+ if (sourceTrackClips.length === 0) {
+ // Source track is empty - delete it
+ this.deleteTrackCommand = new DeleteTrackCommand(this.fromTrackIndex);
+ const result = this.deleteTrackCommand.execute(context);
+
+ // Only set sourceTrackWasDeleted if the command succeeded (not noop)
+ if (result.status === "success") {
+ this.sourceTrackWasDeleted = true;
+
+ // Adjust effective destination track index if it was after the deleted track
+ if (this.toTrackIndex > this.fromTrackIndex) {
+ this.effectiveToTrackIndex = this.toTrackIndex - 1;
+ }
}
- insertIndex += 1;
}
-
- // Insert at correct position
- track.splice(insertIndex, 0, this.player);
-
- // Store new index
- this.originalClipIndex = insertIndex;
}
- // Update the clip position
- this.player.clipConfiguration.start = this.newStart;
+ // Reconciler handles player layer update, container move, timing update
+ context.resolve();
- // Update resolved timing to match the new position
- this.player.setResolvedTiming({
- start: this.newStart * 1000,
- length: this.player.getLength()
- });
-
- // Update timing intent to match new position
- this.player.setTimingIntent({
- start: this.newStart,
- length: this.player.getTimingIntent().length
- });
+ // Find the new clip index after move (for events)
+ const clipInfo = this.clipId ? doc.getClipById(this.clipId) : null;
+ this.newClipIndex = clipInfo?.clipIndex ?? 0;
- // If timing intent changed from "end" to fixed, untrack from endLengthClips Set
- if (this.originalTimingIntent?.length === "end" && this.player.getTimingIntent().length !== "end") {
- context.untrackEndLengthClip(this.player);
- }
-
- // Move the player container to the new track container if needed
- context.movePlayerToTrackContainer(this.player, this.fromTrackIndex, this.toTrackIndex);
-
- // Reconfigure and redraw the player
- this.player.reconfigureAfterRestore();
- this.player.draw();
-
- // Update total duration and emit event
context.updateDuration();
- // If we moved tracks, we need to update all clips in both tracks
- if (this.fromTrackIndex !== this.toTrackIndex) {
- // Force all clips in the affected tracks to redraw
- const sourceTrack = tracks[this.fromTrackIndex];
- const destTrack = tracks[this.toTrackIndex];
-
- [...sourceTrack, ...destTrack].forEach(clip => {
- if (clip && clip !== this.player) {
- clip.draw();
- }
- });
- }
-
// Propagate timing changes to dependent clips
- // Need to propagate on both source and destination tracks if they differ
- if (this.fromTrackIndex !== this.toTrackIndex) {
+ if (this.fromTrackIndex !== this.toTrackIndex && !this.sourceTrackWasDeleted) {
context.propagateTimingChanges(this.fromTrackIndex, this.fromClipIndex - 1);
}
- context.propagateTimingChanges(this.toTrackIndex, this.originalClipIndex);
+ context.propagateTimingChanges(this.effectiveToTrackIndex, this.newClipIndex);
- // Emit events AFTER all changes complete to avoid partial rebuilds
- context.emitEvent("clip:updated", {
+ // Get document clip AFTER mutation
+ const currentDocClip = doc.getClip(this.effectiveToTrackIndex, this.newClipIndex);
+ if (!this.previousDocClip || !currentDocClip)
+ throw new Error(`MoveClipCommand: document clip not found after mutation at ${this.effectiveToTrackIndex}/${this.newClipIndex}`);
+
+ context.emitEvent(EditEvent.ClipUpdated, {
previous: {
- clip: { ...this.player.clipConfiguration, start: this.originalStart },
+ clip: stripInternalProperties(this.previousDocClip),
trackIndex: this.fromTrackIndex,
clipIndex: this.fromClipIndex
},
current: {
- clip: this.player.clipConfiguration,
- trackIndex: this.toTrackIndex,
- clipIndex: this.originalClipIndex
+ clip: stripInternalProperties(currentDocClip),
+ trackIndex: this.effectiveToTrackIndex,
+ clipIndex: this.newClipIndex
}
});
// Re-select the moved clip at its new position
- context.setSelectedClip(this.player);
- context.emitEvent("clip:selected", {
- trackIndex: this.toTrackIndex,
- clipIndex: this.originalClipIndex
- });
+ const updatedPlayer = this.clipId ? context.getPlayerByClipId(this.clipId) : player;
+ if (updatedPlayer) {
+ context.setSelectedClip(updatedPlayer);
+ context.emitEvent(EditEvent.ClipSelected, {
+ clip: stripInternalProperties(currentDocClip),
+ trackIndex: this.effectiveToTrackIndex,
+ clipIndex: this.newClipIndex
+ });
+ }
+
+ return CommandSuccess();
}
- undo(context?: CommandContext): void {
- if (!context || !this.player || this.originalStart === undefined) return;
+ async undo(context?: CommandContext): Promise {
+ if (!context) throw new Error("MoveClipCommand.undo: context is required");
+ if (!this.clipId || this.originalStart === undefined) return CommandNoop("No clip state stored");
- const tracks = context.getTracks();
+ const doc = context.getDocument();
+ if (!doc) throw new Error("MoveClipCommand.undo: document is required");
- // If we moved tracks, move it back
- if (this.fromTrackIndex !== this.toTrackIndex) {
- // Remove from current track
- const currentTrack = tracks[this.toTrackIndex];
- const clipIndex = currentTrack.indexOf(this.player);
- if (clipIndex !== -1) {
- currentTrack.splice(clipIndex, 1);
- }
+ // If source track was deleted, recreate it first
+ if (this.sourceTrackWasDeleted && this.deleteTrackCommand) {
+ this.deleteTrackCommand.undo(context);
+ this.sourceTrackWasDeleted = false;
- // Restore original layer
- this.player.layer = this.fromTrackIndex + 1;
-
- // Add back to original track at original position
- const originalTrack = tracks[this.fromTrackIndex];
- originalTrack.splice(this.fromClipIndex, 0, this.player);
- } else {
- // Same track - need to reorder back to original position
- const track = tracks[this.fromTrackIndex];
- const currentIndex = track.indexOf(this.player);
- if (currentIndex !== -1) {
- track.splice(currentIndex, 1);
+ // Restore effective track index now that the deleted track is back
+ if (this.toTrackIndex > this.fromTrackIndex) {
+ this.effectiveToTrackIndex = this.toTrackIndex;
}
-
- // Insert at original position
- track.splice(this.fromClipIndex, 0, this.player);
}
- // Restore original position
- this.player.clipConfiguration.start = this.originalStart;
-
- // Restore original timing intent
- if (this.originalTimingIntent) {
- this.player.setTimingIntent(this.originalTimingIntent);
- // Update resolved timing to match
- this.player.setResolvedTiming({
- start: typeof this.originalTimingIntent.start === "number" ? this.originalTimingIntent.start * 1000 : this.player.getStart(),
- length: typeof this.originalTimingIntent.length === "number" ? this.originalTimingIntent.length * 1000 : this.player.getLength()
- });
+ // Find current clip position in document
+ const clipInfo = doc.getClipById(this.clipId);
+ if (!clipInfo) return CommandNoop(`Clip ${this.clipId} not found in document`);
+ const currentDocClip = structuredClone(doc.getClip(clipInfo.trackIndex, clipInfo.clipIndex));
- // If restoring "end" length, re-track in endLengthClips Set
- if (this.originalTimingIntent.length === "end") {
- context.trackEndLengthClip(this.player);
- }
- }
-
- // Move the player container back to the original track container if needed
- context.movePlayerToTrackContainer(this.player, this.toTrackIndex, this.fromTrackIndex);
+ // Document-only mutations: move back to original position (always use moveClip for reordering)
+ doc.moveClip(clipInfo.trackIndex, clipInfo.clipIndex, this.fromTrackIndex, {
+ start: this.originalStart
+ });
- // Reconfigure and redraw the player
- this.player.reconfigureAfterRestore();
- this.player.draw();
+ // Reconciler handles player layer update, container move, timing update
+ context.resolve();
context.updateDuration();
// Propagate timing changes on both tracks
- if (this.fromTrackIndex !== this.toTrackIndex) {
- context.propagateTimingChanges(this.toTrackIndex, this.originalClipIndex - 1);
+ if (this.fromTrackIndex !== this.effectiveToTrackIndex) {
+ context.propagateTimingChanges(this.effectiveToTrackIndex, this.newClipIndex - 1);
}
context.propagateTimingChanges(this.fromTrackIndex, this.fromClipIndex);
- // Emit events AFTER all changes complete to avoid partial rebuilds
- context.emitEvent("clip:updated", {
- previous: {
- clip: { ...this.player.clipConfiguration, start: this.newStart },
- trackIndex: this.toTrackIndex,
- clipIndex: this.originalClipIndex
- },
- current: {
- clip: this.player.clipConfiguration,
+ // Get document clip AFTER undo mutation (restored state)
+ const restoredDocClip = doc.getClip(this.fromTrackIndex, this.fromClipIndex);
+
+ if (this.previousDocClip) {
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: {
+ clip: stripInternalProperties(currentDocClip ?? this.previousDocClip),
+ trackIndex: this.effectiveToTrackIndex,
+ clipIndex: this.newClipIndex
+ },
+ current: {
+ clip: stripInternalProperties(restoredDocClip ?? this.previousDocClip),
+ trackIndex: this.fromTrackIndex,
+ clipIndex: this.fromClipIndex
+ }
+ });
+ }
+
+ // Re-select the clip at its restored position
+ const updatedPlayer = context.getPlayerByClipId(this.clipId);
+ if (updatedPlayer && restoredDocClip) {
+ context.setSelectedClip(updatedPlayer);
+ context.emitEvent(EditEvent.ClipSelected, {
+ clip: stripInternalProperties(restoredDocClip),
trackIndex: this.fromTrackIndex,
clipIndex: this.fromClipIndex
- }
- });
+ });
+ }
- // Re-select the clip at its restored position
- context.setSelectedClip(this.player);
- context.emitEvent("clip:selected", {
- trackIndex: this.fromTrackIndex,
- clipIndex: this.fromClipIndex
- });
+ // Reset effective track index for potential re-execute
+ this.effectiveToTrackIndex = this.toTrackIndex;
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.clipId = null;
+ this.deleteTrackCommand = undefined;
}
}
diff --git a/src/core/commands/move-clip-with-push-command.ts b/src/core/commands/move-clip-with-push-command.ts
new file mode 100644
index 00000000..a80c152f
--- /dev/null
+++ b/src/core/commands/move-clip-with-push-command.ts
@@ -0,0 +1,102 @@
+import { type Seconds, sec } from "@core/timing/types";
+
+import { MoveClipCommand } from "./move-clip-command";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+/**
+ * Document-only command to move a clip while pushing other clips forward to make room.
+ */
+export class MoveClipWithPushCommand implements EditCommand {
+ readonly name = "moveClipWithPush";
+ private moveCommand: MoveClipCommand;
+ private pushedClipIds: Array<{ clipId: string; originalStart: Seconds }> = [];
+
+ constructor(
+ private fromTrackIndex: number,
+ private fromClipIndex: number,
+ private toTrackIndex: number,
+ private newStart: Seconds,
+ private pushOffset: Seconds
+ ) {
+ this.moveCommand = new MoveClipCommand(fromTrackIndex, fromClipIndex, toTrackIndex, newStart);
+ }
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("MoveClipWithPushCommand.execute: context is required");
+
+ const doc = context.getDocument();
+ if (!doc) throw new Error("MoveClipWithPushCommand.execute: document is required");
+
+ // Get the clip being moved to know its length
+ const movingPlayer = context.getClipAt(this.fromTrackIndex, this.fromClipIndex);
+ if (!movingPlayer) return CommandNoop(`No clip at ${this.fromTrackIndex}/${this.fromClipIndex}`);
+
+ const movingLength = movingPlayer.clipConfiguration.length as Seconds;
+ const newEnd = this.newStart + movingLength;
+
+ // Get clips in target track from document
+ const targetClips = doc.getClipsInTrack(this.toTrackIndex);
+ const movingClipId = movingPlayer.clipId;
+
+ // Find and record clips that would overlap with the new position
+ this.pushedClipIds = [];
+ for (const clipInfo of targetClips) {
+ const clip = clipInfo as { id?: string; start: number | "auto"; length: number | "auto" | "end" };
+
+ // Skip the clip we're moving
+ if (clip.id && clip.id !== movingClipId) {
+ const clipStart = typeof clip.start === "number" ? sec(clip.start) : sec(0);
+
+ // Push clips that start within our new range
+ if (clipStart >= this.newStart && clipStart < newEnd) {
+ this.pushedClipIds.push({ clipId: clip.id, originalStart: clipStart });
+
+ // Update in document
+ const clipIndex = targetClips.indexOf(clipInfo);
+ doc.updateClip(this.toTrackIndex, clipIndex, {
+ start: sec(clipStart + this.pushOffset)
+ });
+ }
+ }
+ }
+
+ // Execute the move (which will call resolve())
+ this.moveCommand.execute(context);
+
+ // Propagate timing changes for pushed clips
+ context.propagateTimingChanges(this.toTrackIndex, 0);
+
+ return CommandSuccess();
+ }
+
+ async undo(context?: CommandContext): Promise {
+ if (!context) throw new Error("MoveClipWithPushCommand.undo: context is required");
+
+ const doc = context.getDocument();
+ if (!doc) throw new Error("MoveClipWithPushCommand.undo: document is required");
+
+ // Undo the move first
+ await this.moveCommand.undo(context);
+
+ // Restore pushed clips to original positions in document
+ for (const { clipId, originalStart } of this.pushedClipIds) {
+ const clipInfo = doc.getClipById(clipId);
+ if (clipInfo) {
+ doc.updateClip(clipInfo.trackIndex, clipInfo.clipIndex, { start: originalStart });
+ }
+ }
+
+ // Reconciler handles player updates
+ context.resolve();
+
+ context.propagateTimingChanges(this.toTrackIndex, 0);
+ context.updateDuration();
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.moveCommand.dispose();
+ this.pushedClipIds = [];
+ }
+}
diff --git a/src/core/commands/resize-clip-command.ts b/src/core/commands/resize-clip-command.ts
index 7edb6064..03ef15f9 100644
--- a/src/core/commands/resize-clip-command.ts
+++ b/src/core/commands/resize-clip-command.ts
@@ -1,90 +1,117 @@
-import type { Player } from "@canvas/players/player";
-import type { TimingIntent } from "@core/timing/types";
+import { EditEvent } from "@core/events/edit-events";
+import { stripInternalProperties } from "@core/shared/clip-utils";
+import type { Seconds } from "@core/timing/types";
+import type { Clip } from "@schemas";
-import type { EditCommand, CommandContext } from "./types";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+/**
+ * Document-only command to resize a clip's length.
+ */
export class ResizeClipCommand implements EditCommand {
- name = "resizeClip";
- private originalLength?: number | "auto" | "end";
- private originalTimingIntent?: TimingIntent;
- private player?: Player;
+ readonly name = "resizeClip";
+
+ /** Document-layer value for undo (may be "auto", "end", or numeric) */
+ private originalLength?: Clip["length"];
+ private clipId?: string;
constructor(
private trackIndex: number,
private clipIndex: number,
- private newLength: number
+ private newLength: Seconds
) {}
- execute(context?: CommandContext): void {
- if (!context) return;
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("ResizeClipCommand.execute: context is required");
+
+ const doc = context.getDocument();
+ if (!doc) throw new Error("ResizeClipCommand.execute: document is required");
- // Get the specific track
- const track = context.getTrack(this.trackIndex);
- if (!track) {
- console.warn(`Invalid track index: ${this.trackIndex}`);
- return;
+ // Get document clip to store original length
+ const docTrack = context.getDocumentTrack(this.trackIndex);
+ const docClip = docTrack?.clips[this.clipIndex];
+ if (!docClip) {
+ return CommandNoop(`Invalid clip at ${this.trackIndex}/${this.clipIndex}`);
}
- if (this.clipIndex < 0 || this.clipIndex >= track.length) {
- console.warn(`Invalid clip index: ${this.clipIndex} for track ${this.trackIndex}`);
- return;
+ // Get current player for event emission
+ const player = context.getClipAt(this.trackIndex, this.clipIndex);
+ if (!player) {
+ return CommandNoop(`Player not found at ${this.trackIndex}/${this.clipIndex}`);
}
- this.player = track[this.clipIndex];
- this.originalLength = this.player.clipConfiguration.length;
+ // Store document-layer value for undo
+ this.originalLength = docClip.length;
+ this.clipId = player.clipId ?? undefined;
- // Store original timing intent for undo
- this.originalTimingIntent = this.player.getTimingIntent();
+ // Capture document clip BEFORE mutation
+ const previousDocClip = structuredClone(docClip);
- // Convert to fixed timing when manually resized
- this.player.convertToFixedTiming();
+ // Document-only mutation
+ doc.updateClip(this.trackIndex, this.clipIndex, { length: this.newLength });
- this.player.clipConfiguration.length = this.newLength;
+ // Single-clip resolution (O(1) instead of O(n) full resolve)
+ if (this.clipId) {
+ context.resolveClip(this.clipId);
+ } else {
+ context.resolve();
+ }
- // Update resolved timing
- this.player.setResolvedTiming({
- start: this.player.getStart(),
- length: this.newLength * 1000
- });
+ context.updateDuration();
- this.player.reconfigureAfterRestore();
- this.player.draw();
+ // Get document clip AFTER mutation
+ const currentDocClip = context.getDocumentClip(this.trackIndex, this.clipIndex);
+ if (!currentDocClip) throw new Error(`ResizeClipCommand: document clip not found after mutation at ${this.trackIndex}/${this.clipIndex}`);
- context.updateDuration();
- context.emitEvent("clip:updated", {
- previous: { clip: { ...this.player.clipConfiguration, length: this.originalLength }, trackIndex: this.trackIndex, clipIndex: this.clipIndex },
- current: { clip: this.player.clipConfiguration, trackIndex: this.trackIndex, clipIndex: this.clipIndex }
+ // Emit event with document clips
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { clip: stripInternalProperties(previousDocClip), trackIndex: this.trackIndex, clipIndex: this.clipIndex },
+ current: { clip: stripInternalProperties(currentDocClip), trackIndex: this.trackIndex, clipIndex: this.clipIndex }
});
- // Propagate timing changes to dependent clips
context.propagateTimingChanges(this.trackIndex, this.clipIndex);
+
+ return CommandSuccess();
}
- undo(context?: CommandContext): void {
- if (!context || !this.player || this.originalLength === undefined) return;
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("ResizeClipCommand.undo: context is required");
+ if (this.originalLength === undefined) throw new Error("ResizeClipCommand.undo: no original length");
- this.player.clipConfiguration.length = this.originalLength;
+ const doc = context.getDocument();
+ if (!doc) throw new Error("ResizeClipCommand.undo: document is required");
- // Restore original timing intent
- if (this.originalTimingIntent) {
- this.player.setTimingIntent(this.originalTimingIntent);
- // Update resolved timing to match
- this.player.setResolvedTiming({
- start: this.player.getStart(),
- length: typeof this.originalTimingIntent.length === "number" ? this.originalTimingIntent.length * 1000 : this.player.getLength()
- });
- }
+ const player = context.getClipAt(this.trackIndex, this.clipIndex);
+ if (!player) throw new Error("ResizeClipCommand.undo: player not found");
- this.player.reconfigureAfterRestore();
- this.player.draw();
+ // Capture document clip BEFORE undo mutation
+ const currentDocClip = structuredClone(context.getDocumentClip(this.trackIndex, this.clipIndex));
+
+ // Document-only mutation - restore original length (may be "auto", "end", or numeric)
+ doc.updateClip(this.trackIndex, this.clipIndex, { length: this.originalLength });
+
+ // Single-clip resolution (O(1) instead of O(n) full resolve)
+ if (this.clipId) {
+ context.resolveClip(this.clipId);
+ } else {
+ context.resolve();
+ }
context.updateDuration();
- context.emitEvent("clip:updated", {
- previous: { clip: { ...this.player.clipConfiguration, length: this.newLength }, trackIndex: this.trackIndex, clipIndex: this.clipIndex },
- current: { clip: this.player.clipConfiguration, trackIndex: this.trackIndex, clipIndex: this.clipIndex }
+
+ // Get document clip AFTER undo mutation
+ const restoredDocClip = context.getDocumentClip(this.trackIndex, this.clipIndex);
+ if (!currentDocClip || !restoredDocClip) {
+ throw new Error(`ResizeClipCommand: document clip not found after undo at ${this.trackIndex}/${this.clipIndex}`);
+ }
+
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { clip: stripInternalProperties(currentDocClip), trackIndex: this.trackIndex, clipIndex: this.clipIndex },
+ current: { clip: stripInternalProperties(restoredDocClip), trackIndex: this.trackIndex, clipIndex: this.clipIndex }
});
- // Propagate timing changes
context.propagateTimingChanges(this.trackIndex, this.clipIndex);
+
+ return CommandSuccess();
}
}
diff --git a/src/core/commands/select-clip-command.ts b/src/core/commands/select-clip-command.ts
deleted file mode 100644
index fb41148c..00000000
--- a/src/core/commands/select-clip-command.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { EditCommand, CommandContext } from "./types";
-
-export class SelectClipCommand implements EditCommand {
- public readonly name = "SelectClip";
- private previousSelection: { trackIndex: number; clipIndex: number } | null = null;
-
- constructor(
- private trackIndex: number,
- private clipIndex: number
- ) {}
-
- public execute(context: CommandContext): void {
- // Store previous selection for undo
- const currentSelection = context.getSelectedClip();
- if (currentSelection) {
- const indices = context.findClipIndices(currentSelection);
- if (indices) {
- this.previousSelection = indices;
- }
- }
-
- // Get the clip and select it
- const player = context.getClipAt(this.trackIndex, this.clipIndex);
- if (player) {
- // Set the selected clip
- context.setSelectedClip(player);
-
- // Emit selection event
- context.emitEvent("clip:selected", {
- clip: player.clipConfiguration,
- trackIndex: this.trackIndex,
- clipIndex: this.clipIndex
- });
- }
- }
-
- public undo(context: CommandContext): void {
- // Clear current selection
- context.setSelectedClip(null);
-
- // Restore previous selection if any
- if (this.previousSelection) {
- const player = context.getClipAt(this.previousSelection.trackIndex, this.previousSelection.clipIndex);
- if (player) {
- context.setSelectedClip(player);
- context.emitEvent("clip:selected", {
- clip: player.clipConfiguration,
- trackIndex: this.previousSelection.trackIndex,
- clipIndex: this.previousSelection.clipIndex
- });
- }
- } else {
- context.emitEvent("selection:cleared", {});
- }
- }
-}
diff --git a/src/core/commands/set-merge-field-command.ts b/src/core/commands/set-merge-field-command.ts
new file mode 100644
index 00000000..f10187c4
--- /dev/null
+++ b/src/core/commands/set-merge-field-command.ts
@@ -0,0 +1,136 @@
+import type { MergeFieldBinding } from "@core/edit-document";
+import { EditEvent } from "@core/events/edit-events";
+import { setNestedValue } from "@core/shared/utils";
+
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess } from "./types";
+
+/**
+ * Command to apply or remove a merge field on a clip property.
+ *
+ * Supports both asset-level paths (e.g. "asset.font.color") and
+ * clip-level paths (e.g. "opacity", "scale").
+ */
+export class SetMergeFieldCommand implements EditCommand {
+ readonly name = "setMergeField";
+
+ private storedPreviousValue: string;
+ private storedNewValue: string;
+ private storedPreviousBinding: MergeFieldBinding | undefined;
+
+ constructor(
+ private clipId: string,
+ private propertyPath: string,
+ private fieldName: string | null,
+ private previousFieldName: string | null,
+ previousValue: string,
+ newValue: string
+ ) {
+ this.storedPreviousValue = previousValue;
+ this.storedNewValue = newValue;
+ }
+
+ /**
+ * Write a value to the document clip at this command's property path.
+ * When storing a placeholder (raw=true), the string is written as-is.
+ * When storing a resolved value (raw=false), it is coerced to match
+ * the existing property's type (e.g. string "5" → number 5).
+ */
+ private updateDocumentClip(context: CommandContext, value: string, raw: boolean): void {
+ const document = context.getDocument();
+ if (!document) return;
+ const lookup = document.getClipById(this.clipId);
+ if (!lookup) return;
+ const clip = lookup.clip as Record;
+ if (raw) {
+ setNestedValue(clip, this.propertyPath, value);
+ } else {
+ const trimmed = typeof value === "string" ? value.trim() : "";
+ const num = trimmed.length > 0 ? Number(value) : NaN;
+ setNestedValue(clip, this.propertyPath, Number.isFinite(num) ? num : value);
+ }
+ }
+
+ async execute(context?: CommandContext): Promise {
+ if (!context) throw new Error("SetMergeFieldCommand.execute: context is required");
+
+ const mergeFields = context.getMergeFields();
+
+ // Save previous binding for undo (from document - source of truth)
+ this.storedPreviousBinding = context.getClipBinding(this.clipId, this.propertyPath);
+
+ // 1. Update bindings (document = source of truth)
+ if (this.fieldName) {
+ const binding: MergeFieldBinding = {
+ placeholder: mergeFields.createTemplate(this.fieldName),
+ resolvedValue: this.storedNewValue
+ };
+ context.setClipBinding(this.clipId, this.propertyPath, binding);
+ } else {
+ // Removing merge field
+ context.removeClipBinding(this.clipId, this.propertyPath);
+ }
+
+ // 2. Register/update merge field (silent to prevent duplicate reload)
+ if (this.fieldName) {
+ mergeFields.register({ name: this.fieldName, defaultValue: this.storedNewValue }, { silent: true });
+ } else if (this.previousFieldName) {
+ mergeFields.remove(this.previousFieldName, { silent: true });
+ }
+
+ // 3. Update document with placeholder (not resolved value)
+ // This matches how templates are loaded - placeholders stored in document, resolved at runtime
+ if (this.fieldName) {
+ const placeholder = mergeFields.createTemplate(this.fieldName);
+ this.updateDocumentClip(context, placeholder, true);
+ } else {
+ // No merge field - store resolved value with type coercion
+ this.updateDocumentClip(context, this.storedNewValue, false);
+ }
+
+ // 4. Resolve → Reconciler handles player updates (reloadAsset, reconfigure, draw)
+ context.resolve();
+
+ // 5. Emit canonical merge field event
+ context.emitEvent(EditEvent.MergeFieldChanged, { fields: mergeFields.getAll() });
+
+ return CommandSuccess();
+ }
+
+ async undo(context?: CommandContext): Promise {
+ if (!context) throw new Error("SetMergeFieldCommand.undo: context is required");
+
+ const mergeFields = context.getMergeFields();
+
+ // 1. Restore previous binding (document = source of truth)
+ if (this.storedPreviousBinding) {
+ context.setClipBinding(this.clipId, this.propertyPath, this.storedPreviousBinding);
+ } else {
+ context.removeClipBinding(this.clipId, this.propertyPath);
+ }
+
+ // 2. Re-register previous field (silent)
+ if (this.previousFieldName) {
+ mergeFields.register({ name: this.previousFieldName, defaultValue: this.storedPreviousValue }, { silent: true });
+ }
+
+ // 3. Restore document with previous value
+ // If there was a previous binding, store the placeholder; otherwise store resolved value
+ if (this.storedPreviousBinding) {
+ this.updateDocumentClip(context, this.storedPreviousBinding.placeholder, true);
+ } else {
+ this.updateDocumentClip(context, this.storedPreviousValue, false);
+ }
+
+ // 4. Resolve → Reconciler handles player updates
+ context.resolve();
+
+ // 5. Emit canonical merge field event
+ context.emitEvent(EditEvent.MergeFieldChanged, { fields: mergeFields.getAll() });
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.storedPreviousBinding = undefined;
+ }
+}
diff --git a/src/core/commands/set-output-aspect-ratio-command.ts b/src/core/commands/set-output-aspect-ratio-command.ts
new file mode 100644
index 00000000..40e48038
--- /dev/null
+++ b/src/core/commands/set-output-aspect-ratio-command.ts
@@ -0,0 +1,26 @@
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+export class SetOutputAspectRatioCommand implements EditCommand {
+ readonly name = "setOutputAspectRatio";
+ private previousAspectRatio?: string;
+
+ constructor(private aspectRatio: string) {}
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputAspectRatioCommand requires context");
+ this.previousAspectRatio = context.getOutputAspectRatio();
+ context.setOutputAspectRatio(this.aspectRatio);
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputAspectRatioCommand requires context");
+ if (this.previousAspectRatio === undefined) return CommandNoop("No previous aspect ratio stored");
+ context.setOutputAspectRatio(this.previousAspectRatio);
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.previousAspectRatio = undefined;
+ }
+}
diff --git a/src/core/commands/set-output-destinations-command.ts b/src/core/commands/set-output-destinations-command.ts
new file mode 100644
index 00000000..a79bd0d2
--- /dev/null
+++ b/src/core/commands/set-output-destinations-command.ts
@@ -0,0 +1,28 @@
+import type { Destination } from "@schemas";
+
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+export class SetOutputDestinationsCommand implements EditCommand {
+ readonly name = "setOutputDestinations";
+ private previousDestinations?: Destination[];
+
+ constructor(private destinations: Destination[]) {}
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputDestinationsCommand requires context");
+ this.previousDestinations = context.getOutputDestinations();
+ context.setOutputDestinations(this.destinations);
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputDestinationsCommand requires context");
+ if (this.previousDestinations === undefined) return CommandNoop("No previous destinations stored");
+ context.setOutputDestinations(this.previousDestinations);
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.previousDestinations = undefined;
+ }
+}
diff --git a/src/core/commands/set-output-format-command.ts b/src/core/commands/set-output-format-command.ts
new file mode 100644
index 00000000..08255f32
--- /dev/null
+++ b/src/core/commands/set-output-format-command.ts
@@ -0,0 +1,26 @@
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+export class SetOutputFormatCommand implements EditCommand {
+ readonly name = "setOutputFormat";
+ private previousFormat?: string;
+
+ constructor(private format: string) {}
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputFormatCommand requires context");
+ this.previousFormat = context.getOutputFormat();
+ context.setOutputFormat(this.format);
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputFormatCommand requires context");
+ if (this.previousFormat === undefined) return CommandNoop("No previous format stored");
+ context.setOutputFormat(this.previousFormat);
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.previousFormat = undefined;
+ }
+}
diff --git a/src/core/commands/set-output-fps-command.ts b/src/core/commands/set-output-fps-command.ts
new file mode 100644
index 00000000..51ac5fe9
--- /dev/null
+++ b/src/core/commands/set-output-fps-command.ts
@@ -0,0 +1,26 @@
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+export class SetOutputFpsCommand implements EditCommand {
+ readonly name = "setOutputFps";
+ private previousFps?: number;
+
+ constructor(private fps: number) {}
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputFpsCommand requires context");
+ this.previousFps = context.getOutputFps();
+ context.setOutputFps(this.fps);
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputFpsCommand requires context");
+ if (this.previousFps === undefined) return CommandNoop("No previous FPS stored");
+ context.setOutputFps(this.previousFps);
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.previousFps = undefined;
+ }
+}
diff --git a/src/core/commands/set-output-resolution-command.ts b/src/core/commands/set-output-resolution-command.ts
new file mode 100644
index 00000000..16422720
--- /dev/null
+++ b/src/core/commands/set-output-resolution-command.ts
@@ -0,0 +1,26 @@
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+export class SetOutputResolutionCommand implements EditCommand {
+ readonly name = "setOutputResolution";
+ private previousResolution?: string;
+
+ constructor(private resolution: string) {}
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputResolutionCommand requires context");
+ this.previousResolution = context.getOutputResolution();
+ context.setOutputResolution(this.resolution);
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputResolutionCommand requires context");
+ if (this.previousResolution === undefined) return CommandNoop("No previous resolution stored");
+ context.setOutputResolution(this.previousResolution);
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.previousResolution = undefined;
+ }
+}
diff --git a/src/core/commands/set-output-size-command.ts b/src/core/commands/set-output-size-command.ts
new file mode 100644
index 00000000..8d7f6622
--- /dev/null
+++ b/src/core/commands/set-output-size-command.ts
@@ -0,0 +1,29 @@
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+export class SetOutputSizeCommand implements EditCommand {
+ readonly name = "setOutputSize";
+ private previousSize?: { width: number; height: number };
+
+ constructor(
+ private width: number,
+ private height: number
+ ) {}
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputSizeCommand requires context");
+ this.previousSize = context.getOutputSize();
+ context.setOutputSize(this.width, this.height);
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetOutputSizeCommand requires context");
+ if (!this.previousSize) return CommandNoop("No previous size stored");
+ context.setOutputSize(this.previousSize.width, this.previousSize.height);
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.previousSize = undefined;
+ }
+}
diff --git a/src/core/commands/set-timeline-background-command.ts b/src/core/commands/set-timeline-background-command.ts
new file mode 100644
index 00000000..153ecec8
--- /dev/null
+++ b/src/core/commands/set-timeline-background-command.ts
@@ -0,0 +1,26 @@
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+export class SetTimelineBackgroundCommand implements EditCommand {
+ readonly name = "setTimelineBackground";
+ private previousColor?: string;
+
+ constructor(private color: string) {}
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetTimelineBackgroundCommand requires context");
+ this.previousColor = context.getTimelineBackground();
+ context.setTimelineBackground(this.color);
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("SetTimelineBackgroundCommand requires context");
+ if (this.previousColor === undefined) return CommandNoop("No previous color stored");
+ context.setTimelineBackground(this.previousColor);
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.previousColor = undefined;
+ }
+}
diff --git a/src/core/commands/set-updated-clip-command.ts b/src/core/commands/set-updated-clip-command.ts
index 0953d26a..e1d593d8 100644
--- a/src/core/commands/set-updated-clip-command.ts
+++ b/src/core/commands/set-updated-clip-command.ts
@@ -1,83 +1,201 @@
-import type { Player } from "@canvas/players/player";
-import { ClipSchema } from "@schemas/clip";
-import { z } from "zod";
+import type { MergeFieldBinding } from "@core/edit-document";
+import { EditEvent } from "@core/events/edit-events";
+import { stripInternalProperties } from "@core/shared/clip-utils";
+import { getNestedValue } from "@core/shared/utils";
+import type { Clip, ResolvedClip } from "@schemas";
-import type { EditCommand, CommandContext } from "./types";
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
-type ClipType = z.infer;
+type ClipType = ResolvedClip;
+export interface SetUpdatedClipOptions {
+ trackIndex?: number;
+ clipIndex?: number;
+}
+
+/**
+ * Command to update a clip's full configuration.
+ */
export class SetUpdatedClipCommand implements EditCommand {
- name = "setUpdatedClip";
- private storedInitialConfig: ClipType | null;
- private storedFinalConfig: ClipType;
+ readonly name = "setUpdatedClip";
+
+ private clipId: string | null = null;
+ private storedInitialConfig: ClipType | null = null;
+ private storedFinalConfig: ClipType | null = null;
+ private storedInitialBindings: Map = new Map();
+ private previousDocClip: Clip | null = null;
+ private trackIndex: number;
+ private clipIndex: number;
constructor(
- private clip: Player,
private initialClipConfig: ClipType | null,
- private finalClipConfig: ClipType | null
+ private finalClipConfig: ClipType | null,
+ options?: SetUpdatedClipOptions
) {
- this.storedInitialConfig = initialClipConfig ? structuredClone(initialClipConfig) : null;
- this.storedFinalConfig = finalClipConfig ? structuredClone(finalClipConfig) : structuredClone(this.clip.clipConfiguration);
+ this.trackIndex = options?.trackIndex ?? -1;
+ this.clipIndex = options?.clipIndex ?? -1;
}
- async execute(context?: CommandContext): Promise {
- if (!context) return;
- if (this.storedFinalConfig) {
- context.restoreClipConfiguration(this.clip, this.storedFinalConfig);
+ async execute(context?: CommandContext): Promise {
+ if (!context) throw new Error("SetUpdatedClipCommand.execute: context is required");
+
+ const doc = context.getDocument();
+ if (!doc) throw new Error("SetUpdatedClipCommand.execute: document is required");
+
+ // Get player to determine indices if not provided
+ const player = context.getClipAt(this.trackIndex, this.clipIndex);
+ if (!player) {
+ return CommandNoop(`Invalid clip at ${this.trackIndex}/${this.clipIndex}`);
+ }
+
+ // Store for undo (only on first execute - don't overwrite on redo)
+ this.clipId = player.clipId;
+ if (!this.storedInitialConfig) {
+ this.storedInitialConfig = this.initialClipConfig ? structuredClone(this.initialClipConfig) : structuredClone(player.clipConfiguration);
+ }
+ if (!this.storedFinalConfig) {
+ this.storedFinalConfig = this.finalClipConfig ? structuredClone(this.finalClipConfig) : structuredClone(player.clipConfiguration);
}
- context.setUpdatedClip(this.clip);
+ // Capture document clip BEFORE mutation (source of truth for SDK events)
+ const docClip = context.getDocumentClip(
+ this.trackIndex >= 0 ? this.trackIndex : player.layer - 1,
+ this.clipIndex >= 0 ? this.clipIndex : (context.getTracks()[player.layer - 1]?.indexOf(player) ?? -1)
+ );
+ this.previousDocClip = docClip ? structuredClone(docClip) : null;
+
+ // Save bindings before modification (for undo) - read from document (source of truth)
+ const docBindings = this.clipId ? context.getClipBindings(this.clipId) : undefined;
+ this.storedInitialBindings = docBindings ? new Map(docBindings) : new Map();
+
+ // Use provided indices or calculate from player
+ const trackIndex = this.trackIndex >= 0 ? this.trackIndex : player.layer - 1;
+ const clipIndex = this.clipIndex >= 0 ? this.clipIndex : (context.getTracks()[trackIndex]?.indexOf(player) ?? -1);
+
+ // Replace all clip properties with the final configuration.
+ const configToApply = this.storedFinalConfig ?? this.finalClipConfig;
+ if (configToApply) {
+ doc.replaceClipProperties(trackIndex, clipIndex, configToApply as unknown as Partial);
+ }
- const trackIndex = this.clip.layer - 1;
- const clips = context.getClips();
- const clipsByTrack = clips.filter((c: Player) => c.layer === this.clip.layer);
- const clipIndex = clipsByTrack.indexOf(this.clip);
+ // Reconciler handles player updates
+ context.resolve();
- // Check if asset src changed
- const previousAsset = this.storedInitialConfig?.asset as { src?: string } | undefined;
- const currentAsset = this.storedFinalConfig?.asset as { src?: string } | undefined;
+ // Detect broken bindings - if value changed from resolvedValue, remove the binding
+ const resolvedPlayer = context.getClipAt(trackIndex, clipIndex);
+ for (const [path, { resolvedValue }] of this.storedInitialBindings) {
+ const currentValue = resolvedPlayer ? getNestedValue(resolvedPlayer.clipConfiguration, path) : undefined;
+ if (currentValue !== resolvedValue && this.clipId) {
+ context.removeClipBinding(this.clipId, path);
+ }
+ }
+
+ // Check if asset src changed (fallback to constructor params for redo case)
+ const initialConfig = this.storedInitialConfig ?? this.initialClipConfig;
+ const finalConfig = this.storedFinalConfig ?? this.finalClipConfig;
+ const previousAsset = initialConfig?.asset as { src?: string } | undefined;
+ const currentAsset = finalConfig?.asset as { src?: string } | undefined;
if (previousAsset?.src !== currentAsset?.src) {
// Asset changed - if clip has "auto" length, re-resolve it
- const intent = this.clip.getTimingIntent();
- if (intent.length === "auto") {
- await context.resolveClipAutoLength(this.clip);
+ const currentPlayer = context.getClipAt(trackIndex, clipIndex);
+ if (currentPlayer) {
+ const intent = currentPlayer.getTimingIntent();
+ if (intent.length === "auto") {
+ await context.resolveClipAutoLength(currentPlayer);
+ }
}
}
- context.emitEvent("clip:updated", {
- previous: { clip: this.storedInitialConfig || this.initialClipConfig, trackIndex, clipIndex },
- current: { clip: this.storedFinalConfig || this.clip.clipConfiguration, trackIndex, clipIndex }
+ // Get document clip AFTER mutation (source of truth for SDK events)
+ const currentDocClip = context.getDocumentClip(trackIndex, clipIndex);
+ if (!this.previousDocClip || !currentDocClip)
+ throw new Error(`SetUpdatedClipCommand: document clip not found after mutation at ${trackIndex}/${clipIndex}`);
+
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { clip: stripInternalProperties(this.previousDocClip), trackIndex, clipIndex },
+ current: { clip: stripInternalProperties(currentDocClip), trackIndex, clipIndex }
});
+
+ return CommandSuccess();
}
- async undo(context?: CommandContext): Promise {
- if (!context || !this.storedInitialConfig) return;
+ async undo(context?: CommandContext): Promise {
+ if (!context) throw new Error("SetUpdatedClipCommand.undo: context is required");
- context.restoreClipConfiguration(this.clip, this.storedInitialConfig);
+ // Use stored config if execute() was called, otherwise use constructor params
+ // This handles commands added to history without execution (e.g., canvas drags via commitClipUpdate)
+ const configToRestore = this.storedInitialConfig ?? this.initialClipConfig;
+ if (!configToRestore) return CommandNoop("No stored initial config");
- context.setUpdatedClip(this.clip);
+ const doc = context.getDocument();
+ if (!doc) throw new Error("SetUpdatedClipCommand.undo: document is required");
- const trackIndex = this.clip.layer - 1;
- const clips = context.getClips();
- const clipsByTrack = clips.filter((c: Player) => c.layer === this.clip.layer);
- const clipIndex = clipsByTrack.indexOf(this.clip);
+ // Get player by ID or indices
+ const player = this.clipId ? context.getPlayerByClipId(this.clipId) : context.getClipAt(this.trackIndex, this.clipIndex);
+
+ if (!player) return CommandNoop(`Clip not found for undo`);
+
+ // Use provided indices or calculate from player
+ const trackIndex = this.trackIndex >= 0 ? this.trackIndex : player.layer - 1;
+ const clipIndex = this.clipIndex >= 0 ? this.clipIndex : (context.getTracks()[trackIndex]?.indexOf(player) ?? -1);
+
+ // Capture document clip BEFORE undo mutation (source of truth for SDK events)
+ const currentDocClip = structuredClone(context.getDocumentClip(trackIndex, clipIndex));
+
+ // Replace all clip properties with the initial configuration.
+ doc.replaceClipProperties(trackIndex, clipIndex, configToRestore as unknown as Partial);
+
+ // Reconciler handles player updates
+ context.resolve();
+
+ // Restore saved bindings (document = source of truth)
+ if (this.clipId) {
+ const docBindings = new Map(this.storedInitialBindings);
+ if (docBindings.size > 0) {
+ const document = context.getDocument();
+ document?.setClipBindingsForClip(this.clipId, docBindings);
+ } else {
+ context.getDocument()?.clearClipBindings(this.clipId);
+ }
+ }
// Check if asset src changed (reverse direction)
- const previousAsset = this.storedFinalConfig?.asset as { src?: string } | undefined;
- const currentAsset = this.storedInitialConfig?.asset as { src?: string } | undefined;
+ // Use stored config if execute() was called, otherwise use constructor params
+ const configApplied = this.storedFinalConfig ?? this.finalClipConfig;
+ const previousAsset = configApplied?.asset as { src?: string } | undefined;
+ const currentAsset = configToRestore?.asset as { src?: string } | undefined;
if (previousAsset?.src !== currentAsset?.src) {
// Asset changed - if clip has "auto" length, re-resolve it
- const intent = this.clip.getTimingIntent();
- if (intent.length === "auto") {
- await context.resolveClipAutoLength(this.clip);
+ const currentPlayer = context.getClipAt(trackIndex, clipIndex);
+ if (currentPlayer) {
+ const intent = currentPlayer.getTimingIntent();
+ if (intent.length === "auto") {
+ await context.resolveClipAutoLength(currentPlayer);
+ }
}
}
- context.emitEvent("clip:updated", {
- previous: { clip: this.storedFinalConfig, trackIndex, clipIndex },
- current: { clip: this.storedInitialConfig, trackIndex, clipIndex }
+ // Get document clip AFTER undo mutation (restored state)
+ const restoredDocClip = context.getDocumentClip(trackIndex, clipIndex);
+ if (!currentDocClip || !restoredDocClip) {
+ throw new Error(`SetUpdatedClipCommand: document clip not found after undo at ${trackIndex}/${clipIndex}`);
+ }
+
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { clip: stripInternalProperties(currentDocClip), trackIndex, clipIndex },
+ current: { clip: stripInternalProperties(restoredDocClip), trackIndex, clipIndex }
});
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.clipId = null;
+ this.storedInitialConfig = null;
+ this.storedFinalConfig = null;
+ this.storedInitialBindings.clear();
+ this.previousDocClip = null;
}
}
diff --git a/src/core/commands/split-clip-command.ts b/src/core/commands/split-clip-command.ts
deleted file mode 100644
index ab4766a3..00000000
--- a/src/core/commands/split-clip-command.ts
+++ /dev/null
@@ -1,175 +0,0 @@
-import type { Player } from "@canvas/players/player";
-
-import type { AudioAsset } from "../schemas/audio-asset";
-import type { Clip } from "../schemas/clip";
-import type { VideoAsset } from "../schemas/video-asset";
-
-import type { EditCommand, CommandContext } from "./types";
-
-export class SplitClipCommand implements EditCommand {
- public readonly name = "SplitClip";
- private originalClipConfig: Clip | null = null;
- private rightClipPlayer: Player | null = null;
- private splitSuccessful = false;
-
- constructor(
- private trackIndex: number,
- private clipIndex: number,
- private splitTime: number // Time in seconds where to split
- ) {}
-
- public execute(context: CommandContext): void {
- // Get the player to split
- const player = context.getClipAt(this.trackIndex, this.clipIndex);
- if (!player || !player.clipConfiguration) {
- throw new Error("Cannot split clip: invalid player or clip configuration");
- }
-
- const clipConfig = player.clipConfiguration;
- const clipStart = typeof clipConfig.start === "number" ? clipConfig.start : 0;
- const clipLength = typeof clipConfig.length === "number" ? clipConfig.length : player.getLength() / 1000;
-
- // Validate split point
- const MIN_CLIP_LENGTH = 0.1;
- const splitPoint = this.splitTime - clipStart;
-
- if (splitPoint <= MIN_CLIP_LENGTH || splitPoint >= clipLength - MIN_CLIP_LENGTH) {
- throw new Error("Cannot split clip: split point too close to clip boundaries");
- }
-
- // Store original configuration for undo
- this.originalClipConfig = { ...clipConfig };
-
- // Calculate left and right clip configurations
- const leftClip: Clip = {
- ...clipConfig,
- length: splitPoint
- };
-
- const rightClip: Clip = {
- ...clipConfig,
- start: clipStart + splitPoint,
- length: clipLength - splitPoint
- };
-
- // Deep clone assets to avoid shared references
- if (clipConfig.asset) {
- leftClip.asset = { ...clipConfig.asset };
- rightClip.asset = { ...clipConfig.asset };
- }
-
- // Adjust trim values for video/audio assets
- if (clipConfig.asset && (clipConfig.asset.type === "video" || clipConfig.asset.type === "audio")) {
- // The trim value indicates how much was trimmed from the start of the original asset
- const originalTrim = (clipConfig.asset as VideoAsset | AudioAsset).trim || 0;
-
- // Left clip keeps the original trim
- if (leftClip.asset && (leftClip.asset.type === "video" || leftClip.asset.type === "audio")) {
- (leftClip.asset as VideoAsset | AudioAsset).trim = originalTrim;
- }
-
- // Right clip needs trim = original trim + split point
- if (rightClip.asset && (rightClip.asset.type === "video" || rightClip.asset.type === "audio")) {
- (rightClip.asset as VideoAsset | AudioAsset).trim = originalTrim + splitPoint;
- }
- }
-
- // Update the existing clip to be the left portion
- Object.assign(player.clipConfiguration, leftClip);
- player.reconfigureAfterRestore();
- player.draw();
-
- // Create the right clip player
- this.rightClipPlayer = context.createPlayerFromAssetType(rightClip);
- if (!this.rightClipPlayer) {
- // Restore original if creation failed
- Object.assign(player.clipConfiguration, this.originalClipConfig);
- player.reconfigureAfterRestore();
- throw new Error("Failed to create right clip player");
- }
-
- this.rightClipPlayer.layer = this.trackIndex + 1;
-
- // Insert right clip after the current clip
- const track = context.getTrack(this.trackIndex);
- if (!track) {
- throw new Error("Invalid track index");
- }
-
- track.splice(this.clipIndex + 1, 0, this.rightClipPlayer);
-
- // Update global clips array
- const clips = context.getClips();
- const globalIndex = clips.indexOf(player);
- if (globalIndex !== -1) {
- clips.splice(globalIndex + 1, 0, this.rightClipPlayer);
- }
-
- // Add to PIXI container
- context.addPlayerToContainer(this.trackIndex, this.rightClipPlayer);
-
- // Configure and position the new player before loading
- this.rightClipPlayer.reconfigureAfterRestore();
-
- // Load the new player
- this.rightClipPlayer
- .load()
- .then(() => {
- this.splitSuccessful = true;
- // Draw the new player after loading
- if (this.rightClipPlayer) {
- this.rightClipPlayer.draw();
- }
- context.updateDuration();
- context.emitEvent("timeline:updated", {
- current: context.getEditState()
- });
- })
- .catch(error => {
- console.error("Failed to load split clip:", error);
- // Clean up will happen in undo if needed
- });
- }
-
- public undo(context: CommandContext): void {
- if (!this.originalClipConfig) {
- return;
- }
-
- // Get the left clip (original player)
- const leftPlayer = context.getClipAt(this.trackIndex, this.clipIndex);
- if (!leftPlayer) {
- return;
- }
-
- // Restore original configuration
- Object.assign(leftPlayer.clipConfiguration, this.originalClipConfig);
- leftPlayer.reconfigureAfterRestore();
-
- // Remove the right clip if it was created
- if (this.rightClipPlayer) {
- const track = context.getTrack(this.trackIndex);
- if (track) {
- const rightIndex = track.indexOf(this.rightClipPlayer);
- if (rightIndex !== -1) {
- track.splice(rightIndex, 1);
- }
- }
-
- const clips = context.getClips();
- const globalIndex = clips.indexOf(this.rightClipPlayer);
- if (globalIndex !== -1) {
- clips.splice(globalIndex, 1);
- }
-
- // Queue for disposal
- context.queueDisposeClip(this.rightClipPlayer);
- this.rightClipPlayer = null;
- }
-
- context.updateDuration();
- context.emitEvent("timeline:updated", {
- current: context.getEditState()
- });
- }
-}
diff --git a/src/core/commands/transform-clip-asset-command.ts b/src/core/commands/transform-clip-asset-command.ts
new file mode 100644
index 00000000..3b13b6ec
--- /dev/null
+++ b/src/core/commands/transform-clip-asset-command.ts
@@ -0,0 +1,99 @@
+import { EditEvent } from "@core/events/edit-events";
+import { stripInternalProperties } from "@core/shared/clip-utils";
+import type { Clip } from "@schemas";
+
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+/**
+ * Transforms a clip's asset type.
+ * Used for luma attachment (image/video → luma) and detachment (luma → image/video).
+ */
+export class TransformClipAssetCommand implements EditCommand {
+ public readonly name = "TransformClipAsset";
+
+ private originalAsset: Clip["asset"] | null = null;
+ private originalAssetType: "image" | "video" | "luma" | null = null;
+
+ constructor(
+ private trackIndex: number,
+ private clipIndex: number,
+ private targetAssetType: "image" | "video" | "luma"
+ ) {}
+
+ public execute(context: CommandContext): CommandResult {
+ const document = context.getDocument();
+ if (!document) throw new Error("Cannot transform clip: no document");
+
+ const clip = document.getClip(this.trackIndex, this.clipIndex);
+ if (!clip?.asset) return CommandNoop("Invalid clip or no asset");
+
+ // Capture document clip BEFORE mutation (source of truth for SDK events)
+ const previousDocClip = structuredClone(clip);
+
+ // Store original for undo
+ this.originalAsset = structuredClone(clip.asset);
+ this.originalAssetType = ((clip.asset as { type?: string })?.type as "image" | "video" | "luma") ?? null;
+
+ // Only works for src-based assets
+ const originalAsset = clip.asset as { src?: string };
+ if (!originalAsset.src) {
+ return CommandNoop("Asset has no src property");
+ }
+
+ // Create new asset with target type (minimal - resolver fills in details)
+ const newAsset = { type: this.targetAssetType, src: originalAsset.src };
+
+ // Document mutation
+ context.documentUpdateClip(this.trackIndex, this.clipIndex, { asset: newAsset });
+
+ // Full resolve (required for asset type changes - needs Player recreation with tracks array management)
+ context.resolve();
+
+ // Get document clip AFTER mutation (source of truth for SDK events)
+ const currentDocClip = context.getDocumentClip(this.trackIndex, this.clipIndex);
+ if (!currentDocClip) throw new Error(`TransformClipAssetCommand: document clip not found after mutation at ${this.trackIndex}/${this.clipIndex}`);
+
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { trackIndex: this.trackIndex, clipIndex: this.clipIndex, clip: stripInternalProperties(previousDocClip) },
+ current: { trackIndex: this.trackIndex, clipIndex: this.clipIndex, clip: stripInternalProperties(currentDocClip) }
+ });
+
+ return CommandSuccess();
+ }
+
+ public undo(context: CommandContext): CommandResult {
+ if (!this.originalAsset) return CommandNoop("No original asset stored");
+
+ // Capture document clip BEFORE undo mutation (source of truth for SDK events)
+ const currentDocClip = structuredClone(context.getDocumentClip(this.trackIndex, this.clipIndex));
+
+ // Document mutation - restore original asset
+ context.documentUpdateClip(this.trackIndex, this.clipIndex, { asset: this.originalAsset });
+
+ // Full resolve (required for asset type changes - needs Player recreation with tracks array management)
+ context.resolve();
+
+ // Get document clip AFTER undo mutation (restored state)
+ const restoredDocClip = context.getDocumentClip(this.trackIndex, this.clipIndex);
+ if (!currentDocClip || !restoredDocClip) {
+ throw new Error(`TransformClipAssetCommand: document clip not found after undo at ${this.trackIndex}/${this.clipIndex}`);
+ }
+
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { trackIndex: this.trackIndex, clipIndex: this.clipIndex, clip: stripInternalProperties(currentDocClip) },
+ current: { trackIndex: this.trackIndex, clipIndex: this.clipIndex, clip: stripInternalProperties(restoredDocClip) }
+ });
+
+ return CommandSuccess();
+ }
+
+ /** Get the stored original asset type (used for reliable restoration) */
+ public getOriginalAssetType(): "image" | "video" | "luma" | null {
+ return this.originalAssetType;
+ }
+
+ public dispose(): void {
+ this.originalAsset = null;
+ this.originalAssetType = null;
+ }
+}
diff --git a/src/core/commands/types.ts b/src/core/commands/types.ts
index b4b9d315..4f75e713 100644
--- a/src/core/commands/types.ts
+++ b/src/core/commands/types.ts
@@ -1,20 +1,46 @@
import type { Player } from "@canvas/players/player";
-import type { ClipSchema } from "@schemas/clip";
-import type { EditSchema } from "@schemas/edit";
+import type { EditDocument, MergeFieldBinding } from "@core/edit-document";
+import type { EditEventMap, EditEventName } from "@core/events/edit-events";
+import type { MergeFieldService } from "@core/merge";
+import type { ResolutionContext } from "@core/timing/types";
+import type { Clip, Destination, ResolvedClip, ResolvedEdit } from "@schemas";
import type { Container } from "pixi.js";
-import type { z } from "zod";
-type ClipType = z.infer;
-type EditType = z.infer;
+type ClipType = ResolvedClip;
+type EditType = ResolvedEdit;
+// Document clip type (allows "auto"/"end" for timing)
+type DocumentClipType = Clip;
export interface TimelineUpdatedEvent {
previous: { timeline: EditType };
current: { timeline: EditType };
}
+/**
+ * Result of command execution.
+ */
+export interface CommandResult {
+ status: "success" | "noop";
+ message?: string;
+}
+
+/**
+ * Helper to create a success result.
+ */
+export const CommandSuccess = (): CommandResult => ({ status: "success" });
+
+/**
+ * Helper to create a noop result.
+ */
+export const CommandNoop = (message?: string): CommandResult => ({ status: "noop", message });
+
+// Return type for commands
+export type CommandReturn = CommandResult | Promise;
+
export type EditCommand = {
- execute(context?: CommandContext): void | Promise;
- undo?(context?: CommandContext): void | Promise;
+ execute(context?: CommandContext): CommandReturn;
+ undo?(context?: CommandContext): CommandReturn;
+ dispose?(): void;
readonly name: string;
};
@@ -28,11 +54,12 @@ export type CommandContext = {
createPlayerFromAssetType(clipConfiguration: ClipType): Player;
queueDisposeClip(player: Player): void;
disposeClips(): void;
+ clearClipError(trackIdx: number, clipIdx: number): void;
undeleteClip(trackIdx: number, clip: Player): void;
setUpdatedClip(clip: Player): void;
restoreClipConfiguration(clip: Player, previousConfig: ClipType): void;
updateDuration(): void;
- emitEvent(name: string, data: unknown): void;
+ emitEvent(name: T, ...args: EditEventMap[T] extends void ? [] : [EditEventMap[T]]): void;
findClipIndices(player: Player): { trackIndex: number; clipIndex: number } | null;
getClipAt(trackIndex: number, clipIndex: number): Player | null;
getSelectedClip(): Player | null;
@@ -41,6 +68,90 @@ export type CommandContext = {
getEditState(): EditType;
propagateTimingChanges(trackIndex: number, startFromClipIndex: number): void;
resolveClipAutoLength(clip: Player): Promise;
- untrackEndLengthClip(clip: Player): void;
- trackEndLengthClip(clip: Player): void;
+ // Merge field context
+ getMergeFields(): MergeFieldService;
+
+ // Output settings
+ getOutputSize(): { width: number; height: number };
+ setOutputSize(width: number, height: number): void;
+ getOutputFps(): number;
+ setOutputFps(fps: number): void;
+ getOutputFormat(): string;
+ setOutputFormat(format: string): void;
+ getOutputResolution(): string | undefined;
+ setOutputResolution(resolution: string): void;
+ getOutputAspectRatio(): string | undefined;
+ setOutputAspectRatio(aspectRatio: string): void;
+ getOutputDestinations(): Destination[];
+ setOutputDestinations(destinations: Destination[]): void;
+ getTimelineBackground(): string;
+ setTimelineBackground(color: string): void;
+
+ // Document access
+ getDocument(): EditDocument | null;
+ /** Get a track from the document by index */
+ getDocumentTrack(trackIdx: number): { clips: DocumentClipType[] } | null;
+ /** Get a clip from the document by indices (source of truth for SDK events) */
+ getDocumentClip(trackIdx: number, clipIdx: number): DocumentClipType | null;
+ /** Update a clip's properties in the document */
+ documentUpdateClip(trackIdx: number, clipIdx: number, updates: Partial): void;
+ /** Add a clip to the document, returns the added clip */
+ documentAddClip(trackIdx: number, clip: DocumentClipType, clipIdx?: number): DocumentClipType;
+ /** Remove a clip from the document, returns the removed clip or null */
+ documentRemoveClip(trackIdx: number, clipIdx: number): DocumentClipType | null;
+ /** Derive runtime Player state from document clip */
+ derivePlayerFromDocument(trackIdx: number, clipIdx: number): void;
+
+ /**
+ * Build resolution context for a clip at the given position.
+ * Extracts all dependencies upfront so resolution can be pure.
+ *
+ * @param trackIdx - Track index
+ * @param clipIdx - Clip index on that track
+ * @returns Context with previousClipEnd, timelineEnd, intrinsicDuration
+ */
+ buildResolutionContext(trackIdx: number, clipIdx: number): ResolutionContext;
+
+ /**
+ * Resolve the document to a ResolvedEdit and emit the Resolved event.
+ * This is the core of unidirectional data flow:
+ * Command → Document → resolve() → ResolvedEdit → Components
+ */
+ resolve(): EditType;
+
+ /**
+ * Resolve a single clip and update its player.
+ *
+ * This is an optimization for single-clip mutations (timing, asset, properties).
+ * Use instead of resolve() when only one clip changed.
+ *
+ * @param clipId - The clip to resolve and update
+ * @returns true if clip was found and updated, false otherwise
+ */
+ resolveClip(clipId: string): boolean;
+
+ // ID-based Player access (for reconciliation)
+ /** Get a Player by its stable clip ID */
+ getPlayerByClipId(clipId: string): Player | null;
+ /** Register a Player with its clip ID for lookup */
+ registerPlayerByClipId(clipId: string, player: Player): void;
+ /** Unregister a Player from the ID map */
+ unregisterPlayerByClipId(clipId: string): void;
+
+ // Merge field binding management (document-based)
+ /** Set a merge field binding for a clip property */
+ setClipBinding(clipId: string, path: string, binding: MergeFieldBinding): void;
+ /** Get a merge field binding for a clip property */
+ getClipBinding(clipId: string, path: string): MergeFieldBinding | undefined;
+ /** Remove a merge field binding for a clip property */
+ removeClipBinding(clipId: string, path: string): void;
+ /** Get all bindings for a clip */
+ getClipBindings(clipId: string): Map | undefined;
+
+ /** Get reference to EditSession for managing state */
+ getEditSession(): {
+ setLumaContentRelationship(lumaClipId: string, contentClipId: string): void;
+ clearLumaContentRelationship(lumaClipId: string): void;
+ getLumaContentRelationship(lumaClipId: string): string | undefined;
+ };
};
diff --git a/src/core/commands/update-clip-timing-command.ts b/src/core/commands/update-clip-timing-command.ts
new file mode 100644
index 00000000..e22fe167
--- /dev/null
+++ b/src/core/commands/update-clip-timing-command.ts
@@ -0,0 +1,159 @@
+import { EditEvent } from "@core/events/edit-events";
+import { stripInternalProperties } from "@core/shared/clip-utils";
+import type { Seconds } from "@core/timing/types";
+import type { Clip } from "@schemas";
+
+import { type CommandContext, type EditCommand, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+
+/**
+ * Command parameters for timing updates.
+ * Values in Seconds.
+ */
+export interface TimingUpdateParams {
+ start?: Seconds | "auto";
+ length?: Seconds | "auto" | "end";
+}
+
+/**
+ * Document-only command to update a clip's timing.
+ */
+export class UpdateClipTimingCommand implements EditCommand {
+ public readonly name = "UpdateClipTiming";
+
+ /** Document-layer values for undo */
+ private originalStart?: Clip["start"];
+ private originalLength?: Clip["length"];
+ private clipId?: string;
+
+ constructor(
+ private trackIndex: number,
+ private clipIndex: number,
+ private params: TimingUpdateParams
+ ) {}
+
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("UpdateClipTimingCommand.execute: context is required");
+
+ const doc = context.getDocument();
+ if (!doc) throw new Error("UpdateClipTimingCommand.execute: document is required");
+
+ // Get document clip to store original values
+ const docTrack = context.getDocumentTrack(this.trackIndex);
+ const docClip = docTrack?.clips[this.clipIndex];
+ if (!docClip) {
+ return CommandNoop(`Invalid clip at ${this.trackIndex}/${this.clipIndex}`);
+ }
+
+ const player = context.getClipAt(this.trackIndex, this.clipIndex);
+ if (!player) {
+ return CommandNoop(`Player not found at ${this.trackIndex}/${this.clipIndex}`);
+ }
+
+ // Store document-layer values for undo
+ this.originalStart = docClip.start;
+ this.originalLength = docClip.length;
+ this.clipId = player.clipId ?? undefined;
+
+ // Capture document clip BEFORE mutation (source of truth for SDK events)
+ const previousDocClip = structuredClone(docClip);
+
+ // Build document updates from params
+ const updates: Partial<{ start: Seconds | "auto"; length: Seconds | "auto" | "end" }> = {};
+
+ if (this.params.start !== undefined) {
+ updates.start = this.params.start;
+ }
+
+ if (this.params.length !== undefined) {
+ updates.length = this.params.length;
+ }
+
+ // Document-only mutation
+ doc.updateClip(this.trackIndex, this.clipIndex, updates);
+
+ // Single-clip resolution (O(1) instead of O(n) full resolve)
+ if (this.clipId) {
+ context.resolveClip(this.clipId);
+ } else {
+ context.resolve();
+ }
+
+ // Handle "auto" length async resolution
+ if (updates.length === "auto") {
+ context.resolveClipAutoLength(player).then(() => {
+ context.updateDuration();
+ context.propagateTimingChanges(this.trackIndex, this.clipIndex);
+ });
+ }
+
+ context.updateDuration();
+
+ // Get document clip AFTER mutation (source of truth for SDK events)
+ const currentDocClip = context.getDocumentClip(this.trackIndex, this.clipIndex);
+ if (!currentDocClip) throw new Error(`UpdateClipTimingCommand: document clip not found after mutation at ${this.trackIndex}/${this.clipIndex}`);
+
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { trackIndex: this.trackIndex, clipIndex: this.clipIndex, clip: stripInternalProperties(previousDocClip) },
+ current: { trackIndex: this.trackIndex, clipIndex: this.clipIndex, clip: stripInternalProperties(currentDocClip) }
+ });
+
+ context.propagateTimingChanges(this.trackIndex, this.clipIndex);
+
+ return CommandSuccess();
+ }
+
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("UpdateClipTimingCommand.undo: context is required");
+ if (this.originalStart === undefined && this.originalLength === undefined) {
+ throw new Error("UpdateClipTimingCommand.undo: no original values");
+ }
+
+ const doc = context.getDocument();
+ if (!doc) throw new Error("UpdateClipTimingCommand.undo: document is required");
+
+ const player = context.getClipAt(this.trackIndex, this.clipIndex);
+ if (!player) throw new Error("UpdateClipTimingCommand.undo: player not found");
+
+ // Capture document clip BEFORE undo mutation (source of truth for SDK events)
+ const currentDocClip = structuredClone(context.getDocumentClip(this.trackIndex, this.clipIndex));
+
+ // Document-only mutation - restore original timing
+ const updates: Partial<{ start: Clip["start"]; length: Clip["length"] }> = {};
+ if (this.originalStart !== undefined) updates.start = this.originalStart;
+ if (this.originalLength !== undefined) updates.length = this.originalLength;
+
+ doc.updateClip(this.trackIndex, this.clipIndex, updates);
+
+ // Single-clip resolution (O(1) instead of O(n) full resolve)
+ if (this.clipId) {
+ context.resolveClip(this.clipId);
+ } else {
+ context.resolve();
+ }
+
+ // Handle "auto" length async resolution
+ if (this.originalLength === "auto") {
+ context.resolveClipAutoLength(player).then(() => {
+ context.updateDuration();
+ context.propagateTimingChanges(this.trackIndex, this.clipIndex);
+ });
+ }
+
+ context.updateDuration();
+
+ // Get document clip AFTER undo mutation (source of truth for SDK events)
+ const restoredDocClip = context.getDocumentClip(this.trackIndex, this.clipIndex);
+ if (!currentDocClip || !restoredDocClip) {
+ throw new Error(`UpdateClipTimingCommand: document clip not found after undo at ${this.trackIndex}/${this.clipIndex}`);
+ }
+
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { trackIndex: this.trackIndex, clipIndex: this.clipIndex, clip: stripInternalProperties(currentDocClip) },
+ current: { trackIndex: this.trackIndex, clipIndex: this.clipIndex, clip: stripInternalProperties(restoredDocClip) }
+ });
+
+ context.propagateTimingChanges(this.trackIndex, this.clipIndex);
+
+ return CommandSuccess();
+ }
+}
diff --git a/src/core/commands/update-text-content-command.ts b/src/core/commands/update-text-content-command.ts
index 88ab5066..e16594db 100644
--- a/src/core/commands/update-text-content-command.ts
+++ b/src/core/commands/update-text-content-command.ts
@@ -1,72 +1,116 @@
-import type { Player } from "@canvas/players/player";
-import type { ClipSchema } from "@schemas/clip";
-import type { TextAsset } from "@schemas/text-asset";
-import type { z } from "zod";
+import { EditEvent } from "@core/events/edit-events";
+import { stripInternalProperties } from "@core/shared/clip-utils";
+import type { Clip, TextAsset } from "@schemas";
-import type { EditCommand, CommandContext } from "./types";
-
-type ClipType = z.infer;
+import { type EditCommand, type CommandContext, type CommandResult, CommandSuccess, CommandNoop } from "./types";
+/**
+ * Document-only command to update text content in a text clip.
+ */
export class UpdateTextContentCommand implements EditCommand {
- name = "updateTextContent";
- private previousText: string;
+ readonly name = "updateTextContent";
+
+ private clipId: string | null = null;
+ private previousText = "";
+ /** Document clip state before mutation (source of truth for SDK events) */
+ private previousDocClip?: Clip;
constructor(
- private clip: Player,
- private newText: string,
- private initialConfig: ClipType
- ) {
- const { asset } = this.clip.clipConfiguration;
- this.previousText = asset && "text" in asset ? (asset as TextAsset).text : "";
- }
+ private trackIndex: number,
+ private clipIndex: number,
+ private newText: string
+ ) {}
- execute(context?: CommandContext): void {
- if (!context) return;
- if (this.clip.clipConfiguration.asset && "text" in this.clip.clipConfiguration.asset) {
- (this.clip.clipConfiguration.asset as TextAsset).text = this.newText;
+ execute(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("UpdateTextContentCommand.execute: context is required");
- const textSprite = (this.clip as any).text;
- if (textSprite) {
- textSprite.text = this.newText;
- (this.clip as any).positionText(this.clip.clipConfiguration.asset as TextAsset);
- }
+ const doc = context.getDocument();
+ if (!doc) throw new Error("UpdateTextContentCommand.execute: document is required");
+
+ // Get current player for config and ID
+ const player = context.getClipAt(this.trackIndex, this.clipIndex);
+ if (!player) {
+ return CommandNoop(`Invalid clip at ${this.trackIndex}/${this.clipIndex}`);
+ }
- context.setUpdatedClip(this.clip);
+ // Get document clip BEFORE mutation (source of truth for SDK events)
+ const docClip = doc.getClip(this.trackIndex, this.clipIndex);
+ if (!docClip) return CommandNoop("Clip not found in document");
- const trackIndex = this.clip.layer - 1;
- const clips = context.getClips();
- const clipsByTrack = clips.filter((c: Player) => c.layer === this.clip.layer);
- const clipIndex = clipsByTrack.indexOf(this.clip);
+ // Store for undo
+ this.clipId = player.clipId;
+ this.previousDocClip = structuredClone(docClip);
+ const docAsset = docClip.asset as TextAsset;
+ this.previousText = docAsset && "text" in docAsset ? (docAsset.text ?? "") : "";
- context.emitEvent("clip:updated", {
- previous: { clip: this.initialConfig, trackIndex, clipIndex },
- current: { clip: this.clip.clipConfiguration, trackIndex, clipIndex }
- });
+ // Update document with new text
+ const currentAsset = docClip.asset as TextAsset;
+ const newAsset = { ...currentAsset, text: this.newText };
+ doc.updateClip(this.trackIndex, this.clipIndex, { asset: newAsset });
+
+ // Single-clip resolution (O(1) instead of O(n) full resolve)
+ if (this.clipId) {
+ context.resolveClip(this.clipId);
+ } else {
+ context.resolve();
}
+
+ // Get document clip AFTER mutation (source of truth for SDK events)
+ const currentDocClip = context.getDocumentClip(this.trackIndex, this.clipIndex);
+ if (!this.previousDocClip || !currentDocClip)
+ throw new Error(`UpdateTextContentCommand: document clip not found after mutation at ${this.trackIndex}/${this.clipIndex}`);
+
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { clip: stripInternalProperties(this.previousDocClip), trackIndex: this.trackIndex, clipIndex: this.clipIndex },
+ current: { clip: stripInternalProperties(currentDocClip), trackIndex: this.trackIndex, clipIndex: this.clipIndex }
+ });
+
+ return CommandSuccess();
}
- undo(context?: CommandContext): void {
- if (!context) return;
- if (this.clip.clipConfiguration.asset && "text" in this.clip.clipConfiguration.asset) {
- (this.clip.clipConfiguration.asset as TextAsset).text = this.previousText;
+ undo(context?: CommandContext): CommandResult {
+ if (!context) throw new Error("UpdateTextContentCommand.undo: context is required");
- const textSprite = (this.clip as any).text;
- if (textSprite) {
- textSprite.text = this.previousText;
- (this.clip as any).positionText(this.clip.clipConfiguration.asset as TextAsset);
- }
+ const doc = context.getDocument();
+ if (!doc) throw new Error("UpdateTextContentCommand.undo: document is required");
+
+ // Get document clip BEFORE undo mutation (source of truth for SDK events)
+ const currentDocClip = structuredClone(context.getDocumentClip(this.trackIndex, this.clipIndex));
+
+ // Get current clip from document
+ const clip = doc.getClip(this.trackIndex, this.clipIndex);
+ if (!clip) return CommandNoop("Clip not found for undo");
+
+ // Restore previous text in document
+ const currentAsset = clip.asset as TextAsset;
+ const restoredAsset = { ...currentAsset, text: this.previousText };
+ doc.updateClip(this.trackIndex, this.clipIndex, { asset: restoredAsset });
- context.setUpdatedClip(this.clip);
+ // Single-clip resolution (O(1) instead of O(n) full resolve)
+ if (this.clipId) {
+ context.resolveClip(this.clipId);
+ } else {
+ context.resolve();
+ }
- const trackIndex = this.clip.layer - 1;
- const clips = context.getClips();
- const clipsByTrack = clips.filter((c: Player) => c.layer === this.clip.layer);
- const clipIndex = clipsByTrack.indexOf(this.clip);
+ // Get document clip AFTER undo mutation (restored state)
+ const restoredDocClip = context.getDocumentClip(this.trackIndex, this.clipIndex);
- context.emitEvent("clip:updated", {
- previous: { clip: this.clip.clipConfiguration, trackIndex, clipIndex },
- current: { clip: this.initialConfig, trackIndex, clipIndex }
+ if (this.previousDocClip) {
+ if (!currentDocClip || !restoredDocClip) {
+ throw new Error(`UpdateTextContentCommand: document clip not found after undo at ${this.trackIndex}/${this.clipIndex}`);
+ }
+ context.emitEvent(EditEvent.ClipUpdated, {
+ previous: { clip: stripInternalProperties(currentDocClip), trackIndex: this.trackIndex, clipIndex: this.clipIndex },
+ current: { clip: stripInternalProperties(restoredDocClip), trackIndex: this.trackIndex, clipIndex: this.clipIndex }
});
}
+
+ return CommandSuccess();
+ }
+
+ dispose(): void {
+ this.clipId = null;
+ this.previousDocClip = undefined;
}
}
diff --git a/src/core/debug/state-assertions.ts b/src/core/debug/state-assertions.ts
new file mode 100644
index 00000000..a0f740f7
--- /dev/null
+++ b/src/core/debug/state-assertions.ts
@@ -0,0 +1,100 @@
+/**
+ * Debug assertions for state consistency verification.
+ * These run in development builds to catch invariant violations early.
+ *
+ * @internal
+ */
+
+import { resolveTimingIntent } from "@core/timing/resolver";
+import type { ResolutionContext, ResolvedTiming, TimingIntent, TimingValue } from "@core/timing/types";
+
+/**
+ * Check if we're in a development/debug build.
+ * Tree-shaken in production builds.
+ */
+// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle
+export const __DEV__ = process.env["NODE_ENV"] !== "production";
+
+/**
+ * Maximum acceptable difference between expected and actual timing values.
+ * Floating point arithmetic may introduce tiny errors.
+ */
+const TIMING_EPSILON = 0.001;
+
+/**
+ * Format a timing value for error messages.
+ */
+function formatTimingValue(value: TimingValue | "auto"): string {
+ if (typeof value === "string") return `"${value}"`;
+ return `${value}s`;
+}
+
+/**
+ * Assert that resolved timing matches what the pure resolution function would produce.
+ *
+ * INVARIANT: resolved = resolveTimingIntent(intent, context)
+ *
+ * Call this after any timing mutation to verify state consistency.
+ * Only runs in development builds.
+ *
+ * @param intent - The timing intent that was resolved
+ * @param actual - The actual resolved timing values on the player
+ * @param context - The resolution context used
+ * @throws Error if the invariant is violated (only in dev builds)
+ */
+export function assertTimingConsistency(intent: TimingIntent, actual: ResolvedTiming, context: Readonly): void {
+ if (!__DEV__) return;
+
+ const expected = resolveTimingIntent(intent, context);
+
+ const startDiff = Math.abs(expected.start - actual.start);
+ const lengthDiff = Math.abs(expected.length - actual.length);
+
+ if (startDiff > TIMING_EPSILON) {
+ throw new Error(
+ `INVARIANT VIOLATION: Start mismatch\n` +
+ ` Intent: ${formatTimingValue(intent.start)}\n` +
+ ` Context: previousClipEnd=${context.previousClipEnd}\n` +
+ ` Expected: ${expected.start}\n` +
+ ` Actual: ${actual.start}\n` +
+ ` Diff: ${startDiff}`
+ );
+ }
+
+ if (lengthDiff > TIMING_EPSILON) {
+ throw new Error(
+ `INVARIANT VIOLATION: Length mismatch\n` +
+ ` Intent: ${formatTimingValue(intent.length)}\n` +
+ ` Context: timelineEnd=${context.timelineEnd}, intrinsicDuration=${context.intrinsicDuration}\n` +
+ ` Expected: ${expected.length}\n` +
+ ` Actual: ${actual.length}\n` +
+ ` Diff: ${lengthDiff}`
+ );
+ }
+}
+
+/**
+ * Assert that a condition is true. Throws with the provided message if not.
+ * Only runs in development builds.
+ */
+export function assertDev(condition: boolean, message: string): asserts condition {
+ if (!__DEV__) return;
+
+ if (!condition) {
+ throw new Error(`ASSERTION FAILED: ${message}`);
+ }
+}
+
+/**
+ * Assert that a value is not null or undefined.
+ * Returns the value with narrowed type.
+ * Only throws in development builds; in production returns the value (may be undefined).
+ */
+export function assertExists(value: T | null | undefined, name: string): T {
+ if (!__DEV__) return value as T;
+
+ if (value === null || value === undefined) {
+ throw new Error(`ASSERTION FAILED: ${name} is ${value === null ? "null" : "undefined"}`);
+ }
+ return value;
+}
diff --git a/src/core/edit-document.ts b/src/core/edit-document.ts
new file mode 100644
index 00000000..733622f8
--- /dev/null
+++ b/src/core/edit-document.ts
@@ -0,0 +1,658 @@
+/**
+ * EditDocument - Pure data layer for Shotstack Edit JSON
+ *
+ * This class owns the raw Edit configuration with "auto", "end", and merge
+ * field placeholders preserved. It provides CRUD operations on the document
+ * structure.
+ *
+ * The document is the source of truth that serializes to the Shotstack Edit API.
+ */
+
+import type { Size } from "@layouts/geometry";
+
+import type { Clip, Track, Edit, Soundtrack } from "./schemas";
+import { setNestedValue } from "./shared/utils";
+
+// ─── Types ────────────────────────────────────────────────────────────────────
+
+type InternalClip = Clip & { id?: string };
+
+export interface MergeFieldBinding {
+ placeholder: string;
+ resolvedValue: string;
+}
+
+export interface ClipLookupResult {
+ clip: Clip;
+ trackIndex: number;
+ clipIndex: number;
+}
+
+// ─── EditDocument Class ───────────────────────────────────────────────────────
+
+export class EditDocument {
+ private data: Edit;
+
+ /**
+ * Merge field bindings
+ */
+ private clipBindings: Map> = new Map();
+
+ constructor(edit: Edit) {
+ this.data = structuredClone(edit);
+ this.hydrateIds();
+ }
+
+ /**
+ * Hydrate clips with unique IDs.
+ */
+ private hydrateIds(): void {
+ const seenClips = new Set();
+
+ for (const track of this.data.timeline.tracks) {
+ for (let i = 0; i < track.clips.length; i += 1) {
+ const clip = track.clips[i] as InternalClip;
+
+ if (seenClips.has(clip)) {
+ const cloned = structuredClone(clip) as InternalClip;
+ cloned.id = crypto.randomUUID();
+ track.clips[i] = cloned;
+ } else {
+ seenClips.add(clip);
+ if (!clip.id) {
+ clip.id = crypto.randomUUID();
+ }
+ }
+ }
+ }
+ }
+
+ // ─── Timeline Accessors ───────────────────────────────────────────────────
+
+ /**
+ * Get the raw timeline configuration
+ */
+ getTimeline(): Edit["timeline"] {
+ return this.data.timeline;
+ }
+
+ /**
+ * Get timeline background color
+ */
+ getBackground(): string | undefined {
+ return this.data.timeline.background;
+ }
+
+ /**
+ * Get all tracks
+ */
+ getTracks(): Track[] {
+ return this.data.timeline.tracks;
+ }
+
+ /**
+ * Get a specific track by index
+ */
+ getTrack(index: number): Track | null {
+ return this.data.timeline.tracks[index] ?? null;
+ }
+
+ /**
+ * Get total number of tracks
+ */
+ getTrackCount(): number {
+ return this.data.timeline.tracks.length;
+ }
+
+ /**
+ * Get soundtrack configuration
+ */
+ getSoundtrack(): Soundtrack | undefined {
+ return this.data.timeline.soundtrack;
+ }
+
+ // ─── Clip Accessors ───────────────────────────────────────────────────────
+
+ /**
+ * Get a specific clip by track and clip index
+ */
+ getClip(trackIndex: number, clipIndex: number): Clip | null {
+ const track = this.data.timeline.tracks[trackIndex];
+ if (!track) return null;
+ return track.clips[clipIndex] ?? null;
+ }
+
+ /**
+ * Get all clips in a track
+ */
+ getClipsInTrack(trackIndex: number): Clip[] {
+ const track = this.data.timeline.tracks[trackIndex];
+ return track?.clips ?? [];
+ }
+
+ /**
+ * Get total number of clips across all tracks
+ */
+ getClipCount(): number {
+ return this.data.timeline.tracks.reduce((sum, track) => sum + track.clips.length, 0);
+ }
+
+ /**
+ * Get clip count in a specific track
+ */
+ getClipCountInTrack(trackIndex: number): number {
+ const track = this.data.timeline.tracks[trackIndex];
+ return track?.clips.length ?? 0;
+ }
+
+ // ─── ID-Based Clip Accessors ─────────────────────────────────────────────
+
+ /**
+ * Get a clip by its stable ID
+ */
+ getClipById(clipId: string): ClipLookupResult | null {
+ for (let t = 0; t < this.data.timeline.tracks.length; t += 1) {
+ const clips = this.data.timeline.tracks[t].clips as InternalClip[];
+ for (let c = 0; c < clips.length; c += 1) {
+ if (clips[c].id === clipId) {
+ return { clip: clips[c], trackIndex: t, clipIndex: c };
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Update a clip by its stable ID (partial update)
+ */
+ updateClipById(clipId: string, updates: Partial): void {
+ const found = this.getClipById(clipId);
+ if (found) {
+ Object.assign(found.clip, updates);
+ }
+ }
+
+ /**
+ * Remove a clip by its stable ID
+ * @returns The removed clip, or null if not found
+ */
+ removeClipById(clipId: string): Clip | null {
+ const found = this.getClipById(clipId);
+ if (found) {
+ return this.removeClip(found.trackIndex, found.clipIndex);
+ }
+ return null;
+ }
+
+ /**
+ * Get the stable ID of a clip at a given position
+ */
+ getClipId(trackIndex: number, clipIndex: number): string | null {
+ const clip = this.getClip(trackIndex, clipIndex) as InternalClip | null;
+ return clip?.id ?? null;
+ }
+
+ // ─── Output Accessors ─────────────────────────────────────────────────────
+
+ /**
+ * Get the raw output configuration
+ */
+ getOutput(): Edit["output"] {
+ return this.data.output;
+ }
+
+ /**
+ * Get output size (width/height)
+ * @throws Error if size is not defined
+ */
+ getSize(): Size {
+ const { size } = this.data.output;
+ if (!size?.width || !size?.height) {
+ throw new Error("Output size is not defined");
+ }
+ return { width: size.width, height: size.height };
+ }
+
+ /**
+ * Get output format (mp4, gif, etc.)
+ */
+ getFormat(): Edit["output"]["format"] {
+ return this.data.output.format;
+ }
+
+ /**
+ * Get output FPS
+ */
+ getFps(): number | undefined {
+ return this.data.output.fps;
+ }
+
+ /**
+ * Get output resolution preset
+ */
+ getResolution(): Edit["output"]["resolution"] {
+ return this.data.output.resolution;
+ }
+
+ /**
+ * Get output aspect ratio
+ */
+ getAspectRatio(): Edit["output"]["aspectRatio"] {
+ return this.data.output.aspectRatio;
+ }
+
+ // ─── Merge Fields ─────────────────────────────────────────────────────────
+
+ /**
+ * Get merge field definitions
+ */
+ getMergeFields(): Edit["merge"] {
+ return this.data.merge;
+ }
+
+ // ─── Track Mutations ──────────────────────────────────────────────────────
+
+ /**
+ * Add a new track at the specified index
+ * @returns The added track
+ */
+ addTrack(index: number, track?: Track): Track {
+ const newTrack: Track = track ?? { clips: [] };
+ this.data.timeline.tracks.splice(index, 0, newTrack);
+ return newTrack;
+ }
+
+ /**
+ * Remove a track at the specified index
+ * @returns The removed track, or null if index invalid or would leave 0 tracks
+ */
+ removeTrack(index: number): Track | null {
+ if (index < 0 || index >= this.data.timeline.tracks.length) {
+ return null;
+ }
+ if (this.data.timeline.tracks.length <= 1) {
+ console.warn("Cannot remove the last track");
+ return null;
+ }
+ const [removed] = this.data.timeline.tracks.splice(index, 1);
+ return removed ?? null;
+ }
+
+ // ─── Clip Mutations ───────────────────────────────────────────────────────
+
+ /**
+ * Add a clip to a track
+ * @returns The added clip (with hydrated ID)
+ */
+ addClip(trackIndex: number, clip: Clip, clipIndex?: number): Clip {
+ const track = this.data.timeline.tracks[trackIndex];
+ if (!track) {
+ throw new Error(`Track ${trackIndex} does not exist`);
+ }
+
+ // Hydrate with stable ID if not present
+ const internalClip = clip as InternalClip;
+ if (!internalClip.id) {
+ internalClip.id = crypto.randomUUID();
+ }
+
+ const insertIndex = clipIndex ?? track.clips.length;
+ track.clips.splice(insertIndex, 0, clip);
+ return clip;
+ }
+
+ /**
+ * Remove a clip from a track
+ * @returns The removed clip, or null if indices invalid
+ */
+ removeClip(trackIndex: number, clipIndex: number): Clip | null {
+ const track = this.data.timeline.tracks[trackIndex];
+ if (!track || clipIndex < 0 || clipIndex >= track.clips.length) {
+ return null;
+ }
+ const [removed] = track.clips.splice(clipIndex, 1);
+ return removed ?? null;
+ }
+
+ /**
+ * Update a clip's properties (partial update)
+ */
+ updateClip(trackIndex: number, clipIndex: number, updates: Partial): void {
+ const clip = this.getClip(trackIndex, clipIndex);
+ if (!clip) {
+ throw new Error(`Clip at track ${trackIndex}, index ${clipIndex} does not exist`);
+ }
+ Object.assign(clip, updates);
+ }
+
+ /**
+ * Replace all properties on a clip while preserving its internal ID.
+ * Unlike updateClip (which merges via Object.assign), this deletes properties
+ * that exist on the current clip but not in the new state — ensuring undo
+ * correctly removes properties that were added during a drag.
+ */
+ replaceClipProperties(trackIndex: number, clipIndex: number, newProperties: Partial): void {
+ const clip = this.getClip(trackIndex, clipIndex) as (Clip & { id?: string }) | null;
+ if (!clip) {
+ throw new Error(`Clip at track ${trackIndex}, index ${clipIndex} does not exist`);
+ }
+
+ const { id } = clip;
+
+ // Delete all own properties, then assign new ones
+ for (const key of Object.keys(clip)) {
+ if (key !== "id") {
+ delete (clip as Record)[key];
+ }
+ }
+ Object.assign(clip, newProperties);
+
+ // Restore internal ID (in case newProperties contained id or didn't)
+ if (id) {
+ clip.id = id;
+ }
+ }
+
+ /**
+ * Replace a clip entirely
+ */
+ replaceClip(trackIndex: number, clipIndex: number, newClip: Clip): Clip | null {
+ const track = this.data.timeline.tracks[trackIndex];
+ if (!track || clipIndex < 0 || clipIndex >= track.clips.length) {
+ return null;
+ }
+ const oldClip = track.clips[clipIndex];
+ track.clips[clipIndex] = newClip;
+ return oldClip;
+ }
+
+ /**
+ * Move a clip to a different track and/or position, preserving its ID.
+ * @returns The moved clip, or null if source clip not found
+ */
+ moveClip(fromTrackIndex: number, fromClipIndex: number, toTrackIndex: number, updates?: Partial): Clip | null {
+ // Get the source track and clip
+ const fromTrack = this.data.timeline.tracks[fromTrackIndex];
+ if (!fromTrack || fromClipIndex < 0 || fromClipIndex >= fromTrack.clips.length) {
+ return null;
+ }
+
+ // Get destination track (create if needed)
+ const toTrack = this.data.timeline.tracks[toTrackIndex];
+ if (!toTrack) {
+ return null;
+ }
+
+ // Remove clip from source (preserves the clip object with its ID)
+ const [clip] = fromTrack.clips.splice(fromClipIndex, 1);
+ if (!clip) return null;
+
+ // Apply updates (e.g., new start time)
+ if (updates) {
+ Object.assign(clip, updates);
+ }
+
+ // Find insertion point based on start time
+ const clipStart = typeof clip.start === "number" ? clip.start : 0;
+ let insertIndex = 0;
+ for (let i = 0; i < toTrack.clips.length; i += 1) {
+ const existingClipStart = toTrack.clips[i].start;
+ const existingStart = typeof existingClipStart === "number" ? existingClipStart : 0;
+ if (clipStart < existingStart) {
+ break;
+ }
+ insertIndex += 1;
+ }
+
+ // Insert at the correct position
+ toTrack.clips.splice(insertIndex, 0, clip);
+
+ return clip;
+ }
+
+ // ─── Timeline Mutations ───────────────────────────────────────────────────
+
+ /**
+ * Set timeline background color
+ */
+ setBackground(color: string): void {
+ this.data.timeline.background = color;
+ }
+
+ /**
+ * Set soundtrack
+ */
+ setSoundtrack(soundtrack: Soundtrack | undefined): void {
+ this.data.timeline.soundtrack = soundtrack;
+ }
+
+ // ─── Font Mutations ──────────────────────────────────────────────────────
+
+ /**
+ * Get timeline fonts
+ */
+ getFonts(): Array<{ src: string }> {
+ return this.data.timeline.fonts ?? [];
+ }
+
+ /**
+ * Add a font to the timeline (if not already present)
+ */
+ addFont(src: string): void {
+ if (!this.data.timeline.fonts) {
+ this.data.timeline.fonts = [];
+ }
+ if (!this.data.timeline.fonts.some(f => f.src === src)) {
+ this.data.timeline.fonts.push({ src });
+ }
+ }
+
+ /**
+ * Remove a font from the timeline
+ */
+ removeFont(src: string): void {
+ if (this.data.timeline.fonts) {
+ this.data.timeline.fonts = this.data.timeline.fonts.filter(f => f.src !== src);
+ }
+ }
+
+ /**
+ * Set all timeline fonts (replaces existing)
+ */
+ setFonts(fonts: Array<{ src: string }>): void {
+ this.data.timeline.fonts = fonts;
+ }
+
+ // ─── Output Mutations ─────────────────────────────────────────────────────
+
+ /**
+ * Set output size
+ */
+ setSize(size: Size): void {
+ this.data.output.size = { width: size.width, height: size.height };
+ }
+
+ /**
+ * Set output format
+ */
+ setFormat(format: Edit["output"]["format"]): void {
+ this.data.output.format = format;
+ }
+
+ /**
+ * Set output FPS (must be a valid FPS value)
+ */
+ setFps(fps: Edit["output"]["fps"]): void {
+ this.data.output.fps = fps;
+ }
+
+ /**
+ * Set output resolution preset
+ */
+ setResolution(resolution: Edit["output"]["resolution"]): void {
+ this.data.output.resolution = resolution;
+ }
+
+ /**
+ * Clear output resolution preset
+ */
+ clearResolution(): void {
+ delete this.data.output.resolution;
+ }
+
+ /**
+ * Set output aspect ratio
+ */
+ setAspectRatio(aspectRatio: Edit["output"]["aspectRatio"]): void {
+ this.data.output.aspectRatio = aspectRatio;
+ }
+
+ /**
+ * Clear output aspect ratio
+ */
+ clearAspectRatio(): void {
+ delete this.data.output.aspectRatio;
+ }
+
+ /**
+ * Clear output size (for use when setting resolution/aspectRatio)
+ */
+ clearSize(): void {
+ delete this.data.output.size;
+ }
+
+ // ─── Merge Field Mutations ────────────────────────────────────────────────
+
+ /**
+ * Set merge field definitions
+ */
+ setMergeFields(mergeFields: Edit["merge"]): void {
+ this.data.merge = mergeFields;
+ }
+
+ // ─── Clip Binding Management ─────────────────────────────────────────────
+
+ /**
+ * Set a merge field binding for a clip property.
+ * @param clipId - The stable clip ID
+ * @param path - Property path (e.g., "asset.src")
+ * @param binding - The placeholder and resolved value
+ */
+ setClipBinding(clipId: string, path: string, binding: MergeFieldBinding): void {
+ let clipBindingsMap = this.clipBindings.get(clipId);
+ if (!clipBindingsMap) {
+ clipBindingsMap = new Map();
+ this.clipBindings.set(clipId, clipBindingsMap);
+ }
+ clipBindingsMap.set(path, binding);
+ }
+
+ /**
+ * Get a merge field binding for a clip property.
+ * @param clipId - The stable clip ID
+ * @param path - Property path (e.g., "asset.src")
+ * @returns The binding, or undefined if not set
+ */
+ getClipBinding(clipId: string, path: string): MergeFieldBinding | undefined {
+ return this.clipBindings.get(clipId)?.get(path);
+ }
+
+ /**
+ * Remove a merge field binding for a clip property.
+ * @param clipId - The stable clip ID
+ * @param path - Property path (e.g., "asset.src")
+ */
+ removeClipBinding(clipId: string, path: string): void {
+ const clipBindingsMap = this.clipBindings.get(clipId);
+ if (clipBindingsMap) {
+ clipBindingsMap.delete(path);
+ // Clean up empty maps
+ if (clipBindingsMap.size === 0) {
+ this.clipBindings.delete(clipId);
+ }
+ }
+ }
+
+ /**
+ * Get all bindings for a clip.
+ * @param clipId - The stable clip ID
+ * @returns Map of path → binding, or undefined if clip has no bindings
+ */
+ getClipBindings(clipId: string): Map | undefined {
+ return this.clipBindings.get(clipId);
+ }
+
+ /**
+ * Set all bindings for a clip (replaces existing).
+ * @param clipId - The stable clip ID
+ * @param bindings - Map of path → binding
+ */
+ setClipBindingsForClip(clipId: string, bindings: Map): void {
+ if (bindings.size === 0) {
+ this.clipBindings.delete(clipId);
+ } else {
+ this.clipBindings.set(clipId, new Map(bindings));
+ }
+ }
+
+ /**
+ * Clear all bindings for a clip.
+ * @param clipId - The stable clip ID
+ */
+ clearClipBindings(clipId: string): void {
+ this.clipBindings.delete(clipId);
+ }
+
+ /**
+ * Get all clip IDs that have bindings.
+ * @returns Array of clip IDs
+ */
+ getClipIdsWithBindings(): string[] {
+ return Array.from(this.clipBindings.keys());
+ }
+
+ // ─── Serialization ────────────────────────────────────────────────────────
+
+ /**
+ * Export the document as raw Edit JSON (preserves "auto", "end", merge fields, aliases)
+ */
+ toJSON(): Edit {
+ const result = structuredClone(this.data);
+
+ // Restore placeholders from document bindings before stripping IDs
+ for (const track of result.timeline.tracks) {
+ for (const clip of track.clips) {
+ const clipId = (clip as InternalClip).id;
+ if (clipId) {
+ const bindings = this.clipBindings.get(clipId);
+ if (bindings) {
+ for (const [path, { placeholder }] of bindings) {
+ setNestedValue(clip, path, placeholder);
+ }
+ }
+ }
+ // Strip internal ID (not part of Shotstack API)
+ delete (clip as InternalClip).id;
+ }
+ }
+
+ if (result.merge?.length === 0) {
+ delete result.merge;
+ }
+ return result;
+ }
+
+ /**
+ * Create an EditDocument from raw Edit JSON
+ */
+ static fromJSON(json: Edit): EditDocument {
+ return new EditDocument(json);
+ }
+
+ /**
+ * Create a deep clone of this document
+ */
+ clone(): EditDocument {
+ return new EditDocument(this.data);
+ }
+}
diff --git a/src/core/edit-session.ts b/src/core/edit-session.ts
new file mode 100644
index 00000000..763c368b
--- /dev/null
+++ b/src/core/edit-session.ts
@@ -0,0 +1,2128 @@
+import { type Player, PlayerType } from "@canvas/players/player";
+import { PlayerFactory } from "@canvas/players/player-factory";
+import type { Canvas } from "@canvas/shotstack-canvas";
+// TODO: Consolidate commands - many have overlapping concerns and could be unified
+import { AddClipCommand } from "@core/commands/add-clip-command";
+import { AddTrackCommand } from "@core/commands/add-track-command";
+import { DeleteClipCommand } from "@core/commands/delete-clip-command";
+import { DeleteTrackCommand } from "@core/commands/delete-track-command";
+import { SetOutputAspectRatioCommand } from "@core/commands/set-output-aspect-ratio-command";
+import { SetOutputDestinationsCommand } from "@core/commands/set-output-destinations-command";
+import { SetOutputFormatCommand } from "@core/commands/set-output-format-command";
+import { SetOutputFpsCommand } from "@core/commands/set-output-fps-command";
+import { SetOutputResolutionCommand } from "@core/commands/set-output-resolution-command";
+import { SetOutputSizeCommand } from "@core/commands/set-output-size-command";
+import { SetTimelineBackgroundCommand } from "@core/commands/set-timeline-background-command";
+import { SetUpdatedClipCommand } from "@core/commands/set-updated-clip-command";
+import { type TimingUpdateParams, UpdateClipTimingCommand } from "@core/commands/update-clip-timing-command";
+import { UpdateTextContentCommand } from "@core/commands/update-text-content-command";
+import type { MergeFieldBinding } from "@core/edit-document";
+import { EditEvent, InternalEvent, type EditEventMap, type InternalEventMap } from "@core/events/edit-events";
+import { EventEmitter, type ReadonlyEventEmitter } from "@core/events/event-emitter";
+import { parseFontFamily } from "@core/fonts/font-config";
+import { LumaMaskController } from "@core/luma-mask-controller";
+import { MergeFieldService, type SerializedMergeField } from "@core/merge";
+import { calculateSizeFromPreset, OutputSettingsManager } from "@core/output-settings-manager";
+import { SelectionManager } from "@core/selection-manager";
+import { deepMerge, setNestedValue } from "@core/shared/utils";
+import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart } from "@core/timing/resolver";
+import { type Milliseconds, type ResolutionContext, type Seconds, sec, toSec, isAliasReference } from "@core/timing/types";
+import { TimingManager } from "@core/timing-manager";
+import type { Size } from "@layouts/geometry";
+import { AssetLoader } from "@loaders/asset-loader";
+import { FontLoadParser } from "@loaders/font-load-parser";
+import {
+ ClipSchema,
+ EditSchema,
+ HexColorSchema,
+ ResolvedClipSchema,
+ TrackSchema,
+ type Clip,
+ type Destination,
+ type Edit as EditConfig,
+ type ResolvedClip,
+ type ResolvedEdit,
+ type Soundtrack,
+ type Track
+} from "@schemas";
+import { calculateOverlap } from "@timeline/interaction/interaction-calculations";
+import * as pixi from "pixi.js";
+
+import { CommandQueue } from "./commands/command-queue";
+import type { EditCommand, CommandContext, CommandResult } from "./commands/types";
+import { EditDocument } from "./edit-document";
+import { PlayerReconciler } from "./player-reconciler";
+import { resolve as resolveDocument, resolveClip as resolveClipById, type SingleClipContext } from "./resolver";
+
+/** Internal type for clips with hydrated IDs during edit updates */
+type ClipWithId = Clip & { id?: string };
+
+// ─── Edit Session Class ───────────────────────────────────────────────────────
+
+export class Edit {
+ // ─── Constants ────────────────────────────────────────────────────────────
+ private static readonly MAX_HISTORY_SIZE = 100;
+ /** @internal */
+ public static readonly SEEK_ELAPSED_MARKER = 101 as Milliseconds;
+
+ // ─── Core Configuration ───────────────────────────────────────────────────
+ private document: EditDocument;
+ /** @internal */
+ public size: Size;
+ private backgroundColor: string;
+
+ // ─── Primary State ────────────────────────────────────────────────────────
+ private tracks: Player[][];
+ public playbackTime: number;
+ public totalDuration: number;
+ public isPlaying: boolean;
+
+ // ─── Derived State ────────────────────────────────────────────────────────
+ private get clips(): Player[] {
+ return this.tracks.flat();
+ }
+
+ // ─── Services ─────────────────────────────────────────────────────────────
+ /** @internal */
+ public assetLoader: AssetLoader;
+ private internalEvents: EventEmitter;
+ public events: ReadonlyEventEmitter;
+ private canvas: Canvas | null = null;
+
+ // ─── Subsystems ──────────────────────────────────────────────────────────-
+ private timingManager!: TimingManager;
+ private lumaMaskController: LumaMaskController;
+ private playerReconciler: PlayerReconciler;
+ private outputSettings!: OutputSettingsManager;
+ private selectionManager!: SelectionManager;
+ /** @internal */
+ protected mergeFieldService: MergeFieldService;
+
+ // ─── Command History ──────────────────────────────────────────────────────
+ private commandHistory: EditCommand[] = [];
+ private commandIndex: number = -1;
+ private commandQueue = new CommandQueue();
+
+ // ─── Internal Bookkeeping ─────────────────────────────────────────────────
+ private clipsToDispose = new Set();
+ private clipErrors = new Map();
+ private playerByClipId = new Map();
+ private lumaContentRelations = new Map();
+ private fontMetadata = new Map();
+ private isBatchingEvents: boolean = false;
+ private isExporting: boolean = false;
+ private lastResolved: ResolvedEdit | null = null;
+
+ /**
+ * Create an Edit instance from a template configuration.
+ */
+ constructor(template: EditConfig) {
+ // Validate template eagerly so invalid configs fail at construction time
+ EditSchema.parse(template);
+
+ this.tracks = [];
+ this.playbackTime = sec(0);
+ this.totalDuration = sec(0);
+ this.isPlaying = false;
+
+ this.document = new EditDocument(template);
+
+ const resolution = this.document.getResolution();
+ const aspectRatio = this.document.getAspectRatio();
+ this.backgroundColor = this.document.getBackground() ?? "#000000";
+ this.size = resolution ? calculateSizeFromPreset(resolution, aspectRatio) : this.document.getSize();
+
+ this.assetLoader = new AssetLoader();
+ this.internalEvents = new EventEmitter();
+ this.events = this.internalEvents;
+
+ this.lumaMaskController = new LumaMaskController(
+ () => this.canvas,
+ () => this.tracks,
+ this.internalEvents
+ );
+ this.playerReconciler = new PlayerReconciler(this);
+ this.mergeFieldService = new MergeFieldService(this.internalEvents);
+ this.outputSettings = new OutputSettingsManager(this);
+ this.selectionManager = new SelectionManager(this);
+ this.timingManager = new TimingManager(this);
+
+ this.setupIntentListeners();
+ }
+
+ /**
+ * Load the edit session.
+ */
+ public async load(): Promise {
+ await this.initializeFromDocument();
+ }
+
+ /**
+ * Initialize runtime from the document.
+ */
+ private async initializeFromDocument(source: string = "load"): Promise {
+ const rawEdit = this.document.toJSON();
+
+ // 1. Load merge fields into service
+ const serializedMergeFields = rawEdit.merge ?? [];
+ this.mergeFieldService.loadFromSerialized(serializedMergeFields);
+
+ // 2. Invalidate resolved cache and detect merge field bindings
+ this.lastResolved = null;
+ const bindingsPerClip = this.detectMergeFieldBindings(serializedMergeFields);
+
+ // 3. Parse raw edit
+ const parsedEdit = EditSchema.parse(rawEdit) as EditConfig;
+
+ // 4. Load fonts
+ await Promise.all(
+ (parsedEdit.timeline.fonts ?? []).map(async font => {
+ const identifier = font.src;
+ const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: FontLoadParser.Name };
+
+ const fontFace = await this.assetLoader.load(identifier, loadOptions);
+
+ // Store normalized base family + weight (TTF might report "Lato Light" or "Lato")
+ // CSS FontFace.family wraps multi-word names in quotes — strip them
+ if (fontFace?.family) {
+ const family = fontFace.family.replace(/^["']|["']$/g, "");
+ const { baseFontFamily, fontWeight } = parseFontFamily(family);
+ this.fontMetadata.set(identifier, { baseFamilyName: baseFontFamily, weight: fontWeight });
+ }
+
+ return fontFace;
+ })
+ );
+
+ // 5. Resolve the document
+ const resolvedEdit = this.getResolvedEdit();
+
+ // 6. Initialize luma mask controller
+ this.lumaMaskController.initialize();
+
+ // 7. Create players
+ await this.playerReconciler.reconcileInitial(resolvedEdit);
+
+ // 7.5 Establish luma→content relationships before timeline renders
+ this.normalizeLumaAttachments();
+
+ // 8. Set up clip bindings for merge field tracking
+ for (const [clipId, bindings] of bindingsPerClip) {
+ if (bindings.size > 0) {
+ this.document.setClipBindingsForClip(clipId, bindings);
+ }
+ }
+
+ // 9. Resolve async timing (auto-length for videos, etc.)
+ await this.timingManager.resolveAllTiming();
+
+ // 10. Update total duration
+ this.updateTotalDuration();
+
+ // 11. Load soundtrack if present
+ if (parsedEdit.timeline.soundtrack) {
+ await this.loadSoundtrack(parsedEdit.timeline.soundtrack);
+ }
+
+ this.internalEvents.emit(EditEvent.TimelineUpdated, { current: this.getEdit() });
+ this.emitEditChanged(source);
+ }
+
+ /** @internal */
+ public getInternalEvents(): EventEmitter {
+ return this.internalEvents;
+ }
+
+ /** @internal */
+ public update(deltaTime: number, elapsed: Milliseconds): void {
+ for (const clip of this.clips) {
+ if (clip.shouldDispose) this.queueDisposeClip(clip);
+ clip.update(deltaTime, elapsed);
+ }
+
+ this.disposeClips();
+
+ this.lumaMaskController.update();
+
+ if (this.isPlaying) {
+ this.playbackTime = sec(Math.max(0, Math.min(this.playbackTime + toSec(elapsed), this.totalDuration)));
+ if (this.playbackTime === this.totalDuration) this.pause();
+ }
+ }
+
+ /** @internal */
+ public dispose(): void {
+ this.clearClips();
+ this.lumaMaskController.dispose();
+ this.playerReconciler.dispose();
+
+ for (const cmd of this.commandHistory) cmd.dispose?.();
+ this.commandHistory = [];
+ this.commandIndex = -1;
+
+ this.lumaContentRelations.clear();
+
+ PlayerFactory.cleanup();
+ }
+
+ /* @internal Update canvas visuals after size change (viewport mask, background, zoom). */
+ public updateCanvasForSize(): void {
+ this.internalEvents.emit(InternalEvent.ViewportSizeChanged, {
+ width: this.size.width,
+ height: this.size.height,
+ backgroundColor: this.backgroundColor
+ });
+ this.internalEvents.emit(InternalEvent.ViewportNeedsZoomToFit);
+ }
+
+ public play(): void {
+ this.isPlaying = true;
+ this.internalEvents.emit(EditEvent.PlaybackPlay);
+ }
+
+ public pause(): void {
+ this.isPlaying = false;
+ this.internalEvents.emit(EditEvent.PlaybackPause);
+ }
+
+ public seek(target: number): void {
+ this.playbackTime = sec(Math.max(0, Math.min(target, this.totalDuration)));
+ this.pause();
+ this.update(0, Edit.SEEK_ELAPSED_MARKER);
+ }
+
+ public stop(): void {
+ this.seek(sec(0));
+ }
+
+ /**
+ * Reload the edit with a new configuration (hot-reload).
+ */
+ public async loadEdit(edit: EditConfig): Promise {
+ // Validate the incoming config before any mutations
+ EditSchema.parse(edit);
+
+ if (this.tracks.length > 0 && !this.hasStructuralChanges(edit)) {
+ this.lastResolved = null;
+
+ // Clone so preserveClipIdsForGranularUpdate doesn't mutate the caller's object
+ const cloned = structuredClone(edit);
+ this.preserveClipIdsForGranularUpdate(cloned);
+
+ const oldTracks = this.document.getTracks();
+ const oldOutput = this.document.getOutput();
+
+ this.document = new EditDocument(cloned);
+ this.isBatchingEvents = true;
+ await this.applyGranularChanges(cloned, oldTracks, oldOutput);
+ this.isBatchingEvents = false;
+ this.emitEditChanged("loadEdit:granular");
+ return;
+ }
+
+ // Save state for rollback — if initialization fails after mutation,
+ // we restore so the session remains usable for a retry
+ const prevDocument = this.document;
+ const prevLastResolved = this.lastResolved;
+ const prevSize = this.size;
+ const prevBackgroundColor = this.backgroundColor;
+
+ try {
+ this.lastResolved = null;
+ this.document = new EditDocument(edit);
+
+ const resolution = this.document.getResolution();
+ const aspectRatio = this.document.getAspectRatio();
+ this.size = resolution ? calculateSizeFromPreset(resolution, aspectRatio) : this.document.getSize();
+ this.backgroundColor = this.document.getBackground() ?? "#000000";
+
+ this.internalEvents.emit(InternalEvent.ViewportSizeChanged, {
+ width: this.size.width,
+ height: this.size.height,
+ backgroundColor: this.backgroundColor
+ });
+ this.internalEvents.emit(InternalEvent.ViewportNeedsZoomToFit);
+ this.clearClips();
+
+ await this.initializeFromDocument("loadEdit");
+ } catch (error) {
+ this.document = prevDocument;
+ this.lastResolved = prevLastResolved;
+ this.size = prevSize;
+ this.backgroundColor = prevBackgroundColor;
+ throw error;
+ }
+ }
+
+ private async loadSoundtrack(soundtrack: Soundtrack): Promise {
+ const clip: ResolvedClip = {
+ id: crypto.randomUUID(),
+ asset: {
+ type: "audio",
+ src: soundtrack.src,
+ effect: soundtrack.effect,
+ volume: soundtrack.volume ?? 1
+ },
+ fit: "crop",
+ start: sec(0),
+ length: sec(this.totalDuration)
+ };
+
+ const player = this.createPlayerFromAssetType(clip);
+ player.layer = this.tracks.length + 1;
+ await this.addPlayer(this.tracks.length, player);
+ }
+ public getEdit(): EditConfig {
+ const doc = this.document.toJSON();
+ const mergeFields = this.mergeFieldService.toSerializedArray();
+ if (mergeFields.length > 0) doc.merge = mergeFields;
+ return doc;
+ }
+
+ /**
+ * Validates an edit configuration.
+ * @internal
+ */
+ public validateEdit(edit: unknown): { valid: boolean; errors: Array<{ path: string; message: string }> } {
+ const result = EditSchema.safeParse(edit);
+ if (result.success) return { valid: true, errors: [] };
+ return {
+ valid: false,
+ errors: result.error.issues.map(issue => ({
+ path: issue.path.join("."),
+ message: issue.message
+ }))
+ };
+ }
+
+ /**
+ * @internal Get the resolved edit state.
+ */
+ public getResolvedEdit(): ResolvedEdit {
+ if (!this.lastResolved) {
+ this.lastResolved = resolveDocument(this.document, {
+ mergeFields: this.mergeFieldService
+ });
+ }
+ return this.lastResolved;
+ }
+
+ /**
+ * Get a specific clip from the resolved edit.
+ * @internal
+ */
+ public getResolvedClip(trackIdx: number, clipIdx: number): ResolvedClip | null {
+ const resolved = this.getResolvedEdit();
+ return resolved?.timeline?.tracks?.[trackIdx]?.clips?.[clipIdx] ?? null;
+ }
+
+ /**
+ * Get a resolved clip by its stable ID.
+ * @internal
+ */
+ public getResolvedClipById(clipId: string): ResolvedClip | null {
+ const resolved = this.getResolvedEdit();
+ for (const track of resolved.timeline.tracks) {
+ for (const clip of track.clips) {
+ if (clip.id === clipId) return clip;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get the stable clip ID for a clip at a given position.
+ * @internal
+ */
+ public getClipId(trackIdx: number, clipIdx: number): string | null {
+ return this.document?.getClipId(trackIdx, clipIdx) ?? null;
+ }
+
+ /**
+ * Get the raw document clip at a given position.
+ * @internal
+ */
+ public getDocumentClip(trackIdx: number, clipIdx: number): Clip | null {
+ return this.document?.getClip(trackIdx, clipIdx) ?? null;
+ }
+
+ /**
+ * Get the pure document layer.
+ * @internal
+ */
+ public getDocument(): EditDocument | null {
+ return this.document;
+ }
+
+ /** @internal Resolve the document to a ResolvedEdit and emit the Resolved event.
+ */
+ public resolve(): ResolvedEdit {
+ this.lastResolved = resolveDocument(this.document, {
+ mergeFields: this.mergeFieldService
+ });
+
+ // Emit event for components to react
+ this.internalEvents.emit(InternalEvent.Resolved, { edit: this.lastResolved });
+
+ return this.lastResolved;
+ }
+
+ /** @internal Resolve a single clip and update its player. */
+ public resolveClip(clipId: string): boolean {
+ const player = this.getPlayerByClipId(clipId);
+ if (!player) {
+ return false;
+ }
+
+ // Check if this clip has an alias that others might depend on
+ // If so, fall back to full resolution to update all alias dependents
+ const docClip = this.document.getClipById(clipId);
+ if (docClip?.clip.alias) {
+ this.resolve();
+ return true;
+ }
+
+ // Check if this clip references an alias (in start or length)
+ // Single-clip resolution doesn't have the resolved alias values map,
+ // so we need to fall back to full resolution for alias references
+ const clip = docClip?.clip;
+ if (clip && (isAliasReference(clip.start) || isAliasReference(clip.length))) {
+ this.resolve();
+ return true;
+ }
+
+ const trackIndex = player.layer - 1;
+ const track = this.tracks[trackIndex];
+ const clipIndex = track ? track.indexOf(player) : -1;
+
+ if (clipIndex < 0) {
+ return false;
+ }
+
+ // Get previous clip's end time (for "auto" start resolution)
+ const previousPlayer = clipIndex > 0 ? track[clipIndex - 1] : null;
+ const previousClipEnd = previousPlayer ? previousPlayer.getEnd() : sec(0);
+
+ // Build single-clip context
+ const context: SingleClipContext = {
+ mergeFields: this.mergeFieldService,
+ previousClipEnd,
+ cachedTimelineEnd: this.timingManager.getTimelineEnd()
+ };
+
+ // Resolve just this one clip
+ const result = resolveClipById(this.document, clipId, context);
+ if (!result) {
+ return false;
+ }
+
+ // Update lastResolved cache with the newly resolved clip (keeps cache in sync)
+ if (this.lastResolved) {
+ const cachedTrack = this.lastResolved.timeline.tracks[result.trackIndex];
+ if (cachedTrack && cachedTrack.clips[result.clipIndex]) {
+ cachedTrack.clips[result.clipIndex] = result.resolved;
+ }
+ }
+
+ // Update the player via the reconciler's single-player update
+ const updated = this.playerReconciler.updateSinglePlayer(player, result.resolved, result.trackIndex, result.clipIndex);
+
+ return updated !== false;
+ }
+
+ public addClip(trackIdx: number, clip: Clip): void | Promise {
+ ClipSchema.parse(clip);
+ // Cast to ResolvedClip - the Player and timing resolver handle "auto"/"end" at runtime
+ const command = new AddClipCommand(trackIdx, clip as unknown as ResolvedClip);
+ return this.executeCommand(command);
+ }
+
+ public getClip(trackIdx: number, clipIdx: number): Clip | null {
+ // Return from Player array for position-based ordering (matches Player behavior)
+ // Cast to Clip since clipConfiguration is ResolvedClip internally but compatible at runtime
+ const track = this.tracks[trackIdx];
+ if (!track || clipIdx < 0 || clipIdx >= track.length) return null;
+ return track[clipIdx].clipConfiguration as unknown as Clip;
+ }
+
+ /**
+ * Get the error state for a clip that failed to load.
+ * @internal
+ */
+ public getClipError(trackIdx: number, clipIdx: number): { error: string; assetType: string } | null {
+ return this.clipErrors.get(`${trackIdx}-${clipIdx}`) ?? null;
+ }
+
+ /**
+ * Clear the error for a deleted clip and shift indices for remaining errors.
+ */
+ private clearClipErrorAndShift(trackIdx: number, clipIdx: number): void {
+ // Remove the error for the deleted clip
+ this.clipErrors.delete(`${trackIdx}-${clipIdx}`);
+
+ // Shift errors for clips after the deleted one (their indices decrease by 1)
+ const keysToUpdate: Array<{ oldKey: string; newKey: string; value: { error: string; assetType: string } }> = [];
+
+ for (const [key, value] of this.clipErrors) {
+ const [t, c] = key.split("-").map(Number);
+ if (t === trackIdx && c > clipIdx) {
+ keysToUpdate.push({ oldKey: key, newKey: `${t}-${c - 1}`, value });
+ }
+ }
+
+ for (const { oldKey, newKey, value } of keysToUpdate) {
+ this.clipErrors.delete(oldKey);
+ this.clipErrors.set(newKey, value);
+ }
+ }
+
+ /** @internal */
+ public getPlayerClip(trackIdx: number, clipIdx: number): Player | null {
+ const track = this.tracks[trackIdx];
+ if (!track || clipIdx < 0 || clipIdx >= track.length) return null;
+ return track[clipIdx];
+ }
+
+ /**
+ * Get a Player by its stable clip ID.
+ * @internal
+ */
+ public getPlayerByClipId(clipId: string): Player | null {
+ return this.playerByClipId.get(clipId) ?? null;
+ }
+
+ /**
+ * Get the document clip by its stable ID.
+ * @internal
+ */
+ public getDocumentClipById(clipId: string): Clip | null {
+ return this.document?.getClipById(clipId)?.clip ?? null;
+ }
+
+ /**
+ * Register a Player by its clip ID.
+ * @internal Used by PlayerReconciler
+ */
+ public registerPlayerByClipId(clipId: string, player: Player): void {
+ this.playerByClipId.set(clipId, player);
+ }
+
+ /**
+ * Unregister a Player by its clip ID.
+ * @internal Used by PlayerReconciler
+ */
+ public unregisterPlayerByClipId(clipId: string): void {
+ this.playerByClipId.delete(clipId);
+ }
+
+ /**
+ * Get the Player ID map for iteration.
+ * @internal Used by PlayerReconciler
+ */
+ public getPlayerMap(): Map {
+ return this.playerByClipId;
+ }
+
+ /**
+ * Add a Player to the tracks array at the specified index.
+ * @internal Used by PlayerReconciler
+ */
+ public addPlayerToTracksArray(trackIndex: number, player: Player): void {
+ while (this.tracks.length <= trackIndex) {
+ this.tracks.push([]);
+ }
+ this.tracks[trackIndex].push(player);
+ }
+
+ /**
+ * Move a Player between tracks (both container and array).
+ * @internal Used by PlayerReconciler
+ */
+ public movePlayerBetweenTracks(player: Player, fromTrackIndex: number, toTrackIndex: number): void {
+ // Remove from old track array
+ const fromTrack = this.tracks[fromTrackIndex];
+ if (fromTrack) {
+ const idx = fromTrack.indexOf(player);
+ if (idx !== -1) {
+ fromTrack.splice(idx, 1);
+ }
+ }
+
+ // Add to new track array
+ while (this.tracks.length <= toTrackIndex) {
+ this.tracks.push([]);
+ }
+ this.tracks[toTrackIndex].push(player);
+
+ // Move PIXI container
+ this.movePlayerToTrackContainer(player, fromTrackIndex, toTrackIndex);
+ }
+
+ /**
+ * Queue a Player for disposal.
+ * @internal Used by PlayerReconciler
+ */
+ public queuePlayerForDisposal(player: Player): void {
+ this.queueDisposeClip(player);
+ this.disposeClips();
+ }
+
+ /**
+ * Ensure a track exists at the given index.
+ * @internal Used by PlayerReconciler for track syncing
+ */
+ public ensureTrackExists(trackIndex: number): void {
+ while (this.tracks.length <= trackIndex) {
+ this.tracks.push([]);
+ }
+ }
+
+ /**
+ * Remove an empty track at the given index.
+ * @internal Used by PlayerReconciler for track syncing
+ */
+ public removeEmptyTrack(trackIndex: number): void {
+ if (trackIndex < 0 || trackIndex >= this.tracks.length) return;
+
+ // Only remove if track is empty
+ const track = this.tracks[trackIndex];
+ if (track && track.length > 0) {
+ console.warn(`Cannot remove non-empty track ${trackIndex}`);
+ return;
+ }
+
+ // Remove from tracks array
+ this.tracks.splice(trackIndex, 1);
+
+ this.internalEvents.emit(InternalEvent.TrackContainerRemoved, { trackIndex });
+
+ // Update layer numbers for all players in tracks above the removed one
+ for (let i = trackIndex; i < this.tracks.length; i += 1) {
+ for (const player of this.tracks[i]) {
+ player.layer = i + 1;
+ }
+ }
+ }
+
+ /**
+ * Get the exportable asset for a clip, preserving merge field templates.
+ * @internal
+ */
+ public getOriginalAsset(trackIndex: number, clipIndex: number): unknown | undefined {
+ const player = this.getPlayerClip(trackIndex, clipIndex);
+ if (!player) return undefined;
+
+ const clip = player.getExportableClip();
+ if (!clip) return undefined;
+
+ // Restore merge field placeholders from document bindings
+ const { clipId } = player;
+ if (clipId && this.document) {
+ const bindings = this.document.getClipBindings(clipId);
+ if (bindings) {
+ for (const [path, { placeholder }] of bindings) {
+ // Only restore if path is within asset
+ if (path.startsWith("asset.")) {
+ const assetPath = path.slice(6); // Remove "asset." prefix
+ setNestedValue(clip.asset as Record, assetPath, placeholder);
+ }
+ }
+ }
+ }
+
+ return clip.asset;
+ }
+
+ public async deleteClip(trackIdx: number, clipIdx: number): Promise {
+ const track = this.tracks[trackIdx];
+ if (!track) return;
+
+ // Get the clip being deleted
+ const clipToDelete = track[clipIdx];
+ if (!clipToDelete) return;
+
+ // Check if this is a content clip (not a luma)
+ const isContentClip = clipToDelete.playerType !== PlayerType.Luma;
+
+ if (isContentClip) {
+ // Find attached luma in the same track
+ const lumaIndex = track.findIndex(clip => clip.playerType === PlayerType.Luma);
+
+ if (lumaIndex !== -1) {
+ // Delete luma first (handles index shifting correctly)
+ // If luma comes before content clip, content clip index shifts after luma deletion
+ const adjustedContentIdx = lumaIndex < clipIdx ? clipIdx - 1 : clipIdx;
+
+ const lumaCommand = new DeleteClipCommand(trackIdx, lumaIndex);
+ await this.executeCommand(lumaCommand);
+
+ // Now delete content clip with adjusted index
+ const contentCommand = new DeleteClipCommand(trackIdx, adjustedContentIdx);
+ await this.executeCommand(contentCommand);
+ return;
+ }
+ }
+
+ // No luma attachment or deleting a luma directly - just delete the clip
+ const command = new DeleteClipCommand(trackIdx, clipIdx);
+ await this.executeCommand(command);
+ }
+
+ public async addTrack(trackIdx: number, track: Track): Promise {
+ TrackSchema.parse(track);
+
+ const command = new AddTrackCommand(trackIdx);
+ await this.executeCommand(command);
+
+ for (const clip of track.clips) {
+ await this.addClip(trackIdx, clip);
+ }
+ }
+
+ public getTrack(trackIdx: number): Track | null {
+ // Return from Player array for position-based ordering (matches Player behavior)
+ const trackClips = this.clips.filter((clip: Player) => clip.layer === trackIdx + 1);
+ if (trackClips.length === 0) return null;
+
+ return {
+ clips: trackClips.map((clip: Player) => clip.clipConfiguration as unknown as Clip)
+ };
+ }
+
+ public deleteTrack(trackIdx: number): void {
+ const command = new DeleteTrackCommand(trackIdx);
+ this.executeCommand(command);
+ }
+
+ public undo(): Promise {
+ return this.commandQueue.enqueue(async () => {
+ if (this.commandIndex >= 0) {
+ const command = this.commandHistory[this.commandIndex];
+ if (command.undo) {
+ const context = this.createCommandContext();
+ // Always await - harmless on sync results, works across realms
+ await Promise.resolve(command.undo(context));
+ // Only decrement after successful completion
+ this.commandIndex -= 1;
+
+ this.internalEvents.emit(EditEvent.EditUndo, { command: command.name });
+ this.emitEditChanged(`undo:${command.name}`);
+ }
+ }
+ });
+ }
+
+ public redo(): Promise {
+ return this.commandQueue.enqueue(async () => {
+ if (this.commandIndex < this.commandHistory.length - 1) {
+ const nextIndex = this.commandIndex + 1;
+ const command = this.commandHistory[nextIndex];
+ const context = this.createCommandContext();
+ // Always await - harmless on sync results, works across realms
+ await Promise.resolve(command.execute(context));
+ // Only increment after successful completion
+ this.commandIndex = nextIndex;
+
+ this.internalEvents.emit(EditEvent.EditRedo, { command: command.name });
+ this.emitEditChanged(`redo:${command.name}`);
+ }
+ });
+ }
+ /** @internal */
+ public setUpdatedClip(clip: Player, initialClipConfig: ResolvedClip | null = null, finalClipConfig: ResolvedClip | null = null): void {
+ // Find track and clip indices
+ const trackIdx = clip.layer - 1;
+ const track = this.tracks[trackIdx];
+ const clipIdx = track ? track.indexOf(clip) : -1;
+
+ const command = new SetUpdatedClipCommand(initialClipConfig, finalClipConfig, {
+ trackIndex: trackIdx,
+ clipIndex: clipIdx
+ });
+ this.executeCommand(command);
+ }
+
+ // ─── Live Update API (No Undo) ────────────────────────────────────────────────
+
+ /**
+ * Update clip in document only, without resolving.
+ * @internal
+ */
+ public updateClipInDocument(clipId: string, updates: Partial): void {
+ const location = this.document.getClipById(clipId);
+ if (!location) return;
+
+ this.document.updateClip(location.trackIndex, location.clipIndex, updates);
+ }
+
+ /**
+ * Commit a live update session to the undo history.
+ * @param clipId - The clip ID to commit changes for
+ * @param initialConfig - The clip state before the drag/change began
+ * @param finalConfig - Explicit final state (must match what was already applied to document)
+ * @internal
+ */
+ public commitClipUpdate(clipId: string, initialConfig: ResolvedClip, finalConfig: ResolvedClip): void {
+ const location = this.document.getClipById(clipId);
+ if (!location) {
+ console.warn(`commitClipUpdate: clip ${clipId} not found in document`);
+ return;
+ }
+
+ // Validate the final state before committing to history.
+ // Live updates (updateClipInDocument) skip validation for performance,
+ // so this is the gate that catches corrupt data from drag/slider interactions.
+ ResolvedClipSchema.parse(finalConfig);
+
+ const command = new SetUpdatedClipCommand(initialConfig, structuredClone(finalConfig), {
+ trackIndex: location.trackIndex,
+ clipIndex: location.clipIndex
+ });
+
+ // Add to history without executing
+ this.addCommandToHistory(command);
+ }
+
+ /**
+ * Manage command history: dispose redo stack, add command, prune old entries.
+ * @internal
+ */
+ private pushCommandToHistory(command: EditCommand): void {
+ // Dispose any commands we're about to overwrite (redo history)
+ const discarded = this.commandHistory.slice(this.commandIndex + 1);
+ for (const cmd of discarded) {
+ cmd.dispose?.();
+ }
+
+ // Truncate redo history and add new command
+ this.commandHistory = this.commandHistory.slice(0, this.commandIndex + 1);
+ this.commandHistory.push(command);
+ this.commandIndex += 1;
+
+ // Prune old commands
+ while (this.commandHistory.length > Edit.MAX_HISTORY_SIZE) {
+ const pruned = this.commandHistory.shift();
+ pruned?.dispose?.();
+ this.commandIndex -= 1;
+ }
+ }
+
+ /**
+ * Add a command to history without executing it.
+ */
+ private addCommandToHistory(command: EditCommand): void {
+ this.pushCommandToHistory(command);
+ this.emitEditChanged(`commit:${command.name}`);
+ }
+
+ public updateClip(trackIdx: number, clipIdx: number, updates: Partial): Promise {
+ const clip = this.getPlayerClip(trackIdx, clipIdx);
+ if (!clip) {
+ console.warn(`Clip not found at track ${trackIdx}, index ${clipIdx}`);
+ return Promise.resolve();
+ }
+
+ const documentClip = this.document?.getClip(trackIdx, clipIdx);
+ const initialConfig = structuredClone(documentClip ?? clip.clipConfiguration) as ResolvedClip;
+ const currentConfig = structuredClone(documentClip ?? clip.clipConfiguration);
+ // Cast to ResolvedClip - the timing resolver handles "auto"/"end" at runtime
+ const mergedConfig = deepMerge(currentConfig, updates as unknown as Partial) as ResolvedClip;
+
+ // Validate the merged clip before applying
+ ResolvedClipSchema.parse(mergedConfig);
+
+ const command = new SetUpdatedClipCommand(initialConfig, mergedConfig, {
+ trackIndex: trackIdx,
+ clipIndex: clipIdx
+ });
+ return this.executeCommand(command);
+ }
+
+ /**
+ * Update clip timing mode and/or values.
+ * @internal Use updateClip() for public API access
+ */
+ public updateClipTiming(trackIdx: number, clipIdx: number, params: TimingUpdateParams): void {
+ const clip = this.getPlayerClip(trackIdx, clipIdx);
+ if (!clip) {
+ console.warn(`Clip not found at track ${trackIdx}, index ${clipIdx}`);
+ return;
+ }
+
+ const command = new UpdateClipTimingCommand(trackIdx, clipIdx, params);
+ this.executeCommand(command);
+ }
+
+ /** @internal */
+ public updateTextContent(clip: Player, newText: string, _initialConfig: ResolvedClip): void {
+ const trackIndex = clip.layer - 1;
+ const clipIndex = this.tracks[trackIndex]?.indexOf(clip) ?? -1;
+ if (clipIndex < 0) {
+ console.warn("UpdateTextContent: clip not found in track");
+ return;
+ }
+ const command = new UpdateTextContentCommand(trackIndex, clipIndex, newText);
+ this.executeCommand(command);
+ }
+
+ /** @internal */
+ public executeEditCommand(command: EditCommand): void | Promise {
+ return this.executeCommand(command);
+ }
+
+ /** @internal */
+ protected executeCommand(command: EditCommand): Promise {
+ return this.commandQueue.enqueue(async () => {
+ const context = this.createCommandContext();
+ // Always await - harmless on sync results, works across realms
+ const result = await Promise.resolve(command.execute(context));
+ this.handleCommandResult(command, result);
+ });
+ }
+
+ /**
+ * Handle command result - only add to history if successful.
+ */
+ private handleCommandResult(command: EditCommand, result: CommandResult): void {
+ if (result.status === "success") {
+ this.pushCommandToHistory(command);
+ this.emitEditChanged(command.name);
+ }
+ // 'noop' - don't add to history, don't emit
+ }
+
+ /**
+ * Emits a unified `edit:changed` event after any state mutation.
+ * @internal
+ */
+ protected emitEditChanged(source: string): void {
+ if (this.isBatchingEvents) return;
+ this.internalEvents.emit(EditEvent.EditChanged, { source, timestamp: Date.now() });
+ }
+
+ /**
+ * Detects merge field placeholders in the raw edit before substitution.
+ */
+ private detectMergeFieldBindings(mergeFields: SerializedMergeField[]): Map> {
+ const result = new Map>();
+
+ if (!mergeFields.length) return result;
+
+ // Build lookup map: FIELD_NAME -> replacement value
+ const fieldValues = new Map