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 @@ [![License](https://img.shields.io/badge/license-PolyForm_Shield-blue.svg)](https://polyformproject.org/licenses/shield/1.0.0/) [![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue.svg)](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(); + for (const { find, replace } of mergeFields) { + // Convert unknown replace value to string for placeholder matching + const replaceStr = typeof replace === "string" ? replace : JSON.stringify(replace); + fieldValues.set(find.toUpperCase(), replaceStr); + } + + // Walk each clip and detect placeholder strings + for (let t = 0; t < this.document.getTrackCount(); t += 1) { + const clips = this.document.getClipsInTrack(t); + for (let c = 0; c < clips.length; c += 1) { + const clipId = this.document.getClipId(t, c); + if (clipId) { + const bindings = this.detectBindingsInObject(clips[c], "", fieldValues); + if (bindings.size > 0) { + result.set(clipId, bindings); + } + } + } + } + + return result; + } + + /** + * Recursively walks an object to find merge field placeholders. + */ + private detectBindingsInObject(obj: unknown, basePath: string, fieldValues: Map): Map { + const bindings = new Map(); + + if (typeof obj === "string") { + // Check if this string contains a merge field placeholder + const regex = /\{\{\s*([A-Z_0-9]+)\s*\}\}/gi; + const hasMatch = regex.test(obj); + if (hasMatch) { + // Compute the fully resolved text by replacing all merge fields + const resolvedText = obj.replace( + /\{\{\s*([A-Z_0-9]+)\s*\}\}/gi, + (match, fieldName: string) => fieldValues.get(fieldName.toUpperCase()) ?? match + ); + bindings.set(basePath, { + placeholder: obj, + resolvedValue: resolvedText + }); + } + return bindings; + } + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i += 1) { + const path = basePath ? `${basePath}[${i}]` : `[${i}]`; + const childBindings = this.detectBindingsInObject(obj[i], path, fieldValues); + for (const [p, b] of childBindings) { + bindings.set(p, b); + } + } + return bindings; + } + + if (obj !== null && typeof obj === "object") { + for (const [key, value] of Object.entries(obj)) { + const path = basePath ? `${basePath}.${key}` : key; + const childBindings = this.detectBindingsInObject(value, path, fieldValues); + for (const [p, b] of childBindings) { + bindings.set(p, b); + } + } + } + + return bindings; + } + + /** + * Checks if edit has structural changes requiring full reload. + * + * TODO: Expand granular path to handle more cases: + * - Clip add/remove: Use existing addClip()/deleteClip() commands + * - Soundtrack changes: Add/remove AudioPlayer via commands + * - Font changes: Load new fonts incrementally + * - Merge field changes: Re-resolve affected clips + */ + private hasStructuralChanges(newEdit: EditConfig): boolean { + if (!this.document) return true; + + const currentTracks = this.document.getTracks(); + const newTracks = newEdit.timeline.tracks; + + // Different track count = structural + if (currentTracks.length !== newTracks.length) return true; + + // Check each track + for (let t = 0; t < currentTracks.length; t += 1) { + // Different clip count = structural + if (currentTracks[t].clips.length !== newTracks[t].clips.length) return true; + + // Asset TYPE change = structural (ImagePlayer vs VideoPlayer) + for (let c = 0; c < currentTracks[t].clips.length; c += 1) { + const currentType = (currentTracks[t].clips[c]?.asset as { type?: string })?.type; + const newType = (newTracks[t].clips[c]?.asset as { type?: string })?.type; + if (currentType !== newType) return true; + } + } + + // Merge fields changed = structural (affects asset resolution) + if (JSON.stringify(this.document.getMergeFields() ?? []) !== JSON.stringify(newEdit.merge ?? [])) { + return true; + } + + // Fonts changed = structural (requires re-loading fonts) + if (JSON.stringify(this.document.getFonts()) !== JSON.stringify(newEdit.timeline.fonts ?? [])) { + return true; + } + + // Soundtrack changed = structural (requires creating/destroying AudioPlayer) + if (JSON.stringify(this.document.getSoundtrack()) !== JSON.stringify(newEdit.timeline.soundtrack)) { + return true; + } + + return false; + } + + /** + * Transfers existing clip IDs from the current document to the new edit configuration. + */ + private preserveClipIdsForGranularUpdate(newEdit: EditConfig): void { + if (!this.document) return; + + const existingTracks = this.document.getTracks(); + + for (let trackIdx = 0; trackIdx < newEdit.timeline.tracks.length; trackIdx += 1) { + const existingTrack = existingTracks[trackIdx]; + const newTrack = newEdit.timeline.tracks[trackIdx]; + + if (existingTrack && newTrack) { + for (let clipIdx = 0; clipIdx < newTrack.clips.length; clipIdx += 1) { + const existingId = this.document.getClipId(trackIdx, clipIdx); + if (existingId) { + // Add the ID to the new clip so EditDocument.hydrateIds() preserves it + (newTrack.clips[clipIdx] as ClipWithId).id = existingId; + } + } + } + } + } + + /** + * Applies granular changes without full reload. + * @param newEdit - The new edit configuration + * @param oldTracks - The old tracks (captured before document update) + * @param oldOutput - The old output settings (captured before document update) + */ + private async applyGranularChanges(newEdit: EditConfig, oldTracks: Track[], oldOutput: EditConfig["output"]): Promise { + const newOutput = newEdit.output; + + // 1. Apply output changes + if (newOutput?.size && (oldOutput?.size?.width !== newOutput.size.width || oldOutput?.size?.height !== newOutput.size.height)) { + const width = newOutput.size.width ?? this.size.width; + const height = newOutput.size.height ?? this.size.height; + await this.setOutputSize(width, height); + } + + if (newOutput?.fps !== undefined && oldOutput?.fps !== newOutput.fps) { + await this.setOutputFps(newOutput.fps); + } + + if (newOutput?.format !== undefined && oldOutput?.format !== newOutput.format) { + await this.setOutputFormat(newOutput.format); + } + + if (newOutput?.destinations && JSON.stringify(oldOutput?.destinations) !== JSON.stringify(newOutput.destinations)) { + await this.setOutputDestinations(newOutput.destinations); + } + + if (newOutput?.resolution !== undefined && oldOutput?.resolution !== newOutput.resolution) { + await this.setOutputResolution(newOutput.resolution); + } + + if (newOutput?.aspectRatio !== undefined && oldOutput?.aspectRatio !== newOutput.aspectRatio) { + await this.setOutputAspectRatio(newOutput.aspectRatio); + } + + const newBg = newEdit.timeline?.background; + if (newBg && this.backgroundColor !== newBg) { + await this.setTimelineBackground(newBg); + } + + // 2. Diff and update each clip + const newTracks = newEdit.timeline.tracks; + + for (let trackIdx = 0; trackIdx < newTracks.length; trackIdx += 1) { + const oldClips = oldTracks[trackIdx].clips; + const newClips = newTracks[trackIdx].clips; + + for (let clipIdx = 0; clipIdx < newClips.length; clipIdx += 1) { + const oldClip = oldClips[clipIdx]; + const newClip = newClips[clipIdx]; + + // Only update if clip changed + if (JSON.stringify(oldClip) !== JSON.stringify(newClip)) { + // Cast since newClip may have "auto"/"end" strings; updateClip handles resolution + // eslint-disable-next-line no-await-in-loop + await this.updateClip(trackIdx, clipIdx, newClip as unknown as Partial); + } + } + } + } + + /** @internal */ + protected createCommandContext(): CommandContext { + return { + getClips: () => this.clips, + getTracks: () => this.tracks, + getTrack: trackIndex => { + if (trackIndex >= 0 && trackIndex < this.tracks.length) { + return this.tracks[trackIndex]; + } + return null; + }, + getContainer: () => this.getViewportContainer(), + addPlayer: (trackIdx, player) => this.addPlayer(trackIdx, player), + addPlayerToContainer: (trackIdx, player) => { + this.addPlayerToContainer(trackIdx, player); + }, + createPlayerFromAssetType: clipConfiguration => this.createPlayerFromAssetType(clipConfiguration), + queueDisposeClip: player => this.queueDisposeClip(player), + disposeClips: () => this.disposeClips(), + clearClipError: (trackIdx, clipIdx) => this.clearClipErrorAndShift(trackIdx, clipIdx), + undeleteClip: (trackIdx, clip) => { + let insertIdx = 0; + if (trackIdx >= 0 && trackIdx < this.tracks.length) { + const track = this.tracks[trackIdx]; + insertIdx = track.length; + for (let i = 0; i < track.length; i += 1) { + if (track[i].getStart() > clip.getStart()) { + insertIdx = i; + break; + } + } + track.splice(insertIdx, 0, clip); + } + + // Sync with document layer - restore clip at the same position + if (this.document) { + const exportableClip = clip.getExportableClip(); + this.document.addClip(trackIdx, exportableClip, insertIdx); + + // Update Player's clipId to match new document ID and re-register + const newClipId = this.document.getClipId(trackIdx, insertIdx); + if (newClipId) { + // eslint-disable-next-line no-param-reassign + clip.clipId = newClipId; + this.playerByClipId.set(newClipId, clip); + } + } + + this.addPlayerToContainer(trackIdx, clip); + + clip.load().catch(error => { + // Capture load errors for restored clips (same pattern as initial load) + const assetType = (clip.clipConfiguration?.asset as { type?: string })?.type ?? "unknown"; + const errorMessage = error instanceof Error ? error.message : String(error); + this.clipErrors.set(`${trackIdx}-${insertIdx}`, { error: errorMessage, assetType }); + this.internalEvents.emit(EditEvent.ClipLoadFailed, { + trackIndex: trackIdx, + clipIndex: insertIdx, + error: errorMessage, + assetType + }); + }); + + this.updateTotalDuration(); + }, + setUpdatedClip: () => { + // No-op: kept for interface compatibility + }, + restoreClipConfiguration: (clip, previousConfig) => { + const cloned = structuredClone(previousConfig); + const config = clip.clipConfiguration as Record; + for (const key of Object.keys(config)) { + delete config[key]; + } + Object.assign(config, cloned); + clip.reconfigureAfterRestore(); + + // Sync with document layer - update clip configuration + if (this.document) { + const indices = this.findClipIndices(clip); + if (indices) { + const exportableClip = clip.getExportableClip(); + this.document.replaceClip(indices.trackIndex, indices.clipIndex, exportableClip); + } + } + }, + updateDuration: () => this.updateTotalDuration(), + emitEvent: (name, ...args) => (this.internalEvents.emit as EventEmitter["emit"])(name, ...args), + findClipIndices: player => this.selectionManager.findClipIndices(player), + getClipAt: (trackIndex, clipIndex) => this.getClipAt(trackIndex, clipIndex), + getSelectedClip: () => this.selectionManager.getSelectedClip(), + setSelectedClip: clip => { + this.selectionManager.setSelectedClip(clip); + }, + movePlayerToTrackContainer: (player, fromTrackIdx, toTrackIdx) => this.movePlayerToTrackContainer(player, fromTrackIdx, toTrackIdx), + getEditState: () => this.getResolvedEdit(), + propagateTimingChanges: (trackIndex, startFromClipIndex) => this.propagateTimingChanges(trackIndex, startFromClipIndex), + resolveClipAutoLength: clip => this.resolveClipAutoLength(clip), + getMergeFields: () => this.mergeFieldService, + getOutputSize: () => this.outputSettings.getSize(), + setOutputSize: (width, height) => this.outputSettings.setSize(width, height), + getOutputFps: () => this.outputSettings.getFps(), + setOutputFps: fps => this.outputSettings.setFps(fps), + getOutputFormat: () => this.outputSettings.getFormat(), + setOutputFormat: format => this.outputSettings.setFormat(format), + getOutputResolution: () => this.outputSettings.getResolution(), + setOutputResolution: resolution => this.outputSettings.setResolution(resolution), + getOutputAspectRatio: () => this.outputSettings.getAspectRatio(), + setOutputAspectRatio: aspectRatio => this.outputSettings.setAspectRatio(aspectRatio), + getOutputDestinations: () => this.outputSettings.getDestinations(), + setOutputDestinations: destinations => this.outputSettings.setDestinations(destinations), + getTimelineBackground: () => this.getTimelineBackground(), + setTimelineBackground: color => this.setTimelineBackgroundInternal(color), + getDocument: () => this.document, + getDocumentTrack: trackIdx => this.document?.getTrack(trackIdx) ?? null, + getDocumentClip: (trackIdx, clipIdx) => this.document?.getClip(trackIdx, clipIdx) ?? null, + documentUpdateClip: (trackIdx, clipIdx, updates) => { + if (!this.document) { + throw new Error("Document not initialized - cannot update clip"); + } + this.document.updateClip(trackIdx, clipIdx, updates); + }, + documentAddClip: (trackIdx, clip, clipIdx) => { + if (!this.document) { + throw new Error("Document not initialized - cannot add clip"); + } + // Ensure document has enough tracks before adding clip + while (this.document.getTrackCount() <= trackIdx) { + this.document.addTrack(this.document.getTrackCount()); + } + return this.document.addClip(trackIdx, clip, clipIdx); + }, + documentRemoveClip: (trackIdx, clipIdx) => { + if (!this.document) { + throw new Error("Document not initialized - cannot remove clip"); + } + return this.document.removeClip(trackIdx, clipIdx); + }, + derivePlayerFromDocument: (trackIdx, clipIdx) => { + const clip = this.document?.getClip(trackIdx, clipIdx); + if (!clip) { + throw new Error(`derivePlayerFromDocument: No document clip at ${trackIdx}/${clipIdx} - state desync`); + } + + const player = this.getClipAt(trackIdx, clipIdx); + if (!player) { + throw new Error(`derivePlayerFromDocument: No player at ${trackIdx}/${clipIdx} - state desync`); + } + + // Only copy timing-related fields from document to player + // Do NOT copy asset - it contains unresolved merge field placeholders + // (e.g., "{{ FONT_COLOR }}") that would fail validation. + // The player's asset already has resolved values from load time. + const { asset, ...timingFields } = clip; + Object.assign(player.clipConfiguration, timingFields); + player.reconfigureAfterRestore(); + }, + buildResolutionContext: (trackIdx, clipIdx): ResolutionContext => { + // 1. Previous clip end (for start: "auto") + let previousClipEnd: Seconds = sec(0); + if (clipIdx > 0) { + const track = this.tracks[trackIdx]; + if (track && track[clipIdx - 1]) { + previousClipEnd = track[clipIdx - 1].getEnd(); + } + } + + // 2. Timeline end excluding "end" clips (for length: "end") + const timelineEnd = calculateTimelineEnd(this.tracks); + + // 3. Intrinsic duration if available (for length: "auto") + // Note: This may be null if asset metadata hasn't loaded yet + let intrinsicDuration: Seconds | null = null; + const player = this.getClipAt(trackIdx, clipIdx); + if (player) { + const intent = player.getTimingIntent(); + // Only lookup intrinsic duration if the clip uses "auto" length + if (intent.length === "auto") { + // The player's resolved length IS the intrinsic duration after async load + intrinsicDuration = player.getLength(); + } + } + + return { + previousClipEnd, + timelineEnd, + intrinsicDuration + }; + }, + resolve: () => this.resolve(), + resolveClip: clipId => this.resolveClip(clipId), + getPlayerByClipId: clipId => this.playerByClipId.get(clipId) ?? null, + registerPlayerByClipId: (clipId, player) => { + this.playerByClipId.set(clipId, player); + }, + unregisterPlayerByClipId: clipId => { + this.playerByClipId.delete(clipId); + }, + setClipBinding: (clipId, path, binding) => { + this.document?.setClipBinding(clipId, path, binding); + }, + getClipBinding: (clipId, path) => this.document?.getClipBinding(clipId, path), + removeClipBinding: (clipId, path) => { + this.document?.removeClipBinding(clipId, path); + }, + getClipBindings: clipId => this.document?.getClipBindings(clipId), + getEditSession: () => this + }; + } + + private queueDisposeClip(clipToDispose: Player): void { + this.clipsToDispose.add(clipToDispose); + } + + /** @internal */ + protected disposeClips(): void { + if (this.clipsToDispose.size === 0) { + return; + } + + // Clean up luma masks for any luma players being deleted + for (const clip of this.clipsToDispose) { + if (clip.playerType === PlayerType.Luma) { + this.lumaMaskController.cleanupForPlayer(clip); + // Remove the luma→content relationship when disposing + if (clip.clipId) { + this.lumaContentRelations.delete(clip.clipId); + } + } + } + + // Remove from ID→Player map + for (const clip of this.clipsToDispose) { + if (clip.clipId) { + this.playerByClipId.delete(clip.clipId); + } + } + + for (const clip of this.clipsToDispose) { + this.disposeClip(clip); + } + + // Remove from tracks (clips are derived from tracks.flat()) + for (const clip of this.clipsToDispose) { + const trackIdx = clip.layer - 1; + if (trackIdx >= 0 && trackIdx < this.tracks.length) { + const clipIdx = this.tracks[trackIdx].indexOf(clip); + if (clipIdx !== -1) { + this.tracks[trackIdx].splice(clipIdx, 1); + // NOTE: Document sync is NOT done here - commands handle document mutations directly. + // This avoids double-removal when commands already called documentRemoveClip(). + } + } + } + + this.clipsToDispose.clear(); + this.updateTotalDuration(); + + // Clean up fonts that are no longer used by any clip + this.cleanupUnusedFonts(); + } + + /** + * Remove fonts from timeline.fonts that are no longer referenced by any clip. + */ + private cleanupUnusedFonts(): void { + if (!this.document) return; + + const fonts = this.document.getFonts(); + if (fonts.length === 0) return; + + // Collect all font filenames currently used by RichText clips + const usedFilenames = new Set(); + for (const clip of this.clips) { + const { asset } = clip.clipConfiguration; + if (asset && asset.type === "rich-text" && asset.font?.family) { + usedFilenames.add(asset.font.family); + } + } + + // Check each font URL and remove if its filename is not used + // Only prune Google fonts - preserve custom fonts for template integrity + for (const font of fonts) { + const isGoogleFont = font.src.includes("fonts.gstatic.com"); + if (isGoogleFont) { + const filename = this.extractFilenameFromUrl(font.src); + if (filename && !usedFilenames.has(filename)) { + this.document.removeFont(font.src); + } + } + } + } + + /** + * Extract the filename (without extension) from a font URL. + */ + private extractFilenameFromUrl(url: string): string | null { + try { + const { pathname } = new URL(url); + const filename = pathname.split("/").pop(); + if (!filename) return null; + // Remove extension (.ttf, .woff2, etc.) + return filename.replace(/\.[^.]+$/, ""); + } catch { + return null; + } + } + + private disposeClip(clip: Player): void { + try { + const viewportContainer = this.canvas?.getViewportContainer(); + if (viewportContainer) { + for (const child of viewportContainer.children) { + if (child instanceof pixi.Container && child.label?.toString().startsWith("shotstack-track-")) { + if (child.children.includes(clip.getContainer())) { + child.removeChild(clip.getContainer()); + break; + } + } + } + } + } catch (error) { + console.warn(`Attempting to unmount an unmounted clip: ${error}`); + } + + this.unloadClipAssets(clip); + + // Invalidate cache since timeline end may have changed + this.timingManager.invalidateTimelineEndCache(); + + clip.dispose(); + } + + private unloadClipAssets(clip: Player): void { + const { asset } = clip.clipConfiguration; + if (asset && "src" in asset && typeof asset.src === "string") { + const safeToUnload = this.assetLoader.decrementRef(asset.src); + if (safeToUnload && pixi.Assets.cache.has(asset.src)) { + pixi.Assets.unload(asset.src); + } + } + } + + /** @internal */ + protected clearClips(): void { + for (const clip of this.clips) { + this.disposeClip(clip); + } + + this.tracks = []; + this.clipsToDispose.clear(); + this.clipErrors.clear(); + this.lumaContentRelations.clear(); + + this.updateTotalDuration(); + } + + /** @internal */ + public updateTotalDuration(): void { + let maxDurationSeconds = 0; + + for (const track of this.tracks) { + for (const clip of track) { + // clip.getEnd() returns Seconds + maxDurationSeconds = Math.max(maxDurationSeconds, clip.getEnd()); + } + } + + // Store in seconds (consistent with Seconds type) + const previousDuration = this.totalDuration; + this.totalDuration = sec(maxDurationSeconds); + + // Emit event if duration changed + if (previousDuration !== this.totalDuration) { + this.internalEvents.emit(EditEvent.DurationChanged, { duration: this.totalDuration }); + } + } + + /** @internal */ + public propagateTimingChanges(trackIndex: number, startFromClipIndex: number): void { + this.timingManager.propagateTimingChanges(trackIndex, startFromClipIndex); + } + + /** @internal */ + public async resolveClipAutoLength(clip: Player): Promise { + const intent = clip.getTimingIntent(); + if (intent.length !== "auto") return; + + // Find clip indices first (needed if start is also auto) + const indices = this.findClipIndices(clip); + + // Resolve auto start if needed, otherwise use current start + let resolvedStart = clip.getStart(); + if (intent.start === "auto" && indices) { + resolvedStart = resolveAutoStart(indices.trackIndex, indices.clipIndex, this.tracks); + } + + const newLength = await resolveAutoLength(clip.clipConfiguration.asset); + clip.setResolvedTiming({ + start: resolvedStart, + length: newLength + }); + clip.reconfigureAfterRestore(); + + if (indices) { + this.propagateTimingChanges(indices.trackIndex, indices.clipIndex); + } + } + + /** + * Add a Player to the appropriate PIXI track container. + * @internal Used by PlayerReconciler and commands + */ + public addPlayerToContainer(trackIndex: number, player: Player): void { + // Emit event for Canvas to add player to track container + this.internalEvents.emit(InternalEvent.PlayerAddedToTrack, { player, trackIndex }); + } + + // Move a player's container to the appropriate track container + private movePlayerToTrackContainer(player: Player, fromTrackIdx: number, toTrackIdx: number): void { + this.internalEvents.emit(InternalEvent.PlayerMovedBetweenTracks, { + player, + fromTrackIndex: fromTrackIdx, + toTrackIndex: toTrackIdx + }); + } + /** + * Create a Player from a clip configuration based on asset type. + * @internal Used by PlayerReconciler and commands + */ + public createPlayerFromAssetType(clipConfiguration: ResolvedClip): Player { + return PlayerFactory.create(this, clipConfiguration); + } + + private async addPlayer(trackIdx: number, clipToAdd: Player): Promise { + while (this.tracks.length <= trackIdx) { + this.tracks.push([]); + } + + this.tracks[trackIdx].push(clipToAdd); + + // Document sync is handled by AddClipCommand - don't duplicate here + + this.internalEvents.emit(InternalEvent.PlayerAddedToTrack, { player: clipToAdd, trackIndex: trackIdx }); + + await clipToAdd.load(); + + this.updateTotalDuration(); + } + + /** @internal */ + public selectClip(trackIndex: number, clipIndex: number): void { + this.selectionManager.selectClip(trackIndex, clipIndex); + } + + /** @internal */ + public clearSelection(): void { + this.selectionManager.clearSelection(); + } + + /** @internal */ + public isClipSelected(trackIndex: number, clipIndex: number): boolean { + return this.selectionManager.isClipSelected(trackIndex, clipIndex); + } + + /** @internal */ + public getSelectedClipInfo(): { trackIndex: number; clipIndex: number; player: Player } | null { + return this.selectionManager.getSelectedClipInfo(); + } + + /** + * Copy a clip to the internal clipboard. + * @internal + */ + public copyClip(trackIdx: number, clipIdx: number): void { + this.selectionManager.copyClip(trackIdx, clipIdx); + } + + /** + * Paste the copied clip at the current playhead position. + * @internal + */ + public pasteClip(): void { + this.selectionManager.pasteClip(); + } + + /** + * Check if there is a clip in the clipboard. + * @internal + */ + public hasCopiedClip(): boolean { + return this.selectionManager.hasCopiedClip(); + } + + /** @internal */ + public findClipIndices(player: Player): { trackIndex: number; clipIndex: number } | null { + return this.selectionManager.findClipIndices(player); + } + + /** @internal */ + public getClipAt(trackIndex: number, clipIndex: number): Player | null { + if (trackIndex >= 0 && trackIndex < this.tracks.length && clipIndex >= 0 && clipIndex < this.tracks[trackIndex].length) { + return this.tracks[trackIndex][clipIndex]; + } + return null; + } + + /** @internal */ + public selectPlayer(player: Player): void { + this.selectionManager.selectPlayer(player); + } + + /** @internal */ + public isPlayerSelected(player: Player): boolean { + return this.selectionManager.isPlayerSelected(player); + } + + /** @internal Get all active players except the specified one. */ + public getActivePlayersExcept(excludePlayer: Player): Player[] { + const active: Player[] = []; + for (const track of this.tracks) { + for (const player of track) { + if (player !== excludePlayer && player.isActive()) { + active.push(player); + } + } + } + return active; + } + + /** @internal Show an alignment guide line. */ + public showAlignmentGuide(type: "canvas" | "clip", axis: "x" | "y", position: number, bounds?: { start: number; end: number }): void { + this.canvas?.showAlignmentGuide(type, axis, position, bounds); + } + + /** @internal Clear all alignment guides. */ + public clearAlignmentGuides(): void { + this.canvas?.clearAlignmentGuides(); + } + + /** @internal Move the selected clip by a pixel delta. */ + public moveSelectedClip(deltaX: number, deltaY: number): void { + const info = this.getSelectedClipInfo(); + if (!info) return; + + const { player, trackIndex, clipIndex } = info; + + const resolvedClip = this.getResolvedClip(trackIndex, clipIndex); + if (!resolvedClip) return; + + const initialConfig = structuredClone(resolvedClip); + + const newOffset = player.calculateMoveOffset(deltaX, deltaY); + + const finalConfig = structuredClone(initialConfig); + finalConfig.offset = newOffset; + + this.setUpdatedClip(player, initialConfig, finalConfig); + } + + /** @internal */ + public setExportMode(exporting: boolean): void { + this.isExporting = exporting; + } + /** @internal */ + public isInExportMode(): boolean { + return this.isExporting; + } + + /** @internal */ + public setCanvas(canvas: Canvas): void { + this.canvas = canvas; + } + + /** @internal */ + public getCanvas(): Canvas | null { + return this.canvas; + } + + /** @internal */ + public getCanvasZoom(): number { + return this.canvas?.getZoom() ?? 1; + } + + /** + * Get the viewport container for coordinate transforms. + * @internal + */ + public getViewportContainer(): pixi.Container { + if (!this.canvas) { + throw new Error("Canvas not attached. Viewport container requires Canvas."); + } + return this.canvas.getViewportContainer(); + } + + // ─── Output Settings (delegated to OutputSettingsManager) ──────────────────── + + public setOutputSize(width: number, height: number): Promise { + const command = new SetOutputSizeCommand(width, height); + return this.executeCommand(command); + } + + public setOutputFps(fps: number): Promise { + const command = new SetOutputFpsCommand(fps); + return this.executeCommand(command); + } + + public getOutputFps(): number { + return this.outputSettings.getFps(); + } + + public setOutputFormat(format: string): Promise { + const command = new SetOutputFormatCommand(format); + return this.executeCommand(command); + } + + public getOutputFormat(): string { + return this.outputSettings.getFormat(); + } + + public setOutputDestinations(destinations: Destination[]): Promise { + const command = new SetOutputDestinationsCommand(destinations); + return this.executeCommand(command); + } + + public getOutputDestinations(): Destination[] { + return this.outputSettings.getDestinations(); + } + + public setOutputResolution(resolution: string): Promise { + const command = new SetOutputResolutionCommand(resolution); + return this.executeCommand(command); + } + + public getOutputResolution(): string | undefined { + return this.outputSettings.getResolution(); + } + + public setOutputAspectRatio(aspectRatio: string): Promise { + const command = new SetOutputAspectRatioCommand(aspectRatio); + return this.executeCommand(command); + } + + public getOutputAspectRatio(): string | undefined { + return this.outputSettings.getAspectRatio(); + } + + /** @internal */ + public getTimelineFonts(): Array<{ src: string }> { + return this.document.getFonts(); + } + + /** + * Get the font metadata map (URL → parsed binary name and weight). + * Used by the font picker to display correct custom font names. + * @internal + */ + public getFontMetadata(): ReadonlyMap { + return this.fontMetadata; + } + + /** + * Look up a font URL by family name and weight. + * @internal + */ + public getFontUrlByFamilyAndWeight(familyName: string, weight: number): string | null { + // Extract base family name (e.g., "Lato Light" → "Lato") + const { baseFontFamily } = parseFontFamily(familyName); + const lowerBase = baseFontFamily.toLowerCase(); + + // First try exact family + weight match + for (const [url, meta] of this.fontMetadata) { + if (meta.baseFamilyName.toLowerCase() === lowerBase && meta.weight === weight) { + return url; + } + } + + // Fallback: match just family (for single-weight fonts or variable fonts) + for (const [url, meta] of this.fontMetadata) { + if (meta.baseFamilyName.toLowerCase() === lowerBase) { + return url; + } + } + + return null; + } + + /** @internal */ + public pruneUnusedFonts(): void { + this.cleanupUnusedFonts(); + } + + public setTimelineBackground(color: string): Promise { + const command = new SetTimelineBackgroundCommand(color); + return this.executeCommand(command); + } + + private setTimelineBackgroundInternal(color: string): void { + HexColorSchema.parse(color); + + this.backgroundColor = color; + + // Sync with document layer + this.document.setBackground(color); + + this.internalEvents.emit(InternalEvent.ViewportSizeChanged, { + width: this.size.width, + height: this.size.height, + backgroundColor: this.backgroundColor + }); + + this.internalEvents.emit(EditEvent.TimelineBackgroundChanged, { color }); + // Note: emitEditChanged is handled by executeCommand + } + + public getTimelineBackground(): string { + return this.backgroundColor; + } + + /** + * Resolve merge field placeholders in a string. + * @internal + */ + public resolveMergeFields(input: string): string { + return this.mergeFieldService.resolve(input); + } + + // ─── Template Edit Access (via document bindings) ────────────────────────── + + /* @internal Get the exportable clip (with merge field placeholders restored) */ + protected getTemplateClip(trackIndex: number, clipIndex: number): ResolvedClip | null { + const player = this.getPlayerClip(trackIndex, clipIndex); + if (!player) return null; + + const clip = player.getExportableClip(); + if (!clip) return null; + + // 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) { + setNestedValue(clip as Record, path, placeholder); + } + } + } + + return clip as ResolvedClip; + } + + /** + * Get the exportable clip by its stable ID (with merge field placeholders restored). + * @internal + */ + protected getTemplateClipById(clipId: string): ResolvedClip | null { + const player = this.getPlayerByClipId(clipId); + if (!player) return null; + + const clip = player.getExportableClip(); + if (!clip || !this.document) return null; + + const bindings = this.document.getClipBindings(clipId); + if (bindings) { + for (const [path, { placeholder }] of bindings) { + setNestedValue(clip as Record, path, placeholder); + } + } + + return clip as ResolvedClip; + } + + /** + * Get the text content from the template clip (with merge field placeholders). + * @internal + */ + public getTemplateClipText(trackIdx: number, clipIdx: number): string | null { + const templateClip = this.getTemplateClip(trackIdx, clipIdx); + if (!templateClip) return null; + const asset = templateClip.asset as { text?: string } | undefined; + return asset?.text ?? null; + } + + // ─── Luma Mask API ────────────────────────────────────────────────────────── + + /** + * @internal Get the luma clip ID attached to a content clip. + */ + public getLumaClipIdForContent(contentClipId: string): string | null { + for (const [lumaId, contentId] of this.lumaContentRelations) { + if (contentId === contentClipId) return lumaId; + } + return null; + } + + /** + * Get the content clip ID for a luma clip. + * @internal + */ + public getContentClipIdForLuma(lumaClipId: string): string | null { + return this.lumaContentRelations.get(lumaClipId) ?? null; + } + + /** + * Set a luma→content relationship. + * @internal Used by commands for managing luma attachments + */ + public setLumaContentRelationship(lumaClipId: string, contentClipId: string): void { + this.lumaContentRelations.set(lumaClipId, contentClipId); + } + + /** + * Clear a luma→content relationship. + * @internal Used by commands for managing luma attachments + */ + public clearLumaContentRelationship(lumaClipId: string): void { + this.lumaContentRelations.delete(lumaClipId); + } + + /** + * Get the luma→content relationship for a luma clip. + * @internal Used by commands for managing luma attachments + */ + public getLumaContentRelationship(lumaClipId: string): string | undefined { + return this.lumaContentRelations.get(lumaClipId); + } + + /** + * Normalize luma attachments after loading. + * @internal + */ + public normalizeLumaAttachments(): void { + let needsResolve = false; + + for (let trackIdx = 0; trackIdx < this.tracks.length; trackIdx += 1) { + const track = this.tracks[trackIdx]; + + for (const player of track) { + if (player.playerType === PlayerType.Luma) { + // Find best content match (by overlap, not exact timing) + const contentPlayer = this.findBestContentMatch(trackIdx, player); + if (contentPlayer) { + // Establish luma→content relationship using clip IDs + if (player.clipId && contentPlayer.clipId) { + this.lumaContentRelations.set(player.clipId, contentPlayer.clipId); + } + + const lumaIdx = track.indexOf(player); + this.document.updateClip(trackIdx, lumaIdx, { + start: contentPlayer.getStart(), + length: contentPlayer.getLength() + }); + needsResolve = true; + } + } + } + } + + // Single resolve at end → Reconciler syncs all players + if (needsResolve) { + this.resolve(); + } + } + + /** + * Find the content clip that best matches a luma (by temporal overlap). + */ + private findBestContentMatch(trackIdx: number, lumaPlayer: Player): Player | null { + const track = this.tracks[trackIdx]; + const lumaStart = lumaPlayer.getStart(); + const lumaEnd = lumaStart + lumaPlayer.getLength(); + + let bestMatch: Player | null = null; + let bestOverlap = 0; + + for (const player of track) { + if (player.playerType !== PlayerType.Luma) { + const contentStart = player.getStart(); + const contentEnd = contentStart + player.getLength(); + const overlap = calculateOverlap(lumaStart, lumaEnd, contentStart, contentEnd); + + if (overlap > bestOverlap) { + bestOverlap = overlap; + bestMatch = player; + } + } + } + + return bestMatch; + } + + // ─── Intent Listeners ──────────────────────────────────────────────────────── + + private setupIntentListeners(): void { + this.internalEvents.on(InternalEvent.CanvasClipClicked, data => { + this.selectPlayer(data.player); + }); + + this.internalEvents.on(InternalEvent.CanvasBackgroundClicked, () => { + this.clearSelection(); + }); + } + + // ─── Protected Accessors for Subclasses ───────────────────────────────────── + + /** @internal Get the tracks array for subclass and reconciler access */ + public getTracks(): Player[][] { + return this.tracks; + } +} diff --git a/src/core/edit.ts b/src/core/edit.ts deleted file mode 100644 index e44af0e3..00000000 --- a/src/core/edit.ts +++ /dev/null @@ -1,833 +0,0 @@ -import { AudioPlayer } from "@canvas/players/audio-player"; -import { HtmlPlayer } from "@canvas/players/html-player"; -import { ImagePlayer } from "@canvas/players/image-player"; -import { LumaPlayer } from "@canvas/players/luma-player"; -import type { Player } from "@canvas/players/player"; -import { RichTextPlayer } from "@canvas/players/rich-text-player"; -import { ShapePlayer } from "@canvas/players/shape-player"; -import { TextPlayer } from "@canvas/players/text-player"; -import { VideoPlayer } from "@canvas/players/video-player"; -import { AddClipCommand } from "@core/commands/add-clip-command"; -import { AddTrackCommand } from "@core/commands/add-track-command"; -import { ClearSelectionCommand } from "@core/commands/clear-selection-command"; -import { DeleteClipCommand } from "@core/commands/delete-clip-command"; -import { DeleteTrackCommand } from "@core/commands/delete-track-command"; -import { SelectClipCommand } from "@core/commands/select-clip-command"; -import { SetUpdatedClipCommand } from "@core/commands/set-updated-clip-command"; -import { SplitClipCommand } from "@core/commands/split-clip-command"; -import { UpdateTextContentCommand } from "@core/commands/update-text-content-command"; -import { EventEmitter } from "@core/events/event-emitter"; -import { applyMergeFields } from "@core/merge/merge-fields"; -import { Entity } from "@core/shared/entity"; -import { deepMerge } from "@core/shared/utils"; -import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; -import type { Size } from "@layouts/geometry"; -import { AssetLoader } from "@loaders/asset-loader"; -import { FontLoadParser } from "@loaders/font-load-parser"; -import { ClipSchema } from "@schemas/clip"; -import { EditSchema } from "@schemas/edit"; -import { TrackSchema } from "@schemas/track"; -import * as pixi from "pixi.js"; -import { z } from "zod"; - -import type { EditCommand, CommandContext } from "./commands/types"; - -type EditType = z.infer; -type ClipType = z.infer; -type TrackType = z.infer; - -export class Edit extends Entity { - private static readonly ZIndexPadding = 100; - - public assetLoader: AssetLoader; - public events: EventEmitter; - - private edit: EditType | null; - private tracks: Player[][]; - private clipsToDispose: Player[]; - private clips: Player[]; - private commandHistory: EditCommand[] = []; - private commandIndex: number = -1; - - public playbackTime: number; - /** @internal */ - public size: Size; - /** @internal */ - private backgroundColor: string; - public totalDuration: number; - /** @internal */ - public isPlaying: boolean; - /** @internal */ - private selectedClip: Player | null; - /** @internal */ - private updatedClip: Player | null; - /** @internal */ - private viewportMask?: pixi.Graphics; - /** @internal */ - private background: pixi.Graphics | null; - /** @internal */ - private isExporting: boolean = false; - - // Performance optimization: cache timeline end and track "end" length clips - private cachedTimelineEnd: number = 0; - private endLengthClips: Set = new Set(); - - constructor(size: Size, backgroundColor: string = "#ffffff") { - super(); - - this.assetLoader = new AssetLoader(); - this.edit = null; - - this.tracks = []; - this.clipsToDispose = []; - this.clips = []; - - this.events = new EventEmitter(); - - this.size = size; - - this.playbackTime = 0; - this.totalDuration = 0; - this.isPlaying = false; - this.selectedClip = null; - this.updatedClip = null; - this.backgroundColor = backgroundColor; - this.background = null; - - // Set up event-driven architecture - this.setupIntentListeners(); - } - - public override async load(): Promise { - const background = new pixi.Graphics(); - this.background = background; - background.fillStyle = { - color: this.backgroundColor - }; - - background.rect(0, 0, this.size.width, this.size.height); - background.fill(); - - this.getContainer().addChild(background); - - // Ensure content outside the edit viewport is not visible - this.viewportMask = new pixi.Graphics(); - this.viewportMask.rect(0, 0, this.size.width, this.size.height); - this.viewportMask.fill(0xffffff); - this.getContainer().addChild(this.viewportMask); - this.getContainer().setMask({ mask: this.viewportMask }); - } - - /** @internal */ - public override update(deltaTime: number, elapsed: number): void { - for (const clip of this.clips) { - if (clip.shouldDispose) { - this.queueDisposeClip(clip); - } - - clip.update(deltaTime, elapsed); - } - - this.disposeClips(); - - if (this.isPlaying) { - this.playbackTime = Math.max(0, Math.min(this.playbackTime + elapsed, this.totalDuration)); - - if (this.playbackTime === this.totalDuration) { - this.pause(); - } - } - } - /** @internal */ - public override draw(): void { - for (const clip of this.clips) { - clip.draw(); - } - } - /** @internal */ - public override dispose(): void { - this.clearClips(); - - // Clean up mask - if (this.viewportMask) { - try { - // Remove mask first, then destroy the graphics - this.getContainer().setMask(null as any); - } catch { - // Intentionally ignore errors when removing the mask during dispose - } - this.viewportMask.destroy(); - this.viewportMask = undefined; - } - } - - public play(): void { - this.isPlaying = true; - this.events.emit("playback:play", {}); - } - public pause(): void { - this.isPlaying = false; - this.events.emit("playback:pause", {}); - } - public seek(target: number): void { - this.playbackTime = Math.max(0, Math.min(target, this.totalDuration)); - this.pause(); - // Force immediate render - elapsed > 100 triggers VideoPlayer sync - this.update(0, 101); - this.draw(); - } - public stop(): void { - this.seek(0); - } - - public async loadEdit(edit: EditType): Promise { - this.clearClips(); - - // Apply merge fields transparently (if present) - const mergeFields = edit.merge ?? []; - const mergedEdit = mergeFields.length > 0 ? applyMergeFields(edit, mergeFields) : edit; - - // Note: We no longer resolve smart-clips here - timing intent is preserved - // and resolved after all clips are loaded - this.edit = EditSchema.parse(mergedEdit); - - this.backgroundColor = this.edit.timeline.background || "#000000"; - - if (this.background) { - this.background.clear(); - this.background.fillStyle = { - color: this.backgroundColor - }; - this.background.rect(0, 0, this.size.width, this.size.height); - this.background.fill(); - } - - await Promise.all( - (this.edit.timeline.fonts ?? []).map(async font => { - const identifier = font.src; - const loadOptions: pixi.UnresolvedAsset = { src: identifier, loadParser: FontLoadParser.Name }; - - return this.assetLoader.load(identifier, loadOptions); - }) - ); - - for (const [trackIdx, track] of this.edit.timeline.tracks.entries()) { - for (const clip of track.clips) { - const clipPlayer = this.createPlayerFromAssetType(clip); - clipPlayer.layer = trackIdx + 1; - await this.addPlayer(trackIdx, clipPlayer); - } - } - - // Resolve all timing after clips are loaded - await this.resolveAllTiming(); - - this.updateTotalDuration(); - - // Notify listeners that edit has been reloaded (use resolved values for Timeline) - this.events.emit("timeline:updated", { current: this.getResolvedEdit() }); - } - public getEdit(): EditType { - return this.buildEditSnapshot(player => player.getTimingIntent()); - } - - public getResolvedEdit(): EditType { - return this.buildEditSnapshot(player => ({ - start: player.getStart() / 1000, - length: player.getLength() / 1000 - })); - } - - private buildEditSnapshot(getClipTiming: (player: Player) => { start: number | "auto"; length: number | "auto" | "end" }): EditType { - const tracks: TrackType[] = this.tracks.map(track => ({ - clips: track - .filter(player => player && !this.clipsToDispose.includes(player)) - .map(player => { - const timing = getClipTiming(player); - return { - ...player.clipConfiguration, - start: timing.start, - length: timing.length - }; - }) - })); - - return { - timeline: { - background: this.backgroundColor, - tracks, - fonts: this.edit?.timeline.fonts || [] - }, - output: this.edit?.output || { size: this.size, format: "mp4" } - }; - } - - public addClip(trackIdx: number, clip: ClipType): void { - const command = new AddClipCommand(trackIdx, clip); - this.executeCommand(command); - } - public getClip(trackIdx: number, clipIdx: number): ClipType | null { - const clipsByTrack = this.clips.filter((clip: Player) => clip.layer === trackIdx + 1); - if (clipIdx < 0 || clipIdx >= clipsByTrack.length) return null; - - return clipsByTrack[clipIdx].clipConfiguration; - } - - public getPlayerClip(trackIdx: number, clipIdx: number): Player | null { - const clipsByTrack = this.clips.filter((clip: Player) => clip.layer === trackIdx + 1); - if (clipIdx < 0 || clipIdx >= clipsByTrack.length) return null; - - return clipsByTrack[clipIdx]; - } - public deleteClip(trackIdx: number, clipIdx: number): void { - const command = new DeleteClipCommand(trackIdx, clipIdx); - this.executeCommand(command); - } - - public splitClip(trackIndex: number, clipIndex: number, splitTime: number): void { - const command = new SplitClipCommand(trackIndex, clipIndex, splitTime); - this.executeCommand(command); - } - - public addTrack(trackIdx: number, track: TrackType): void { - const command = new AddTrackCommand(trackIdx); - this.executeCommand(command); - track?.clips?.forEach(clip => this.addClip(trackIdx, clip)); - } - public getTrack(trackIdx: number): TrackType | null { - 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) - }; - } - public deleteTrack(trackIdx: number): void { - const command = new DeleteTrackCommand(trackIdx); - this.executeCommand(command); - } - - public getTotalDuration(): number { - return this.totalDuration; - } - - public undo(): void { - if (this.commandIndex >= 0) { - const command = this.commandHistory[this.commandIndex]; - if (command.undo) { - const context = this.createCommandContext(); - command.undo(context); - this.commandIndex -= 1; - this.events.emit("edit:undo", { command: command.name }); - } - } - } - - public redo(): void { - if (this.commandIndex < this.commandHistory.length - 1) { - this.commandIndex += 1; - const command = this.commandHistory[this.commandIndex]; - const context = this.createCommandContext(); - command.execute(context); - this.events.emit("edit:redo", { command: command.name }); - } - } - /** @internal */ - public setUpdatedClip(clip: Player, initialClipConfig: ClipType | null = null, finalClipConfig: ClipType | null = null): void { - const command = new SetUpdatedClipCommand(clip, initialClipConfig, finalClipConfig); - this.executeCommand(command); - } - - public updateClip(trackIdx: number, clipIdx: number, updates: Partial): void { - const clip = this.getPlayerClip(trackIdx, clipIdx); - if (!clip) { - console.warn(`Clip not found at track ${trackIdx}, index ${clipIdx}`); - return; - } - - const initialConfig = structuredClone(clip.clipConfiguration); - const currentConfig = structuredClone(clip.clipConfiguration); - const mergedConfig = deepMerge(currentConfig, updates); - this.setUpdatedClip(clip, initialConfig, mergedConfig); - } - - /** @internal */ - public updateTextContent(clip: Player, newText: string, initialConfig: ClipType): void { - const command = new UpdateTextContentCommand(clip, newText, initialConfig); - this.executeCommand(command); - } - - public executeEditCommand(command: EditCommand): void | Promise { - return this.executeCommand(command); - } - - private executeCommand(command: EditCommand): void | Promise { - const context = this.createCommandContext(); - const result = command.execute(context); - this.commandHistory = this.commandHistory.slice(0, this.commandIndex + 1); - this.commandHistory.push(command); - this.commandIndex += 1; - return result; - } - - private createCommandContext(): CommandContext { - return { - getClips: () => this.clips, - getTracks: () => this.tracks, - getTrack: trackIndex => { - if (trackIndex >= 0 && trackIndex < this.tracks.length) { - return this.tracks[trackIndex]; - } - return null; - }, - getContainer: () => this.getContainer(), - addPlayer: (trackIdx, player) => this.addPlayer(trackIdx, player), - addPlayerToContainer: (trackIdx, player) => { - this.addPlayerToContainer(trackIdx, player); - }, - createPlayerFromAssetType: clipConfiguration => this.createPlayerFromAssetType(clipConfiguration), - queueDisposeClip: player => this.queueDisposeClip(player), - disposeClips: () => this.disposeClips(), - undeleteClip: (trackIdx, clip) => { - this.clips.push(clip); - - if (trackIdx >= 0 && trackIdx < this.tracks.length) { - const track = this.tracks[trackIdx]; - let insertIdx = track.length; - for (let i = 0; i < track.length; i += 1) { - if (track[i].getStart() > clip.getStart()) { - insertIdx = i; - break; - } - } - track.splice(insertIdx, 0, clip); - } - - this.addPlayerToContainer(trackIdx, clip); - - clip.load(); - - this.updateTotalDuration(); - }, - setUpdatedClip: clip => { - this.updatedClip = clip; - }, - restoreClipConfiguration: (clip, previousConfig) => { - const cloned = structuredClone(previousConfig); - const config = clip.clipConfiguration as Record; - for (const key of Object.keys(config)) { - delete config[key]; - } - Object.assign(config, cloned); - clip.reconfigureAfterRestore(); - clip.draw(); - }, - updateDuration: () => this.updateTotalDuration(), - emitEvent: (name, data) => this.events.emit(name, data), - findClipIndices: player => this.findClipIndices(player), - getClipAt: (trackIndex, clipIndex) => this.getClipAt(trackIndex, clipIndex), - getSelectedClip: () => this.selectedClip, - setSelectedClip: clip => { - this.selectedClip = clip; - }, - movePlayerToTrackContainer: (player, fromTrackIdx, toTrackIdx) => this.movePlayerToTrackContainer(player, fromTrackIdx, toTrackIdx), - getEditState: () => this.getResolvedEdit(), - propagateTimingChanges: (trackIndex, startFromClipIndex) => this.propagateTimingChanges(trackIndex, startFromClipIndex), - resolveClipAutoLength: clip => this.resolveClipAutoLength(clip), - untrackEndLengthClip: clip => this.endLengthClips.delete(clip), - trackEndLengthClip: clip => this.endLengthClips.add(clip) - }; - } - - private queueDisposeClip(clipToDispose: Player): void { - this.clipsToDispose.push(clipToDispose); - } - protected disposeClips(): void { - if (this.clipsToDispose.length === 0) { - return; - } - - for (const clip of this.clipsToDispose) { - this.disposeClip(clip); - } - - this.clips = this.clips.filter((clip: Player) => !this.clipsToDispose.includes(clip)); - - for (const clip of this.clipsToDispose) { - const trackIdx = clip.layer - 1; - if (trackIdx >= 0 && trackIdx < this.tracks.length) { - const clipIdx = this.tracks[trackIdx].indexOf(clip); - if (clipIdx !== -1) { - this.tracks[trackIdx].splice(clipIdx, 1); - } - } - } - - this.clipsToDispose = []; - this.updateTotalDuration(); - } - private disposeClip(clip: Player): void { - try { - if (this.getContainer().children.includes(clip.getContainer())) { - const childIndex = this.getContainer().getChildIndex(clip.getContainer()); - this.getContainer().removeChildAt(childIndex); - } else { - for (const child of this.getContainer().children) { - if (child instanceof pixi.Container && child.label?.toString().startsWith("shotstack-track-")) { - if (child.children.includes(clip.getContainer())) { - child.removeChild(clip.getContainer()); - break; - } - } - } - } - } catch (error) { - console.warn(`Attempting to unmount an unmounted clip: ${error}`); - } - - this.unloadClipAssets(clip); - - // Remove from endLengthClips tracking - this.endLengthClips.delete(clip); - - // Invalidate cache since timeline end may have changed - this.cachedTimelineEnd = 0; - - clip.dispose(); - } - private unloadClipAssets(clip: Player): void { - const { asset } = clip.clipConfiguration; - if (asset && "src" in asset && typeof asset.src === "string") { - try { - pixi.Assets.unload(asset.src); - } catch (error) { - console.warn(`Failed to unload asset: ${asset.src}`, error); - } - } - } - protected clearClips(): void { - for (const clip of this.clips) { - this.disposeClip(clip); - } - - this.clips = []; - this.tracks = []; - this.clipsToDispose = []; - - this.updateTotalDuration(); - } - private updateTotalDuration(): void { - let maxDuration = 0; - - for (const track of this.tracks) { - for (const clip of track) { - maxDuration = Math.max(maxDuration, clip.getEnd()); - } - } - - const previousDuration = this.totalDuration; - this.totalDuration = maxDuration; - - // Emit event if duration changed - if (previousDuration !== this.totalDuration) { - this.events.emit("duration:changed", { duration: this.totalDuration }); - } - } - - private async resolveAllTiming(): Promise { - for (let trackIdx = 0; trackIdx < this.tracks.length; trackIdx += 1) { - for (let clipIdx = 0; clipIdx < this.tracks[trackIdx].length; clipIdx += 1) { - const clip = this.tracks[trackIdx][clipIdx]; - const intent = clip.getTimingIntent(); - - let resolvedStart: number; - if (intent.start === "auto") { - resolvedStart = resolveAutoStart(trackIdx, clipIdx, this.tracks); - } else { - resolvedStart = intent.start * 1000; - } - - let resolvedLength: number; - if (intent.length === "auto") { - resolvedLength = await resolveAutoLength(clip.clipConfiguration.asset); - } else if (intent.length === "end") { - resolvedLength = 0; - } else { - resolvedLength = intent.length * 1000; - } - - clip.setResolvedTiming({ start: resolvedStart, length: resolvedLength }); - } - } - - const timelineEnd = calculateTimelineEnd(this.tracks); - - this.cachedTimelineEnd = timelineEnd; - - for (const clip of [...this.endLengthClips]) { - const resolved = clip.getResolvedTiming(); - clip.setResolvedTiming({ - start: resolved.start, - length: resolveEndLength(resolved.start, timelineEnd) - }); - } - } - - public propagateTimingChanges(trackIndex: number, startFromClipIndex: number): void { - const track = this.tracks[trackIndex]; - if (!track) return; - - for (let i = Math.max(0, startFromClipIndex + 1); i < track.length; i += 1) { - const clip = track[i]; - if (clip.getTimingIntent().start === "auto") { - const newStart = resolveAutoStart(trackIndex, i, this.tracks); - clip.setResolvedTiming({ - start: newStart, - length: clip.getLength() - }); - clip.reconfigureAfterRestore(); - } - } - - const newTimelineEnd = calculateTimelineEnd(this.tracks); - if (newTimelineEnd !== this.cachedTimelineEnd) { - this.cachedTimelineEnd = newTimelineEnd; - - for (const clip of [...this.endLengthClips]) { - const newLength = resolveEndLength(clip.getStart(), newTimelineEnd); - const currentLength = clip.getLength(); - - if (Math.abs(newLength - currentLength) > 1) { - clip.setResolvedTiming({ - start: clip.getStart(), - length: newLength - }); - clip.reconfigureAfterRestore(); - } - } - } - - this.updateTotalDuration(); - - // Notify Timeline to update visuals with new timing (use resolved values) - this.events.emit("timeline:updated", { - current: this.getResolvedEdit() - }); - } - - public async resolveClipAutoLength(clip: Player): Promise { - const intent = clip.getTimingIntent(); - if (intent.length !== "auto") return; - - const newLength = await resolveAutoLength(clip.clipConfiguration.asset); - clip.setResolvedTiming({ - start: clip.getStart(), - length: newLength - }); - clip.reconfigureAfterRestore(); - - const indices = this.findClipIndices(clip); - if (indices) { - this.propagateTimingChanges(indices.trackIndex, indices.clipIndex); - } - } - - private addPlayerToContainer(trackIndex: number, player: Player): void { - const zIndex = 100000 - (trackIndex + 1) * Edit.ZIndexPadding; - const trackContainerKey = `shotstack-track-${zIndex}`; - let trackContainer = this.getContainer().getChildByLabel(trackContainerKey, false); - - if (!trackContainer) { - trackContainer = new pixi.Container({ label: trackContainerKey, zIndex }); - this.getContainer().addChild(trackContainer); - } - - trackContainer.addChild(player.getContainer()); - } - - // Move a player's container to the appropriate track container - private movePlayerToTrackContainer(player: Player, fromTrackIdx: number, toTrackIdx: number): void { - if (fromTrackIdx === toTrackIdx) return; - - // Calculate z-indices for track containers - const fromZIndex = 100000 - (fromTrackIdx + 1) * Edit.ZIndexPadding; - const toZIndex = 100000 - (toTrackIdx + 1) * Edit.ZIndexPadding; - - // Get track containers - const fromTrackContainerKey = `shotstack-track-${fromZIndex}`; - const toTrackContainerKey = `shotstack-track-${toZIndex}`; - - const fromTrackContainer = this.getContainer().getChildByLabel(fromTrackContainerKey, false); - let toTrackContainer = this.getContainer().getChildByLabel(toTrackContainerKey, false); - - // Create new track container if it doesn't exist - if (!toTrackContainer) { - toTrackContainer = new pixi.Container({ label: toTrackContainerKey, zIndex: toZIndex }); - this.getContainer().addChild(toTrackContainer); - } - - // Move player container from old track container to new one - if (fromTrackContainer) { - fromTrackContainer.removeChild(player.getContainer()); - } - toTrackContainer.addChild(player.getContainer()); - } - private createPlayerFromAssetType(clipConfiguration: ClipType): Player { - if (!clipConfiguration.asset?.type) { - throw new Error("Invalid clip configuration: missing asset type"); - } - - let player: Player; - - switch (clipConfiguration.asset.type) { - case "text": { - player = new TextPlayer(this, clipConfiguration); - break; - } - case "rich-text": { - player = new RichTextPlayer(this, clipConfiguration); - break; - } - case "shape": { - player = new ShapePlayer(this, clipConfiguration); - break; - } - case "html": { - player = new HtmlPlayer(this, clipConfiguration); - break; - } - case "image": { - player = new ImagePlayer(this, clipConfiguration); - break; - } - case "video": { - player = new VideoPlayer(this, clipConfiguration); - break; - } - case "audio": { - player = new AudioPlayer(this, clipConfiguration); - break; - } - case "luma": { - player = new LumaPlayer(this, clipConfiguration); - break; - } - default: - throw new Error(`Unsupported clip type: ${(clipConfiguration.asset as any).type}`); - } - - return player; - } - private async addPlayer(trackIdx: number, clipToAdd: Player): Promise { - while (this.tracks.length <= trackIdx) { - this.tracks.push([]); - } - - this.tracks[trackIdx].push(clipToAdd); - - this.clips.push(clipToAdd); - - if (clipToAdd.getTimingIntent().length === "end") { - this.endLengthClips.add(clipToAdd); - } - - const zIndex = 100000 - (trackIdx + 1) * Edit.ZIndexPadding; - - const trackContainerKey = `shotstack-track-${zIndex}`; - let trackContainer = this.getContainer().getChildByLabel(trackContainerKey, false); - - if (!trackContainer) { - trackContainer = new pixi.Container({ label: trackContainerKey, zIndex }); - this.getContainer().addChild(trackContainer); - } - - trackContainer.addChild(clipToAdd.getContainer()); - - const isClipMask = clipToAdd instanceof LumaPlayer; - - await clipToAdd.load(); - - if (isClipMask) { - trackContainer.setMask({ mask: clipToAdd.getMask(), inverse: true }); - } - - this.updateTotalDuration(); - } - public selectClip(trackIndex: number, clipIndex: number): void { - const command = new SelectClipCommand(trackIndex, clipIndex); - this.executeCommand(command); - } - public clearSelection(): void { - const command = new ClearSelectionCommand(); - this.executeCommand(command); - } - public isClipSelected(trackIndex: number, clipIndex: number): boolean { - if (!this.selectedClip) return false; - - const selectedTrackIndex = this.selectedClip.layer - 1; - const selectedClipIndex = this.tracks[selectedTrackIndex].indexOf(this.selectedClip); - - return trackIndex === selectedTrackIndex && clipIndex === selectedClipIndex; - } - public getSelectedClipInfo(): { trackIndex: number; clipIndex: number; player: Player } | null { - if (!this.selectedClip) return null; - - const trackIndex = this.selectedClip.layer - 1; - const clipIndex = this.tracks[trackIndex].indexOf(this.selectedClip); - - return { trackIndex, clipIndex, player: this.selectedClip }; - } - public findClipIndices(player: Player): { trackIndex: number; clipIndex: number } | null { - for (let trackIndex = 0; trackIndex < this.tracks.length; trackIndex += 1) { - const clipIndex = this.tracks[trackIndex].indexOf(player); - if (clipIndex !== -1) { - return { trackIndex, clipIndex }; - } - } - return null; - } - public getClipAt(trackIndex: number, clipIndex: number): Player | null { - if (trackIndex >= 0 && trackIndex < this.tracks.length && clipIndex >= 0 && clipIndex < this.tracks[trackIndex].length) { - return this.tracks[trackIndex][clipIndex]; - } - return null; - } - public selectPlayer(player: Player): void { - const indices = this.findClipIndices(player); - if (indices) { - this.selectClip(indices.trackIndex, indices.clipIndex); - } - } - public isPlayerSelected(player: Player): boolean { - if (this.isExporting) return false; - return this.selectedClip === player; - } - public setExportMode(exporting: boolean): void { - this.isExporting = exporting; - } - public isInExportMode(): boolean { - return this.isExporting; - } - - private setupIntentListeners(): void { - this.events.on("timeline:clip:clicked", (data: { player: Player; trackIndex: number; clipIndex: number }) => { - if (data.player) { - this.selectPlayer(data.player); - } else { - this.selectClip(data.trackIndex, data.clipIndex); - } - }); - - this.events.on("timeline:background:clicked", () => { - this.clearSelection(); - }); - - this.events.on("canvas:clip:clicked", (data: { player: Player }) => { - this.selectPlayer(data.player); - }); - - this.events.on("canvas:background:clicked", () => { - this.clearSelection(); - }); - } -} diff --git a/src/core/events/edit-events.ts b/src/core/events/edit-events.ts new file mode 100644 index 00000000..a2bcc85d --- /dev/null +++ b/src/core/events/edit-events.ts @@ -0,0 +1,206 @@ +import type { Player } from "@canvas/players/player"; +import type { MergeField } from "@core/merge/types"; +import type { Clip, Destination, Edit as EditConfig, Output, ResolvedEdit } from "@schemas"; + +// ───────────────────────────────────────────────────────────── +// Event Emission Patterns +// ───────────────────────────────────────────────────────────── +// +// Events are emitted from 4 different contexts, each with its own pattern: +// +// 1. EditSession (direct emit): +// this.internalEvents.emit(EditEvent.TimelineUpdated, { current }); +// → EditSession owns the EventEmitter, so it emits directly. +// +// 2. EditSession (emitEditChanged wrapper): +// this.emitEditChanged("command-name"); +// → Special wrapper for EditChanged only. Has batching (skips if +// isBatchingEvents is true) and auto-adds timestamp. +// +// 3. Commands (context delegation): +// context.emitEvent(EditEvent.ClipUpdated, { previous, current }); +// → Commands receive a CommandContext to stay decoupled from Edit. +// This enables testing commands in isolation. +// +// 4. Managers (delegate through edit): +// this.edit.getInternalEvents().emit(EditEvent.OutputResized, { width, height }); +// → Managers hold a reference to Edit and delegate via getInternalEvents(). +// +// ───────────────────────────────────────────────────────────── + +// ───────────────────────────────────────────────────────────── +// Shared Payload Types +// ───────────────────────────────────────────────────────────── + +export type ClipLocation = { + trackIndex: number; + clipIndex: number; +}; + +/** + * Reference to a clip from the document (source of truth). + * Contains original timing values like "auto", "end", and alias references. + * Used in public SDK events so consumers see the document state. + */ +export type ClipReference = ClipLocation & { + clip: Clip; +}; + +// ───────────────────────────────────────────────────────────── +// Public Events (External API) +// ───────────────────────────────────────────────────────────── + +export const EditEvent = { + // Playback + PlaybackPlay: "playback:play", + PlaybackPause: "playback:pause", + + // Timeline structure + TimelineUpdated: "timeline:updated", + TimelineBackgroundChanged: "timeline:backgroundChanged", + + // Clip lifecycle + ClipAdded: "clip:added", + ClipSelected: "clip:selected", + ClipUpdated: "clip:updated", + ClipDeleted: "clip:deleted", + ClipRestored: "clip:restored", + ClipCopied: "clip:copied", + ClipLoadFailed: "clip:loadFailed", + ClipUnresolved: "clip:unresolved", + + // Selection + SelectionCleared: "selection:cleared", + + // Edit state + EditChanged: "edit:changed", + EditUndo: "edit:undo", + EditRedo: "edit:redo", + + // Track + TrackAdded: "track:added", + TrackRemoved: "track:removed", + + // Duration + DurationChanged: "duration:changed", + + // Output configuration + OutputResized: "output:resized", + OutputResolutionChanged: "output:resolutionChanged", + OutputAspectRatioChanged: "output:aspectRatioChanged", + OutputFpsChanged: "output:fpsChanged", + OutputFormatChanged: "output:formatChanged", + OutputDestinationsChanged: "output:destinationsChanged", + + // Merge fields + MergeFieldChanged: "mergefield:changed" +} as const; + +export type EditEventName = (typeof EditEvent)[keyof typeof EditEvent]; + +// ───────────────────────────────────────────────────────────── +// Internal Events (SDK Plumbing - Not Exported) +// ───────────────────────────────────────────────────────────── + +// Internal SDK component communication - not part of public API +export const InternalEvent = { + // Canvas → Edit communication + CanvasClipClicked: "canvas:clipClicked", + CanvasBackgroundClicked: "canvas:backgroundClicked", + + // Font capability detection + FontCapabilitiesChanged: "font:capabilitiesChanged", + + // Resolution - document to resolved edit transformation + Resolved: "resolved", + + // Edit → Canvas visual sync + PlayerAddedToTrack: "player:addedToTrack", + PlayerMovedBetweenTracks: "player:movedBetweenTracks", + PlayerRemovedFromTrack: "player:removedFromTrack", + PlayerLoaded: "player:loaded", + TrackContainerRemoved: "track:containerRemoved", + ViewportSizeChanged: "viewport:sizeChanged", + ViewportNeedsZoomToFit: "viewport:needsZoomToFit" +} as const; + +// ───────────────────────────────────────────────────────────── +// Event Payload Maps +// ───────────────────────────────────────────────────────────── + +export type EditEventMap = { + // Playback + [EditEvent.PlaybackPlay]: void; + [EditEvent.PlaybackPause]: void; + + // Timeline + /** Contains the document (source of truth) with original timing values like "auto", "end" */ + [EditEvent.TimelineUpdated]: { current: EditConfig }; + [EditEvent.TimelineBackgroundChanged]: { color: string }; + + // Clip lifecycle + [EditEvent.ClipAdded]: ClipLocation; + [EditEvent.ClipSelected]: ClipReference; + [EditEvent.ClipUpdated]: { previous: ClipReference; current: ClipReference }; + [EditEvent.ClipDeleted]: ClipLocation; + [EditEvent.ClipRestored]: ClipLocation; + [EditEvent.ClipCopied]: ClipLocation; + [EditEvent.ClipLoadFailed]: ClipLocation & { error: string; assetType: string }; + [EditEvent.ClipUnresolved]: ClipLocation & { assetType: string; clipId: string }; + + // Selection + [EditEvent.SelectionCleared]: void; + + // Edit state + [EditEvent.EditChanged]: { source: string; timestamp: number }; + [EditEvent.EditUndo]: { command: string }; + [EditEvent.EditRedo]: { command: string }; + + // Track + [EditEvent.TrackAdded]: { trackIndex: number; totalTracks: number }; + [EditEvent.TrackRemoved]: { trackIndex: number }; + + // Duration + [EditEvent.DurationChanged]: { duration: number }; + + // Output + [EditEvent.OutputResized]: { width: number; height: number }; + [EditEvent.OutputResolutionChanged]: { resolution: Output["resolution"] }; + [EditEvent.OutputAspectRatioChanged]: { aspectRatio: Output["aspectRatio"] }; + [EditEvent.OutputFpsChanged]: { fps: number }; + [EditEvent.OutputFormatChanged]: { format: Output["format"] }; + [EditEvent.OutputDestinationsChanged]: { destinations: Destination[] }; + + // Merge fields + [EditEvent.MergeFieldChanged]: { fields: MergeField[] }; +}; + +// Internal event payloads - not part of public API +export type InternalEventMap = { + // Canvas interaction + [InternalEvent.CanvasClipClicked]: { player: Player }; + [InternalEvent.CanvasBackgroundClicked]: void; + + // Font + [InternalEvent.FontCapabilitiesChanged]: { supportsBold: boolean }; + + // Resolution + [InternalEvent.Resolved]: { edit: ResolvedEdit }; + + // Edit → Canvas visual sync + [InternalEvent.PlayerAddedToTrack]: { player: Player; trackIndex: number }; + [InternalEvent.PlayerMovedBetweenTracks]: { + player: Player; + fromTrackIndex: number; + toTrackIndex: number; + }; + [InternalEvent.PlayerRemovedFromTrack]: { player: Player; trackIndex: number }; + [InternalEvent.PlayerLoaded]: { player: Player; trackIndex: number; clipIndex: number }; + [InternalEvent.TrackContainerRemoved]: { trackIndex: number }; + [InternalEvent.ViewportSizeChanged]: { + width: number; + height: number; + backgroundColor: string; + }; + [InternalEvent.ViewportNeedsZoomToFit]: void; +}; diff --git a/src/core/events/event-emitter.ts b/src/core/events/event-emitter.ts index 560c2bc5..795357fa 100644 --- a/src/core/events/event-emitter.ts +++ b/src/core/events/event-emitter.ts @@ -2,6 +2,16 @@ export type Listener = (payload: TPayload) => void; export type EventPayloadMap = Record; +/** + * Read-only view of an EventEmitter that only exposes subscription methods. + * Used as the public `events` type on Edit to prevent consumers from emitting events. + */ +export interface ReadonlyEventEmitter { + on(name: K, listener: Listener): () => void; + once(name: K, listener: Listener): () => void; + off(name: K, listener: Listener): void; +} + export class EventEmitter { private readonly events: { [K in keyof TEventPayloadMap]?: Set>; @@ -11,12 +21,23 @@ export class EventEmitter(name: TEventName, listener: Listener): void { + public on(name: TEventName, listener: Listener): () => void { if (!this.events[name]) { this.events[name] = new Set(); } this.events[name].add(listener); + + return () => this.off(name, listener); + } + + public once(name: TEventName, listener: Listener): () => void { + const wrappedListener = ((payload: TEventPayloadMap[TEventName]) => { + this.off(name, wrappedListener); + listener(payload); + }) as Listener; + + return this.on(name, wrappedListener); } public off(name: TEventName, listener: Listener): void { @@ -32,15 +53,21 @@ export class EventEmitter(name: TEventName, payload: TEventPayloadMap[TEventName]): void { + /** @internal */ + public emit( + name: TEventName, + ...args: TEventPayloadMap[TEventName] extends void ? [] : [TEventPayloadMap[TEventName]] + ): void { if (!this.events[name]) { return; } + const payload = args[0] as TEventPayloadMap[TEventName]; for (const listener of this.events[name]) { listener(payload); } diff --git a/src/core/export/audio-processor.ts b/src/core/export/audio-processor.ts index e46b8aa7..d438f778 100644 --- a/src/core/export/audio-processor.ts +++ b/src/core/export/audio-processor.ts @@ -1,5 +1,6 @@ import { AudioPlayer } from "@canvas/players/audio-player"; -import type { AudioAsset } from "@schemas/audio-asset"; +import { PlayerType } from "@canvas/players/player"; +import type { AudioAsset } from "@schemas"; import { Output, AudioSampleSource, AudioSample } from "mediabunny"; export class AudioProcessor { @@ -87,13 +88,11 @@ export class AudioProcessor { } private isAudioPlayer(clip: unknown): clip is AudioPlayer { - if (clip instanceof AudioPlayer) return true; if (!clip || typeof clip !== "object") return false; - const c = clip as Record; - const hasAudioConstructor = c.constructor?.name === "AudioPlayer"; - const config = c["clipConfiguration"] as Record | undefined; - const asset = config?.["asset"] as Record | undefined; - const hasAudioAsset = asset?.["type"] === "audio"; - return hasAudioConstructor || hasAudioAsset; + const c = clip as { playerType?: string }; + if (c.playerType === PlayerType.Audio) return true; + // Fallback for cases where playerType might not exist + const config = (clip as { clipConfiguration?: { asset?: { type?: string } } }).clipConfiguration; + return config?.asset?.type === "audio"; } } diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 9593312f..6de36ffb 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -1,6 +1,7 @@ import { Canvas } from "@canvas/shotstack-canvas"; import { ExportCommand } from "@core/commands/export-command"; -import { Edit } from "@core/edit"; +import { Edit } from "@core/edit-session"; +import { sec } from "@core/timing/types"; import { Output, Mp4OutputFormat, BufferTarget, CanvasSource } from "mediabunny"; import * as pixi from "pixi.js"; @@ -55,10 +56,11 @@ export class ExportCoordinator { this.progressUI.create(); this.canvas.pauseTicker(); + this.edit.executeEditCommand(this.exportCommand); + const cfg = this.prepareConfig(fps ?? this.edit.getEdit().output?.fps ?? 30); this.progressUI.update(0, 100, "Preparing..."); - this.edit.executeEditCommand(this.exportCommand); await this.videoProcessor.initialize(this.exportCommand.getClips()); this.progressUI.update(10, 100, "Video ready"); @@ -116,7 +118,7 @@ export class ExportCoordinator { _canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D ): Promise { - const container = this.edit.getContainer(); + const container = this.edit.getViewportContainer(); this.edit.pause(); Object.assign(container.position, { x: 0, y: 0 }); Object.assign(container.scale, { x: 1, y: 1 }); @@ -126,7 +128,7 @@ export class ExportCoordinator { for (let i = 0; i < cfg.frames; i += 1) { const frameTime = i * cfg.frameDuration; - this.edit.playbackTime = frameTime; + this.edit.playbackTime = sec(frameTime); for (const clip of this.exportCommand.getClips()) { clip.update(0, 0); @@ -140,7 +142,6 @@ export class ExportCoordinator { } } - this.edit.draw(); this.app.renderer.render(this.app.stage); const pixels = this.app.renderer.extract.pixels({ @@ -173,18 +174,19 @@ export class ExportCoordinator { } private prepareConfig(fps: number): ExportConfig { - const size = this.edit.getEdit().output?.size || { width: 1920, height: 1080 }; - const durationSec = this.edit.totalDuration / 1000; + const outputSize = this.edit.getEdit().output?.size; + const size = { width: outputSize?.width ?? 1920, height: outputSize?.height ?? 1080 }; + // totalDuration is in seconds return { fps, size, - frames: Math.ceil(durationSec * fps), - frameDuration: 1000 / fps + frames: Math.ceil(this.edit.totalDuration * fps), + frameDuration: 1 / fps // seconds per frame }; } private saveEditState(): EditState { - const c = this.edit.getContainer(); + const c = this.edit.getViewportContainer(); return { wasPlaying: this.edit.isPlaying, time: this.edit.playbackTime, @@ -195,7 +197,7 @@ export class ExportCoordinator { } private restoreEditState(state: EditState): void { - const c = this.edit.getContainer(); + const c = this.edit.getViewportContainer(); this.edit.setExportMode(false); for (const clip of this.exportCommand.getClips()) { @@ -220,7 +222,7 @@ export class ExportCoordinator { Object.assign(c.position, state.pos); Object.assign(c.scale, state.scale); c.visible = state.visible; - this.edit.seek(state.time); + this.edit.seek(sec(state.time)); if (state.wasPlaying) this.edit.play(); } diff --git a/src/core/export/export-progress-ui.ts b/src/core/export/export-progress-ui.ts index 0e6210be..ee8adb51 100644 --- a/src/core/export/export-progress-ui.ts +++ b/src/core/export/export-progress-ui.ts @@ -7,7 +7,7 @@ export class ExportProgressUI { create(): void { this.overlay = Object.assign(document.createElement("div"), { style: - "position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:9999;display:flex;justify-content:center;align-items:center;color:white;font-family:Arial" + "position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:20;display:flex;justify-content:center;align-items:center;color:white;font-family:Arial" }); const card = Object.assign(document.createElement("div"), { diff --git a/src/core/export/export-utils.ts b/src/core/export/export-utils.ts index f6f33f93..cb70d8df 100644 --- a/src/core/export/export-utils.ts +++ b/src/core/export/export-utils.ts @@ -41,7 +41,7 @@ export class ExportError extends Error { constructor( message: string, public readonly phase: string = "unknown", - public readonly context: Record = {}, + public readonly context: Record = {}, cause?: Error ) { super(message); diff --git a/src/core/fonts/font-config.ts b/src/core/fonts/font-config.ts new file mode 100644 index 00000000..27c11525 --- /dev/null +++ b/src/core/fonts/font-config.ts @@ -0,0 +1,124 @@ +/** + * Shared font configuration for text and rich-text players + */ + +import { GOOGLE_FONTS_BY_FILENAME, GOOGLE_FONTS_BY_NAME } from "./google-fonts"; + +const FONT_CDN = "https://templates.shotstack.io/basic/asset/font"; + +/** Font family name to file path mapping */ +export const FONT_PATHS: Record = { + Arapey: `${FONT_CDN}/arapey-regular.ttf`, + "Clear Sans": `${FONT_CDN}/clearsans-regular.ttf`, + "Clear Sans Bold": `${FONT_CDN}/clearsans-bold.ttf`, + "Didact Gothic": `${FONT_CDN}/didactgothic-regular.ttf`, + Montserrat: `${FONT_CDN}/montserrat-regular.ttf`, + "Montserrat Bold": `${FONT_CDN}/montserrat-bold.ttf`, + "Montserrat ExtraBold": `${FONT_CDN}/montserrat-extrabold.ttf`, + "Montserrat SemiBold": `${FONT_CDN}/montserrat-semibold.ttf`, + "Montserrat Light": `${FONT_CDN}/montserrat-light.ttf`, + "Montserrat Medium": `${FONT_CDN}/montserrat-medium.ttf`, + "Montserrat Black": `${FONT_CDN}/montserrat-black.ttf`, + MovLette: `${FONT_CDN}/movlette.ttf`, + "Open Sans": `${FONT_CDN}/opensans-regular.ttf`, + "Open Sans Bold": `${FONT_CDN}/opensans-bold.ttf`, + "Open Sans ExtraBold": `${FONT_CDN}/opensans-extrabold.ttf`, + "Permanent Marker": `${FONT_CDN}/permanentmarker-regular.ttf`, + Roboto: `${FONT_CDN}/roboto-regular.ttf`, + "Roboto Bold": `${FONT_CDN}/roboto-bold.ttf`, + "Roboto Light": `${FONT_CDN}/roboto-light.ttf`, + "Roboto Medium": `${FONT_CDN}/roboto-medium.ttf`, + "Sue Ellen Francisco": `${FONT_CDN}/sueellenfrancisco-regular.ttf`, + "Work Sans": `${FONT_CDN}/worksans.ttf` +}; + +/** Alternative names (camelCase, etc.) mapped to canonical names */ +export const FONT_ALIASES: Record = { + ClearSans: "Clear Sans", + DidactGothic: "Didact Gothic", + OpenSans: "Open Sans", + PermanentMarker: "Permanent Marker", + SueEllenFrancisco: "Sue Ellen Francisco", + WorkSans: "Work Sans" +}; + +/** Weight modifier suffixes mapped to CSS font-weight values */ +export const WEIGHT_MODIFIERS: Record = { + Thin: 100, + ExtraLight: 200, + Light: 300, + Regular: 400, + Medium: 500, + SemiBold: 600, + Bold: 700, + ExtraBold: 800, + Black: 900 +}; + +/** + * Parse a font family name to extract base family and weight + * e.g., "Montserrat ExtraBold" → { baseFontFamily: "Montserrat", fontWeight: 800 } + * Case-insensitive matching for weight modifiers (handles "Extrabold", "ExtraBold", etc.) + */ +export function parseFontFamily(fontFamily: string): { baseFontFamily: string; fontWeight: number } { + const lowerFamily = fontFamily.toLowerCase(); + for (const [modifier, weight] of Object.entries(WEIGHT_MODIFIERS)) { + const lowerModifier = ` ${modifier.toLowerCase()}`; + if (lowerFamily.endsWith(lowerModifier)) { + return { + baseFontFamily: fontFamily.slice(0, -modifier.length - 1), + fontWeight: weight + }; + } + } + return { baseFontFamily: fontFamily, fontWeight: 400 }; +} + +/** + * Resolve a font family name to its file path + */ +export function resolveFontPath(fontFamily: string): string | undefined { + // Try Google Fonts by filename hash (from FontPicker selection) + const googleFontByFilename = GOOGLE_FONTS_BY_FILENAME.get(fontFamily); + if (googleFontByFilename) { + return googleFontByFilename.url; + } + + // Try built-in fonts by exact match (e.g., "Montserrat ExtraBold") + if (FONT_PATHS[fontFamily]) { + return FONT_PATHS[fontFamily]; + } + + // Try built-in fonts by alias or base name + const { baseFontFamily } = parseFontFamily(fontFamily); + const resolvedName = FONT_ALIASES[baseFontFamily] ?? baseFontFamily; + if (FONT_PATHS[resolvedName]) { + return FONT_PATHS[resolvedName]; + } + + // Fall back to Google Fonts by display name (for fonts not in built-in list) + const googleFontByName = GOOGLE_FONTS_BY_NAME.get(fontFamily); + if (googleFontByName) { + return googleFontByName.url; + } + + return undefined; +} + +/** + * Check if a font family is a Google Font + */ +export function isGoogleFont(fontFamily: string): boolean { + return GOOGLE_FONTS_BY_FILENAME.has(fontFamily) || GOOGLE_FONTS_BY_NAME.has(fontFamily); +} + +/** + * Get the display name for a font (resolves Google Font filename hashes to readable names) + */ +export function getFontDisplayName(fontFamily: string): string { + const googleFont = GOOGLE_FONTS_BY_FILENAME.get(fontFamily); + if (googleFont) { + return googleFont.displayName; + } + return fontFamily; +} diff --git a/src/core/fonts/google-fonts.ts b/src/core/fonts/google-fonts.ts new file mode 100644 index 00000000..e4ebb5a8 --- /dev/null +++ b/src/core/fonts/google-fonts.ts @@ -0,0 +1,15309 @@ +/** + * Google Fonts Metadata + * + * Auto-generated by scripts/fetch-google-fonts.ts + * DO NOT EDIT MANUALLY + * + * Contains 1909 fonts from Google Fonts, sorted by popularity. + * Generated: 2026-01-29T22:13:17.925Z + */ + +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[] = [ + { + displayName: "Roboto", + filename: "KFOmCnqEu92Fr1Me5WZLCzYlKw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/roboto/v50/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Open Sans", + filename: "mem8YaGs126MiZpBA-U1UpcaXcl0Aw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/opensans/v44/mem8YaGs126MiZpBA-U1UpcaXcl0Aw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Google Sans", + filename: "4UaGrENHsxJlGDuGo1OIlI3JyJ98KhtH", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/googlesans/v67/4UaGrENHsxJlGDuGo1OIlI3JyJ98KhtH.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans JP", + filename: "-F62fjtqLzI2JPCgQBnw7HFoxgIO2lZ9hg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansjp/v56/-F62fjtqLzI2JPCgQBnw7HFoxgIO2lZ9hg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Inter", + filename: "UcCo3FwrK3iLTfvlaQc78lA2", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/inter/v20/UcCo3FwrK3iLTfvlaQc78lA2.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Montserrat", + filename: "JTUSjIg1_i6t8kCHKm45xW5rygbi49c", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/montserrat/v31/JTUSjIg1_i6t8kCHKm45xW5rygbi49c.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Poppins", + filename: "pxiEyp8kv8JHgFVrFJDUc1NECPY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/poppins/v24/pxiEyp8kv8JHgFVrFJDUc1NECPY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lato", + filename: "S6uyw4BMUTPHvxk6XweuBCY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHvxk6XweuBCY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Roboto Condensed", + filename: "ieVl2ZhZI2eCN5jzbjEETS9weq8-59WxDCs5cvI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/robotocondensed/v31/ieVl2ZhZI2eCN5jzbjEETS9weq8-59WxDCs5cvI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Arimo", + filename: "P5sMzZCDf9_T_20eziBMjI-u", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/arimo/v35/P5sMzZCDf9_T_20eziBMjI-u.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Roboto Mono", + filename: "L0x5DF4xlVMF-BfR8bXMIghMoX6-XqKC", + category: "monospace", + url: "https://fonts.gstatic.com/s/robotomono/v31/L0x5DF4xlVMF-BfR8bXMIghMoX6-XqKC.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans", + filename: "o-0IIpQlx3QUlC5A4PNb4j5Ba_2c7A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosans/v42/o-0IIpQlx3QUlC5A4PNb4j5Ba_2c7A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Oswald", + filename: "TK3iWkUHHAIjg75GHjUHte5fKg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/oswald/v57/TK3iWkUHHAIjg75GHjUHte5fKg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Raleway", + filename: "1Ptug8zYS_SKggPN-CoCTqluHfE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/raleway/v37/1Ptug8zYS_SKggPN-CoCTqluHfE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Nunito Sans", + filename: "pe0qMImSLYBIv1o4X1M8cfe6Kdpickwp", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/nunitosans/v19/pe0qMImSLYBIv1o4X1M8cfe6Kdpickwp.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Nunito", + filename: "XRXV3I6Li01BKof4MuyAbsrVcA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/nunito/v32/XRXV3I6Li01BKof4MuyAbsrVcA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playfair Display", + filename: "nuFiD-vYSZviVYUb_rj3ij__anPXPTvSgWE_-xU", + category: "serif", + url: "https://fonts.gstatic.com/s/playfairdisplay/v40/nuFiD-vYSZviVYUb_rj3ij__anPXPTvSgWE_-xU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rubik", + filename: "iJWKBXyIfDnIV4nGp32S0H3f", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rubik/v31/iJWKBXyIfDnIV4nGp32S0H3f.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ubuntu", + filename: "4iCs6KVjbNBYlgo6eAT3v02QFg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ubuntu/v21/4iCs6KVjbNBYlgo6eAT3v02QFg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Roboto Slab", + filename: "BngMUXZYTXPIvIBgJJSb6tfK7KSJ4ACD", + category: "serif", + url: "https://fonts.gstatic.com/s/robotoslab/v36/BngMUXZYTXPIvIBgJJSb6tfK7KSJ4ACD.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "DM Sans", + filename: "rP2Hp2ywxg089UriOZSCHBeHFl0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/dmsans/v17/rP2Hp2ywxg089UriOZSCHBeHFl0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Merriweather", + filename: "u-440qyriQwlOrhSvowK_l5OeyxNV-bnrw", + category: "serif", + url: "https://fonts.gstatic.com/s/merriweather/v33/u-440qyriQwlOrhSvowK_l5OeyxNV-bnrw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans KR", + filename: "PbykFmXiEBPT4ITbgNA5Cgm21nTs4JMMuA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskr/v39/PbykFmXiEBPT4ITbgNA5Cgm21nTs4JMMuA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Work Sans", + filename: "QGYsz_wNahGAdqQ43RhPe6rol_lQ4A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/worksans/v24/QGYsz_wNahGAdqQ43RhPe6rol_lQ4A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "PT Sans", + filename: "jizaRExUiTo99u79P0WOxOGMMDQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ptsans/v18/jizaRExUiTo99u79P0WOxOGMMDQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lora", + filename: "0QIvMX1D_JOuAw3xItNPh_A", + category: "serif", + url: "https://fonts.gstatic.com/s/lora/v37/0QIvMX1D_JOuAw3xItNPh_A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mulish", + filename: "1Ptvg83HX_SGhgqU2AAsQqB3BA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mulish/v18/1Ptvg83HX_SGhgqU2AAsQqB3BA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Kanit", + filename: "nKKZ-Go6G5tXcoaSEQGodLxA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kanit/v17/nKKZ-Go6G5tXcoaSEQGodLxA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Quicksand", + filename: "6xKtdSZaM9iE8KbpRA_RLF4MQOPiPg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/quicksand/v37/6xKtdSZaM9iE8KbpRA_RLF4MQOPiPg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Manrope", + filename: "xn7gYHE41ni1AdIRsgC7S9XdZN8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/manrope/v20/xn7gYHE41ni1AdIRsgC7S9XdZN8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Archivo Black", + filename: "HTxqL289NzCGg4MzN6KJ7eW6OYuP_x7yx3A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/archivoblack/v23/HTxqL289NzCGg4MzN6KJ7eW6OYuP_x7yx3A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Archivo", + filename: "k3kQo8UDI-1M0wlSTd7iL0nAMaM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/archivo/v25/k3kQo8UDI-1M0wlSTd7iL0nAMaM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Fjalla One", + filename: "Yq6R-LCAWCX3-6Ky7FAFnOZwkxgtUb8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/fjallaone/v16/Yq6R-LCAWCX3-6Ky7FAFnOZwkxgtUb8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans TC", + filename: "-nF7OG829Oofr2wohFbTp9iFPysLA_ZJ1g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstc/v39/-nF7OG829Oofr2wohFbTp9iFPysLA_ZJ1g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Outfit", + filename: "QGYvz_MVcBeNP4N5s0Frc4H0ng", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/outfit/v15/QGYvz_MVcBeNP4N5s0Frc4H0ng.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Fira Sans", + filename: "va9E4kDNxMZdWfMOD5VfkILKSTbndQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/firasans/v18/va9E4kDNxMZdWfMOD5VfkILKSTbndQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Inconsolata", + filename: "QldKNThLqRwH-OJ1UHjlKFle7KlmxuHx", + category: "monospace", + url: "https://fonts.gstatic.com/s/inconsolata/v37/QldKNThLqRwH-OJ1UHjlKFle7KlmxuHx.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Figtree", + filename: "_Xms-HUzqDCFdgfMq4O3DIZs3ik", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/figtree/v9/_Xms-HUzqDCFdgfMq4O3DIZs3ik.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bebas Neue", + filename: "JTUSjIg69CK48gW7PXooxW5rygbi49c", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bebasneue/v16/JTUSjIg69CK48gW7PXooxW5rygbi49c.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Sans", + filename: "zYXgKVElMYYaJe8bpLHnCwDKtdbUFI5NadY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsans/v23/zYXgKVElMYYaJe8bpLHnCwDKtdbUFI5NadY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Source Sans 3", + filename: "nwpStKy2OAdR1K-IwhWudF-R7wgQZMrc9HY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sourcesans3/v19/nwpStKy2OAdR1K-IwhWudF-R7wgQZMrc9HY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Barlow", + filename: "7cHpv4kjgoGqM7EPC8E46HsxnA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/barlow/v13/7cHpv4kjgoGqM7EPC8E46HsxnA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hind Siliguri", + filename: "ijwTs5juQtsyLLR5jN4cxBEofJvQxuk0Nig", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hindsiliguri/v14/ijwTs5juQtsyLLR5jN4cxBEofJvQxuk0Nig.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gravitas One", + filename: "5h1diZ4hJ3cblKy3LWakKQmaDWRNr3DzbQ", + category: "display", + url: "https://fonts.gstatic.com/s/gravitasone/v21/5h1diZ4hJ3cblKy3LWakKQmaDWRNr3DzbQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Prompt", + filename: "-W__XJnvUD7dzB26Z9AcZkIzeg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/prompt/v12/-W__XJnvUD7dzB26Z9AcZkIzeg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Titillium Web", + filename: "NaPecZTIAOhVxoMyOr9n_E7fRMTsDIRSfr0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/titilliumweb/v19/NaPecZTIAOhVxoMyOr9n_E7fRMTsDIRSfr0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif", + filename: "ga6Iaw1J5X9T9RW6j9bNTFAcaRi_bMQ", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserif/v33/ga6Iaw1J5X9T9RW6j9bNTFAcaRi_bMQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Karla", + filename: "qkBbXvYC6trAT4RSJN225aZO", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/karla/v33/qkBbXvYC6trAT4RSJN225aZO.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bungee", + filename: "N0bU2SZBIuF2PU_ECn50Kd_PmA", + category: "display", + url: "https://fonts.gstatic.com/s/bungee/v17/N0bU2SZBIuF2PU_ECn50Kd_PmA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "PT Serif", + filename: "EJRVQgYoZZY2vCFuvDFRxL6ddjb-", + category: "serif", + url: "https://fonts.gstatic.com/s/ptserif/v19/EJRVQgYoZZY2vCFuvDFRxL6ddjb-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Heebo", + filename: "NGS6v5_NC0k9P-HxR7BDsbMB", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/heebo/v28/NGS6v5_NC0k9P-HxR7BDsbMB.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Saira", + filename: "memwYa2wxmKQyOkgR5IdU6Uf", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/saira/v23/memwYa2wxmKQyOkgR5IdU6Uf.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Jost", + filename: "92zatBhPNqw77oPX4xYlbxM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/jost/v20/92zatBhPNqw77oPX4xYlbxM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Dancing Script", + filename: "If2RXTr6YS-zF4S-kcSWSVi_swLngOAliz4X", + category: "handwriting", + url: "https://fonts.gstatic.com/s/dancingscript/v29/If2RXTr6YS-zF4S-kcSWSVi_swLngOAliz4X.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bricolage Grotesque", + filename: "3y996as8bTXq_nANBjzKo3IeZx8z6up5H--HGN6NLPo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bricolagegrotesque/v9/3y996as8bTXq_nANBjzKo3IeZx8z6up5H--HGN6NLPo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Share Tech", + filename: "7cHtv4Uyi5K0OeZ7bohUwHoDmTcibrA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sharetech/v23/7cHtv4Uyi5K0OeZ7bohUwHoDmTcibrA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libre Baskerville", + filename: "kmKnZrc3Hgbbcjq75U4uslyuy4kn0pNeYRI4CN2V", + category: "serif", + url: "https://fonts.gstatic.com/s/librebaskerville/v24/kmKnZrc3Hgbbcjq75U4uslyuy4kn0pNeYRI4CN2V.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Smooch Sans", + filename: "c4mk1n5uGsXss2LJh1QH6ad91qa3WFv8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/smoochsans/v15/c4mk1n5uGsXss2LJh1QH6ad91qa3WFv8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Source Code Pro", + filename: "HI_SiYsKILxRpg3hIP6sJ7fM7PqVOuHXvMY3xw", + category: "monospace", + url: "https://fonts.gstatic.com/s/sourcecodepro/v31/HI_SiYsKILxRpg3hIP6sJ7fM7PqVOuHXvMY3xw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Plus Jakarta Sans", + filename: "LDIoaomQNQcsA88c7O9yZ4KMCoOg4Jox2S2CgOva", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/plusjakartasans/v12/LDIoaomQNQcsA88c7O9yZ4KMCoOg4Jox2S2CgOva.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "EB Garamond", + filename: "SlGUmQSNjdsmc35JDF1K5FRyQjgdYxPJ", + category: "serif", + url: "https://fonts.gstatic.com/s/ebgaramond/v32/SlGUmQSNjdsmc35JDF1K5FRyQjgdYxPJ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Libre Franklin", + filename: "jizDREVItHgc8qDIbSTKq4XkRhUY0TY7ikbI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/librefranklin/v20/jizDREVItHgc8qDIbSTKq4XkRhUY0TY7ikbI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Cairo", + filename: "SLXGc1nY6HkvamImRJqExst1", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cairo/v31/SLXGc1nY6HkvamImRJqExst1.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Josefin Sans", + filename: "Qw3aZQNVED7rKGKxtqIqX5EkCnZ5dHw8iw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/josefinsans/v34/Qw3aZQNVED7rKGKxtqIqX5EkCnZ5dHw8iw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Anton", + filename: "1Ptgg87LROyAm0K08i4gS7lu", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anton/v27/1Ptgg87LROyAm0K08i4gS7lu.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif JP", + filename: "xn7mYHs72GKoTvER4Gn3b5eMXN6kYkY0T84", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifjp/v33/xn7mYHs72GKoTvER4Gn3b5eMXN6kYkY0T84.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Tinos", + filename: "buE4poGnedXvwgX8dGVh8TI-", + category: "serif", + url: "https://fonts.gstatic.com/s/tinos/v25/buE4poGnedXvwgX8dGVh8TI-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Public Sans", + filename: "ijwRs572Xtc6ZYQws9YVwkNBdp_yw_k0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/publicsans/v21/ijwRs572Xtc6ZYQws9YVwkNBdp_yw_k0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Schibsted Grotesk", + filename: "Jqz55SSPQuCQF3t8uOwiUL-taUTtaq9BYSsBdjFP", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/schibstedgrotesk/v7/Jqz55SSPQuCQF3t8uOwiUL-taUTtaq9BYSsBdjFP.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Nanum Gothic", + filename: "PN_3Rfi-oW3hYwmKDpxS7F_z_tLfxno73g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/nanumgothic/v26/PN_3Rfi-oW3hYwmKDpxS7F_z_tLfxno73g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mukta", + filename: "iJWKBXyXfDDVXYnGp32S0H3f", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mukta/v17/iJWKBXyXfDDVXYnGp32S0H3f.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Dosis", + filename: "HhyaU5sn9vOmLwlvAfSKEZZL", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/dosis/v34/HhyaU5sn9vOmLwlvAfSKEZZL.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bitter", + filename: "rax8HiqOu8IVPmnLeIZoDDlCmg", + category: "serif", + url: "https://fonts.gstatic.com/s/bitter/v40/rax8HiqOu8IVPmnLeIZoDDlCmg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans SC", + filename: "k3kXo84MPvpLmixcA63oeALhKYiJ-Q7m8w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssc/v40/k3kXo84MPvpLmixcA63oeALhKYiJ-Q7m8w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Cabin", + filename: "u-4x0qWljRw-Pe839fxqmjRv", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cabin/v35/u-4x0qWljRw-Pe839fxqmjRv.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Barlow Condensed", + filename: "HTx3L3I-JCGChYJ8VI-L6OO_au7B2xbZ23n3pKg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/barlowcondensed/v13/HTx3L3I-JCGChYJ8VI-L6OO_au7B2xbZ23n3pKg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Roboto Flex", + filename: "NaPccZLOBv5T3oB7Cb4i0wu9TsDOCZRS", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/robotoflex/v30/NaPccZLOBv5T3oB7Cb4i0wu9TsDOCZRS.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Changa One", + filename: "xfu00W3wXn3QLUJXhzq46AbouLfbK64", + category: "display", + url: "https://fonts.gstatic.com/s/changaone/v22/xfu00W3wXn3QLUJXhzq46AbouLfbK64.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ramabhadra", + filename: "EYq2maBOwqRW9P1SQ83LehNGX5uWw3o", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ramabhadra/v17/EYq2maBOwqRW9P1SQ83LehNGX5uWw3o.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Telugu", + filename: "0FlPVOGZlE2Rrtr-HmgkMWJNjJ5_XS_eTyer338", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstelugu/v30/0FlPVOGZlE2Rrtr-HmgkMWJNjJ5_XS_eTyer338.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Anek Telugu", + filename: "LhWhMVrUNvsddMtYGCx4Fd9dGNWQg_am", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anektelugu/v13/LhWhMVrUNvsddMtYGCx4Fd9dGNWQg_am.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Pacifico", + filename: "FwZY7-Qmy14u9lezJ96A4sijpFu_", + category: "handwriting", + url: "https://fonts.gstatic.com/s/pacifico/v23/FwZY7-Qmy14u9lezJ96A4sijpFu_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Space Grotesk", + filename: "V8mDoQDjQSkFtoMM3T6r8E7mDbZyCts0DqQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/spacegrotesk/v22/V8mDoQDjQSkFtoMM3T6r8E7mDbZyCts0DqQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Assistant", + filename: "2sDcZGJYnIjSi6H75xkDb2-4C7wFZQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/assistant/v24/2sDcZGJYnIjSi6H75xkDb2-4C7wFZQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Slabo 27px", + filename: "mFT0WbgBwKPR_Z4hGN2qsxgJ1EJ7i90", + category: "serif", + url: "https://fonts.gstatic.com/s/slabo27px/v16/mFT0WbgBwKPR_Z4hGN2qsxgJ1EJ7i90.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Red Hat Display", + filename: "8vIQ7wUr0m80wwYf0QCXZzYzUoTQ-jSgZYvdCQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/redhatdisplay/v21/8vIQ7wUr0m80wwYf0QCXZzYzUoTQ-jSgZYvdCQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Hind", + filename: "5aU69_a8oxmIRG5yBROzkDM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hind/v18/5aU69_a8oxmIRG5yBROzkDM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oxygen", + filename: "2sDfZG1Wl4Lcnbu6iUcnZ0SkAg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/oxygen/v16/2sDfZG1Wl4Lcnbu6iUcnZ0SkAg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Exo 2", + filename: "7cHmv4okm5zmbuYvIe804WIo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/exo2/v26/7cHmv4okm5zmbuYvIe804WIo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Lexend", + filename: "wlpwgwvFAVdoq2_f_K4V0WdXaQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lexend/v26/wlpwgwvFAVdoq2_f_K4V0WdXaQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Alfa Slab One", + filename: "6NUQ8FmMKwSEKjnm5-4v-4Jh6dVretWvYmE", + category: "display", + url: "https://fonts.gstatic.com/s/alfaslabone/v21/6NUQ8FmMKwSEKjnm5-4v-4Jh6dVretWvYmE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Inter Tight", + filename: "NGSwv5HMAFg6IuGlBNMjxIsA-6lMQHe9", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/intertight/v9/NGSwv5HMAFg6IuGlBNMjxIsA-6lMQHe9.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Lobster", + filename: "neILzCirqoswsqX9_oWsMqEzSJQ", + category: "display", + url: "https://fonts.gstatic.com/s/lobster/v32/neILzCirqoswsqX9_oWsMqEzSJQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Urbanist", + filename: "L0x-DF02iFML4hGCyPqiZyxEimK3", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/urbanist/v18/L0x-DF02iFML4hGCyPqiZyxEimK3.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Crimson Text", + filename: "wlp2gwHKFkZgtmSR3NB0oRJvaAJSA_JN3Q", + category: "serif", + url: "https://fonts.gstatic.com/s/crimsontext/v19/wlp2gwHKFkZgtmSR3NB0oRJvaAJSA_JN3Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Overpass", + filename: "qFdH35WCmI96Ajtm82GiWdrCwwcJ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/overpass/v19/qFdH35WCmI96Ajtm82GiWdrCwwcJ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Lobster Two", + filename: "BngMUXZGTXPUvIoyV6yN59fK7KSJ4ACD", + category: "display", + url: "https://fonts.gstatic.com/s/lobstertwo/v22/BngMUXZGTXPUvIoyV6yN59fK7KSJ4ACD.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cormorant Garamond", + filename: "co3bmX5slCNuHLi8bLeY9MK7whWMhyjornFLsS6V7w", + category: "serif", + url: "https://fonts.gstatic.com/s/cormorantgaramond/v21/co3bmX5slCNuHLi8bLeY9MK7whWMhyjornFLsS6V7w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Comfortaa", + filename: "1Ptsg8LJRfWJmhDAuUsISotrDfGGxA", + category: "display", + url: "https://fonts.gstatic.com/s/comfortaa/v47/1Ptsg8LJRfWJmhDAuUsISotrDfGGxA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sora", + filename: "xMQbuFFYT72X_QIjD4e2OX8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sora/v17/xMQbuFFYT72X_QIjD4e2OX8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Arvo", + filename: "tDbD2oWUg0MKmSAa7Lzr7vs", + category: "serif", + url: "https://fonts.gstatic.com/s/arvo/v23/tDbD2oWUg0MKmSAa7Lzr7vs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "M PLUS Rounded 1c", + filename: "VdGEAYIAV6gnpUpoWwNkYvrugw9RuPWGzr8C7vav", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mplusrounded1c/v20/VdGEAYIAV6gnpUpoWwNkYvrugw9RuPWGzr8C7vav.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "PT Sans Narrow", + filename: "BngRUXNadjH0qYEzV7ab-oWlsYCByxyKeuDp", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ptsansnarrow/v19/BngRUXNadjH0qYEzV7ab-oWlsYCByxyKeuDp.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tajawal", + filename: "Iura6YBj_oCad4k1rzaLCr5IlLA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tajawal/v12/Iura6YBj_oCad4k1rzaLCr5IlLA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Caveat", + filename: "Wnz6HAc5bAfYB2QLYTwZqg_MPQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/caveat/v23/Wnz6HAc5bAfYB2QLYTwZqg_MPQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "DM Serif Display", + filename: "-nFnOHM81r4j6k0gjAW3mujVU2B2K_d709jy92k", + category: "serif", + url: "https://fonts.gstatic.com/s/dmserifdisplay/v17/-nFnOHM81r4j6k0gjAW3mujVU2B2K_d709jy92k.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rajdhani", + filename: "LDIxapCSOBg7S-QT7q4AOeekWPrP", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rajdhani/v17/LDIxapCSOBg7S-QT7q4AOeekWPrP.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Abel", + filename: "MwQ5bhbm2POE6VhLPJp6qGI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/abel/v18/MwQ5bhbm2POE6VhLPJp6qGI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fira Sans Condensed", + filename: "wEOhEADFm8hSaQTFG18FErVhsC9x-tarYfHnrMtVbx8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/firasanscondensed/v11/wEOhEADFm8hSaQTFG18FErVhsC9x-tarYfHnrMtVbx8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Teko", + filename: "LYjNdG7kmE0gTaR3pCtBtVs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/teko/v23/LYjNdG7kmE0gTaR3pCtBtVs.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Merriweather Sans", + filename: "2-c99IRs1JiJN1FRAMjTN5zd9vgsFEXySDTL8wtf", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/merriweathersans/v28/2-c99IRs1JiJN1FRAMjTN5zd9vgsFEXySDTL8wtf.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Almarai", + filename: "tsstApxBaigK_hnnc1qPonC3vqc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/almarai/v19/tsstApxBaigK_hnnc1qPonC3vqc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Source Serif 4", + filename: "vEFI2_tTDB4M7-auWDN0ahZJW2gc-NaXXq7H", + category: "serif", + url: "https://fonts.gstatic.com/s/sourceserif4/v14/vEFI2_tTDB4M7-auWDN0ahZJW2gc-NaXXq7H.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Satisfy", + filename: "rP2Hp2yn6lkG50LoOZSCHBeHFl0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/satisfy/v22/rP2Hp2yn6lkG50LoOZSCHBeHFl0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Asap", + filename: "KFOoCniXp96a-zwU4UROGzY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/asap/v34/KFOoCniXp96a-zwU4UROGzY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Lexend Deca", + filename: "K2F1fZFYk-dHSE0UPPuwQ6qgLS76ZHOM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lexenddeca/v25/K2F1fZFYk-dHSE0UPPuwQ6qgLS76ZHOM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Domine", + filename: "L0x8DFMnlVwD4h3RvPCmRSlUig", + category: "serif", + url: "https://fonts.gstatic.com/s/domine/v25/L0x8DFMnlVwD4h3RvPCmRSlUig.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Shadows Into Light", + filename: "UqyNK9UOIntux_czAvDQx_ZcHqZXBNQDcsr4xzSMYA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/shadowsintolight/v22/UqyNK9UOIntux_czAvDQx_ZcHqZXBNQDcsr4xzSMYA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Barlow Semi Condensed", + filename: "wlpvgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRnf4CrCEo4gg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/barlowsemicondensed/v16/wlpvgxjLBV1hqnzfr-F8sEYMB0Yybp0mudRnf4CrCEo4gg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Arabic", + filename: "nwpPtLGrOAZMl5nJ_wfgRg3DrWFZQML36H986K0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansarabic/v33/nwpPtLGrOAZMl5nJ_wfgRg3DrWFZQML36H986K0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Lilita One", + filename: "i7dPIFZ9Zz-WBtRtedDbUEZ2RFq7AwU", + category: "display", + url: "https://fonts.gstatic.com/s/lilitaone/v17/i7dPIFZ9Zz-WBtRtedDbUEZ2RFq7AwU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Indie Flower", + filename: "m8JVjfNVeKWVnh3QMuKkFcZlbkGG1dKEDw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/indieflower/v24/m8JVjfNVeKWVnh3QMuKkFcZlbkGG1dKEDw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Play", + filename: "6aez4K2oVqwIjtI8Hp8Tx3A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/play/v21/6aez4K2oVqwIjtI8Hp8Tx3A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chakra Petch", + filename: "cIf6MapbsEk7TDLdtEz1BwkmmKBhSL7Y1Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/chakrapetch/v13/cIf6MapbsEk7TDLdtEz1BwkmmKBhSL7Y1Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Mono", + filename: "-F63fjptAgt5VM-kVkqdyU8n5igg1l9kn-s", + category: "monospace", + url: "https://fonts.gstatic.com/s/ibmplexmono/v20/-F63fjptAgt5VM-kVkqdyU8n5igg1l9kn-s.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Thai", + filename: "iJWdBXeUZi_OHPqn4wq6hQ2_hah-5c-dUX0x", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansthai/v29/iJWdBXeUZi_OHPqn4wq6hQ2_hah-5c-dUX0x.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Varela Round", + filename: "w8gdH283Tvk__Lua32TysjIvoMGOD9gxZw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/varelaround/v21/w8gdH283Tvk__Lua32TysjIvoMGOD9gxZw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cinzel", + filename: "8vIJ7ww63mVu7gtL8W76HEdHMg", + category: "serif", + url: "https://fonts.gstatic.com/s/cinzel/v26/8vIJ7ww63mVu7gtL8W76HEdHMg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Questrial", + filename: "QdVUSTchPBm7nuUeVf7EuStkm20oJA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/questrial/v19/QdVUSTchPBm7nuUeVf7EuStkm20oJA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Maven Pro", + filename: "7Au9p_AqnyWWAxW2Wk32ym4JMFge0g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mavenpro/v40/7Au9p_AqnyWWAxW2Wk32ym4JMFge0g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "M PLUS 1p", + filename: "e3tjeuShHdiFyPFzBRro-D4Ec2jKqw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mplus1p/v33/e3tjeuShHdiFyPFzBRro-D4Ec2jKqw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Orbitron", + filename: "yMJRMIlzdpvBhQQL_Tq8fSx5i814", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/orbitron/v35/yMJRMIlzdpvBhQQL_Tq8fSx5i814.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "IBM Plex Serif", + filename: "jizDREVNn1dOx-zrZ2X3pZvkThUY0TY7ikbI", + category: "serif", + url: "https://fonts.gstatic.com/s/ibmplexserif/v20/jizDREVNn1dOx-zrZ2X3pZvkThUY0TY7ikbI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Abril Fatface", + filename: "zOL64pLDlL1D99S8g8PtiKchm-BsjOLhZBY", + category: "display", + url: "https://fonts.gstatic.com/s/abrilfatface/v25/zOL64pLDlL1D99S8g8PtiKchm-BsjOLhZBY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bodoni Moda", + filename: "aFTQ7PxzY382XsXX63LUYKSNQqn0X0BO", + category: "serif", + url: "https://fonts.gstatic.com/s/bodonimoda/v28/aFTQ7PxzY382XsXX63LUYKSNQqn0X0BO.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Kalam", + filename: "YA9dr0Wd4kDdMuhWMibDszkB", + category: "handwriting", + url: "https://fonts.gstatic.com/s/kalam/v18/YA9dr0Wd4kDdMuhWMibDszkB.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Instrument Serif", + filename: "jizBRFtNs2ka5fXjeivQ4LroWlx-2zIZj1bIkNo", + category: "serif", + url: "https://fonts.gstatic.com/s/instrumentserif/v5/jizBRFtNs2ka5fXjeivQ4LroWlx-2zIZj1bIkNo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fredoka", + filename: "X7nl4b87HvSqjb_WOCaQ4MTgAgk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/fredoka/v17/X7nl4b87HvSqjb_WOCaQ4MTgAgk.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Exo", + filename: "4UaOrEtFpBIidHSi-DP-5g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/exo/v25/4UaOrEtFpBIidHSi-DP-5g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Symbols", + filename: "rP2dp3q65FkAtHfwd-eIS2brbDN6gwn8wq-72bOY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssymbols/v47/rP2dp3q65FkAtHfwd-eIS2brbDN6gwn8wq-72bOY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Archivo Narrow", + filename: "tss0ApVBdCYD5Q7hcxTE1ArZ0Yb3g31S2s8p", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/archivonarrow/v35/tss0ApVBdCYD5Q7hcxTE1ArZ0Yb3g31S2s8p.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Zilla Slab", + filename: "dFa6ZfeM_74wlPZtksIFWj0w_HyIRlE", + category: "serif", + url: "https://fonts.gstatic.com/s/zillaslab/v12/dFa6ZfeM_74wlPZtksIFWj0w_HyIRlE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Great Vibes", + filename: "RWmMoKWR9v4ksMfaWd_JN-XCg6UKDXlq", + category: "handwriting", + url: "https://fonts.gstatic.com/s/greatvibes/v21/RWmMoKWR9v4ksMfaWd_JN-XCg6UKDXlq.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "ABeeZee", + filename: "esDR31xSG-6AGleN6tKukbcHCpE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/abeezee/v23/esDR31xSG-6AGleN6tKukbcHCpE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nanum Myeongjo", + filename: "9Btx3DZF0dXLMZlywRbVRNhxy1LreHQ8juyl", + category: "serif", + url: "https://fonts.gstatic.com/s/nanummyeongjo/v31/9Btx3DZF0dXLMZlywRbVRNhxy1LreHQ8juyl.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Unbounded", + filename: "Yq6W-LOTXCb04q32xlpAvMxenxE0SA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/unbounded/v12/Yq6W-LOTXCb04q32xlpAvMxenxE0SA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Zen Kaku Gothic New", + filename: "gNMYW2drQpDw0GjzrVNFf_valaDBcznOkjtiTWz5UGA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zenkakugothicnew/v18/gNMYW2drQpDw0GjzrVNFf_valaDBcznOkjtiTWz5UGA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Marcellus", + filename: "wEO_EBrOk8hQLDvIAF8FUfAL3EsHiA", + category: "serif", + url: "https://fonts.gstatic.com/s/marcellus/v14/wEO_EBrOk8hQLDvIAF8FUfAL3EsHiA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "JetBrains Mono", + filename: "tDbV2o-flEEny0FZhsfKu5WU4yD8MQCPTFrV", + category: "monospace", + url: "https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbV2o-flEEny0FZhsfKu5WU4yD8MQCPTFrV.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sofia Sans", + filename: "Yq6R-LCVXSLy9uPBwlATnOZwkxgtUb8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sofiasans/v20/Yq6R-LCVXSLy9uPBwlATnOZwkxgtUb8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Be Vietnam Pro", + filename: "QdVPSTAyLFyeg_IDWvOJmVES_EwwD3s6ZKAi", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bevietnampro/v12/QdVPSTAyLFyeg_IDWvOJmVES_EwwD3s6ZKAi.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Google Sans Flex", + filename: "t5t7IQcYNIWbFgDgAAzZ34auoVyXipusfhcat2c", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/googlesansflex/v16/t5t7IQcYNIWbFgDgAAzZ34auoVyXipusfhcat2c.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Albert Sans", + filename: "i7dOIFdwYjGaAMFtZd_QA2ZcalayGhyV", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/albertsans/v4/i7dOIFdwYjGaAMFtZd_QA2ZcalayGhyV.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "DM Mono", + filename: "aFTU7PB1QTsUX8KYhh2aBYyMcKw", + category: "monospace", + url: "https://fonts.gstatic.com/s/dmmono/v16/aFTU7PB1QTsUX8KYhh2aBYyMcKw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Permanent Marker", + filename: "Fh4uPib9Iyv2ucM6pGQMWimMp004HaqIfrT5nlk", + category: "handwriting", + url: "https://fonts.gstatic.com/s/permanentmarker/v16/Fh4uPib9Iyv2ucM6pGQMWimMp004HaqIfrT5nlk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Signika", + filename: "vEFR2_JTCgwQ5ejvK1YsB3hod0k", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/signika/v29/vEFR2_JTCgwQ5ejvK1YsB3hod0k.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "IBM Plex Sans Arabic", + filename: "Qw3CZRtWPQCuHme67tEYUIx3Kh0PHR9N6bs61vSbfdlA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsansarabic/v14/Qw3CZRtWPQCuHme67tEYUIx3Kh0PHR9N6bs61vSbfdlA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Instrument Sans", + filename: "pxicypc9vsFDm051Uf6KVwgkfoSrSGNAom-wpw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/instrumentsans/v4/pxicypc9vsFDm051Uf6KVwgkfoSrSGNAom-wpw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Titan One", + filename: "mFTzWbsGxbbS_J5cQcjykzIn2Etikg", + category: "display", + url: "https://fonts.gstatic.com/s/titanone/v17/mFTzWbsGxbbS_J5cQcjykzIn2Etikg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif KR", + filename: "3Jn7SDn90Gmq2mr3blnHaTZXduBp1ONyKHQ", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifkr/v31/3Jn7SDn90Gmq2mr3blnHaTZXduBp1ONyKHQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Spectral", + filename: "rnCr-xNNww_2s0amA-M-mHnOSOuk", + category: "serif", + url: "https://fonts.gstatic.com/s/spectral/v15/rnCr-xNNww_2s0amA-M-mHnOSOuk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sanchez", + filename: "Ycm2sZJORluHnXbITm5b_BwE1l0", + category: "serif", + url: "https://fonts.gstatic.com/s/sanchez/v17/Ycm2sZJORluHnXbITm5b_BwE1l0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rethink Sans", + filename: "AMOWz4SDuXOMCPfdoglY9JQ0U1K2w9lb4g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rethinksans/v7/AMOWz4SDuXOMCPfdoglY9JQ0U1K2w9lb4g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Vollkorn", + filename: "0yb9GDoxxrvAnPhYGykuYkw2rQg1", + category: "serif", + url: "https://fonts.gstatic.com/s/vollkorn/v30/0yb9GDoxxrvAnPhYGykuYkw2rQg1.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Kufi Arabic", + filename: "CSRk4ydQnPyaDxEXLFF6LZVLKrodnOQPF2KpMzE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notokufiarabic/v27/CSRk4ydQnPyaDxEXLFF6LZVLKrodnOQPF2KpMzE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Google Sans Code", + filename: "pxifyogzv91QhV44Z_GQBHsGf5PuaElurmapvvM", + category: "monospace", + url: "https://fonts.gstatic.com/s/googlesanscode/v14/pxifyogzv91QhV44Z_GQBHsGf5PuaElurmapvvM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Cormorant", + filename: "H4clBXOCl9bbnla_nHIa6JG8iqeuag", + category: "serif", + url: "https://fonts.gstatic.com/s/cormorant/v24/H4clBXOCl9bbnla_nHIa6JG8iqeuag.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Serif SC", + filename: "H4chBXePl9DZ0Xe7gG9cyOj7oqacbzhqDtg", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifsc/v35/H4chBXePl9DZ0Xe7gG9cyOj7oqacbzhqDtg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bree Serif", + filename: "4UaHrEJCrhhnVA3DgluAx63j5pN1MwI", + category: "serif", + url: "https://fonts.gstatic.com/s/breeserif/v18/4UaHrEJCrhhnVA3DgluAx63j5pN1MwI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sarabun", + filename: "DtVjJx26TKEr37c9WBJDnlQN9gk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sarabun/v17/DtVjJx26TKEr37c9WBJDnlQN9gk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Geologica", + filename: "oY1c8evIr7j9P3TN9YwXCv5xY4QBLw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/geologica/v5/oY1c8evIr7j9P3TN9YwXCv5xY4QBLw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Saira Condensed", + filename: "EJROQgErUN8XuHNEtX81i9TmEkrfpeFE-IyCrw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sairacondensed/v12/EJROQgErUN8XuHNEtX81i9TmEkrfpeFE-IyCrw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Geist", + filename: "gyByhwUxId8gMHwbElSvO5Tc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/geist/v4/gyByhwUxId8gMHwbElSvO5Tc.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Onest", + filename: "gNMKW3F-SZuj7ymY8ncKEZez", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/onest/v9/gNMKW3F-SZuj7ymY8ncKEZez.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rowdies", + filename: "ptRJTieMYPNBAK21zrdJwObZNQo", + category: "display", + url: "https://fonts.gstatic.com/s/rowdies/v19/ptRJTieMYPNBAK21zrdJwObZNQo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "League Spartan", + filename: "kJEqBuEW6A0lliaV_m88ja5TwsZ3J5i1DJZg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/leaguespartan/v15/kJEqBuEW6A0lliaV_m88ja5TwsZ3J5i1DJZg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Catamaran", + filename: "o-0IIpQoyXQa2RxT7-5b4j5Ba_2c7A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/catamaran/v28/o-0IIpQoyXQa2RxT7-5b4j5Ba_2c7A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Display", + filename: "RLplK4fy6r6tOBEJg0IAKzqdFZVZxokvfn_BDLxR", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansdisplay/v30/RLplK4fy6r6tOBEJg0IAKzqdFZVZxokvfn_BDLxR.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Montserrat Alternates", + filename: "mFTvWacfw6zH4dthXcyms1lPpC8I_b0juU0J7K3RCJ1b0w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/montserratalternates/v18/mFTvWacfw6zH4dthXcyms1lPpC8I_b0juU0J7K3RCJ1b0w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Advent Pro", + filename: "V8mAoQfxVT4Dvddr_yOwtT2nKb5ZFtI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/adventpro/v33/V8mAoQfxVT4Dvddr_yOwtT2nKb5ZFtI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Roboto Serif", + filename: "R70djywflP6FLr3gZx7K8Uy0Vxn9R5ShnA", + category: "serif", + url: "https://fonts.gstatic.com/s/robotoserif/v17/R70djywflP6FLr3gZx7K8Uy0Vxn9R5ShnA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Hind Madurai", + filename: "f0Xx0e2p98ZvDXdZQIOcpqjn8Y0DceA0OQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hindmadurai/v13/f0Xx0e2p98ZvDXdZQIOcpqjn8Y0DceA0OQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Frank Ruhl Libre", + filename: "j8_w6_fAw7jrcalD7oKYNX0QfAnPa7fv4JjnmY4", + category: "serif", + url: "https://fonts.gstatic.com/s/frankruhllibre/v23/j8_w6_fAw7jrcalD7oKYNX0QfAnPa7fv4JjnmY4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Acme", + filename: "RrQfboBx-C5_bx3Lb23lzLk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/acme/v27/RrQfboBx-C5_bx3Lb23lzLk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif TC", + filename: "XLYgIZb5bJNDGYxLBibeHZ0BhncESXFtUsM", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriftc/v35/XLYgIZb5bJNDGYxLBibeHZ0BhncESXFtUsM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Space Mono", + filename: "i7dPIFZifjKcF5UAWdDRUEZ2RFq7AwU", + category: "monospace", + url: "https://fonts.gstatic.com/s/spacemono/v17/i7dPIFZifjKcF5UAWdDRUEZ2RFq7AwU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oleo Script", + filename: "rax5HieDvtMOe0iICsUccBhasU7Q8Cad", + category: "display", + url: "https://fonts.gstatic.com/s/oleoscript/v15/rax5HieDvtMOe0iICsUccBhasU7Q8Cad.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alegreya", + filename: "4UaBrEBBsBhlBjvfkRLmzanB44N1", + category: "serif", + url: "https://fonts.gstatic.com/s/alegreya/v39/4UaBrEBBsBhlBjvfkRLmzanB44N1.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Encode Sans", + filename: "LDI2apOFNxEwR-Bd1O9uYMOsc-bGkqIw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/encodesans/v23/LDI2apOFNxEwR-Bd1O9uYMOsc-bGkqIw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Alegreya Sans", + filename: "5aUz9_-1phKLFgshYDvh6Vwt3V1nvEVXlm4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alegreyasans/v26/5aUz9_-1phKLFgshYDvh6Vwt3V1nvEVXlm4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Newsreader", + filename: "cY9AfjOCX1hbuyalUrK479n4jaBGNpY", + category: "serif", + url: "https://fonts.gstatic.com/s/newsreader/v26/cY9AfjOCX1hbuyalUrK479n4jaBGNpY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Unna", + filename: "AYCEpXzofN0NCpgBlGHCWFM", + category: "serif", + url: "https://fonts.gstatic.com/s/unna/v25/AYCEpXzofN0NCpgBlGHCWFM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Luckiest Guy", + filename: "_gP_1RrxsjcxVyin9l9n_j2RStR3qDpraA", + category: "display", + url: "https://fonts.gstatic.com/s/luckiestguy/v25/_gP_1RrxsjcxVyin9l9n_j2RStR3qDpraA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hanken Grotesk", + filename: "ieVn2YZDLWuGJpnzaiwFXS9tYupa7dGTCTs5", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hankengrotesk/v12/ieVn2YZDLWuGJpnzaiwFXS9tYupa7dGTCTs5.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Creepster", + filename: "AlZy_zVUqJz4yMrniH4hdXf4XB0Tow", + category: "display", + url: "https://fonts.gstatic.com/s/creepster/v13/AlZy_zVUqJz4yMrniH4hdXf4XB0Tow.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Mono One", + filename: "UqyJK8kPP3hjw6ANTdfRk9YSN-8wRqQrc_j9", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rubikmonoone/v20/UqyJK8kPP3hjw6ANTdfRk9YSN-8wRqQrc_j9.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "STIX Two Text", + filename: "YA9Vr02F12Xkf5whdwKf11l0l7mGiv_Q7dA", + category: "serif", + url: "https://fonts.gstatic.com/s/stixtwotext/v18/YA9Vr02F12Xkf5whdwKf11l0l7mGiv_Q7dA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Amiri", + filename: "J7aRnpd8CGxBHqUpvrIw74NL", + category: "serif", + url: "https://fonts.gstatic.com/s/amiri/v30/J7aRnpd8CGxBHqUpvrIw74NL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Russo One", + filename: "Z9XUDmZRWg6M1LvRYsH-yMOInrib9Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/russoone/v18/Z9XUDmZRWg6M1LvRYsH-yMOInrib9Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Maru Gothic", + filename: "o-0SIpIxzW5b-RxT-6A8jWAtCp-k7UJmNLGG9A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zenmarugothic/v19/o-0SIpIxzW5b-RxT-6A8jWAtCp-k7UJmNLGG9A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cardo", + filename: "wlp_gwjKBV1pqiv_1oAZ2H5O", + category: "serif", + url: "https://fonts.gstatic.com/s/cardo/v21/wlp_gwjKBV1pqiv_1oAZ2H5O.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Atkinson Hyperlegible", + filename: "9Bt23C1KxNDXMspQ1lPyU89-1h6ONRlW45GE5ZgpewSSbQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/atkinsonhyperlegible/v12/9Bt23C1KxNDXMspQ1lPyU89-1h6ONRlW45GE5ZgpewSSbQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Amatic SC", + filename: "TUZyzwprpvBS1izr_vO0De6ecZQf1A", + category: "handwriting", + url: "https://fonts.gstatic.com/s/amaticsc/v28/TUZyzwprpvBS1izr_vO0De6ecZQf1A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Signika Negative", + filename: "E218_cfngu7HiRpPX3ZpNE4kY5zKUvKrrpno9zY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/signikanegative/v26/E218_cfngu7HiRpPX3ZpNE4kY5zKUvKrrpno9zY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Chivo", + filename: "va9I4kzIxd1KFoBvS-J3kbDP", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/chivo/v21/va9I4kzIxd1KFoBvS-J3kbDP.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noticia Text", + filename: "VuJ2dNDF2Yv9qppOePKYRP1GYTFZt0rNpQ", + category: "serif", + url: "https://fonts.gstatic.com/s/noticiatext/v16/VuJ2dNDF2Yv9qppOePKYRP1GYTFZt0rNpQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yanone Kaffeesatz", + filename: "3y976aknfjLm_3lMKjiMgmUUYBs04b8cFeulHc6N", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/yanonekaffeesatz/v32/3y976aknfjLm_3lMKjiMgmUUYBs04b8cFeulHc6N.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Libre Caslon Text", + filename: "DdT878IGsGw1aF1JU10PUbTvNNaDMcq_3eNrHgO1", + category: "serif", + url: "https://fonts.gstatic.com/s/librecaslontext/v5/DdT878IGsGw1aF1JU10PUbTvNNaDMcq_3eNrHgO1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Righteous", + filename: "1cXxaUPXBpj2rGoU7C9mj3uEicG01A", + category: "display", + url: "https://fonts.gstatic.com/s/righteous/v18/1cXxaUPXBpj2rGoU7C9mj3uEicG01A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Prata", + filename: "6xKhdSpbNNCT-vWIAG_5LWwJ", + category: "serif", + url: "https://fonts.gstatic.com/s/prata/v22/6xKhdSpbNNCT-vWIAG_5LWwJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Changa", + filename: "2-cm9JNi2YuVOUcUYZa_Wu_lpA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/changa/v29/2-cm9JNi2YuVOUcUYZa_Wu_lpA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Antic Slab", + filename: "bWt97fPFfRzkCa9Jlp6IWcJWXW5p5Qo", + category: "serif", + url: "https://fonts.gstatic.com/s/anticslab/v17/bWt97fPFfRzkCa9Jlp6IWcJWXW5p5Qo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libre Barcode 39", + filename: "-nFnOHM08vwC6h8Li1eQnP_AHzI2K_d709jy92k", + category: "display", + url: "https://fonts.gstatic.com/s/librebarcode39/v25/-nFnOHM08vwC6h8Li1eQnP_AHzI2K_d709jy92k.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "News Cycle", + filename: "CSR64z1Qlv-GDxkbKVQ_TOcATNt_pOU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/newscycle/v26/CSR64z1Qlv-GDxkbKVQ_TOcATNt_pOU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Delius", + filename: "PN_xRfK0pW_9e1rtYcI-jT3L_w", + category: "handwriting", + url: "https://fonts.gstatic.com/s/delius/v21/PN_xRfK0pW_9e1rtYcI-jT3L_w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Comic Neue", + filename: "4UaHrEJDsxBrF37olUeDx63j5pN1MwI", + category: "handwriting", + url: "https://fonts.gstatic.com/s/comicneue/v9/4UaHrEJDsxBrF37olUeDx63j5pN1MwI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Literata", + filename: "or3hQ6P12-iJxAIgLbT3LLQ1niPn", + category: "serif", + url: "https://fonts.gstatic.com/s/literata/v40/or3hQ6P12-iJxAIgLbT3LLQ1niPn.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Alata", + filename: "PbytFmztEwbIofe6xKcRQEOX", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alata/v12/PbytFmztEwbIofe6xKcRQEOX.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Syne", + filename: "8vIH7w4qzmVxq2dB9Uz_DEc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/syne/v24/8vIH7w4qzmVxq2dB9Uz_DEc.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Martel", + filename: "PN_xRfK9oXHga0XtYcI-jT3L_w", + category: "serif", + url: "https://fonts.gstatic.com/s/martel/v12/PN_xRfK9oXHga0XtYcI-jT3L_w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yellowtail", + filename: "OZpGg_pnoDtINPfRIlLotlzNwED-b4g", + category: "handwriting", + url: "https://fonts.gstatic.com/s/yellowtail/v25/OZpGg_pnoDtINPfRIlLotlzNwED-b4g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fraunces", + filename: "6NUV8FyLNQOQZAnv9awPnugMyM1A", + category: "serif", + url: "https://fonts.gstatic.com/s/fraunces/v38/6NUV8FyLNQOQZAnv9awPnugMyM1A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Kumbh Sans", + filename: "c4ml1n92AsfhuCq6tVsauodX-Kq-QUI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kumbhsans/v27/c4ml1n92AsfhuCq6tVsauodX-Kq-QUI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "DM Serif Text", + filename: "rnCu-xZa_krGokauCeNq1wWyafOPXHIJErY", + category: "serif", + url: "https://fonts.gstatic.com/s/dmseriftext/v13/rnCu-xZa_krGokauCeNq1wWyafOPXHIJErY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Courgette", + filename: "wEO_EBrAnc9BLjLQAUkFUfAL3EsHiA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/courgette/v19/wEO_EBrAnc9BLjLQAUkFUfAL3EsHiA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Crimson Pro", + filename: "q5uDsoa5M_tv7IihmnkabDRcq4BYCdKi", + category: "serif", + url: "https://fonts.gstatic.com/s/crimsonpro/v28/q5uDsoa5M_tv7IihmnkabDRcq4BYCdKi.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "PT Sans Caption", + filename: "0FlMVP6Hrxmt7-fsUFhlFXNIlpcqfQXwQy6yxg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ptsanscaption/v20/0FlMVP6Hrxmt7-fsUFhlFXNIlpcqfQXwQy6yxg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Andada Pro", + filename: "HhyRU5Qi9-SuOEhPe4LtMI5gSbkI5_E", + category: "serif", + url: "https://fonts.gstatic.com/s/andadapro/v24/HhyRU5Qi9-SuOEhPe4LtMI5gSbkI5_E.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Tenor Sans", + filename: "bx6ANxqUneKx06UkIXISr3JyC22IyqI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tenorsans/v21/bx6ANxqUneKx06UkIXISr3JyC22IyqI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Courier Prime", + filename: "u-450q2lgwslOqpF_6gQ8kELWwZjW-_-tvg", + category: "monospace", + url: "https://fonts.gstatic.com/s/courierprime/v11/u-450q2lgwslOqpF_6gQ8kELWwZjW-_-tvg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Paytone One", + filename: "0nksC9P7MfYHj2oFtYm2CiTqivr9iBq_", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/paytoneone/v25/0nksC9P7MfYHj2oFtYm2CiTqivr9iBq_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shippori Mincho", + filename: "VdGGAZweH5EbgHY6YExcZfDoj0BA2_-C7LoS7g", + category: "serif", + url: "https://fonts.gstatic.com/s/shipporimincho/v17/VdGGAZweH5EbgHY6YExcZfDoj0BA2_-C7LoS7g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Readex Pro", + filename: "SLXNc1bJ7HE5YDoGPuzj59NebXZkiSo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/readexpro/v27/SLXNc1bJ7HE5YDoGPuzj59NebXZkiSo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Alumni Sans", + filename: "nwpQtKqkOwdO2aOIwhWudF-i5QwyYdrc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alumnisans/v20/nwpQtKqkOwdO2aOIwhWudF-i5QwyYdrc.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gothic A1", + filename: "CSR94z5ZnPydRjlCCwl6bM0uQNJmvQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gothica1/v18/CSR94z5ZnPydRjlCCwl6bM0uQNJmvQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sawarabi Mincho", + filename: "8QIRdiDaitzr7brc8ahpxt6GcIJTLahP46UDUw", + category: "serif", + url: "https://fonts.gstatic.com/s/sawarabimincho/v20/8QIRdiDaitzr7brc8ahpxt6GcIJTLahP46UDUw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "League Gothic", + filename: "qFdC35CBi4tvBz81xy7WG7ep4h8ij1I7LLE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/leaguegothic/v13/qFdC35CBi4tvBz81xy7WG7ep4h8ij1I7LLE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Actor", + filename: "wEOzEBbCkc5cO3ekXygtUMIO", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/actor/v18/wEOzEBbCkc5cO3ekXygtUMIO.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Red Hat Text", + filename: "RrQXbohi_ic6B3yVSzGBrMxgb60sE8yZPA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/redhattext/v19/RrQXbohi_ic6B3yVSzGBrMxgb60sE8yZPA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Passion One", + filename: "PbynFmL8HhTPqbjUzux3JHuW_Frg6YoV", + category: "display", + url: "https://fonts.gstatic.com/s/passionone/v20/PbynFmL8HhTPqbjUzux3JHuW_Frg6YoV.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Naskh Arabic", + filename: "RrQKbpV-9Dd1b1OAGA6M9PkyDuVBeO2BF1yELmgy", + category: "serif", + url: "https://fonts.gstatic.com/s/notonaskharabic/v44/RrQKbpV-9Dd1b1OAGA6M9PkyDuVBeO2BF1yELmgy.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Encode Sans Condensed", + filename: "j8_16_LD37rqfuwxyIuaZhE6cRXOLtm2gfTGgaWNDw8VIw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/encodesanscondensed/v11/j8_16_LD37rqfuwxyIuaZhE6cRXOLtm2gfTGgaWNDw8VIw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Golos Text", + filename: "q5uCsoe9Lv5t7Meb31EcIxR2hYxREMs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/golostext/v7/q5uCsoe9Lv5t7Meb31EcIxR2hYxREMs.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Crete Round", + filename: "55xoey1sJNPjPiv1ZZZrxJ1827zAKnxN", + category: "serif", + url: "https://fonts.gstatic.com/s/creteround/v16/55xoey1sJNPjPiv1ZZZrxJ1827zAKnxN.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Baskervville", + filename: "YA9Ur0yU4l_XOrogbkun3kQgt5OohvbJ9A", + category: "serif", + url: "https://fonts.gstatic.com/s/baskervville/v20/YA9Ur0yU4l_XOrogbkun3kQgt5OohvbJ9A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Patua One", + filename: "ZXuke1cDvLCKLDcimxBI5PNvNA9LuA", + category: "display", + url: "https://fonts.gstatic.com/s/patuaone/v22/ZXuke1cDvLCKLDcimxBI5PNvNA9LuA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kaushan Script", + filename: "vm8vdRfvXFLG3OLnsO15WYS5DF7_ytN3M48a", + category: "handwriting", + url: "https://fonts.gstatic.com/s/kaushanscript/v19/vm8vdRfvXFLG3OLnsO15WYS5DF7_ytN3M48a.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Sans Condensed", + filename: "Gg8lN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHbauwq_jhJsM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsanscondensed/v15/Gg8lN4UfRSqiPg7Jn2ZI12V4DCEwkj1E4LVeHbauwq_jhJsM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hammersmith One", + filename: "qWcyB624q4L_C4jGQ9IK0O_dFlnbshsks4MRXw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hammersmithone/v18/qWcyB624q4L_C4jGQ9IK0O_dFlnbshsks4MRXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Allura", + filename: "9oRPNYsQpS4zjuAPjAIXPtrrGA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/allura/v23/9oRPNYsQpS4zjuAPjAIXPtrrGA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Young Serif", + filename: "3qTpojO2nS2VtkB3KtkQZ2t61EcYaQ7F", + category: "serif", + url: "https://fonts.gstatic.com/s/youngserif/v2/3qTpojO2nS2VtkB3KtkQZ2t61EcYaQ7F.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gloria Hallelujah", + filename: "LYjYdHv3kUk9BMV96EIswT9DIbW-MLSy3TKEvkCF", + category: "handwriting", + url: "https://fonts.gstatic.com/s/gloriahallelujah/v24/LYjYdHv3kUk9BMV96EIswT9DIbW-MLSy3TKEvkCF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Philosopher", + filename: "vEFV2_5QCwIS4_Dhez5jcVBpRUwU08qe", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/philosopher/v21/vEFV2_5QCwIS4_Dhez5jcVBpRUwU08qe.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Old Standard TT", + filename: "MwQubh3o1vLImiwAVvYawgcf2eVurVC5RHdCZg", + category: "serif", + url: "https://fonts.gstatic.com/s/oldstandardtt/v22/MwQubh3o1vLImiwAVvYawgcf2eVurVC5RHdCZg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Commissioner", + filename: "tDbL2o2WnlgI0FNDgduEk4jajCr4EwWfTA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/commissioner/v24/tDbL2o2WnlgI0FNDgduEk4jajCr4EwWfTA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Nanum Gothic Coding", + filename: "8QIVdjzHisX_8vv59_xMxtPFW4IXROwsy6QxVs1X7tc", + category: "handwriting", + url: "https://fonts.gstatic.com/s/nanumgothiccoding/v27/8QIVdjzHisX_8vv59_xMxtPFW4IXROwsy6QxVs1X7tc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Viga", + filename: "xMQbuFFdSaiX_QIjD4e2OX8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/viga/v15/xMQbuFFdSaiX_QIjD4e2OX8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sawarabi Gothic", + filename: "x3d4ckfVaqqa-BEj-I9mE65u3k3NBSk3E2YljQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sawarabigothic/v16/x3d4ckfVaqqa-BEj-I9mE65u3k3NBSk3E2YljQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aleo", + filename: "c4mv1nF8G8_s8ArD0D1ogoY", + category: "serif", + url: "https://fonts.gstatic.com/s/aleo/v16/c4mv1nF8G8_s8ArD0D1ogoY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Press Start 2P", + filename: "e3t4euO8T-267oIAQAu6jDQyK0nSgPJE4580", + category: "display", + url: "https://fonts.gstatic.com/s/pressstart2p/v16/e3t4euO8T-267oIAQAu6jDQyK0nSgPJE4580.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Quattrocento", + filename: "OZpEg_xvsDZQL_LKIF7q4jPHxGL7f4jFuA", + category: "serif", + url: "https://fonts.gstatic.com/s/quattrocento/v24/OZpEg_xvsDZQL_LKIF7q4jPHxGL7f4jFuA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cantarell", + filename: "B50NF7ZDq37KMUvlO01Ji6hqHK-CLA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cantarell/v18/B50NF7ZDq37KMUvlO01Ji6hqHK-CLA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Asap Condensed", + filename: "pxidypY1o9NHyXh3WvSbGSggdNeLYk1Mq3ap", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/asapcondensed/v18/pxidypY1o9NHyXh3WvSbGSggdNeLYk1Mq3ap.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Didact Gothic", + filename: "ahcfv8qz1zt6hCC5G4F_P4ASpUySp0LlcyQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/didactgothic/v21/ahcfv8qz1zt6hCC5G4F_P4ASpUySp0LlcyQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gruppo", + filename: "WwkfxPmzE06v_ZWFWXDAOIEQUQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gruppo/v23/WwkfxPmzE06v_ZWFWXDAOIEQUQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sacramento", + filename: "buEzpo6gcdjy0EiZMBUG0CoV_NxLeiw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sacramento/v17/buEzpo6gcdjy0EiZMBUG0CoV_NxLeiw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Francois One", + filename: "_Xmr-H4zszafZw3A-KPSZutNxgKQu_avAg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/francoisone/v22/_Xmr-H4zszafZw3A-KPSZutNxgKQu_avAg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fugaz One", + filename: "rax_HiWKp9EAITukFslMBBJek0vA8A", + category: "display", + url: "https://fonts.gstatic.com/s/fugazone/v21/rax_HiWKp9EAITukFslMBBJek0vA8A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oxanium", + filename: "RrQQboN_4yJ0JmiMS2XO0LBBd4Y", + category: "display", + url: "https://fonts.gstatic.com/s/oxanium/v21/RrQQboN_4yJ0JmiMS2XO0LBBd4Y.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bangers", + filename: "FeVQS0BTqb0h60ACL5la2bxii28", + category: "display", + url: "https://fonts.gstatic.com/s/bangers/v25/FeVQS0BTqb0h60ACL5la2bxii28.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rammetto One", + filename: "LhWiMV3HOfMbMetJG3lQDpp9Mvuciu-_SQ", + category: "display", + url: "https://fonts.gstatic.com/s/rammettoone/v21/LhWiMV3HOfMbMetJG3lQDpp9Mvuciu-_SQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ubuntu Condensed", + filename: "u-4k0rCzjgs5J7oXnJcM_0kACGMtf-fVqvHoJXw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ubuntucondensed/v17/u-4k0rCzjgs5J7oXnJcM_0kACGMtf-fVqvHoJXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fira Code", + filename: "uU9NCBsR6Z2vfE9aq3bR2t6CilKOdQ", + category: "monospace", + url: "https://fonts.gstatic.com/s/firacode/v27/uU9NCBsR6Z2vfE9aq3bR2t6CilKOdQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Devanagari", + filename: "TuGOUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv2lRdRhtCC4d", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansdevanagari/v30/TuGOUUFzXI5FBtUq5a8bjKYTZjtRU6Sgv2lRdRhtCC4d.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Mono", + filename: "BngRUXNETWXI6LwhGYvaxZikqYCByxyKeuDp", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmono/v37/BngRUXNETWXI6LwhGYvaxZikqYCByxyKeuDp.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "El Messiri", + filename: "K2F0fZBRmr9vQ1pHEey6AoqKAyLzfWo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/elmessiri/v25/K2F0fZBRmr9vQ1pHEey6AoqKAyLzfWo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Yantramanav", + filename: "flU8Rqu5zY00QEpyWJYWN6f0V-dRCQ41", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/yantramanav/v15/flU8Rqu5zY00QEpyWJYWN6f0V-dRCQ41.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Josefin Slab", + filename: "lW-5wjwOK3Ps5GSJlNNkMalXrQSuJsv4Pw", + category: "serif", + url: "https://fonts.gstatic.com/s/josefinslab/v29/lW-5wjwOK3Ps5GSJlNNkMalXrQSuJsv4Pw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Baloo 2", + filename: "wXKrE3kTposypRyd11_WAewrhXY", + category: "display", + url: "https://fonts.gstatic.com/s/baloo2/v23/wXKrE3kTposypRyd11_WAewrhXY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sen", + filename: "6xKjdSxYI9_Hm_-MImrpLQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sen/v12/6xKjdSxYI9_Hm_-MImrpLQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Zeyada", + filename: "11hAGpPTxVPUbgZDNGatWKaZ3g", + category: "handwriting", + url: "https://fonts.gstatic.com/s/zeyada/v22/11hAGpPTxVPUbgZDNGatWKaZ3g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Patrick Hand", + filename: "LDI1apSQOAYtSuYWp8ZhfYeMWcjKm7sp8g", + category: "handwriting", + url: "https://fonts.gstatic.com/s/patrickhand/v25/LDI1apSQOAYtSuYWp8ZhfYeMWcjKm7sp8g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Eczar", + filename: "BXRlvF3Pi-DLmw0iBu9y8Hf0", + category: "serif", + url: "https://fonts.gstatic.com/s/eczar/v27/BXRlvF3Pi-DLmw0iBu9y8Hf0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mitr", + filename: "pxiLypw5ucZFyTsyMJj_b1o", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mitr/v13/pxiLypw5ucZFyTsyMJj_b1o.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "BIZ UDPGothic", + filename: "hES36X5pHAIBjmS84VL0Bue83nUMQWkMUAk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bizudpgothic/v16/hES36X5pHAIBjmS84VL0Bue83nUMQWkMUAk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Tamil", + filename: "ieVm2YdFI3GCY6SyQy1KfStzYKZ6x_-fACIgaw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstamil/v31/ieVm2YdFI3GCY6SyQy1KfStzYKZ6x_-fACIgaw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rokkitt", + filename: "qFdE35qfgYFjGy5hoEGId9bL2h4", + category: "serif", + url: "https://fonts.gstatic.com/s/rokkitt/v39/qFdE35qfgYFjGy5hoEGId9bL2h4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Bengali", + filename: "Cn-sJsCGWQxOjaGwMQ6fIiMywrNJIlaxvBuclp_T", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbengali/v33/Cn-sJsCGWQxOjaGwMQ6fIiMywrNJIlaxvBuclp_T.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Quattrocento Sans", + filename: "va9c4lja2NVIDdIAAoMR5MfuElaRB3zOvU7eHGHJ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/quattrocentosans/v22/va9c4lja2NVIDdIAAoMR5MfuElaRB3zOvU7eHGHJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chango", + filename: "2V0cKI0OB5U7WaJyz324TFUaAw", + category: "display", + url: "https://fonts.gstatic.com/s/chango/v29/2V0cKI0OB5U7WaJyz324TFUaAw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alexandria", + filename: "UMBXrPdDqW66y0Y2usFeWirXArM58BY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alexandria/v6/UMBXrPdDqW66y0Y2usFeWirXArM58BY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Cookie", + filename: "syky-y18lb0tSbfNlQCT9tPdpw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/cookie/v23/syky-y18lb0tSbfNlQCT9tPdpw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Epilogue", + filename: "O4ZRFGj5hxF0EhjimmIjuAkalnmd", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/epilogue/v20/O4ZRFGj5hxF0EhjimmIjuAkalnmd.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Libre Bodoni", + filename: "_Xmr-H45qDWDYULr5OfyZudNxgKQu_avAg", + category: "serif", + url: "https://fonts.gstatic.com/s/librebodoni/v9/_Xmr-H45qDWDYULr5OfyZudNxgKQu_avAg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Vazirmatn", + filename: "Dxxo8j6PP2D_kU2muijVGs-XAmn4eg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/vazirmatn/v16/Dxxo8j6PP2D_kU2muijVGs-XAmn4eg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Special Elite", + filename: "XLYgIZbkc4JPUL5CVArUVL0nhncESXFtUsM", + category: "display", + url: "https://fonts.gstatic.com/s/specialelite/v20/XLYgIZbkc4JPUL5CVArUVL0nhncESXFtUsM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Amaranth", + filename: "KtkuALODe433f0j1zPnCF9GqwnzW", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/amaranth/v19/KtkuALODe433f0j1zPnCF9GqwnzW.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Concert One", + filename: "VEM1Ro9xs5PjtzCu-srDqRTlhv-CuVAQ", + category: "display", + url: "https://fonts.gstatic.com/s/concertone/v24/VEM1Ro9xs5PjtzCu-srDqRTlhv-CuVAQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Eater", + filename: "mtG04_FCK7bOvpu2u3FwsXsR", + category: "display", + url: "https://fonts.gstatic.com/s/eater/v27/mtG04_FCK7bOvpu2u3FwsXsR.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Parisienne", + filename: "E21i_d3kivvAkxhLEVZpcy96DuKuavM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/parisienne/v14/E21i_d3kivvAkxhLEVZpcy96DuKuavM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tangerine", + filename: "IurY6Y5j_oScZZow4VOBDpxNhLBQ4Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/tangerine/v18/IurY6Y5j_oScZZow4VOBDpxNhLBQ4Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playfair", + filename: "0nkrC9D7PO4KhmUJ5-bYRQDioeb0", + category: "serif", + url: "https://fonts.gstatic.com/s/playfair/v10/0nkrC9D7PO4KhmUJ5-bYRQDioeb0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playfair Display SC", + filename: "ke85OhoaMkR6-hSn7kbHVoFf7ZfgMPr_pb4GEcM2M4s", + category: "serif", + url: "https://fonts.gstatic.com/s/playfairdisplaysc/v18/ke85OhoaMkR6-hSn7kbHVoFf7ZfgMPr_pb4GEcM2M4s.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Forum", + filename: "6aey4Ky-Vb8Ew_IWMJMa3mnT", + category: "display", + url: "https://fonts.gstatic.com/s/forum/v19/6aey4Ky-Vb8Ew_IWMJMa3mnT.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Geist Mono", + filename: "or3nQ6H-1_WfwkMZI_qYJrAXmzPnnks", + category: "monospace", + url: "https://fonts.gstatic.com/s/geistmono/v4/or3nQ6H-1_WfwkMZI_qYJrAXmzPnnks.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Krub", + filename: "sZlLdRyC6CRYXkYQDLlTW6E", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/krub/v11/sZlLdRyC6CRYXkYQDLlTW6E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kosugi Maru", + filename: "0nksC9PgP_wGh21A2KeqGiTqivr9iBq_", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kosugimaru/v17/0nksC9PgP_wGh21A2KeqGiTqivr9iBq_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gilda Display", + filename: "t5tmIRoYMoaYG0WEOh7HwMeR7TnFrpOHYh4", + category: "serif", + url: "https://fonts.gstatic.com/s/gildadisplay/v20/t5tmIRoYMoaYG0WEOh7HwMeR7TnFrpOHYh4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Neuton", + filename: "UMBTrPtMoH62xUZyyII7civlBw", + category: "serif", + url: "https://fonts.gstatic.com/s/neuton/v24/UMBTrPtMoH62xUZyyII7civlBw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Architects Daughter", + filename: "KtkxAKiDZI_td1Lkx62xHZHDtgO_Y-bvfY5q4szgE-Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/architectsdaughter/v20/KtkxAKiDZI_td1Lkx62xHZHDtgO_Y-bvfY5q4szgE-Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Italianno", + filename: "dg4n_p3sv6gCJkwzT6Rnj5YpQwM-gg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/italianno/v18/dg4n_p3sv6gCJkwzT6Rnj5YpQwM-gg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Poiret One", + filename: "UqyVK80NJXN4zfRgbdfbk5lWVscxdKE", + category: "display", + url: "https://fonts.gstatic.com/s/poiretone/v18/UqyVK80NJXN4zfRgbdfbk5lWVscxdKE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playball", + filename: "TK3gWksYAxQ7jbsKcj8Dl-tPKo2t", + category: "display", + url: "https://fonts.gstatic.com/s/playball/v22/TK3gWksYAxQ7jbsKcj8Dl-tPKo2t.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Black Han Sans", + filename: "ea8Aad44WunzF9a-dL6toA8r8nqVIXSkH-Hc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/blackhansans/v24/ea8Aad44WunzF9a-dL6toA8r8nqVIXSkH-Hc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pathway Gothic One", + filename: "MwQrbgD32-KAvjkYGNUUxAtW7pEBwx-dTFxeb80flQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/pathwaygothicone/v16/MwQrbgD32-KAvjkYGNUUxAtW7pEBwx-dTFxeb80flQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Antonio", + filename: "gNMEW3NwSYq_9WD3-HMoFIez5MI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/antonio/v22/gNMEW3NwSYq_9WD3-HMoFIez5MI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Homemade Apple", + filename: "Qw3EZQFXECDrI2q789EKQZJob3x9Vnksi4M7", + category: "handwriting", + url: "https://fonts.gstatic.com/s/homemadeapple/v24/Qw3EZQFXECDrI2q789EKQZJob3x9Vnksi4M7.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sorts Mill Goudy", + filename: "Qw3GZR9MED_6PSuS_50nEaVrfzgEXH0OjpM75PE", + category: "serif", + url: "https://fonts.gstatic.com/s/sortsmillgoudy/v16/Qw3GZR9MED_6PSuS_50nEaVrfzgEXH0OjpM75PE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rock Salt", + filename: "MwQ0bhv11fWD6QsAVOZbsEk7hbBWrA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/rocksalt/v24/MwQ0bhv11fWD6QsAVOZbsEk7hbBWrA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Radio Canada", + filename: "XRXT3ISXn0dBMcibU6jlAqrtcRADBHJ6ZA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/radiocanada/v26/XRXT3ISXn0dBMcibU6jlAqrtcRADBHJ6ZA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ropa Sans", + filename: "EYqxmaNOzLlWtsZSScyKWjloU5KP2g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ropasans/v16/EYqxmaNOzLlWtsZSScyKWjloU5KP2g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jura", + filename: "z7NbdRfiaC4VbcNDUCRDzJ0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/jura/v34/z7NbdRfiaC4VbcNDUCRDzJ0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Berkshire Swash", + filename: "ptRRTi-cavZOGqCvnNJDl5m5XmNPrcQybX4pQA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/berkshireswash/v22/ptRRTi-cavZOGqCvnNJDl5m5XmNPrcQybX4pQA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gelasio", + filename: "cIf9MaFfvUQxTTqSxCmrYGkHgIs", + category: "serif", + url: "https://fonts.gstatic.com/s/gelasio/v14/cIf9MaFfvUQxTTqSxCmrYGkHgIs.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "PT Mono", + filename: "9oRONYoBnWILk-9ArCg5MtPyAcg", + category: "monospace", + url: "https://fonts.gstatic.com/s/ptmono/v14/9oRONYoBnWILk-9ArCg5MtPyAcg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Saira Extra Condensed", + filename: "-nFiOHYr-vcC7h8MklGBkrvmUG9rbpkisrTT70L11Ct8sw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sairaextracondensed/v15/-nFiOHYr-vcC7h8MklGBkrvmUG9rbpkisrTT70L11Ct8sw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lexend Giga", + filename: "PlI5Fl67Mah5Y8yMHE7lkVxEt8CwfGaD", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lexendgiga/v27/PlI5Fl67Mah5Y8yMHE7lkVxEt8CwfGaD.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Handlee", + filename: "-F6xfjBsISg9aMakDmr6oilJ3ik", + category: "handwriting", + url: "https://fonts.gstatic.com/s/handlee/v20/-F6xfjBsISg9aMakDmr6oilJ3ik.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bai Jamjuree", + filename: "LDI1apSCOBt_aeQQ7ftydoaMWcjKm7sp8g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/baijamjuree/v13/LDI1apSCOBt_aeQQ7ftydoaMWcjKm7sp8g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Sans JP", + filename: "Z9XNDn9KbTDf6_f7dISNqYf_tvPT1Cr4iNJ-pwc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsansjp/v7/Z9XNDn9KbTDf6_f7dISNqYf_tvPT1Cr4iNJ-pwc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Khand", + filename: "TwMA-IINQlQQ0YpVWHU_TBqO", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/khand/v22/TwMA-IINQlQQ0YpVWHU_TBqO.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Old Mincho", + filename: "tss0ApVaYytLwxTqcxfMyBveyYb3g31S2s8p", + category: "serif", + url: "https://fonts.gstatic.com/s/zenoldmincho/v13/tss0ApVaYytLwxTqcxfMyBveyYb3g31S2s8p.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Staatliches", + filename: "HI_OiY8KO6hCsQSoAPmtMbectJG9O9PS", + category: "display", + url: "https://fonts.gstatic.com/s/staatliches/v15/HI_OiY8KO6hCsQSoAPmtMbectJG9O9PS.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lusitana", + filename: "CSR84z9ShvucWzsMKxhaRuMiSct_", + category: "serif", + url: "https://fonts.gstatic.com/s/lusitana/v14/CSR84z9ShvucWzsMKxhaRuMiSct_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Taviraj", + filename: "ahcZv8Cj3ylylTXzfO4hU-FwnU0", + category: "serif", + url: "https://fonts.gstatic.com/s/taviraj/v15/ahcZv8Cj3ylylTXzfO4hU-FwnU0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Calistoga", + filename: "6NUU8F2OJg6MeR7l4e0vtMYAwdRZfw", + category: "display", + url: "https://fonts.gstatic.com/s/calistoga/v18/6NUU8F2OJg6MeR7l4e0vtMYAwdRZfw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Monoton", + filename: "5h1aiZUrOngCibe4fkbBQ2S7FU8", + category: "display", + url: "https://fonts.gstatic.com/s/monoton/v22/5h1aiZUrOngCibe4fkbBQ2S7FU8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sofia Sans Condensed", + filename: "r05EGKVS5aVKd567NYXawnFKJaTtoAuLnLcPrNDVemxE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sofiasanscondensed/v6/r05EGKVS5aVKd567NYXawnFKJaTtoAuLnLcPrNDVemxE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Blinker", + filename: "cIf9MaFatEE-VTaPxCmrYGkHgIs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/blinker/v14/cIf9MaFatEE-VTaPxCmrYGkHgIs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Macondo", + filename: "RrQQboN9-iB1IXmOS2XO0LBBd4Y", + category: "display", + url: "https://fonts.gstatic.com/s/macondo/v27/RrQQboN9-iB1IXmOS2XO0LBBd4Y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Quantico", + filename: "rax-HiSdp9cPL3KIF4xsLjxSmlLZ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/quantico/v19/rax-HiSdp9cPL3KIF4xsLjxSmlLZ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alex Brush", + filename: "SZc83FzrJKuqFbwMKk6EtUL57DtOmCc", + category: "handwriting", + url: "https://fonts.gstatic.com/s/alexbrush/v23/SZc83FzrJKuqFbwMKk6EtUL57DtOmCc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Audiowide", + filename: "l7gdbjpo0cum0ckerWCtkQXPExpQBw", + category: "display", + url: "https://fonts.gstatic.com/s/audiowide/v22/l7gdbjpo0cum0ckerWCtkQXPExpQBw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "M PLUS 1", + filename: "R70ZjygA28ymD4HgBVu4si6cViv4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mplus1/v15/R70ZjygA28ymD4HgBVu4si6cViv4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Merienda", + filename: "gNMHW3x8Qoy5_mf8uVMCOou6_dvg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/merienda/v22/gNMHW3x8Qoy5_mf8uVMCOou6_dvg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Faustina", + filename: "XLYlIZPxYpJfTbZAFW-4F81Kp28v", + category: "serif", + url: "https://fonts.gstatic.com/s/faustina/v23/XLYlIZPxYpJfTbZAFW-4F81Kp28v.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mukta Malar", + filename: "MCoXzAXyz8LOE2FpJMxZqLv4LfQJwHbn", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/muktamalar/v14/MCoXzAXyz8LOE2FpJMxZqLv4LfQJwHbn.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "VT323", + filename: "pxiKyp0ihIEF2hsYHpT2dkNE", + category: "monospace", + url: "https://fonts.gstatic.com/s/vt323/v18/pxiKyp0ihIEF2hsYHpT2dkNE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lustria", + filename: "9oRONYodvDEyjuhOrCg5MtPyAcg", + category: "serif", + url: "https://fonts.gstatic.com/s/lustria/v14/9oRONYodvDEyjuhOrCg5MtPyAcg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yeseva One", + filename: "OpNJno4ck8vc-xYpwWWxpipfWhXD00c", + category: "display", + url: "https://fonts.gstatic.com/s/yesevaone/v24/OpNJno4ck8vc-xYpwWWxpipfWhXD00c.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Amita", + filename: "HhyaU5si9Om7PQlvAfSKEZZL", + category: "handwriting", + url: "https://fonts.gstatic.com/s/amita/v20/HhyaU5si9Om7PQlvAfSKEZZL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans HK", + filename: "nKKQ-GM_FYFRJvXzVXaAPe9hNHB3Eu7mOQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanshk/v35/nKKQ-GM_FYFRJvXzVXaAPe9hNHB3Eu7mOQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Reenie Beanie", + filename: "z7NSdR76eDkaJKZJFkkjuvWxbP2_qoOgf_w", + category: "handwriting", + url: "https://fonts.gstatic.com/s/reeniebeanie/v22/z7NSdR76eDkaJKZJFkkjuvWxbP2_qoOgf_w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alice", + filename: "OpNCnoEEmtHa6FcJpA_chzJ0", + category: "serif", + url: "https://fonts.gstatic.com/s/alice/v21/OpNCnoEEmtHa6FcJpA_chzJ0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Volkhov", + filename: "SlGQmQieoJcKemNeQTIOhHxzcD0", + category: "serif", + url: "https://fonts.gstatic.com/s/volkhov/v18/SlGQmQieoJcKemNeQTIOhHxzcD0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Hebrew", + filename: "or35Q7v33eiDljA1IufXTtVf7V6Rpko_aen0c78", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanshebrew/v50/or35Q7v33eiDljA1IufXTtVf7V6Rpko_aen0c78.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Arsenal", + filename: "wXKrE3kQtZQ4pF3D11_WAewrhXY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/arsenal/v13/wXKrE3kQtZQ4pF3D11_WAewrhXY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Monda", + filename: "TK3tWkYFABsmjvpmNBsLvPdG", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/monda/v19/TK3tWkYFABsmjvpmNBsLvPdG.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Dela Gothic One", + filename: "hESp6XxvMDRA-2eD0lXpDa6QkBAGRUsJQAlbUA", + category: "display", + url: "https://fonts.gstatic.com/s/delagothicone/v19/hESp6XxvMDRA-2eD0lXpDa6QkBAGRUsJQAlbUA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pridi", + filename: "2sDQZG5JnZLfkfWao2krbl29", + category: "serif", + url: "https://fonts.gstatic.com/s/pridi/v15/2sDQZG5JnZLfkfWao2krbl29.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mada", + filename: "7Auwp_0qnzeSTTXMLCrX0kU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mada/v21/7Auwp_0qnzeSTTXMLCrX0kU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Istok Web", + filename: "3qTvojGmgSyUukBzKslZAWF-9kIIaQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/istokweb/v26/3qTvojGmgSyUukBzKslZAWF-9kIIaQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gochi Hand", + filename: "hES06XlsOjtJsgCkx1PkTo71-n0nXWA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/gochihand/v25/hES06XlsOjtJsgCkx1PkTo71-n0nXWA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Caveat Brush", + filename: "EYq0maZfwr9S9-ETZc3fKXtMW7mT03pdQw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/caveatbrush/v12/EYq0maZfwr9S9-ETZc3fKXtMW7mT03pdQw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Abhaya Libre", + filename: "e3tmeuGtX-Co5MNzeAOqinEge0PWovdU4w", + category: "serif", + url: "https://fonts.gstatic.com/s/abhayalibre/v18/e3tmeuGtX-Co5MNzeAOqinEge0PWovdU4w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anonymous Pro", + filename: "rP2Bp2a15UIB7Un-bOeISG3pLlw89CH98Ko", + category: "monospace", + url: "https://fonts.gstatic.com/s/anonymouspro/v22/rP2Bp2a15UIB7Un-bOeISG3pLlw89CH98Ko.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cuprum", + filename: "dg4k_pLmvrkcOkB9IeFDh701Sg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cuprum/v29/dg4k_pLmvrkcOkB9IeFDh701Sg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Petrona", + filename: "mtG64_NXL7bZo9XXsXVStGsRwCU", + category: "serif", + url: "https://fonts.gstatic.com/s/petrona/v36/mtG64_NXL7bZo9XXsXVStGsRwCU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Vidaloka", + filename: "7cHrv4c3ipenMKlEass8yn4hnCci", + category: "serif", + url: "https://fonts.gstatic.com/s/vidaloka/v19/7cHrv4c3ipenMKlEass8yn4hnCci.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Bengali", + filename: "hYkUPvggTvnzO14VSXltirUdnnkt1pw8UZiHeH5yig", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifbengali/v31/hYkUPvggTvnzO14VSXltirUdnnkt1pw8UZiHeH5yig.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Hind Guntur", + filename: "wXKvE3UZrok56nvamSuJd8Qqt3M7tMDT", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hindguntur/v14/wXKvE3UZrok56nvamSuJd8Qqt3M7tMDT.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ultra", + filename: "zOLy4prXmrtY-tT6yLOD6NxF", + category: "serif", + url: "https://fonts.gstatic.com/s/ultra/v25/zOLy4prXmrtY-tT6yLOD6NxF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pinyon Script", + filename: "6xKpdSJbL9-e9LuoeQiDRQR8aOLQO4bhiDY", + category: "handwriting", + url: "https://fonts.gstatic.com/s/pinyonscript/v24/6xKpdSJbL9-e9LuoeQiDRQR8aOLQO4bhiDY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Goldman", + filename: "pe0uMIWbN4JFplR2LDJ4Bt-7G98", + category: "display", + url: "https://fonts.gstatic.com/s/goldman/v21/pe0uMIWbN4JFplR2LDJ4Bt-7G98.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pontano Sans", + filename: "qFdD35GdgYR8EzR6oBLDHa3qwjUMg1siNQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/pontanosans/v19/qFdD35GdgYR8EzR6oBLDHa3qwjUMg1siNQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Cousine", + filename: "d6lIkaiiRdih4SpPzSMlzTbtz9k", + category: "monospace", + url: "https://fonts.gstatic.com/s/cousine/v29/d6lIkaiiRdih4SpPzSMlzTbtz9k.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nanum Pen Script", + filename: "daaDSSYiLGqEal3MvdA_FOL_3FkN2z7-aMFCcTU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/nanumpenscript/v25/daaDSSYiLGqEal3MvdA_FOL_3FkN2z7-aMFCcTU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Andika", + filename: "mem_Ya6iyW-LwqgAbbwRWrwGVA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/andika/v27/mem_Ya6iyW-LwqgAbbwRWrwGVA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ubuntu Mono", + filename: "KFOjCneDtsqEr0keqCMhbBc9AMX6lJBP", + category: "monospace", + url: "https://fonts.gstatic.com/s/ubuntumono/v19/KFOjCneDtsqEr0keqCMhbBc9AMX6lJBP.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hind Vadodara", + filename: "neINzCKvrIcn5pbuuuriV9tTcJXfrXsfvSo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hindvadodara/v16/neINzCKvrIcn5pbuuuriV9tTcJXfrXsfvSo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pangolin", + filename: "cY9GfjGcW0FPpi-tWPfK5d3aiLBG", + category: "handwriting", + url: "https://fonts.gstatic.com/s/pangolin/v12/cY9GfjGcW0FPpi-tWPfK5d3aiLBG.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nothing You Could Do", + filename: "oY1B8fbBpaP5OX3DtrRYf_Q2BPB1SnfZb0OJl1ol2Ymo", + category: "handwriting", + url: "https://fonts.gstatic.com/s/nothingyoucoulddo/v21/oY1B8fbBpaP5OX3DtrRYf_Q2BPB1SnfZb0OJl1ol2Ymo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Unica One", + filename: "DPEuYwWHyAYGVTSmalshdtffuEY7FA", + category: "display", + url: "https://fonts.gstatic.com/s/unicaone/v20/DPEuYwWHyAYGVTSmalshdtffuEY7FA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cinzel Decorative", + filename: "daaCSScvJGqLYhG8nNt8KPPswUAPnh7URs1LaCyC", + category: "display", + url: "https://fonts.gstatic.com/s/cinzeldecorative/v19/daaCSScvJGqLYhG8nNt8KPPswUAPnh7URs1LaCyC.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Nastaliq Urdu", + filename: "LhW4MUPbN-oZdNFcBy1-DJYsEoTq5puHSPANO9blOA", + category: "serif", + url: "https://fonts.gstatic.com/s/notonastaliqurdu/v23/LhW4MUPbN-oZdNFcBy1-DJYsEoTq5puHSPANO9blOA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mr Dafoe", + filename: "lJwE-pIzkS5NXuMMrGiqg7MCxz_C", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mrdafoe/v15/lJwE-pIzkS5NXuMMrGiqg7MCxz_C.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Biryani", + filename: "hv-WlzNxIFoO84YdTUwZPTh5T-s", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/biryani/v15/hv-WlzNxIFoO84YdTUwZPTh5T-s.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Share Tech Mono", + filename: "J7aHnp1uDWRBEqV98dVQztYldFc7pAsEIc3Xew", + category: "monospace", + url: "https://fonts.gstatic.com/s/sharetechmono/v16/J7aHnp1uDWRBEqV98dVQztYldFc7pAsEIc3Xew.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bad Script", + filename: "6NUT8F6PJgbFWQn47_x7lOwuzd1AZtw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/badscript/v18/6NUT8F6PJgbFWQn47_x7lOwuzd1AZtw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gabarito", + filename: "QGYtz_0dZAGKJJ4t3EtvUYTknuBJ", + category: "display", + url: "https://fonts.gstatic.com/s/gabarito/v9/QGYtz_0dZAGKJJ4t3EtvUYTknuBJ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Fira Sans Extra Condensed", + filename: "NaPKcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda5fiku3efvE8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/firasansextracondensed/v11/NaPKcYDaAO5dirw6IaFn7lPJFqXmS-M9Atn3wgda5fiku3efvE8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Boogaloo", + filename: "kmK-Zq45GAvOdnaW6x1F_SrQo_1K", + category: "display", + url: "https://fonts.gstatic.com/s/boogaloo/v25/kmK-Zq45GAvOdnaW6x1F_SrQo_1K.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Martel Sans", + filename: "h0GsssGi7VdzDgKjM-4d8ijfze-PPlUu", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/martelsans/v14/h0GsssGi7VdzDgKjM-4d8ijfze-PPlUu.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cabin Condensed", + filename: "nwpMtK6mNhBK2err_hqkYhHRqmwaYOjZ5HZl8Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cabincondensed/v21/nwpMtK6mNhBK2err_hqkYhHRqmwaYOjZ5HZl8Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Saira Semi Condensed", + filename: "U9MD6c-2-nnJkHxyCjRcnMHcWVWV1cWRRU8LYuceqGT-", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sairasemicondensed/v15/U9MD6c-2-nnJkHxyCjRcnMHcWVWV1cWRRU8LYuceqGT-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fira Mono", + filename: "N0bX2SlFPv1weGeLZDtQIfTTkdbJYA", + category: "monospace", + url: "https://fonts.gstatic.com/s/firamono/v16/N0bX2SlFPv1weGeLZDtQIfTTkdbJYA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Belanosima", + filename: "3y9k6bI8ejDo_3MfCDSLxABbF3JBg54", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/belanosima/v4/3y9k6bI8ejDo_3MfCDSLxABbF3JBg54.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Racing Sans One", + filename: "sykr-yRtm7EvTrXNxkv5jfKKyDCwL3rmWpIBtA", + category: "display", + url: "https://fonts.gstatic.com/s/racingsansone/v17/sykr-yRtm7EvTrXNxkv5jfKKyDCwL3rmWpIBtA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cormorant Infant", + filename: "HhyPU44g9vKiM1sORYSiWeAsLN993_Af2DsAXq4", + category: "serif", + url: "https://fonts.gstatic.com/s/cormorantinfant/v22/HhyPU44g9vKiM1sORYSiWeAsLN993_Af2DsAXq4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sofia", + filename: "8QIHdirahM3j_vu-sowsrqjk", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sofia/v15/8QIHdirahM3j_vu-sowsrqjk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Syncopate", + filename: "pe0sMIuPIYBCpEV5eFdyAv2-C99ycg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/syncopate/v24/pe0sMIuPIYBCpEV5eFdyAv2-C99ycg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sriracha", + filename: "0nkrC9D4IuYBgWcI9ObYRQDioeb0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sriracha/v16/0nkrC9D4IuYBgWcI9ObYRQDioeb0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Belleza", + filename: "0nkoC9_pNeMfhX4BtcbyawzruP8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/belleza/v18/0nkoC9_pNeMfhX4BtcbyawzruP8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Varela", + filename: "DPEtYwqExx0AWHXJBBQFfvzDsQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/varela/v17/DPEtYwqExx0AWHXJBBQFfvzDsQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tilt Warp", + filename: "AlZy_zVDs5XpmO7yn3whdXf4XB0Tow", + category: "display", + url: "https://fonts.gstatic.com/s/tiltwarp/v18/AlZy_zVDs5XpmO7yn3whdXf4XB0Tow.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ruda", + filename: "k3kfo8YQJOpFmn8XadbJM0A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ruda/v30/k3kfo8YQJOpFmn8XadbJM0A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Georama", + filename: "MCoTzAn438bIEyxFZaAS3pP5H_E", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/georama/v15/MCoTzAn438bIEyxFZaAS3pP5H_E.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Anuphan", + filename: "2sDeZGxYgY7LkLT0qW0Ja029G7w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anuphan/v6/2sDeZGxYgY7LkLT0qW0Ja029G7w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Black Ops One", + filename: "qWcsB6-ypo7xBdr6Xshe96H3WDzRtjkho4M", + category: "display", + url: "https://fonts.gstatic.com/s/blackopsone/v21/qWcsB6-ypo7xBdr6Xshe96H3WDzRtjkho4M.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lalezar", + filename: "zrfl0HLVx-HwTP82UaDyIiL0RCg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lalezar/v16/zrfl0HLVx-HwTP82UaDyIiL0RCg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cutive Mono", + filename: "m8JWjfRfY7WVjVi2E-K9H5RFRG-K3Mud", + category: "monospace", + url: "https://fonts.gstatic.com/s/cutivemono/v23/m8JWjfRfY7WVjVi2E-K9H5RFRG-K3Mud.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "GFS Didot", + filename: "Jqzh5TybZ9vZMWFssvwiF-fGFSCGAA", + category: "serif", + url: "https://fonts.gstatic.com/s/gfsdidot/v18/Jqzh5TybZ9vZMWFssvwiF-fGFSCGAA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gudea", + filename: "neIFzCqgsI0mp-CP9IGON7Ez", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gudea/v16/neIFzCqgsI0mp-CP9IGON7Ez.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Michroma", + filename: "PN_zRfy9qWD8fEagAMg6rzjb_-Da", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/michroma/v21/PN_zRfy9qWD8fEagAMg6rzjb_-Da.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Carter One", + filename: "q5uCsoe5IOB2-pXv9UcNIxR2hYxREMs", + category: "display", + url: "https://fonts.gstatic.com/s/carterone/v18/q5uCsoe5IOB2-pXv9UcNIxR2hYxREMs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Niramit", + filename: "I_uuMpWdvgLdNxVLbbRQkiCvs5Y", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/niramit/v12/I_uuMpWdvgLdNxVLbbRQkiCvs5Y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Julius Sans One", + filename: "1Pt2g8TAX_SGgBGUi0tGOYEga5W-xXEW6aGXHw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/juliussansone/v20/1Pt2g8TAX_SGgBGUi0tGOYEga5W-xXEW6aGXHw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Marck Script", + filename: "nwpTtK2oNgBA3Or78gapdwuCzyI-aMPF7Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/marckscript/v22/nwpTtK2oNgBA3Or78gapdwuCzyI-aMPF7Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cedarville Cursive", + filename: "yYL00g_a2veiudhUmxjo5VKkoqA-B_neJbBxw8BeTg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/cedarvillecursive/v18/yYL00g_a2veiudhUmxjo5VKkoqA-B_neJbBxw8BeTg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Squada One", + filename: "BCasqZ8XsOrx4mcOk6MtWaA8WDBkHgs", + category: "display", + url: "https://fonts.gstatic.com/s/squadaone/v20/BCasqZ8XsOrx4mcOk6MtWaA8WDBkHgs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Economica", + filename: "Qw3fZQZaHCLgIWa29ZBrMcgAAl1lfQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/economica/v17/Qw3fZQZaHCLgIWa29ZBrMcgAAl1lfQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pragati Narrow", + filename: "vm8vdRf0T0bS1ffgsPB7WZ-mD17_ytN3M48a", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/pragatinarrow/v15/vm8vdRf0T0bS1ffgsPB7WZ-mD17_ytN3M48a.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tomorrow", + filename: "WBLmrETNbFtZCeGqgSXVcWHALdio", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tomorrow/v19/WBLmrETNbFtZCeGqgSXVcWHALdio.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mona Sans", + filename: "o-0IIpQmx24alC5A4PNb4j5Ba_2c7A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/monasans/v4/o-0IIpQmx24alC5A4PNb4j5Ba_2c7A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Serif Display", + filename: "buErppa9f8_vkXaZLAgP0G5Wi6QmA1QAcqRNOlqMKg", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifdisplay/v29/buErppa9f8_vkXaZLAgP0G5Wi6QmA1QAcqRNOlqMKg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Arapey", + filename: "-W__XJn-UDDA2RC6Z9AcZkIzeg", + category: "serif", + url: "https://fonts.gstatic.com/s/arapey/v17/-W__XJn-UDDA2RC6Z9AcZkIzeg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Six Caps", + filename: "6ae_4KGrU7VR7bNmabcS9XXaPCop", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sixcaps/v23/6ae_4KGrU7VR7bNmabcS9XXaPCop.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Afacad", + filename: "6NUX8FKMIQOGaw6qhqYLvO0cyA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/afacad/v3/6NUX8FKMIQOGaw6qhqYLvO0cyA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Itim", + filename: "0nknC9ziJOYewARKkc7ZdwU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/itim/v16/0nknC9ziJOYewARKkc7ZdwU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Khula", + filename: "OpNCnoEOns3V7FcJpA_chzJ0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/khula/v17/OpNCnoEOns3V7FcJpA_chzJ0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Besley", + filename: "PlI8FlO1MaNwaNGMWw2G-H1cnA", + category: "serif", + url: "https://fonts.gstatic.com/s/besley/v22/PlI8FlO1MaNwaNGMWw2G-H1cnA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Averia Serif Libre", + filename: "neIWzD2ms4wxr6GvjeD0X88SHPyX2xY-pQGOyYw2fw", + category: "display", + url: "https://fonts.gstatic.com/s/averiaseriflibre/v19/neIWzD2ms4wxr6GvjeD0X88SHPyX2xY-pQGOyYw2fw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Akshar", + filename: "Yq6V-LyHWTfz9rGyoxRktOdClg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/akshar/v17/Yq6V-LyHWTfz9rGyoxRktOdClg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Coda", + filename: "SLXHc1jY5nQ8JUIMapaN39I", + category: "display", + url: "https://fonts.gstatic.com/s/coda/v22/SLXHc1jY5nQ8JUIMapaN39I.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Damion", + filename: "hv-XlzJ3KEUe_YZUbWY3MTFgVg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/damion/v15/hv-XlzJ3KEUe_YZUbWY3MTFgVg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sofia Sans Extra Condensed", + filename: "raxoHjafvdAIOju4GcIfJH0i7zi50X3zRtuLNiMS0cSpLE9Ukzek", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sofiasansextracondensed/v6/raxoHjafvdAIOju4GcIfJH0i7zi50X3zRtuLNiMS0cSpLE9Ukzek.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sarala", + filename: "uK_y4riEZv4o1w9RCh0TMv6EXw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sarala/v14/uK_y4riEZv4o1w9RCh0TMv6EXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shippori Mincho B1", + filename: "wXK2E2wCr44tulPdnn-xbIpJ9RgT9-nyjqBr1lO97Q", + category: "serif", + url: "https://fonts.gstatic.com/s/shipporiminchob1/v24/wXK2E2wCr44tulPdnn-xbIpJ9RgT9-nyjqBr1lO97Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Kaku Gothic Antique", + filename: "6qLQKYkHvh-nlUpKPAdoVFBtfxDzIn1eCzpB21-g3RKjc4d7", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zenkakugothicantique/v18/6qLQKYkHvh-nlUpKPAdoVFBtfxDzIn1eCzpB21-g3RKjc4d7.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alegreya Sans SC", + filename: "mtGh4-RGJqfMvt7P8FUr0Q1j-Hf1Nk5v9ixALYs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alegreyasanssc/v24/mtGh4-RGJqfMvt7P8FUr0Q1j-Hf1Nk5v9ixALYs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Adamina", + filename: "j8_r6-DH1bjoc-dwu-reETl4Bno", + category: "serif", + url: "https://fonts.gstatic.com/s/adamina/v22/j8_r6-DH1bjoc-dwu-reETl4Bno.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Secular One", + filename: "8QINdiTajsj_87rMuMdKypDlMul7LJpK", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/secularone/v14/8QINdiTajsj_87rMuMdKypDlMul7LJpK.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shrikhand", + filename: "a8IbNovtLWfR7T7bMJwbBIiQ0zhMtA", + category: "display", + url: "https://fonts.gstatic.com/s/shrikhand/v17/a8IbNovtLWfR7T7bMJwbBIiQ0zhMtA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yrsa", + filename: "wlp-gwnQFlxs5QvV-IwQwWc", + category: "serif", + url: "https://fonts.gstatic.com/s/yrsa/v25/wlp-gwnQFlxs5QvV-IwQwWc.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Allison", + filename: "X7nl4b88AP2nkbvZOCaQ4MTgAgk", + category: "handwriting", + url: "https://fonts.gstatic.com/s/allison/v13/X7nl4b88AP2nkbvZOCaQ4MTgAgk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bevan", + filename: "4iCj6KZ0a9NXjF8aUir7tlSJ", + category: "serif", + url: "https://fonts.gstatic.com/s/bevan/v26/4iCj6KZ0a9NXjF8aUir7tlSJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Balsamiq Sans", + filename: "P5sEzZiAbNrN8SB3lQQX7Pnc8dkdIYdNHzs", + category: "display", + url: "https://fonts.gstatic.com/s/balsamiqsans/v15/P5sEzZiAbNrN8SB3lQQX7Pnc8dkdIYdNHzs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jua", + filename: "co3KmW9ljjAjc-DZCsKgsg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/jua/v18/co3KmW9ljjAjc-DZCsKgsg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chewy", + filename: "uK_94ruUb-k-wk5xIDMfO-ed", + category: "display", + url: "https://fonts.gstatic.com/s/chewy/v18/uK_94ruUb-k-wk5xIDMfO-ed.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Leckerli One", + filename: "V8mCoQH8VCsNttEnxnGQ-1itLZxcBtItFw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/leckerlione/v22/V8mCoQH8VCsNttEnxnGQ-1itLZxcBtItFw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nixie One", + filename: "lW-8wjkKLXjg5y2o2uUoUOFzpS-yLw", + category: "display", + url: "https://fonts.gstatic.com/s/nixieone/v17/lW-8wjkKLXjg5y2o2uUoUOFzpS-yLw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Reddit Sans", + filename: "EYq3maFOxq1T_-ETdN7EKTNscZef2mNE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/redditsans/v6/EYq3maFOxq1T_-ETdN7EKTNscZef2mNE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mrs Saint Delafield", + filename: "v6-IGZDIOVXH9xtmTZfRagunqBw5WC62cK4tLsubB2w", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mrssaintdelafield/v14/v6-IGZDIOVXH9xtmTZfRagunqBw5WC62cK4tLsubB2w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "BenchNine", + filename: "ahcbv8612zF4jxrwMosrV8N1jU2gog", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/benchnine/v17/ahcbv8612zF4jxrwMosrV8N1jU2gog.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rochester", + filename: "6ae-4KCqVa4Zy6Fif-Uy31vWNTMwoQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/rochester/v24/6ae-4KCqVa4Zy6Fif-Uy31vWNTMwoQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Atkinson Hyperlegible Next", + filename: "NaPNcYPdHfdVxJw0IfIP0lvYFqijb-UxCtm5_wdGsdiOlXuWpVZ-", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/atkinsonhyperlegiblenext/v7/NaPNcYPdHfdVxJw0IfIP0lvYFqijb-UxCtm5_wdGsdiOlXuWpVZ-.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ms Madi", + filename: "HTxsL2UxNnOji5E1N-DPiI7QAYo", + category: "handwriting", + url: "https://fonts.gstatic.com/s/msmadi/v2/HTxsL2UxNnOji5E1N-DPiI7QAYo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Palanquin", + filename: "9XUnlJ90n1fBFg7ceXwsdlFMzLC2Zw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/palanquin/v17/9XUnlJ90n1fBFg7ceXwsdlFMzLC2Zw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Malayalam", + filename: "sJoY3K5XjsSdcnzn071rL37lpAOsUThnF5k90TZO69o", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmalayalam/v29/sJoY3K5XjsSdcnzn071rL37lpAOsUThnF5k90TZO69o.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Lateef", + filename: "hESw6XVnNCxEvkbMpheEZo_H_w", + category: "serif", + url: "https://fonts.gstatic.com/s/lateef/v35/hESw6XVnNCxEvkbMpheEZo_H_w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "La Belle Aurore", + filename: "RrQIbot8-mNYKnGNDkWlocovHeIIG-eFNVmULg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/labelleaurore/v23/RrQIbot8-mNYKnGNDkWlocovHeIIG-eFNVmULg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sansita", + filename: "QldONTRRphEb_-V7HBm7TXFf3qw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sansita/v12/QldONTRRphEb_-V7HBm7TXFf3qw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Do Hyeon", + filename: "TwMN-I8CRRU2zM86HFE3ZwaH__-C", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/dohyeon/v21/TwMN-I8CRRU2zM86HFE3ZwaH__-C.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Sans Thai", + filename: "m8JPje1VVIzcq1HzJq2AEdo2Tj_qvLq8DtwhZcNaUg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsansthai/v11/m8JPje1VVIzcq1HzJq2AEdo2Tj_qvLq8DtwhZcNaUg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Reem Kufi", + filename: "2sDcZGJLip7W2J7v7wQDb2-4C7wFZQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/reemkufi/v28/2sDcZGJLip7W2J7v7wQDb2-4C7wFZQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Tektur", + filename: "XoHn2YHtS7q969kNAR4Jt9Yxlw", + category: "display", + url: "https://fonts.gstatic.com/s/tektur/v6/XoHn2YHtS7q969kNAR4Jt9Yxlw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Kaisei Decol", + filename: "bMrwmSqP45sidWf3QmfFW6iyW1EP22OjoA", + category: "serif", + url: "https://fonts.gstatic.com/s/kaiseidecol/v11/bMrwmSqP45sidWf3QmfFW6iyW1EP22OjoA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Basic", + filename: "xfu_0WLxV2_XKQN34lDVyR7D", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/basic/v18/xfu_0WLxV2_XKQN34lDVyR7D.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Judson", + filename: "FeVRS0Fbvbc14VxRD7N01bV7kg", + category: "serif", + url: "https://fonts.gstatic.com/s/judson/v20/FeVRS0Fbvbc14VxRD7N01bV7kg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Wix Madefor Text", + filename: "-W_lXI_oSymQ8Qj-Apx3HGN_Hu1RViIb5gwfsj4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/wixmadefortext/v17/-W_lXI_oSymQ8Qj-Apx3HGN_Hu1RViIb5gwfsj4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Pirata One", + filename: "I_urMpiDvgLdLh0fAtoftiiEr5_BdZ8", + category: "display", + url: "https://fonts.gstatic.com/s/pirataone/v23/I_urMpiDvgLdLh0fAtoftiiEr5_BdZ8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bona Nova SC", + filename: "mem5YaShyGWDiYdPG_c1Af4-VeJoCqeDjg", + category: "serif", + url: "https://fonts.gstatic.com/s/bonanovasc/v1/mem5YaShyGWDiYdPG_c1Af4-VeJoCqeDjg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Covered By Your Grace", + filename: "QGYwz-AZahWOJJI9kykWW9mD6opopoqXSOS0FgItq6bFIg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/coveredbyyourgrace/v17/QGYwz-AZahWOJJI9kykWW9mD6opopoqXSOS0FgItq6bFIg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Potta One", + filename: "FeVSS05Bp6cy7xI-YfxQ3Z5nm29Gww", + category: "display", + url: "https://fonts.gstatic.com/s/pottaone/v19/FeVSS05Bp6cy7xI-YfxQ3Z5nm29Gww.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fredericka the Great", + filename: "9Bt33CxNwt7aOctW2xjbCstzwVKsIBVV-9Skz7Ylch2L", + category: "display", + url: "https://fonts.gstatic.com/s/frederickathegreat/v23/9Bt33CxNwt7aOctW2xjbCstzwVKsIBVV-9Skz7Ylch2L.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Livvic", + filename: "rnCp-x1S2hzjrlfnb-k6unzeSA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/livvic/v15/rnCp-x1S2hzjrlfnb-k6unzeSA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Spline Sans", + filename: "_6_7ED73Uf-2WfU2LzycEYAlkiw_SQ5j", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/splinesans/v16/_6_7ED73Uf-2WfU2LzycEYAlkiw_SQ5j.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Hachi Maru Pop", + filename: "HI_TiYoRLqpLrEiMAuO9Ysfz7rW1EM_btd8u", + category: "handwriting", + url: "https://fonts.gstatic.com/s/hachimarupop/v23/HI_TiYoRLqpLrEiMAuO9Ysfz7rW1EM_btd8u.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Wix Madefor Display", + filename: "SZcl3EX9IbbyeJ8aOluD52KXgUA_7Ed1OVbkA0VeBNE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/wixmadefordisplay/v12/SZcl3EX9IbbyeJ8aOluD52KXgUA_7Ed1OVbkA0VeBNE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Nova Square", + filename: "RrQUbo9-9DV7b06QHgSWsZhARYMgGtWA", + category: "display", + url: "https://fonts.gstatic.com/s/novasquare/v27/RrQUbo9-9DV7b06QHgSWsZhARYMgGtWA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Funnel Sans", + filename: "OpNIno8Dg9bX6Bsp3Wq69Qp1dBnKyl7c", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/funnelsans/v3/OpNIno8Dg9bX6Bsp3Wq69Qp1dBnKyl7c.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Londrina Solid", + filename: "flUhRq6sw40kQEJxWNgkLuudGcNZIhI8tIHh", + category: "display", + url: "https://fonts.gstatic.com/s/londrinasolid/v19/flUhRq6sw40kQEJxWNgkLuudGcNZIhI8tIHh.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "MuseoModerno", + filename: "zrfi0HnU0_7wWdMrFcWqSEXVXAPqgALaow", + category: "display", + url: "https://fonts.gstatic.com/s/museomoderno/v29/zrfi0HnU0_7wWdMrFcWqSEXVXAPqgALaow.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rufina", + filename: "Yq6V-LyURyLy-aKyoxRktOdClg", + category: "serif", + url: "https://fonts.gstatic.com/s/rufina/v17/Yq6V-LyURyLy-aKyoxRktOdClg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Overpass Mono", + filename: "_Xmq-H86tzKDdAPa-KPQZ-AC5ii-t_-2G38", + category: "monospace", + url: "https://fonts.gstatic.com/s/overpassmono/v21/_Xmq-H86tzKDdAPa-KPQZ-AC5ii-t_-2G38.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Allerta Stencil", + filename: "HTx0L209KT-LmIE9N7OR6eiycOeF-zz313DuvQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/allertastencil/v24/HTx0L209KT-LmIE9N7OR6eiycOeF-zz313DuvQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Armata", + filename: "gokvH63_HV5jQ-E9lD53Q2u_mQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/armata/v21/gokvH63_HV5jQ-E9lD53Q2u_mQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aboreto", + filename: "5DCXAKLhwDDQ4N8blKTeA2yuxSY", + category: "display", + url: "https://fonts.gstatic.com/s/aboreto/v2/5DCXAKLhwDDQ4N8blKTeA2yuxSY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Just Another Hand", + filename: "845CNN4-AJyIGvIou-6yJKyptyOpOcr_BmmlS5aw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/justanotherhand/v21/845CNN4-AJyIGvIou-6yJKyptyOpOcr_BmmlS5aw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "K2D", + filename: "J7aTnpF2V0ETd68tnLcg7w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/k2d/v13/J7aTnpF2V0ETd68tnLcg7w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Palanquin Dark", + filename: "xn75YHgl1nqmANMB-26xC7yuF_6OTEo9VtfE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/palanquindark/v17/xn75YHgl1nqmANMB-26xC7yuF_6OTEo9VtfE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kreon", + filename: "t5tuIRIUKY-TFEXAeWm_rb7p", + category: "serif", + url: "https://fonts.gstatic.com/s/kreon/v40/t5tuIRIUKY-TFEXAeWm_rb7p.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Grandstander", + filename: "ga6KawtA-GpSsTWrnNHPCSIWbTq6fMRRMw", + category: "display", + url: "https://fonts.gstatic.com/s/grandstander/v20/ga6KawtA-GpSsTWrnNHPCSIWbTq6fMRRMw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gloock", + filename: "Iurb6YFw84WUY4N5jxylBrdRjQ", + category: "serif", + url: "https://fonts.gstatic.com/s/gloock/v8/Iurb6YFw84WUY4N5jxylBrdRjQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Neucha", + filename: "q5uGsou0JOdh94bvugNsCxVEgA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/neucha/v18/q5uGsou0JOdh94bvugNsCxVEgA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Charm", + filename: "7cHmv4oii5K0MeYvIe804WIo", + category: "handwriting", + url: "https://fonts.gstatic.com/s/charm/v14/7cHmv4oii5K0MeYvIe804WIo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Marcellus SC", + filename: "ke8iOgUHP1dg-Rmi6RWjbLEPgdydGKikhA", + category: "serif", + url: "https://fonts.gstatic.com/s/marcellussc/v14/ke8iOgUHP1dg-Rmi6RWjbLEPgdydGKikhA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fahkwang", + filename: "Noax6Uj3zpmBOgbNpNqPsr1ZPTZ4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/fahkwang/v18/Noax6Uj3zpmBOgbNpNqPsr1ZPTZ4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shadows Into Light Two", + filename: "4iC86LVlZsRSjQhpWGedwyOoW-0A6_kpsyNmlAvNGLNnIF0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/shadowsintolighttwo/v19/4iC86LVlZsRSjQhpWGedwyOoW-0A6_kpsyNmlAvNGLNnIF0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Electrolize", + filename: "cIf5Ma1dtE0zSiGSiED7AUEGso5tQafB", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/electrolize/v20/cIf5Ma1dtE0zSiGSiED7AUEGso5tQafB.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ovo", + filename: "yYLl0h7Wyfzjy4Q5_3WVxA", + category: "serif", + url: "https://fonts.gstatic.com/s/ovo/v18/yYLl0h7Wyfzjy4Q5_3WVxA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yatra One", + filename: "C8ch4copsHzj8p7NaF0xw1OBbRDvXw", + category: "display", + url: "https://fonts.gstatic.com/s/yatraone/v16/C8ch4copsHzj8p7NaF0xw1OBbRDvXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Days One", + filename: "mem9YaCnxnKRiYZOCLYVeLkWVNBt", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/daysone/v19/mem9YaCnxnKRiYZOCLYVeLkWVNBt.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lemonada", + filename: "0QIjMXFD9oygTWy_R_tOtfWm8qTX", + category: "display", + url: "https://fonts.gstatic.com/s/lemonada/v31/0QIjMXFD9oygTWy_R_tOtfWm8qTX.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Pattaya", + filename: "ea8ZadcqV_zkHY-XNdCn92ZEmVs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/pattaya/v18/ea8ZadcqV_zkHY-XNdCn92ZEmVs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Italiana", + filename: "QldNNTtLsx4E__B0XTmRY31Wx7Vv", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/italiana/v21/QldNNTtLsx4E__B0XTmRY31Wx7Vv.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kiwi Maru", + filename: "R70YjykGkuuDep-hRg6YmACQXzLhTg", + category: "serif", + url: "https://fonts.gstatic.com/s/kiwimaru/v20/R70YjykGkuuDep-hRg6YmACQXzLhTg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sintony", + filename: "XoHm2YDqR7-98cVUITQnu98ojjs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sintony/v17/XoHm2YDqR7-98cVUITQnu98ojjs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alef", + filename: "FeVfS0NQpLYgrjJbC5FxxbU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alef/v24/FeVfS0NQpLYgrjJbC5FxxbU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chonburi", + filename: "8AtqGs-wOpGRTBq66IWaFr3biAfZ", + category: "display", + url: "https://fonts.gstatic.com/s/chonburi/v14/8AtqGs-wOpGRTBq66IWaFr3biAfZ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Laila", + filename: "LYjMdG_8nE8jDIRdiidIrEIu", + category: "serif", + url: "https://fonts.gstatic.com/s/laila/v20/LYjMdG_8nE8jDIRdiidIrEIu.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aclonica", + filename: "K2FyfZJVlfNNSEBXGb7TCI6oBjLz", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/aclonica/v25/K2FyfZJVlfNNSEBXGb7TCI6oBjLz.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Corben", + filename: "LYjDdGzzklQtCMp9oAlEpVs3VQ", + category: "display", + url: "https://fonts.gstatic.com/s/corben/v23/LYjDdGzzklQtCMp9oAlEpVs3VQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cabin Sketch", + filename: "QGYpz_kZZAGCONcK2A4bGOjMn9JM6fnuKg", + category: "display", + url: "https://fonts.gstatic.com/s/cabinsketch/v23/QGYpz_kZZAGCONcK2A4bGOjMn9JM6fnuKg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Darker Grotesque", + filename: "U9MH6cuh-mLQlC4BKCtayOfARkSVm7beJWcKUOI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/darkergrotesque/v10/U9MH6cuh-mLQlC4BKCtayOfARkSVm7beJWcKUOI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Enriqueta", + filename: "goksH6L7AUFrRvV44HVTS0CjkP1Yog", + category: "serif", + url: "https://fonts.gstatic.com/s/enriqueta/v19/goksH6L7AUFrRvV44HVTS0CjkP1Yog.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stardos Stencil", + filename: "X7n94bcuGPC8hrvEOHXOgaKCc2TR71R3tiSx0g", + category: "display", + url: "https://fonts.gstatic.com/s/stardosstencil/v15/X7n94bcuGPC8hrvEOHXOgaKCc2TR71R3tiSx0g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kameron", + filename: "vm82dR7vXErQxuznsL4wL-XIYH8", + category: "serif", + url: "https://fonts.gstatic.com/s/kameron/v18/vm82dR7vXErQxuznsL4wL-XIYH8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bowlby One SC", + filename: "DtVlJxerQqQm37tzN3wMug9Pzgj8owhNjuE", + category: "display", + url: "https://fonts.gstatic.com/s/bowlbyonesc/v27/DtVlJxerQqQm37tzN3wMug9Pzgj8owhNjuE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Karma", + filename: "va9I4kzAzMZRGIBvS-J3kbDP", + category: "serif", + url: "https://fonts.gstatic.com/s/karma/v18/va9I4kzAzMZRGIBvS-J3kbDP.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alatsi", + filename: "TK3iWkUJAxQ2nLNGHjUHte5fKg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alatsi/v14/TK3iWkUJAxQ2nLNGHjUHte5fKg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "BIZ UDGothic", + filename: "daafSTouBF7RUjnbt8p3LuKttQN98z_MbQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bizudgothic/v12/daafSTouBF7RUjnbt8p3LuKttQN98z_MbQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "M PLUS 2", + filename: "7Au8p_Eq3gO_OGbGGgLW4EAFOUEH", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mplus2/v15/7Au8p_Eq3gO_OGbGGgLW4EAFOUEH.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mali", + filename: "N0ba2SRONuN4eCrODlxxOd8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mali/v13/N0ba2SRONuN4eCrODlxxOd8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rozha One", + filename: "AlZy_zVFtYP12Zncg2khdXf4XB0Tow", + category: "serif", + url: "https://fonts.gstatic.com/s/rozhaone/v17/AlZy_zVFtYP12Zncg2khdXf4XB0Tow.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Red Rose", + filename: "QdVVSTYiLBjouPgEUbLkkwVoknQx", + category: "display", + url: "https://fonts.gstatic.com/s/redrose/v25/QdVVSTYiLBjouPgEUbLkkwVoknQx.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Limelight", + filename: "XLYkIZL7aopJVbZJHDuYPeNGrnY2TA", + category: "display", + url: "https://fonts.gstatic.com/s/limelight/v21/XLYkIZL7aopJVbZJHDuYPeNGrnY2TA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cormorant Upright", + filename: "VuJrdM3I2Y35poFONtLdafkUCHw1y2vVjjTkeMnz", + category: "serif", + url: "https://fonts.gstatic.com/s/cormorantupright/v19/VuJrdM3I2Y35poFONtLdafkUCHw1y2vVjjTkeMnz.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Herr Von Muellerhoff", + filename: "WBL6rFjRZkREW8WqmCWYLgCkQKXb4CAft3c6_qJY3QPQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/herrvonmuellerhoff/v23/WBL6rFjRZkREW8WqmCWYLgCkQKXb4CAft3c6_qJY3QPQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anek Bangla", + filename: "_gP81R38qTExHg-17BhM6mSxYPp7oSNy", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anekbangla/v16/_gP81R38qTExHg-17BhM6mSxYPp7oSNy.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Anek Latin", + filename: "co3DmWZulTRoU4a8dqrWk6Pjp3Di8U0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/aneklatin/v11/co3DmWZulTRoU4a8dqrWk6Pjp3Di8U0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Yuji Mai", + filename: "ZgNQjPxdJ7DEHrS0gC38hmHmNpCO", + category: "serif", + url: "https://fonts.gstatic.com/s/yujimai/v8/ZgNQjPxdJ7DEHrS0gC38hmHmNpCO.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Goudy Bookletter 1911", + filename: "sykt-z54laciWfKv-kX8krex0jDiD2HbY6I5tRbXZ4IXAA", + category: "serif", + url: "https://fonts.gstatic.com/s/goudybookletter1911/v21/sykt-z54laciWfKv-kX8krex0jDiD2HbY6I5tRbXZ4IXAA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nanum Brush Script", + filename: "wXK2E2wfpokopxzthSqPbcR5_gVaxazyjqBr1lO97Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/nanumbrushscript/v26/wXK2E2wfpokopxzthSqPbcR5_gVaxazyjqBr1lO97Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Inria Serif", + filename: "fC1lPYxPY3rXxEndZJAzN0SsfSzNr0Ck", + category: "serif", + url: "https://fonts.gstatic.com/s/inriaserif/v18/fC1lPYxPY3rXxEndZJAzN0SsfSzNr0Ck.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Podkova", + filename: "K2FxfZ1EmftJSV9VWJ75JoKhHys", + category: "serif", + url: "https://fonts.gstatic.com/s/podkova/v33/K2FxfZ1EmftJSV9VWJ75JoKhHys.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rye", + filename: "r05XGLJT86YDFpTsXOqx4w", + category: "display", + url: "https://fonts.gstatic.com/s/rye/v17/r05XGLJT86YDFpTsXOqx4w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mandali", + filename: "LhWlMVbYOfASNfNUVFk1ZPdcKtA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mandali/v16/LhWlMVbYOfASNfNUVFk1ZPdcKtA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Protest Revolution", + filename: "11hcGofZ0kXBbxQXFB7MJsjtqnVw6Z2s8PIzTG1nQw", + category: "display", + url: "https://fonts.gstatic.com/s/protestrevolution/v2/11hcGofZ0kXBbxQXFB7MJsjtqnVw6Z2s8PIzTG1nQw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gantari", + filename: "jVyK7nvyB2HL8iZyFEUkpiwFR80", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gantari/v4/jVyK7nvyB2HL8iZyFEUkpiwFR80.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Major Mono Display", + filename: "RWmVoLyb5fEqtsfBX9PDZIGr2tFubRhLCn2QIndPww", + category: "monospace", + url: "https://fonts.gstatic.com/s/majormonodisplay/v18/RWmVoLyb5fEqtsfBX9PDZIGr2tFubRhLCn2QIndPww.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lexend Exa", + filename: "UMBXrPdOoHOnxExyjdBeWirXArM58BY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lexendexa/v35/UMBXrPdOoHOnxExyjdBeWirXArM58BY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Nobile", + filename: "m8JTjflSeaOVl1i2XqfXeLVdbw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/nobile/v19/m8JTjflSeaOVl1i2XqfXeLVdbw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "PT Serif Caption", + filename: "ieVl2ZhbGCW-JoW6S34pSDpqYKU059WxDCs5cvI", + category: "serif", + url: "https://fonts.gstatic.com/s/ptserifcaption/v18/ieVl2ZhbGCW-JoW6S34pSDpqYKU059WxDCs5cvI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Suez One", + filename: "taiJGmd_EZ6rqscQgNFJkIqg-I0w", + category: "serif", + url: "https://fonts.gstatic.com/s/suezone/v15/taiJGmd_EZ6rqscQgNFJkIqg-I0w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Caudex", + filename: "esDQ311QOP6BJUrIyviAnb4eEw", + category: "serif", + url: "https://fonts.gstatic.com/s/caudex/v19/esDQ311QOP6BJUrIyviAnb4eEw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fustat", + filename: "NaPZcZ_aHO9Iy5t7T_hDoyqlZQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/fustat/v4/NaPZcZ_aHO9Iy5t7T_hDoyqlZQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Castoro", + filename: "1q2GY5yMCld3-O4cHYhEzOYenEU", + category: "serif", + url: "https://fonts.gstatic.com/s/castoro/v20/1q2GY5yMCld3-O4cHYhEzOYenEU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Amiko", + filename: "WwkQxPq1DFK04tqlc17MMZgJ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/amiko/v15/WwkQxPq1DFK04tqlc17MMZgJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Antic", + filename: "TuGfUVB8XY5DRaZLodgzydtk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/antic/v20/TuGfUVB8XY5DRaZLodgzydtk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Candal", + filename: "XoHn2YH6T7-t_8cNAR4Jt9Yxlw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/candal/v16/XoHn2YH6T7-t_8cNAR4Jt9Yxlw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Glegoo", + filename: "_Xmt-HQyrTKWaw2Ji6mZAI91xw", + category: "serif", + url: "https://fonts.gstatic.com/s/glegoo/v17/_Xmt-HQyrTKWaw2Ji6mZAI91xw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Host Grotesk", + filename: "co3BmWBnlCJ3U42vbbfdwMjpo1Ln4U3Qrw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hostgrotesk/v5/co3BmWBnlCJ3U42vbbfdwMjpo1Ln4U3Qrw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Athiti", + filename: "pe0vMISdLIZIv1w4DBhWCtaiAg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/athiti/v14/pe0vMISdLIZIv1w4DBhWCtaiAg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Proza Libre", + filename: "LYjGdGHgj0k1DIQRyUEyyHovftvXWYyz", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/prozalibre/v9/LYjGdGHgj0k1DIQRyUEyyHovftvXWYyz.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bungee Spice", + filename: "nwpTtK2nIhxE0q-IwgSpZBqCzyI-aMPF7Q", + category: "display", + url: "https://fonts.gstatic.com/s/bungeespice/v15/nwpTtK2nIhxE0q-IwgSpZBqCzyI-aMPF7Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Brygada 1918", + filename: "pe0pMI6eKpdGqlF5LANrM--aA_Rue1UwVg", + category: "serif", + url: "https://fonts.gstatic.com/s/brygada1918/v27/pe0pMI6eKpdGqlF5LANrM--aA_Rue1UwVg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Aldrich", + filename: "MCoTzAn-1s3IGyJMZaAS3pP5H_E", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/aldrich/v22/MCoTzAn-1s3IGyJMZaAS3pP5H_E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Spinnaker", + filename: "w8gYH2oyX-I0_rvR6Hmn3HwLqOqSBg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/spinnaker/v21/w8gYH2oyX-I0_rvR6Hmn3HwLqOqSBg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Share", + filename: "i7dEIFliZjKNF5VNHLq2cV5d", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/share/v20/i7dEIFliZjKNF5VNHLq2cV5d.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Krona One", + filename: "jAnEgHdjHcjgfIb1ZcUCMY-h3cWkWg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kronaone/v15/jAnEgHdjHcjgfIb1ZcUCMY-h3cWkWg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hepta Slab", + filename: "ea8cadoyU_jkHdalebHv025vhVKUE3E", + category: "serif", + url: "https://fonts.gstatic.com/s/heptaslab/v25/ea8cadoyU_jkHdalebHv025vhVKUE3E.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sevillana", + filename: "KFOlCnWFscmDt1Bfiy1vAx05IsDqlA", + category: "display", + url: "https://fonts.gstatic.com/s/sevillana/v25/KFOlCnWFscmDt1Bfiy1vAx05IsDqlA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Caprasimo", + filename: "esDT31JQOPuXIUGBp72klZUCGpG-GQ", + category: "display", + url: "https://fonts.gstatic.com/s/caprasimo/v6/esDT31JQOPuXIUGBp72klZUCGpG-GQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oranienbaum", + filename: "OZpHg_txtzZKMuXLIVrx-3zn7kz3dpHc", + category: "serif", + url: "https://fonts.gstatic.com/s/oranienbaum/v16/OZpHg_txtzZKMuXLIVrx-3zn7kz3dpHc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bellota Text", + filename: "0FlTVP2VnlWS4f3-UE9hHXMB-dMOdS7sSg", + category: "display", + url: "https://fonts.gstatic.com/s/bellotatext/v20/0FlTVP2VnlWS4f3-UE9hHXMB-dMOdS7sSg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Grand Hotel", + filename: "7Au7p_IgjDKdCRWuR1azpmQNEl0O0kEx", + category: "handwriting", + url: "https://fonts.gstatic.com/s/grandhotel/v21/7Au7p_IgjDKdCRWuR1azpmQNEl0O0kEx.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Coming Soon", + filename: "qWcuB6mzpYL7AJ2VfdQR1u-SUjjzsykh", + category: "handwriting", + url: "https://fonts.gstatic.com/s/comingsoon/v20/qWcuB6mzpYL7AJ2VfdQR1u-SUjjzsykh.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Murecho", + filename: "q5uHsoq3NOBn_I-gmilCBxxdmYU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/murecho/v17/q5uHsoq3NOBn_I-gmilCBxxdmYU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Radley", + filename: "LYjDdGzinEIjCN19oAlEpVs3VQ", + category: "serif", + url: "https://fonts.gstatic.com/s/radley/v24/LYjDdGzinEIjCN19oAlEpVs3VQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Averia Libre", + filename: "2V0aKIcMGZEnV6xygz7eNjEiAqPJZ2Xx8w", + category: "display", + url: "https://fonts.gstatic.com/s/averialibre/v16/2V0aKIcMGZEnV6xygz7eNjEiAqPJZ2Xx8w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ma Shan Zheng", + filename: "NaPecZTRCLxvwo41b4gvzkXaRMTsDIRSfr0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mashanzheng/v14/NaPecZTRCLxvwo41b4gvzkXaRMTsDIRSfr0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lexend Peta", + filename: "BXRvvFPGjeLPh0kCfI4OkE_1c8Tf1IW3", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lexendpeta/v30/BXRvvFPGjeLPh0kCfI4OkE_1c8Tf1IW3.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mate", + filename: "m8JdjftRd7WZ2z28WoXSaLU", + category: "serif", + url: "https://fonts.gstatic.com/s/mate/v19/m8JdjftRd7WZ2z28WoXSaLU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Metrophobic", + filename: "sJoA3LZUhMSAPV_u0qwiAT-J737FPEEL", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/metrophobic/v24/sJoA3LZUhMSAPV_u0qwiAT-J737FPEEL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Caladea", + filename: "kJEzBugZ7AAjhybUjR93-9IztOc", + category: "serif", + url: "https://fonts.gstatic.com/s/caladea/v8/kJEzBugZ7AAjhybUjR93-9IztOc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rakkas", + filename: "Qw3cZQlNHiblL3j_lttPOeMcCw", + category: "display", + url: "https://fonts.gstatic.com/s/rakkas/v22/Qw3cZQlNHiblL3j_lttPOeMcCw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Arbutus Slab", + filename: "oY1Z8e7OuLXkJGbXtr5ba7ZVa68dJlaFAQ", + category: "serif", + url: "https://fonts.gstatic.com/s/arbutusslab/v17/oY1Z8e7OuLXkJGbXtr5ba7ZVa68dJlaFAQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gowun Batang", + filename: "ijwSs5nhRMIjYsdSgcMa3wRhXLH-yuAtLw", + category: "serif", + url: "https://fonts.gstatic.com/s/gowunbatang/v12/ijwSs5nhRMIjYsdSgcMa3wRhXLH-yuAtLw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Silkscreen", + filename: "m8JXjfVPf62XiF7kO-i9ULRvamODxdI", + category: "display", + url: "https://fonts.gstatic.com/s/silkscreen/v6/m8JXjfVPf62XiF7kO-i9ULRvamODxdI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bellefair", + filename: "kJExBuYY6AAuhiXUxG19__A2pOdvDA", + category: "serif", + url: "https://fonts.gstatic.com/s/bellefair/v15/kJExBuYY6AAuhiXUxG19__A2pOdvDA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Montagu Slab", + filename: "6qLHKZIQtB_zv0xUaXRDWkYlFlnXaxIyYw", + category: "serif", + url: "https://fonts.gstatic.com/s/montaguslab/v17/6qLHKZIQtB_zv0xUaXRDWkYlFlnXaxIyYw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Graduate", + filename: "C8cg4cs3o2n15t_2YxgR6X2NZAn2", + category: "serif", + url: "https://fonts.gstatic.com/s/graduate/v19/C8cg4cs3o2n15t_2YxgR6X2NZAn2.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oooh Baby", + filename: "2sDcZGJWgJTT2Jf76xQDb2-4C7wFZQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/ooohbaby/v4/2sDcZGJWgJTT2Jf76xQDb2-4C7wFZQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Average Sans", + filename: "1Ptpg8fLXP2dlAXR-HlJJNJPBdqazVoK4A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/averagesans/v17/1Ptpg8fLXP2dlAXR-HlJJNJPBdqazVoK4A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Norican", + filename: "MwQ2bhXp1eSBqjkPGJJRtGs-lbA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/norican/v16/MwQ2bhXp1eSBqjkPGJJRtGs-lbA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Familjen Grotesk", + filename: "Qw3GZR9ZHiDnImG6-NEMQ41wby8WXH0OjpM75PE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/familjengrotesk/v11/Qw3GZR9ZHiDnImG6-NEMQ41wby8WXH0OjpM75PE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Tiro Bangla", + filename: "IFSgHe1Tm95E3O8b5i2V8MG9-UPeuz4i", + category: "serif", + url: "https://fonts.gstatic.com/s/tirobangla/v6/IFSgHe1Tm95E3O8b5i2V8MG9-UPeuz4i.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "UnifrakturMaguntia", + filename: "WWXPlieVYwiGNomYU-ciRLRvEmK7oaVun2xNNgNa1A", + category: "display", + url: "https://fonts.gstatic.com/s/unifrakturmaguntia/v22/WWXPlieVYwiGNomYU-ciRLRvEmK7oaVun2xNNgNa1A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jockey One", + filename: "HTxpL2g2KjCFj4x8WI6ArIb7HYOk4xc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/jockeyone/v23/HTxpL2g2KjCFj4x8WI6ArIb7HYOk4xc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cormorant Unicase", + filename: "HI_QiZUaILtOqhqgDeXoF_n1_fTGX-vTnsMnx3C9", + category: "serif", + url: "https://fonts.gstatic.com/s/cormorantunicase/v25/HI_QiZUaILtOqhqgDeXoF_n1_fTGX-vTnsMnx3C9.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kosugi", + filename: "pxiFyp4_v8FCjlI4NLr6f1pdEQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kosugi/v19/pxiFyp4_v8FCjlI4NLr6f1pdEQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anaheim", + filename: "8vII7w042Wp87g4G0UTUEE5eK_w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anaheim/v17/8vII7w042Wp87g4G0UTUEE5eK_w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rancho", + filename: "46kulbzmXjLaqZRlbWXgd0RY1g", + category: "handwriting", + url: "https://fonts.gstatic.com/s/rancho/v22/46kulbzmXjLaqZRlbWXgd0RY1g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cantata One", + filename: "PlI5Fl60Nb5obNzNe2jslVxEt8CwfGaD", + category: "serif", + url: "https://fonts.gstatic.com/s/cantataone/v16/PlI5Fl60Nb5obNzNe2jslVxEt8CwfGaD.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Glory", + filename: "q5uJsoi9Lf1w5sfPkC1gAgxd", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/glory/v18/q5uJsoi9Lf1w5sfPkC1gAgxd.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Dongle", + filename: "sJoF3Ltdjt6VPkqmveRPah6RxA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/dongle/v16/sJoF3Ltdjt6VPkqmveRPah6RxA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Hebrew", + filename: "k3kKo9MMPvpLmixYH7euCwmkS9Dohi_-2KiSGg-H", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifhebrew/v30/k3kKo9MMPvpLmixYH7euCwmkS9Dohi_-2KiSGg-H.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Annie Use Your Telescope", + filename: "daaLSS4tI2qYYl3Jq9s_Hu74xwktnlKxH6osGVGjlDfB3UUVZA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/annieuseyourtelescope/v20/daaLSS4tI2qYYl3Jq9s_Hu74xwktnlKxH6osGVGjlDfB3UUVZA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "REM", + filename: "Wnz3HAIoSDydZjovaRcFow", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rem/v4/Wnz3HAIoSDydZjovaRcFow.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Koulen", + filename: "AMOQz46as3KIBPeWgnA9kuYMUg", + category: "display", + url: "https://fonts.gstatic.com/s/koulen/v30/AMOQz46as3KIBPeWgnA9kuYMUg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "SUSE", + filename: "MwQ5bhb078Wt6VhLPJp6qGI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/suse/v4/MwQ5bhb078Wt6VhLPJp6qGI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Baloo Da 2", + filename: "2-ci9J9j0IaUMQZwAJyJcu7XoZFDf2Q", + category: "display", + url: "https://fonts.gstatic.com/s/balooda2/v26/2-ci9J9j0IaUMQZwAJyJcu7XoZFDf2Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Solway", + filename: "AMOQz46Cs2uTAOCWgnA9kuYMUg", + category: "serif", + url: "https://fonts.gstatic.com/s/solway/v19/AMOQz46Cs2uTAOCWgnA9kuYMUg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kristi", + filename: "uK_y4ricdeU6zwdRCh0TMv6EXw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/kristi/v23/uK_y4ricdeU6zwdRCh0TMv6EXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hanuman", + filename: "VuJxdNvD15HhpJJBeKbXOIFneRo", + category: "serif", + url: "https://fonts.gstatic.com/s/hanuman/v24/VuJxdNvD15HhpJJBeKbXOIFneRo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Big Shoulders", + filename: "qFdC35CPh40oITJ69S3GFqy54h8ij1I7LLE", + category: "display", + url: "https://fonts.gstatic.com/s/bigshoulders/v4/qFdC35CPh40oITJ69S3GFqy54h8ij1I7LLE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Monsieur La Doulaise", + filename: "_Xmz-GY4rjmCbQfc-aPRaa4pqV340p7EZl5ewkEU4HTy", + category: "handwriting", + url: "https://fonts.gstatic.com/s/monsieurladoulaise/v20/_Xmz-GY4rjmCbQfc-aPRaa4pqV340p7EZl5ewkEU4HTy.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bayon", + filename: "9XUrlJNmn0LPFl-pOhYEd2NJ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bayon/v36/9XUrlJNmn0LPFl-pOhYEd2NJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Dawning of a New Day", + filename: "t5t_IQMbOp2SEwuncwLRjMfIg1yYit_nAz8bhWJGNoBE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/dawningofanewday/v22/t5t_IQMbOp2SEwuncwLRjMfIg1yYit_nAz8bhWJGNoBE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mochiy Pop One", + filename: "QdVPSTA9Jh-gg-5XZP2UmU4O9kwwD3s6ZKAi", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mochiypopone/v12/QdVPSTA9Jh-gg-5XZP2UmU4O9kwwD3s6ZKAi.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alegreya SC", + filename: "taiOGmRtCJ62-O0HhNEa-a6o05E5abe_", + category: "serif", + url: "https://fonts.gstatic.com/s/alegreyasc/v26/taiOGmRtCJ62-O0HhNEa-a6o05E5abe_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cairo Play", + filename: "wXKuE3QSpo4vpRz_mz6FJeQAmX8yrdk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cairoplay/v13/wXKuE3QSpo4vpRz_mz6FJeQAmX8yrdk.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Trirong", + filename: "7r3GqXNgp8wxdOdOr4wi2aZg-ug", + category: "serif", + url: "https://fonts.gstatic.com/s/trirong/v17/7r3GqXNgp8wxdOdOr4wi2aZg-ug.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Allerta", + filename: "TwMO-IAHRlkbx940UnEdSQqO5uY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/allerta/v19/TwMO-IAHRlkbx940UnEdSQqO5uY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Azeret Mono", + filename: "3XFuErsiyJsY9O_Gepph-EHmb_jU3eRL", + category: "monospace", + url: "https://fonts.gstatic.com/s/azeretmono/v21/3XFuErsiyJsY9O_Gepph-EHmb_jU3eRL.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Contrail One", + filename: "eLGbP-j_JA-kG0_Zo51noafdZUvt_c092w", + category: "display", + url: "https://fonts.gstatic.com/s/contrailone/v21/eLGbP-j_JA-kG0_Zo51noafdZUvt_c092w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kantumruy Pro", + filename: "1q2AY5aECkp34vEBSPFOmJxwpETLeo2gm2U", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kantumruypro/v12/1q2AY5aECkp34vEBSPFOmJxwpETLeo2gm2U.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Overlock", + filename: "Z9XVDmdMWRiN1_T9Z4Te4u2El6GC", + category: "display", + url: "https://fonts.gstatic.com/s/overlock/v19/Z9XVDmdMWRiN1_T9Z4Te4u2El6GC.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chivo Mono", + filename: "mFT0WbgRxKvF_Z5eQMO9sxgJ1EJ7i90", + category: "monospace", + url: "https://fonts.gstatic.com/s/chivomono/v11/mFT0WbgRxKvF_Z5eQMO9sxgJ1EJ7i90.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Geo", + filename: "CSRz4zRZlufVL3BmQjlCbQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/geo/v23/CSRz4zRZlufVL3BmQjlCbQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rampart One", + filename: "K2F1fZFGl_JSR1tAWNG9R6qgLS76ZHOM", + category: "display", + url: "https://fonts.gstatic.com/s/rampartone/v13/K2F1fZFGl_JSR1tAWNG9R6qgLS76ZHOM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "ADLaM Display", + filename: "KFOhCnGXkPOLlhx6jD8_b1ZECsHYkYBPY3o", + category: "display", + url: "https://fonts.gstatic.com/s/adlamdisplay/v1/KFOhCnGXkPOLlhx6jD8_b1ZECsHYkYBPY3o.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Wallpoet", + filename: "f0X10em2_8RnXVVdUNbu7cXP8L8G", + category: "display", + url: "https://fonts.gstatic.com/s/wallpoet/v21/f0X10em2_8RnXVVdUNbu7cXP8L8G.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "DotGothic16", + filename: "v6-QGYjBJFKgyw5nSoDAGE7L435YPFrT", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/dotgothic16/v21/v6-QGYjBJFKgyw5nSoDAGE7L435YPFrT.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "BioRhyme", + filename: "1cXwaULHBpDMsHYW_HxGpVWIgNit", + category: "serif", + url: "https://fonts.gstatic.com/s/biorhyme/v21/1cXwaULHBpDMsHYW_HxGpVWIgNit.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Libre Barcode 128", + filename: "cIfnMbdUsUoiW3O_hVviCwVjuLtXeJ_A_gMk0izH", + category: "display", + url: "https://fonts.gstatic.com/s/librebarcode128/v31/cIfnMbdUsUoiW3O_hVviCwVjuLtXeJ_A_gMk0izH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vina Sans", + filename: "m8JQjfZKf6-d2273MP7zcJ5BZmqa3A", + category: "display", + url: "https://fonts.gstatic.com/s/vinasans/v8/m8JQjfZKf6-d2273MP7zcJ5BZmqa3A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libre Barcode 39 Text", + filename: "sJoa3KhViNKANw_E3LwoDXvs5Un0HQ1vT-031RRL-9rYaw", + category: "display", + url: "https://fonts.gstatic.com/s/librebarcode39text/v32/sJoa3KhViNKANw_E3LwoDXvs5Un0HQ1vT-031RRL-9rYaw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Petit Formal Script", + filename: "B50TF6xQr2TXJBnGOFME6u5OR83oRP5qoHnqP4gZSiE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/petitformalscript/v19/B50TF6xQr2TXJBnGOFME6u5OR83oRP5qoHnqP4gZSiE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Faster One", + filename: "H4ciBXCHmdfClFb-vWhfyLuShq63czE", + category: "display", + url: "https://fonts.gstatic.com/s/fasterone/v20/H4ciBXCHmdfClFb-vWhfyLuShq63czE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Parkinsans", + filename: "-W_7XJXvQyPb1QfpBpRrTkMBf50kbiM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/parkinsans/v3/-W_7XJXvQyPb1QfpBpRrTkMBf50kbiM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Love Ya Like A Sister", + filename: "R70EjzUBlOqPeouhFDfR80-0FhOqJubN-Be78nZcsGGycA", + category: "display", + url: "https://fonts.gstatic.com/s/loveyalikeasister/v23/R70EjzUBlOqPeouhFDfR80-0FhOqJubN-Be78nZcsGGycA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Arizonia", + filename: "neIIzCemt4A5qa7mv6WGHK06UY30", + category: "handwriting", + url: "https://fonts.gstatic.com/s/arizonia/v23/neIIzCemt4A5qa7mv6WGHK06UY30.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Schoolbell", + filename: "92zQtBZWOrcgoe-fgnJIVxIQ6mRqfiQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/schoolbell/v18/92zQtBZWOrcgoe-fgnJIVxIQ6mRqfiQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Honk", + filename: "m8JdjftUea-X2z28WoXSaLU", + category: "display", + url: "https://fonts.gstatic.com/s/honk/v6/m8JdjftUea-X2z28WoXSaLU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Klee One", + filename: "LDIxapCLNRc6A8oT4q4AOeekWPrP", + category: "handwriting", + url: "https://fonts.gstatic.com/s/kleeone/v13/LDIxapCLNRc6A8oT4q4AOeekWPrP.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oxygen Mono", + filename: "h0GsssGg9FxgDgCjLeAd7ijfze-PPlUu", + category: "monospace", + url: "https://fonts.gstatic.com/s/oxygenmono/v15/h0GsssGg9FxgDgCjLeAd7ijfze-PPlUu.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "ZCOOL XiaoWei", + filename: "i7dMIFFrTRywPpUVX9_RJyM1YFKQHwyVd3U", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zcoolxiaowei/v15/i7dMIFFrTRywPpUVX9_RJyM1YFKQHwyVd3U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Waiting for the Sunrise", + filename: "WBL1rFvOYl9CEv2i1mO6KUW8RKWJ2zoXoz5JsYZQ9h_ZYk5J", + category: "handwriting", + url: "https://fonts.gstatic.com/s/waitingforthesunrise/v23/WBL1rFvOYl9CEv2i1mO6KUW8RKWJ2zoXoz5JsYZQ9h_ZYk5J.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Trocchi", + filename: "qWcqB6WkuIDxDZLcDrtUvMeTYD0", + category: "serif", + url: "https://fonts.gstatic.com/s/trocchi/v19/qWcqB6WkuIDxDZLcDrtUvMeTYD0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hina Mincho", + filename: "2sDaZGBRhpXa2Jjz5w5LAGW8KbkVZTHR", + category: "serif", + url: "https://fonts.gstatic.com/s/hinamincho/v16/2sDaZGBRhpXa2Jjz5w5LAGW8KbkVZTHR.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Telex", + filename: "ieVw2Y1fKWmIO9fTB1piKFIf", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/telex/v18/ieVw2Y1fKWmIO9fTB1piKFIf.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sigmar One", + filename: "co3DmWZ8kjZuErj9Ta3dk6Pjp3Di8U0", + category: "display", + url: "https://fonts.gstatic.com/s/sigmarone/v20/co3DmWZ8kjZuErj9Ta3dk6Pjp3Di8U0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Halant", + filename: "u-4-0qaujRI2PbsX39Jmky12eg", + category: "serif", + url: "https://fonts.gstatic.com/s/halant/v17/u-4-0qaujRI2PbsX39Jmky12eg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yesteryear", + filename: "dg4g_p78rroaKl8kRKo1r7wHTwonmyw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/yesteryear/v21/dg4g_p78rroaKl8kRKo1r7wHTwonmyw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fondamento", + filename: "4UaHrEJGsxNmFTPDnkaJx63j5pN1MwI", + category: "handwriting", + url: "https://fonts.gstatic.com/s/fondamento/v22/4UaHrEJGsxNmFTPDnkaJx63j5pN1MwI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Knewave", + filename: "sykz-yx0lLcxQaSItSq9-trEvlQ", + category: "display", + url: "https://fonts.gstatic.com/s/knewave/v15/sykz-yx0lLcxQaSItSq9-trEvlQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Croissant One", + filename: "3y9n6bU9bTPg4m8NDy3Kq24UM3pqn5cdJ-4", + category: "display", + url: "https://fonts.gstatic.com/s/croissantone/v28/3y9n6bU9bTPg4m8NDy3Kq24UM3pqn5cdJ-4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Agbalumo", + filename: "55xvey5uMdT2N37KZcMFirl08KDJ", + category: "display", + url: "https://fonts.gstatic.com/s/agbalumo/v6/55xvey5uMdT2N37KZcMFirl08KDJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "RocknRoll One", + filename: "kmK7ZqspGAfCeUiW6FFlmEC9guVhs7tfUxc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rocknrollone/v16/kmK7ZqspGAfCeUiW6FFlmEC9guVhs7tfUxc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Maitree", + filename: "MjQGmil5tffhpBrkrtmmfJmDoL4", + category: "serif", + url: "https://fonts.gstatic.com/s/maitree/v11/MjQGmil5tffhpBrkrtmmfJmDoL4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Style Script", + filename: "vm8xdRX3SV7Z0aPa88xzW5npeFT76NZnMw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/stylescript/v13/vm8xdRX3SV7Z0aPa88xzW5npeFT76NZnMw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bentham", + filename: "VdGeAZQPEpYfmHglKWw7CJaK_y4", + category: "serif", + url: "https://fonts.gstatic.com/s/bentham/v20/VdGeAZQPEpYfmHglKWw7CJaK_y4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anton SC", + filename: "4UaBrEBBsgltGn71sxLmzanB44N1", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/antonsc/v1/4UaBrEBBsgltGn71sxLmzanB44N1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sniglet", + filename: "cIf9MaFLtkE3UjaJxCmrYGkHgIs", + category: "display", + url: "https://fonts.gstatic.com/s/sniglet/v18/cIf9MaFLtkE3UjaJxCmrYGkHgIs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sofia Sans Semi Condensed", + filename: "46k9laPnUDrQoNsWDCGXXxYlujh5Wv0nwP4RwxURgWQ-826XvcQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sofiasanssemicondensed/v8/46k9laPnUDrQoNsWDCGXXxYlujh5Wv0nwP4RwxURgWQ-826XvcQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Calligraffitti", + filename: "46k2lbT3XjDVqJw3DCmCFjE0vnFZM5ZBpYN-", + category: "handwriting", + url: "https://fonts.gstatic.com/s/calligraffitti/v20/46k2lbT3XjDVqJw3DCmCFjE0vnFZM5ZBpYN-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cormorant SC", + filename: "0yb5GD4kxqXBmOVLG30OGwserDow9Tbu-Q", + category: "serif", + url: "https://fonts.gstatic.com/s/cormorantsc/v19/0yb5GD4kxqXBmOVLG30OGwserDow9Tbu-Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Markazi Text", + filename: "syk0-ydym6AtQaiEtX7yhqblpn-UJ1H6Uw", + category: "serif", + url: "https://fonts.gstatic.com/s/markazitext/v28/syk0-ydym6AtQaiEtX7yhqblpn-UJ1H6Uw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Niconne", + filename: "w8gaH2QvRug1_rTfrQut2F4OuOo", + category: "handwriting", + url: "https://fonts.gstatic.com/s/niconne/v16/w8gaH2QvRug1_rTfrQut2F4OuOo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tilt Neon", + filename: "E21l_d7gguXdwD9LEFYsUwVUAuu3cw", + category: "display", + url: "https://fonts.gstatic.com/s/tiltneon/v12/E21l_d7gguXdwD9LEFYsUwVUAuu3cw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Irish Grover", + filename: "buExpoi6YtLz2QW7LA4flVgf-P5Oaiw4cw", + category: "display", + url: "https://fonts.gstatic.com/s/irishgrover/v23/buExpoi6YtLz2QW7LA4flVgf-P5Oaiw4cw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libre Caslon Display", + filename: "TuGOUUFxWphYQ6YI6q9Xp61FQzxDRKmzr2lRdRhtCC4d", + category: "serif", + url: "https://fonts.gstatic.com/s/librecaslondisplay/v18/TuGOUUFxWphYQ6YI6q9Xp61FQzxDRKmzr2lRdRhtCC4d.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yusei Magic", + filename: "yYLt0hbAyuCmoo5wlhPkpjHR-tdfcIT_", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/yuseimagic/v16/yYLt0hbAyuCmoo5wlhPkpjHR-tdfcIT_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IM Fell English", + filename: "Ktk1ALSLW8zDe0rthJysWrnLsAz3F6mZVY9Y5w", + category: "serif", + url: "https://fonts.gstatic.com/s/imfellenglish/v14/Ktk1ALSLW8zDe0rthJysWrnLsAz3F6mZVY9Y5w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Encode Sans Semi Condensed", + filename: "3qT4oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG2yR_sVPRsjp", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/encodesanssemicondensed/v11/3qT4oiKqnDuUtQUEHMoXcmspmy55SFWrXFRp9FTOG2yR_sVPRsjp.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu SA Beginner", + filename: "rnCw-xRb1x-1lHXnLaZZ2xOoLIGFWFAMArZKqQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/edusabeginner/v5/rnCw-xRb1x-1lHXnLaZZ2xOoLIGFWFAMArZKqQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Funnel Display", + filename: "B50WF7FGv37QNVWgE0ga--4PbY6aB4oWgWHB", + category: "display", + url: "https://fonts.gstatic.com/s/funneldisplay/v3/B50WF7FGv37QNVWgE0ga--4PbY6aB4oWgWHB.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mountains of Christmas", + filename: "3y9w6a4zcCnn5X0FDyrKi2ZRUBIy8uxoUo7ePNamMPNpJpc", + category: "display", + url: "https://fonts.gstatic.com/s/mountainsofchristmas/v24/3y9w6a4zcCnn5X0FDyrKi2ZRUBIy8uxoUo7ePNamMPNpJpc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Marmelad", + filename: "Qw3eZQdSHj_jK2e-8tFLG-YMC0R8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/marmelad/v19/Qw3eZQdSHj_jK2e-8tFLG-YMC0R8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fragment Mono", + filename: "4iCr6K5wfMRRjxp0DA6-2CLnN4RNh4UI_1U", + category: "monospace", + url: "https://fonts.gstatic.com/s/fragmentmono/v6/4iCr6K5wfMRRjxp0DA6-2CLnN4RNh4UI_1U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Licorice", + filename: "t5tjIR8TMomTCAyjNk23hqLgzCHu", + category: "handwriting", + url: "https://fonts.gstatic.com/s/licorice/v8/t5tjIR8TMomTCAyjNk23hqLgzCHu.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Carlito", + filename: "3Jn9SDPw3m-pk039PDCLTXUETuE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/carlito/v4/3Jn9SDPw3m-pk039PDCLTXUETuE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Charis SIL", + filename: "oPWK_kV3l-s-Q8govXvKrPrmYjZ2Xn0", + category: "serif", + url: "https://fonts.gstatic.com/s/charissil/v2/oPWK_kV3l-s-Q8govXvKrPrmYjZ2Xn0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lexend Zetta", + filename: "ll87K2KYXje7CdOFnEWcU8soliQejRR7AQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lexendzetta/v32/ll87K2KYXje7CdOFnEWcU8soliQejRR7AQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Meddon", + filename: "kmK8ZqA2EgDNeHTZhBdB3y_Aow", + category: "handwriting", + url: "https://fonts.gstatic.com/s/meddon/v27/kmK8ZqA2EgDNeHTZhBdB3y_Aow.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rosario", + filename: "xfux0WDhWW_fOEoY6FT3zA7DpL4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rosario/v35/xfux0WDhWW_fOEoY6FT3zA7DpL4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Pixelify Sans", + filename: "CHylV-3HFUT7aC4iv1TxGDR9FnoOimlReJw", + category: "display", + url: "https://fonts.gstatic.com/s/pixelifysans/v3/CHylV-3HFUT7aC4iv1TxGDR9FnoOimlReJw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Over the Rainbow", + filename: "11haGoXG1k_HKhMLUWz7Mc7vvW5upvOm9NA2XG0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/overtherainbow/v23/11haGoXG1k_HKhMLUWz7Mc7vvW5upvOm9NA2XG0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Freeman", + filename: "S6u9w4NGQiLN8nh-ew-FGC_p9dw", + category: "display", + url: "https://fonts.gstatic.com/s/freeman/v1/S6u9w4NGQiLN8nh-ew-FGC_p9dw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Sans KR", + filename: "vEFK2-VJISZe3O_rc3ZVYh4aTwNO8tK1W77HtMo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsanskr/v11/vEFK2-VJISZe3O_rc3ZVYh4aTwNO8tK1W77HtMo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Flow Circular", + filename: "lJwB-pc4j2F-H8YKuyvfxdZ45ifpWdr2rIg", + category: "display", + url: "https://fonts.gstatic.com/s/flowcircular/v15/lJwB-pc4j2F-H8YKuyvfxdZ45ifpWdr2rIg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Encode Sans Expanded", + filename: "c4m_1mF4GcnstG_Jh1QH6ac4hNLeNyeYUqoiIwdAd5Ab", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/encodesansexpanded/v12/c4m_1mF4GcnstG_Jh1QH6ac4hNLeNyeYUqoiIwdAd5Ab.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Average", + filename: "fC1hPYBHe23MxA7rIeJwVWytTyk", + category: "serif", + url: "https://fonts.gstatic.com/s/average/v19/fC1hPYBHe23MxA7rIeJwVWytTyk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Manjari", + filename: "k3kQo8UPMOBO2w1UTd7iL0nAMaM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/manjari/v14/k3kQo8UPMOBO2w1UTd7iL0nAMaM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fjord One", + filename: "zOL-4pbEnKBY_9S1jNKr6e5As-FeiQ", + category: "serif", + url: "https://fonts.gstatic.com/s/fjordone/v22/zOL-4pbEnKBY_9S1jNKr6e5As-FeiQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "BIZ UDPMincho", + filename: "ypvfbXOBrmYppy7oWWTg1_58nhhYtUb0gZk", + category: "serif", + url: "https://fonts.gstatic.com/s/bizudpmincho/v11/ypvfbXOBrmYppy7oWWTg1_58nhhYtUb0gZk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Baloo Bhaijaan 2", + filename: "zYX9KUwuEqdVGqM8tPDdAA_Y-_bMMIZmdd_qFmo", + category: "display", + url: "https://fonts.gstatic.com/s/baloobhaijaan2/v21/zYX9KUwuEqdVGqM8tPDdAA_Y-_bMMIZmdd_qFmo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Math", + filename: "7Aump_cpkSecTWaHRlH2hyV5UHkG-V048PW0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmath/v18/7Aump_cpkSecTWaHRlH2hyV5UHkG-V048PW0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "DynaPuff", + filename: "z7NXdRvsZDIVHbYPMgxC_pjcTeWU", + category: "display", + url: "https://fonts.gstatic.com/s/dynapuff/v9/z7NXdRvsZDIVHbYPMgxC_pjcTeWU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "TikTok Sans", + filename: "70lXu7g-Lm8OXGnh_Ow1sV3OEJeJRVdC", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tiktoksans/v7/70lXu7g-Lm8OXGnh_Ow1sV3OEJeJRVdC.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Hahmlet", + filename: "BngIUXpCQ3nKpIo0V_jQjP_L3qE", + category: "serif", + url: "https://fonts.gstatic.com/s/hahmlet/v21/BngIUXpCQ3nKpIo0V_jQjP_L3qE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Scada", + filename: "RLpxK5Pv5qumeWJoxzUobkvv", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/scada/v16/RLpxK5Pv5qumeWJoxzUobkvv.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ibarra Real Nova", + filename: "sZlfdQiA-DBIDCcaWtQzL4BZHoiDoHxSENxuLuE", + category: "serif", + url: "https://fonts.gstatic.com/s/ibarrarealnova/v30/sZlfdQiA-DBIDCcaWtQzL4BZHoiDoHxSENxuLuE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Antic Didone", + filename: "RWmPoKKX6u8sp8fIWdnDKqDiqYsGBGBzCw", + category: "serif", + url: "https://fonts.gstatic.com/s/anticdidone/v17/RWmPoKKX6u8sp8fIWdnDKqDiqYsGBGBzCw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Radio Canada Big", + filename: "LYjZdHrinEImAoQewU0hyTsPFra4eJSY8z6Np1k", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/radiocanadabig/v3/LYjZdHrinEImAoQewU0hyTsPFra4eJSY8z6Np1k.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Kufam", + filename: "C8ct4cY7pG7w_p6CLDwZwmGE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kufam/v26/C8ct4cY7pG7w_p6CLDwZwmGE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gabriela", + filename: "qkBWXvsO6sreR8E-b_m-zrpHmRzC", + category: "serif", + url: "https://fonts.gstatic.com/s/gabriela/v23/qkBWXvsO6sreR8E-b_m-zrpHmRzC.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Skranji", + filename: "OZpDg_dtriVFNerMYzuuklTm3Ek", + category: "display", + url: "https://fonts.gstatic.com/s/skranji/v14/OZpDg_dtriVFNerMYzuuklTm3Ek.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Sinhala", + filename: "yMJIMJBya43H0SUF_WmcBEEf4rQVO3ny-WmZp1A3", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssinhala/v36/yMJIMJBya43H0SUF_WmcBEEf4rQVO3ny-WmZp1A3.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bubblegum Sans", + filename: "AYCSpXb_Z9EORv1M5QTjEzMEtdaHzoPPb7R4", + category: "display", + url: "https://fonts.gstatic.com/s/bubblegumsans/v22/AYCSpXb_Z9EORv1M5QTjEzMEtdaHzoPPb7R4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Megrim", + filename: "46kulbz5WjvLqJZlbWXgd0RY1g", + category: "display", + url: "https://fonts.gstatic.com/s/megrim/v18/46kulbz5WjvLqJZlbWXgd0RY1g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mansalva", + filename: "aWB4m0aacbtDfvq5NJllI47vdyBg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mansalva/v16/aWB4m0aacbtDfvq5NJllI47vdyBg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "AR One Sans", + filename: "TUZ0zwhrmbFp0Srr_tH6fuSaU5EP1H3r", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/aronesans/v6/TUZ0zwhrmbFp0Srr_tH6fuSaU5EP1H3r.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Grenze Gotisch", + filename: "Fh4sPjjqNDz1osh_jX9YfjudpAhKF66qe6T5", + category: "display", + url: "https://fonts.gstatic.com/s/grenzegotisch/v20/Fh4sPjjqNDz1osh_jX9YfjudpAhKF66qe6T5.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Carrois Gothic", + filename: "Z9XPDmFATg-N1PLtLOOxvIHl9ZmD3i7ajcJ-", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/carroisgothic/v17/Z9XPDmFATg-N1PLtLOOxvIHl9ZmD3i7ajcJ-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bowlby One", + filename: "taiPGmVuC4y96PFeqp8smo6C_Z0wcK4", + category: "display", + url: "https://fonts.gstatic.com/s/bowlbyone/v25/taiPGmVuC4y96PFeqp8smo6C_Z0wcK4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bungee Inline", + filename: "Gg8zN58UcgnlCweMrih332VuDGJ1-FEglsc", + category: "display", + url: "https://fonts.gstatic.com/s/bungeeinline/v19/Gg8zN58UcgnlCweMrih332VuDGJ1-FEglsc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Turret Road", + filename: "pxiAypMgpcBFjE84Zv-fE3tFOvODSVFF", + category: "display", + url: "https://fonts.gstatic.com/s/turretroad/v11/pxiAypMgpcBFjE84Zv-fE3tFOvODSVFF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Inria Sans", + filename: "ptRMTiqXYfZMCOiVj9kQ5O7yKQNute8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/inriasans/v15/ptRMTiqXYfZMCOiVj9kQ5O7yKQNute8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fauna One", + filename: "wlpzgwTPBVpjpCuwkuEx2UxLYClOCg", + category: "serif", + url: "https://fonts.gstatic.com/s/faunaone/v16/wlpzgwTPBVpjpCuwkuEx2UxLYClOCg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cherry Bomb One", + filename: "y83DW4od1h6KlV3c6JJhRhGOdhrKDNpF41fr-w", + category: "display", + url: "https://fonts.gstatic.com/s/cherrybombone/v11/y83DW4od1h6KlV3c6JJhRhGOdhrKDNpF41fr-w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IM Fell English SC", + filename: "a8IENpD3CDX-4zrWfr1VY879qFF05pZLO4gOg0shzA", + category: "serif", + url: "https://fonts.gstatic.com/s/imfellenglishsc/v16/a8IENpD3CDX-4zrWfr1VY879qFF05pZLO4gOg0shzA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ubuntu Sans", + filename: "co3CmWd6mSRtB7_9UaLWwIPJiXzr6FTJ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ubuntusans/v4/co3CmWd6mSRtB7_9UaLWwIPJiXzr6FTJ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Recursive", + filename: "8vIK7wMr0mhh-RQChyHeFGxbO_zo-w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/recursive/v44/8vIK7wMr0mhh-RQChyHeFGxbO_zo-w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Copse", + filename: "11hPGpDKz1rGb0djHkihUb-A", + category: "serif", + url: "https://fonts.gstatic.com/s/copse/v16/11hPGpDKz1rGb0djHkihUb-A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Qwitcher Grypen", + filename: "pxicypclp9tDilN9RrC5BSI1dZmrSGNAom-wpw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/qwitchergrypen/v8/pxicypclp9tDilN9RrC5BSI1dZmrSGNAom-wpw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kelly Slab", + filename: "-W_7XJX0Rz3cxUnJC5t6TkMBf50kbiM", + category: "display", + url: "https://fonts.gstatic.com/s/kellyslab/v18/-W_7XJX0Rz3cxUnJC5t6TkMBf50kbiM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Piazzolla", + filename: "N0bX2SlTPu5rIkWIZjVQIfTTkdbJYA", + category: "serif", + url: "https://fonts.gstatic.com/s/piazzolla/v40/N0bX2SlTPu5rIkWIZjVQIfTTkdbJYA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Inknut Antiqua", + filename: "Y4GSYax7VC4ot_qNB4nYpBdaKXUD6pzxRwYB", + category: "serif", + url: "https://fonts.gstatic.com/s/inknutantiqua/v16/Y4GSYax7VC4ot_qNB4nYpBdaKXUD6pzxRwYB.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Quintessential", + filename: "fdNn9sOGq31Yjnh3qWU14DdtjY5wS7kmAyxM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/quintessential/v24/fdNn9sOGq31Yjnh3qWU14DdtjY5wS7kmAyxM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Saira Stencil One", + filename: "SLXSc03I6HkvZGJ1GvvipLoYSTEL9AsMawif2YQ2", + category: "display", + url: "https://fonts.gstatic.com/s/sairastencilone/v18/SLXSc03I6HkvZGJ1GvvipLoYSTEL9AsMawif2YQ2.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chelsea Market", + filename: "BCawqZsHqfr89WNP_IApC8tzKBhlLA4uKkWk", + category: "display", + url: "https://fonts.gstatic.com/s/chelseamarket/v14/BCawqZsHqfr89WNP_IApC8tzKBhlLA4uKkWk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Dots", + filename: "XRXX3ICfm00IGoesQeaETM_FcCIG", + category: "display", + url: "https://fonts.gstatic.com/s/zendots/v14/XRXX3ICfm00IGoesQeaETM_FcCIG.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Uncial Antiqua", + filename: "N0bM2S5WOex4OUbESzoESK-i-PfRS5VBBSSF", + category: "display", + url: "https://fonts.gstatic.com/s/uncialantiqua/v22/N0bM2S5WOex4OUbESzoESK-i-PfRS5VBBSSF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Spectral SC", + filename: "KtkpALCRZonmalTgyPmRfvWi6WDfFpuc", + category: "serif", + url: "https://fonts.gstatic.com/s/spectralsc/v15/KtkpALCRZonmalTgyPmRfvWi6WDfFpuc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kadwa", + filename: "rnCm-x5V0g7iphTHRcc2s2XH", + category: "serif", + url: "https://fonts.gstatic.com/s/kadwa/v13/rnCm-x5V0g7iphTHRcc2s2XH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "KoHo", + filename: "K2F-fZ5fmddNBikefJbSOos", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/koho/v18/K2F-fZ5fmddNBikefJbSOos.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rambla", + filename: "snfrs0ip98hx6mr0I7IONthkwQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rambla/v14/snfrs0ip98hx6mr0I7IONthkwQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mouse Memoirs", + filename: "t5tmIRoSNJ-PH0WNNgDYxdSb7TnFrpOHYh4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mousememoirs/v19/t5tmIRoSNJ-PH0WNNgDYxdSb7TnFrpOHYh4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Sans Hebrew", + filename: "BCa2qYENg9Kw1mpLpO0bGM5lfHAAZHhDXH2l8Fk3rSaM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsanshebrew/v12/BCa2qYENg9Kw1mpLpO0bGM5lfHAAZHhDXH2l8Fk3rSaM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Agdasima", + filename: "PN_zRfyxp2f1fUCgAMg6rzjb_-Da", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/agdasima/v5/PN_zRfyxp2f1fUCgAMg6rzjb_-Da.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Beth Ellen", + filename: "WwkbxPW2BE-3rb_JNT-qEIAiVNo5xNY", + category: "handwriting", + url: "https://fonts.gstatic.com/s/bethellen/v22/WwkbxPW2BE-3rb_JNT-qEIAiVNo5xNY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Kannada", + filename: "8vIS7xs32H97qzQKnzfeXycxXZyUmz6kR47NCV5Z", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskannada/v31/8vIS7xs32H97qzQKnzfeXycxXZyUmz6kR47NCV5Z.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Voltaire", + filename: "1Pttg8PcRfSblAvGvQooYKVnBOif", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/voltaire/v23/1Pttg8PcRfSblAvGvQooYKVnBOif.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Birthstone", + filename: "8AtsGs2xO4yLRhy87sv_HLn5jRfZHzM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/birthstone/v16/8AtsGs2xO4yLRhy87sv_HLn5jRfZHzM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Baloo Thambi 2", + filename: "cY9cfjeOW0NHpmOQXranrbDyu4hHBJOxZQPp", + category: "display", + url: "https://fonts.gstatic.com/s/baloothambi2/v22/cY9cfjeOW0NHpmOQXranrbDyu4hHBJOxZQPp.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Syne Mono", + filename: "K2FzfZNHj_FHBmRbFvHzIqCkDyvqZA", + category: "monospace", + url: "https://fonts.gstatic.com/s/synemono/v16/K2FzfZNHj_FHBmRbFvHzIqCkDyvqZA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Atma", + filename: "uK_84rqWc-Eom25bDj8WIv4", + category: "display", + url: "https://fonts.gstatic.com/s/atma/v19/uK_84rqWc-Eom25bDj8WIv4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Marvel", + filename: "nwpVtKeoNgBV0qaIkV7ED366zg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/marvel/v17/nwpVtKeoNgBV0qaIkV7ED366zg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hurricane", + filename: "pe0sMIuULZxTolZ5YldyAv2-C99ycg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/hurricane/v9/pe0sMIuULZxTolZ5YldyAv2-C99ycg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nova Mono", + filename: "Cn-0JtiGWQ5Ajb--MRKfYGxYrdM9Sg", + category: "monospace", + url: "https://fonts.gstatic.com/s/novamono/v23/Cn-0JtiGWQ5Ajb--MRKfYGxYrdM9Sg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lekton", + filename: "SZc43FDmLaWmWpBeXxfonUPL6Q", + category: "monospace", + url: "https://fonts.gstatic.com/s/lekton/v21/SZc43FDmLaWmWpBeXxfonUPL6Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pathway Extreme", + filename: "neITzCC3pJ0rsaH2_sD-QttXPfDVqVkarSqSwA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/pathwayextreme/v7/neITzCC3pJ0rsaH2_sD-QttXPfDVqVkarSqSwA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Libre Barcode 39 Extended Text", + filename: "eLG1P_rwIgOiDA7yrs9LoKaYRVLQ1YldrrOnnL7xPO4jNP68fLIiPopNNA", + category: "display", + url: "https://fonts.gstatic.com/s/librebarcode39extendedtext/v30/eLG1P_rwIgOiDA7yrs9LoKaYRVLQ1YldrrOnnL7xPO4jNP68fLIiPopNNA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Georgian", + filename: "PlIgFke5O6RzLfvNNVSitxkr76PRHBCiaf5GGPq86g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansgeorgian/v48/PlIgFke5O6RzLfvNNVSitxkr76PRHBCiaf5GGPq86g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Baloo Chettan 2", + filename: "vm8udRbmXEva26PK-NtuX4ynWEzf4P17OpYDlg", + category: "display", + url: "https://fonts.gstatic.com/s/baloochettan2/v23/vm8udRbmXEva26PK-NtuX4ynWEzf4P17OpYDlg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Della Respira", + filename: "RLp5K5v44KaueWI6iEJQBiGPRfkSu6EuTHo", + category: "serif", + url: "https://fonts.gstatic.com/s/dellarespira/v24/RLp5K5v44KaueWI6iEJQBiGPRfkSu6EuTHo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alike", + filename: "HI_EiYEYI6BIoEjBSZXAQ4-d", + category: "serif", + url: "https://fonts.gstatic.com/s/alike/v22/HI_EiYEYI6BIoEjBSZXAQ4-d.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Amarante", + filename: "xMQXuF1KTa6EvGx9bq-3C3rAmD-b", + category: "display", + url: "https://fonts.gstatic.com/s/amarante/v30/xMQXuF1KTa6EvGx9bq-3C3rAmD-b.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Quando", + filename: "xMQVuFNaVa6YuW0pC6WzKX_QmA", + category: "serif", + url: "https://fonts.gstatic.com/s/quando/v18/xMQVuFNaVa6YuW0pC6WzKX_QmA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Devanagari", + filename: "x3d-cl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4djEWqKMxsKw", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifdevanagari/v34/x3d-cl3IZKmUqiMk48ZHXJ5jwU-DZGRSaQ4djEWqKMxsKw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "IM Fell DW Pica", + filename: "2sDGZGRQotv9nbn2qSl0TxXVYNw9ZAPUvi88MQ", + category: "serif", + url: "https://fonts.gstatic.com/s/imfelldwpica/v16/2sDGZGRQotv9nbn2qSl0TxXVYNw9ZAPUvi88MQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "McLaren", + filename: "2EbnL-ZuAXFqZFXISYYf8z2Yt_c", + category: "display", + url: "https://fonts.gstatic.com/s/mclaren/v19/2EbnL-ZuAXFqZFXISYYf8z2Yt_c.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Qwigley", + filename: "1cXzaU3UGJb5tGoCuVxsi1mBmcE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/qwigley/v20/1cXzaU3UGJb5tGoCuVxsi1mBmcE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Magra", + filename: "uK_94ruaZus72k5xIDMfO-ed", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/magra/v15/uK_94ruaZus72k5xIDMfO-ed.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sunflower", + filename: "RWmPoKeF8fUjqIj7Vc-06MfiqYsGBGBzCw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sunflower/v18/RWmPoKeF8fUjqIj7Vc-06MfiqYsGBGBzCw.ttf", + weight: 300, + isVariable: false + }, + { + displayName: "Xanh Mono", + filename: "R70YjykVmvKCep-vWhSYmACQXzLhTg", + category: "monospace", + url: "https://fonts.gstatic.com/s/xanhmono/v19/R70YjykVmvKCep-vWhSYmACQXzLhTg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Barriecito", + filename: "WWXXlj-CbBOSLY2QTuY_KdUiYwTO0MU", + category: "display", + url: "https://fonts.gstatic.com/s/barriecito/v18/WWXXlj-CbBOSLY2QTuY_KdUiYwTO0MU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Metamorphous", + filename: "Wnz8HA03aAXcC39ZEX5y1330PCCthTsmaQ", + category: "display", + url: "https://fonts.gstatic.com/s/metamorphous/v22/Wnz8HA03aAXcC39ZEX5y1330PCCthTsmaQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "David Libre", + filename: "snfus0W_99N64iuYSvp4W_l86p6TYS-Y", + category: "serif", + url: "https://fonts.gstatic.com/s/davidlibre/v17/snfus0W_99N64iuYSvp4W_l86p6TYS-Y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fresca", + filename: "6ae94K--SKgCzbM2Gr0W13DKPA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/fresca/v24/6ae94K--SKgCzbM2Gr0W13DKPA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cambay", + filename: "SLXJc1rY6H0_ZDsGbrSIz9JsaA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cambay/v14/SLXJc1rY6H0_ZDsGbrSIz9JsaA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bokor", + filename: "m8JcjfpeeaqTiR2WdInbcaxE", + category: "display", + url: "https://fonts.gstatic.com/s/bokor/v32/m8JcjfpeeaqTiR2WdInbcaxE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zalando Sans Expanded", + filename: "JTUJjJci8Cy470GaeFwsix1hi3aTmrgRwU-Dq0Y6QX5Mog", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zalandosansexpanded/v2/JTUJjJci8Cy470GaeFwsix1hi3aTmrgRwU-Dq0Y6QX5Mog.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Corinthia", + filename: "wEO_EBrAnchaJyPMHE0FUfAL3EsHiA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/corinthia/v13/wEO_EBrAnchaJyPMHE0FUfAL3EsHiA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fanwood Text", + filename: "3XFtErwl05Ad_vSCF6Fq7xXGRdbY1P1Sbg", + category: "serif", + url: "https://fonts.gstatic.com/s/fanwoodtext/v17/3XFtErwl05Ad_vSCF6Fq7xXGRdbY1P1Sbg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kurale", + filename: "4iCs6KV9e9dXjho6eAT3v02QFg", + category: "serif", + url: "https://fonts.gstatic.com/s/kurale/v14/4iCs6KV9e9dXjho6eAT3v02QFg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Seaweed Script", + filename: "bx6cNx6Tne2pxOATYE8C_Rsoe0WJ-KcGVbLW", + category: "display", + url: "https://fonts.gstatic.com/s/seaweedscript/v17/bx6cNx6Tne2pxOATYE8C_Rsoe0WJ-KcGVbLW.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "ZCOOL KuaiLe", + filename: "tssqApdaRQokwFjFJjvM6h2WpozzoXhC2g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zcoolkuaile/v20/tssqApdaRQokwFjFJjvM6h2WpozzoXhC2g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "B612 Mono", + filename: "kmK_Zq85QVWbN1eW6lJl1wTcquRTtg", + category: "monospace", + url: "https://fonts.gstatic.com/s/b612mono/v16/kmK_Zq85QVWbN1eW6lJl1wTcquRTtg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Antique", + filename: "AYCPpXPnd91Ma_Zf-Ri2JXJq7PKP5Z_G", + category: "serif", + url: "https://fonts.gstatic.com/s/zenantique/v14/AYCPpXPnd91Ma_Zf-Ri2JXJq7PKP5Z_G.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Encode Sans Semi Expanded", + filename: "ke83OhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TC4o_LyjgOXc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/encodesanssemiexpanded/v20/ke83OhAPMEZs-BDuzwftTNJ85JvwMOzE9d9Cca5TC4o_LyjgOXc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Baloo Paaji 2", + filename: "i7dMIFFzbz-QHZUdV9_UGWZuYFKQHwyVd3U", + category: "display", + url: "https://fonts.gstatic.com/s/baloopaaji2/v29/i7dMIFFzbz-QHZUdV9_UGWZuYFKQHwyVd3U.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Meie Script", + filename: "_LOImzDK7erRjhunIspaMjxn5IXg0WDz", + category: "handwriting", + url: "https://fonts.gstatic.com/s/meiescript/v22/_LOImzDK7erRjhunIspaMjxn5IXg0WDz.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Farro", + filename: "i7dEIFl3byGNHZVNHLq2cV5d", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/farro/v15/i7dEIFl3byGNHZVNHLq2cV5d.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jaldi", + filename: "or3sQ67z0_CI30NUZpD_B6g8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/jaldi/v14/or3sQ67z0_CI30NUZpD_B6g8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Miriam Libre", + filename: "DdTh798HsHwubBAqfkcBTL_vYJn_Teun9g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/miriamlibre/v19/DdTh798HsHwubBAqfkcBTL_vYJn_Teun9g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mako", + filename: "H4coBX6Mmc_Z0ST09g478Lo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mako/v19/H4coBX6Mmc_Z0ST09g478Lo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Baloo Tamma 2", + filename: "vEFX2_hCAgcR46PaajtrYlBbT0g21tqeR7c", + category: "display", + url: "https://fonts.gstatic.com/s/balootamma2/v20/vEFX2_hCAgcR46PaajtrYlBbT0g21tqeR7c.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sue Ellen Francisco", + filename: "wXK3E20CsoJ9j1DDkjHcQ5ZL8xRaxru9ropF2lqk9H4", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sueellenfrancisco/v22/wXK3E20CsoJ9j1DDkjHcQ5ZL8xRaxru9ropF2lqk9H4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zain", + filename: "syk8-y9lm7soANLHkSKW5tM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zain/v4/syk8-y9lm7soANLHkSKW5tM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Buenard", + filename: "OD5DuM6Cyma8FnnsPzf9qGi9HL4", + category: "serif", + url: "https://fonts.gstatic.com/s/buenard/v22/OD5DuM6Cyma8FnnsPzf9qGi9HL4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ruslan Display", + filename: "Gw6jwczl81XcIZuckK_e3UpfdzxrldyFvm1n", + category: "display", + url: "https://fonts.gstatic.com/s/ruslandisplay/v27/Gw6jwczl81XcIZuckK_e3UpfdzxrldyFvm1n.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vibur", + filename: "DPEiYwmEzw0QRjTpLjoJd-Xa", + category: "handwriting", + url: "https://fonts.gstatic.com/s/vibur/v24/DPEiYwmEzw0QRjTpLjoJd-Xa.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gotu", + filename: "o-0FIpksx3QOlH0Lioh6-hU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gotu/v18/o-0FIpksx3QOlH0Lioh6-hU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Inder", + filename: "w8gUH2YoQe8_4vq6pw-P3U4O", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/inder/v15/w8gUH2YoQe8_4vq6pw-P3U4O.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pompiere", + filename: "VEMyRoxis5Dwuyeov6Wt5jDtreOL", + category: "display", + url: "https://fonts.gstatic.com/s/pompiere/v21/VEMyRoxis5Dwuyeov6Wt5jDtreOL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Germania One", + filename: "Fh4yPjrqIyv2ucM2qzBjeS3ezAJONau6ew", + category: "display", + url: "https://fonts.gstatic.com/s/germaniaone/v21/Fh4yPjrqIyv2ucM2qzBjeS3ezAJONau6ew.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rasa", + filename: "xn7vYHIn1mWmTqJelgiQV9w", + category: "serif", + url: "https://fonts.gstatic.com/s/rasa/v27/xn7vYHIn1mWmTqJelgiQV9w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gluten", + filename: "HhyVU5gk9fW7OUdPK9qGGI9STA", + category: "display", + url: "https://fonts.gstatic.com/s/gluten/v18/HhyVU5gk9fW7OUdPK9qGGI9STA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Fuzzy Bubbles", + filename: "6qLGKZMbrgv9pwtjPEVNV0F2NnP5Zxsreko", + category: "handwriting", + url: "https://fonts.gstatic.com/s/fuzzybubbles/v9/6qLGKZMbrgv9pwtjPEVNV0F2NnP5Zxsreko.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aguafina Script", + filename: "If2QXTv_ZzSxGIO30LemWEOmt1bHqs4pgicOrg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/aguafinascript/v24/If2QXTv_ZzSxGIO30LemWEOmt1bHqs4pgicOrg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Delius Unicase", + filename: "845BNMEwEIOVT8BmgfSzIr_6mmLHd-73LXWs", + category: "handwriting", + url: "https://fonts.gstatic.com/s/deliusunicase/v30/845BNMEwEIOVT8BmgfSzIr_6mmLHd-73LXWs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gurajada", + filename: "FwZY7-Qx308m-l-0Kd6A4sijpFu_", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gurajada/v22/FwZY7-Qx308m-l-0Kd6A4sijpFu_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Coustard", + filename: "3XFpErgg3YsZ5fqUU9UPvWXuROTd", + category: "serif", + url: "https://fonts.gstatic.com/s/coustard/v17/3XFpErgg3YsZ5fqUU9UPvWXuROTd.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Protest Strike", + filename: "0QI5MXdf4Y67Rn6vBog67ZjFlpzW0gZOs7BX", + category: "display", + url: "https://fonts.gstatic.com/s/proteststrike/v2/0QI5MXdf4Y67Rn6vBog67ZjFlpzW0gZOs7BX.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Happy Monkey", + filename: "K2F2fZZcl-9SXwl5F_C4R_OABwD2bWqVjw", + category: "display", + url: "https://fonts.gstatic.com/s/happymonkey/v15/K2F2fZZcl-9SXwl5F_C4R_OABwD2bWqVjw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Thasadith", + filename: "mtG44_1TIqPYrd_f5R1YsEkU0CWuFw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/thasadith/v13/mtG44_1TIqPYrd_f5R1YsEkU0CWuFw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Euphoria Script", + filename: "mFTpWb0X2bLb_cx6To2B8GpKoD5ak_ZT1D8x7Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/euphoriascript/v22/mFTpWb0X2bLb_cx6To2B8GpKoD5ak_ZT1D8x7Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sansita Swashed", + filename: "BXRzvFfZifTZgFlDDLgNkBydPKT31beyoRkJIA", + category: "display", + url: "https://fonts.gstatic.com/s/sansitaswashed/v23/BXRzvFfZifTZgFlDDLgNkBydPKT31beyoRkJIA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Capriola", + filename: "wXKoE3YSppcvo1PDln_8L-AinG8y", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/capriola/v15/wXKoE3YSppcvo1PDln_8L-AinG8y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zalando Sans", + filename: "FwZc7-Asy1Em_lq_aK3hpr-LpWm6_K7bkA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zalandosans/v2/FwZc7-Asy1Em_lq_aK3hpr-LpWm6_K7bkA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mukta Mahee", + filename: "XRXQ3IOIi0hcP8iVU67hA-vNWz4PDWtj", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/muktamahee/v19/XRXQ3IOIi0hcP8iVU67hA-vNWz4PDWtj.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Slackey", + filename: "N0bV2SdQO-5yM0-dKlRaJdbWgdY", + category: "display", + url: "https://fonts.gstatic.com/s/slackey/v29/N0bV2SdQO-5yM0-dKlRaJdbWgdY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Voces", + filename: "-F6_fjJyLyU8d4PBBG7YpzlJ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/voces/v24/-F6_fjJyLyU8d4PBBG7YpzlJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Afacad Flux", + filename: "9oRKNYYQryMlneUPykRmTvvzM83LHq0O", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/afacadflux/v4/9oRKNYYQryMlneUPykRmTvvzM83LHq0O.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Monomaniac One", + filename: "4iC06K17YctZjx50EU-QlwPmcqRnqYkB5kwI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/monomaniacone/v15/4iC06K17YctZjx50EU-QlwPmcqRnqYkB5kwI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vast Shadow", + filename: "pe0qMImKOZ1V62ZwbVY9dfe6Kdpickwp", + category: "serif", + url: "https://fonts.gstatic.com/s/vastshadow/v21/pe0qMImKOZ1V62ZwbVY9dfe6Kdpickwp.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Moul", + filename: "nuF2D__FSo_3E-RYiJCy-00", + category: "display", + url: "https://fonts.gstatic.com/s/moul/v30/nuF2D__FSo_3E-RYiJCy-00.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jaro", + filename: "ea8WadQwV_r_XPbcEdiM628", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/jaro/v8/ea8WadQwV_r_XPbcEdiM628.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vujahday Script", + filename: "RWmQoKGA8fEkrIPtSZ3_J7er2dUiDEtvAlaMKw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/vujahdayscript/v10/RWmQoKGA8fEkrIPtSZ3_J7er2dUiDEtvAlaMKw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "MedievalSharp", + filename: "EvOJzAlL3oU5AQl2mP5KdgptAq96MwvXLDk", + category: "display", + url: "https://fonts.gstatic.com/s/medievalsharp/v28/EvOJzAlL3oU5AQl2mP5KdgptAq96MwvXLDk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kaisei Opti", + filename: "QldKNThJphYb8_g6c2nlIFle7KlmxuHx", + category: "serif", + url: "https://fonts.gstatic.com/s/kaiseiopti/v11/QldKNThJphYb8_g6c2nlIFle7KlmxuHx.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shantell Sans", + filename: "FeVWS0pCoLIo-lcdY7kjvNoQs250xsQuLFg", + category: "display", + url: "https://fonts.gstatic.com/s/shantellsans/v13/FeVWS0pCoLIo-lcdY7kjvNoQs250xsQuLFg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mr De Haviland", + filename: "OpNVnooIhJj96FdB73296ksbOj3C4ULVNTlB", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mrdehaviland/v15/OpNVnooIhJj96FdB73296ksbOj3C4ULVNTlB.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rouge Script", + filename: "LYjFdGbiklMoCIQOw1Ep3S4PVPXbUJWq9g", + category: "handwriting", + url: "https://fonts.gstatic.com/s/rougescript/v20/LYjFdGbiklMoCIQOw1Ep3S4PVPXbUJWq9g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Coiny", + filename: "gyByhwU1K989PXwbElSvO5Tc", + category: "display", + url: "https://fonts.gstatic.com/s/coiny/v17/gyByhwU1K989PXwbElSvO5Tc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Arima", + filename: "neIFzCqmt4Aup-CP9IGON7Ez", + category: "display", + url: "https://fonts.gstatic.com/s/arima/v7/neIFzCqmt4Aup-CP9IGON7Ez.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mina", + filename: "-nFzOGc18vARrz9j7i3y65o", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mina/v14/-nFzOGc18vARrz9j7i3y65o.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Supermercado One", + filename: "OpNXnpQWg8jc_xps_Gi14kVVEXOn60b3MClBRTs", + category: "display", + url: "https://fonts.gstatic.com/s/supermercadoone/v29/OpNXnpQWg8jc_xps_Gi14kVVEXOn60b3MClBRTs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Prosto One", + filename: "OpNJno4VhNfK-RgpwWWxpipfWhXD00c", + category: "display", + url: "https://fonts.gstatic.com/s/prostoone/v21/OpNJno4VhNfK-RgpwWWxpipfWhXD00c.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Short Stack", + filename: "bMrzmS2X6p0jZC6EcmPFX-SScX8D0nq6", + category: "handwriting", + url: "https://fonts.gstatic.com/s/shortstack/v16/bMrzmS2X6p0jZC6EcmPFX-SScX8D0nq6.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Molengo", + filename: "I_uuMpWeuBzZNBtQbbRQkiCvs5Y", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/molengo/v17/I_uuMpWeuBzZNBtQbbRQkiCvs5Y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Expletus Sans", + filename: "RLp5K5v5_bqufTYdnhFzDj2dRfkSu6EuTHo", + category: "display", + url: "https://fonts.gstatic.com/s/expletussans/v31/RLp5K5v5_bqufTYdnhFzDj2dRfkSu6EuTHo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sofadi One", + filename: "JIA2UVBxdnVBuElZaMFGcDOIETkmYDU", + category: "display", + url: "https://fonts.gstatic.com/s/sofadione/v22/JIA2UVBxdnVBuElZaMFGcDOIETkmYDU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Asul", + filename: "VuJ-dNjKxYr46fMFXK78JIg", + category: "serif", + url: "https://fonts.gstatic.com/s/asul/v22/VuJ-dNjKxYr46fMFXK78JIg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lacquer", + filename: "EYqzma1QwqpG4_BBB7-AXhttQ5I", + category: "display", + url: "https://fonts.gstatic.com/s/lacquer/v16/EYqzma1QwqpG4_BBB7-AXhttQ5I.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "WindSong", + filename: "KR1WBsyu-P-GFEW57r95HdG6vjH3", + category: "handwriting", + url: "https://fonts.gstatic.com/s/windsong/v13/KR1WBsyu-P-GFEW57r95HdG6vjH3.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kranky", + filename: "hESw6XVgJzlPsFnMpheEZo_H_w", + category: "display", + url: "https://fonts.gstatic.com/s/kranky/v29/hESw6XVgJzlPsFnMpheEZo_H_w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Original Surfer", + filename: "RWmQoKGZ9vIirYntXJ3_MbekzNMiDEtvAlaMKw", + category: "display", + url: "https://fonts.gstatic.com/s/originalsurfer/v25/RWmQoKGZ9vIirYntXJ3_MbekzNMiDEtvAlaMKw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cal Sans", + filename: "fdN99sWUv3gWqXxqqSBbvloE4LZx", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/calsans/v2/fdN99sWUv3gWqXxqqSBbvloE4LZx.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mallanna", + filename: "hv-Vlzx-KEQb84YaDGwzEzRwVvJ-", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mallanna/v15/hv-Vlzx-KEQb84YaDGwzEzRwVvJ-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Give You Glory", + filename: "8QIQdiHOgt3vv4LR7ahjw9-XYc1zB4ZD6rwa", + category: "handwriting", + url: "https://fonts.gstatic.com/s/giveyouglory/v17/8QIQdiHOgt3vv4LR7ahjw9-XYc1zB4ZD6rwa.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Modak", + filename: "EJRYQgs1XtIEsnMH8BVZ76KU", + category: "display", + url: "https://fonts.gstatic.com/s/modak/v21/EJRYQgs1XtIEsnMH8BVZ76KU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aref Ruqaa", + filename: "WwkbxPW1E165rajQKDulEIAiVNo5xNY", + category: "serif", + url: "https://fonts.gstatic.com/s/arefruqaa/v26/WwkbxPW1E165rajQKDulEIAiVNo5xNY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Angkor", + filename: "H4cmBXyAlsPdnlb-8iw-4Lqggw", + category: "display", + url: "https://fonts.gstatic.com/s/angkor/v35/H4cmBXyAlsPdnlb-8iw-4Lqggw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ephesis", + filename: "uU9PCBUS8IerL2VG7xPb3vyHmlI", + category: "handwriting", + url: "https://fonts.gstatic.com/s/ephesis/v11/uU9PCBUS8IerL2VG7xPb3vyHmlI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bungee Shade", + filename: "DtVkJxarWL0t2KdzK3oI_jks7iLSrwFUlw", + category: "display", + url: "https://fonts.gstatic.com/s/bungeeshade/v17/DtVkJxarWL0t2KdzK3oI_jks7iLSrwFUlw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kodchasan", + filename: "1cXxaUPOAJv9sG4I-DJmj3uEicG01A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kodchasan/v20/1cXxaUPOAJv9sG4I-DJmj3uEicG01A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bilbo Swash Caps", + filename: "zrf-0GXbz-H3Wb4XBsGrTgq2PVmdqAPopiRfKp8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/bilboswashcaps/v23/zrf-0GXbz-H3Wb4XBsGrTgq2PVmdqAPopiRfKp8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Julee", + filename: "TuGfUVB3RpZPQ6ZLodgzydtk", + category: "handwriting", + url: "https://fonts.gstatic.com/s/julee/v26/TuGfUVB3RpZPQ6ZLodgzydtk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sarina", + filename: "-F6wfjF3ITQwasLhLkDUriBQxw", + category: "display", + url: "https://fonts.gstatic.com/s/sarina/v25/-F6wfjF3ITQwasLhLkDUriBQxw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Battambang", + filename: "uk-mEGe7raEw-HjkzZabDnWj4yxx7o8", + category: "display", + url: "https://fonts.gstatic.com/s/battambang/v26/uk-mEGe7raEw-HjkzZabDnWj4yxx7o8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tiro Devanagari Hindi", + filename: "55xyezN7P8T4e0_CfIJrwdodg9HoYw0i-M9fSOkOfG0Y3A", + category: "serif", + url: "https://fonts.gstatic.com/s/tirodevanagarihindi/v5/55xyezN7P8T4e0_CfIJrwdodg9HoYw0i-M9fSOkOfG0Y3A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tienne", + filename: "AYCKpX7pe9YCRP0LkEPHSFNyxw", + category: "serif", + url: "https://fonts.gstatic.com/s/tienne/v21/AYCKpX7pe9YCRP0LkEPHSFNyxw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mukta Vaani", + filename: "3Jn5SD_-ynaxmxnEfVHPIF0FfORL0fNy", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/muktavaani/v15/3Jn5SD_-ynaxmxnEfVHPIF0FfORL0fNy.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "B612", + filename: "3JnySDDxiSz32jm4GDigUXw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/b612/v13/3JnySDDxiSz32jm4GDigUXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Teachers", + filename: "H4ckBXKVncXVmUGsgSY6wr-wg763", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/teachers/v6/H4ckBXKVncXVmUGsgSY6wr-wg763.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Brawler", + filename: "xn7gYHE3xXewAscGsgC7S9XdZN8", + category: "serif", + url: "https://fonts.gstatic.com/s/brawler/v20/xn7gYHE3xXewAscGsgC7S9XdZN8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Viaoda Libre", + filename: "vEFW2_lWCgoR6OKuRz9kcRVJb2IY2tOHXg", + category: "display", + url: "https://fonts.gstatic.com/s/viaodalibre/v20/vEFW2_lWCgoR6OKuRz9kcRVJb2IY2tOHXg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cutive", + filename: "NaPZcZ_fHOhV3Ip7T_hDoyqlZQ", + category: "serif", + url: "https://fonts.gstatic.com/s/cutive/v24/NaPZcZ_fHOhV3Ip7T_hDoyqlZQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anybody", + filename: "VuJxdNvK2Ib2ppdWeKbXOIFneRo", + category: "display", + url: "https://fonts.gstatic.com/s/anybody/v13/VuJxdNvK2Ib2ppdWeKbXOIFneRo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rubik Glitch", + filename: "qkBSXv8b_srFRYQVYrDKh9ZvmC7HONiSFQ", + category: "display", + url: "https://fonts.gstatic.com/s/rubikglitch/v2/qkBSXv8b_srFRYQVYrDKh9ZvmC7HONiSFQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Crafty Girls", + filename: "va9B4kXI39VaDdlPJo8N_NvuQR37fF3Wlg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/craftygirls/v16/va9B4kXI39VaDdlPJo8N_NvuQR37fF3Wlg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Poetsen One", + filename: "ke8hOgIaMUB37xCgvCntWtIvq_KREbG9", + category: "display", + url: "https://fonts.gstatic.com/s/poetsenone/v3/ke8hOgIaMUB37xCgvCntWtIvq_KREbG9.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anek Malayalam", + filename: "6qLZKZActRTs_mZAJUZWWkhke1PTSRciY1M1", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anekmalayalam/v18/6qLZKZActRTs_mZAJUZWWkhke1PTSRciY1M1.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Goblin One", + filename: "CSR64z1ZnOqZRjRCBVY_TOcATNt_pOU", + category: "display", + url: "https://fonts.gstatic.com/s/goblinone/v28/CSR64z1ZnOqZRjRCBVY_TOcATNt_pOU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vesper Libre", + filename: "bx6CNxyWnf-uxPdXDHUD_Rd4D0-N2qIWVQ", + category: "serif", + url: "https://fonts.gstatic.com/s/vesperlibre/v21/bx6CNxyWnf-uxPdXDHUD_Rd4D0-N2qIWVQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Allan", + filename: "ea8XadU7WuTxEtb2P9SF8nZE", + category: "display", + url: "https://fonts.gstatic.com/s/allan/v26/ea8XadU7WuTxEtb2P9SF8nZE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Special Gothic Condensed One", + filename: "R70Njzwei_mJM7OsFDzX7EL9NBO6IPvd-Avolzh49w7PUZt_5YtxbX8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/specialgothiccondensedone/v2/R70Njzwei_mJM7OsFDzX7EL9NBO6IPvd-Avolzh49w7PUZt_5YtxbX8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Walter Turncoat", + filename: "snfys0Gs98ln43n0d-14ULoToe67YB2dQ5ZPqQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/walterturncoat/v24/snfys0Gs98ln43n0d-14ULoToe67YB2dQ5ZPqQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Esteban", + filename: "r05bGLZE-bdGdN-GdOuD5jokU8E", + category: "serif", + url: "https://fonts.gstatic.com/s/esteban/v16/r05bGLZE-bdGdN-GdOuD5jokU8E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Elsie", + filename: "BCanqZABrez54yYu9slAeLgX", + category: "display", + url: "https://fonts.gstatic.com/s/elsie/v26/BCanqZABrez54yYu9slAeLgX.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Poly", + filename: "MQpb-W6wKNitRLCAq2Lpris", + category: "serif", + url: "https://fonts.gstatic.com/s/poly/v18/MQpb-W6wKNitRLCAq2Lpris.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oregano", + filename: "If2IXTPxciS3H4S2kZffPznO3yM", + category: "display", + url: "https://fonts.gstatic.com/s/oregano/v17/If2IXTPxciS3H4S2kZffPznO3yM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gowun Dodum", + filename: "3Jn5SD_00GqwlBnWc1TUJF0FfORL0fNy", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gowundodum/v12/3Jn5SD_00GqwlBnWc1TUJF0FfORL0fNy.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anek Devanagari", + filename: "jVyS7nP0CGrUsxB-QiRgw0NlLaV39ifscRzoQA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anekdevanagari/v17/jVyS7nP0CGrUsxB-QiRgw0NlLaV39ifscRzoQA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Poller One", + filename: "ahccv82n0TN3gia5E4Bud-lbgUS5u0s", + category: "display", + url: "https://fonts.gstatic.com/s/pollerone/v25/ahccv82n0TN3gia5E4Bud-lbgUS5u0s.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Federo", + filename: "iJWFBX-cbD_ETsbmjVOe2WTG7Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/federo/v20/iJWFBX-cbD_ETsbmjVOe2WTG7Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Slabo 13px", + filename: "11hEGp_azEvXZUdSBzzRcKer2wkYnvI", + category: "serif", + url: "https://fonts.gstatic.com/s/slabo13px/v17/11hEGp_azEvXZUdSBzzRcKer2wkYnvI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Myanmar", + filename: "AlZq_y1ZtY3ymOryg38hOCSdOnFq0En23OU4o1AC", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmyanmar/v27/AlZq_y1ZtY3ymOryg38hOCSdOnFq0En23OU4o1AC.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gaegu", + filename: "TuGfUVB6Up9NU6ZLodgzydtk", + category: "handwriting", + url: "https://fonts.gstatic.com/s/gaegu/v23/TuGfUVB6Up9NU6ZLodgzydtk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sedgwick Ave", + filename: "uK_04rKEYuguzAcSYRdWTJq8Xmg1Vcf5JA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sedgwickave/v13/uK_04rKEYuguzAcSYRdWTJq8Xmg1Vcf5JA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Iceland", + filename: "rax9HiuFsdMNOnWPWKxGADBbg0s", + category: "display", + url: "https://fonts.gstatic.com/s/iceland/v22/rax9HiuFsdMNOnWPWKxGADBbg0s.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kalnia", + filename: "11hAGpPCwUbbYwZDNGatWKaZ3g", + category: "serif", + url: "https://fonts.gstatic.com/s/kalnia/v6/11hAGpPCwUbbYwZDNGatWKaZ3g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Imprima", + filename: "VEMxRoN7sY3yuy-7-oWHyDzktPo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/imprima/v19/VEMxRoN7sY3yuy-7-oWHyDzktPo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Iceberg", + filename: "8QIJdijAiM7o-qnZuIgOq7jkAOw", + category: "display", + url: "https://fonts.gstatic.com/s/iceberg/v26/8QIJdijAiM7o-qnZuIgOq7jkAOw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Finger Paint", + filename: "0QInMXVJ-o-oRn_7dron8YWO85bS8ANesw", + category: "display", + url: "https://fonts.gstatic.com/s/fingerpaint/v21/0QInMXVJ-o-oRn_7dron8YWO85bS8ANesw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Henny Penny", + filename: "wXKvE3UZookzsxz_kjGSfMQqt3M7tMDT", + category: "display", + url: "https://fonts.gstatic.com/s/hennypenny/v18/wXKvE3UZookzsxz_kjGSfMQqt3M7tMDT.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nosifer", + filename: "ZGjXol5JTp0g5bxZaC1RVDNdGDs", + category: "display", + url: "https://fonts.gstatic.com/s/nosifer/v23/ZGjXol5JTp0g5bxZaC1RVDNdGDs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Waterfall", + filename: "MCoRzAfo293fACdFKcwY2rH8D_EZwA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/waterfall/v8/MCoRzAfo293fACdFKcwY2rH8D_EZwA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "NTR", + filename: "RLpzK5Xy0ZjiGGhs5TA4bg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ntr/v19/RLpzK5Xy0ZjiGGhs5TA4bg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jersey 25", + filename: "ll8-K2eeXj2tAs6F9BXIJ4AMng8ChA", + category: "display", + url: "https://fonts.gstatic.com/s/jersey25/v4/ll8-K2eeXj2tAs6F9BXIJ4AMng8ChA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Red Hat Mono", + filename: "jVyN7nDnA2uf2zVvFAhhzEskX-ZT_gzweA", + category: "monospace", + url: "https://fonts.gstatic.com/s/redhatmono/v16/jVyN7nDnA2uf2zVvFAhhzEskX-ZT_gzweA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bakbak One", + filename: "zOL54pXAl6RI-p_ardnuycRuv-hHkOs", + category: "display", + url: "https://fonts.gstatic.com/s/bakbakone/v11/zOL54pXAl6RI-p_ardnuycRuv-hHkOs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Odibee Sans", + filename: "neIPzCSooYAho6WvjeToRYkyepH9qGsf", + category: "display", + url: "https://fonts.gstatic.com/s/odibeesans/v20/neIPzCSooYAho6WvjeToRYkyepH9qGsf.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aladin", + filename: "ZgNSjPJFPrvJV5f16Sf4pGT2Ng", + category: "display", + url: "https://fonts.gstatic.com/s/aladin/v26/ZgNSjPJFPrvJV5f16Sf4pGT2Ng.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Puritan", + filename: "845YNMgkAJ2VTtIo9JrwRdaI50M", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/puritan/v25/845YNMgkAJ2VTtIo9JrwRdaI50M.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Montez", + filename: "845ZNMk5GoGIX8lm1LDeSd-R_g", + category: "handwriting", + url: "https://fonts.gstatic.com/s/montez/v25/845ZNMk5GoGIX8lm1LDeSd-R_g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Kurenaido", + filename: "3XFsEr0515BK2u6UUptu_gWJZfz22PRLd0U", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zenkurenaido/v19/3XFsEr0515BK2u6UUptu_gWJZfz22PRLd0U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Orelega One", + filename: "3qTpojOggD2XtAdFb-QXZGt61EcYaQ7F", + category: "display", + url: "https://fonts.gstatic.com/s/orelegaone/v14/3qTpojOggD2XtAdFb-QXZGt61EcYaQ7F.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bigshot One", + filename: "u-470qukhRkkO6BD_7cM_gxuUQJBXv_-", + category: "display", + url: "https://fonts.gstatic.com/s/bigshotone/v31/u-470qukhRkkO6BD_7cM_gxuUQJBXv_-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lemon", + filename: "HI_EiYEVKqRMq0jBSZXAQ4-d", + category: "display", + url: "https://fonts.gstatic.com/s/lemon/v19/HI_EiYEVKqRMq0jBSZXAQ4-d.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "League Script", + filename: "CSR54zpSlumSWj9CGVsoBZdeaNNUuOwkC2s", + category: "handwriting", + url: "https://fonts.gstatic.com/s/leaguescript/v30/CSR54zpSlumSWj9CGVsoBZdeaNNUuOwkC2s.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Padauk", + filename: "RrQRboJg-id7OnbBa0_g3LlYbg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/padauk/v17/RrQRboJg-id7OnbBa0_g3LlYbg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Truculenta", + filename: "LhWgMVvBKusVIfNYGi1-QP93NtmZmu8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/truculenta/v27/LhWgMVvBKusVIfNYGi1-QP93NtmZmu8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Wendy One", + filename: "2sDcZGJOipXfgfXV5wgDb2-4C7wFZQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/wendyone/v20/2sDcZGJOipXfgfXV5wgDb2-4C7wFZQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Reddit Sans Condensed", + filename: "m8JMjepOc6WYkkm2Dey9A5QGAQXmuL3va5IFbehGW74OXw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/redditsanscondensed/v5/m8JMjepOc6WYkkm2Dey9A5QGAQXmuL3va5IFbehGW74OXw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Cantora One", + filename: "gyB4hws1JdgnKy56GB_JX6zdZ4vZVbgZ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cantoraone/v20/gyB4hws1JdgnKy56GB_JX6zdZ4vZVbgZ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Dirt", + filename: "DtVmJxC7WLEj1uIXEWAdulwm6gDXvwE", + category: "display", + url: "https://fonts.gstatic.com/s/rubikdirt/v2/DtVmJxC7WLEj1uIXEWAdulwm6gDXvwE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fontdiner Swanky", + filename: "ijwOs4XgRNsiaI5-hcVb4hQgMvCD4uEfKiGvxts", + category: "display", + url: "https://fonts.gstatic.com/s/fontdinerswanky/v24/ijwOs4XgRNsiaI5-hcVb4hQgMvCD4uEfKiGvxts.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "The Girl Next Door", + filename: "pe0zMJCIMIsBjFxqYBIcZ6_OI5oFHCYIV7t7w6bE2A", + category: "handwriting", + url: "https://fonts.gstatic.com/s/thegirlnextdoor/v25/pe0zMJCIMIsBjFxqYBIcZ6_OI5oFHCYIV7t7w6bE2A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gentium Plus", + filename: "Iurd6Ytw-oSPaZ00r2bNe8VpjJtM6G0t9w", + category: "serif", + url: "https://fonts.gstatic.com/s/gentiumplus/v2/Iurd6Ytw-oSPaZ00r2bNe8VpjJtM6G0t9w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tenali Ramakrishna", + filename: "raxgHj6Yt9gAN3LLKs0BZVMo8jmwn1-8KJXqUFFvtA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tenaliramakrishna/v14/raxgHj6Yt9gAN3LLKs0BZVMo8jmwn1-8KJXqUFFvtA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Lao Looped", + filename: "a8IGNpbwKmHXpgXbMIsbSMP7-3U72qUOX4IKoU4xzO9n", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslaolooped/v10/a8IGNpbwKmHXpgXbMIsbSMP7-3U72qUOX4IKoU4xzO9n.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Shippori Antique", + filename: "-F6qfid3KC8pdMyzR0qRyFUht11v8ldPg-IUDNg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/shipporiantique/v11/-F6qfid3KC8pdMyzR0qRyFUht11v8ldPg-IUDNg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Redressed", + filename: "x3dickHUbrmJ7wMy9MsBfPACvy_1BA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/redressed/v32/x3dickHUbrmJ7wMy9MsBfPACvy_1BA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Loved by the King", + filename: "Gw6gwdP76VDVJNXerebZxUMeRXUF2PiNlXFu2R64", + category: "handwriting", + url: "https://fonts.gstatic.com/s/lovedbytheking/v24/Gw6gwdP76VDVJNXerebZxUMeRXUF2PiNlXFu2R64.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Almendra", + filename: "H4ckBXKAlMnTn0CskyY6wr-wg763", + category: "serif", + url: "https://fonts.gstatic.com/s/almendra/v28/H4ckBXKAlMnTn0CskyY6wr-wg763.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Numans", + filename: "SlGRmQmGupYAfH8IYRggiHVqaQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/numans/v16/SlGRmQmGupYAfH8IYRggiHVqaQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Doppio One", + filename: "Gg8wN5gSaBfyBw2MqCh-lgshKGpe5Fg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/doppioone/v14/Gg8wN5gSaBfyBw2MqCh-lgshKGpe5Fg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Antique Soft", + filename: "DtV4JwqzSL1q_KwnEWMc_3xfgW6ihwBmkui5HNg", + category: "serif", + url: "https://fonts.gstatic.com/s/zenantiquesoft/v14/DtV4JwqzSL1q_KwnEWMc_3xfgW6ihwBmkui5HNg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cambo", + filename: "IFSqHeNEk8FJk416ok7xkPm8", + category: "serif", + url: "https://fonts.gstatic.com/s/cambo/v19/IFSqHeNEk8FJk416ok7xkPm8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gamja Flower", + filename: "6NUR8FiKJg-Pa0rM6uN40Z4kyf9Fdty2ew", + category: "handwriting", + url: "https://fonts.gstatic.com/s/gamjaflower/v26/6NUR8FiKJg-Pa0rM6uN40Z4kyf9Fdty2ew.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Averia Sans Libre", + filename: "ga6XaxZG_G5OvCf_rt7FH3B6BHLMEeVJGIMYDo_8", + category: "display", + url: "https://fonts.gstatic.com/s/averiasanslibre/v20/ga6XaxZG_G5OvCf_rt7FH3B6BHLMEeVJGIMYDo_8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sunshiney", + filename: "LDIwapGTLBwsS-wT4vcgE8moUePWkg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sunshiney/v24/LDIwapGTLBwsS-wT4vcgE8moUePWkg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Freehand", + filename: "cIf-Ma5eqk01VjKTgAmBTmUOmZJk", + category: "display", + url: "https://fonts.gstatic.com/s/freehand/v34/cIf-Ma5eqk01VjKTgAmBTmUOmZJk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Just Me Again Down Here", + filename: "MwQmbgXtz-Wc6RUEGNMc0QpRrfUh2hSdBBMoAuwHvqDwc_fg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/justmeagaindownhere/v25/MwQmbgXtz-Wc6RUEGNMc0QpRrfUh2hSdBBMoAuwHvqDwc_fg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Khmer", + filename: "ijwNs5roRME5LLRxjsRb-gssOenawssxJii23w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskhmer/v29/ijwNs5roRME5LLRxjsRb-gssOenawssxJii23w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Amethysta", + filename: "rP2Fp2K15kgb_F3ibfWIGDWCBl0O8Q", + category: "serif", + url: "https://fonts.gstatic.com/s/amethysta/v17/rP2Fp2K15kgb_F3ibfWIGDWCBl0O8Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Martian Mono", + filename: "2V0aKIcADoYhV6w87xrTKjsiAqPJZ2Xx8w", + category: "monospace", + url: "https://fonts.gstatic.com/s/martianmono/v6/2V0aKIcADoYhV6w87xrTKjsiAqPJZ2Xx8w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Meow Script", + filename: "0FlQVPqanlaJrtr8AnJ0ESch0_0CfDf1", + category: "handwriting", + url: "https://fonts.gstatic.com/s/meowscript/v6/0FlQVPqanlaJrtr8AnJ0ESch0_0CfDf1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kaisei Tokumin", + filename: "Gg8sN5wdZg7xCwuMsylww2ZiQkJf1l0pj946", + category: "serif", + url: "https://fonts.gstatic.com/s/kaiseitokumin/v11/Gg8sN5wdZg7xCwuMsylww2ZiQkJf1l0pj946.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Spicy Rice", + filename: "uK_24rSEd-Uqwk4jY1RyGv-2WkowRcc", + category: "display", + url: "https://fonts.gstatic.com/s/spicyrice/v28/uK_24rSEd-Uqwk4jY1RyGv-2WkowRcc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cherry Cream Soda", + filename: "UMBIrOxBrW6w2FFyi9paG0fdVdRciTd6Cd47DJ7G", + category: "display", + url: "https://fonts.gstatic.com/s/cherrycreamsoda/v21/UMBIrOxBrW6w2FFyi9paG0fdVdRciTd6Cd47DJ7G.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Road Rage", + filename: "6NUU8F2fKAOBKjjr4ekvtMYAwdRZfw", + category: "display", + url: "https://fonts.gstatic.com/s/roadrage/v9/6NUU8F2fKAOBKjjr4ekvtMYAwdRZfw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Scheherazade New", + filename: "4UaZrFhTvxVnHDvUkUiHg8jprP4DCwNsOl4p5Is", + category: "serif", + url: "https://fonts.gstatic.com/s/scheherazadenew/v20/4UaZrFhTvxVnHDvUkUiHg8jprP4DCwNsOl4p5Is.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Salsa", + filename: "gNMKW3FiRpKj-imY8ncKEZez", + category: "display", + url: "https://fonts.gstatic.com/s/salsa/v23/gNMKW3FiRpKj-imY8ncKEZez.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Buda", + filename: "GFDqWAN8mnyIJSSrG7UBr7pZKA0", + category: "display", + url: "https://fonts.gstatic.com/s/buda/v31/GFDqWAN8mnyIJSSrG7UBr7pZKA0.ttf", + weight: 300, + isVariable: false + }, + { + displayName: "Codystar", + filename: "FwZY7-Q1xVk-40qxOt6A4sijpFu_", + category: "display", + url: "https://fonts.gstatic.com/s/codystar/v19/FwZY7-Q1xVk-40qxOt6A4sijpFu_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Special Gothic", + filename: "1q2fY5WcG0Fg_v0fHc8BvIZ252ThVIGpgnxL", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/specialgothic/v3/1q2fY5WcG0Fg_v0fHc8BvIZ252ThVIGpgnxL.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rubik Bubbles", + filename: "JIA1UVdwbHFJtwA7Us1BPFbRNTENfDxyRXI", + category: "display", + url: "https://fonts.gstatic.com/s/rubikbubbles/v3/JIA1UVdwbHFJtwA7Us1BPFbRNTENfDxyRXI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hedvig Letters Serif", + filename: "OD5cuN2I2mekHmyoU1Kj2AXOd5_7v7gIDk_3iBYVfsc4", + category: "serif", + url: "https://fonts.gstatic.com/s/hedviglettersserif/v4/OD5cuN2I2mekHmyoU1Kj2AXOd5_7v7gIDk_3iBYVfsc4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Artifika", + filename: "VEMyRoxzronptCuxu6Wt5jDtreOL", + category: "serif", + url: "https://fonts.gstatic.com/s/artifika/v22/VEMyRoxzronptCuxu6Wt5jDtreOL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lily Script One", + filename: "LhW9MV7ZMfIPdMxeBjBvFN8SXLS4gsSjQNsRMg", + category: "display", + url: "https://fonts.gstatic.com/s/lilyscriptone/v16/LhW9MV7ZMfIPdMxeBjBvFN8SXLS4gsSjQNsRMg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jomhuria", + filename: "Dxxp8j-TMXf-llKur2b1MOGbC3Dh", + category: "display", + url: "https://fonts.gstatic.com/s/jomhuria/v22/Dxxp8j-TMXf-llKur2b1MOGbC3Dh.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hi Melody", + filename: "46ktlbP8Vnz0pJcqCTbEf29E31BBGA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/himelody/v19/46ktlbP8Vnz0pJcqCTbEf29E31BBGA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Baumans", + filename: "-W_-XJj9QyTd3QfpR_oyaksqY5Q", + category: "display", + url: "https://fonts.gstatic.com/s/baumans/v18/-W_-XJj9QyTd3QfpR_oyaksqY5Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Unkempt", + filename: "2EbnL-Z2DFZue0DSSYYf8z2Yt_c", + category: "display", + url: "https://fonts.gstatic.com/s/unkempt/v22/2EbnL-Z2DFZue0DSSYYf8z2Yt_c.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oleo Script Swash Caps", + filename: "Noaj6Vb-w5SFbTTAsZP_7JkCS08K-jCzDn_HMXquSY0Hg90", + category: "display", + url: "https://fonts.gstatic.com/s/oleoscriptswashcaps/v14/Noaj6Vb-w5SFbTTAsZP_7JkCS08K-jCzDn_HMXquSY0Hg90.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fenix", + filename: "XoHo2YL_S7-g5ostKzAFvs8o", + category: "serif", + url: "https://fonts.gstatic.com/s/fenix/v21/XoHo2YL_S7-g5ostKzAFvs8o.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Grape Nuts", + filename: "syk2-yF4iLM2RfKj4F7k3tLvol2RN1E", + category: "handwriting", + url: "https://fonts.gstatic.com/s/grapenuts/v7/syk2-yF4iLM2RfKj4F7k3tLvol2RN1E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Milonga", + filename: "SZc53FHnIaK9W5kffz3GkUrS8DI", + category: "display", + url: "https://fonts.gstatic.com/s/milonga/v24/SZc53FHnIaK9W5kffz3GkUrS8DI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Averia Gruesa Libre", + filename: "NGSov4nEGEktOaDRKsY-1dhh8eEtIx3ZUmmJw0SLRA8", + category: "display", + url: "https://fonts.gstatic.com/s/averiagruesalibre/v22/NGSov4nEGEktOaDRKsY-1dhh8eEtIx3ZUmmJw0SLRA8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Balthazar", + filename: "d6lKkaajS8Gm4CVQjFEvyRTo39l8hw", + category: "serif", + url: "https://fonts.gstatic.com/s/balthazar/v18/d6lKkaajS8Gm4CVQjFEvyRTo39l8hw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nokora", + filename: "hYkIPuwgTubzaWxQOzoPovZg8Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/nokora/v34/hYkIPuwgTubzaWxQOzoPovZg8Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Holtwood One SC", + filename: "yYLx0hLR0P-3vMFSk1TCq3Txg5B3cbb6LZttyg", + category: "serif", + url: "https://fonts.gstatic.com/s/holtwoodonesc/v23/yYLx0hLR0P-3vMFSk1TCq3Txg5B3cbb6LZttyg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kablammo", + filename: "bWt77fHPcgrhC-J3ld_qU8Z0WH5p", + category: "display", + url: "https://fonts.gstatic.com/s/kablammo/v4/bWt77fHPcgrhC-J3ld_qU8Z0WH5p.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Prociono", + filename: "r05YGLlR-KxAf9GGO8upyDYtStiJ", + category: "serif", + url: "https://fonts.gstatic.com/s/prociono/v28/r05YGLlR-KxAf9GGO8upyDYtStiJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Notable", + filename: "gNMEW3N_SIqx-WX9-HMoFIez5MI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notable/v20/gNMEW3N_SIqx-WX9-HMoFIez5MI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IM Fell Double Pica", + filename: "3XF2EqMq_94s9PeKF7Fg4gOKINyMtZ8rT0S1UL5Ayp0", + category: "serif", + url: "https://fonts.gstatic.com/s/imfelldoublepica/v14/3XF2EqMq_94s9PeKF7Fg4gOKINyMtZ8rT0S1UL5Ayp0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sarpanch", + filename: "hESy6Xt4NCpRuk6Pzh2ARIrX_20n", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sarpanch/v15/hESy6Xt4NCpRuk6Pzh2ARIrX_20n.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gentium Book Plus", + filename: "vEFL2-RHBgUK5fbjKxRpbBtJPyRpofKfdbLOrdPV", + category: "serif", + url: "https://fonts.gstatic.com/s/gentiumbookplus/v1/vEFL2-RHBgUK5fbjKxRpbBtJPyRpofKfdbLOrdPV.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Wire One", + filename: "qFdH35Wah5htUhV75WGiWdrCwwcJ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/wireone/v30/qFdH35Wah5htUhV75WGiWdrCwwcJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vollkorn SC", + filename: "j8_v6-zQ3rXpceZj9cqnVhF5NH-iSq_E", + category: "serif", + url: "https://fonts.gstatic.com/s/vollkornsc/v12/j8_v6-zQ3rXpceZj9cqnVhF5NH-iSq_E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rosarivo", + filename: "PlI-Fl2lO6N9f8HaNAeC2nhMnNy5", + category: "serif", + url: "https://fonts.gstatic.com/s/rosarivo/v24/PlI-Fl2lO6N9f8HaNAeC2nhMnNy5.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "ZCOOL QingKe HuangYou", + filename: "2Eb5L_R5IXJEWhD3AOhSvFC554MOOahI4mRIi_28c8bHWA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zcoolqingkehuangyou/v16/2Eb5L_R5IXJEWhD3AOhSvFC554MOOahI4mRIi_28c8bHWA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shojumaru", + filename: "rax_HiWfutkLLnaKCtlMBBJek0vA8A", + category: "display", + url: "https://fonts.gstatic.com/s/shojumaru/v16/rax_HiWfutkLLnaKCtlMBBJek0vA8A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Charmonman", + filename: "MjQDmiR3vP_nuxDv47jiWJGovLdh6OE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/charmonman/v20/MjQDmiR3vP_nuxDv47jiWJGovLdh6OE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Thai Looped", + filename: "B50RF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3gO6ocWiHvWQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansthailooped/v16/B50RF6pOpWTRcGrhOVJJ3-oPfY7WQuFu5R3gO6ocWiHvWQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Dynalight", + filename: "1Ptsg8LOU_aOmQvTsF4ISotrDfGGxA", + category: "display", + url: "https://fonts.gstatic.com/s/dynalight/v24/1Ptsg8LOU_aOmQvTsF4ISotrDfGGxA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Meetei Mayek", + filename: "HTx9L3QyKieByqY9eZPFweO0be7M21uSphSdnKkpbrJN35k", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmeeteimayek/v20/HTx9L3QyKieByqY9eZPFweO0be7M21uSphSdnKkpbrJN35k.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Special Gothic Expanded One", + filename: "IurO6Zxk74-YaYk1r3HOet4g75ENmBxUmOK61tA0Iu5gn5t-KhUVvQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/specialgothicexpandedone/v2/IurO6Zxk74-YaYk1r3HOet4g75ENmBxUmOK61tA0Iu5gn5t-KhUVvQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Asar", + filename: "sZlLdRyI6TBIXkYQDLlTW6E", + category: "serif", + url: "https://fonts.gstatic.com/s/asar/v24/sZlLdRyI6TBIXkYQDLlTW6E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shanti", + filename: "t5thIREMM4uSDgzgU0ezpKfwzA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/shanti/v25/t5thIREMM4uSDgzgU0ezpKfwzA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Square Peg", + filename: "y83eW48Nzw6ZlUHc-phrBDHrHHfrFPE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/squarepeg/v7/y83eW48Nzw6ZlUHc-phrBDHrHHfrFPE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Whisper", + filename: "q5uHsoqtKftx74K9milCBxxdmYU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/whisper/v7/q5uHsoqtKftx74K9milCBxxdmYU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Gujarati", + filename: "wlpsgx_HC1ti5ViekvcxnhMlCVo3f5p13JpDd6u3AQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansgujarati/v27/wlpsgx_HC1ti5ViekvcxnhMlCVo3f5p13JpDd6u3AQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Madimi One", + filename: "2V0YKIEADpA8U6RygDnZZFQoBoHMd2U", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/madimione/v1/2V0YKIEADpA8U6RygDnZZFQoBoHMd2U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "MonteCarlo", + filename: "buEzpo6-f9X01GadLA0G0CoV_NxLeiw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/montecarlo/v13/buEzpo6-f9X01GadLA0G0CoV_NxLeiw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anta", + filename: "gyBzhwQ3KsIyZFwxPFimIo0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anta/v1/gyBzhwQ3KsIyZFwxPFimIo0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ribeye", + filename: "L0x8DFMxk1MP9R3RvPCmRSlUig", + category: "display", + url: "https://fonts.gstatic.com/s/ribeye/v27/L0x8DFMxk1MP9R3RvPCmRSlUig.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alike Angular", + filename: "3qTrojWunjGQtEBlIcwMbSoI3kM6bB7FKjE", + category: "serif", + url: "https://fonts.gstatic.com/s/alikeangular/v27/3qTrojWunjGQtEBlIcwMbSoI3kM6bB7FKjE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playpen Sans", + filename: "dg4i_pj1p6gXP0gzAZgm4c8NSygiiyyhRw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playpensans/v22/dg4i_pj1p6gXP0gzAZgm4c8NSygiiyyhRw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bellota", + filename: "MwQ2bhXl3_qEpiwAGJJRtGs-lbA", + category: "display", + url: "https://fonts.gstatic.com/s/bellota/v17/MwQ2bhXl3_qEpiwAGJJRtGs-lbA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "New Rocker", + filename: "MwQzbhjp3-HImzcCU_cJkGMViblPtXs", + category: "display", + url: "https://fonts.gstatic.com/s/newrocker/v17/MwQzbhjp3-HImzcCU_cJkGMViblPtXs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Song Myung", + filename: "1cX2aUDWAJH5-EIC7DIhr1GqhcitzeM", + category: "serif", + url: "https://fonts.gstatic.com/s/songmyung/v22/1cX2aUDWAJH5-EIC7DIhr1GqhcitzeM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Suranna", + filename: "gokuH6ztGkFjWe58tBRZT2KmgP0", + category: "serif", + url: "https://fonts.gstatic.com/s/suranna/v15/gokuH6ztGkFjWe58tBRZT2KmgP0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Scope One", + filename: "WBLnrEXKYFlGHrOKmGD1W0_MJMGxiQ", + category: "serif", + url: "https://fonts.gstatic.com/s/scopeone/v15/WBLnrEXKYFlGHrOKmGD1W0_MJMGxiQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Arya", + filename: "ga6CawNG-HJd9Ub1-beqdFE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/arya/v21/ga6CawNG-HJd9Ub1-beqdFE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Delicious Handrawn", + filename: "wlpsgx_NAUNkpmKQifcxkQchDFo3fJ113JpDd6u3AQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/delicioushandrawn/v10/wlpsgx_NAUNkpmKQifcxkQchDFo3fJ113JpDd6u3AQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Metal Mania", + filename: "RWmMoKWb4e8kqMfBUdPFJeXCg6UKDXlq", + category: "display", + url: "https://fonts.gstatic.com/s/metalmania/v23/RWmMoKWb4e8kqMfBUdPFJeXCg6UKDXlq.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Clicker Script", + filename: "raxkHiKPvt8CMH6ZWP8PdlEq72rY2zqUKafv", + category: "handwriting", + url: "https://fonts.gstatic.com/s/clickerscript/v14/raxkHiKPvt8CMH6ZWP8PdlEq72rY2zqUKafv.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sigmar", + filename: "hv-XlzJgIE8a85pUbWY3MTFgVg", + category: "display", + url: "https://fonts.gstatic.com/s/sigmar/v9/hv-XlzJgIE8a85pUbWY3MTFgVg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Platypi", + filename: "bMr3mSGU7pMlaX6-JgKMMMyTQ3o", + category: "serif", + url: "https://fonts.gstatic.com/s/platypi/v6/bMr3mSGU7pMlaX6-JgKMMMyTQ3o.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sour Gummy", + filename: "8AtsGs2gPYuNDii97MjjHLn5jRfZHzM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sourgummy/v3/8AtsGs2gPYuNDii97MjjHLn5jRfZHzM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sancreek", + filename: "pxiHypAnsdxUm159X7D-XV9NEe-K", + category: "display", + url: "https://fonts.gstatic.com/s/sancreek/v27/pxiHypAnsdxUm159X7D-XV9NEe-K.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif HK", + filename: "BngOUXBETWXI6LwlBZGcqL-B5qCr5RCDY_k", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifhk/v12/BngOUXBETWXI6LwlBZGcqL-B5qCr5RCDY_k.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Jersey 10", + filename: "GftH7vZKsggXMf9n_J5X-JLgy1wtSw", + category: "display", + url: "https://fonts.gstatic.com/s/jersey10/v4/GftH7vZKsggXMf9n_J5X-JLgy1wtSw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Carattere", + filename: "4iCv6Kp1b9dXlgt_CkvTt2aMH4V_gg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/carattere/v8/4iCv6Kp1b9dXlgt_CkvTt2aMH4V_gg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mochiy Pop P One", + filename: "Ktk2AKuPeY_td1-h9LayHYWCjAqyN4O3WYZB_sU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mochiypoppone/v12/Ktk2AKuPeY_td1-h9LayHYWCjAqyN4O3WYZB_sU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Frijole", + filename: "uU9PCBUR8oakM2BQ7xPb3vyHmlI", + category: "display", + url: "https://fonts.gstatic.com/s/frijole/v15/uU9PCBUR8oakM2BQ7xPb3vyHmlI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sumana", + filename: "4UaDrE5TqRBjGj-G8Bji76zR4w", + category: "serif", + url: "https://fonts.gstatic.com/s/sumana/v12/4UaDrE5TqRBjGj-G8Bji76zR4w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Modern Antiqua", + filename: "NGStv5TIAUg6Iq_RLNo_2dp1sI1Ea2u0c3Gi", + category: "display", + url: "https://fonts.gstatic.com/s/modernantiqua/v26/NGStv5TIAUg6Iq_RLNo_2dp1sI1Ea2u0c3Gi.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Armenian", + filename: "ZgNOjOZKPa7CHqq0h37c7ReDUubm2SEHHliXap5UrA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansarmenian/v47/ZgNOjOZKPa7CHqq0h37c7ReDUubm2SEHHliXap5UrA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Nova Round", + filename: "flU9Rqquw5UhEnlwTJYTYYfeeetYEBc", + category: "display", + url: "https://fonts.gstatic.com/s/novaround/v23/flU9Rqquw5UhEnlwTJYTYYfeeetYEBc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libre Barcode 128 Text", + filename: "fdNv9tubt3ZEnz1Gu3I4-zppwZ9CWZ16Z0w5cV3Y6M90w4k", + category: "display", + url: "https://fonts.gstatic.com/s/librebarcode128text/v31/fdNv9tubt3ZEnz1Gu3I4-zppwZ9CWZ16Z0w5cV3Y6M90w4k.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Duru Sans", + filename: "xn7iYH8xwmSyTvEV_HOxT_fYdN-WZw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/durusans/v21/xn7iYH8xwmSyTvEV_HOxT_fYdN-WZw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Crushed", + filename: "U9Mc6dym6WXImTlFT1kfuIqyLzA", + category: "display", + url: "https://fonts.gstatic.com/s/crushed/v32/U9Mc6dym6WXImTlFT1kfuIqyLzA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Monofett", + filename: "mFTyWbofw6zc9NtnW43SuRwr0VJ7", + category: "monospace", + url: "https://fonts.gstatic.com/s/monofett/v24/mFTyWbofw6zc9NtnW43SuRwr0VJ7.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Patrick Hand SC", + filename: "0nkwC9f7MfsBiWcLtY65AWDK873ViSi6JQc7Vg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/patrickhandsc/v17/0nkwC9f7MfsBiWcLtY65AWDK873ViSi6JQc7Vg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Qahiri", + filename: "tsssAp1RZy0C_hGuU3Chrnmupw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/qahiri/v11/tsssAp1RZy0C_hGuU3Chrnmupw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lumanosimo", + filename: "K2F0fZBYg_JDSEZHEfO8AoqKAyLzfWo", + category: "handwriting", + url: "https://fonts.gstatic.com/s/lumanosimo/v5/K2F0fZBYg_JDSEZHEfO8AoqKAyLzfWo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Solitreo", + filename: "r05YGLlS5a9KYsyNO8upyDYtStiJ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/solitreo/v2/r05YGLlS5a9KYsyNO8upyDYtStiJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "LINE Seed JP", + filename: "MwQxbh7r89it6QsEXfZb-jMfjZtKpXulTQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lineseedjp/v1/MwQxbh7r89it6QsEXfZb-jMfjZtKpXulTQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shippori Antique B1", + filename: "2Eb7L_JwClR7Zl_UAKZ0mUHw3oMKd40grRFCj9-5Y8Y", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/shipporiantiqueb1/v11/2Eb7L_JwClR7Zl_UAKZ0mUHw3oMKd40grRFCj9-5Y8Y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Train One", + filename: "gyB-hwkiNtc6KnxUVjWHOqbZRY7JVQ", + category: "display", + url: "https://fonts.gstatic.com/s/trainone/v16/gyB-hwkiNtc6KnxUVjWHOqbZRY7JVQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kdam Thmor Pro", + filename: "EJRPQgAzVdcI-Qdvt34jzurnGA7_j89I8ZWb", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kdamthmorpro/v7/EJRPQgAzVdcI-Qdvt34jzurnGA7_j89I8ZWb.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Galada", + filename: "H4cmBXyGmcjXlUX-8iw-4Lqggw", + category: "display", + url: "https://fonts.gstatic.com/s/galada/v21/H4cmBXyGmcjXlUX-8iw-4Lqggw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Convergence", + filename: "rax5HiePvdgXPmmMHcIPYRhasU7Q8Cad", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/convergence/v16/rax5HiePvdgXPmmMHcIPYRhasU7Q8Cad.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bubbler One", + filename: "f0Xy0eqj68ppQV9KBLmAouHH26MPePkt", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bubblerone/v22/f0Xy0eqj68ppQV9KBLmAouHH26MPePkt.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Baloo Bhai 2", + filename: "sZlDdRSL-z1VEWZ4YNA7Y5I3cdTmiH1gFQ", + category: "display", + url: "https://fonts.gstatic.com/s/baloobhai2/v30/sZlDdRSL-z1VEWZ4YNA7Y5I3cdTmiH1gFQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Denk One", + filename: "dg4m_pzhrqcFb2IzROtHpbglShon", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/denkone/v21/dg4m_pzhrqcFb2IzROtHpbglShon.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Swanky and Moo Moo", + filename: "flUlRrKz24IuWVI_WJYTYcqbEsMUZ3kUtbPkR64SYQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/swankyandmoomoo/v24/flUlRrKz24IuWVI_WJYTYcqbEsMUZ3kUtbPkR64SYQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yuji Syuku", + filename: "BngNUXdTV3vO6Lw5ApOPqPfgwqiA-Rk", + category: "serif", + url: "https://fonts.gstatic.com/s/yujisyuku/v8/BngNUXdTV3vO6Lw5ApOPqPfgwqiA-Rk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Orienta", + filename: "PlI9FlK4Jrl5Y9zNeyeo9HRFhcU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/orienta/v16/PlI9FlK4Jrl5Y9zNeyeo9HRFhcU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Castoro Titling", + filename: "buEupouwccj03leTfjUAhEZWlrNqYgckeo9RMw", + category: "display", + url: "https://fonts.gstatic.com/s/castorotitling/v10/buEupouwccj03leTfjUAhEZWlrNqYgckeo9RMw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "UnifrakturCook", + filename: "IurA6Yli8YOdcoky-0PTTdkm56n05Uw13ILXs-h6", + category: "display", + url: "https://fonts.gstatic.com/s/unifrakturcook/v25/IurA6Yli8YOdcoky-0PTTdkm56n05Uw13ILXs-h6.ttf", + weight: 700, + isVariable: false + }, + { + displayName: "Medula One", + filename: "YA9Wr0qb5kjJM6l2V0yukiEqs7GtlvY", + category: "display", + url: "https://fonts.gstatic.com/s/medulaone/v20/YA9Wr0qb5kjJM6l2V0yukiEqs7GtlvY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gulzar", + filename: "Wnz6HAc9eB3HB2ILYTwZqg_MPQ", + category: "serif", + url: "https://fonts.gstatic.com/s/gulzar/v14/Wnz6HAc9eB3HB2ILYTwZqg_MPQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Manuale", + filename: "f0X20eas_8Z-TFZdBPbEw8nG6aY", + category: "serif", + url: "https://fonts.gstatic.com/s/manuale/v31/f0X20eas_8Z-TFZdBPbEw8nG6aY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Life Savers", + filename: "ZXuie1UftKKabUQMgxAal_lrFgpbuNvB", + category: "display", + url: "https://fonts.gstatic.com/s/lifesavers/v23/ZXuie1UftKKabUQMgxAal_lrFgpbuNvB.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Georgian", + filename: "VEMtRpd8s4nv8hG_qOzL7HOAw4nt0Sl_RRe4_Tsdkf4", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifgeorgian/v29/VEMtRpd8s4nv8hG_qOzL7HOAw4nt0Sl_RRe4_Tsdkf4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Dokdo", + filename: "esDf315XNuCBLxLo4NaMlKcH", + category: "display", + url: "https://fonts.gstatic.com/s/dokdo/v23/esDf315XNuCBLxLo4NaMlKcH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Maiden Orange", + filename: "kJE1BuIX7AUmhi2V4m08kb1XjOZdCZS8FY8", + category: "serif", + url: "https://fonts.gstatic.com/s/maidenorange/v32/kJE1BuIX7AUmhi2V4m08kb1XjOZdCZS8FY8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zhi Mang Xing", + filename: "f0Xw0ey79sErYFtWQ9a2rq-g0actfektIJ0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/zhimangxing/v19/f0Xw0ey79sErYFtWQ9a2rq-g0actfektIJ0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Baloo Tammudu 2", + filename: "1Pt2g8TIS_SAmkLguUdFP8UaJcK-xXEW6aGXHw", + category: "display", + url: "https://fonts.gstatic.com/s/balootammudu2/v27/1Pt2g8TIS_SAmkLguUdFP8UaJcK-xXEW6aGXHw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sometype Mono", + filename: "70lVu745KGk_R3uxyq0WrROhGpOrQEdC7m8", + category: "monospace", + url: "https://fonts.gstatic.com/s/sometypemono/v4/70lVu745KGk_R3uxyq0WrROhGpOrQEdC7m8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "M PLUS 1 Code", + filename: "ypvfbXOOx2xFpzmYJS3N2_J2nhhYtUb0gZk", + category: "monospace", + url: "https://fonts.gstatic.com/s/mplus1code/v16/ypvfbXOOx2xFpzmYJS3N2_J2nhhYtUb0gZk.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Timmana", + filename: "6xKvdShfL9yK-rvpCmvbKHwJUOM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/timmana/v14/6xKvdShfL9yK-rvpCmvbKHwJUOM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Borel", + filename: "6qLOKZsftAPisgshYyMnOjwE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/borel/v10/6qLOKZsftAPisgshYyMnOjwE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aoboshi One", + filename: "Gg8xN5kXaAXtHQrFxwl10ysLBmZX_UEg", + category: "serif", + url: "https://fonts.gstatic.com/s/aoboshione/v13/Gg8xN5kXaAXtHQrFxwl10ysLBmZX_UEg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Carme", + filename: "ptRHTiWdbvZIDOjGxLNrxfbZ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/carme/v17/ptRHTiWdbvZIDOjGxLNrxfbZ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Peralta", + filename: "hYkJPu0-RP_9d3kRGxAhrv956B8", + category: "serif", + url: "https://fonts.gstatic.com/s/peralta/v21/hYkJPu0-RP_9d3kRGxAhrv956B8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vampiro One", + filename: "gokqH6DoDl5yXvJytFsdLkqnsvhIor3K", + category: "display", + url: "https://fonts.gstatic.com/s/vampiroone/v19/gokqH6DoDl5yXvJytFsdLkqnsvhIor3K.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IM Fell Great Primer", + filename: "bx6aNwSJtayYxOkbYFsT6hMsLzX7u85rJorXvDo3SQY1", + category: "serif", + url: "https://fonts.gstatic.com/s/imfellgreatprimer/v21/bx6aNwSJtayYxOkbYFsT6hMsLzX7u85rJorXvDo3SQY1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Macondo Swash Caps", + filename: "6NUL8EaAJgGKZA7lpt941Z9s6ZYgDq6Oekoa_mm5bA", + category: "display", + url: "https://fonts.gstatic.com/s/macondoswashcaps/v26/6NUL8EaAJgGKZA7lpt941Z9s6ZYgDq6Oekoa_mm5bA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Trade Winds", + filename: "AYCPpXPpYNIIT7h8-QenM3Jq7PKP5Z_G", + category: "display", + url: "https://fonts.gstatic.com/s/tradewinds/v18/AYCPpXPpYNIIT7h8-QenM3Jq7PKP5Z_G.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nata Sans", + filename: "1q2EY5KBClBit88SU_tOyMQbjEX5fw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/natasans/v1/1q2EY5KBClBit88SU_tOyMQbjEX5fw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Abyssinica SIL", + filename: "oY1H8ezOqK7iI3rK_45WKoc8J6UZBFOVAXuI", + category: "serif", + url: "https://fonts.gstatic.com/s/abyssinicasil/v9/oY1H8ezOqK7iI3rK_45WKoc8J6UZBFOVAXuI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Reggae One", + filename: "7r3DqX5msMIkeuwJwOJt_a5L5uH-mts", + category: "display", + url: "https://fonts.gstatic.com/s/reggaeone/v19/7r3DqX5msMIkeuwJwOJt_a5L5uH-mts.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Thai", + filename: "k3kIo80MPvpLmixYH7euCxWpSMut8SX6-q2CGg", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifthai/v28/k3kIo80MPvpLmixYH7euCxWpSMut8SX6-q2CGg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Hubot Sans", + filename: "pe0rMIiULYxOvxVLbVwhIteQB9Zra1U", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hubotsans/v5/pe0rMIiULYxOvxVLbVwhIteQB9Zra1U.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mirza", + filename: "co3ImWlikiN5EurdKMewsrvI", + category: "serif", + url: "https://fonts.gstatic.com/s/mirza/v19/co3ImWlikiN5EurdKMewsrvI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IM Fell French Canon", + filename: "-F6ufiNtDWYfYc-tDiyiw08rrghJszkK6coVPt1ozoPz", + category: "serif", + url: "https://fonts.gstatic.com/s/imfellfrenchcanon/v21/-F6ufiNtDWYfYc-tDiyiw08rrghJszkK6coVPt1ozoPz.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Akatab", + filename: "VuJwdNrK3Z7gqJEPWIz5NIh-YA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/akatab/v9/VuJwdNrK3Z7gqJEPWIz5NIh-YA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stick No Bills", + filename: "bWth7ffXZwHuAa9Uld-oEK4QKkZo1w-1YxiC", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sticknobills/v17/bWth7ffXZwHuAa9Uld-oEK4QKkZo1w-1YxiC.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Fuggles", + filename: "k3kQo8UEJOlD1hpOTd7iL0nAMaM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/fuggles/v14/k3kQo8UEJOlD1hpOTd7iL0nAMaM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Momo Signature", + filename: "RrQJbop99C51b06IDAuFoM0yCqcoMcmJPECN", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/momosignature/v2/RrQJbop99C51b06IDAuFoM0yCqcoMcmJPECN.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Harmattan", + filename: "goksH6L2DkFvVvRp9XpTS0CjkP1Yog", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/harmattan/v24/goksH6L2DkFvVvRp9XpTS0CjkP1Yog.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Emilys Candy", + filename: "2EbgL-1mD1Rnb0OGKudbk0y5r9xrX84JjA", + category: "display", + url: "https://fonts.gstatic.com/s/emilyscandy/v21/2EbgL-1mD1Rnb0OGKudbk0y5r9xrX84JjA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Comic Relief", + filename: "BCauqZkHrvL55SZ8uaEhHMY2XBJhDgs-Kg", + category: "display", + url: "https://fonts.gstatic.com/s/comicrelief/v2/BCauqZkHrvL55SZ8uaEhHMY2XBJhDgs-Kg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Elsie Swash Caps", + filename: "845DNN8xGZyVX5MVo_upKf7KnjK0ferVKGWsUo8", + category: "display", + url: "https://fonts.gstatic.com/s/elsieswashcaps/v25/845DNN8xGZyVX5MVo_upKf7KnjK0ferVKGWsUo8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Miniver", + filename: "eLGcP-PxIg-5H0vC770Cy8r8fWA", + category: "display", + url: "https://fonts.gstatic.com/s/miniver/v27/eLGcP-PxIg-5H0vC770Cy8r8fWA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chicle", + filename: "lJwG-pw9i2dqU-BDyWKuobYSxw", + category: "display", + url: "https://fonts.gstatic.com/s/chicle/v27/lJwG-pw9i2dqU-BDyWKuobYSxw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Benne", + filename: "L0xzDFAhn18E6Vjxlt6qTDBN", + category: "serif", + url: "https://fonts.gstatic.com/s/benne/v24/L0xzDFAhn18E6Vjxlt6qTDBN.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Habibi", + filename: "CSR-4zFWkuqcTTNCShJeZOYySQ", + category: "serif", + url: "https://fonts.gstatic.com/s/habibi/v22/CSR-4zFWkuqcTTNCShJeZOYySQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gayathri", + filename: "MCoQzAb429DbBilWLIA48J_wBugA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gayathri/v20/MCoQzAb429DbBilWLIA48J_wBugA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nova Flat", + filename: "QdVUSTc-JgqpytEbVebEuStkm20oJA", + category: "display", + url: "https://fonts.gstatic.com/s/novaflat/v26/QdVUSTc-JgqpytEbVebEuStkm20oJA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Phudu", + filename: "0FlaVPSHk0ya-6mfWh8MZB8g", + category: "display", + url: "https://fonts.gstatic.com/s/phudu/v6/0FlaVPSHk0ya-6mfWh8MZB8g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gugi", + filename: "A2BVn5dXywshVA6A9DEfgqM", + category: "display", + url: "https://fonts.gstatic.com/s/gugi/v21/A2BVn5dXywshVA6A9DEfgqM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Freckle Face", + filename: "AMOWz4SXrmKHCvXTohxY-YI0U1K2w9lb4g", + category: "display", + url: "https://fonts.gstatic.com/s/freckleface/v16/AMOWz4SXrmKHCvXTohxY-YI0U1K2w9lb4g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Moon Dance", + filename: "WBLgrEbUbFlYW9ekmGawe2XiKMiokE4", + category: "handwriting", + url: "https://fonts.gstatic.com/s/moondance/v8/WBLgrEbUbFlYW9ekmGawe2XiKMiokE4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Londrina Outline", + filename: "C8c44dM8vmb14dfsZxhetg3pDH-SfuoxrSKMDvI", + category: "display", + url: "https://fonts.gstatic.com/s/londrinaoutline/v29/C8c44dM8vmb14dfsZxhetg3pDH-SfuoxrSKMDvI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Genos", + filename: "SlGemQqPqpUOYSwoSzYsgWxz", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/genos/v16/SlGemQqPqpUOYSwoSzYsgWxz.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Eagle Lake", + filename: "ptRMTiqbbuNJDOiKj9wG5O7yKQNute8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eaglelake/v26/ptRMTiqbbuNJDOiKj9wG5O7yKQNute8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ceviche One", + filename: "gyB4hws1IcA6JzR-GB_JX6zdZ4vZVbgZ", + category: "display", + url: "https://fonts.gstatic.com/s/cevicheone/v17/gyB4hws1IcA6JzR-GB_JX6zdZ4vZVbgZ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Caesar Dressing", + filename: "yYLx0hLa3vawqtwdswbotmK4vrR3cbb6LZttyg", + category: "display", + url: "https://fonts.gstatic.com/s/caesardressing/v22/yYLx0hLa3vawqtwdswbotmK4vrR3cbb6LZttyg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IM Fell DW Pica SC", + filename: "0ybjGCAu5PfqkvtGVU15aBhXz3EUrnTW-BiKEUiBGA", + category: "serif", + url: "https://fonts.gstatic.com/s/imfelldwpicasc/v21/0ybjGCAu5PfqkvtGVU15aBhXz3EUrnTW-BiKEUiBGA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Raleway Dots", + filename: "6NUR8FifJg6AfQvzpshgwJ8kyf9Fdty2ew", + category: "display", + url: "https://fonts.gstatic.com/s/ralewaydots/v19/6NUR8FifJg6AfQvzpshgwJ8kyf9Fdty2ew.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Spline Sans Mono", + filename: "R70BjzAei_CDNLfgZxrW6wrZOF2Wb5WTmW2a6l0", + category: "monospace", + url: "https://fonts.gstatic.com/s/splinesansmono/v13/R70BjzAei_CDNLfgZxrW6wrZOF2Wb5WTmW2a6l0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Homenaje", + filename: "FwZY7-Q-xVAi_l-6Ld6A4sijpFu_", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/homenaje/v17/FwZY7-Q-xVAi_l-6Ld6A4sijpFu_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rhodium Libre", + filename: "1q2AY5adA0tn_ukeHcQHqpx6pETLeo2gm2U", + category: "serif", + url: "https://fonts.gstatic.com/s/rhodiumlibre/v21/1q2AY5adA0tn_ukeHcQHqpx6pETLeo2gm2U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Akaya Kanadaka", + filename: "N0bM2S5CPO5oOQqvazoRRb-8-PfRS5VBBSSF", + category: "display", + url: "https://fonts.gstatic.com/s/akayakanadaka/v18/N0bM2S5CPO5oOQqvazoRRb-8-PfRS5VBBSSF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Smythe", + filename: "MwQ3bhT01--coT1BOLh_uGInjA", + category: "display", + url: "https://fonts.gstatic.com/s/smythe/v24/MwQ3bhT01--coT1BOLh_uGInjA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Asset", + filename: "SLXGc1na-mM4cWImRJqExst1", + category: "display", + url: "https://fonts.gstatic.com/s/asset/v30/SLXGc1na-mM4cWImRJqExst1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Thaana", + filename: "C8c44dM-vnz-s-3jaEsxlxHkBH-WfuoxrSKMDvI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansthaana/v26/C8c44dM-vnz-s-3jaEsxlxHkBH-WfuoxrSKMDvI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "The Nautigal", + filename: "VdGZAZ8ZH51Lvng9fQV2bfKr5wVk09Se5Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/thenautigal/v8/VdGZAZ8ZH51Lvng9fQV2bfKr5wVk09Se5Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tiny5", + filename: "KFOpCnmCvdGT7hw-z0hHAi88", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tiny5/v3/KFOpCnmCvdGT7hw-z0hHAi88.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Lao", + filename: "bx6DNx2Ol_ixgdYWLm9BwxM3L2Wj1qsPTKs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslao/v33/bx6DNx2Ol_ixgdYWLm9BwxM3L2Wj1qsPTKs.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sail", + filename: "DPEjYwiBxwYJFBTDADYAbvw", + category: "display", + url: "https://fonts.gstatic.com/s/sail/v17/DPEjYwiBxwYJFBTDADYAbvw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Montaga", + filename: "H4cnBX2Ml8rCkEO_0gYQ7LO5mqc", + category: "serif", + url: "https://fonts.gstatic.com/s/montaga/v14/H4cnBX2Ml8rCkEO_0gYQ7LO5mqc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Khojki", + filename: "I_u0MoOduATTei9aP90ctmPGxP2rBL7HwJfIeG71", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifkhojki/v13/I_u0MoOduATTei9aP90ctmPGxP2rBL7HwJfIeG71.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ledger", + filename: "j8_q6-HK1L3if_sxm8DwHTBhHw", + category: "serif", + url: "https://fonts.gstatic.com/s/ledger/v17/j8_q6-HK1L3if_sxm8DwHTBhHw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nova Slim", + filename: "Z9XUDmZNQAuem8jyZcn-yMOInrib9Q", + category: "display", + url: "https://fonts.gstatic.com/s/novaslim/v26/Z9XUDmZNQAuem8jyZcn-yMOInrib9Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Storm", + filename: "eLGYP-_uPgO5Ag7ju9JaouL9T2Xh9NQk", + category: "display", + url: "https://fonts.gstatic.com/s/rubikstorm/v1/eLGYP-_uPgO5Ag7ju9JaouL9T2Xh9NQk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Pavanam", + filename: "BXRrvF_aiezLh0xPDOtQ9Wf0QcE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/pavanam/v13/BXRrvF_aiezLh0xPDOtQ9Wf0QcE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Inclusive Sans", + filename: "0nkxC9biPuwflXcJ46P4PGWE0971owa2LB4i", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/inclusivesans/v4/0nkxC9biPuwflXcJ46P4PGWE0971owa2LB4i.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Tauri", + filename: "TwMA-IISS0AM3IpVWHU_TBqO", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tauri/v20/TwMA-IISS0AM3IpVWHU_TBqO.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Faculty Glyphic", + filename: "RrQIbot2-iBvI2mYSyKIrcgoBuQIG-eFNVmULg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/facultyglyphic/v4/RrQIbot2-iBvI2mYSyKIrcgoBuQIG-eFNVmULg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Malayalam", + filename: "JIAsUU5sdmdP_HMcVcZFcH7DeVBeGVgSMFM9UJWbN_un", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifmalayalam/v32/JIAsUU5sdmdP_HMcVcZFcH7DeVBeGVgSMFM9UJWbN_un.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bona Nova", + filename: "B50NF7ZCpX7fcHfvIUBJi6hqHK-CLA", + category: "serif", + url: "https://fonts.gstatic.com/s/bonanova/v12/B50NF7ZCpX7fcHfvIUBJi6hqHK-CLA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Overlock SC", + filename: "1cX3aUHKGZrstGAY8nwVzHGAq8Sk1PoH", + category: "display", + url: "https://fonts.gstatic.com/s/overlocksc/v25/1cX3aUHKGZrstGAY8nwVzHGAq8Sk1PoH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IM Fell Double Pica SC", + filename: "neIazDmuiMkFo6zj_sHpQ8teNbWlwBB_hXjJ4Y0Eeru2dGg", + category: "serif", + url: "https://fonts.gstatic.com/s/imfelldoublepicasc/v21/neIazDmuiMkFo6zj_sHpQ8teNbWlwBB_hXjJ4Y0Eeru2dGg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Birthstone Bounce", + filename: "ga6XaxZF43lIvTWrktHOTBJZGH7dEeVJGIMYDo_8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/birthstonebounce/v13/ga6XaxZF43lIvTWrktHOTBJZGH7dEeVJGIMYDo_8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gwendolyn", + filename: "qkBXXvoO_M3CSss-d7ee5JRLkAXbMQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/gwendolyn/v9/qkBXXvoO_M3CSss-d7ee5JRLkAXbMQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Luxurious Script", + filename: "ahcCv9e7yydulT32KZ0rBIoD7DzMg0rOby1JtYk", + category: "handwriting", + url: "https://fonts.gstatic.com/s/luxuriousscript/v9/ahcCv9e7yydulT32KZ0rBIoD7DzMg0rOby1JtYk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Odor Mean Chey", + filename: "raxkHiKDttkTe1aOGcJMR1A_4mrY2zqUKafv", + category: "serif", + url: "https://fonts.gstatic.com/s/odormeanchey/v31/raxkHiKDttkTe1aOGcJMR1A_4mrY2zqUKafv.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Miltonian Tattoo", + filename: "EvOUzBRL0o0kCxF-lcMCQxlpVsA_FwP8MDBku-s", + category: "display", + url: "https://fonts.gstatic.com/s/miltoniantattoo/v34/EvOUzBRL0o0kCxF-lcMCQxlpVsA_FwP8MDBku-s.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ysabeau Office", + filename: "LDIrapaZKhM9RuQIp8FmdYrPPMLOub458jGL", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ysabeauoffice/v4/LDIrapaZKhM9RuQIp8FmdYrPPMLOub458jGL.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gemunu Libre", + filename: "X7ni4bQ6Cfy7jKGXVE_YlqnBGiL1539rvw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gemunulibre/v18/X7ni4bQ6Cfy7jKGXVE_YlqnBGiL1539rvw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Imbue", + filename: "RLpxK5P16Ki3fWJoxzUobkvv", + category: "serif", + url: "https://fonts.gstatic.com/s/imbue/v29/RLpxK5P16Ki3fWJoxzUobkvv.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Kavivanar", + filename: "o-0IIpQgyXYSwhxP7_Jb4j5Ba_2c7A", + category: "handwriting", + url: "https://fonts.gstatic.com/s/kavivanar/v22/o-0IIpQgyXYSwhxP7_Jb4j5Ba_2c7A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Khmer", + filename: "MjQImit_vPPwpF-BpN2EeYmD", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/khmer/v38/MjQImit_vPPwpF-BpN2EeYmD.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sulphur Point", + filename: "RLp5K5vv8KaycDcazWFPBj2aRfkSu6EuTHo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sulphurpoint/v16/RLp5K5vv8KaycDcazWFPBj2aRfkSu6EuTHo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stylish", + filename: "m8JSjfhPYriQkk7-fo35dLxEdmo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/stylish/v25/m8JSjfhPYriQkk7-fo35dLxEdmo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "East Sea Dokdo", + filename: "xfuo0Wn2V2_KanASqXSZp22m05_aGavYS18y", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eastseadokdo/v26/xfuo0Wn2V2_KanASqXSZp22m05_aGavYS18y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zilla Slab Highlight", + filename: "gNMbW2BrTpK8-inLtBJgMMfbm6uNVDvRxhtIY2DwSXlM", + category: "serif", + url: "https://fonts.gstatic.com/s/zillaslabhighlight/v21/gNMbW2BrTpK8-inLtBJgMMfbm6uNVDvRxhtIY2DwSXlM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lavishly Yours", + filename: "jizDREVIvGwH5OjiZmX9r5z_WxUY0TY7ikbI", + category: "handwriting", + url: "https://fonts.gstatic.com/s/lavishlyyours/v7/jizDREVIvGwH5OjiZmX9r5z_WxUY0TY7ikbI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu TAS Beginner", + filename: "ZXu9e04WubHfGVY-1TcNg7AFUmshmcPqoeRWfbs", + category: "handwriting", + url: "https://fonts.gstatic.com/s/edutasbeginner/v5/ZXu9e04WubHfGVY-1TcNg7AFUmshmcPqoeRWfbs.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "IM Fell Great Primer SC", + filename: "ga6daxBOxyt6sCqz3fjZCTFCTUDMHagsQKdDTLf9BXz0s8FG", + category: "serif", + url: "https://fonts.gstatic.com/s/imfellgreatprimersc/v21/ga6daxBOxyt6sCqz3fjZCTFCTUDMHagsQKdDTLf9BXz0s8FG.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mohave", + filename: "7cHpv4ksjJunKqMPC8E46HsxnA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mohave/v13/7cHpv4ksjJunKqMPC8E46HsxnA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Serif Kannada", + filename: "v6-JGZHLJFKIhClqUYqXDiWqpxQxWSPyUIQDIsKCHg", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifkannada/v30/v6-JGZHLJFKIhClqUYqXDiWqpxQxWSPyUIQDIsKCHg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Fascinate", + filename: "z7NWdRrufC8XJK0IIEli1LbQRPyNrw", + category: "display", + url: "https://fonts.gstatic.com/s/fascinate/v23/z7NWdRrufC8XJK0IIEli1LbQRPyNrw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Symbols 2", + filename: "I_uyMoGduATTei9eI8daxVHDyfisHr71ypPqfX71-AI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssymbols2/v25/I_uyMoGduATTei9eI8daxVHDyfisHr71ypPqfX71-AI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alkalami", + filename: "zOL_4pfDmqRL95WXi5eLw8BMuvhH", + category: "serif", + url: "https://fonts.gstatic.com/s/alkalami/v8/zOL_4pfDmqRL95WXi5eLw8BMuvhH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bitcount Single", + filename: "or36Q6T72-iP2RY6OLSkb95a817GhmARZeDtag", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountsingle/v3/or36Q6T72-iP2RY6OLSkb95a817GhmARZeDtag.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Baloo Bhaina 2", + filename: "qWczB6yyq4P9Adr3RtoX1q6yShz7mDUoupoI", + category: "display", + url: "https://fonts.gstatic.com/s/baloobhaina2/v29/qWczB6yyq4P9Adr3RtoX1q6yShz7mDUoupoI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rubik Doodle Shadow", + filename: "rP2bp3im_k8G_wTVdvvMdHqmXTR3lEaLyKuZ3KOY7Gw", + category: "display", + url: "https://fonts.gstatic.com/s/rubikdoodleshadow/v1/rP2bp3im_k8G_wTVdvvMdHqmXTR3lEaLyKuZ3KOY7Gw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mooli", + filename: "-F6_fjJpLyk1bYPBBG7YpzlJ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mooli/v1/-F6_fjJpLyk1bYPBBG7YpzlJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stalemate", + filename: "taiIGmZ_EJq97-UfkZRpuqSs8ZQpaQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/stalemate/v24/taiIGmZ_EJq97-UfkZRpuqSs8ZQpaQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sono", + filename: "aFTb7PNiY3U2EKzdohWxGYU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sono/v12/aFTb7PNiY3U2EKzdohWxGYU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Atomic Age", + filename: "f0Xz0eug6sdmRFkYZZGL58Ht9a8GYeA", + category: "display", + url: "https://fonts.gstatic.com/s/atomicage/v29/f0Xz0eug6sdmRFkYZZGL58Ht9a8GYeA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rationale", + filename: "9XUnlJ92n0_JFxHIfHcsdlFMzLC2Zw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rationale/v30/9XUnlJ92n0_JFxHIfHcsdlFMzLC2Zw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Astloch", + filename: "TuGRUVJ8QI5GSeUjq9wRzMtkH1Q", + category: "display", + url: "https://fonts.gstatic.com/s/astloch/v27/TuGRUVJ8QI5GSeUjq9wRzMtkH1Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Grenze", + filename: "O4ZTFGb7hR12Bxq3_2gnmgwKlg", + category: "serif", + url: "https://fonts.gstatic.com/s/grenze/v16/O4ZTFGb7hR12Bxq3_2gnmgwKlg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IM Fell French Canon SC", + filename: "FBVmdCru5-ifcor2bgq9V89khWcmQghEURY7H3c0UBCVIVqH", + category: "serif", + url: "https://fonts.gstatic.com/s/imfellfrenchcanonsc/v23/FBVmdCru5-ifcor2bgq9V89khWcmQghEURY7H3c0UBCVIVqH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yomogi", + filename: "VuJwdNrS2ZL7rpoPWIz5NIh-YA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/yomogi/v13/VuJwdNrS2ZL7rpoPWIz5NIh-YA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Delius Swash Caps", + filename: "oY1E8fPLr7v4JWCExZpWebxVKORpXXedKmeBvEYs", + category: "handwriting", + url: "https://fonts.gstatic.com/s/deliusswashcaps/v25/oY1E8fPLr7v4JWCExZpWebxVKORpXXedKmeBvEYs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cherry Swash", + filename: "i7dNIFByZjaNAMxtZcnfAy58QHi-EwWMbg", + category: "display", + url: "https://fonts.gstatic.com/s/cherryswash/v22/i7dNIFByZjaNAMxtZcnfAy58QHi-EwWMbg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lexend Mega", + filename: "qFdA35aBi5JtHD41zSTFEv7K6BsAikI7", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lexendmega/v27/qFdA35aBi5JtHD41zSTFEv7K6BsAikI7.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Winky Sans", + filename: "ll85K2SDUiG1Hpf2p06bB6oikgYbnRQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/winkysans/v3/ll85K2SDUiG1Hpf2p06bB6oikgYbnRQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Anek Tamil", + filename: "XLYjIZH2bYJHGYtPGSbUHcloon8vVXg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anektamil/v18/XLYjIZH2bYJHGYtPGSbUHcloon8vVXg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Underdog", + filename: "CHygV-jCElj7diMroVSiU14GN2Il", + category: "display", + url: "https://fonts.gstatic.com/s/underdog/v24/CHygV-jCElj7diMroVSiU14GN2Il.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Comforter Brush", + filename: "Y4GTYa1xVSggrfzZI5WMjxRaOz0jwLL9Th8YYA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/comforterbrush/v9/Y4GTYa1xVSggrfzZI5WMjxRaOz0jwLL9Th8YYA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nova Script", + filename: "7Au7p_IpkSWSTWaFWkumvmQNEl0O0kEx", + category: "display", + url: "https://fonts.gstatic.com/s/novascript/v27/7Au7p_IpkSWSTWaFWkumvmQNEl0O0kEx.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kenia", + filename: "jizURE5PuHQH9qCONUGswfGM", + category: "display", + url: "https://fonts.gstatic.com/s/kenia/v30/jizURE5PuHQH9qCONUGswfGM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shalimar", + filename: "uU9MCBoE6I6iNWFUvTPx8PCOg0uX", + category: "handwriting", + url: "https://fonts.gstatic.com/s/shalimar/v9/uU9MCBoE6I6iNWFUvTPx8PCOg0uX.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stick", + filename: "Qw3TZQpMCyTtJSvfvPVDMPoF", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/stick/v20/Qw3TZQpMCyTtJSvfvPVDMPoF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite GB J Guides", + filename: "CSRh4yJOn-mMWCgLPl16K6UKAvM5yY1BdhmIKxooUh-_nQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritegbjguides/v2/CSRh4yJOn-mMWCgLPl16K6UKAvM5yY1BdhmIKxooUh-_nQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ysabeau SC", + filename: "Noa36Uro3JCIKAbW46nMuLl7OCZ4ihE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ysabeausc/v4/Noa36Uro3JCIKAbW46nMuLl7OCZ4ihE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "BhuTuka Expanded One", + filename: "SLXXc0jZ4WUJcClHTtv0t7IaDRsBsWRiJCyX8pg_RVH1", + category: "serif", + url: "https://fonts.gstatic.com/s/bhutukaexpandedone/v9/SLXXc0jZ4WUJcClHTtv0t7IaDRsBsWRiJCyX8pg_RVH1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Doto", + filename: "t5tvIRMbNJ6TWmXqV2W2tKc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/doto/v3/t5tvIRMbNJ6TWmXqV2W2tKc.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Varta", + filename: "Qw3TZQpJHj_6LyvfvPVDMPoF", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/varta/v25/Qw3TZQpJHj_6LyvfvPVDMPoF.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Belgrano", + filename: "55xvey5tM9rwKWrJZcMFirl08KDJ", + category: "serif", + url: "https://fonts.gstatic.com/s/belgrano/v19/55xvey5tM9rwKWrJZcMFirl08KDJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sonsie One", + filename: "PbymFmP_EAnPqbKaoc18YVu80lbp8JM", + category: "display", + url: "https://fonts.gstatic.com/s/sonsieone/v22/PbymFmP_EAnPqbKaoc18YVu80lbp8JM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jolly Lodger", + filename: "BXRsvFTAh_bGkA1uQ48dlB3VWerT3ZyuqA", + category: "display", + url: "https://fonts.gstatic.com/s/jollylodger/v21/BXRsvFTAh_bGkA1uQ48dlB3VWerT3ZyuqA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Akronim", + filename: "fdN-9sqWtWZZlHRp-gBxkFYN-a8", + category: "display", + url: "https://fonts.gstatic.com/s/akronim/v23/fdN-9sqWtWZZlHRp-gBxkFYN-a8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fasthand", + filename: "0yb9GDohyKTYn_ZEESkuYkw2rQg1", + category: "display", + url: "https://fonts.gstatic.com/s/fasthand/v33/0yb9GDohyKTYn_ZEESkuYkw2rQg1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Atkinson Hyperlegible Mono", + filename: "tss4AoFBci4C4gvhPXrt3wjT1MqSzhA4t7IIcncBizKqjl2hHT0i", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/atkinsonhyperlegiblemono/v8/tss4AoFBci4C4gvhPXrt3wjT1MqSzhA4t7IIcncBizKqjl2hHT0i.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Strait", + filename: "DtViJxy6WaEr1LZzeDhtkl0U7w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/strait/v19/DtViJxy6WaEr1LZzeDhtkl0U7w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mystery Quest", + filename: "-nF6OG414u0E6k0wynSGlujRHwElD_9Qz9E", + category: "display", + url: "https://fonts.gstatic.com/s/mysteryquest/v21/-nF6OG414u0E6k0wynSGlujRHwElD_9Qz9E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gafata", + filename: "XRXV3I6Cn0VJKon4MuyAbsrVcA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gafata/v22/XRXV3I6Cn0VJKon4MuyAbsrVcA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alkatra", + filename: "r05bGLZA5qhCYsyJdOuD5jokU8E", + category: "display", + url: "https://fonts.gstatic.com/s/alkatra/v5/r05bGLZA5qhCYsyJdOuD5jokU8E.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Ethiopic", + filename: "7cH1v50vjIepfJVOZZgcpQ5B9FBTH9KcPtq-rpvLpQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansethiopic/v50/7cH1v50vjIepfJVOZZgcpQ5B9FBTH9KcPtq-rpvLpQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Miltonian", + filename: "zOL-4pbPn6Ne9JqTg9mr6e5As-FeiQ", + category: "display", + url: "https://fonts.gstatic.com/s/miltonian/v32/zOL-4pbPn6Ne9JqTg9mr6e5As-FeiQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite IN Guides", + filename: "GFD2WBRug3mQSvrAT9AL4vx4d3lQNQV4Tt1qdGqgdlM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteinguides/v1/GFD2WBRug3mQSvrAT9AL4vx4d3lQNQV4Tt1qdGqgdlM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nova Cut", + filename: "KFOkCnSYu8mL-39LkWxPKTM1K9nz", + category: "display", + url: "https://fonts.gstatic.com/s/novacut/v26/KFOkCnSYu8mL-39LkWxPKTM1K9nz.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sansation", + filename: "LYjAdGPjnEg8DNA0z01grXArXN7HWQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sansation/v1/LYjAdGPjnEg8DNA0z01grXArXN7HWQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Flamenco", + filename: "neIIzCehqYguo67ssaWGHK06UY30", + category: "display", + url: "https://fonts.gstatic.com/s/flamenco/v19/neIIzCehqYguo67ssaWGHK06UY30.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Srisakdi", + filename: "yMJRMIlvdpDbkB0A-jq8fSx5i814", + category: "display", + url: "https://fonts.gstatic.com/s/srisakdi/v18/yMJRMIlvdpDbkB0A-jq8fSx5i814.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Barrio", + filename: "wEO8EBXBk8hBIDiEdQYhWdsX1Q", + category: "display", + url: "https://fonts.gstatic.com/s/barrio/v20/wEO8EBXBk8hBIDiEdQYhWdsX1Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Redacted", + filename: "Z9XVDmdRShme2O_7aITe4u2El6GC", + category: "display", + url: "https://fonts.gstatic.com/s/redacted/v11/Z9XVDmdRShme2O_7aITe4u2El6GC.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nova Oval", + filename: "jAnEgHdmANHvPenMaswCMY-h3cWkWg", + category: "display", + url: "https://fonts.gstatic.com/s/novaoval/v26/jAnEgHdmANHvPenMaswCMY-h3cWkWg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vend Sans", + filename: "E21l_d7ijufNwCJPEUssUwVUAuu3cw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/vendsans/v1/E21l_d7ijufNwCJPEUssUwVUAuu3cw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Comme", + filename: "8QIHdirKhMbn-vu-sowsrqjk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/comme/v4/8QIHdirKhMbn-vu-sowsrqjk.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Imperial Script", + filename: "5DCPAKrpzy_H98IV2ISnZBbGrVNvPenlvttWNg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/imperialscript/v8/5DCPAKrpzy_H98IV2ISnZBbGrVNvPenlvttWNg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Island Moments", + filename: "NaPBcZfVGvBdxIt7Ar0qzkXJF-TGIohbZ6SY", + category: "handwriting", + url: "https://fonts.gstatic.com/s/islandmoments/v8/NaPBcZfVGvBdxIt7Ar0qzkXJF-TGIohbZ6SY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Englebert", + filename: "xn7iYH8w2XGrC8AR4HSxT_fYdN-WZw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/englebert/v24/xn7iYH8w2XGrC8AR4HSxT_fYdN-WZw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sedgwick Ave Display", + filename: "xfuu0XPgU3jZPUoUo3ScvmPi-NapQ8OxM2czd-YnOzUD", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sedgwickavedisplay/v23/xfuu0XPgU3jZPUoUo3ScvmPi-NapQ8OxM2czd-YnOzUD.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Unlock", + filename: "7Au-p_8ykD-cDl7GKAjSwkUVOQ", + category: "display", + url: "https://fonts.gstatic.com/s/unlock/v28/7Au-p_8ykD-cDl7GKAjSwkUVOQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Sans Thai Looped", + filename: "tss_AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30LxBKAoFGoBCQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsansthailooped/v12/tss_AoJJRAhL3BTrK3r2xxbFhvKfyBB6l7hHT30LxBKAoFGoBCQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lovers Quarrel", + filename: "Yq6N-LSKXTL-5bCy8ksBzpQ_-zAsY7pO6siz", + category: "handwriting", + url: "https://fonts.gstatic.com/s/loversquarrel/v25/Yq6N-LSKXTL-5bCy8ksBzpQ_-zAsY7pO6siz.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Devonshire", + filename: "46kqlbDwWirWr4gtBD2BX0Vq01lYAZM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/devonshire/v29/46kqlbDwWirWr4gtBD2BX0Vq01lYAZM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Keania One", + filename: "zOL54pXJk65E8pXardnuycRuv-hHkOs", + category: "display", + url: "https://fonts.gstatic.com/s/keaniaone/v26/zOL54pXJk65E8pXardnuycRuv-hHkOs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chau Philomene One", + filename: "55xxezRsPtfie1vPY49qzdgSlJiHRQFsnIx7QMISdQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/chauphilomeneone/v16/55xxezRsPtfie1vPY49qzdgSlJiHRQFsnIx7QMISdQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Music", + filename: "pe0rMIiSN5pO63htf1sxIteQB9Zra1U", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notomusic/v21/pe0rMIiSN5pO63htf1sxIteQB9Zra1U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yeon Sung", + filename: "QldMNTpbohAGtsJvUn6xSVNazqx2xg", + category: "display", + url: "https://fonts.gstatic.com/s/yeonsung/v22/QldMNTpbohAGtsJvUn6xSVNazqx2xg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kode Mono", + filename: "A2BYn5pb0QgtVEPFnlY-mojxW5KJuQ", + category: "monospace", + url: "https://fonts.gstatic.com/s/kodemono/v4/A2BYn5pb0QgtVEPFnlY-mojxW5KJuQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Headland One", + filename: "yYLu0hHR2vKnp89Tk1TCq3Tx0PlTeZ3mJA", + category: "serif", + url: "https://fonts.gstatic.com/s/headlandone/v17/yYLu0hHR2vKnp89Tk1TCq3Tx0PlTeZ3mJA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Akaya Telivigala", + filename: "lJwc-oo_iG9wXqU3rCTD395tp0uifdLdsIH0YH8", + category: "display", + url: "https://fonts.gstatic.com/s/akayatelivigala/v28/lJwc-oo_iG9wXqU3rCTD395tp0uifdLdsIH0YH8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Warang Citi", + filename: "EYqtmb9SzL1YtsZSScyKDXIeOv3w-zgsNvKRpeVCCXzdgA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanswarangciti/v19/EYqtmb9SzL1YtsZSScyKDXIeOv3w-zgsNvKRpeVCCXzdgA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Darumadrop One", + filename: "cY9cfjeIW11dpCKgRLi675a87IhHBJOxZQPp", + category: "display", + url: "https://fonts.gstatic.com/s/darumadropone/v14/cY9cfjeIW11dpCKgRLi675a87IhHBJOxZQPp.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Badeen Display", + filename: "pxidypY2sdZSjFU4cPmNBzckadeLYk1Mq3ap", + category: "display", + url: "https://fonts.gstatic.com/s/badeendisplay/v1/pxidypY2sdZSjFU4cPmNBzckadeLYk1Mq3ap.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jomolhari", + filename: "EvONzA1M1Iw_CBd2hsQCF1IZKq5INg", + category: "serif", + url: "https://fonts.gstatic.com/s/jomolhari/v21/EvONzA1M1Iw_CBd2hsQCF1IZKq5INg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Long Cang", + filename: "LYjAdGP8kkgoTec8zkRgrXArXN7HWQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/longcang/v21/LYjAdGP8kkgoTec8zkRgrXArXN7HWQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ballet", + filename: "QGYvz_MYZA-HM4N5s0Frc4H0ng", + category: "handwriting", + url: "https://fonts.gstatic.com/s/ballet/v30/QGYvz_MYZA-HM4N5s0Frc4H0ng.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Margarine", + filename: "qkBXXvoE6trLT9Y7YLye5JRLkAXbMQ", + category: "display", + url: "https://fonts.gstatic.com/s/margarine/v27/qkBXXvoE6trLT9Y7YLye5JRLkAXbMQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Orbit", + filename: "_LOCmz7I-uHd2mjEeqciRwRm", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/orbit/v1/_LOCmz7I-uHd2mjEeqciRwRm.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Joan", + filename: "ZXupe1oZsqWRbRdH8X1p_Ng", + category: "serif", + url: "https://fonts.gstatic.com/s/joan/v12/ZXupe1oZsqWRbRdH8X1p_Ng.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mate SC", + filename: "-nF8OGQ1-uoVr2wKyiXZ95OkJwA", + category: "serif", + url: "https://fonts.gstatic.com/s/matesc/v23/-nF8OGQ1-uoVr2wKyiXZ95OkJwA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nerko One", + filename: "m8JQjfZSc7OXlB3ZMOjzcJ5BZmqa3A", + category: "handwriting", + url: "https://fonts.gstatic.com/s/nerkoone/v17/m8JQjfZSc7OXlB3ZMOjzcJ5BZmqa3A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sree Krushnadevaraya", + filename: "R70FjzQeifmPepmyQQjQ9kvwMkWYPfTA_EWb2FhQuXir", + category: "serif", + url: "https://fonts.gstatic.com/s/sreekrushnadevaraya/v23/R70FjzQeifmPepmyQQjQ9kvwMkWYPfTA_EWb2FhQuXir.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kulim Park", + filename: "fdN79secq3hflz1Uu3IwtF4m5aZxebw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kulimpark/v15/fdN79secq3hflz1Uu3IwtF4m5aZxebw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Karantina", + filename: "buE0po24ccnh31GVMABJ8AA78NVSYw", + category: "display", + url: "https://fonts.gstatic.com/s/karantina/v13/buE0po24ccnh31GVMABJ8AA78NVSYw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fascinate Inline", + filename: "jVyR7mzzB3zc-jp6QCAu60poNqIy1g3CfRXxWZQ", + category: "display", + url: "https://fonts.gstatic.com/s/fascinateinline/v24/jVyR7mzzB3zc-jp6QCAu60poNqIy1g3CfRXxWZQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Single Day", + filename: "LYjHdGDjlEgoAcF95EI5jVoFUNfeQJU", + category: "display", + url: "https://fonts.gstatic.com/s/singleday/v19/LYjHdGDjlEgoAcF95EI5jVoFUNfeQJU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Beau Rivage", + filename: "UcCi3FIgIG2bH4mMNWJUlmg3NZp8K2sL", + category: "handwriting", + url: "https://fonts.gstatic.com/s/beaurivage/v2/UcCi3FIgIG2bH4mMNWJUlmg3NZp8K2sL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ranchers", + filename: "zrfm0H3Lx-P2Xvs2AoDYDC79XTHv", + category: "display", + url: "https://fonts.gstatic.com/s/ranchers/v19/zrfm0H3Lx-P2Xvs2AoDYDC79XTHv.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Trispace", + filename: "Yq6X-LKSQC3o56Lxxh5gluJSlggt", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/trispace/v27/Yq6X-LKSQC3o56Lxxh5gluJSlggt.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Engagement", + filename: "x3dlckLDZbqa7RUs9MFVXNossybsHQI", + category: "handwriting", + url: "https://fonts.gstatic.com/s/engagement/v29/x3dlckLDZbqa7RUs9MFVXNossybsHQI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Katibeh", + filename: "ZGjXol5MQJog4bxDaC1RVDNdGDs", + category: "display", + url: "https://fonts.gstatic.com/s/katibeh/v22/ZGjXol5MQJog4bxDaC1RVDNdGDs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Boldonse", + filename: "ZgNQjPxGPbbJUZemjC38hmHmNpCO", + category: "display", + url: "https://fonts.gstatic.com/s/boldonse/v1/ZgNQjPxGPbbJUZemjC38hmHmNpCO.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bonheur Royale", + filename: "c4m51nt_GMTrtX-b9GcG4-YRmYK_c0f1N5Ij", + category: "handwriting", + url: "https://fonts.gstatic.com/s/bonheurroyale/v15/c4m51nt_GMTrtX-b9GcG4-YRmYK_c0f1N5Ij.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kapakana", + filename: "sykw-yN0m6InS7OD9AqX1NbNp02R", + category: "handwriting", + url: "https://fonts.gstatic.com/s/kapakana/v19/sykw-yN0m6InS7OD9AqX1NbNp02R.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite US Trad", + filename: "fdNk9tyHsnVPjW9trmV7wQ0stdwRBZ0uKDBFUEXz", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteustrad/v11/fdNk9tyHsnVPjW9trmV7wQ0stdwRBZ0uKDBFUEXz.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Poltawski Nowy", + filename: "flUhRq6ww480U1xsUpFXD-iDBMNZIhI8tIHh", + category: "serif", + url: "https://fonts.gstatic.com/s/poltawskinowy/v5/flUhRq6ww480U1xsUpFXD-iDBMNZIhI8tIHh.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gupter", + filename: "2-cm9JNmxJqPO1QUYZa_Wu_lpA", + category: "serif", + url: "https://fonts.gstatic.com/s/gupter/v18/2-cm9JNmxJqPO1QUYZa_Wu_lpA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Dekko", + filename: "46khlb_wWjfSrttFR0vsfl1B", + category: "handwriting", + url: "https://fonts.gstatic.com/s/dekko/v23/46khlb_wWjfSrttFR0vsfl1B.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stint Ultra Expanded", + filename: "CSRg4yNNh-GbW3o3JkwoDcdvMKMf0oBAd0qoATQkWwam", + category: "serif", + url: "https://fonts.gstatic.com/s/stintultraexpanded/v24/CSRg4yNNh-GbW3o3JkwoDcdvMKMf0oBAd0qoATQkWwam.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tiro Devanagari Sanskrit", + filename: "MCoAzBbr09vVUgVBM8FWu_yZdZkhkg-I0nUlb59pEoEqgtOh0w", + category: "serif", + url: "https://fonts.gstatic.com/s/tirodevanagarisanskrit/v5/MCoAzBbr09vVUgVBM8FWu_yZdZkhkg-I0nUlb59pEoEqgtOh0w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anek Gujarati", + filename: "l7gZbj5oysqknvkCo2T_8FuiOxtiArlM4k8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anekgujarati/v17/l7gZbj5oysqknvkCo2T_8FuiOxtiArlM4k8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rum Raisin", + filename: "nwpRtKu3Ih8D5avB4h2uJ3-IywA7eMM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/rumraisin/v24/nwpRtKu3Ih8D5avB4h2uJ3-IywA7eMM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chocolate Classical Sans", + filename: "nuFqD-PLTZX4XIgT-P2ToCDudWHHflqUpTpfjWdDPI2J9mHITw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/chocolateclassicalsans/v15/nuFqD-PLTZX4XIgT-P2ToCDudWHHflqUpTpfjWdDPI2J9mHITw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ruthie", + filename: "gokvH63sGkdqXuU9lD53Q2u_mQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/ruthie/v28/gokvH63sGkdqXuU9lD53Q2u_mQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Protest Riot", + filename: "d6lPkaOxWMKm7TdezXFmpkrM1_JgjmRpOA", + category: "display", + url: "https://fonts.gstatic.com/s/protestriot/v2/d6lPkaOxWMKm7TdezXFmpkrM1_JgjmRpOA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Liu Jian Mao Cao", + filename: "845DNN84HJrccNonurqXILGpvCOoferVKGWsUo8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/liujianmaocao/v24/845DNN84HJrccNonurqXILGpvCOoferVKGWsUo8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Erica One", + filename: "WBLnrEXccV9VGrOKmGD1W0_MJMGxiQ", + category: "display", + url: "https://fonts.gstatic.com/s/ericaone/v29/WBLnrEXccV9VGrOKmGD1W0_MJMGxiQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Water Brush", + filename: "AYCPpXPqc8cJWLhp4hywKHJq7PKP5Z_G", + category: "handwriting", + url: "https://fonts.gstatic.com/s/waterbrush/v6/AYCPpXPqc8cJWLhp4hywKHJq7PKP5Z_G.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zalando Sans SemiExpanded", + filename: "6qLSKYcHuh3msE9OaXROVVclRRa-ClZSEipa2hrE1xaBdpd7fjg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/zalandosanssemiexpanded/v2/6qLSKYcHuh3msE9OaXROVVclRRa-ClZSEipa2hrE1xaBdpd7fjg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Spirax", + filename: "buE3poKgYNLy0F3cXktt-Csn-Q", + category: "display", + url: "https://fonts.gstatic.com/s/spirax/v22/buE3poKgYNLy0F3cXktt-Csn-Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sura", + filename: "SZc23FL5PbyzFf5UWzXtjUM", + category: "serif", + url: "https://fonts.gstatic.com/s/sura/v21/SZc23FL5PbyzFf5UWzXtjUM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "IBM Plex Sans Devanagari", + filename: "XRXH3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O__VUL0c83gCA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ibmplexsansdevanagari/v12/XRXH3JCMvG4IDoS9SubXB6W-UX5iehIMBFR2-O__VUL0c83gCA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Gurmukhi", + filename: "w8gHH3EvQP81sInb43inmyN9zZ7hb7AJZgdEAj--IQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansgurmukhi/v29/w8gHH3EvQP81sInb43inmyN9zZ7hb7AJZgdEAj--IQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Texturina", + filename: "c4mi1nxpEtL3pXiAulRJmq159KOnWA", + category: "serif", + url: "https://fonts.gstatic.com/s/texturina/v32/c4mi1nxpEtL3pXiAulRJmq159KOnWA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Samaritan", + filename: "buEqppe9f8_vkXadMBJJo0tSmaYjFkxOUo5jNlOVMzQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssamaritan/v17/buEqppe9f8_vkXadMBJJo0tSmaYjFkxOUo5jNlOVMzQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hedvig Letters Sans", + filename: "CHy_V_PfGVjobSBkihHWDT98RVp37w8jQJ1N3Twgi1w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hedvigletterssans/v2/CHy_V_PfGVjobSBkihHWDT98RVp37w8jQJ1N3Twgi1w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kavoon", + filename: "pxiFyp4_scRYhlU4NLr6f1pdEQ", + category: "display", + url: "https://fonts.gstatic.com/s/kavoon/v25/pxiFyp4_scRYhlU4NLr6f1pdEQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cactus Classical Serif", + filename: "sZlVdQ6K-zJOCzUaS90zMNN-Ep-OoC8dZr0JFuBIFX-pv-E", + category: "serif", + url: "https://fonts.gstatic.com/s/cactusclassicalserif/v14/sZlVdQ6K-zJOCzUaS90zMNN-Ep-OoC8dZr0JFuBIFX-pv-E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Port Lligat Slab", + filename: "LDIpaoiQNgArA8kR7ulhZ8P_NYOss7ob9yGLmfI", + category: "serif", + url: "https://fonts.gstatic.com/s/portlligatslab/v27/LDIpaoiQNgArA8kR7ulhZ8P_NYOss7ob9yGLmfI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "BIZ UDMincho", + filename: "EJRRQgI6eOxFjBdKs38yhtW1dwT7rcpY8Q", + category: "serif", + url: "https://fonts.gstatic.com/s/bizudmincho/v11/EJRRQgI6eOxFjBdKs38yhtW1dwT7rcpY8Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Dangrek", + filename: "LYjCdG30nEgoH8E2gCNqqVIuTN4", + category: "display", + url: "https://fonts.gstatic.com/s/dangrek/v33/LYjCdG30nEgoH8E2gCNqqVIuTN4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cute Font", + filename: "Noaw6Uny2oWPbSHMrY6vmJNVNC9hkw", + category: "display", + url: "https://fonts.gstatic.com/s/cutefont/v28/Noaw6Uny2oWPbSHMrY6vmJNVNC9hkw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Osmanya", + filename: "8vIS7xs32H97qzQKnzfeWzUyUpOJmz6kR47NCV5Z", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansosmanya/v20/8vIS7xs32H97qzQKnzfeWzUyUpOJmz6kR47NCV5Z.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Passions Conflict", + filename: "kmKnZrcrFhfafnWX9x0GuEC-zowow5NeYRI4CN2V", + category: "handwriting", + url: "https://fonts.gstatic.com/s/passionsconflict/v9/kmKnZrcrFhfafnWX9x0GuEC-zowow5NeYRI4CN2V.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bagel Fat One", + filename: "hYkPPucsQOr5dy02WmQr5Zkd0B5mvv0dSbM", + category: "display", + url: "https://fonts.gstatic.com/s/bagelfatone/v2/hYkPPucsQOr5dy02WmQr5Zkd0B5mvv0dSbM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Braah One", + filename: "KFOlCnWUpt6LsxxxiylvAx05IsDqlA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/braahone/v8/KFOlCnWUpt6LsxxxiylvAx05IsDqlA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stint Ultra Condensed", + filename: "-W_gXIrsVjjeyEnPC45qD2NoFPtBE0xCh2A-qhUO2cNvdg", + category: "serif", + url: "https://fonts.gstatic.com/s/stintultracondensed/v25/-W_gXIrsVjjeyEnPC45qD2NoFPtBE0xCh2A-qhUO2cNvdg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Content", + filename: "zrfl0HLayePhU_AwUaDyIiL0RCg", + category: "display", + url: "https://fonts.gstatic.com/s/content/v27/zrfl0HLayePhU_AwUaDyIiL0RCg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Handjet", + filename: "oY1e8eXHq7n1OnbQtu0dDtx0c4Q", + category: "display", + url: "https://fonts.gstatic.com/s/handjet/v22/oY1e8eXHq7n1OnbQtu0dDtx0c4Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Almendra Display", + filename: "0FlPVOGWl1Sb4O3tETtADHRRlZhzXS_eTyer338", + category: "display", + url: "https://fonts.gstatic.com/s/almendradisplay/v33/0FlPVOGWl1Sb4O3tETtADHRRlZhzXS_eTyer338.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bodoni Moda SC", + filename: "LYjbdGTykkIgA8197UwkzHp8F__fcpC69i6N", + category: "serif", + url: "https://fonts.gstatic.com/s/bodonimodasc/v3/LYjbdGTykkIgA8197UwkzHp8F__fcpC69i6N.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Baskervville SC", + filename: "X7n94bc_DeKlh6bBbk_WiKnBSUvR71R3tiSx0g", + category: "serif", + url: "https://fonts.gstatic.com/s/baskervvillesc/v4/X7n94bc_DeKlh6bBbk_WiKnBSUvR71R3tiSx0g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Text Me One", + filename: "i7dOIFdlayuLUvgoFvHQFWZcalayGhyV", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/textmeone/v26/i7dOIFdlayuLUvgoFvHQFWZcalayGhyV.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Tokyo Zoo", + filename: "NGSyv5ffC0J_BK6aFNtr6sRv8a1uRWe9amg", + category: "display", + url: "https://fonts.gstatic.com/s/zentokyozoo/v8/NGSyv5ffC0J_BK6aFNtr6sRv8a1uRWe9amg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Suwannaphum", + filename: "jAnCgHV7GtDvc8jbe8hXXIWl_8C0Wg2V", + category: "serif", + url: "https://fonts.gstatic.com/s/suwannaphum/v33/jAnCgHV7GtDvc8jbe8hXXIWl_8C0Wg2V.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mynerve", + filename: "P5sCzZKPdNjb4jt7xCRuiZ-uydg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mynerve/v8/P5sCzZKPdNjb4jt7xCRuiZ-uydg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Risque", + filename: "VdGfAZUfHosahXxoCUYVBJ-T5g", + category: "display", + url: "https://fonts.gstatic.com/s/risque/v24/VdGfAZUfHosahXxoCUYVBJ-T5g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Carrois Gothic SC", + filename: "ZgNJjOVHM6jfUZCmyUqT2A2HVKjc-28nNHabY4dN", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/carroisgothicsc/v16/ZgNJjOVHM6jfUZCmyUqT2A2HVKjc-28nNHabY4dN.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kaisei HarunoUmi", + filename: "HI_RiZQSLqBQoAHhK_C6N_nzy_jcGsv5sM8u3mk", + category: "serif", + url: "https://fonts.gstatic.com/s/kaiseiharunoumi/v11/HI_RiZQSLqBQoAHhK_C6N_nzy_jcGsv5sM8u3mk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libre Barcode 39 Extended", + filename: "8At7Gt6_O5yNS0-K4Nf5U922qSzhJ3dUdfJpwNUgfNRCOZ1GOBw", + category: "display", + url: "https://fonts.gstatic.com/s/librebarcode39extended/v30/8At7Gt6_O5yNS0-K4Nf5U922qSzhJ3dUdfJpwNUgfNRCOZ1GOBw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jersey 15", + filename: "_6_9EDzuROGsUuk2TWjSYoohsCkvSQ", + category: "display", + url: "https://fonts.gstatic.com/s/jersey15/v4/_6_9EDzuROGsUuk2TWjSYoohsCkvSQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Farsan", + filename: "VEMwRoJ0vY_zsyz62q-pxDX9rQ", + category: "display", + url: "https://fonts.gstatic.com/s/farsan/v24/VEMwRoJ0vY_zsyz62q-pxDX9rQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ysabeau Infant", + filename: "hv-PlzpqOkkV94kBTQVdX1EWI8p_dREcBXxP", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ysabeauinfant/v4/hv-PlzpqOkkV94kBTQVdX1EWI8p_dREcBXxP.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gorditas", + filename: "ll8_K2aTVD26DsPEtQDoDa4AlxYb", + category: "display", + url: "https://fonts.gstatic.com/s/gorditas/v24/ll8_K2aTVD26DsPEtQDoDa4AlxYb.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stoke", + filename: "z7NadRb7aTMfKONpfihK1YTV", + category: "serif", + url: "https://fonts.gstatic.com/s/stoke/v26/z7NadRb7aTMfKONpfihK1YTV.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Finlandica", + filename: "-nF5OGk-8vAc7lEtg0aS05uPOwkOE_Y", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/finlandica/v10/-nF5OGk-8vAc7lEtg0aS05uPOwkOE_Y.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Oriya", + filename: "AYCTpXfzfccDCstK_hrjDyADv5en5K3DZq1hIg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansoriya/v33/AYCTpXfzfccDCstK_hrjDyADv5en5K3DZq1hIg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Cagliostro", + filename: "ZgNWjP5HM73BV5amnX-TjGXEM4COoE4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cagliostro/v22/ZgNWjP5HM73BV5amnX-TjGXEM4COoE4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gideon Roman", + filename: "e3tmeuGrVOys8sxzZgWlmXoge0PWovdU4w", + category: "display", + url: "https://fonts.gstatic.com/s/gideonroman/v13/e3tmeuGrVOys8sxzZgWlmXoge0PWovdU4w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Scribble", + filename: "snfzs0Cp48d67SuHQOpjXLsQpbqbSjORSo9W", + category: "display", + url: "https://fonts.gstatic.com/s/rubikscribble/v1/snfzs0Cp48d67SuHQOpjXLsQpbqbSjORSo9W.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Paprika", + filename: "8QIJdijZitv49rDfuIgOq7jkAOw", + category: "display", + url: "https://fonts.gstatic.com/s/paprika/v24/8QIJdijZitv49rDfuIgOq7jkAOw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stack Sans Headline", + filename: "1Ptyg9jZXvmMnkLnuURbaukKZJTyrDV3waClGrw-PTY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/stacksansheadline/v1/1Ptyg9jZXvmMnkLnuURbaukKZJTyrDV3waClGrw-PTY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Seymour One", + filename: "4iCp6Khla9xbjQpoWGGd0myIPYBvgpUI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/seymourone/v25/4iCp6Khla9xbjQpoWGGd0myIPYBvgpUI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ysabeau", + filename: "kmK9ZqEiBAXLcnuMpD1v0ybZuuQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ysabeau/v5/kmK9ZqEiBAXLcnuMpD1v0ybZuuQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Junge", + filename: "gokgH670Gl1lUqAdvhB7SnKm", + category: "serif", + url: "https://fonts.gstatic.com/s/junge/v26/gokgH670Gl1lUqAdvhB7SnKm.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "My Soul", + filename: "3XFqErcuy945_u6KF_Ulk2nnXf0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mysoul/v7/3XFqErcuy945_u6KF_Ulk2nnXf0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Climate Crisis", + filename: "wEOkEB3AntNeKCPBVW9XOKlmp2ofo7fHQOnM", + category: "display", + url: "https://fonts.gstatic.com/s/climatecrisis/v14/wEOkEB3AntNeKCPBVW9XOKlmp2ofo7fHQOnM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lexend Tera", + filename: "RrQUbo98_jt_IXnBPwCWtZhARYMgGtWA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lexendtera/v29/RrQUbo98_jt_IXnBPwCWtZhARYMgGtWA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rubik Wet Paint", + filename: "HTx0L20uMDGHgdULcpTF3Oe4d_-F-zz313DuvQ", + category: "display", + url: "https://fonts.gstatic.com/s/rubikwetpaint/v2/HTx0L20uMDGHgdULcpTF3Oe4d_-F-zz313DuvQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tillana", + filename: "VuJxdNvf35P4qJ1OeKbXOIFneRo", + category: "display", + url: "https://fonts.gstatic.com/s/tillana/v15/VuJxdNvf35P4qJ1OeKbXOIFneRo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu NSW ACT Cursive", + filename: "xn78YGUw02PnIPEjskHSG_2fCaz9DzhQd8_v3bT4Ycc", + category: "handwriting", + url: "https://fonts.gstatic.com/s/edunswactcursive/v3/xn78YGUw02PnIPEjskHSG_2fCaz9DzhQd8_v3bT4Ycc.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mogra", + filename: "f0X40eSs8c95TBo4DvLmxtnG", + category: "display", + url: "https://fonts.gstatic.com/s/mogra/v22/f0X40eSs8c95TBo4DvLmxtnG.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Montserrat Underline", + filename: "mFTuWaYfw6zH4dthXcyms01NtC8I_7U5uR4pxoPdAYRC", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/montserratunderline/v3/mFTuWaYfw6zH4dthXcyms01NtC8I_7U5uR4pxoPdAYRC.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Victor Mono", + filename: "Yq6Q-LGQWyfv-LGy7lEO08ZavRQkSKZH", + category: "monospace", + url: "https://fonts.gstatic.com/s/victormono/v5/Yq6Q-LGQWyfv-LGy7lEO08ZavRQkSKZH.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite IN", + filename: "uk-kEGGpoLQ97mfv2J3cZzup5w50_o9T7Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritein/v11/uk-kEGGpoLQ97mfv2J3cZzup5w50_o9T7Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Girassol", + filename: "JTUUjIo_-DK48laaNC9Nz2pJzxbi", + category: "display", + url: "https://fonts.gstatic.com/s/girassol/v24/JTUUjIo_-DK48laaNC9Nz2pJzxbi.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bilbo", + filename: "o-0EIpgpwWwZ210hpIRz4wxE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/bilbo/v21/o-0EIpgpwWwZ210hpIRz4wxE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tourney", + filename: "AlZw_ztDtYzv1tzqzQwrcVX9TB0", + category: "display", + url: "https://fonts.gstatic.com/s/tourney/v16/AlZw_ztDtYzv1tzqzQwrcVX9TB0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mea Culpa", + filename: "AMOTz4GcuWbEIuza8jsZms0QW3mqyg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/meaculpa/v8/AMOTz4GcuWbEIuza8jsZms0QW3mqyg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ramaraja", + filename: "SlGTmQearpYAYG1CABIkqnB6aSQU", + category: "serif", + url: "https://fonts.gstatic.com/s/ramaraja/v17/SlGTmQearpYAYG1CABIkqnB6aSQU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Romanesco", + filename: "w8gYH2ozQOY7_r_J7mSn3HwLqOqSBg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/romanesco/v22/w8gYH2ozQOY7_r_J7mSn3HwLqOqSBg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tac One", + filename: "ahcZv8Cj3zw7qDr8fO4hU-FwnU0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tacone/v5/ahcZv8Cj3zw7qDr8fO4hU-FwnU0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Almendra SC", + filename: "Iure6Yx284eebowr7hbyTZZJprVA4XQ0", + category: "serif", + url: "https://fonts.gstatic.com/s/almendrasc/v31/Iure6Yx284eebowr7hbyTZZJprVA4XQ0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Plaster", + filename: "DdTm79QatW80eRh4Ei5JOtLOeLI", + category: "display", + url: "https://fonts.gstatic.com/s/plaster/v25/DdTm79QatW80eRh4Ei5JOtLOeLI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chilanka", + filename: "WWXRlj2DZQiMJYaYRrJQI9EAZhTO", + category: "handwriting", + url: "https://fonts.gstatic.com/s/chilanka/v23/WWXRlj2DZQiMJYaYRrJQI9EAZhTO.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Metal", + filename: "lW-wwjUJIXTo7i3nnoQAUdN2", + category: "display", + url: "https://fonts.gstatic.com/s/metal/v32/lW-wwjUJIXTo7i3nnoQAUdN2.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anek Kannada", + filename: "rax6HiCNvNMKe1CKFsINYFl6m2Dc-T-EKQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anekkannada/v15/rax6HiCNvNMKe1CKFsINYFl6m2Dc-T-EKQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Londrina Shadow", + filename: "oPWX_kB4kOQoWNJmjxLV5JuoCUlXRlaSxkrMCQ", + category: "display", + url: "https://fonts.gstatic.com/s/londrinashadow/v28/oPWX_kB4kOQoWNJmjxLV5JuoCUlXRlaSxkrMCQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Tagalog", + filename: "J7aFnoNzCnFcV9ZI-sUYuvote1R0wwEAA8jHexnL", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstagalog/v23/J7aFnoNzCnFcV9ZI-sUYuvote1R0wwEAA8jHexnL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Buhid", + filename: "Dxxy8jiXMW75w3OmoDXVWJD7YwzAe6tgnaFoGA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbuhid/v23/Dxxy8jiXMW75w3OmoDXVWJD7YwzAe6tgnaFoGA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chathura", + filename: "_gP71R7-rzUuVjim418goUC5S-Zy", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/chathura/v22/_gP71R7-rzUuVjim418goUC5S-Zy.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Smooch", + filename: "o-0LIps4xW8U1xUBjqp_6hVdYg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/smooch/v9/o-0LIps4xW8U1xUBjqp_6hVdYg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Condiment", + filename: "pONk1hggFNmwvXALyH6Sq4n4o1vyCQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/condiment/v26/pONk1hggFNmwvXALyH6Sq4n4o1vyCQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Festive", + filename: "cY9Ffj6KX1xcoDWhFtfgy9HTkak", + category: "handwriting", + url: "https://fonts.gstatic.com/s/festive/v11/cY9Ffj6KX1xcoDWhFtfgy9HTkak.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Marhey", + filename: "x3dhck7Laq-T7wlhkYUldNsetg", + category: "display", + url: "https://fonts.gstatic.com/s/marhey/v8/x3dhck7Laq-T7wlhkYUldNsetg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Stack Sans Text", + filename: "kJErBuAJ-Q0hiGPmzHEu345X1JJXDba5BY95mQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/stacksanstext/v1/kJErBuAJ-Q0hiGPmzHEu345X1JJXDba5BY95mQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Tilt Prism", + filename: "5h1fiZgyPHoZ3YikNzWGZ2yQCUZIv3A", + category: "display", + url: "https://fonts.gstatic.com/s/tiltprism/v16/5h1fiZgyPHoZ3YikNzWGZ2yQCUZIv3A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Glass Antiqua", + filename: "xfu30Wr0Wn3NOQM2piC0uXOjnL_wN6fRUkY", + category: "display", + url: "https://fonts.gstatic.com/s/glassantiqua/v26/xfu30Wr0Wn3NOQM2piC0uXOjnL_wN6fRUkY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jacques Francois", + filename: "ZXu9e04ZvKeOOHIe1TMahbcIU2cgmcPqoeRWfbs", + category: "serif", + url: "https://fonts.gstatic.com/s/jacquesfrancois/v26/ZXu9e04ZvKeOOHIe1TMahbcIU2cgmcPqoeRWfbs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Sora Sompeng", + filename: "PlIsFkO5O6RzLfvNNVSioxM2_OTrEhPyDLolMPuO7-gt-ec", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssorasompeng/v26/PlIsFkO5O6RzLfvNNVSioxM2_OTrEhPyDLolMPuO7-gt-ec.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Nabla", + filename: "j8_l6-LI0Lvpe6kRse78FCl4", + category: "display", + url: "https://fonts.gstatic.com/s/nabla/v17/j8_l6-LI0Lvpe6kRse78FCl4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Reem Kufi Fun", + filename: "uK_14rOFYukkmyUEbF43fIryfkIbWc7gPbQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/reemkufifun/v13/uK_14rOFYukkmyUEbF43fIryfkIbWc7gPbQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Dorsa", + filename: "yYLn0hjd0OGwqo493XCFxAnQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/dorsa/v29/yYLn0hjd0OGwqo493XCFxAnQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Trykker", + filename: "KtktALyWZJXudUPzhNnoOd2j22U", + category: "serif", + url: "https://fonts.gstatic.com/s/trykker/v22/KtktALyWZJXudUPzhNnoOd2j22U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kite One", + filename: "70lQu7shLnA_E02vyq1b6HnGO4uA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kiteone/v23/70lQu7shLnA_E02vyq1b6HnGO4uA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alan Sans", + filename: "zOL-4pbDmq5Eu6ebjMSr6e5As-FeiQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alansans/v5/zOL-4pbDmq5Eu6ebjMSr6e5As-FeiQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Jacquard 12", + filename: "vm8ydRLuXETEweL79J4rGc3JUnr34c9-", + category: "display", + url: "https://fonts.gstatic.com/s/jacquard12/v8/vm8ydRLuXETEweL79J4rGc3JUnr34c9-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ruluko", + filename: "xMQVuFNZVaODtm0pC6WzKX_QmA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ruluko/v22/xMQVuFNZVaODtm0pC6WzKX_QmA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Poor Story", + filename: "jizfREFUsnUct9P6cDfd4OmnLD0Z4zM", + category: "display", + url: "https://fonts.gstatic.com/s/poorstory/v24/jizfREFUsnUct9P6cDfd4OmnLD0Z4zM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jacques Francois Shadow", + filename: "KR1FBtOz8PKTMk-kqdkLVrvR0ECFrB6Pin-2_q8VsHuV5ULS", + category: "display", + url: "https://fonts.gstatic.com/s/jacquesfrancoisshadow/v27/KR1FBtOz8PKTMk-kqdkLVrvR0ECFrB6Pin-2_q8VsHuV5ULS.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "New Tegomin", + filename: "SLXMc1fV7Gd9USdBAfPlqfN0Q3ptkDMN", + category: "serif", + url: "https://fonts.gstatic.com/s/newtegomin/v13/SLXMc1fV7Gd9USdBAfPlqfN0Q3ptkDMN.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Beiruti", + filename: "JTUXjIU69Cmr9FGceA9n4WZA1g8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/beiruti/v5/JTUXjIU69Cmr9FGceA9n4WZA1g8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Edu AU VIC WA NT Hand", + filename: "C8c94dY1tX2x0uuiUHFS4y7ERV-jfqJ6x06tFtksa4Q7LA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduauvicwanthand/v3/C8c94dY1tX2x0uuiUHFS4y7ERV-jfqJ6x06tFtksa4Q7LA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Autour One", + filename: "UqyVK80cP25l3fJgbdfbk5lWVscxdKE", + category: "display", + url: "https://fonts.gstatic.com/s/autourone/v25/UqyVK80cP25l3fJgbdfbk5lWVscxdKE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Simonetta", + filename: "x3dickHVYrCU5BU15c4BfPACvy_1BA", + category: "display", + url: "https://fonts.gstatic.com/s/simonetta/v29/x3dickHVYrCU5BU15c4BfPACvy_1BA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Tai Viet", + filename: "8QIUdj3HhN_lv4jf9vsE-9GMOLsaSPZr644fWsRO9w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstaiviet/v20/8QIUdj3HhN_lv4jf9vsE-9GMOLsaSPZr644fWsRO9w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Uchen", + filename: "nKKZ-GokGZ1baIaSEQGodLxA", + category: "serif", + url: "https://fonts.gstatic.com/s/uchen/v11/nKKZ-GokGZ1baIaSEQGodLxA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Syriac", + filename: "Ktk2AKuMeZjqPnXgyqribqzQqgW0N4O3WYZB_sU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssyriac/v18/Ktk2AKuMeZjqPnXgyqribqzQqgW0N4O3WYZB_sU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Serif Ahom", + filename: "FeVIS0hfp6cprmEUffAW_fUL_AN-wuYrPFiwaw", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifahom/v21/FeVIS0hfp6cprmEUffAW_fUL_AN-wuYrPFiwaw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Galindo", + filename: "HI_KiYMeLqVKqwyuQ5HiRp-dhpQ", + category: "display", + url: "https://fonts.gstatic.com/s/galindo/v26/HI_KiYMeLqVKqwyuQ5HiRp-dhpQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yaldevi", + filename: "cY9Ffj6VW0NMrDWtFtfgy9HTkak", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/yaldevi/v17/cY9Ffj6VW0NMrDWtFtfgy9HTkak.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Inika", + filename: "rnCm-x5X3QP-phTHRcc2s2XH", + category: "serif", + url: "https://fonts.gstatic.com/s/inika/v22/rnCm-x5X3QP-phTHRcc2s2XH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kotta One", + filename: "S6u_w41LXzPc_jlfNWqPHA3s5dwt7w", + category: "serif", + url: "https://fonts.gstatic.com/s/kottaone/v21/S6u_w41LXzPc_jlfNWqPHA3s5dwt7w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Liter", + filename: "SLXGc1nX4GQ4d2ImRJqExst1", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/liter/v4/SLXGc1nX4GQ4d2ImRJqExst1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bungee Hairline", + filename: "snfys0G548t04270a_ljTLUVrv-7YB2dQ5ZPqQ", + category: "display", + url: "https://fonts.gstatic.com/s/bungeehairline/v26/snfys0G548t04270a_ljTLUVrv-7YB2dQ5ZPqQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kumar One", + filename: "bMr1mS-P958wYi6YaGeGNO6WU3oT0g", + category: "display", + url: "https://fonts.gstatic.com/s/kumarone/v25/bMr1mS-P958wYi6YaGeGNO6WU3oT0g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Moonrocks", + filename: "845ANMAmAI2VUZMLu_W0M7HqlDHnXcD7JGy1Sw", + category: "display", + url: "https://fonts.gstatic.com/s/rubikmoonrocks/v7/845ANMAmAI2VUZMLu_W0M7HqlDHnXcD7JGy1Sw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Donegal One", + filename: "m8JWjfRYea-ZnFz6fsK9FZRFRG-K3Mud", + category: "serif", + url: "https://fonts.gstatic.com/s/donegalone/v22/m8JWjfRYea-ZnFz6fsK9FZRFRG-K3Mud.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Linden Hill", + filename: "-F61fjxoKSg9Yc3hZgO8ygFI7CwC009k", + category: "serif", + url: "https://fonts.gstatic.com/s/lindenhill/v27/-F61fjxoKSg9Yc3hZgO8ygFI7CwC009k.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DE Grund", + filename: "EJRLQhwoXdccriFurnRxqv-1MFyKy69g8Keepi2lHw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedegrund/v11/EJRLQhwoXdccriFurnRxqv-1MFyKy69g8Keepi2lHw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Canadian Aboriginal", + filename: "4C_gLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmly1RyBEnAtbz", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscanadianaboriginal/v28/4C_gLjTuEqPj-8J01CwaGkiZ9os0iGVkezM1mUT-j_Lmly1RyBEnAtbz.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Offside", + filename: "HI_KiYMWKa9QrAykQ5HiRp-dhpQ", + category: "display", + url: "https://fonts.gstatic.com/s/offside/v26/HI_KiYMWKa9QrAykQ5HiRp-dhpQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite IS", + filename: "JTUQjI4o_SGg9lecLGptrD1hziTn89dtpQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteis/v10/JTUQjI4o_SGg9lecLGptrD1hziTn89dtpQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Alumni Sans Pinstripe", + filename: "ZgNNjOFFPq_AUJD1umyS30W-Xub8zD1ObhezYrVIpcDA5w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alumnisanspinstripe/v8/ZgNNjOFFPq_AUJD1umyS30W-Xub8zD1ObhezYrVIpcDA5w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stalinist One", + filename: "MQpS-WezM9W4Dd7D3B7I-UT7eZ-UPyacPbo", + category: "display", + url: "https://fonts.gstatic.com/s/stalinistone/v58/MQpS-WezM9W4Dd7D3B7I-UT7eZ-UPyacPbo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bruno Ace SC", + filename: "ptROTiycffFLBuiHjdJDl634LSFrpe8uZA", + category: "display", + url: "https://fonts.gstatic.com/s/brunoacesc/v7/ptROTiycffFLBuiHjdJDl634LSFrpe8uZA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Meera Inimai", + filename: "845fNMM5EIqOW5MPuvO3ILep_2jDVevnLQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/meerainimai/v14/845fNMM5EIqOW5MPuvO3ILep_2jDVevnLQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Arbutus", + filename: "NaPYcZ7dG_5J3poob9JtryO8fMU", + category: "serif", + url: "https://fonts.gstatic.com/s/arbutus/v30/NaPYcZ7dG_5J3poob9JtryO8fMU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tiro Devanagari Marathi", + filename: "fC1xPZBSZHrRhS3rd4M0MAPNJUHl4znXCxAkotDrDJYM2lAZ", + category: "serif", + url: "https://fonts.gstatic.com/s/tirodevanagarimarathi/v5/fC1xPZBSZHrRhS3rd4M0MAPNJUHl4znXCxAkotDrDJYM2lAZ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Reddit Mono", + filename: "oPWL_kRmmu4oQ88oo13o49rMTDp_R2SX", + category: "monospace", + url: "https://fonts.gstatic.com/s/redditmono/v5/oPWL_kRmmu4oQ88oo13o49rMTDp_R2SX.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "TASA Orbiter", + filename: "3XFtErw3860rsdSUVZx78hPGRdbY1P1Sbg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tasaorbiter/v2/3XFtErw3860rsdSUVZx78hPGRdbY1P1Sbg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bruno Ace", + filename: "WwkcxPa2E06x4trkOj_kMKoMWNMg3Q", + category: "display", + url: "https://fonts.gstatic.com/s/brunoace/v7/WwkcxPa2E06x4trkOj_kMKoMWNMg3Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ribeye Marrow", + filename: "GFDsWApshnqMRO2JdtRZ2d0vEAwTVWgKdtw", + category: "display", + url: "https://fonts.gstatic.com/s/ribeyemarrow/v26/GFDsWApshnqMRO2JdtRZ2d0vEAwTVWgKdtw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite VN Guides", + filename: "JIAvUUlydXJZq1IQU8oDBn2CUkROHFEAfXMXfpmSLuI", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritevnguides/v1/JIAvUUlydXJZq1IQU8oDBn2CUkROHFEAfXMXfpmSLuI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Preahvihear", + filename: "6NUS8F-dNQeEYhzj7uluxswE49FJf8Wv", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/preahvihear/v32/6NUS8F-dNQeEYhzj7uluxswE49FJf8Wv.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu AU VIC WA NT Pre", + filename: "f0Xp0fWk-t0rbG8Ycr-t55aG0elTWbFeXbwD1TB_JHHY", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduauvicwantpre/v3/f0Xp0fWk-t0rbG8Ycr-t55aG0elTWbFeXbwD1TB_JHHY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Amiri Quran", + filename: "_Xmo-Hk0rD6DbUL4_vH8Zq5t7Cycsu-2", + category: "serif", + url: "https://fonts.gstatic.com/s/amiriquran/v19/_Xmo-Hk0rD6DbUL4_vH8Zq5t7Cycsu-2.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Distressed", + filename: "GFDxWBdsmnqAVqjtUsZf2dcrQ2ldcWAhatVBaGM", + category: "display", + url: "https://fonts.gstatic.com/s/rubikdistressed/v1/GFDxWBdsmnqAVqjtUsZf2dcrQ2ldcWAhatVBaGM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sedan SC", + filename: "yMJRMIlvYZ3Jn1Y30Dq8fSx5i814", + category: "serif", + url: "https://fonts.gstatic.com/s/sedansc/v2/yMJRMIlvYZ3Jn1Y30Dq8fSx5i814.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ga Maamli", + filename: "uU9NCBsQ4c-DPW1Yo3rR2t6CilKOdQ", + category: "display", + url: "https://fonts.gstatic.com/s/gamaamli/v3/uU9NCBsQ4c-DPW1Yo3rR2t6CilKOdQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Comforter", + filename: "H4clBXOCl8nQnlaql3Qa6JG8iqeuag", + category: "handwriting", + url: "https://fonts.gstatic.com/s/comforter/v9/H4clBXOCl8nQnlaql3Qa6JG8iqeuag.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Marko One", + filename: "9Btq3DFG0cnVM5lw1haaKpUfrHPzUw", + category: "serif", + url: "https://fonts.gstatic.com/s/markoone/v24/9Btq3DFG0cnVM5lw1haaKpUfrHPzUw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Micro 5", + filename: "H4cnBX2MkcfEngTr0gYQ7LO5mqc", + category: "display", + url: "https://fonts.gstatic.com/s/micro5/v2/H4cnBX2MkcfEngTr0gYQ7LO5mqc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "New Amsterdam", + filename: "YA9Vr02Y5lucHqUlbEe51kBtl7mGiv_Q7dA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/newamsterdam/v1/YA9Vr02Y5lucHqUlbEe51kBtl7mGiv_Q7dA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ewert", + filename: "va9I4kzO2tFODYBvS-J3kbDP", + category: "display", + url: "https://fonts.gstatic.com/s/ewert/v27/va9I4kzO2tFODYBvS-J3kbDP.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Encode Sans SC", + filename: "jVyT7nLwCGzQ9zE7ZyRg0QRXHOxX3AngeAXx", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/encodesanssc/v14/jVyT7nLwCGzQ9zE7ZyRg0QRXHOxX3AngeAXx.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Chela One", + filename: "6ae-4KC7Uqgdz_JZdPIy31vWNTMwoQ", + category: "display", + url: "https://fonts.gstatic.com/s/chelaone/v22/6ae-4KC7Uqgdz_JZdPIy31vWNTMwoQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Smokum", + filename: "TK3iWkUbAhopmrdGHjUHte5fKg", + category: "display", + url: "https://fonts.gstatic.com/s/smokum/v30/TK3iWkUbAhopmrdGHjUHte5fKg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Joti One", + filename: "Z9XVDmdJQAmWm9TwaYTe4u2El6GC", + category: "display", + url: "https://fonts.gstatic.com/s/jotione/v28/Z9XVDmdJQAmWm9TwaYTe4u2El6GC.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yuji Boku", + filename: "P5sAzZybeNzXsA9xj1Fkjb2r2dgvJA", + category: "serif", + url: "https://fonts.gstatic.com/s/yujiboku/v8/P5sAzZybeNzXsA9xj1Fkjb2r2dgvJA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "National Park", + filename: "GftD7vJOtg4NO-gmoY4nmcqP410fThX-Zsw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/nationalpark/v4/GftD7vJOtg4NO-gmoY4nmcqP410fThX-Zsw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Caramel", + filename: "P5sCzZKBbMTf_ShyxCRuiZ-uydg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/caramel/v8/P5sCzZKBbMTf_ShyxCRuiZ-uydg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Gothic", + filename: "TuGKUUVzXI5FBtUq5a8bj6wRbzxTFMX40kFQRx0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansgothic/v17/TuGKUUVzXI5FBtUq5a8bj6wRbzxTFMX40kFQRx0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ruwudu", + filename: "syky-y1tj6UzRKfNlQCT9tPdpw", + category: "serif", + url: "https://fonts.gstatic.com/s/ruwudu/v4/syky-y1tj6UzRKfNlQCT9tPdpw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bigelow Rules", + filename: "RrQWboly8iR_I3KWSzeRuN0zT4cCH8WAJVk", + category: "display", + url: "https://fonts.gstatic.com/s/bigelowrules/v31/RrQWboly8iR_I3KWSzeRuN0zT4cCH8WAJVk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Galdeano", + filename: "uU9MCBoQ4YOqOW1boDPx8PCOg0uX", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/galdeano/v23/uU9MCBoQ4YOqOW1boDPx8PCOg0uX.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "LXGW WenKai TC", + filename: "w8gDH20td8wNsI3f40DmtXZb48uKLd0hZzVB", + category: "handwriting", + url: "https://fonts.gstatic.com/s/lxgwwenkaitc/v9/w8gDH20td8wNsI3f40DmtXZb48uKLd0hZzVB.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lancelot", + filename: "J7acnppxBGtQEulG4JY4xJ9CGyAa", + category: "display", + url: "https://fonts.gstatic.com/s/lancelot/v28/J7acnppxBGtQEulG4JY4xJ9CGyAa.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Sinhala", + filename: "DtV-JwinQqclnZE2CnsPug9lgGC3y2Fslsq8DNibFw", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifsinhala/v30/DtV-JwinQqclnZE2CnsPug9lgGC3y2Fslsq8DNibFw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite CU", + filename: "VuJ2dNDb2p7tvoFGLMPdf9xGYTFZt0rNpQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritecu/v6/VuJ2dNDb2p7tvoFGLMPdf9xGYTFZt0rNpQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Siemreap", + filename: "Gg82N5oFbgLvHAfNl2YbnA8DLXpe", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/siemreap/v30/Gg82N5oFbgLvHAfNl2YbnA8DLXpe.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bungee Outline", + filename: "_6_mEDvmVP24UvU2MyiGDslL3Qg3YhJqPXxo", + category: "display", + url: "https://fonts.gstatic.com/s/bungeeoutline/v24/_6_mEDvmVP24UvU2MyiGDslL3Qg3YhJqPXxo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Telugu", + filename: "tDbW2pCbnkEKmXNVmt2M1q6f4HWbbiSHZ0bc5Qj9", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriftelugu/v29/tDbW2pCbnkEKmXNVmt2M1q6f4HWbbiSHZ0bc5Qj9.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Gunjala Gondi", + filename: "bWto7e7KfBziStx7lIzKPrcSMwcEnCv6DW7n5hcVXYMTK4q1", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansgunjalagondi/v21/bWto7e7KfBziStx7lIzKPrcSMwcEnCv6DW7n5hcVXYMTK4q1.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Tulpen One", + filename: "dFa6ZfeC474skLgesc0CWj0w_HyIRlE", + category: "display", + url: "https://fonts.gstatic.com/s/tulpenone/v26/dFa6ZfeC474skLgesc0CWj0w_HyIRlE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Passero One", + filename: "JTUTjIko8DOq5FeaeEAjgE5B5Arr-s50", + category: "display", + url: "https://fonts.gstatic.com/s/passeroone/v28/JTUTjIko8DOq5FeaeEAjgE5B5Arr-s50.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sixtyfour", + filename: "OD5BuMCT1numDm3nakX3rEq4DL6w2w", + category: "monospace", + url: "https://fonts.gstatic.com/s/sixtyfour/v3/OD5BuMCT1numDm3nakX3rEq4DL6w2w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Nuosu SIL", + filename: "8vIK7wM3wmRn_kc4uAjeFGxbO_zo-w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/nuosusil/v12/8vIK7wM3wmRn_kc4uAjeFGxbO_zo-w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Wellfleet", + filename: "nuF7D_LfQJb3VYgX6eyT42aLDhO2HA", + category: "serif", + url: "https://fonts.gstatic.com/s/wellfleet/v25/nuF7D_LfQJb3VYgX6eyT42aLDhO2HA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tiro Telugu", + filename: "aFTQ7PxlZWk2EPiSymjXdKSNQqn0X0BO", + category: "serif", + url: "https://fonts.gstatic.com/s/tirotelugu/v7/aFTQ7PxlZWk2EPiSymjXdKSNQqn0X0BO.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sahitya", + filename: "6qLAKZkOuhnuqlJAaScFPywEDnI", + category: "serif", + url: "https://fonts.gstatic.com/s/sahitya/v20/6qLAKZkOuhnuqlJAaScFPywEDnI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Miss Fajardose", + filename: "E21-_dn5gvrawDdPFVl-N0Ajb8qvWPaJq4no", + category: "handwriting", + url: "https://fonts.gstatic.com/s/missfajardose/v23/E21-_dn5gvrawDdPFVl-N0Ajb8qvWPaJq4no.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Koh Santepheap", + filename: "gNMdW3p6SJbwyGj2rBZyeOrTjOPhF1ixsyNJ", + category: "serif", + url: "https://fonts.gstatic.com/s/kohsantepheap/v15/gNMdW3p6SJbwyGj2rBZyeOrTjOPhF1ixsyNJ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Port Lligat Sans", + filename: "kmKmZrYrGBbdN1aV7Vokow6Lw4s4l7N0Tx4xEcQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/portlligatsans/v24/kmKmZrYrGBbdN1aV7Vokow6Lw4s4l7N0Tx4xEcQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ravi Prakash", + filename: "gokpH6fsDkVrF9Bv9X8SOAKHmNZEq6TTFw", + category: "display", + url: "https://fonts.gstatic.com/s/raviprakash/v21/gokpH6fsDkVrF9Bv9X8SOAKHmNZEq6TTFw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Piedra", + filename: "ke8kOg8aN0Bn7hTunEyHN_M3gA", + category: "display", + url: "https://fonts.gstatic.com/s/piedra/v27/ke8kOg8aN0Bn7hTunEyHN_M3gA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Iso", + filename: "x3dickHUfr-S4VAI4sABfPACvy_1BA", + category: "display", + url: "https://fonts.gstatic.com/s/rubikiso/v2/x3dickHUfr-S4VAI4sABfPACvy_1BA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Big Shoulders Stencil", + filename: "TwMQ-JIEQ1Je5sI6Bx1TKHD83rT3u3NSCfbfzYRDT8Yy-w", + category: "display", + url: "https://fonts.gstatic.com/s/bigshouldersstencil/v4/TwMQ-JIEQ1Je5sI6Bx1TKHD83rT3u3NSCfbfzYRDT8Yy-w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Felipa", + filename: "FwZa7-owz1Eu4F_wSNSEwM2zpA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/felipa/v27/FwZa7-owz1Eu4F_wSNSEwM2zpA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bacasime Antique", + filename: "tDbX2pGXkFYEykldjZSrmI6T_XWZOwStSUrV_BE", + category: "serif", + url: "https://fonts.gstatic.com/s/bacasimeantique/v1/tDbX2pGXkFYEykldjZSrmI6T_XWZOwStSUrV_BE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Geostar Fill", + filename: "AMOWz4SWuWiXFfjEohxQ9os0U1K2w9lb4g", + category: "display", + url: "https://fonts.gstatic.com/s/geostarfill/v27/AMOWz4SWuWiXFfjEohxQ9os0U1K2w9lb4g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Reem Kufi Ink", + filename: "oPWJ_kJmmu8hCvB9iFumxZSnRj5dQnSX1ko", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/reemkufiink/v11/oPWJ_kJmmu8hCvB9iFumxZSnRj5dQnSX1ko.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite HR", + filename: "WWXVljmQYQCZM5qaU_dwQYcoZybLwMVWng", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritehr/v6/WWXVljmQYQCZM5qaU_dwQYcoZybLwMVWng.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Peddana", + filename: "aFTU7PBhaX89UcKWhh2aBYyMcKw", + category: "serif", + url: "https://fonts.gstatic.com/s/peddana/v24/aFTU7PBhaX89UcKWhh2aBYyMcKw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ojuju", + filename: "7r3IqXF7v9ApbqkppYgA3LZg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ojuju/v5/7r3IqXF7v9ApbqkppYgA3LZg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite HR Lijeva", + filename: "gNMYW2dhS5-p7HvxrBYiWN2SsKqLWCrYkjtiTWz5UGA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritehrlijeva/v6/gNMYW2dhS5-p7HvxrBYiWN2SsKqLWCrYkjtiTWz5UGA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Dr Sugiyama", + filename: "HTxoL2k4N3O9n5I1boGI7abRM4-t-g7y", + category: "handwriting", + url: "https://fonts.gstatic.com/s/drsugiyama/v30/HTxoL2k4N3O9n5I1boGI7abRM4-t-g7y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mr Bedfort", + filename: "MQpR-WCtNZSWAdTMwBicliq0XZe_Iy8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mrbedfort/v23/MQpR-WCtNZSWAdTMwBicliq0XZe_Iy8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Oi", + filename: "w8gXH2EuRqtaut6yjBOG", + category: "display", + url: "https://fonts.gstatic.com/s/oi/v21/w8gXH2EuRqtaut6yjBOG.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Londrina Sketch", + filename: "c4m41npxGMTnomOHtRU68eIJn8qfWWn5Pos6CA", + category: "display", + url: "https://fonts.gstatic.com/s/londrinasketch/v27/c4m41npxGMTnomOHtRU68eIJn8qfWWn5Pos6CA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Diplomata SC", + filename: "buExpoi3ecvs3kidKgBJo2kf-P5Oaiw4cw", + category: "display", + url: "https://fonts.gstatic.com/s/diplomatasc/v30/buExpoi3ecvs3kidKgBJo2kf-P5Oaiw4cw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bahiana", + filename: "uU9PCBUV4YenPWJU7xPb3vyHmlI", + category: "display", + url: "https://fonts.gstatic.com/s/bahiana/v25/uU9PCBUV4YenPWJU7xPb3vyHmlI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Luxurious Roman", + filename: "buEupou_ZcP1w0yTKxJJokVSmbpqYgckeo9RMw", + category: "display", + url: "https://fonts.gstatic.com/s/luxuriousroman/v10/buEupou_ZcP1w0yTKxJJokVSmbpqYgckeo9RMw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Flow Rounded", + filename: "-zki91mtwsU9qlLiGwD4oQX3oZX-Xup87g", + category: "display", + url: "https://fonts.gstatic.com/s/flowrounded/v15/-zki91mtwsU9qlLiGwD4oQX3oZX-Xup87g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anek Gurmukhi", + filename: "0QImMXRO_YSkA0quVLY79JnH07z8_ApHqqk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anekgurmukhi/v13/0QImMXRO_YSkA0quVLY79JnH07z8_ApHqqk.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Purple Purse", + filename: "qWctB66gv53iAp-Vfs4My6qyeBb_ujA4ug", + category: "display", + url: "https://fonts.gstatic.com/s/purplepurse/v25/qWctB66gv53iAp-Vfs4My6qyeBb_ujA4ug.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lugrasimo", + filename: "qkBXXvoF_s_eT9c7Y7ae5JRLkAXbMQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/lugrasimo/v5/qkBXXvoF_s_eT9c7Y7ae5JRLkAXbMQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Griffy", + filename: "FwZa7-ox2FQh9kfwSNSEwM2zpA", + category: "display", + url: "https://fonts.gstatic.com/s/griffy/v23/FwZa7-ox2FQh9kfwSNSEwM2zpA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Monomakh", + filename: "Wnz4HAk3Yh_SC3FACTYdiArcPRKo", + category: "display", + url: "https://fonts.gstatic.com/s/monomakh/v1/Wnz4HAk3Yh_SC3FACTYdiArcPRKo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fleur De Leah", + filename: "AYCNpXX7ftYZWLhv9UmPJTMC5vat4I_Gdq0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/fleurdeleah/v11/AYCNpXX7ftYZWLhv9UmPJTMC5vat4I_Gdq0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Grey Qo", + filename: "BXRrvF_Nmv_TyXxNDOtQ9Wf0QcE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/greyqo/v11/BXRrvF_Nmv_TyXxNDOtQ9Wf0QcE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Updock", + filename: "nuF4D_3dVZ70UI9SjLK3602XBw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/updock/v7/nuF4D_3dVZ70UI9SjLK3602XBw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hubballi", + filename: "o-0JIpUj3WIZ1RFN56B7yBBNYuSF", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hubballi/v10/o-0JIpUj3WIZ1RFN56B7yBBNYuSF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Grechen Fuemen", + filename: "vEFI2_tHEQ4d5ObgKxBzZh0MAWgc-NaXXq7H", + category: "handwriting", + url: "https://fonts.gstatic.com/s/grechenfuemen/v11/vEFI2_tHEQ4d5ObgKxBzZh0MAWgc-NaXXq7H.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jersey 20", + filename: "ZgNRjP1ON6jeW4D12z3crE_qP4mXuQ", + category: "display", + url: "https://fonts.gstatic.com/s/jersey20/v4/ZgNRjP1ON6jeW4D12z3crE_qP4mXuQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lakki Reddy", + filename: "S6u5w49MUSzD9jlCPmvLZQfox9k97-xZ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/lakkireddy/v25/S6u5w49MUSzD9jlCPmvLZQfox9k97-xZ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Fruktur", + filename: "SZc53FHsOru5QYsMfz3GkUrS8DI", + category: "display", + url: "https://fonts.gstatic.com/s/fruktur/v28/SZc53FHsOru5QYsMfz3GkUrS8DI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gasoek One", + filename: "EJRTQgQ_UMUKvDgnlX80zrq_cyb-vco", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gasoekone/v3/EJRTQgQ_UMUKvDgnlX80zrq_cyb-vco.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Federant", + filename: "2sDdZGNfip_eirT0_U0jRUG0AqUc", + category: "display", + url: "https://fonts.gstatic.com/s/federant/v31/2sDdZGNfip_eirT0_U0jRUG0AqUc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libre Barcode EAN13 Text", + filename: "wlpigxXFDU1_oCu9nfZytgIqSG0XRcJm_OQiB96PAGEki52WfA", + category: "display", + url: "https://fonts.gstatic.com/s/librebarcodeean13text/v25/wlpigxXFDU1_oCu9nfZytgIqSG0XRcJm_OQiB96PAGEki52WfA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Inspiration", + filename: "x3dkckPPZa6L4wIg5cZOEvoGnSrlBBsy", + category: "handwriting", + url: "https://fonts.gstatic.com/s/inspiration/v7/x3dkckPPZa6L4wIg5cZOEvoGnSrlBBsy.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Konkhmer Sleokchher", + filename: "_Xmw-GE-rjmabA_M-aPOZOsCrUv825LFI3507E0d-W0", + category: "display", + url: "https://fonts.gstatic.com/s/konkhmersleokchher/v3/_Xmw-GE-rjmabA_M-aPOZOsCrUv825LFI3507E0d-W0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Alumni Sans Collegiate One", + filename: "MQpB-XChK8G5CtmK_AuGxQrdNvPSXkn0RM-XqjWWhjdayDiPw2ta", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alumnisanscollegiateone/v7/MQpB-XChK8G5CtmK_AuGxQrdNvPSXkn0RM-XqjWWhjdayDiPw2ta.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Armenian", + filename: "3XF2EqMt3YoFsciDRZxptyCUKJmytZ0kT0S1UL5Ayp0", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifarmenian/v30/3XF2EqMt3YoFsciDRZxptyCUKJmytZ0kT0S1UL5Ayp0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Moderustic", + filename: "2-ci9J9s3o6eLFNHFdXYcu7XoZFDf2Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/moderustic/v3/2-ci9J9s3o6eLFNHFdXYcu7XoZFDf2Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bitcount Prop Single", + filename: "-W_hXIv9SyXT0xz0E9pIHCxbW8ZMGEVdhyQegDsC0Np2", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountpropsingle/v3/-W_hXIv9SyXT0xz0E9pIHCxbW8ZMGEVdhyQegDsC0Np2.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Blaka", + filename: "8vIG7w8722p_6kdr20D2FV5e", + category: "display", + url: "https://fonts.gstatic.com/s/blaka/v8/8vIG7w8722p_6kdr20D2FV5e.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kirang Haerang", + filename: "E21-_dn_gvvIjhYON1lpIU4-bcqvWPaJq4no", + category: "display", + url: "https://fonts.gstatic.com/s/kiranghaerang/v22/E21-_dn_gvvIjhYON1lpIU4-bcqvWPaJq4no.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Princess Sofia", + filename: "qWczB6yguIb8DZ_GXZst16n7GRz7mDUoupoI", + category: "handwriting", + url: "https://fonts.gstatic.com/s/princesssofia/v27/qWczB6yguIb8DZ_GXZst16n7GRz7mDUoupoI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Butcherman", + filename: "2EbiL-thF0loflXUBOdb1zWzq_5uT84", + category: "display", + url: "https://fonts.gstatic.com/s/butcherman/v25/2EbiL-thF0loflXUBOdb1zWzq_5uT84.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "GFS Neohellenic", + filename: "8QIRdiDOrfiq0b7R8O1Iw9WLcY5TLahP46UDUw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gfsneohellenic/v27/8QIRdiDOrfiq0b7R8O1Iw9WLcY5TLahP46UDUw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mozilla Headline", + filename: "QGY1z-UXahmCOps4kyMKGuSA9pYtwfjcL4mVDik", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mozillaheadline/v1/QGY1z-UXahmCOps4kyMKGuSA9pYtwfjcL4mVDik.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Explora", + filename: "tsstApxFfjUH4wrvc1qPonC3vqc", + category: "handwriting", + url: "https://fonts.gstatic.com/s/explora/v11/tsstApxFfjUH4wrvc1qPonC3vqc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Anek Odia", + filename: "TK3hWkoJARApz5UCd34jvcVDI5S01g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/anekodia/v17/TK3hWkoJARApz5UCd34jvcVDI5S01g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Neonderthaw", + filename: "Iure6Yx5-oWVZI0r-17AeZZJprVA4XQ0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/neonderthaw/v8/Iure6Yx5-oWVZI0r-17AeZZJprVA4XQ0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Manufacturing Consent", + filename: "N0bL2TVONuFkPkuHfiECSLCwuZS-D-IsakikR6QvbfFYLA", + category: "display", + url: "https://fonts.gstatic.com/s/manufacturingconsent/v1/N0bL2TVONuFkPkuHfiECSLCwuZS-D-IsakikR6QvbfFYLA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Rashi Hebrew", + filename: "EJRMQh82XsIK-QFmqXk4zvLwFVya0utA2omSrzS8", + category: "serif", + url: "https://fonts.gstatic.com/s/notorashihebrew/v28/EJRMQh82XsIK-QFmqXk4zvLwFVya0utA2omSrzS8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Mrs Sheppards", + filename: "PN_2Rfm9snC0XUGoEZhb91ig3vjxynMix4Y", + category: "handwriting", + url: "https://fonts.gstatic.com/s/mrssheppards/v25/PN_2Rfm9snC0XUGoEZhb91ig3vjxynMix4Y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Revalia", + filename: "WwkexPimBE2-4ZPEeVruNIgJSNM", + category: "display", + url: "https://fonts.gstatic.com/s/revalia/v24/WwkexPimBE2-4ZPEeVruNIgJSNM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tiro Gurmukhi", + filename: "x3dmckXSYq-Uqjc048JUF7Jvly7HAQsyA2Y", + category: "serif", + url: "https://fonts.gstatic.com/s/tirogurmukhi/v6/x3dmckXSYq-Uqjc048JUF7Jvly7HAQsyA2Y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU NSW", + filename: "6qLbKY4NtxD-qVlIPUIPenElWCCEQxMAZkM1pn4", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteaunsw/v11/6qLbKY4NtxD-qVlIPUIPenElWCCEQxMAZkM1pn4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Praise", + filename: "qkBUXvUZ-cnFXcFyDvO67L9XmQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/praise/v9/qkBUXvUZ-cnFXcFyDvO67L9XmQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Babylonica", + filename: "5aUw9_i2qxWVCAE2aHjTqDJ0-VVMoEw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/babylonica/v7/5aUw9_i2qxWVCAE2aHjTqDJ0-VVMoEw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Momo Trust Display", + filename: "WWXPlieNYgyPZLyBUuEkKZFhFHyjqb1un2xNNgNa1A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/momotrustdisplay/v2/WWXPlieNYgyPZLyBUuEkKZFhFHyjqb1un2xNNgNa1A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jim Nightshade", + filename: "PlIkFlu9Pb08Q8HLM1PxmB0g-OS4V3qKaMxD", + category: "handwriting", + url: "https://fonts.gstatic.com/s/jimnightshade/v21/PlIkFlu9Pb08Q8HLM1PxmB0g-OS4V3qKaMxD.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU QLD", + filename: "SlGLmR-Yo5oYZX5BFVcEwSFSOXBRQgviqWC_O7Y", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteauqld/v11/SlGLmR-Yo5oYZX5BFVcEwSFSOXBRQgviqWC_O7Y.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ranga", + filename: "C8ct4cYisGb28p6CLDwZwmGE", + category: "display", + url: "https://fonts.gstatic.com/s/ranga/v22/C8ct4cYisGb28p6CLDwZwmGE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Lao", + filename: "3y946bYwcCjmsU8JEzCMxEwQfFpAsZsUPves", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriflao/v29/3y946bYwcCjmsU8JEzCMxEwQfFpAsZsUPves.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bonbon", + filename: "0FlVVPeVlFec4ee_cDEAbQY5-A", + category: "handwriting", + url: "https://fonts.gstatic.com/s/bonbon/v32/0FlVVPeVlFec4ee_cDEAbQY5-A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Gemstones", + filename: "zrf90HrL0-_8Xb4DFM2rUkWbOVrOiCnGqi1GMw", + category: "display", + url: "https://fonts.gstatic.com/s/rubikgemstones/v1/zrf90HrL0-_8Xb4DFM2rUkWbOVrOiCnGqi1GMw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Wittgenstein", + filename: "WBLirEDOakJCHParhXGwMgvoLOqtgE5h0A", + category: "serif", + url: "https://fonts.gstatic.com/s/wittgenstein/v4/WBLirEDOakJCHParhXGwMgvoLOqtgE5h0A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Dhurjati", + filename: "_6_8ED3gSeatXfFiFX3ySKQtuTA2", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/dhurjati/v27/_6_8ED3gSeatXfFiFX3ySKQtuTA2.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tiro Kannada", + filename: "CSR44ztKmvqaDxEDJFY7CIYKSPl6tOU9Eg", + category: "serif", + url: "https://fonts.gstatic.com/s/tirokannada/v6/CSR44ztKmvqaDxEDJFY7CIYKSPl6tOU9Eg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Emblema One", + filename: "nKKT-GQ0F5dSY8vzG0rOEIRBHl57G_f_", + category: "display", + url: "https://fonts.gstatic.com/s/emblemaone/v22/nKKT-GQ0F5dSY8vzG0rOEIRBHl57G_f_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cascadia Code", + filename: "qWcsB6-zq5zxD57cT5s916v3WDzRtjkho4M", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cascadiacode/v5/qWcsB6-zq5zxD57cT5s916v3WDzRtjkho4M.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Asta Sans", + filename: "XoHk2Y74XaWovvhMb0ctv_0tnjvw8Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/astasans/v3/XoHk2Y74XaWovvhMb0ctv_0tnjvw8Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Alumni Sans Inline One", + filename: "RrQBbpJx9zZ3IXTBOASKp5gJAetBdaihcjbpD3AZcr7xbYw", + category: "display", + url: "https://fonts.gstatic.com/s/alumnisansinlineone/v7/RrQBbpJx9zZ3IXTBOASKp5gJAetBdaihcjbpD3AZcr7xbYw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Gujarati", + filename: "hESt6WBlOixO-3OJ1FTmTsmqlBRUJBVkaAhpVQOwBDU", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifgujarati/v29/hESt6WBlOixO-3OJ1FTmTsmqlBRUJBVkaAhpVQOwBDU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Agu Display", + filename: "iJWABXKbbi6BeMC1_RX7qEXexox2ztOU", + category: "display", + url: "https://fonts.gstatic.com/s/agudisplay/v3/iJWABXKbbi6BeMC1_RX7qEXexox2ztOU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Langar", + filename: "kJEyBukW7AIlgjGVrTVZ99sqrQ", + category: "display", + url: "https://fonts.gstatic.com/s/langar/v30/kJEyBukW7AIlgjGVrTVZ99sqrQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Dai Banna SIL", + filename: "lW-4wj0AJWmpwGyJ2uEoA4I7jS6AKsLhJgo", + category: "serif", + url: "https://fonts.gstatic.com/s/daibannasil/v2/lW-4wj0AJWmpwGyJ2uEoA4I7jS6AKsLhJgo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mozilla Text", + filename: "SZc-3FrnJ7S7WZIff2mJ7Tbz6BlLiCcUGA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mozillatext/v1/SZc-3FrnJ7S7WZIff2mJ7Tbz6BlLiCcUGA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "BioRhyme Expanded", + filename: "i7dQIE1zZzytGswgU577CDY9LjbffySURXCPYsje", + category: "serif", + url: "https://fonts.gstatic.com/s/biorhymeexpanded/v23/i7dQIE1zZzytGswgU577CDY9LjbffySURXCPYsje.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hanalei Fill", + filename: "fC1mPYtObGbfyQznIaQzPQiMVwLBplm9aw", + category: "display", + url: "https://fonts.gstatic.com/s/hanaleifill/v23/fC1mPYtObGbfyQznIaQzPQiMVwLBplm9aw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chenla", + filename: "SZc43FDpIKu8WZ9eXxfonUPL6Q", + category: "display", + url: "https://fonts.gstatic.com/s/chenla/v25/SZc43FDpIKu8WZ9eXxfonUPL6Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aref Ruqaa Ink", + filename: "1q2fY5WOGUFlt84GTOkP6Kdx72ThVIGpgnxL", + category: "serif", + url: "https://fonts.gstatic.com/s/arefruqaaink/v11/1q2fY5WOGUFlt84GTOkP6Kdx72ThVIGpgnxL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playpen Sans Arabic", + filename: "KtkxAKiSeo38bkPvhIqjU6aCgha2der-fY5q4szgE-Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playpensansarabic/v8/KtkxAKiSeo38bkPvhIqjU6aCgha2der-fY5q4szgE-Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Hanunoo", + filename: "f0Xs0fCv8dxkDWlZSoXOj6CphMloFsEsEpgL_ix2", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanshanunoo/v22/f0Xs0fCv8dxkDWlZSoXOj6CphMloFsEsEpgL_ix2.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Khmer", + filename: "-F6qfidqLzI2JPCkXAO2hmogq0148ldPg-IUDNg", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifkhmer/v29/-F6qfidqLzI2JPCkXAO2hmogq0148ldPg-IUDNg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Flavors", + filename: "FBV2dDrhxqmveJTpbkzlNqkG9UY", + category: "display", + url: "https://fonts.gstatic.com/s/flavors/v28/FBV2dDrhxqmveJTpbkzlNqkG9UY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kumar One Outline", + filename: "Noao6VH62pyLP0fsrZ-v18wlUEcX9zDwRQu8EGKF", + category: "display", + url: "https://fonts.gstatic.com/s/kumaroneoutline/v20/Noao6VH62pyLP0fsrZ-v18wlUEcX9zDwRQu8EGKF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Tamil", + filename: "LYjZdHr-klIgTfc40komjQ5OObazeJSY8z6Np1k", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriftamil/v31/LYjZdHr-klIgTfc40komjQ5OObazeJSY8z6Np1k.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Oldenburg", + filename: "fC1jPY5JYWzbywv7c4V6UU6oXyndrw", + category: "display", + url: "https://fonts.gstatic.com/s/oldenburg/v24/fC1jPY5JYWzbywv7c4V6UU6oXyndrw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sirin Stencil", + filename: "mem4YaWwznmLx-lzGfN7MdRydchGBq6al6o", + category: "display", + url: "https://fonts.gstatic.com/s/sirinstencil/v27/mem4YaWwznmLx-lzGfN7MdRydchGBq6al6o.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gidugu", + filename: "L0x8DFMkk1Uf6w3RvPCmRSlUig", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gidugu/v28/L0x8DFMkk1Uf6w3RvPCmRSlUig.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libertinus Math", + filename: "Gw6iwc3770TVMoHVurPejWtfenRLv_KJt3R-2Q", + category: "display", + url: "https://fonts.gstatic.com/s/libertinusmath/v1/Gw6iwc3770TVMoHVurPejWtfenRLv_KJt3R-2Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Molle", + filename: "E21n_dL5hOXFhWEsXzgmVydREus", + category: "handwriting", + url: "https://fonts.gstatic.com/s/molle/v25/E21n_dL5hOXFhWEsXzgmVydREus.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Diplomata", + filename: "Cn-0JtiMXwhNwp-wKxyfYGxYrdM9Sg", + category: "display", + url: "https://fonts.gstatic.com/s/diplomata/v33/Cn-0JtiMXwhNwp-wKxyfYGxYrdM9Sg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Foldit", + filename: "aFTV7PF3Y3c9WdjXpje0CYWVaQ", + category: "display", + url: "https://fonts.gstatic.com/s/foldit/v8/aFTV7PF3Y3c9WdjXpje0CYWVaQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ponomar", + filename: "or3iQ6zp3fKD2wImbJTdArg8hzo", + category: "display", + url: "https://fonts.gstatic.com/s/ponomar/v5/or3iQ6zp3fKD2wImbJTdArg8hzo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ancizar Serif", + filename: "PN_2RfmxrmD9dEi_Qbtf91W13vjxynMix4Y", + category: "serif", + url: "https://fonts.gstatic.com/s/ancizarserif/v8/PN_2RfmxrmD9dEi_Qbtf91W13vjxynMix4Y.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Iansui", + filename: "w8gbH2UoTuUp5bOajSGD1FcXoQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/iansui/v8/w8gbH2UoTuUp5bOajSGD1FcXoQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Snippet", + filename: "bWt47f7XfQH9Gupu2v_Afcp9QWc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/snippet/v21/bWt47f7XfQH9Gupu2v_Afcp9QWc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jacquard 24", + filename: "jVyO7nf_B2zO5jVpUGU8lgQEdchf9xXp", + category: "display", + url: "https://fonts.gstatic.com/s/jacquard24/v4/jVyO7nf_B2zO5jVpUGU8lgQEdchf9xXp.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Arsenal SC", + filename: "x3dlckLHea6e5BEtsfxiXNossybsHQI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/arsenalsc/v1/x3dlckLHea6e5BEtsfxiXNossybsHQI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libertinus Sans", + filename: "YA9Lr0-a6k7ZLbw_dle4knJh2cqMjt3V_dB0Yw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/libertinussans/v1/YA9Lr0-a6k7ZLbw_dle4knJh2cqMjt3V_dB0Yw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tsukimi Rounded", + filename: "sJoc3LJNksWZO0LvnZwkF3HtoB7tPXMOP5gP1A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tsukimirounded/v14/sJoc3LJNksWZO0LvnZwkF3HtoB7tPXMOP5gP1A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bungee Tint", + filename: "J7abnpl_EGtUEuAJwN9WmrtKMDwTpTkB", + category: "display", + url: "https://fonts.gstatic.com/s/bungeetint/v3/J7abnpl_EGtUEuAJwN9WmrtKMDwTpTkB.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite PL", + filename: "0QInMXVf_4C2VH-yUr5uz72O85bS8ANesw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritepl/v10/0QInMXVf_4C2VH-yUr5uz72O85bS8ANesw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Trochut", + filename: "CHyjV-fDDlP9bDIw5nSIfVIPLns", + category: "display", + url: "https://fonts.gstatic.com/s/trochut/v24/CHyjV-fDDlP9bDIw5nSIfVIPLns.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Annapurna SIL", + filename: "yYLv0hDY0f2iu9tPmRWtllid8NN9dZT_PZs", + category: "serif", + url: "https://fonts.gstatic.com/s/annapurnasil/v2/yYLv0hDY0f2iu9tPmRWtllid8NN9dZT_PZs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Epunda Sans", + filename: "ea8dads_Rv3-GJfWRrHjgE5Fq16dCmit", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/epundasans/v5/ea8dads_Rv3-GJfWRrHjgE5Fq16dCmit.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Edu VIC WA NT Beginner", + filename: "jizLRF1BuW9OwcnNPxLl4KfZCHd9nFtd5Tu7qNuL4o61H_E", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduvicwantbeginner/v6/jizLRF1BuW9OwcnNPxLl4KfZCHd9nFtd5Tu7qNuL4o61H_E.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Love Light", + filename: "t5tlIR0TNJyZWimpNAXDjKbCyTHuspo", + category: "handwriting", + url: "https://fonts.gstatic.com/s/lovelight/v8/t5tlIR0TNJyZWimpNAXDjKbCyTHuspo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chiron GoRound TC", + filename: "tss3AopDbiwZ4xauFDX3yQ3Ywoaj6lla8dMgPgBu", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/chirongoroundtc/v3/tss3AopDbiwZ4xauFDX3yQ3Ywoaj6lla8dMgPgBu.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Science Gothic", + filename: "CHy6V-7EH1X7aiQh5jPNDTJnVVokpGVYYYVm", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sciencegothic/v5/CHy6V-7EH1X7aiQh5jPNDTJnVVokpGVYYYVm.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Diphylleia", + filename: "DtVmJxCtRKMixK4_HXsIulwm6gDXvwE", + category: "serif", + url: "https://fonts.gstatic.com/s/diphylleia/v2/DtVmJxCtRKMixK4_HXsIulwm6gDXvwE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite US Modern", + filename: "H4c7BWmRlMXPhla3hmMaveiYz8nSDkIFNtk6Z7xL8AQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteusmodern/v11/H4c7BWmRlMXPhla3hmMaveiYz8nSDkIFNtk6Z7xL8AQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Anatolian Hieroglyphs", + filename: "ijw9s4roRME5LLRxjsRb8A0gKPSWq4BbDmHHu6j2pEtUJzZWXybIymc5QYo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansanatolianhieroglyphs/v17/ijw9s4roRME5LLRxjsRb8A0gKPSWq4BbDmHHu6j2pEtUJzZWXybIymc5QYo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Javanese", + filename: "2V0AKJkDAIA6Hp4zoSScDjV0Y-eoHAHJ8r88Rp29eA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansjavanese/v25/2V0AKJkDAIA6Hp4zoSScDjV0Y-eoHAHJ8r88Rp29eA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Multani", + filename: "9Bty3ClF38_RfOpe1gCaZ8p30BOFO1A0pfCs5Kos", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmultani/v22/9Bty3ClF38_RfOpe1gCaZ8p30BOFO1A0pfCs5Kos.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Send Flowers", + filename: "If2PXTjtZS-0Xqy13uCQSULvxwjjouU1iw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sendflowers/v7/If2PXTjtZS-0Xqy13uCQSULvxwjjouU1iw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Twinkle Star", + filename: "pe0pMI6IL4dPoFl9LGEmY6WaA_Rue1UwVg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/twinklestar/v8/pe0pMI6IL4dPoFl9LGEmY6WaA_Rue1UwVg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Tibetan", + filename: "gokzH7nwAEdtF9N45n0Vaz7O-pk0wsvrFsIn6WYDvA", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriftibetan/v24/gokzH7nwAEdtF9N45n0Vaz7O-pk0wsvrFsIn6WYDvA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Geom", + filename: "X7nq4bw6Cf6jwtSdHC67_M0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/geom/v1/X7nq4bw6Cf6jwtSdHC67_M0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ancizar Sans", + filename: "fC1mPYtHY2vX3wj8IbE7PxeMVwLBplm9aw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/ancizarsans/v8/fC1mPYtHY2vX3wj8IbE7PxeMVwLBplm9aw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Flow Block", + filename: "wlp0gwfPCEB65UmTk-d6-WZlbCBXE_I", + category: "display", + url: "https://fonts.gstatic.com/s/flowblock/v15/wlp0gwfPCEB65UmTk-d6-WZlbCBXE_I.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Are You Serious", + filename: "ll8kK2GVSSr-PtjQ5nONVcNn4306hT9nCGRayg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/areyouserious/v14/ll8kK2GVSSr-PtjQ5nONVcNn4306hT9nCGRayg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "WDXL Lubrifont JP N", + filename: "8At1GtSkFqazDiO949fzWta9_T-SVxJiIZctoLhFRNU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/wdxllubrifontjpn/v2/8At1GtSkFqazDiO949fzWta9_T-SVxJiIZctoLhFRNU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Story Script", + filename: "mem5YaSw02SQ0OlzDuR8Isk-VeJoCqeDjg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/storyscript/v3/mem5YaSw02SQ0OlzDuR8Isk-VeJoCqeDjg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "LXGW WenKai Mono TC", + filename: "pxiYyos4iPVgyWx9WtufHnsIf5nkaB0Him6CovpOkXA", + category: "monospace", + url: "https://fonts.gstatic.com/s/lxgwwenkaimonotc/v9/pxiYyos4iPVgyWx9WtufHnsIf5nkaB0Him6CovpOkXA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Syloti Nagri", + filename: "uU9eCAQZ75uhfF9UoWDRiY3q7Sf_VFV3m4dGFVfxN87gsj0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssylotinagri/v25/uU9eCAQZ75uhfF9UoWDRiY3q7Sf_VFV3m4dGFVfxN87gsj0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "TASA Explorer", + filename: "K2F3fZdAt8xjBmxMCPK8UO_SJyrYYWOMluQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tasaexplorer/v2/K2F3fZdAt8xjBmxMCPK8UO_SJyrYYWOMluQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Lunasima", + filename: "wEO-EBvPh9RSOj7JFAwle94H1VIe", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lunasima/v1/wEO-EBvPh9RSOj7JFAwle94H1VIe.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Glitch Pop", + filename: "tDbX2pGHhFcM0gB3hN2elZLa3G-MOwStSUrV_BE", + category: "display", + url: "https://fonts.gstatic.com/s/rubikglitchpop/v1/tDbX2pGHhFcM0gB3hN2elZLa3G-MOwStSUrV_BE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Gidole", + filename: "sZlFdR6O8zVVEiMaCJtWS6EPcA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/gidole/v24/sZlFdR6O8zVVEiMaCJtWS6EPcA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Vinyl", + filename: "iJWABXKIfDnIV4mQ5BfjvUXexox2ztOU", + category: "display", + url: "https://fonts.gstatic.com/s/rubikvinyl/v1/iJWABXKIfDnIV4mQ5BfjvUXexox2ztOU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU SA", + filename: "YcmusZpNS1SdgmHbGgtRuUElnR3YkgJJtci2kA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteausa/v11/YcmusZpNS1SdgmHbGgtRuUElnR3YkgJJtci2kA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Libertinus Serif", + filename: "RLpkK4bw7KinajYBg0RTTwCLF5Ben6kFUHPIFaU", + category: "serif", + url: "https://fonts.gstatic.com/s/libertinusserif/v1/RLpkK4bw7KinajYBg0RTTwCLF5Ben6kFUHPIFaU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sedan", + filename: "Yq6a-L-VVyD6-eOSiTpovf5b", + category: "serif", + url: "https://fonts.gstatic.com/s/sedan/v1/Yq6a-L-VVyD6-eOSiTpovf5b.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kings", + filename: "8AtnGsK4O5CYXU_Iq6GSPaHS", + category: "handwriting", + url: "https://fonts.gstatic.com/s/kings/v9/8AtnGsK4O5CYXU_Iq6GSPaHS.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sassy Frass", + filename: "LhWhMVrGOe0FLb97BjhsE99dGNWQg_am", + category: "handwriting", + url: "https://fonts.gstatic.com/s/sassyfrass/v9/LhWhMVrGOe0FLb97BjhsE99dGNWQg_am.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Coptic", + filename: "iJWfBWmUZi_OHPqn4wq6kgqumOEd78u_VG0xR4Y", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscoptic/v22/iJWfBWmUZi_OHPqn4wq6kgqumOEd78u_VG0xR4Y.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Spray Paint", + filename: "WnzhHBAoeBPUDTB4EWR82y6EXWPH-Ro-QoaBZQxP", + category: "display", + url: "https://fonts.gstatic.com/s/rubikspraypaint/v1/WnzhHBAoeBPUDTB4EWR82y6EXWPH-Ro-QoaBZQxP.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Labrada", + filename: "ieV-2Y9HLWefIpOyDV5ALUIfEcs", + category: "serif", + url: "https://fonts.gstatic.com/s/labrada/v4/ieV-2Y9HLWefIpOyDV5ALUIfEcs.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bahianita", + filename: "yYLr0hTb3vuqqsBUgxWtxTvV2NJPcA", + category: "display", + url: "https://fonts.gstatic.com/s/bahianita/v23/yYLr0hTb3vuqqsBUgxWtxTvV2NJPcA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Aubrey", + filename: "q5uGsou7NPBw-p7vugNsCxVEgA", + category: "display", + url: "https://fonts.gstatic.com/s/aubrey/v29/q5uGsou7NPBw-p7vugNsCxVEgA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Butterfly Kids", + filename: "ll8lK2CWTjuqAsXDqlnIbMNs5S4arxFrAX1D", + category: "handwriting", + url: "https://fonts.gstatic.com/s/butterflykids/v27/ll8lK2CWTjuqAsXDqlnIbMNs5S4arxFrAX1D.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Mongolian", + filename: "VdGCAYADGIwE0EopZx8xQfHlgEAMsrToxLsg6-av1x0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmongolian/v23/VdGCAYADGIwE0EopZx8xQfHlgEAMsrToxLsg6-av1x0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu SA Hand", + filename: "mem6YaOmw37C-ogAJfd7Np0ef8xkA76a", + category: "handwriting", + url: "https://fonts.gstatic.com/s/edusahand/v3/mem6YaOmw37C-ogAJfd7Np0ef8xkA76a.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Huninn", + filename: "OpNNnoINg9bQ4xkpjiHQjittXw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/huninn/v2/OpNNnoINg9bQ4xkpjiHQjittXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Redacted Script", + filename: "ypvBbXGRglhokR7dcC3d1-R6zmxSsWTxkZkr_g", + category: "display", + url: "https://fonts.gstatic.com/s/redactedscript/v12/ypvBbXGRglhokR7dcC3d1-R6zmxSsWTxkZkr_g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Snowburst One", + filename: "MQpS-WezKdujBsXY3B7I-UT7eZ-UPyacPbo", + category: "display", + url: "https://fonts.gstatic.com/s/snowburstone/v21/MQpS-WezKdujBsXY3B7I-UT7eZ-UPyacPbo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu NSW ACT Foundation", + filename: "raxsHjqJtsNBFUi8WO0vUBgc9D-2lV_oQdCAeFBdseyoHko", + category: "handwriting", + url: "https://fonts.gstatic.com/s/edunswactfoundation/v5/raxsHjqJtsNBFUi8WO0vUBgc9D-2lV_oQdCAeFBdseyoHko.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "BBH Bartle", + filename: "zYXjKVYuMYMaN-IMqP3RSm6Tkd7_CIc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bbhbartle/v1/zYXjKVYuMYMaN-IMqP3RSm6Tkd7_CIc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Danfo", + filename: "snfks0u_98t16SvUCZwCP8F9", + category: "serif", + url: "https://fonts.gstatic.com/s/danfo/v5/snfks0u_98t16SvUCZwCP8F9.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Taprom", + filename: "UcCn3F82JHycULbFQyk3-0kvHg", + category: "display", + url: "https://fonts.gstatic.com/s/taprom/v29/UcCn3F82JHycULbFQyk3-0kvHg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU TAS", + filename: "Gfte7u9QuxsdI_QuuctXue3Elxxmah3VesW0BfM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteautas/v11/Gfte7u9QuxsdI_QuuctXue3Elxxmah3VesW0BfM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rubik Beastly", + filename: "0QImMXRd5oOmSC2ZQ7o9653X07z8_ApHqqk", + category: "display", + url: "https://fonts.gstatic.com/s/rubikbeastly/v11/0QImMXRd5oOmSC2ZQ7o9653X07z8_ApHqqk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chiron Hei HK", + filename: "wXKtE3MSr44vpVKPvzqVJaxhvXcZsdDTlos", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/chironheihk/v4/wXKtE3MSr44vpVKPvzqVJaxhvXcZsdDTlos.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Geostar", + filename: "sykz-yx4n701VLOftSq9-trEvlQ", + category: "display", + url: "https://fonts.gstatic.com/s/geostar/v27/sykz-yx4n701VLOftSq9-trEvlQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Burned", + filename: "Jqzk5TmOVOqQHihKqPpscqniHQuaCY5ZSg", + category: "display", + url: "https://fonts.gstatic.com/s/rubikburned/v1/Jqzk5TmOVOqQHihKqPpscqniHQuaCY5ZSg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Adlam", + filename: "neITzCCpqp0s5pPusPamd81eMfjVqVkarSqSwA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansadlam/v27/neITzCCpqp0s5pPusPamd81eMfjVqVkarSqSwA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Ubuntu Sans Mono", + filename: "jVyR7mzgBHrR5yE7ZyRg0QRJMKI41g3CfRXxWZQ", + category: "monospace", + url: "https://fonts.gstatic.com/s/ubuntusansmono/v3/jVyR7mzgBHrR5yE7ZyRg0QRJMKI41g3CfRXxWZQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Combo", + filename: "BXRlvF3Jh_fIhg0iBu9y8Hf0", + category: "display", + url: "https://fonts.gstatic.com/s/combo/v22/BXRlvF3Jh_fIhg0iBu9y8Hf0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cascadia Mono", + filename: "TUZ2zw5pquJF3iuizJDZYqr1WZUt0W3r4WA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cascadiamono/v5/TUZ2zw5pquJF3iuizJDZYqr1WZUt0W3r4WA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite GB S", + filename: "oPWW_kFkk-s1Xclhmlemy7jsNR53bHiez1PV", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritegbs/v12/oPWW_kFkk-s1Xclhmlemy7jsNR53bHiez1PV.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Serif Tangut", + filename: "xn76YGc72GKoTvER4Gn3b4m9Ern7Em41fcvN2KT4", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriftangut/v19/xn76YGc72GKoTvER4Gn3b4m9Ern7Em41fcvN2KT4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu AU VIC WA NT Dots", + filename: "S6ujw5FFVDKI3kwwDUbsPHCpzZNhzrA3or3lDKWpMFUwWQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduauvicwantdots/v4/S6ujw5FFVDKI3kwwDUbsPHCpzZNhzrA3or3lDKWpMFUwWQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Splash", + filename: "KtksAL2RZoDkbU6hpPPGNdS6wg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/splash/v8/KtksAL2RZoDkbU6hpPPGNdS6wg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jacquarda Bastarda 9", + filename: "f0Xp0fWr_8t6WFtKQJfOhaC0hcZ1HYAMAbwD1TB_JHHY", + category: "display", + url: "https://fonts.gstatic.com/s/jacquardabastarda9/v6/f0Xp0fWr_8t6WFtKQJfOhaC0hcZ1HYAMAbwD1TB_JHHY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Workbench", + filename: "FeVSS05Gp6Et7FcfbPFQ3Z5nm29Gww", + category: "monospace", + url: "https://fonts.gstatic.com/s/workbench/v3/FeVSS05Gp6Et7FcfbPFQ3Z5nm29Gww.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tai Heritage Pro", + filename: "sZlfdQid-zgaNiNIYcUzJMU3IYyNoHxSENxuLuE", + category: "serif", + url: "https://fonts.gstatic.com/s/taiheritagepro/v9/sZlfdQid-zgaNiNIYcUzJMU3IYyNoHxSENxuLuE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "WDXL Lubrifont SC", + filename: "gNMeW2VmY6acu0XtugFrduDciOOyfny5mD9ASHz5", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/wdxllubrifontsc/v2/gNMeW2VmY6acu0XtugFrduDciOOyfny5mD9ASHz5.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Moulpali", + filename: "H4ckBXKMl9HagUWymyY6wr-wg763", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/moulpali/v33/H4ckBXKMl9HagUWymyY6wr-wg763.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Mahajani", + filename: "-F6sfiVqLzI2JPCgQBnw60Agp0JrvD5Fh8ARHNh4zg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmahajani/v20/-F6sfiVqLzI2JPCgQBnw60Agp0JrvD5Fh8ARHNh4zg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Vithkuqi", + filename: "YA9Pr1OY7FjTf5szakutkndpw9HH-4a41dFGZiCpggI", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifvithkuqi/v3/YA9Pr1OY7FjTf5szakutkndpw9HH-4a41dFGZiCpggI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Protest Guerrilla", + filename: "Qw3HZR5PDSL6K3irtrY-VJB2YzARHV0koJ8y_eiS", + category: "display", + url: "https://fonts.gstatic.com/s/protestguerrilla/v2/Qw3HZR5PDSL6K3irtrY-VJB2YzARHV0koJ8y_eiS.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NO", + filename: "nuF-D_fYSZviRJYb-P2TrQOvBjiqFTrghA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteno/v10/nuF-D_fYSZviRJYb-P2TrQOvBjiqFTrghA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Tiro Tamil", + filename: "m8JXjfVIf7OT22n3M-S_ULRvamODxdI", + category: "serif", + url: "https://fonts.gstatic.com/s/tirotamil/v11/m8JXjfVIf7OT22n3M-S_ULRvamODxdI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Black And White Picture", + filename: "TwMe-JAERlQd3ooUHBUXGmrmioKjjnRSFO-NqI5HbcMi-yWY", + category: "display", + url: "https://fonts.gstatic.com/s/blackandwhitepicture/v30/TwMe-JAERlQd3ooUHBUXGmrmioKjjnRSFO-NqI5HbcMi-yWY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Stack Sans Notch", + filename: "TwMV-JcVXlQd3ooGEx9EbUzgioTr_h0bZJL-1a8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/stacksansnotch/v5/TwMV-JcVXlQd3ooGEx9EbUzgioTr_h0bZJL-1a8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite CU Guides", + filename: "c4m81mZtG8v6p3iAoFBJ2dJdu9fWPSaOFooIDQtJbok", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritecuguides/v1/c4m81mZtG8v6p3iAoFBJ2dJdu9fWPSaOFooIDQtJbok.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "M PLUS Code Latin", + filename: "hv-MlyV-aXg7x7tULiNXXBA0Np4WMTUULmBGjSwZ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mpluscodelatin/v16/hv-MlyV-aXg7x7tULiNXXBA0Np4WMTUULmBGjSwZ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Momo Trust Sans", + filename: "BXRzvFfHh_fFyXlQWZgO0TyUN7P31beyoRkJIA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/momotrustsans/v4/BXRzvFfHh_fFyXlQWZgO0TyUN7P31beyoRkJIA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Chokokutai", + filename: "kmK4Zqw4HwvCeHGM8Fws9y7ypu1Kr7I", + category: "display", + url: "https://fonts.gstatic.com/s/chokokutai/v12/kmK4Zqw4HwvCeHGM8Fws9y7ypu1Kr7I.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Vibes", + filename: "QdVYSTsmIB6tmbd3HpbsuBlh", + category: "display", + url: "https://fonts.gstatic.com/s/vibes/v16/QdVYSTsmIB6tmbd3HpbsuBlh.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Zen Loop", + filename: "h0GrssK16UsnJwHsEK9zqwzX5vOG", + category: "display", + url: "https://fonts.gstatic.com/s/zenloop/v11/h0GrssK16UsnJwHsEK9zqwzX5vOG.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tagesschrift", + filename: "pe0pMI6IOYlEuEZ7ZEA7ZKOaA_Rue1UwVg", + category: "display", + url: "https://fonts.gstatic.com/s/tagesschrift/v2/pe0pMI6IOYlEuEZ7ZEA7ZKOaA_Rue1UwVg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Carian", + filename: "LDIpaoiONgYwA9Yc6f0gUILeMIOgs7ob9yGLmfI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscarian/v17/LDIpaoiONgYwA9Yc6f0gUILeMIOgs7ob9yGLmfI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Puddles", + filename: "1Ptog8bYX_qGnkLkrU5MJsQcJfC0wVMT-aE", + category: "display", + url: "https://fonts.gstatic.com/s/rubikpuddles/v2/1Ptog8bYX_qGnkLkrU5MJsQcJfC0wVMT-aE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ruge Boogie", + filename: "JIA3UVFwbHRF_GIWSMhKNROiPzUveSxy", + category: "handwriting", + url: "https://fonts.gstatic.com/s/rugeboogie/v30/JIA3UVFwbHRF_GIWSMhKNROiPzUveSxy.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rock 3D", + filename: "yYLp0hrL0PCo651513SnwRnQyNI", + category: "display", + url: "https://fonts.gstatic.com/s/rock3d/v13/yYLp0hrL0PCo651513SnwRnQyNI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sixtyfour Convergence", + filename: "m8JMjepPf7mIglv5K__zM9srGA7wurbybZMFbehGW74OXw", + category: "monospace", + url: "https://fonts.gstatic.com/s/sixtyfourconvergence/v5/m8JMjepPf7mIglv5K__zM9srGA7wurbybZMFbehGW74OXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Petemoss", + filename: "A2BZn5tA2xgtGWHZgxkesKb9UouQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/petemoss/v9/A2BZn5tA2xgtGWHZgxkesKb9UouQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Palette Mosaic", + filename: "AMOIz4aBvWuBFe3TohdW6YZ9MFiy4dxL4jSr", + category: "display", + url: "https://fonts.gstatic.com/s/palettemosaic/v13/AMOIz4aBvWuBFe3TohdW6YZ9MFiy4dxL4jSr.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Triodion", + filename: "IFSnHe5TgMVEmMQV5mr5u-W10l_X", + category: "display", + url: "https://fonts.gstatic.com/s/triodion/v3/IFSnHe5TgMVEmMQV5mr5u-W10l_X.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libertinus Mono", + filename: "_gPg1RnxrjY_TDm97ApTqwneJJFToBF3YROW_w", + category: "monospace", + url: "https://fonts.gstatic.com/s/libertinusmono/v1/_gPg1RnxrjY_TDm97ApTqwneJJFToBF3YROW_w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Estonia", + filename: "7Au_p_4ijSecA1yHCCL8zkwMIFg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/estonia/v13/7Au_p_4ijSecA1yHCCL8zkwMIFg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bitcount Grid Single", + filename: "cY9afi2OU1tLpjaqQveNvbC2qfsuQPDVATvobxLCBJkS", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountgridsingle/v3/cY9afi2OU1tLpjaqQveNvbC2qfsuQPDVATvobxLCBJkS.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite DK Loopet", + filename: "memiYbuzy2qb3rtJGfM1FvY-GacDcsPvr6v9WSCHpm8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedkloopet/v6/memiYbuzy2qb3rtJGfM1FvY-GacDcsPvr6v9WSCHpm8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Batak", + filename: "gok2H6TwAEdtF9N8-mdTCQvT-Zdgo4_PHuk74A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbatak/v23/gok2H6TwAEdtF9N8-mdTCQvT-Zdgo4_PHuk74A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kolker Brush", + filename: "iJWDBXWRZjfKWdvmzwvvog3-7KJ6x8qNUQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/kolkerbrush/v8/iJWDBXWRZjfKWdvmzwvvog3-7KJ6x8qNUQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Winky Rough", + filename: "t5tkIRwIMoSXA0WSPBjQxIbo5z3nq4OH", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/winkyrough/v4/t5tkIRwIMoSXA0WSPBjQxIbo5z3nq4OH.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Glagolitic", + filename: "1q2ZY4-BBFBst88SU_tOj4J-4yuNF_HI4ERK4Amu7nM1", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansglagolitic/v19/1q2ZY4-BBFBst88SU_tOj4J-4yuNF_HI4ERK4Amu7nM1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Duployan", + filename: "gokzH7nwAEdtF9N8-mdTDx_X9JM5wsvrFsIn6WYDvA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansduployan/v19/gokzH7nwAEdtF9N8-mdTDx_X9JM5wsvrFsIn6WYDvA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Shizuru", + filename: "O4ZSFGfvnxFiCA3i30IJlgUTj2A", + category: "display", + url: "https://fonts.gstatic.com/s/shizuru/v13/O4ZSFGfvnxFiCA3i30IJlgUTj2A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Matemasie", + filename: "OD5BuMCN3ne3Gmr7dlL3rEq4DL6w2w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/matemasie/v4/OD5BuMCN3ne3Gmr7dlL3rEq4DL6w2w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "SUSE Mono", + filename: "y83ZW4wN6yi9x2mTxJIsJBvFEH7yDQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/susemono/v1/y83ZW4wN6yi9x2mTxJIsJBvFEH7yDQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Cypro Minoan", + filename: "2Eb2L_dtDUlkNmPHB_UVtEzp3ZlPGqZ_4nAGq9eSf8_eQSE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscyprominoan/v1/2Eb2L_dtDUlkNmPHB_UVtEzp3ZlPGqZ_4nAGq9eSf8_eQSE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik 80s Fade", + filename: "U9MF6dW37nLSmnwZXyoV-uPXUhHwkbL8IHcK", + category: "display", + url: "https://fonts.gstatic.com/s/rubik80sfade/v2/U9MF6dW37nLSmnwZXyoV-uPXUhHwkbL8IHcK.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libertinus Serif Display", + filename: "0FlHVOmbklub_P32Hm53RVREi5BsXWudOF_Gpgcrg81gHhVOxQ", + category: "display", + url: "https://fonts.gstatic.com/s/libertinusserifdisplay/v2/0FlHVOmbklub_P32Hm53RVREi5BsXWudOF_Gpgcrg81gHhVOxQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Suravaram", + filename: "_gP61R_usiY7SCym4xIAi261Qv9roQ", + category: "serif", + url: "https://fonts.gstatic.com/s/suravaram/v23/_gP61R_usiY7SCym4xIAi261Qv9roQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite HU", + filename: "A2Bdn59A0g0xA3zDhFw-0vfVU7mVsM4Fqg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritehu/v6/A2Bdn59A0g0xA3zDhFw-0vfVU7mVsM4Fqg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Gajraj One", + filename: "1cX2aUDCDpXsuWVb1jIjr1GqhcitzeM", + category: "display", + url: "https://fonts.gstatic.com/s/gajrajone/v7/1cX2aUDCDpXsuWVb1jIjr1GqhcitzeM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Balinese", + filename: "QdVKSS0-JginysQSRvuCmUMB_wVeQAxXRbgJdhapcUU", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifbalinese/v21/QdVKSS0-JginysQSRvuCmUMB_wVeQAxXRbgJdhapcUU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ole", + filename: "dFazZf6Z-rd89fw69qJ_ew", + category: "handwriting", + url: "https://fonts.gstatic.com/s/ole/v3/dFazZf6Z-rd89fw69qJ_ew.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tapestry", + filename: "SlGTmQecrosEYXhaGBIkqnB6aSQU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/tapestry/v6/SlGTmQecrosEYXhaGBIkqnB6aSQU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Grantha", + filename: "qkBIXuEH5NzDDvc3fLDYxPk9-Wq3WLiqFENLR7fHGw", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifgrantha/v22/qkBIXuEH5NzDDvc3fLDYxPk9-Wq3WLiqFENLR7fHGw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cossette Titre", + filename: "11hYGpvKz1nGbxMXUWz9OdPzuiEZrPeE8cA2", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cossettetitre/v3/11hYGpvKz1nGbxMXUWz9OdPzuiEZrPeE8cA2.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Chiron Sung HK", + filename: "nuFgD_XLTZPpXIpS3-3dhGzHTTKuNz_whHE_", + category: "serif", + url: "https://fonts.gstatic.com/s/chironsunghk/v2/nuFgD_XLTZPpXIpS3-3dhGzHTTKuNz_whHE_.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Khojki", + filename: "-nFnOHM29Oofr2wohFbTuPPKVWpmK_d709jy92k", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskhojki/v20/-nFnOHM29Oofr2wohFbTuPPKVWpmK_d709jy92k.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Mingzat", + filename: "0QIgMX5C-o-oWWyvBttkm_mv670", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/mingzat/v11/0QIgMX5C-o-oWWyvBttkm_mv670.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cherish", + filename: "ll88K2mXUyqsDsTN5iDCI6IJjg8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/cherish/v9/ll88K2mXUyqsDsTN5iDCI6IJjg8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Slackside One", + filename: "EJRQQgMrXdcGsiBuvnRxodTwVy7VocNB6Iw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/slacksideone/v14/EJRQQgMrXdcGsiBuvnRxodTwVy7VocNB6Iw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Ethiopic", + filename: "V8mZoR7-XjwJ8_Au3Ti5tXj5Rd83frpWNqU_FjYhCmo", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifethiopic/v32/V8mZoR7-XjwJ8_Au3Ti5tXj5Rd83frpWNqU_FjYhCmo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite CA", + filename: "z7NTdR_4cT0NOrEAIElil930TNeRpoq5Zg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteca/v11/z7NTdR_4cT0NOrEAIElil930TNeRpoq5Zg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "WDXL Lubrifont TC", + filename: "nKKN-H4mPq1yJurnWXfJE8svQHonWc_-EqxyqaA8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/wdxllubrifonttc/v5/nKKN-H4mPq1yJurnWXfJE8svQHonWc_-EqxyqaA8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Avestan", + filename: "bWti7ejKfBziStx7lIzKOLQZKhIJkyu9SASLji8U", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansavestan/v22/bWti7ejKfBziStx7lIzKOLQZKhIJkyu9SASLji8U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Elms Sans", + filename: "q5uFsoS_Lf9xv7Su1FpIAz5YiYVICQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/elmssans/v7/q5uFsoS_Lf9xv7Su1FpIAz5YiYVICQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Savate", + filename: "QdVXSTgjKAqpnvJXNLjgsQB4kg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/savate/v5/QdVXSTgjKAqpnvJXNLjgsQB4kg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Puppies Play", + filename: "wlp2gwHZEV99rG6M3NR9uB9vaAJSA_JN3Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/puppiesplay/v11/wlp2gwHZEV99rG6M3NR9uB9vaAJSA_JN3Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Phetsarath", + filename: "N0bQ2SpTP-plK0uWayAYAd79nd_QeZA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/phetsarath/v3/N0bQ2SpTP-plK0uWayAYAd79nd_QeZA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hanalei", + filename: "E21n_dD8iufIjBRHXzgmVydREus", + category: "display", + url: "https://fonts.gstatic.com/s/hanalei/v24/E21n_dD8iufIjBRHXzgmVydREus.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "LXGW Marker Gothic", + filename: "Gg8oN4AaXyDVTi_NlS1-xCtMQxY3lToBjuw_cZe26Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/lxgwmarkergothic/v1/Gg8oN4AaXyDVTi_NlS1-xCtMQxY3lToBjuw_cZe26Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu NSW ACT Hand Pre", + filename: "kmKiZrI-ExGJWUmupHwGgw6Qw4svl-MsLjYwI8Gcw6Oi", + category: "handwriting", + url: "https://fonts.gstatic.com/s/edunswacthandpre/v3/kmKiZrI-ExGJWUmupHwGgw6Qw4svl-MsLjYwI8Gcw6Oi.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Warnes", + filename: "pONn1hc0GsW6sW5OpiC2o6Lkqg", + category: "display", + url: "https://fonts.gstatic.com/s/warnes/v29/pONn1hc0GsW6sW5OpiC2o6Lkqg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kalnia Glaze", + filename: "wlp2gwHCBUNjrGrfu-hwowNvaAJSA_JN3Q", + category: "display", + url: "https://fonts.gstatic.com/s/kalniaglaze/v5/wlp2gwHCBUNjrGrfu-hwowNvaAJSA_JN3Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite DE SAS", + filename: "1Pt1g9vaRvmWghDdrE8IDuRPVrHN5Vs45aiOBrU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedesas/v11/1Pt1g9vaRvmWghDdrE8IDuRPVrHN5Vs45aiOBrU.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Nag Mundari", + filename: "3qTzoi2hnSyU8TNFIdhZTyod3g5lBnKlQFksmg2vd02J1Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansnagmundari/v4/3qTzoi2hnSyU8TNFIdhZTyod3g5lBnKlQFksmg2vd02J1Q.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Lilex", + filename: "DPEiYwmezwMATDTpLjoJd-Xa", + category: "monospace", + url: "https://fonts.gstatic.com/s/lilex/v1/DPEiYwmezwMATDTpLjoJd-Xa.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite NL", + filename: "k3kXo84SPe9dzQ1UGbvoZQ3hKYiJ-Q7m8w", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritenl/v10/k3kXo84SPe9dzQ1UGbvoZQ3hKYiJ-Q7m8w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Yi", + filename: "sJoD3LFXjsSdcnzn071rO3apxVDJNVgSNg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansyi/v22/sJoD3LFXjsSdcnzn071rO3apxVDJNVgSNg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite ZA", + filename: "Noa16Uzhw5CTOhXKt5-vwvhxPAR9mhHobg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteza/v11/Noa16Uzhw5CTOhXKt5-vwvhxPAR9mhHobg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite RO", + filename: "gokpH6fuA1J7QPJ04HFTGSWHmNZEq6TTFw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritero/v10/gokpH6fuA1J7QPJ04HFTGSWHmNZEq6TTFw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Moirai One", + filename: "2sDbZGFUgJLJmby6xgNGT0WWB7UcfCg", + category: "display", + url: "https://fonts.gstatic.com/s/moiraione/v1/2sDbZGFUgJLJmby6xgNGT0WWB7UcfCg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Balinese", + filename: "NaPFcYvSBuhTirw6IaFn6UrRDaqje-lzZpaduWMmxA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbalinese/v27/NaPFcYvSBuhTirw6IaFn6UrRDaqje-lzZpaduWMmxA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Asimovian", + filename: "oY1c8evOub78P2XN94MXCv5xY4QBLw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/asimovian/v2/oY1c8evOub78P2XN94MXCv5xY4QBLw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Sekuya", + filename: "fdN_9suEu39Dg3wk2ipfnF8U4A", + category: "display", + url: "https://fonts.gstatic.com/s/sekuya/v1/fdN_9suEu39Dg3wk2ipfnF8U4A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Hind Mysuru", + filename: "syk3-yB3k7wiAJ-U5l_li_LFjFGYLkjj", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/hindmysuru/v1/syk3-yB3k7wiAJ-U5l_li_LFjFGYLkjj.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite BE VLG", + filename: "GFDxWBdug3mQSvrAT9AL6fd4ZkB-cWAhatVBaGM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritebevlg/v6/GFDxWBdug3mQSvrAT9AL6fd4ZkB-cWAhatVBaGM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Alumni Sans SC", + filename: "Y4GSYaxzVjArrOeNFYbCvkZ8C3UD6pzxRwYB", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/alumnisanssc/v3/Y4GSYaxzVjArrOeNFYbCvkZ8C3UD6pzxRwYB.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Old Hungarian", + filename: "E213_cD6hP3GwCJPEUssHEM0KqLaHJXg2PiIgRfjbg5nCYXt", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansoldhungarian/v19/E213_cD6hP3GwCJPEUssHEM0KqLaHJXg2PiIgRfjbg5nCYXt.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Old North Arabian", + filename: "esDF30BdNv-KYGGJpKGk2tNiMt7Jar6olZDyNdr81zBQmUo_xw4ABw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansoldnortharabian/v17/esDF30BdNv-KYGGJpKGk2tNiMt7Jar6olZDyNdr81zBQmUo_xw4ABw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Moo Lah Lah", + filename: "dg4h_p_opKZOA0w1AYcm55wtYQYugjW4", + category: "display", + url: "https://fonts.gstatic.com/s/moolahlah/v8/dg4h_p_opKZOA0w1AYcm55wtYQYugjW4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Pixels", + filename: "SlGXmQOaupkIeSx4CEpB7AdSaBYRagrQrA", + category: "display", + url: "https://fonts.gstatic.com/s/rubikpixels/v3/SlGXmQOaupkIeSx4CEpB7AdSaBYRagrQrA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Cherokee", + filename: "KFO6Cm6Yu8uF-29fiz9vQF9YWK6Z8O1ue1GwCVgHYg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscherokee/v25/KFO6Cm6Yu8uF-29fiz9vQF9YWK6Z8O1ue1GwCVgHYg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Nandinagari", + filename: "or38Q7733eiDljA1IufXSNFT-1KI5y10H4jVa5RXzC1KOw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansnandinagari/v4/or38Q7733eiDljA1IufXSNFT-1KI5y10H4jVa5RXzC1KOw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Broken Fax", + filename: "NGSvv4rXG042O-GzH9sg1cUgl8w8YW-WdmGi300", + category: "display", + url: "https://fonts.gstatic.com/s/rubikbrokenfax/v1/NGSvv4rXG042O-GzH9sg1cUgl8w8YW-WdmGi300.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jaini", + filename: "fC1vPYJMbGHQzEmOK-ZSUHyt", + category: "display", + url: "https://fonts.gstatic.com/s/jaini/v1/fC1vPYJMbGHQzEmOK-ZSUHyt.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Marker Hatch", + filename: "QldTNSFQsh0B_bFXXWv6LAt-jswapJHQDL4iw0H6zw", + category: "display", + url: "https://fonts.gstatic.com/s/rubikmarkerhatch/v1/QldTNSFQsh0B_bFXXWv6LAt-jswapJHQDL4iw0H6zw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Epunda Slab", + filename: "46krlbHxTynXpZplPiOFHWVA_VVRGIpI", + category: "serif", + url: "https://fonts.gstatic.com/s/epundaslab/v2/46krlbHxTynXpZplPiOFHWVA_VVRGIpI.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite SK", + filename: "9XUilJp0klrZDw3AZHcsJTBoxJuqbi3ezg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritesk/v10/9XUilJp0klrZDw3AZHcsJTBoxJuqbi3ezg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite IE", + filename: "fC1mPYtWYWnH0hvndYd6GCGMVwLBplm9aw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteie/v11/fC1mPYtWYWnH0hvndYd6GCGMVwLBplm9aw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite FR Moderne", + filename: "3y9-6awucz3w5m4FFTzKolJRXhUk_u1yWs-tNtKENeNp", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritefrmoderne/v11/3y9-6awucz3w5m4FFTzKolJRXhUk_u1yWs-tNtKENeNp.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Grandiflora One", + filename: "0ybmGD0g27bCk_5MGWZcKWhxwnUU_R3y8DOWGA", + category: "serif", + url: "https://fonts.gstatic.com/s/grandifloraone/v3/0ybmGD0g27bCk_5MGWZcKWhxwnUU_R3y8DOWGA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Tangsa", + filename: "z7NPdQPmcigbbZAIOl9igP26K470jouLY_WiBuM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstangsa/v9/z7NPdQPmcigbbZAIOl9igP26K470jouLY_WiBuM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bitcount Grid Double", + filename: "WBL6rFjbakJVFOargiWSKQysDITG_S0VtHc6_qJY3QPQ", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountgriddouble/v3/WBL6rFjbakJVFOargiWSKQysDITG_S0VtHc6_qJY3QPQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Syriac Eastern", + filename: "Noah6Vj_wIWFbTTCrYmvy8AjVU8aslWRHHvRYxSkTa8Ck93gbw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssyriaceastern/v3/Noah6Vj_wIWFbTTCrYmvy8AjVU8aslWRHHvRYxSkTa8Ck93gbw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite IT Moderna", + filename: "mFTuWaYCwKPK5cx6W8jy2kwDnSUe9q45vR4pxoPdAYRC", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteitmoderna/v11/mFTuWaYCwKPK5cx6W8jy2kwDnSUe9q45vR4pxoPdAYRC.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Intel One Mono", + filename: "P5sbzZuLY8Lb_G1RikFkwPjBvvk3D4tEBiLF", + category: "monospace", + url: "https://fonts.gstatic.com/s/intelonemono/v2/P5sbzZuLY8Lb_G1RikFkwPjBvvk3D4tEBiLF.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Serif Dogra", + filename: "MQpP-XquKMC7ROPP3QOOlm7xPu3fGy63IbPzkns", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifdogra/v24/MQpP-XquKMC7ROPP3QOOlm7xPu3fGy63IbPzkns.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu AU VIC WA NT Arrows", + filename: "z7NEdQTteSlUDJZJAmUB9MuVbLPBjsrTFZLUbcLsaJmY0RIQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduauvicwantarrows/v4/z7NEdQTteSlUDJZJAmUB9MuVbLPBjsrTFZLUbcLsaJmY0RIQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sirivennela", + filename: "kmK5Zq0oHhbAYX-X6lgptg7YiOFDtqtf", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sirivennela/v2/kmK5Zq0oHhbAYX-X6lgptg7YiOFDtqtf.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ingrid Darling", + filename: "LDIrapaJNxUtSuFdw-9yf4rCPsLOub458jGL", + category: "handwriting", + url: "https://fonts.gstatic.com/s/ingriddarling/v7/LDIrapaJNxUtSuFdw-9yf4rCPsLOub458jGL.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yuji Hentaigana Akari", + filename: "cY9bfiyVT0VB6QuhWKOrpr6z58lnb_zYFnLIRTzODYALaA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/yujihentaiganaakari/v14/cY9bfiyVT0VB6QuhWKOrpr6z58lnb_zYFnLIRTzODYALaA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Microbe", + filename: "UqyWK8oPP3hjw6ANS9rM3PsZcs8aaKgiauE", + category: "display", + url: "https://fonts.gstatic.com/s/rubikmicrobe/v2/UqyWK8oPP3hjw6ANS9rM3PsZcs8aaKgiauE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Yezidi", + filename: "XLY8IYr5bJNDGYxLBibeHZAn3B5KJFlsYMYHGGeT", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifyezidi/v23/XLY8IYr5bJNDGYxLBibeHZAn3B5KJFlsYMYHGGeT.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Rubik Maps", + filename: "Gw6_wcjl80TZK9XxtbbejSYUChRqp9k", + category: "display", + url: "https://fonts.gstatic.com/s/rubikmaps/v1/Gw6_wcjl80TZK9XxtbbejSYUChRqp9k.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite MX", + filename: "6xKodSNbKtCe7KfhXg7RYSwoSMj-N4_4kQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritemx/v13/6xKodSNbKtCe7KfhXg7RYSwoSMj-N4_4kQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite MX Guides", + filename: "k3kMo9ESPe9dzQ1UGbvoZhnhbtfklWqN0qywHx-HpY0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritemxguides/v1/k3kMo9ESPe9dzQ1UGbvoZhnhbtfklWqN0qywHx-HpY0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite BE WAL", + filename: "DtV4Jwq5QbIzyrA6DHdJ2BksuUmahwBmkui5HNg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritebewal/v7/DtV4Jwq5QbIzyrA6DHdJ2BksuUmahwBmkui5HNg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Serif Todhri", + filename: "dFalZeyY-aYz1YVbjMoBWml1nBz7N3ByX6n0fnNk", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriftodhri/v3/dFalZeyY-aYz1YVbjMoBWml1nBz7N3ByX6n0fnNk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Myanmar", + filename: "VuJsdM7F2Yv76aBKKs-bHMQfAHUw3jn1pBrocdDqRA", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifmyanmar/v14/VuJsdM7F2Yv76aBKKs-bHMQfAHUw3jn1pBrocdDqRA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Toto", + filename: "Ktk1ALSMeZjqPnXk1rCkHYHNtwv3F6mZVY9Y5w", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriftoto/v7/Ktk1ALSMeZjqPnXk1rCkHYHNtwv3F6mZVY9Y5w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Traditional Nushu", + filename: "SZco3EDkJ7q9FaoMPlmF4Su8hlIjoGh5aj67J011GNh6SYA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/nototraditionalnushu/v23/SZco3EDkJ7q9FaoMPlmF4Su8hlIjoGh5aj67J011GNh6SYA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Old Italic", + filename: "TuGOUUFzXI5FBtUq5a8bh68BJxxEVam7tWlRdRhtCC4d", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansolditalic/v18/TuGOUUFzXI5FBtUq5a8bh68BJxxEVam7tWlRdRhtCC4d.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu QLD Beginner", + filename: "AMOKz5iUuHLEMNXyohhc_Y56PR3A69hp5ySriqg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduqldbeginner/v5/AMOKz5iUuHLEMNXyohhc_Y56PR3A69hp5ySriqg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Palmyrene", + filename: "ZgNPjOdKPa7CHqq0h37c_ASCWvH93SFCPnK5ZpdNtcA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanspalmyrene/v17/ZgNPjOdKPa7CHqq0h37c_ASCWvH93SFCPnK5ZpdNtcA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bitcount", + filename: "ijwWs53kQsE1Y5J-lJd7m2dJXYP7", + category: "display", + url: "https://fonts.gstatic.com/s/bitcount/v3/ijwWs53kQsE1Y5J-lJd7m2dJXYP7.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Parastoo", + filename: "-F6yfj90ITQ4d9euQUrQjCVAxzAL", + category: "serif", + url: "https://fonts.gstatic.com/s/parastoo/v3/-F6yfj90ITQ4d9euQUrQjCVAxzAL.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Miao", + filename: "Dxxz8jmXMW75w3OmoDXVV4zyZUjgUYVslLhx", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmiao/v19/Dxxz8jmXMW75w3OmoDXVV4zyZUjgUYVslLhx.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Old Uyghur", + filename: "v6-KGZbLJFKIhClqUYqXDiGnrVoFRCW6JdwnKumeF2yVgA", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifolduyghur/v4/v6-KGZbLJFKIhClqUYqXDiGnrVoFRCW6JdwnKumeF2yVgA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Doodle Triangles", + filename: "esDA301BLOmMKxKspb3g-domRuLPeaSn2bTzdLi_slZxgWE", + category: "display", + url: "https://fonts.gstatic.com/s/rubikdoodletriangles/v1/esDA301BLOmMKxKspb3g-domRuLPeaSn2bTzdLi_slZxgWE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Oriya", + filename: "MjQdmj56u-r69izk_LDqWN7w0cYB0OBNBn8Kwb0", + category: "serif", + url: "https://fonts.gstatic.com/s/notoseriforiya/v6/MjQdmj56u-r69izk_LDqWN7w0cYB0OBNBn8Kwb0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "BBH Hegarty", + filename: "yYLt0hbb_dvjg8talgb5vDHR-tdfcIT_", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bbhhegarty/v1/yYLt0hbb_dvjg8talgb5vDHR-tdfcIT_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Coral Pixels", + filename: "qWctB66zpZ3zAtrlR8Mb1LyyeBb_ujA4ug", + category: "display", + url: "https://fonts.gstatic.com/s/coralpixels/v1/qWctB66zpZ3zAtrlR8Mb1LyyeBb_ujA4ug.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Syne Tactile", + filename: "11hGGpna2UTQKjMCVzjAPMKh3ysdjvKU8Q", + category: "display", + url: "https://fonts.gstatic.com/s/synetactile/v16/11hGGpna2UTQKjMCVzjAPMKh3ysdjvKU8Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DE LA", + filename: "oY1G8e3fprboJ2HN4ogXTpFVJ8Q5Ln2ZCGKRvA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedela/v11/oY1G8e3fprboJ2HN4ogXTpFVJ8Q5Ln2ZCGKRvA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite AT", + filename: "Gw69wc7n6kfJN4fVoKON7HIeDjZvt9mVvg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteat/v6/Gw69wc7n6kfJN4fVoKON7HIeDjZvt9mVvg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Linear A", + filename: "oPWS_l16kP4jCuhpgEGmwJOiA18FZj22zmHQAGQicw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslineara/v19/oPWS_l16kP4jCuhpgEGmwJOiA18FZj22zmHQAGQicw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Adlam Unjoined", + filename: "P5sRzY2MYsLRsB5_ildkzPPDsLQXcOEmaFOqOGcAaZ41lBRPOw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansadlamunjoined/v28/P5sRzY2MYsLRsB5_ildkzPPDsLQXcOEmaFOqOGcAaZ41lBRPOw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite ES", + filename: "kJE0BuMK4Q07lDHc2Xp9uokSrMxzBZ2lDA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritees/v11/kJE0BuMK4Q07lDHc2Xp9uokSrMxzBZ2lDA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "BBH Bogle", + filename: "GFDoWA58rVDJf-fOV9ALq5hcOA0hUA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bbhbogle/v1/GFDoWA58rVDJf-fOV9ALq5hcOA0hUA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Namdhinggo", + filename: "uk-mEGe3rbgg8Xzoy5-TDnWj4yxx7o8", + category: "serif", + url: "https://fonts.gstatic.com/s/namdhinggo/v2/uk-mEGe3rbgg8Xzoy5-TDnWj4yxx7o8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playpen Sans Hebrew", + filename: "lJwb-okuj29wT-AN6RvLx8QqjkKhL7eAlInffHZXQf0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playpensanshebrew/v8/lJwb-okuj29wT-AN6RvLx8QqjkKhL7eAlInffHZXQf0.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Linefont", + filename: "dg4m_pzpoqcLKUIzVetHpbglShon", + category: "display", + url: "https://fonts.gstatic.com/s/linefont/v10/dg4m_pzpoqcLKUIzVetHpbglShon.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Old Persian", + filename: "wEOjEAbNnc5caQTFG18FHrZr9Bp6-8CmIJ_tqOlQfx9CjA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansoldpersian/v17/wEOjEAbNnc5caQTFG18FHrZr9Bp6-8CmIJ_tqOlQfx9CjA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Chorasmian", + filename: "MQpL-X6uKMC7ROPLwRnI9ULxK_7NVkf8S5vyoH7w4g9b", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanschorasmian/v3/MQpL-X6uKMC7ROPLwRnI9ULxK_7NVkf8S5vyoH7w4g9b.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playpen Sans Deva", + filename: "vm8sdQj0UUbMxObnsO17RZ7pPBuJgfd_GJMTlo_4", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playpensansdeva/v4/vm8sdQj0UUbMxObnsO17RZ7pPBuJgfd_GJMTlo_4.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Shafarik", + filename: "RWmLoKaF7PojpZXlW52sbsHKqLkD", + category: "display", + url: "https://fonts.gstatic.com/s/shafarik/v3/RWmLoKaF7PojpZXlW52sbsHKqLkD.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Bamum", + filename: "uk-7EGK3o6EruUbnwovcbBTkkklQ9qRP5I18Mg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbamum/v35/uk-7EGK3o6EruUbnwovcbBTkkklQ9qRP5I18Mg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Takri", + filename: "TuGJUVpzXI5FBtUq5a8bnKIOdTwQNO_W3khJXg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstakri/v25/TuGJUVpzXI5FBtUq5a8bnKIOdTwQNO_W3khJXg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Egyptian Hieroglyphs", + filename: "vEF42-tODB8RrNDvZSUmRhcQHzx1s7y_F9-j3qSzEcbEYindSVK8xRg7iw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansegyptianhieroglyphs/v30/vEF42-tODB8RrNDvZSUmRhcQHzx1s7y_F9-j3qSzEcbEYindSVK8xRg7iw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "UoqMunThenKhung", + filename: "Y4GTYa1nVTQLt-D5LoLChg5aJjIjwLL9Th8YYA", + category: "serif", + url: "https://fonts.gstatic.com/s/uoqmunthenkhung/v1/Y4GTYa1nVTQLt-D5LoLChg5aJjIjwLL9Th8YYA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Tifinagh", + filename: "I_uzMoCduATTei9eI8dawkHIwvmhCvbn6rnEcXfs4Q", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstifinagh/v21/I_uzMoCduATTei9eI8dawkHIwvmhCvbn6rnEcXfs4Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Amarna", + filename: "MCoSzAj-18jIHCAERYo80prgBg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/amarna/v1/MCoSzAj-18jIHCAERYo80prgBg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bytesized", + filename: "goksH6L8FkdnROln8XBTS0CjkP1Yog", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/bytesized/v1/goksH6L8FkdnROln8XBTS0CjkP1Yog.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Blaka Hollow", + filename: "MCoUzAL91sjRE2FsKsxUtezYB9oFyW_-oA", + category: "display", + url: "https://fonts.gstatic.com/s/blakahollow/v8/MCoUzAL91sjRE2FsKsxUtezYB9oFyW_-oA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Lisu", + filename: "uk-6EGO3o6EruUbnwovcYhz6kgRw3IpD7ZRl", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslisu/v27/uk-6EGO3o6EruUbnwovcYhz6kgRw3IpD7ZRl.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Karla Tamil Upright", + filename: "IFS4HfVMk95HnY0u6SeQ_cHoozW_3U5XoBJ9hK8kMK4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/karlatamilupright/v2/IFS4HfVMk95HnY0u6SeQ_cHoozW_3U5XoBJ9hK8kMK4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Newa", + filename: "7r3fqXp6utEsO9pI4f8ok8sWg8n_qN4R5lNU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansnewa/v18/7r3fqXp6utEsO9pI4f8ok8sWg8n_qN4R5lNU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tuffy", + filename: "1q2IY56bHkJl7rxzF4xmyfYe", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tuffy/v1/1q2IY56bHkJl7rxzF4xmyfYe.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playpen Sans Thai", + filename: "VdGEAYIdG5kSgHwmKT9wYu2rs0cBsvWGzr8C7vav", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playpensansthai/v8/VdGEAYIdG5kSgHwmKT9wYu2rs0cBsvWGzr8C7vav.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Blaka Ink", + filename: "AlZy_zVVtpj22Znag2chdXf4XB0Tow", + category: "display", + url: "https://fonts.gstatic.com/s/blakaink/v10/AlZy_zVVtpj22Znag2chdXf4XB0Tow.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Lisu Bosa", + filename: "3XFoErkv240fsdmJRJQvl0viTf3E3Q", + category: "serif", + url: "https://fonts.gstatic.com/s/lisubosa/v2/3XFoErkv240fsdmJRJQvl0viTf3E3Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Medefaidrin", + filename: "WwkAxOq6Dk-wranENynkfeVsNbRZtbOIdLbvcDV8OQsNbw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmedefaidrin/v29/WwkAxOq6Dk-wranENynkfeVsNbRZtbOIdLbvcDV8OQsNbw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Shavian", + filename: "CHy5V_HZE0jxJBQlqAeCKjJvQBNF4EFQSplv2Cwg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansshavian/v18/CHy5V_HZE0jxJBQlqAeCKjJvQBNF4EFQSplv2Cwg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Exile", + filename: "pxiKyp0xqNtbjBsYHpT2dkNE", + category: "display", + url: "https://fonts.gstatic.com/s/exile/v1/pxiKyp0xqNtbjBsYHpT2dkNE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Khitan Small Script", + filename: "jizzRFVKsm4Bt9PrbSzC4KLlQUF5lRJg5j-l5PvyhfTdd4TsZ8lb39iddA", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifkhitansmallscript/v4/jizzRFVKsm4Bt9PrbSzC4KLlQUF5lRJg5j-l5PvyhfTdd4TsZ8lb39iddA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Sundanese", + filename: "FwZH7_84xUkosG2xJo2gm7nFwSLQkdymsWKGP6kvdPA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssundanese/v28/FwZH7_84xUkosG2xJo2gm7nFwSLQkdymsWKGP6kvdPA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite VN", + filename: "mtG94_hXJqPSu8nf5RBY5i0w2A6yHk9d8w", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritevn/v11/mtG94_hXJqPSu8nf5RBY5i0w2A6yHk9d8w.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Vithkuqi", + filename: "jVyX7m77CXvQswd6WjYu9E1wN6cih2TIeTf0SZST2g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansvithkuqi/v3/jVyX7m77CXvQswd6WjYu9E1wN6cih2TIeTf0SZST2g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Narnoor", + filename: "cIf9MaFWuVo-UTyPxCmrYGkHgIs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/narnoor/v10/cIf9MaFWuVo-UTyPxCmrYGkHgIs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Lines", + filename: "_gP81R3vsjYzVW2Y6xFF-GSxYPp7oSNy", + category: "display", + url: "https://fonts.gstatic.com/s/rubiklines/v1/_gP81R3vsjYzVW2Y6xFF-GSxYPp7oSNy.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NZ", + filename: "d6lPkaOxRsyr_zZDmUYvh2TM1_JgjmRpOA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritenz/v12/d6lPkaOxRsyr_zZDmUYvh2TM1_JgjmRpOA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Znamenny Musical Notation", + filename: "CSRW4ylQnPyaDwAMK1U_AolTaJ4Lz41GcgaIZV9YO2rO88jvtpqqdoWa7g", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notoznamennymusicalnotation/v7/CSRW4ylQnPyaDwAMK1U_AolTaJ4Lz41GcgaIZV9YO2rO88jvtpqqdoWa7g.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Indic Siyaq Numbers", + filename: "6xK5dTJFKcWIu4bpRBjRZRpsIYHabOeZ8UZLubTzpXNHKx2WPOpVd5Iu", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansindicsiyaqnumbers/v17/6xK5dTJFKcWIu4bpRBjRZRpsIYHabOeZ8UZLubTzpXNHKx2WPOpVd5Iu.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AR", + filename: "VEM2RohisJz5pTCzruCNjWbFrNGOsEkJZA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritear/v6/VEM2RohisJz5pTCzruCNjWbFrNGOsEkJZA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Serif Makasar", + filename: "memjYbqtyH-NiZpFH_9zcvB_PqkfY9S7j4HTVSmevw", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifmakasar/v1/memjYbqtyH-NiZpFH_9zcvB_PqkfY9S7j4HTVSmevw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif NP Hmong", + filename: "pON61gItFMO79E4L1GPUi-2sixKHZyFj7peYDHDLnAo", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifnphmong/v5/pON61gItFMO79E4L1GPUi-2sixKHZyFj7peYDHDLnAo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite DK Uloopet", + filename: "bWtn7e3Ufwn0Hf1zjprKPYlcDAoHknvYFiCDpTMddp8a", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedkuloopet/v6/bWtn7e3Ufwn0Hf1zjprKPYlcDAoHknvYFiCDpTMddp8a.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Brahmi", + filename: "vEFK2-VODB8RrNDvZSUmQQIIByV18tK1W77HtMo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbrahmi/v20/vEFK2-VODB8RrNDvZSUmQQIIByV18tK1W77HtMo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite PT", + filename: "6NUR8FidKwOcfRjj8ukv5Lgkyf9Fdty2ew", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritept/v10/6NUR8FidKwOcfRjj8ukv5Lgkyf9Fdty2ew.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans New Tai Lue", + filename: "H4c5BW-Pl9DZ0Xe_nHUapt7PovLXAhAnY7wwY55O4AS32A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansnewtailue/v24/H4c5BW-Pl9DZ0Xe_nHUapt7PovLXAhAnY7wwY55O4AS32A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Big Shoulders Inline", + filename: "bx6aNwSCkev-8u0YNXAF6gArLyznvspgMYrXvDo3SQY1", + category: "display", + url: "https://fonts.gstatic.com/s/bigshouldersinline/v4/bx6aNwSCkev-8u0YNXAF6gArLyznvspgMYrXvDo3SQY1.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Edu AU VIC WA NT Guides", + filename: "TuGBUUJ4V48KZ9Nr3ZV46JQkJxtkFIKnvy00LDxlIzIU5RwD", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduauvicwantguides/v3/TuGBUUJ4V48KZ9Nr3ZV46JQkJxtkFIKnvy00LDxlIzIU5RwD.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite CO", + filename: "0FlTVP2Hl1iH-fv2BH4kJkgB-dMOdS7sSg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteco/v13/0FlTVP2Hl1iH-fv2BH4kJkgB-dMOdS7sSg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Kedebideri", + filename: "t5tlIR0UPo6ZGAykNh_ejKbCyTHuspo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kedebideri/v2/t5tlIR0UPo6ZGAykNh_ejKbCyTHuspo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Ottoman Siyaq", + filename: "fC1yPZ9IYnzRhTrrc4s8cSvYI0eozzaFOQ01qoHLJrgA00kAdA", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifottomansiyaq/v2/fC1yPZ9IYnzRhTrrc4s8cSvYI0eozzaFOQ01qoHLJrgA00kAdA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Rubik Maze", + filename: "xMQRuF9ZVa2ftiJEavXSAX7inS-bxV4", + category: "display", + url: "https://fonts.gstatic.com/s/rubikmaze/v2/xMQRuF9ZVa2ftiJEavXSAX7inS-bxV4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NG Modern", + filename: "ijwJs4b2R9Qve5V5lNJb_yRhEfSep5NbDimE2tKY2yY", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritengmodern/v11/ijwJs4b2R9Qve5V5lNJb_yRhEfSep5NbDimE2tKY2yY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Tai Le", + filename: "vEFK2-VODB8RrNDvZSUmVxEATwR58tK1W77HtMo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstaile/v19/vEFK2-VODB8RrNDvZSUmVxEATwR58tK1W77HtMo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jacquard 12 Charted", + filename: "i7dWIE97bzCOB9Q_Up6PQmYfKDPIb2HwT3StZ9jetKY", + category: "display", + url: "https://fonts.gstatic.com/s/jacquard12charted/v4/i7dWIE97bzCOB9Q_Up6PQmYfKDPIb2HwT3StZ9jetKY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Gurmukhi", + filename: "92zJtA9LNqsg7tCYlXdCV1VPnAEeDU0vNI0un_HLMEo", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifgurmukhi/v22/92zJtA9LNqsg7tCYlXdCV1VPnAEeDU0vNI0un_HLMEo.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Cham", + filename: "pe03MIySN5pO62Z5YkFyQb_bbv5qWVAgVol-", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscham/v33/pe03MIySN5pO62Z5YkFyQb_bbv5qWVAgVol-.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Elbasan", + filename: "-F6rfiZqLzI2JPCgQBnw400qp1trvHdlre4dFcFh", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanselbasan/v17/-F6rfiZqLzI2JPCgQBnw400qp1trvHdlre4dFcFh.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans NKo", + filename: "esDX31ZdNv-KYGGJpKGk2_RpMpCMHMLBrdA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansnko/v7/esDX31ZdNv-KYGGJpKGk2_RpMpCMHMLBrdA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite GB J", + filename: "k3kJo8wSPe9dzQ1UGbvobAPhY4KN2wv287Sb", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritegbj/v11/k3kJo8wSPe9dzQ1UGbvobAPhY4KN2wv287Sb.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans SignWriting", + filename: "Noas6VX_wIWFbTTCrYmvy9A2UnkL-2SZAWiUEVCARYQemg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssignwriting/v5/Noas6VX_wIWFbTTCrYmvy9A2UnkL-2SZAWiUEVCARYQemg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Matangi", + filename: "kmK9ZqE2FhDIeX2QpD1v0ybZuuQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/matangi/v5/kmK9ZqE2FhDIeX2QpD1v0ybZuuQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Osage", + filename: "oPWX_kB6kP4jCuhpgEGmw4mtAVtXRlaSxkrMCQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansosage/v20/oPWX_kB6kP4jCuhpgEGmw4mtAVtXRlaSxkrMCQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Hentaigana", + filename: "uk-9EHi3o6EruUbj3pGaDj3siVARn-kqgu1EM1vLGR4V2g", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifhentaigana/v17/uk-9EHi3o6EruUbj3pGaDj3siVARn-kqgu1EM1vLGR4V2g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Cause", + filename: "or3sQ6760-mf00NUZpD_B6g8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/cause/v2/or3sQ6760-mf00NUZpD_B6g8.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Lydian", + filename: "c4m71mVzGN7s8FmIukZJ1v4ZlcPReUPXMoIjEQI", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslydian/v19/c4m71mVzGN7s8FmIukZJ1v4ZlcPReUPXMoIjEQI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Wavefont", + filename: "L0x-DF00m0cP6hefyPqiZyxEimK3", + category: "display", + url: "https://fonts.gstatic.com/s/wavefont/v19/L0x-DF00m0cP6hefyPqiZyxEimK3.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Menbere", + filename: "lJwH-p0zhmBrWvcG6UiArb8L3iY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/menbere/v1/lJwH-p0zhmBrWvcG6UiArb8L3iY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite CO Guides", + filename: "AYCXpWvtftIVXepC5AzjAx1KgYPugOK0TqxTJw_GOM0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritecoguides/v5/AYCXpWvtftIVXepC5AzjAx1KgYPugOK0TqxTJw_GOM0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Tagbanwa", + filename: "Y4GWYbB8VTEp4t3MKJSMmQdIKjRtt_nZRjQEaYpGoQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstagbanwa/v21/Y4GWYbB8VTEp4t3MKJSMmQdIKjRtt_nZRjQEaYpGoQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Caucasian Albanian", + filename: "nKKA-HM_FYFRJvXzVXaANsU0VzsAc46QGOkWytlTs-TXrYDmoVmRSZo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscaucasianalbanian/v19/nKKA-HM_FYFRJvXzVXaANsU0VzsAc46QGOkWytlTs-TXrYDmoVmRSZo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite CZ", + filename: "8vIP7wYp22pt_BUChSHeVxx_M9f08h-8bA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritecz/v6/8vIP7wYp22pt_BUChSHeVxx_M9f08h-8bA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Edu QLD Hand", + filename: "d6lPkaOkTtjy2QhuzWtup1rM1_JgjmRpOA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduqldhand/v3/d6lPkaOkTtjy2QhuzWtup1rM1_JgjmRpOA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans PhagsPa", + filename: "XLY8IYr5bJNDGYxPGjyYbaEjwQR-LFlsYMYHGGeT", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansphagspa/v24/XLY8IYr5bJNDGYxPGjyYbaEjwQR-LFlsYMYHGGeT.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Padyakke Expanded One", + filename: "K2FvfY9El_tbR0JfHb6WWvrBaU6XAUvC4IAYOKRkpDjeoQ", + category: "serif", + url: "https://fonts.gstatic.com/s/padyakkeexpandedone/v8/K2FvfY9El_tbR0JfHb6WWvrBaU6XAUvC4IAYOKRkpDjeoQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Maname", + filename: "gNMFW3J8RpCx9my42FkGGI6q_Q", + category: "serif", + url: "https://fonts.gstatic.com/s/maname/v2/gNMFW3J8RpCx9my42FkGGI6q_Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bitcount Single Ink", + filename: "FwZH7_80w0kk_0u-PN7ToaLMySaevcGosWKGP6kvdPA", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountsingleink/v4/FwZH7_80w0kk_0u-PN7ToaLMySaevcGosWKGP6kvdPA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Jaini Purva", + filename: "CHynV-vdHVXwbWcUswbUGHoOHH4sj3lR", + category: "display", + url: "https://fonts.gstatic.com/s/jainipurva/v1/CHynV-vdHVXwbWcUswbUGHoOHH4sj3lR.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu VIC WA NT Hand Pre", + filename: "neIazDmioZxjkInM_tLHFudmcN2Uxxc-9Vnv4Y0Eeru2dGg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduvicwanthandpre/v3/neIazDmioZxjkInM_tLHFudmcN2Uxxc-9Vnv4Y0Eeru2dGg.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Sankofa Display", + filename: "Ktk1ALSRd4LucUDghJ2rTqXOoh33F6mZVY9Y5w", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/sankofadisplay/v2/Ktk1ALSRd4LucUDghJ2rTqXOoh33F6mZVY9Y5w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Imperial Aramaic", + filename: "a8IMNpjwKmHXpgXbMIsbTc_kvks91LlLetBr5itQrtdml3YfPNno", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansimperialaramaic/v18/a8IMNpjwKmHXpgXbMIsbTc_kvks91LlLetBr5itQrtdml3YfPNno.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Karla Tamil Inclined", + filename: "vm8pdQ3vXFXZ1aPd8dNzR82AFh2TibkaVrcbvZPxCDLR", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/karlatamilinclined/v2/vm8pdQ3vXFXZ1aPd8dNzR82AFh2TibkaVrcbvZPxCDLR.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Tai Tham", + filename: "kJEuBv0U4hgtwxDUw2x9q7tbjLIfbPGdDaRlkJrUNw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstaitham/v25/kJEuBv0U4hgtwxDUw2x9q7tbjLIfbPGdDaRlkJrUNw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite US Trad Guides", + filename: "-zk29027wssz_XLkGgu8kTL39c2bMssjmiZPNnk5nJCZAyrUdw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteustradguides/v1/-zk29027wssz_XLkGgu8kTL39c2bMssjmiZPNnk5nJCZAyrUdw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU VIC", + filename: "bWtj7enUfwn0Hf1zjprKOJdcDy8r3QuXZgiClzY", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteauvic/v11/bWtj7enUfwn0Hf1zjprKOJdcDy8r3QuXZgiClzY.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Wancho", + filename: "zrf-0GXXyfn6Fs0lH9P4cUubP0GBqAPopiRfKp8", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanswancho/v19/zrf-0GXXyfn6Fs0lH9P4cUubP0GBqAPopiRfKp8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Ol Chiki", + filename: "N0bI2TJNOPt-eHmFZCdQbrL32r-4CvhpBBaAT48zZA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansolchiki/v31/N0bI2TJNOPt-eHmFZCdQbrL32r-4CvhpBBaAT48zZA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Playwrite CL", + filename: "-zki91m7wssz_XLkGgu8hy33oZX-Xup87g", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritecl/v6/-zki91m7wssz_XLkGgu8hy33oZX-Xup87g.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Sharada", + filename: "gok0H7rwAEdtF9N8-mdTGALG6p0kwoXLPOwr4H8a", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssharada/v18/gok0H7rwAEdtF9N8-mdTGALG6p0kwoXLPOwr4H8a.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Sogdian", + filename: "taiQGn5iC4--qtsfi4Jp6eHPnfxQBo--Pm6KHidM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssogdian/v17/taiQGn5iC4--qtsfi4Jp6eHPnfxQBo--Pm6KHidM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Ponnala", + filename: "w8gaH2QxQOU08bbbrQut2F4OuOo", + category: "display", + url: "https://fonts.gstatic.com/s/ponnala/v3/w8gaH2QxQOU08bbbrQut2F4OuOo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Edu VIC WA NT Hand", + filename: "UcC73EsnIXnOaZKmY1Ry0wZjP9YVRBcq0pj6eUcpng", + category: "handwriting", + url: "https://fonts.gstatic.com/s/eduvicwanthand/v3/UcC73EsnIXnOaZKmY1Ry0wZjP9YVRBcq0pj6eUcpng.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Inscriptional Pahlavi", + filename: "ll8UK3GaVDuxR-TEqFPIbsR79Xxz9WEKbwsjpz7VklYlC7FCVtqVOAYK0QA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansinscriptionalpahlavi/v18/ll8UK3GaVDuxR-TEqFPIbsR79Xxz9WEKbwsjpz7VklYlC7FCVtqVOAYK0QA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Cuneiform", + filename: "bMrrmTWK7YY-MF22aHGGd7H8PhJtvBDWgb9JlRQueeQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscuneiform/v18/bMrrmTWK7YY-MF22aHGGd7H8PhJtvBDWgb9JlRQueeQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite PE", + filename: "FwZc7-Amxlw-50y5PJugmImLpWm6_K7bkA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritepe/v6/FwZc7-Amxlw-50y5PJugmImLpWm6_K7bkA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Jacquard 24 Charted", + filename: "mtGm4-dNK6HaudrE9VVKhENTsEXEYish0iRrMYJ_K-4", + category: "display", + url: "https://fonts.gstatic.com/s/jacquard24charted/v5/mtGm4-dNK6HaudrE9VVKhENTsEXEYish0iRrMYJ_K-4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Marchen", + filename: "aFTO7OZ_Y282EP-WyG6QTOX_C8WZMHhPk652ZaHk", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmarchen/v21/aFTO7OZ_Y282EP-WyG6QTOX_C8WZMHhPk652ZaHk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Mro", + filename: "qWcsB6--pZv9TqnUQMhe9b39WDzRtjkho4M", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmro/v20/qWcsB6--pZv9TqnUQMhe9b39WDzRtjkho4M.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kay Pho Du", + filename: "jizfREFPvGNOx-jhPwHR4OmnLD0Z4zM", + category: "serif", + url: "https://fonts.gstatic.com/s/kayphodu/v2/jizfREFPvGNOx-jhPwHR4OmnLD0Z4zM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Kanchenjunga", + filename: "RWmPoKKd5fUmrILiWsjCI6TiqYsGBGBzCw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/kanchenjunga/v2/RWmPoKKd5fUmrILiWsjCI6TiqYsGBGBzCw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Hanifi Rohingya", + filename: "5h1IiYsoOmIC3Yu3MDXLDw3UZCgghyOEBBY7hhLN0IbPeXAy64Y", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanshanifirohingya/v30/5h1IiYsoOmIC3Yu3MDXLDw3UZCgghyOEBBY7hhLN0IbPeXAy64Y.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bitcount Grid Single Ink", + filename: "NaPLcYHeAOhfxZo1O_IA2ULZRJevZus_A5zQ3wkexdKKt36GpQ", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountgridsingleink/v2/NaPLcYHeAOhfxZo1O_IA2ULZRJevZus_A5zQ3wkexdKKt36GpQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Jersey 15 Charted", + filename: "nuFjD-rCQIjoVp1Sva2ToCTudGbLeRv4r2024gxi", + category: "display", + url: "https://fonts.gstatic.com/s/jersey15charted/v4/nuFjD-rCQIjoVp1Sva2ToCTudGbLeRv4r2024gxi.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bitcount Prop Double", + filename: "K2FufY5Wn-tBSVxaDL6DUOXQJ26dEAnh68U4EoporSHH", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountpropdouble/v3/K2FufY5Wn-tBSVxaDL6DUOXQJ26dEAnh68U4EoporSHH.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Psalter Pahlavi", + filename: "rP2Vp3K65FkAtHfwd-eISGznYihzggmsicPfud3w1G3KsUQBct4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanspsalterpahlavi/v18/rP2Vp3K65FkAtHfwd-eISGznYihzggmsicPfud3w1G3KsUQBct4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Old South Arabian", + filename: "3qT5oiOhnSyU8TNFIdhZTice3hB_HWKsEnF--0XCHiKx1OtDT9HwTA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansoldsoutharabian/v17/3qT5oiOhnSyU8TNFIdhZTice3hB_HWKsEnF--0XCHiKx1OtDT9HwTA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Runic", + filename: "H4c_BXWPl9DZ0Xe_nHUaus7W68WWaxpvHtgIYg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansrunic/v18/H4c_BXWPl9DZ0Xe_nHUaus7W68WWaxpvHtgIYg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Rejang", + filename: "Ktk2AKuMeZjqPnXgyqrib7DIogqwN4O3WYZB_sU", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansrejang/v23/Ktk2AKuMeZjqPnXgyqrib7DIogqwN4O3WYZB_sU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite ES Deco", + filename: "7Aulp-g3kjKKGkePXEf2jxctfDxlvHkw2-m9x2iC", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteesdeco/v11/7Aulp-g3kjKKGkePXEf2jxctfDxlvHkw2-m9x2iC.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Buginese", + filename: "esDM30ldNv-KYGGJpKGk18phe_7Da6_gtfuEXLmNtw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbuginese/v21/esDM30ldNv-KYGGJpKGk18phe_7Da6_gtfuEXLmNtw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans NKo Unjoined", + filename: "MCoCzBjx1d3VUhJFK9MYlNCXJ6VvqwGPz3szJutjFqMvktM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansnkounjoined/v4/MCoCzBjx1d3VUhJFK9MYlNCXJ6VvqwGPz3szJutjFqMvktM.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Mayan Numerals", + filename: "PlIuFk25O6RzLfvNNVSivR09_KqYMwvvDKYjfIiE68oo6eepYQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmayannumerals/v17/PlIuFk25O6RzLfvNNVSivR09_KqYMwvvDKYjfIiE68oo6eepYQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Zanabazar Square", + filename: "Cn-jJsuGWQxOjaGwMQ6fOicyxLBEMRfDtkzl4uagQtJxOCEgN0Gc", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanszanabazarsquare/v20/Cn-jJsuGWQxOjaGwMQ6fOicyxLBEMRfDtkzl4uagQtJxOCEgN0Gc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Khudawadi", + filename: "fdNi9t6ZsWBZ2k5ltHN73zZ5hc8HANlHIjRnVVXz9MY", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskhudawadi/v23/fdNi9t6ZsWBZ2k5ltHN73zZ5hc8HANlHIjRnVVXz9MY.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Bhaiksuki", + filename: "UcC63EosKniBH4iELXATsSBWdvUHXxhj8rLUdU4wh9U", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbhaiksuki/v18/UcC63EosKniBH4iELXATsSBWdvUHXxhj8rLUdU4wh9U.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Vai", + filename: "NaPecZTSBuhTirw6IaFn_UrURMTsDIRSfr0", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansvai/v19/NaPecZTSBuhTirw6IaFn_UrURMTsDIRSfr0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Masaram Gondi", + filename: "6xK_dThFKcWIu4bpRBjRYRV7KZCbUq6n_1kPnuGe7RI9WSWX", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmasaramgondi/v19/6xK_dThFKcWIu4bpRBjRYRV7KZCbUq6n_1kPnuGe7RI9WSWX.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bitcount Prop Single Ink", + filename: "wXK4E2YTrpM-pUnBg3-sd4tavSRa27--66omsDmF7FUIszQijA", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountpropsingleink/v4/wXK4E2YTrpM-pUnBg3-sd4tavSRa27--66omsDmF7FUIszQijA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Kawi", + filename: "92zMtBJLNqsg7tCciW0EPHNNh0xrTCFOFZUF", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskawi/v5/92zMtBJLNqsg7tCciW0EPHNNh0xrTCFOFZUF.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Saurashtra", + filename: "ea8GacQ0Wfz_XKWXe6OtoA8w8zvmYwTef9ndjhPTSIx9", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssaurashtra/v24/ea8GacQ0Wfz_XKWXe6OtoA8w8zvmYwTef9ndjhPTSIx9.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Tirra", + filename: "WBLrrEnNakREGrPF3AHdWn3J", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/tirra/v2/WBLrrEnNakREGrPF3AHdWn3J.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Mandaic", + filename: "cIfnMbdWt1w_HgCcilqhKQBo_OsMI5_A_gMk0izH", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmandaic/v18/cIfnMbdWt1w_HgCcilqhKQBo_OsMI5_A_gMk0izH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Chakma", + filename: "Y4GQYbJ8VTEp4t3MKJSMjg5OIzhi4JjTQhYBeYo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanschakma/v19/Y4GQYbJ8VTEp4t3MKJSMjg5OIzhi4JjTQhYBeYo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Elymaic", + filename: "UqyKK9YTJW5liNMhTMqe9vUFP65ZD4AjWOT0zi2V", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanselymaic/v18/UqyKK9YTJW5liNMhTMqe9vUFP65ZD4AjWOT0zi2V.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Micro 5 Charted", + filename: "hESp6XxmPDtTtADZhn7oD_yrmxEGRUsJQAlbUA", + category: "display", + url: "https://fonts.gstatic.com/s/micro5charted/v2/hESp6XxmPDtTtADZhn7oD_yrmxEGRUsJQAlbUA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Inscriptional Parthian", + filename: "k3k7o-IMPvpLmixcA63oYi-yStDkgXuXncL7dzfW3P4TAJ2yklBJ2jNkLlLr", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansinscriptionalparthian/v18/k3k7o-IMPvpLmixcA63oYi-yStDkgXuXncL7dzfW3P4TAJ2yklBJ2jNkLlLr.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite ID", + filename: "Cn-xJt2YWhlY2oC4KxifKQJ8pfghQ3eplw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteid/v11/Cn-xJt2YWhlY2oC4KxifKQJ8pfghQ3eplw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Pochaevsk", + filename: "55xuey9_OdX_Om7ReYgloJd4-bnQKg", + category: "display", + url: "https://fonts.gstatic.com/s/pochaevsk/v5/55xuey9_OdX_Om7ReYgloJd4-bnQKg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Syriac Western", + filename: "ke82OhEEMVFsvCav8hWjbItd6Jf6MP7Z9spJZ6UXK6ARIyH5IA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssyriacwestern/v2/ke82OhEEMVFsvCav8hWjbItd6Jf6MP7Z9spJZ6UXK6ARIyH5IA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Kaithi", + filename: "buEtppS9f8_vkXadMBJJu0tWjLwjQi0KdoZIKlo", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskaithi/v23/buEtppS9f8_vkXadMBJJu0tWjLwjQi0KdoZIKlo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Hatran", + filename: "A2BBn4Ne0RgnVF3Lnko-0sOBIfL_mM83r1nwzDs", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanshatran/v17/A2BBn4Ne0RgnVF3Lnko-0sOBIfL_mM83r1nwzDs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite TZ", + filename: "RLp4K5rs6au7bzABmVQAOwnOZdM8t6g3VQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritetz/v11/RLp4K5rs6au7bzABmVQAOwnOZdM8t6g3VQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Grantha", + filename: "3y976akwcCjmsU8NDyrKo3IQfQ4o-r8cFeulHc6N", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansgrantha/v20/3y976akwcCjmsU8NDyrKo3IQfQ4o-r8cFeulHc6N.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Linear B", + filename: "HhyJU4wt9vSgfHoORYOiXOckKNB737IV3BkFTq4EPw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslinearb/v18/HhyJU4wt9vSgfHoORYOiXOckKNB737IV3BkFTq4EPw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DE VA", + filename: "VuJpdNPb2p7tvoFGLMPdeMxGN1p9v2HRrDH0eA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedeva/v11/VuJpdNPb2p7tvoFGLMPdeMxGN1p9v2HRrDH0eA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Jersey 25 Charted", + filename: "6NUM8EWHIhCWbxOqtLkv94Rlu6EkGv2uUGQW93Cg", + category: "display", + url: "https://fonts.gstatic.com/s/jersey25charted/v3/6NUM8EWHIhCWbxOqtLkv94Rlu6EkGv2uUGQW93Cg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Pahawh Hmong", + filename: "bWtp7e_KfBziStx7lIzKKaMUOBEA3UPQDW7krzc_c48aMpM", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanspahawhhmong/v21/bWtp7e_KfBziStx7lIzKKaMUOBEA3UPQDW7krzc_c48aMpM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jersey 10 Charted", + filename: "oY1E8fPFr6XiNWqEp90XSbwUGfF8SnedKmeBvEYs", + category: "display", + url: "https://fonts.gstatic.com/s/jersey10charted/v4/oY1E8fPFr6XiNWqEp90XSbwUGfF8SnedKmeBvEYs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yuji Hentaigana Akebono", + filename: "EJRGQhkhRNwM-RtitGUwh930GU_f5KAlkuL0wQy9NKXRzrrF", + category: "handwriting", + url: "https://fonts.gstatic.com/s/yujihentaiganaakebono/v15/EJRGQhkhRNwM-RtitGUwh930GU_f5KAlkuL0wQy9NKXRzrrF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Libertinus Keyboard", + filename: "NaPEcYrQAP5Z2JsyIac0i2DYHaapaf43RryztWo_3fk", + category: "display", + url: "https://fonts.gstatic.com/s/libertinuskeyboard/v2/NaPEcYrQAP5Z2JsyIac0i2DYHaapaf43RryztWo_3fk.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Sunuwar", + filename: "FwZB7_04xUkosG2xJo2gm7nF0DTfho_Du2akOrkv", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssunuwar/v1/FwZB7_04xUkosG2xJo2gm7nF0DTfho_Du2akOrkv.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "SN Pro", + filename: "NGS1v5zWIAwPIq7RbZ5PuKoY0A", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/snpro/v1/NGS1v5zWIAwPIq7RbZ5PuKoY0A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Limbu", + filename: "3JnlSDv90Gmq2mrzckOBBRRoNJVj0MF3OHRDnA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslimbu/v26/3JnlSDv90Gmq2mrzckOBBRRoNJVj0MF3OHRDnA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DE Grund Guides", + filename: "OD5RuNCQ02KrAHnha1L36CWcQ83dtK5BLxqexnduX98TJlnjHAA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedegrundguides/v1/OD5RuNCQ02KrAHnha1L36CWcQ83dtK5BLxqexnduX98TJlnjHAA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Nabataean", + filename: "IFS4HfVJndhE3P4b5jnZ34DfsjO330dNoBJ9hK8kMK4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansnabataean/v17/IFS4HfVJndhE3P4b5jnZ34DfsjO330dNoBJ9hK8kMK4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Pau Cin Hau", + filename: "x3d-cl3IZKmUqiMg_9wBLLtzl22EayN7ehIdjEWqKMxsKw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanspaucinhau/v21/x3d-cl3IZKmUqiMg_9wBLLtzl22EayN7ehIdjEWqKMxsKw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite PL Guides", + filename: "jVyW7m_lCm7G5CZyQCAu8mgkGLk-kmibWR3aRZ2Kw7A", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteplguides/v1/jVyW7m_lCm7G5CZyQCAu8mgkGLk-kmibWR3aRZ2Kw7A.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Cypriot", + filename: "8AtzGta9PYqQDjyp79a6f8Cj-3a3cxIsK5MPpahF", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanscypriot/v20/8AtzGta9PYqQDjyp79a6f8Cj-3a3cxIsK5MPpahF.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite PT Guides", + filename: "sJoY3K5JjdGLJV3vyatrMkupgg-kWTx5F5k90TZO69o", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteptguides/v2/sJoY3K5JjdGLJV3vyatrMkupgg-kWTx5F5k90TZO69o.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite IT Trad", + filename: "SlGKmR6Yo5oYZX5BFVcEySBSPE50BivIh2y2Iq91", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteittrad/v11/SlGKmR6Yo5oYZX5BFVcEySBSPE50BivIh2y2Iq91.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Tamil Supplement", + filename: "DdTz78kEtnooLS5rXF1DaruiCd_bFp_Ph4sGcn7ax_vsAeMkeq1x", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstamilsupplement/v23/DdTz78kEtnooLS5rXF1DaruiCd_bFp_Ph4sGcn7ax_vsAeMkeq1x.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite PE Guides", + filename: "AMONz5uBsGadFuvf9j8ZyqI0FA3br70wwyyAlqETME8", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritepeguides/v1/AMONz5uBsGadFuvf9j8ZyqI0FA3br70wwyyAlqETME8.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Nushu", + filename: "rnCw-xRQ3B7652emAbAe_Ai1IYaFWFAMArZKqQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansnushu/v20/rnCw-xRQ3B7652emAbAe_Ai1IYaFWFAMArZKqQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bitcount Prop Double Ink", + filename: "Y4GYYbpwUzElrfvDMsf8vwlfaBFstfqVIz5JD-B-oNRyiOmDZQ", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountpropdoubleink/v2/Y4GYYbpwUzElrfvDMsf8vwlfaBFstfqVIz5JD-B-oNRyiOmDZQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Ugaritic", + filename: "3qTwoiqhnSyU8TNFIdhZVCwbjCpkAXXkMhoIkiazfg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansugaritic/v17/3qTwoiqhnSyU8TNFIdhZVCwbjCpkAXXkMhoIkiazfg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Bassa Vah", + filename: "PN_sRee-r3f7LnqsD5sax12gjZn7mBpL_4c2VNUQptE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansbassavah/v21/PN_sRee-r3f7LnqsD5sax12gjZn7mBpL_4c2VNUQptE.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Old Permic", + filename: "snf1s1q1-dF8pli1TesqcbUY4Mr-ElrwKLdXgv_dKYB5", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansoldpermic/v18/snf1s1q1-dF8pli1TesqcbUY4Mr-ElrwKLdXgv_dKYB5.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Serif Dives Akuru", + filename: "QldfNSVMqAsHtsJ_TnD3aT03sMgd57ibeeZT60DIyoV9Ejs", + category: "serif", + url: "https://fonts.gstatic.com/s/notoserifdivesakuru/v8/QldfNSVMqAsHtsJ_TnD3aT03sMgd57ibeeZT60DIyoV9Ejs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Modi", + filename: "pe03MIySN5pO62Z5YkFyT7jeav5qWVAgVol-", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmodi/v25/pe03MIySN5pO62Z5YkFyT7jeav5qWVAgVol-.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jersey 20 Charted", + filename: "JTUNjJMy9DKq5FzVaj9tpgYgvHqGn_Z1ji-rqnQ_", + category: "display", + url: "https://fonts.gstatic.com/s/jersey20charted/v4/JTUNjJMy9DKq5FzVaj9tpgYgvHqGn_Z1ji-rqnQ_.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Old Turkic", + filename: "yMJNMJVya43H0SUF_WmcGEQVqoEMKDKbsE2RjEw-Vyws", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansoldturkic/v19/yMJNMJVya43H0SUF_WmcGEQVqoEMKDKbsE2RjEw-Vyws.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Kayah Li", + filename: "B50SF61OpWTRcGrhOVJJwOMXdca6Yec-gFPEM4EAUw", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskayahli/v26/B50SF61OpWTRcGrhOVJJwOMXdca6Yec-gFPEM4EAUw.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Bitcount Ink", + filename: "CHykV-zVFUj9azIqslTrFzEuNlAghmBIYQ", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountink/v2/CHykV-zVFUj9azIqslTrFzEuNlAghmBIYQ.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Deseret", + filename: "MwQsbgPp1eKH6QsAVuFb9AZM6MMr2Vq9ZnJSZtQG", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansdeseret/v18/MwQsbgPp1eKH6QsAVuFb9AZM6MMr2Vq9ZnJSZtQG.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yarndings 12 Charted", + filename: "eLGDP_DlKhO-DUfeqM4I_vDdJgmIh7hAvvbJ0t-dHaJH", + category: "display", + url: "https://fonts.gstatic.com/s/yarndings12charted/v4/eLGDP_DlKhO-DUfeqM4I_vDdJgmIh7hAvvbJ0t-dHaJH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite BR", + filename: "kJE0BuMK4Q07lDHc2Xp9vYgSrMxzBZ2lDA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritebr/v11/kJE0BuMK4Q07lDHc2Xp9vYgSrMxzBZ2lDA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Yarndings 12", + filename: "55xreyp2N8T5P2LJbZAlkY9c8ZLMI2VUnQ", + category: "display", + url: "https://fonts.gstatic.com/s/yarndings12/v4/55xreyp2N8T5P2LJbZAlkY9c8ZLMI2VUnQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite FR Trad", + filename: "sJoe3KxJjdGLJV3vyatrJE2pkQisWXkKHZ0f1CZO", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritefrtrad/v13/sJoe3KxJjdGLJV3vyatrJE2pkQisWXkKHZ0f1CZO.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Ogham", + filename: "kmKlZqk1GBDGN0mY6k5lmEmww4hrt5laQxcoCA", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansogham/v18/kmKlZqk1GBDGN0mY6k5lmEmww4hrt5laQxcoCA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Lepcha", + filename: "0QI7MWlB_JWgA166SKhu05TekNS32AJstqBXgd4", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslepcha/v20/0QI7MWlB_JWgA166SKhu05TekNS32AJstqBXgd4.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Soyombo", + filename: "RWmSoL-Y6-8q5LTtXs6MF6q7xsxgY0FrIFOcK25W", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssoyombo/v18/RWmSoL-Y6-8q5LTtXs6MF6q7xsxgY0FrIFOcK25W.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Manichaean", + filename: "taiVGntiC4--qtsfi4Jp9-_GkPZZCcrfekqCNTtFCtdX", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmanichaean/v19/taiVGntiC4--qtsfi4Jp9-_GkPZZCcrfekqCNTtFCtdX.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Yarndings 20", + filename: "TuGWUVlkUohEQu8l7K8b-vNFB380PMTK1w", + category: "display", + url: "https://fonts.gstatic.com/s/yarndings20/v4/TuGWUVlkUohEQu8l7K8b-vNFB380PMTK1w.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Bitcount Grid Double Ink", + filename: "55x_ez5tP8L0NH7JfsNC0tQY0fynXgYg-YY2JqgqdEYE1Ubx_A", + category: "display", + url: "https://fonts.gstatic.com/s/bitcountgriddoubleink/v2/55x_ez5tP8L0NH7JfsNC0tQY0fynXgYg-YY2JqgqdEYE1Ubx_A.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Noto Sans Old Sogdian", + filename: "3JnjSCH90Gmq2mrzckOBBhFhdrMst48aURt7neIqM-9uyg", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansoldsogdian/v18/3JnjSCH90Gmq2mrzckOBBhFhdrMst48aURt7neIqM-9uyg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Tirhuta", + filename: "t5t6IQYRNJ6TWjahPR6X-M-apUyby7uGUBsTrn5P", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanstirhuta/v17/t5t6IQYRNJ6TWjahPR6X-M-apUyby7uGUBsTrn5P.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Phoenician", + filename: "jizFRF9Ksm4Bt9PvcTaEkIHiTVtxmFtS5X7Jot-p5561", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansphoenician/v18/jizFRF9Ksm4Bt9PvcTaEkIHiTVtxmFtS5X7Jot-p5561.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Jacquarda Bastarda 9 Charted", + filename: "Yq6D-KaMUyfq4qLgx19A_ocp43FeLd9m0vDxm-yf8JPuf0cPaL8pmQg", + category: "display", + url: "https://fonts.gstatic.com/s/jacquardabastarda9charted/v4/Yq6D-KaMUyfq4qLgx19A_ocp43FeLd9m0vDxm-yf8JPuf0cPaL8pmQg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Siddham", + filename: "OZpZg-FwqiNLe9PELUikxTWDoCCeGqndk3Ic92ZH", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanssiddham/v21/OZpZg-FwqiNLe9PELUikxTWDoCCeGqndk3Ic92ZH.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NZ Basic", + filename: "YcmrsYdNS1SdgmHbGgtRtk4ljD2L-0ttveOqmaM9pA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritenzbasic/v1/YcmrsYdNS1SdgmHbGgtRtk4ljD2L-0ttveOqmaM9pA.ttf", + weight: 400, + isVariable: true + }, + { + displayName: "Yarndings 20 Charted", + filename: "QldRNSdbpg0G8vh0W2qxe0l-hcUPtY2VaLQm4UTqz5V9", + category: "display", + url: "https://fonts.gstatic.com/s/yarndings20charted/v4/QldRNSdbpg0G8vh0W2qxe0l-hcUPtY2VaLQm4UTqz5V9.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NZ Basic Guides", + filename: "R70Ijzkdl_2TLaCpQB6Y1nC0FVKKJvaZ2hDyljkrmGCAdZNU-YI", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritenzbasicguides/v1/R70Ijzkdl_2TLaCpQB6Y1nC0FVKKJvaZ2hDyljkrmGCAdZNU-YI.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Meroitic", + filename: "IFS5HfRJndhE3P4b5jnZ3ITPvC6i00UDgDhTiKY9KQ", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmeroitic/v19/IFS5HfRJndhE3P4b5jnZ3ITPvC6i00UDgDhTiKY9KQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Mende Kikakui", + filename: "11hRGoLHz17aKjQCWj-JHcLvu2Q5zZrnkbNCLUx_aDJLAHer", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosansmendekikakui/v30/11hRGoLHz17aKjQCWj-JHcLvu2Q5zZrnkbNCLUx_aDJLAHer.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NZ Guides", + filename: "t5t8IQQPN4uFDRepJwiX4vzIikyGzv71Wh8xq25PL5k", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritenzguides/v2/t5t8IQQPN4uFDRepJwiX4vzIikyGzv71Wh8xq25PL5k.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Kharoshthi", + filename: "Fh4qPiLjKS30-P4-pGMMXCCfvkc5Vd7KE5z4rFyx5mR1", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanskharoshthi/v17/Fh4qPiLjKS30-P4-pGMMXCCfvkc5Vd7KE5z4rFyx5mR1.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Noto Sans Lycian", + filename: "QldVNSNMqAsHtsJ7UmqxBQA9r8wA5_naCJwn00E", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/notosanslycian/v16/QldVNSNMqAsHtsJ7UmqxBQA9r8wA5_naCJwn00E.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite BE WAL Guides", + filename: "l7gPbiR5yM62ycwevWCt02rrTFoEJvY4kyrrUzHlVJabaOSA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritebewalguides/v1/l7gPbiR5yM62ycwevWCt02rrTFoEJvY4kyrrUzHlVJabaOSA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU VIC Guides", + filename: "ll8sK3mEVy6nEMXMskXIZv8owEdZpVIWaQEnuD6F2TpBa98q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteauvicguides/v1/ll8sK3mEVy6nEMXMskXIZv8owEdZpVIWaQEnuD6F2TpBa98q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite IE Guides", + filename: "LhW5MULFNP8PI-1UADw_Kbp9daTx5ovUaNojN9_8IVQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteieguides/v1/LhW5MULFNP8PI-1UADw_Kbp9daTx5ovUaNojN9_8IVQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite TZ Guides", + filename: "SLXUc0_L5XEkcjBPGvusk4lULgsM9U5_YQy93JQ2XEg", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritetzguides/v1/SLXUc0_L5XEkcjBPGvusk4lULgsM9U5_YQy93JQ2XEg.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NL Guides", + filename: "FwZH7_8mxlw-50y5PJughoCL4jbXkMqwsWKGP6kvdPA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritenlguides/v1/FwZH7_8mxlw-50y5PJughoCL4jbXkMqwsWKGP6kvdPA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Cossette Texte", + filename: "S6ukw4pDXzTb-m1kPi_7eV-ciP01xPBQ19bE", + category: "sans-serif", + url: "https://fonts.gstatic.com/s/cossettetexte/v3/S6ukw4pDXzTb-m1kPi_7eV-ciP01xPBQ19bE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite ES Deco Guides", + filename: "flUrRriwwII5RVl2TZ1XBNTUOYY6ZzZzwPCEKtwqYEyDBQWJ7Q", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteesdecoguides/v1/flUrRriwwII5RVl2TZ1XBNTUOYY6ZzZzwPCEKtwqYEyDBQWJ7Q.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite ZA Guides", + filename: "O4ZOFHPsmxlhCg3-iycDyEwy0BT1ribk2HDoCLfQmgE", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritezaguides/v1/O4ZOFHPsmxlhCg3-iycDyEwy0BT1ribk2HDoCLfQmgE.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DE VA Guides", + filename: "WwkPxOmkDVqm-ojMLT_kdMUoBpMYm6KTeb28UB9SNQIUdqQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedevaguides/v1/WwkPxOmkDVqm-ojMLT_kdMUoBpMYm6KTeb28UB9SNQIUdqQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite FR Moderne Guides", + filename: "CSRr4yxOn-mMWCgLPl16KrUKBbwa2ZZLdkrvXllIP22HnIzLvrG2fw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritefrmoderneguides/v2/CSRr4yxOn-mMWCgLPl16KrUKBbwa2ZZLdkrvXllIP22HnIzLvrG2fw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite FR Trad Guides", + filename: "l7gMbit5yM62ycwevWCt133rT2kpYpEKjyfqRWLFfriXYf2ZXw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritefrtradguides/v1/l7gMbit5yM62ycwevWCt133rT2kpYpEKjyfqRWLFfriXYf2ZXw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite IT Moderna Guides", + filename: "2sDKZHBJg5rCj6fz_QgDJhGcTtJ5AVu-1w5jRQjRv9qPpOVtBhB5gQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteitmodernaguides/v1/2sDKZHBJg5rCj6fz_QgDJhGcTtJ5AVu-1w5jRQjRv9qPpOVtBhB5gQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite IT Trad Guides", + filename: "SlGDmReYo5oYZX5BFVcEySBSPE50BiuP2AHaRsRUA4V-e6yHgQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteittradguides/v2/SlGDmReYo5oYZX5BFVcEySBSPE50BiuP2AHaRsRUA4V-e6yHgQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite US Modern Guides", + filename: "0QI1MWNf_4C2VH-yUr5uyqKOvtOynXAoku8j8Lv9pryxZQscrW1V", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteusmodernguides/v1/0QI1MWNf_4C2VH-yUr5uyqKOvtOynXAoku8j8Lv9pryxZQscrW1V.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite BR Guides", + filename: "tssxAohQaiQS-wrnJz-F5CqW4dOezRwp-9cCOxBu_BM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritebrguides/v1/tssxAohQaiQS-wrnJz-F5CqW4dOezRwp-9cCOxBu_BM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU SA Guides", + filename: "3JnsSCLj03y8jUv7aFWBCCglBaFjl54aVBAovcgEP-Z3054", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteausaguides/v1/3JnsSCLj03y8jUv7aFWBCCglBaFjl54aVBAovcgEP-Z3054.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite CL Guides", + filename: "Z9XKDnxTQxyGzOn3eMH-i6Ws0czqkE-hrNpVuw5_BAM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteclguides/v2/Z9XKDnxTQxyGzOn3eMH-i6Ws0czqkE-hrNpVuw5_BAM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite ID Guides", + filename: "MjQamj1kuP_soQ3o-rysMdWi_8oJlIUUInch3bTfcxs", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteidguides/v1/MjQamj1kuP_soQ3o-rysMdWi_8oJlIUUInch3bTfcxs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DK Uloopet Guides", + filename: "WwkKxOSkDVqm-ojMLT_kdMsoBb5Xs6efafiIBXYcVHk1bo9bkIONtA", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedkuloopetguides/v1/WwkKxOSkDVqm-ojMLT_kdMsoBb5Xs6efafiIBXYcVHk1bo9bkIONtA.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DK Loopet Guides", + filename: "4iC46LlmYsRPlQ1zDEvT8weoW-sI8-h9xxN83W-Cb5tmElj-b0nB", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedkloopetguides/v1/4iC46LlmYsRPlQ1zDEvT8weoW-sI8-h9xxN83W-Cb5tmElj-b0nB.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite ES Guides", + filename: "VuJtdM_b2p7tvoFGLMPdedpGJm402y6mhDDGfdnzXeU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteesguides/v1/VuJtdM_b2p7tvoFGLMPdedpGJm402y6mhDDGfdnzXeU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU QLD Guides", + filename: "TuGBUUJtX5tTUfQi_7kbiZZFVhl0FIKnvy00LDxlIzIU5RwD", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteauqldguides/v1/TuGBUUJtX5tTUfQi_7kbiZZFVhl0FIKnvy00LDxlIzIU5RwD.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NG Modern Guides", + filename: "6qLVKYQNtxD-qVlIPUIPdWMlWxy3BmFEQgxB1xvFhDarWJtyZyGU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritengmodernguides/v1/6qLVKYQNtxD-qVlIPUIPdWMlWxy3BmFEQgxB1xvFhDarWJtyZyGU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite HU Guides", + filename: "AYCXpWvtftIVXepC5AzjCAdKgYPugOK0TqxTJw_GOM0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritehuguides/v1/AYCXpWvtftIVXepC5AzjCAdKgYPugOK0TqxTJw_GOM0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU TAS Guides", + filename: "cY9Vfi6cVk5RvjGtQrLqjozy3ekUDtDMDX-NNjbKL4UbaDZD", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteautasguides/v1/cY9Vfi6cVk5RvjGtQrLqjozy3ekUDtDMDX-NNjbKL4UbaDZD.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite HR Lijeva Guides", + filename: "uU9aCAgH7I63K35cu3bRkqamzjr8EW133LJaXDO-QObhgDgMKYJO", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritehrlijevaguides/v1/uU9aCAgH7I63K35cu3bRkqamzjr8EW133LJaXDO-QObhgDgMKYJO.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DE LA Guides", + filename: "1q2XY42fB0V64O4aSe1OjKs_yAXBOfDI5wE56g2M62M1AXs", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedelaguides/v2/1q2XY42fB0V64O4aSe1OjKs_yAXBOfDI5wE56g2M62M1AXs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AT Guides", + filename: "QdVKSS0gJR2xneUeQPfE-FVA1BlZQRpBRbgJdhapcUU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteatguides/v1/QdVKSS0gJR2xneUeQPfE-FVA1BlZQRpBRbgJdhapcUU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AU NSW Guides", + filename: "LDIiao-QNRMmVPcU8-sgUraMF7GZs_1Emk3v8tPbZeQ3YBXs", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteaunswguides/v1/LDIiao-QNRMmVPcU8-sgUraMF7GZs_1Emk3v8tPbZeQ3YBXs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite BE VLG Guides", + filename: "EYqjmb1Mz6hO4edaU9qKGFZMDd_Q-zwwK__U1u9GK3nNgEoc", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritebevlgguides/v2/EYqjmb1Mz6hO4edaU9qKGFZMDd_Q-zwwK__U1u9GK3nNgEoc.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite SK Guides", + filename: "P5sezYaSYdfH5z93kEFk3tyPlqxeQeo_JzruWQshcbU", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteskguides/v1/P5sezYaSYdfH5z93kEFk3tyPlqxeQeo_JzruWQshcbU.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite GB S Guides", + filename: "0FlKVOSHl1iH-fv2BH4kIkUBqtlNCEaQLlyKx1QPi-Z8Fw", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritegbsguides/v1/0FlKVOSHl1iH-fv2BH4kIkUBqtlNCEaQLlyKx1QPi-Z8Fw.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite HR Guides", + filename: "6NUK8EedKwOcfRjj8ukv_L4kjqAoGrjdWmA08mCgdfM", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritehrguides/v1/6NUK8EedKwOcfRjj8ukv_L4kjqAoGrjdWmA08mCgdfM.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite NO Guides", + filename: "DPEwYx-Cyg4cQ2aAcFshOLL79zJKccqHe2-Z2vnLeAs", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritenoguides/v1/DPEwYx-Cyg4cQ2aAcFshOLL79zJKccqHe2-Z2vnLeAs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite RO Guides", + filename: "wlptgx7ZCE50snmWiOExiylvL10_b5Ym_LBte6KuGEo", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteroguides/v1/wlptgx7ZCE50snmWiOExiylvL10_b5Ym_LBte6KuGEo.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite CA Guides", + filename: "MjQamj1kuP_soQ3o-rysO9Ci_8oJlIUUInch3bTfcxs", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritecaguides/v1/MjQamj1kuP_soQ3o-rysO9Ci_8oJlIUUInch3bTfcxs.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite DE SAS Guides", + filename: "8At5GtCjPp-GWR2h9cC6ePzz2l6LJ3VZaPNi15BEdtBgPI1G", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritedesasguides/v1/8At5GtCjPp-GWR2h9cC6ePzz2l6LJ3VZaPNi15BEdtBgPI1G.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite IS Guides", + filename: "5aUp9-GkphaVExwxdX6SwWF-uigk3Cglrm9ptxu7HZ0", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteisguides/v1/5aUp9-GkphaVExwxdX6SwWF-uigk3Cglrm9ptxu7HZ0.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite AR Guides", + filename: "iJWYBWqKZTrYS9uv-Ry6kDf-q_0Xq67mcGUaW4_MiYQ", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwritearguides/v5/iJWYBWqKZTrYS9uv-Ry6kDf-q_0Xq67mcGUaW4_MiYQ.ttf", + weight: 400, + isVariable: false + }, + { + displayName: "Playwrite CZ Guides", + filename: "6qLcKY0NtxD-qVlIPUIPeH4lUQa6B3ZZQkseuneh7xc", + category: "handwriting", + url: "https://fonts.gstatic.com/s/playwriteczguides/v1/6qLcKY0NtxD-qVlIPUIPeH4lUQa6B3ZZQkseuneh7xc.ttf", + weight: 400, + isVariable: false + } +]; + +/** 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]; diff --git a/src/core/inputs/controls.ts b/src/core/inputs/controls.ts index 589df268..bacf70a0 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -1,13 +1,14 @@ -import { Edit } from "@core/edit"; +import { Edit } from "@core/edit-session"; +import { sec } from "@core/timing/types"; export class Controls { private edit: Edit; - private seekDistance: number = 50; - private seekDistanceLarge: number = 500; - private frameTime: number = 16.67; + private seekDistance: number = 0.05; // 50ms in seconds + private seekDistanceLarge: number = 0.5; // 500ms in seconds + private frameTime: number = 1 / 60; // ~16.67ms in seconds - constructor(timeline: Edit) { - this.edit = timeline; + constructor(edit: Edit) { + this.edit = edit; } public async load(): Promise { @@ -21,13 +22,32 @@ export class Controls { document.removeEventListener("keyup", this.handleKeyUp); } + private shouldIgnoreKeyboardEvent(event: KeyboardEvent): boolean { + const target = event.target as HTMLElement; + + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return true; + } + + if (target.isContentEditable) { + return true; + } + + if (target.getAttribute?.("role") === "textbox") { + return true; + } + + return false; + } + private handleKeyDown = (event: KeyboardEvent): void => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + if (this.shouldIgnoreKeyboardEvent(event)) { return; } switch (event.code) { case "Space": { + event.preventDefault(); if (!this.edit.isPlaying) { this.edit.play(); } else { @@ -36,20 +56,44 @@ export class Controls { break; } case "ArrowLeft": { - if (event.metaKey) { - this.edit.seek(0); + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + event.preventDefault(); + const delta = event.shiftKey ? 10 : 1; + this.edit.moveSelectedClip(-delta, 0); } else { const seekAmount = event.shiftKey ? this.seekDistanceLarge : this.seekDistance; - this.edit.seek(this.edit.playbackTime - seekAmount); + this.edit.seek(sec(this.edit.playbackTime - seekAmount)); } break; } case "ArrowRight": { - if (event.metaKey) { - this.edit.seek(this.edit.getTotalDuration()); + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + event.preventDefault(); + const delta = event.shiftKey ? 10 : 1; + this.edit.moveSelectedClip(delta, 0); } else { const seekAmount = event.shiftKey ? this.seekDistanceLarge : this.seekDistance; - this.edit.seek(this.edit.playbackTime + seekAmount); + this.edit.seek(sec(this.edit.playbackTime + seekAmount)); + } + break; + } + case "ArrowUp": { + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + event.preventDefault(); + const delta = event.shiftKey ? 10 : 1; + this.edit.moveSelectedClip(0, -delta); + } + break; + } + case "ArrowDown": { + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + event.preventDefault(); + const delta = event.shiftKey ? 10 : 1; + this.edit.moveSelectedClip(0, delta); } break; } @@ -67,12 +111,36 @@ export class Controls { } case "Comma": { // Frame step backward - this.edit.seek(this.edit.playbackTime - this.frameTime); + this.edit.seek(sec(this.edit.playbackTime - this.frameTime)); break; } case "Period": { // Frame step forward - this.edit.seek(this.edit.playbackTime + this.frameTime); + this.edit.seek(sec(this.edit.playbackTime + this.frameTime)); + break; + } + case "Home": { + event.preventDefault(); + const selected = this.edit.getSelectedClipInfo(); + if (event.shiftKey && selected) { + // Go to selected clip start + this.edit.seek(sec(selected.player.getStart())); + } else { + // Go to timeline start + this.edit.seek(sec(0)); + } + break; + } + case "End": { + event.preventDefault(); + const selected = this.edit.getSelectedClipInfo(); + if (event.shiftKey && selected) { + // Go to selected clip end + this.edit.seek(sec(selected.player.getEnd())); + } else { + // Go to timeline end + this.edit.seek(this.edit.totalDuration); + } break; } case "KeyZ": { @@ -95,6 +163,23 @@ export class Controls { } break; } + case "KeyC": { + if (event.metaKey || event.ctrlKey) { + event.preventDefault(); + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + this.edit.copyClip(selected.trackIndex, selected.clipIndex); + } + } + break; + } + case "KeyV": { + if (event.metaKey || event.ctrlKey) { + event.preventDefault(); + this.edit.pasteClip(); + } + break; + } default: { break; } @@ -102,8 +187,7 @@ export class Controls { }; private handleKeyUp = (event: KeyboardEvent): void => { - // Skip if inside input elements - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + if (this.shouldIgnoreKeyboardEvent(event)) { return; } diff --git a/src/core/interaction/clip-interaction.ts b/src/core/interaction/clip-interaction.ts new file mode 100644 index 00000000..c196fc83 --- /dev/null +++ b/src/core/interaction/clip-interaction.ts @@ -0,0 +1,242 @@ +/** + * ClipInteractionSystem - Pure functions for clip interaction calculations + * + * This module provides testable functions for: + * - Hit zone detection (edges, corners) + * - Corner scale calculations + * - Edge resize calculations + * - Dimension clamping + */ + +import type { Size, Vector } from "@layouts/geometry"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const INTERACTION_CONSTANTS = { + /** Minimum clip dimension in pixels */ + MIN_DIMENSION: 50, + /** Maximum clip dimension in pixels */ + MAX_DIMENSION: 3840 +} as const; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type ScaleDirection = "topLeft" | "topRight" | "bottomLeft" | "bottomRight"; +export type EdgeDirection = "left" | "right" | "top" | "bottom"; + +/** + * Original dimensions captured at the start of a resize operation + */ +export interface OriginalDimensions { + width: number; + height: number; + offsetX: number; + offsetY: number; +} + +/** + * Result of a resize/scale calculation + */ +export interface ResizeResult { + width: number; + height: number; + offsetX: number; + offsetY: number; +} + +// ─── Edge Zone Detection ────────────────────────────────────────────────────── + +/** + * Detect if a point is within an edge resize zone. + * Returns the edge direction or null if not in any edge zone. + * Pure function - no side effects. + * + * @param point - Local position within the element + * @param size - Element size + * @param hitZone - Distance in pixels for edge detection + */ +export function detectEdgeZone(point: Vector, size: Size, hitZone: number): EdgeDirection | null { + if (hitZone <= 0) return null; + + // Check if pointer is near any edge (within hit zone) + const nearLeft = point.x >= -hitZone && point.x <= hitZone; + const nearRight = point.x >= size.width - hitZone && point.x <= size.width + hitZone; + const nearTop = point.y >= -hitZone && point.y <= hitZone; + const nearBottom = point.y >= size.height - hitZone && point.y <= size.height + hitZone; + + // Determine if within vertical/horizontal range (not in corner) + const withinVerticalRange = point.y > hitZone && point.y < size.height - hitZone; + const withinHorizontalRange = point.x > hitZone && point.x < size.width - hitZone; + + if (nearLeft && withinVerticalRange) return "left"; + if (nearRight && withinVerticalRange) return "right"; + if (nearTop && withinHorizontalRange) return "top"; + if (nearBottom && withinHorizontalRange) return "bottom"; + + return null; +} + +// ─── Corner Zone Detection ──────────────────────────────────────────────────── + +const CORNER_NAMES: ScaleDirection[] = ["topLeft", "topRight", "bottomRight", "bottomLeft"]; + +/** + * Detect if a point is within a rotation zone (outside corners). + * Returns the corner name or null if not in any rotation zone. + * Pure function - no side effects. + * + * @param point - Local position within the element + * @param corners - Array of corner positions [topLeft, topRight, bottomRight, bottomLeft] + * @param handleRadius - Radius of scale handles (inner boundary) + * @param rotationZone - Size of rotation detection zone (outer boundary) + */ +export function detectCornerZone(point: Vector, corners: Vector[], handleRadius: number, rotationZone: number): ScaleDirection | null { + for (let i = 0; i < corners.length; i += 1) { + const corner = corners[i]; + const dx = point.x - corner.x; + const dy = point.y - corner.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + // Outside handle radius but within rotation zone + if (distance > handleRadius && distance < handleRadius + rotationZone) { + return CORNER_NAMES[i]; + } + } + + return null; +} + +// ─── Corner Scale Calculation ───────────────────────────────────────────────── + +/** + * Calculate new dimensions when scaling from a corner. + * Pure function - no side effects. + * + * @param direction - Which corner is being dragged + * @param delta - Movement delta from drag start + * @param original - Original dimensions at drag start + * @param canvasSize - Canvas size for offset normalization + */ +export function calculateCornerScale(direction: ScaleDirection, delta: Vector, original: OriginalDimensions, canvasSize: Size): ResizeResult { + let newWidth = original.width; + let newHeight = original.height; + let newOffsetX = original.offsetX; + let newOffsetY = original.offsetY; + + // Avoid division by zero + const canvasWidth = canvasSize.width || 1; + const canvasHeight = canvasSize.height || 1; + + switch (direction) { + case "topLeft": + // Decrease width, decrease height, shift offset to keep bottom-right fixed + newWidth = original.width - delta.x; + newHeight = original.height - delta.y; + newOffsetX = original.offsetX + delta.x / 2 / canvasWidth; + newOffsetY = original.offsetY - delta.y / 2 / canvasHeight; + break; + case "topRight": + // Increase width, decrease height, shift offset to keep bottom-left fixed + newWidth = original.width + delta.x; + newHeight = original.height - delta.y; + newOffsetX = original.offsetX + delta.x / 2 / canvasWidth; + newOffsetY = original.offsetY - delta.y / 2 / canvasHeight; + break; + case "bottomLeft": + // Decrease width, increase height, shift offset to keep top-right fixed + newWidth = original.width - delta.x; + newHeight = original.height + delta.y; + newOffsetX = original.offsetX + delta.x / 2 / canvasWidth; + newOffsetY = original.offsetY - delta.y / 2 / canvasHeight; + break; + case "bottomRight": + // Increase width, increase height, shift offset to keep top-left fixed + newWidth = original.width + delta.x; + newHeight = original.height + delta.y; + newOffsetX = original.offsetX + delta.x / 2 / canvasWidth; + newOffsetY = original.offsetY - delta.y / 2 / canvasHeight; + break; + default: + // All cases covered by ScaleDirection type + break; + } + + return { width: newWidth, height: newHeight, offsetX: newOffsetX, offsetY: newOffsetY }; +} + +// ─── Edge Resize Calculation ────────────────────────────────────────────────── + +/** + * Calculate new dimensions when resizing from an edge. + * Pure function - no side effects. + * + * @param direction - Which edge is being dragged + * @param delta - Movement delta from drag start + * @param original - Original dimensions at drag start + * @param canvasSize - Canvas size for offset normalization + */ +export function calculateEdgeResize(direction: EdgeDirection, delta: Vector, original: OriginalDimensions, canvasSize: Size): ResizeResult { + let newWidth = original.width; + let newHeight = original.height; + let newOffsetX = original.offsetX; + let newOffsetY = original.offsetY; + + // Avoid division by zero + const canvasWidth = canvasSize.width || 1; + const canvasHeight = canvasSize.height || 1; + + switch (direction) { + case "left": + // Dragging left edge: width decreases, offset shifts right to keep right edge fixed + newWidth = original.width - delta.x; + newOffsetX = original.offsetX + delta.x / 2 / canvasWidth; + break; + case "right": + // Dragging right edge: width increases, offset shifts right to keep left edge fixed + newWidth = original.width + delta.x; + newOffsetX = original.offsetX + delta.x / 2 / canvasWidth; + break; + case "top": + // Dragging top edge: height decreases, offset shifts up to keep bottom edge fixed + newHeight = original.height - delta.y; + newOffsetY = original.offsetY - delta.y / 2 / canvasHeight; + break; + case "bottom": + // Dragging bottom edge: height increases, offset shifts down to keep top edge fixed + newHeight = original.height + delta.y; + newOffsetY = original.offsetY - delta.y / 2 / canvasHeight; + break; + default: + // All cases covered by EdgeDirection type + break; + } + + return { width: newWidth, height: newHeight, offsetX: newOffsetX, offsetY: newOffsetY }; +} + +// ─── Dimension Clamping ─────────────────────────────────────────────────────── + +/** + * Clamp dimensions to valid bounds. + * Pure function - no side effects. + */ +export function clampDimensions(width: number, height: number): { width: number; height: number } { + return { + width: Math.max(INTERACTION_CONSTANTS.MIN_DIMENSION, Math.min(width, INTERACTION_CONSTANTS.MAX_DIMENSION)), + height: Math.max(INTERACTION_CONSTANTS.MIN_DIMENSION, Math.min(height, INTERACTION_CONSTANTS.MAX_DIMENSION)) + }; +} + +// ─── Dimension Rounding ─────────────────────────────────────────────────────── + +/** + * Round dimensions to integers. + * Clip width/height should always be whole numbers. + * Pure function - no side effects. + */ +export function roundDimensions(width: number, height: number): { width: number; height: number } { + return { + width: Math.round(width), + height: Math.round(height) + }; +} diff --git a/src/core/interaction/selection-overlay.ts b/src/core/interaction/selection-overlay.ts new file mode 100644 index 00000000..7b67bcfe --- /dev/null +++ b/src/core/interaction/selection-overlay.ts @@ -0,0 +1,180 @@ +/** + * SelectionOverlay - Pure functions and constants for selection UI + * + * This module provides testable functions for: + * - Cursor generation (rotation, resize) + * - Cursor angle calculations + * - Hit area calculations + * - Selection styling constants + */ + +import type { Size } from "@layouts/geometry"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const SELECTION_CONSTANTS = { + /** Radius of corner scale handles in pixels */ + SCALE_HANDLE_RADIUS: 4, + /** Width of selection outline in pixels */ + OUTLINE_WIDTH: 1, + /** Hit zone for edge resize detection in pixels */ + EDGE_HIT_ZONE: 8, + /** Hit zone for rotation detection outside corners in pixels */ + ROTATION_HIT_ZONE: 15, + /** Default selection outline color (blue) */ + DEFAULT_COLOR: 0x0d99ff, + /** Active/hover selection color (soft cyan) */ + ACTIVE_COLOR: 0x40e0ff +} as const; + +/** + * Base angles for cursors at each corner/edge position (before clip rotation) + */ +export const CURSOR_BASE_ANGLES: Record = { + // Rotation cursor angles + topLeft: 0, + topRight: 90, + bottomRight: 180, + bottomLeft: 270, + // Resize cursor angles (NW-SE diagonal = 45°, NE-SW = -45°, horizontal = 0°, vertical = 90°) + topLeftResize: 45, + topRightResize: -45, + bottomRightResize: 45, + bottomLeftResize: -45, + left: 0, + right: 0, + top: 90, + bottom: 90 +}; + +export const CORNER_NAMES = ["topLeft", "topRight", "bottomRight", "bottomLeft"] as const; +export type CornerName = (typeof CORNER_NAMES)[number]; + +// ─── SVG Cursor Paths ───────────────────────────────────────────────────────── + +// Curved arrow for rotation cursor +const ROTATION_CURSOR_PATH = + "M1113.142,1956.331C1008.608,1982.71 887.611,2049.487 836.035,2213.487" + + "L891.955,2219.403L779,2396L705.496,2199.678L772.745,2206.792" + + "C832.051,1999.958 984.143,1921.272 1110.63,1892.641L1107.952,1824.711" + + "L1299,1911L1115.34,2012.065L1113.142,1956.331Z"; + +// Double-headed arrow for resize cursor +const RESIZE_CURSOR_PATH = "M1320,2186L1085,2421L1120,2457L975,2496L1014,2351L1050,2386L1285,2151L1250,2115L1396,2075L1356,2221L1320,2186Z"; +const RESIZE_CURSOR_MATRIX = "matrix(0.807871,0.707107,-0.807871,0.707107,2111.872433,-206.020386)"; + +// ─── Cursor Generation ──────────────────────────────────────────────────────── + +/** + * Build a rotation cursor SVG data URI for the given angle. + * Pure function - no side effects. + */ +export function buildRotationCursor(angleDeg: number): string { + const transform = angleDeg === 0 ? "" : ``; + const closeTag = angleDeg === 0 ? "" : ""; + const svg = `${transform}${closeTag}`; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto`; +} + +/** + * Build a resize cursor SVG data URI for the given angle. + * Pure function - no side effects. + */ +export function buildResizeCursor(angleDeg: number): string { + const svg = + `` + + `` + + ``; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto`; +} + +// ─── Cursor Angle Calculation ───────────────────────────────────────────────── + +/** + * Get the rotation cursor angle for a corner, accounting for clip rotation. + * Pure function - no side effects. + */ +export function getRotationCursorAngle(corner: string, clipRotation: number): number { + const baseAngle = CURSOR_BASE_ANGLES[corner] ?? 0; + return baseAngle + clipRotation; +} + +/** + * Get the resize cursor angle for a corner or edge, accounting for clip rotation. + * Pure function - no side effects. + */ +export function getResizeCursorAngle(cornerOrEdge: string, clipRotation: number): number { + const baseAngle = CURSOR_BASE_ANGLES[cornerOrEdge] ?? 0; + return baseAngle + clipRotation; +} + +// ─── Hit Area Calculation ───────────────────────────────────────────────────── + +/** + * Result of hit area calculation + */ +export interface HitAreaRect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Calculate the expanded hit area that includes rotation zones outside corners. + * The margin scales inversely with UI scale so hit zones stay consistent in screen space. + * Pure function - no side effects. + */ +export function calculateHitArea(size: Size, uiScale: number): HitAreaRect { + const hitMargin = (SELECTION_CONSTANTS.ROTATION_HIT_ZONE + SELECTION_CONSTANTS.SCALE_HANDLE_RADIUS) / uiScale; + + return { + x: -hitMargin, + y: -hitMargin, + width: size.width + hitMargin * 2, + height: size.height + hitMargin * 2 + }; +} + +// ─── Selection State Types ──────────────────────────────────────────────────── + +/** + * State needed to render a selection overlay + */ +export interface SelectionState { + /** Whether the element is currently selected */ + isSelected: boolean; + /** Whether the mouse is hovering over the element */ + isHovering: boolean; + /** Whether the element is being dragged/transformed */ + isInteracting: boolean; + /** Current UI scale (for handle sizing) */ + uiScale: number; +} + +/** + * Get the selection outline color based on state. + * Pure function - no side effects. + */ +export function getSelectionColor(state: SelectionState): number { + return state.isHovering || state.isInteracting ? SELECTION_CONSTANTS.ACTIVE_COLOR : SELECTION_CONSTANTS.DEFAULT_COLOR; +} + +/** + * Determine if selection UI should be visible. + * Pure function - no side effects. + */ +export function shouldShowSelection(state: SelectionState, isActive: boolean, isExporting: boolean): boolean { + if (isExporting) return false; + if (!isActive && !state.isHovering) return false; + if (!state.isSelected && !state.isHovering) return false; + return true; +} + +/** + * Determine if scale handles should be visible. + * Pure function - no side effects. + */ +export function shouldShowHandles(state: SelectionState, isActive: boolean): boolean { + return isActive && state.isSelected; +} diff --git a/src/core/interaction/snap-system.ts b/src/core/interaction/snap-system.ts new file mode 100644 index 00000000..419a0511 --- /dev/null +++ b/src/core/interaction/snap-system.ts @@ -0,0 +1,357 @@ +/** + * SnapSystem - Pure functions for position snapping + * + * This module provides pure, testable functions for snapping clip positions + * to canvas edges, centers, and other clips. No side effects, no dependencies + * on Edit or Player instances. + */ + +import type { Size, Vector } from "@layouts/geometry"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +/** + * Bounds of a clip in absolute coordinates + */ +export interface ClipBounds { + left: number; + right: number; + top: number; + bottom: number; + centerX: number; + centerY: number; +} + +/** + * A snap guide to be rendered + */ +export interface SnapGuide { + axis: "x" | "y"; + position: number; + type: "canvas" | "clip"; + /** For clip guides, the extent of the guide line */ + bounds?: { start: number; end: number }; +} + +/** + * Result of a snap operation + */ +export interface SnapResult { + /** The snapped position (may equal input if no snap occurred) */ + position: Vector; + /** Guides to render for visual feedback */ + guides: SnapGuide[]; +} + +/** + * Configuration for snap behavior + */ +export interface SnapConfig { + /** Distance in pixels within which snapping occurs */ + threshold: number; + /** Whether to snap to canvas edges and center */ + snapToCanvas: boolean; + /** Whether to snap to other clips */ + snapToClips: boolean; +} + +/** + * Context needed for snapping calculations + */ +export interface SnapContext { + /** Size of the clip being dragged */ + clipSize: Size; + /** Size of the canvas */ + canvasSize: Size; + /** Bounds of other clips to snap to */ + otherClips: ClipBounds[]; + /** Snap configuration */ + config: SnapConfig; +} + +// ─── Constants ─────────────────────────────────────────────────────────────── + +export const DEFAULT_SNAP_THRESHOLD = 20; + +export const DEFAULT_SNAP_CONFIG: SnapConfig = { + threshold: DEFAULT_SNAP_THRESHOLD, + snapToCanvas: true, + snapToClips: true +}; + +// ─── Helper Functions ──────────────────────────────────────────────────────── + +/** + * Calculate the snap points for a clip at a given position + */ +export function getClipSnapPoints(position: Vector, size: Size): { x: number[]; y: number[] } { + const left = position.x; + const right = position.x + size.width; + const centerX = position.x + size.width / 2; + const top = position.y; + const bottom = position.y + size.height; + const centerY = position.y + size.height / 2; + + return { + x: [left, centerX, right], + y: [top, centerY, bottom] + }; +} + +/** + * Calculate snap points for the canvas (edges + center) + */ +export function getCanvasSnapPoints(canvasSize: Size): { x: number[]; y: number[] } { + return { + x: [0, canvasSize.width / 2, canvasSize.width], + y: [0, canvasSize.height / 2, canvasSize.height] + }; +} + +/** + * Convert a ClipBounds to snap points + */ +export function boundsToSnapPoints(bounds: ClipBounds): { x: number[]; y: number[] } { + return { + x: [bounds.left, bounds.centerX, bounds.right], + y: [bounds.top, bounds.centerY, bounds.bottom] + }; +} + +// ─── Core Snap Functions ───────────────────────────────────────────────────── + +/** + * Snap a position to canvas edges and center. + * Pure function - no side effects. + */ +export function snapToCanvas(position: Vector, clipSize: Size, canvasSize: Size, threshold: number): SnapResult { + const guides: SnapGuide[] = []; + const snapped = { ...position }; + + const clipPoints = getClipSnapPoints(position, clipSize); + const canvasPoints = getCanvasSnapPoints(canvasSize); + + let closestDistanceX = threshold; + let closestDistanceY = threshold; + + // Check X-axis snapping + for (const clipX of clipPoints.x) { + for (const canvasX of canvasPoints.x) { + const distance = Math.abs(clipX - canvasX); + if (distance < closestDistanceX) { + closestDistanceX = distance; + snapped.x = position.x + (canvasX - clipX); + // Remove previous X guide if any + const existingXIdx = guides.findIndex(g => g.axis === "x"); + if (existingXIdx >= 0) guides.splice(existingXIdx, 1); + guides.push({ axis: "x", position: canvasX, type: "canvas" }); + } + } + } + + // Check Y-axis snapping + for (const clipY of clipPoints.y) { + for (const canvasY of canvasPoints.y) { + const distance = Math.abs(clipY - canvasY); + if (distance < closestDistanceY) { + closestDistanceY = distance; + snapped.y = position.y + (canvasY - clipY); + // Remove previous Y guide if any + const existingYIdx = guides.findIndex(g => g.axis === "y"); + if (existingYIdx >= 0) guides.splice(existingYIdx, 1); + guides.push({ axis: "y", position: canvasY, type: "canvas" }); + } + } + } + + return { position: snapped, guides }; +} + +/** + * Snap a position to other clips. + * Pure function - no side effects. + */ +export function snapToClips(position: Vector, clipSize: Size, otherClips: ClipBounds[], threshold: number): SnapResult { + const guides: SnapGuide[] = []; + const snapped = { ...position }; + + if (otherClips.length === 0) { + return { position: snapped, guides }; + } + + const clipPoints = getClipSnapPoints(position, clipSize); + const myTop = position.y; + const myBottom = position.y + clipSize.height; + const myLeft = position.x; + const myRight = position.x + clipSize.width; + + let closestDistanceX = threshold; + let closestDistanceY = threshold; + + for (const other of otherClips) { + const otherPoints = boundsToSnapPoints(other); + + // Check X-axis snapping + for (const clipX of clipPoints.x) { + for (const targetX of otherPoints.x) { + const distance = Math.abs(clipX - targetX); + if (distance < closestDistanceX) { + closestDistanceX = distance; + snapped.x = position.x + (targetX - clipX); + + // Calculate guide bounds (vertical line spanning both clips) + const minY = Math.min(myTop, other.top); + const maxY = Math.max(myBottom, other.bottom); + + // Remove previous X guide if any + const existingXIdx = guides.findIndex(g => g.axis === "x"); + if (existingXIdx >= 0) guides.splice(existingXIdx, 1); + + guides.push({ + axis: "x", + position: targetX, + type: "clip", + bounds: { start: minY, end: maxY } + }); + } + } + } + + // Check Y-axis snapping + for (const clipY of clipPoints.y) { + for (const targetY of otherPoints.y) { + const distance = Math.abs(clipY - targetY); + if (distance < closestDistanceY) { + closestDistanceY = distance; + snapped.y = position.y + (targetY - clipY); + + // Calculate guide bounds (horizontal line spanning both clips) + const minX = Math.min(myLeft, other.left); + const maxX = Math.max(myRight, other.right); + + // Remove previous Y guide if any + const existingYIdx = guides.findIndex(g => g.axis === "y"); + if (existingYIdx >= 0) guides.splice(existingYIdx, 1); + + guides.push({ + axis: "y", + position: targetY, + type: "clip", + bounds: { start: minX, end: maxX } + }); + } + } + } + } + + return { position: snapped, guides }; +} + +/** + * Combined snap function that checks both canvas and clips. + * Clip snapping takes priority over canvas snapping when both are within threshold. + * Pure function - no side effects. + */ +export function snap(position: Vector, context: SnapContext): SnapResult { + const { clipSize, canvasSize, otherClips, config } = context; + const { threshold, snapToCanvas: doSnapToCanvas, snapToClips: doSnapToClips } = config; + + let result: SnapResult = { position: { ...position }, guides: [] }; + + // First apply canvas snapping + if (doSnapToCanvas) { + result = snapToCanvas(result.position, clipSize, canvasSize, threshold); + } + + // Then apply clip snapping (takes priority - will override canvas snap if closer) + if (doSnapToClips && otherClips.length > 0) { + const clipResult = snapToClips(result.position, clipSize, otherClips, threshold); + + // Merge results - clip snaps take priority + const hasClipSnapX = clipResult.guides.some(g => g.axis === "x"); + const hasClipSnapY = clipResult.guides.some(g => g.axis === "y"); + + if (hasClipSnapX) { + result.position.x = clipResult.position.x; + // Replace canvas X guide with clip X guide + result.guides = result.guides.filter(g => g.axis !== "x"); + } + if (hasClipSnapY) { + result.position.y = clipResult.position.y; + // Replace canvas Y guide with clip Y guide + result.guides = result.guides.filter(g => g.axis !== "y"); + } + + // Add clip guides + result.guides.push(...clipResult.guides); + } + + return result; +} + +// ─── Rotation Snapping ─────────────────────────────────────────────────────── + +/** + * Default angles to snap to during rotation (in degrees) + */ +export const DEFAULT_ROTATION_SNAP_ANGLES = [0, 45, 90, 135, 180, 225, 270, 315]; +export const DEFAULT_ROTATION_SNAP_THRESHOLD = 5; // degrees + +/** + * Snap a rotation angle to predefined angles. + * Pure function - no side effects. + */ +export function snapRotation( + angle: number, + snapAngles: number[] = DEFAULT_ROTATION_SNAP_ANGLES, + threshold: number = DEFAULT_ROTATION_SNAP_THRESHOLD +): { angle: number; snapped: boolean } { + // Normalize angle to 0-360 range for comparison + const normalizedAngle = ((angle % 360) + 360) % 360; + + for (const snapAngle of snapAngles) { + const distance = Math.abs(normalizedAngle - snapAngle); + const wrappedDistance = Math.min(distance, 360 - distance); + + if (wrappedDistance < threshold) { + // Preserve full rotations (e.g., 720 degrees stays as 720, not 0) + const fullRotations = Math.round(angle / 360) * 360; + return { angle: fullRotations + snapAngle, snapped: true }; + } + } + + return { angle, snapped: false }; +} + +// ─── Utility Functions ─────────────────────────────────────────────────────── + +/** + * Create ClipBounds from a position and size + */ +export function createClipBounds(position: Vector, size: Size): ClipBounds { + return { + left: position.x, + right: position.x + size.width, + top: position.y, + bottom: position.y + size.height, + centerX: position.x + size.width / 2, + centerY: position.y + size.height / 2 + }; +} + +/** + * Create a SnapContext with default config + */ +export function createSnapContext( + clipSize: Size, + canvasSize: Size, + otherClips: ClipBounds[] = [], + configOverrides: Partial = {} +): SnapContext { + return { + clipSize, + canvasSize, + otherClips, + config: { ...DEFAULT_SNAP_CONFIG, ...configOverrides } + }; +} diff --git a/src/core/layout/fit-system.ts b/src/core/layout/fit-system.ts new file mode 100644 index 00000000..20f86bf8 --- /dev/null +++ b/src/core/layout/fit-system.ts @@ -0,0 +1,157 @@ +/** + * FitSystem - Pure functions for fit mode calculations + * + * This module provides testable functions for: + * - Fit scale calculations (crop, cover, contain, none) + * - Container scale vectors + * - Sprite transform calculations for fixed dimensions + */ + +import type { Size, Vector } from "@layouts/geometry"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export const FIT_MODES = ["crop", "cover", "contain", "none"] as const; +export type FitMode = (typeof FIT_MODES)[number]; + +/** + * Transform result for sprite positioning + */ +export interface SpriteTransform { + scaleX: number; + scaleY: number; + positionX: number; + positionY: number; +} + +// ─── Fit Scale Calculation ──────────────────────────────────────────────────── + +/** + * Calculate the uniform fit scale factor for content within a target size. + * Pure function - no side effects. + * + * @param contentSize - Original content dimensions + * @param targetSize - Target container dimensions + * @param fit - Fit mode (crop, cover, contain, none) + * @returns Uniform scale factor + * + * Fit modes: + * - crop: Fill target using max ratio (overflow is cropped) + * - cover: Same as crop for uniform scale + * - contain: Fit within target using min ratio (may letterbox) + * - none: No scaling (returns 1) + */ +export function calculateFitScale(contentSize: Size, targetSize: Size, fit: FitMode): number { + const ratioX = targetSize.width / contentSize.width; + const ratioY = targetSize.height / contentSize.height; + + switch (fit ?? "crop") { + case "crop": + case "cover": + return Math.max(ratioX, ratioY); + case "contain": + return Math.min(ratioX, ratioY); + case "none": + default: + return 1; + } +} + +// ─── Container Scale Calculation ────────────────────────────────────────────── + +/** + * Calculate the container scale vector based on fit mode. + * Pure function - no side effects. + * + * @param contentSize - Original content dimensions + * @param targetSize - Target container dimensions + * @param fit - Fit mode (crop, cover, contain, none) + * @param baseScale - User-specified scale multiplier + * @param hasFixedDimensions - Whether explicit width/height are set + * @returns Scale vector {x, y} + * + * When hasFixedDimensions is true, returns just baseScale (fit handled by sprite). + * When false, calculates appropriate scale based on fit mode: + * - contain: Uniform min scale + * - crop: Uniform max scale + * - cover: Non-uniform stretch (distorts content) + * - none: No scaling + */ +export function calculateContainerScale(contentSize: Size, targetSize: Size, fit: FitMode, baseScale: number, hasFixedDimensions: boolean): Vector { + // When explicit dimensions are set, applyFixedDimensions handles fit scaling + if (hasFixedDimensions) { + return { x: baseScale, y: baseScale }; + } + + // Guard against zero-size content + if (contentSize.width === 0 || contentSize.height === 0) { + return { x: baseScale, y: baseScale }; + } + + const ratioX = targetSize.width / contentSize.width; + const ratioY = targetSize.height / contentSize.height; + + switch (fit ?? "crop") { + case "contain": { + const uniform = Math.min(ratioX, ratioY) * baseScale; + return { x: uniform, y: uniform }; + } + case "crop": { + const uniform = Math.max(ratioX, ratioY) * baseScale; + return { x: uniform, y: uniform }; + } + case "cover": { + // Non-uniform stretch to exactly fill + return { x: ratioX * baseScale, y: ratioY * baseScale }; + } + case "none": + default: + return { x: baseScale, y: baseScale }; + } +} + +// ─── Sprite Transform Calculation ───────────────────────────────────────────── + +/** + * Calculate sprite transform for fixed dimensions mode. + * Pure function - no side effects. + * + * @param nativeSize - Native sprite/texture dimensions + * @param targetSize - Target clip dimensions (explicit width/height) + * @param fit - Fit mode (crop, cover, contain, none) + * @returns Transform with scale and position + * + * Fit modes for fixed dimensions: + * - cover: Non-uniform stretch to exactly fill (distorts) + * - crop: Uniform max scale (overflow masked) + * - contain: Uniform min scale (letterbox/pillarbox) + * - none: Native size (centered, overflow masked) + */ +export function calculateSpriteTransform(nativeSize: Size, targetSize: Size, fit: FitMode): SpriteTransform { + const centerX = targetSize.width / 2; + const centerY = targetSize.height / 2; + + switch (fit ?? "crop") { + case "cover": { + // Non-uniform stretch to exactly fill target + const scaleX = targetSize.width / nativeSize.width; + const scaleY = targetSize.height / nativeSize.height; + return { scaleX, scaleY, positionX: centerX, positionY: centerY }; + } + case "crop": { + // Uniform max scale (fill, overflow is masked) + const cropScale = Math.max(targetSize.width / nativeSize.width, targetSize.height / nativeSize.height); + return { scaleX: cropScale, scaleY: cropScale, positionX: centerX, positionY: centerY }; + } + case "contain": { + // Uniform min scale (fit fully, may letterbox) + const containScale = Math.min(targetSize.width / nativeSize.width, targetSize.height / nativeSize.height); + return { scaleX: containScale, scaleY: containScale, positionX: centerX, positionY: centerY }; + } + case "none": + default: { + // Native size, centered + return { scaleX: 1, scaleY: 1, positionX: centerX, positionY: centerY }; + } + } +} diff --git a/src/core/layouts/position-builder.ts b/src/core/layouts/position-builder.ts index ae07b459..a7c6cee3 100644 --- a/src/core/layouts/position-builder.ts +++ b/src/core/layouts/position-builder.ts @@ -1,4 +1,4 @@ -import { type ClipAnchor } from "../schemas/clip"; +import { type ClipAnchor } from "@schemas"; import { type Size, type Vector } from "./geometry"; diff --git a/src/core/loaders/asset-loader.ts b/src/core/loaders/asset-loader.ts index 5bb8f32f..7ef86523 100644 --- a/src/core/loaders/asset-loader.ts +++ b/src/core/loaders/asset-loader.ts @@ -13,23 +13,109 @@ export class AssetLoader { }; public readonly loadTracker = new AssetLoadTracker(); + /** Reference counts for loaded assets - prevents premature unloading during transforms */ + private refCounts = new Map(); + + /** + * Increment reference count for an asset. + * Called when a player starts loading an asset. + */ + public incrementRef(src: string): void { + this.refCounts.set(src, (this.refCounts.get(src) ?? 0) + 1); + } + + /** + * Decrement reference count for an asset. + * @returns true if asset can be safely unloaded (count reached zero) + */ + public decrementRef(src: string): boolean { + const count = this.refCounts.get(src) ?? 0; + if (count <= 1) { + this.refCounts.delete(src); + return true; // Safe to unload + } + this.refCounts.set(src, count - 1); + return false; // Still in use + } + constructor() { pixi.Assets.setPreferences({ crossOrigin: "anonymous" }); } public async load(identifier: string, loadOptions: pixi.UnresolvedAsset): Promise { this.updateAssetLoadMetadata(identifier, "pending", 0); + this.incrementRef(identifier); try { - if (await this.shouldUseSafariVideoLoader(loadOptions)) { + const useSafari = await this.shouldUseSafariVideoLoader(loadOptions); + + if (useSafari) { return await this.loadVideoForSafari(identifier, loadOptions); } const resolvedAsset = await pixi.Assets.load(loadOptions, progress => { this.updateAssetLoadMetadata(identifier, "loading", progress); }); + this.updateAssetLoadMetadata(identifier, "success", 1); return resolvedAsset; + } catch (error) { + console.warn(`[AssetLoader] Failed to load asset "${identifier}":`, error); + this.updateAssetLoadMetadata(identifier, "failed", 1); + this.decrementRef(identifier); + return null; + } + } + + /** + * Load a video with a unique HTMLVideoElement (not cached). + * Each call creates an independent video element, allowing multiple VideoPlayers + * to control playback independently even when using the same video URL. + */ + public async loadVideoUnique(identifier: string, loadOptions: pixi.UnresolvedAsset): Promise | null> { + this.updateAssetLoadMetadata(identifier, "pending", 0); + // Note: Don't increment ref count - each unique video manages its own lifecycle + + try { + const url = this.extractUrl(loadOptions); + if (!url) { + throw new Error("No URL provided for video loading"); + } + + const data = typeof loadOptions === "object" ? (loadOptions.data ?? {}) : {}; + + const texture = await new Promise>((resolve, reject) => { + const video = document.createElement("video"); + video.crossOrigin = "anonymous"; + video.playsInline = true; + video.muted = data.muted ?? false; + video.preload = "auto"; // Preload for smooth seeking + + video.addEventListener( + "loadedmetadata", + () => { + try { + const source = new pixi.VideoSource({ + resource: video, + autoPlay: data.autoPlay ?? false, + ...data + }); + resolve(new pixi.Texture({ source })); + } catch (error) { + reject(error); + } + }, + { once: true } + ); + + video.addEventListener("error", () => reject(new Error("Video loading failed")), { once: true }); + + this.updateAssetLoadMetadata(identifier, "loading", 0.5); + video.src = url; + }); + + this.updateAssetLoadMetadata(identifier, "success", 1); + return texture; } catch (_error) { this.updateAssetLoadMetadata(identifier, "failed", 1); return null; diff --git a/src/core/loaders/font-load-parser.ts b/src/core/loaders/font-load-parser.ts index 3c8d6619..4061abc0 100644 --- a/src/core/loaders/font-load-parser.ts +++ b/src/core/loaders/font-load-parser.ts @@ -1,3 +1,4 @@ +import * as opentype from "opentype.js"; import * as pixi from "pixi.js"; type Woff2Decompressor = { @@ -33,13 +34,13 @@ export class FontLoadParser implements pixi.LoaderParser { } public async load(url: string, _?: pixi.ResolvedAsset, __?: pixi.Loader): Promise { - const urlWithoutQuery = url.split("?")[0] ?? ""; - const extension = urlWithoutQuery.split(".").pop()?.toLowerCase() ?? ""; - - const filename = urlWithoutQuery.split("/").pop() || ""; - const familyName = filename.replace(/\.(ttf|otf|woff|woff2)$/i, ""); + const extension = url.split("?")[0]?.split(".").pop()?.toLowerCase() ?? ""; + const buffer = await fetch(url).then(res => res.arrayBuffer()); if (extension !== "woff2") { + const font = opentype.parse(new Uint8Array(buffer).buffer); + const familyName = font.names.fontFamily["en"] || font.names.fontFamily[Object.keys(font.names.fontFamily)[0]]; + const fontFace = new FontFace(familyName, `url(${url})`); await fontFace.load(); @@ -47,8 +48,6 @@ export class FontLoadParser implements pixi.LoaderParser { return fontFace; } - const buffer = await fetch(url).then(res => res.arrayBuffer()); - await this.loadWoff2Decompressor(); if (!this.woff2Decompressor) { throw new Error("Cannot initialize Woff2 decompressor."); @@ -56,6 +55,9 @@ export class FontLoadParser implements pixi.LoaderParser { const decompressed = this.woff2Decompressor.decompress(buffer); + const font = opentype.parse(new Uint8Array(decompressed).buffer); + const familyName = font.names.fontFamily["en"] || font.names.fontFamily[Object.keys(font.names.fontFamily)[0]]; + const blob = new Blob([decompressed], { type: "font/ttf" }); const blobUrl = URL.createObjectURL(blob); diff --git a/src/core/loaders/subtitle-load-parser.ts b/src/core/loaders/subtitle-load-parser.ts new file mode 100644 index 00000000..548c85a6 --- /dev/null +++ b/src/core/loaders/subtitle-load-parser.ts @@ -0,0 +1,52 @@ +import { type Cue, parseSubtitle } from "@core/captions"; +import * as pixi from "pixi.js"; + +export interface SubtitleAsset { + content: string; + cues: Cue[]; +} + +export class SubtitleLoadParser implements pixi.LoaderParser { + public static readonly Name = "SubtitleLoadParser"; + + public id: string; + public name: string; + public extension: pixi.ExtensionFormat; + private validExtensions: string[]; + + constructor() { + this.id = SubtitleLoadParser.Name; + this.name = SubtitleLoadParser.Name; + this.extension = { + type: [pixi.ExtensionType.LoadParser], + priority: pixi.LoaderParserPriority.Normal, + ref: null + }; + this.validExtensions = ["srt", "vtt"]; + } + + public test(url: string): boolean { + const extension = url.split("?")[0]?.split(".").pop()?.toLowerCase() ?? ""; + return this.validExtensions.includes(extension); + } + + public async load(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + return null; + } + const content = await response.text(); + return { + content, + cues: parseSubtitle(content) + }; + } catch { + return null; + } + } + + public unload(_asset: SubtitleAsset | null): void { + // No cleanup needed for text content + } +} diff --git a/src/core/luma-mask-controller.ts b/src/core/luma-mask-controller.ts new file mode 100644 index 00000000..da9cdcfb --- /dev/null +++ b/src/core/luma-mask-controller.ts @@ -0,0 +1,280 @@ +import { LumaPlayer } from "@canvas/players/luma-player"; +import { type Player, PlayerType } from "@canvas/players/player"; +import type { Canvas } from "@canvas/shotstack-canvas"; +import * as pixi from "pixi.js"; + +import { EditEvent, InternalEvent, type EditEventMap, type InternalEventMap } from "./events/edit-events"; +import type { EventEmitter } from "./events/event-emitter"; + +const LUMA_MASK_RESOLUTION = 0.5; + +// TODO: Set this based on actual video source frame rate instead of hardcoding 30fps +const LUMA_VIDEO_UPDATE_INTERVAL = 1 / 30; + +interface ActiveLumaMask { + lumaPlayer: LumaPlayer; + maskSprite: pixi.Sprite; + tempContainer: pixi.Container; + contentClip: Player; + lastVideoTime: number; +} + +interface PendingMaskCleanup { + maskSprite: pixi.Sprite; + frameCount: number; +} + +/** + * Manages luma mask setup, updates, and cleanup for the Edit class. + * Luma masks apply grayscale video/image textures as alpha masks to content clips. + */ +export class LumaMaskController { + private activeLumaMasks: ActiveLumaMask[] = []; + private pendingMaskCleanup: PendingMaskCleanup[] = []; + private readonly onClipChangedBound: () => void; + private readonly onPlayerLoadedBound: (payload: { player: Player; trackIndex: number; clipIndex: number }) => void; + + constructor( + private getCanvas: () => Canvas | null, + private getTracks: () => Player[][], + private events: EventEmitter + ) { + this.onClipChangedBound = () => this.rebuildLumaMasksIfNeeded(); + this.onPlayerLoadedBound = payload => this.onPlayerLoaded(payload); + } + + /** + * Initialize luma masking by setting up event listeners. + */ + initialize(): void { + this.setupEventListeners(); + } + + /** + * Update luma masks each frame. For video sources, regenerates mask texture. + */ + update(): void { + this.updateLumaMasks(); + this.processPendingMaskCleanup(); + } + + /** + * Get the number of active luma masks. + */ + getActiveMaskCount(): number { + return this.activeLumaMasks.length; + } + + /** + * Clean up all luma masks. + */ + dispose(): void { + this.removeEventListeners(); + + for (const mask of this.activeLumaMasks) { + mask.tempContainer.destroy({ children: true }); + mask.maskSprite.destroy({ texture: true }); + } + this.activeLumaMasks = []; + + for (const item of this.pendingMaskCleanup) { + try { + item.maskSprite.parent?.removeChild(item.maskSprite); + item.maskSprite.destroy({ texture: true }); + } catch { + // Ignore cleanup errors during dispose + } + } + this.pendingMaskCleanup = []; + } + + /** + * Clean up luma mask when a luma player is being deleted. + */ + cleanupForPlayer(player: Player): void { + const maskIndex = this.activeLumaMasks.findIndex(mask => mask.lumaPlayer === player); + if (maskIndex === -1) { + return; + } + + const mask = this.activeLumaMasks[maskIndex]; + + if (mask.contentClip) { + mask.contentClip.getLumaWrapper().mask = null; + } + + mask.maskSprite.parent?.removeChild(mask.maskSprite); + mask.tempContainer.destroy({ children: true }); + this.activeLumaMasks.splice(maskIndex, 1); + + this.pendingMaskCleanup.push({ maskSprite: mask.maskSprite, frameCount: 0 }); + } + + /** + * Handle PlayerLoaded event - set up luma mask if player is a luma player. + */ + private onPlayerLoaded(payload: { player: Player; trackIndex: number; clipIndex: number }): void { + const { player, trackIndex } = payload; + + // Only handle luma players + if (player.playerType !== PlayerType.Luma) { + return; + } + + const lumaPlayer = player as LumaPlayer; + const lumaSprite = lumaPlayer.getSprite(); + + // Texture should always be ready when PlayerLoaded fires (after load completes) + if (!lumaSprite?.texture) { + console.warn("PlayerLoaded fired for luma player before texture ready"); + return; + } + + const tracks = this.getTracks(); + if (trackIndex >= tracks.length) { + return; + } + + const trackClips = tracks[trackIndex]; + const contentClips = trackClips.filter(clip => clip.playerType !== PlayerType.Luma); + + if (contentClips.length === 0) { + return; + } + + // Check if mask already exists (avoid duplicates) + const existingMask = this.activeLumaMasks.find(m => m.lumaPlayer === lumaPlayer); + if (existingMask) { + return; + } + + this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); + + // Hide the luma player container (lumas are rendered as masks, not visible clips) + lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); + } + + private setupLumaMask(lumaPlayer: LumaPlayer, lumaTexture: pixi.Texture, contentClip: Player): void { + const canvas = this.getCanvas(); + if (!canvas) { + return; + } + + const { renderer } = canvas.application; + const { width, height } = contentClip.getSize(); + + const tempContainer = new pixi.Container(); + const tempSprite = new pixi.Sprite(lumaTexture); + tempSprite.width = width; + tempSprite.height = height; + + const invertFilter = new pixi.ColorMatrixFilter(); + invertFilter.negative(false); + tempSprite.filters = [invertFilter]; + tempContainer.addChild(tempSprite); + + const maskTexture = renderer.generateTexture({ + target: tempContainer, + resolution: LUMA_MASK_RESOLUTION + }); + + const maskSprite = new pixi.Sprite(maskTexture); + + contentClip.getContainer().addChild(maskSprite); + + const lumaWrapper = contentClip.getLumaWrapper(); + lumaWrapper.mask = maskSprite; + + this.activeLumaMasks.push({ lumaPlayer, maskSprite, tempContainer, contentClip, lastVideoTime: -1 }); + } + + private updateLumaMasks(): void { + const canvas = this.getCanvas(); + if (!canvas) return; + + const { renderer } = canvas.application; + + for (const mask of this.activeLumaMasks) { + if (mask.lumaPlayer.isVideoSource()) { + const videoTime = mask.lumaPlayer.getVideoCurrentTime(); + const frameChanged = Math.abs(videoTime - mask.lastVideoTime) >= LUMA_VIDEO_UPDATE_INTERVAL; + + if (frameChanged) { + mask.lastVideoTime = videoTime; + + const oldTexture = mask.maskSprite.texture; + mask.maskSprite.texture = renderer.generateTexture({ + target: mask.tempContainer, + resolution: LUMA_MASK_RESOLUTION + }); + + oldTexture.destroy(true); + } + } + } + } + + private setupEventListeners(): void { + // PlayerLoaded handles initial mask setup for new luma players + this.events.on(InternalEvent.PlayerLoaded, this.onPlayerLoadedBound); + // ClipUpdated handles property changes to existing masks + this.events.on(EditEvent.ClipUpdated, this.onClipChangedBound); + // PlayerMovedBetweenTracks re-detaches luma containers after track reparenting + this.events.on(InternalEvent.PlayerMovedBetweenTracks, this.onClipChangedBound); + // Note: ClipAdded and ClipRestored trigger PlayerLoaded, so no need to subscribe separately + } + + private removeEventListeners(): void { + this.events.off(InternalEvent.PlayerLoaded, this.onPlayerLoadedBound); + this.events.off(EditEvent.ClipUpdated, this.onClipChangedBound); + this.events.off(InternalEvent.PlayerMovedBetweenTracks, this.onClipChangedBound); + } + + private processPendingMaskCleanup(): void { + for (let i = this.pendingMaskCleanup.length - 1; i >= 0; i -= 1) { + const item = this.pendingMaskCleanup[i]; + item.frameCount += 1; + + if (item.frameCount >= 3) { + try { + item.maskSprite.parent?.removeChild(item.maskSprite); + item.maskSprite.destroy({ texture: true }); + } catch { + // Ignore cleanup errors + } + this.pendingMaskCleanup.splice(i, 1); + } + } + } + + /** + * Update existing luma masks when clip properties change. + * This is called in response to ClipUpdated events for already-loaded players. + */ + private rebuildLumaMasksIfNeeded(): void { + const canvas = this.getCanvas(); + if (!canvas) return; + + const tracks = this.getTracks(); + for (let trackIdx = 0; trackIdx < tracks.length; trackIdx += 1) { + const trackClips = tracks[trackIdx]; + const lumaPlayer = trackClips.find(clip => clip.playerType === PlayerType.Luma) as LumaPlayer | undefined; + const contentClips = trackClips.filter(clip => clip.playerType !== PlayerType.Luma); + + if (lumaPlayer) { + // Hide luma player container (lumas are rendered as masks, not visible clips) + lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); + } + + const existingMask = lumaPlayer && this.activeLumaMasks.find(m => m.lumaPlayer === lumaPlayer); + + // Only set up mask if player is already loaded (texture ready) + if (lumaPlayer && !existingMask && contentClips.length > 0) { + const lumaSprite = lumaPlayer.getSprite(); + if (lumaSprite?.texture) { + this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); + } + } + } + } +} diff --git a/src/core/merge/index.ts b/src/core/merge/index.ts new file mode 100644 index 00000000..7fe4da0f --- /dev/null +++ b/src/core/merge/index.ts @@ -0,0 +1,15 @@ +/** + * Merge field module for the Shotstack Studio SDK. + * + * Provides types, services, and utilities for merge field management. + */ + +// Types +export type { MergeField, SerializedMergeField } from "./types"; +export { toSerialized, fromSerialized } from "./types"; + +// Service +export { MergeFieldService, MERGE_FIELD_PATTERN, MERGE_FIELD_TEST_PATTERN } from "./merge-field-service"; + +// Utility +export { applyMergeFields } from "./merge-fields"; diff --git a/src/core/merge/merge-field-service.ts b/src/core/merge/merge-field-service.ts new file mode 100644 index 00000000..445681f7 --- /dev/null +++ b/src/core/merge/merge-field-service.ts @@ -0,0 +1,159 @@ +/** + * Centralized service for merge field management. + * + * Provides CRUD operations, string resolution, and event emission + * for merge fields throughout the SDK. + */ + +import { EditEvent, type EditEventMap } from "@core/events/edit-events"; +import type { EventEmitter } from "@core/events/event-emitter"; + +import { type MergeField, type SerializedMergeField, fromSerialized, toSerialized } from "./types"; + +/** Regex pattern for merge field detection and extraction */ +export const MERGE_FIELD_PATTERN = /\{\{\s*([A-Z_0-9]+)\s*\}\}/gi; + +/** Regex pattern for testing if a string contains any merge field */ +export const MERGE_FIELD_TEST_PATTERN = /\{\{\s*[A-Z_0-9]+\s*\}\}/i; + +export class MergeFieldService { + private fields: Map = new Map(); + private events: EventEmitter; + + constructor(events: EventEmitter) { + this.events = events; + } + + // ─── CRUD Operations ──────────────────────────────────────────────────────── + + /** + * Register or update a merge field. + * @param field The merge field to register + * @param options.silent If true, suppresses event emission (for command-based operations) + */ + register(field: MergeField, options?: { silent?: boolean }): void { + this.fields.set(field.name, field); + + if (!options?.silent) { + this.events.emit(EditEvent.MergeFieldChanged, { fields: this.getAll() }); + } + } + + /** + * Remove a merge field by name. + * @param name The field name to remove + * @param options.silent If true, suppresses event emission (for command-based operations) + */ + remove(name: string, options?: { silent?: boolean }): boolean { + const removed = this.fields.delete(name); + if (removed && !options?.silent) { + this.events.emit(EditEvent.MergeFieldChanged, { fields: this.getAll() }); + } + return removed; + } + + /** Get a merge field by name */ + get(name: string): MergeField | undefined { + return this.fields.get(name); + } + + /** Get all registered merge fields */ + getAll(): MergeField[] { + return Array.from(this.fields.values()); + } + + /** Clear all merge fields */ + clear(): void { + this.fields.clear(); + } + + // ─── String Operations ────────────────────────────────────────────────────── + + /** + * Apply merge field substitutions to a string. + * Replaces {{ FIELD_NAME }} patterns with their default values. + */ + resolve(input: string): string { + if (!input || this.fields.size === 0) return input; + + return input.replace(MERGE_FIELD_PATTERN, (match, fieldName: string) => { + const field = this.fields.get(fieldName); + return field?.defaultValue ?? match; + }); + } + + /** + * Resolve a merge field template to a numeric value. + * Returns the number if successful, or null if: + * - Input is not a merge field template + * - Resolved value is not a valid number + */ + resolveToNumber(input: string): number | null { + if (!this.isMergeFieldTemplate(input)) return null; + + const resolved = this.resolve(input); + const num = parseFloat(resolved); + return Number.isFinite(num) ? num : null; + } + + /** + * Check if a string contains unresolved merge fields. + * Returns true if any {{ FIELD_NAME }} patterns remain after resolution. + */ + hasUnresolved(input: string): boolean { + if (!input) return false; + const resolved = this.resolve(input); + return MERGE_FIELD_TEST_PATTERN.test(resolved); + } + + /** + * Extract the first merge field name from a string. + * Returns null if no merge field pattern is found. + */ + extractFieldName(input: string): string | null { + if (!input) return null; + const match = MERGE_FIELD_TEST_PATTERN.exec(input); + if (!match) return null; + + const nameMatch = match[0].match(/\{\{\s*([A-Z_0-9]+)\s*\}\}/i); + return nameMatch ? nameMatch[1] : null; + } + + /** Check if a string is a merge field template (contains {{ FIELD }}) */ + isMergeFieldTemplate(input: string): boolean { + return MERGE_FIELD_TEST_PATTERN.test(input); + } + + /** Create a merge field template string from a field name */ + createTemplate(fieldName: string): string { + return `{{ ${fieldName} }}`; + } + + // ─── Serialization ────────────────────────────────────────────────────────── + + /** Export fields in Shotstack API format ({ find, replace }) */ + toSerializedArray(): SerializedMergeField[] { + return this.getAll().map(toSerialized); + } + + /** Import fields from Shotstack API format (does not emit event - called during loadEdit) */ + loadFromSerialized(fields: SerializedMergeField[]): void { + this.fields.clear(); + for (const f of fields) { + const internal = fromSerialized(f); + this.fields.set(f.find, internal); + } + } + + // ─── Utility ──────────────────────────────────────────────────────────────── + + /** Generate a unique field name with a given prefix (e.g., MEDIA_1, MEDIA_2) */ + generateUniqueName(prefix: string): string { + const existingNames = new Set(this.fields.keys()); + let counter = 1; + while (existingNames.has(`${prefix}_${counter}`)) { + counter += 1; + } + return `${prefix}_${counter}`; + } +} diff --git a/src/core/merge/merge-fields.ts b/src/core/merge/merge-fields.ts index 9e510c48..dcbcf34a 100644 --- a/src/core/merge/merge-fields.ts +++ b/src/core/merge/merge-fields.ts @@ -1,13 +1,10 @@ /** * Merge field replacement utility for Shotstack Studio SDK. - * Replaces {{ VARIABLE_NAME }} placeholders with actual values. + * Applies merge field substitutions to entire data structures. * @internal */ -export interface MergeField { - find: string; - replace: string; -} +import type { SerializedMergeField } from "./types"; /** * Escapes special regex characters in a string. @@ -16,11 +13,13 @@ function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function replaceMergeFieldsRecursive(obj: T, fields: MergeField[]): T { +function replaceMergeFieldsRecursive(obj: T, fields: SerializedMergeField[]): T { if (typeof obj === "string") { let result: string = obj; for (const { find, replace } of fields) { - result = result.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(find)}\\s*\\}\\}`, "g"), replace); + // Convert replace value to string (handles unknown type from external schema) + const replaceStr = typeof replace === "string" ? replace : JSON.stringify(replace); + result = result.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(find)}\\s*\\}\\}`, "gi"), replaceStr); } return result as unknown as T; } @@ -42,8 +41,12 @@ function replaceMergeFieldsRecursive(obj: T, fields: MergeField[]): T { /** * Applies merge field replacements to any data structure. * Recursively traverses objects and arrays, replacing placeholders in strings. + * + * @param data - The data structure to process + * @param mergeFields - Array of { find, replace } pairs + * @returns A deep clone of the data with all merge fields replaced */ -export function applyMergeFields(data: T, mergeFields: MergeField[]): T { +export function applyMergeFields(data: T, mergeFields: SerializedMergeField[]): T { if (!mergeFields?.length) return data; return replaceMergeFieldsRecursive(structuredClone(data), mergeFields); } diff --git a/src/core/merge/types.ts b/src/core/merge/types.ts new file mode 100644 index 00000000..85081bb8 --- /dev/null +++ b/src/core/merge/types.ts @@ -0,0 +1,41 @@ +/** + * Merge field types for the Shotstack Studio SDK. + * + * Merge fields allow dynamic content substitution using {{ FIELD_NAME }} syntax. + * Values are replaced at render time, enabling template-based video generation. + */ + +/** + * A merge field definition used throughout the SDK. + */ +export interface MergeField { + /** Field identifier (uppercase convention: MY_FIELD) */ + name: string; + + /** Default value used for preview when no runtime value is provided */ + defaultValue: string; + + /** Optional description for UI display */ + description?: string; +} + +/** + * Serialized format for JSON export (matches Shotstack API). + * The replace value can be any type - strings, numbers, booleans, objects. + */ +export interface SerializedMergeField { + find: string; + replace: unknown; +} + +/** Convert internal MergeField to serialized API format */ +export function toSerialized(field: MergeField): SerializedMergeField { + return { find: field.name, replace: field.defaultValue }; +} + +/** Convert serialized API format to internal MergeField */ +export function fromSerialized(field: SerializedMergeField): MergeField { + // Coerce unknown replace value to string for internal SDK use + const replaceValue = typeof field.replace === "string" ? field.replace : JSON.stringify(field.replace); + return { name: field.find, defaultValue: replaceValue }; +} diff --git a/src/core/output-settings-manager.ts b/src/core/output-settings-manager.ts new file mode 100644 index 00000000..1d99ceee --- /dev/null +++ b/src/core/output-settings-manager.ts @@ -0,0 +1,265 @@ +/** + * OutputSettingsManager - Manages output configuration (size, fps, format, resolution, etc.) + * Handles validation, state updates, document sync, and event emission. + */ + +import { EditEvent } from "@core/events/edit-events"; +import type { Destination } from "@core/schemas"; +import { + DestinationSchema, + OutputAspectRatioSchema, + OutputFormatSchema, + OutputFpsSchema, + OutputResolutionSchema, + OutputSizeSchema +} from "@core/schemas"; +import type { Size } from "@layouts/geometry"; + +import type { Edit } from "./edit-session"; + +// ─── Resolution Preset Dimensions ───────────────────────────────────────────── + +/** + * Base dimensions for each resolution preset (16:9 aspect ratio) + */ +const RESOLUTION_DIMENSIONS: Record = { + preview: { width: 512, height: 288 }, + mobile: { width: 640, height: 360 }, + sd: { width: 1024, height: 576 }, + hd: { width: 1280, height: 720 }, + "1080": { width: 1920, height: 1080 }, + "4k": { width: 3840, height: 2160 } +}; + +/** + * Calculate output size from resolution preset and aspect ratio. + * Resolution defines the base dimensions (16:9), aspectRatio transforms them. + */ +export function calculateSizeFromPreset(resolution: string, aspectRatio: string = "16:9"): Size { + const base = RESOLUTION_DIMENSIONS[resolution]; + if (!base) { + throw new Error(`Unknown resolution: ${resolution}`); + } + + // Apply aspect ratio transformation + // Base dimensions are 16:9, so we transform to the target aspect ratio + switch (aspectRatio) { + case "16:9": + return { width: base.width, height: base.height }; + case "9:16": + // Flip width and height for vertical orientation + return { width: base.height, height: base.width }; + case "1:1": + // Square - use height as base dimension + return { width: base.height, height: base.height }; + case "4:5": + // Short vertical - maintain height, adjust width to 4:5 ratio + return { width: Math.round((base.height * 4) / 5), height: base.height }; + case "4:3": + // Legacy TV - maintain height, adjust width to 4:3 ratio + return { width: Math.round((base.height * 4) / 3), height: base.height }; + default: + throw new Error(`Unknown aspectRatio: ${aspectRatio}`); + } +} + +// ─── OutputSettingsManager ──────────────────────────────────────────────────── + +export class OutputSettingsManager { + constructor(private readonly edit: Edit) {} + + // ─── Size ───────────────────────────────────────────────────────────────── + + /** + * Set output size (internal - called by SetOutputSizeCommand). + */ + setSize(width: number, height: number): void { + OutputSizeSchema.parse({ width, height }); + + const size: Size = { width, height }; + this.edit.size = size; + + const resolvedEdit = this.edit.getResolvedEdit(); + if (resolvedEdit) { + resolvedEdit.output = { + ...resolvedEdit.output, + size + }; + // Clear resolution/aspectRatio (mutually exclusive with custom size) + delete resolvedEdit.output.resolution; + delete resolvedEdit.output.aspectRatio; + } + + // Sync with document layer + const doc = this.edit.getDocument(); + doc?.setSize(size); + doc?.clearResolution(); + doc?.clearAspectRatio(); + + this.edit.updateCanvasForSize(); + + this.edit.getInternalEvents().emit(EditEvent.OutputResized, size); + // Note: emitEditChanged is handled by executeCommand + } + + getSize(): Size { + return this.edit.size; + } + + // ─── FPS ────────────────────────────────────────────────────────────────── + + /** + * Set output FPS (internal - called by SetOutputFpsCommand). + */ + setFps(fps: number): void { + const validated = OutputFpsSchema.parse(fps); + + const resolvedEdit = this.edit.getResolvedEdit(); + if (resolvedEdit) { + resolvedEdit.output = { + ...resolvedEdit.output, + fps: validated + }; + } + + // Sync with document layer + this.edit.getDocument()?.setFps(validated); + + this.edit.getInternalEvents().emit(EditEvent.OutputFpsChanged, { fps }); + // Note: emitEditChanged is handled by executeCommand + } + + getFps(): number { + return this.edit.getResolvedEdit()?.output?.fps ?? 30; + } + + // ─── Format ─────────────────────────────────────────────────────────────── + + setFormat(format: string): void { + const validated = OutputFormatSchema.parse(format); + + const resolvedEdit = this.edit.getResolvedEdit(); + if (resolvedEdit) { + resolvedEdit.output = { + ...resolvedEdit.output, + format: validated + }; + } + + // Sync with document layer + this.edit.getDocument()?.setFormat(validated); + + this.edit.getInternalEvents().emit(EditEvent.OutputFormatChanged, { format: validated }); + } + + getFormat(): string { + return this.edit.getResolvedEdit()?.output?.format ?? "mp4"; + } + + // ─── Destinations ───────────────────────────────────────────────────────── + + setDestinations(destinations: Destination[]): void { + const validated = DestinationSchema.array().parse(destinations); + + const resolvedEdit = this.edit.getResolvedEdit(); + if (resolvedEdit) { + resolvedEdit.output = { + ...resolvedEdit.output, + destinations: validated + }; + } + + this.edit.getInternalEvents().emit(EditEvent.OutputDestinationsChanged, { destinations: validated }); + } + + getDestinations(): Destination[] { + return this.edit.getResolvedEdit()?.output?.destinations ?? []; + } + + // ─── Resolution ─────────────────────────────────────────────────────────── + + setResolution(resolution: string): void { + // Schema is optional in output, but required here — parse validates, ! narrows + const validatedResolution = OutputResolutionSchema.parse(resolution)!; + const resolvedEdit = this.edit.getResolvedEdit(); + const aspectRatio = resolvedEdit?.output?.aspectRatio ?? "16:9"; + const newSize = calculateSizeFromPreset(validatedResolution, aspectRatio); + + // Update runtime state + this.edit.size = newSize; + + if (resolvedEdit) { + resolvedEdit.output = { + ...resolvedEdit.output, + resolution: validatedResolution + }; + // Clear custom size (mutually exclusive with resolution/aspectRatio) + delete resolvedEdit.output.size; + } + + // Sync with document layer (size is cleared for mutual exclusivity) + const doc = this.edit.getDocument(); + doc?.setResolution(validatedResolution); + doc?.clearSize(); + + this.edit.updateCanvasForSize(); + + this.edit.getInternalEvents().emit(EditEvent.OutputResolutionChanged, { resolution: validatedResolution }); + this.edit.getInternalEvents().emit(EditEvent.OutputResized, { width: newSize.width, height: newSize.height }); + } + + getResolution(): string | undefined { + return this.edit.getResolvedEdit()?.output?.resolution; + } + + // ─── Aspect Ratio ───────────────────────────────────────────────────────── + + setAspectRatio(aspectRatio: string): void { + // Schema is optional in output, but required here — parse validates, ! narrows + const validatedAspectRatio = OutputAspectRatioSchema.parse(aspectRatio)!; + const resolvedEdit = this.edit.getResolvedEdit(); + const resolution = resolvedEdit?.output?.resolution; + + if (!resolution) { + // If no resolution is set, just store the aspectRatio without recalculating size + if (resolvedEdit) { + resolvedEdit.output = { + ...resolvedEdit.output, + aspectRatio: validatedAspectRatio + }; + } + this.edit.getDocument()?.setAspectRatio(validatedAspectRatio); + this.edit.getInternalEvents().emit(EditEvent.OutputAspectRatioChanged, { aspectRatio: validatedAspectRatio }); + return; + } + + // Recalculate size based on current resolution and new aspectRatio + const newSize = calculateSizeFromPreset(resolution, validatedAspectRatio); + + // Update runtime state + this.edit.size = newSize; + + if (resolvedEdit) { + resolvedEdit.output = { + ...resolvedEdit.output, + aspectRatio: validatedAspectRatio + }; + // Clear custom size (mutually exclusive with resolution/aspectRatio) + delete resolvedEdit.output.size; + } + + // Sync with document layer (size is cleared for mutual exclusivity) + const doc = this.edit.getDocument(); + doc?.setAspectRatio(validatedAspectRatio); + doc?.clearSize(); + + this.edit.updateCanvasForSize(); + + this.edit.getInternalEvents().emit(EditEvent.OutputAspectRatioChanged, { aspectRatio: validatedAspectRatio }); + this.edit.getInternalEvents().emit(EditEvent.OutputResized, { width: newSize.width, height: newSize.height }); + } + + getAspectRatio(): string | undefined { + return this.edit.getResolvedEdit()?.output?.aspectRatio; + } +} diff --git a/src/core/player-reconciler.ts b/src/core/player-reconciler.ts new file mode 100644 index 00000000..d9271f4f --- /dev/null +++ b/src/core/player-reconciler.ts @@ -0,0 +1,433 @@ +/** + * PlayerReconciler - Manages Player lifecycle based on ResolvedEdit state + * + * This is the approach to Player management: + * - Commands mutate the Document + * - Resolver produces ResolvedEdit + * - Reconciler diffs ResolvedEdit against current Players + * - Creates/updates/disposes Players to match resolved state + */ + +import type { Player } from "@canvas/players/player"; +import type { ResolvedClip, ResolvedEdit } from "@schemas"; + +import type { Edit } from "./edit-session"; +import { EditEvent, InternalEvent } from "./events/edit-events"; +import type { Seconds } from "./timing/types"; + +export interface ReconcileResult { + created: string[]; + updated: string[]; + disposed: string[]; + pendingLoads: Promise[]; +} + +/** Properties handled by dedicated checks in updatePlayer — skip in generic diff/patch */ +const HANDLED_PROPS = new Set(["asset", "start", "length", "id"]); + +const AI_ASSET_TYPES = new Set(["text-to-image", "image-to-video", "text-to-speech"]); + +export class PlayerReconciler { + private isReconciling = false; + + /** + * When true, the reconciler handles all Player lifecycle: + * - Creates Players for new clip IDs + * - Disposes Players for removed clip IDs + * - Recreates Players when asset type changes + */ + private enableCreation = true; + + constructor(private readonly edit: Edit) { + this.edit.getInternalEvents().on(InternalEvent.Resolved, this.onResolved); + } + + private onResolved = ({ edit: resolved }: { edit: ResolvedEdit }): void => { + this.reconcile(resolved); + }; + + /** + * Initial reconciliation - creates all players from ResolvedEdit. + * + * Called during load, before any players exist. This method: + * 1. Uses reconcile() to create all players + * 2. Waits for all player loads to complete + * + * @param resolved - The fully resolved edit state from the resolver + * @returns Promise that resolves when all players are loaded + */ + public async reconcileInitial(resolved: ResolvedEdit): Promise { + const result = this.reconcile(resolved); + await Promise.all(result.pendingLoads); + return result; + } + + /** + * Reconcile Players to match the ResolvedEdit. + * + * Four-pass algorithm: + * 1. Add new Players (clips in resolved but not in playerMap) + * 2. Update existing Players (timing, track, asset changes) + * 3. Dispose orphaned Players (in playerMap but not in resolved) + * 4. Sync track containers (add/remove empty containers - runs AFTER disposal) + */ + public reconcile(resolved: ResolvedEdit): ReconcileResult { + if (this.isReconciling) { + return { created: [], updated: [], disposed: [], pendingLoads: [] }; + } + + this.isReconciling = true; + + try { + const pendingLoads: Promise[] = []; + const result: ReconcileResult = { + created: [], + updated: [], + disposed: [], + pendingLoads + }; + + const resolvedClipIds = new Set(); + + // Pass 1 & 2: Create new Players, update existing + for (let trackIndex = 0; trackIndex < resolved.timeline.tracks.length; trackIndex += 1) { + const track = resolved.timeline.tracks[trackIndex]; + + for (let clipIndex = 0; clipIndex < track.clips.length; clipIndex += 1) { + const clip = track.clips[clipIndex]; + const clipId = (clip as ResolvedClip & { id?: string }).id; + if (clipId) { + resolvedClipIds.add(clipId); + + const existingPlayer = this.edit.getPlayerByClipId(clipId); + + if (existingPlayer) { + // Update existing Player + const updateResult = this.updatePlayer(existingPlayer, clip, trackIndex); + + if (updateResult === "recreate") { + // Asset type changed - dispose old and create new + this.disposePlayer(clipId); + pendingLoads.push(this.createPlayer(clip, clipId, trackIndex, clipIndex)); + result.disposed.push(clipId); + result.created.push(clipId); + } else if (updateResult) { + result.updated.push(clipId); + } + } else if (this.enableCreation) { + // Create new Player + this.createPlayer(clip, clipId, trackIndex, clipIndex); + result.created.push(clipId); + } + } + } + } + + // Pass 3: Dispose orphaned Players + const orphanedIds = this.findOrphanedPlayers(resolvedClipIds); + for (const clipId of orphanedIds) { + this.disposePlayer(clipId); + result.disposed.push(clipId); + } + + // Rebuild tracks array ordering to match resolved order + // This handles both new/deleted players AND position changes within tracks + if (result.created.length > 0 || result.disposed.length > 0 || result.updated.length > 0) { + this.rebuildTracksOrdering(resolved); + } + + // Sync track containers AFTER players are disposed and tracks rebuilt + // This ensures empty tracks are correctly identified for removal + this.syncTrackContainers(resolved.timeline.tracks.length); + + return result; + } finally { + this.isReconciling = false; + } + } + + /** + * Update a single player to match a resolved clip. + * + * This is the optimised path for single-clip mutations. Instead of running + * a full reconcile() which processes ALL clips, this updates just ONE player. + * + * @param player - The player to update + * @param resolvedClip - The resolved clip state to sync to + * @param trackIndex - The track index (for track change detection) + * @param clipIndex - The clip index within the track (for error events) + * @returns true if changes were made, false if no changes, 'recreate' if asset type changed + */ + public updateSinglePlayer(player: Player, resolvedClip: ResolvedClip, trackIndex: number, clipIndex: number = 0): boolean | "recreate" { + const result = this.updatePlayer(player, resolvedClip, trackIndex); + + // Handle asset type change (rare case - requires full recreation) + if (result === "recreate") { + const { clipId } = player; + if (clipId) { + this.disposePlayer(clipId); + this.createPlayer(resolvedClip, clipId, trackIndex, clipIndex); + } + return "recreate"; + } + + return result; + } + + /** + * Create a new Player for a clip. + */ + private createPlayer(clip: ResolvedClip, clipId: string, trackIndex: number, clipIndex: number): Promise { + const player = this.edit.createPlayerFromAssetType(clip); + player.layer = trackIndex + 1; + player.clipId = clipId; + + // Register in ID map + this.edit.registerPlayerByClipId(clipId, player); + + // Add to tracks array (clips are derived from tracks) + this.edit.addPlayerToTracksArray(trackIndex, player); + + // Add to PIXI container + this.edit.addPlayerToContainer(trackIndex, player); + + // Load asynchronously + const assetType = (clip.asset as { type?: string })?.type ?? "unknown"; + const loadPromise = player + .load() + .then(() => { + // Emit PlayerLoaded for all players + this.edit.getInternalEvents().emit(InternalEvent.PlayerLoaded, { + player, + trackIndex, + clipIndex + }); + + // Also emit ClipUnresolved for AI assets + if (AI_ASSET_TYPES.has(assetType)) { + this.edit.getInternalEvents().emit(EditEvent.ClipUnresolved, { + trackIndex, + clipIndex, + assetType, + clipId + }); + } + }) + .catch(error => { + const errorMessage = error instanceof Error ? error.message : String(error); + this.edit.getInternalEvents().emit(EditEvent.ClipLoadFailed, { + trackIndex, + clipIndex, + error: errorMessage, + assetType + }); + }); + return loadPromise; + } + + /** + * Update an existing Player to match resolved clip state. + * Returns true if any changes were made. + * Returns 'recreate' if the asset type changed and player needs recreation. + */ + private updatePlayer(player: Player, clip: ResolvedClip, trackIndex: number): boolean | "recreate" { + // Check if asset type changed - requires full recreation + const currentAssetType = (player.clipConfiguration.asset as { type?: string })?.type; + const newAssetType = (clip.asset as { type?: string })?.type; + + if (currentAssetType !== newAssetType) return "recreate"; + + let changed = false; + const currentTrackIndex = player.layer - 1; + + // Check timing changes + const currentStart = player.clipConfiguration.start as Seconds; + const currentLength = player.clipConfiguration.length as Seconds; + + if (currentStart !== clip.start || currentLength !== clip.length) { + // Update resolved timing + player.setResolvedTiming({ + start: clip.start, + length: clip.length + }); + player.reconfigureAfterRestore(); + changed = true; + } + + // Check track changes + if (currentTrackIndex !== trackIndex) { + // eslint-disable-next-line no-param-reassign -- Intentional player state update + player.layer = trackIndex + 1; + this.edit.movePlayerBetweenTracks(player, currentTrackIndex, trackIndex); + changed = true; + } + + // Check asset changes (property updates within same asset type) + if (this.assetChanged(player.clipConfiguration.asset, clip.asset)) { + this.updateAsset(player, clip.asset); + changed = true; + } + + // Check other clip configuration changes (position, offset, fit, opacity, etc.) + if (this.clipPropertiesChanged(player.clipConfiguration, clip)) { + this.updateClipProperties(player, clip); + changed = true; + } + + return changed; + } + + /** + * Check if non-timing, non-asset clip properties changed. + */ + private clipPropertiesChanged(current: ResolvedClip, resolved: ResolvedClip): boolean { + const currentRecord = current as Record; + const resolvedRecord = resolved as Record; + const allKeys = new Set([...Object.keys(currentRecord), ...Object.keys(resolvedRecord)]); + + for (const key of allKeys) { + if (!HANDLED_PROPS.has(key) && JSON.stringify(currentRecord[key]) !== JSON.stringify(resolvedRecord[key])) { + return true; + } + } + return false; + } + + /** + * Update player's clip-level properties from resolved clip. + */ + private updateClipProperties(player: Player, clip: ResolvedClip): void { + const playerConfig = player.clipConfiguration as Record; + const clipRecord = clip as Record; + const allKeys = new Set([...Object.keys(playerConfig), ...Object.keys(clipRecord)]); + + for (const key of allKeys) { + if (!HANDLED_PROPS.has(key)) { + if (clipRecord[key] !== undefined) { + playerConfig[key] = clipRecord[key]; + } else { + delete playerConfig[key]; + } + } + } + + player.reconfigureAfterRestore(); + } + + /** + * Check if asset properties changed (excluding type, which is handled separately). + */ + private assetChanged(current: unknown, resolved: unknown): boolean { + return JSON.stringify(current) !== JSON.stringify(resolved); + } + + /** + * Update player's asset and trigger reload if src changed. + */ + private updateAsset(player: Player, newAsset: unknown): void { + const oldSrc = (player.clipConfiguration.asset as { src?: string })?.src; + const newSrc = (newAsset as { src?: string })?.src; + + // Update the asset + // eslint-disable-next-line no-param-reassign -- Intentional player state update + player.clipConfiguration.asset = newAsset as ResolvedClip["asset"]; + + // If src changed, trigger async reload + if (oldSrc !== newSrc && player.reloadAsset) { + player + .reloadAsset() + .then(() => { + player.reconfigureAfterRestore(); + }) + .catch(error => { + console.error("Failed to reload asset:", error); + }); + } else { + player.reconfigureAfterRestore(); + } + } + + /** + * Sync PIXI track containers to match resolved track count. + * Creates new containers for added tracks, removes empty containers for deleted tracks. + */ + private syncTrackContainers(newTrackCount: number): void { + const currentTrackCount = this.edit.getTracks().length; + + if (newTrackCount > currentTrackCount) { + // Add new track containers and expand tracks array + for (let i = currentTrackCount; i < newTrackCount; i += 1) { + this.edit.ensureTrackExists(i); + } + } else if (newTrackCount < currentTrackCount) { + // Remove empty track containers and shrink tracks array + for (let i = currentTrackCount - 1; i >= newTrackCount; i -= 1) { + this.edit.removeEmptyTrack(i); + } + } + } + + /** + * Find Players that exist in the map but not in resolved clips. + */ + private findOrphanedPlayers(resolvedClipIds: Set): string[] { + const orphaned: string[] = []; + + for (const [clipId] of this.edit.getPlayerMap()) { + if (!resolvedClipIds.has(clipId)) { + orphaned.push(clipId); + } + } + + return orphaned; + } + + /** + * Dispose a Player by its clip ID. + */ + private disposePlayer(clipId: string): void { + const player = this.edit.getPlayerByClipId(clipId); + if (!player) return; + + // Remove from ID map + this.edit.unregisterPlayerByClipId(clipId); + + // Queue for disposal (handles PIXI cleanup, tracks array, etc.) + this.edit.queuePlayerForDisposal(player); + } + + /** + * Rebuild the tracks array ordering to match resolved order. + */ + private rebuildTracksOrdering(resolved: ResolvedEdit): void { + // Clear existing tracks + const tracks = this.edit.getTracks(); + for (let i = 0; i < tracks.length; i += 1) { + tracks[i] = []; + } + + // Rebuild from resolved order + for (let trackIndex = 0; trackIndex < resolved.timeline.tracks.length; trackIndex += 1) { + while (tracks.length <= trackIndex) { + tracks.push([]); + } + + for (const clip of resolved.timeline.tracks[trackIndex].clips) { + const clipId = (clip as ResolvedClip & { id?: string }).id; + if (clipId) { + const player = this.edit.getPlayerByClipId(clipId); + if (player) { + tracks[trackIndex].push(player); + } + } + } + } + } + + /** + * Clean up event subscriptions. + */ + public dispose(): void { + this.edit.getInternalEvents().off(InternalEvent.Resolved, this.onResolved); + } +} diff --git a/src/core/resolver.ts b/src/core/resolver.ts new file mode 100644 index 00000000..90f7f270 --- /dev/null +++ b/src/core/resolver.ts @@ -0,0 +1,510 @@ +/** + * Resolver - Pure function that transforms EditDocument → ResolvedEdit + * + * Resolves "auto" starts, "end" lengths, "alias://x" timing references, + * and merge field placeholders. Preserves stable clip IDs for reconciliation. + * + * ## Alias System + * + * Clips declare `alias: "intro"` to be referenceable. + * Other clips use `start: "alias://intro"` or `length: "alias://intro"`. + * + * **This file:** Resolves alias references in `start`/`length` fields to timing values. + * **Caption resolver:** Resolves `asset.src: "alias://x"` to extract audio for transcription. + * + * Both share the same alias namespace (clip.alias property). + */ + +import type { EditDocument } from "./edit-document"; +import type { MergeFieldService } from "./merge/merge-field-service"; +import type { Clip, ResolvedClip, ResolvedEdit, ResolvedTrack } from "./schemas"; +import { type Seconds, sec, isAliasReference, parseAliasName } from "./timing/types"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface ResolveContext { + mergeFields: MergeFieldService; +} + +/** + * Internal clip type with stable ID. + * The `id` field is SDK-internal (not part of Shotstack API) and used for: + * - Player reconciliation (tracking which player belongs to which clip) + * - Undo/redo (restoring specific clips by identity) + * - Single-clip resolution optimization + */ +type InternalClip = Clip & { id?: string }; + +/** Resolved clip with guaranteed ID and pending flag */ +interface PartialResolvedClip extends ResolvedClip { + id: string; + pendingEndLength?: boolean; +} + +// ─── Alias Types ────────────────────────────────────────────────────────────── + +/** + * Resolved timing values for a clip with an alias. + * Used during topological resolution to provide values for alias references. + */ +interface AliasValue { + /** The resolved start time of the aliased clip */ + start: Seconds; + /** The resolved length of the aliased clip */ + length: Seconds; +} + +interface ClipLocation { + clip: InternalClip; + trackIndex: number; + clipIndex: number; +} + +// ─── Helper Functions ───────────────────────────────────────────────────────── + +/** + * Deep-resolve merge field templates in a clip. + * Walks all properties recursively and resolves {{ FIELD }} patterns to their values. + * + * - Tries numeric conversion first (for timing, scale, offset, etc.) + * - Falls back to string resolution (for text content) + */ +function resolveMergeFieldsInClip(clip: InternalClip, mergeFields: MergeFieldService): InternalClip { + function processValue(value: unknown): unknown { + if (typeof value === "string" && mergeFields.isMergeFieldTemplate(value)) { + const num = mergeFields.resolveToNumber(value); + return num !== null ? num : mergeFields.resolve(value); + } + if (Array.isArray(value)) { + return value.map(processValue); + } + if (value !== null && typeof value === "object") { + const result: Record = {}; + for (const [k, v] of Object.entries(value)) { + result[k] = processValue(v); + } + return result; + } + return value; + } + + return processValue(structuredClone(clip)) as InternalClip; +} + +/** + * Build a map of dependencies for topological sorting. + * Includes both alias dependencies AND implicit "auto" start dependencies. + */ +function buildDependencyGraph(document: EditDocument): { + dependencies: Map>; + clipsByAlias: Map; + allClips: Array; +} { + const dependencies = new Map>(); + const clipsByAlias = new Map(); + const allClips: Array = []; + + // First pass: collect all clips and build alias map + for (let t = 0; t < document.getTrackCount(); t += 1) { + const trackClips = document.getClipsInTrack(t) as InternalClip[]; + + for (let c = 0; c < trackClips.length; c += 1) { + const clip = trackClips[c]; + if (!clip.id) { + throw new Error(`Clip at track ${t}, index ${c} is missing an ID. EditDocument hydration may have been skipped.`); + } + const clipId = clip.alias ?? clip.id; + + const location: ClipLocation = { clip, trackIndex: t, clipIndex: c }; + allClips.push({ ...location, id: clipId }); + + if (clip.alias) { + if (clipsByAlias.has(clip.alias)) throw new Error(`Duplicate alias "${clip.alias}" found. Each alias must be unique.`); + clipsByAlias.set(clip.alias, location); + } + } + } + + // Second pass: build dependencies and validate alias references + for (let t = 0; t < document.getTrackCount(); t += 1) { + const trackClips = document.getClipsInTrack(t) as InternalClip[]; + + for (let c = 0; c < trackClips.length; c += 1) { + const clip = trackClips[c]; + const clipId = clip.alias ?? clip.id!; + const deps = new Set(); + + if (isAliasReference(clip.start)) { + const aliasName = parseAliasName(clip.start); + if (!clipsByAlias.has(aliasName)) { + throw new Error(`Alias reference "alias://${aliasName}" not found. No clip defines alias "${aliasName}".`); + } + deps.add(aliasName); + } + if (isAliasReference(clip.length)) { + const aliasName = parseAliasName(clip.length); + if (!clipsByAlias.has(aliasName)) { + throw new Error(`Alias reference "alias://${aliasName}" not found. No clip defines alias "${aliasName}".`); + } + deps.add(aliasName); + } + + if (clip.start === "auto" && c > 0) { + const prevClip = trackClips[c - 1] as InternalClip; + const prevClipId = prevClip.alias ?? prevClip.id!; + deps.add(prevClipId); + } + + if (deps.size > 0) { + dependencies.set(clipId, deps); + } + } + } + + return { dependencies, clipsByAlias, allClips }; +} + +/** + * Detect circular references in the dependency graph. + */ +function detectCircularReferences(dependencies: Map>): string[] | null { + const visited = new Set(); + const recursionStack = new Set(); + + function findCycle(node: string, path: string[]): string[] | null { + visited.add(node); + recursionStack.add(node); + + const deps = dependencies.get(node); + if (deps) { + for (const dep of deps) { + if (recursionStack.has(dep)) { + return [...path, dep]; + } + if (!visited.has(dep)) { + const cycle = findCycle(dep, [...path, dep]); + if (cycle) return cycle; + } + } + } + + recursionStack.delete(node); + return null; + } + + for (const node of dependencies.keys()) { + if (!visited.has(node)) { + const cycle = findCycle(node, [node]); + if (cycle) return cycle; + } + } + + return null; +} + +/** + * Topologically sort clip IDs so dependencies are resolved first. + */ +function topologicalSort(dependencies: Map>, allClipIds: string[]): string[] { + const result: string[] = []; + const visited = new Set(); + + function visit(node: string): void { + if (visited.has(node)) return; + visited.add(node); + + const deps = dependencies.get(node); + if (deps) { + for (const dep of deps) { + visit(dep); + } + } + + result.push(node); + } + + // First visit all nodes that have dependencies + for (const node of dependencies.keys()) { + visit(node); + } + + // Then visit remaining clips + for (const clipId of allClipIds) { + visit(clipId); + } + + return result; +} + +/** + * Resolve a single clip's timing using alias values. + * This is called after topological sorting ensures dependencies are resolved first. + * Note: Merge fields should be resolved via resolveMergeFieldsInClip() BEFORE calling this. + */ +function resolveClipWithAliases(clip: InternalClip, previousClipEnd: Seconds, resolvedAliases: Map): PartialResolvedClip { + // Resolve start + let start: Seconds; + if (clip.start === "auto") { + start = previousClipEnd; + } else if (isAliasReference(clip.start)) { + const aliasName = parseAliasName(clip.start); + const aliasValue = resolvedAliases.get(aliasName); + if (!aliasValue) throw new Error(`Internal error: Alias "${aliasName}" not resolved.`); + start = aliasValue.start; + } else { + start = sec(clip.start as number); + } + + // Resolve length (partially - "end" deferred to second pass) + let length: Seconds; + let pendingEndLength = false; + + if (clip.length === "end") { + // Mark for second pass - needs timeline end calculation + length = sec(1); // Temporary placeholder + pendingEndLength = true; + } else if (clip.length === "auto") { + // Use intrinsic duration if available, else fallback + // Note: For now use fallback; intrinsic duration will be provided by Players + length = sec(3); + } else if (isAliasReference(clip.length)) { + const aliasName = parseAliasName(clip.length); + const aliasValue = resolvedAliases.get(aliasName); + if (!aliasValue) throw new Error(`Internal error: Alias "${aliasName}" not resolved.`); + length = aliasValue.length; + } else { + length = sec(clip.length as number); + } + + return { + ...clip, + id: clip.id ?? crypto.randomUUID(), + start, + length, + pendingEndLength: pendingEndLength || undefined + }; +} + +function calculateTimelineEndFromTracks(tracks: Array<{ clips: PartialResolvedClip[] }>): number { + let max = 0; + + for (const track of tracks) { + for (const clip of track.clips) { + // Exclude clips with pending "end" length + if (!clip.pendingEndLength) { + const end = clip.start + clip.length; + if (end > max) { + max = end; + } + } + } + } + + return max; +} + +function cleanupPendingFlags(clip: PartialResolvedClip): ResolvedClip { + const { pendingEndLength, ...cleanClip } = clip; + return cleanClip; +} + +// ─── Single-Clip Resolution ─────────────────────────────────────────────────── + +/** Result of single-clip resolution */ +export interface ResolveClipResult { + resolved: ResolvedClip; + trackIndex: number; + clipIndex: number; +} + +/** Extended context for single-clip resolution with cached values */ +export interface SingleClipContext extends ResolveContext { + /** + * End time of the previous clip in the same track. + * Required for clips with start: "auto". + * Get from previous player's getEnd() for already-resolved timing. + */ + previousClipEnd: Seconds; + + /** + * Cached timeline end for clips with length: "end". + * Get from edit.cachedTimelineEnd for already-calculated value. + */ + cachedTimelineEnd?: Seconds; + + /** + * Resolved alias values for alias reference resolution. + */ + resolvedAliases?: Map; +} + +/** + * Resolve a single clip by ID. + * + * This is an optimization for single-clip mutations (timing, asset, properties). + * Instead of re-resolving ALL clips, we resolve just the one that changed. + * + * Use cases: + * - Resize a clip → resolveClip() is 10x faster than resolve() + * - Update asset property → instant feedback + * - Text content change → no full timeline recalc needed + * + * NOT for structural changes: + * - Adding/deleting clips (affects downstream "auto" starts) + * - Moving clips between tracks + * - Track add/delete + * + * @param document - The EditDocument (source of truth) + * @param clipId - The clip to resolve + * @param context - Resolution context with cached values for efficiency + * @returns Resolved clip with location, or null if clip not found + */ +export function resolveClip(document: EditDocument, clipId: string, context: SingleClipContext): ResolveClipResult | null { + // 1. Locate clip in document + const lookup = document.getClipById(clipId); + if (!lookup) { + return null; + } + + const { clip, trackIndex, clipIndex } = lookup; + const internalClip = clip as InternalClip; + + // 2. Pre-process merge fields in entire clip + const processedClip = resolveMergeFieldsInClip(internalClip, context.mergeFields); + + // 3. Resolve the single clip using alias-aware logic + const resolvedAliases = context.resolvedAliases ?? new Map(); + const resolvedClip = resolveClipWithAliases(processedClip, context.previousClipEnd, resolvedAliases); + + // 4. Handle "end" length (second pass for this single clip) + if (resolvedClip.pendingEndLength && context.cachedTimelineEnd !== undefined) { + resolvedClip.length = sec(Math.max(context.cachedTimelineEnd - resolvedClip.start, 0.1)); + } + + // 5. Clean up and return + return { + resolved: cleanupPendingFlags(resolvedClip), + trackIndex, + clipIndex + }; +} + +// ─── Main Resolver ──────────────────────────────────────────────────────────── + +/** + * Resolve an EditDocument to a ResolvedEdit. + * + * This is a pure function - given the same document and context, it always + * produces the same output. No side effects, no mutations. + * + * Algorithm: + * 1. Build alias dependency graph from document + * 2. Detect circular references (throw if found) + * 3. Topologically sort clips (dependencies resolve first) + * 4. Resolve clips in order, building alias values map + * 5. Second pass: resolve "end" lengths (needs full timeline context) + */ +export function resolve(document: EditDocument, context: ResolveContext): ResolvedEdit { + // Build dependency graph for alias resolution + const { dependencies, allClips } = buildDependencyGraph(document); + + // Detect circular references + if (dependencies.size > 0) { + const cycle = detectCircularReferences(dependencies); + if (cycle) { + throw new Error(`Circular alias reference detected: ${cycle.join(" -> ")}`); + } + } + + // Topologically sort clips (dependencies resolve first) + const allClipIds = allClips.map(c => c.id); + const resolveOrder = topologicalSort(dependencies, allClipIds); + + // Build clip lookup by ID + const clipById = new Map(); + for (const clipInfo of allClips) { + clipById.set(clipInfo.id, clipInfo); + } + + // Use a Map to collect resolved clips by position + const resolvedClipsByPosition = new Map(); + + // Track resolved aliases + const resolvedAliases = new Map(); + + // Track previous clip end per track for "auto" start resolution + const previousClipEndByTrack = new Map(); + + // Resolve clips in topological order + for (const clipId of resolveOrder) { + const clipInfo = clipById.get(clipId); + if (clipInfo) { + const { clip, trackIndex, clipIndex } = clipInfo; + + // Pre-process merge fields in entire clip + const processedClip = resolveMergeFieldsInClip(clip, context.mergeFields); + + // Get previous clip end for this track (for "auto" start) + const previousClipEnd = previousClipEndByTrack.get(trackIndex) ?? sec(0); + + // Resolve the clip with alias support + const resolvedClip = resolveClipWithAliases(processedClip, previousClipEnd, resolvedAliases); + + // Store in map by position key + resolvedClipsByPosition.set(`${trackIndex}-${clipIndex}`, resolvedClip); + + // Update previous clip end for this track + const clipEnd = sec(resolvedClip.start + resolvedClip.length); + const existingEnd = previousClipEndByTrack.get(trackIndex) ?? sec(0); + if (clipEnd > existingEnd) { + previousClipEndByTrack.set(trackIndex, clipEnd); + } + + // Store resolved alias value if this clip has an alias + if (clip.alias) { + resolvedAliases.set(clip.alias, { + start: resolvedClip.start, + length: resolvedClip.length + }); + } + } + } + + // Rebuild contiguous arrays from the map (preserves document order) + const partialTracks: Array<{ clips: PartialResolvedClip[] }> = []; + for (let t = 0; t < document.getTrackCount(); t += 1) { + const trackClipCount = document.getClipsInTrack(t).length; + const clips: PartialResolvedClip[] = []; + for (let c = 0; c < trackClipCount; c += 1) { + const clip = resolvedClipsByPosition.get(`${t}-${c}`); + if (!clip) { + throw new Error(`Internal error: Clip at track ${t}, index ${c} was not resolved.`); + } + clips.push(clip); + } + partialTracks.push({ clips }); + } + + // Second pass: resolve "end" lengths (needs full timeline context) + const timelineEnd = calculateTimelineEndFromTracks(partialTracks); + + const tracks: ResolvedTrack[] = partialTracks.map(track => ({ + clips: track.clips.map(clip => { + if (clip.pendingEndLength) { + // Resolve "end" length now that we know the timeline end + const resolvedLength = sec(Math.max(timelineEnd - clip.start, 0.1)); + return cleanupPendingFlags({ ...clip, length: resolvedLength }); + } + return cleanupPendingFlags(clip); + }) + })); + + return { + timeline: { + background: document.getBackground(), + tracks, + fonts: document.getFonts() + }, + output: document.getOutput() + }; +} diff --git a/src/core/schemas/asset.ts b/src/core/schemas/asset.ts deleted file mode 100644 index 5d4722c1..00000000 --- a/src/core/schemas/asset.ts +++ /dev/null @@ -1,59 +0,0 @@ -import * as zod from "zod"; - -import { AudioAssetSchema } from "./audio-asset"; -import { HtmlAssetSchema } from "./html-asset"; -import { ImageAssetSchema } from "./image-asset"; -import { LumaAssetSchema } from "./luma-asset"; -import { RichTextAssetSchema } from "./rich-text-asset"; -import { ShapeAssetSchema } from "./shape-asset"; -import { TextAssetSchema } from "./text-asset"; -import { VideoAssetSchema } from "./video-asset"; - -export const AssetSchema = zod - .union([ - TextAssetSchema, - RichTextAssetSchema, - ShapeAssetSchema, - HtmlAssetSchema, - ImageAssetSchema, - VideoAssetSchema, - LumaAssetSchema, - AudioAssetSchema - ]) - .refine(schema => { - if (schema.type === "text") { - return TextAssetSchema.safeParse(schema); - } - - if (schema.type === "rich-text") { - return RichTextAssetSchema.safeParse(schema); - } - - if (schema.type === "shape") { - return ShapeAssetSchema.safeParse(schema); - } - - if (schema.type === "html") { - return HtmlAssetSchema.safeParse(schema); - } - - if (schema.type === "image") { - return ImageAssetSchema.safeParse(schema); - } - - if (schema.type === "video") { - return VideoAssetSchema.safeParse(schema); - } - - if (schema.type === "luma") { - return LumaAssetSchema.safeParse(schema); - } - - if (schema.type === "audio") { - return AudioAssetSchema.safeParse(schema); - } - - return false; - }); - -export type Asset = zod.infer; diff --git a/src/core/schemas/audio-asset.ts b/src/core/schemas/audio-asset.ts deleted file mode 100644 index 4abbf202..00000000 --- a/src/core/schemas/audio-asset.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as zod from "zod"; - -import { KeyframeSchema } from "./keyframe"; - -export const AudioAssetUrlSchema = zod.string().url("Invalid audio url format."); - -export const AudioAssetVolumeSchema = KeyframeSchema.extend({ - from: zod.number().min(0).max(1), - to: zod.number().min(0).max(1) -}) - .array() - .or(zod.number().min(0).max(1)); - -export const AudioAssetEffectSchema = zod.enum(["none", "fadeIn", "fadeOut", "fadeInFadeOut"]); - -export const AudioAssetSchema = zod - .object({ - type: zod.literal("audio"), - src: AudioAssetUrlSchema, - trim: zod.number().optional(), - volume: AudioAssetVolumeSchema.optional(), - effect: AudioAssetEffectSchema.optional() - }) - .strict(); - -export type AudioAsset = zod.infer; diff --git a/src/core/schemas/clip.ts b/src/core/schemas/clip.ts deleted file mode 100644 index e913025d..00000000 --- a/src/core/schemas/clip.ts +++ /dev/null @@ -1,108 +0,0 @@ -import * as zod from "zod"; - -import { AssetSchema } from "./asset"; -import { KeyframeSchema } from "./keyframe"; - -/** - * TODO: Rename all these to clip configuration - * TODO: Change all default to optional - */ - -const ClipAnchorSchema = zod.enum(["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"]); - -const ClipFitSchema = zod.enum(["crop", "cover", "contain", "none"]); - -const ClipOffsetValueSchema = zod.number().min(-10).max(10).default(0); - -const ClipOffsetXSchema = KeyframeSchema.extend({ - from: ClipOffsetValueSchema, - to: ClipOffsetValueSchema -}) - .array() - .or(ClipOffsetValueSchema); - -const ClipOffsetYSchema = KeyframeSchema.extend({ - from: ClipOffsetValueSchema, - to: ClipOffsetValueSchema -}) - .array() - .or(ClipOffsetValueSchema); - -const ClipOffsetSchema = zod - .object({ - x: ClipOffsetXSchema.default(0), - y: ClipOffsetYSchema.default(0) - }) - .strict(); - -const ClipOpacitySchema = KeyframeSchema.extend({ - from: zod.number().min(0).max(1), - to: zod.number().min(0).max(1) -}) - .array() - .or(zod.number().min(0).max(1)); - -const ClipScaleSchema = KeyframeSchema.extend({ - from: zod.number().min(0), - to: zod.number().min(0) -}) - .array() - .or(zod.number().min(0)); - -const ClipTransformRotationSchema = zod - .object({ - angle: KeyframeSchema.extend({ - from: zod.number(), - to: zod.number() - }) - .array() - .or(zod.number()) - }) - .strict(); - -const ClipEffectSchema = zod.string(); -const ClipTransitionValueSchema = zod.string(); - -const ClipTransitionSchema = zod - .object({ - in: ClipTransitionValueSchema.optional(), - out: ClipTransitionValueSchema.optional() - }) - .strict(); - -const ClipTransformSchema = zod - .object({ - rotate: ClipTransformRotationSchema.default({ angle: 0 }) - }) - .strict(); - -export const ClipSchema = zod - .object({ - asset: AssetSchema, - start: zod.union([zod.number().min(0), zod.literal("auto")]), - length: zod.union([zod.number().positive(), zod.literal("auto"), zod.literal("end")]), - position: ClipAnchorSchema.default("center").optional(), - fit: ClipFitSchema.optional(), - offset: ClipOffsetSchema.default({ x: 0, y: 0 }).optional(), - opacity: ClipOpacitySchema.default(1).optional(), - scale: ClipScaleSchema.default(1).optional(), - transform: ClipTransformSchema.default({ rotate: { angle: 0 } }).optional(), - effect: ClipEffectSchema.optional(), - transition: ClipTransitionSchema.optional(), - width: zod.number().min(1).max(3840).optional(), - height: zod.number().min(1).max(2160).optional() - }) - .strict() - .transform(data => ({ - ...data, - fit: data.fit ?? (data.asset.type === "rich-text" ? "cover" : "crop") - })); - -export type ClipAnchor = zod.infer; -export type Clip = zod.infer; - -/** Clip with resolved numeric timing values in seconds (no "auto" or "end") */ -export type ResolvedClipConfig = Omit & { - start: number; - length: number; -}; diff --git a/src/core/schemas/edit.ts b/src/core/schemas/edit.ts deleted file mode 100644 index 6f7db8e1..00000000 --- a/src/core/schemas/edit.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as zod from "zod"; - -import { TrackSchema } from "./track"; - -export const FontSourceUrlSchema = zod.string().url("Invalid image url format."); - -export const FontSourceSchema = zod - .object({ - src: FontSourceUrlSchema - }) - .strict(); - -export const TimelineSchema = zod - .object({ - background: zod.string().optional(), - fonts: FontSourceSchema.array().optional(), - tracks: TrackSchema.array() - }) - .strict(); - -export const OutputSchema = zod - .object({ - size: zod - .object({ - width: zod.number().positive(), - height: zod.number().positive() - }) - .strict(), - fps: zod.number().positive().optional(), - format: zod.string() - }) - .strict(); - -export const MergeFieldSchema = zod.object({ - find: zod.string().min(1), - replace: zod.string() -}); - -export const EditSchema = zod - .object({ - timeline: TimelineSchema, - output: OutputSchema, - merge: zod.array(MergeFieldSchema).optional() - }) - .strict(); - -export type Track = zod.infer; -export type MergeField = zod.infer; diff --git a/src/core/schemas/html-asset.ts b/src/core/schemas/html-asset.ts deleted file mode 100644 index f87dee90..00000000 --- a/src/core/schemas/html-asset.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as zod from "zod"; - -const HtmlAssetPositionSchema = zod.enum(["top", "topRight", "right", "bottomRight", "bottom", "bottomLeft", "left", "topLeft", "center"]); - -export const HtmlAssetSchema = zod - .object({ - type: zod.literal("html"), - html: zod.string(), - css: zod.string(), - width: zod.number().positive().optional(), - height: zod.number().positive().optional(), - position: HtmlAssetPositionSchema.optional() - }) - .strict(); - -export type HtmlAsset = zod.infer; -export type HtmlAssetPosition = zod.infer; diff --git a/src/core/schemas/image-asset.ts b/src/core/schemas/image-asset.ts deleted file mode 100644 index c74511c9..00000000 --- a/src/core/schemas/image-asset.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as zod from "zod"; - -export const ImageAssetUrlSchema = zod.string().url("Invalid image url format."); - -export const ImageAssetCropSchema = zod - .object({ - top: zod.number().min(0).optional(), - right: zod.number().min(0).optional(), - bottom: zod.number().min(0).optional(), - left: zod.number().min(0).optional() - }) - .strict(); - -export const ImageAssetSchema = zod - .object({ - type: zod.literal("image"), - src: ImageAssetUrlSchema, - crop: ImageAssetCropSchema.optional() - }) - .strict(); - -export type ImageAsset = zod.infer; diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index e3152dbe..0cd13614 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -1,66 +1,193 @@ -// Asset -export { AssetSchema } from "./asset"; -export type { Asset } from "./asset"; +/** + * Shotstack Schema Types + * + * This module exports types from @shotstack/schemas as the canonical source of truth + * for the Shotstack data model. SDK-specific "Resolved" types are defined locally + * for runtime values where "auto", "end", and aliases are resolved to concrete values. + * + * @see https://github.com/shotstack/oas-api-definition + */ -// Audio Asset -export { AudioAssetUrlSchema, AudioAssetVolumeSchema, AudioAssetSchema } from "./audio-asset"; -export type { AudioAsset } from "./audio-asset"; +import type { components } from "@shotstack/schemas"; +import { + editSchema, + timelineSchema, + trackSchema, + clipSchema, + outputSchema, + videoAssetSchema, + audioAssetSchema, + imageAssetSchema, + textAssetSchema, + richTextAssetSchema, + htmlAssetSchema, + captionAssetSchema, + shapeAssetSchema, + lumaAssetSchema, + svgAssetSchema, + textToImageAssetSchema, + imageToVideoAssetSchema, + textToSpeechAssetSchema, + assetSchema, + tweenSchema, + cropSchema, + offsetSchema, + transitionSchema, + transformationSchema, + destinationsSchema, + sizeSchema +} from "@shotstack/schemas/zod"; +import type { Seconds } from "@timing/types"; +import { z } from "zod"; -// Clip -export { ClipSchema } from "./clip"; -export type { Clip, ClipAnchor } from "./clip"; +// ─── Primary Types (from external package) ───────────────────────────────── -// Edit, Timeline, Output, Fonts -export { FontSourceUrlSchema, FontSourceSchema, TimelineSchema, OutputSchema, EditSchema } from "./edit"; -export type { Track } from "./edit"; +export type Edit = components["schemas"]["Edit"]; +export type Timeline = components["schemas"]["Timeline"]; +export type Track = components["schemas"]["Track"]; +export type Clip = components["schemas"]["Clip"]; +export type Output = components["schemas"]["Output"]; +export type Asset = components["schemas"]["Asset"]; +export type MergeField = components["schemas"]["MergeField"]; +export type Soundtrack = components["schemas"]["Soundtrack"]; +export type Font = components["schemas"]["Font"]; -// HTML Asset -export { HtmlAssetSchema } from "./html-asset"; -export type { HtmlAsset, HtmlAssetPosition } from "./html-asset"; +// Asset types +export type VideoAsset = components["schemas"]["VideoAsset"]; +export type AudioAsset = components["schemas"]["AudioAsset"]; +export type ImageAsset = components["schemas"]["ImageAsset"]; +export type TextAsset = components["schemas"]["TextAsset"]; +export type RichTextAsset = components["schemas"]["RichTextAsset"]; +export type HtmlAsset = components["schemas"]["HtmlAsset"]; +export type CaptionAsset = components["schemas"]["CaptionAsset"]; +export type ShapeAsset = components["schemas"]["ShapeAsset"]; +export type LumaAsset = components["schemas"]["LumaAsset"]; +export type TitleAsset = components["schemas"]["TitleAsset"]; +export type SvgAsset = components["schemas"]["SvgAsset"]; +export type TextToImageAsset = components["schemas"]["TextToImageAsset"]; +export type ImageToVideoAsset = components["schemas"]["ImageToVideoAsset"]; +export type TextToSpeechAsset = components["schemas"]["TextToSpeechAsset"]; -// Image Asset -export { ImageAssetUrlSchema, ImageAssetCropSchema, ImageAssetSchema } from "./image-asset"; -export type { ImageAsset } from "./image-asset"; +// Sub-types +export type Crop = components["schemas"]["Crop"]; +export type Offset = components["schemas"]["Offset"]; +export type Transition = components["schemas"]["Transition"]; +export type Transformation = components["schemas"]["Transformation"]; +export type ChromaKey = components["schemas"]["ChromaKey"]; +export type Tween = components["schemas"]["Tween"]; -// Keyframe -export { KeyframeInterpolationSchema, KeyframeEasingSchema, KeyframeSchema } from "./keyframe"; -export type { Keyframe } from "./keyframe"; +// Destination types (camelCase from external) +export type Destination = components["schemas"]["Destinations"]; -// Luma Asset -export { LumaAssetUrlSchema, LumaAssetSchema } from "./luma-asset"; -export type { LumaAsset } from "./luma-asset"; +// ─── SDK-Specific Resolved Types ─────────────────────────────────────────── +// Runtime types where "auto", "end", and aliases are resolved to concrete values -// Rich Text Asset -export { RichTextAssetSchema } from "./rich-text-asset"; -export type { RichTextAsset } from "./rich-text-asset"; +export type ResolvedClip = Omit & { + id: string; + start: Seconds; + length: Seconds; +}; -// Shape Asset -export { - ShapeAssetColorSchema, - ShapeAssetRectangleSchema, - ShapeAssetCircleSchema, - ShapeAssetLineSchema, - ShapeAssetFillSchema, - ShapeAssetStrokeSchema, - ShapeAssetSchema -} from "./shape-asset"; -export type { ShapeAsset } from "./shape-asset"; - -// Text Asset +export type ResolvedTrack = { + clips: ResolvedClip[]; +}; + +export type ResolvedEdit = Omit & { + timeline: Omit & { + tracks: ResolvedTrack[]; + }; +}; + +// ─── Backward Compatibility Aliases ──────────────────────────────────────── + +/** Configuration for defining an edit - the structure passed to EditSession */ +export type EditConfig = Edit; +export type ClipAnchor = Clip["position"]; +export type HtmlAssetPosition = NonNullable; +export type Keyframe = Tween; // SDK previously called Tween "Keyframe" + +// ─── SDK-Extended Asset Types ─────────────────────────────────────────────── +// Extended types with additional SDK-specific properties not in external schema + +/** SDK-extended CaptionAsset with stroke, width, height, alignment */ +export type ExtendedCaptionAsset = CaptionAsset & { + stroke?: { width: number; color: string }; + width?: number; + height?: number; + alignment?: { horizontal?: "left" | "center" | "right"; vertical?: "top" | "center" | "bottom" }; +}; + +// ─── Internal Animation Types ─────────────────────────────────────────────── +// Keyframes with all required fields and numeric values for animation interpolation + +export interface NumericKeyframe { + start: number; + length: number; + from: number; + to: number; + interpolation?: Tween["interpolation"]; + easing?: Tween["easing"]; +} + +// ─── Zod Schemas (for validation) ────────────────────────────────────────── + +// TODO: Enable strict mode on all Zod schemas to reject unknown properties. +// Currently, typos like "transformation" instead of "transform" are silently stripped. +// This should be implemented at the @shotstack/schemas library level (oas-api-definition) +// by adding .strict() to all z.object() schemas in the post-processing script. +// Additionally, we need to emit an EditEvent.ValidationError when schema validation fails +// so consumers can handle validation errors gracefully. + +// Re-export external schemas with SDK naming convention export { - TextAssetColorSchema, - TextAssetFontSchema, - TextAssetAlignmentSchema, - TextAssetBackgroundSchema, - TextAssetStrokeSchema, - TextAssetSchema -} from "./text-asset"; -export type { TextAsset } from "./text-asset"; - -// Track -export { TrackSchema } from "./track"; -// Note: Track type is exported from "./edit" for historical reasons - -// Video Asset -export { VideoAssetUrlSchema, VideoAssetCropSchema, VideoAssetVolumeSchema, VideoAssetSchema } from "./video-asset"; -export type { VideoAsset } from "./video-asset"; + editSchema as EditSchema, + timelineSchema as TimelineSchema, + trackSchema as TrackSchema, + clipSchema as ClipSchema, + outputSchema as OutputSchema, + videoAssetSchema as VideoAssetSchema, + audioAssetSchema as AudioAssetSchema, + imageAssetSchema as ImageAssetSchema, + textAssetSchema as TextAssetSchema, + richTextAssetSchema as RichTextAssetSchema, + htmlAssetSchema as HtmlAssetSchema, + captionAssetSchema as CaptionAssetSchema, + shapeAssetSchema as ShapeAssetSchema, + lumaAssetSchema as LumaAssetSchema, + svgAssetSchema as SvgAssetSchema, + textToImageAssetSchema as TextToImageAssetSchema, + imageToVideoAssetSchema as ImageToVideoAssetSchema, + textToSpeechAssetSchema as TextToSpeechAssetSchema, + assetSchema as AssetSchema, + tweenSchema as TweenSchema, + tweenSchema as KeyframeSchema, + cropSchema as CropSchema, + offsetSchema as OffsetSchema, + transitionSchema as TransitionSchema, + transformationSchema as TransformationSchema +}; + +// SDK-specific validation schemas (derived from external schemas) +export const DestinationSchema = destinationsSchema; +export const OutputSizeSchema = sizeSchema; +export const OutputFormatSchema = outputSchema.shape.format; +export const OutputFpsSchema = outputSchema.shape.fps.unwrap(); // unwrap optional +export const OutputResolutionSchema = outputSchema.shape.resolution; +export const OutputAspectRatioSchema = outputSchema.shape.aspectRatio; +export const HexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$|^#[0-9A-Fa-f]{8}$/); + +// ─── Resolved Zod Schemas ─────────────────────────────────────────────────── +// Extended schemas that accept the SDK's internal `id` field on clips. +// Used for validating data that has already entered the system and been resolved. + +export const ResolvedClipSchema = clipSchema.extend({ id: z.string() }); + +export const ResolvedTrackSchema = trackSchema.extend({ + clips: z.array(ResolvedClipSchema).min(1) +}); + +export const ResolvedEditSchema = editSchema.extend({ + timeline: timelineSchema.extend({ + tracks: z.array(ResolvedTrackSchema).min(1) + }) +}); diff --git a/src/core/schemas/keyframe.ts b/src/core/schemas/keyframe.ts deleted file mode 100644 index bff42c5e..00000000 --- a/src/core/schemas/keyframe.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as zod from "zod"; - -export const KeyframeInterpolationSchema = zod.enum(["linear", "bezier", "constant"]); - -export const KeyframeEasingSchema = zod.enum([ - "ease", - "easeIn", - "easeOut", - "easeInOut", - "easeInQuad", - "easeInCubic", - "easeInQuart", - "easeInQuint", - "easeInSine", - "easeInExpo", - "easeInCirc", - "easeInBack", - "easeOutQuad", - "easeOutCubic", - "easeOutQuart", - "easeOutQuint", - "easeOutSine", - "easeOutExpo", - "easeOutCirc", - "easeOutBack", - "easeInOutQuad", - "easeInOutCubic", - "easeInOutQuart", - "easeInOutQuint", - "easeInOutSine", - "easeInOutExpo", - "easeInOutCirc", - "easeInOutBack" -]); - -export const KeyframeSchema = zod - .object({ - from: zod.number(), - to: zod.number(), - start: zod.number().min(0), - length: zod.number().positive(), - interpolation: KeyframeInterpolationSchema.optional(), - easing: KeyframeEasingSchema.optional() - }) - .strict(); - -export type Keyframe = zod.infer; diff --git a/src/core/schemas/luma-asset.ts b/src/core/schemas/luma-asset.ts deleted file mode 100644 index f9045187..00000000 --- a/src/core/schemas/luma-asset.ts +++ /dev/null @@ -1,12 +0,0 @@ -import * as zod from "zod"; - -export const LumaAssetUrlSchema = zod.string().url("Invalid luma url format."); - -export const LumaAssetSchema = zod - .object({ - type: zod.literal("luma"), - src: LumaAssetUrlSchema - }) - .strict(); - -export type LumaAsset = zod.infer; diff --git a/src/core/schemas/rich-text-asset.ts b/src/core/schemas/rich-text-asset.ts deleted file mode 100644 index 73bcc084..00000000 --- a/src/core/schemas/rich-text-asset.ts +++ /dev/null @@ -1,126 +0,0 @@ -import * as zod from "zod"; - -const HexColorSchema = zod.string().regex(/^#[A-Fa-f0-9]{6}$/, "Invalid hex color format"); - -const GradientStopSchema = zod - .object({ - offset: zod.number().min(0).max(1), - color: HexColorSchema - }) - .strict(); - -const GradientSchema = zod - .object({ - type: zod.enum(["linear", "radial"]).default("linear"), - angle: zod.number().min(0).max(360).default(0), - stops: zod.array(GradientStopSchema).min(2) - }) - .strict(); - -const RichTextFontSchema = zod - .object({ - family: zod.string().default("Roboto"), - size: zod.number().min(8).max(500).default(48), - weight: zod.union([zod.string(), zod.number()]).default("400"), - color: HexColorSchema.default("#000000"), - opacity: zod.number().min(0).max(1).default(1), - background: HexColorSchema.optional(), - stroke: zod - .object({ - width: zod.number().min(0).default(0), - color: HexColorSchema.default("#000000"), - opacity: zod.number().min(0).max(1).default(1) - }) - .strict() - .optional() - }) - .strict(); - -const RichTextStyleSchema = zod - .object({ - letterSpacing: zod.number().default(0), - lineHeight: zod.number().min(0.1).max(10).default(1.2), - textTransform: zod.enum(["none", "uppercase", "lowercase", "capitalize"]).default("none"), - textDecoration: zod.enum(["none", "underline", "line-through"]).default("none"), - gradient: GradientSchema.optional() - }) - .strict(); - -const RichTextStrokeSchema = zod - .object({ - width: zod.number().min(0).default(0), - color: HexColorSchema.default("#000000"), - opacity: zod.number().min(0).max(1).default(1) - }) - .strict(); - -const RichTextShadowSchema = zod - .object({ - offsetX: zod.number().default(0), - offsetY: zod.number().default(0), - blur: zod.number().min(0).default(0), - color: HexColorSchema.default("#000000"), - opacity: zod.number().min(0).max(1).default(0.5) - }) - .strict(); - -const RichTextBorderSchema = zod - .object({ - width: zod.number().min(0).default(0), - color: HexColorSchema.default("#000000"), - opacity: zod.number().min(0).max(1).default(1), - radius: zod.number().min(0).default(0), - }) - .strict(); - -const RichTextBackgroundSchema = zod - .object({ - color: HexColorSchema.optional(), - opacity: zod.number().min(0).max(1).default(1) - }) - .strict(); - -const RichTextPaddingSchema = zod.union([ - zod.number().min(0), - zod - .object({ - top: zod.number().min(0).default(0), - right: zod.number().min(0).default(0), - bottom: zod.number().min(0).default(0), - left: zod.number().min(0).default(0) - }) - .strict() -]); - -const RichTextAlignmentSchema = zod - .object({ - horizontal: zod.enum(["left", "center", "right"]).default("left"), - vertical: zod.enum(["top", "middle", "bottom"]).default("middle") - }) - .strict(); - -const RichTextAnimationSchema = zod - .object({ - preset: zod.enum(["fadeIn", "slideIn", "typewriter", "shift", "ascend", "movingLetters", "bounce", "elastic", "pulse"]), - duration: zod.number().min(0.1).max(60).optional(), - style: zod.enum(["character", "word"]).optional(), - direction: zod.enum(["left", "right", "up", "down"]).optional() - }) - .strict(); - -export const RichTextAssetSchema = zod - .object({ - type: zod.literal("rich-text"), - text: zod.string().max(10000).default(""), - font: RichTextFontSchema.optional(), - style: RichTextStyleSchema.optional(), - shadow: RichTextShadowSchema.optional(), - background: RichTextBackgroundSchema.optional(), - border: RichTextBorderSchema.optional(), - padding: RichTextPaddingSchema.optional(), - align: RichTextAlignmentSchema.optional(), - animation: RichTextAnimationSchema.optional() - }) - .strict(); - -export type RichTextAsset = zod.infer; diff --git a/src/core/schemas/shape-asset.ts b/src/core/schemas/shape-asset.ts deleted file mode 100644 index 755089a3..00000000 --- a/src/core/schemas/shape-asset.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as zod from "zod"; - -export const ShapeAssetColorSchema = zod.string().regex(/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|transparent$/, "Invalid color format."); - -export const ShapeAssetRectangleSchema = zod - .object({ - width: zod.number().positive(), - height: zod.number().positive() - }) - .strict(); - -export const ShapeAssetCircleSchema = zod - .object({ - radius: zod.number().positive() - }) - .strict(); - -export const ShapeAssetLineSchema = zod - .object({ - length: zod.number().positive(), - thickness: zod.number().positive() - }) - .strict(); - -export const ShapeAssetFillSchema = zod - .object({ - color: ShapeAssetColorSchema, - opacity: zod.number().min(0).max(1) - }) - .strict(); - -export const ShapeAssetStrokeSchema = zod - .object({ - color: ShapeAssetColorSchema, - width: zod.number().positive() - }) - .strict(); - -export const ShapeAssetSchema = zod - .object({ - type: zod.literal("shape"), - width: zod.number().positive().optional(), - height: zod.number().positive().optional(), - shape: zod.enum(["rectangle", "circle", "line"]), - fill: ShapeAssetFillSchema.optional(), - stroke: ShapeAssetStrokeSchema.optional(), - rectangle: ShapeAssetRectangleSchema.optional(), - circle: ShapeAssetCircleSchema.optional(), - line: ShapeAssetLineSchema.optional() - }) - .strict() - .refine(schema => { - if (schema.shape === "rectangle") { - return ShapeAssetRectangleSchema.safeParse(schema.rectangle); - } - - if (schema.shape === "circle") { - return ShapeAssetCircleSchema.safeParse(schema.circle); - } - - if (schema.shape === "line") { - return ShapeAssetLineSchema.safeParse(schema.line); - } - - return false; - }); - -export type ShapeAsset = zod.infer; diff --git a/src/core/schemas/text-asset.ts b/src/core/schemas/text-asset.ts deleted file mode 100644 index 21831df2..00000000 --- a/src/core/schemas/text-asset.ts +++ /dev/null @@ -1,49 +0,0 @@ -import * as zod from "zod"; - -export const TextAssetColorSchema = zod.string().regex(/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|transparent$/, "Invalid color format."); - -export const TextAssetFontSchema = zod - .object({ - color: TextAssetColorSchema.optional(), - family: zod.string().optional(), - size: zod.number().positive().optional(), - weight: zod.number().optional(), - lineHeight: zod.number().optional() - }) - .strict(); - -export const TextAssetAlignmentSchema = zod - .object({ - horizontal: zod.enum(["left", "center", "right"]).optional(), - vertical: zod.enum(["top", "center", "bottom"]).optional() - }) - .strict(); - -export const TextAssetBackgroundSchema = zod - .object({ - color: TextAssetColorSchema, - opacity: zod.number().min(0).max(1) - }) - .strict(); - -export const TextAssetStrokeSchema = zod - .object({ - width: zod.number().positive(), - color: TextAssetColorSchema - }) - .strict(); - -export const TextAssetSchema = zod - .object({ - type: zod.literal("text"), - text: zod.string(), - width: zod.number().positive().optional(), - height: zod.number().positive().optional(), - font: TextAssetFontSchema.optional(), - alignment: TextAssetAlignmentSchema.optional(), - background: TextAssetBackgroundSchema.optional(), - stroke: TextAssetStrokeSchema.optional() - }) - .strict(); - -export type TextAsset = zod.infer; diff --git a/src/core/schemas/track.ts b/src/core/schemas/track.ts deleted file mode 100644 index 681d322d..00000000 --- a/src/core/schemas/track.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as zod from "zod"; - -import { ClipSchema } from "./clip"; - -export const TrackSchema = zod - .object({ - clips: ClipSchema.array() - }) - .strict(); diff --git a/src/core/schemas/video-asset.ts b/src/core/schemas/video-asset.ts deleted file mode 100644 index 22175583..00000000 --- a/src/core/schemas/video-asset.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as zod from "zod"; - -import { KeyframeSchema } from "./keyframe"; - -export const VideoAssetUrlSchema = zod.string().url("Invalid video url format."); - -export const VideoAssetCropSchema = zod - .object({ - top: zod.number().min(0).optional(), - right: zod.number().min(0).optional(), - bottom: zod.number().min(0).optional(), - left: zod.number().min(0).optional() - }) - .strict(); - -export const VideoAssetVolumeSchema = KeyframeSchema.extend({ - from: zod.number().min(0).max(1), - to: zod.number().min(0).max(1) -}) - .array() - .or(zod.number().min(0).max(1)); - -export const VideoAssetSchema = zod - .object({ - type: zod.literal("video"), - src: VideoAssetUrlSchema, - trim: zod.number().optional(), - crop: VideoAssetCropSchema.optional(), - volume: VideoAssetVolumeSchema.optional() - }) - .strict(); - -export type VideoAsset = zod.infer; diff --git a/src/core/selection-manager.ts b/src/core/selection-manager.ts new file mode 100644 index 00000000..5489b4cb --- /dev/null +++ b/src/core/selection-manager.ts @@ -0,0 +1,179 @@ +/** + * SelectionManager - Manages clip selection and clipboard state. + * Handles selection state, clipboard operations, and related events. + */ + +import type { Player } from "@canvas/players/player"; +import { EditEvent } from "@core/events/edit-events"; +import type { ResolvedClip } from "@core/schemas"; +import { stripInternalProperties } from "@core/shared/clip-utils"; +import type { Seconds } from "@core/timing/types"; + +import type { Edit } from "./edit-session"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface CopiedClip { + trackIndex: number; + clipConfiguration: ResolvedClip; +} + +export interface SelectedClipInfo { + trackIndex: number; + clipIndex: number; + player: Player; +} + +// ─── SelectionManager ───────────────────────────────────────────────────────── + +export class SelectionManager { + private selectedClip: Player | null = null; + private copiedClip: CopiedClip | null = null; + + constructor(private readonly edit: Edit) {} + + // ─── Selection ──────────────────────────────────────────────────────────── + + /** + * Select a clip by track and clip index. + */ + selectClip(trackIndex: number, clipIndex: number): void { + const player = this.edit.getPlayerClip(trackIndex, clipIndex); + if (player) { + this.selectedClip = player; + const clip = this.edit.getDocumentClip(trackIndex, clipIndex); + if (clip) { + this.edit.getInternalEvents().emit(EditEvent.ClipSelected, { + clip: stripInternalProperties(clip), + trackIndex, + clipIndex + }); + } + } + } + + /** + * Select a player directly. + */ + selectPlayer(player: Player): void { + const indices = this.findClipIndices(player); + if (indices) { + this.selectClip(indices.trackIndex, indices.clipIndex); + } + } + + /** + * Clear the current selection. + */ + clearSelection(): void { + this.selectedClip = null; + this.edit.getInternalEvents().emit(EditEvent.SelectionCleared); + } + + /** + * Check if a specific clip is selected by indices. + */ + isClipSelected(trackIndex: number, clipIndex: number): boolean { + if (!this.selectedClip) return false; + + const selectedTrackIndex = this.selectedClip.layer - 1; + const tracks = this.edit.getTracks(); + const track = tracks[selectedTrackIndex]; + if (!track) return false; + + const selectedClipIndex = track.indexOf(this.selectedClip); + return trackIndex === selectedTrackIndex && clipIndex === selectedClipIndex; + } + + /** + * Check if a specific player is selected. + */ + isPlayerSelected(player: Player): boolean { + if (this.edit.isInExportMode()) return false; + return this.selectedClip === player; + } + + /** + * Get information about the selected clip. + */ + getSelectedClipInfo(): SelectedClipInfo | null { + if (!this.selectedClip) return null; + + const trackIndex = this.selectedClip.layer - 1; + const tracks = this.edit.getTracks(); + const track = tracks[trackIndex]; + if (!track) return null; // Track was deleted + + const clipIndex = track.indexOf(this.selectedClip); + return { trackIndex, clipIndex, player: this.selectedClip }; + } + + /** + * Get the selected player (for internal use). + */ + getSelectedClip(): Player | null { + return this.selectedClip; + } + + /** + * Set the selected player directly (for internal use by commands). + */ + setSelectedClip(clip: Player | null): void { + this.selectedClip = clip; + } + + // ─── Clipboard ──────────────────────────────────────────────────────────── + + /** + * Copy a clip to the internal clipboard. + */ + copyClip(trackIdx: number, clipIdx: number): void { + const clip = this.edit.getResolvedClip(trackIdx, clipIdx); + if (clip) { + this.copiedClip = { + trackIndex: trackIdx, + clipConfiguration: structuredClone(clip) + }; + this.edit.getInternalEvents().emit(EditEvent.ClipCopied, { trackIndex: trackIdx, clipIndex: clipIdx }); + } + } + + /** + * Paste the copied clip at the current playhead position. + */ + pasteClip(): void { + if (!this.copiedClip) return; + + const pastedClip = structuredClone(this.copiedClip.clipConfiguration); + pastedClip.start = this.edit.playbackTime as Seconds; + + // Remove ID so document generates a new one (otherwise reconciler + // would see duplicate IDs and update instead of create) + delete (pastedClip as { id?: string }).id; + + this.edit.addClip(this.copiedClip.trackIndex, pastedClip); + } + + /** + * Check if there is a clip in the clipboard. + */ + hasCopiedClip(): boolean { + return this.copiedClip !== null; + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + /** + * Find the track and clip indices for a given player. + */ + findClipIndices(player: Player): { trackIndex: number; clipIndex: number } | null { + const tracks = this.edit.getTracks(); + for (let trackIndex = 0; trackIndex < tracks.length; trackIndex += 1) { + const clipIndex = tracks[trackIndex].indexOf(player); + if (clipIndex !== -1) { + return { trackIndex, clipIndex }; + } + } + return null; + } +} diff --git a/src/core/shared/ai-asset-utils.ts b/src/core/shared/ai-asset-utils.ts new file mode 100644 index 00000000..fa8dd211 --- /dev/null +++ b/src/core/shared/ai-asset-utils.ts @@ -0,0 +1,159 @@ +import type { ResolvedClip } from "@schemas"; + +export const AI_ASSET_TYPES = new Set(["text-to-image", "image-to-video", "text-to-speech"]); + +/** + * AI asset type definition + */ +export interface AiAsset { + type: "text-to-image" | "image-to-video" | "text-to-speech"; + prompt?: string; + src?: string; +} + +/** + * Extended clip type with ID property + */ +export interface ResolvedClipWithId extends ResolvedClip { + id: string; +} + +/** + * Type guard to check if an asset is an AI asset + */ +export function isAiAsset(asset: unknown): asset is AiAsset { + return ( + typeof asset === "object" && + asset !== null && + "type" in asset && + typeof (asset as { type: unknown }).type === "string" && + AI_ASSET_TYPES.has((asset as { type: string }).type) + ); +} + +/** + * Type guard to check if a clip has an ID + */ +function hasId(clip: ResolvedClip): clip is ResolvedClipWithId { + return "id" in clip && typeof (clip as { id: unknown }).id === "string"; +} + +/** + * Cache for sorted clips to avoid redundant sorting. + */ +const sortedClipsCache = new WeakMap(); + +/** + * Get chronologically sorted clips, using cache when available. + * @param allClips - Array of clips to sort + * @returns Sorted array of clips by start time + */ +function getSortedClips(allClips: ResolvedClip[]): ResolvedClip[] { + let sortedClips = sortedClipsCache.get(allClips); + + if (!sortedClips) { + sortedClips = [...allClips].sort((a, b) => a.start - b.start); + sortedClipsCache.set(allClips, sortedClips); + } + + return sortedClips; +} + +/** + * Compute the sequential number for an AI asset based on chronological position. + */ +export function computeAiAssetNumber(allClips: ResolvedClip[], clipId: string): number | null { + const clip = allClips.find(c => hasId(c) && c.id === clipId); + if (!clip || !hasId(clip)) return null; + + if (!clip.asset || !isAiAsset(clip.asset)) return null; + + const assetType = clip.asset.type; + + // Get sorted clips + const sortedClips = getSortedClips(allClips); + + // Count clips of same type that appear before this one chronologically + let count = 0; + for (const c of sortedClips) { + if (hasId(c) && c.id === clipId) break; + if (c.asset && isAiAsset(c.asset) && c.asset.type === assetType) { + count += 1; + } + } + + return count + 1; +} + +/** + * Generate HSL hue values for aurora layers using golden angle distribution. + * Returns array of 5 hues for the multi-layer aurora effect. + */ +export function getAuroraHues(assetNumber: number): number[] { + const baseHue = (assetNumber * 137.5) % 360; + return [baseHue, (baseHue + 30) % 360, (baseHue + 60) % 360, (baseHue + 90) % 360, (baseHue + 120) % 360]; +} + +/** + * Convert HSL to hex color code for use in PIXI. + */ +export function hslToHex(h: number, s: number, l: number): number { + const hDecimal = h / 360; + const sDecimal = s / 100; + const lDecimal = l / 100; + + let r: number; + let g: number; + let b: number; + + if (sDecimal === 0) { + r = lDecimal; + g = lDecimal; + b = lDecimal; + } else { + const hue2rgb = (p: number, q: number, tParam: number): number => { + let t = tParam; + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; + }; + + const q = lDecimal < 0.5 ? lDecimal * (1 + sDecimal) : lDecimal + sDecimal - lDecimal * sDecimal; + const p = 2 * lDecimal - q; + + r = hue2rgb(p, q, hDecimal + 1 / 3); + g = hue2rgb(p, q, hDecimal); + b = hue2rgb(p, q, hDecimal - 1 / 3); + } + + // Pack RGB into PIXI color format: 0xRRGGBB + const red = Math.round(r * 255) * 65536; // Shift left 16 bits + const green = Math.round(g * 255) * 256; // Shift left 8 bits + const blue = Math.round(b * 255); + return red + green + blue; +} + +/** + * Get friendly label for AI asset type. + */ +export function getAiAssetTypeLabel(assetType: string): string { + const labels: Record = { + "text-to-image": "Image", + "image-to-video": "Video", + "text-to-speech": "Audio" + }; + return labels[assetType] || assetType; +} + +/** + * Truncate prompt text for display. + */ +export function truncatePrompt(prompt: string, maxLength = 60): string { + if (prompt.length <= maxLength) { + return prompt; + } + return `${prompt.substring(0, maxLength - 3)}...`; +} diff --git a/src/core/shared/asset-utils.ts b/src/core/shared/asset-utils.ts new file mode 100644 index 00000000..c55053e6 --- /dev/null +++ b/src/core/shared/asset-utils.ts @@ -0,0 +1,18 @@ +/** + * Infer asset type (image or video) from a URL by examining its file extension. + * + * This is a heuristic fallback used when the asset type isn't explicitly known, + * such as when transforming luma masks back to their original type. + * + * @param src - The asset source URL + * @returns "video" if the URL has a video extension, otherwise "image" + */ +export function inferAssetTypeFromUrl(src: string): "image" | "video" { + const url = src.toLowerCase().split("?")[0]; + const videoExtensions = [".mp4", ".webm", ".mov", ".m4v", ".avi", ".mkv", ".ogv", ".ogg"]; + + if (videoExtensions.some(ext => url.endsWith(ext))) { + return "video"; + } + return "image"; +} diff --git a/src/core/shared/clip-utils.ts b/src/core/shared/clip-utils.ts new file mode 100644 index 00000000..c0232510 --- /dev/null +++ b/src/core/shared/clip-utils.ts @@ -0,0 +1,12 @@ +import type { Clip } from "@schemas"; + +/** + * Remove internal properties from a clip before exposing it in events. + * The `id` property is internal to the SDK for reconciliation and should + * never be exposed to consumers or backend APIs. + */ +export function stripInternalProperties(clip: Clip): Clip { + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- intentionally stripping id + const { id, ...publicClip } = clip as Clip & { id?: string }; + return publicClip; +} diff --git a/src/core/shared/entity.ts b/src/core/shared/entity.ts index a04853c6..5cc26013 100644 --- a/src/core/shared/entity.ts +++ b/src/core/shared/entity.ts @@ -12,9 +12,6 @@ export abstract class Entity { /** @internal */ public abstract update(deltaTime: number, elapsed: number): void; - /** @internal */ - public abstract draw(): void; - /** @internal */ public abstract dispose(): void; diff --git a/src/core/shared/merge-asset.ts b/src/core/shared/merge-asset.ts new file mode 100644 index 00000000..becb4fe2 --- /dev/null +++ b/src/core/shared/merge-asset.ts @@ -0,0 +1,10 @@ +import type { Asset } from "@schemas"; + +/** + * Merges original asset (with merge field templates) with current asset (with runtime changes). + * Current asset properties override original, preserving both merge fields and runtime changes. + */ +export function mergeAssetForExport(originalAsset: Asset | undefined, currentAsset: Asset): Asset { + if (!originalAsset) return currentAsset; + return { ...originalAsset, ...currentAsset } as Asset; +} diff --git a/src/core/shared/serialize-edit.ts b/src/core/shared/serialize-edit.ts new file mode 100644 index 00000000..727b100c --- /dev/null +++ b/src/core/shared/serialize-edit.ts @@ -0,0 +1,46 @@ +import type { Clip, ResolvedClip, Edit as EditConfig, ResolvedEdit } from "@schemas"; + +import { mergeAssetForExport } from "./merge-asset"; + +export interface ClipExportData { + clipConfiguration: ResolvedClip; + getTimingIntent: () => { start: number | "auto" | string; length: number | "auto" | "end" | string }; +} + +export function serializeClipForExport(clip: ClipExportData, originalClip: Clip | undefined): Clip { + const timing = clip.getTimingIntent(); + const mergedAsset = mergeAssetForExport(originalClip?.asset, clip.clipConfiguration.asset); + + return { + ...(originalClip ?? clip.clipConfiguration), + asset: mergedAsset, + start: timing.start, + length: timing.length + } as Clip; +} + +export function serializeEditForExport( + clips: ClipExportData[][], + originalEdit: ResolvedEdit | null, + backgroundColor: string, + fonts: Array<{ src: string }>, + output: EditConfig["output"], + mergeFields: Array<{ find: string; replace: string }> +): EditConfig { + const tracks = clips.map((track, trackIdx) => ({ + clips: track.map((clip, clipIdx) => { + const originalClip = originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; + return serializeClipForExport(clip, originalClip); + }) + })); + + return { + timeline: { + background: backgroundColor, + tracks, + fonts + }, + output, + merge: mergeFields + }; +} diff --git a/src/core/shared/svg-utils.ts b/src/core/shared/svg-utils.ts new file mode 100644 index 00000000..a9a11454 --- /dev/null +++ b/src/core/shared/svg-utils.ts @@ -0,0 +1,106 @@ +/** + * SVG manipulation utilities for programmatic SVG transformations. + */ + +/** + * Detect if SVG is simple (rect-only) vs complex (paths, circles, etc.) + * + * Simple SVGs need viewBox manipulation to maintain toolbar compatibility. + * Complex SVGs should NOT be manipulated - the renderer handles scaling. + * Over time we'll add circle, ellipse, line support to the viewBox scaling logic. + * + * @param svg - The SVG markup to analyze + * @returns true if SVG only contains rect elements + */ +export function isSimpleRectSvg(svg: string): boolean { + const parser = new DOMParser(); + const doc = parser.parseFromString(svg, "image/svg+xml"); + if (doc.querySelector("parsererror")) return false; + + const svgEl = doc.querySelector("svg"); + if (!svgEl) return false; + + const complexElements = svgEl.querySelectorAll("path, polygon, polyline, circle, ellipse, line, g"); + return complexElements.length === 0; +} + +/** + * Update SVG viewBox and scale rect elements proportionally. + * + * @param svg - The SVG markup string to transform + * @param width - New width for the viewBox + * @param height - New height for the viewBox + * @returns The modified SVG markup, or original on error + */ +export function updateSvgViewBox(svg: string, width: number, height: number): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(svg, "image/svg+xml"); + + // Check for parse errors + const errorNode = doc.querySelector("parsererror"); + if (errorNode) { + console.warn("[SVG Utils] Invalid SVG markup"); + return svg; + } + + const svgEl = doc.documentElement; + const viewBox = svgEl.getAttribute("viewBox"); + if (!viewBox) { + console.warn("[SVG Utils] SVG missing viewBox"); + return svg; + } + + const [, , vbWidth, vbHeight] = viewBox.split(/\s+/).map(Number); + if (!vbWidth || !vbHeight) { + console.warn("[SVG Utils] Invalid viewBox dimensions"); + return svg; + } + + // Calculate scale factors + const scaleX = width / vbWidth; + const scaleY = height / vbHeight; + const radiusScale = Math.min(scaleX, scaleY); + + // Update viewBox + svgEl.setAttribute("viewBox", `0 0 ${width} ${height}`); + + // Scale rect elements + doc.querySelectorAll("rect").forEach(rect => { + const scale = (attr: string, factor: number) => { + const val = rect.getAttribute(attr); + if (val) rect.setAttribute(attr, String(parseFloat(val) * factor)); + }; + + scale("x", scaleX); + scale("y", scaleY); + scale("width", scaleX); + scale("height", scaleY); + scale("rx", radiusScale); + scale("ry", radiusScale); + }); + + return new XMLSerializer().serializeToString(doc); +} + +/** + * Update a specific attribute on the first SVG shape element. + * Used by toolbar controls to modify fill, corner radius, etc. + * + * @param svg - The SVG markup string to transform + * @param attr - The attribute name to update (e.g., "fill", "rx", "ry") + * @param value - The new attribute value + * @returns The modified SVG markup + */ +export function updateSvgAttribute(svg: string, attr: string, value: string): string { + const doc = new DOMParser().parseFromString(svg, "image/svg+xml"); + const shape = doc.querySelector("svg")?.querySelector("rect, circle, polygon, path, ellipse, line, polyline"); + + if (!shape) { + // Fallback: insert attribute on first shape tag + const shapePattern = /(<(?:rect|circle|polygon|path|ellipse|line|polyline)[^>]*)(>)/; + return svg.replace(shapePattern, `$1 ${attr}="${value}"$2`); + } + + shape.setAttribute(attr, value); + return new XMLSerializer().serializeToString(doc); +} diff --git a/src/core/shared/utils.ts b/src/core/shared/utils.ts index 04c5a563..2140bd81 100644 --- a/src/core/shared/utils.ts +++ b/src/core/shared/utils.ts @@ -36,3 +36,144 @@ export function deepMerge, U extends Record)[key]; + if (next == null || typeof next !== "object") (current as Record)[key] = {}; + current = (current as Record)[key]; + } + if (current !== null && current !== undefined && typeof current === "object") { + (current as Record)[parts[parts.length - 1]] = value; + } +} + +/** + * Get a nested value from an object using dot notation. + * e.g., getNestedValue(obj, "asset.src") returns obj.asset.src + * @internal + */ +export function getNestedValue(obj: unknown, path: string): unknown { + const parts = path.split("."); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + return current; +} + +/** + * Creates a throttled version of a function that limits how often it can be called. + * The function will be called at most once per interval, with the most recent arguments. + * + * Unlike debounce, throttle provides immediate feedback during continuous interaction + * (like slider drags) while still limiting update frequency. + * + * @param fn - The function to throttle + * @param intervalMs - Minimum time between calls (default: 50ms = ~20 updates/sec) + * @returns Object with throttled function and cleanup method + * @internal + */ +export function createThrottle( + fn: (...args: TArgs) => void, + intervalMs: number = 50 +): { + /** Call the throttled function */ + call: (...args: TArgs) => void; + /** Force execution of any pending call and clear timer */ + flush: () => void; + /** Clear timer without executing pending call */ + cancel: () => void; +} { + let timer: ReturnType | null = null; + let lastArgs: TArgs | null = null; + let lastCallTime = 0; + + const call = (...args: TArgs): void => { + const now = Date.now(); + const elapsed = now - lastCallTime; + + lastArgs = args; + + if (elapsed >= intervalMs) { + // Enough time has passed, call immediately + lastCallTime = now; + fn(...args); + } else if (!timer) { + // Schedule call for remaining time + timer = setTimeout(() => { + timer = null; + lastCallTime = Date.now(); + if (lastArgs) { + fn(...lastArgs); + lastArgs = null; + } + }, intervalMs - elapsed); + } + // If timer exists, lastArgs is updated and will be used when timer fires + }; + + const flush = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + if (lastArgs) { + lastCallTime = Date.now(); + fn(...lastArgs); + lastArgs = null; + } + }; + + const cancel = (): void => { + if (timer) { + clearTimeout(timer); + timer = null; + } + lastArgs = null; + }; + + return { call, flush, cancel }; +} + +export interface UrlValidationResult { + valid: boolean; + error?: string; +} + +/** + * Validate that a URL is accessible before attempting to load it as an asset. + * Uses HEAD request to check CORS and availability without downloading the full asset. + * @internal + */ +export async function validateAssetUrl(url: string): Promise { + // Basic URL format validation + try { + // eslint-disable-next-line no-new -- URL constructor validates format + new URL(url); + } catch { + return { valid: false, error: "Invalid URL format" }; + } + + // Check accessibility via HEAD request + try { + const response = await fetch(url, { method: "HEAD", mode: "cors" }); + if (!response.ok) { + return { valid: false, error: `URL returned ${response.status} ${response.statusText}` }; + } + return { valid: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "URL not accessible"; + return { valid: false, error: message }; + } +} diff --git a/src/core/shotstack-edit.ts b/src/core/shotstack-edit.ts new file mode 100644 index 00000000..e5080e8b --- /dev/null +++ b/src/core/shotstack-edit.ts @@ -0,0 +1,545 @@ +import { SetMergeFieldCommand } from "./commands/set-merge-field-command"; +import { Edit } from "./edit-session"; +import { EditEvent } from "./events/edit-events"; +import { parseFontFamily } from "./fonts/font-config"; +import type { MergeFieldService } from "./merge"; +import { ClipSchema, type Clip, type RichTextAsset, type TextAsset } from "./schemas"; +import { getNestedValue, setNestedValue } from "./shared/utils"; + +/** + * Type guard for empty TextAsset (used as shape). + */ +function isEmptyTextAsset(asset: unknown): asset is TextAsset { + if (typeof asset !== "object" || asset === null) return false; + const a = asset as { type?: string; text?: string }; + return a.type === "text" && (!a.text || a.text.trim() === ""); +} + +/** + * Escape a string for use in XML/SVG attribute values. + */ +function escapeXmlAttr(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); +} + +/** + * Build raw SVG markup from empty text asset properties. + */ +function buildSvgMarkup(textAsset: TextAsset): string { + const width = textAsset.width ?? 100; + const height = textAsset.height ?? 100; + const fillColor = escapeXmlAttr(textAsset.background?.color ?? "#000000"); + const fillOpacity = textAsset.background?.opacity ?? 1; + const borderRadius = textAsset.background?.borderRadius ?? 0; + + const rectAttrs: string[] = [`width="${width}"`, `height="${height}"`, `fill="${fillColor}"`]; + + if (fillOpacity !== 1) { + rectAttrs.push(`fill-opacity="${fillOpacity}"`); + } + + if (borderRadius > 0) { + rectAttrs.push(`rx="${borderRadius}"`, `ry="${borderRadius}"`); + } + + if (textAsset.stroke?.width && textAsset.stroke.width > 0) { + const strokeColor = escapeXmlAttr(textAsset.stroke.color ?? "#000000"); + rectAttrs.push(`stroke="${strokeColor}"`); + rectAttrs.push(`stroke-width="${textAsset.stroke.width}"`); + } + + return ``; +} + +/** + * Convert an empty TextAsset clip to an SvgAsset clip (on raw JSON). + */ +function convertEmptyTextClipToSvg(clip: Clip): Clip { + const textAsset = clip.asset as TextAsset; + const svgMarkup = buildSvgMarkup(textAsset); + + // Build new clip with SVG asset + const newClip: Clip = { + ...clip, + asset: { + type: "svg", + src: svgMarkup, + opacity: 1 + } + }; + + // Move width/height from asset to clip level + if (textAsset.width !== undefined && newClip.width === undefined) { + newClip.width = textAsset.width; + } + if (textAsset.height !== undefined && newClip.height === undefined) { + newClip.height = textAsset.height; + } + + return newClip; +} + +/** + * Extended Edit with Shotstack-specific capabilities. + * + * This class is for Shotstack products only. + * External SDK consumers should use the base `Edit` class. + */ +export class ShotstackEdit extends Edit { + // Recursion guard for merge field updates (prevents stack overflow) + private isUpdatingMergeFields = false; + + // ─── Merge Field API ─────────────────────────────────────────────────────── + + /** + * Merge field service for managing dynamic content placeholders. + * Use this to register, update, and query merge fields in templates. + */ + public get mergeFields(): MergeFieldService { + return this.mergeFieldService; + } + + /** + * Apply a merge field to a clip property. + */ + public applyMergeField(clipId: string, propertyPath: string, fieldName: string, value: string, originalValue?: string): Promise { + const resolvedClip = this.getResolvedClipById(clipId); + const currentValue = resolvedClip ? getNestedValue(resolvedClip, propertyPath) : null; + const previousValue = originalValue ?? (currentValue != null ? String(currentValue) : ""); + + // Check if there's already a merge field on this property + const templateClip = this.getTemplateClipById(clipId); + const templateValue = templateClip ? getNestedValue(templateClip, propertyPath) : null; + const previousFieldName = typeof templateValue === "string" ? this.mergeFieldService.extractFieldName(templateValue) : null; + + const command = new SetMergeFieldCommand(clipId, propertyPath, fieldName, previousFieldName, previousValue, value); + return this.executeCommand(command); + } + + /** + * Remove a merge field from a clip property, restoring the original value. + */ + public removeMergeField(clipId: string, propertyPath: string, restoreValue: string): Promise { + const currentFieldName = this.getMergeFieldForProperty(clipId, propertyPath); + if (!currentFieldName) return Promise.resolve(); + + const command = new SetMergeFieldCommand(clipId, propertyPath, null, currentFieldName, restoreValue, restoreValue); + return this.executeCommand(command); + } + + /** + * Get the merge field name for a clip property, if any. + */ + public getMergeFieldForProperty(clipId: string, propertyPath: string): string | null { + const templateClip = this.getTemplateClipById(clipId); + if (!templateClip) return null; + + const value = getNestedValue(templateClip, propertyPath); + if (typeof value === "string") return this.mergeFieldService.extractFieldName(value); + return null; + } + + /** + * Update the placeholder value of a merge field. + */ + public updateMergeFieldValueLive(fieldName: string, newValue: string): void { + // Recursion guard: prevent stack overflow from event cascades + if (this.isUpdatingMergeFields) return; + + this.isUpdatingMergeFields = true; + try { + // Update the field in the registry + const field = this.mergeFieldService.get(fieldName); + if (!field) return; + this.mergeFieldService.register({ ...field, defaultValue: newValue }, { silent: true }); + + // Update document bindings with new resolved values + for (const [, player] of this.getPlayerMap()) { + this.updateMergeFieldBindings(player, fieldName, newValue); + } + + // Document-first: resolve() triggers reconciler which updates players + this.resolve(); + + // Notify timeline so clip bars redraw (e.g., when start/length changed) + this.getInternalEvents().emit(EditEvent.TimelineUpdated, { current: this.getEdit() }); + } finally { + this.isUpdatingMergeFields = false; + } + } + + /** + * Check if a merge field is used for asset.src in any clip. + * Used by UI to determine if URL validation should be applied. + */ + public isSrcMergeField(fieldName: string): boolean { + for (const [clipId, player] of this.getPlayerMap()) { + if (player.clipId) { + const templateClip = this.getTemplateClipById(clipId); + if (templateClip) { + const assetType = (templateClip.asset as { type?: string })?.type; + const isUrlBasedAsset = assetType === "image" || assetType === "video" || assetType === "audio"; + if (isUrlBasedAsset) { + const usageInfo = this.getMergeFieldUsage(templateClip, fieldName); + if (usageInfo.used && usageInfo.isSrcField) { + return true; + } + } + } + } + } + return false; + } + + /** + * Check if a value is type-compatible with all properties a merge field is bound to. + * Temporarily swaps the field value, resolves each bound clip, and validates + * against ClipSchema — the same Zod schema used at load time. + */ + public validateMergeFieldValue(fieldName: string, value: string): string | null { + const document = this.getDocument(); + if (!document) return null; + + const field = this.mergeFieldService.get(fieldName); + if (!field) return null; + + // Temporarily swap the field value for resolution + const savedValue = field.defaultValue; + this.mergeFieldService.register({ ...field, defaultValue: value }, { silent: true }); + + try { + for (const clipId of document.getClipIdsWithBindings()) { + const bindings = document.getClipBindings(clipId); + if (bindings && this.clipUsesField(bindings, fieldName)) { + const lookup = document.getClipById(clipId); + if (lookup) { + // Build a validation clip by resolving bindings directly. + // Each binding has a placeholder (e.g. "{{ START }}") — resolve it + // using the merge field service (which has the candidate value swapped in) + // and set the raw string on the clip. ClipSchema's z.preprocess handles + // type coercion (string → number, etc.) during safeParse. + const clipForValidation = structuredClone(lookup.clip) as Record; + for (const [path, binding] of bindings) { + const resolved = this.mergeFieldService.resolve(binding.placeholder); + setNestedValue(clipForValidation, path, resolved); + } + + delete clipForValidation["id"]; + const result = ClipSchema.safeParse(clipForValidation); + if (!result.success) { + return result.error.issues[0]?.message ?? "Invalid value"; + } + } + } + } + return null; + } finally { + this.mergeFieldService.register({ ...field, defaultValue: savedValue }, { silent: true }); + } + } + + /** + * Check if a value is compatible with a specific clip property via Zod schema validation. + * Used by the merge field label manager to filter compatible fields in dropdowns. + */ + public isValueCompatibleWithClipProperty(clipId: string, propertyPath: string, value: string): boolean { + const clip = this.getResolvedClipById(clipId); + if (!clip) return true; + + const testClip = structuredClone(clip) as Record; + setNestedValue(testClip, propertyPath, value); + delete testClip["id"]; + return ClipSchema.safeParse(testClip).success; + } + + /** Check if any binding in a clip references the given field name. */ + private clipUsesField(bindings: Map, fieldName: string): boolean { + for (const [, binding] of bindings) { + if (this.mergeFieldService.extractFieldName(binding.placeholder) === fieldName) return true; + } + return false; + } + + /** + * Remove a merge field globally from all clips and the registry. + */ + public async deleteMergeFieldGlobally(fieldName: string): Promise { + const field = this.mergeFieldService.get(fieldName); + if (!field) return; + + const template = this.mergeFieldService.createTemplate(fieldName); + const restoreValue = field.defaultValue; + + // Find and restore all clips using this merge field + for (const [clipId] of this.getPlayerMap()) { + const templateClip = this.getTemplateClipById(clipId); + if (templateClip) { + await this.restoreMergeFieldInClip(clipId, templateClip, template, restoreValue); // eslint-disable-line no-await-in-loop + } + } + + // Remove from registry + // remove() emits mergefield:changed on success; this handles the case where + // the field was already removed from the registry by restoreMergeFieldInClip + const removedFromRegistry = this.mergeFieldService.remove(fieldName); + if (!removedFromRegistry) { + this.getInternalEvents().emit(EditEvent.MergeFieldChanged, { fields: this.mergeFieldService.getAll() }); + } + } + + // ─── Text Conversion API ─────────────────────────────────────────────────── + + /** + * Convert all text assets and log the resulting template JSON to console. + */ + public async convertAllTextAssets(): Promise<{ richText: number; svg: number }> { + const document = this.getDocument(); + if (!document) { + console.error("No document available for conversion"); + return { richText: 0, svg: 0 }; + } + + // Deep clone the current document state (preserves "auto"/"end" keywords) + const template = JSON.parse(JSON.stringify(document.toJSON())) as { timeline?: { tracks?: { clips?: Clip[] }[] } }; + + let richTextCount = 0; + let svgCount = 0; + + // Transform tracks + const tracks = template.timeline?.tracks; + if (tracks) { + for (const track of tracks) { + if (track.clips) { + for (let clipIdx = 0; clipIdx < track.clips.length; clipIdx += 1) { + const clip = track.clips[clipIdx]; + const asset = clip.asset as { type?: string; text?: string } | undefined; + + if (asset?.type === "text") { + if (isEmptyTextAsset(asset)) { + // Convert empty text to SVG + track.clips[clipIdx] = convertEmptyTextClipToSvg(clip); + svgCount += 1; + } else { + // Convert text with content to rich-text + track.clips[clipIdx] = this.convertTextClipToRichText(clip); + richTextCount += 1; + } + } + } + } + } + } + + // Note: We skip strict Zod validation here because templates contain + // merge field placeholders (e.g., "{{ FONT_COLOR_1 }}") that don't pass + // strict schema validation until resolved at runtime. + + // Log the converted template to console + console.log("CONVERTED TEMPLATE - Copy the JSON below:"); + console.log(JSON.stringify(template, null, "\t")); + + return { richText: richTextCount, svg: svgCount }; + } + + /** + * Map TextAsset vertical alignment to RichTextAsset vertical alignment. + * TextAsset uses "center", RichTextAsset uses "middle". + */ + private mapVerticalAlign(textAlign: string | undefined): "top" | "middle" | "bottom" { + if (textAlign === "center") return "middle"; + if (textAlign === "top" || textAlign === "bottom") return textAlign; + return "middle"; // default + } + + /** + * Convert a text clip to rich-text format (pure JSON transformation). + */ + private convertTextClipToRichText(clip: Clip): Clip { + const textAsset = clip.asset as TextAsset; + + // Extract weight from font family suffix (e.g., "Montserrat ExtraBold" → 800) + const fontFamily = textAsset.font?.family ?? "Open Sans"; + const { fontWeight } = parseFontFamily(fontFamily); + + // Build font object + const font: RichTextAsset["font"] = { + family: fontFamily, + size: typeof textAsset.font?.size === "string" ? Number(textAsset.font.size) : (textAsset.font?.size ?? 32), + weight: textAsset.font?.weight ?? fontWeight, + color: textAsset.font?.color ?? "#ffffff", + opacity: textAsset.font?.opacity ?? 1 + }; + + // Nest stroke inside font if present + if (textAsset.stroke?.width && textAsset.stroke.width > 0) { + font.stroke = { + width: textAsset.stroke.width, + color: textAsset.stroke.color ?? "#000000", + opacity: 1 + }; + } + + // Build style object + const style: RichTextAsset["style"] = { + letterSpacing: 0, + wordSpacing: 0, + lineHeight: textAsset.font?.lineHeight ?? 1.2, + textTransform: "none", + textDecoration: "none" + }; + + // Build the RichTextAsset + const richTextAsset: RichTextAsset = { + type: "rich-text", + text: textAsset.text ?? "", + font, + style, + align: { + horizontal: textAsset.alignment?.horizontal ?? "center", + vertical: this.mapVerticalAlign(textAsset.alignment?.vertical) + } + }; + + // Map background + if (textAsset.background) { + richTextAsset.background = { + color: textAsset.background.color, + opacity: textAsset.background.opacity ?? 1, + borderRadius: textAsset.background.borderRadius ?? 0 + }; + + // Extract padding from background to top-level + if (textAsset.background.padding) { + richTextAsset.padding = textAsset.background.padding; + } + } + + // Map animation + if (textAsset.animation) { + richTextAsset.animation = { + preset: textAsset.animation.preset, + duration: textAsset.animation.duration + }; + } + + // Warn if ellipsis is dropped + if (textAsset.ellipsis !== undefined) { + console.warn("TextAsset ellipsis property not supported in RichTextAsset, value dropped"); + } + + const newClip: Clip = { + ...clip, + asset: richTextAsset + }; + + // Move width/height to clip level + if (textAsset.width !== undefined && newClip.width === undefined) { + newClip.width = textAsset.width; + } + if (textAsset.height !== undefined && newClip.height === undefined) { + newClip.height = textAsset.height; + } + + return newClip; + } + + // ─── Private Helpers ─────────────────────────────────────────────────────── + + /** + * Helper: Update merge field bindings and document clip data for a player. + * Must update both bindings and clip data because the resolver reads clip data directly. + */ + private updateMergeFieldBindings(player: ReturnType, fieldName: string, _newValue: string): void { + if (!player) return; + + const { clipId } = player; + if (!clipId) return; + + const document = this.getDocument(); + if (!document) return; + + // Read bindings from document (source of truth) + const bindings = document.getClipBindings(clipId); + if (!bindings) return; + + // Get document clip by ID (stable lookup) + const clipLookup = document.getClipById(clipId); + + for (const [path, binding] of bindings) { + // Check if this binding's placeholder contains this field + const extractedField = this.mergeFieldService.extractFieldName(binding.placeholder); + if (extractedField === fieldName) { + // Recompute the resolved value from the placeholder with the new field value + const newResolvedValue = this.mergeFieldService.resolve(binding.placeholder); + const updatedBinding = { + placeholder: binding.placeholder, + resolvedValue: newResolvedValue + }; + + // Update document binding + document.setClipBinding(clipId, path, updatedBinding); + + // Also update document clip data (resolver reads clip data, not bindings). + // Use numeric-first coercion (matching the resolver strategy) because the + // document clip may still hold a string placeholder like "{{ OPACITY }}". + if (clipLookup) { + const trimmed = typeof newResolvedValue === "string" ? newResolvedValue.trim() : ""; + const num = trimmed.length > 0 ? Number(newResolvedValue) : NaN; + const typedValue = Number.isFinite(num) ? num : newResolvedValue; + setNestedValue(clipLookup.clip as Record, path, typedValue); + } + } + } + } + + /** Helper: Check if and how a clip uses a specific merge field */ + private getMergeFieldUsage(clip: unknown, fieldName: string, path: string = ""): { used: boolean; isSrcField: boolean } { + if (!clip || typeof clip !== "object") return { used: false, isSrcField: false }; + + for (const [key, value] of Object.entries(clip as Record)) { + const currentPath = path ? `${path}.${key}` : key; + + if (typeof value === "string") { + const extractedField = this.mergeFieldService.extractFieldName(value); + if (extractedField === fieldName) { + // Check if this is an asset.src property + const isSrcField = currentPath === "asset.src" || currentPath.endsWith(".src"); + return { used: true, isSrcField }; + } + } else if (typeof value === "object" && value !== null) { + const nested = this.getMergeFieldUsage(value, fieldName, currentPath); + if (nested.used) return nested; + } + } + return { used: false, isSrcField: false }; + } + + /** + * Helper: Find and restore merge field occurrences in a clip + */ + private async restoreMergeFieldInClip( + clipId: string, + templateClip: unknown, + template: string, + restoreValue: string, + path: string = "" + ): Promise { + if (!templateClip || typeof templateClip !== "object") return; + + for (const key of Object.keys(templateClip as Record)) { + const value = (templateClip as Record)[key]; + const propertyPath = path ? `${path}.${key}` : key; + + if (typeof value === "string") { + const extractedField = this.mergeFieldService.extractFieldName(value); + const templateFieldName = this.mergeFieldService.extractFieldName(template); + if (extractedField && templateFieldName && extractedField === templateFieldName) { + const substitutedValue = value.replace(new RegExp(`\\{\\{\\s*${extractedField}\\s*\\}\\}`, "gi"), restoreValue); + await this.removeMergeField(clipId, propertyPath, substitutedValue); // eslint-disable-line no-await-in-loop + } + } else if (typeof value === "object" && value !== null) { + await this.restoreMergeFieldInClip(clipId, value, template, restoreValue, propertyPath); // eslint-disable-line no-await-in-loop + } + } + } +} diff --git a/src/core/theme/theme-utils.ts b/src/core/theme/theme-utils.ts index 71737e4a..f5521f4b 100644 --- a/src/core/theme/theme-utils.ts +++ b/src/core/theme/theme-utils.ts @@ -25,17 +25,20 @@ export function hexToPixiColor(hex: string): number { * Convert a TimelineThemeInput (with hex strings) to TimelineTheme (with PIXI numbers) */ export function convertThemeColors(themeInput: TimelineThemeInput): TimelineTheme { - const convertColors = (obj: any): any => { + const convertColors = (obj: unknown): unknown => { if (typeof obj === "string") { return hexToPixiColor(obj); } if (typeof obj === "object" && obj !== null) { - const converted: any = Array.isArray(obj) ? [] : {}; + if (Array.isArray(obj)) { + return obj.map(item => convertColors(item)); + } + const converted: Record = {}; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { - converted[key] = convertColors(obj[key]); + converted[key] = convertColors((obj as Record)[key]); } } @@ -53,19 +56,22 @@ export function convertThemeColors(themeInput: TimelineThemeInput): TimelineThem */ export function convertThemeColorsGeneric(theme: T): T { if (typeof theme === "string") { - return hexToPixiColor(theme) as any; + return hexToPixiColor(theme) as T; } if (typeof theme === "object" && theme !== null) { - const converted: any = Array.isArray(theme) ? [] : {}; + if (Array.isArray(theme)) { + return theme.map(item => convertThemeColorsGeneric(item)) as T; + } + const converted: Record = {}; for (const key in theme) { if (Object.prototype.hasOwnProperty.call(theme, key)) { - converted[key] = convertThemeColorsGeneric(theme[key]); + converted[key] = convertThemeColorsGeneric((theme as Record)[key]); } } - return converted; + return converted as T; } return theme; diff --git a/src/core/timing-manager.ts b/src/core/timing-manager.ts new file mode 100644 index 00000000..6223117a --- /dev/null +++ b/src/core/timing-manager.ts @@ -0,0 +1,208 @@ +/** + * TimingManager - Handles async timing resolution and propagation. + */ + +import type { Player } from "@canvas/players/player"; +import { EditEvent } from "@core/events/edit-events"; +import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; +import { type Seconds, isAliasReference, sec } from "@core/timing/types"; + +import type { Edit } from "./edit-session"; + +// ─── TimingManager ──────────────────────────────────────────────────────────── + +export class TimingManager { + private cachedTimelineEnd: Seconds = sec(0); + + constructor(private readonly edit: Edit) {} + + // ─── Timeline End Cache ────────────────────────────────────────────────── + + /** + * Get the cached timeline end (excluding "end" clips). + */ + getTimelineEnd(): Seconds { + return this.cachedTimelineEnd; + } + + /** + * Invalidate the timeline end cache. + * Call when clips are added/removed or timing changes. + * @internal + */ + invalidateTimelineEndCache(): void { + this.cachedTimelineEnd = sec(0); + } + + // ─── Full Resolution ───────────────────────────────────────────────────── + + /** + * Resolve timing for all clips, syncing with the resolver's output. + */ + async resolveAllTiming(): Promise { + const tracks = this.edit.getTracks(); + const resolved = this.edit.getResolvedEdit(); + + // Apply resolved timing from the resolver to Players + for (let trackIdx = 0; trackIdx < tracks.length; trackIdx += 1) { + const track = tracks[trackIdx]; + const resolvedTrack = resolved.timeline.tracks[trackIdx]; + + for (let clipIdx = 0; clipIdx < track.length; clipIdx += 1) { + const player = track[clipIdx]; + const resolvedClip = resolvedTrack?.clips[clipIdx]; + + if (resolvedClip) { + const intent = player.getTimingIntent(); + + // Use resolved values from the resolver + const resolvedStart = resolvedClip.start; + let resolvedLength = resolvedClip.length; + + // Special handling for "auto" length - requires async asset loading + if (intent.length === "auto") { + resolvedLength = await resolveAutoLength(player.clipConfiguration.asset); + } + + player.setResolvedTiming({ start: resolvedStart, length: resolvedLength }); + } + } + } + + // Calculate timeline end and cache it + const timelineEnd = calculateTimelineEnd(tracks); + this.cachedTimelineEnd = timelineEnd; + + // Resolve "end" clips now that we have the final timeline end + // (accounts for any "auto" length changes from async resolution above) + const endLengthClips = this.getEndLengthClips(); + for (const clip of endLengthClips) { + const currentTiming = clip.getResolvedTiming(); + clip.setResolvedTiming({ + start: currentTiming.start, + length: resolveEndLength(currentTiming.start, timelineEnd) + }); + } + + // Reconfigure "end" clips to rebuild keyframes + for (const clip of endLengthClips) { + clip.reconfigureAfterRestore(); + } + } + + // ─── Propagation ───────────────────────────────────────────────────────── + + /** + * Propagate timing changes through the track. + * Updates "auto" start clips and "end" length clips. + */ + propagateTimingChanges(trackIndex: number, startFromClipIndex: number): void { + const tracks = this.edit.getTracks(); + const track = tracks[trackIndex]; + if (!track) return; + + // Include the clip itself (not just subsequent clips) so auto start on first clip resolves to 0 + for (let i = Math.max(0, startFromClipIndex); i < track.length; i += 1) { + const clip = track[i]; + if (clip.getTimingIntent().start === "auto") { + const newStart = resolveAutoStart(trackIndex, i, tracks); + clip.setResolvedTiming({ + start: newStart, + length: clip.getLength() + }); + clip.reconfigureAfterRestore(); + } + } + + // Propagate alias changes (clips referencing other clips via alias) + this.propagateAliasChanges(); + + const newTimelineEnd = calculateTimelineEnd(tracks); + if (newTimelineEnd !== this.cachedTimelineEnd) { + this.cachedTimelineEnd = newTimelineEnd; + + const endLengthClips = this.getEndLengthClips(); + for (const clip of endLengthClips) { + const newLength = resolveEndLength(clip.getStart(), newTimelineEnd); + const currentLength = clip.getLength(); + + if (Math.abs(newLength - currentLength) > 0.001) { + clip.setResolvedTiming({ + start: clip.getStart(), + length: newLength + }); + clip.reconfigureAfterRestore(); + } + } + } + + this.edit.updateTotalDuration(); + + // Notify SDK consumers of timeline changes + this.edit.getInternalEvents().emit(EditEvent.TimelineUpdated, { + current: this.edit.getEdit() + }); + } + + /** + * Propagate alias changes when a clip's timing changes. + * + * Delegates to the resolver's output for alias values, then updates Players + * that have alias references. + */ + private propagateAliasChanges(): void { + const tracks = this.edit.getTracks(); + const resolved = this.edit.getResolvedEdit(); + + // Update Players with alias references using resolved values + for (let trackIdx = 0; trackIdx < tracks.length; trackIdx += 1) { + const track = tracks[trackIdx]; + const resolvedTrack = resolved.timeline.tracks[trackIdx]; + + for (let clipIdx = 0; clipIdx < track.length; clipIdx += 1) { + const player = track[clipIdx]; + const resolvedClip = resolvedTrack?.clips[clipIdx]; + const intent = player.getTimingIntent(); + + // Only update if this clip has alias references + const hasAliasStart = isAliasReference(intent.start); + const hasAliasLength = isAliasReference(intent.length); + + if ((hasAliasStart || hasAliasLength) && resolvedClip) { + const currentStart = player.getStart(); + const currentLength = player.getLength(); + + // Check if values differ from resolver's output + const startDiffers = Math.abs(resolvedClip.start - currentStart) > 0.001; + const lengthDiffers = Math.abs(resolvedClip.length - currentLength) > 0.001; + + if (startDiffers || lengthDiffers) { + player.setResolvedTiming({ + start: resolvedClip.start, + length: resolvedClip.length + }); + player.reconfigureAfterRestore(); + } + } + } + } + } + + // ─── Query Helpers ─────────────────────────────────────────────────────── + + /** + * Get all clips with length: "end" intent. + */ + private getEndLengthClips(): Player[] { + const tracks = this.edit.getTracks(); + const clips: Player[] = []; + for (const track of tracks) { + for (const clip of track) { + if (clip.getTimingIntent().length === "end") { + clips.push(clip); + } + } + } + return clips; + } +} diff --git a/src/core/timing/index.ts b/src/core/timing/index.ts index 3a9fad2a..0520e661 100644 --- a/src/core/timing/index.ts +++ b/src/core/timing/index.ts @@ -1,2 +1,3 @@ export * from "./types"; export * from "./resolver"; +export * from "../timing-manager"; diff --git a/src/core/timing/resolver.ts b/src/core/timing/resolver.ts index 17b03e05..1dd9067c 100644 --- a/src/core/timing/resolver.ts +++ b/src/core/timing/resolver.ts @@ -4,11 +4,44 @@ */ import type { Player } from "@canvas/players/player"; -import type { Asset } from "@schemas/asset"; +import type { Asset } from "@schemas"; -import type { ResolvedTiming, TimingIntent } from "./types"; +import { type ResolutionContext, type ResolvedTiming, type Seconds, type TimingIntent, isAliasReference, sec } from "./types"; -const DEFAULT_AUTO_LENGTH_MS = 3000; +const DEFAULT_AUTO_LENGTH_FALLBACK = sec(1); + +const DEFAULT_AUTO_LENGTH_SEC = sec(3); + +export function resolveTimingIntent(intent: TimingIntent, context: Readonly): ResolvedTiming { + // Alias references must be resolved before calling this function + if (isAliasReference(intent.start)) { + throw new Error(`Cannot resolve alias reference "${intent.start}" - aliases must be resolved by TimingManager first`); + } + if (isAliasReference(intent.length)) { + throw new Error(`Cannot resolve alias reference "${intent.length}" - aliases must be resolved by TimingManager first`); + } + + // Resolve start + const start: Seconds = intent.start === "auto" ? context.previousClipEnd : intent.start; + + // Resolve length + let length: Seconds; + if (intent.length === "end") { + // Extend to timeline end (minimum 0.1s to prevent zero-length clips) + length = sec(Math.max(context.timelineEnd - start, 0.1)); + } else if (intent.length === "auto") { + // Use intrinsic duration if available, fallback otherwise + length = context.intrinsicDuration ?? DEFAULT_AUTO_LENGTH_FALLBACK; + } else { + // Fixed value - use as-is + length = intent.length; + } + + return { start, length }; +} + +// ─── Legacy Resolution Functions ────────────────────────────────────────────── +// These still access tracks directly. Prefer resolveTimingIntent with explicit context. export function probeMediaDuration(src: string): Promise { return new Promise(resolve => { @@ -21,73 +54,44 @@ export function probeMediaDuration(src: string): Promise { }); } -export async function resolveAutoLength(asset: Asset): Promise { +export async function resolveAutoLength(asset: Asset): Promise { const assetWithSrc = asset as { type: string; src?: string; trim?: number }; if (["video", "audio", "luma"].includes(assetWithSrc.type) && assetWithSrc.src) { const duration = await probeMediaDuration(assetWithSrc.src); if (duration !== null && !Number.isNaN(duration)) { const trim = assetWithSrc.trim ?? 0; - return (duration - trim) * 1000; + return sec(duration - trim); } } - return DEFAULT_AUTO_LENGTH_MS; + return DEFAULT_AUTO_LENGTH_SEC; } -export function resolveAutoStart(trackIndex: number, clipIndex: number, tracks: Player[][]): number { +export function resolveAutoStart(trackIndex: number, clipIndex: number, tracks: Player[][]): Seconds { if (clipIndex === 0) { - return 0; + return sec(0); } const previousClip = tracks[trackIndex][clipIndex - 1]; return previousClip.getEnd(); } -export function resolveEndLength(clipStart: number, timelineEnd: number): number { - return Math.max(0, timelineEnd - clipStart); +export function resolveEndLength(clipStart: Seconds, timelineEnd: Seconds): Seconds { + return sec(Math.max(0, timelineEnd - clipStart)); } -export function calculateTimelineEnd(tracks: Player[][]): number { - let max = 0; +export function calculateTimelineEnd(tracks: Player[][]): Seconds { + let max = sec(0); for (const track of tracks) { for (const clip of track) { // Exclude "end" clips to avoid circular dependency if (clip.getTimingIntent().length !== "end") { - max = Math.max(max, clip.getEnd()); + max = sec(Math.max(max, clip.getEnd())); } } } return max; } - -export async function resolveClipTiming( - intent: TimingIntent, - asset: Asset, - trackIndex: number, - clipIndex: number, - tracks: Player[][] -): Promise { - // Resolve start - let resolvedStart: number; - if (intent.start === "auto") { - resolvedStart = resolveAutoStart(trackIndex, clipIndex, tracks); - } else { - resolvedStart = intent.start * 1000; - } - - // Resolve length (except "end" which needs a separate pass) - let resolvedLength: number; - if (intent.length === "auto") { - resolvedLength = await resolveAutoLength(asset); - } else if (intent.length === "end") { - // Placeholder - will be resolved in second pass - resolvedLength = 0; - } else { - resolvedLength = intent.length * 1000; - } - - return { start: resolvedStart, length: resolvedLength }; -} diff --git a/src/core/timing/types.ts b/src/core/timing/types.ts index 3e5f4c8c..28d110c7 100644 --- a/src/core/timing/types.ts +++ b/src/core/timing/types.ts @@ -1,25 +1,115 @@ +// ─── Branded Types ─────────────────────────────────────────────────────────── +// These provide compile-time safety to prevent mixing seconds and milliseconds. +// The brands are erased at runtime - zero overhead. + +declare const SecondsSymbol: unique symbol; +declare const MillisecondsSymbol: unique symbol; + +/** + * A number representing time in seconds. + * Used at API/configuration boundaries where users specify timing. + */ +export type Seconds = number & { readonly [SecondsSymbol]: typeof SecondsSymbol }; + +/** + * A number representing time in milliseconds. + * Used internally for rendering and playback calculations. + */ +export type Milliseconds = number & { readonly [MillisecondsSymbol]: typeof MillisecondsSymbol }; + +// ─── Conversion Functions ──────────────────────────────────────────────────── + +/** + * Convert seconds to milliseconds. + * @example toMs(sec(2.5)) // 2500 + */ +export function toMs(seconds: Seconds): Milliseconds { + return (seconds * 1000) as Milliseconds; +} + +/** + * Convert milliseconds to seconds. + * @example toSec(ms(2500)) // 2.5 + */ +export function toSec(milliseconds: Milliseconds): Seconds { + return (milliseconds / 1000) as Seconds; +} + +/** + * Create a typed Seconds value from an untyped number. + * Use at API boundaries where we receive user input. + * @example sec(2.5) + */ +export function sec(value: number): Seconds { + return value as Seconds; +} + +/** + * Create a typed Milliseconds value from an untyped number. + * Use at internal boundaries where we have raw ms values. + * @example ms(2500) + */ +export function ms(value: number): Milliseconds { + return value as Milliseconds; +} + +// ─── Timing Types ──────────────────────────────────────────────────────────── + +/** + * Alias reference format: `alias://name` (alphanumeric, underscore, hyphen). + * Used in `start`/`length` for timing. + */ +export type AliasReference = `alias://${string}`; + +/** Check if a value is an alias reference. Pattern shared with captions/alias-resolver.ts. */ +export function isAliasReference(value: unknown): value is AliasReference { + return typeof value === "string" && /^alias:\/\/[a-zA-Z0-9_-]+$/.test(value); +} + +/** Extract alias name from reference. */ +export function parseAliasName(value: AliasReference): string { + return value.replace(/^alias:\/\//, ""); +} + /** * A timing value can be a numeric value (in seconds) or a special string. * - "auto" for start: position after previous clip on track * - "auto" for length: asset's intrinsic duration * - "end" for length: extend to timeline end + * - "alias://x" for start/length: reference another clip's timing */ -export type TimingValue = number | "auto" | "end"; +export type TimingValue = Seconds | "auto" | "end" | AliasReference; /** * Stores the original timing intent as specified by the user. * This is preserved even after resolution to numeric values. + * All numeric values are in seconds. */ export interface TimingIntent { - start: number | "auto"; + start: Seconds | "auto" | AliasReference; length: TimingValue; } /** - * Resolved timing values in milliseconds. - * Used for rendering and playback. + * Resolved timing values in seconds. + * Matches the external @shotstack/schemas unit convention. + * Conversion to milliseconds happens only in the Player layer for pixi rendering. */ export interface ResolvedTiming { - start: number; - length: number; + start: Seconds; + length: Seconds; +} + +/** + * Context required to resolve timing intent to concrete values. + * + * @see resolveTimingIntent - the pure function that uses this context + */ +export interface ResolutionContext { + /** End time of previous clip on same track (for start: "auto") */ + readonly previousClipEnd: Seconds; + /** Total duration of timeline excluding "end" clips (for length: "end") */ + readonly timelineEnd: Seconds; + /** Intrinsic duration from asset metadata, null if not yet loaded (for length: "auto") */ + readonly intrinsicDuration: Seconds | null; } diff --git a/src/core/ui/asset-toolbar.ts b/src/core/ui/asset-toolbar.ts new file mode 100644 index 00000000..27f5c5e8 --- /dev/null +++ b/src/core/ui/asset-toolbar.ts @@ -0,0 +1,101 @@ +import { injectShotstackStyles } from "@styles/inject"; + +import { makeToolbarDraggable, type ToolbarDragHandle, type ToolbarDragState } from "./toolbar-drag"; +import type { UIController } from "./ui-controller"; + +export class AssetToolbar { + private container: HTMLDivElement | null = null; + private buttonsContainer: HTMLDivElement | null = null; + private ui: UIController; + private unsubscribe: (() => void) | null = null; + private dragResult: ToolbarDragHandle | null = null; + + constructor(ui: UIController) { + this.ui = ui; + injectShotstackStyles(); + } + + /** @internal */ + getDragState(): ToolbarDragState | null { + return this.dragResult?.getState() ?? null; + } + + /** @internal */ + setPosition(screenX: number, screenY: number): void { + if (this.container) { + this.container.style.left = `${screenX}px`; + this.container.style.top = `${screenY}px`; + } + } + + mount(parent: HTMLElement, options?: { onDragReset?: () => void }): void { + this.container?.remove(); + + this.container = document.createElement("div"); + this.container.className = "ss-asset-toolbar"; + + // Create a dedicated wrapper for buttons so render() doesn't destroy the drag handle + this.buttonsContainer = document.createElement("div"); + this.buttonsContainer.className = "ss-asset-toolbar-buttons"; + this.container.appendChild(this.buttonsContainer); + + this.render(); + + parent.appendChild(this.container); + + // Wire up drag (handle prepended automatically) + if (options?.onDragReset) { + this.dragResult = makeToolbarDraggable({ + container: this.container, + onReset: options.onDragReset + }); + } + + // Listen to UIController for button changes + this.unsubscribe = this.ui.onButtonsChanged(() => this.render()); + } + + private render(): void { + if (!this.container || !this.buttonsContainer) return; + + const buttons = this.ui.getButtons(); + + // Hide toolbar if no buttons registered + this.container.style.display = buttons.length === 0 ? "none" : "flex"; + + this.buttonsContainer.innerHTML = buttons + .map( + btn => ` + ${btn.dividerBefore ? '
' : ""} + + ` + ) + .join(""); + + this.setupEventListeners(); + } + + private setupEventListeners(): void { + this.buttonsContainer?.querySelectorAll("[data-button-id]").forEach(btn => { + btn.addEventListener("click", () => { + const id = (btn as HTMLElement).dataset["buttonId"]; + if (id) { + // Emit button click through UIController + this.ui.emitButtonClick(id); + } + }); + }); + } + + dispose(): void { + this.dragResult?.dispose(); + this.dragResult = null; + this.unsubscribe?.(); + this.unsubscribe = null; + this.container?.remove(); + this.container = null; + this.buttonsContainer = null; + } +} diff --git a/src/core/ui/background-color-picker.ts b/src/core/ui/background-color-picker.ts new file mode 100644 index 00000000..2a818383 --- /dev/null +++ b/src/core/ui/background-color-picker.ts @@ -0,0 +1,256 @@ +import { injectShotstackStyles } from "@styles/inject"; + +type ColorChangeCallback = (controlId: string, enabled: boolean, color: string, opacity: number) => void; +type DragCallback = (controlId: string) => void; + +export class BackgroundColorPicker { + private container: HTMLDivElement | null = null; + private enableCheckbox: HTMLInputElement | null = null; + private colorInput: HTMLInputElement | null = null; + private opacitySlider: HTMLInputElement | null = null; + private opacityValue: HTMLSpanElement | null = null; + + private enabled: boolean = false; // Default to disabled (no background) + private currentColor: string = "#FFFFFF"; + private currentOpacity: number = 1; + private onColorChange: ColorChangeCallback | null = null; + + // Two-phase drag pattern state + private dragActive: boolean = false; + private dragStartCallback: DragCallback | null = null; + private dragEndCallback: DragCallback | null = null; + private currentControlId: string | null = null; // Track which control is active + private abortController = new AbortController(); // For cleanup of dynamic event listeners + + // Arrow function handlers for proper cleanup + private handleEnableChange = (): void => { + this.enabled = this.enableCheckbox?.checked ?? false; + this.updateControlsState(); + this.emitColorChange("background-checkbox"); + }; + + private handleEnableInteractionStart = (): void => { + const wasInactive = this.currentControlId === null; + this.dragActive = true; + this.currentControlId = "background-checkbox"; + if (wasInactive) { + this.dragStartCallback?.("background-checkbox"); + } + }; + + private handleEnableInteractionEnd = (): void => { + // Only end the drag if THIS control (checkbox) is the active one + if (this.dragActive && this.currentControlId === "background-checkbox") { + this.dragActive = false; + this.currentControlId = null; + this.dragEndCallback?.("background-checkbox"); + } + }; + + private handleColorInput = (): void => { + this.currentColor = this.colorInput?.value ?? "#FFFFFF"; + this.emitColorChange("background-color"); + }; + + private handleOpacityInput = (e: Event): void => { + const opacity = parseInt((e.target as HTMLInputElement).value, 10); + this.currentOpacity = opacity / 100; + if (this.opacityValue) { + this.opacityValue.textContent = `${opacity}%`; + } + this.emitColorChange("background-opacity"); + }; + + /** + * Wire up two-phase drag pattern for any input control. + * Handles pointerdown (drag start), input (live updates), and blur (drag end). + */ + private setupDragPattern(element: HTMLInputElement, controlId: string, onInput: (e: Event) => void): void { + const { signal } = this.abortController; + + // Start: pointerdown (for range) or click/input (for color picker) + element.addEventListener( + "pointerdown", + () => { + const wasInactive = this.currentControlId === null; + this.dragActive = true; + this.currentControlId = controlId; + if (wasInactive) { + this.dragStartCallback?.(controlId); + } + }, + { signal } + ); + + // During: input events (live updates) + element.addEventListener("input", onInput, { signal }); + + // End: blur event (drag complete - fires when picker closes regardless of value change) + element.addEventListener( + "blur", + () => { + // Only end the drag if THIS specific control is the active one + if (this.dragActive && this.currentControlId === controlId) { + this.dragActive = false; + this.currentControlId = null; + this.dragEndCallback?.(controlId); + } + }, + { signal } + ); + } + + constructor() { + injectShotstackStyles(); + } + + mount(parent: HTMLElement): void { + this.container = document.createElement("div"); + this.container.className = "ss-color-picker"; + + this.container.innerHTML = ` +
Background Fill
+
+ +
+
+
+
Color
+
+ +
+
+
+
Opacity
+
+ + 100% +
+
+
+ `; + + parent.appendChild(this.container); + + this.enableCheckbox = this.container.querySelector(".ss-color-picker-enable-checkbox"); + this.colorInput = this.container.querySelector(".ss-color-picker-color"); + this.opacitySlider = this.container.querySelector(".ss-color-picker-opacity"); + this.opacityValue = this.container.querySelector(".ss-color-picker-opacity-value"); + + // Enable checkbox also uses drag pattern for proper command history + if (this.enableCheckbox) { + this.enableCheckbox.addEventListener("pointerdown", this.handleEnableInteractionStart); + this.enableCheckbox.addEventListener("change", this.handleEnableChange); + this.enableCheckbox.addEventListener("blur", this.handleEnableInteractionEnd); + } + + if (this.colorInput) { + this.setupDragPattern(this.colorInput, "background-color", this.handleColorInput); + } + + if (this.opacitySlider) { + this.setupDragPattern(this.opacitySlider, "background-opacity", this.handleOpacityInput); + } + } + + private emitColorChange(controlId: string): void { + if (this.onColorChange && this.colorInput && this.opacitySlider) { + const color = this.colorInput.value; + const opacity = parseInt(this.opacitySlider.value, 10) / 100; + this.onColorChange(controlId, this.enabled, color, opacity); + } + } + + private updateControlsState(): void { + const controls = this.container?.querySelector(".ss-color-picker-controls"); + if (controls) { + controls.classList.toggle("disabled", !this.enabled); + } + + if (this.colorInput) this.colorInput.disabled = !this.enabled; + if (this.opacitySlider) this.opacitySlider.disabled = !this.enabled; + } + + // Public API + setEnabled(enabled: boolean): void { + this.enabled = enabled; + if (this.enableCheckbox) { + this.enableCheckbox.checked = enabled; + } + this.updateControlsState(); + } + + isEnabled(): boolean { + return this.enabled; + } + + setColor(hex: string): void { + this.currentColor = hex.toUpperCase(); + if (this.colorInput) { + this.colorInput.value = this.currentColor; + } + } + + setOpacity(opacity: number): void { + const opacityPercent = Math.round(Math.max(0, Math.min(100, opacity))); + this.currentOpacity = opacityPercent / 100; + if (this.opacitySlider) { + this.opacitySlider.value = String(opacityPercent); + } + if (this.opacityValue) { + this.opacityValue.textContent = `${opacityPercent}%`; + } + } + + getColor(): string { + return this.currentColor; + } + + getOpacity(): number { + return this.currentOpacity; + } + + onChange(callback: ColorChangeCallback): void { + this.onColorChange = callback; + } + + onDragStart(callback: DragCallback): void { + this.dragStartCallback = callback; + } + + onDragEnd(callback: DragCallback): void { + this.dragEndCallback = callback; + } + + isDragging(): boolean { + return this.dragActive; + } + + dispose(): void { + // Abort all listeners registered via setupDragPattern + this.abortController.abort(); + + // Remove explicitly tracked handlers for checkbox + if (this.enableCheckbox) { + this.enableCheckbox.removeEventListener("pointerdown", this.handleEnableInteractionStart); + this.enableCheckbox.removeEventListener("change", this.handleEnableChange); + this.enableCheckbox.removeEventListener("blur", this.handleEnableInteractionEnd); + } + + // Remove container from DOM + this.container?.remove(); + + // Clear references + this.container = null; + this.enableCheckbox = null; + this.colorInput = null; + this.opacitySlider = null; + this.opacityValue = null; + this.onColorChange = null; + this.dragStartCallback = null; + this.dragEndCallback = null; + } +} diff --git a/src/core/ui/base-toolbar.ts b/src/core/ui/base-toolbar.ts new file mode 100644 index 00000000..ff62feb7 --- /dev/null +++ b/src/core/ui/base-toolbar.ts @@ -0,0 +1,221 @@ +import type { Edit } from "@core/edit-session"; + +import { makeToolbarDraggable, type ToolbarDragHandle } from "./toolbar-drag"; + +/** Default top offset for CSS-centered top toolbars */ +const DEFAULT_TOP_OFFSET = "12px"; + +/** Preset font sizes used by text toolbars */ +export const FONT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 96, 128]; + +/** Built-in font families */ +export const BUILT_IN_FONTS = [ + "Arapey", + "Clear Sans", + "Didact Gothic", + "Montserrat", + "MovLette", + "Open Sans", + "Permanent Marker", + "Roboto", + "Sue Ellen Francisco", + "Work Sans" +]; + +/** Shared SVG icon paths for toolbars */ +export const TOOLBAR_ICONS = { + alignLeft: ``, + alignCenter: ``, + alignRight: ``, + anchorTop: ``, + anchorMiddle: ``, + anchorBottom: ``, + sizeUp: ``, + sizeDown: ``, + spacing: ``, + fontColor: ``, + background: ``, + stroke: ``, + edit: ``, + chevron: ``, + transition: ``, + effect: `` +}; + +/** + * Abstract base class for toolbars providing shared lifecycle, popup management, + * and UI helper methods. + */ +export abstract class BaseToolbar { + protected container: HTMLDivElement | null = null; + protected edit: Edit; + protected selectedTrackIdx = -1; + protected selectedClipIdx = -1; + protected clickOutsideHandler: ((e: MouseEvent) => void) | null = null; + protected dragResult: ToolbarDragHandle | null = null; + + constructor(edit: Edit) { + this.edit = edit; + } + + /** + * Add a drag handle to the toolbar container and enable free-form dragging. + */ + protected enableDrag(): void { + if (!this.container) return; + + this.dragResult = makeToolbarDraggable({ + container: this.container, + handleClassName: "ss-toolbar-drag-handle ss-toolbar-drag-handle--dark", + handleOrientation: "vertical", + onReset: () => { + if (!this.container) return; + // Restore CSS centering + this.container.style.left = "50%"; + this.container.style.top = DEFAULT_TOP_OFFSET; + this.container.style.transform = "translateX(-50%)"; + } + }); + } + + /** + * Mount the toolbar to a parent element. + * Subclasses must implement to build their specific HTML structure. + */ + abstract mount(parent: HTMLElement): void; + + /** Resolve the stable clipId for the currently selected clip. */ + getSelectedClipId(): string | null { + return this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + } + + /** + * Show the toolbar for a specific clip. + * Subclasses can override to add custom behavior. + */ + show(trackIndex: number, clipIndex: number): void { + this.selectedTrackIdx = trackIndex; + this.selectedClipIdx = clipIndex; + this.syncState(); + if (this.container) { + this.container.classList.add("visible"); + this.container.style.display = ""; // Clear inline style, let CSS control + } + } + + /** + * Hide the toolbar. + */ + hide(): void { + if (this.container) { + this.container.classList.remove("visible"); + this.container.style.display = ""; // Clear inline style, let CSS control + } + this.closeAllPopups(); + this.selectedTrackIdx = -1; + this.selectedClipIdx = -1; + } + + /** + * Dispose the toolbar and clean up resources. + * Subclasses should call super.dispose() and then null their own references. + */ + dispose(): void { + this.dragResult?.dispose(); + this.dragResult = null; + + if (this.clickOutsideHandler) { + document.removeEventListener("click", this.clickOutsideHandler); + this.clickOutsideHandler = null; + } + + this.container?.remove(); + this.container = null; + } + + /** + * Check if a popup is currently visible (handles both CSS class and inline style patterns). + */ + protected isPopupOpen(popup: HTMLElement | null): boolean { + if (!popup) return false; + return popup.classList.contains("visible") || popup.style.display === "block"; + } + + /** + * Toggle a popup's visibility, closing all others first. + * Uses CSS class-based visibility. Optional callback fires when popup opens. + */ + protected togglePopup(popup: HTMLElement | null, onOpen?: () => void): void { + const isOpen = this.isPopupOpen(popup); + + this.closeAllPopups(); + + if (!isOpen && popup) { + popup.classList.add("visible"); + popup.style.display = ""; // eslint-disable-line no-param-reassign -- Clear inline style, let CSS control + onOpen?.(); + } + } + + /** + * Close all popups. + * Uses CSS class-based visibility. + */ + protected closeAllPopups(): void { + for (const popup of this.getPopupList()) { + if (popup) { + popup.classList.remove("visible"); + popup.style.display = ""; // Clear inline style, let CSS control + } + } + } + + /** + * Set up a document click handler to close popups when clicking outside. + */ + protected setupOutsideClickHandler(): void { + this.clickOutsideHandler = (e: MouseEvent) => { + if (!this.container?.contains(e.target as Node)) { + this.closeAllPopups(); + } + }; + document.addEventListener("click", this.clickOutsideHandler); + } + + /** + * Create a slider input handler with value display update. + */ + protected createSliderHandler( + slider: HTMLInputElement | null, + valueDisplay: HTMLSpanElement | null, + callback: (value: number) => void, + formatter: (value: number) => string = String + ): void { + slider?.addEventListener("input", e => { + const val = parseFloat((e.target as HTMLInputElement).value); + if (valueDisplay) { + Object.assign(valueDisplay, { textContent: formatter(val) }); + } + callback(val); + }); + } + + /** + * Set active state on a button element. + */ + protected setButtonActive(btn: HTMLElement | null, active: boolean): void { + btn?.classList.toggle("active", active); + } + + /** + * Sync UI state with current clip configuration. + * Subclasses must implement. + */ + protected abstract syncState(): void; + + /** + * Get the list of popup elements for popup management. + * Subclasses must implement. + */ + protected abstract getPopupList(): (HTMLElement | null)[]; +} diff --git a/src/core/ui/canvas-toolbar.ts b/src/core/ui/canvas-toolbar.ts new file mode 100644 index 00000000..71f47770 --- /dev/null +++ b/src/core/ui/canvas-toolbar.ts @@ -0,0 +1,694 @@ +import type { Edit } from "@core/edit-session"; +import type { MergeField } from "@core/merge"; +import { validateAssetUrl } from "@core/shared/utils"; +import { ShotstackEdit } from "@core/shotstack-edit"; +import { injectShotstackStyles } from "@styles/inject"; + +import { makeToolbarDraggable, type ToolbarDragHandle, type ToolbarDragState } from "./toolbar-drag"; + +interface CanvasToolbarOptions { + mergeFields?: boolean; + /** Maximum total pixels allowed for custom resolution input. Omit for unlimited. */ + maxPixels?: number; +} + +type ResolutionChangeCallback = (width: number, height: number) => void; +type FpsChangeCallback = (fps: number) => void; +type BackgroundChangeCallback = (color: string) => void; + +interface ResolutionPreset { + label: string; + sublabel: string; + width: number; + height: number; +} + +const RESOLUTION_PRESETS: ResolutionPreset[] = [ + { label: "1920 × 1080", sublabel: "16:9 • 1080p", width: 1920, height: 1080 }, + { label: "1280 × 720", sublabel: "16:9 • 720p", width: 1280, height: 720 }, + { label: "1080 × 1920", sublabel: "9:16 • Vertical", width: 1080, height: 1920 }, + { label: "1080 × 1080", sublabel: "1:1 • Square", width: 1080, height: 1080 }, + { label: "1080 × 1350", sublabel: "4:5 • Portrait", width: 1080, height: 1350 } +]; + +const FPS_OPTIONS = [24, 25, 30, 60]; + +const COLOR_SWATCHES = [ + "#000000", + "#FFFFFF", + "#1a1a1a", + "#374151", + "#6B7280", + "#9CA3AF", + "#EF4444", + "#F97316", + "#EAB308", + "#22C55E", + "#3B82F6", + "#8B5CF6" +]; + +// SVG Icons +const ICONS = { + monitor: ``, + check: ``, + variables: `` +}; + +export class CanvasToolbar { + private container: HTMLDivElement | null = null; + private edit: Edit | null = null; + + // Current state + private currentWidth: number = 1920; + private currentHeight: number = 1080; + private currentFps: number = 25; + private currentBgColor: string = "#000000"; + + // Popup elements + private resolutionPopup: HTMLDivElement | null = null; + private backgroundPopup: HTMLDivElement | null = null; + private fpsPopup: HTMLDivElement | null = null; + private variablesPopup: HTMLDivElement | null = null; + + // Button elements + private resolutionBtn: HTMLButtonElement | null = null; + private backgroundBtn: HTMLButtonElement | null = null; + private fpsBtn: HTMLButtonElement | null = null; + private variablesBtn: HTMLButtonElement | null = null; + + // Variables elements + private variablesList: HTMLDivElement | null = null; + private variablesEmpty: HTMLDivElement | null = null; + + // Label elements + private resolutionLabel: HTMLSpanElement | null = null; + private fpsLabel: HTMLSpanElement | null = null; + private bgColorDot: HTMLSpanElement | null = null; + + // Custom size inputs + private customWidthInput: HTMLInputElement | null = null; + private customHeightInput: HTMLInputElement | null = null; + + // Color input + private colorInput: HTMLInputElement | null = null; + + // Callbacks + private resolutionChangeCallback: ResolutionChangeCallback | null = null; + private fpsChangeCallback: FpsChangeCallback | null = null; + private backgroundChangeCallback: BackgroundChangeCallback | null = null; + + // Click outside handler + private clickOutsideHandler: ((e: MouseEvent) => void) | null = null; + + // Drag + private dragResult: ToolbarDragHandle | null = null; + + // Feature flags + private showMergeFields: boolean; + + // Constraint configuration + private maxPixels?: number; + + constructor(edit?: Edit, options: CanvasToolbarOptions = {}) { + this.edit = edit ?? null; + this.showMergeFields = options.mergeFields ?? false; + this.maxPixels = options.maxPixels; + injectShotstackStyles(); + } + + /** Check if given dimensions exceed the configured pixel limit */ + private isOverLimit(width: number, height: number): boolean { + if (!this.maxPixels) return false; + return width * height > this.maxPixels; + } + + /** Get the edit as ShotstackEdit if it has merge field capabilities */ + private getShotstackEdit(): ShotstackEdit | null { + // Use duck typing instead of instanceof to avoid module resolution issues + // when the SDK is consumed as an npm package + if (this.edit && "mergeFields" in this.edit) { + return this.edit as ShotstackEdit; + } + return null; + } + + /** @internal */ + getDragState(): ToolbarDragState | null { + return this.dragResult?.getState() ?? null; + } + + /** @internal */ + setPosition(screenX: number, screenY: number): void { + if (this.container) { + this.container.style.left = `${screenX}px`; + this.container.style.top = `${screenY}px`; + } + } + + mount(parent: HTMLElement, options?: { onDragReset?: () => void }): void { + this.container?.remove(); + + this.container = document.createElement("div"); + this.container.className = "ss-canvas-toolbar"; + + this.container.innerHTML = ` + +
+ +
+
Presets
+ ${RESOLUTION_PRESETS.map( + preset => ` +
+
+ ${preset.label} + ${preset.sublabel} +
+ ${ICONS.check} +
+ ` + ).join("")} +
+
Custom
+
+ + × + +
+
+
+ +
+ + +
+ +
+
+ +
+
+ ${COLOR_SWATCHES.map( + color => ` +
+ ` + ).join("")} +
+
+
+ +
+ + +
+ +
+ ${FPS_OPTIONS.map( + fps => ` +
+ ${fps} fps + ${ICONS.check} +
+ ` + ).join("")} +
+
+ + ${ + this.showMergeFields + ? ` +
+ + +
+ +
+
+ Merge Fields + +
+
+
No merge fields defined
+
+
+ ` + : "" + } + `; + + parent.appendChild(this.container); + + // Query elements + this.resolutionBtn = this.container.querySelector('[data-action="resolution"]'); + this.backgroundBtn = this.container.querySelector('[data-action="background"]'); + this.fpsBtn = this.container.querySelector('[data-action="fps"]'); + this.variablesBtn = this.container.querySelector('[data-action="variables"]'); + + this.resolutionPopup = this.container.querySelector('[data-popup="resolution"]'); + this.backgroundPopup = this.container.querySelector('[data-popup="background"]'); + this.fpsPopup = this.container.querySelector('[data-popup="fps"]'); + this.variablesPopup = this.container.querySelector('[data-popup="variables"]'); + + this.variablesList = this.container.querySelector("[data-variables-list]"); + this.variablesEmpty = this.container.querySelector("[data-variables-empty]"); + + this.resolutionLabel = this.container.querySelector("[data-resolution-label]"); + this.fpsLabel = this.container.querySelector("[data-fps-label]"); + this.bgColorDot = this.container.querySelector("[data-bg-preview]"); + + this.customWidthInput = this.container.querySelector("[data-custom-width]"); + this.customHeightInput = this.container.querySelector("[data-custom-height]"); + this.colorInput = this.container.querySelector("[data-color-input]"); + + // Setup event listeners + this.setupEventListeners(); + + // Sync toolbar state with actual edit size + if (this.edit) { + this.setResolution(this.edit.size.width, this.edit.size.height); + } + + // Wire up drag (handle prepended automatically) + if (options?.onDragReset) { + this.dragResult = makeToolbarDraggable({ + container: this.container, + onReset: options.onDragReset + }); + } + } + + private setupEventListeners(): void { + // Toggle popups + this.resolutionBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup("resolution"); + }); + this.backgroundBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup("background"); + }); + this.fpsBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup("fps"); + }); + this.variablesBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup("variables"); + this.renderVariablesList(); + }); + + // Variables - Add button + this.variablesPopup?.querySelector('[data-action="add-variable"]')?.addEventListener("click", e => { + e.stopPropagation(); + this.addVariable(); + }); + + // Resolution preset clicks + this.resolutionPopup?.querySelectorAll("[data-width]").forEach(item => { + item.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const width = parseInt(el.dataset["width"] || "1920", 10); + const height = parseInt(el.dataset["height"] || "1080", 10); + this.handleResolutionSelect(width, height); + }); + }); + + // Custom size inputs + this.customWidthInput?.addEventListener("change", () => this.handleCustomSizeChange()); + this.customHeightInput?.addEventListener("change", () => this.handleCustomSizeChange()); + + // FPS clicks + this.fpsPopup?.querySelectorAll("[data-fps]").forEach(item => { + item.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const fps = parseInt(el.dataset["fps"] || "30", 10); + this.handleFpsSelect(fps); + }); + }); + + // Color input + this.colorInput?.addEventListener("input", () => { + if (this.colorInput) { + this.handleColorChange(this.colorInput.value); + } + }); + + // Color swatches + this.backgroundPopup?.querySelectorAll("[data-swatch-color]").forEach(swatch => { + swatch.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const color = el.dataset["swatchColor"] || "#000000"; + this.handleColorChange(color); + }); + }); + + // Click outside to close + this.clickOutsideHandler = (e: MouseEvent) => { + if (!this.container?.contains(e.target as Node)) { + this.closeAllPopups(); + } + }; + document.addEventListener("click", this.clickOutsideHandler); + } + + private togglePopup(popup: "resolution" | "background" | "fps" | "variables"): void { + const popupMap = { + resolution: { popup: this.resolutionPopup, btn: this.resolutionBtn }, + background: { popup: this.backgroundPopup, btn: this.backgroundBtn }, + fps: { popup: this.fpsPopup, btn: this.fpsBtn }, + variables: { popup: this.variablesPopup, btn: this.variablesBtn } + }; + + const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); + + // Close all popups + this.closeAllPopups(); + + // If it wasn't open, open it + if (!isCurrentlyOpen) { + popupMap[popup].popup?.classList.add("visible"); + popupMap[popup].btn?.classList.add("active"); + + // Sync custom inputs when opening resolution popup + if (popup === "resolution") { + if (this.customWidthInput) this.customWidthInput.value = String(this.currentWidth); + if (this.customHeightInput) this.customHeightInput.value = String(this.currentHeight); + } + } + } + + private closeAllPopups(): void { + this.resolutionPopup?.classList.remove("visible"); + this.backgroundPopup?.classList.remove("visible"); + this.fpsPopup?.classList.remove("visible"); + this.variablesPopup?.classList.remove("visible"); + this.resolutionBtn?.classList.remove("active"); + this.backgroundBtn?.classList.remove("active"); + this.fpsBtn?.classList.remove("active"); + this.variablesBtn?.classList.remove("active"); + } + + private handleResolutionSelect(width: number, height: number): void { + this.currentWidth = width; + this.currentHeight = height; + this.updateResolutionLabel(); + this.updateActiveStates(); + this.updateConstraintWarning(); + this.closeAllPopups(); + + if (this.resolutionChangeCallback) { + this.resolutionChangeCallback(width, height); + } + } + + private handleCustomSizeChange(): void { + if (this.customWidthInput && this.customHeightInput) { + const width = parseInt(this.customWidthInput.value, 10); + const height = parseInt(this.customHeightInput.value, 10); + + if (!Number.isNaN(width) && !Number.isNaN(height) && width > 0 && height > 0) { + // Check pixel limit before applying (only if configured) + if (this.isOverLimit(width, height)) { + this.customWidthInput.classList.add("error"); + this.customHeightInput.classList.add("error"); + const errorMsg = `Exceeds limit (${this.maxPixels!.toLocaleString()} pixels)`; + this.customWidthInput.title = errorMsg; + this.customHeightInput.title = errorMsg; + return; + } + + // Clear error/warning state + this.customWidthInput.classList.remove("error", "warning"); + this.customHeightInput.classList.remove("error", "warning"); + this.customWidthInput.title = ""; + this.customHeightInput.title = ""; + + this.currentWidth = width; + this.currentHeight = height; + this.updateResolutionLabel(); + this.updateActiveStates(); + + if (this.resolutionChangeCallback) { + this.resolutionChangeCallback(width, height); + } + } + } + } + + private handleFpsSelect(fps: number): void { + this.currentFps = fps; + this.updateFpsLabel(); + this.updateActiveStates(); + this.closeAllPopups(); + + if (this.fpsChangeCallback) { + this.fpsChangeCallback(fps); + } + } + + private handleColorChange(color: string): void { + this.currentBgColor = color; + this.updateColorPreview(); + this.updateActiveStates(); + + if (this.colorInput) { + this.colorInput.value = color; + } + + if (this.backgroundChangeCallback) { + this.backgroundChangeCallback(color); + } + } + + private updateResolutionLabel(): void { + if (this.resolutionLabel) { + this.resolutionLabel.textContent = `${this.currentWidth} × ${this.currentHeight}`; + } + } + + private updateFpsLabel(): void { + if (this.fpsLabel) { + this.fpsLabel.textContent = `${this.currentFps} fps`; + } + } + + private updateColorPreview(): void { + if (this.bgColorDot) { + this.bgColorDot.style.background = this.currentBgColor; + } + } + + private updateActiveStates(): void { + // Update resolution presets + this.resolutionPopup?.querySelectorAll("[data-width]").forEach(item => { + const el = item as HTMLElement; + const width = parseInt(el.dataset["width"] || "0", 10); + const height = parseInt(el.dataset["height"] || "0", 10); + el.classList.toggle("active", width === this.currentWidth && height === this.currentHeight); + }); + + // Update FPS options + this.fpsPopup?.querySelectorAll("[data-fps]").forEach(item => { + const el = item as HTMLElement; + const fps = parseInt(el.dataset["fps"] || "0", 10); + el.classList.toggle("active", fps === this.currentFps); + }); + + // Update color swatches + this.backgroundPopup?.querySelectorAll("[data-swatch-color]").forEach(swatch => { + const el = swatch as HTMLElement; + const color = el.dataset["swatchColor"] || ""; + el.classList.toggle("active", color.toLowerCase() === this.currentBgColor.toLowerCase()); + }); + } + + private renderVariablesList(): void { + if (!this.variablesList || !this.variablesEmpty || !this.edit) return; + + const shotstackEdit = this.getShotstackEdit(); + if (!shotstackEdit) return; + + const fields = shotstackEdit.mergeFields.getAll(); + + if (fields.length === 0) { + this.variablesList.innerHTML = ""; + this.variablesList.style.display = "none"; + this.variablesEmpty.style.display = "block"; + return; + } + + this.variablesEmpty.style.display = "none"; + this.variablesList.style.display = "block"; + this.variablesList.innerHTML = fields + .map( + (f: MergeField) => ` +
+
+ {{ ${f.name} }} + +
+ +
+ ` + ) + .join(""); + + // Add event listeners for value changes and delete buttons + this.variablesList.querySelectorAll("[data-var-input]").forEach(input => { + input.addEventListener("change", async e => { + const el = e.target as HTMLInputElement; + const name = el.dataset["varInput"]; + const ssEdit = this.getShotstackEdit(); + if (name && ssEdit) { + // Validate URL if this is a src-type merge field + if (ssEdit.isSrcMergeField(name)) { + const validation = await validateAssetUrl(el.value); + if (!validation.valid) { + el.classList.add("error"); + el.title = validation.error || "Invalid URL"; + return; + } + } + + // Validate type compatibility against all bound properties + const typeError = ssEdit.validateMergeFieldValue(name, el.value); + if (typeError) { + el.classList.add("error"); + el.title = typeError; + return; + } + + el.classList.remove("error"); + el.title = ""; + + // Update the merge field value (resolve() handles canvas refresh) + ssEdit.updateMergeFieldValueLive(name, el.value); + } + }); + }); + + this.variablesList.querySelectorAll("[data-delete-var]").forEach(btn => { + btn.addEventListener("click", async e => { + e.stopPropagation(); + const el = e.target as HTMLElement; + const name = el.dataset["deleteVar"]; + const ssEdit = this.getShotstackEdit(); + if (name && ssEdit) { + await ssEdit.deleteMergeFieldGlobally(name); + this.renderVariablesList(); + } + }); + }); + } + + private addVariable(): void { + const ssEdit = this.getShotstackEdit(); + if (!ssEdit) return; + + // eslint-disable-next-line no-alert -- Intentional use of prompt for quick variable name input + const name = prompt("Variable name:"); + if (!name || !name.trim()) return; + + const sanitizedName = name.trim().toUpperCase().replace(/\s+/g, "_"); + ssEdit.mergeFields.register({ name: sanitizedName, defaultValue: "" }); + this.renderVariablesList(); + } + + setResolution(width: number, height: number): void { + this.currentWidth = Math.round(width); + this.currentHeight = Math.round(height); + this.updateResolutionLabel(); + this.updateActiveStates(); + this.updateConstraintWarning(); + + if (this.customWidthInput) this.customWidthInput.value = String(this.currentWidth); + if (this.customHeightInput) this.customHeightInput.value = String(this.currentHeight); + } + + /** Update warning state for inputs when loaded resolution exceeds configured limit */ + private updateConstraintWarning(): void { + const isOver = this.isOverLimit(this.currentWidth, this.currentHeight); + + if (isOver) { + // Template loaded with over-limit resolution - show visual warning + // Inputs remain editable so users can reduce to valid values + this.customWidthInput?.classList.add("warning"); + this.customHeightInput?.classList.add("warning"); + } else { + this.customWidthInput?.classList.remove("warning"); + this.customHeightInput?.classList.remove("warning"); + } + } + + setFps(fps: number): void { + this.currentFps = fps; + this.updateFpsLabel(); + this.updateActiveStates(); + } + + setBackground(color: string): void { + const hexColor = color.startsWith("#") ? color : `#${color}`; + this.currentBgColor = hexColor; + this.updateColorPreview(); + this.updateActiveStates(); + + if (this.colorInput) { + this.colorInput.value = hexColor; + } + } + + onResolutionChange(callback: ResolutionChangeCallback): void { + this.resolutionChangeCallback = callback; + } + + onFpsChange(callback: FpsChangeCallback): void { + this.fpsChangeCallback = callback; + } + + onBackgroundChange(callback: BackgroundChangeCallback): void { + this.backgroundChangeCallback = callback; + } + + dispose(): void { + this.dragResult?.dispose(); + this.dragResult = null; + + if (this.clickOutsideHandler) { + document.removeEventListener("click", this.clickOutsideHandler); + this.clickOutsideHandler = null; + } + + this.container?.remove(); + this.container = null; + + this.resolutionPopup = null; + this.backgroundPopup = null; + this.fpsPopup = null; + this.variablesPopup = null; + this.resolutionBtn = null; + this.backgroundBtn = null; + this.fpsBtn = null; + this.variablesBtn = null; + this.variablesList = null; + this.variablesEmpty = null; + this.resolutionLabel = null; + this.fpsLabel = null; + this.bgColorDot = null; + this.customWidthInput = null; + this.customHeightInput = null; + this.colorInput = null; + + this.resolutionChangeCallback = null; + this.fpsChangeCallback = null; + this.backgroundChangeCallback = null; + } +} diff --git a/src/core/ui/clip-toolbar.ts b/src/core/ui/clip-toolbar.ts new file mode 100644 index 00000000..aac4e28d --- /dev/null +++ b/src/core/ui/clip-toolbar.ts @@ -0,0 +1,283 @@ +import { EditEvent } from "@core/events/edit-events"; +import type { MergeField } from "@core/merge/types"; +import { getNestedValue } from "@core/shared/utils"; +import { ShotstackEdit } from "@core/shotstack-edit"; +import { type Milliseconds, sec, toMs, toSec } from "@core/timing/types"; +import { injectShotstackStyles } from "@styles/inject"; + +import { BaseToolbar } from "./base-toolbar"; +import { TimingControl } from "./composites/TimingControl"; +import { MergeFieldLabel } from "./primitives/MergeFieldLabel"; + +/** + * Toolbar for clip-level properties (timing, linking). + * Shows compact timing controls for start and length with: + * - Click-to-cycle mode badges + * - Scrubbable time values (drag to adjust) + * - Keyboard increment/decrement (arrow keys) + */ +export class ClipToolbar extends BaseToolbar { + // Timing controls + private startControl: TimingControl | null = null; + private lengthControl: TimingControl | null = null; + private editChangedListener: (() => void) | null = null; + private mergeFieldChangedListener: (() => void) | null = null; + + // Merge field labels + private startMergeLabel: MergeFieldLabel | null = null; + private lengthMergeLabel: MergeFieldLabel | null = null; + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-clip-toolbar"; + + this.container.innerHTML = ` + +
+ + + +
+
+ +
+
+ `; + + parent.insertBefore(this.container, parent.firstChild); + + this.mountComponents(); + this.setupOutsideClickHandler(); + this.setupEventListeners(); + this.enableDrag(); + } + + private setupEventListeners(): void { + this.editChangedListener = () => { + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.syncState(); + } + }; + this.edit.events.on(EditEvent.EditChanged, this.editChangedListener); + + this.mergeFieldChangedListener = () => { + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.syncState(); + } + }; + this.edit.events.on(EditEvent.MergeFieldChanged, this.mergeFieldChangedListener); + } + + private mountComponents(): void { + // Mount start timing control + const startMount = this.container?.querySelector("[data-start-mount]"); + if (startMount) { + this.startControl = new TimingControl("start"); + this.startControl.onChange(() => this.applyTimingUpdate()); + this.startControl.mount(startMount as HTMLElement); + } + + // Mount length timing control + const lengthMount = this.container?.querySelector("[data-length-mount]"); + if (lengthMount) { + this.lengthControl = new TimingControl("length"); + this.lengthControl.onChange(() => this.applyTimingUpdate()); + this.lengthControl.mount(lengthMount as HTMLElement); + } + + // Mount MergeFieldLabel into each timing control's merge mount point + if (this.getShotstackEdit()) { + const startMergeMount = this.startControl?.getMergeMountPoint(); + if (startMergeMount) { + this.startMergeLabel = new MergeFieldLabel({ label: "", propertyPath: "start", namePrefix: "START" }); + this.startMergeLabel.mount(startMergeMount); + this.wireBindCallback(this.startMergeLabel, "start"); + this.wireClearCallback(this.startMergeLabel, "start"); + } + + const lengthMergeMount = this.lengthControl?.getMergeMountPoint(); + if (lengthMergeMount) { + this.lengthMergeLabel = new MergeFieldLabel({ label: "", propertyPath: "length", namePrefix: "LENGTH" }); + this.lengthMergeLabel.mount(lengthMergeMount); + this.wireBindCallback(this.lengthMergeLabel, "length"); + this.wireClearCallback(this.lengthMergeLabel, "length"); + } + } + } + + private applyTimingUpdate(): void { + if (this.selectedTrackIdx < 0 || this.selectedClipIdx < 0) return; + + const startValue = this.startControl?.getStartValue(); + const lengthValue = this.lengthControl?.getLengthValue(); + + // Convert from Milliseconds to Seconds for command + const startSeconds = typeof startValue === "number" ? toSec(startValue as Milliseconds) : startValue; + const lengthSeconds = typeof lengthValue === "number" ? toSec(lengthValue as Milliseconds) : lengthValue; + + this.edit.updateClipTiming(this.selectedTrackIdx, this.selectedClipIdx, { + start: startSeconds, + length: lengthSeconds + }); + } + + /** Get the edit as ShotstackEdit if it has merge field capabilities. */ + private getShotstackEdit(): ShotstackEdit | null { + if (this.edit && "mergeFields" in this.edit) { + return this.edit as ShotstackEdit; + } + return null; + } + + private wireBindCallback(label: MergeFieldLabel, propertyPath: string): void { + label.onBind((nameOrPrefix: string) => { + const shotstackEdit = this.getShotstackEdit(); + if (!shotstackEdit) return; + + const clipId = this.getSelectedClipId(); + if (!clipId) return; + + const existingField = shotstackEdit.mergeFields.get(nameOrPrefix); + + let fieldName: string; + let value: string; + + if (existingField) { + if (!shotstackEdit.isValueCompatibleWithClipProperty(clipId, propertyPath, existingField.defaultValue)) { + return; + } + fieldName = existingField.name; + value = existingField.defaultValue; + } else { + fieldName = shotstackEdit.mergeFields.generateUniqueName(nameOrPrefix); + const resolvedClip = this.edit.getResolvedClipById(clipId); + const currentValue = resolvedClip ? getNestedValue(resolvedClip, propertyPath) : null; + value = currentValue != null ? String(currentValue) : "0"; + } + + shotstackEdit.applyMergeField(clipId, propertyPath, fieldName, value).then(() => { + this.syncState(); + }); + }); + } + + private wireClearCallback(label: MergeFieldLabel, propertyPath: string): void { + label.onClear(() => { + const shotstackEdit = this.getShotstackEdit(); + if (!shotstackEdit) return; + + const clipId = this.getSelectedClipId(); + if (!clipId) return; + + const boundFieldName = shotstackEdit.getMergeFieldForProperty(clipId, propertyPath); + const field = boundFieldName ? shotstackEdit.mergeFields.get(boundFieldName) : null; + const restoreValue = field?.defaultValue ?? ""; + + shotstackEdit.removeMergeField(clipId, propertyPath, restoreValue).then(() => { + this.syncState(); + }); + }); + } + + private syncMergeLabel( + label: MergeFieldLabel | null, + allFields: MergeField[], + propertyPath: string, + clipId: string, + fieldName: string | null, + shotstackEdit: ShotstackEdit + ): void { + if (!label) return; + + const compatibleNames = new Set(); + for (const field of allFields) { + if (shotstackEdit.isValueCompatibleWithClipProperty(clipId, propertyPath, field.defaultValue)) { + compatibleNames.add(field.name); + } + } + + label.setFields(allFields, compatibleNames); + label.setState(fieldName !== null, fieldName ?? undefined); + } + + protected override syncState(): void { + const docClip = this.edit.getDocumentClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!docClip) return; + + // Check merge field bound state first — we need to know whether to read + // from the document clip (raw template) or the resolved clip (placeholder values applied). + const clipId = this.getSelectedClipId(); + const shotstackEdit = this.getShotstackEdit(); + + const startFieldName = clipId && shotstackEdit ? shotstackEdit.getMergeFieldForProperty(clipId, "start") : null; + const lengthFieldName = clipId && shotstackEdit ? shotstackEdit.getMergeFieldForProperty(clipId, "length") : null; + + // When a property is merge-field-bound, the document clip holds the placeholder + // string (e.g. "{{start}}") which isn't a valid timing value. Use the resolved + // clip instead — it has the merge field's default value applied. + const resolvedClip = clipId ? this.edit.getResolvedClipById(clipId) : null; + + const startIntent = startFieldName && resolvedClip ? (resolvedClip.start as number | "auto") : (docClip.start as number | "auto"); + const lengthIntent = + lengthFieldName && resolvedClip ? (resolvedClip.length as number | "auto" | "end") : (docClip.length as number | "auto" | "end"); + + this.startControl?.setFromClip(typeof startIntent === "number" ? toMs(sec(startIntent)) : startIntent); + this.lengthControl?.setFromClip(typeof lengthIntent === "number" ? toMs(sec(lengthIntent)) : lengthIntent); + + // Update merge field bound state on timing controls and labels + if (clipId && shotstackEdit) { + this.startControl?.setMergeFieldBound(startFieldName); + this.lengthControl?.setMergeFieldBound(lengthFieldName); + + // Sync MergeFieldLabel states (field list, compatibility, bound state) + const allFields = shotstackEdit.mergeFields.getAll(); + this.syncMergeLabel(this.startMergeLabel, allFields, "start", clipId, startFieldName, shotstackEdit); + this.syncMergeLabel(this.lengthMergeLabel, allFields, "length", clipId, lengthFieldName, shotstackEdit); + } else { + this.startControl?.setMergeFieldBound(null); + this.lengthControl?.setMergeFieldBound(null); + + this.startMergeLabel?.setState(false); + this.lengthMergeLabel?.setState(false); + } + } + + protected override getPopupList(): (HTMLElement | null)[] { + return []; + } + + override dispose(): void { + if (this.editChangedListener) { + this.edit.events.off(EditEvent.EditChanged, this.editChangedListener); + this.editChangedListener = null; + } + if (this.mergeFieldChangedListener) { + this.edit.events.off(EditEvent.MergeFieldChanged, this.mergeFieldChangedListener); + this.mergeFieldChangedListener = null; + } + + this.startMergeLabel?.dispose(); + this.lengthMergeLabel?.dispose(); + this.startControl?.dispose(); + this.lengthControl?.dispose(); + + super.dispose(); + + this.startMergeLabel = null; + this.lengthMergeLabel = null; + this.startControl = null; + this.lengthControl = null; + } +} diff --git a/src/core/ui/composites/EffectPanel.ts b/src/core/ui/composites/EffectPanel.ts new file mode 100644 index 00000000..00a2964a --- /dev/null +++ b/src/core/ui/composites/EffectPanel.ts @@ -0,0 +1,254 @@ +import { UIComponent } from "../primitives/UIComponent"; + +/** + * State for effect configuration. + */ +export interface EffectState { + type: "" | "zoom" | "slide"; + variant: "In" | "Out"; + direction: "Left" | "Right" | "Up" | "Down"; + speed: number; +} + +/** + * A complete effect configuration panel with type selection, + * variant (In/Out), direction, and speed controls. + * + * This composite replaces ~120 lines of duplicated code in each toolbar + * (MediaToolbar, TextToolbar, RichTextToolbar). + * + * @example + * ```typescript + * const effects = new EffectPanel(); + * effects.onChange(state => this.applyEffect(state)); + * effects.mount(container); + * + * // Sync from clip + * effects.setFromClip(clip.effect); + * ``` + */ +export class EffectPanel extends UIComponent { + private static readonly EFFECT_TYPES = ["", "zoom", "slide"]; + private static readonly DIRECTIONS = ["Left", "Right", "Up", "Down"] as const; + private static readonly SPEEDS = [0.5, 1.0, 2.0]; + + private state: EffectState = { + type: "", + variant: "In", + direction: "Right", + speed: 1.0 + }; + + // DOM references + private typeButtons: NodeListOf | null = null; + private variantRow: HTMLDivElement | null = null; + private variantButtons: NodeListOf | null = null; + private directionRow: HTMLDivElement | null = null; + private directionButtons: NodeListOf | null = null; + private speedRow: HTMLDivElement | null = null; + private speedLabel: HTMLSpanElement | null = null; + private speedDecreaseBtn: HTMLButtonElement | null = null; + private speedIncreaseBtn: HTMLButtonElement | null = null; + + render(): string { + const typeButtons = EffectPanel.EFFECT_TYPES.map( + t => `` + ).join(""); + + const variantButtons = ` + + + `; + + const directionButtons = EffectPanel.DIRECTIONS.map( + d => `` + ).join(""); + + return ` +
${typeButtons}
+
+ Variant +
${variantButtons}
+
+
+ Direction +
${directionButtons}
+
+
+ Speed +
+ + 1s + +
+
+ `; + } + + protected bindElements(): void { + this.typeButtons = this.container?.querySelectorAll("[data-effect-type]") ?? null; + this.variantRow = this.container?.querySelector("[data-effect-variant-row]") ?? null; + this.variantButtons = this.container?.querySelectorAll("[data-variant]") ?? null; + this.directionRow = this.container?.querySelector("[data-effect-direction-row]") ?? null; + this.directionButtons = this.container?.querySelectorAll("[data-effect-dir]") ?? null; + this.speedRow = this.container?.querySelector("[data-effect-speed-row]") ?? null; + this.speedLabel = this.container?.querySelector(".ss-effect-speed-value") ?? null; + this.speedDecreaseBtn = this.container?.querySelector("[data-effect-speed-decrease]") ?? null; + this.speedIncreaseBtn = this.container?.querySelector("[data-effect-speed-increase]") ?? null; + } + + protected setupEvents(): void { + // Effect type selection + this.events.onAll(this.typeButtons!, "click", (_, el) => { + this.state.type = (el as HTMLElement).dataset["effectType"] as EffectState["type"]; + this.updateUI(); + this.emit(this.state); + }); + + // Variant selection + this.events.onAll(this.variantButtons!, "click", (_, el) => { + this.state.variant = (el as HTMLElement).dataset["variant"] as EffectState["variant"]; + this.updateUI(); + this.emit(this.state); + }); + + // Direction selection + this.events.onAll(this.directionButtons!, "click", (_, el) => { + this.state.direction = (el as HTMLElement).dataset["effectDir"] as EffectState["direction"]; + this.updateUI(); + this.emit(this.state); + }); + + // Speed controls + this.events.on(this.speedDecreaseBtn, "click", e => { + e.stopPropagation(); + this.stepSpeed(-1); + }); + this.events.on(this.speedIncreaseBtn, "click", e => { + e.stopPropagation(); + this.stepSpeed(1); + }); + } + + /** + * Set state from clip effect string. + */ + setFromClip(effect: string | undefined): void { + this.parseEffectValue(effect ?? ""); + this.updateUI(); + } + + /** + * Get the effect value for clip update. + */ + getClipValue(): string | undefined { + return this.buildEffectValue() || undefined; + } + + /** + * Get current state. + */ + getState(): EffectState { + return { ...this.state }; + } + + // ─── Private Methods ───────────────────────────────────────────────────── + + private stepSpeed(direction: number): void { + const speeds = EffectPanel.SPEEDS; + const currentIdx = speeds.indexOf(this.state.speed); + const newIdx = Math.max(0, Math.min(speeds.length - 1, currentIdx + direction)); + this.state.speed = speeds[newIdx]; + this.updateUI(); + this.emit(this.state); + } + + private updateUI(): void { + // Update type buttons + this.typeButtons?.forEach(btn => { + btn.classList.toggle("active", btn.dataset["effectType"] === this.state.type); + }); + + // Show/hide variant row (only for zoom) + this.variantRow?.classList.toggle("visible", this.state.type === "zoom"); + + // Update variant buttons + this.variantButtons?.forEach(btn => { + btn.classList.toggle("active", btn.dataset["variant"] === this.state.variant); + }); + + // Show/hide direction row (only for slide) + this.directionRow?.classList.toggle("visible", this.state.type === "slide"); + + // Update direction buttons + this.directionButtons?.forEach(btn => { + btn.classList.toggle("active", btn.dataset["effectDir"] === this.state.direction); + }); + + // Show/hide speed row (when effect is selected) + this.speedRow?.classList.toggle("visible", this.state.type !== ""); + + // Update speed display + if (this.speedLabel) { + this.speedLabel.textContent = `${this.state.speed}s`; + } + + // Update stepper button states + const speedIdx = EffectPanel.SPEEDS.indexOf(this.state.speed); + if (this.speedDecreaseBtn) this.speedDecreaseBtn.disabled = speedIdx <= 0; + if (this.speedIncreaseBtn) this.speedIncreaseBtn.disabled = speedIdx >= EffectPanel.SPEEDS.length - 1; + } + + // ─── Effect Value Parsing/Building ─────────────────────────────────────── + + private parseEffectValue(effect: string): void { + if (!effect) { + this.state.type = ""; + this.state.speed = 1.0; + return; + } + + let base = effect; + if (effect.endsWith("Slow")) { + this.state.speed = 2.0; + base = effect.slice(0, -4); + } else if (effect.endsWith("Fast")) { + this.state.speed = 0.5; + base = effect.slice(0, -4); + } else { + this.state.speed = 1.0; + } + + if (base.startsWith("zoom")) { + this.state.type = "zoom"; + this.state.variant = base === "zoomOut" ? "Out" : "In"; + } else if (base.startsWith("slide")) { + this.state.type = "slide"; + const dir = base.replace("slide", "") as EffectState["direction"]; + this.state.direction = dir || "Right"; + } else { + this.state.type = ""; + } + } + + private buildEffectValue(): string { + if (this.state.type === "") return ""; + + let value = ""; + if (this.state.type === "zoom") { + value = `zoom${this.state.variant}`; + } else if (this.state.type === "slide") { + value = `slide${this.state.direction}`; + } + + if (this.state.speed === 0.5) value += "Fast"; + else if (this.state.speed === 2.0) value += "Slow"; + + return value; + } + + private directionIcon(dir: string): string { + const icons: Record = { Left: "←", Right: "→", Up: "↑", Down: "↓" }; + return icons[dir] ?? ""; + } +} diff --git a/src/core/ui/composites/SpacingPanel.ts b/src/core/ui/composites/SpacingPanel.ts new file mode 100644 index 00000000..8f4b3039 --- /dev/null +++ b/src/core/ui/composites/SpacingPanel.ts @@ -0,0 +1,237 @@ +import { UIComponent } from "../primitives/UIComponent"; + +/** + * State for spacing configuration. + */ +export interface SpacingState { + letterSpacing: number; + lineHeight: number; +} + +/** + * Configuration for SpacingPanel. + */ +export interface SpacingPanelConfig { + /** Whether to show letter spacing control (default: true) */ + showLetterSpacing?: boolean; + /** Min value for letter spacing (default: -50) */ + letterSpacingMin?: number; + /** Max value for letter spacing (default: 100) */ + letterSpacingMax?: number; + /** Min value for line height slider (default: 5, represents 0.5) */ + lineHeightMin?: number; + /** Max value for line height slider (default: 30, represents 3.0) */ + lineHeightMax?: number; +} + +/** + * A spacing configuration panel with letter spacing and line height controls. + * + * This composite can be used by both TextToolbar (line height only) and + * RichTextToolbar (letter spacing + line height). + * + * @example + * ```typescript + * // Full spacing (RichTextToolbar) + * const spacing = new SpacingPanel(); + * spacing.onChange(state => this.applySpacing(state)); + * spacing.mount(container); + * + * // Line height only (TextToolbar) + * const spacing = new SpacingPanel({ showLetterSpacing: false }); + * ``` + */ +export class SpacingPanel extends UIComponent { + private panelConfig: Required; + + private state: SpacingState = { + letterSpacing: 0, + lineHeight: 1.2 + }; + + // DOM references + private letterSpacingSlider: HTMLInputElement | null = null; + private letterSpacingValue: HTMLSpanElement | null = null; + private lineHeightSlider: HTMLInputElement | null = null; + private lineHeightValue: HTMLSpanElement | null = null; + + // Two-phase pattern: Track if drag is active + private spacingDragActive: boolean = false; + + // Callbacks for drag lifecycle + private dragStartCallback: (() => void) | null = null; + private dragEndCallback: (() => void) | null = null; + + constructor(panelConfig: SpacingPanelConfig = {}) { + super(); // No wrapper class - mounted inside existing popup + this.panelConfig = { + showLetterSpacing: panelConfig.showLetterSpacing ?? true, + letterSpacingMin: panelConfig.letterSpacingMin ?? -50, + letterSpacingMax: panelConfig.letterSpacingMax ?? 100, + lineHeightMin: panelConfig.lineHeightMin ?? 5, + lineHeightMax: panelConfig.lineHeightMax ?? 30 + }; + } + + render(): string { + const { showLetterSpacing, letterSpacingMin, letterSpacingMax, lineHeightMin, lineHeightMax } = this.panelConfig; + + const letterSpacingHtml = showLetterSpacing + ? ` +
Letter spacing
+
+ + 0 +
+ ` + : ""; + + return ` + ${letterSpacingHtml} +
Line spacing
+
+ + 1.2 +
+ `; + } + + protected bindElements(): void { + this.letterSpacingSlider = this.container?.querySelector("[data-letter-spacing-slider]") ?? null; + this.letterSpacingValue = this.container?.querySelector("[data-letter-spacing-value]") ?? null; + this.lineHeightSlider = this.container?.querySelector("[data-line-height-slider]") ?? null; + this.lineHeightValue = this.container?.querySelector("[data-line-height-value]") ?? null; + } + + protected setupEvents(): void { + // Phase 1: Mark drag active on pointerdown + const setupPointerdown = (slider: HTMLInputElement | null): void => { + if (slider) { + this.events.on(slider, "pointerdown", () => { + const wasInactive = !this.spacingDragActive; + this.spacingDragActive = true; + if (wasInactive) { + this.dragStartCallback?.(); + } + }); + } + }; + + setupPointerdown(this.letterSpacingSlider); + setupPointerdown(this.lineHeightSlider); + + // Phase 2: Live update during drag + if (this.letterSpacingSlider) { + this.events.on(this.letterSpacingSlider, "input", () => { + const value = parseInt(this.letterSpacingSlider!.value, 10); + this.state.letterSpacing = value; + this.updateLetterSpacingDisplay(); + this.emit(this.state); + }); + } + + if (this.lineHeightSlider) { + this.events.on(this.lineHeightSlider, "input", () => { + const rawValue = parseInt(this.lineHeightSlider!.value, 10); + const lineHeight = rawValue / 10; + this.state.lineHeight = lineHeight; + this.updateLineHeightDisplay(); + this.emit(this.state); + }); + } + + // Phase 3: Mark drag complete on release + const onDragEnd = (): void => { + if (this.spacingDragActive) { + this.spacingDragActive = false; + this.dragEndCallback?.(); + } + }; + + if (this.letterSpacingSlider) this.events.on(this.letterSpacingSlider, "change", onDragEnd); + if (this.lineHeightSlider) this.events.on(this.lineHeightSlider, "change", onDragEnd); + } + + /** + * Set spacing values from clip data. + */ + setState(letterSpacing: number, lineHeight: number): void { + this.state.letterSpacing = letterSpacing; + this.state.lineHeight = lineHeight; + this.updateUI(); + } + + /** + * Set only line height (for TextToolbar which doesn't use letter spacing). + */ + setLineHeight(lineHeight: number): void { + this.state.lineHeight = lineHeight; + this.updateLineHeightDisplay(); + if (this.lineHeightSlider) { + this.lineHeightSlider.value = String(Math.round(lineHeight * 10)); + } + } + + /** + * Get current spacing state. + */ + getState(): SpacingState { + return { ...this.state }; + } + + /** + * Register callback for drag start (when pointerdown occurs on any slider). + */ + onDragStart(callback: () => void): void { + this.dragStartCallback = callback; + } + + /** + * Register callback for drag end (when change event occurs on any slider). + */ + onDragEnd(callback: () => void): void { + this.dragEndCallback = callback; + } + + /** + * Check if any spacing slider is currently being dragged. + * @internal Used by parent to determine if live updates should skip command creation. + */ + isDragging(): boolean { + return this.spacingDragActive; + } + + // ─── Private Methods ───────────────────────────────────────────────────── + + private updateUI(): void { + this.updateLetterSpacingDisplay(); + this.updateLineHeightDisplay(); + + if (this.letterSpacingSlider) { + this.letterSpacingSlider.value = String(this.state.letterSpacing); + } + if (this.lineHeightSlider) { + this.lineHeightSlider.value = String(Math.round(this.state.lineHeight * 10)); + } + } + + private updateLetterSpacingDisplay(): void { + if (this.letterSpacingValue) { + this.letterSpacingValue.textContent = String(this.state.letterSpacing); + } + } + + private updateLineHeightDisplay(): void { + if (this.lineHeightValue) { + this.lineHeightValue.textContent = this.state.lineHeight.toFixed(1); + } + } + + override dispose(): void { + // Clear drag state + this.spacingDragActive = false; + super.dispose(); + } +} diff --git a/src/core/ui/composites/StylePanel.ts b/src/core/ui/composites/StylePanel.ts new file mode 100644 index 00000000..8c1cb03d --- /dev/null +++ b/src/core/ui/composites/StylePanel.ts @@ -0,0 +1,703 @@ +import { UIComponent } from "../primitives/UIComponent"; + +/** + * State for all style properties. + */ +export interface StyleState { + fill: { + color: string; + opacity: number; + }; + border: { + width: number; + color: string; + opacity: number; + radius: number; + }; + padding: { + top: number; + right: number; + bottom: number; + left: number; + }; + shadow: { + enabled: boolean; + offsetX: number; + offsetY: number; + blur: number; + color: string; + opacity: number; + }; +} + +type StyleTab = "fill" | "border" | "padding" | "shadow"; + +/** + * A consolidated style panel with tabbed UI for Fill, Border, Padding, and Shadow. + * + * This composite replaces 4 separate toolbar buttons with a single "Style" dropdown + * containing a tabbed panel. Follows video editor UX patterns for progressive disclosure. + * + * @example + * ```typescript + * const stylePanel = new StylePanel(); + * stylePanel.onFillChange(state => this.applyFill(state)); + * stylePanel.onBorderChange(state => this.applyBorder(state)); + * stylePanel.onPaddingChange(state => this.applyPadding(state)); + * stylePanel.onShadowChange(state => this.applyShadow(state)); + * stylePanel.mount(popupContainer); + * ``` + */ +export class StylePanel extends UIComponent { + private activeTab: StyleTab = "fill"; + + private state: StyleState = { + fill: { color: "#000000", opacity: 100 }, + border: { width: 0, color: "#000000", opacity: 100, radius: 0 }, + padding: { top: 0, right: 0, bottom: 0, left: 0 }, + // blur is fixed at 4 - canvas only checks blur > 0, doesn't implement actual blur effect + shadow: { enabled: false, offsetX: 0, offsetY: 0, blur: 4, color: "#000000", opacity: 50 } + }; + + // Callbacks for each section + private fillChangeCallback: ((state: StyleState["fill"]) => void) | null = null; + private borderChangeCallback: ((state: StyleState["border"]) => void) | null = null; + private paddingChangeCallback: ((state: StyleState["padding"]) => void) | null = null; + private shadowChangeCallback: ((state: StyleState["shadow"]) => void) | null = null; + + // Two-phase pattern callbacks + private dragStartCallback: (() => void) | null = null; + private dragEndCallback: (() => void) | null = null; + + // DOM references + private tabButtons: NodeListOf | null = null; + private tabPanels: NodeListOf | null = null; + + // Fill elements + private fillColorPicker: HTMLDivElement | null = null; + + // Border elements + private borderWidthSlider: HTMLInputElement | null = null; + private borderWidthValue: HTMLSpanElement | null = null; + private borderColorInput: HTMLInputElement | null = null; + private borderOpacitySlider: HTMLInputElement | null = null; + private borderOpacityValue: HTMLSpanElement | null = null; + private borderRadiusSlider: HTMLInputElement | null = null; + private borderRadiusValue: HTMLSpanElement | null = null; + + // Padding elements + private paddingTopSlider: HTMLInputElement | null = null; + private paddingTopValue: HTMLSpanElement | null = null; + private paddingRightSlider: HTMLInputElement | null = null; + private paddingRightValue: HTMLSpanElement | null = null; + private paddingBottomSlider: HTMLInputElement | null = null; + private paddingBottomValue: HTMLSpanElement | null = null; + private paddingLeftSlider: HTMLInputElement | null = null; + private paddingLeftValue: HTMLSpanElement | null = null; + + // Shadow elements (blur not exposed in UI - canvas doesn't implement actual blur effect) + private shadowToggle: HTMLInputElement | null = null; + private shadowOffsetXSlider: HTMLInputElement | null = null; + private shadowOffsetXValue: HTMLSpanElement | null = null; + private shadowOffsetYSlider: HTMLInputElement | null = null; + private shadowOffsetYValue: HTMLSpanElement | null = null; + private shadowColorInput: HTMLInputElement | null = null; + private shadowOpacitySlider: HTMLInputElement | null = null; + private shadowOpacityValue: HTMLSpanElement | null = null; + + // Two-phase pattern: Track if drag is active + private borderDragActive: boolean = false; + private paddingDragActive: boolean = false; + private shadowDragActive: boolean = false; + + render(): string { + return ` +
+ + + + +
+
+ ${this.renderFillTab()} + ${this.renderBorderTab()} + ${this.renderPaddingTab()} + ${this.renderShadowTab()} +
+ `; + } + + private renderFillTab(): string { + return ` +
+
+
+ `; + } + + private renderBorderTab(): string { + return ` + + `; + } + + private renderPaddingTab(): string { + return ` + + `; + } + + private renderShadowTab(): string { + return ` + + `; + } + + protected bindElements(): void { + // Tab navigation + this.tabButtons = this.container?.querySelectorAll("[data-style-tab]") ?? null; + this.tabPanels = this.container?.querySelectorAll("[data-tab-content]") ?? null; + + // Fill (color picker will be mounted by parent) + this.fillColorPicker = this.container?.querySelector("[data-fill-color-picker]") ?? null; + + // Border + this.borderWidthSlider = this.container?.querySelector("[data-border-width-slider]") ?? null; + this.borderWidthValue = this.container?.querySelector("[data-border-width-value]") ?? null; + this.borderColorInput = this.container?.querySelector("[data-border-color]") ?? null; + this.borderOpacitySlider = this.container?.querySelector("[data-border-opacity-slider]") ?? null; + this.borderOpacityValue = this.container?.querySelector("[data-border-opacity-value]") ?? null; + this.borderRadiusSlider = this.container?.querySelector("[data-border-radius-slider]") ?? null; + this.borderRadiusValue = this.container?.querySelector("[data-border-radius-value]") ?? null; + + // Padding + this.paddingTopSlider = this.container?.querySelector("[data-padding-top-slider]") ?? null; + this.paddingTopValue = this.container?.querySelector("[data-padding-top-value]") ?? null; + this.paddingRightSlider = this.container?.querySelector("[data-padding-right-slider]") ?? null; + this.paddingRightValue = this.container?.querySelector("[data-padding-right-value]") ?? null; + this.paddingBottomSlider = this.container?.querySelector("[data-padding-bottom-slider]") ?? null; + this.paddingBottomValue = this.container?.querySelector("[data-padding-bottom-value]") ?? null; + this.paddingLeftSlider = this.container?.querySelector("[data-padding-left-slider]") ?? null; + this.paddingLeftValue = this.container?.querySelector("[data-padding-left-value]") ?? null; + + // Shadow (blur not exposed - canvas doesn't implement actual blur effect) + this.shadowToggle = this.container?.querySelector("[data-shadow-toggle]") ?? null; + this.shadowOffsetXSlider = this.container?.querySelector("[data-shadow-offset-x]") ?? null; + this.shadowOffsetXValue = this.container?.querySelector("[data-shadow-offset-x-value]") ?? null; + this.shadowOffsetYSlider = this.container?.querySelector("[data-shadow-offset-y]") ?? null; + this.shadowOffsetYValue = this.container?.querySelector("[data-shadow-offset-y-value]") ?? null; + this.shadowColorInput = this.container?.querySelector("[data-shadow-color]") ?? null; + this.shadowOpacitySlider = this.container?.querySelector("[data-shadow-opacity]") ?? null; + this.shadowOpacityValue = this.container?.querySelector("[data-shadow-opacity-value]") ?? null; + } + + protected setupEvents(): void { + // Tab switching + this.tabButtons?.forEach(btn => { + this.events.on(btn, "click", () => { + const tab = btn.dataset["styleTab"] as StyleTab; + this.switchTab(tab); + }); + }); + + // Border events + this.setupBorderEvents(); + + // Padding events + this.setupPaddingEvents(); + + // Shadow events + this.setupShadowEvents(); + } + + // ─── Phase 2 Helper Methods ──────────────────────────────────── + + /** + * Update border value display for a specific property. + */ + private updateBorderValueDisplay(property: "width" | "opacity" | "radius"): void { + const valueMap = { + width: this.borderWidthValue, + opacity: this.borderOpacityValue, + radius: this.borderRadiusValue + }; + const el = valueMap[property]; + if (el) el.textContent = String(this.state.border[property]); + } + + /** + * Update shadow value display for a specific property. + */ + private updateShadowValueDisplay(property: "offsetX" | "offsetY" | "opacity"): void { + const valueMap = { + offsetX: this.shadowOffsetXValue, + offsetY: this.shadowOffsetYValue, + opacity: this.shadowOpacityValue + }; + const el = valueMap[property]; + if (el) el.textContent = String(this.state.shadow[property]); + } + + private setupBorderEvents(): void { + // Phase 1: Save state on pointerdown (parent will handle initial state capture) + const setupBorderPointerdown = (element: HTMLInputElement | null): void => { + if (element) { + this.events.on(element, "pointerdown", () => { + const wasInactive = !this.borderDragActive; + this.borderDragActive = true; + if (wasInactive) { + this.dragStartCallback?.(); + } + }); + } + }; + + setupBorderPointerdown(this.borderWidthSlider); + setupBorderPointerdown(this.borderColorInput); + setupBorderPointerdown(this.borderOpacitySlider); + setupBorderPointerdown(this.borderRadiusSlider); + + // Phase 2: Live update during drag (emit on every input for visual feedback) + if (this.borderWidthSlider) { + this.events.on(this.borderWidthSlider, "input", () => { + this.state.border.width = parseInt(this.borderWidthSlider!.value, 10); + this.updateBorderValueDisplay("width"); + this.emitBorderChange(); + }); + } + if (this.borderColorInput) { + this.events.on(this.borderColorInput, "input", () => { + this.state.border.color = this.borderColorInput!.value; + this.emitBorderChange(); + }); + } + if (this.borderOpacitySlider) { + this.events.on(this.borderOpacitySlider, "input", () => { + this.state.border.opacity = parseInt(this.borderOpacitySlider!.value, 10); + this.updateBorderValueDisplay("opacity"); + this.emitBorderChange(); + }); + } + if (this.borderRadiusSlider) { + this.events.on(this.borderRadiusSlider, "input", () => { + this.state.border.radius = parseInt(this.borderRadiusSlider!.value, 10); + this.updateBorderValueDisplay("radius"); + this.emitBorderChange(); + }); + } + + // Phase 3: Mark drag complete on release + const onBorderDragEnd = (): void => { + if (this.borderDragActive) { + this.borderDragActive = false; + this.dragEndCallback?.(); + } + }; + + if (this.borderWidthSlider) this.events.on(this.borderWidthSlider, "change", onBorderDragEnd); + if (this.borderColorInput) this.events.on(this.borderColorInput, "change", onBorderDragEnd); + if (this.borderOpacitySlider) this.events.on(this.borderOpacitySlider, "change", onBorderDragEnd); + if (this.borderRadiusSlider) this.events.on(this.borderRadiusSlider, "change", onBorderDragEnd); + } + + private setupPaddingEvents(): void { + const sliders = [ + { slider: this.paddingTopSlider, key: "top" as const }, + { slider: this.paddingRightSlider, key: "right" as const }, + { slider: this.paddingBottomSlider, key: "bottom" as const }, + { slider: this.paddingLeftSlider, key: "left" as const } + ]; + + // Phase 1: Mark drag active on pointerdown + sliders.forEach(({ slider }) => { + if (slider) { + this.events.on(slider, "pointerdown", () => { + const wasInactive = !this.paddingDragActive; + this.paddingDragActive = true; + if (wasInactive) { + this.dragStartCallback?.(); + } + }); + } + }); + + // Phase 2: Live update during drag (emit on every input for visual feedback) + sliders.forEach(({ slider, key }) => { + if (slider) { + this.events.on(slider, "input", () => { + const value = parseInt(slider.value, 10); + this.state.padding[key] = value; + this.updatePaddingDisplay(key); + this.emitPaddingChange(); + }); + } + }); + + // Phase 3: Mark drag complete on release + sliders.forEach(({ slider }) => { + if (slider) { + this.events.on(slider, "change", () => { + if (this.paddingDragActive) { + this.paddingDragActive = false; + this.dragEndCallback?.(); + } + }); + } + }); + } + + private setupShadowEvents(): void { + // Visible defaults when enabling shadow for the first time + // Note: blur is fixed at 4 (canvas only checks blur > 0, doesn't implement actual blur) + const SHADOW_DEFAULTS = { offsetX: 2, offsetY: 2, blur: 4, color: "#000000", opacity: 50 }; + + // Auto-enable shadow when any slider is changed (better UX) + const autoEnableAndEmit = (): void => { + if (!this.state.shadow.enabled) { + this.state.shadow.enabled = true; + if (this.shadowToggle) this.shadowToggle.checked = true; + } + this.emitShadowChange(); + }; + + // Phase 1: Mark drag active on pointerdown + const setupShadowPointerdown = (element: HTMLInputElement | null): void => { + if (element) { + this.events.on(element, "pointerdown", () => { + const wasInactive = !this.shadowDragActive; + this.shadowDragActive = true; + if (wasInactive) { + this.dragStartCallback?.(); + } + }); + } + }; + + setupShadowPointerdown(this.shadowOffsetXSlider); + setupShadowPointerdown(this.shadowOffsetYSlider); + setupShadowPointerdown(this.shadowColorInput); + setupShadowPointerdown(this.shadowOpacitySlider); + + // Toggle is a discrete action - emit immediately (no dragging involved) + if (this.shadowToggle) { + this.events.on(this.shadowToggle, "change", () => { + const enabling = this.shadowToggle!.checked; + this.state.shadow.enabled = enabling; + + // Apply visible defaults when enabling shadow with zeroed offsets + if (enabling && this.state.shadow.offsetX === 0 && this.state.shadow.offsetY === 0) { + this.state.shadow = { ...this.state.shadow, enabled: true, ...SHADOW_DEFAULTS }; + this.updateShadowUI(); + } + + this.emitShadowChange(); + }); + } + + // Phase 2: Live update during drag (emit on every input for visual feedback) + if (this.shadowOffsetXSlider) { + this.events.on(this.shadowOffsetXSlider, "input", () => { + this.state.shadow.offsetX = parseInt(this.shadowOffsetXSlider!.value, 10); + this.updateShadowValueDisplay("offsetX"); + autoEnableAndEmit(); + }); + } + if (this.shadowOffsetYSlider) { + this.events.on(this.shadowOffsetYSlider, "input", () => { + this.state.shadow.offsetY = parseInt(this.shadowOffsetYSlider!.value, 10); + this.updateShadowValueDisplay("offsetY"); + autoEnableAndEmit(); + }); + } + if (this.shadowColorInput) { + this.events.on(this.shadowColorInput, "input", () => { + this.state.shadow.color = this.shadowColorInput!.value; + autoEnableAndEmit(); + }); + } + if (this.shadowOpacitySlider) { + this.events.on(this.shadowOpacitySlider, "input", () => { + this.state.shadow.opacity = parseInt(this.shadowOpacitySlider!.value, 10); + this.updateShadowValueDisplay("opacity"); + autoEnableAndEmit(); + }); + } + + // Phase 3: Mark drag complete on release + const onShadowDragEnd = (): void => { + if (this.shadowDragActive) { + this.shadowDragActive = false; + this.dragEndCallback?.(); + } + }; + + if (this.shadowOffsetXSlider) this.events.on(this.shadowOffsetXSlider, "change", onShadowDragEnd); + if (this.shadowOffsetYSlider) this.events.on(this.shadowOffsetYSlider, "change", onShadowDragEnd); + if (this.shadowColorInput) this.events.on(this.shadowColorInput, "change", onShadowDragEnd); + if (this.shadowOpacitySlider) this.events.on(this.shadowOpacitySlider, "change", onShadowDragEnd); + } + + // ─── Tab Switching ──────────────────────────────────────────────────────── + + private switchTab(tab: StyleTab): void { + this.activeTab = tab; + + // Update tab button active state + this.tabButtons?.forEach(btn => { + btn.classList.toggle("active", btn.dataset["styleTab"] === tab); + }); + + // Show/hide tab panels + this.tabPanels?.forEach(el => { + const isActive = el.dataset["tabContent"] === tab; + el.style.display = isActive ? "block" : "none"; // eslint-disable-line no-param-reassign -- DOM manipulation + }); + } + + // ─── Callbacks ──────────────────────────────────────────────────────────── + + onFillChange(callback: (state: StyleState["fill"]) => void): void { + this.fillChangeCallback = callback; + } + + onBorderChange(callback: (state: StyleState["border"]) => void): void { + this.borderChangeCallback = callback; + } + + onPaddingChange(callback: (state: StyleState["padding"]) => void): void { + this.paddingChangeCallback = callback; + } + + onShadowChange(callback: (state: StyleState["shadow"]) => void): void { + this.shadowChangeCallback = callback; + } + + /** + * Register callback for drag start (when pointerdown occurs on any slider). + */ + onDragStart(callback: () => void): void { + this.dragStartCallback = callback; + } + + /** + * Register callback for drag end (when change event occurs on any slider). + */ + onDragEnd(callback: () => void): void { + this.dragEndCallback = callback; + } + + private emitBorderChange(): void { + this.borderChangeCallback?.({ ...this.state.border }); + this.emit(this.state); + } + + private emitPaddingChange(): void { + this.paddingChangeCallback?.({ ...this.state.padding }); + this.emit(this.state); + } + + private emitShadowChange(): void { + this.shadowChangeCallback?.({ ...this.state.shadow }); + this.emit(this.state); + } + + // ─── State Setters ──────────────────────────────────────────────────────── + + /** + * Get the fill color picker mount point for external ColorPicker. + */ + getFillColorPickerMount(): HTMLDivElement | null { + return this.fillColorPicker; + } + + /** + * Set fill state (called by parent when ColorPicker changes). + */ + setFillState(state: Partial): void { + this.state.fill = { ...this.state.fill, ...state }; + } + + /** + * Set border state from clip data. + */ + setBorderState(state: Partial): void { + this.state.border = { ...this.state.border, ...state }; + this.updateBorderUI(); + } + + /** + * Set padding state from clip data. + */ + setPaddingState(state: Partial): void { + this.state.padding = { ...this.state.padding, ...state }; + this.updatePaddingUI(); + } + + /** + * Set shadow state from clip data. + */ + setShadowState(state: Partial): void { + this.state.shadow = { ...this.state.shadow, ...state }; + this.updateShadowUI(); + } + + /** + * Get current full state (immutable copy). + */ + getState(): StyleState { + return { + fill: { ...this.state.fill }, + border: { ...this.state.border }, + padding: { ...this.state.padding }, + shadow: { ...this.state.shadow } + }; + } + + /** + * Check if any property is currently being dragged. + * @internal Used by parent to determine if live updates should skip command creation. + */ + isDragging(): boolean { + return this.borderDragActive || this.paddingDragActive || this.shadowDragActive; + } + + // ─── UI Updates ─────────────────────────────────────────────────────────── + + private updateBorderUI(): void { + if (this.borderWidthSlider) this.borderWidthSlider.value = String(this.state.border.width); + if (this.borderColorInput) this.borderColorInput.value = this.state.border.color; + if (this.borderOpacitySlider) this.borderOpacitySlider.value = String(this.state.border.opacity); + if (this.borderRadiusSlider) this.borderRadiusSlider.value = String(this.state.border.radius); + this.updateBorderValueDisplay("width"); + this.updateBorderValueDisplay("opacity"); + this.updateBorderValueDisplay("radius"); + } + + private updatePaddingUI(): void { + if (this.paddingTopSlider) this.paddingTopSlider.value = String(this.state.padding.top); + if (this.paddingRightSlider) this.paddingRightSlider.value = String(this.state.padding.right); + if (this.paddingBottomSlider) this.paddingBottomSlider.value = String(this.state.padding.bottom); + if (this.paddingLeftSlider) this.paddingLeftSlider.value = String(this.state.padding.left); + this.updatePaddingDisplay("top"); + this.updatePaddingDisplay("right"); + this.updatePaddingDisplay("bottom"); + this.updatePaddingDisplay("left"); + } + + private updateShadowUI(): void { + if (this.shadowToggle) this.shadowToggle.checked = this.state.shadow.enabled; + if (this.shadowOffsetXSlider) this.shadowOffsetXSlider.value = String(this.state.shadow.offsetX); + if (this.shadowOffsetYSlider) this.shadowOffsetYSlider.value = String(this.state.shadow.offsetY); + if (this.shadowColorInput) this.shadowColorInput.value = this.state.shadow.color; + if (this.shadowOpacitySlider) this.shadowOpacitySlider.value = String(this.state.shadow.opacity); + this.updateShadowValueDisplay("offsetX"); + this.updateShadowValueDisplay("offsetY"); + this.updateShadowValueDisplay("opacity"); + } + + private updatePaddingDisplay(key: keyof StyleState["padding"]): void { + const valueMap = { + top: this.paddingTopValue, + right: this.paddingRightValue, + bottom: this.paddingBottomValue, + left: this.paddingLeftValue + }; + const el = valueMap[key]; + if (el) el.textContent = String(this.state.padding[key]); + } + + // ─── Disposal ───────────────────────────────────────────────────────────── + + override dispose(): void { + // Clear drag state + this.borderDragActive = false; + this.paddingDragActive = false; + this.shadowDragActive = false; + super.dispose(); + } +} diff --git a/src/core/ui/composites/TimingControl.ts b/src/core/ui/composites/TimingControl.ts new file mode 100644 index 00000000..ebce3c1c --- /dev/null +++ b/src/core/ui/composites/TimingControl.ts @@ -0,0 +1,521 @@ +import { type Milliseconds, ms } from "@core/timing/types"; + +import { UIComponent } from "../primitives/UIComponent"; + +/** + * Timing control type - determines available modes. + */ +export type TimingType = "start" | "length"; + +/** + * Mode configuration for timing controls. + */ +interface TimingMode { + id: string; + icon: string; // SVG path + tooltip: string; +} + +/** + * Start timing modes: Manual or Auto. + * Icons use 1px strokes for a refined look. + */ +const START_MODES: TimingMode[] = [ + { + id: "manual", + icon: ``, + tooltip: "Manual: Set specific time" + }, + { + id: "auto", + icon: ``, + tooltip: "Auto: After previous clip" + } +]; + +/** + * Length timing modes: Manual, Auto, or End. + * Icons use 1px strokes for a refined look. + */ +const LENGTH_MODES: TimingMode[] = [ + { + id: "manual", + icon: ``, + tooltip: "Manual: Set specific duration" + }, + { + id: "auto", + icon: ``, + tooltip: "Auto: Asset's natural duration" + }, + { + id: "end", + icon: ``, + tooltip: "End: Extend to timeline end" + } +]; + +/** + * State for timing control. + * Value uses Milliseconds branded type for UI-layer type safety. + */ +export interface TimingControlState { + mode: string; + value: Milliseconds; +} + +/** + * Compact timing control with: + * - Click-to-cycle mode badge with SVG icons + * - Scrubbable time value (drag to adjust) + * - Double-click to enter text edit mode + * - Arrow key increment/decrement + * - Smart time formatting (5.2s instead of 0:05.200) + * - Tooltip on hover (500ms delay) + */ +export class TimingControl extends UIComponent { + private type: TimingType; + private modes: TimingMode[]; + private state: TimingControlState; + + // DOM references + private modeBtn: HTMLButtonElement | null = null; + private valueInput: HTMLInputElement | null = null; + private tooltipEl: HTMLDivElement | null = null; + private mergeMountEl: HTMLDivElement | null = null; + + // Scrub state + private isDragging = false; + private dragStartX = 0; + private dragStartValue = 0; + private tooltipTimeout: number | null = null; + + // Merge field bound state + private mergeFieldName: string | null = null; + + constructor(type: TimingType) { + super({ className: "ss-timing-control", attributes: { "data-type": type } }); + this.type = type; + this.modes = type === "start" ? START_MODES : LENGTH_MODES; + this.state = { mode: "manual", value: type === "start" ? ms(0) : ms(3000) }; + } + + render(): string { + const mode = this.modes[0]; + const label = this.type === "start" ? "START" : "LENGTH"; + return ` + + +
+
${mode.tooltip}
+ `; + } + + protected bindElements(): void { + this.modeBtn = this.container?.querySelector(".ss-timing-mode") ?? null; + this.valueInput = this.container?.querySelector(".ss-timing-value") ?? null; + this.mergeMountEl = this.container?.querySelector(".ss-timing-merge-mount") ?? null; + this.tooltipEl = this.container?.querySelector(".ss-timing-tooltip") ?? null; + } + + protected setupEvents(): void { + // Mode cycling (click to cycle) — disabled when merge field bound + this.events.on(this.modeBtn, "click", e => { + e.preventDefault(); + if (this.mergeFieldName) return; + this.cycleMode(); + }); + + // Tooltip on hover (500ms delay) + this.events.on(this.modeBtn, "mouseenter", () => this.showTooltipDelayed()); + this.events.on(this.modeBtn, "mouseleave", () => this.hideTooltip()); + + // Value input events + if (this.valueInput) { + // Scrub: mousedown starts drag — disabled when merge field bound + this.events.on(this.valueInput, "mousedown", (e: Event) => { + if (this.mergeFieldName) return; + const mouseEvent = e as MouseEvent; + // Only start drag if not already editing + if (this.valueInput?.readOnly && this.state.mode === "manual") { + this.startDrag(mouseEvent); + } + }); + + // Double-click to enter edit mode — disabled when merge field bound + this.events.on(this.valueInput, "dblclick", () => { + if (this.mergeFieldName) return; + if (this.state.mode === "manual") { + this.enterEditMode(); + } + }); + + // Keyboard: arrow keys for increment/decrement + this.events.on(this.valueInput, "keydown", (e: Event) => { + this.handleKeydown(e as KeyboardEvent); + }); + + // Blur: exit edit mode + this.events.on(this.valueInput, "blur", () => { + if (!this.valueInput?.readOnly) { + this.exitEditMode(); + } + }); + + // Focus: select all when entering edit mode + this.events.on(this.valueInput, "focus", () => { + if (!this.valueInput?.readOnly) { + requestAnimationFrame(() => this.valueInput?.select()); + } + }); + } + + // Global mouse events for dragging + document.addEventListener("mousemove", this.handleMouseMove); + document.addEventListener("mouseup", this.handleMouseUp); + } + + // ─── Mode Cycling ──────────────────────────────────────────────────────────── + + private cycleMode(): void { + const currentIndex = this.modes.findIndex(m => m.id === this.state.mode); + const nextIndex = (currentIndex + 1) % this.modes.length; + const nextMode = this.modes[nextIndex]; + + this.state.mode = nextMode.id; + + // Set default value for non-manual modes + if (nextMode.id !== "manual") { + // Keep the last manual value in case user switches back + } + + this.updateUI(); + this.emit(this.state); + } + + // ─── Tooltip ───────────────────────────────────────────────────────────────── + + private showTooltipDelayed(): void { + this.tooltipTimeout = window.setTimeout(() => { + this.tooltipEl?.classList.add("visible"); + }, 500); + } + + private hideTooltip(): void { + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout); + this.tooltipTimeout = null; + } + this.tooltipEl?.classList.remove("visible"); + } + + // ─── Scrubbing (Drag to Adjust) ────────────────────────────────────────────── + + private startDrag(e: MouseEvent): void { + if (this.state.mode !== "manual") return; + + this.isDragging = true; + this.dragStartX = e.clientX; + this.dragStartValue = this.state.value; + + // Add dragging class to container (unified field highlights) + this.container?.classList.add("dragging"); + + e.preventDefault(); + } + + private handleMouseMove = (e: MouseEvent): void => { + if (!this.isDragging) return; + + const deltaX = e.clientX - this.dragStartX; + + // Sensitivity: 100ms per pixel, Shift = 1000ms, Alt = 10ms + let sensitivity = 100; + if (e.shiftKey) sensitivity = 1000; + else if (e.altKey) sensitivity = 10; + + const deltaMs = deltaX * sensitivity; + const newValue = Math.max(0, this.dragStartValue + deltaMs); + + this.state.value = ms(Math.round(newValue)); + this.updateValueDisplay(); + }; + + private handleMouseUp = (): void => { + if (this.isDragging) { + this.isDragging = false; + this.container?.classList.remove("dragging"); + this.emit(this.state); + } + }; + + // ─── Edit Mode (Double-Click) ──────────────────────────────────────────────── + + private enterEditMode(): void { + if (!this.valueInput || this.state.mode !== "manual") return; + + this.valueInput.readOnly = false; + this.container?.classList.add("editing"); + this.valueInput.focus(); + } + + private exitEditMode(): void { + if (!this.valueInput) return; + + // Parse and validate input + const parsed = this.parseTimeString(this.valueInput.value); + if (parsed !== null && parsed >= 0) { + this.state.value = ms(parsed); + this.emit(this.state); + } + + // Reset to readonly and update display + this.valueInput.readOnly = true; + this.container?.classList.remove("editing"); + this.updateValueDisplay(); + } + + // ─── Keyboard Support ──────────────────────────────────────────────────────── + + private handleKeydown(e: KeyboardEvent): void { + if (!this.valueInput) return; + if (this.mergeFieldName) return; + + // Handle edit mode keys + if (!this.valueInput.readOnly) { + if (e.key === "Enter") { + e.preventDefault(); + this.valueInput.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + // Revert and exit + this.valueInput.value = this.formatTime(this.state.value); + this.valueInput.readOnly = true; + this.valueInput.classList.remove("editing"); + this.valueInput.blur(); + } + return; + } + + // Handle scrub mode keys (arrow increment/decrement) + if (this.state.mode !== "manual") return; + + let delta = 0; + let step = 100; + if (e.shiftKey) step = 1000; + else if (e.altKey) step = 10; + + if (e.key === "ArrowUp") { + delta = step; + } else if (e.key === "ArrowDown") { + delta = -step; + } else { + return; + } + + e.preventDefault(); + this.state.value = ms(Math.max(0, this.state.value + delta)); + this.updateValueDisplay(); + this.emit(this.state); + } + + // ─── Time Formatting ───────────────────────────────────────────────────────── + + /** + * Smart time formatting (follows CapCut convention): + * - < 60s: "5.2s" (unit suffix for clarity) + * - 1min+: "1:23.4" (colon implies time) + * - 10min+: "12:34" + */ + private formatTime(msValue: Milliseconds): string { + const totalSecs = msValue / 1000; + + if (totalSecs < 60) { + // Show as seconds with unit suffix for clarity + return `${totalSecs.toFixed(1)}s`; + } + + if (totalSecs < 600) { + // Show as M:SS.t + const mins = Math.floor(totalSecs / 60); + const secs = totalSecs % 60; + return `${mins}:${secs.toFixed(1).padStart(4, "0")}`; + } + + // Show as MM:SS (no decimals for long durations) + const mins = Math.floor(totalSecs / 60); + const secs = Math.floor(totalSecs % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; + } + + /** + * Parse various time formats: + * - "5", "5.2", "5.2s" → seconds + * - "1:23", "1:23.4" → minutes:seconds + */ + private parseTimeString(str: string): number | null { + const trimmed = str.trim().toLowerCase().replace("s", ""); + + // Try M:SS.t or M:SS format + const colonMatch = trimmed.match(/^(\d+):(\d{1,2})(?:\.(\d+))?$/); + if (colonMatch) { + const mins = parseInt(colonMatch[1], 10); + const secs = parseFloat(colonMatch[2] + (colonMatch[3] ? `.${colonMatch[3]}` : "")); + return Math.round((mins * 60 + secs) * 1000); + } + + // Try plain number (seconds) + const num = parseFloat(trimmed); + if (!Number.isNaN(num)) { + return Math.round(num * 1000); + } + + return null; + } + + // ─── UI Updates ────────────────────────────────────────────────────────────── + + private updateUI(): void { + const mode = this.modes.find(m => m.id === this.state.mode) ?? this.modes[0]; + + // Update mode button icon + if (this.modeBtn) { + this.modeBtn.dataset["mode"] = mode.id; + this.modeBtn.title = mode.tooltip; + const svg = this.modeBtn.querySelector("svg"); + if (svg) { + svg.innerHTML = mode.icon; + } + } + + // Update tooltip text + if (this.tooltipEl) { + this.tooltipEl.textContent = mode.tooltip; + } + + // Update value display + this.updateValueDisplay(); + + // Update container data attribute for CSS styling + this.container?.setAttribute("data-mode", mode.id); + } + + private updateValueDisplay(): void { + if (!this.valueInput) return; + + if (this.state.mode === "manual") { + this.valueInput.value = this.formatTime(this.state.value); + this.valueInput.dataset["ms"] = this.state.value.toString(); + this.valueInput.classList.remove("auto-mode"); + } else { + this.valueInput.value = this.state.mode; + this.valueInput.classList.add("auto-mode"); + } + } + + // ─── Public API ────────────────────────────────────────────────────────────── + + /** + * Set state from clip configuration. + */ + setFromClip(value: number | "auto" | "end"): void { + if (value === "auto") { + this.state.mode = "auto"; + } else if (value === "end") { + this.state.mode = "end"; + } else { + this.state.mode = "manual"; + this.state.value = typeof value === "number" ? ms(value) : ms(0); + } + this.updateUI(); + } + + /** + * Get value for clip update (start timing). + * Returns Milliseconds | "auto" for start controls. + */ + getStartValue(): Milliseconds | "auto" { + if (this.state.mode === "auto") return "auto"; + return this.state.value; + } + + /** + * Get value for clip update (length timing). + * Returns Milliseconds | "auto" | "end" for length controls. + */ + getLengthValue(): Milliseconds | "auto" | "end" { + if (this.state.mode === "auto") return "auto"; + if (this.state.mode === "end") return "end"; + return this.state.value; + } + + /** + * Get current state. + */ + getState(): TimingControlState { + return { ...this.state }; + } + + /** + * Set merge field bound state. + * When bound, the micro-label turns teal, the value becomes non-interactive, + * and the tooltip shows the bound field name. The MergeFieldLabel component + * (mounted externally into the merge mount point) handles its own icon state. + * Pass `null` to clear. + */ + setMergeFieldBound(fieldName: string | null): void { + this.mergeFieldName = fieldName; + const isBound = fieldName !== null; + + // Toggle data attribute for CSS styling + if (isBound) { + this.container?.setAttribute("data-merge-bound", ""); + } else { + this.container?.removeAttribute("data-merge-bound"); + } + + // Update tooltip + if (this.tooltipEl) { + if (isBound) { + this.tooltipEl.textContent = `Merge field: {{ ${fieldName} }}`; + } else { + const mode = this.modes.find(m => m.id === this.state.mode) ?? this.modes[0]; + this.tooltipEl.textContent = mode.tooltip; + } + } + } + + /** + * Whether this control is currently bound to a merge field. + */ + isMergeFieldBound(): boolean { + return this.mergeFieldName !== null; + } + + /** + * Get the mount point element for an external MergeFieldLabel component. + */ + getMergeMountPoint(): HTMLElement | null { + return this.mergeMountEl; + } + + override dispose(): void { + document.removeEventListener("mousemove", this.handleMouseMove); + document.removeEventListener("mouseup", this.handleMouseUp); + if (this.tooltipTimeout) { + clearTimeout(this.tooltipTimeout); + } + super.dispose(); + } +} diff --git a/src/core/ui/composites/TransitionPanel.ts b/src/core/ui/composites/TransitionPanel.ts new file mode 100644 index 00000000..be0821b8 --- /dev/null +++ b/src/core/ui/composites/TransitionPanel.ts @@ -0,0 +1,342 @@ +import { UIComponent } from "../primitives/UIComponent"; + +/** + * State for transition in/out configuration. + */ +export interface TransitionState { + tab: "in" | "out"; + inEffect: string; + inDirection: string; + inSpeed: number; + outEffect: string; + outDirection: string; + outSpeed: number; +} + +/** + * Parsed transition value from clip config. + */ +export interface ParsedTransition { + in?: string; + out?: string; +} + +/** + * A complete transition configuration panel with In/Out tabs, + * effect selection, direction, and speed controls. + * + * This composite replaces ~150 lines of duplicated code in each toolbar + * (MediaToolbar, TextToolbar, RichTextToolbar). + * + * @example + * ```typescript + * const transitions = new TransitionPanel(); + * transitions.onChange(state => this.applyTransition(state)); + * transitions.mount(container); + * + * // Sync from clip + * transitions.setFromClip(clip.transition); + * ``` + */ +export class TransitionPanel extends UIComponent { + private static readonly EFFECTS = ["", "fade", "zoom", "slide", "wipe", "carousel"]; + private static readonly DIRECTIONS = ["Left", "Right", "Up", "Down"]; + private static readonly SPEEDS = [0.25, 0.5, 1.0, 2.0]; + + private state: TransitionState = { + tab: "in", + inEffect: "", + inDirection: "", + inSpeed: 1.0, + outEffect: "", + outDirection: "", + outSpeed: 1.0 + }; + + // DOM references + private tabButtons: NodeListOf | null = null; + private effectButtons: NodeListOf | null = null; + private directionRow: HTMLDivElement | null = null; + private directionButtons: NodeListOf | null = null; + private speedLabel: HTMLSpanElement | null = null; + private speedDecreaseBtn: HTMLButtonElement | null = null; + private speedIncreaseBtn: HTMLButtonElement | null = null; + + render(): string { + const effectButtons = TransitionPanel.EFFECTS.map(e => ``).join( + "" + ); + + const directionButtons = TransitionPanel.DIRECTIONS.map( + d => `` + ).join(""); + + return ` +
+ + +
+
${effectButtons}
+
+ Direction +
${directionButtons}
+
+
+ Speed +
+ + 1.00s + +
+
+ `; + } + + protected bindElements(): void { + this.tabButtons = this.container?.querySelectorAll("[data-tab]") ?? null; + this.effectButtons = this.container?.querySelectorAll("[data-effect]") ?? null; + this.directionRow = this.container?.querySelector("[data-direction-row]") ?? null; + this.directionButtons = this.container?.querySelectorAll("[data-dir]") ?? null; + this.speedLabel = this.container?.querySelector(".ss-transition-speed-value") ?? null; + this.speedDecreaseBtn = this.container?.querySelector("[data-speed-decrease]") ?? null; + this.speedIncreaseBtn = this.container?.querySelector("[data-speed-increase]") ?? null; + } + + protected setupEvents(): void { + // Tab switching + this.events.onAll(this.tabButtons!, "click", (_, el) => { + this.state.tab = (el as HTMLElement).dataset["tab"] as "in" | "out"; + this.updateUI(); + }); + + // Effect selection + this.events.onAll(this.effectButtons!, "click", (_, el) => { + const effect = (el as HTMLElement).dataset["effect"] ?? ""; + this.setCurrentEffect(effect); + this.updateUI(); + this.emit(this.state); + }); + + // Direction selection + this.events.onAll(this.directionButtons!, "click", (_, el) => { + const dir = (el as HTMLElement).dataset["dir"] ?? ""; + this.setCurrentDirection(dir); + this.updateUI(); + this.emit(this.state); + }); + + // Speed controls + this.events.on(this.speedDecreaseBtn, "click", e => { + e.stopPropagation(); + this.stepSpeed(-1); + }); + this.events.on(this.speedIncreaseBtn, "click", e => { + e.stopPropagation(); + this.stepSpeed(1); + }); + } + + /** + * Set state from parsed clip transition. + */ + setFromClip(transition: ParsedTransition | undefined): void { + const parsedIn = this.parseTransitionValue(transition?.in ?? ""); + const parsedOut = this.parseTransitionValue(transition?.out ?? ""); + + this.state.inEffect = parsedIn.effect; + this.state.inDirection = parsedIn.direction; + this.state.inSpeed = parsedIn.speed; + this.state.outEffect = parsedOut.effect; + this.state.outDirection = parsedOut.direction; + this.state.outSpeed = parsedOut.speed; + + this.updateUI(); + } + + /** + * Get the transition value for clip update. + */ + getClipValue(): ParsedTransition | undefined { + const transitionIn = this.buildTransitionValue(this.state.inEffect, this.state.inDirection, this.state.inSpeed); + const transitionOut = this.buildTransitionValue(this.state.outEffect, this.state.outDirection, this.state.outSpeed); + + if (!transitionIn && !transitionOut) { + return undefined; + } + + const result: ParsedTransition = {}; + if (transitionIn) result.in = transitionIn; + if (transitionOut) result.out = transitionOut; + return result; + } + + /** + * Get current state. + */ + getState(): TransitionState { + return { ...this.state }; + } + + // ─── Private Methods ───────────────────────────────────────────────────── + + private setCurrentEffect(effect: string): void { + if (this.state.tab === "in") { + this.state.inEffect = effect; + this.state.inDirection = this.needsDirection(effect) ? "Right" : ""; + } else { + this.state.outEffect = effect; + this.state.outDirection = this.needsDirection(effect) ? "Right" : ""; + } + } + + private setCurrentDirection(direction: string): void { + if (this.state.tab === "in") { + this.state.inDirection = direction; + } else { + this.state.outDirection = direction; + } + } + + private stepSpeed(direction: number): void { + const speeds = TransitionPanel.SPEEDS; + const currentSpeed = this.state.tab === "in" ? this.state.inSpeed : this.state.outSpeed; + + let currentIdx = speeds.indexOf(currentSpeed); + if (currentIdx === -1) { + currentIdx = speeds.findIndex(v => v >= currentSpeed); + if (currentIdx === -1) currentIdx = speeds.length - 1; + } + + const newIdx = Math.max(0, Math.min(speeds.length - 1, currentIdx + direction)); + const newSpeed = speeds[newIdx]; + + if (this.state.tab === "in") { + this.state.inSpeed = newSpeed; + } else { + this.state.outSpeed = newSpeed; + } + + this.updateUI(); + this.emit(this.state); + } + + private needsDirection(effect: string): boolean { + return ["slide", "wipe", "carousel"].includes(effect); + } + + private updateUI(): void { + const { tab } = this.state; + const effect = tab === "in" ? this.state.inEffect : this.state.outEffect; + const direction = tab === "in" ? this.state.inDirection : this.state.outDirection; + const speed = tab === "in" ? this.state.inSpeed : this.state.outSpeed; + + // Update tabs + this.tabButtons?.forEach(btn => { + btn.classList.toggle("active", btn.dataset["tab"] === tab); + }); + + // Update effects + this.effectButtons?.forEach(btn => { + btn.classList.toggle("active", btn.dataset["effect"] === effect); + }); + + // Show/hide direction row + const showDirection = this.needsDirection(effect); + this.directionRow?.classList.toggle("visible", showDirection); + + // Update directions + this.directionButtons?.forEach(btn => { + const dir = btn.dataset["dir"] ?? ""; + // Hide vertical directions for wipe + btn.classList.toggle("hidden", effect === "wipe" && (dir === "Up" || dir === "Down")); + btn.classList.toggle("active", dir === direction); + }); + + // Update speed + if (this.speedLabel) { + this.speedLabel.textContent = `${speed.toFixed(2)}s`; + } + + // Update stepper states + const speedIdx = TransitionPanel.SPEEDS.indexOf(speed); + if (this.speedDecreaseBtn) this.speedDecreaseBtn.disabled = speedIdx <= 0; + if (this.speedIncreaseBtn) this.speedIncreaseBtn.disabled = speedIdx >= TransitionPanel.SPEEDS.length - 1; + } + + // ─── Transition Value Parsing/Building ─────────────────────────────────── + + private parseTransitionValue(value: string): { effect: string; direction: string; speed: number } { + if (!value) return { effect: "", direction: "", speed: 1.0 }; + + let speedSuffix = ""; + let base = value; + if (value.endsWith("Fast")) { + speedSuffix = "Fast"; + base = value.slice(0, -4); + } else if (value.endsWith("Slow")) { + speedSuffix = "Slow"; + base = value.slice(0, -4); + } + + const directions = ["Left", "Right", "Up", "Down"]; + for (const dir of directions) { + if (base.endsWith(dir)) { + const effect = base.slice(0, -dir.length); + const speed = this.suffixToSpeed(speedSuffix, effect); + return { effect, direction: dir, speed }; + } + } + + const speed = this.suffixToSpeed(speedSuffix, base); + return { effect: base, direction: "", speed }; + } + + private buildTransitionValue(effect: string, direction: string, speed: number): string { + if (!effect) return ""; + + const speedSuffix = this.speedToSuffix(speed, effect); + + if (!this.needsDirection(effect)) { + return effect + speedSuffix; + } + + return effect + direction + speedSuffix; + } + + private speedToSuffix(speed: number, effect: string): string { + const isSlideOrCarousel = effect === "slide" || effect === "carousel"; + + if (isSlideOrCarousel) { + if (speed === 0.5) return ""; + if (speed === 1.0) return "Slow"; + if (speed === 0.25) return "Fast"; + if (speed === 2.0) return "Slow"; + } else { + if (speed === 1.0) return ""; + if (speed === 2.0) return "Slow"; + if (speed === 0.5) return "Fast"; + if (speed === 0.25) return "Fast"; + } + return ""; + } + + private suffixToSpeed(suffix: string, effect: string): number { + const isSlideOrCarousel = effect === "slide" || effect === "carousel"; + + if (isSlideOrCarousel) { + if (suffix === "") return 0.5; + if (suffix === "Slow") return 1.0; + if (suffix === "Fast") return 0.25; + } else { + if (suffix === "") return 1.0; + if (suffix === "Slow") return 2.0; + if (suffix === "Fast") return 0.5; + } + return 1.0; + } + + private directionIcon(dir: string): string { + const icons: Record = { Left: "←", Right: "→", Up: "↑", Down: "↓" }; + return icons[dir] ?? ""; + } +} diff --git a/src/core/ui/composites/index.ts b/src/core/ui/composites/index.ts new file mode 100644 index 00000000..a53fca99 --- /dev/null +++ b/src/core/ui/composites/index.ts @@ -0,0 +1,9 @@ +// Composite components - pre-built UI sections that eliminate toolbar duplication +export { EffectPanel } from "./EffectPanel"; +export type { EffectState } from "./EffectPanel"; + +export { TransitionPanel } from "./TransitionPanel"; +export type { TransitionState, ParsedTransition } from "./TransitionPanel"; + +export { SpacingPanel } from "./SpacingPanel"; +export type { SpacingState, SpacingPanelConfig } from "./SpacingPanel"; diff --git a/src/core/ui/drag-state-manager.ts b/src/core/ui/drag-state-manager.ts new file mode 100644 index 00000000..c063033c --- /dev/null +++ b/src/core/ui/drag-state-manager.ts @@ -0,0 +1,96 @@ +import type { ResolvedClip } from "@schemas"; + +// Polyfill for structuredClone (for older environments like Jest) +const clone = (obj: T): T => { + if (typeof structuredClone === "function") { + return structuredClone(obj); + } + return JSON.parse(JSON.stringify(obj)) as T; +}; + +/** + * Represents an active drag session for a specific UI control. + */ +export interface DragSession { + /** The clip being modified during this drag */ + clipId: string; + /** Snapshot of the clip's resolved state when the drag started */ + initialState: ResolvedClip; + /** Timestamp when the drag session started (for debugging/telemetry) */ + startTime: number; +} + +/** + * Holds per-control drag sessions so the toolbar can snapshot clip state on + * pointerdown and commit a single undo entry on release. + */ +export class DragStateManager { + private sessions = new Map(); + + /** + * Start a new drag session for the specified control. + * + * @param controlId - Unique identifier for the control (e.g., "background-opacity") + * @param clipId - ID of the clip being modified + * @param initialState - Snapshot of the clip's resolved state at drag start + */ + start(controlId: string, clipId: string, initialState: ResolvedClip): void { + this.sessions.set(controlId, { + clipId, + initialState: clone(initialState), + startTime: Date.now() + }); + } + + /** + * Get the active drag session for a control, if one exists. + * + * @param controlId - Unique identifier for the control + * @returns The active drag session, or null if no session exists + */ + get(controlId: string): DragSession | null { + return this.sessions.get(controlId) ?? null; + } + + /** + * End the drag session for a control and return its state. + * + * @param controlId - Unique identifier for the control + * @returns The drag session that was ended, or null if no session existed + */ + end(controlId: string): DragSession | null { + const session = this.sessions.get(controlId); + this.sessions.delete(controlId); + return session ?? null; + } + + /** + * Clear drag sessions. Can clear all sessions or only those for a specific clip. + * + * @param clipId - Optional clip ID to filter sessions. If provided, only clears + * sessions for that clip. If omitted, clears all sessions. + */ + clear(clipId?: string): void { + if (clipId) { + // Clear sessions for specific clip (when clip selection changes) + for (const [id, session] of this.sessions.entries()) { + if (session.clipId === clipId) { + this.sessions.delete(id); + } + } + } else { + // Clear all sessions + this.sessions.clear(); + } + } + + /** + * Check if a control is currently in a drag session. + * + * @param controlId - Unique identifier for the control + * @returns true if the control has an active drag session + */ + isDragging(controlId: string): boolean { + return this.sessions.has(controlId); + } +} diff --git a/src/core/ui/font-color-picker.ts b/src/core/ui/font-color-picker.ts new file mode 100644 index 00000000..65792e97 --- /dev/null +++ b/src/core/ui/font-color-picker.ts @@ -0,0 +1,552 @@ +import { createThrottle } from "@core/shared/utils"; +import { injectShotstackStyles } from "@styles/inject"; + +type GradientPreset = { + type: "linear"; + angle: number; + stops: Array<{ offset: number; color: string }>; +}; + +const GRADIENT_PRESETS: Array<{ name: string; gradients: GradientPreset[] }> = [ + { + name: "Cool Tones", + gradients: [ + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#8B5CF6" }, + { offset: 1, color: "#06B6D4" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#3B82F6" }, + { offset: 1, color: "#8B5CF6" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#06B6D4" }, + { offset: 1, color: "#3B82F6" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#3B82F6" }, + { offset: 1, color: "#6366F1" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#06B6D4" }, + { offset: 1, color: "#14B8A6" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#0EA5E9" }, + { offset: 1, color: "#38BDF8" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#8B5CF6" }, + { offset: 0.5, color: "#3B82F6" }, + { offset: 1, color: "#06B6D4" } + ] + } + ] + }, + { + name: "Warm Tones", + gradients: [ + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#EF4444" }, + { offset: 1, color: "#F97316" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#F97316" }, + { offset: 1, color: "#EAB308" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#EC4899" }, + { offset: 1, color: "#F43F5E" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#EF4444" }, + { offset: 1, color: "#EC4899" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#F97316" }, + { offset: 1, color: "#F59E0B" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#EC4899" }, + { offset: 1, color: "#F97316" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#8B5CF6" }, + { offset: 0.5, color: "#EC4899" }, + { offset: 1, color: "#EAB308" } + ] + } + ] + }, + { + name: "Monochromatic", + gradients: [ + // Neutrals row + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#878274" }, + { offset: 1, color: "#24221a" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#dfdcda" }, + { offset: 1, color: "#858176" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#fffcf5" }, + { offset: 1, color: "#d8d5ca" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#feffff" }, + { offset: 1, color: "#c5c5c5" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#e9f0f3" }, + { offset: 1, color: "#a2a5ac" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#a5acb9" }, + { offset: 1, color: "#303643" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#6d7486" }, + { offset: 1, color: "#0a0d13" } + ] + }, + // Dark to light colors row + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#731919" }, + { offset: 1, color: "#e52b2b" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#963e15" }, + { offset: 1, color: "#f4773e" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#997300" }, + { offset: 1, color: "#ffc000" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#226214" }, + { offset: 1, color: "#43cc25" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#004d48" }, + { offset: 1, color: "#3ff3e7" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#001f65" }, + { offset: 1, color: "#6895fd" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#450050" }, + { offset: 1, color: "#e753fe" } + ] + }, + // Pastels row + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#ff6767" }, + { offset: 1, color: "#ffd1d1" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#ff9869" }, + { offset: 1, color: "#ffd2bd" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#ffda6a" }, + { offset: 1, color: "#fff7de" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#7cce6b" }, + { offset: 1, color: "#d8ffd0" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#7af6ee" }, + { offset: 1, color: "#eafffe" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#84a9ff" }, + { offset: 1, color: "#f5f8ff" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#f093ff" }, + { offset: 1, color: "#fdf1ff" } + ] + } + ] + } +]; + +type ColorMode = "color" | "gradient"; +type FontColorChangeCallback = (updates: { + color?: string; + opacity?: number; + background?: string; + gradient?: { type: "linear" | "radial"; angle: number; stops: Array<{ offset: number; color: string }> }; +}) => void; + +export class FontColorPicker { + private container: HTMLDivElement | null = null; + + // Tab buttons + private colorTab: HTMLButtonElement | null = null; + private gradientTab: HTMLButtonElement | null = null; + + // Tab content containers + private colorContent: HTMLDivElement | null = null; + private gradientContent: HTMLDivElement | null = null; + + // Color tab elements + private colorInput: HTMLInputElement | null = null; + private colorOpacitySlider: HTMLInputElement | null = null; + private colorOpacityValue: HTMLSpanElement | null = null; + + // Highlight elements (in color tab) + private highlightColorInput: HTMLInputElement | null = null; + + private onColorChange: FontColorChangeCallback | null = null; + + // Throttle instances for rate-limiting slider updates (~20 updates/sec max) + private colorThrottle = createThrottle(() => this.emitColorChange(), 50); + private highlightThrottle = createThrottle(() => this.emitHighlightChange(), 50); + + // Arrow function handlers for proper cleanup (like BackgroundColorPicker pattern) + private handleColorInputChange = (): void => { + this.colorThrottle.call(); + }; + + private handleColorOpacityInput = (e: Event): void => { + const opacity = parseInt((e.target as HTMLInputElement).value, 10); + if (this.colorOpacityValue) { + this.colorOpacityValue.textContent = `${opacity}%`; + } + this.colorThrottle.call(); + }; + + private handleHighlightInputChange = (): void => { + this.highlightThrottle.call(); + }; + + // Flush throttle on slider release (change event) to ensure final value is applied + private handleColorOpacityChange = (): void => { + this.colorThrottle.flush(); + }; + + private handleTabClick = (mode: ColorMode): void => { + this.setMode(mode); + }; + + constructor() { + injectShotstackStyles(); + } + + mount(parent: HTMLElement): void { + this.container = document.createElement("div"); + this.container.className = "ss-font-color-picker"; + + this.container.innerHTML = ` +
+ + +
+ +
+
+
Color
+ +
+
+
Opacity
+
+ + 100% +
+
+
+
Highlight
+ +
+
+ +
+ ${this.buildGradientHTML()} +
+ `; + + parent.appendChild(this.container); + + // Query tab buttons + this.colorTab = this.container.querySelector('[data-tab="color"]'); + this.gradientTab = this.container.querySelector('[data-tab="gradient"]'); + + // Query tab content + this.colorContent = this.container.querySelector('[data-content="color"]'); + this.gradientContent = this.container.querySelector('[data-content="gradient"]'); + + // Query color elements + this.colorInput = this.container.querySelector("[data-color-input]"); + this.colorOpacitySlider = this.container.querySelector("[data-color-opacity]"); + this.colorOpacityValue = this.container.querySelector("[data-color-opacity-value]"); + + // Query highlight elements + this.highlightColorInput = this.container.querySelector("[data-highlight-color]"); + + // Setup event listeners using arrow function handlers for proper cleanup + this.colorTab?.addEventListener("click", () => this.handleTabClick("color")); + this.gradientTab?.addEventListener("click", () => this.handleTabClick("gradient")); + + // Color input: throttle on input, immediate flush on blur + this.colorInput?.addEventListener("input", this.handleColorInputChange); + + // Opacity slider: throttle on input, flush on change (mouse release) + this.colorOpacitySlider?.addEventListener("input", this.handleColorOpacityInput); + this.colorOpacitySlider?.addEventListener("change", this.handleColorOpacityChange); + + // Highlight color: throttle on input + this.highlightColorInput?.addEventListener("input", this.handleHighlightInputChange); + + // Setup gradient swatch click handlers + this.container.querySelectorAll("[data-cat]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLButtonElement; + this.handleGradientClick(parseInt(el.dataset["cat"] || "0", 10), parseInt(el.dataset["idx"] || "0", 10)); + }); + }); + } + + private emitColorChange(): void { + if (this.onColorChange && this.colorInput && this.colorOpacitySlider) { + const color = this.colorInput.value; + const opacity = parseInt(this.colorOpacitySlider.value, 10) / 100; + this.onColorChange({ color, opacity }); + } + } + + private emitHighlightChange(): void { + if (this.onColorChange && this.highlightColorInput) { + const background = this.highlightColorInput.value; + this.onColorChange({ background }); + } + } + + private buildCSSGradient(gradient: GradientPreset): string { + const stops = gradient.stops.map(s => `${s.color} ${Math.round(s.offset * 100)}%`).join(", "); + return `linear-gradient(${gradient.angle}deg, ${stops})`; + } + + private buildGradientHTML(): string { + let html = ""; + GRADIENT_PRESETS.forEach((category, catIdx) => { + html += `
+
${category.name}
+
`; + category.gradients.forEach((g, idx) => { + html += ``; + }); + html += `
`; + }); + return html; + } + + private handleGradientClick(catIdx: number, idx: number): void { + const gradient = GRADIENT_PRESETS[catIdx]?.gradients[idx]; + if (gradient && this.onColorChange) { + this.onColorChange({ gradient: { type: gradient.type, angle: gradient.angle, stops: gradient.stops } }); + } + } + + setMode(mode: ColorMode): void { + // Update tab buttons + if (mode === "color") { + this.colorTab?.classList.add("active"); + this.gradientTab?.classList.remove("active"); + this.colorContent?.classList.add("active"); + this.gradientContent?.classList.remove("active"); + } else { + this.colorTab?.classList.remove("active"); + this.gradientTab?.classList.add("active"); + this.colorContent?.classList.remove("active"); + this.gradientContent?.classList.add("active"); + } + } + + setColor(color: string, opacity: number): void { + if (this.colorInput) { + this.colorInput.value = color.toUpperCase(); + } + const opacityPercent = Math.round(Math.max(0, Math.min(100, opacity * 100))); + if (this.colorOpacitySlider) { + this.colorOpacitySlider.value = String(opacityPercent); + } + if (this.colorOpacityValue) { + this.colorOpacityValue.textContent = `${opacityPercent}%`; + } + } + + setHighlight(color: string): void { + if (this.highlightColorInput) { + this.highlightColorInput.value = color.toUpperCase(); + } + } + + onChange(callback: FontColorChangeCallback): void { + this.onColorChange = callback; + } + + dispose(): void { + // Cancel throttles to prevent any pending callbacks after disposal + this.colorThrottle.cancel(); + this.highlightThrottle.cancel(); + + // Remove event listeners before destroying references + this.colorInput?.removeEventListener("input", this.handleColorInputChange); + this.colorOpacitySlider?.removeEventListener("input", this.handleColorOpacityInput); + this.colorOpacitySlider?.removeEventListener("change", this.handleColorOpacityChange); + this.highlightColorInput?.removeEventListener("input", this.handleHighlightInputChange); + + this.container?.remove(); + this.container = null; + this.colorTab = null; + this.gradientTab = null; + this.colorContent = null; + this.gradientContent = null; + this.colorInput = null; + this.colorOpacitySlider = null; + this.colorOpacityValue = null; + this.highlightColorInput = null; + this.onColorChange = null; + } +} diff --git a/src/core/ui/font-picker.ts b/src/core/ui/font-picker.ts new file mode 100644 index 00000000..768139bf --- /dev/null +++ b/src/core/ui/font-picker.ts @@ -0,0 +1,539 @@ +/** + * Font Picker Component + * + * A refined, editorial-style font picker for the video editing SDK. + * Features search, category filtering, recently used fonts, and + * virtual scrolling for smooth performance with 200+ fonts. + * + * Trackpad scrolling works because the picker is mounted inside a + * `.ss-toolbar-popup` ancestor, which is exempted from the canvas's + * capturing wheel handler (shotstack-canvas.ts `onWheel`). + */ + +import { GOOGLE_FONTS_BY_FILENAME, GOOGLE_FONTS_BY_NAME, type FontInfo, type GoogleFontCategory } from "../fonts/google-fonts"; + +import { getFontPreviewLoader } from "./font-preview-loader"; +import { VirtualFontList } from "./virtual-font-list"; + +// Re-export for convenience +export type { FontInfo } from "../fonts/google-fonts"; + +/** LocalStorage key for recently used fonts */ +const RECENT_FONTS_KEY = "ss-recent-fonts"; + +/** Maximum number of recent fonts to track */ +const MAX_RECENT_FONTS = 6; + +/** Icon fonts that should not appear in recently used (they don't render as text) */ +const ICON_FONT_NAMES = new Set(["Material Icons", "Material Symbols Outlined", "Material Symbols Rounded", "Material Symbols Sharp"]); + +/** Check if a font URL is from Google Fonts */ +const isGoogleFont = (src: string): boolean => src.includes("fonts.gstatic.com"); + +/** Check if a font URL is a built-in Shotstack font */ +const isBuiltInFont = (src: string): boolean => src.includes("templates.shotstack.io"); + +/** Check if a font URL is a custom font (not Google or built-in) */ +const isCustomFont = (src: string): boolean => !isGoogleFont(src) && !isBuiltInFont(src); + +/** Extract display name from a font URL */ +const extractFontDisplayName = (url: string): string => { + const filename = url.split("/").pop() ?? ""; + const withoutExtension = filename.replace(/\.(ttf|otf|woff|woff2)$/i, ""); + // Remove weight suffixes like -Bold, -Regular, etc. + const baseFamily = withoutExtension.replace(/-(Bold|Light|Regular|Italic|Medium|SemiBold|Black|Thin|ExtraLight|ExtraBold|Heavy)$/i, ""); + return baseFamily; +}; + +export interface FontPickerOptions { + /** Currently selected font filename */ + selectedFilename?: string; + /** Callback when a font is selected */ + onSelect?: (font: FontInfo) => void; + /** Callback when picker is closed */ + onClose?: () => void; + /** Timeline fonts for detecting custom fonts */ + timelineFonts?: Array<{ src: string }>; + /** Font metadata from binary parsing (URL → family name) */ + fontMetadata?: ReadonlyMap; +} + +/** + * Font Picker UI component. + * Renders a dropdown with search, categories, and scrollable font list. + */ +export class FontPicker { + private element: HTMLElement; + private searchInput: HTMLInputElement; + private categoryTabs: HTMLElement; + private recentSection: HTMLElement; + private customSection: HTMLElement; + private customDivider: HTMLElement; + private listContainer: HTMLElement; + private virtualList: VirtualFontList; + private selectedFilename?: string; + private onSelect?: (font: FontInfo) => void; + private onClose?: () => void; + private activeCategory?: GoogleFontCategory; + private searchQuery = ""; + private recentFonts: string[] = []; + private timelineFonts: Array<{ src: string }> = []; + private fontMetadata?: ReadonlyMap; + private fontLoader = getFontPreviewLoader(); + + constructor(options: FontPickerOptions) { + this.selectedFilename = options.selectedFilename; + this.onSelect = options.onSelect; + this.onClose = options.onClose; + this.timelineFonts = options.timelineFonts ?? []; + this.fontMetadata = options.fontMetadata; + + this.loadRecentFonts(); + this.element = this.createElement(); + this.searchInput = this.element.querySelector(".ss-font-picker-search-input") as HTMLInputElement; + this.categoryTabs = this.element.querySelector(".ss-font-picker-categories") as HTMLElement; + this.recentSection = this.element.querySelector(".ss-font-picker-recent") as HTMLElement; + this.customSection = this.element.querySelector(".ss-font-picker-custom") as HTMLElement; + this.customDivider = this.element.querySelector(".ss-font-picker-custom-divider") as HTMLElement; + this.listContainer = this.element.querySelector(".ss-font-picker-list") as HTMLElement; + + // Create virtual list + this.virtualList = new VirtualFontList({ + container: this.listContainer, + selectedFilename: this.selectedFilename, + onSelect: font => this.handleFontSelect(font) + }); + + // Update sections + this.updateRecentSection(); + this.updateCustomSection(); + + // Focus search on open + requestAnimationFrame(() => { + this.searchInput.focus(); + }); + } + + /** + * Get the picker element for mounting. + */ + getElement(): HTMLElement { + return this.element; + } + + /** + * Set the currently selected font. + */ + setSelected(filename?: string): void { + this.selectedFilename = filename; + this.virtualList.setSelected(filename); + + if (filename) { + this.virtualList.scrollToFont(filename); + } + } + + /** + * Create the picker DOM structure. + */ + private createElement(): HTMLElement { + const picker = document.createElement("div"); + picker.className = "ss-font-picker"; + + picker.innerHTML = ` +
+ +
+ + + + + + +
+
+
+
+
+
+
+ + `; + + // Bind events + this.bindEvents(picker); + + return picker; + } + + /** + * Bind event listeners. + */ + private bindEvents(picker: HTMLElement): void { + // Search input + const searchInput = picker.querySelector(".ss-font-picker-search-input") as HTMLInputElement; + searchInput.addEventListener("input", () => { + this.searchQuery = searchInput.value; + this.applyFilter(); + }); + + // Clear search on escape + searchInput.addEventListener("keydown", e => { + if (e.key === "Escape") { + if (this.searchQuery) { + this.searchQuery = ""; + searchInput.value = ""; + this.applyFilter(); + } else { + this.onClose?.(); + } + } + }); + + // Category tabs + const categoryButtons = picker.querySelectorAll(".ss-font-picker-category"); + categoryButtons.forEach(button => { + button.addEventListener("click", () => { + const category = (button as HTMLElement).dataset["category"] as GoogleFontCategory | ""; + this.setCategory(category || undefined); + + // Update active state + categoryButtons.forEach(b => b.classList.remove("ss-font-picker-category--active")); + button.classList.add("ss-font-picker-category--active"); + }); + }); + + // Prevent clicks from closing (handled by parent) + picker.addEventListener("click", e => { + e.stopPropagation(); + }); + } + + /** + * Set the active category filter. + */ + private setCategory(category?: GoogleFontCategory): void { + this.activeCategory = category; + this.applyFilter(); + } + + /** + * Apply current search and category filters. + */ + private applyFilter(): void { + this.virtualList.setFilter(this.searchQuery, this.activeCategory); + this.updateFooter(); + + // Hide sections when searching + if (this.searchQuery) { + this.recentSection.style.display = "none"; + this.customSection.style.display = "none"; + (this.element.querySelector(".ss-font-picker-divider") as HTMLElement).style.display = "none"; + this.customDivider.style.display = "none"; + } else { + this.recentSection.style.display = ""; + (this.element.querySelector(".ss-font-picker-divider") as HTMLElement).style.display = ""; + // Re-evaluate custom section visibility + this.updateCustomSection(); + } + } + + /** + * Update the footer with font count. + */ + private updateFooter(): void { + const count = this.virtualList.fontCount; + const footer = this.element.querySelector(".ss-font-picker-count") as HTMLElement; + footer.textContent = `${count} font${count !== 1 ? "s" : ""}`; + } + + /** + * Handle font selection. + */ + private handleFontSelect(font: FontInfo): void { + this.selectedFilename = font.filename; + this.addToRecentFonts(font.displayName); + this.onSelect?.(font); + } + + /** + * Load recently used fonts from localStorage. + */ + private loadRecentFonts(): void { + try { + const stored = localStorage.getItem(RECENT_FONTS_KEY); + if (stored) { + this.recentFonts = JSON.parse(stored); + } + } catch { + this.recentFonts = []; + } + } + + /** + * Add a font to the recently used list. + */ + private addToRecentFonts(fontName: string): void { + // Don't track icon fonts - they don't render as text + if (ICON_FONT_NAMES.has(fontName)) return; + + // Remove if already exists + this.recentFonts = this.recentFonts.filter(f => f !== fontName); + + // Add to front + this.recentFonts.unshift(fontName); + + // Limit size + if (this.recentFonts.length > MAX_RECENT_FONTS) { + this.recentFonts = this.recentFonts.slice(0, MAX_RECENT_FONTS); + } + + // Save to localStorage + try { + localStorage.setItem(RECENT_FONTS_KEY, JSON.stringify(this.recentFonts)); + } catch { + // Ignore storage errors + } + + // Update UI + this.updateRecentSection(); + } + + /** + * Update the recently used fonts section. + */ + private updateRecentSection(): void { + if (this.recentFonts.length === 0) { + this.recentSection.style.display = "none"; + (this.element.querySelector(".ss-font-picker-divider") as HTMLElement).style.display = "none"; + return; + } + + this.recentSection.style.display = ""; + (this.element.querySelector(".ss-font-picker-divider") as HTMLElement).style.display = ""; + + this.recentSection.innerHTML = ` +
Recently Used
+
+ `; + + const list = this.recentSection.querySelector(".ss-font-picker-recent-list") as HTMLElement; + + // Filter out icon fonts and fonts not found in the registry + const validFonts = this.recentFonts + .filter(fontName => !ICON_FONT_NAMES.has(fontName)) + .map(fontName => ({ fontName, font: GOOGLE_FONTS_BY_NAME.get(fontName) })) + .filter((entry): entry is { fontName: string; font: FontInfo } => entry.font !== undefined); + + for (const { fontName, font } of validFonts) { + const chip = document.createElement("button"); + chip.className = "ss-font-picker-recent-chip"; + chip.style.fontFamily = `"${fontName}", system-ui, sans-serif`; + chip.textContent = fontName; + + if (font.filename === this.selectedFilename) { + chip.classList.add("ss-font-picker-recent-chip--selected"); + } + + // Preload the font for preview + this.fontLoader.preload(fontName); + + // Add loaded class when font loads + this.fontLoader.onLoad(fontName, () => { + chip.classList.add("ss-font-picker-recent-chip--loaded"); + }); + + chip.addEventListener("click", () => { + this.handleFontSelect(font); + }); + + list.appendChild(chip); + } + } + + /** + * Resolve the display name for a custom font URL. + * Prefers the binary-parsed family name from fontMetadata, falls back to URL filename extraction. + */ + private resolveCustomFontName(src: string): string { + const name = this.fontMetadata?.get(src)?.baseFamilyName ?? extractFontDisplayName(src); + return name.replace(/^["']+|["']+$/g, ""); + } + + /** + * Update the custom fonts section. + * Shows non-Google fonts from timeline.fonts. + */ + private updateCustomSection(): void { + // Get custom fonts from timeline (non-Google, non-built-in) + const customFonts = this.timelineFonts.filter(font => isCustomFont(font.src)); + + // Hide section if no custom fonts + if (customFonts.length === 0) { + this.customSection.style.display = "none"; + this.customDivider.style.display = "none"; + return; + } + + this.customSection.style.display = ""; + this.customDivider.style.display = ""; + + this.customSection.innerHTML = ` +
Custom Fonts
+
+ `; + + const list = this.customSection.querySelector(".ss-font-picker-custom-list") as HTMLElement; + + for (const font of customFonts) { + const displayName = this.resolveCustomFontName(font.src); + const item = this.createCustomFontItem(font.src, displayName); + list.appendChild(item); + } + } + + /** + * Create a custom font item element. + */ + private createCustomFontItem(src: string, displayName: string): HTMLElement { + const item = document.createElement("div"); + item.className = "ss-font-picker-custom-item"; + item.dataset["fontSrc"] = src; + + // Font name preview (shows text in the font itself) + const name = document.createElement("span"); + name.className = "ss-font-picker-custom-item-name"; + name.textContent = displayName; + name.style.fontFamily = `"${displayName}", system-ui, sans-serif`; + item.appendChild(name); + + // "Custom" badge + const badge = document.createElement("span"); + badge.className = "ss-font-picker-custom-item-badge"; + badge.textContent = "Custom"; + item.appendChild(badge); + + // Check if this is the selected font + if (this.selectedFilename && this.isMatchingCustomFont(src, this.selectedFilename)) { + item.classList.add("ss-font-picker-custom-item--selected"); + } + + // Load font for preview + this.loadCustomFontForPreview(src, displayName, item); + + // Click handler + item.addEventListener("click", () => { + this.handleCustomFontSelect(src, displayName); + }); + + return item; + } + + /** + * Check if a font URL matches the selected filename. + */ + private isMatchingCustomFont(src: string, selectedFilename: string): boolean { + const binaryName = this.fontMetadata?.get(src)?.baseFamilyName; + if (binaryName && binaryName === selectedFilename) return true; + + const urlFilename = + src + .split("/") + .pop() + ?.replace(/\.(ttf|otf|woff|woff2)$/i, "") ?? ""; + const displayName = extractFontDisplayName(src); + return urlFilename === selectedFilename || displayName === selectedFilename; + } + + /** + * Load a custom font for preview using the FontFace API. + */ + private async loadCustomFontForPreview(src: string, displayName: string, item: HTMLElement): Promise { + try { + // Check if font is already loaded + if (document.fonts.check(`16px "${displayName}"`)) { + item.classList.add("ss-font-picker-custom-item--loaded"); + return; + } + + // Load the font via FontFace API + const fontFace = new FontFace(displayName, `url(${src})`, { + weight: "400", + style: "normal" + }); + + await fontFace.load(); + document.fonts.add(fontFace); + item.classList.add("ss-font-picker-custom-item--loaded"); + } catch { + // Failed to load - still show but without font preview + item.classList.add("ss-font-picker-custom-item--loaded"); + } + } + + /** + * Handle custom font selection. + */ + private handleCustomFontSelect(src: string, displayName: string): void { + // Create a FontInfo object for consistency + // Custom fonts are assumed to support variable weights (user controls the font file) + const resolvedName = this.resolveCustomFontName(src); + const customFont: FontInfo = { + displayName, + filename: resolvedName, + category: "sans-serif", // Default category for custom fonts + url: src, + weight: 400, + isVariable: true + }; + + this.selectedFilename = customFont.filename; + // Don't add to recent fonts (custom fonts are already special) + this.onSelect?.(customFont); + } + + /** + * Clean up resources. + */ + destroy(): void { + this.virtualList.destroy(); + this.element.remove(); + } +} + +/** + * Get the display name for a font filename. + * Used to show human-readable name in the toolbar. + */ +export function getFontDisplayName(filename: string): string { + const font = GOOGLE_FONTS_BY_FILENAME.get(filename); + return font?.displayName ?? filename; +} + +/** + * Get a FontInfo by its filename. + */ +export function getFontByFilename(filename: string): FontInfo | undefined { + return GOOGLE_FONTS_BY_FILENAME.get(filename); +} + +/** + * Get a FontInfo by its display name. + */ +export function getFontByName(name: string): FontInfo | undefined { + return GOOGLE_FONTS_BY_NAME.get(name); +} diff --git a/src/core/ui/font-preview-loader.ts b/src/core/ui/font-preview-loader.ts new file mode 100644 index 00000000..a391620e --- /dev/null +++ b/src/core/ui/font-preview-loader.ts @@ -0,0 +1,237 @@ +/** + * Font Preview Loader + * + * Lazily loads Google Fonts as they become visible in the viewport. + * Uses IntersectionObserver for visibility detection and a loading queue + * to limit concurrent font downloads and prevent browser freezing. + */ + +import { GOOGLE_FONTS_BY_NAME } from "../fonts/google-fonts"; + +/** Maximum concurrent font downloads */ +const MAX_CONCURRENT_LOADS = 3; + +/** Preload fonts when within this distance of viewport */ +const ROOT_MARGIN = "100px"; + +/** + * Manages lazy loading of Google Fonts for preview purposes. + * Uses IntersectionObserver to detect when font items enter the viewport + * and loads them with a concurrency limit. + */ +export class FontPreviewLoader { + private loadingQueue: string[] = []; + private loadedFonts = new Set(); + private pendingFonts = new Set(); + private activeLoads = 0; + private observer: IntersectionObserver | null = null; + private observerRoot: Element | null = null; + private callbacks = new Map void>>(); + + constructor() { + this.createObserver(null); + } + + /** + * Set the root element for the IntersectionObserver. + * Call this when using the loader inside a scrollable container. + */ + setRoot(root: Element | null): void { + if (this.observerRoot === root) return; + this.observerRoot = root; + this.createObserver(root); + } + + /** + * Create or recreate the IntersectionObserver with the given root. + */ + private createObserver(root: Element | null): void { + if (this.observer) { + this.observer.disconnect(); + } + this.observer = new IntersectionObserver( + entries => { + for (const entry of entries) { + if (entry.isIntersecting) { + const element = entry.target as HTMLElement; + const fontName = element.dataset["fontFamily"]; + if (fontName) { + this.enqueue(fontName); + } + } + } + }, + { root, rootMargin: ROOT_MARGIN } + ); + } + + /** + * Start observing an element for visibility. + * When visible, the font specified in data-font-family will be loaded. + */ + observe(element: HTMLElement): void { + this.observer?.observe(element); + } + + /** + * Stop observing an element. + */ + unobserve(element: HTMLElement): void { + this.observer?.unobserve(element); + } + + /** + * Check if a font has been loaded. + */ + isLoaded(fontName: string): boolean { + return this.loadedFonts.has(fontName); + } + + /** + * Register a callback to be called when a font is loaded. + * If the font is already loaded, callback is called immediately. + */ + onLoad(fontName: string, callback: () => void): void { + if (this.loadedFonts.has(fontName)) { + callback(); + return; + } + + let callbacks = this.callbacks.get(fontName); + if (!callbacks) { + callbacks = new Set(); + this.callbacks.set(fontName, callbacks); + } + callbacks.add(callback); + } + + /** + * Remove a load callback. + */ + offLoad(fontName: string, callback: () => void): void { + const callbacks = this.callbacks.get(fontName); + if (callbacks) { + callbacks.delete(callback); + if (callbacks.size === 0) { + this.callbacks.delete(fontName); + } + } + } + + /** + * Preload a font immediately (bypasses intersection observer). + * Useful for loading the currently selected font. + */ + async preload(fontName: string): Promise { + if (this.loadedFonts.has(fontName) || this.pendingFonts.has(fontName)) { + return; + } + + // Add to front of queue for priority loading + this.loadingQueue.unshift(fontName); + this.pendingFonts.add(fontName); + await this.processQueue(); + } + + /** + * Add a font to the loading queue. + */ + private enqueue(fontName: string): void { + if (this.loadedFonts.has(fontName) || this.pendingFonts.has(fontName)) { + return; + } + + this.loadingQueue.push(fontName); + this.pendingFonts.add(fontName); + this.processQueue(); + } + + /** + * Process the loading queue with concurrency limit. + */ + private async processQueue(): Promise { + while (this.activeLoads < MAX_CONCURRENT_LOADS && this.loadingQueue.length > 0) { + const fontName = this.loadingQueue.shift(); + if (fontName) { + this.activeLoads += 1; + + // Don't await - allow parallel loading + this.loadFont(fontName) + .catch(() => { + // Font loading failed - remove from pending + this.pendingFonts.delete(fontName); + }) + .finally(() => { + this.activeLoads -= 1; + this.processQueue(); + }); + } + } + } + + /** + * Load a single font using the FontFace API. + */ + private async loadFont(fontName: string): Promise { + const font = GOOGLE_FONTS_BY_NAME.get(fontName); + if (!font) { + this.pendingFonts.delete(fontName); + return; + } + + try { + // Note: We don't use document.fonts.check() because it returns true for + // system fonts with the same name (e.g., Roboto is built into Chrome/Android). + // We always load from our URL to ensure the correct Google Font is used. + const fontFace = new FontFace(fontName, `url(${font.url})`, { + weight: String(font.weight), + style: "normal" + }); + + await fontFace.load(); + document.fonts.add(fontFace); + this.markLoaded(fontName); + } catch { + // Failed to load font - silently continue + this.pendingFonts.delete(fontName); + } + } + + /** + * Mark a font as loaded and notify callbacks. + */ + private markLoaded(fontName: string): void { + this.loadedFonts.add(fontName); + this.pendingFonts.delete(fontName); + + const callbacks = this.callbacks.get(fontName); + if (callbacks) { + for (const callback of callbacks) { + callback(); + } + this.callbacks.delete(fontName); + } + } + + /** + * Clean up resources. + */ + destroy(): void { + this.observer?.disconnect(); + this.loadingQueue = []; + this.callbacks.clear(); + } +} + +// Singleton instance for the application +let instance: FontPreviewLoader | null = null; + +/** + * Get the shared FontPreviewLoader instance. + */ +export function getFontPreviewLoader(): FontPreviewLoader { + if (!instance) { + instance = new FontPreviewLoader(); + } + return instance; +} diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts new file mode 100644 index 00000000..ec60986c --- /dev/null +++ b/src/core/ui/media-toolbar.ts @@ -0,0 +1,1140 @@ +import type { Edit } from "@core/edit-session"; +import { EditEvent } from "@core/events/edit-events"; +import { validateAssetUrl } from "@core/shared/utils"; +import { ShotstackEdit } from "@core/shotstack-edit"; +import type { ResolvedClip } from "@schemas"; +import { injectShotstackStyles } from "@styles/inject"; + +import { BaseToolbar } from "./base-toolbar"; +import { EffectPanel } from "./composites/EffectPanel"; +import { TransitionPanel } from "./composites/TransitionPanel"; +import { DragStateManager } from "./drag-state-manager"; +import { MergeFieldLabelManager, type MergeFieldLabelHost } from "./merge-field-label-manager"; +import { SliderControl } from "./primitives/SliderControl"; + +type FitValue = "crop" | "cover" | "contain" | "none"; + +interface FitOption { + value: FitValue; + label: string; + description: string; +} + +const FIT_OPTIONS: FitOption[] = [ + { value: "crop", label: "Crop", description: "Fill frame, clip overflow" }, + { value: "cover", label: "Cover", description: "Fill frame, keep ratio" }, + { value: "contain", label: "Contain", description: "Fit inside frame" }, + { value: "none", label: "None", description: "Original size" } +]; + +const ICONS = { + fit: ``, + opacity: ``, + scale: ``, + volume: ``, + volumeMute: ``, + transition: ``, + chevron: ``, + check: ``, + moreVertical: ``, + effect: ``, + fadeIn: ``, + fadeOut: ``, + fadeInOut: ``, + fadeNone: `` +}; + +type MediaAssetType = "video" | "image" | "audio" | "text-to-image" | "image-to-video" | "text-to-speech"; + +/** Asset types that have visual controls (fit, opacity, scale, transition, effect) */ +const VISUAL_ASSET_TYPES: ReadonlySet = new Set(["video", "image", "text-to-image", "image-to-video"]); + +/** Asset types that have volume controls */ +const VOLUME_ASSET_TYPES: ReadonlySet = new Set(["video", "audio", "text-to-speech"]); + +/** Asset types that have audio fade controls (audio-only types) */ +const AUDIO_FADE_ASSET_TYPES: ReadonlySet = new Set(["audio", "text-to-speech"]); + +export interface MediaToolbarOptions { + mergeFields?: boolean; +} + +export class MediaToolbar extends BaseToolbar { + /** Default values for merge-field-bindable media properties. */ + private static readonly MEDIA_PROPERTY_DEFAULTS: Record = { + opacity: "1", + scale: "1", + "asset.volume": "1" + }; + + private showMergeFields: boolean; + private assetType: MediaAssetType = "image"; + + constructor(edit: Edit, options: MediaToolbarOptions = {}) { + super(edit); + this.showMergeFields = options.mergeFields ?? false; + } + + /** Get the edit as ShotstackEdit if it has merge field capabilities */ + private getShotstackEdit(): ShotstackEdit | null { + if (this.edit && "mergeFields" in this.edit) { + return this.edit as ShotstackEdit; + } + return null; + } + + // Current values + private currentFit: FitValue = "crop"; + private currentVolume: number = 100; + + // ─── Composite UI Components ───────────────────────────────────────────────── + private transitionPanel: TransitionPanel | null = null; + private effectPanel: EffectPanel | null = null; + private opacitySlider: SliderControl | null = null; + private scaleSlider: SliderControl | null = null; + private mergeFieldManager: MergeFieldLabelManager | null = null; + private unsubMergeFieldChanged: (() => void) | null = null; + + // ─── Button Elements ───────────────────────────────────────────────────────── + private fitBtn: HTMLButtonElement | null = null; + private opacityBtn: HTMLButtonElement | null = null; + private scaleBtn: HTMLButtonElement | null = null; + private volumeBtn: HTMLButtonElement | null = null; + private transitionBtn: HTMLButtonElement | null = null; + private effectBtn: HTMLButtonElement | null = null; + private advancedBtn: HTMLButtonElement | null = null; + private audioFadeBtn: HTMLButtonElement | null = null; + + // ─── Popup Elements ────────────────────────────────────────────────────────── + private fitPopup: HTMLDivElement | null = null; + private opacityPopup: HTMLDivElement | null = null; + private scalePopup: HTMLDivElement | null = null; + private volumePopup: HTMLDivElement | null = null; + private transitionPopup: HTMLDivElement | null = null; + private effectPopup: HTMLDivElement | null = null; + private advancedPopup: HTMLDivElement | null = null; + private audioFadePopup: HTMLDivElement | null = null; + + // ─── Other Elements ────────────────────────────────────────────────────────── + private fitLabel: HTMLSpanElement | null = null; + private volumeSlider: HTMLInputElement | null = null; + private volumeValue: HTMLSpanElement | null = null; + private volumeDisplayInput: HTMLInputElement | null = null; + private volumeSection: HTMLDivElement | null = null; + private visualSection: HTMLDivElement | null = null; + private audioSection: HTMLDivElement | null = null; + + // ─── Advanced Menu ─────────────────────────────────────────────────────────── + private dynamicToggle: HTMLInputElement | null = null; + private dynamicPanel: HTMLDivElement | null = null; + private dynamicInput: HTMLInputElement | null = null; + + // ─── State ─────────────────────────────────────────────────────────────────── + private dragManager = new DragStateManager(); + private audioFadeEffect: "" | "fadeIn" | "fadeOut" | "fadeInFadeOut" = ""; + private isDynamicSource: boolean = false; + private dynamicFieldName: string = ""; + private originalSrc: string = ""; + + // AbortController for cleanup of event listeners + private abortController: AbortController | null = null; + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-media-toolbar"; + + this.container.innerHTML = ` + +
+ + + +
+
+ + +
+ +
+ +
+ ${FIT_OPTIONS.map( + opt => ` +
+
+ ${opt.label} + ${opt.description} +
+ ${ICONS.check} +
+ ` + ).join("")} +
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+
+ + +
+
+
+ +
+
Volume
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+ + + + +
+
+
+
+ + ${ + this.showMergeFields + ? ` +
+ + +
+ +
+
+ Dynamic Source + +
+
+ +
+
+
+ ` + : "" + } + `; + + parent.insertBefore(this.container, parent.firstChild); + + // Query elements + this.fitBtn = this.container.querySelector('[data-action="fit"]'); + this.opacityBtn = this.container.querySelector('[data-action="opacity"]'); + this.scaleBtn = this.container.querySelector('[data-action="scale"]'); + this.volumeBtn = this.container.querySelector('[data-action="volume"]'); + this.transitionBtn = this.container.querySelector('[data-action="transition"]'); + this.effectBtn = this.container.querySelector('[data-action="effect"]'); + this.advancedBtn = this.container.querySelector('[data-action="advanced"]'); + this.audioFadeBtn = this.container.querySelector('[data-action="audio-fade"]'); + + this.fitPopup = this.container.querySelector('[data-popup="fit"]'); + this.opacityPopup = this.container.querySelector('[data-popup="opacity"]'); + this.scalePopup = this.container.querySelector('[data-popup="scale"]'); + this.volumePopup = this.container.querySelector('[data-popup="volume"]'); + this.transitionPopup = this.container.querySelector('[data-popup="transition"]'); + this.effectPopup = this.container.querySelector('[data-popup="effect"]'); + this.advancedPopup = this.container.querySelector('[data-popup="advanced"]'); + this.audioFadePopup = this.container.querySelector('[data-popup="audio-fade"]'); + + this.fitLabel = this.container.querySelector("[data-fit-label]"); + this.volumeValue = this.container.querySelector("[data-volume-value]"); + this.volumeSlider = this.container.querySelector("[data-volume-slider]"); + this.volumeDisplayInput = this.container.querySelector("[data-volume-display]"); + this.volumeSection = this.container.querySelector("[data-volume-section]"); + this.visualSection = this.container.querySelector("[data-visual-section]"); + this.audioSection = this.container.querySelector("[data-audio-section]"); + + this.dynamicToggle = this.container.querySelector("[data-dynamic-toggle]"); + this.dynamicPanel = this.container.querySelector("[data-dynamic-panel]"); + this.dynamicInput = this.container.querySelector("[data-dynamic-input]"); + + // ─── Mount Composite Components ────────────────────────────────────────────── + this.mountCompositeComponents(); + + // ─── Merge Field Labels ────────────────────────────────────────────────────── + if (this.showMergeFields) { + this.mergeFieldManager = new MergeFieldLabelManager(this as unknown as MergeFieldLabelHost, MediaToolbar.MEDIA_PROPERTY_DEFAULTS); + this.mergeFieldManager.init(); + + // Re-sync merge field labels when fields are added/removed globally + this.unsubMergeFieldChanged = this.edit.getInternalEvents().on(EditEvent.MergeFieldChanged, () => { + if (this.container?.style.display !== "none" && this.mergeFieldManager?.hasLabels) { + this.mergeFieldManager.sync(); + } + }); + } + + this.setupEventListeners(); + this.setupOutsideClickHandler(); + this.enableDrag(); + } + + /** + * Mount composite UI components into their placeholder elements. + */ + private mountCompositeComponents(): void { + // Mount opacity slider (two-phase: live preview during drag, single undo on release) + const opacityMount = this.container?.querySelector("[data-opacity-slider-mount]"); + if (opacityMount) { + this.opacitySlider = new SliderControl({ + label: "Opacity", + min: 0, + max: 100, + initialValue: 100, + formatValue: v => `${Math.round(v)}%`, + labelAttributes: this.showMergeFields ? { "data-merge-path": "opacity", "data-merge-prefix": "MEDIA_OPACITY" } : undefined + }); + this.opacitySlider.onDragStart(() => this.startSliderDrag("opacity")); + this.opacitySlider.onChange(value => this.handleOpacityChange(value)); + this.opacitySlider.onDragEnd(() => this.endSliderDrag("opacity")); + this.opacitySlider.mount(opacityMount as HTMLElement); + } + + // Mount scale slider (two-phase: live preview during drag, single undo on release) + const scaleMount = this.container?.querySelector("[data-scale-slider-mount]"); + if (scaleMount) { + this.scaleSlider = new SliderControl({ + label: "Scale", + min: 10, + max: 200, + initialValue: 100, + formatValue: v => `${Math.round(v)}%`, + labelAttributes: this.showMergeFields ? { "data-merge-path": "scale", "data-merge-prefix": "MEDIA_SCALE" } : undefined + }); + this.scaleSlider.onDragStart(() => this.startSliderDrag("scale")); + this.scaleSlider.onChange(value => this.handleScaleChange(value)); + this.scaleSlider.onDragEnd(() => this.endSliderDrag("scale")); + this.scaleSlider.mount(scaleMount as HTMLElement); + } + + // Mount transition panel + const transitionMount = this.container?.querySelector("[data-transition-panel-mount]"); + if (transitionMount) { + this.transitionPanel = new TransitionPanel(); + this.transitionPanel.onChange(() => this.applyTransitionUpdate()); + this.transitionPanel.mount(transitionMount as HTMLElement); + } + + // Mount effect panel + const effectMount = this.container?.querySelector("[data-effect-panel-mount]"); + if (effectMount) { + this.effectPanel = new EffectPanel(); + this.effectPanel.onChange(() => this.applyEffect()); + this.effectPanel.mount(effectMount as HTMLElement); + } + } + + private setupEventListeners(): void { + // Create AbortController for cleanup + this.abortController = new AbortController(); + const { signal } = this.abortController; + + // Toggle popups + this.fitBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("fit"); + }, + { signal } + ); + this.opacityBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("opacity"); + }, + { signal } + ); + this.scaleBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("scale"); + }, + { signal } + ); + this.volumeBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("volume"); + }, + { signal } + ); + this.transitionBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("transition"); + }, + { signal } + ); + this.effectBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("effect"); + }, + { signal } + ); + this.advancedBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("advanced"); + }, + { signal } + ); + this.audioFadeBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("audio-fade"); + }, + { signal } + ); + + // Dynamic source handlers + this.setupDynamicSourceHandlers(signal); + + // Fit options + this.fitPopup?.querySelectorAll("[data-fit]").forEach(item => { + item.addEventListener( + "click", + e => { + const el = e.currentTarget as HTMLElement; + const fit = el.dataset["fit"] as FitValue; + this.handleFitChange(fit); + }, + { signal } + ); + }); + + // Volume slider (two-phase: live preview during drag, single undo on release) + this.volumeSlider?.addEventListener("pointerdown", () => this.startSliderDrag("volume"), { signal }); + this.volumeSlider?.addEventListener( + "input", + () => { + const value = parseInt(this.volumeSlider!.value, 10); + this.handleVolumeChange(value); + }, + { signal } + ); + this.volumeSlider?.addEventListener("change", () => this.endSliderDrag("volume"), { signal }); + + // Volume display input: commit on blur or Enter, revert on Escape + this.volumeDisplayInput?.addEventListener("blur", () => this.commitVolumeInputValue(), { signal }); + this.volumeDisplayInput?.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + this.commitVolumeInputValue(); + this.volumeDisplayInput?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + this.revertVolumeInputValue(); + this.volumeDisplayInput?.blur(); + } + }, + { signal } + ); + this.volumeDisplayInput?.addEventListener( + "focus", + () => { + this.volumeDisplayInput?.select(); + }, + { signal } + ); + + // Audio fade options + this.audioFadePopup?.querySelectorAll("[data-audio-fade]").forEach(btn => { + btn.addEventListener( + "click", + e => { + const el = e.currentTarget as HTMLElement; + const fadeValue = el.dataset["audioFade"] || ""; + this.handleAudioFadeSelect(fadeValue as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut"); + }, + { signal } + ); + }); + } + + private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "effect" | "advanced" | "audio-fade"): void { + const popupMap = { + fit: { popup: this.fitPopup, btn: this.fitBtn }, + opacity: { popup: this.opacityPopup, btn: this.opacityBtn }, + scale: { popup: this.scalePopup, btn: this.scaleBtn }, + volume: { popup: this.volumePopup, btn: this.volumeBtn }, + transition: { popup: this.transitionPopup, btn: this.transitionBtn }, + effect: { popup: this.effectPopup, btn: this.effectBtn }, + advanced: { popup: this.advancedPopup, btn: this.advancedBtn }, + "audio-fade": { popup: this.audioFadePopup, btn: this.audioFadeBtn } + }; + + const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); + this.closeAllPopups(); + + if (!isCurrentlyOpen) { + this.togglePopup(popupMap[popup].popup); + popupMap[popup].btn?.classList.add("active"); + } + } + + protected override closeAllPopups(): void { + super.closeAllPopups(); + + // Also remove active state from buttons + this.fitBtn?.classList.remove("active"); + this.opacityBtn?.classList.remove("active"); + this.scaleBtn?.classList.remove("active"); + this.volumeBtn?.classList.remove("active"); + this.transitionBtn?.classList.remove("active"); + this.effectBtn?.classList.remove("active"); + this.advancedBtn?.classList.remove("active"); + this.audioFadeBtn?.classList.remove("active"); + } + + protected override getPopupList(): (HTMLElement | null)[] { + return [ + this.fitPopup, + this.opacityPopup, + this.scalePopup, + this.volumePopup, + this.transitionPopup, + this.effectPopup, + this.advancedPopup, + this.audioFadePopup + ]; + } + + protected override syncState(): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (clip) { + // Fit + this.currentFit = (clip.fit as FitValue) || "crop"; + + // Opacity (convert from 0-1 to 0-100) + const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; + this.opacitySlider?.setValue(Math.round(opacity * 100)); + + // Scale (convert from 0-1 to percentage) + const scale = typeof clip.scale === "number" ? clip.scale : 1; + this.scaleSlider?.setValue(Math.round(scale * 100)); + + // Volume (types with audio output) + if (VOLUME_ASSET_TYPES.has(this.assetType)) { + const asset = clip.asset as { volume?: number }; + const volume = typeof asset.volume === "number" ? asset.volume : 1; + this.currentVolume = Math.round(volume * 100); + } + + // Transition - use composite + this.transitionPanel?.setFromClip(clip.transition); + + // Effect - use composite + this.effectPanel?.setFromClip(clip.effect); + + // Audio fade effect (for audio and text-to-speech assets) + if (AUDIO_FADE_ASSET_TYPES.has(this.assetType)) { + const asset = clip.asset as { effect?: string }; + this.audioFadeEffect = (asset.effect as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut") || ""; + } + } + + // Update displays + this.updateFitDisplay(); + this.updateOpacityDisplay(); + this.updateScaleDisplay(); + this.updateVolumeDisplay(); + + // Update active states + this.updateFitActiveState(); + this.updateAudioFadeUI(); + this.updateDynamicSourceUI(); + + // Show/hide visual section (only for visual asset types) + if (this.visualSection) { + this.visualSection.classList.toggle("hidden", !VISUAL_ASSET_TYPES.has(this.assetType)); + } + + // Show/hide volume section (only for types with audio output) + if (this.volumeSection) { + this.volumeSection.classList.toggle("hidden", !VOLUME_ASSET_TYPES.has(this.assetType)); + } + + // Show/hide audio fade section (only for audio-only types) + if (this.audioSection) { + this.audioSection.classList.toggle("hidden", !AUDIO_FADE_ASSET_TYPES.has(this.assetType)); + } + + // Hide the advanced/dynamic source divider and button for AI types + const advancedDivider = this.container?.querySelector("[data-divider-before-advanced]") as HTMLElement | null; + if (advancedDivider) { + advancedDivider.classList.toggle("hidden", !VISUAL_ASSET_TYPES.has(this.assetType) || !this.showMergeFields); + } + if (this.advancedBtn?.parentElement) { + this.advancedBtn.parentElement.classList.toggle("hidden", !VISUAL_ASSET_TYPES.has(this.assetType) || !this.showMergeFields); + } + + // Sync merge field label bound states + if (this.showMergeFields && this.mergeFieldManager?.hasLabels) { + this.mergeFieldManager.sync(); + } + } + + // ─── Two-Phase Drag Helpers ────────────────────────────────────────────────── + // + // Without this, every slider tick creates an undo command and Ctrl-Z steps + // through dozens of intermediate values instead of reverting the whole drag. + // + // pointerdown → snapshot clip state + // input → live preview (bypass command system) + // change → commit one undo entry for the entire gesture + // + // Text-input commits (blur / Enter) skip the drag path and go straight + // through applyClipUpdate(). + + /** + * Capture and deep-clone the current clip state for drag rollback. + */ + private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; + } + + /** + * Start a drag session for a slider control. + */ + private startSliderDrag(controlId: string): void { + const state = this.captureClipState(); + if (state) { + this.dragManager.start(controlId, state.clipId, state.initialState); + } + } + + /** + * End a drag session and commit a single undo entry. + */ + private endSliderDrag(controlId: string): void { + const session = this.dragManager.end(controlId); + if (!session) return; + + const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (finalClip) { + this.edit.commitClipUpdate(session.clipId, session.initialState, structuredClone(finalClip)); + } + } + + // ─── Value Change Handlers ─────────────────────────────────────────────────── + + private handleFitChange(fit: FitValue): void { + this.currentFit = fit; + this.updateFitDisplay(); + this.updateFitActiveState(); + this.closeAllPopups(); + this.applyClipUpdate({ fit }); + } + + private handleOpacityChange(value: number): void { + this.updateOpacityDisplay(); + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const updates = { opacity: value / 100 }; + + if (this.dragManager.isDragging("opacity")) { + this.edit.updateClipInDocument(clipId, updates); + this.edit.resolveClip(clipId); + } else { + this.applyClipUpdate(updates); + } + } + + private handleScaleChange(value: number): void { + this.updateScaleDisplay(); + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const updates = { scale: value / 100 }; + + if (this.dragManager.isDragging("scale")) { + this.edit.updateClipInDocument(clipId, updates); + this.edit.resolveClip(clipId); + } else { + this.applyClipUpdate(updates); + } + } + + private handleVolumeChange(value: number): void { + this.currentVolume = value; + this.updateVolumeDisplay(); + + if (!VOLUME_ASSET_TYPES.has(this.assetType)) return; + + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip) return; + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const asset = clip.asset as Record; + const updates = { asset: { ...asset, volume: value / 100 } as typeof clip.asset }; + + if (this.dragManager.isDragging("volume")) { + this.edit.updateClipInDocument(clipId, updates); + this.edit.resolveClip(clipId); + } else { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + } + + /** + * Parse and commit the value from the volume text input. + */ + private commitVolumeInputValue(): void { + if (!this.volumeDisplayInput) return; + + const parsed = this.parseVolumeInputValue(this.volumeDisplayInput.value); + this.handleVolumeChange(parsed); + } + + /** + * Revert the volume text input to match the current slider value. + */ + private revertVolumeInputValue(): void { + this.updateVolumeDisplay(); + } + + /** + * Parse user input, strip non-numeric chars, clamp to 0-100. + */ + private parseVolumeInputValue(input: string): number { + const stripped = input.replace(/[^0-9]/g, ""); + const num = parseInt(stripped, 10); + if (Number.isNaN(num)) return this.currentVolume; + return Math.max(0, Math.min(100, num)); + } + + // ─── Transition (using composite) ──────────────────────────────────────────── + + private applyTransitionUpdate(): void { + const transition = this.transitionPanel?.getClipValue(); + this.applyClipUpdate({ transition }); + } + + // ─── Effect (using composite) ──────────────────────────────────────────────── + + private applyEffect(): void { + const effectValue = this.effectPanel?.getClipValue(); + this.applyClipUpdate({ effect: effectValue }); + } + + // ─── Audio Fade Handlers ───────────────────────────────────────────────────── + + private handleAudioFadeSelect(effect: "" | "fadeIn" | "fadeOut" | "fadeInFadeOut"): void { + this.audioFadeEffect = effect; + this.updateAudioFadeUI(); + this.applyAudioFade(); + } + + private applyAudioFade(): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || !AUDIO_FADE_ASSET_TYPES.has(this.assetType)) return; + + const asset = clip.asset as Record; + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { ...asset, effect: this.audioFadeEffect || undefined } as typeof clip.asset + }); + } + + private updateAudioFadeUI(): void { + if (!this.audioFadePopup) return; + + const buttons = this.audioFadePopup.querySelectorAll("[data-audio-fade]"); + buttons.forEach(btn => { + const fadeValue = (btn as HTMLElement).dataset["audioFade"] || ""; + btn.classList.toggle("active", fadeValue === this.audioFadeEffect); + }); + + if (this.audioFadeBtn) { + const iconMap: Record = { + "": ICONS.fadeNone, + fadeIn: ICONS.fadeIn, + fadeOut: ICONS.fadeOut, + fadeInFadeOut: ICONS.fadeInOut + }; + const svg = this.audioFadeBtn.querySelector("svg"); + if (svg) { + svg.outerHTML = iconMap[this.audioFadeEffect] || ICONS.fadeNone; + } + } + } + + private applyClipUpdate(updates: Record): void { + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + } + + // ─── Dynamic Source Handlers ───────────────────────────────────────────────── + + private setupDynamicSourceHandlers(signal: AbortSignal): void { + this.dynamicToggle?.addEventListener( + "change", + () => { + const checked = this.dynamicToggle?.checked || false; + this.isDynamicSource = checked; + + if (this.dynamicPanel) { + this.dynamicPanel.style.display = checked ? "block" : "none"; + } + + if (checked) { + this.dynamicInput?.focus(); + } else { + this.clearDynamicSource(); + } + }, + { signal } + ); + + this.dynamicInput?.addEventListener( + "keydown", + e => { + if (e.key === "Enter") { + e.preventDefault(); + this.applyDynamicUrl(); + } else if (e.key === "Escape") { + this.dynamicInput?.blur(); + } + }, + { signal } + ); + + this.dynamicInput?.addEventListener( + "blur", + () => { + this.applyDynamicUrl(); + }, + { signal } + ); + } + + private async applyDynamicUrl(): Promise { + const url = (this.dynamicInput?.value || "").trim(); + if (!url) return; + + const validation = await validateAssetUrl(url); + if (!validation.valid) { + this.showUrlError(validation.error || "Invalid URL"); + return; + } + + this.clearUrlError(); + + const shotstackEdit = this.getShotstackEdit(); + if (!shotstackEdit) return; + + if (this.dynamicFieldName) { + // Document-first: resolve() triggers reconciler which handles reloadAsset() + shotstackEdit.updateMergeFieldValueLive(this.dynamicFieldName, url); + return; + } + + const fieldName = shotstackEdit.mergeFields.generateUniqueName("MEDIA"); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + shotstackEdit.applyMergeField(clipId, "asset.src", fieldName, url, this.originalSrc); + this.dynamicFieldName = fieldName; + } + + private showUrlError(message: string): void { + if (this.dynamicInput) { + this.dynamicInput.classList.add("error"); + this.dynamicInput.title = message; + } + } + + private clearUrlError(): void { + if (this.dynamicInput) { + this.dynamicInput.classList.remove("error"); + this.dynamicInput.title = ""; + } + } + + private clearDynamicSource(): void { + if (!this.dynamicFieldName) return; + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (clipId) { + let restoreValue = this.originalSrc; + if (!restoreValue) { + const field = this.getShotstackEdit()?.mergeFields.get(this.dynamicFieldName); + restoreValue = field?.defaultValue || ""; + } + if (!restoreValue) { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + restoreValue = (clip?.asset as { src?: string })?.src || ""; + } + this.getShotstackEdit()?.removeMergeField(clipId, "asset.src", restoreValue); + } + this.dynamicFieldName = ""; + if (this.dynamicInput) { + this.dynamicInput.value = ""; + } + } + + private updateDynamicSourceUI(): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip) return; + + const shotstackEdit = this.getShotstackEdit(); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const fieldName = (clipId && shotstackEdit?.getMergeFieldForProperty(clipId, "asset.src")) ?? null; + + if (fieldName) { + this.isDynamicSource = true; + this.dynamicFieldName = fieldName; + const mergeField = shotstackEdit?.mergeFields.get(fieldName); + this.originalSrc = mergeField?.defaultValue || ""; + if (this.dynamicToggle) this.dynamicToggle.checked = true; + if (this.dynamicPanel) this.dynamicPanel.style.display = "block"; + if (this.dynamicInput) { + this.dynamicInput.value = mergeField?.defaultValue || ""; + } + } else { + this.isDynamicSource = false; + this.dynamicFieldName = ""; + + const asset = clip.asset as { src?: string }; + this.originalSrc = asset?.src || ""; + + if (this.dynamicToggle) this.dynamicToggle.checked = false; + if (this.dynamicPanel) this.dynamicPanel.style.display = "none"; + if (this.dynamicInput) this.dynamicInput.value = ""; + } + } + + // ─── Display Updates ───────────────────────────────────────────────────────── + + private updateFitDisplay(): void { + if (this.fitLabel) { + const option = FIT_OPTIONS.find(o => o.value === this.currentFit); + this.fitLabel.textContent = option?.label || "Crop"; + } + } + + private updateOpacityDisplay(): void { + const value = this.opacitySlider?.getValue() ?? 100; + const text = `${Math.round(value)}%`; + const opacityValue = this.container?.querySelector("[data-opacity-value]"); + if (opacityValue) opacityValue.textContent = text; + } + + private updateScaleDisplay(): void { + const value = this.scaleSlider?.getValue() ?? 100; + const text = `${Math.round(value)}%`; + const scaleValue = this.container?.querySelector("[data-scale-value]"); + if (scaleValue) scaleValue.textContent = text; + } + + private updateVolumeDisplay(): void { + const text = `${this.currentVolume}%`; + if (this.volumeValue) this.volumeValue.textContent = text; + if (this.volumeSlider) this.volumeSlider.value = String(this.currentVolume); + if (this.volumeDisplayInput) this.volumeDisplayInput.value = text; + + const iconContainer = this.container?.querySelector("[data-volume-icon]"); + if (iconContainer) { + iconContainer.innerHTML = this.currentVolume === 0 ? ICONS.volumeMute : ICONS.volume; + } + } + + private updateFitActiveState(): void { + this.fitPopup?.querySelectorAll("[data-fit]").forEach(item => { + const el = item as HTMLElement; + el.classList.toggle("active", el.dataset["fit"] === this.currentFit); + }); + } + + /** + * Show the toolbar for a specific clip. + * Derives assetType from the clip's asset configuration. + */ + override show(trackIndex: number, clipIndex: number): void { + const clip = this.edit.getResolvedClip(trackIndex, clipIndex); + this.assetType = (clip?.asset?.type ?? "image") as MediaAssetType; + super.show(trackIndex, clipIndex); + } + + override dispose(): void { + // Abort all event listeners + this.abortController?.abort(); + this.abortController = null; + + // Clear any in-progress drag sessions + this.dragManager.clear(); + + // Dispose composite components + this.transitionPanel?.dispose(); + this.effectPanel?.dispose(); + this.opacitySlider?.dispose(); + this.scaleSlider?.dispose(); + + // Dispose merge field labels + this.unsubMergeFieldChanged?.(); + this.unsubMergeFieldChanged = null; + this.mergeFieldManager?.dispose(); + this.mergeFieldManager = null; + + super.dispose(); + + this.transitionPanel = null; + this.effectPanel = null; + this.opacitySlider = null; + this.scaleSlider = null; + + this.fitBtn = null; + this.opacityBtn = null; + this.scaleBtn = null; + this.volumeBtn = null; + this.transitionBtn = null; + this.effectBtn = null; + this.advancedBtn = null; + this.audioFadeBtn = null; + + this.fitPopup = null; + this.opacityPopup = null; + this.scalePopup = null; + this.volumePopup = null; + this.transitionPopup = null; + this.effectPopup = null; + this.advancedPopup = null; + this.audioFadePopup = null; + + this.fitLabel = null; + this.volumeSlider = null; + this.volumeValue = null; + this.volumeDisplayInput = null; + this.volumeSection = null; + this.visualSection = null; + this.audioSection = null; + + this.dynamicToggle = null; + this.dynamicPanel = null; + this.dynamicInput = null; + } +} diff --git a/src/core/ui/merge-field-label-manager.ts b/src/core/ui/merge-field-label-manager.ts new file mode 100644 index 00000000..0f573b22 --- /dev/null +++ b/src/core/ui/merge-field-label-manager.ts @@ -0,0 +1,216 @@ +import type { Edit } from "@core/edit-session"; +import type { MergeField } from "@core/merge"; +import { getNestedValue } from "@core/shared/utils"; +import { ShotstackEdit } from "@core/shotstack-edit"; + +import { MergeFieldLabel } from "./primitives"; + +/** + * Interface that any toolbar must satisfy to host merge field labels. + * All properties are available on BaseToolbar (protected/public). + */ +export interface MergeFieldLabelHost { + container: HTMLElement | null; + edit: Edit; + getSelectedClipId(): string | null; + syncState(): void; +} + +/** + * Reusable manager that scans a toolbar container for `[data-merge-path]` annotated + * labels, replaces them with interactive MergeFieldLabel components, and keeps their + * state synchronised with the document's merge field bindings. + * + * Used by RichTextToolbar, MediaToolbar, and potentially other toolbars. + */ +export class MergeFieldLabelManager { + private labels: MergeFieldLabel[] = []; + + constructor( + private host: MergeFieldLabelHost, + private propertyDefaults: Record = {} + ) {} + + /** Whether any labels were initialised. */ + get hasLabels(): boolean { + return this.labels.length > 0; + } + + /** + * Scan for all `[data-merge-path]` annotated label elements in the host container + * and replace them with MergeFieldLabel components that support merge field binding. + */ + init(): void { + if (!this.host.container) return; + + const annotatedLabels = this.host.container.querySelectorAll("[data-merge-path]"); + + for (const labelEl of annotatedLabels) { + const propertyPath = labelEl.dataset["mergePath"]; + const namePrefix = labelEl.dataset["mergePrefix"]; + const labelText = labelEl.textContent?.trim() ?? ""; + + if (propertyPath && namePrefix) { + const mergeLabel = new MergeFieldLabel({ + label: labelText, + propertyPath, + namePrefix + }); + + // Replace the original label element with the MergeFieldLabel. + // Preserve the original CSS class so labels in different panels keep their styling. + const mountPoint = document.createElement("div"); + mountPoint.className = labelEl.className; + labelEl.replaceWith(mountPoint); + mergeLabel.mount(mountPoint); + + this.wireBindCallback(mergeLabel, propertyPath); + this.wireClearCallback(mergeLabel, propertyPath); + + this.labels.push(mergeLabel); + } + } + } + + /** + * Sync all merge field label states with current clip bindings. + * Call from the host toolbar's syncState(). + */ + sync(): void { + const shotstackEdit = this.getShotstackEdit(); + if (!shotstackEdit) return; + + const allFields = shotstackEdit.mergeFields.getAll(); + + const clipId = this.getSelectedClipId(); + if (!clipId) return; + + for (const label of this.labels) { + const propertyPath = label.getPropertyPath(); + + // Compute which fields are type-compatible with this property + const compatibleNames = this.getCompatibleFieldNames(allFields, propertyPath, clipId); + label.setFields(allFields, compatibleNames); + + const fieldName = shotstackEdit.getMergeFieldForProperty(clipId, propertyPath); + + if (fieldName) { + label.setState(true, fieldName); + this.setControlDisabled(label, true); + } else { + label.setState(false); + this.setControlDisabled(label, false); + } + } + } + + /** Dispose all labels. */ + dispose(): void { + for (const label of this.labels) { + label.dispose(); + } + this.labels = []; + } + + // ─── Private ─────────────────────────────────────────────────────────── + + private getShotstackEdit(): ShotstackEdit | null { + if (this.host.edit && "mergeFields" in this.host.edit) { + return this.host.edit as ShotstackEdit; + } + return null; + } + + /** Resolve the stable clipId for the currently selected clip. */ + private getSelectedClipId(): string | null { + return this.host.getSelectedClipId(); + } + + private wireBindCallback(mergeLabel: MergeFieldLabel, propertyPath: string): void { + mergeLabel.onBind((nameOrPrefix: string) => { + const shotstackEdit = this.getShotstackEdit(); + if (!shotstackEdit) return; + + const clipId = this.getSelectedClipId(); + if (!clipId) return; + + const existingField = shotstackEdit.mergeFields.get(nameOrPrefix); + + let fieldName: string; + let value: string; + + if (existingField) { + // Guard: reject binding if field value is incompatible with property type. + if (!shotstackEdit.isValueCompatibleWithClipProperty(clipId, propertyPath, existingField.defaultValue)) { + return; + } + + fieldName = existingField.name; + value = existingField.defaultValue; + } else { + fieldName = shotstackEdit.mergeFields.generateUniqueName(nameOrPrefix); + + const resolvedClip = this.host.edit.getResolvedClipById(clipId); + const currentValue = resolvedClip ? getNestedValue(resolvedClip, propertyPath) : null; + value = currentValue != null ? String(currentValue) : (this.propertyDefaults[propertyPath] ?? "0"); + } + + shotstackEdit.applyMergeField(clipId, propertyPath, fieldName, value).then(() => { + this.host.syncState(); + }); + }); + } + + private wireClearCallback(mergeLabel: MergeFieldLabel, propertyPath: string): void { + mergeLabel.onClear(() => { + const shotstackEdit = this.getShotstackEdit(); + if (!shotstackEdit) return; + + const clipId = this.getSelectedClipId(); + if (!clipId) return; + + const boundFieldName = shotstackEdit.getMergeFieldForProperty(clipId, propertyPath); + const field = boundFieldName ? shotstackEdit.mergeFields.get(boundFieldName) : null; + const restoreValue = field?.defaultValue ?? ""; + + shotstackEdit.removeMergeField(clipId, propertyPath, restoreValue).then(() => { + this.host.syncState(); + }); + }); + } + + /** + * Get the set of field names whose default values are type-compatible with a property. + * Incompatible fields will be greyed out in the dropdown. + */ + private getCompatibleFieldNames(fields: MergeField[], propertyPath: string, clipId: string): Set { + const shotstackEdit = this.getShotstackEdit(); + const compatible = new Set(); + for (const field of fields) { + if (!shotstackEdit || shotstackEdit.isValueCompatibleWithClipProperty(clipId, propertyPath, field.defaultValue)) { + compatible.add(field.name); + } + } + return compatible; + } + + private setControlDisabled(label: MergeFieldLabel, disabled: boolean): void { + const wrapper = label.getContainer()?.parentElement; + if (!wrapper) return; + + const section = wrapper.closest(".ss-toolbar-popup-section, .ss-font-color-section"); + if (!section) return; + + const row = section.querySelector(".ss-toolbar-popup-row, .ss-font-color-opacity-row"); + const controlContainer = row ?? section; + + controlContainer.querySelectorAll("input").forEach(input => { + if (wrapper.contains(input)) return; + input.disabled = disabled; // eslint-disable-line no-param-reassign -- DOM manipulation + }); + + if (row) { + row.classList.toggle("ss-toolbar-popup-row--disabled", disabled); + } + } +} diff --git a/src/core/ui/primitives/EventManager.ts b/src/core/ui/primitives/EventManager.ts new file mode 100644 index 00000000..8065d102 --- /dev/null +++ b/src/core/ui/primitives/EventManager.ts @@ -0,0 +1,90 @@ +import type { Disposable, EventBinding } from "./types"; + +/** + * Centralized event listener management that prevents memory leaks. + * + * All event listeners registered through EventManager are automatically + * tracked and can be cleaned up with a single dispose() call. + * + * @example + * ```typescript + * const events = new EventManager(); + * events.on(button, "click", handleClick); + * events.onAll(items, "click", handleItemClick); + * // Later... + * events.dispose(); // All listeners removed + * ``` + */ +export class EventManager implements Disposable { + private bindings: EventBinding[] = []; + + /** + * Register an event listener on a single element. + */ + on( + element: HTMLElement | null | undefined, + type: K, + handler: (e: HTMLElementEventMap[K]) => void, + options?: AddEventListenerOptions + ): void { + if (!element) return; + element.addEventListener(type, handler as EventListener, options); + this.bindings.push({ + element, + type, + handler: handler as EventListener, + options + }); + } + + /** + * Register an event listener on multiple elements. + * The handler receives both the event and the target element. + */ + onAll( + elements: NodeListOf | Element[] | null | undefined, + type: K, + handler: (e: HTMLElementEventMap[K], el: Element) => void + ): void { + if (!elements) return; + elements.forEach(el => { + const wrappedHandler = (e: Event) => handler(e as HTMLElementEventMap[K], el); + el.addEventListener(type, wrappedHandler); + this.bindings.push({ + element: el, + type, + handler: wrappedHandler + }); + }); + } + + /** + * Register a document-level event listener. + */ + onDocument(type: K, handler: (e: DocumentEventMap[K]) => void, options?: AddEventListenerOptions): void { + document.addEventListener(type, handler as EventListener, options); + this.bindings.push({ + element: document, + type, + handler: handler as EventListener, + options + }); + } + + /** + * Remove all registered event listeners. + */ + dispose(): void { + for (const { element, type, handler, options } of this.bindings) { + element.removeEventListener(type, handler, options); + } + this.bindings = []; + } + + /** + * Get the count of registered listeners (useful for debugging). + */ + get listenerCount(): number { + return this.bindings.length; + } +} diff --git a/src/core/ui/primitives/MergeFieldLabel.ts b/src/core/ui/primitives/MergeFieldLabel.ts new file mode 100644 index 00000000..86d4d845 --- /dev/null +++ b/src/core/ui/primitives/MergeFieldLabel.ts @@ -0,0 +1,231 @@ +import type { MergeField } from "@core/merge/types"; + +import type { ChangeCallback, MergeFieldLabelConfig } from "./types"; +import { UIComponent } from "./UIComponent"; + +/** + * A property label that supports merge field binding via a `{ }` icon. + * + * Default state: Shows label text with a `{ }` icon. + * Clicking the icon opens a dropdown to bind an existing merge field or create a new one. + * + * Bound state: The `{ }` icon displays in accent color. + * The label shows the field name. The dropdown shows the bound field and a "Clear" option. + */ +export class MergeFieldLabel extends UIComponent { + private iconBtn: HTMLButtonElement | null = null; + private dropdown: HTMLElement | null = null; + private listEl: HTMLElement | null = null; + + private fields: MergeField[] = []; + private compatibleFieldNames: Set | null = null; + private bound = false; + private boundFieldName: string | null = null; + + private bindCallbacks: ChangeCallback[] = []; + private clearCallbacks: ChangeCallback[] = []; + + constructor(private labelConfig: MergeFieldLabelConfig) { + super({ className: labelConfig.className }); + } + + render(): string { + return ` +
+ ${this.labelConfig.label} + + +
+ `; + } + + protected bindElements(): void { + this.iconBtn = this.container?.querySelector(".ss-merge-label__icon") ?? null; + this.dropdown = this.container?.querySelector(".ss-merge-label__dropdown") ?? null; + this.listEl = this.container?.querySelector(".ss-merge-label__list") ?? null; + } + + protected setupEvents(): void { + // Toggle dropdown on icon click + this.events.on(this.iconBtn, "click", (e: MouseEvent) => { + e.stopPropagation(); + this.toggleDropdown(); + }); + + // Create & Select + const createBtn = this.container?.querySelector(".ss-merge-label__create") as HTMLButtonElement | null; + this.events.on(createBtn, "click", (e: MouseEvent) => { + e.stopPropagation(); + this.hideDropdown(); + for (const cb of this.bindCallbacks) cb(this.labelConfig.namePrefix); + }); + + // Close dropdown on outside click + this.events.onDocument("pointerdown", (e: PointerEvent) => { + if (!this.dropdown || this.dropdown.style.display === "none") return; + const inside = this.container?.contains(e.target as Node); + if (!inside) { + this.hideDropdown(); + } + }); + } + + // ─── Public API ──────────────────────────────────────────────────────── + + /** Register callback for when a merge field is bound to this property. */ + onBind(callback: ChangeCallback): void { + this.bindCallbacks.push(callback); + } + + /** Register callback for when the merge field binding is cleared. */ + onClear(callback: ChangeCallback): void { + this.clearCallbacks.push(callback); + } + + /** + * Update the list of available merge fields for the dropdown. + * @param compatibleNames — Set of field names whose values are type-compatible + * with this property. Incompatible fields are shown greyed out. Pass null to + * treat all fields as compatible. + */ + setFields(fields: MergeField[], compatibleNames?: Set | null): void { + this.fields = fields; + this.compatibleFieldNames = compatibleNames ?? null; + } + + /** Update bound/unbound visual state. */ + setState(bound: boolean, fieldName?: string): void { + this.bound = bound; + this.boundFieldName = fieldName ?? null; + + const labelEl = this.container?.querySelector(".ss-merge-label") as HTMLElement | null; + if (!labelEl) return; + + // Remove existing bound-name span if any + const existingBadge = labelEl.querySelector(".ss-merge-label__bound-name"); + existingBadge?.remove(); + + if (bound && fieldName) { + labelEl.classList.add("ss-merge-label--bound"); + + // Add field name badge after the text + const textEl = labelEl.querySelector(".ss-merge-label__text"); + if (textEl) { + const badge = document.createElement("span"); + badge.className = "ss-merge-label__bound-name"; + badge.textContent = ` \u00b7 {{ ${fieldName} }}`; + textEl.after(badge); + } + } else { + labelEl.classList.remove("ss-merge-label--bound"); + } + } + + /** Get the label text for this property. */ + getLabel(): string { + return this.labelConfig.label; + } + + /** Get the property path this label controls. */ + getPropertyPath(): string { + return this.labelConfig.propertyPath; + } + + /** Get the name prefix for auto-generated field names. */ + getNamePrefix(): string { + return this.labelConfig.namePrefix; + } + + /** Whether a merge field is currently bound. */ + isBound(): boolean { + return this.bound; + } + + // ─── Private ─────────────────────────────────────────────────────────── + + private toggleDropdown(): void { + if (!this.dropdown) return; + const isHidden = this.dropdown.style.display === "none"; + if (isHidden) { + this.showDropdown(); + } else { + this.hideDropdown(); + } + } + + private showDropdown(): void { + if (!this.dropdown || !this.listEl) return; + this.renderList(); + this.dropdown.style.display = ""; + } + + private hideDropdown(): void { + if (this.dropdown) { + this.dropdown.style.display = "none"; + } + } + + private renderList(): void { + if (!this.listEl) return; + + // Clear previous content and listeners (innerHTML replacement detaches old nodes) + this.listEl.innerHTML = ""; + + for (const field of this.fields) { + const isActive = this.bound && this.boundFieldName === field.name; + const isCompatible = !this.compatibleFieldNames || this.compatibleFieldNames.has(field.name); + const btn = document.createElement("button"); + btn.type = "button"; + + let className = "ss-merge-label__field"; + if (isActive) className += " ss-merge-label__field--active"; + if (!isCompatible) className += " ss-merge-label__field--disabled"; + btn.className = className; + + btn.dataset["fieldName"] = field.name; + btn.innerHTML = ` + {{ ${field.name} }} + ${field.defaultValue} + `; + + if (isCompatible) { + btn.addEventListener("click", (e: MouseEvent) => { + e.stopPropagation(); + this.hideDropdown(); + for (const cb of this.bindCallbacks) cb(field.name); + }); + } else { + btn.title = "Incompatible value type"; + } + + this.listEl.appendChild(btn); + } + + if (this.bound) { + const clearBtn = document.createElement("button"); + clearBtn.type = "button"; + clearBtn.className = "ss-merge-label__clear"; + clearBtn.textContent = "Clear"; + clearBtn.addEventListener("click", (e: MouseEvent) => { + e.stopPropagation(); + this.hideDropdown(); + for (const cb of this.clearCallbacks) cb(); + }); + this.listEl.appendChild(clearBtn); + } + } + + override dispose(): void { + super.dispose(); + this.bindCallbacks = []; + this.clearCallbacks = []; + } +} diff --git a/src/core/ui/primitives/ScrollableList.ts b/src/core/ui/primitives/ScrollableList.ts new file mode 100644 index 00000000..a4980027 --- /dev/null +++ b/src/core/ui/primitives/ScrollableList.ts @@ -0,0 +1,139 @@ +import type { ScrollableListConfig, ScrollableListGroup, ScrollableListItem } from "./types"; +import { UIComponent } from "./UIComponent"; + +/** + * A scrollable grouped list component. + * + * Uses a three-layer DOM structure for reliable scroll containment: + * + * .ss-scrollable-list (height: Npx, overflow: hidden, flex column) + * .ss-scrollable-list-body (flex: 1, min-height: 0, overflow: hidden) + * .ss-scrollable-list-viewport (height: 100%, overflow-y: auto) + * + * **Trackpad scrolling note:** The canvas has a capturing wheel handler + * (shotstack-canvas.ts `onWheel`) that calls `preventDefault()` for zoom/pan. + * For trackpad scrolling to work, the popup ancestor must use a CSS class + * exempted in that handler (e.g. `.ss-toolbar-popup`, `.ss-media-toolbar-popup`). + */ +export class ScrollableList extends UIComponent { + private viewport: HTMLElement | null = null; + private selectedValue: string | undefined; + private groups: ScrollableListGroup[]; + private height: number; + + constructor(private listConfig: ScrollableListConfig) { + super({ className: "ss-scrollable-list", ...listConfig }); + this.groups = listConfig.groups; + this.height = listConfig.height ?? 300; + this.selectedValue = listConfig.selectedValue; + } + + override mount(parent: HTMLElement): void { + super.mount(parent); + // Set definite height on the container (NOT max-height) + if (this.container) { + this.container.style.height = `${this.height}px`; + } + } + + render(): string { + return ` +
+
+ ${this.renderGroups()} +
+
+ `; + } + + private renderGroups(): string { + return this.groups + .map( + group => ` +
+
+ ${group.header} + ${group.headerDetail ? `${group.headerDetail}` : ""} +
+ ${group.items.map(item => this.renderItem(item)).join("")} +
+ ` + ) + .join(""); + } + + private renderItem(item: ScrollableListItem): string { + const selected = item.value === this.selectedValue ? " ss-scrollable-list-item--selected" : ""; + const dataAttrs = item.data + ? Object.entries(item.data) + .map(([k, v]) => ` data-${k}="${v}"`) + .join("") + : ""; + return `
${item.label}
`; + } + + protected bindElements(): void { + this.viewport = this.container?.querySelector(".ss-scrollable-list-viewport") ?? null; + } + + protected setupEvents(): void { + // Event delegation: single click listener on the viewport + this.events.on(this.viewport, "click", (e: MouseEvent) => { + const item = (e.target as HTMLElement).closest(".ss-scrollable-list-item"); + if (!item) return; + + const { value } = item.dataset; + if (value !== undefined) { + this.setSelected(value); + this.emit(value); + } + }); + } + + /** + * Update the selected value and visual state. + */ + setSelected(value: string | undefined): void { + this.selectedValue = value; + + if (!this.viewport) return; + + // Remove previous selection + const prev = this.viewport.querySelector(".ss-scrollable-list-item--selected"); + prev?.classList.remove("ss-scrollable-list-item--selected"); + + // Apply new selection + if (value !== undefined) { + const next = this.viewport.querySelector(`[data-value="${CSS.escape(value)}"]`); + next?.classList.add("ss-scrollable-list-item--selected"); + } + } + + /** + * Scroll the selected item into view. + */ + scrollToSelected(): void { + if (!this.viewport || this.selectedValue === undefined) return; + + const item = this.viewport.querySelector(`[data-value="${CSS.escape(this.selectedValue)}"]`); + item?.scrollIntoView({ block: "center" }); + } + + /** + * Get the data attributes of the currently selected item. + */ + getSelectedData(): Record | undefined { + if (!this.viewport || this.selectedValue === undefined) return undefined; + + const item = this.viewport.querySelector(`[data-value="${CSS.escape(this.selectedValue)}"]`); + if (!item) return undefined; + + const data: Record = {}; + for (const key of Object.keys(item.dataset)) { + if (key !== "value") { + data[key] = item.dataset[key]!; + } + } + return data; + } +} diff --git a/src/core/ui/primitives/SliderControl.ts b/src/core/ui/primitives/SliderControl.ts new file mode 100644 index 00000000..d471dac9 --- /dev/null +++ b/src/core/ui/primitives/SliderControl.ts @@ -0,0 +1,180 @@ +import type { ChangeCallback, SliderConfig } from "./types"; +import { UIComponent } from "./UIComponent"; + +/** + * Slider input with label and formatted value display. + * + * Drag lifecycle hooks (`onDragStart`, `onChange`, `onDragEnd`) let callers + * preview changes live during a drag and commit one undo entry on release, + * instead of flooding the undo stack with every intermediate tick. + */ +export class SliderControl extends UIComponent { + private slider: HTMLInputElement | null = null; + private valueInput: HTMLInputElement | null = null; + private formatValue: (value: number) => string; + private dragStartCallbacks: ChangeCallback[] = []; + private dragEndCallbacks: ChangeCallback[] = []; + + constructor(private sliderConfig: SliderConfig) { + super({ className: sliderConfig.className ?? "ss-toolbar-popup-section" }); + this.formatValue = sliderConfig.formatValue ?? String; + } + + render(): string { + const { label, min, max, step = 1, initialValue, labelAttributes } = this.sliderConfig; + const value = initialValue ?? min; + const labelAttrs = labelAttributes + ? ` ${Object.entries(labelAttributes) + .map(([k, v]) => `${k}="${v}"`) + .join(" ")}` + : ""; + return ` +
${label}
+
+ + +
+ `; + } + + protected bindElements(): void { + this.slider = this.container?.querySelector('input[type="range"]') ?? null; + this.valueInput = this.container?.querySelector(".ss-toolbar-popup-value") ?? null; + } + + protected setupEvents(): void { + // Drag lifecycle: pointerdown → start, input → live update, change → end + this.events.on(this.slider, "pointerdown", () => { + for (const cb of this.dragStartCallbacks) cb(); + }); + + // Slider drag updates the display and emits + this.events.on(this.slider, "input", () => { + const value = this.getValue(); + this.updateDisplay(value); + this.emit(value); + }); + + // change fires once on slider release (end of drag) or keyboard commit + this.events.on(this.slider, "change", () => { + const value = this.getValue(); + for (const cb of this.dragEndCallbacks) cb(value); + }); + + // Value input: commit on blur or Enter, revert on Escape + this.events.on(this.valueInput, "blur", () => this.commitInputValue()); + this.events.on(this.valueInput, "keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + this.commitInputValue(); + this.valueInput?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + this.revertInputValue(); + this.valueInput?.blur(); + } + }); + + // Select all text on focus for easy replacement + this.events.on(this.valueInput, "focus", () => { + this.valueInput?.select(); + }); + } + + /** + * Parse and commit the value from the text input. + */ + private commitInputValue(): void { + if (!this.valueInput) return; + + const { min, max } = this.sliderConfig; + const parsed = this.parseInputValue(this.valueInput.value, min, max); + this.slider!.value = String(parsed); + this.updateDisplay(parsed); + this.emit(parsed); + } + + /** + * Revert the text input to match the current slider value. + */ + private revertInputValue(): void { + this.updateDisplay(this.getValue()); + } + + /** + * Parse user input, strip non-numeric chars, clamp to range. + */ + private parseInputValue(input: string, min: number, max: number): number { + const stripped = input.replace(/[^0-9.-]/g, ""); + const num = parseFloat(stripped); + if (Number.isNaN(num)) return this.getValue(); // Keep current if invalid + return Math.max(min, Math.min(max, num)); + } + + /** + * Register a callback for drag start (pointerdown on the range input). + * Use this to capture initial state for two-phase update patterns. + * @internal + */ + onDragStart(callback: ChangeCallback): void { + this.dragStartCallbacks.push(callback); + } + + /** + * Register a callback for drag end (change event on the range input). + * Fires once when the user releases the slider with the final value. + * Use this to commit a single undo entry for the entire drag. + * @internal + */ + onDragEnd(callback: ChangeCallback): void { + this.dragEndCallbacks.push(callback); + } + + /** + * Get the current slider value. + * @internal + */ + getValue(): number { + return parseFloat(this.slider?.value ?? String(this.sliderConfig.min)); + } + + /** + * Set the slider value programmatically. + * @internal + */ + setValue(value: number): void { + if (this.slider) { + this.slider.value = String(value); + } + this.updateDisplay(value); + } + + /** + * Update the displayed value text. + */ + private updateDisplay(value: number): void { + if (this.valueInput) { + this.valueInput.value = this.formatValue(value); + } + } + + /** + * Enable or disable the slider and input. + * @internal + */ + setEnabled(enabled: boolean): void { + if (this.slider) { + this.slider.disabled = !enabled; + } + if (this.valueInput) { + this.valueInput.disabled = !enabled; + } + } + + override dispose(): void { + super.dispose(); + this.dragStartCallbacks = []; + this.dragEndCallbacks = []; + } +} diff --git a/src/core/ui/primitives/UIComponent.ts b/src/core/ui/primitives/UIComponent.ts new file mode 100644 index 00000000..a2139d79 --- /dev/null +++ b/src/core/ui/primitives/UIComponent.ts @@ -0,0 +1,162 @@ +import { EventManager } from "./EventManager"; +import type { ChangeCallback, Disposable, Mountable, UIComponentConfig } from "./types"; + +/** + * Abstract base class for all UI components. + * + * Provides a consistent lifecycle (mount, unmount, dispose), centralized + * event management, and a callback system for value changes. + * + * @typeParam T - The type of value this component emits via onChange + * + * @example + * ```typescript + * class MySlider extends UIComponent { + * render() { return ''; } + * protected bindElements() { this.slider = this.container?.querySelector('input'); } + * protected setupEvents() { this.events.on(this.slider, 'input', () => this.emit(value)); } + * } + * ``` + */ +export abstract class UIComponent implements Mountable, Disposable { + protected container: HTMLElement | null = null; + protected events: EventManager; + protected changeCallbacks: ChangeCallback[] = []; + protected mounted = false; + + constructor(protected config: UIComponentConfig = {}) { + this.events = new EventManager(); + } + + /** + * Return the HTML string for this component. + * Called during mount() to populate the container. + * @internal + */ + abstract render(): string; + + /** + * Query and store references to DOM elements. + * Called after render() during mount(). + */ + protected abstract bindElements(): void; + + /** + * Set up event listeners using this.events. + * Called after bindElements() during mount(). + */ + protected abstract setupEvents(): void; + + /** + * Mount the component to a parent element. + * @internal + */ + mount(parent: HTMLElement): void { + if (this.mounted) return; + + this.container = document.createElement("div"); + + if (this.config.className) { + this.container.className = this.config.className; + } + + if (this.config.attributes) { + for (const [key, value] of Object.entries(this.config.attributes)) { + this.container.setAttribute(key, value); + } + } + + this.container.innerHTML = this.render(); + parent.appendChild(this.container); + + this.bindElements(); + this.setupEvents(); + this.mounted = true; + } + + /** + * Remove the component from the DOM without disposing. + * @internal + */ + unmount(): void { + this.container?.remove(); + this.mounted = false; + } + + /** + * Register a callback for value changes. + * @internal + */ + onChange(callback: ChangeCallback): void { + this.changeCallbacks.push(callback); + } + + /** + * Emit a value to all registered callbacks. + */ + protected emit(value: T): void { + for (const callback of this.changeCallbacks) { + callback(value); + } + } + + /** + * Clean up all resources: event listeners, DOM, callbacks. + * @internal + */ + dispose(): void { + this.events.dispose(); + this.unmount(); + this.container = null; + this.changeCallbacks = []; + } + + /** + * Get the root container element. + * @internal + */ + getContainer(): HTMLElement | null { + return this.container; + } + + /** + * Check if component is currently mounted. + * @internal + */ + isMounted(): boolean { + return this.mounted; + } + + /** + * Show the component (set display to default). + * @internal + */ + show(): void { + if (this.container) { + this.container.style.display = ""; + } + } + + /** + * Hide the component. + * @internal + */ + hide(): void { + if (this.container) { + this.container.style.display = "none"; + } + } + + /** + * Toggle visibility. + * @internal + */ + toggle(visible?: boolean): void { + const shouldShow = visible ?? this.container?.style.display === "none"; + if (shouldShow) { + this.show(); + } else { + this.hide(); + } + } +} diff --git a/src/core/ui/primitives/index.ts b/src/core/ui/primitives/index.ts new file mode 100644 index 00000000..8d7a05a0 --- /dev/null +++ b/src/core/ui/primitives/index.ts @@ -0,0 +1,11 @@ +// Core types and interfaces +export * from "./types"; + +// Base classes +export { EventManager } from "./EventManager"; +export { UIComponent } from "./UIComponent"; + +// Primitive components +export { MergeFieldLabel } from "./MergeFieldLabel"; +export { ScrollableList } from "./ScrollableList"; +export { SliderControl } from "./SliderControl"; diff --git a/src/core/ui/primitives/types.ts b/src/core/ui/primitives/types.ts new file mode 100644 index 00000000..dc7feb87 --- /dev/null +++ b/src/core/ui/primitives/types.ts @@ -0,0 +1,107 @@ +/** + * Core interfaces for the UI primitives system. + * These provide the foundation for composable toolbar components. + */ + +/** + * Configuration options common to all UI components. + */ +export interface UIComponentConfig { + /** CSS class name(s) to apply to the container */ + className?: string; + /** HTML attributes to apply to the container */ + attributes?: Record; +} + +/** + * Interface for components that can be cleaned up. + */ +export interface Disposable { + dispose(): void; +} + +/** + * Interface for components that can be mounted to the DOM. + */ +export interface Mountable { + mount(parent: HTMLElement): void; + unmount(): void; +} + +/** + * Callback type for value change notifications. + */ +export type ChangeCallback = (value: T) => void; + +/** + * Event binding record for EventManager tracking. + */ +export interface EventBinding { + element: EventTarget; + type: string; + handler: EventListener; + options?: AddEventListenerOptions; +} + +/** + * Slider control configuration. + */ +export interface SliderConfig extends UIComponentConfig { + label: string; + min: number; + max: number; + step?: number; + initialValue?: number; + formatValue?: (value: number) => string; + /** HTML attributes to apply to the label element (e.g. data-merge-path) */ + labelAttributes?: Record; +} + +/** + * Merge field label configuration. + * Replaces a static property label with one that supports merge field binding. + */ +export interface MergeFieldLabelConfig extends UIComponentConfig { + /** Display label for the property (e.g. "Opacity") */ + label: string; + /** Dot-notation path to the clip property (e.g. "asset.font.opacity") */ + propertyPath: string; + /** Prefix for auto-generated field names (e.g. "TEXT_OPACITY") */ + namePrefix: string; +} + +/** + * A single item in a ScrollableList. + */ +export interface ScrollableListItem { + /** Value identifier (emitted on selection) */ + value: string; + /** Display label */ + label: string; + /** Optional data attributes to apply to the item element */ + data?: Record; +} + +/** + * A group of items with a header in a ScrollableList. + */ +export interface ScrollableListGroup { + /** Group header text */ + header: string; + /** Optional secondary text displayed alongside the header */ + headerDetail?: string; + /** Items in this group */ + items: ScrollableListItem[]; +} + +/** + * Configuration for the ScrollableList component. + */ +export interface ScrollableListConfig extends UIComponentConfig { + /** Groups of items to display */ + groups: ScrollableListGroup[]; + /** Fixed height in pixels (required for scroll containment) */ + height?: number; + /** Currently selected value */ + selectedValue?: string; +} diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts new file mode 100644 index 00000000..a0d770ba --- /dev/null +++ b/src/core/ui/rich-text-toolbar.ts @@ -0,0 +1,1843 @@ +import type { Edit } from "@core/edit-session"; +import { EditEvent, InternalEvent } from "@core/events/edit-events"; +import type { MergeField } from "@core/merge"; +import { ShotstackEdit } from "@core/shotstack-edit"; +import type { ResolvedClip, RichTextAsset } from "@schemas"; +import { injectShotstackStyles } from "@styles/inject"; + +import { GOOGLE_FONTS_BY_FILENAME } from "../fonts/google-fonts"; + +import { BackgroundColorPicker } from "./background-color-picker"; +import { BaseToolbar, FONT_SIZES } from "./base-toolbar"; +import { EffectPanel } from "./composites/EffectPanel"; +import { SpacingPanel } from "./composites/SpacingPanel"; +import { StylePanel } from "./composites/StylePanel"; +import { TransitionPanel } from "./composites/TransitionPanel"; +import { DragStateManager } from "./drag-state-manager"; +import { FontColorPicker } from "./font-color-picker"; +import { FontPicker, type FontInfo } from "./font-picker"; +import { MergeFieldLabelManager, type MergeFieldLabelHost } from "./merge-field-label-manager"; + +export interface RichTextToolbarOptions { + mergeFields?: boolean; +} + +export class RichTextToolbar extends BaseToolbar { + private showMergeFields: boolean; + private fontPopup: HTMLDivElement | null = null; + private fontPreview: HTMLSpanElement | null = null; + private fontPicker: FontPicker | null = null; + private sizeInput: HTMLInputElement | null = null; + private sizePopup: HTMLDivElement | null = null; + private weightDropdown: HTMLElement | null = null; + private weightPopup: HTMLDivElement | null = null; + private weightPreview: HTMLSpanElement | null = null; + private spacingPopup: HTMLDivElement | null = null; + private spacingPanel: SpacingPanel | null = null; + private anchorTopBtn: HTMLButtonElement | null = null; + private anchorMiddleBtn: HTMLButtonElement | null = null; + private anchorBottomBtn: HTMLButtonElement | null = null; + private alignIcon: SVGElement | null = null; + private transformBtn: HTMLButtonElement | null = null; + private underlineBtn: HTMLButtonElement | null = null; + private linethroughBtn: HTMLButtonElement | null = null; + private textEditPopup: HTMLDivElement | null = null; + private textEditArea: HTMLTextAreaElement | null = null; + private textEditDebounceTimer: ReturnType | null = null; + + // Autocomplete for merge field variables + private autocompletePopup: HTMLDivElement | null = null; + private autocompleteItems: HTMLDivElement | null = null; + private autocompleteVisible: boolean = false; + private autocompleteFilter: string = ""; + private autocompleteStartPos: number = 0; + private selectedAutocompleteIndex: number = 0; + private backgroundPopup: HTMLDivElement | null = null; + private backgroundColorPicker: BackgroundColorPicker | null = null; + + private fontColorPopup: HTMLDivElement | null = null; + private fontColorPicker: FontColorPicker | null = null; + private colorDisplay: HTMLButtonElement | null = null; + + private animationPopup: HTMLDivElement | null = null; + private animationDurationSlider: HTMLInputElement | null = null; + private animationDurationValue: HTMLSpanElement | null = null; + private animationStyleSection: HTMLDivElement | null = null; + private animationDirectionSection: HTMLDivElement | null = null; + + /** + * Per-control drag state manager. + */ + private dragManager = new DragStateManager(); + + private lastSyncedClipId: string | null = null; + + // Current animation duration during drag (for explicit final config) + private currentAnimationDuration: number = 1; + + // Composite panels (replace ~400 lines of duplicated transition/effect code) + private transitionPopup: HTMLDivElement | null = null; + private transitionPanel: TransitionPanel | null = null; + private effectPopup: HTMLDivElement | null = null; + private effectPanel: EffectPanel | null = null; + private stylePopup: HTMLDivElement | null = null; + private stylePanel: StylePanel | null = null; + + // Merge field label manager (bound to data-merge-path annotated labels) + private mergeFieldManager: MergeFieldLabelManager | null = null; + + // Bound handlers for proper cleanup + private boundHandleClick: ((e: MouseEvent) => void) | null = null; + private unsubFontCapabilities: (() => void) | null = null; + private unsubMergeFieldChanged: (() => void) | null = null; + + constructor(edit: Edit, options: RichTextToolbarOptions = {}) { + super(edit); + this.showMergeFields = options.mergeFields ?? false; + } + + private getShotstackEdit(): ShotstackEdit | null { + if (this.edit && "mergeFields" in this.edit) { + return this.edit as ShotstackEdit; + } + return null; + } + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-toolbar"; + + this.container.innerHTML = ` + +
+ + + +
+
+ +
+ +
+
Edit Text
+
+ +
+
+
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+
+ +
+ +
+
+
+
+
Anchor text box
+
+ + + +
+
+
+
+ +
+ + + + + + + +
+ + +
+ +
+
+ +
+ + +
+ +
+
+
Preset
+
+ + + + + +
+
+
+
Duration
+
+ + 1.0s +
+
+
+
Writing Style
+
+ + +
+
+
+
Direction
+
+ + + + +
+
+
+ +
+
+ +
+ +
+
+ +
+ +
+
+ `; + + this.sizeInput = this.container.querySelector("[data-size-input]"); + this.sizePopup = this.container.querySelector("[data-size-popup]"); + this.weightDropdown = this.container.querySelector(".ss-toolbar-dropdown--weight"); + this.weightPopup = this.container.querySelector("[data-weight-popup]"); + this.weightPreview = this.container.querySelector("[data-weight-preview]"); + this.buildWeightPopup(); + this.fontPopup = this.container.querySelector("[data-font-popup]"); + this.fontPreview = this.container.querySelector("[data-font-preview]"); + this.alignIcon = this.container.querySelector("[data-align-icon]"); + this.transformBtn = this.container.querySelector("[data-action='transform']"); + this.underlineBtn = this.container.querySelector("[data-action='underline']"); + this.linethroughBtn = this.container.querySelector("[data-action='linethrough']"); + this.textEditPopup = this.container.querySelector("[data-text-edit-popup]"); + this.textEditArea = this.container.querySelector("[data-text-edit-area]"); + this.autocompletePopup = this.container.querySelector("[data-autocomplete-popup]"); + this.autocompleteItems = this.container.querySelector("[data-autocomplete-items]"); + + // Delegated click handler for autocomplete items (set up once, no leak on repeated shows) + this.autocompleteItems?.addEventListener("click", (e: MouseEvent) => { + const item = (e.target as HTMLElement).closest("[data-var-name]") as HTMLElement | null; + if (!item) return; + e.stopPropagation(); + const { varName } = item.dataset; + if (varName) { + this.insertVariable(varName); + } + }); + + this.boundHandleClick = this.handleClick.bind(this); + this.container.addEventListener("click", this.boundHandleClick); + + // Size input handlers + this.sizeInput?.addEventListener("click", e => { + e.stopPropagation(); + this.toggleSizePopup(); + }); + this.sizeInput?.addEventListener("blur", () => this.applyManualSize()); + this.sizeInput?.addEventListener("keydown", e => { + if (e.key === "Enter") { + this.applyManualSize(); + this.sizeInput?.blur(); + this.closeAllPopups(); + } + }); + this.buildSizePopup(); + + // Font color picker + this.colorDisplay = this.container.querySelector("[data-color-display]"); + this.fontColorPopup = this.container.querySelector("[data-font-color-popup]"); + const fontColorPickerContainer = this.container.querySelector("[data-font-color-picker]"); + + if (fontColorPickerContainer) { + this.fontColorPicker = new FontColorPicker(); + this.fontColorPicker.mount(fontColorPickerContainer as HTMLElement); + this.fontColorPicker.onChange(updates => { + this.updateFontColorProperty(updates); + }); + } + + this.spacingPopup = this.container.querySelector("[data-spacing-popup]"); + this.anchorTopBtn = this.container.querySelector("[data-action='anchor-top']"); + this.anchorMiddleBtn = this.container.querySelector("[data-action='anchor-middle']"); + this.anchorBottomBtn = this.container.querySelector("[data-action='anchor-bottom']"); + + // Mount SpacingPanel composite (letter spacing + line height) + const spacingContainer = this.container.querySelector("[data-spacing-panel-container]") as HTMLElement | null; + if (spacingContainer) { + this.spacingPanel = new SpacingPanel(); + + // Phase 1: Capture initial state when drag starts + this.spacingPanel.onDragStart(() => { + const state = this.captureClipState(); + if (state) { + this.dragManager.start("spacing-panel", state.clipId, state.initialState); + } + }); + + // Phase 2: Live updates during drag + this.spacingPanel.onChange(state => { + const isDragging = this.spacingPanel?.isDragging() ?? false; + + if (isDragging) { + // Live update during drag (no command) + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const asset = this.getCurrentAsset(); + if (!asset) return; + + const updatedAsset = { + ...asset, + style: { + ...(asset.style || {}), + letterSpacing: state.letterSpacing, + lineHeight: state.lineHeight + } + }; + this.edit.updateClipInDocument(clipId, { asset: updatedAsset as ResolvedClip["asset"] }); + this.edit.resolveClip(clipId); + } else { + // Discrete update (creates command) + this.updateClipProperty({ + style: { letterSpacing: state.letterSpacing, lineHeight: state.lineHeight } + }); + } + }); + + // Phase 3: Commit single command when drag ends + this.spacingPanel.onDragEnd(() => { + const session = this.dragManager.end("spacing-panel"); + if (!session || !this.spacingPanel) return; + + // Use clipId from session (user may have switched clips during drag) + const { clipId } = session; + + // Read final state from SpacingPanel + const finalState = this.spacingPanel.getState(); + + // Construct final clip state + const finalClip = structuredClone(session.initialState); + if (finalClip.asset && finalClip.asset.type === "rich-text" && finalClip.asset.style) { + finalClip.asset.style.letterSpacing = finalState.letterSpacing; + finalClip.asset.style.lineHeight = finalState.lineHeight; + } + + this.edit.commitClipUpdate(clipId, session.initialState, finalClip); + }); + + this.spacingPanel.mount(spacingContainer); + } + + // Animation controls + this.animationPopup = this.container.querySelector("[data-animation-popup]"); + this.animationDurationSlider = this.container.querySelector("[data-animation-duration]"); + this.animationDurationValue = this.container.querySelector("[data-animation-duration-value]"); + this.animationStyleSection = this.container.querySelector("[data-animation-style-section]"); + this.animationDirectionSection = this.container.querySelector("[data-animation-direction-section]"); + + // Composite panels for transition/effect (mount containers) + this.transitionPopup = this.container.querySelector("[data-transition-popup]"); + this.effectPopup = this.container.querySelector("[data-effect-popup]"); + + // Mount TransitionPanel composite + if (this.transitionPopup) { + this.transitionPanel = new TransitionPanel(); + this.transitionPanel.onChange(() => { + const transitionValue = this.transitionPanel?.getClipValue(); + this.applyClipUpdate({ transition: transitionValue }); + }); + this.transitionPanel.mount(this.transitionPopup); + } + + // Mount EffectPanel composite + if (this.effectPopup) { + this.effectPanel = new EffectPanel(); + this.effectPanel.onChange(() => { + const effectValue = this.effectPanel?.getClipValue(); + this.applyClipUpdate({ effect: effectValue }); + }); + this.effectPanel.mount(this.effectPopup); + } + + // Preset buttons + this.container.querySelectorAll("[data-preset]").forEach(btn => { + btn.addEventListener("click", () => { + const preset = btn.dataset["preset"] as "typewriter" | "fadeIn" | "slideIn" | "ascend" | "shift" | "movingLetters"; + if (preset) this.updateAnimationProperty({ preset }); + }); + }); + + // Duration slider - Two-phase pattern (creates exactly 1 command per drag) + // Phase 1: Capture initial state on pointerdown + this.animationDurationSlider?.addEventListener("pointerdown", () => { + const state = this.captureClipState(); + if (state) { + this.dragManager.start("animation-duration", state.clipId, state.initialState); + } + }); + + // Phase 2: Live update during drag (no command) + this.animationDurationSlider?.addEventListener("input", e => { + const value = parseFloat((e.target as HTMLInputElement).value); + this.currentAnimationDuration = value; // Track locally + if (this.animationDurationValue) this.animationDurationValue.textContent = `${value.toFixed(1)}s`; + this.updateAnimationLive({ duration: value }); + }); + + // Phase 3: Commit single command on release + this.animationDurationSlider?.addEventListener("change", () => { + const session = this.dragManager.end("animation-duration"); + if (!session) return; + + // Use clipId from session (user may have switched clips during drag) + const { clipId } = session; + + // Construct final clip state with explicit animation duration + const finalClip = structuredClone(session.initialState); + if (finalClip.asset && finalClip.asset.type === "rich-text") { + if (!finalClip.asset.animation) { + finalClip.asset.animation = { preset: "fadeIn" }; + } + finalClip.asset.animation.duration = this.currentAnimationDuration; + } + + this.edit.commitClipUpdate(clipId, session.initialState, finalClip); + }); + + // Style buttons + this.container.querySelectorAll("[data-animation-style]").forEach(btn => { + btn.addEventListener("click", () => { + const style = btn.dataset["animationStyle"] as "character" | "word"; + if (style) this.updateAnimationProperty({ style }); + }); + }); + + // Direction buttons + this.container.querySelectorAll("[data-animation-direction]").forEach(btn => { + btn.addEventListener("click", () => { + const direction = btn.dataset["animationDirection"] as "left" | "right" | "up" | "down"; + if (direction) this.updateAnimationProperty({ direction }); + }); + }); + + // Mount StylePanel composite (consolidated fill/border/padding/shadow) + this.stylePopup = this.container.querySelector("[data-style-popup]"); + if (this.stylePopup) { + this.stylePanel = new StylePanel(); + + // Phase 1: Capture initial state when drag starts + this.stylePanel.onDragStart(() => { + const state = this.captureClipState(); + if (state) { + this.dragManager.start("style-panel", state.clipId, state.initialState); + } + }); + + // Phase 2: Live updates during drag (no commands) + this.stylePanel.onBorderChange(border => { + const isDragging = this.stylePanel?.isDragging() ?? false; + + if (isDragging) { + // Live update during drag (no command) + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const asset = this.getCurrentAsset(); + if (!asset) return; + + const updatedAsset = { + ...asset, + border: { + width: border.width, + color: border.color, + opacity: border.opacity / 100, + radius: border.radius + } + }; + this.edit.updateClipInDocument(clipId, { asset: updatedAsset as ResolvedClip["asset"] }); + this.edit.resolveClip(clipId); + } else { + // Discrete update (creates command) + this.updateBorderProperty({ + width: border.width, + color: border.color, + opacity: border.opacity / 100, + radius: border.radius + }); + } + }); + + this.stylePanel.onPaddingChange(padding => { + const isDragging = this.stylePanel?.isDragging() ?? false; + + if (isDragging) { + // Live update during drag (no command) + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const asset = this.getCurrentAsset(); + if (!asset) return; + + const updatedAsset = { ...asset, padding }; + this.edit.updateClipInDocument(clipId, { asset: updatedAsset as ResolvedClip["asset"] }); + this.edit.resolveClip(clipId); + } else { + // Discrete update (creates command) + this.updatePaddingProperty(padding); + } + }); + + this.stylePanel.onShadowChange(shadow => { + const isDragging = this.stylePanel?.isDragging() ?? false; + const shadowValue = shadow.enabled + ? { + offsetX: shadow.offsetX, + offsetY: shadow.offsetY, + blur: shadow.blur, + color: shadow.color, + opacity: shadow.opacity / 100 + } + : undefined; + + if (isDragging) { + // Live update during drag (no command) + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const asset = this.getCurrentAsset(); + if (!asset) return; + + const updatedAsset = { ...asset, shadow: shadowValue }; + this.edit.updateClipInDocument(clipId, { asset: updatedAsset as ResolvedClip["asset"] }); + this.edit.resolveClip(clipId); + } else if (shadow.enabled) { + // Discrete update (creates command) + this.updateShadowProperty(shadowValue!); + } else { + this.updateClipProperty({ shadow: undefined }); + } + }); + + // Phase 3: Commit single command when drag ends + this.stylePanel.onDragEnd(() => { + const session = this.dragManager.end("style-panel"); + if (!session) return; + + // Use clipId from session (user may have switched clips during drag) + const { clipId } = session; + if (!this.stylePanel) return; + + // Read final state from StylePanel (source of truth) + const finalState = this.stylePanel.getState(); + + // Construct final clip state with actual user-selected values + const finalClip = structuredClone(session.initialState); + if (finalClip.asset && finalClip.asset.type === "rich-text") { + // Border: Convert opacity from percentage (0-100) to decimal (0-1) + finalClip.asset.border = { + width: finalState.border.width, + color: finalState.border.color, + opacity: finalState.border.opacity / 100, + radius: finalState.border.radius + }; + finalClip.asset.padding = finalState.padding; + finalClip.asset.shadow = finalState.shadow.enabled + ? { + offsetX: finalState.shadow.offsetX, + offsetY: finalState.shadow.offsetY, + blur: finalState.shadow.blur, + color: finalState.shadow.color, + opacity: finalState.shadow.opacity / 100 + } + : undefined; + // Note: fill is handled by BackgroundColorPicker, not StylePanel + } + + // Commit with explicit final state (avoids race condition) + this.edit.commitClipUpdate(clipId, session.initialState, finalClip); + }); + + this.stylePanel.mount(this.stylePopup); + + // Mount BackgroundColorPicker inside StylePanel's fill tab + const fillMount = this.stylePanel.getFillColorPickerMount(); + if (fillMount) { + this.backgroundColorPicker = new BackgroundColorPicker(); + this.backgroundColorPicker.mount(fillMount); + + // Drag start - capture initial state + this.backgroundColorPicker.onDragStart(controlId => { + const state = this.captureClipState(); + if (state) { + this.dragManager.start(controlId, state.clipId, state.initialState); + } + }); + + // Live updates during drag (or discrete commands outside drag) + this.backgroundColorPicker.onChange((controlId, enabled, color, opacity) => { + const session = this.dragManager.get(controlId); + + if (session) { + // During drag: live updates only (no commands) + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const asset = this.getCurrentAsset(); + if (!asset) return; + + if (enabled) { + const currentBackground = asset.background || { color: "#FFFFFF", opacity: 1, borderRadius: 0 }; + const updatedAsset = { + ...asset, + background: { ...currentBackground, color, opacity } + }; + this.edit.updateClipInDocument(clipId, { asset: updatedAsset }); + } else { + const updatedAsset = { ...asset }; + delete updatedAsset.background; + this.edit.updateClipInDocument(clipId, { asset: updatedAsset }); + } + this.edit.resolveClip(clipId); + } else if (enabled) { + // Outside drag: discrete command (e.g., toggle checkbox without drag) + this.updateBackgroundProperty({ color, opacity }); + } else { + this.removeBackgroundProperty(); + } + }); + + // Drag end - commit the final state as a single command + this.backgroundColorPicker.onDragEnd(controlId => { + const session = this.dragManager.end(controlId); + if (!session) { + return; + } + + // Use clipId from session (user may have switched clips during drag) + const { clipId } = session; + + // Build final state + const finalClip = structuredClone(session.initialState); + if (finalClip.asset && finalClip.asset.type === "rich-text") { + const enabled = this.backgroundColorPicker?.isEnabled() ?? false; + const color = this.backgroundColorPicker?.getColor() ?? "#FFFFFF"; + const opacity = this.backgroundColorPicker?.getOpacity() ?? 1; + if (enabled) { + finalClip.asset.background = { + color, + opacity, + borderRadius: finalClip.asset.background?.borderRadius ?? 0 + }; + } else { + delete finalClip.asset.background; + } + } + + // Commit command to history + this.edit.commitClipUpdate(clipId, session.initialState, finalClip); + }); + } + } + + // Replace annotated labels with MergeFieldLabel components when merge fields are enabled + if (this.showMergeFields) { + this.mergeFieldManager = new MergeFieldLabelManager(this as unknown as MergeFieldLabelHost, RichTextToolbar.PROPERTY_DEFAULTS); + this.mergeFieldManager.init(); + } + + // Text edit area handlers + this.textEditArea?.addEventListener("input", () => { + this.checkAutocomplete(); + this.debouncedApplyTextEdit(); + }); + this.textEditArea?.addEventListener("keydown", e => { + // Handle autocomplete navigation when visible + if (this.autocompleteVisible) { + if (e.key === "ArrowDown") { + e.preventDefault(); + const count = this.getFilteredFieldCount(); + this.selectedAutocompleteIndex = Math.min(this.selectedAutocompleteIndex + 1, count - 1); + this.showAutocomplete(); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + this.selectedAutocompleteIndex = Math.max(this.selectedAutocompleteIndex - 1, 0); + this.showAutocomplete(); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + this.insertSelectedVariable(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + this.hideAutocomplete(); + return; + } + } + + // Apply on Ctrl/Cmd+Enter (allow normal Enter for newlines) + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + this.textEditDebounceTimer = null; + } + this.applyTextEdit(); + this.closeAllPopups(); + } + if (e.key === "Escape") { + this.closeAllPopups(); + } + }); + + this.setupOutsideClickHandler(); + this.enableDrag(); + + // eslint-disable-next-line no-param-reassign -- Intentional DOM parent styling + parent.style.position = "relative"; + parent.insertBefore(this.container, parent.firstChild); + + // Re-sync when font capabilities change (async operation) + this.unsubFontCapabilities = this.edit.getInternalEvents().on(InternalEvent.FontCapabilitiesChanged, () => { + if (this.container?.style.display !== "none") { + this.syncState(); + } + }); + + // Re-sync merge field labels when fields are added/removed globally + this.unsubMergeFieldChanged = this.edit.getInternalEvents().on(EditEvent.MergeFieldChanged, () => { + if (this.container?.style.display !== "none" && this.showMergeFields && this.mergeFieldManager?.hasLabels) { + this.mergeFieldManager.sync(); + } + }); + } + + // ─── Merge Field Label Defaults ──────────────────────────────────────── + + /** Default values for merge-field-bindable properties when the property is undefined on the clip. */ + private static readonly PROPERTY_DEFAULTS: Record = { + "asset.font.color": "#ffffff", + "asset.font.opacity": "1", + "asset.font.background": "#FFFF00", + "asset.border.width": "0", + "asset.border.color": "#000000", + "asset.border.radius": "0", + "asset.padding.top": "0", + "asset.padding.right": "0", + "asset.padding.bottom": "0", + "asset.padding.left": "0", + "asset.shadow.offsetX": "0", + "asset.shadow.offsetY": "0", + "asset.shadow.color": "#000000", + "asset.style.letterSpacing": "0", + "asset.style.lineHeight": "1.2" + }; + + private handleClick(e: MouseEvent): void { + const target = e.target as HTMLElement; + const button = target.closest("button"); + if (!button) return; + + const { action } = button.dataset; + if (!action) return; + + const asset = this.getCurrentAsset(); + if (!asset) return; + + switch (action) { + case "size-down": + this.updateSize((asset.font?.size ?? 48) - 4); + break; + case "size-up": + this.updateSize((asset.font?.size ?? 48) + 4); + break; + case "weight-toggle": + this.toggleWeightPopup(); + break; + case "font-toggle": + this.toggleFontPopup(); + break; + case "text-edit-toggle": + this.toggleTextEditPopup(); + break; + case "spacing-toggle": + this.toggleSpacingPopup(); + break; + case "style-toggle": + this.togglePopup(this.stylePopup); + break; + case "font-color-toggle": + this.toggleFontColorPopup(); + break; + case "anchor-top": + this.updateVerticalAlign("top"); + break; + case "anchor-middle": + this.updateVerticalAlign("middle"); + break; + case "anchor-bottom": + this.updateVerticalAlign("bottom"); + break; + case "align-cycle": + this.cycleAlignment(asset); + break; + case "transform": + this.cycleTransform(asset); + break; + case "underline": + this.toggleUnderline(asset); + break; + case "linethrough": + this.toggleLinethrough(asset); + break; + case "animation-toggle": + this.toggleAnimationPopup(); + break; + case "animation-clear": + this.updateClipProperty({ animation: undefined }); + this.closeAllPopups(); + break; + case "transition-toggle": + this.togglePopup(this.transitionPopup); + break; + case "effect-toggle": + this.togglePopup(this.effectPopup); + break; + default: + break; + } + } + + private getCurrentAsset(): RichTextAsset | null { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip) return null; + return clip.asset as RichTextAsset; + } + + private updateSize(newSize: number): void { + const clampedSize = Math.max(8, Math.min(500, newSize)); + this.updateClipProperty({ font: { size: clampedSize } }); + } + + // Font weight options: name → numeric value + private static readonly FONT_WEIGHTS: Array<{ name: string; value: number }> = [ + { name: "Light", value: 300 }, + { name: "Regular", value: 400 }, + { name: "Medium", value: 500 }, + { name: "Bold", value: 700 }, + { name: "Black", value: 900 } + ]; + + // Checkmark SVG (constant to avoid rebuilding string) + private static readonly CHECKMARK_SVG = + ''; + + /** Single source of truth for weight normalization - handles string, number, or object */ + private normalizeWeight(raw: unknown): number { + if (typeof raw === "number") return raw; + if (typeof raw === "string") return parseInt(raw, 10) || 400; + return 400; + } + + private getWeightName(weight: unknown): string { + const numWeight = this.normalizeWeight(weight); + const found = RichTextToolbar.FONT_WEIGHTS.find(w => w.value === numWeight); + return found?.name ?? "Regular"; + } + + private toggleWeightPopup(): void { + this.togglePopup(this.weightPopup, () => this.updateWeightPopupState()); + } + + /** Build popup once at mount - uses event delegation (no per-item listeners) */ + private buildWeightPopup(): void { + if (!this.weightPopup) return; + + // Build static HTML once + this.weightPopup.innerHTML = RichTextToolbar.FONT_WEIGHTS.map( + ({ name, value }) => ` +
+ ${name} + +
+ ` + ).join(""); + + // Single delegated click handler (no leak on repeated opens) + this.weightPopup.addEventListener("click", (e: MouseEvent) => { + const item = (e.target as HTMLElement).closest("[data-weight]") as HTMLElement | null; + if (!item) return; + const weight = parseInt(item.dataset["weight"]!, 10); + this.setFontWeight(weight); + this.closeAllPopups(); + }); + + this.updateWeightPopupState(); + } + + /** Update active state without rebuilding DOM */ + private updateWeightPopupState(): void { + if (!this.weightPopup) return; + const asset = this.getCurrentAsset(); + const currentWeight = this.normalizeWeight(asset?.font?.weight); + + this.weightPopup.querySelectorAll("[data-weight]").forEach(item => { + const el = item as HTMLElement; + const value = parseInt(el.dataset["weight"]!, 10); + const isActive = value === currentWeight; + el.classList.toggle("active", isActive); + + // Update checkmark slot + const slot = el.querySelector(".ss-toolbar-weight-check-slot"); + if (slot) { + slot.innerHTML = isActive ? RichTextToolbar.CHECKMARK_SVG : ""; + } + }); + } + + private setFontWeight(weight: number): void { + this.updateClipProperty({ font: { weight } }); + } + + private toggleSizePopup(): void { + this.togglePopup(this.sizePopup, () => this.updateSizePopupState()); + } + + /** Build popup once at mount - uses event delegation (no per-item listeners) */ + private buildSizePopup(): void { + if (!this.sizePopup) return; + + // Build static HTML once + this.sizePopup.innerHTML = FONT_SIZES.map(size => `
${size}
`).join(""); + + // Single delegated click handler (no leak on repeated opens) + this.sizePopup.addEventListener("click", (e: MouseEvent) => { + const item = (e.target as HTMLElement).closest("[data-size]") as HTMLElement | null; + if (!item) return; + const size = parseInt(item.dataset["size"]!, 10); + this.updateSize(size); + this.closeAllPopups(); + }); + + this.updateSizePopupState(); + } + + /** Update active state without rebuilding DOM */ + private updateSizePopupState(): void { + if (!this.sizePopup) return; + const asset = this.getCurrentAsset(); + const currentSize = asset?.font?.size ?? 48; + + this.sizePopup.querySelectorAll("[data-size]").forEach(item => { + const el = item as HTMLElement; + const size = parseInt(el.dataset["size"]!, 10); + el.classList.toggle("active", size === currentSize); + }); + } + + private applyManualSize(): void { + if (!this.sizeInput) return; + const value = parseInt(this.sizeInput.value, 10); + if (!Number.isNaN(value) && value > 0) { + this.updateSize(value); + } + this.syncState(); + } + + private toggleSpacingPopup(): void { + this.togglePopup(this.spacingPopup); + } + + private toggleAnimationPopup(): void { + this.togglePopup(this.animationPopup, () => { + const asset = this.getCurrentAsset(); + this.updateAnimationSections(asset?.animation?.preset); + }); + } + + private toggleFontColorPopup(): void { + this.togglePopup(this.fontColorPopup, () => { + if (this.fontColorPicker) { + const asset = this.getCurrentAsset(); + const font = asset?.font; + const style = asset?.style; + + if (style?.gradient) { + this.fontColorPicker.setMode("gradient"); + } else { + this.fontColorPicker.setMode("color"); + this.fontColorPicker.setColor(font?.color || "#000000", font?.opacity ?? 1); + // Check for SDK-extended background property (not in external schema) + const fontExt = font as typeof font & { background?: string }; + if (fontExt?.background) { + this.fontColorPicker.setHighlight(fontExt.background); + } + } + } + }); + } + + private toggleFontPopup(): void { + this.togglePopup(this.fontPopup, () => this.buildFontPicker()); + } + + private toggleTextEditPopup(): void { + this.togglePopup(this.textEditPopup, () => { + if (this.textEditArea) { + const templateText = this.edit.getTemplateClipText(this.selectedTrackIdx, this.selectedClipIdx); + const asset = this.getCurrentAsset(); + this.textEditArea.value = templateText ?? asset?.text ?? ""; + this.textEditArea.focus(); + } + }); + } + + private debouncedApplyTextEdit(): void { + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + } + this.textEditDebounceTimer = setTimeout(() => { + this.applyTextEdit(); + this.textEditDebounceTimer = null; + }, 150); + } + + private applyTextEdit(): void { + if (!this.textEditArea) return; + const templateText = this.textEditArea.value; + + const shotstackEdit = this.getShotstackEdit(); + + // Resolve any merge field templates in the text for canvas rendering + const resolvedText = shotstackEdit?.mergeFields.resolve(templateText) ?? templateText; + + // Update merge field binding for export to preserve templates + const document = this.edit.getDocument(); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + + if (shotstackEdit?.mergeFields.isMergeFieldTemplate(templateText)) { + const binding = { + placeholder: templateText, + resolvedValue: resolvedText + }; + // Document binding (source of truth) + if (clipId && document) { + document.setClipBinding(clipId, "asset.text", binding); + } + } else if (clipId && document) { + // Document binding (source of truth) + document.removeClipBinding(clipId, "asset.text"); + } + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { text: resolvedText } as ResolvedClip["asset"] + }); + this.syncState(); + } + + // ─── Autocomplete for Merge Field Variables ───────────────────────────────── + + private checkAutocomplete(): void { + if (!this.showMergeFields) return; + if (!this.textEditArea) return; + + const pos = this.textEditArea.selectionStart; + const text = this.textEditArea.value.substring(0, pos); + const match = text.match(/\{\{\s*([A-Z_0-9]*)$/i); + + if (match) { + this.autocompleteStartPos = pos - match[0].length; + this.autocompleteFilter = match[1].toUpperCase(); + this.showAutocomplete(); + } else { + this.hideAutocomplete(); + } + } + + private showAutocomplete(): void { + if (!this.autocompletePopup || !this.autocompleteItems) return; + + const fields = this.getShotstackEdit()?.mergeFields.getAll() ?? []; + const filtered = fields.filter((f: MergeField) => f.name.toUpperCase().includes(this.autocompleteFilter)); + + if (filtered.length === 0) { + this.hideAutocomplete(); + return; + } + + // Reset selection if out of bounds + if (this.selectedAutocompleteIndex >= filtered.length) { + this.selectedAutocompleteIndex = 0; + } + + // Update HTML content only - click handler is delegated at mount time + this.autocompleteItems.innerHTML = filtered + .map( + (f: MergeField, i: number) => ` +
+ {{ ${f.name} }} + ${f.defaultValue ? `${f.defaultValue}` : ""} +
+ ` + ) + .join(""); + + this.autocompletePopup.classList.add("visible"); + this.autocompleteVisible = true; + } + + private hideAutocomplete(): void { + if (this.autocompletePopup) { + this.autocompletePopup.classList.remove("visible"); + } + this.autocompleteVisible = false; + this.selectedAutocompleteIndex = 0; + } + + private insertVariable(varName: string): void { + if (!this.textEditArea) return; + + const before = this.textEditArea.value.substring(0, this.autocompleteStartPos); + const after = this.textEditArea.value.substring(this.textEditArea.selectionStart); + + // Build template string (keeps {{ VAR }}) + const templateText = `${before}{{ ${varName} }}${after}`; + + // Resolve for clipConfiguration (canvas rendering) + const field = this.getShotstackEdit()?.mergeFields.get(varName); + const resolvedValue = field?.defaultValue ?? `{{ ${varName} }}`; + const resolvedText = `${before}${resolvedValue}${after}`; + + // Keep template in text area (user can see merge fields) + this.textEditArea.value = templateText; + + // Position cursor after inserted template + const newPos = this.autocompleteStartPos + varName.length + 6; // "{{ " + name + " }}" + this.textEditArea.selectionStart = newPos; + this.textEditArea.selectionEnd = newPos; + this.textEditArea.focus(); + + this.hideAutocomplete(); + + // Update merge field binding for export to preserve templates + const document = this.edit.getDocument(); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + + // Document binding (source of truth) + if (clipId && document) { + const binding = { + placeholder: templateText, + resolvedValue: resolvedText + }; + document.setClipBinding(clipId, "asset.text", binding); + } + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { text: resolvedText } as ResolvedClip["asset"] + }); + this.syncState(); + } + + private insertSelectedVariable(): void { + const selected = this.autocompleteItems?.querySelector(".selected") as HTMLElement | null; + if (!selected) return; + + const { varName } = selected.dataset; + if (varName) { + this.insertVariable(varName); + } + } + + private getFilteredFieldCount(): number { + const fields = this.getShotstackEdit()?.mergeFields.getAll() ?? []; + return fields.filter((f: MergeField) => f.name.toUpperCase().includes(this.autocompleteFilter)).length; + } + + private buildFontPicker(): void { + if (!this.fontPopup) return; + + // Clean up existing picker + if (this.fontPicker) { + this.fontPicker.destroy(); + this.fontPicker = null; + } + + const asset = this.getCurrentAsset(); + const currentFilename = asset?.font?.family; + + // Get timeline fonts for custom fonts section + const document = this.edit.getDocument(); + const timelineFonts = document?.getFonts() ?? []; + + this.fontPicker = new FontPicker({ + selectedFilename: currentFilename, + timelineFonts, + fontMetadata: this.edit.getFontMetadata(), + onSelect: font => this.selectFont(font), + onClose: () => this.closeAllPopups() + }); + + this.fontPopup.innerHTML = ""; + this.fontPopup.appendChild(this.fontPicker.getElement()); + } + + private getDisplayName(fontFamily: string): string { + // First check if it's a Google Font filename (hash) + const googleFont = GOOGLE_FONTS_BY_FILENAME.get(fontFamily); + if (googleFont) { + return googleFont.displayName; + } + + // Check if this is a custom font with a binary name available. + // The stored fontFamily may be a URL-extracted name (e.g. "source") that doesn't + // match the real font name. Look up the binary name from fontMetadata. + const fontMetadata = this.edit.getFontMetadata(); + for (const [url, meta] of fontMetadata) { + const binaryName = meta.baseFamilyName.replace(/^["']+|["']+$/g, ""); + // Match if the stored family equals the binary name OR the URL-extracted name + const urlFilename = + url + .split("/") + .pop() + ?.replace(/\.(ttf|otf|woff|woff2)$/i, "") ?? ""; + if (fontFamily === urlFilename || fontFamily === binaryName) { + return binaryName; + } + } + + // Fall back to cleaning up font names: "Oswald-VariableFont" → "Oswald" + return fontFamily.replace(/-VariableFont$/i, "").replace(/-/g, " "); + } + + // ─── Phase 2 Helper Methods ──────────────────────────────────── + + // TODO: Audit all command history routes for two-phase pattern consistency + // After refactoring to explicit updateClipInDocument() + resolveClip() calls, + // verify that all other property update paths (not using two-phase drag) are + // also consistent with the pattern. Check for any remaining command creation + // inconsistencies or places where UI updates should happen before commitClipUpdate(). + + /** + * Live update animation property during drag. + */ + private updateAnimationLive(updates: Partial<{ duration: number }>): void { + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const asset = this.getCurrentAsset(); + if (!asset) return; + + const currentAnimation = asset.animation || { preset: "fadeIn" as const }; + const updatedAnimation = { ...currentAnimation, ...updates } as typeof currentAnimation; + const updatedAsset = { ...asset, animation: updatedAnimation } as RichTextAsset; + + this.edit.updateClipInDocument(clipId, { asset: updatedAsset as ResolvedClip["asset"] }); + this.edit.resolveClip(clipId); + } + + /** + * Capture current clip state for two-phase drag pattern (Phase 1). + * Creates a deep clone of the clip's current state to enable command rollback on drag end. + * + * @returns Object with clipId and cloned initial state, or null if no clip selected + */ + private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; + } + + private selectFont(font: FontInfo): void { + // Add font URL to timeline.fonts via document layer (persists properly) + const document = this.edit.getDocument(); + if (document) { + document.addFont(font.url); + } + + // Set the filename (hash) as the font family - this is what the backend expects + this.updateClipProperty({ font: { family: font.filename } }); + + // Clean up old font if no longer used by any clip + this.edit.pruneUnusedFonts(); + + this.closeAllPopups(); + } + + private updateVerticalAlign(align: "top" | "middle" | "bottom"): void { + this.updateClipProperty({ align: { vertical: align } }); + } + + private cycleAlignment(asset: RichTextAsset): void { + const current = asset.align?.horizontal ?? "center"; + const cycle: Array<"left" | "center" | "right"> = ["left", "center", "right"]; + const currentIdx = cycle.indexOf(current as "left" | "center" | "right"); + const nextIdx = (currentIdx + 1) % cycle.length; + this.updateAlignment(cycle[nextIdx]); + } + + private updateAlignment(align: "left" | "center" | "right"): void { + this.updateClipProperty({ align: { horizontal: align } }); + this.updateAlignIcon(align); + } + + private updateAlignIcon(align: "left" | "center" | "right"): void { + if (!this.alignIcon) return; + const paths: Record = { + left: "M3 5h18v2H3V5zm0 4h12v2H3V9zm0 4h18v2H3v-2zm0 4h12v2H3v-2z", + center: "M3 5h18v2H3V5zm3 4h12v2H6V9zm-3 4h18v2H3v-2zm3 4h12v2H6v-2z", + right: "M3 5h18v2H3V5zm6 4h12v2H9V9zm-6 4h18v2H3v-2zm6 4h12v2H9v-2z" + }; + const path = this.alignIcon.querySelector("path"); + if (path) { + path.setAttribute("d", paths[align]); + } + } + + private cycleTransform(asset: RichTextAsset): void { + const current = asset.style?.textTransform ?? "none"; + const cycle: Array<"none" | "uppercase" | "lowercase"> = ["none", "uppercase", "lowercase"]; + const currentIdx = cycle.indexOf(current as "none" | "uppercase" | "lowercase"); + const nextIdx = (currentIdx + 1) % cycle.length; + this.updateClipProperty({ style: { textTransform: cycle[nextIdx] } }); + } + + private toggleUnderline(asset: RichTextAsset): void { + const current = asset.style?.textDecoration ?? "none"; + const newValue = current === "underline" ? "none" : "underline"; + this.updateClipProperty({ style: { textDecoration: newValue } }); + } + + private toggleLinethrough(asset: RichTextAsset): void { + const current = asset.style?.textDecoration ?? "none"; + const newValue = current === "line-through" ? "none" : "line-through"; + this.updateClipProperty({ style: { textDecoration: newValue } }); + } + + private updateBorderProperty(updates: Partial<{ width: number; color: string; opacity: number; radius: number }>): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + const currentBorder = asset.border || { width: 0, color: "#000000", opacity: 1, radius: 0 }; + const updatedBorder = { ...currentBorder, ...updates }; + this.updateClipProperty({ border: updatedBorder }); + } + + private updateShadowProperty(updates: Partial<{ offsetX: number; offsetY: number; blur: number; color: string; opacity: number }>): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + const currentShadow = asset.shadow || { offsetX: 0, offsetY: 0, blur: 0, color: "#000000", opacity: 0.5 }; + const updatedShadow = { ...currentShadow, ...updates }; + this.updateClipProperty({ shadow: updatedShadow }); + } + + private updateAnimationProperty(updates: Partial<{ preset: string; duration: number; style: string; direction: string }>): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + const currentAnimation = asset.animation || { preset: "fadeIn" as const }; + const updatedAnimation = { ...currentAnimation, ...updates }; + this.updateClipProperty({ animation: updatedAnimation }); + + // Update UI sections visibility when preset changes + if (updates.preset) { + this.updateAnimationSections(updates.preset); + } + } + + private updateAnimationSections(preset?: string): void { + // style is allowed for: typewriter, shift, fadeIn, slideIn + const stylePresets = ["typewriter", "shift", "fadeIn", "slideIn"]; + // direction is allowed for: ascend, shift, slideIn + const directionPresets = ["ascend", "shift", "slideIn"]; + + if (this.animationStyleSection) { + this.animationStyleSection.style.display = preset && stylePresets.includes(preset) ? "block" : "none"; + } + if (this.animationDirectionSection) { + this.animationDirectionSection.style.display = preset && directionPresets.includes(preset) ? "block" : "none"; + } + } + + private updateBackgroundProperty(updates: Partial<{ color?: string; opacity: number }>): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + const currentBackground = asset.background || { color: "#FFFFFF", opacity: 1, borderRadius: 0 }; + const updatedBackground = { ...currentBackground, ...updates }; + + this.updateClipProperty({ background: updatedBackground }); + } + + /** + * Remove background property entirely (sets to undefined). + */ + private removeBackgroundProperty(): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + // Explicitly set background to undefined to remove it + this.updateClipProperty({ background: undefined }); + } + + private updatePaddingProperty(updates: Partial<{ top: number; right: number; bottom: number; left: number }>): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + // Get current padding (handle both number and object formats) + let currentPadding: { top: number; right: number; bottom: number; left: number }; + + if (typeof asset.padding === "number") { + // Convert uniform padding to object format + currentPadding = { + top: asset.padding, + right: asset.padding, + bottom: asset.padding, + left: asset.padding + }; + } else if (asset.padding) { + // Already object format — default any missing sides to 0 + currentPadding = { + top: asset.padding.top ?? 0, + right: asset.padding.right ?? 0, + bottom: asset.padding.bottom ?? 0, + left: asset.padding.left ?? 0 + }; + } else { + // No padding set, use defaults + currentPadding = { top: 0, right: 0, bottom: 0, left: 0 }; + } + + // Merge updates + const updatedPadding = { ...currentPadding, ...updates }; + + // Check if all sides are equal (can simplify to uniform padding) + const allEqual = + updatedPadding.top === updatedPadding.right && updatedPadding.right === updatedPadding.bottom && updatedPadding.bottom === updatedPadding.left; + + // If all sides are 0, remove padding entirely + if (updatedPadding.top === 0 && updatedPadding.right === 0 && updatedPadding.bottom === 0 && updatedPadding.left === 0) { + const { padding, ...assetWithoutPadding } = asset; + this.updateClipProperty(assetWithoutPadding); + } + // If all sides are equal, use uniform padding (simpler format) + else if (allEqual) { + this.updateClipProperty({ padding: updatedPadding.top }); + } + // Otherwise use object format + else { + this.updateClipProperty({ padding: updatedPadding }); + } + } + + private updateFontColorProperty(updates: { + color?: string; + opacity?: number; + background?: string; + gradient?: { type: "linear" | "radial"; angle: number; stops: Array<{ offset: number; color: string }> }; + }): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + const currentFont = asset.font || {}; + + const fontUpdates: Record = { ...currentFont }; + + // Handle solid color and opacity + if (updates.color !== undefined) { + fontUpdates["color"] = updates.color; + } + if (updates.opacity !== undefined) { + fontUpdates["opacity"] = updates.opacity; + } + + // Handle text highlight (font.background) + if (updates.background !== undefined) { + fontUpdates["background"] = updates.background; + } + + // Handle gradient (stored in style.gradient) + if (updates.gradient !== undefined) { + const currentStyle = asset.style || {}; + this.updateClipProperty({ + font: fontUpdates, + style: { ...currentStyle, gradient: updates.gradient } + }); + return; + } + + // Clear gradient when setting solid color + const currentStyle = asset.style || ({} as Record); + if ((updates.color !== undefined || updates.opacity !== undefined) && currentStyle.gradient) { + this.updateClipProperty({ + font: fontUpdates, + style: { ...currentStyle, gradient: undefined } + }); + return; + } + + // Apply updates + this.updateClipProperty({ + font: fontUpdates + }); + } + + private updateClipProperty(assetUpdates: Record): void { + const updates: Partial = { asset: assetUpdates as ResolvedClip["asset"] }; + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + this.syncState(); + } + + private applyClipUpdate(updates: Record): void { + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + } + + protected override getPopupList(): (HTMLElement | null)[] { + return [ + this.sizePopup, + this.weightPopup, + this.spacingPopup, + this.stylePopup, + this.fontPopup, + this.textEditPopup, + this.fontColorPopup, + this.animationPopup, + this.transitionPopup, + this.effectPopup + ]; + } + + protected override syncState(): void { + const currentClipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const clipChanged = this.lastSyncedClipId !== currentClipId; + + if (clipChanged) { + // CRITICAL: Clear all drag sessions when clip selection changes + this.dragManager.clear(); + this.lastSyncedClipId = currentClipId; + } + + const asset = this.getCurrentAsset(); + if (!asset) return; + + // Update weight preview to show current weight name + if (this.weightPreview) { + this.weightPreview.textContent = this.getWeightName(asset.font?.weight); + } + + // Hide weight dropdown for non-variable fonts (they only support one weight) + if (this.weightDropdown) { + const currentFont = GOOGLE_FONTS_BY_FILENAME.get(asset.font?.family ?? ""); + // Show for variable fonts, custom fonts (no match = assume variable), or when no font info + const supportsWeights = !currentFont || currentFont.isVariable; + this.weightDropdown.style.display = supportsWeights ? "" : "none"; + } + + if (this.sizeInput) { + this.sizeInput.value = String(asset.font?.size ?? 48); + } + + if (this.fontPreview) { + const fontFamily = asset.font?.family ?? "Roboto"; + this.fontPreview.textContent = this.getDisplayName(fontFamily); + this.fontPreview.style.fontFamily = `'${fontFamily}', sans-serif`; + } + + if (this.colorDisplay) { + const color = asset.font?.color ?? "#000000"; + this.colorDisplay.style.backgroundColor = color; + } + + // Sync spacing panel + this.spacingPanel?.setState(asset.style?.letterSpacing ?? 0, asset.style?.lineHeight ?? 1.2); + + const verticalAlign = asset.align?.vertical ?? "middle"; + this.setButtonActive(this.anchorTopBtn, verticalAlign === "top"); + this.setButtonActive(this.anchorMiddleBtn, verticalAlign === "middle"); + this.setButtonActive(this.anchorBottomBtn, verticalAlign === "bottom"); + + const align = asset.align?.horizontal ?? "center"; + this.updateAlignIcon(align as "left" | "center" | "right"); + + const transform = asset.style?.textTransform ?? "none"; + if (this.transformBtn) { + let transformLabel = "Aa"; + if (transform === "uppercase") { + transformLabel = "AA"; + } else if (transform === "lowercase") { + transformLabel = "aa"; + } + this.transformBtn.textContent = transformLabel; + this.setButtonActive(this.transformBtn, transform !== "none"); + } + + const isUnderline = asset.style?.textDecoration === "underline"; + this.setButtonActive(this.underlineBtn, isUnderline); + + const isLinethrough = asset.style?.textDecoration === "line-through"; + this.setButtonActive(this.linethroughBtn, isLinethrough); + + // Sync StylePanel (consolidated border/padding/shadow/fill) + if (this.stylePanel) { + // Border — default each property individually since partial border objects + // can exist (e.g. merge field sets only width, leaving color/radius undefined) + const { border } = asset; + this.stylePanel.setBorderState({ + width: border?.width ?? 0, + color: border?.color ?? "#000000", + opacity: Math.round((border?.opacity ?? 1) * 100), + radius: border?.radius ?? 0 + }); + + // Shadow (blur fixed at 4 - canvas only checks blur > 0, doesn't implement actual blur) + const { shadow } = asset; + this.stylePanel.setShadowState({ + enabled: !!shadow, + offsetX: shadow?.offsetX ?? 0, + offsetY: shadow?.offsetY ?? 0, + blur: shadow?.blur ?? 4, + color: shadow?.color ?? "#000000", + opacity: shadow ? Math.round((shadow.opacity ?? 1) * 100) : 50 + }); + + // Padding (handled below, needs special normalization) + } + + // Background fill sync + if (this.backgroundColorPicker) { + const { background } = asset; + const hasBackground = !!background; + + this.backgroundColorPicker.setEnabled(hasBackground); + this.backgroundColorPicker.setColor(background?.color || "#FFFFFF"); + this.backgroundColorPicker.setOpacity((background?.opacity ?? 1) * 100); + } + + // Animation + const { animation } = asset; + this.container?.querySelectorAll("[data-preset]").forEach(btn => { + this.setButtonActive(btn, btn.dataset["preset"] === animation?.preset); + }); + if (this.animationDurationSlider && this.animationDurationValue) { + const duration = animation?.duration ?? 1; + this.animationDurationSlider.value = String(duration); + this.animationDurationValue.textContent = `${duration.toFixed(1)}s`; + } + this.container?.querySelectorAll("[data-animation-style]").forEach(btn => { + this.setButtonActive(btn, btn.dataset["animationStyle"] === animation?.style); + }); + this.container?.querySelectorAll("[data-animation-direction]").forEach(btn => { + this.setButtonActive(btn, btn.dataset["animationDirection"] === animation?.direction); + }); + this.updateAnimationSections(animation?.preset); + + // Padding - sync with StylePanel + if (this.stylePanel) { + const padding = + typeof asset.padding === "number" + ? { top: asset.padding, right: asset.padding, bottom: asset.padding, left: asset.padding } + : { + top: asset.padding?.top ?? 0, + right: asset.padding?.right ?? 0, + bottom: asset.padding?.bottom ?? 0, + left: asset.padding?.left ?? 0 + }; + this.stylePanel.setPaddingState(padding); + } + + // Get clip for transition and effect values + const clip = this.edit.getClip(this.selectedTrackIdx, this.selectedClipIdx); + + // Sync composite panels + this.transitionPanel?.setFromClip(clip?.transition as { in?: string; out?: string } | undefined); + this.effectPanel?.setFromClip((clip?.effect as string) ?? ""); + + // Sync merge field label bound states + if (this.showMergeFields && this.mergeFieldManager?.hasLabels) { + this.mergeFieldManager.sync(); + } + } + + override dispose(): void { + // Clear all drag sessions + this.dragManager.clear(); + this.lastSyncedClipId = null; + + // Clean up event listeners before super.dispose() removes container + this.unsubFontCapabilities?.(); + this.unsubFontCapabilities = null; + this.unsubMergeFieldChanged?.(); + this.unsubMergeFieldChanged = null; + if (this.boundHandleClick) { + this.container?.removeEventListener("click", this.boundHandleClick); + this.boundHandleClick = null; + } + super.dispose(); + this.sizeInput = null; + this.sizePopup = null; + this.weightPopup = null; + this.weightPreview = null; + this.fontPopup = null; + this.fontPreview = null; + this.fontPicker?.destroy(); + this.fontPicker = null; + + this.fontColorPicker?.dispose(); + this.fontColorPicker = null; + this.fontColorPopup = null; + this.colorDisplay = null; + + this.spacingPopup = null; + this.spacingPanel?.dispose(); + this.spacingPanel = null; + this.stylePopup = null; + this.stylePanel?.dispose(); + this.stylePanel = null; + this.anchorTopBtn = null; + this.anchorMiddleBtn = null; + this.anchorBottomBtn = null; + this.alignIcon = null; + this.transformBtn = null; + this.underlineBtn = null; + this.linethroughBtn = null; + this.textEditPopup = null; + this.textEditArea = null; + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + this.textEditDebounceTimer = null; + } + + this.animationPopup = null; + this.animationDurationSlider = null; + this.animationDurationValue = null; + this.animationStyleSection = null; + this.animationDirectionSection = null; + + this.backgroundColorPicker?.dispose(); + this.backgroundColorPicker = null; + this.backgroundPopup = null; + + // Dispose composite panels (auto-cleans events via EventManager) + this.transitionPanel?.dispose(); + this.transitionPanel = null; + this.transitionPopup = null; + + this.effectPanel?.dispose(); + this.effectPanel = null; + this.effectPopup = null; + + // Dispose merge field labels + this.mergeFieldManager?.dispose(); + this.mergeFieldManager = null; + } +} diff --git a/src/core/ui/selection-handles.ts b/src/core/ui/selection-handles.ts new file mode 100644 index 00000000..081641e4 --- /dev/null +++ b/src/core/ui/selection-handles.ts @@ -0,0 +1,824 @@ +import type { Player } from "@canvas/players/player"; +import type { Edit } from "@core/edit-session"; +import { EditEvent } from "@core/events/edit-events"; +import { + calculateCornerScale, + calculateEdgeResize, + clampDimensions, + roundDimensions, + detectCornerZone, + detectEdgeZone +} from "@core/interaction/clip-interaction"; +import { SELECTION_CONSTANTS, CURSOR_BASE_ANGLES, type CornerName, buildResizeCursor } from "@core/interaction/selection-overlay"; +import { type ClipBounds, createClipBounds, createSnapContext, snap, snapRotation } from "@core/interaction/snap-system"; +import { updateSvgViewBox, isSimpleRectSvg } from "@core/shared/svg-utils"; +import { Pointer } from "@inputs/pointer"; +import type { Vector } from "@layouts/geometry"; +import { PositionBuilder } from "@layouts/position-builder"; +import type { ResolvedClip, SvgAsset } from "@schemas"; +import * as pixi from "pixi.js"; + +import type { CanvasOverlayRegistration } from "./ui-controller"; + +type ScaleDirection = "topLeft" | "topRight" | "bottomLeft" | "bottomRight"; +type EdgeDirection = "left" | "right" | "top" | "bottom"; + +/** + * SelectionHandles renders selection UI (outline + resize handles) on selected players + * and handles all drag/resize/rotate interactions. + * + * This class decouples interaction logic from Player, allowing Canvas to work as a pure renderer. + */ +// Edge handle dimensions - refined for Canva-like elegance +const EDGE_HANDLE_LENGTH = 20; +const EDGE_HANDLE_THICKNESS = 4; + +// Dimension label style +const DIMENSION_FONT_SIZE = 11; +const DIMENSION_PADDING_X = 6; +const DIMENSION_PADDING_Y = 3; +const DIMENSION_GAP = 8; // px below the clip outline + +export class SelectionHandles implements CanvasOverlayRegistration { + private container: pixi.Container; + private outline: pixi.Graphics; + private handles: Map; + private edgeHandles: Map; + private app: pixi.Application | null = null; + private positionBuilder: PositionBuilder; + + // Dimension label shown during resize (lives in overlay parent, not rotated container) + private dimensionContainer: pixi.Container; + private dimensionBackground: pixi.Graphics; + private dimensionLabel: pixi.Text; + + // Selection state + private selectedPlayer: Player | null = null; + private selectedClipId: string | null = null; + private selectedTrackIndex = -1; + private selectedClipIndex = -1; + + // Interaction state + private isHovering = false; + private isDragging = false; + private dragOffset: Vector = { x: 0, y: 0 }; + + private scaleDirection: ScaleDirection | null = null; + private edgeDragDirection: EdgeDirection | null = null; + private edgeDragStart: Vector = { x: 0, y: 0 }; + private originalDimensions: { width: number; height: number; offsetX: number; offsetY: number } | null = null; + + private isRotating = false; + private rotationStart: number | null = null; + private initialRotation = 0; + private rotationCorner: CornerName | null = null; + + private initialClipConfiguration: ResolvedClip | null = null; + + // Final drag state + private finalDragState: { + offset?: { x: number; y: number }; + width?: number; + height?: number; + transform?: { rotate?: { angle: number } }; + } | null = null; + + // Bound event handlers for cleanup + private onClipSelectedBound: (payload: { trackIndex: number; clipIndex: number }) => void; + private onSelectionClearedBound: () => void; + private onPointerDownBound: (event: pixi.FederatedPointerEvent) => void; + private onPointerMoveBound: (event: pixi.FederatedPointerEvent) => void; + private onPointerUpBound: () => void; + + constructor(private edit: Edit) { + this.container = new pixi.Container(); + this.container.zIndex = 18; + this.container.sortableChildren = true; + + this.outline = new pixi.Graphics(); + this.handles = new Map(); + this.edgeHandles = new Map(); + + // Dimension label (axis-aligned, not rotated with the clip) + this.dimensionContainer = new pixi.Container(); + this.dimensionContainer.zIndex = 20; + this.dimensionContainer.visible = false; + this.dimensionBackground = new pixi.Graphics(); + this.dimensionLabel = new pixi.Text({ + text: "", + style: { + fontFamily: "system-ui, -apple-system, sans-serif", + fontSize: DIMENSION_FONT_SIZE, + fill: "#ffffff" + } + }); + this.dimensionContainer.addChild(this.dimensionBackground); + this.dimensionContainer.addChild(this.dimensionLabel); + + this.positionBuilder = new PositionBuilder(edit.size); + + // Bind event handlers + this.onClipSelectedBound = this.onClipSelected.bind(this); + this.onSelectionClearedBound = this.onSelectionCleared.bind(this); + this.onPointerDownBound = this.onPointerDown.bind(this); + this.onPointerMoveBound = this.onPointerMove.bind(this); + this.onPointerUpBound = this.onPointerUp.bind(this); + + // Listen to selection events + this.edit.events.on(EditEvent.ClipSelected, this.onClipSelectedBound); + this.edit.events.on(EditEvent.SelectionCleared, this.onSelectionClearedBound); + } + + mount(parent: pixi.Container, app: pixi.Application): void { + this.app = app; + + // Create outline + this.container.addChild(this.outline); + + // Create corner handles + const corners: CornerName[] = ["topLeft", "topRight", "bottomRight", "bottomLeft"]; + for (const corner of corners) { + const handle = new pixi.Graphics(); + handle.zIndex = 19; + handle.eventMode = "static"; + this.handles.set(corner, handle); + this.container.addChild(handle); + } + + // Create edge handles (for players that support edge resize) + const edges: EdgeDirection[] = ["top", "bottom", "left", "right"]; + for (const edge of edges) { + const handle = new pixi.Graphics(); + handle.zIndex = 19; + handle.eventMode = "static"; + this.edgeHandles.set(edge, handle); + this.container.addChild(handle); + } + + parent.addChild(this.container); + parent.addChild(this.dimensionContainer); + + // Setup pointer events on the stage + app.stage.on("pointerdown", this.onPointerDownBound); + app.stage.on("globalpointermove", this.onPointerMoveBound); + app.stage.on("pointerup", this.onPointerUpBound); + app.stage.on("pointerupoutside", this.onPointerUpBound); + } + + update(_deltaTime: number, _elapsed: number): void { + // Sync position/scale with selected player each frame + if (this.selectedPlayer) { + this.syncToPlayer(); + } + } + + draw(): void { + if (!this.selectedPlayer || !this.selectedPlayer.isActive() || this.edit.isInExportMode()) { + this.container.visible = false; + return; + } + + this.container.visible = true; + this.drawOutline(); + this.drawHandles(); + } + + dispose(): void { + this.edit.events.off(EditEvent.ClipSelected, this.onClipSelectedBound); + this.edit.events.off(EditEvent.SelectionCleared, this.onSelectionClearedBound); + + if (this.app) { + this.app.stage.off("pointerdown", this.onPointerDownBound); + this.app.stage.off("globalpointermove", this.onPointerMoveBound); + this.app.stage.off("pointerup", this.onPointerUpBound); + this.app.stage.off("pointerupoutside", this.onPointerUpBound); + } + + this.outline.destroy(); + for (const handle of this.handles.values()) { + handle.destroy(); + } + for (const handle of this.edgeHandles.values()) { + handle.destroy(); + } + this.container.destroy(); + this.dimensionLabel.destroy(); + this.dimensionBackground.destroy(); + this.dimensionContainer.destroy(); + } + + // ─── Selection Event Handlers ──────────────────────────────────────────────── + + private onClipSelected({ trackIndex, clipIndex }: { trackIndex: number; clipIndex: number }): void { + this.selectedPlayer = this.edit.getPlayerClip(trackIndex, clipIndex); + this.selectedClipId = this.selectedPlayer?.clipId ?? null; + this.selectedTrackIndex = trackIndex; + this.selectedClipIndex = clipIndex; + } + + private onSelectionCleared(): void { + this.selectedPlayer = null; + this.selectedClipId = null; + this.selectedTrackIndex = -1; + this.selectedClipIndex = -1; + this.resetDragState(); + } + + // ─── Rendering ─────────────────────────────────────────────────────────────── + + private syncToPlayer(): void { + if (!this.selectedPlayer) return; + + const playerContainer = this.selectedPlayer.getContainer(); + + // Match player's transform + this.container.position.copyFrom(playerContainer.position); + this.container.scale.copyFrom(playerContainer.scale); + this.container.rotation = playerContainer.rotation; + this.container.pivot.copyFrom(playerContainer.pivot); + } + + private drawOutline(): void { + if (!this.selectedPlayer) return; + + const size = this.selectedPlayer.getSize(); + const uiScale = this.getUIScale(); + const color = this.isHovering || this.isDragging ? SELECTION_CONSTANTS.ACTIVE_COLOR : SELECTION_CONSTANTS.DEFAULT_COLOR; + + this.outline.clear(); + this.outline.strokeStyle = { width: SELECTION_CONSTANTS.OUTLINE_WIDTH / uiScale, color }; + this.outline.rect(0, 0, size.width, size.height); + this.outline.stroke(); + } + + private drawHandles(): void { + if (!this.selectedPlayer) return; + + const size = this.selectedPlayer.getSize(); + const uiScale = this.getUIScale(); + const color = this.isHovering || this.isDragging ? SELECTION_CONSTANTS.ACTIVE_COLOR : SELECTION_CONSTANTS.DEFAULT_COLOR; + const handleSize = (SELECTION_CONSTANTS.SCALE_HANDLE_RADIUS * 2) / uiScale; + + // Corner positions + const positions: Record = { + topLeft: { x: 0, y: 0 }, + topRight: { x: size.width, y: 0 }, + bottomRight: { x: size.width, y: size.height }, + bottomLeft: { x: 0, y: size.height } + }; + + const cornerRadius = 1 / uiScale; // Subtle softening + + for (const [corner, handle] of this.handles) { + const pos = positions[corner]; + handle.clear(); + handle.fillStyle = { color }; + handle.roundRect(pos.x - handleSize / 2, pos.y - handleSize / 2, handleSize, handleSize, cornerRadius); + handle.fill(); + + // Set cursor + handle.cursor = this.getCornerResizeCursor(corner); + } + + // Draw edge handles for players that support edge resize + this.drawEdgeHandles(size, uiScale, color); + } + + private drawEdgeHandles(size: { width: number; height: number }, uiScale: number, color: number): void { + const supportsEdge = this.selectedPlayer?.supportsEdgeResize() ?? false; + + // Edge handle dimensions scaled for current zoom + const barLength = EDGE_HANDLE_LENGTH / uiScale; + const barThickness = EDGE_HANDLE_THICKNESS / uiScale; + const borderRadius = barThickness / 2; // Pill-shaped rounded corners + + // Edge midpoint positions + const edgePositions: Record = { + top: { x: size.width / 2, y: 0, isHorizontal: true }, + bottom: { x: size.width / 2, y: size.height, isHorizontal: true }, + left: { x: 0, y: size.height / 2, isHorizontal: false }, + right: { x: size.width, y: size.height / 2, isHorizontal: false } + }; + + for (const [edge, handle] of this.edgeHandles) { + handle.clear(); + + // Hide edge handles if player doesn't support edge resize + if (!supportsEdge) { + handle.visible = false; + } else { + handle.visible = true; + const pos = edgePositions[edge]; + + if (pos.isHorizontal) { + // Horizontal pill (top/bottom edges) + const x = pos.x - barLength / 2; + const y = pos.y - barThickness / 2; + + handle.fillStyle = { color }; + handle.roundRect(x, y, barLength, barThickness, borderRadius); + handle.fill(); + } else { + // Vertical pill (left/right edges) + const x = pos.x - barThickness / 2; + const y = pos.y - barLength / 2; + + handle.fillStyle = { color }; + handle.roundRect(x, y, barThickness, barLength, borderRadius); + handle.fill(); + } + + // Set resize cursor + const rotation = this.selectedPlayer?.getRotation() ?? 0; + const baseAngle = CURSOR_BASE_ANGLES[edge] ?? 0; + handle.cursor = buildResizeCursor(baseAngle + rotation); + } + } + } + + private getUIScale(): number { + if (!this.selectedPlayer) return 1; + const playerScale = this.selectedPlayer.getScale(); + const canvasZoom = this.edit.getCanvasZoom(); + return playerScale * canvasZoom; + } + + // ─── Pointer Event Handlers ────────────────────────────────────────────────── + + private onPointerDown(event: pixi.FederatedPointerEvent): void { + if (event.button !== Pointer.ButtonLeftClick) return; + if (!this.selectedPlayer) return; + + // Check if click is on selected player + const playerContainer = this.selectedPlayer.getContainer(); + const localPoint = event.getLocalPosition(playerContainer); + const size = this.selectedPlayer.getSize(); + + // Store initial state for undo + this.initialClipConfiguration = structuredClone(this.selectedPlayer.clipConfiguration); + + // Check for rotation zone (outside corners) + const rotationCorner = this.getRotationCorner(localPoint, size); + if (rotationCorner) { + this.startRotation(event, rotationCorner); + return; + } + + // Check corner handles + for (const [corner, handle] of this.handles) { + if (handle.getBounds().containsPoint(event.globalX, event.globalY)) { + this.startCornerResize(event, corner); + return; + } + } + + // Check if inside player bounds for drag + if (localPoint.x >= 0 && localPoint.x <= size.width && localPoint.y >= 0 && localPoint.y <= size.height) { + // Check for edge resize first + const hitZone = SELECTION_CONSTANTS.EDGE_HIT_ZONE / this.getUIScale(); + const edge = detectEdgeZone(localPoint, size, hitZone); + if (edge && this.selectedPlayer.supportsEdgeResize()) { + this.startEdgeResize(event, edge); + return; + } + + // Start position drag + this.startDrag(event); + } + } + + private onPointerMove(event: pixi.FederatedPointerEvent): void { + if (!this.selectedPlayer) return; + + // Handle active drag operations + if (this.scaleDirection) { + this.handleCornerResize(event); + return; + } + + if (this.edgeDragDirection) { + this.handleEdgeResize(event); + return; + } + + if (this.isRotating) { + this.handleRotation(event); + return; + } + + if (this.isDragging) { + this.handleDrag(event); + return; + } + + // Update hover state and cursor + this.updateHoverState(event); + } + + private onPointerUp(): void { + if (!this.selectedPlayer) return; + + if ( + (this.isDragging || this.scaleDirection || this.edgeDragDirection || this.isRotating) && + this.finalDragState && + this.selectedClipId && + this.initialClipConfiguration + ) { + // Construct final config from local state with deep merge + const finalClip = structuredClone(this.initialClipConfiguration); + + // Deep merge for nested properties (transform, offset) + if (this.finalDragState.transform) { + finalClip.transform = { + ...finalClip.transform, + ...this.finalDragState.transform + }; + } + if (this.finalDragState.offset) { + finalClip.offset = { + ...finalClip.offset, + ...this.finalDragState.offset + }; + } + // Shallow merge for flat properties (width, height) + if (this.finalDragState.width !== undefined) finalClip.width = this.finalDragState.width; + if (this.finalDragState.height !== undefined) finalClip.height = this.finalDragState.height; + + // Update SVG viewBox if this is an SVG clip resize + if ((this.scaleDirection || this.edgeDragDirection) && finalClip.asset?.type === "svg") { + const svgAsset = finalClip.asset as SvgAsset; + if (svgAsset.src && finalClip.width && finalClip.height) { + // Only manipulate simple rect-based SVGs (maintains toolbar compatibility) + // Complex SVGs (paths, circles, etc.) are scaled by the renderer automatically + if (isSimpleRectSvg(svgAsset.src)) { + const updatedSrc = updateSvgViewBox(svgAsset.src, finalClip.width, finalClip.height); + + // Update document BEFORE commitClipUpdate (two-phase pattern) + this.edit.updateClipInDocument(this.selectedClipId, { + asset: { ...svgAsset, src: updatedSrc } + }); + this.edit.resolveClip(this.selectedClipId); + } + } + } + + // Commit with explicit final state (adds to history, doesn't execute) + this.edit.commitClipUpdate(this.selectedClipId, this.initialClipConfiguration, finalClip); + + // Notify player if dimensions changed (corner or edge resize) + if ((this.scaleDirection || this.edgeDragDirection) && this.selectedPlayer) { + this.selectedPlayer.notifyDimensionsChanged(); + } + } + + this.finalDragState = null; // Clear final state + this.resetDragState(); + this.edit.clearAlignmentGuides(); + } + + // ─── Drag Operations ───────────────────────────────────────────────────────── + + private startDrag(event: pixi.FederatedPointerEvent): void { + if (!this.selectedPlayer) return; + + this.isDragging = true; + const viewportContainer = this.edit.getViewportContainer(); + const timelinePoint = event.getLocalPosition(viewportContainer); + const playerPos = this.selectedPlayer.getContainer().position; + + this.dragOffset = { + x: timelinePoint.x - playerPos.x, + y: timelinePoint.y - playerPos.y + }; + } + + private handleDrag(event: pixi.FederatedPointerEvent): void { + if (!this.selectedPlayer || !this.selectedClipId) return; + + const viewportContainer = this.edit.getViewportContainer(); + const timelinePoint = event.getLocalPosition(viewportContainer); + const pivot = this.selectedPlayer.getPivot(); + + const cursorPosition: Vector = { + x: timelinePoint.x - this.dragOffset.x, + y: timelinePoint.y - this.dragOffset.y + }; + const rawPosition: Vector = { + x: cursorPosition.x - pivot.x, + y: cursorPosition.y - pivot.y + }; + + // Clear and recalculate snap guides + this.edit.clearAlignmentGuides(); + + const otherPlayers = this.edit.getActivePlayersExcept(this.selectedPlayer); + const otherClipBounds: ClipBounds[] = otherPlayers.map(other => { + const pos = other.getContainer().position; + const size = other.getSize(); + return createClipBounds({ x: pos.x, y: pos.y }, size); + }); + + const snapContext = createSnapContext(this.selectedPlayer.getSize(), this.edit.size, otherClipBounds); + const snapResult = snap(rawPosition, snapContext); + + // Draw alignment guides + for (const guide of snapResult.guides) { + this.edit.showAlignmentGuide(guide.type, guide.axis, guide.position, guide.bounds); + } + + // Calculate new offset position + const size = this.selectedPlayer.getSize(); + const position = this.selectedPlayer.clipConfiguration.position ?? "center"; + const updatedRelative = this.positionBuilder.absoluteToRelative(size, position, snapResult.position); + + // Store final state locally + this.finalDragState = { + offset: { x: updatedRelative.x, y: updatedRelative.y } + }; + + // Document-first: Update document, then resolve + this.edit.updateClipInDocument(this.selectedClipId, { + offset: { x: updatedRelative.x, y: updatedRelative.y } + }); + this.edit.resolveClip(this.selectedClipId); + } + + private startCornerResize(event: pixi.FederatedPointerEvent, corner: ScaleDirection): void { + if (!this.selectedPlayer) return; + + this.scaleDirection = corner; + const timelinePoint = event.getLocalPosition(this.edit.getViewportContainer()); + this.edgeDragStart = timelinePoint; + + this.captureOriginalDimensions(); + } + + private handleCornerResize(event: pixi.FederatedPointerEvent): void { + if (!this.selectedPlayer || !this.selectedClipId || !this.scaleDirection || !this.originalDimensions) return; + + const timelinePoint = event.getLocalPosition(this.edit.getViewportContainer()); + const delta = { + x: timelinePoint.x - this.edgeDragStart.x, + y: timelinePoint.y - this.edgeDragStart.y + }; + + const result = calculateCornerScale(this.scaleDirection, delta, this.originalDimensions, this.edit.size); + const clamped = clampDimensions(result.width, result.height); + const rounded = roundDimensions(clamped.width, clamped.height); + + // Store final state locally + this.finalDragState = { + width: rounded.width, + height: rounded.height, + offset: { x: result.offsetX, y: result.offsetY } + }; + + // Document-first: Update document, then resolve + this.edit.updateClipInDocument(this.selectedClipId, { + width: rounded.width, + height: rounded.height, + offset: { x: result.offsetX, y: result.offsetY } + }); + this.edit.resolveClip(this.selectedClipId); + + this.showDimensionLabel(rounded.width, rounded.height); + } + + private startEdgeResize(event: pixi.FederatedPointerEvent, edge: EdgeDirection): void { + if (!this.selectedPlayer) return; + + this.edgeDragDirection = edge; + const timelinePoint = event.getLocalPosition(this.edit.getViewportContainer()); + this.edgeDragStart = timelinePoint; + + this.captureOriginalDimensions(); + } + + private handleEdgeResize(event: pixi.FederatedPointerEvent): void { + if (!this.selectedPlayer || !this.selectedClipId || !this.edgeDragDirection || !this.originalDimensions) return; + + const timelinePoint = event.getLocalPosition(this.edit.getViewportContainer()); + const delta = { + x: timelinePoint.x - this.edgeDragStart.x, + y: timelinePoint.y - this.edgeDragStart.y + }; + + const result = calculateEdgeResize(this.edgeDragDirection, delta, this.originalDimensions, this.edit.size); + const clamped = clampDimensions(result.width, result.height); + const rounded = roundDimensions(clamped.width, clamped.height); + + // Store final state locally + this.finalDragState = { + width: rounded.width, + height: rounded.height, + offset: { x: result.offsetX, y: result.offsetY } + }; + + // Document-first: Update document, then resolve + this.edit.updateClipInDocument(this.selectedClipId, { + width: rounded.width, + height: rounded.height, + offset: { x: result.offsetX, y: result.offsetY } + }); + this.edit.resolveClip(this.selectedClipId); + + this.showDimensionLabel(rounded.width, rounded.height); + } + + private startRotation(event: pixi.FederatedPointerEvent, corner: CornerName): void { + if (!this.selectedPlayer) return; + + this.isRotating = true; + this.rotationCorner = corner; + + const center = this.getContentCenter(); + this.rotationStart = Math.atan2(event.globalY - center.y, event.globalX - center.x); + this.initialRotation = this.selectedPlayer.getRotation(); + } + + private handleRotation(event: pixi.FederatedPointerEvent): void { + if (!this.selectedPlayer || !this.selectedClipId || this.rotationStart === null) return; + + const center = this.getContentCenter(); + const currentAngle = Math.atan2(event.globalY - center.y, event.globalX - center.x); + const deltaAngle = (currentAngle - this.rotationStart) * (180 / Math.PI); + + const rawRotation = this.initialRotation + deltaAngle; + const { angle: snappedRotation } = snapRotation(rawRotation); + + // Get current transform to preserve other properties (scale, etc.) + const currentTransform = this.selectedPlayer.clipConfiguration.transform ?? {}; + + // Store final state locally + this.finalDragState = { + transform: { + ...currentTransform, + rotate: { angle: snappedRotation } + } + }; + + // Document-first: Update document, then resolve + this.edit.updateClipInDocument(this.selectedClipId, { + transform: { + ...currentTransform, + rotate: { angle: snappedRotation } + } + }); + this.edit.resolveClip(this.selectedClipId); + } + + // ─── Dimension Label ──────────────────────────────────────────────────────── + + private showDimensionLabel(width: number, height: number): void { + if (!this.selectedPlayer) return; + + // Update text + this.dimensionLabel.text = `${width} \u00d7 ${height}`; + + // Redraw background pill to fit text + const textWidth = this.dimensionLabel.width; + const textHeight = this.dimensionLabel.height; + const pillWidth = textWidth + DIMENSION_PADDING_X * 2; + const pillHeight = textHeight + DIMENSION_PADDING_Y * 2; + + this.dimensionBackground.clear(); + this.dimensionBackground.fillStyle = { color: 0x000000, alpha: 0.7 }; + this.dimensionBackground.roundRect(0, 0, pillWidth, pillHeight, pillHeight / 2); + this.dimensionBackground.fill(); + + this.dimensionLabel.position.set(DIMENSION_PADDING_X, DIMENSION_PADDING_Y); + + // Position at bottom-center of clip in overlay-parent space. + // The clip's container is rotated/scaled, so transform the bottom-center + // point through the worldTransform to get a screen-axis-aligned position. + const playerContainer = this.selectedPlayer.getContainer(); + const size = this.selectedPlayer.getSize(); + const bottomCenter = playerContainer.toGlobal({ x: size.width / 2, y: size.height }); + + // Convert from global to the overlay parent's local space + const overlayParent = this.dimensionContainer.parent; + const local = overlayParent ? overlayParent.toLocal(bottomCenter) : bottomCenter; + + // Account for canvas zoom so the gap is constant screen-space pixels + const canvasZoom = this.edit.getCanvasZoom(); + this.dimensionContainer.position.set(local.x - pillWidth / 2, local.y + DIMENSION_GAP / canvasZoom); + + this.dimensionContainer.scale.set(1 / canvasZoom); + this.dimensionContainer.visible = true; + } + + private hideDimensionLabel(): void { + this.dimensionContainer.visible = false; + } + + // ─── Helpers ───────────────────────────────────────────────────────────────── + + private captureOriginalDimensions(): void { + if (!this.selectedPlayer || !this.selectedClipId) return; + + const config = this.selectedPlayer.clipConfiguration; + let width: number; + let height: number; + + if (config.width && config.height) { + width = config.width; + height = config.height; + } else { + // Use canvas size to preserve visual appearance (clips without explicit dimensions fill the canvas) + width = this.edit.size.width; + height = this.edit.size.height; + + // Document-first: Update document, then resolve + this.edit.updateClipInDocument(this.selectedClipId, { width, height }); + this.edit.resolveClip(this.selectedClipId); + } + + const currentOffsetX = config.offset?.x ?? 0; + const currentOffsetY = config.offset?.y ?? 0; + + this.originalDimensions = { + width, + height, + offsetX: typeof currentOffsetX === "number" ? currentOffsetX : 0, + offsetY: typeof currentOffsetY === "number" ? currentOffsetY : 0 + }; + } + + private getContentCenter(): Vector { + if (!this.selectedPlayer) return { x: 0, y: 0 }; + const bounds = this.selectedPlayer.getContentContainer().getBounds(); + return { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2 + }; + } + + private getRotationCorner(localPoint: Vector, size: { width: number; height: number }): CornerName | null { + // Rotation zones only active outside content bounds + if (localPoint.x >= 0 && localPoint.x <= size.width && localPoint.y >= 0 && localPoint.y <= size.height) { + return null; + } + + const uiScale = this.getUIScale(); + const handleRadius = SELECTION_CONSTANTS.SCALE_HANDLE_RADIUS / uiScale; + const rotationZone = SELECTION_CONSTANTS.ROTATION_HIT_ZONE / uiScale; + + const corners = [ + { x: 0, y: 0 }, + { x: size.width, y: 0 }, + { x: size.width, y: size.height }, + { x: 0, y: size.height } + ]; + + return detectCornerZone(localPoint, corners, handleRadius, rotationZone); + } + + private getCornerResizeCursor(corner: string): string { + const rotation = this.selectedPlayer?.getRotation() ?? 0; + const baseAngle = CURSOR_BASE_ANGLES[`${corner}Resize`] ?? 45; + return buildResizeCursor(baseAngle + rotation); + } + + private updateHoverState(event: pixi.FederatedPointerEvent): void { + if (!this.selectedPlayer) { + this.isHovering = false; + return; + } + + const playerContainer = this.selectedPlayer.getContainer(); + const localPoint = event.getLocalPosition(playerContainer); + const size = this.selectedPlayer.getSize(); + + this.isHovering = localPoint.x >= 0 && localPoint.x <= size.width && localPoint.y >= 0 && localPoint.y <= size.height; + + // Update cursor for edge resize zones + if (this.selectedPlayer.supportsEdgeResize()) { + const hitZone = SELECTION_CONSTANTS.EDGE_HIT_ZONE / this.getUIScale(); + const edge = detectEdgeZone(localPoint, size, hitZone); + + if (edge) { + const rotation = this.selectedPlayer.getRotation() ?? 0; + const baseAngle = CURSOR_BASE_ANGLES[edge] ?? 0; + this.outline.cursor = buildResizeCursor(baseAngle + rotation); + return; + } + } + + // Reset cursor when not over an edge + this.outline.cursor = this.isHovering ? "move" : "default"; + } + + private resetDragState(): void { + this.isDragging = false; + this.dragOffset = { x: 0, y: 0 }; + this.scaleDirection = null; + this.edgeDragDirection = null; + this.edgeDragStart = { x: 0, y: 0 }; + this.originalDimensions = null; + this.isRotating = false; + this.rotationStart = null; + this.rotationCorner = null; + this.initialClipConfiguration = null; + this.hideDimensionLabel(); + } +} diff --git a/src/core/ui/svg-toolbar.ts b/src/core/ui/svg-toolbar.ts new file mode 100644 index 00000000..6d7cbd8c --- /dev/null +++ b/src/core/ui/svg-toolbar.ts @@ -0,0 +1,517 @@ +import { updateSvgAttribute } from "@core/shared/svg-utils"; +import type { ResolvedClip, SvgAsset } from "@schemas"; +import { injectShotstackStyles } from "@styles/inject"; + +import { BaseToolbar } from "./base-toolbar"; +import { EffectPanel } from "./composites/EffectPanel"; +import { TransitionPanel } from "./composites/TransitionPanel"; +import { DragStateManager } from "./drag-state-manager"; +import { SliderControl } from "./primitives/SliderControl"; + +const ICONS = { + opacity: ``, + scale: ``, + transition: ``, + effect: `` +}; + +type PopupName = "opacity" | "scale" | "transition" | "effect"; + +/** + * Toolbar for editing SVG clip properties. + * + * Asset-level controls: fill color, corner radius (simple rect SVGs only). + * Clip-level controls: opacity, scale, transition, effect. + */ +export class SvgToolbar extends BaseToolbar { + // ─── SVG Asset Controls ────────────────────────────────────────────────────── + private fillColorInput: HTMLInputElement | null = null; + private cornerRadiusInput: HTMLInputElement | null = null; + private currentFill = "#0000ff"; + private currentRadius = 0; + + // ─── Clip-Level Controls ───────────────────────────────────────────────────── + private transitionPanel: TransitionPanel | null = null; + private effectPanel: EffectPanel | null = null; + private opacitySlider: SliderControl | null = null; + private scaleSlider: SliderControl | null = null; + + // ─── Cached DOM References ─────────────────────────────────────────────────── + // Queried once at mount time. We own the template so these are guaranteed to exist. + private readonly buttons = new Map(); + private readonly popups = new Map(); + + // ─── State ─────────────────────────────────────────────────────────────────── + // Single DragStateManager for all controls: "fill", "corner", "opacity", "scale" + private dragManager = new DragStateManager(); + private abortController: AbortController | null = null; + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + this.container = document.createElement("div"); + this.container.className = "ss-toolbar ss-svg-toolbar"; + this.container.innerHTML = ` +
+ + + +
+
+ + + +
+
+ + + + +
+ +
+ + + + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ `; + parent.insertBefore(this.container, parent.firstChild); + + // ─── Cache DOM References ──────────────────────────────────────────────── + const popupNames: PopupName[] = ["opacity", "scale", "transition", "effect"]; + for (const name of popupNames) { + const btn = this.container.querySelector(`[data-action="${name}"]`); + const popup = this.container.querySelector(`[data-popup="${name}"]`); + if (btn) this.buttons.set(name, btn); + if (popup) this.popups.set(name, popup); + } + + // ─── SVG Asset Controls ────────────────────────────────────────────────── + this.fillColorInput = this.container.querySelector("[data-fill-color]"); + this.cornerRadiusInput = this.container.querySelector("[data-corner-radius-input]"); + this.setupFillColorControl(); + this.setupCornerRadiusControl(); + + // ─── Clip-Level Composite Components ───────────────────────────────────── + this.mountCompositeComponents(); + this.setupClipEventListeners(); + + this.setupOutsideClickHandler(); + this.enableDrag(); + } + + // ─── SVG Asset Control Setup ────────────────────────────────────────────────── + + private setupFillColorControl(): void { + if (!this.fillColorInput) return; + + this.fillColorInput.addEventListener("pointerdown", () => { + this.startAssetDrag("fill"); + }); + + this.fillColorInput.addEventListener("input", e => { + this.currentFill = (e.target as HTMLInputElement).value; + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId || !clip || clip.asset?.type !== "svg") return; + + const svgAsset = clip.asset as SvgAsset; + if (!svgAsset.src) return; + + const updated = structuredClone(svgAsset); + updated.src = updateSvgAttribute(svgAsset.src, "fill", this.currentFill); + + this.edit.updateClipInDocument(clipId, { asset: updated as ResolvedClip["asset"] }); + this.edit.resolveClip(clipId); + }); + + this.fillColorInput.addEventListener("change", () => { + this.endAssetDrag("fill"); + }); + } + + private setupCornerRadiusControl(): void { + if (!this.cornerRadiusInput) return; + + this.cornerRadiusInput.addEventListener("input", () => { + try { + // Capture initial state on first input (lazy start) + if (!this.dragManager.isDragging("corner")) { + this.startAssetDrag("corner"); + } + + this.currentRadius = parseInt(this.cornerRadiusInput!.value, 10); + + if (Number.isNaN(this.currentRadius)) { + return; + } + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + + if (!clipId || !clip || clip.asset?.type !== "svg") { + return; + } + + const svgAsset = clip.asset as SvgAsset; + if (!svgAsset.src) return; + + const doc = new DOMParser().parseFromString(svgAsset.src, "image/svg+xml"); + const shape = doc.querySelector("svg")?.querySelector("rect, circle, polygon, path, ellipse, line, polyline"); + + const maxRadius = shape ? SvgToolbar.getMaxRadius(shape) : 100; + + const clampedRadius = Math.max(0, Math.min(this.currentRadius, maxRadius)); + if (clampedRadius !== this.currentRadius) { + this.currentRadius = clampedRadius; + this.cornerRadiusInput!.value = String(clampedRadius); + } + + const updated = structuredClone(svgAsset); + updated.src = updateSvgAttribute(svgAsset.src, "rx", String(clampedRadius)); + updated.src = updateSvgAttribute(updated.src, "ry", String(clampedRadius)); + + this.edit.updateClipInDocument(clipId, { asset: updated as ResolvedClip["asset"] }); + this.edit.resolveClip(clipId); + } catch (error) { + console.error("[SVG Corner Radius] Error applying radius:", error); + this.dragManager.end("corner"); + } + }); + + this.cornerRadiusInput.addEventListener("blur", () => { + this.endAssetDrag("corner"); + }); + } + + /** + * Max corner radius is half the shortest side (same as Figma). + * Since viewBox units match pixel dimensions, rx/ry values are already in pixels. + */ + private static getMaxRadius(shape: Element): number { + const rectWidth = parseFloat(shape.getAttribute("width") || "100"); + const rectHeight = parseFloat(shape.getAttribute("height") || "100"); + return Math.min(rectWidth, rectHeight) / 2; + } + + // ─── Clip-Level Component Setup ────────────────────────────────────────────── + + private mountCompositeComponents(): void { + const opacityMount = this.container?.querySelector("[data-opacity-slider-mount]"); + if (opacityMount) { + this.opacitySlider = new SliderControl({ + label: "Opacity", + min: 0, + max: 100, + initialValue: 100, + formatValue: v => `${Math.round(v)}%` + }); + this.opacitySlider.onDragStart(() => this.startAssetDrag("opacity")); + this.opacitySlider.onChange(value => this.handleOpacityChange(value)); + this.opacitySlider.onDragEnd(() => this.endAssetDrag("opacity")); + this.opacitySlider.mount(opacityMount as HTMLElement); + } + + const scaleMount = this.container?.querySelector("[data-scale-slider-mount]"); + if (scaleMount) { + this.scaleSlider = new SliderControl({ + label: "Scale", + min: 10, + max: 200, + initialValue: 100, + formatValue: v => `${Math.round(v)}%` + }); + this.scaleSlider.onDragStart(() => this.startAssetDrag("scale")); + this.scaleSlider.onChange(value => this.handleScaleChange(value)); + this.scaleSlider.onDragEnd(() => this.endAssetDrag("scale")); + this.scaleSlider.mount(scaleMount as HTMLElement); + } + + const transitionMount = this.container?.querySelector("[data-transition-panel-mount]"); + if (transitionMount) { + this.transitionPanel = new TransitionPanel(); + this.transitionPanel.onChange(() => this.applyTransitionUpdate()); + this.transitionPanel.mount(transitionMount as HTMLElement); + } + + const effectMount = this.container?.querySelector("[data-effect-panel-mount]"); + if (effectMount) { + this.effectPanel = new EffectPanel(); + this.effectPanel.onChange(() => this.applyEffect()); + this.effectPanel.mount(effectMount as HTMLElement); + } + } + + private setupClipEventListeners(): void { + this.abortController = new AbortController(); + const { signal } = this.abortController; + + for (const [name, btn] of this.buttons) { + btn.addEventListener( + "click", + (e: Event) => { + e.stopPropagation(); + this.togglePopupByName(name); + }, + { signal } + ); + } + } + + // ─── Popup Management ───────────────────────────────────────────────────────── + + private togglePopupByName(name: PopupName): void { + const popupEl = this.popups.get(name); + const isCurrentlyOpen = popupEl?.classList.contains("visible"); + this.closeAllPopups(); + + if (!isCurrentlyOpen) { + this.togglePopup(popupEl ?? null); + this.buttons.get(name)?.classList.add("active"); + } + } + + protected override closeAllPopups(): void { + super.closeAllPopups(); + for (const btn of this.buttons.values()) { + btn.classList.remove("active"); + } + } + + protected override getPopupList(): (HTMLElement | null)[] { + return [...this.popups.values()]; + } + + // ─── Sync State ────────────────────────────────────────────────────────────── + + protected override syncState(): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || clip.asset?.type !== "svg") { + return; + } + + // SVG asset controls + const svgAsset = clip.asset as SvgAsset; + if (svgAsset.src) { + const doc = new DOMParser().parseFromString(svgAsset.src, "image/svg+xml"); + const shape = doc.querySelector("svg")?.querySelector("rect, circle, polygon, path, ellipse, line, polyline"); + if (shape) { + this.currentFill = shape.getAttribute("fill") || "#0000ff"; + if (this.fillColorInput) this.fillColorInput.value = this.currentFill; + + const rx = shape.getAttribute("rx") || "0"; + const maxRadius = SvgToolbar.getMaxRadius(shape); + + if (this.cornerRadiusInput) { + this.cornerRadiusInput.max = String(Math.round(maxRadius)); + } + + this.currentRadius = Math.round(parseFloat(rx)); + + if (this.cornerRadiusInput) { + this.cornerRadiusInput.value = String(this.currentRadius); + } + } + } + + // Clip-level controls + const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; + this.opacitySlider?.setValue(Math.round(opacity * 100)); + this.updateOpacityDisplay(); + + const scale = typeof clip.scale === "number" ? clip.scale : 1; + this.scaleSlider?.setValue(Math.round(scale * 100)); + this.updateScaleDisplay(); + + this.transitionPanel?.setFromClip(clip.transition); + this.effectPanel?.setFromClip(clip.effect); + } + + // ─── Two-Phase Drag Helpers ────────────────────────────────────────────────── + // + // Without this, every slider tick creates an undo command and Ctrl-Z steps + // through dozens of intermediate values instead of reverting the whole drag. + // + // pointerdown → snapshot clip state + // input → live preview (bypass command system) + // change → commit one undo entry for the entire gesture + // + // Text-input commits (blur / Enter) skip the drag path and go straight + // through applyClipUpdate(). + + private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; + } + + /** Start a drag session for any control (asset or clip-level). */ + private startAssetDrag(controlId: string): void { + const state = this.captureClipState(); + if (state) { + this.dragManager.start(controlId, state.clipId, state.initialState); + } + } + + /** End a drag session and commit a single undo entry. */ + private endAssetDrag(controlId: string): void { + const session = this.dragManager.end(controlId); + if (!session) return; + + const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (finalClip) { + this.edit.commitClipUpdate(session.clipId, session.initialState, structuredClone(finalClip)); + } + } + + // ─── Value Change Handlers ─────────────────────────────────────────────────── + + private handleOpacityChange(value: number): void { + this.updateOpacityDisplay(); + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const updates = { opacity: value / 100 }; + + if (this.dragManager.isDragging("opacity")) { + this.edit.updateClipInDocument(clipId, updates); + this.edit.resolveClip(clipId); + } else { + this.applyClipUpdate(updates); + } + } + + private handleScaleChange(value: number): void { + this.updateScaleDisplay(); + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const updates = { scale: value / 100 }; + + if (this.dragManager.isDragging("scale")) { + this.edit.updateClipInDocument(clipId, updates); + this.edit.resolveClip(clipId); + } else { + this.applyClipUpdate(updates); + } + } + + private applyTransitionUpdate(): void { + const transition = this.transitionPanel?.getClipValue(); + this.applyClipUpdate({ transition }); + } + + private applyEffect(): void { + const effectValue = this.effectPanel?.getClipValue(); + this.applyClipUpdate({ effect: effectValue }); + } + + // ─── Display Updates ────────────────────────────────────────────────────────── + + private updateOpacityDisplay(): void { + const value = this.opacitySlider?.getValue() ?? 100; + const el = this.container?.querySelector("[data-opacity-value]"); + if (el) el.textContent = `${Math.round(value)}%`; + } + + private updateScaleDisplay(): void { + const value = this.scaleSlider?.getValue() ?? 100; + const el = this.container?.querySelector("[data-scale-value]"); + if (el) el.textContent = `${Math.round(value)}%`; + } + + // ─── Update Helpers ─────────────────────────────────────────────────────────── + + private applyClipUpdate(updates: Record): void { + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + } + + // ─── Lifecycle ──────────────────────────────────────────────────────────────── + + override dispose(): void { + this.abortController?.abort(); + this.abortController = null; + + this.dragManager.clear(); + + this.buttons.clear(); + this.popups.clear(); + this.fillColorInput = null; + this.cornerRadiusInput = null; + + this.transitionPanel?.dispose(); + this.effectPanel?.dispose(); + this.opacitySlider?.dispose(); + this.scaleSlider?.dispose(); + + super.dispose(); + + this.transitionPanel = null; + this.effectPanel = null; + this.opacitySlider = null; + this.scaleSlider = null; + } +} diff --git a/src/core/ui/text-to-image-toolbar.ts b/src/core/ui/text-to-image-toolbar.ts new file mode 100644 index 00000000..860417e8 --- /dev/null +++ b/src/core/ui/text-to-image-toolbar.ts @@ -0,0 +1,678 @@ +import { truncatePrompt } from "@core/shared/ai-asset-utils"; +import { ShotstackEdit } from "@core/shotstack-edit"; +import type { ResolvedClip, TextToImageAsset } from "@schemas"; +import { injectShotstackStyles } from "@styles/inject"; + +import { BaseToolbar } from "./base-toolbar"; +import { EffectPanel } from "./composites/EffectPanel"; +import { TransitionPanel } from "./composites/TransitionPanel"; +import { DragStateManager } from "./drag-state-manager"; +import { SliderControl } from "./primitives/SliderControl"; + +type FitValue = "crop" | "cover" | "contain" | "none"; + +interface FitOption { + value: FitValue; + label: string; + description: string; +} + +const FIT_OPTIONS: FitOption[] = [ + { value: "crop", label: "Crop", description: "Fill frame, clip overflow" }, + { value: "cover", label: "Cover", description: "Fill frame, keep ratio" }, + { value: "contain", label: "Contain", description: "Fit inside frame" }, + { value: "none", label: "None", description: "Original size" } +]; + +const DIMENSION_OPTIONS = [256, 512, 768, 1024, 1280]; + +const ICONS = { + prompt: ``, + fit: ``, + opacity: ``, + scale: ``, + transition: ``, + chevron: ``, + check: ``, + effect: ``, + dimensions: `` +}; + +const PROMPT_DEBOUNCE_MS = 150; + +export class TextToImageToolbar extends BaseToolbar { + // ─── Current Values ────────────────────────────────────────────────────────── + private currentFit: FitValue = "crop"; + private currentWidth: number = 1024; + private currentHeight: number = 1024; + + // ─── Composite UI Components ───────────────────────────────────────────────── + private transitionPanel: TransitionPanel | null = null; + private effectPanel: EffectPanel | null = null; + private opacitySlider: SliderControl | null = null; + private scaleSlider: SliderControl | null = null; + + // ─── Cached Elements (only those accessed frequently or needing typed refs) ── + private promptTextarea: HTMLTextAreaElement | null = null; + + // ─── State ─────────────────────────────────────────────────────────────────── + private dragManager = new DragStateManager(); + private promptDebounceTimer: ReturnType | null = null; + private abortController: AbortController | null = null; + + // ─── DOM Query Helpers ──────────────────────────────────────────────────────── + + private btn(name: PopupName): HTMLButtonElement | null { + return this.container?.querySelector(`[data-action="${name}"]`) ?? null; + } + + private popup(name: PopupName): HTMLDivElement | null { + return this.container?.querySelector(`[data-popup="${name}"]`) ?? null; + } + + private label(attr: string): HTMLSpanElement | null { + return this.container?.querySelector(`[data-${attr}]`) ?? null; + } + + /** Get the edit as ShotstackEdit if it has merge field capabilities */ + private getShotstackEdit(): ShotstackEdit | null { + if (this.edit && "mergeFields" in this.edit) { + return this.edit as ShotstackEdit; + } + return null; + } + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-tti-toolbar"; + + this.container.innerHTML = ` + +
+
+ +
+
Prompt
+ +
Use {{ FIELD_NAME }} for merge fields
+
+
+
+ +
+ + +
+ ${ICONS.dimensions} +
+ +
+
Generation Width
+
Pixel width of the AI-generated image
+ ${DIMENSION_OPTIONS.map( + dim => ` +
+
+ ${dim}px +
+ ${ICONS.check} +
+ ` + ).join("")} +
+
+ × +
+ +
+
Generation Height
+
Pixel height of the AI-generated image
+ ${DIMENSION_OPTIONS.map( + dim => ` +
+
+ ${dim}px +
+ ${ICONS.check} +
+ ` + ).join("")} +
+
+
+ +
+ + +
+ +
+ +
+ ${FIT_OPTIONS.map( + opt => ` +
+
+ ${opt.label} + ${opt.description} +
+ ${ICONS.check} +
+ ` + ).join("")} +
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+ +
+ + +
+ +
+
+
+
+
+ `; + + parent.insertBefore(this.container, parent.firstChild); + + this.promptTextarea = this.container.querySelector("[data-prompt-textarea]"); + + // ─── Mount Composite Components ────────────────────────────────────────────── + this.mountCompositeComponents(); + + this.setupEventListeners(); + this.setupOutsideClickHandler(); + this.enableDrag(); + } + + private mountCompositeComponents(): void { + // Mount opacity slider (two-phase: live preview during drag, single undo on release) + const opacityMount = this.container?.querySelector("[data-opacity-slider-mount]"); + if (opacityMount) { + this.opacitySlider = new SliderControl({ + label: "Opacity", + min: 0, + max: 100, + initialValue: 100, + formatValue: v => `${Math.round(v)}%` + }); + this.opacitySlider.onDragStart(() => this.startSliderDrag("opacity")); + this.opacitySlider.onChange(value => this.handleOpacityChange(value)); + this.opacitySlider.onDragEnd(() => this.endSliderDrag("opacity")); + this.opacitySlider.mount(opacityMount as HTMLElement); + } + + // Mount scale slider (two-phase: live preview during drag, single undo on release) + const scaleMount = this.container?.querySelector("[data-scale-slider-mount]"); + if (scaleMount) { + this.scaleSlider = new SliderControl({ + label: "Scale", + min: 10, + max: 200, + initialValue: 100, + formatValue: v => `${Math.round(v)}%` + }); + this.scaleSlider.onDragStart(() => this.startSliderDrag("scale")); + this.scaleSlider.onChange(value => this.handleScaleChange(value)); + this.scaleSlider.onDragEnd(() => this.endSliderDrag("scale")); + this.scaleSlider.mount(scaleMount as HTMLElement); + } + + const transitionMount = this.container?.querySelector("[data-transition-panel-mount]"); + if (transitionMount) { + this.transitionPanel = new TransitionPanel(); + this.transitionPanel.onChange(() => this.applyTransitionUpdate()); + this.transitionPanel.mount(transitionMount as HTMLElement); + } + + const effectMount = this.container?.querySelector("[data-effect-panel-mount]"); + if (effectMount) { + this.effectPanel = new EffectPanel(); + this.effectPanel.onChange(() => this.applyEffect()); + this.effectPanel.mount(effectMount as HTMLElement); + } + } + + private setupEventListeners(): void { + this.abortController = new AbortController(); + const { signal } = this.abortController; + + // Toggle popups — bind all buttons by name + const popupNames: PopupName[] = ["prompt", "width", "height", "fit", "opacity", "scale", "transition", "effect"]; + for (const name of popupNames) { + this.btn(name)?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName(name); + }, + { signal } + ); + } + + // Prompt textarea input (debounced) + this.promptTextarea?.addEventListener("input", () => this.debouncedApplyPromptEdit(), { signal }); + + // Width options + this.popup("width") + ?.querySelectorAll("[data-dim-width]") + .forEach(item => { + item.addEventListener("click", () => this.handleWidthChange(parseInt(item.dataset["dimWidth"] || "1024", 10)), { signal }); + }); + + // Height options + this.popup("height") + ?.querySelectorAll("[data-dim-height]") + .forEach(item => { + item.addEventListener("click", () => this.handleHeightChange(parseInt(item.dataset["dimHeight"] || "1024", 10)), { signal }); + }); + + // Fit options + this.popup("fit") + ?.querySelectorAll("[data-fit]") + .forEach(item => { + item.addEventListener("click", () => this.handleFitChange(item.dataset["fit"] as FitValue), { signal }); + }); + } + + // ─── Popup Management ───────────────────────────────────────────────────────── + + private togglePopupByName(name: PopupName): void { + const popupEl = this.popup(name); + const isCurrentlyOpen = popupEl?.classList.contains("visible"); + this.closeAllPopups(); + + if (!isCurrentlyOpen) { + this.togglePopup(popupEl); + this.btn(name)?.classList.add("active"); + + // Auto-focus textarea when prompt popup opens + if (name === "prompt" && this.promptTextarea) { + requestAnimationFrame(() => this.promptTextarea?.focus()); + } + } + } + + protected override closeAllPopups(): void { + super.closeAllPopups(); + const names: PopupName[] = ["prompt", "width", "height", "fit", "opacity", "scale", "transition", "effect"]; + for (const name of names) { + this.btn(name)?.classList.remove("active"); + } + } + + protected override getPopupList(): (HTMLElement | null)[] { + const names: PopupName[] = ["prompt", "width", "height", "fit", "opacity", "scale", "transition", "effect"]; + return names.map(name => this.popup(name)); + } + + // ─── Sync State ────────────────────────────────────────────────────────────── + + protected override syncState(): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || clip.asset.type !== "text-to-image") return; + + const asset = clip.asset as TextToImageAsset; + + // Prompt - show merge field placeholder if present, otherwise resolved value + if (this.promptTextarea) { + const document = this.edit.getDocument(); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const binding = clipId ? document?.getClipBinding(clipId, "asset.prompt") : undefined; + this.promptTextarea.value = binding?.placeholder ?? asset.prompt ?? ""; + } + this.updatePromptDisplay(clip.asset as TextToImageAsset); + + // Dimensions + this.currentWidth = asset.width ?? this.edit.size.width; + this.currentHeight = asset.height ?? this.edit.size.height; + this.updateWidthDisplay(); + this.updateHeightDisplay(); + this.updateDimensionActiveStates(); + + // Fit + this.currentFit = (clip.fit as FitValue) || "crop"; + this.updateFitDisplay(); + this.updateFitActiveState(); + + // Opacity + const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; + this.opacitySlider?.setValue(Math.round(opacity * 100)); + this.updateOpacityDisplay(); + + // Scale + const scale = typeof clip.scale === "number" ? clip.scale : 1; + this.scaleSlider?.setValue(Math.round(scale * 100)); + this.updateScaleDisplay(); + + // Transition + this.transitionPanel?.setFromClip(clip.transition); + + // Effect + this.effectPanel?.setFromClip(clip.effect); + } + + // ─── Prompt Handlers ────────────────────────────────────────────────────────── + + private debouncedApplyPromptEdit(): void { + if (this.promptDebounceTimer) { + clearTimeout(this.promptDebounceTimer); + } + this.promptDebounceTimer = setTimeout(() => { + const rawText = this.promptTextarea?.value ?? ""; + const shotstackEdit = this.getShotstackEdit(); + const resolvedText = shotstackEdit?.mergeFields.resolve(rawText) ?? rawText; + + const document = this.edit.getDocument(); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + + if (shotstackEdit?.mergeFields.isMergeFieldTemplate(rawText)) { + const binding = { + placeholder: rawText, + resolvedValue: resolvedText + }; + if (clipId && document) { + document.setClipBinding(clipId, "asset.prompt", binding); + } + } else if (clipId && document) { + document.removeClipBinding(clipId, "asset.prompt"); + } + + this.updateAssetProperty({ prompt: resolvedText }); + this.updatePromptButtonText(rawText); + }, PROMPT_DEBOUNCE_MS); + } + + private updatePromptDisplay(asset: TextToImageAsset): void { + const document = this.edit.getDocument(); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const binding = clipId ? document?.getClipBinding(clipId, "asset.prompt") : undefined; + const displayText = binding?.placeholder ?? asset.prompt ?? ""; + this.updatePromptButtonText(displayText); + } + + private updatePromptButtonText(text: string): void { + const el = this.label("prompt-text"); + if (el) { + el.textContent = text ? truncatePrompt(text, 20) : "Prompt"; + el.title = text || ""; + } + } + + // ─── Dimension Handlers ─────────────────────────────────────────────────────── + + private handleWidthChange(width: number): void { + this.currentWidth = width; + this.updateWidthDisplay(); + this.updateDimensionActiveStates(); + this.closeAllPopups(); + this.updateAssetProperty({ width }); + } + + private handleHeightChange(height: number): void { + this.currentHeight = height; + this.updateHeightDisplay(); + this.updateDimensionActiveStates(); + this.closeAllPopups(); + this.updateAssetProperty({ height }); + } + + private updateWidthDisplay(): void { + const el = this.label("width-label"); + if (el) el.textContent = String(this.currentWidth); + } + + private updateHeightDisplay(): void { + const el = this.label("height-label"); + if (el) el.textContent = String(this.currentHeight); + } + + private updateDimensionActiveStates(): void { + this.popup("width") + ?.querySelectorAll("[data-dim-width]") + .forEach(el => { + el.classList.toggle("active", el.dataset["dimWidth"] === String(this.currentWidth)); + }); + this.popup("height") + ?.querySelectorAll("[data-dim-height]") + .forEach(el => { + el.classList.toggle("active", el.dataset["dimHeight"] === String(this.currentHeight)); + }); + } + + // ─── Two-Phase Drag Helpers ────────────────────────────────────────────────── + // + // Without this, every slider tick creates an undo command and Ctrl-Z steps + // through dozens of intermediate values instead of reverting the whole drag. + // + // pointerdown → snapshot clip state + // input → live preview (bypass command system) + // change → commit one undo entry for the entire gesture + // + // Text-input commits (blur / Enter) skip the drag path and go straight + // through applyClipUpdate(). + + /** + * Capture and deep-clone the current clip state for drag rollback. + */ + private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; + } + + /** + * Start a drag session for a slider control. + */ + private startSliderDrag(controlId: string): void { + const state = this.captureClipState(); + if (state) { + this.dragManager.start(controlId, state.clipId, state.initialState); + } + } + + /** + * End a drag session and commit a single undo entry. + */ + private endSliderDrag(controlId: string): void { + const session = this.dragManager.end(controlId); + if (!session) return; + + const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (finalClip) { + this.edit.commitClipUpdate(session.clipId, session.initialState, structuredClone(finalClip)); + } + } + + // ─── Visual Control Handlers ────────────────────────────────────────────────── + + private handleFitChange(fit: FitValue): void { + this.currentFit = fit; + this.updateFitDisplay(); + this.updateFitActiveState(); + this.closeAllPopups(); + this.applyClipUpdate({ fit }); + } + + private handleOpacityChange(value: number): void { + this.updateOpacityDisplay(); + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const updates = { opacity: value / 100 }; + + if (this.dragManager.isDragging("opacity")) { + this.edit.updateClipInDocument(clipId, updates); + this.edit.resolveClip(clipId); + } else { + this.applyClipUpdate(updates); + } + } + + private handleScaleChange(value: number): void { + this.updateScaleDisplay(); + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const updates = { scale: value / 100 }; + + if (this.dragManager.isDragging("scale")) { + this.edit.updateClipInDocument(clipId, updates); + this.edit.resolveClip(clipId); + } else { + this.applyClipUpdate(updates); + } + } + + private applyTransitionUpdate(): void { + const transition = this.transitionPanel?.getClipValue(); + this.applyClipUpdate({ transition }); + } + + private applyEffect(): void { + const effectValue = this.effectPanel?.getClipValue(); + this.applyClipUpdate({ effect: effectValue }); + } + + // ─── Display Updates ────────────────────────────────────────────────────────── + + private updateFitDisplay(): void { + const el = this.label("fit-label"); + if (el) { + const option = FIT_OPTIONS.find(o => o.value === this.currentFit); + el.textContent = option?.label || "Crop"; + } + } + + private updateFitActiveState(): void { + this.popup("fit") + ?.querySelectorAll("[data-fit]") + .forEach(el => { + el.classList.toggle("active", el.dataset["fit"] === this.currentFit); + }); + } + + private updateOpacityDisplay(): void { + const value = this.opacitySlider?.getValue() ?? 100; + const text = `${Math.round(value)}%`; + const opacityValue = this.container?.querySelector("[data-opacity-value]"); + if (opacityValue) opacityValue.textContent = text; + } + + private updateScaleDisplay(): void { + const value = this.scaleSlider?.getValue() ?? 100; + const text = `${Math.round(value)}%`; + const scaleValue = this.container?.querySelector("[data-scale-value]"); + if (scaleValue) scaleValue.textContent = text; + } + + // ─── Update Helpers ─────────────────────────────────────────────────────────── + + private updateAssetProperty(updates: Partial): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || clip.asset.type !== "text-to-image") return; + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { ...clip.asset, ...updates } as TextToImageAsset + }); + } + + private applyClipUpdate(updates: Record): void { + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + } + + // ─── Lifecycle ──────────────────────────────────────────────────────────────── + + override dispose(): void { + this.abortController?.abort(); + this.abortController = null; + + // Clear any in-progress drag sessions + this.dragManager.clear(); + + if (this.promptDebounceTimer) { + clearTimeout(this.promptDebounceTimer); + this.promptDebounceTimer = null; + } + + this.transitionPanel?.dispose(); + this.effectPanel?.dispose(); + this.opacitySlider?.dispose(); + this.scaleSlider?.dispose(); + + super.dispose(); + + this.transitionPanel = null; + this.effectPanel = null; + this.opacitySlider = null; + this.scaleSlider = null; + this.promptTextarea = null; + } +} + +type PopupName = "prompt" | "width" | "height" | "fit" | "opacity" | "scale" | "transition" | "effect"; diff --git a/src/core/ui/text-to-speech-toolbar.ts b/src/core/ui/text-to-speech-toolbar.ts new file mode 100644 index 00000000..4da39288 --- /dev/null +++ b/src/core/ui/text-to-speech-toolbar.ts @@ -0,0 +1,618 @@ +import type { ResolvedClip, TextToSpeechAsset } from "@schemas"; +import { injectShotstackStyles } from "@styles/inject"; + +import { BaseToolbar } from "./base-toolbar"; +import { DragStateManager } from "./drag-state-manager"; +import { ScrollableList } from "./primitives/ScrollableList"; +import type { ScrollableListGroup } from "./primitives/types"; + +// ─── Voice Registry ───────────────────────────────────────────────────────── + +interface VoiceInfo { + name: string; + language: string; + languageCode: string; + gender: "Male" | "Female"; +} + +const TTS_VOICES: VoiceInfo[] = [ + // English (US) + { name: "Matthew", language: "English (US)", languageCode: "en-US", gender: "Male" }, + { name: "Joanna", language: "English (US)", languageCode: "en-US", gender: "Female" }, + { name: "Salli", language: "English (US)", languageCode: "en-US", gender: "Female" }, + { name: "Joey", language: "English (US)", languageCode: "en-US", gender: "Male" }, + { name: "Kendra", language: "English (US)", languageCode: "en-US", gender: "Female" }, + { name: "Kimberly", language: "English (US)", languageCode: "en-US", gender: "Female" }, + { name: "Ivy", language: "English (US)", languageCode: "en-US", gender: "Female" }, + { name: "Kevin", language: "English (US)", languageCode: "en-US", gender: "Male" }, + { name: "Ruth", language: "English (US)", languageCode: "en-US", gender: "Female" }, + { name: "Stephen", language: "English (US)", languageCode: "en-US", gender: "Male" }, + // English (UK) + { name: "Amy", language: "English (UK)", languageCode: "en-GB", gender: "Female" }, + { name: "Brian", language: "English (UK)", languageCode: "en-GB", gender: "Male" }, + { name: "Emma", language: "English (UK)", languageCode: "en-GB", gender: "Female" }, + { name: "Arthur", language: "English (UK)", languageCode: "en-GB", gender: "Male" }, + // English (AU) + { name: "Olivia", language: "English (AU)", languageCode: "en-AU", gender: "Female" }, + // English (IN) + { name: "Kajal", language: "English (IN)", languageCode: "en-IN", gender: "Female" }, + // English (ZA) + { name: "Ayanda", language: "English (ZA)", languageCode: "en-ZA", gender: "Female" }, + // English (IE) + { name: "Niamh", language: "English (IE)", languageCode: "en-IE", gender: "Female" }, + // French + { name: "Léa", language: "French", languageCode: "fr-FR", gender: "Female" }, + { name: "Rémi", language: "French", languageCode: "fr-FR", gender: "Male" }, + // German + { name: "Vicki", language: "German", languageCode: "de-DE", gender: "Female" }, + { name: "Daniel", language: "German", languageCode: "de-DE", gender: "Male" }, + // Spanish (ES) + { name: "Lucia", language: "Spanish (ES)", languageCode: "es-ES", gender: "Female" }, + { name: "Sergio", language: "Spanish (ES)", languageCode: "es-ES", gender: "Male" }, + // Spanish (US) + { name: "Lupe", language: "Spanish (US)", languageCode: "es-US", gender: "Female" }, + { name: "Pedro", language: "Spanish (US)", languageCode: "es-US", gender: "Male" }, + // Portuguese (BR) + { name: "Camila", language: "Portuguese (BR)", languageCode: "pt-BR", gender: "Female" }, + { name: "Vitória", language: "Portuguese (BR)", languageCode: "pt-BR", gender: "Female" }, + { name: "Thiago", language: "Portuguese (BR)", languageCode: "pt-BR", gender: "Male" }, + // Italian + { name: "Bianca", language: "Italian", languageCode: "it-IT", gender: "Female" }, + { name: "Adriano", language: "Italian", languageCode: "it-IT", gender: "Male" }, + // Japanese + { name: "Kazuha", language: "Japanese", languageCode: "ja-JP", gender: "Female" }, + { name: "Tomoko", language: "Japanese", languageCode: "ja-JP", gender: "Female" }, + { name: "Takumi", language: "Japanese", languageCode: "ja-JP", gender: "Male" }, + // Korean + { name: "Seoyeon", language: "Korean", languageCode: "ko-KR", gender: "Female" }, + // Chinese (Mandarin) + { name: "Zhiyu", language: "Chinese (Mandarin)", languageCode: "cmn-CN", gender: "Female" }, + // Chinese (Cantonese) + { name: "Hiujin", language: "Chinese (Cantonese)", languageCode: "yue-CN", gender: "Female" }, + // Dutch + { name: "Laura", language: "Dutch", languageCode: "nl-NL", gender: "Female" }, + { name: "Lisa", language: "Dutch (BE)", languageCode: "nl-BE", gender: "Female" }, + // Swedish + { name: "Elin", language: "Swedish", languageCode: "sv-SE", gender: "Female" }, + // Danish + { name: "Sofie", language: "Danish", languageCode: "da-DK", gender: "Female" }, + // Norwegian + { name: "Ida", language: "Norwegian", languageCode: "nb-NO", gender: "Female" }, + // Finnish + { name: "Suvi", language: "Finnish", languageCode: "fi-FI", gender: "Female" }, + // Polish + { name: "Ola", language: "Polish", languageCode: "pl-PL", gender: "Female" }, + // Arabic + { name: "Hala", language: "Arabic", languageCode: "ar-AE", gender: "Female" } +]; + +/** Group voices by language for the ScrollableList */ +function buildVoiceGroups(): ScrollableListGroup[] { + const grouped = new Map(); + for (const voice of TTS_VOICES) { + const list = grouped.get(voice.language) ?? []; + list.push(voice); + grouped.set(voice.language, list); + } + + return Array.from(grouped.entries()).map(([language, voices]) => ({ + header: language, + headerDetail: `${voices.length} voice${voices.length > 1 ? "s" : ""}`, + items: voices.map(v => ({ + value: v.name, + label: v.name, + data: { gender: v.gender, lang: v.languageCode } + })) + })); +} + +// ─── Icons ────────────────────────────────────────────────────────────────── + +const ICONS = { + voice: ``, + text: ``, + volume: ``, + volumeMute: ``, + chevron: ``, + fadeIn: ``, + fadeOut: ``, + fadeInOut: ``, + fadeNone: `` +}; + +const TEXT_DEBOUNCE_MS = 300; + +// ─── Toolbar ──────────────────────────────────────────────────────────────── + +export class TextToSpeechToolbar extends BaseToolbar { + // State + private currentVoice = "Matthew"; + private currentVolume = 100; + private audioFadeEffect: "" | "fadeIn" | "fadeOut" | "fadeInFadeOut" = ""; + private textDebounceTimer: ReturnType | null = null; + private dragManager = new DragStateManager(); + + // Composite components + private voiceList: ScrollableList | null = null; + + // Elements + private voiceBtn: HTMLButtonElement | null = null; + private voicePopup: HTMLDivElement | null = null; + private textBtn: HTMLButtonElement | null = null; + private textPopup: HTMLDivElement | null = null; + private textArea: HTMLTextAreaElement | null = null; + private volumeBtn: HTMLButtonElement | null = null; + private volumePopup: HTMLDivElement | null = null; + private volumeSlider: HTMLInputElement | null = null; + private volumeDisplayInput: HTMLInputElement | null = null; + private audioFadeBtn: HTMLButtonElement | null = null; + private audioFadePopup: HTMLDivElement | null = null; + + private abortController: AbortController | null = null; + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-tts-toolbar"; + + this.container.innerHTML = ` + +
+ + + +
+
+ + +
+ +
+
Voice
+
+
+
+ +
+ + +
+ +
+
Speech Text
+ +
+
+ +
+ + +
+ +
+
Volume
+
+ + +
+
+
+ +
+ + +
+ +
+
+ + + + +
+
+
+ `; + + parent.insertBefore(this.container, parent.firstChild); + + // Query elements + this.voiceBtn = this.container.querySelector('[data-action="voice"]'); + this.voicePopup = this.container.querySelector('[data-popup="voice"]'); + this.textBtn = this.container.querySelector('[data-action="text"]'); + this.textPopup = this.container.querySelector('[data-popup="text"]'); + this.textArea = this.container.querySelector("[data-tts-textarea]"); + this.volumeBtn = this.container.querySelector('[data-action="volume"]'); + this.volumePopup = this.container.querySelector('[data-popup="volume"]'); + this.volumeSlider = this.container.querySelector("[data-volume-slider]"); + this.volumeDisplayInput = this.container.querySelector("[data-volume-display]"); + this.audioFadeBtn = this.container.querySelector('[data-action="audio-fade"]'); + this.audioFadePopup = this.container.querySelector('[data-popup="audio-fade"]'); + + this.mountCompositeComponents(); + this.setupEventListeners(); + this.setupOutsideClickHandler(); + this.enableDrag(); + } + + private mountCompositeComponents(): void { + const voiceMount = this.container?.querySelector("[data-voice-list-mount]"); + if (voiceMount) { + this.voiceList = new ScrollableList({ + groups: buildVoiceGroups(), + height: 300, + selectedValue: this.currentVoice + }); + this.voiceList.onChange(value => this.handleVoiceChange(value)); + this.voiceList.mount(voiceMount as HTMLElement); + } + } + + private setupEventListeners(): void { + this.abortController = new AbortController(); + const { signal } = this.abortController; + + // Popup toggles + this.voiceBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("voice"); + }, + { signal } + ); + this.textBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("text"); + }, + { signal } + ); + this.volumeBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("volume"); + }, + { signal } + ); + this.audioFadeBtn?.addEventListener( + "click", + e => { + e.stopPropagation(); + this.togglePopupByName("audio-fade"); + }, + { signal } + ); + + // Text area (debounced) + this.textArea?.addEventListener("input", () => this.debouncedApplyTextEdit(), { signal }); + + // Volume slider (two-phase drag) + this.volumeSlider?.addEventListener("pointerdown", () => this.startSliderDrag("volume"), { signal }); + this.volumeSlider?.addEventListener( + "input", + () => { + const value = parseInt(this.volumeSlider!.value, 10); + this.handleVolumeChange(value); + }, + { signal } + ); + this.volumeSlider?.addEventListener("change", () => this.endSliderDrag("volume"), { signal }); + + // Volume display input + this.volumeDisplayInput?.addEventListener("blur", () => this.commitVolumeInputValue(), { signal }); + this.volumeDisplayInput?.addEventListener( + "keydown", + (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + this.commitVolumeInputValue(); + this.volumeDisplayInput?.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + this.updateVolumeDisplay(); + this.volumeDisplayInput?.blur(); + } + }, + { signal } + ); + this.volumeDisplayInput?.addEventListener("focus", () => this.volumeDisplayInput?.select(), { signal }); + + // Audio fade options + this.audioFadePopup?.querySelectorAll("[data-audio-fade]").forEach(btn => { + btn.addEventListener( + "click", + e => { + const el = e.currentTarget as HTMLElement; + const fadeValue = el.dataset["audioFade"] || ""; + this.handleAudioFadeSelect(fadeValue as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut"); + }, + { signal } + ); + }); + } + + // ─── Popup Management ────────────────────────────────────────────────────── + + private togglePopupByName(popup: "voice" | "text" | "volume" | "audio-fade"): void { + const popupMap = { + voice: { popup: this.voicePopup, btn: this.voiceBtn }, + text: { popup: this.textPopup, btn: this.textBtn }, + volume: { popup: this.volumePopup, btn: this.volumeBtn }, + "audio-fade": { popup: this.audioFadePopup, btn: this.audioFadeBtn } + }; + + const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); + this.closeAllPopups(); + + if (!isCurrentlyOpen) { + this.togglePopup(popupMap[popup].popup); + popupMap[popup].btn?.classList.add("active"); + + if (popup === "voice") { + this.voiceList?.scrollToSelected(); + } else if (popup === "text" && this.textArea) { + requestAnimationFrame(() => this.textArea?.focus()); + } + } + } + + protected override closeAllPopups(): void { + super.closeAllPopups(); + this.voiceBtn?.classList.remove("active"); + this.textBtn?.classList.remove("active"); + this.volumeBtn?.classList.remove("active"); + this.audioFadeBtn?.classList.remove("active"); + } + + protected override getPopupList(): (HTMLElement | null)[] { + return [this.voicePopup, this.textPopup, this.volumePopup, this.audioFadePopup]; + } + + // ─── Sync State ────────────────────────────────────────────────────────────── + + protected override syncState(): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || clip.asset.type !== "text-to-speech") return; + + const asset = clip.asset as TextToSpeechAsset; + + // Voice + this.currentVoice = asset.voice ?? "Matthew"; + this.voiceList?.setSelected(this.currentVoice); + this.updateVoiceDisplay(); + + // Text + if (this.textArea) { + this.textArea.value = asset.text ?? ""; + } + this.updateTextPreview(asset.text ?? ""); + + // Volume + const volume = typeof asset.volume === "number" ? asset.volume : 1; + this.currentVolume = Math.round(volume * 100); + this.updateVolumeDisplay(); + + // Audio fade + this.audioFadeEffect = (asset.effect as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut") || ""; + this.updateAudioFadeUI(); + } + + // ─── Voice Handlers ────────────────────────────────────────────────────────── + + private handleVoiceChange(voiceName: string): void { + this.currentVoice = voiceName; + this.updateVoiceDisplay(); + this.updateAssetProperty({ voice: voiceName }); + } + + private updateVoiceDisplay(): void { + const label = this.container?.querySelector("[data-voice-label]"); + if (label) label.textContent = this.currentVoice; + } + + // ─── Text Handlers ─────────────────────────────────────────────────────────── + + private debouncedApplyTextEdit(): void { + if (this.textDebounceTimer) clearTimeout(this.textDebounceTimer); + this.textDebounceTimer = setTimeout(() => { + const text = this.textArea?.value ?? ""; + this.updateTextPreview(text); + this.updateAssetProperty({ text }); + }, TEXT_DEBOUNCE_MS); + } + + private updateTextPreview(text: string): void { + const el = this.container?.querySelector("[data-text-preview]"); + if (el) { + el.textContent = text ? this.truncateText(text, 20) : "Text"; + (el as HTMLElement).title = text || ""; + } + } + + private truncateText(text: string, maxLen: number): string { + return text.length > maxLen ? `${text.slice(0, maxLen)}...` : text; + } + + // ─── Volume Handlers ───────────────────────────────────────────────────────── + + private handleVolumeChange(value: number): void { + this.currentVolume = value; + this.updateVolumeDisplay(); + + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip) return; + + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + if (!clipId) return; + + const asset = clip.asset as Record; + const updates = { asset: { ...asset, volume: value / 100 } as typeof clip.asset }; + + if (this.dragManager.isDragging("volume")) { + this.edit.updateClipInDocument(clipId, updates); + this.edit.resolveClip(clipId); + } else { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + } + + private commitVolumeInputValue(): void { + if (!this.volumeDisplayInput) return; + const stripped = this.volumeDisplayInput.value.replace(/[^0-9]/g, ""); + const num = parseInt(stripped, 10); + const parsed = Number.isNaN(num) ? this.currentVolume : Math.max(0, Math.min(100, num)); + this.handleVolumeChange(parsed); + } + + private updateVolumeDisplay(): void { + const text = `${this.currentVolume}%`; + const volumeValue = this.container?.querySelector("[data-volume-value]"); + if (volumeValue) volumeValue.textContent = text; + if (this.volumeSlider) this.volumeSlider.value = String(this.currentVolume); + if (this.volumeDisplayInput) this.volumeDisplayInput.value = text; + + const iconContainer = this.container?.querySelector("[data-volume-icon]"); + if (iconContainer) { + iconContainer.innerHTML = this.currentVolume === 0 ? ICONS.volumeMute : ICONS.volume; + } + } + + // ─── Audio Fade ────────────────────────────────────────────────────────────── + + private handleAudioFadeSelect(effect: "" | "fadeIn" | "fadeOut" | "fadeInFadeOut"): void { + this.audioFadeEffect = effect; + this.updateAudioFadeUI(); + this.updateAssetProperty({ effect: effect || undefined }); + } + + private updateAudioFadeUI(): void { + if (!this.audioFadePopup) return; + + this.audioFadePopup.querySelectorAll("[data-audio-fade]").forEach(btn => { + const fadeValue = (btn as HTMLElement).dataset["audioFade"] || ""; + btn.classList.toggle("active", fadeValue === this.audioFadeEffect); + }); + + if (this.audioFadeBtn) { + const iconMap: Record = { + "": ICONS.fadeNone, + fadeIn: ICONS.fadeIn, + fadeOut: ICONS.fadeOut, + fadeInFadeOut: ICONS.fadeInOut + }; + const svg = this.audioFadeBtn.querySelector("svg"); + if (svg) { + svg.outerHTML = iconMap[this.audioFadeEffect] || ICONS.fadeNone; + } + } + } + + // ─── Two-Phase Drag ────────────────────────────────────────────────────────── + + private captureClipState(): { clipId: string; initialState: ResolvedClip } | null { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + return clip && clipId ? { clipId, initialState: structuredClone(clip) } : null; + } + + private startSliderDrag(controlId: string): void { + const state = this.captureClipState(); + if (state) { + this.dragManager.start(controlId, state.clipId, state.initialState); + } + } + + private endSliderDrag(controlId: string): void { + const session = this.dragManager.end(controlId); + if (!session) return; + + const finalClip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (finalClip) { + this.edit.commitClipUpdate(session.clipId, session.initialState, structuredClone(finalClip)); + } + } + + // ─── Update Helpers ────────────────────────────────────────────────────────── + + private updateAssetProperty(updates: Partial): void { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || clip.asset.type !== "text-to-speech") return; + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { ...clip.asset, ...updates } as TextToSpeechAsset + }); + } + + // ─── Lifecycle ─────────────────────────────────────────────────────────────── + + override dispose(): void { + this.abortController?.abort(); + this.abortController = null; + + this.dragManager.clear(); + + if (this.textDebounceTimer) { + clearTimeout(this.textDebounceTimer); + this.textDebounceTimer = null; + } + + this.voiceList?.dispose(); + this.voiceList = null; + + super.dispose(); + + this.voiceBtn = null; + this.voicePopup = null; + this.textBtn = null; + this.textPopup = null; + this.textArea = null; + this.volumeBtn = null; + this.volumePopup = null; + this.volumeSlider = null; + this.volumeDisplayInput = null; + this.audioFadeBtn = null; + this.audioFadePopup = null; + } +} diff --git a/src/core/ui/text-toolbar.ts b/src/core/ui/text-toolbar.ts new file mode 100644 index 00000000..44d3ca3c --- /dev/null +++ b/src/core/ui/text-toolbar.ts @@ -0,0 +1,818 @@ +import { ShotstackEdit } from "@core/shotstack-edit"; +import type { TextAsset } from "@schemas"; +import { injectShotstackStyles } from "@styles/inject"; + +import { BaseToolbar, BUILT_IN_FONTS, FONT_SIZES, TOOLBAR_ICONS } from "./base-toolbar"; +import { EffectPanel } from "./composites/EffectPanel"; +import { SpacingPanel } from "./composites/SpacingPanel"; +import { TransitionPanel } from "./composites/TransitionPanel"; + +export class TextToolbar extends BaseToolbar { + // Text edit + private textEditBtn: HTMLButtonElement | null = null; + private textEditPopup: HTMLDivElement | null = null; + private textEditArea: HTMLTextAreaElement | null = null; + private textEditDebounceTimer: ReturnType | null = null; + + // Font size + private sizeInput: HTMLInputElement | null = null; + private sizePopup: HTMLDivElement | null = null; + + // Font family + private fontBtn: HTMLButtonElement | null = null; + private fontPopup: HTMLDivElement | null = null; + private fontPreview: HTMLSpanElement | null = null; + + // Bold + private boldBtn: HTMLButtonElement | null = null; + + // Font color + private fontColorBtn: HTMLButtonElement | null = null; + private fontColorPopup: HTMLDivElement | null = null; + private fontColorInput: HTMLInputElement | null = null; + private colorDisplay: HTMLButtonElement | null = null; + + // Spacing (line height + vertical anchor) + private spacingBtn: HTMLButtonElement | null = null; + private spacingPopup: HTMLDivElement | null = null; + private spacingPanel: SpacingPanel | null = null; + private anchorTopBtn: HTMLButtonElement | null = null; + private anchorMiddleBtn: HTMLButtonElement | null = null; + private anchorBottomBtn: HTMLButtonElement | null = null; + + // Horizontal alignment + private alignBtn: HTMLButtonElement | null = null; + private alignIcon: SVGElement | null = null; + + // Background + private backgroundBtn: HTMLButtonElement | null = null; + private backgroundPopup: HTMLDivElement | null = null; + private bgColorInput: HTMLInputElement | null = null; + private bgOpacitySlider: HTMLInputElement | null = null; + private bgOpacityValue: HTMLSpanElement | null = null; + + // Stroke + private strokeBtn: HTMLButtonElement | null = null; + private strokePopup: HTMLDivElement | null = null; + private strokeWidthSlider: HTMLInputElement | null = null; + private strokeWidthValue: HTMLSpanElement | null = null; + private strokeColorInput: HTMLInputElement | null = null; + + // Composite panels (replace ~400 lines of duplicated transition/effect code) + private transitionBtn: HTMLButtonElement | null = null; + private transitionPopup: HTMLDivElement | null = null; + private transitionPanel: TransitionPanel | null = null; + + private effectBtn: HTMLButtonElement | null = null; + private effectPopup: HTMLDivElement | null = null; + private effectPanel: EffectPanel | null = null; + + // Stored bound handler for proper cleanup + private boundHandleClick: ((e: MouseEvent) => void) | null = null; + + private getShotstackEdit(): ShotstackEdit | null { + if (this.edit && "mergeFields" in this.edit) { + return this.edit as ShotstackEdit; + } + return null; + } + + override mount(parent: HTMLElement): void { + injectShotstackStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-toolbar ss-text-toolbar"; + + this.container.innerHTML = ` + +
+ + + +
+
+ +
+ +
+
Edit Text
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+ + + +
+ +
+
+ +
+ +
+
Font Color
+
+
Color
+
+ +
+
+
+
+ +
+ +
+
+
+
+
Anchor text box
+
+ + + +
+
+
+
+ +
+ + + +
+ +
+
Background
+
+
Color
+
+ +
+
+
+
Opacity
+
+ + 100 +
+
+
+
+ +
+ +
+
Text Stroke
+
+
Width
+
+ + 0 +
+
+
+
Color
+
+ +
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+
+ `; + + parent.insertBefore(this.container, parent.firstChild); + + this.bindElements(); + this.buildFontPopup(); + this.buildSizePopup(); + this.setupEventListeners(); + this.enableDrag(); + } + + private bindElements(): void { + if (!this.container) return; + + // Text edit + this.textEditBtn = this.container.querySelector("[data-action='text-edit-toggle']"); + this.textEditPopup = this.container.querySelector("[data-text-edit-popup]"); + this.textEditArea = this.container.querySelector("[data-text-edit-area]"); + + // Font size + this.sizeInput = this.container.querySelector("[data-size-input]"); + this.sizePopup = this.container.querySelector("[data-size-popup]"); + + // Font family + this.fontBtn = this.container.querySelector("[data-action='font-toggle']"); + this.fontPopup = this.container.querySelector("[data-font-popup]"); + this.fontPreview = this.container.querySelector("[data-font-preview]"); + + // Bold + this.boldBtn = this.container.querySelector("[data-action='bold']"); + + // Font color + this.fontColorBtn = this.container.querySelector("[data-action='font-color-toggle']"); + this.fontColorPopup = this.container.querySelector("[data-font-color-popup]"); + this.fontColorInput = this.container.querySelector("[data-font-color]"); + this.colorDisplay = this.container.querySelector("[data-color-display]"); + + // Spacing + this.spacingBtn = this.container.querySelector("[data-action='spacing-toggle']"); + this.spacingPopup = this.container.querySelector("[data-spacing-popup]"); + this.anchorTopBtn = this.container.querySelector("[data-action='anchor-top']"); + this.anchorMiddleBtn = this.container.querySelector("[data-action='anchor-middle']"); + this.anchorBottomBtn = this.container.querySelector("[data-action='anchor-bottom']"); + + // Alignment + this.alignBtn = this.container.querySelector("[data-action='align-cycle']"); + this.alignIcon = this.container.querySelector("[data-align-icon]"); + + // Background + this.backgroundBtn = this.container.querySelector("[data-action='background-toggle']"); + this.backgroundPopup = this.container.querySelector("[data-background-popup]"); + this.bgColorInput = this.container.querySelector("[data-bg-color]"); + this.bgOpacitySlider = this.container.querySelector("[data-bg-opacity]"); + this.bgOpacityValue = this.container.querySelector("[data-bg-opacity-value]"); + + // Stroke + this.strokeBtn = this.container.querySelector("[data-action='stroke-toggle']"); + this.strokePopup = this.container.querySelector("[data-stroke-popup]"); + this.strokeWidthSlider = this.container.querySelector("[data-stroke-width]"); + this.strokeWidthValue = this.container.querySelector("[data-stroke-width-value]"); + this.strokeColorInput = this.container.querySelector("[data-stroke-color]"); + + // Transition (popup container for composite) + this.transitionBtn = this.container.querySelector("[data-action='transition-toggle']"); + this.transitionPopup = this.container.querySelector("[data-transition-popup]"); + + // Effect (popup container for composite) + this.effectBtn = this.container.querySelector("[data-action='effect-toggle']"); + this.effectPopup = this.container.querySelector("[data-effect-popup]"); + } + + private setupEventListeners(): void { + this.boundHandleClick = this.handleClick.bind(this); + this.container?.addEventListener("click", this.boundHandleClick); + + // Text edit + this.textEditArea?.addEventListener("input", () => this.debouncedApplyTextEdit()); + + // Size input + this.sizeInput?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup(this.sizePopup); + }); + this.sizeInput?.addEventListener("blur", () => this.applyManualSize()); + this.sizeInput?.addEventListener("keydown", e => { + if (e.key === "Enter") { + this.applyManualSize(); + this.sizeInput?.blur(); + this.closeAllPopups(); + } + }); + + // Font color + this.fontColorInput?.addEventListener("input", () => this.handleFontColorChange()); + + // Background color + this.bgColorInput?.addEventListener("input", () => this.handleBackgroundChange()); + + // Background opacity - use base class helper + this.createSliderHandler(this.bgOpacitySlider, this.bgOpacityValue, () => { + this.handleBackgroundChange(); + }); + + // Stroke width - use base class helper + this.createSliderHandler(this.strokeWidthSlider, this.strokeWidthValue, () => { + this.handleStrokeChange(); + }); + this.strokeColorInput?.addEventListener("input", () => this.handleStrokeChange()); + + // Mount composite panels + this.mountCompositePanels(); + + // Use base class outside click handler + this.setupOutsideClickHandler(); + } + + private mountCompositePanels(): void { + // SpacingPanel - line height only for TextToolbar + const spacingContainer = this.container?.querySelector("[data-spacing-panel-container]") as HTMLElement | null; + if (spacingContainer) { + this.spacingPanel = new SpacingPanel({ showLetterSpacing: false }); + this.spacingPanel.onChange(state => { + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, lineHeight: state.lineHeight } }); + }); + this.spacingPanel.mount(spacingContainer); + } + + // TransitionPanel - replaces ~200 lines of transition handling + if (this.transitionPopup) { + this.transitionPanel = new TransitionPanel(); + this.transitionPanel.onChange(() => { + const transitionValue = this.transitionPanel?.getClipValue(); + this.applyClipUpdate({ transition: transitionValue }); + }); + this.transitionPanel.mount(this.transitionPopup); + } + + // EffectPanel - replaces ~120 lines of effect handling + if (this.effectPopup) { + this.effectPanel = new EffectPanel(); + this.effectPanel.onChange(() => { + const effectValue = this.effectPanel?.getClipValue(); + this.applyClipUpdate({ effect: effectValue }); + }); + this.effectPanel.mount(this.effectPopup); + } + } + + private handleClick(e: Event): void { + const target = e.target as HTMLElement; + const btn = target.closest("[data-action]") as HTMLElement | null; + if (!btn) return; + + const { action } = btn.dataset; + e.stopPropagation(); + + switch (action) { + case "text-edit-toggle": + this.togglePopup(this.textEditPopup); + break; + case "size-down": + this.adjustSize(-1); + break; + case "size-up": + this.adjustSize(1); + break; + case "bold": + this.toggleBold(); + break; + case "font-toggle": + this.togglePopup(this.fontPopup); + break; + case "font-color-toggle": + this.togglePopup(this.fontColorPopup); + break; + case "spacing-toggle": + this.togglePopup(this.spacingPopup); + break; + case "anchor-top": + this.setVerticalAnchor("top"); + break; + case "anchor-middle": + this.setVerticalAnchor("center"); + break; + case "anchor-bottom": + this.setVerticalAnchor("bottom"); + break; + case "align-cycle": + this.cycleAlignment(); + break; + case "background-toggle": + this.togglePopup(this.backgroundPopup); + break; + case "stroke-toggle": + this.togglePopup(this.strokePopup); + break; + case "transition-toggle": + this.togglePopup(this.transitionPopup); + break; + case "effect-toggle": + this.togglePopup(this.effectPopup); + break; + default: + break; + } + } + + protected override getPopupList(): (HTMLElement | null)[] { + return [ + this.textEditPopup, + this.sizePopup, + this.fontPopup, + this.fontColorPopup, + this.spacingPopup, + this.backgroundPopup, + this.strokePopup, + this.transitionPopup, + this.effectPopup + ]; + } + + private buildFontPopup(): void { + if (!this.fontPopup) return; + + const html = `
+
Built-in Fonts
+ ${BUILT_IN_FONTS.map(f => `
${f}
`).join("")} +
`; + + this.fontPopup.innerHTML = html; + + this.fontPopup.querySelectorAll("[data-font]").forEach(item => { + item.addEventListener("click", () => { + const { font } = (item as HTMLElement).dataset; + if (font) this.setFont(font); + this.closeAllPopups(); + }); + }); + } + + private buildSizePopup(): void { + if (!this.sizePopup) return; + + this.sizePopup.innerHTML = FONT_SIZES.map(size => `
${size}
`).join(""); + + this.sizePopup.querySelectorAll("[data-size]").forEach(item => { + item.addEventListener("click", () => { + const size = parseInt((item as HTMLElement).dataset["size"] || "32", 10); + this.setSize(size); + this.closeAllPopups(); + }); + }); + } + + private getCurrentAsset(): TextAsset | null { + const clip = this.edit.getResolvedClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!clip || clip.asset.type !== "text") return null; + return clip.asset as TextAsset; + } + + private updateAssetProperty(updates: Partial): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { ...asset, ...updates } as TextAsset + }); + } + + // Text content + private debouncedApplyTextEdit(): void { + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + } + this.textEditDebounceTimer = setTimeout(() => { + const rawText = this.textEditArea?.value ?? ""; + const shotstackEdit = this.getShotstackEdit(); + const resolvedText = shotstackEdit?.mergeFields.resolve(rawText) ?? rawText; + + const document = this.edit.getDocument(); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + + if (shotstackEdit?.mergeFields.isMergeFieldTemplate(rawText)) { + const binding = { + placeholder: rawText, + resolvedValue: resolvedText + }; + // Document binding (source of truth) + if (clipId && document) { + document.setClipBinding(clipId, "asset.text", binding); + } + } else if (clipId && document) { + // Document binding (source of truth) + document.removeClipBinding(clipId, "asset.text"); + } + + this.updateAssetProperty({ text: resolvedText }); + }, 150); + } + + // Font size + private adjustSize(direction: number): void { + const asset = this.getCurrentAsset(); + const currentSize = asset?.font?.size ?? 32; + const currentIdx = FONT_SIZES.findIndex(s => s >= currentSize); + const idx = Math.max(0, Math.min(FONT_SIZES.length - 1, (currentIdx === -1 ? FONT_SIZES.length - 1 : currentIdx) + direction)); + this.setSize(FONT_SIZES[idx]); + } + + private setSize(size: number): void { + if (this.sizeInput) this.sizeInput.value = String(size); + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, size } }); + } + + private applyManualSize(): void { + const size = parseInt(this.sizeInput?.value || "32", 10); + if (!Number.isNaN(size) && size > 0) { + this.setSize(size); + } + } + + // Font family + private setFont(font: string): void { + if (this.fontPreview) this.fontPreview.textContent = "Aa"; + if (this.fontPreview) this.fontPreview.style.fontFamily = `'${font}'`; + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, family: font } }); + this.updateFontActiveState(font); + } + + private updateFontActiveState(currentFont: string): void { + this.fontPopup?.querySelectorAll("[data-font]").forEach(item => { + item.classList.toggle("active", (item as HTMLElement).dataset["font"] === currentFont); + }); + } + + // Bold + private toggleBold(): void { + const asset = this.getCurrentAsset(); + const currentWeight = asset?.font?.weight ?? 400; + const newWeight = currentWeight >= 700 ? 400 : 700; + this.updateAssetProperty({ font: { ...asset?.font, weight: newWeight } }); + this.setButtonActive(this.boldBtn, newWeight >= 700); + } + + // Font color + private handleFontColorChange(): void { + const color = this.fontColorInput?.value ?? "#FFFFFF"; + if (this.colorDisplay) { + this.colorDisplay.style.backgroundColor = color; + } + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, color } }); + } + + // Vertical anchor + private setVerticalAnchor(anchor: "top" | "center" | "bottom"): void { + this.updateAssetProperty({ alignment: { ...this.getCurrentAsset()?.alignment, vertical: anchor } }); + this.updateAnchorActiveState(anchor); + } + + private updateAnchorActiveState(anchor: string): void { + this.setButtonActive(this.anchorTopBtn, anchor === "top"); + this.setButtonActive(this.anchorMiddleBtn, anchor === "center"); + this.setButtonActive(this.anchorBottomBtn, anchor === "bottom"); + } + + // Horizontal alignment + private cycleAlignment(): void { + const asset = this.getCurrentAsset(); + const current = asset?.alignment?.horizontal ?? "center"; + const cycle: Array<"left" | "center" | "right"> = ["left", "center", "right"]; + const idx = cycle.indexOf(current as "left" | "center" | "right"); + const next = cycle[(idx + 1) % cycle.length]; + + this.updateAssetProperty({ alignment: { ...asset?.alignment, horizontal: next } }); + this.updateAlignmentIcon(next); + } + + private updateAlignmentIcon(alignment: string): void { + if (!this.alignIcon) return; + + const icons: Record = { + left: TOOLBAR_ICONS.alignLeft, + center: TOOLBAR_ICONS.alignCenter, + right: TOOLBAR_ICONS.alignRight + }; + + this.alignIcon.innerHTML = icons[alignment] || icons["center"]; + } + + // Background + private handleBackgroundChange(): void { + const color = this.bgColorInput?.value ?? "#000000"; + const opacity = parseInt(this.bgOpacitySlider?.value ?? "100", 10) / 100; + + this.updateAssetProperty({ + background: { color, opacity } + }); + } + + // Stroke + private handleStrokeChange(): void { + const width = parseInt(this.strokeWidthSlider?.value ?? "0", 10); + const color = this.strokeColorInput?.value ?? "#000000"; + + this.updateAssetProperty({ + stroke: { width, color } + }); + } + + private applyClipUpdate(updates: Record): void { + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + } + + // Sync UI with current clip state + protected override syncState(): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + // Text - show merge field placeholder if present, otherwise resolved value + if (this.textEditArea) { + const document = this.edit.getDocument(); + const clipId = this.edit.getClipId(this.selectedTrackIdx, this.selectedClipIdx); + const binding = clipId ? document?.getClipBinding(clipId, "asset.text") : undefined; + this.textEditArea.value = binding?.placeholder ?? asset.text ?? ""; + } + + // Size + if (this.sizeInput) { + this.sizeInput.value = String(asset.font?.size ?? 32); + } + + // Font family + const fontFamily = asset.font?.family ?? "Open Sans"; + if (this.fontPreview) { + this.fontPreview.style.fontFamily = `'${fontFamily}'`; + } + this.updateFontActiveState(fontFamily); + + // Bold + const weight = asset.font?.weight ?? 400; + this.setButtonActive(this.boldBtn, weight >= 700); + + // Font color + if (this.fontColorInput && asset.font?.color) { + this.fontColorInput.value = asset.font.color; + } + if (this.colorDisplay) { + this.colorDisplay.style.backgroundColor = asset.font?.color ?? "#FFFFFF"; + } + + // Line height + this.spacingPanel?.setLineHeight(asset.font?.lineHeight ?? 1.2); + + // Vertical anchor + const verticalAnchor = asset.alignment?.vertical ?? "center"; + this.updateAnchorActiveState(verticalAnchor); + + // Horizontal alignment + const horizontalAlign = asset.alignment?.horizontal ?? "center"; + this.updateAlignmentIcon(horizontalAlign); + + // Background + if (this.bgColorInput && asset.background?.color) { + this.bgColorInput.value = asset.background.color; + } + const bgOpacity = Math.round((asset.background?.opacity ?? 1) * 100); + if (this.bgOpacitySlider) this.bgOpacitySlider.value = String(bgOpacity); + if (this.bgOpacityValue) this.bgOpacityValue.textContent = String(bgOpacity); + + // Stroke + const strokeWidth = asset.stroke?.width ?? 0; + if (this.strokeWidthSlider) this.strokeWidthSlider.value = String(strokeWidth); + if (this.strokeWidthValue) this.strokeWidthValue.textContent = String(strokeWidth); + if (this.strokeColorInput && asset.stroke?.color) { + this.strokeColorInput.value = asset.stroke.color; + } + + // Get clip for transition and effect values + const clip = this.edit.getClip(this.selectedTrackIdx, this.selectedClipIdx); + + // Sync composite panels + this.transitionPanel?.setFromClip(clip?.transition as { in?: string; out?: string } | undefined); + this.effectPanel?.setFromClip((clip?.effect as string) ?? ""); + } + + override dispose(): void { + // Clean up event listener before super.dispose() removes container + if (this.boundHandleClick) { + this.container?.removeEventListener("click", this.boundHandleClick); + this.boundHandleClick = null; + } + + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + } + + // Dispose composite panels (auto-cleans events via EventManager) + this.spacingPanel?.dispose(); + this.transitionPanel?.dispose(); + this.effectPanel?.dispose(); + + // Call base dispose + super.dispose(); + + // Null all element references + this.textEditBtn = null; + this.textEditPopup = null; + this.textEditArea = null; + this.sizeInput = null; + this.sizePopup = null; + this.fontBtn = null; + this.fontPopup = null; + this.fontPreview = null; + this.boldBtn = null; + this.fontColorBtn = null; + this.fontColorPopup = null; + this.fontColorInput = null; + this.colorDisplay = null; + this.spacingBtn = null; + this.spacingPopup = null; + this.spacingPanel = null; + this.anchorTopBtn = null; + this.anchorMiddleBtn = null; + this.anchorBottomBtn = null; + this.alignBtn = null; + this.alignIcon = null; + this.backgroundBtn = null; + this.backgroundPopup = null; + this.bgColorInput = null; + this.bgOpacitySlider = null; + this.bgOpacityValue = null; + this.strokeBtn = null; + this.strokePopup = null; + this.strokeWidthSlider = null; + this.strokeWidthValue = null; + this.strokeColorInput = null; + + // Composite panel references + this.transitionBtn = null; + this.transitionPopup = null; + this.transitionPanel = null; + this.effectBtn = null; + this.effectPopup = null; + this.effectPanel = null; + } +} diff --git a/src/core/ui/toolbar-drag.ts b/src/core/ui/toolbar-drag.ts new file mode 100644 index 00000000..0166cc64 --- /dev/null +++ b/src/core/ui/toolbar-drag.ts @@ -0,0 +1,181 @@ +/** + * Reusable drag utility for toolbar positioning. + */ + +/** Vertical 2×3 dot grid (portrait) – used on horizontal/top toolbars. */ +const DRAG_HANDLE_SVG_VERTICAL = ` + + + +`; + +/** Horizontal 3×2 dot grid (landscape) – used on vertical/side toolbars. */ +const DRAG_HANDLE_SVG_HORIZONTAL = ` + + +`; + +export type DragHandleOrientation = "vertical" | "horizontal"; + +/** Create a drag handle DOM element with the standard 6-dot icon. */ +function createDragHandle(className = "ss-toolbar-drag-handle", orientation: DragHandleOrientation = "horizontal"): HTMLDivElement { + const handle = document.createElement("div"); + handle.className = className; + handle.innerHTML = orientation === "vertical" ? DRAG_HANDLE_SVG_VERTICAL : DRAG_HANDLE_SVG_HORIZONTAL; + return handle; +} + +export interface ToolbarDragOptions { + container: HTMLElement; + /** CSS class(es) for the drag handle. Default `"ss-toolbar-drag-handle"`. */ + handleClassName?: string; + /** + * Dot-grid orientation of the drag handle icon. + * - `"horizontal"` – 3×2 landscape grid (default, for vertical/side toolbars). + * - `"vertical"` – 2×3 portrait grid (for horizontal/top toolbars). + */ + handleOrientation?: DragHandleOrientation; + onReset?: () => void; + /** Minimum distance from viewport edge. Default 12. */ + boundsPadding?: number; +} + +export interface ToolbarDragState { + readonly hasUserPosition: boolean; + readonly userX: number; + readonly userY: number; +} + +export interface ToolbarDragHandle { + getState(): ToolbarDragState; + dispose(): void; +} + +export function makeToolbarDraggable(options: ToolbarDragOptions): ToolbarDragHandle { + const { container, handleClassName, handleOrientation, boundsPadding = 12 } = options; + + const handle = createDragHandle(handleClassName, handleOrientation); + container.insertBefore(handle, container.firstChild); + + let hasUserPosition = false; + let userX = 0; + let userY = 0; + + // Drag state + let isPointerDown = false; + let isDragging = false; + let startPointerX = 0; + let startPointerY = 0; + let startLeft = 0; + let startTop = 0; + + function clamp(x: number, y: number): { x: number; y: number } { + const rect = container.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + return { + x: Math.max(boundsPadding, Math.min(vw - rect.width - boundsPadding, x)), + y: Math.max(boundsPadding, Math.min(vh - rect.height - boundsPadding, y)) + }; + } + + function onPointerMove(e: PointerEvent): void { + if (!isPointerDown) return; + + // Begin drag on first move + if (!isDragging) { + isDragging = true; + container.style.transform = "none"; + container.classList.add("ss-toolbar--dragging"); + document.body.classList.add("ss-dragging-toolbar"); + } + + const dx = e.clientX - startPointerX; + const dy = e.clientY - startPointerY; + const rawX = startLeft + dx; + const rawY = startTop + dy; + const clamped = clamp(rawX, rawY); + + container.style.left = `${clamped.x}px`; + container.style.top = `${clamped.y}px`; + + userX = clamped.x; + userY = clamped.y; + hasUserPosition = true; + } + + function onPointerUp(): void { + if (!isPointerDown) return; + isPointerDown = false; + + if (isDragging) { + container.classList.remove("ss-toolbar--dragging"); + document.body.classList.remove("ss-dragging-toolbar"); + } + isDragging = false; + + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + } + + function resetPosition(): void { + hasUserPosition = false; + userX = 0; + userY = 0; + + options.onReset?.(); + } + + function onDblClick(e: MouseEvent): void { + e.preventDefault(); + e.stopPropagation(); + resetPosition(); + } + + function onPointerDown(e: PointerEvent): void { + // Only left mouse button / primary touch + if (e.button !== 0) return; + e.preventDefault(); + e.stopPropagation(); + + isPointerDown = true; + startPointerX = e.clientX; + startPointerY = e.clientY; + + // Resolve current position from the rendered rect + const rect = container.getBoundingClientRect(); + const parent = container.offsetParent as HTMLElement | null; + const parentRect = parent?.getBoundingClientRect() ?? { left: 0, top: 0 }; + + // For position:fixed containers, offsetParent is null, use viewport coords directly + if (container.style.position === "fixed" || getComputedStyle(container).position === "fixed") { + startLeft = rect.left; + startTop = rect.top; + } else { + startLeft = rect.left - parentRect.left; + startTop = rect.top - parentRect.top; + } + + document.addEventListener("pointermove", onPointerMove); + document.addEventListener("pointerup", onPointerUp); + } + + // Attach listeners + handle.addEventListener("pointerdown", onPointerDown); + handle.addEventListener("dblclick", onDblClick); + + return { + getState(): ToolbarDragState { + return { hasUserPosition, userX, userY }; + }, + dispose(): void { + handle.removeEventListener("pointerdown", onPointerDown); + handle.removeEventListener("dblclick", onDblClick); + handle.remove(); + document.removeEventListener("pointermove", onPointerMove); + document.removeEventListener("pointerup", onPointerUp); + document.body.classList.remove("ss-dragging-toolbar"); + container.classList.remove("ss-toolbar--dragging"); + } + }; +} diff --git a/src/core/ui/ui-controller.ts b/src/core/ui/ui-controller.ts new file mode 100644 index 00000000..bc340143 --- /dev/null +++ b/src/core/ui/ui-controller.ts @@ -0,0 +1,724 @@ +import { Canvas } from "@canvas/shotstack-canvas"; +import type { Edit } from "@core/edit-session"; +import { EditEvent } from "@core/events/edit-events"; +import { EventEmitter } from "@core/events/event-emitter"; +import { ShotstackEdit } from "@core/shotstack-edit"; +import type * as pixi from "pixi.js"; + +import { AssetToolbar } from "./asset-toolbar"; +import { CanvasToolbar } from "./canvas-toolbar"; +import { ClipToolbar } from "./clip-toolbar"; +import { MediaToolbar } from "./media-toolbar"; +import { RichTextToolbar } from "./rich-text-toolbar"; +import { SelectionHandles } from "./selection-handles"; +import { SvgToolbar } from "./svg-toolbar"; +import { TextToImageToolbar } from "./text-to-image-toolbar"; +import { TextToSpeechToolbar } from "./text-to-speech-toolbar"; +import { TextToolbar } from "./text-toolbar"; +import type { ToolbarDragState } from "./toolbar-drag"; + +// Toolbar positioning constants +const TOOLBAR_WIDTH = 48; +const TOOLBAR_PADDING = 12; +const TOOLBAR_MIN_Y = 80; // Minimum Y to avoid overlapping with top navigation + +/** + * Configuration for a toolbar button. + */ +export interface ToolbarButtonConfig { + /** Unique identifier for the button (used in event names) */ + id: string; + /** SVG icon markup */ + icon: string; + /** Tooltip text shown on hover */ + tooltip: string; + /** Whether to show a divider before this button */ + dividerBefore?: boolean; +} + +/** + * Payload passed to button click handlers. + */ +export interface ButtonClickPayload { + /** Current playback position in seconds */ + position: number; + /** Currently selected clip, if any */ + selectedClip: { trackIndex: number; clipIndex: number } | null; +} + +/** + * Event map for UIController button events. + * Events are typed as `button:${buttonId}`. + */ +export type UIButtonEventMap = Record<`button:${string}`, ButtonClickPayload>; + +/** + * Interface for HTML/DOM UI components that can be registered with UIController. + * Toolbars, inspectors, and other UI elements should implement this interface. + */ +/** @internal */ +export interface UIRegistration { + /** Mount the component to a parent container */ + mount(container: HTMLElement): void; + /** Show the component for a specific clip (optional - utilities may not need this) */ + show?(trackIndex: number, clipIndex: number): void; + /** Hide the component */ + hide?(): void; + /** Clean up resources */ + dispose(): void; +} + +/** + * Interface for PixiJS-based overlays that render on the canvas. + * Used for interactive elements like selection handles, alignment guides, etc. + */ +/** @internal */ +export interface CanvasOverlayRegistration { + /** Mount to PixiJS container */ + mount(container: pixi.Container, app: pixi.Application): void; + /** Called each frame to update state */ + update(deltaTime: number, elapsed: number): void; + /** Called each frame to render */ + draw(): void; + /** Clean up resources */ + dispose(): void; +} + +/** + * Options for UIController configuration. + */ +export interface UIControllerOptions { + /** Enable selection handles for drag/resize/rotate interactions. Default: true */ + selectionHandles?: boolean; + /** Enable merge fields UI (Variables panel, autocomplete). Default: false (vanilla video editor) */ + mergeFields?: boolean; + /** Maximum total pixels allowed for resolution picker input. Omit for unlimited. */ + maxPixels?: number; +} + +/** + * Controller for managing UI elements (toolbars, utilities) separately from Canvas. + * + * This enables: + * - Pure preview mode (Canvas without UI) + * - Optional UI element loading + * + * @example + * ```typescript + * const ui = UIController.create(edit, canvas, { mergeFields: true }); + * ui.registerButton({ id: "text", icon: "...", tooltip: "Add Text" }); + * ``` + */ +export class UIController { + private toolbars = new Map(); + private utilities: UIRegistration[] = []; + private canvasOverlays: CanvasOverlayRegistration[] = []; + private container: HTMLElement | null = null; + private canvas: Canvas | null = null; + private isDisposed = false; + + /** Whether merge fields UI is enabled (Variables panel, autocomplete) */ + readonly mergeFieldsEnabled: boolean; + /** Whether selection handles are enabled for drag/resize/rotate */ + private readonly selectionHandlesEnabled: boolean; + /** Maximum total pixels for resolution picker (undefined = unlimited) */ + private readonly maxPixels?: number; + + // Toolbar mode switching + private clipToolbar: ClipToolbar | null = null; + private toolbarMode: "asset" | "clip" = "asset"; + private currentAssetType: string | null = null; + private currentTrackIndex = -1; + private currentClipIndex = -1; + private onKeyDownBound: (e: KeyboardEvent) => void; + + // Track mode button handlers for cleanup (prevents memory leak) + private modeButtonHandlers: Array<{ btn: Element; handler: () => void }> = []; + + // Button registry + private buttonRegistry: ToolbarButtonConfig[] = []; + private buttonEvents = new EventEmitter(); + private assetToolbar: AssetToolbar | null = null; + private canvasToolbar: CanvasToolbar | null = null; + + // Track auto-calculated positions for sidebar offset computation + private lastAutoAssetX = 0; + private lastAutoAssetY = 0; + private lastAutoCanvasX = 0; + private lastAutoCanvasY = 0; + + // ─── Static Factory Methods ───────────────────────────────────────────────── + + /** + * Create a UIController with all standard toolbars pre-registered. + * This is the recommended way to create a UIController for most use cases. + * + * @param edit - The Edit instance + * @param canvas - The Canvas instance + * @param options - Configuration options + * @returns A fully configured UIController + * + * @example + * ```typescript + * const ui = UIController.create(edit, canvas, { mergeFields: true }); + * ui.registerButton({ id: "text", icon: "...", tooltip: "Add Text" }); + * ui.on("button:text", ({ position }) => { ... }); + * ``` + */ + static create(edit: Edit, canvas: Canvas, options: UIControllerOptions = {}): UIController { + const ui = new UIController(edit, canvas, options); + ui.subscribeToEvents(); + canvas.setUIController(ui); + ui.registerStandardToolbars(); + + // Auto-mount if canvas is already loaded (element exists in DOM) + // This handles the case where UIController is created after canvas.load() + const root = document.querySelector(Canvas.CanvasSelector); + if (root && root.querySelector("canvas")) { + ui.mount(root); // mount() handles deferred positioning via double rAF + } + + return ui; + } + + /** + * Create a minimal UIController without pre-registered toolbars. + * Use this when you only need custom buttons without default toolbars. + * + * @param edit - The Edit instance + * @param canvas - Optional Canvas instance + * @returns A minimal UIController ready for custom configuration + * + * @example + * ```typescript + * const ui = UIController.minimal(edit, canvas); + * ui.registerButton({ id: "text", icon: "...", tooltip: "Add Text" }); + * ``` + */ + static minimal(edit: Edit, canvas?: Canvas): UIController { + const ui = new UIController(edit, canvas ?? null, {}); + ui.subscribeToEvents(); + if (canvas) canvas.setUIController(ui); + return ui; + } + + // ─── Private Constructor ──────────────────────────────────────────────────── + + /** + * Private constructor - use UIController.create() or UIController.minimal() instead. + */ + private constructor(edit: Edit, canvas: Canvas | null, options: UIControllerOptions) { + this.edit = edit; + this.canvas = canvas; + // Auto-detect Shotstack mode unless explicitly overridden + this.mergeFieldsEnabled = options.mergeFields ?? edit instanceof ShotstackEdit; + this.selectionHandlesEnabled = options.selectionHandles ?? true; + this.maxPixels = options.maxPixels; + this.onKeyDownBound = this.onKeyDown.bind(this); + } + + private readonly edit: Edit; + + /** + * Subscribe to edit events. Called by factory methods. + */ + private subscribeToEvents(): void { + this.edit.events.on(EditEvent.ClipSelected, this.onClipSelected); + this.edit.events.on(EditEvent.SelectionCleared, this.onSelectionCleared); + } + + /** + * Register all standard toolbars. Called by create() factory. + */ + private registerStandardToolbars(): void { + // Selection handles + if (this.selectionHandlesEnabled) { + this.registerCanvasOverlay(new SelectionHandles(this.edit)); + } + + // Asset-specific toolbars + this.registerToolbar("text", new TextToolbar(this.edit)); + this.registerToolbar("rich-text", new RichTextToolbar(this.edit, { mergeFields: this.mergeFieldsEnabled })); + this.registerToolbar("svg", new SvgToolbar(this.edit)); + this.registerToolbar(["video", "image"], new MediaToolbar(this.edit, { mergeFields: this.mergeFieldsEnabled })); + this.registerToolbar("audio", new MediaToolbar(this.edit, { mergeFields: this.mergeFieldsEnabled })); + + // AI asset toolbars + this.registerToolbar("text-to-image", new TextToImageToolbar(this.edit)); + this.registerToolbar("image-to-video", new MediaToolbar(this.edit)); + this.registerToolbar("text-to-speech", new TextToSpeechToolbar(this.edit)); + + // Utilities + this.canvasToolbar = new CanvasToolbar(this.edit, { + mergeFields: this.mergeFieldsEnabled, + maxPixels: this.maxPixels + }); + this.registerUtility(this.canvasToolbar); + + // Wire up toolbar callbacks to Edit methods + this.canvasToolbar.onResolutionChange((w, h) => this.edit.setOutputSize(w, h)); + this.canvasToolbar.onFpsChange(fps => this.edit.setOutputFps(fps)); + this.canvasToolbar.onBackgroundChange(color => this.edit.setTimelineBackground(color)); + + this.assetToolbar = new AssetToolbar(this); + this.registerUtility(this.assetToolbar); + + // ClipToolbar - managed separately for mode toggle + this.clipToolbar = new ClipToolbar(this.edit); + } + + // ─── Public API ───────────────────────────────────────────────────────────── + + /** + * Register a toolbar for one or more asset types. + * When a clip of that type is selected, the toolbar will be shown. + * + * @param assetTypes - Single type or array of types (e.g., 'text', ['video', 'image']) + * @param toolbar - The toolbar component implementing UIRegistration + * @returns this (for chaining) + * @internal + */ + registerToolbar(assetTypes: string | string[], toolbar: UIRegistration): this { + const types = Array.isArray(assetTypes) ? assetTypes : [assetTypes]; + for (const type of types) { + this.toolbars.set(type, toolbar); + } + return this; + } + + /** + * Register a utility component (Inspector, custom overlays, etc.). + * Utilities are mounted but not tied to clip selection. + * + * @param component - The utility component implementing UIRegistration + * @returns this (for chaining) + * @internal + */ + registerUtility(component: UIRegistration): this { + this.utilities.push(component); + return this; + } + + /** + * Register a PixiJS-based canvas overlay (SelectionHandles, AlignmentGuides, etc.). + * Overlays render on the canvas and receive update/draw calls each frame. + * + * @param overlay - The overlay component implementing CanvasOverlayRegistration + * @returns this (for chaining) + * @internal + */ + registerCanvasOverlay(overlay: CanvasOverlayRegistration): this { + this.canvasOverlays.push(overlay); + return this; + } + + /** + * Mount all registered UI components to a container. + * Should be called after all registrations are complete. + * + * @param container - The DOM element to mount UI components into + */ + mount(container: HTMLElement): void { + this.container = container; + + // Find the canvas container - toolbars need to mount here for correct positioning + const canvasContainer = document.querySelector(Canvas.CanvasSelector) ?? container; + + // Mount all toolbars to canvas container + const mountedToolbars = new Set(); + for (const toolbar of this.toolbars.values()) { + if (!mountedToolbars.has(toolbar)) { + toolbar.mount(canvasContainer); + mountedToolbars.add(toolbar); + } + } + + // Mount ClipToolbar to canvas container (managed separately for mode toggle) + this.clipToolbar?.mount(canvasContainer); + + // Mount utilities, passing drag reset to sidebar toolbars + const resetPositions = () => this.updateToolbarPositions(); + for (const utility of this.utilities) { + if (utility === this.assetToolbar) { + this.assetToolbar.mount(canvasContainer, { onDragReset: resetPositions }); + } else if (utility === this.canvasToolbar) { + this.canvasToolbar.mount(canvasContainer, { onDragReset: resetPositions }); + } else { + utility.mount(canvasContainer); + } + } + + // Mount canvas overlays to the PixiJS overlay container + if (this.canvas) { + for (const overlay of this.canvasOverlays) { + overlay.mount(this.canvas.overlayContainer, this.canvas.application); + } + } + + // Wire up mode toggle buttons (after DOM is ready) + // Use document.querySelectorAll since toolbars are mounted to canvasContainer, not this.container + requestAnimationFrame(() => { + document.querySelectorAll(".ss-toolbar-mode-btn").forEach(btn => { + const handler = (): void => { + const mode = (btn as HTMLElement).dataset["mode"] as "asset" | "clip"; + if (mode) { + this.setToolbarMode(mode); + } + }; + btn.addEventListener("click", handler); + this.modeButtonHandlers.push({ btn, handler }); + }); + }); + + // Backtick key shortcut for mode toggle + document.addEventListener("keydown", this.onKeyDownBound); + + // Position toolbars after DOM is ready + // Using nested rAF to ensure layout is complete before measuring + requestAnimationFrame(() => { + requestAnimationFrame(() => { + this.updateToolbarPositions(); + }); + }); + } + + /** + * Update all canvas overlays. Called by Canvas each tick. + * @internal + */ + updateOverlays(deltaTime: number, elapsed: number): void { + for (const overlay of this.canvasOverlays) { + overlay.update(deltaTime, elapsed); + overlay.draw(); + } + } + + /** + * Position a sidebar toolbar at autoX/autoY, applying the user's drag offset if present. + */ + private applySidebarPosition( + toolbar: { getDragState(): ToolbarDragState | null; setPosition(x: number, y: number): void } | null, + autoX: number, + autoY: number, + lastAutoX: number, + lastAutoY: number + ): void { + if (!toolbar) return; + const drag = toolbar.getDragState(); + if (drag?.hasUserPosition) { + toolbar.setPosition(autoX + (drag.userX - lastAutoX), autoY + (drag.userY - lastAutoY)); + } else { + toolbar.setPosition(autoX, autoY); + } + } + + /** + * Update toolbar positions to be adjacent to the canvas content. + * Uses position: fixed with screen coordinates for complete independence from parent CSS. + * Called by Canvas after zoom, pan, or resize operations. + * @internal + */ + updateToolbarPositions(): void { + if (!this.canvas) return; + + const canvasRect = this.canvas.application.canvas.getBoundingClientRect(); + const bounds = this.canvas.getContentBounds(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // Calculate raw screen coordinates + const videoLeftScreen = canvasRect.left + bounds.left; + const videoRightScreen = canvasRect.left + bounds.right; + const videoCenterYScreen = canvasRect.top + (bounds.top + bounds.bottom) / 2; + + // Clamp Y to stay within viewport (avoiding top nav and bottom timeline) + const clampedY = Math.max(TOOLBAR_MIN_Y, Math.min(viewportHeight - TOOLBAR_MIN_Y, videoCenterYScreen)); + + // Left toolbar: position to the left of video, clamped to viewport + const leftX = Math.max(TOOLBAR_PADDING, videoLeftScreen - TOOLBAR_WIDTH - TOOLBAR_PADDING); + + // Right toolbar: position to the right of video, clamped to viewport + const maxRightX = viewportWidth - TOOLBAR_WIDTH - TOOLBAR_PADDING; + const rightX = Math.min(maxRightX, videoRightScreen + TOOLBAR_PADDING); + + // Position sidebars with user drag offset applied + this.applySidebarPosition(this.assetToolbar, leftX, clampedY, this.lastAutoAssetX, this.lastAutoAssetY); + this.lastAutoAssetX = leftX; + this.lastAutoAssetY = clampedY; + + this.applySidebarPosition(this.canvasToolbar, rightX, clampedY, this.lastAutoCanvasX, this.lastAutoCanvasY); + this.lastAutoCanvasX = rightX; + this.lastAutoCanvasY = clampedY; + } + + /** + * Dispose all registered UI components and clean up event listeners. + */ + dispose(): void { + if (this.isDisposed) return; + this.isDisposed = true; + + this.edit.events.off(EditEvent.ClipSelected, this.onClipSelected); + this.edit.events.off(EditEvent.SelectionCleared, this.onSelectionCleared); + + // Remove keyboard listener + document.removeEventListener("keydown", this.onKeyDownBound); + + // Remove mode button handlers (prevents memory leak) + for (const { btn, handler } of this.modeButtonHandlers) { + btn.removeEventListener("click", handler); + } + this.modeButtonHandlers = []; + + // Dispose toolbars (avoid double-dispose for shared instances) + const disposedToolbars = new Set(); + for (const toolbar of this.toolbars.values()) { + if (!disposedToolbars.has(toolbar)) { + toolbar.dispose(); + disposedToolbars.add(toolbar); + } + } + + // Dispose ClipToolbar (managed separately) + this.clipToolbar?.dispose(); + + // Dispose utilities + for (const utility of this.utilities) { + utility.dispose(); + } + + // Dispose canvas overlays + for (const overlay of this.canvasOverlays) { + overlay.dispose(); + } + + this.toolbars.clear(); + this.utilities = []; + this.canvasOverlays = []; + this.container = null; + this.canvas = null; + } + + // ─── Button Registry ───────────────────────────────────────────────────────── + + /** + * Register a toolbar button. + * Buttons appear in the left toolbar and trigger events when clicked. + * + * @example + * ```typescript + * ui.registerButton({ + * id: "text", + * icon: `...`, + * tooltip: "Add Text" + * }); + * + * ui.on("button:text", ({ position }) => { + * edit.addTrack(0, { clips: [{ ... }] }); + * }); + * ``` + * + * @param config - Button configuration + * @returns this (for chaining) + */ + registerButton(config: ToolbarButtonConfig): this { + const existing = this.buttonRegistry.findIndex(b => b.id === config.id); + if (existing >= 0) { + this.buttonRegistry[existing] = config; + } else { + this.buttonRegistry.push(config); + } + this.buttonEvents.emit("buttons:changed"); + return this; + } + + /** + * Unregister a toolbar button. + * + * @param id - Button ID to remove + * @returns this (for chaining) + */ + unregisterButton(id: string): this { + const index = this.buttonRegistry.findIndex(b => b.id === id); + if (index >= 0) { + this.buttonRegistry.splice(index, 1); + this.buttonEvents.emit("buttons:changed"); + } + return this; + } + + /** + * Get all registered toolbar buttons. + * @internal + */ + getButtons(): ToolbarButtonConfig[] { + return [...this.buttonRegistry]; + } + + /** + * Subscribe to a button click event. + * + * @example + * ```typescript + * ui.on("button:text", ({ position, selectedClip }) => { + * console.log("Text button clicked at position:", position); + * }); + * ``` + * + * @param event - Event name in format `button:${buttonId}` + * @param handler - Callback function + * @returns Unsubscribe function + */ + on(event: K, handler: (payload: ButtonClickPayload) => void): () => void { + return this.buttonEvents.on(event as keyof UIButtonEventMap, handler); + } + + /** + * Unsubscribe from a button click event. + * @internal + */ + off(event: K, handler: (payload: ButtonClickPayload) => void): void { + this.buttonEvents.off(event as keyof UIButtonEventMap, handler); + } + + /** + * Subscribe to button registry changes. + * Called when buttons are added or removed. + * + * @internal Used by AssetToolbar + */ + onButtonsChanged(handler: () => void): () => void { + return this.buttonEvents.on("buttons:changed", handler); + } + + /** + * Emit a button click event. + * @internal Called by AssetToolbar when a button is clicked. + */ + emitButtonClick(buttonId: string): void { + const payload: ButtonClickPayload = { + position: this.edit.playbackTime, // playbackTime is in seconds + selectedClip: this.edit.getSelectedClipInfo() + }; + this.buttonEvents.emit(`button:${buttonId}`, payload); + } + + /** + * Get the current playback time in seconds. + * @internal Used by AssetToolbar + */ + getPlaybackTime(): number { + return this.edit.playbackTime; // playbackTime is in seconds + } + + /** + * Get the currently selected clip info. + * @internal Used by AssetToolbar + */ + getSelectedClip(): { trackIndex: number; clipIndex: number } | null { + return this.edit.getSelectedClipInfo(); + } + + // ─── Mode Toggle ──────────────────────────────────────────────────────────── + + /** + * Set the toolbar mode and update visibility accordingly. + * @param mode - "asset" shows asset-specific toolbar, "clip" shows ClipToolbar + */ + private setToolbarMode(mode: "asset" | "clip"): void { + this.toolbarMode = mode; + + // Update all toggle UIs + this.container?.querySelectorAll(".ss-toolbar-mode-toggle").forEach(toggle => { + toggle.setAttribute("data-mode", mode); + toggle.querySelectorAll(".ss-toolbar-mode-btn").forEach(btn => { + btn.classList.toggle("active", (btn as HTMLElement).dataset["mode"] === mode); + }); + }); + + this.updateToolbarVisibility(); + } + + /** + * Hide all registered toolbars. + */ + private hideAllToolbars(): void { + const hidden = new Set(); + for (const toolbar of this.toolbars.values()) { + if (!hidden.has(toolbar)) { + toolbar.hide?.(); + hidden.add(toolbar); + } + } + this.clipToolbar?.hide?.(); + } + + /** + * Update toolbar visibility based on current mode and selection. + */ + private updateToolbarVisibility(): void { + this.hideAllToolbars(); + + // No selection = nothing to show + if (this.currentTrackIndex < 0 || this.currentClipIndex < 0) return; + + if (this.toolbarMode === "clip") { + this.clipToolbar?.show?.(this.currentTrackIndex, this.currentClipIndex); + } else if (this.currentAssetType) { + const toolbar = this.toolbars.get(this.currentAssetType); + toolbar?.show?.(this.currentTrackIndex, this.currentClipIndex); + } + } + + /** + * Check if any toolbar is currently visible (clip is selected). + */ + private hasVisibleToolbar(): boolean { + return this.currentTrackIndex >= 0 && this.currentClipIndex >= 0; + } + + /** + * Check if an input element is focused (to avoid intercepting typing). + */ + private isInputFocused(): boolean { + const el = document.activeElement; + return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el as HTMLElement)?.isContentEditable; + } + + /** + * Handle backtick (`) key to toggle between asset and clip mode. + * Backtick is the video editor convention for mode/view toggling (Premiere, After Effects). + */ + private onKeyDown(e: KeyboardEvent): void { + const isBacktick = e.key === "`" || e.code === "Backquote"; + if (isBacktick && this.hasVisibleToolbar() && !this.isInputFocused()) { + e.preventDefault(); + this.setToolbarMode(this.toolbarMode === "asset" ? "clip" : "asset"); + } + } + + // ─── Event Handlers ───────────────────────────────────────────────────────── + + private onClipSelected = ({ trackIndex, clipIndex }: { trackIndex: number; clipIndex: number }): void => { + const clip = this.edit.getResolvedClip(trackIndex, clipIndex); + const assetType = clip?.asset?.type; + + // Track current selection for mode toggle + this.currentAssetType = assetType ?? null; + this.currentTrackIndex = trackIndex; + this.currentClipIndex = clipIndex; + + // Update visibility based on mode + this.updateToolbarVisibility(); + }; + + private onSelectionCleared = (): void => { + // Reset selection state + this.currentAssetType = null; + this.currentTrackIndex = -1; + this.currentClipIndex = -1; + + // Hide all toolbars + this.hideAllToolbars(); + }; +} diff --git a/src/core/ui/virtual-font-list.ts b/src/core/ui/virtual-font-list.ts new file mode 100644 index 00000000..a7dc07eb --- /dev/null +++ b/src/core/ui/virtual-font-list.ts @@ -0,0 +1,285 @@ +/** + * Virtual Font List + * + * Efficiently renders a large list of fonts using virtual scrolling. + * Only renders visible items plus a small buffer, allowing smooth + * scrolling of 1500+ fonts without DOM bloat. + */ + +import { GOOGLE_FONTS, type FontInfo, type GoogleFontCategory } from "../fonts/google-fonts"; + +import { getFontPreviewLoader } from "./font-preview-loader"; + +/** Height of each font item in pixels */ +const ITEM_HEIGHT = 40; + +/** Number of items to render above/below visible area */ +const BUFFER_COUNT = 5; + +export interface VirtualFontListOptions { + /** Container element to render into */ + container: HTMLElement; + /** Currently selected font filename */ + selectedFilename?: string; + /** Callback when a font is selected */ + onSelect?: (font: FontInfo) => void; +} + +/** + * Virtual scrolling list for fonts. + * Renders only visible items for optimal performance with large font lists. + */ +export class VirtualFontList { + private container: HTMLElement; + private viewport: HTMLElement; + private content: HTMLElement; + private items = new Map(); + private filteredFonts: FontInfo[] = [...GOOGLE_FONTS]; + private selectedFilename?: string; + private onSelect?: (font: FontInfo) => void; + private scrollTop = 0; + private viewportHeight = 0; + private resizeObserver: ResizeObserver; + private fontLoader = getFontPreviewLoader(); + + // Event delegation handler for item clicks + private handleContentClick = (e: MouseEvent): void => { + const target = e.target as HTMLElement; + const item = target.closest(".ss-font-item") as HTMLElement | null; + if (!item || !item.dataset["index"]) return; + + const index = parseInt(item.dataset["index"], 10); + const font = this.filteredFonts[index]; + if (!font) return; + + this.selectedFilename = font.filename; + this.render(); + this.onSelect?.(font); + }; + + constructor(options: VirtualFontListOptions) { + this.container = options.container; + this.selectedFilename = options.selectedFilename; + this.onSelect = options.onSelect; + + // Create viewport (scrollable container) + this.viewport = document.createElement("div"); + this.viewport.className = "ss-font-list-viewport"; + this.viewport.addEventListener("scroll", this.handleScroll); + + // Create content (sized to full list height) + this.content = document.createElement("div"); + this.content.className = "ss-font-list-content"; + this.content.addEventListener("click", this.handleContentClick); + this.viewport.appendChild(this.content); + + this.container.appendChild(this.viewport); + + // Set the viewport as the root for the font loader's IntersectionObserver + this.fontLoader.setRoot(this.viewport); + + // Observe container size changes + this.resizeObserver = new ResizeObserver(() => { + this.viewportHeight = this.viewport.clientHeight; + this.render(); + }); + this.resizeObserver.observe(this.viewport); + + // Initial render + requestAnimationFrame(() => { + this.viewportHeight = this.viewport.clientHeight; + this.render(); + }); + } + + /** + * Filter fonts by search query and/or category. + */ + setFilter(query?: string, category?: GoogleFontCategory): void { + const lowerQuery = query?.toLowerCase().trim() || ""; + + this.filteredFonts = GOOGLE_FONTS.filter(font => { + const matchesQuery = !lowerQuery || font.displayName.toLowerCase().includes(lowerQuery); + const matchesCategory = !category || font.category === category; + return matchesQuery && matchesCategory; + }); + + // Reset scroll position when filter changes + this.viewport.scrollTop = 0; + this.scrollTop = 0; + this.render(); + } + + /** + * Set the currently selected font. + */ + setSelected(filename?: string): void { + this.selectedFilename = filename; + this.render(); + } + + /** + * Scroll to make a font visible. + */ + scrollToFont(filename: string): void { + const index = this.filteredFonts.findIndex(f => f.filename === filename); + if (index >= 0) { + const targetScroll = index * ITEM_HEIGHT - this.viewportHeight / 2 + ITEM_HEIGHT / 2; + this.viewport.scrollTop = Math.max(0, targetScroll); + } + } + + /** + * Get the number of fonts in the current filtered view. + */ + get fontCount(): number { + return this.filteredFonts.length; + } + + /** + * Handle scroll events. + */ + private handleScroll = (): void => { + this.scrollTop = this.viewport.scrollTop; + this.render(); + }; + + /** + * Render visible items. + */ + private render(): void { + const totalHeight = this.filteredFonts.length * ITEM_HEIGHT; + this.content.style.height = `${totalHeight}px`; + + // Calculate visible range + const visibleCount = Math.ceil(this.viewportHeight / ITEM_HEIGHT); + const startIndex = Math.max(0, Math.floor(this.scrollTop / ITEM_HEIGHT) - BUFFER_COUNT); + const endIndex = Math.min(this.filteredFonts.length, startIndex + visibleCount + BUFFER_COUNT * 2); + + // Track which items are still needed + const neededIndices = new Set(); + for (let i = startIndex; i < endIndex; i += 1) { + neededIndices.add(i); + } + + // Remove items no longer in view + for (const [index, element] of this.items) { + if (!neededIndices.has(index)) { + this.fontLoader.unobserve(element); + element.remove(); + this.items.delete(index); + } + } + + // Add/update items in view + for (let i = startIndex; i < endIndex; i += 1) { + const font = this.filteredFonts[i]; + if (!font) { + // Skip missing fonts (shouldn't happen, but guard against out-of-bounds) + // eslint-disable-next-line no-continue + continue; + } + + let item = this.items.get(i); + + if (!item) { + // Create new item + item = this.createFontItem(font, i); + this.content.appendChild(item); + this.items.set(i, item); + + // Start observing for lazy font loading + this.fontLoader.observe(item); + + // Update when font loads + this.fontLoader.onLoad(font.displayName, () => { + if (item) { + item.classList.add("ss-font-item--loaded"); + } + }); + } else { + // Update position if needed + const expectedTop = i * ITEM_HEIGHT; + if (item.style.transform !== `translateY(${expectedTop}px)`) { + item.style.transform = `translateY(${expectedTop}px)`; + } + + // Update selected state + const isSelected = font.filename === this.selectedFilename; + item.classList.toggle("ss-font-item--selected", isSelected); + } + } + } + + /** + * Create a font item element. + */ + private createFontItem(font: FontInfo, index: number): HTMLElement { + const item = document.createElement("div"); + item.className = "ss-font-item"; + item.dataset["fontFamily"] = font.displayName; + item.dataset["index"] = String(index); + item.style.transform = `translateY(${index * ITEM_HEIGHT}px)`; + + // Add loaded class if font is already loaded + if (this.fontLoader.isLoaded(font.displayName)) { + item.classList.add("ss-font-item--loaded"); + } + + // Add selected class + if (font.filename === this.selectedFilename) { + item.classList.add("ss-font-item--selected"); + } + + // Font name preview + const name = document.createElement("span"); + name.className = "ss-font-item-name"; + name.textContent = font.displayName; + name.style.fontFamily = `"${font.displayName}", system-ui, sans-serif`; + item.appendChild(name); + + // Category badge (subtle) + const category = document.createElement("span"); + category.className = "ss-font-item-category"; + category.textContent = this.formatCategory(font.category); + item.appendChild(category); + + return item; + } + + /** + * Format category for display. + */ + private formatCategory(category: string): string { + switch (category) { + case "sans-serif": + return "Sans"; + case "serif": + return "Serif"; + case "display": + return "Display"; + case "handwriting": + return "Script"; + case "monospace": + return "Mono"; + default: + return category; + } + } + + /** + * Clean up resources. + */ + destroy(): void { + this.content.removeEventListener("click", this.handleContentClick); + this.viewport.removeEventListener("scroll", this.handleScroll); + this.resizeObserver.disconnect(); + + for (const [, element] of this.items) { + this.fontLoader.unobserve(element); + } + + this.items.clear(); + this.viewport.remove(); + } +} diff --git a/src/core/webgl-support.ts b/src/core/webgl-support.ts new file mode 100644 index 00000000..a6d1a9d4 --- /dev/null +++ b/src/core/webgl-support.ts @@ -0,0 +1,30 @@ +/** + * WebGL Support Detection + * + * Checks if the browser supports WebGL, which is required for PixiJS rendering. + * Should be called before attempting to initialize the canvas. + */ + +export interface WebGLSupportResult { + supported: boolean; + reason?: "webgl-unavailable" | "webgl-error"; +} + +/** + * Check if WebGL is available in the current browser. + * Tests both WebGL2 and WebGL1 for maximum compatibility. + */ +export function checkWebGLSupport(): WebGLSupportResult { + try { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl2") || canvas.getContext("webgl"); + + if (!gl) { + return { supported: false, reason: "webgl-unavailable" }; + } + + return { supported: true }; + } catch { + return { supported: false, reason: "webgl-error" }; + } +} diff --git a/src/index.ts b/src/index.ts index 870cbb39..6922a95c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,13 @@ import pkg from "../package.json"; -export { Edit } from "@core/edit"; +export { Edit } from "@core/edit-session"; export { Canvas } from "@canvas/shotstack-canvas"; export { Controls } from "@core/inputs/controls"; export { VideoExporter } from "@core/export"; -export { Timeline } from "./components/timeline/timeline"; +export { Timeline } from "@timeline/index"; +export { UIController } from "@core/ui/ui-controller"; -// Export theme types for library users -export type { TimelineTheme, TimelineThemeInput } from "./core/theme/theme.types"; - -// Export Zod schemas for library users -export * from "./core/schemas"; +export type { UIControllerOptions, ToolbarButtonConfig } from "@core/ui/ui-controller"; +export type { EditConfig } from "@core/schemas"; export const VERSION = pkg.version; diff --git a/src/internal.ts b/src/internal.ts new file mode 100644 index 00000000..735a5291 --- /dev/null +++ b/src/internal.ts @@ -0,0 +1,13 @@ +/** + * Shotstack-only exports + * + * These are NOT part of the public SDK API. + * External consumers should use the main export: + * import { Edit } from '@shotstack/studio-sdk'; + */ +// Re-export Edit to ensure type identity with main export +export { Edit } from "@core/edit-session"; +export { ShotstackEdit } from "@core/shotstack-edit"; + +// Re-export MergeField types for ShotstackEdit users +export type { MergeField, MergeFieldService } from "@core/merge"; diff --git a/src/main.ts b/src/main.ts index a47819ef..61483299 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,52 +1,62 @@ -import { Timeline } from "./components/timeline"; -import theme from "./themes/minimal.json"; +import { type Edit as EditSchema } from "@schemas"; +import { Timeline } from "@timeline/index"; -import { Edit, Canvas, Controls, VideoExporter } from "./index"; +import template from "./templates/test.json"; + +import { Edit, Canvas, Controls, UIController } from "./index"; /** - * This is a simple example that implements the README quick start guide - * Run with `npm run dev` to see it in action + * Simple example implementing the README quick start guide. + * Run with `npm run dev` to see it in action. */ async function main() { try { - // 1. Load the hello.json template from local file - const templateModule = await import("./templates/hello.json"); - const template = templateModule.default as any; + // 1. Create core components + const edit = new Edit(template as EditSchema); + const canvas = new Canvas(edit); + const ui = UIController.create(edit, canvas); - // 2. Initialize the edit with dimensions and background color - const edit = new Edit(template.output.size, template.timeline.background); + // 2. Load canvas and edit + 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 + // 3. Register toolbar buttons + ui.registerButton({ + id: "text", + icon: ``, + tooltip: "Add Text" + }); - // 4. Load the template - await edit.loadEdit(template); + // 4. Handle button clicks + 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 with size and theme - const timeline = new Timeline( - edit, - { - width: template.output.size.width, - height: 300 - }, - { - theme // Uses imported theme from JSON - } - ); - await timeline.load(); // Renders to [data-shotstack-timeline] element + // 5. Initialize the Timeline + 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(); - // 7. Enable video export (Cmd/Ctrl+E) - // eslint-disable-next-line no-new - new VideoExporter(edit, canvas); - - // 8. Add event handlers - + // 7. Add event handlers edit.events.on("clip:selected", data => { console.log("Clip selected:", data); }); @@ -54,16 +64,6 @@ async function main() { edit.events.on("clip:updated", data => { console.log("Clip updated:", data); }); - - // Additional helpful information for the demo - console.log("Demo loaded successfully! Try the following keyboard controls:"); - console.log("- Space: Play/Pause"); - console.log("- J: Stop"); - console.log("- K: Pause"); - console.log("- L: Play"); - console.log("- Left/Right Arrow: Seek"); - console.log("- Shift+Left/Right: Seek faster"); - console.log("- Comma/Period: Step frame by frame"); } catch (error) { console.error("Error in Shotstack Studio demo:", error); } diff --git a/src/schema.ts b/src/schema.ts deleted file mode 100644 index 692a3fa5..00000000 --- a/src/schema.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Schema-only exports for validation without WASM initialization - * Use this import path when you only need schema validation: - * import { AssetSchema, ClipSchema } from '@shotstack/shotstack-studio/schema' - */ - -// Re-export everything from the schemas barrel export -export * from "@schemas/index"; diff --git a/src/styles/index.css b/src/styles/index.css new file mode 100644 index 00000000..e1925ef4 --- /dev/null +++ b/src/styles/index.css @@ -0,0 +1,25 @@ +/** + * Shotstack Studio SDK Styles + * + * All component styles are imported here and bundled together. + * The order matters - shared styles first, then components. + */ + +/* UI Component Styles */ +@import "./ui/toolbar-drag.css"; +@import "./ui/scrollable-list.css"; +@import "./ui/rich-text-toolbar.css"; +@import "./ui/svg-toolbar.css"; +@import "./ui/media-toolbar.css"; +@import "./ui/text-to-image-toolbar.css"; +@import "./ui/text-to-speech-toolbar.css"; +@import "./ui/clip-toolbar.css"; +@import "./ui/canvas-toolbar.css"; +@import "./ui/asset-toolbar.css"; +@import "./ui/font-color-picker.css"; +@import "./ui/background-color-picker.css"; +@import "./ui/font-picker.css"; +@import "./ui/webgl-error.css"; + +/* Timeline Styles */ +@import "./timeline/timeline.css"; diff --git a/src/styles/inject.ts b/src/styles/inject.ts new file mode 100644 index 00000000..6d4943fe --- /dev/null +++ b/src/styles/inject.ts @@ -0,0 +1,38 @@ +// @ts-ignore Vite's ?inline query is handled at build time, not by tsc +import styles from "./index.css?inline"; + +let injected = false; + +/** + * Injects the Shotstack Studio SDK styles into the document head. + * This function is idempotent - calling it multiple times has no effect. + * + * The styles are bundled inline via Vite's ?inline import and injected + * as a single