diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml index 58198586..386b42bc 100644 --- a/.github/workflows/bun-ci.yml +++ b/.github/workflows/bun-ci.yml @@ -31,11 +31,6 @@ jobs: MARBLE_WORKSPACE_KEY: "placeholder" FREESOUND_CLIENT_ID: "placeholder" FREESOUND_API_KEY: "placeholder" - CLOUDFLARE_ACCOUNT_ID: "placeholder" - R2_ACCESS_KEY_ID: "placeholder" - R2_SECRET_ACCESS_KEY: "placeholder" - R2_BUCKET_NAME: "placeholder" - MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com" steps: - name: Checkout repository diff --git a/AGENTS.md b/AGENTS.md index aef17c2e..cd59a85c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,19 @@ # Agents.md -## Apps +## Architecture -- Web -- Desktop +An ongoing migration is moving all business logic into `rust/`. Each app under `apps/` is a UI shell — it owns rendering, interaction, and platform-specific concerns, but never owns logic. The UI framework for any given app is a replaceable detail. -## Rust +### `rust/` -Shared code between apps live in `rust/`, not in `packages/` +The single source of truth for all non-UI code. Everything platform-agnostic belongs here: no components, no hooks, no framework imports. + +### `apps/` + +Each app is a frontend that calls into Rust. Logic is never duplicated between apps — only UI is, because each platform may use an entirely different framework and language to build it. + +- `web/` — Next.js +- `desktop/` — GPUI ## Web diff --git a/Cargo.lock b/Cargo.lock index 8a34d0ea..b673fb3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1427,6 +1427,16 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "effects" +version = "0.1.0" +dependencies = [ + "bytemuck", + "gpu", + "thiserror 2.0.18", + "wgpu", +] + [[package]] name = "either" version = "1.15.0" @@ -3217,6 +3227,15 @@ dependencies = [ "libc", ] +[[package]] +name = "masks" +version = "0.1.0" +dependencies = [ + "bytemuck", + "gpu", + "wgpu", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3777,10 +3796,14 @@ dependencies = [ [[package]] name = "opencut-wasm" -version = "0.1.2" +version = "0.2.3" dependencies = [ + "bridge", + "effects", "gpu", "js-sys", + "masks", + "num-traits", "serde", "serde-wasm-bindgen", "time", @@ -5581,6 +5604,7 @@ name = "time" version = "0.1.0" dependencies = [ "bridge", + "num-traits", "serde", "tsify-next", "wasm-bindgen", diff --git a/Cargo.toml b/Cargo.toml index 2b63476b..6c80c54f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,5 +4,8 @@ members = [ "apps/desktop", "rust/crates/time", "rust/crates/bridge", - "rust/wasm", "rust/crates/gpu", + "rust/crates/effects", + "rust/crates/gpu", + "rust/crates/masks", + "rust/wasm", ] diff --git a/apps/web/.env.example b/apps/web/.env.example index 48d48b1d..b15779a5 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -19,10 +19,3 @@ MARBLE_WORKSPACE_KEY=your_workspace_key_here FREESOUND_CLIENT_ID=your_client_id_here FREESOUND_API_KEY=your_api_key_here - -CLOUDFLARE_ACCOUNT_ID=your_account_id_here -R2_ACCESS_KEY_ID=your_access_key_here -R2_SECRET_ACCESS_KEY=your_secret_key_here -R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket - -MODAL_TRANSCRIPTION_URL=your_modal_url_here \ No newline at end of file diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 34b63e3c..a08d9b68 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -29,11 +29,6 @@ ENV UPSTASH_REDIS_REST_TOKEN="example_token" ENV NEXT_PUBLIC_SITE_URL="http://localhost:3000" ENV NEXT_PUBLIC_MARBLE_API_URL=$NEXT_PUBLIC_MARBLE_API_URL ENV MARBLE_WORKSPACE_KEY=$MARBLE_WORKSPACE_KEY -ENV CLOUDFLARE_ACCOUNT_ID="build-placeholder" -ENV R2_ACCESS_KEY_ID="build-placeholder" -ENV R2_SECRET_ACCESS_KEY="build-placeholder" -ENV R2_BUCKET_NAME="build-placeholder" -ENV MODAL_TRANSCRIPTION_URL="http://localhost:0" ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID ENV FREESOUND_API_KEY=$FREESOUND_API_KEY diff --git a/apps/web/content/changelog/0.3.0.md b/apps/web/content/changelog/0.3.0.md index 42c7c9a7..b9338e80 100644 --- a/apps/web/content/changelog/0.3.0.md +++ b/apps/web/content/changelog/0.3.0.md @@ -79,5 +79,9 @@ changes: text: "Auto-generated captions were sometimes inaccurate. The underlying issue has been fixed." - type: new text: "You can now import transcript files to generate captions instead of running auto-transcription." + - type: new + text: "Graph editor for keyframe curves. Shape the easing between keyframes by dragging bezier handles." + - type: fixed + text: "Text element handles no longer shift position as you type." --- diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index 494fd35d..a4b933c6 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -2,7 +2,6 @@ import type { Config } from "drizzle-kit"; import * as dotenv from "dotenv"; import { webEnv } from "@/lib/env/web"; -// Load the right env file based on environment if (webEnv.NODE_ENV === "production") { dotenv.config({ path: ".env.production" }); } else { diff --git a/apps/web/package.json b/apps/web/package.json index f765216c..7ee5fe1a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -52,7 +52,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.1.2", + "opencut-wasm": "^0.2.3", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx index 8ee9d504..93d6e189 100644 --- a/apps/web/src/app/projects/page.tsx +++ b/apps/web/src/app/projects/page.tsx @@ -21,7 +21,7 @@ import type { TProjectSortKey, TProjectSortOption, } from "@/lib/project/types"; -import { formatTimeCode } from "opencut-wasm"; +import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm"; import { formatDate } from "@/utils/date"; import { HugeiconsIcon } from "@hugeicons/react"; import { @@ -76,8 +76,9 @@ const formatProjectDuration = ({ return null; } - const format = duration >= 3600 ? "HH:MM:SS" : "MM:SS"; - return formatTimeCode({ timeInSeconds: duration, format }) ?? ""; + const durationSeconds = mediaTimeToSeconds({ time: duration }); + const format = durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS"; + return formatTimecode({ time: duration, format }) ?? ""; }; const VIEW_MODE_OPTIONS = [ diff --git a/apps/web/src/components/editable-timecode.tsx b/apps/web/src/components/editable-timecode.tsx index 6a323a1c..276cb490 100644 --- a/apps/web/src/components/editable-timecode.tsx +++ b/apps/web/src/components/editable-timecode.tsx @@ -1,14 +1,14 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { formatTimeCode, parseTimeCode, type TimeCodeFormat } from "opencut-wasm"; +import { formatTimecode, parseTimecode, snappedSeekTime, type FrameRate, type TimeCodeFormat } from "opencut-wasm"; import { cn } from "@/utils/ui"; interface EditableTimecodeProps { time: number; duration: number; format?: TimeCodeFormat; - fps: number; + fps: FrameRate; onTimeChange?: ({ time }: { time: number }) => void; className?: string; disabled?: boolean; @@ -28,7 +28,7 @@ export function EditableTimecode({ const [hasError, setHasError] = useState(false); const inputRef = useRef(null); const enterPressedRef = useRef(false); - const formattedTime = formatTimeCode({ timeInSeconds: time, format, fps }) ?? ""; + const formattedTime = formatTimecode({ time, format, rate: fps }) ?? ""; const startEditing = () => { if (disabled) return; @@ -46,17 +46,16 @@ export function EditableTimecode({ }; const applyEdit = () => { - const parsedTime = parseTimeCode({ timeCode: inputValue, format, fps }); + const parsedTime = parseTimecode({ timeCode: inputValue, format, rate: fps }); if (parsedTime == null) { setHasError(true); return; } - const clampedTime = Math.max( - 0, - duration ? Math.min(duration, parsedTime) : parsedTime, - ); + const clampedTime = duration + ? (snappedSeekTime({ time: parsedTime, duration, rate: fps }) ?? parsedTime) + : parsedTime; onTimeChange?.({ time: clampedTime }); setIsEditing(false); diff --git a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx index c31c8e96..ed4eb990 100644 --- a/apps/web/src/components/editor/dialogs/project-info-dialog.tsx +++ b/apps/web/src/components/editor/dialogs/project-info-dialog.tsx @@ -8,7 +8,7 @@ import { } from "@/components/ui/dialog"; import type { TProjectMetadata } from "@/lib/project/types"; import { formatDate } from "@/utils/date"; -import { formatTimeCode } from "opencut-wasm"; +import { formatTimecode, mediaTimeToSeconds } from "opencut-wasm"; import { Button } from "@/components/ui/button"; function InfoRow({ @@ -35,9 +35,10 @@ export function ProjectInfoDialog({ onOpenChange: (open: boolean) => void; project: TProjectMetadata; }) { + const durationSeconds = mediaTimeToSeconds({ time: project.duration }); const durationFormatted = project.duration > 0 - ? (formatTimeCode({ timeInSeconds: project.duration, format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "") + ? (formatTimecode({ time: project.duration, format: durationSeconds >= 3600 ? "HH:MM:SS" : "MM:SS" }) ?? "") : "0:00"; return ( diff --git a/apps/web/src/components/editor/panels/assets/views/assets.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx index 031d2e6a..85888dce 100644 --- a/apps/web/src/components/editor/panels/assets/views/assets.tsx +++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx @@ -25,7 +25,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; import { useEditor } from "@/hooks/use-editor"; import { useFileUpload } from "@/hooks/use-file-upload"; import { invokeAction } from "@/lib/actions"; @@ -257,7 +257,7 @@ function MediaAssetDraggable({ startTime: number; }) => { const duration = - asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS; + asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: asset.id, mediaType: asset.type, diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx index f35e0e81..756cf1bd 100644 --- a/apps/web/src/components/editor/panels/assets/views/captions.tsx +++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx @@ -63,7 +63,7 @@ export function Captions() { setProcessingStep("Extracting audio..."); const audioBlob = await extractTimelineAudio({ - tracks: editor.timeline.getTracks(), + tracks: editor.scenes.getActiveScene().tracks, mediaAssets: editor.media.getAssets(), totalDuration: editor.timeline.getTotalDuration(), }); @@ -274,7 +274,7 @@ export function Captions() { )} {warnings.length > 0 && (
-
-
-
-
- {Object.entries(STICKER_CATEGORIES).map(([key, label]) => { - const isActive = key === selectedCategory; - return ( - - ); - })} -
-
+ { + setSelectedCategory({ category: value as StickerCategory }); + }} + variant="underline" + className="mt-2 flex min-h-0 flex-1 flex-col" + > + + {Object.entries(STICKER_CATEGORIES).map(([key, label]) => ( + + {label} + + ))} +
-
+ ); } diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 8cfe7f49..369d854c 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -7,6 +7,7 @@ import { useRafLoop } from "@/hooks/use-raf-loop"; import { useContainerSize } from "@/hooks/use-container-size"; import { useFullscreen } from "@/hooks/use-fullscreen"; import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; @@ -72,7 +73,9 @@ export function PreviewPanel() { function RenderTreeController() { const editor = useEditor(); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); const mediaAssets = useEditor((e) => e.media.getAssets()); const activeProject = useEditor((e) => e.project.getActive()); @@ -129,12 +132,16 @@ function PreviewCanvas({ height: nativeHeight, fps: activeProject.settings.fps, }); - }, [nativeWidth, nativeHeight, activeProject.settings.fps]); + }, [nativeWidth, nativeHeight, activeProject.settings.fps.numerator, activeProject.settings.fps.denominator]); const render = useCallback(() => { if (canvasRef.current && renderTree && !renderingRef.current) { - const renderTime = editor.playback.getCurrentTime(); - const frame = Math.floor(renderTime * renderer.fps); + const renderTime = Math.min( + editor.playback.getCurrentTime(), + editor.timeline.getLastFrameTime(), + ); + const ticksPerFrame = Math.round(TICKS_PER_SECOND * renderer.fps.denominator / renderer.fps.numerator); + const frame = Math.floor(renderTime / ticksPerFrame); if ( frame !== lastFrameRef.current || diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx index 0be1ec46..555cde17 100644 --- a/apps/web/src/components/editor/panels/preview/toolbar.tsx +++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useEditor } from "@/hooks/use-editor"; -import { formatTimeCode } from "opencut-wasm"; +import { formatTimecode } from "opencut-wasm"; import { invokeAction } from "@/lib/actions"; import { EditableTimecode } from "@/components/editable-timecode"; import { Button } from "@/components/ui/button"; @@ -93,7 +93,7 @@ function TimecodeDisplay() { /> / - {formatTimeCode({ timeInSeconds: totalDuration, format: "HH:MM:SS:FF", fps })} + {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })} ); diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts index 95f338e1..27300784 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-element-playhead.ts @@ -1,24 +1,22 @@ -import { useEditor } from "@/hooks/use-editor"; -import { getElementLocalTime } from "@/lib/animation"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; - -export function useElementPlayhead({ - startTime, - duration, -}: { - startTime: number; - duration: number; -}) { - const editor = useEditor(); - const playheadTime = editor.playback.getCurrentTime(); - const localTime = getElementLocalTime({ - timelineTime: playheadTime, - elementStartTime: startTime, - elementDuration: duration, - }); - const isPlayheadWithinElementRange = - playheadTime >= startTime - TIME_EPSILON_SECONDS && - playheadTime <= startTime + duration + TIME_EPSILON_SECONDS; - - return { localTime, isPlayheadWithinElementRange }; -} +import { useEditor } from "@/hooks/use-editor"; +import { getElementLocalTime } from "@/lib/animation"; + +export function useElementPlayhead({ + startTime, + duration, +}: { + startTime: number; + duration: number; +}) { + const playheadTime = useEditor((editor) => editor.playback.getCurrentTime()); + const localTime = getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: startTime, + elementDuration: duration, + }); + const isPlayheadWithinElementRange = + playheadTime >= startTime && + playheadTime <= startTime + duration; + + return { localTime, isPlayheadWithinElementRange }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts index ddd3d2e4..872d55c0 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-number-property.ts @@ -21,6 +21,7 @@ export function useKeyframedNumberProperty({ valueAtPlayhead, step, buildBaseUpdates, + buildAdditionalKeyframes, }: { trackId: string; elementId: string; @@ -33,6 +34,9 @@ export function useKeyframedNumberProperty({ valueAtPlayhead: number; step?: number; buildBaseUpdates: ({ value }: { value: number }) => Partial; + buildAdditionalKeyframes?: ({ + value, + }: { value: number }) => Array<{ propertyPath: AnimationPropertyPath; value: number }>; }) { const editor = useEditor(); const snapValue = (value: number) => @@ -50,19 +54,26 @@ export function useKeyframedNumberProperty({ const previewValue = ({ value }: { value: number }) => { const nextValue = snapValue(value); if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; + const updatedAnimations = [ + { propertyPath, value: nextValue }, + ...additionalKeyframes, + ].reduce( + (currentAnimations, keyframe) => + upsertElementKeyframe({ + animations: currentAnimations, + propertyPath: keyframe.propertyPath, + time: localTime, + value: keyframe.value, + }), + animations, + ); editor.timeline.previewElements({ updates: [ { trackId, elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: nextValue, - }), - }, + updates: { animations: updatedAnimations }, }, ], }); @@ -125,15 +136,17 @@ export function useKeyframedNumberProperty({ const commitValue = ({ value }: { value: number }) => { const nextValue = snapValue(value); if (shouldUseAnimatedChannel) { + const additionalKeyframes = buildAdditionalKeyframes?.({ value: nextValue }) ?? []; editor.timeline.upsertKeyframes({ keyframes: [ - { + { trackId, elementId, propertyPath, time: localTime, value: nextValue }, + ...additionalKeyframes.map((keyframe) => ({ trackId, elementId, - propertyPath, + propertyPath: keyframe.propertyPath, time: localTime, - value: nextValue, - }, + value: keyframe.value, + })), ], }); return; @@ -144,7 +157,7 @@ export function useKeyframedNumberProperty({ { trackId, elementId, - updates: buildBaseUpdates({ value: nextValue }), + patch: buildBaseUpdates({ value: nextValue }), }, ], }); diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts index 92b5d634..29b290d9 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-param-property.ts @@ -1,148 +1,150 @@ -"use client"; - -import { useEditor } from "@/hooks/use-editor"; -import { - buildGraphicParamPath, - getKeyframeAtTime, - getParamDefaultInterpolation, - getParamValueKind, - hasKeyframesForPath, - upsertPathKeyframe, -} from "@/lib/animation"; -import type { - ElementAnimations, -} from "@/lib/animation/types"; -import type { ParamDefinition } from "@/lib/params"; -import type { TimelineElement } from "@/lib/timeline"; - -export interface KeyframedParamPropertyResult { - hasAnimatedKeyframes: boolean; - isKeyframedAtTime: boolean; - keyframeIdAtTime: string | null; - onPreview: (value: number | string | boolean) => void; - onCommit: () => void; - toggleKeyframe: () => void; -} - -export function useKeyframedParamProperty({ - param, - trackId, - elementId, - animations, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - buildBaseUpdates, -}: { - param: ParamDefinition; - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: number | string | boolean; - buildBaseUpdates: ({ - value, - }: { - value: number | string | boolean; - }) => Partial; -}): KeyframedParamPropertyResult { - const editor = useEditor(); - const propertyPath = buildGraphicParamPath({ paramKey: param.key }); - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ - animations, - propertyPath, - time: localTime, - }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertPathKeyframe({ - animations, - propertyPath, - time: localTime, - value, - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ - param, - }), - numericRange: - param.type === "number" - ? { min: param.min, max: param.max, step: param.step } - : undefined, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: buildBaseUpdates({ value }), - }, - ], - }); - }; - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) { - return; - } - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - keyframeId: keyframeIdAtTime, - }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - return { - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - onPreview: previewValue, - onCommit: () => editor.timeline.commitPreview(), - toggleKeyframe, - }; -} +"use client"; + +import { useEditor } from "@/hooks/use-editor"; +import { + buildGraphicParamPath, + coerceAnimationValueForParam, + getKeyframeAtTime, + getParamDefaultInterpolation, + getParamValueKind, + hasKeyframesForPath, + upsertPathKeyframe, +} from "@/lib/animation"; +import type { + ElementAnimations, +} from "@/lib/animation/types"; +import type { ParamDefinition } from "@/lib/params"; +import type { TimelineElement } from "@/lib/timeline"; + +export interface KeyframedParamPropertyResult { + hasAnimatedKeyframes: boolean; + isKeyframedAtTime: boolean; + keyframeIdAtTime: string | null; + onPreview: (value: number | string | boolean) => void; + onCommit: () => void; + toggleKeyframe: () => void; +} + +export function useKeyframedParamProperty({ + param, + trackId, + elementId, + animations, + localTime, + isPlayheadWithinElementRange, + resolvedValue, + buildBaseUpdates, +}: { + param: ParamDefinition; + trackId: string; + elementId: string; + animations: ElementAnimations | undefined; + localTime: number; + isPlayheadWithinElementRange: boolean; + resolvedValue: number | string | boolean; + buildBaseUpdates: ({ + value, + }: { + value: number | string | boolean; + }) => Partial; +}): KeyframedParamPropertyResult { + const editor = useEditor(); + const propertyPath = buildGraphicParamPath({ paramKey: param.key }); + const hasAnimatedKeyframes = hasKeyframesForPath({ + animations, + propertyPath, + }); + const keyframeAtTime = isPlayheadWithinElementRange + ? getKeyframeAtTime({ + animations, + propertyPath, + time: localTime, + }) + : null; + const keyframeIdAtTime = keyframeAtTime?.id ?? null; + const isKeyframedAtTime = keyframeAtTime !== null; + const shouldUseAnimatedChannel = + hasAnimatedKeyframes && isPlayheadWithinElementRange; + + const previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => { + if (shouldUseAnimatedChannel) { + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + animations: upsertPathKeyframe({ + animations, + propertyPath, + time: localTime, + value, + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ + param, + }), + coerceValue: ({ value: nextValue }) => + coerceAnimationValueForParam({ + param, + value: nextValue, + }), + }), + }, + }, + ], + }); + return; + } + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: buildBaseUpdates({ value }), + }, + ], + }); + }; + + const toggleKeyframe = () => { + if (!isPlayheadWithinElementRange) { + return; + } + + if (keyframeIdAtTime) { + editor.timeline.removeKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + keyframeId: keyframeIdAtTime, + }, + ], + }); + return; + } + + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId, + propertyPath, + time: localTime, + value: resolvedValue, + }, + ], + }); + }; + + return { + hasAnimatedKeyframes, + isKeyframedAtTime, + keyframeIdAtTime, + onPreview: previewValue, + onCommit: () => editor.timeline.commitPreview(), + toggleKeyframe, + }; +} diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts index a342859c..7ee1f221 100644 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts +++ b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts @@ -152,7 +152,7 @@ export function useKeyframedVectorProperty({ } editor.timeline.updateElements({ updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + { trackId, elementId, patch: buildBaseUpdates({ value: vector }) }, ], }); }; @@ -172,7 +172,7 @@ export function useKeyframedVectorProperty({ } editor.timeline.updateElements({ updates: [ - { trackId, elementId, updates: buildBaseUpdates({ value: vector }) }, + { trackId, elementId, patch: buildBaseUpdates({ value: vector }) }, ], }); }; diff --git a/apps/web/src/components/editor/panels/properties/index.tsx b/apps/web/src/components/editor/panels/properties/index.tsx index 3f3ff1f3..75319ef3 100644 --- a/apps/web/src/components/editor/panels/properties/index.tsx +++ b/apps/web/src/components/editor/panels/properties/index.tsx @@ -17,7 +17,7 @@ import { EmptyView } from "./empty-view"; export function PropertiesPanel() { const editor = useEditor(); - useEditor((e) => e.timeline.getTracks()); + useEditor((e) => e.scenes.getActiveSceneOrNull()); useEditor((e) => e.media.getAssets()); const { selectedElements } = useElementSelection(); const { activeTabPerType, setActiveTab } = usePropertiesStore(); diff --git a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx index a69acebf..f8e5397d 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/blending-tab.tsx @@ -41,7 +41,7 @@ type BlendingElement = { animations?: ElementAnimations; }; -const BLEND_MODE_GROUPS = [ +const BLEND_MODE_GROUPS: { value: BlendMode; label: string }[][] = [ [{ value: "normal", label: "Normal" }], [ { value: "darken", label: "Darken" }, @@ -99,7 +99,7 @@ export function BlendingTab({ ], }); - const commitBlendMode = (value: string) => { + const commitBlendMode = (value: BlendMode) => { if (editor.timeline.isPreviewActive()) { editor.timeline.commitPreview(); } else { @@ -108,7 +108,7 @@ export function BlendingTab({ { trackId, elementId: element.id, - updates: { blendMode: value as BlendMode }, + patch: { blendMode: value }, }, ], }); @@ -209,7 +209,7 @@ export function BlendingTab({ key={option.value} value={option.value} onPointerEnter={() => - previewBlendMode({ value: option.value as BlendMode }) + previewBlendMode({ value: option.value }) } > {option.label} diff --git a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx index 4593050e..c61c9cd7 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/graphic-tab.tsx @@ -123,7 +123,7 @@ function StrokeSection({ { trackId, elementId: element.id, - updates: { params: { ...element.params, strokeWidth: 0 } }, + patch: { params: { ...element.params, strokeWidth: 0 } }, }, ], }); @@ -133,7 +133,7 @@ function StrokeSection({ { trackId, elementId: element.id, - updates: { + patch: { params: { ...element.params, strokeWidth: lastStrokeWidth.current, diff --git a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx index d416dc28..8148b457 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/masks-tab.tsx @@ -15,7 +15,6 @@ import { PlusSignIcon, RotateClockwiseIcon, } from "@hugeicons/core-free-icons"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { ColorPicker } from "@/components/ui/color-picker"; @@ -88,7 +87,9 @@ export function MasksTab({ element, trackId }: MasksTabProps) { fallback: element, }); const maskDefs = masksRegistry.getAll(); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( @@ -102,7 +103,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) { const elementBounds = useMemo(() => { const clampedTime = Math.min( Math.max(currentTime, element.startTime), - element.startTime + element.duration - TIME_EPSILON_SECONDS, + element.startTime + element.duration - 1, ); return ( @@ -165,7 +166,7 @@ export function MasksTab({ element, trackId }: MasksTabProps) { { trackId, elementId: element.id, - updates: { + patch: { masks: [ buildDefaultMaskInstance({ maskType, diff --git a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx index 11412c04..5fffbcec 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/text-tab.tsx @@ -165,7 +165,7 @@ function TypographySection({ { trackId, elementId: element.id, - updates: { fontFamily: value }, + patch: { fontFamily: value }, }, ], }) @@ -188,7 +188,7 @@ function TypographySection({ { trackId, elementId: element.id, - updates: { + patch: { fontSize: DEFAULTS.text.element.fontSize, }, }, @@ -291,7 +291,7 @@ function SpacingSection({ { trackId, elementId: element.id, - updates: { letterSpacing: DEFAULTS.text.letterSpacing }, + patch: { letterSpacing: DEFAULTS.text.letterSpacing }, }, ], }) @@ -317,7 +317,7 @@ function SpacingSection({ { trackId, elementId: element.id, - updates: { lineHeight: DEFAULTS.text.lineHeight }, + patch: { lineHeight: DEFAULTS.text.lineHeight }, }, ], }) @@ -512,7 +512,7 @@ function BackgroundSection({ { trackId, elementId: element.id, - updates: { + patch: { background: { ...element.background, enabled, diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx index 288b8349..40519c1d 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx @@ -120,6 +120,9 @@ export function TransformTab({ ...(isScaleLocked ? { scaleY: value } : {}), }, }), + buildAdditionalKeyframes: isScaleLocked + ? ({ value }) => [{ propertyPath: "transform.scaleY", value }] + : undefined, }); const scaleY = useKeyframedNumberProperty({ @@ -140,6 +143,9 @@ export function TransformTab({ ...(isScaleLocked ? { scaleX: value } : {}), }, }), + buildAdditionalKeyframes: isScaleLocked + ? ({ value }) => [{ propertyPath: "transform.scaleX", value }] + : undefined, }); const scaleFieldPropsX = { diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx index d4b1657e..9342c30e 100644 --- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx +++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx @@ -4,13 +4,12 @@ import { useEffect, useState } from "react"; import type { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag"; -import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; import { DEFAULT_TIMELINE_BOOKMARK_COLOR, } from "./theme"; import { TIMELINE_BOOKMARK_ROW_HEIGHT_PX } from "./layout"; import { DEFAULT_FPS } from "@/lib/fps/constants"; -import { getSnappedSeekTime } from "opencut-wasm"; +import { snappedSeekTime } from "opencut-wasm"; import { ArrowTurnBackwardIcon, Delete02Icon, @@ -49,8 +48,8 @@ function seekToBookmarkTime({ }) { const activeProject = editor.project.getActive(); const duration = editor.timeline.getTotalDuration(); - const fps = activeProject?.settings.fps ?? DEFAULT_FPS; - const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps }); + const rate = activeProject?.settings.fps ?? DEFAULT_FPS; + const snappedTime = snappedSeekTime({ time, duration, rate }) ?? time; editor.playback.seek({ time: snappedTime }); } @@ -139,7 +138,7 @@ function TimelineBookmark({ const isDragging = dragState.isDragging && dragState.bookmarkTime !== null && - Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON; + dragState.bookmarkTime === bookmark.time; const displayTime = isDragging ? dragState.currentTime : bookmark.time; const time = bookmark.time; diff --git a/apps/web/src/components/editor/panels/timeline/drop-target.ts b/apps/web/src/components/editor/panels/timeline/drop-target.ts index dbd4efaf..f65170fe 100644 --- a/apps/web/src/components/editor/panels/timeline/drop-target.ts +++ b/apps/web/src/components/editor/panels/timeline/drop-target.ts @@ -105,6 +105,8 @@ export function computeDropTarget({ excludeElementId, targetElementTypes, }: ComputeDropTargetParams): DropTarget { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + const mainTrackIndex = tracks.overlay.length; const xPosition = typeof startTimeOverride === "number" ? startTimeOverride @@ -112,7 +114,7 @@ export function computeDropTarget({ ? playheadTime : Math.max(0, mouseX / (pixelsPerSecond * zoomLevel)); - if (tracks.length === 0) { + if (orderedTracks.length === 0) { const placementResult = resolveTrackPlacement({ tracks, elementType, @@ -139,7 +141,11 @@ export function computeDropTarget({ }; } - const trackAtMouse = getTrackAtY({ mouseY, tracks, verticalDragDirection }); + const trackAtMouse = getTrackAtY({ + mouseY, + tracks: orderedTracks, + verticalDragDirection, + }); if (!trackAtMouse) { const isAboveAllTracks = mouseY < 0; @@ -150,7 +156,7 @@ export function computeDropTarget({ timeSpans: [{ startTime: xPosition, duration: elementDuration, excludeElementId }], strategy: { type: "preferIndex", - trackIndex: isAboveAllTracks ? 0 : tracks.length - 1, + trackIndex: isAboveAllTracks ? 0 : orderedTracks.length - 1, hoverDirection: isAboveAllTracks ? "above" : "below", createNewTrackOnly: true, }, @@ -171,12 +177,12 @@ export function computeDropTarget({ } const { trackIndex, relativeY } = trackAtMouse; - const track = tracks[trackIndex]; + const track = orderedTracks[trackIndex]; if (targetElementTypes && targetElementTypes.length > 0) { const targetElement = findElementAtPosition({ mouseX, - tracks, + tracks: orderedTracks, trackIndex, targetElementTypes, pixelsPerSecond, diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx new file mode 100644 index 00000000..3f78350b --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/bezier-graph.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useRef, useState, type PointerEvent } from "react"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { getBezierPoint } from "@/lib/animation/bezier"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import { cn } from "@/utils/ui"; + +const GRAPH_WIDTH = 140; +const GRAPH_HEIGHT = 94; +const GRAPH_PADDING = 12; +const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2; +const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2; +const HANDLE_RADIUS = 3.5; +const ENDPOINT_RADIUS = 2; +const SNAP_THRESHOLD = 0.06; +const SNAP_TARGETS = [0, 1]; +const CURVE_SEGMENTS = 64; +const Y_CLAMP_MIN = -0.5; +const Y_CLAMP_MAX = 1.5; + +type BezierHandle = "c1" | "c2"; + +export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT; + +function snap({ + value, + targets, + isEnabled, +}: { + value: number; + targets: number[]; + isEnabled: boolean; +}) { + if (!isEnabled) return value; + for (const target of targets) { + if (Math.abs(value - target) < SNAP_THRESHOLD) return target; + } + return value; +} + +function toSvgX({ value }: { value: number }) { + return GRAPH_PADDING + value * GRAPH_WIDTH; +} + +function toSvgY({ value }: { value: number }) { + return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT; +} + +function fromSvgX({ svgX }: { svgX: number }) { + return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH)); +} + +function fromSvgY({ svgY }: { svgY: number }) { + return Math.max( + Y_CLAMP_MIN, + Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT), + ); +} + +function curvePath({ curve }: { curve: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= CURVE_SEGMENTS; i++) { + const progress = i / CURVE_SEGMENTS; + const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) }); + const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) }); + points.push(`${x},${y}`); + } + return `M${points.join("L")}`; +} + +function clampHandleY({ svgY }: { svgY: number }) { + return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY)); +} + +export function BezierGraph({ + value, + onChange, + onChangeEnd, + onCancel, +}: { + value: NormalizedCubicBezier; + onChange?: (value: NormalizedCubicBezier) => void; + onChangeEnd?: (value: NormalizedCubicBezier) => void; + onCancel?: () => void; +}) { + const svgRef = useRef(null); + const [activeHandle, setActiveHandle] = useState(null); + const isShiftPressedRef = useShiftKey(); + const latestValueRef = useRef(value); + + latestValueRef.current = value; + + function getPointerPosition({ + event, + }: { + event: PointerEvent; + }): { x: number; y: number } { + const svg = svgRef.current; + if (!svg) return { x: 0, y: 0 }; + const rect = svg.getBoundingClientRect(); + const scale = SVG_WIDTH / rect.width; + return { + x: (event.clientX - rect.left) * scale, + y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height), + }; + } + + function onHandlePointerDown({ handle }: { handle: BezierHandle }) { + return (event: PointerEvent) => { + event.preventDefault(); + event.stopPropagation(); + setActiveHandle(handle); + event.currentTarget.setPointerCapture(event.pointerId); + }; + } + + function onPointerMove({ event }: { event: PointerEvent }) { + if (!activeHandle) return; + const pointerPos = getPointerPosition({ event }); + const x = fromSvgX({ svgX: pointerPos.x }); + const y = snap({ + value: fromSvgY({ svgY: pointerPos.y }), + targets: SNAP_TARGETS, + isEnabled: !isShiftPressedRef.current, + }); + const next: NormalizedCubicBezier = [...value]; + if (activeHandle === "c1") { + next[0] = x; + next[1] = y; + } else { + next[2] = x; + next[3] = y; + } + latestValueRef.current = next; + onChange?.(next); + } + + function onPointerUp() { + if (!activeHandle) return; + setActiveHandle(null); + onChangeEnd?.(latestValueRef.current); + } + + function onPointerCancel() { + if (!activeHandle) return; + setActiveHandle(null); + onCancel?.(); + } + + const path = curvePath({ curve: value }); + const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) }; + const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) }; + const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) }; + const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) }; + const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) }; + const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) }; + + return ( + onPointerMove({ event })} + onPointerUp={onPointerUp} + onPointerCancel={onPointerCancel} + > + Bezier curve editor + + + + + + + + + + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts new file mode 100644 index 00000000..54722ec2 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/custom-presets-store.ts @@ -0,0 +1,101 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { generateUUID } from "@/utils/id"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import type { EasingPreset } from "./easing-presets"; + +const STORAGE_KEY = "opencut:graph-editor-presets"; + +let cachedPresets: EasingPreset[] | null = null; +const listeners = new Set<() => void>(); + +function isValidPresetArray(value: unknown): value is EasingPreset[] { + return ( + Array.isArray(value) && + value.every( + (item) => + typeof item === "object" && + item !== null && + typeof item.id === "string" && + typeof item.label === "string" && + Array.isArray(item.value) && + item.value.length === 4 && + item.value.every((number: unknown) => typeof number === "number"), + ) + ); +} + +function readFromStorage(): EasingPreset[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + return isValidPresetArray(parsed) ? parsed : []; + } catch { + // Silently recover — corrupted localStorage shouldn't crash the editor + return []; + } +} + +function writeToStorage({ presets }: { presets: EasingPreset[] }): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(presets)); +} + +function getSnapshot(): EasingPreset[] { + cachedPresets ??= readFromStorage(); + return cachedPresets; +} + +function getServerSnapshot(): EasingPreset[] { + return []; +} + +function notify(): void { + cachedPresets = null; + for (const listener of listeners) { + listener(); + } +} + +function onStorageChange(event: StorageEvent): void { + if (event.key === STORAGE_KEY) notify(); +} + +function subscribe(listener: () => void): () => void { + if (listeners.size === 0 && typeof window !== "undefined") { + window.addEventListener("storage", onStorageChange); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0 && typeof window !== "undefined") { + window.removeEventListener("storage", onStorageChange); + } + }; +} + +export function useCustomPresets(): EasingPreset[] { + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} + +export function savePreset({ value }: { value: NormalizedCubicBezier }): void { + const current = getSnapshot(); + writeToStorage({ + presets: [ + ...current, + { + id: generateUUID(), + label: `Custom ${current.length + 1}`, + value, + isCustom: true, + }, + ], + }); + notify(); +} + +export function removePreset({ id }: { id: string }): void { + writeToStorage({ presets: getSnapshot().filter((preset) => preset.id !== id) }); + notify(); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts new file mode 100644 index 00000000..6ccc9071 --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/easing-presets.ts @@ -0,0 +1,19 @@ +import type { NormalizedCubicBezier } from "@/lib/animation/types"; + +export const PRESET_MATCH_TOLERANCE = 0.02; + +export interface EasingPreset { + id: string; + label: string; + value: NormalizedCubicBezier; + isCustom?: boolean; +} + +export const BUILTIN_PRESETS: EasingPreset[] = [ + { id: "smooth", label: "Smooth", value: [0.25, 0.1, 0.25, 1] }, + { id: "ease-out", label: "Ease out", value: [0, 0, 0.2, 1] }, + { id: "ease-in", label: "Ease in", value: [0.8, 0, 1, 1] }, + { id: "ease-in-out", label: "In out", value: [0.4, 0, 0.2, 1] }, + { id: "pop", label: "Pop", value: [0.175, 0.885, 0.32, 1.275] }, + { id: "linear", label: "Linear", value: [0, 0, 1, 1] }, +]; diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx new file mode 100644 index 00000000..733195ed --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/popover.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useState } from "react"; +import { Popover, PopoverContent } from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/utils/ui"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowDown01Icon, + Delete02Icon, + PlusSignIcon, +} from "@hugeicons/core-free-icons"; +import { getBezierPoint } from "@/lib/animation/bezier"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import type { GraphEditorComponentOption } from "./session"; +import { + BUILTIN_PRESETS, + PRESET_MATCH_TOLERANCE, + type EasingPreset, +} from "./easing-presets"; +import { removePreset, savePreset, useCustomPresets } from "./custom-presets-store"; +import { BezierGraph, BEZIER_GRAPH_MIN_HEIGHT } from "./bezier-graph"; + +const COLLAPSED_MAX = 6; +const THUMB_SEGMENTS = 24; +const THUMB_WIDTH = 40; +const THUMB_HEIGHT = 22; +const THUMB_PADDING_X = 4; +const THUMB_PADDING_Y = 3; +const COLLAPSED_GRID_MAX_HEIGHT = 120; +const EXPANDED_GRID_MAX_HEIGHT = 240; + +export function GraphEditorPopover({ + children, + side, + open, + onOpenChange, + value, + message, + componentOptions, + activeComponentKey, + onActiveComponentKeyChange, + onPreviewValue, + onCommitValue, + onCancelPreview, +}: { + children: React.ReactNode; + side?: "top" | "bottom" | "left" | "right"; + open?: boolean; + onOpenChange?: (open: boolean) => void; + value: NormalizedCubicBezier | null; + message: string; + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + onActiveComponentKeyChange?: (componentKey: string) => void; + onPreviewValue?: (value: NormalizedCubicBezier) => void; + onCommitValue?: (value: NormalizedCubicBezier) => void; + onCancelPreview?: () => void; +}) { + const [isExpanded, setIsExpanded] = useState(false); + const custom = useCustomPresets(); + const allPresets = [...BUILTIN_PRESETS, ...custom]; + const canEdit = value !== null; + const activePresetId = + value == null + ? null + : (allPresets.find((preset) => + preset.value.every( + (presetValue, index) => + Math.abs(presetValue - value[index]) < PRESET_MATCH_TOLERANCE, + ), + )?.id ?? null); + + return ( + { + if (!nextOpen) { + onCancelPreview?.(); + } + onOpenChange?.(nextOpen); + }} + > + {children} + + {componentOptions.length > 1 && ( +
+
+ {componentOptions.map((component) => ( + + ))} +
+
+ )} + +
+ {value ? ( + + ) : ( + + )} +
+ + + + + Presets + + + Saved + + + + COLLAPSED_MAX} + onExpand={() => setIsExpanded(true)} + > + {BUILTIN_PRESETS.map((preset) => ( + onCommitValue?.(preset.value)} + /> + ))} + + + +
+ {custom.map((preset) => ( + onCommitValue?.(preset.value)} + onDelete={() => removePreset({ id: preset.id })} + /> + ))} + +
+
+
+
+
+ ); +} + +function GraphEditorEmptyState({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} + +function ExpandableGrid({ + children, + isExpanded, + shouldExpand, + onExpand, +}: { + children: React.ReactNode; + isExpanded: boolean; + shouldExpand: boolean; + onExpand: () => void; +}) { + const gridStyle = shouldExpand + ? isExpanded + ? { maxHeight: EXPANDED_GRID_MAX_HEIGHT, overflowY: "auto" as const } + : { maxHeight: COLLAPSED_GRID_MAX_HEIGHT, overflow: "hidden" as const } + : undefined; + + return ( +
+
+ {children} +
+ {!isExpanded && shouldExpand && ( +
+ +
+ )} +
+ ); +} + +function PresetItem({ + preset, + isActive, + onSelect, + onDelete, + disabled, +}: { + preset: EasingPreset; + isActive: boolean; + onSelect: () => void; + onDelete?: () => void; + disabled?: boolean; +}) { + return ( + + )} + + ); +} + +function toThumbX({ value }: { value: number }) { + return THUMB_PADDING_X + value * (THUMB_WIDTH - THUMB_PADDING_X * 2); +} + +function toThumbY({ value }: { value: number }) { + return THUMB_PADDING_Y + (1 - value) * (THUMB_HEIGHT - THUMB_PADDING_Y * 2); +} + +function CurveThumb({ value }: { value: NormalizedCubicBezier }) { + const points: string[] = []; + for (let i = 0; i <= THUMB_SEGMENTS; i++) { + const progress = i / THUMB_SEGMENTS; + const x = toThumbX({ value: getBezierPoint({ progress, p0: 0, p1: value[0], p2: value[2], p3: 1 }) }); + const y = toThumbY({ value: getBezierPoint({ progress, p0: 0, p1: value[1], p2: value[3], p3: 1 }) }); + points.push(`${x},${y}`); + } + return ( + + Curve preset preview + + + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts new file mode 100644 index 00000000..15657bfb --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts @@ -0,0 +1,446 @@ +import { + getCurveHandlesForNormalizedCubicBezier, + getEditableScalarChannels, + getNormalizedCubicBezierForScalarSegment, + getScalarKeyframeContext, + updateScalarKeyframeCurve, +} from "@/lib/animation"; +import type { + AnimationPath, + ElementAnimations, + NormalizedCubicBezier, + ScalarCurveKeyframePatch, + ScalarGraphKeyframeContext, + SelectedKeyframeRef, +} from "@/lib/animation/types"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; + +const GRAPH_LINEAR_CURVE: NormalizedCubicBezier = [0, 0, 1, 1]; +const FLAT_VALUE_EPSILON = 1e-6; +const LINEAR_CURVE_EPSILON = 1e-6; + +export type GraphEditorUnavailableReason = + | "no-keyframe-selected" + | "multiple-keyframes-selected" + | "selected-element-missing" + | "selected-element-has-no-animations" + | "selected-keyframe-has-no-scalar-channel" + | "selected-keyframe-missing-on-channel" + | "selected-keyframe-has-no-next-segment" + | "selected-segment-is-hold" + | "selected-segment-is-flat"; + +export interface GraphEditorComponentOption { + key: string; + label: string; +} + +interface GraphEditorBaseSelectionState { + componentOptions: GraphEditorComponentOption[]; + activeComponentKey: string | null; + message: string; +} + +export interface GraphEditorUnavailableState + extends GraphEditorBaseSelectionState { + status: "unavailable"; + reason: GraphEditorUnavailableReason; +} + +export interface GraphEditorReadyState extends GraphEditorBaseSelectionState { + status: "ready"; + trackId: string; + elementId: string; + propertyPath: SelectedKeyframeRef["propertyPath"]; + keyframeId: string; + element: TimelineElement; + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; +} + +export type GraphEditorSelectionState = + | GraphEditorUnavailableState + | GraphEditorReadyState; + +export interface GraphEditorCurvePatch { + keyframeId: string; + patch: ScalarCurveKeyframePatch; +} + +function createUnavailableState({ + reason, + message, + componentOptions = [], + activeComponentKey = null, +}: { + reason: GraphEditorUnavailableReason; + message: string; + componentOptions?: GraphEditorComponentOption[]; + activeComponentKey?: string | null; +}): GraphEditorUnavailableState { + return { + status: "unavailable", + reason, + message, + componentOptions, + activeComponentKey, + }; +} + +function findElementByKeyframe({ + tracks, + keyframe, +}: { + tracks: SceneTracks; + keyframe: SelectedKeyframeRef; +}): { element: TimelineElement; trackId: string; elementId: string } | null { + for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { + if (track.id !== keyframe.trackId) { + continue; + } + + const element = track.elements.find( + (trackElement) => trackElement.id === keyframe.elementId, + ); + if (!element) { + return null; + } + + return { + element, + trackId: track.id, + elementId: element.id, + }; + } + + return null; +} + +function findKeyframeTime({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations; + propertyPath: AnimationPath; + keyframeId: string; +}): number | null { + const binding = animations.bindings[propertyPath]; + if (!binding) return null; + + for (const component of binding.components) { + const channel = animations.channels[component.channelId]; + if (channel?.kind !== "scalar") continue; + const key = channel.keys.find((k) => k.id === keyframeId); + if (key !== undefined) return key.time; + } + + return null; +} + +function getComponentLabel({ componentKey }: { componentKey: string }): string { + switch (componentKey) { + case "value": + return "Value"; + default: + return componentKey.toUpperCase(); + } +} + +function isFlatSegment({ + context, +}: { + context: ScalarGraphKeyframeContext; +}): boolean { + if (!context.nextKey) { + return true; + } + + return ( + Math.abs(context.nextKey.value - context.keyframe.value) <= + FLAT_VALUE_EPSILON + ); +} + +function isLinearCurve({ + cubicBezier, +}: { + cubicBezier: NormalizedCubicBezier; +}): boolean { + return ( + Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON && + Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON + ); +} + +export function resolveGraphEditorSelectionState({ + tracks, + selectedKeyframes, + preferredComponentKey, +}: { + tracks: SceneTracks; + selectedKeyframes: SelectedKeyframeRef[]; + preferredComponentKey?: string | null; +}): GraphEditorSelectionState { + if (selectedKeyframes.length === 0) { + return createUnavailableState({ + reason: "no-keyframe-selected", + message: "Select a keyframe to edit its curve.", + }); + } + + if (selectedKeyframes.length > 2) { + return createUnavailableState({ + reason: "multiple-keyframes-selected", + message: "Select one or two adjacent keyframes to edit a curve.", + }); + } + + if (selectedKeyframes.length === 2) { + const [firstKeyframe, secondKeyframe] = selectedKeyframes; + if ( + firstKeyframe.trackId !== secondKeyframe.trackId || + firstKeyframe.elementId !== secondKeyframe.elementId || + firstKeyframe.propertyPath !== secondKeyframe.propertyPath + ) { + return createUnavailableState({ + reason: "multiple-keyframes-selected", + message: "Selected keyframes must be on the same element and property.", + }); + } + } + + const primaryKeyframe = selectedKeyframes[0]; + const secondaryKeyframeId = + selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null; + + const selectedElement = findElementByKeyframe({ + tracks, + keyframe: primaryKeyframe, + }); + if (!selectedElement) { + return createUnavailableState({ + reason: "selected-element-missing", + message: "The selected keyframe could not be resolved.", + }); + } + + if (!selectedElement.element.animations) { + return createUnavailableState({ + reason: "selected-element-has-no-animations", + message: "The selected keyframe has no editable graph.", + }); + } + + const scalarChannels = getEditableScalarChannels({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + }); + if (scalarChannels.length === 0) { + return createUnavailableState({ + reason: "selected-keyframe-has-no-scalar-channel", + message: "The selected keyframe has no editable graph channel.", + }); + } + + // When 2 keyframes are selected, resolve the earlier one as the outgoing-segment + // anchor so the graph editor edits the curve between the two selected keyframes. + let resolvedKeyframeId = primaryKeyframe.keyframeId; + if (secondaryKeyframeId !== null) { + const time1 = findKeyframeTime({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: primaryKeyframe.keyframeId, + }); + const time2 = findKeyframeTime({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: secondaryKeyframeId, + }); + if (time2 !== null && (time1 === null || time2 < time1)) { + resolvedKeyframeId = secondaryKeyframeId; + } + } + + const contexts = scalarChannels.flatMap((channel) => { + const context = getScalarKeyframeContext({ + animations: selectedElement.element.animations, + propertyPath: primaryKeyframe.propertyPath, + componentKey: channel.componentKey, + keyframeId: resolvedKeyframeId, + }); + if (!context) { + return []; + } + + return [ + { + context, + option: { + key: channel.componentKey, + label: getComponentLabel({ componentKey: channel.componentKey }), + }, + }, + ]; + }); + + if (contexts.length === 0) { + return createUnavailableState({ + reason: "selected-keyframe-missing-on-channel", + message: "The selected keyframe is not editable as a graph segment.", + }); + } + + const nextSegmentContexts = contexts.filter( + ({ context }) => context.nextKey !== null, + ); + const preferredContext = + contexts.find(({ option }) => option.key === preferredComponentKey) ?? null; + const activeContext = + preferredContext ?? nextSegmentContexts[0] ?? contexts[0]; + const componentOptions = contexts.map(({ option }) => option); + + if (!activeContext.context.nextKey) { + return createUnavailableState({ + reason: "selected-keyframe-has-no-next-segment", + message: "Select a keyframe that has an outgoing segment.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + if (isFlatSegment({ context: activeContext.context })) { + return createUnavailableState({ + reason: "selected-segment-is-flat", + message: "Flat segments are not graph-editable in this popover yet.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + if (activeContext.context.keyframe.segmentToNext === "step") { + return createUnavailableState({ + reason: "selected-segment-is-hold", + message: "Hold segments are not graph-editable in this popover yet.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + const cubicBezier = + activeContext.context.keyframe.segmentToNext === "linear" + ? GRAPH_LINEAR_CURVE + : getNormalizedCubicBezierForScalarSegment({ + leftKey: activeContext.context.keyframe, + rightKey: activeContext.context.nextKey, + }); + if (!cubicBezier) { + return createUnavailableState({ + reason: "selected-segment-is-flat", + message: "The selected segment cannot be represented in this graph view.", + componentOptions, + activeComponentKey: activeContext.option.key, + }); + } + + return { + status: "ready", + message: "Edit graph", + componentOptions, + activeComponentKey: activeContext.option.key, + trackId: selectedElement.trackId, + elementId: selectedElement.elementId, + propertyPath: primaryKeyframe.propertyPath, + keyframeId: resolvedKeyframeId, + element: selectedElement.element, + context: activeContext.context, + cubicBezier, + }; +} + +export function buildGraphEditorCurvePatches({ + context, + cubicBezier, +}: { + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; +}): GraphEditorCurvePatch[] | null { + if (!context.nextKey) { + return null; + } + + if (isLinearCurve({ cubicBezier })) { + return [ + { + keyframeId: context.keyframe.id, + patch: { + segmentToNext: "linear", + rightHandle: null, + }, + }, + { + keyframeId: context.nextKey.id, + patch: { + leftHandle: null, + }, + }, + ]; + } + + const handles = getCurveHandlesForNormalizedCubicBezier({ + leftKey: context.keyframe, + rightKey: context.nextKey, + cubicBezier, + }); + if (!handles) { + return null; + } + + return [ + { + keyframeId: context.keyframe.id, + patch: { + segmentToNext: "bezier", + rightHandle: handles.rightHandle, + }, + }, + { + keyframeId: context.nextKey.id, + patch: { + leftHandle: handles.leftHandle, + }, + }, + ]; +} + +export function applyGraphEditorCurvePreview({ + animations, + context, + cubicBezier, +}: { + animations: ElementAnimations | undefined; + context: ScalarGraphKeyframeContext; + cubicBezier: NormalizedCubicBezier; +}): ElementAnimations | undefined { + const patches = buildGraphEditorCurvePatches({ + context, + cubicBezier, + }); + if (!patches) { + return animations; + } + + return patches.reduce( + (nextAnimations, { keyframeId, patch }) => + updateScalarKeyframeCurve({ + animations: nextAnimations, + propertyPath: context.propertyPath, + componentKey: context.componentKey, + keyframeId, + patch, + }), + animations, + ); +} diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts new file mode 100644 index 00000000..d0303b6f --- /dev/null +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts @@ -0,0 +1,160 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { registerCanceller } from "@/lib/cancel-interaction"; +import type { NormalizedCubicBezier } from "@/lib/animation/types"; +import { useKeyframeSelection } from "@/hooks/timeline/element/use-keyframe-selection"; +import { + applyGraphEditorCurvePreview, + buildGraphEditorCurvePatches, + resolveGraphEditorSelectionState, + type GraphEditorSelectionState, +} from "./session"; + +export function useGraphEditorController() { + const editor = useEditor(); + const renderTracks = useEditor((currentEditor) => + currentEditor.timeline.getPreviewTracks() ?? + currentEditor.scenes.getActiveScene().tracks, + ); + const { selectedKeyframes } = useKeyframeSelection(); + const [open, setOpen] = useState(false); + const [activeComponentKey, setActiveComponentKey] = useState( + null, + ); + const hasPreviewRef = useRef(false); + + const state = useMemo( + () => + resolveGraphEditorSelectionState({ + tracks: renderTracks, + selectedKeyframes, + preferredComponentKey: activeComponentKey, + }), + [activeComponentKey, renderTracks, selectedKeyframes], + ); + + const stateKey = + state.status === "ready" + ? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}` + : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`; + const previousStateKeyRef = useRef(stateKey); + + const discardPreview = useCallback(() => { + if (!hasPreviewRef.current) { + return; + } + + editor.timeline.discardPreview(); + hasPreviewRef.current = false; + }, [editor]); + + useEffect(() => { + if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) { + discardPreview(); + } + + previousStateKeyRef.current = stateKey; + }, [discardPreview, stateKey]); + + useEffect(() => { + if (!open) { + return; + } + + return registerCanceller({ + fn: () => { + discardPreview(); + setOpen(false); + }, + }); + }, [discardPreview, open]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + discardPreview(); + } + + setOpen(nextOpen); + }, + [discardPreview], + ); + + const handleActiveComponentKeyChange = useCallback( + (nextComponentKey: string) => { + discardPreview(); + setActiveComponentKey(nextComponentKey); + }, + [discardPreview], + ); + + const handlePreviewValue = useCallback( + (nextValue: NormalizedCubicBezier) => { + if (state.status !== "ready") { + return; + } + + const nextAnimations = applyGraphEditorCurvePreview({ + animations: state.element.animations, + context: state.context, + cubicBezier: nextValue, + }); + editor.timeline.previewElements({ + updates: [ + { + trackId: state.trackId, + elementId: state.elementId, + updates: { + animations: nextAnimations, + }, + }, + ], + }); + hasPreviewRef.current = true; + }, + [editor, state], + ); + + const handleCommitValue = useCallback( + (nextValue: NormalizedCubicBezier) => { + if (state.status !== "ready") { + return; + } + + const patches = buildGraphEditorCurvePatches({ + context: state.context, + cubicBezier: nextValue, + }); + if (!patches) { + return; + } + + editor.timeline.updateKeyframeCurves({ + keyframes: patches.map(({ keyframeId, patch }) => ({ + trackId: state.trackId, + elementId: state.elementId, + propertyPath: state.propertyPath, + componentKey: state.context.componentKey, + keyframeId, + patch, + })), + }); + hasPreviewRef.current = false; + }, + [editor, state], + ); + + return { + open, + onOpenChange: handleOpenChange, + canOpen: state.status === "ready", + tooltip: state.status === "ready" ? "Open graph editor" : state.message, + state, + onActiveComponentKeyChange: handleActiveComponentKeyChange, + onPreviewValue: handlePreviewValue, + onCommitValue: handleCommitValue, + onCancelPreview: discardPreview, + }; +} diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx index 4114ae2c..883011d1 100644 --- a/apps/web/src/components/editor/panels/timeline/index.tsx +++ b/apps/web/src/components/editor/panels/timeline/index.tsx @@ -50,7 +50,7 @@ import { getTimelineZoomMin, getTimelinePaddingPx, } from "@/lib/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils"; import { getTrackHeight, getCumulativeHeightBefore, @@ -58,7 +58,6 @@ import { } from "./track-layout"; import { SELECTED_TRACK_ROW_CLASS } from "./theme"; import { TIMELINE_HORIZONTAL_WHEEL_STEP_PX } from "./interaction"; -import { isMainTrack } from "@/lib/timeline/placement"; import { TimelineToolbar } from "./timeline-toolbar"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek"; @@ -112,7 +111,15 @@ export function Timeline() { } = useElementSelection(); const editor = useEditor(); const timeline = editor.timeline; - const tracks = useEditor((editor) => editor.timeline.getTracks()); + const scene = useEditor((currentEditor) => currentEditor.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); + const mainTrackId = scene?.tracks.main.id ?? null; const seek = (time: number) => editor.playback.seek({ time }); const timelineRef = useRef(null); @@ -340,8 +347,7 @@ export function Timeline() { const containerWidth = tracksContainerRef.current?.clientWidth || FALLBACK_CONTAINER_WIDTH; - const contentWidth = - timelineDuration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + const contentWidth = timelineTimeToPixels({ time: timelineDuration, zoomLevel }); const paddingPx = getTimelinePaddingPx({ containerWidth, zoomLevel, @@ -425,13 +431,13 @@ export function Timeline() { /> @@ -509,6 +515,7 @@ export function Timeline() { {tracks.length > 0 && ( e.timeline.getTracks()); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); const { selectedElements } = useElementSelection(); const tracksWithSelection = useMemo( () => new Set(selectedElements.map((el) => el.trackId)), @@ -651,6 +665,7 @@ function TrackLabelsPanel({ function TimelineTrackRows({ dragElementId, + mainTrackId, zoomLevel, dragState, tracksScrollRef, @@ -666,6 +681,7 @@ function TimelineTrackRows({ dropTarget, }: { dragElementId: string | null; + mainTrackId: string | null; zoomLevel: number; dragState: ElementDragState; tracksScrollRef: React.RefObject; @@ -685,7 +701,14 @@ function TimelineTrackRows({ dropTarget: DropTarget | null; }) { const timeline = useEditor((e) => e.timeline); - const tracks = useEditor((e) => e.timeline.getTracks()); + const scene = useEditor((e) => e.scenes.getActiveSceneOrNull()); + const tracks = useMemo( + () => + scene + ? [...scene.tracks.overlay, scene.tracks.main, ...scene.tracks.audio] + : [], + [scene], + ); const { selectedElements } = useElementSelection(); const tracksWithSelection = useMemo( () => new Set(selectedElements.map((el) => el.trackId)), @@ -780,7 +803,7 @@ function TimelineTrackRows({ ? "Show track" : "Hide track"} - {!isMainTrack(track) && ( + {track.id !== mainTrackId && ( } onClick={(event: React.MouseEvent) => { diff --git a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts index 6488e29a..551393e9 100644 --- a/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts +++ b/apps/web/src/components/editor/panels/timeline/selection-hit-testing.ts @@ -1,5 +1,5 @@ import type { TimelineTrack } from "@/lib/timeline"; -import { getTimelinePixelsPerSecond } from "@/lib/timeline/pixel-utils"; +import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils"; import { TIMELINE_CONTENT_TOP_PADDING_PX, } from "./layout"; @@ -96,7 +96,6 @@ export function resolveTimelineElementIntersections({ startPos, endPos: currentPos, }); - const pixelsPerSecond = getTimelinePixelsPerSecond({ zoomLevel }); const selectedElements: TimelineElementRef[] = []; for (const [trackIndex, track] of tracks.entries()) { @@ -109,8 +108,8 @@ export function resolveTimelineElementIntersections({ const elementBottom = elementTop + trackHeight; for (const element of track.elements) { - const elementLeft = element.startTime * pixelsPerSecond; - const elementRight = elementLeft + element.duration * pixelsPerSecond; + const elementLeft = timelineTimeToPixels({ time: element.startTime, zoomLevel }); + const elementRight = timelineTimeToPixels({ time: element.startTime + element.duration, zoomLevel }); const elementRectangle = { left: elementLeft, top: elementTop, diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index 25218d78..1d3606e6 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -292,10 +292,7 @@ export function TimelineElement({ const canToggleCurrentSourceAudio = selectedElements.length === 1 && isCurrentElementSelected && - canToggleSourceAudio({ - element, - mediaAsset, - }); + canToggleSourceAudio(element, mediaAsset); const sourceAudioLabel = element.type === "video" ? getSourceAudioActionLabel({ element }) diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx index 361f1398..d9218371 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx @@ -7,6 +7,7 @@ import { timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; import { useEditor } from "@/hooks/use-editor"; import { TIMELINE_SCROLLBAR_SIZE_PX, @@ -72,10 +73,11 @@ export function TimelinePlayhead({ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; event.preventDefault(); - const step = 1 / Math.max(1, editor.project.getActive().settings.fps); + const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator); const direction = event.key === "ArrowRight" ? 1 : -1; const now = editor.playback.getCurrentTime(); - const nextTime = Math.max(0, Math.min(duration, now + direction * step)); + const nextTime = Math.max(0, Math.min(duration, now + direction * ticksPerFrame)); editor.playback.seek({ time: nextTime }); }; diff --git a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx index 9e49bf6c..8b8dab32 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-ruler.tsx @@ -2,6 +2,8 @@ import { type JSX, useLayoutEffect, useRef } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND, } from "@/lib/timeline/scale"; +import { mediaTimeToSeconds } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; import { TIMELINE_RULER_HEIGHT_PX } from "./layout"; import { DEFAULT_FPS } from "@/lib/fps/constants"; import { useEditor } from "@/hooks/use-editor"; @@ -30,17 +32,18 @@ export function TimelineRuler({ handleRulerTrackingMouseDown, handleRulerMouseDown, }: TimelineRulerProps) { - const duration = useEditor((e) => e.timeline.getTotalDuration()); + const durationTicks = useEditor((e) => e.timeline.getTotalDuration()); + const durationSeconds = mediaTimeToSeconds({ time: durationTicks }); const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - const visibleDuration = dynamicTimelineWidth / pixelsPerSecond; - const effectiveDuration = Math.max(duration, visibleDuration); + const visibleDurationSeconds = dynamicTimelineWidth / pixelsPerSecond; + const effectiveDurationSeconds = Math.max(durationSeconds, visibleDurationSeconds); const fps = useEditor((e) => e.project.getActiveOrNull()?.settings.fps) ?? DEFAULT_FPS; const { labelIntervalSeconds, tickIntervalSeconds } = getRulerConfig({ zoomLevel, fps, }); - const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1; + const tickCount = Math.ceil(effectiveDurationSeconds / tickIntervalSeconds) + 1; const { scrollLeft, viewportWidth } = useScrollPosition({ scrollRef: tracksScrollRef, @@ -61,20 +64,20 @@ export function TimelineRuler({ prevZoomRef.current = zoomLevel; }, [zoomLevel]); - const visibleStartTime = Math.max( + const visibleStartTimeSeconds = Math.max( 0, (scrollLeft - bufferPx) / pixelsPerSecond, ); - const visibleEndTime = + const visibleEndTimeSeconds = (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond; const startTickIndex = Math.max( 0, - Math.floor(visibleStartTime / tickIntervalSeconds), + Math.floor(visibleStartTimeSeconds / tickIntervalSeconds), ); const endTickIndex = Math.min( tickCount - 1, - Math.ceil(visibleEndTime / tickIntervalSeconds), + Math.ceil(visibleEndTimeSeconds / tickIntervalSeconds), ); const timelineTicks: Array = []; @@ -83,14 +86,16 @@ export function TimelineRuler({ tickIndex <= endTickIndex; tickIndex += 1 ) { - const time = tickIndex * tickIntervalSeconds; - if (time > effectiveDuration) break; + const timeSeconds = tickIndex * tickIntervalSeconds; + if (timeSeconds > effectiveDurationSeconds) break; - const showLabel = shouldShowLabel({ time, labelIntervalSeconds }); + const timeTicks = Math.round(timeSeconds * TICKS_PER_SECOND); + const showLabel = shouldShowLabel({ time: timeSeconds, labelIntervalSeconds }); timelineTicks.push( - {label} - - ); - } - - return ( -
- ); -} +"use client"; + +import type { FrameRate } from "opencut-wasm"; +import { timelineTimeToSnappedPixels } from "@/lib/timeline"; +import { formatRulerLabel } from "@/lib/timeline/ruler-utils"; + +interface TimelineTickProps { + time: number; + timeInSeconds: number; + zoomLevel: number; + fps: FrameRate; + showLabel: boolean; +} + +export function TimelineTick({ + time, + timeInSeconds, + zoomLevel, + fps, + showLabel, +}: TimelineTickProps) { + const leftPosition = timelineTimeToSnappedPixels({ time, zoomLevel }); + + if (showLabel) { + const label = formatRulerLabel({ timeInSeconds, fps }); + return ( + + {label} + + ); + } + + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx index ecaf37e2..80ea10d0 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx @@ -14,9 +14,7 @@ import { SplitButtonSeparator, } from "@/components/ui/split-button"; import { Slider } from "@/components/ui/slider"; -import { - TIMELINE_ZOOM_BUTTON_FACTOR, -} from "./interaction"; +import { TIMELINE_ZOOM_BUTTON_FACTOR } from "./interaction"; import { TIMELINE_ZOOM_MAX } from "@/lib/timeline/scale"; import { sliderToZoom, zoomToSlider } from "@/lib/timeline/zoom-utils"; import { ScenesView } from "@/components/editor/scenes-view"; @@ -36,7 +34,6 @@ import { SnowIcon, ScissorIcon, MagnetIcon, - Link04Icon, SearchAddIcon, SearchMinusIcon, Copy01Icon, @@ -49,6 +46,9 @@ import { } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { OcRippleIcon } from "@/components/icons"; +import { GraphEditorPopover } from "./graph-editor/popover"; +import { PopoverTrigger } from "@/components/ui/popover"; +import { useGraphEditorController } from "./graph-editor/use-controller"; export function TimelineToolbar({ zoomLevel, @@ -62,10 +62,7 @@ export function TimelineToolbar({ const handleZoom = ({ direction }: { direction: "in" | "out" }) => { const newZoomLevel = direction === "in" - ? Math.min( - TIMELINE_ZOOM_MAX, - zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR, - ) + ? Math.min(TIMELINE_ZOOM_MAX, zoomLevel * TIMELINE_ZOOM_BUTTON_FACTOR) : Math.max(minZoom, zoomLevel / TIMELINE_ZOOM_BUTTON_FACTOR); setZoomLevel({ zoom: newZoomLevel }); }; @@ -90,8 +87,11 @@ export function TimelineToolbar({ function ToolbarLeftSection() { const editor = useEditor(); - const mediaAssets = useEditor((currentEditor) => currentEditor.media.getAssets()); + const mediaAssets = useEditor((currentEditor) => + currentEditor.media.getAssets(), + ); const { selectedElements } = useElementSelection(); + const graphEditor = useGraphEditorController(); const isCurrentlyBookmarked = useEditor((e) => e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), ); @@ -115,10 +115,7 @@ function ToolbarLeftSection() { })(); const canToggleSelectedSourceAudio = !!selectedElement && - canToggleSourceAudio({ - element: selectedElement.element, - mediaAsset: selectedMediaAsset, - }); + canToggleSourceAudio(selectedElement.element, selectedMediaAsset); const sourceAudioLabel = selectedElement?.element.type === "video" ? getSourceAudioActionLabel({ @@ -166,11 +163,11 @@ function ToolbarLeftSection() { /> - } + icon={ + + } tooltip={sourceAudioLabel} disabled={!canToggleSelectedSourceAudio} onClick={({ event }) => @@ -214,13 +211,35 @@ function ToolbarLeftSection() { /> - + } - tooltip="Open graph editor" - onClick={() => {}} + tooltip={graphEditor.tooltip} + disabled={!graphEditor.canOpen} + buttonWrapper={(button) => + graphEditor.canOpen ? ( + {button} + ) : ( + button + ) + } /> - +
); @@ -317,29 +336,40 @@ function ToolbarButton({ onClick, disabled, isActive, + buttonWrapper, }: { icon: React.ReactNode; tooltip: string; - onClick: ({ event }: { event: React.MouseEvent }) => void; + onClick?: ({ event }: { event: React.MouseEvent }) => void; disabled?: boolean; isActive?: boolean; + buttonWrapper?: (button: React.ReactElement) => React.ReactElement; }) { + const button = ( + + ); + const trigger = disabled ? ( + {button} + ) : buttonWrapper ? ( + buttonWrapper(button) + ) : ( + button + ); + return ( - - - + {trigger} {tooltip} ); diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index 20476a13..12b64853 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -7,6 +7,7 @@ import { EditorCore } from "@/core"; import { useEditor } from "@/hooks/use-editor"; import { useKeybindingsListener } from "@/hooks/use-keybindings"; import { useKeybindingsStore } from "@/stores/keybindings-store"; +import { useTimelineStore } from "@/stores/timeline-store"; import { useEditorActions } from "@/hooks/actions/use-editor-actions"; import { loadFontAtlas } from "@/lib/fonts/google-fonts"; import { initializeGpuRenderer } from "@/services/renderer/gpu-renderer"; @@ -117,6 +118,13 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { function EditorRuntimeBindings() { const editor = useEditor(); + const rippleEditingEnabled = useTimelineStore( + (state) => state.rippleEditingEnabled, + ); + + useEffect(() => { + editor.command.isRippleEnabled = rippleEditingEnabled; + }, [editor, rippleEditingEnabled]); useEffect(() => { const handleBeforeUnload = (event: BeforeUnloadEvent) => { diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx index 2279de5a..bdf1a30a 100644 --- a/apps/web/src/components/ui/context-menu.tsx +++ b/apps/web/src/components/ui/context-menu.tsx @@ -36,7 +36,7 @@ const ContextMenuSub = ContextMenuPrimitive.Sub; const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; const contextMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0", + "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-foreground/85 outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-3.5 [&_svg]:shrink-0", { variants: { variant: { @@ -94,7 +94,7 @@ const ContextMenuSubContent = React.forwardRef< {icon && ( - + {icon} )} @@ -242,7 +242,7 @@ const ContextMenuLabel = React.forwardRef< (({ className, ...props }, ref) => ( )); diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 62e69cf1..4cc27132 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -1,293 +1,293 @@ -"use client"; - -import * as React from "react"; -import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; -import { Check, ChevronRight, Circle } from "lucide-react"; -import { cva, type VariantProps } from "class-variance-authority"; -import { cn } from "@/utils/ui"; -import { useOverlayOpenChange } from "./use-overlay-open-change"; - -function DropdownMenu({ - open, - onOpenChange, - ...props -}: React.ComponentProps) { - const handleOpenChange = useOverlayOpenChange({ - source: "dropdown-menu", - open, - onOpenChange, - }); - return ( - - ); -} - -const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -const DropdownMenuGroup = DropdownMenuPrimitive.Group; - -const DropdownMenuPortal = DropdownMenuPrimitive.Portal; - -const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -const dropdownMenuItemVariants = cva( - "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", - { - variants: { - variant: { - default: "", - destructive: - "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); - -const DropdownMenuSubTrigger = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - variant?: VariantProps["variant"]; - } ->(({ className, inset, children, variant = "default", ...props }, ref) => ( - - {children} - - -)); -DropdownMenuSubTrigger.displayName = - DropdownMenuPrimitive.SubTrigger.displayName; - -const DropdownMenuSubContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSubContent.displayName = - DropdownMenuPrimitive.SubContent.displayName; - -const DropdownMenuContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - - { - e.stopPropagation(); - e.preventDefault(); - }} - className={cn( - "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg", - className, - )} - {...props} - /> - -)); -DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; - -const DropdownMenuItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - icon?: React.ReactNode; - variant?: VariantProps["variant"]; - } ->( - ( - { - className, - inset, - icon, - variant = "default", - children, - asChild, - ...props - }, - ref, - ) => { - const iconSlot = ( - - {icon} - - ); - - const renderedChildren = - asChild && React.isValidElement(children) ? ( - React.cloneElement( - children as React.ReactElement<{ children?: React.ReactNode }>, - {}, - iconSlot, - (children as React.ReactElement<{ children?: React.ReactNode }>).props - .children, - ) - ) : ( - <> - {iconSlot} - {children} - - ); - - return ( - - {renderedChildren} - - ); - }, -); -DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; - -const DropdownMenuCheckboxItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - variant?: VariantProps["variant"]; - } ->(({ className, children, checked, variant = "default", ...props }, ref) => ( - { - e.preventDefault(); - }} - {...props} - > - {children} - - - - - - -)); - -DropdownMenuCheckboxItem.displayName = - DropdownMenuPrimitive.CheckboxItem.displayName; - -const DropdownMenuRadioItem = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - variant?: VariantProps["variant"]; - } ->(({ className, children, variant = "default", ...props }, ref) => ( - - - - - - - {children} - -)); -DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; - -const DropdownMenuLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & { - inset?: boolean; - } ->(({ className, inset, ...props }, ref) => ( - -)); -DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; - -const DropdownMenuSeparator = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; - -const DropdownMenuShortcut = ({ - className, - ...props -}: React.HTMLAttributes) => { - return ( - - ); -}; -DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; - -export { - DropdownMenu, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuGroup, - DropdownMenuPortal, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuRadioGroup, -}; +"use client"; + +import * as React from "react"; +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/utils/ui"; +import { useOverlayOpenChange } from "./use-overlay-open-change"; + +function DropdownMenu({ + open, + onOpenChange, + ...props +}: React.ComponentProps) { + const handleOpenChange = useOverlayOpenChange({ + source: "dropdown-menu", + open, + onOpenChange, + }); + return ( + + ); +} + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const dropdownMenuItemVariants = cva( + "relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2.5 py-1.5 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0", + { + variants: { + variant: { + default: "", + destructive: + "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + variant?: VariantProps["variant"]; + } +>(({ className, inset, children, variant = "default", ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + { + e.stopPropagation(); + e.preventDefault(); + }} + className={cn( + "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-md border p-1 shadow-lg", + className, + )} + {...props} + /> + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + icon?: React.ReactNode; + variant?: VariantProps["variant"]; + } +>( + ( + { + className, + inset, + icon, + variant = "default", + children, + asChild, + ...props + }, + ref, + ) => { + const iconSlot = ( + + {icon} + + ); + + const renderedChildren = + asChild && React.isValidElement(children) ? ( + React.cloneElement( + children as React.ReactElement<{ children?: React.ReactNode }>, + {}, + iconSlot, + (children as React.ReactElement<{ children?: React.ReactNode }>).props + .children, + ) + ) : ( + <> + {iconSlot} + {children} + + ); + + return ( + + {renderedChildren} + + ); + }, +); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + variant?: VariantProps["variant"]; + } +>(({ className, children, checked, variant = "default", ...props }, ref) => ( + { + e.preventDefault(); + }} + {...props} + > + {children} + + + + + + +)); + +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + variant?: VariantProps["variant"]; + } +>(({ className, children, variant = "default", ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/apps/web/src/components/ui/tabs.tsx b/apps/web/src/components/ui/tabs.tsx index e436e876..5e70f88d 100644 --- a/apps/web/src/components/ui/tabs.tsx +++ b/apps/web/src/components/ui/tabs.tsx @@ -50,11 +50,11 @@ const TabsTrigger = React.forwardRef< { - const tracks = this.timeline.getTracks(); - const prunedTracks = tracks.filter( - (track) => track.elements.length > 0 || isMainTrack(track), - ); - if (prunedTracks.length !== tracks.length) { + const activeScene = this.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return; + } + + const tracks = activeScene.tracks; + const prunedTracks = { + ...tracks, + overlay: tracks.overlay.filter((track) => track.elements.length > 0), + audio: tracks.audio.filter((track) => track.elements.length > 0), + }; + if ( + prunedTracks.overlay.length !== tracks.overlay.length || + prunedTracks.audio.length !== tracks.audio.length + ) { this.timeline.updateTracks(prunedTracks); } }); diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts index 0f642b1f..076b998d 100644 --- a/apps/web/src/core/managers/audio-manager.ts +++ b/apps/web/src/core/managers/audio-manager.ts @@ -1,708 +1,709 @@ -import type { EditorCore } from "@/core"; -import { - clampRetimeRate, - shouldMaintainPitch, -} from "@/lib/retime/rate"; -import type { AudioClipSource } from "@/lib/media/audio"; -import { createAudioContext, collectAudioClips } from "@/lib/media/audio"; -import { - buildAudioGainAutomation, - hasAnimatedVolume, -} from "@/lib/timeline/audio-state"; -import { createAudioMasteringChain } from "@/lib/media/audio-mastering"; -import { - getClipTimeAtSourceTime, - getSourceTimeAtClipTime, - renderRetimedBuffer, -} from "@/lib/retime"; -import { - ALL_FORMATS, - AudioBufferSink, - BlobSource, - Input, - type WrappedAudioBuffer, -} from "mediabunny"; - -export class AudioManager { - private audioContext: AudioContext | null = null; - private masterGain: GainNode | null = null; - private playbackStartTime = 0; - private playbackStartContextTime = 0; - private scheduleTimer: number | null = null; - private lookaheadSeconds = 2; - private scheduleIntervalMs = 500; - private clips: AudioClipSource[] = []; - private sinks = new Map(); - private inputs = new Map(); - private activeClipIds = new Set(); - private clipIterators = new Map< - string, - AsyncGenerator - >(); - private queuedSources = new Set(); - private preparedClipBuffers = new Map>(); - private decodedBuffers = new Map>(); - private playbackSessionId = 0; - private lastIsPlaying = false; - private lastVolume = 1; - private playbackLatencyCompensationSeconds = 0; - private unsubscribers: Array<() => void> = []; - - constructor(private editor: EditorCore) { - this.lastVolume = this.editor.playback.getVolume(); - - this.unsubscribers.push( - this.editor.playback.subscribe(this.handlePlaybackChange), - this.editor.timeline.subscribe(this.handleTimelineChange), - this.editor.media.subscribe(this.handleTimelineChange), - ); - if (typeof window !== "undefined") { - window.addEventListener("playback-seek", this.handleSeek); - } - } - - dispose(): void { - this.stopPlayback(); - for (const unsub of this.unsubscribers) { - unsub(); - } - this.unsubscribers = []; - if (typeof window !== "undefined") { - window.removeEventListener("playback-seek", this.handleSeek); - } - this.disposeSinks(); - this.preparedClipBuffers.clear(); - this.decodedBuffers.clear(); - if (this.audioContext) { - void this.audioContext.close(); - this.audioContext = null; - this.masterGain = null; - } - } - - private handlePlaybackChange = (): void => { - const isPlaying = this.editor.playback.getIsPlaying(); - const volume = this.editor.playback.getVolume(); - - if (volume !== this.lastVolume) { - this.lastVolume = volume; - this.updateGain(); - } - - if (isPlaying !== this.lastIsPlaying) { - this.lastIsPlaying = isPlaying; - if (isPlaying) { - void this.startPlayback({ - time: this.editor.playback.getCurrentTime(), - }); - } else { - this.stopPlayback(); - } - } - }; - - private handleSeek = (event: Event): void => { - const detail = (event as CustomEvent<{ time: number }>).detail; - if (!detail) return; - - if (this.editor.playback.getIsScrubbing()) { - this.stopPlayback(); - return; - } - - if (this.editor.playback.getIsPlaying()) { - void this.startPlayback({ time: detail.time }); - return; - } - - this.stopPlayback(); - }; - - private handleTimelineChange = (): void => { - this.disposeSinks(); - this.preparedClipBuffers.clear(); - this.decodedBuffers.clear(); - - if (!this.editor.playback.getIsPlaying()) return; - - void this.startPlayback({ time: this.editor.playback.getCurrentTime() }); - }; - - private ensureAudioContext(): AudioContext | null { - if (this.audioContext) return this.audioContext; - if (typeof window === "undefined") return null; - - this.audioContext = createAudioContext(); - const { input } = createAudioMasteringChain({ - audioContext: this.audioContext, - destination: this.audioContext.destination, - }); - this.masterGain = input; - this.masterGain.gain.value = this.lastVolume; - return this.audioContext; - } - - private updateGain(): void { - if (!this.masterGain) return; - this.masterGain.gain.value = this.lastVolume; - } - - private getPlaybackTime(): number { - if (!this.audioContext) return this.playbackStartTime; - const elapsed = - this.audioContext.currentTime - this.playbackStartContextTime; - return this.playbackStartTime + elapsed; - } - - private async startPlayback({ time }: { time: number }): Promise { - const audioContext = this.ensureAudioContext(); - if (!audioContext) return; - - this.stopPlayback(); - this.playbackSessionId++; - this.playbackLatencyCompensationSeconds = 0; - - const tracks = this.editor.timeline.getTracks(); - const mediaAssets = this.editor.media.getAssets(); - const duration = this.editor.timeline.getTotalDuration(); - - if (duration <= 0) return; - - if (audioContext.state === "suspended") { - await audioContext.resume(); - } - - this.clips = await collectAudioClips({ tracks, mediaAssets }); - if (!this.editor.playback.getIsPlaying()) return; - - this.playbackStartTime = time; - this.playbackStartContextTime = audioContext.currentTime; - - this.scheduleUpcomingClips(); - - if (typeof window !== "undefined") { - this.scheduleTimer = window.setInterval(() => { - this.scheduleUpcomingClips(); - }, this.scheduleIntervalMs); - } - } - - private scheduleUpcomingClips(): void { - if (!this.editor.playback.getIsPlaying()) return; - - const currentTime = this.getPlaybackTime(); - const windowEnd = currentTime + this.lookaheadSeconds; - - for (const clip of this.clips) { - if (clip.muted) continue; - if (this.activeClipIds.has(clip.id)) continue; - - const clipEnd = clip.startTime + clip.duration; - if (clipEnd <= currentTime) continue; - if (clip.startTime > windowEnd) continue; - - this.activeClipIds.add(clip.id); - if (this.shouldUsePreparedClipBuffer({ clip })) { - void this.schedulePreparedClip({ - clip, - startTime: currentTime, - sessionId: this.playbackSessionId, - }); - } else { - void this.runClipIterator({ - clip, - startTime: currentTime, - sessionId: this.playbackSessionId, - }); - } - } - } - - private stopPlayback(): void { - if (this.scheduleTimer && typeof window !== "undefined") { - window.clearInterval(this.scheduleTimer); - } - this.scheduleTimer = null; - - for (const iterator of this.clipIterators.values()) { - void iterator.return(); - } - this.clipIterators.clear(); - this.activeClipIds.clear(); - - for (const source of this.queuedSources) { - try { - source.stop(); - } catch {} - source.disconnect(); - } - this.queuedSources.clear(); - } - - private async runClipIterator({ - clip, - startTime, - sessionId, - }: { - clip: AudioClipSource; - startTime: number; - sessionId: number; - }): Promise { - const audioContext = this.ensureAudioContext(); - if (!audioContext) return; - - const sink = await this.getAudioSink({ clip }); - if (!sink || !this.editor.playback.getIsPlaying()) return; - if (sessionId !== this.playbackSessionId) return; - - const clipStart = clip.startTime; - const clipEnd = clip.startTime + clip.duration; - const playbackTimeAfterSinkReady = this.getPlaybackTime(); - const iteratorStartTime = Math.max( - startTime, - clipStart, - playbackTimeAfterSinkReady, - ); - if (iteratorStartTime >= clipEnd) { - return; - } - const sourceStartTime = - clip.trimStart + - getSourceTimeAtClipTime({ - clipTime: iteratorStartTime - clip.startTime, - retime: clip.retime, - }); - - const iterator = sink.buffers(sourceStartTime); - this.clipIterators.set(clip.id, iterator); - let consecutiveDroppedBufferCount = 0; - - for await (const { buffer, timestamp } of iterator) { - if (!this.editor.playback.getIsPlaying()) return; - if (sessionId !== this.playbackSessionId) return; - - const timelineTime = - clip.startTime + - getClipTimeAtSourceTime({ - sourceTime: timestamp - clip.trimStart, - retime: clip.retime, - }); - if (timelineTime >= clipEnd) break; - - const node = audioContext.createBufferSource(); - node.buffer = buffer; - if (clip.retime) { - node.playbackRate.value = clampRetimeRate({ rate: clip.retime.rate }); - } - const clipGain = audioContext.createGain(); - clipGain.gain.value = clip.volume; - node.connect(clipGain); - clipGain.connect(this.masterGain ?? audioContext.destination); - - const startTimestamp = - this.playbackStartContextTime + - this.playbackLatencyCompensationSeconds + - (timelineTime - this.playbackStartTime); - - if (startTimestamp >= audioContext.currentTime) { - node.start(startTimestamp); - consecutiveDroppedBufferCount = 0; - } else { - const offset = audioContext.currentTime - startTimestamp; - if (offset < buffer.duration) { - node.start(audioContext.currentTime, offset); - consecutiveDroppedBufferCount = 0; - } else { - consecutiveDroppedBufferCount += 1; - if (consecutiveDroppedBufferCount >= 5) { - const nextCompensationSeconds = Math.max( - this.playbackLatencyCompensationSeconds, - Math.min(0.25, offset + 0.01), - ); - if ( - nextCompensationSeconds > - this.playbackLatencyCompensationSeconds + 0.001 - ) { - this.playbackLatencyCompensationSeconds = nextCompensationSeconds; - } - const resyncStartTime = this.getPlaybackTime(); - this.clipIterators.delete(clip.id); - void this.runClipIterator({ - clip, - startTime: resyncStartTime, - sessionId, - }); - return; - } - continue; - } - } - - this.queuedSources.add(node); - node.addEventListener("ended", () => { - node.disconnect(); - clipGain.disconnect(); - this.queuedSources.delete(node); - }); - - const aheadTime = timelineTime - this.getPlaybackTime(); - if (aheadTime >= 1) { - await this.waitUntilCaughtUp({ timelineTime, targetAhead: 1 }); - if (sessionId !== this.playbackSessionId) return; - } - } - - this.clipIterators.delete(clip.id); - // don't remove from activeClipIds - prevents scheduler from restarting this clip - // the set is cleared on stopPlayback anyway - } - - private async schedulePreparedClip({ - clip, - startTime, - sessionId, - }: { - clip: AudioClipSource; - startTime: number; - sessionId: number; - }): Promise { - const audioContext = this.ensureAudioContext(); - if (!audioContext) return; - - const buffer = await this.getPreparedClipBuffer({ clip }); - if (!buffer || !this.editor.playback.getIsPlaying()) return; - if (sessionId !== this.playbackSessionId) return; - - const clipStart = clip.startTime; - const clipEnd = clip.startTime + clip.duration; - const playbackTimeAfterReady = this.getPlaybackTime(); - const effectiveStartTime = Math.max( - startTime, - clipStart, - playbackTimeAfterReady, - ); - if (effectiveStartTime >= clipEnd) { - return; - } - - const node = audioContext.createBufferSource(); - node.buffer = buffer; - const clipGain = audioContext.createGain(); - node.connect(clipGain); - clipGain.connect(this.masterGain ?? audioContext.destination); - - const startTimestamp = - this.playbackStartContextTime + - this.playbackLatencyCompensationSeconds + - (effectiveStartTime - this.playbackStartTime); - const clipOffset = effectiveStartTime - clipStart; - let actualStartTimestamp = startTimestamp; - let actualClipOffset = clipOffset; - - if (startTimestamp >= audioContext.currentTime) { - node.start(startTimestamp, clipOffset); - } else { - const lateOffset = audioContext.currentTime - startTimestamp; - actualStartTimestamp = audioContext.currentTime; - actualClipOffset = clipOffset + lateOffset; - node.start(actualStartTimestamp, actualClipOffset); - } - - this.scheduleClipGainAutomation({ - audioContext, - clip, - clipGain, - startTimestamp: actualStartTimestamp, - startLocalTime: actualClipOffset, - }); - - this.queuedSources.add(node); - node.addEventListener("ended", () => { - node.disconnect(); - clipGain.disconnect(); - this.queuedSources.delete(node); - }); - } - - private waitUntilCaughtUp({ - timelineTime, - targetAhead, - }: { - timelineTime: number; - targetAhead: number; - }): Promise { - return new Promise((resolve) => { - const checkInterval = setInterval(() => { - if (!this.editor.playback.getIsPlaying()) { - clearInterval(checkInterval); - resolve(); - return; - } - - const playbackTime = this.getPlaybackTime(); - if (timelineTime - playbackTime < targetAhead) { - clearInterval(checkInterval); - resolve(); - } - }, 100); - }); - } - - private disposeSinks(): void { - for (const iterator of this.clipIterators.values()) { - void iterator.return(); - } - this.clipIterators.clear(); - this.activeClipIds.clear(); - - for (const input of this.inputs.values()) { - input.dispose(); - } - this.inputs.clear(); - this.sinks.clear(); - } - - private shouldUsePreparedClipBuffer({ - clip, - }: { - clip: AudioClipSource; - }): boolean { - return ( - this.hasCurveRetime({ clip }) || - hasAnimatedVolume({ element: clip.timelineElement }) || - shouldMaintainPitch({ - rate: clip.retime?.rate ?? 1, - maintainPitch: clip.retime?.maintainPitch, - }) - ); - } - - private hasCurveRetime({ clip }: { clip: AudioClipSource }): boolean { - const mode = (clip.retime as { mode?: unknown } | undefined)?.mode; - return mode === "curve"; - } - - private scheduleClipGainAutomation({ - audioContext, - clip, - clipGain, - startTimestamp, - startLocalTime, - }: { - audioContext: AudioContext; - clip: AudioClipSource; - clipGain: GainNode; - startTimestamp: number; - startLocalTime: number; - }): void { - clipGain.gain.cancelScheduledValues(startTimestamp); - clipGain.gain.setValueAtTime(clip.volume, startTimestamp); - - if (!hasAnimatedVolume({ element: clip.timelineElement })) { - return; - } - - const points = buildAudioGainAutomation({ - element: clip.timelineElement, - fromLocalTime: startLocalTime, - toLocalTime: clip.duration, - }); - - if (points.length === 0) { - return; - } - - clipGain.gain.setValueAtTime(points[0].gain, startTimestamp); - for (let index = 1; index < points.length; index++) { - const point = points[index]; - const pointTimestamp = - startTimestamp + (point.localTime - startLocalTime); - if (pointTimestamp < audioContext.currentTime) { - continue; - } - - clipGain.gain.linearRampToValueAtTime(point.gain, pointTimestamp); - } - } - - private buildPreparedClipCacheKey({ - clip, - }: { - clip: AudioClipSource; - }): string { - return JSON.stringify({ - id: clip.id, - sourceKey: clip.sourceKey, - startTime: clip.startTime, - duration: clip.duration, - trimStart: clip.trimStart, - trimEnd: clip.trimEnd, - retime: clip.retime ?? null, - }); - } - - private async getPreparedClipBuffer({ - clip, - }: { - clip: AudioClipSource; - }): Promise { - const cacheKey = this.buildPreparedClipCacheKey({ clip }); - const existing = this.preparedClipBuffers.get(cacheKey); - if (existing) { - return existing; - } - - const promise = (async () => { - const audioContext = this.ensureAudioContext(); - if (!audioContext) { - return null; - } - - const decodedBuffer = await this.getDecodedBuffer({ clip }); - if (!decodedBuffer) { - return null; - } - - return await renderRetimedBuffer({ - audioContext, - sourceBuffer: decodedBuffer, - trimStart: clip.trimStart, - clipDuration: clip.duration, - retime: clip.retime, - maintainPitch: clip.retime?.maintainPitch === true, - }); - })(); - - this.preparedClipBuffers.set(cacheKey, promise); - return promise; - } - - private async getDecodedBuffer({ - clip, - }: { - clip: AudioClipSource; - }): Promise { - const existing = this.decodedBuffers.get(clip.sourceKey); - if (existing) { - return existing; - } - - const promise = this.decodeClipBuffer({ clip }); - this.decodedBuffers.set(clip.sourceKey, promise); - return promise; - } - - private async decodeClipBuffer({ - clip, - }: { - clip: AudioClipSource; - }): Promise { - const audioContext = this.ensureAudioContext(); - if (!audioContext) { - return null; - } - - const input = new Input({ - source: new BlobSource(clip.file), - formats: ALL_FORMATS, - }); - - try { - const audioTrack = await input.getPrimaryAudioTrack(); - if (!audioTrack) { - return null; - } - - const sink = new AudioBufferSink(audioTrack); - const chunks: AudioBuffer[] = []; - let totalSamples = 0; - - for await (const { buffer } of sink.buffers(0)) { - chunks.push(buffer); - totalSamples += buffer.length; - } - - if (chunks.length === 0) { - return null; - } - - const targetSampleRate = audioContext.sampleRate; - const nativeSampleRate = chunks[0].sampleRate; - const numChannels = Math.min(2, chunks[0].numberOfChannels); - const nativeChannels = Array.from( - { length: numChannels }, - () => new Float32Array(totalSamples), - ); - - let offset = 0; - for (const chunk of chunks) { - for (let channel = 0; channel < numChannels; channel++) { - nativeChannels[channel].set( - chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1)), - offset, - ); - } - offset += chunk.length; - } - - const outputSamples = Math.ceil( - totalSamples * (targetSampleRate / nativeSampleRate), - ); - const offlineContext = new OfflineAudioContext( - numChannels, - outputSamples, - targetSampleRate, - ); - const nativeBuffer = audioContext.createBuffer( - numChannels, - totalSamples, - nativeSampleRate, - ); - - for (let channel = 0; channel < numChannels; channel++) { - nativeBuffer.copyToChannel(nativeChannels[channel], channel); - } - - const sourceNode = offlineContext.createBufferSource(); - sourceNode.buffer = nativeBuffer; - sourceNode.connect(offlineContext.destination); - sourceNode.start(0); - - return await offlineContext.startRendering(); - } catch (error) { - console.warn("Failed to decode clip audio:", error); - return null; - } finally { - input.dispose(); - } - } - - private async getAudioSink({ - clip, - }: { - clip: AudioClipSource; - }): Promise { - const existingSink = this.sinks.get(clip.sourceKey); - if (existingSink) return existingSink; - - try { - const input = new Input({ - source: new BlobSource(clip.file), - formats: ALL_FORMATS, - }); - const audioTrack = await input.getPrimaryAudioTrack(); - if (!audioTrack) { - input.dispose(); - return null; - } - - const sink = new AudioBufferSink(audioTrack); - this.inputs.set(clip.sourceKey, input); - this.sinks.set(clip.sourceKey, sink); - return sink; - } catch (error) { - console.warn("Failed to initialize audio sink:", error); - return null; - } - } -} +import type { EditorCore } from "@/core"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; +import { + clampRetimeRate, + shouldMaintainPitch, +} from "@/lib/retime/rate"; +import type { AudioClipSource } from "@/lib/media/audio"; +import { createAudioContext, collectAudioClips } from "@/lib/media/audio"; +import { + buildAudioGainAutomation, + hasAnimatedVolume, +} from "@/lib/timeline/audio-state"; +import { createAudioMasteringChain } from "@/lib/media/audio-mastering"; +import { + getClipTimeAtSourceTime, + getSourceTimeAtClipTime, + renderRetimedBuffer, +} from "@/lib/retime"; +import { + ALL_FORMATS, + AudioBufferSink, + BlobSource, + Input, + type WrappedAudioBuffer, +} from "mediabunny"; + +export class AudioManager { + private audioContext: AudioContext | null = null; + private masterGain: GainNode | null = null; + private playbackStartTime = 0; + private playbackStartContextTime = 0; + private scheduleTimer: number | null = null; + private lookaheadSeconds = 2; + private scheduleIntervalMs = 500; + private clips: AudioClipSource[] = []; + private sinks = new Map(); + private inputs = new Map(); + private activeClipIds = new Set(); + private clipIterators = new Map< + string, + AsyncGenerator + >(); + private queuedSources = new Set(); + private preparedClipBuffers = new Map>(); + private decodedBuffers = new Map>(); + private playbackSessionId = 0; + private lastIsPlaying = false; + private lastVolume = 1; + private playbackLatencyCompensationSeconds = 0; + private unsubscribers: Array<() => void> = []; + + constructor(private editor: EditorCore) { + this.lastVolume = this.editor.playback.getVolume(); + + this.unsubscribers.push( + this.editor.playback.subscribe(this.handlePlaybackChange), + this.editor.timeline.subscribe(this.handleTimelineChange), + this.editor.media.subscribe(this.handleTimelineChange), + ); + if (typeof window !== "undefined") { + window.addEventListener("playback-seek", this.handleSeek); + } + } + + dispose(): void { + this.stopPlayback(); + for (const unsub of this.unsubscribers) { + unsub(); + } + this.unsubscribers = []; + if (typeof window !== "undefined") { + window.removeEventListener("playback-seek", this.handleSeek); + } + this.disposeSinks(); + this.preparedClipBuffers.clear(); + this.decodedBuffers.clear(); + if (this.audioContext) { + void this.audioContext.close(); + this.audioContext = null; + this.masterGain = null; + } + } + + private handlePlaybackChange = (): void => { + const isPlaying = this.editor.playback.getIsPlaying(); + const volume = this.editor.playback.getVolume(); + + if (volume !== this.lastVolume) { + this.lastVolume = volume; + this.updateGain(); + } + + if (isPlaying !== this.lastIsPlaying) { + this.lastIsPlaying = isPlaying; + if (isPlaying) { + void this.startPlayback({ + time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND, + }); + } else { + this.stopPlayback(); + } + } + }; + + private handleSeek = (event: Event): void => { + const detail = (event as CustomEvent<{ time: number }>).detail; + if (!detail) return; + + if (this.editor.playback.getIsScrubbing()) { + this.stopPlayback(); + return; + } + + if (this.editor.playback.getIsPlaying()) { + void this.startPlayback({ time: detail.time / TICKS_PER_SECOND }); + return; + } + + this.stopPlayback(); + }; + + private handleTimelineChange = (): void => { + this.disposeSinks(); + this.preparedClipBuffers.clear(); + this.decodedBuffers.clear(); + + if (!this.editor.playback.getIsPlaying()) return; + + void this.startPlayback({ time: this.editor.playback.getCurrentTime() / TICKS_PER_SECOND }); + }; + + private ensureAudioContext(): AudioContext | null { + if (this.audioContext) return this.audioContext; + if (typeof window === "undefined") return null; + + this.audioContext = createAudioContext(); + const { input } = createAudioMasteringChain({ + audioContext: this.audioContext, + destination: this.audioContext.destination, + }); + this.masterGain = input; + this.masterGain.gain.value = this.lastVolume; + return this.audioContext; + } + + private updateGain(): void { + if (!this.masterGain) return; + this.masterGain.gain.value = this.lastVolume; + } + + private getPlaybackTime(): number { + if (!this.audioContext) return this.playbackStartTime; + const elapsed = + this.audioContext.currentTime - this.playbackStartContextTime; + return this.playbackStartTime + elapsed; + } + + private async startPlayback({ time }: { time: number }): Promise { + const audioContext = this.ensureAudioContext(); + if (!audioContext) return; + + this.stopPlayback(); + this.playbackSessionId++; + this.playbackLatencyCompensationSeconds = 0; + + const tracks = this.editor.scenes.getActiveScene().tracks; + const mediaAssets = this.editor.media.getAssets(); + const duration = this.editor.timeline.getTotalDuration(); + + if (duration <= 0) return; + + if (audioContext.state === "suspended") { + await audioContext.resume(); + } + + this.clips = await collectAudioClips({ tracks, mediaAssets }); + if (!this.editor.playback.getIsPlaying()) return; + + this.playbackStartTime = time; + this.playbackStartContextTime = audioContext.currentTime; + + this.scheduleUpcomingClips(); + + if (typeof window !== "undefined") { + this.scheduleTimer = window.setInterval(() => { + this.scheduleUpcomingClips(); + }, this.scheduleIntervalMs); + } + } + + private scheduleUpcomingClips(): void { + if (!this.editor.playback.getIsPlaying()) return; + + const currentTime = this.getPlaybackTime(); + const windowEnd = currentTime + this.lookaheadSeconds; + + for (const clip of this.clips) { + if (clip.muted) continue; + if (this.activeClipIds.has(clip.id)) continue; + + const clipEnd = clip.startTime + clip.duration; + if (clipEnd <= currentTime) continue; + if (clip.startTime > windowEnd) continue; + + this.activeClipIds.add(clip.id); + if (this.shouldUsePreparedClipBuffer({ clip })) { + void this.schedulePreparedClip({ + clip, + startTime: currentTime, + sessionId: this.playbackSessionId, + }); + } else { + void this.runClipIterator({ + clip, + startTime: currentTime, + sessionId: this.playbackSessionId, + }); + } + } + } + + private stopPlayback(): void { + if (this.scheduleTimer && typeof window !== "undefined") { + window.clearInterval(this.scheduleTimer); + } + this.scheduleTimer = null; + + for (const iterator of this.clipIterators.values()) { + void iterator.return(); + } + this.clipIterators.clear(); + this.activeClipIds.clear(); + + for (const source of this.queuedSources) { + try { + source.stop(); + } catch {} + source.disconnect(); + } + this.queuedSources.clear(); + } + + private async runClipIterator({ + clip, + startTime, + sessionId, + }: { + clip: AudioClipSource; + startTime: number; + sessionId: number; + }): Promise { + const audioContext = this.ensureAudioContext(); + if (!audioContext) return; + + const sink = await this.getAudioSink({ clip }); + if (!sink || !this.editor.playback.getIsPlaying()) return; + if (sessionId !== this.playbackSessionId) return; + + const clipStart = clip.startTime; + const clipEnd = clip.startTime + clip.duration; + const playbackTimeAfterSinkReady = this.getPlaybackTime(); + const iteratorStartTime = Math.max( + startTime, + clipStart, + playbackTimeAfterSinkReady, + ); + if (iteratorStartTime >= clipEnd) { + return; + } + const sourceStartTime = + clip.trimStart + + getSourceTimeAtClipTime({ + clipTime: iteratorStartTime - clip.startTime, + retime: clip.retime, + }); + + const iterator = sink.buffers(sourceStartTime); + this.clipIterators.set(clip.id, iterator); + let consecutiveDroppedBufferCount = 0; + + for await (const { buffer, timestamp } of iterator) { + if (!this.editor.playback.getIsPlaying()) return; + if (sessionId !== this.playbackSessionId) return; + + const timelineTime = + clip.startTime + + getClipTimeAtSourceTime({ + sourceTime: timestamp - clip.trimStart, + retime: clip.retime, + }); + if (timelineTime >= clipEnd) break; + + const node = audioContext.createBufferSource(); + node.buffer = buffer; + if (clip.retime) { + node.playbackRate.value = clampRetimeRate({ rate: clip.retime.rate }); + } + const clipGain = audioContext.createGain(); + clipGain.gain.value = clip.volume; + node.connect(clipGain); + clipGain.connect(this.masterGain ?? audioContext.destination); + + const startTimestamp = + this.playbackStartContextTime + + this.playbackLatencyCompensationSeconds + + (timelineTime - this.playbackStartTime); + + if (startTimestamp >= audioContext.currentTime) { + node.start(startTimestamp); + consecutiveDroppedBufferCount = 0; + } else { + const offset = audioContext.currentTime - startTimestamp; + if (offset < buffer.duration) { + node.start(audioContext.currentTime, offset); + consecutiveDroppedBufferCount = 0; + } else { + consecutiveDroppedBufferCount += 1; + if (consecutiveDroppedBufferCount >= 5) { + const nextCompensationSeconds = Math.max( + this.playbackLatencyCompensationSeconds, + Math.min(0.25, offset + 0.01), + ); + if ( + nextCompensationSeconds > + this.playbackLatencyCompensationSeconds + 0.001 + ) { + this.playbackLatencyCompensationSeconds = nextCompensationSeconds; + } + const resyncStartTime = this.getPlaybackTime(); + this.clipIterators.delete(clip.id); + void this.runClipIterator({ + clip, + startTime: resyncStartTime, + sessionId, + }); + return; + } + continue; + } + } + + this.queuedSources.add(node); + node.addEventListener("ended", () => { + node.disconnect(); + clipGain.disconnect(); + this.queuedSources.delete(node); + }); + + const aheadTime = timelineTime - this.getPlaybackTime(); + if (aheadTime >= 1) { + await this.waitUntilCaughtUp({ timelineTime, targetAhead: 1 }); + if (sessionId !== this.playbackSessionId) return; + } + } + + this.clipIterators.delete(clip.id); + // don't remove from activeClipIds - prevents scheduler from restarting this clip + // the set is cleared on stopPlayback anyway + } + + private async schedulePreparedClip({ + clip, + startTime, + sessionId, + }: { + clip: AudioClipSource; + startTime: number; + sessionId: number; + }): Promise { + const audioContext = this.ensureAudioContext(); + if (!audioContext) return; + + const buffer = await this.getPreparedClipBuffer({ clip }); + if (!buffer || !this.editor.playback.getIsPlaying()) return; + if (sessionId !== this.playbackSessionId) return; + + const clipStart = clip.startTime; + const clipEnd = clip.startTime + clip.duration; + const playbackTimeAfterReady = this.getPlaybackTime(); + const effectiveStartTime = Math.max( + startTime, + clipStart, + playbackTimeAfterReady, + ); + if (effectiveStartTime >= clipEnd) { + return; + } + + const node = audioContext.createBufferSource(); + node.buffer = buffer; + const clipGain = audioContext.createGain(); + node.connect(clipGain); + clipGain.connect(this.masterGain ?? audioContext.destination); + + const startTimestamp = + this.playbackStartContextTime + + this.playbackLatencyCompensationSeconds + + (effectiveStartTime - this.playbackStartTime); + const clipOffset = effectiveStartTime - clipStart; + let actualStartTimestamp = startTimestamp; + let actualClipOffset = clipOffset; + + if (startTimestamp >= audioContext.currentTime) { + node.start(startTimestamp, clipOffset); + } else { + const lateOffset = audioContext.currentTime - startTimestamp; + actualStartTimestamp = audioContext.currentTime; + actualClipOffset = clipOffset + lateOffset; + node.start(actualStartTimestamp, actualClipOffset); + } + + this.scheduleClipGainAutomation({ + audioContext, + clip, + clipGain, + startTimestamp: actualStartTimestamp, + startLocalTime: actualClipOffset, + }); + + this.queuedSources.add(node); + node.addEventListener("ended", () => { + node.disconnect(); + clipGain.disconnect(); + this.queuedSources.delete(node); + }); + } + + private waitUntilCaughtUp({ + timelineTime, + targetAhead, + }: { + timelineTime: number; + targetAhead: number; + }): Promise { + return new Promise((resolve) => { + const checkInterval = setInterval(() => { + if (!this.editor.playback.getIsPlaying()) { + clearInterval(checkInterval); + resolve(); + return; + } + + const playbackTime = this.getPlaybackTime(); + if (timelineTime - playbackTime < targetAhead) { + clearInterval(checkInterval); + resolve(); + } + }, 100); + }); + } + + private disposeSinks(): void { + for (const iterator of this.clipIterators.values()) { + void iterator.return(); + } + this.clipIterators.clear(); + this.activeClipIds.clear(); + + for (const input of this.inputs.values()) { + input.dispose(); + } + this.inputs.clear(); + this.sinks.clear(); + } + + private shouldUsePreparedClipBuffer({ + clip, + }: { + clip: AudioClipSource; + }): boolean { + return ( + this.hasCurveRetime({ clip }) || + hasAnimatedVolume({ element: clip.timelineElement }) || + shouldMaintainPitch({ + rate: clip.retime?.rate ?? 1, + maintainPitch: clip.retime?.maintainPitch, + }) + ); + } + + private hasCurveRetime({ clip }: { clip: AudioClipSource }): boolean { + const mode = (clip.retime as { mode?: unknown } | undefined)?.mode; + return mode === "curve"; + } + + private scheduleClipGainAutomation({ + audioContext, + clip, + clipGain, + startTimestamp, + startLocalTime, + }: { + audioContext: AudioContext; + clip: AudioClipSource; + clipGain: GainNode; + startTimestamp: number; + startLocalTime: number; + }): void { + clipGain.gain.cancelScheduledValues(startTimestamp); + clipGain.gain.setValueAtTime(clip.volume, startTimestamp); + + if (!hasAnimatedVolume({ element: clip.timelineElement })) { + return; + } + + const points = buildAudioGainAutomation({ + element: clip.timelineElement, + fromLocalTime: startLocalTime, + toLocalTime: clip.duration, + }); + + if (points.length === 0) { + return; + } + + clipGain.gain.setValueAtTime(points[0].gain, startTimestamp); + for (let index = 1; index < points.length; index++) { + const point = points[index]; + const pointTimestamp = + startTimestamp + (point.localTime - startLocalTime); + if (pointTimestamp < audioContext.currentTime) { + continue; + } + + clipGain.gain.linearRampToValueAtTime(point.gain, pointTimestamp); + } + } + + private buildPreparedClipCacheKey({ + clip, + }: { + clip: AudioClipSource; + }): string { + return JSON.stringify({ + id: clip.id, + sourceKey: clip.sourceKey, + startTime: clip.startTime, + duration: clip.duration, + trimStart: clip.trimStart, + trimEnd: clip.trimEnd, + retime: clip.retime ?? null, + }); + } + + private async getPreparedClipBuffer({ + clip, + }: { + clip: AudioClipSource; + }): Promise { + const cacheKey = this.buildPreparedClipCacheKey({ clip }); + const existing = this.preparedClipBuffers.get(cacheKey); + if (existing) { + return existing; + } + + const promise = (async () => { + const audioContext = this.ensureAudioContext(); + if (!audioContext) { + return null; + } + + const decodedBuffer = await this.getDecodedBuffer({ clip }); + if (!decodedBuffer) { + return null; + } + + return await renderRetimedBuffer({ + audioContext, + sourceBuffer: decodedBuffer, + trimStart: clip.trimStart, + clipDuration: clip.duration, + retime: clip.retime, + maintainPitch: clip.retime?.maintainPitch === true, + }); + })(); + + this.preparedClipBuffers.set(cacheKey, promise); + return promise; + } + + private async getDecodedBuffer({ + clip, + }: { + clip: AudioClipSource; + }): Promise { + const existing = this.decodedBuffers.get(clip.sourceKey); + if (existing) { + return existing; + } + + const promise = this.decodeClipBuffer({ clip }); + this.decodedBuffers.set(clip.sourceKey, promise); + return promise; + } + + private async decodeClipBuffer({ + clip, + }: { + clip: AudioClipSource; + }): Promise { + const audioContext = this.ensureAudioContext(); + if (!audioContext) { + return null; + } + + const input = new Input({ + source: new BlobSource(clip.file), + formats: ALL_FORMATS, + }); + + try { + const audioTrack = await input.getPrimaryAudioTrack(); + if (!audioTrack) { + return null; + } + + const sink = new AudioBufferSink(audioTrack); + const chunks: AudioBuffer[] = []; + let totalSamples = 0; + + for await (const { buffer } of sink.buffers(0)) { + chunks.push(buffer); + totalSamples += buffer.length; + } + + if (chunks.length === 0) { + return null; + } + + const targetSampleRate = audioContext.sampleRate; + const nativeSampleRate = chunks[0].sampleRate; + const numChannels = Math.min(2, chunks[0].numberOfChannels); + const nativeChannels = Array.from( + { length: numChannels }, + () => new Float32Array(totalSamples), + ); + + let offset = 0; + for (const chunk of chunks) { + for (let channel = 0; channel < numChannels; channel++) { + nativeChannels[channel].set( + chunk.getChannelData(Math.min(channel, chunk.numberOfChannels - 1)), + offset, + ); + } + offset += chunk.length; + } + + const outputSamples = Math.ceil( + totalSamples * (targetSampleRate / nativeSampleRate), + ); + const offlineContext = new OfflineAudioContext( + numChannels, + outputSamples, + targetSampleRate, + ); + const nativeBuffer = audioContext.createBuffer( + numChannels, + totalSamples, + nativeSampleRate, + ); + + for (let channel = 0; channel < numChannels; channel++) { + nativeBuffer.copyToChannel(nativeChannels[channel], channel); + } + + const sourceNode = offlineContext.createBufferSource(); + sourceNode.buffer = nativeBuffer; + sourceNode.connect(offlineContext.destination); + sourceNode.start(0); + + return await offlineContext.startRendering(); + } catch (error) { + console.warn("Failed to decode clip audio:", error); + return null; + } finally { + input.dispose(); + } + } + + private async getAudioSink({ + clip, + }: { + clip: AudioClipSource; + }): Promise { + const existingSink = this.sinks.get(clip.sourceKey); + if (existingSink) return existingSink; + + try { + const input = new Input({ + source: new BlobSource(clip.file), + formats: ALL_FORMATS, + }); + const audioTrack = await input.getPrimaryAudioTrack(); + if (!audioTrack) { + input.dispose(); + return null; + } + + const sink = new AudioBufferSink(audioTrack); + this.inputs.set(clip.sourceKey, input); + this.sinks.set(clip.sourceKey, sink); + return sink; + } catch (error) { + console.warn("Failed to initialize audio sink:", error); + return null; + } + } +} diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts index 918fc33e..2f652a85 100644 --- a/apps/web/src/core/managers/commands.ts +++ b/apps/web/src/core/managers/commands.ts @@ -1,6 +1,7 @@ import type { EditorCore } from "@/core"; import type { Command, CommandResult } from "@/lib/commands"; -import type { ElementRef } from "@/lib/timeline/types"; +import { applyRippleAdjustments, computeRippleAdjustments } from "@/lib/ripple"; +import type { ElementRef, SceneTracks } from "@/lib/timeline/types"; interface CommandHistoryEntry { command: Command; @@ -9,6 +10,7 @@ interface CommandHistoryEntry { } export class CommandManager { + public isRippleEnabled = false; private history: CommandHistoryEntry[] = []; private redoStack: CommandHistoryEntry[] = []; private reactors: Array<() => void> = []; @@ -16,8 +18,12 @@ export class CommandManager { constructor(private editor: EditorCore) {} execute({ command }: { command: Command }): Command { + const beforeTracks = this.isRippleEnabled + ? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null + : null; const previousSelection = this.getSelectionSnapshot(); const result = command.execute(); + this.applyRippleIfEnabled({ beforeTracks }); const selectionOverride = this.applySelectionOverride(result); this.runReactors(); this.history.push({ @@ -67,8 +73,12 @@ export class CommandManager { return; } + const beforeTracks = this.isRippleEnabled + ? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null + : null; const previousSelection = this.getSelectionSnapshot(); const result = entry.command.redo(); + this.applyRippleIfEnabled({ beforeTracks }); const selectionOverride = this.applySelectionOverride(result); this.runReactors(); @@ -113,4 +123,32 @@ export class CommandManager { reactor(); } } + + private applyRippleIfEnabled({ + beforeTracks, + }: { + beforeTracks: SceneTracks | null; + }): void { + if (!this.isRippleEnabled || !beforeTracks) { + return; + } + + const afterTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks; + if (!afterTracks) { + return; + } + const adjustments = computeRippleAdjustments({ + beforeTracks, + afterTracks, + }); + if (adjustments.length === 0) { + return; + } + + const tracksWithRipple = applyRippleAdjustments({ + tracks: afterTracks, + adjustments, + }); + this.editor.timeline.updateTracks(tracksWithRipple); + } } diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 6943f187..47909bfe 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -1,4 +1,6 @@ import type { EditorCore } from "@/core"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; +import { roundToFrame } from "opencut-wasm"; export class PlaybackManager { private isPlaying = false; @@ -9,11 +11,12 @@ export class PlaybackManager { private isScrubbing = false; private listeners = new Set<() => void>(); private playbackTimer: number | null = null; - private lastUpdate = 0; + private playbackStartWallTime = 0; + private playbackStartTime = 0; constructor(private editor: EditorCore) { this.editor.timeline.subscribe(() => { - const maxTime = this.editor.timeline.getLastSeekableTime(); + const maxTime = this.editor.timeline.getTotalDuration(); if (this.currentTime > maxTime && maxTime > 0) { this.currentTime = maxTime; this.notify(); @@ -22,7 +25,7 @@ export class PlaybackManager { } play(): void { - const maxTime = this.editor.timeline.getLastSeekableTime(); + const maxTime = this.editor.timeline.getTotalDuration(); if (maxTime > 0) { if (this.currentTime >= maxTime) { @@ -50,8 +53,12 @@ export class PlaybackManager { } seek({ time }: { time: number }): void { - const maxTime = this.editor.timeline.getLastSeekableTime(); + const maxTime = this.editor.timeline.getTotalDuration(); this.currentTime = Math.max(0, Math.min(maxTime, time)); + if (this.isPlaying) { + this.playbackStartWallTime = performance.now(); + this.playbackStartTime = this.currentTime; + } this.notify(); window.dispatchEvent( @@ -135,7 +142,8 @@ export class PlaybackManager { cancelAnimationFrame(this.playbackTimer); } - this.lastUpdate = performance.now(); + this.playbackStartWallTime = performance.now(); + this.playbackStartTime = this.currentTime; this.updateTime(); } @@ -149,12 +157,11 @@ export class PlaybackManager { private updateTime = (): void => { if (!this.isPlaying) return; - const now = performance.now(); - const delta = (now - this.lastUpdate) / 1000; - this.lastUpdate = now; - - const newTime = this.currentTime + delta; - const maxTime = this.editor.timeline.getLastSeekableTime(); + const fps = this.editor.project.getActive()?.settings.fps; + const elapsedSeconds = (performance.now() - this.playbackStartWallTime) / 1000; + const rawTime = this.playbackStartTime + Math.round(elapsedSeconds * TICKS_PER_SECOND); + const newTime = fps ? (roundToFrame({ time: rawTime, rate: fps }) ?? rawTime) : rawTime; + const maxTime = this.editor.timeline.getTotalDuration(); if (maxTime > 0 && newTime >= maxTime) { this.pause(); diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 10a9229d..6324b988 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -156,9 +156,14 @@ export class ProjectManager { await this.editor.media.loadProjectMedia({ projectId: id }); - const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks); await loadFonts({ - families: getElementFontFamilies({ tracks: allTracks }), + families: [ + ...new Set( + (project.scenes ?? []).flatMap((scene) => + getElementFontFamilies({ tracks: scene.tracks }), + ), + ), + ], }); if (!project.metadata.thumbnail) { @@ -496,7 +501,7 @@ export class ProjectManager { importedAssets, }: { importedAssets: Array>; - }): number | null { + }): import("opencut-wasm").FrameRate | null { if (!this.active) return null; const nextFps = getRaisedProjectFpsForImportedMedia({ @@ -643,7 +648,7 @@ export class ProjectManager { private async updateThumbnailFromTimeline(): Promise { if (!this.active) return false; - const tracks = this.editor.timeline.getTracks(); + const tracks = this.editor.scenes.getActiveScene().tracks; const mediaAssets = this.editor.media.getAssets(); const duration = this.editor.timeline.getTotalDuration(); const { canvasSize, background } = this.active.settings; diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index aa34f496..eec4bf37 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -5,7 +5,8 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import { SceneExporter } from "@/services/renderer/scene-exporter"; import { buildScene } from "@/services/renderer/scene-builder"; import { createTimelineAudioBuffer } from "@/lib/media/audio"; -import { formatTimeCode } from "opencut-wasm"; +import { formatTimecode } from "opencut-wasm"; +import { frameRateToFloat } from "@/lib/fps/utils"; import { downloadBlob } from "@/utils/browser"; type SnapshotResult = @@ -81,7 +82,10 @@ export class RendererManager { } const { canvasSize, fps } = activeProject.settings; - const renderTime = this.editor.playback.getCurrentTime(); + const renderTime = Math.min( + this.editor.playback.getCurrentTime(), + this.editor.timeline.getLastFrameTime(), + ); const renderer = new CanvasRenderer({ width: canvasSize.width, @@ -107,7 +111,7 @@ export class RendererManager { return { success: false, error: "Failed to create image" }; } - const timecode = formatTimeCode({ timeInSeconds: renderTime, fps })!.replace(/:/g, "-"); + const timecode = formatTimecode({ time: renderTime, rate: fps })!.replace(/:/g, "-"); const safeName = activeProject.metadata.name.replace(/[<>:"/\\|?*]/g, "-").trim() || "snapshot"; @@ -135,7 +139,7 @@ export class RendererManager { const { format, quality, fps, includeAudio } = options; try { - const tracks = this.editor.timeline.getTracks(); + const tracks = this.editor.scenes.getActiveScene().tracks; const mediaAssets = this.editor.media.getAssets(); const activeProject = this.editor.project.getActive(); @@ -148,7 +152,7 @@ export class RendererManager { return { success: false, error: "Project is empty" }; } - const exportFps = fps || activeProject.settings.fps; + const exportFps = fps ?? activeProject.settings.fps; const canvasSize = activeProject.settings.canvasSize; let audioBuffer: AudioBuffer | null = null; diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts index 43938f96..3508a066 100644 --- a/apps/web/src/core/managers/scenes-manager.ts +++ b/apps/web/src/core/managers/scenes-manager.ts @@ -1,5 +1,5 @@ import type { EditorCore } from "@/core"; -import type { TimelineTrack, TScene } from "@/lib/timeline"; +import type { SceneTracks, TScene } from "@/lib/timeline"; import { storageService } from "@/services/storage/service"; import { getMainScene, @@ -12,7 +12,6 @@ import { getFrameTime, isBookmarkAtTime, } from "@/lib/timeline/bookmarks"; -import { ensureMainTrack } from "@/lib/timeline/placement"; import { CreateSceneCommand, DeleteSceneCommand, @@ -174,10 +173,7 @@ export class ScenesManager { try { const result = await storageService.loadProject({ id: projectId }); if (result?.project.scenes) { - const { scenes: ensuredScenes, hasAddedMainTrack } = - this.ensureScenesHaveMainTrack({ - scenes: result.project.scenes ?? [], - }); + const ensuredScenes = result.project.scenes ?? []; const currentScene = findCurrentScene({ scenes: ensuredScenes, currentSceneId: result.project.currentSceneId, @@ -186,22 +182,6 @@ export class ScenesManager { this.list = ensuredScenes; this.active = currentScene; this.notify(); - - if (hasAddedMainTrack) { - const activeProject = this.editor.project.getActive(); - if (activeProject) { - const updatedProject = { - ...activeProject, - scenes: ensuredScenes, - metadata: { - ...activeProject.metadata, - updatedAt: new Date(), - }, - }; - this.editor.project.setActiveProject({ project: updatedProject }); - this.editor.save.markDirty({ force: true }); - } - } } } catch (error) { console.error("Failed to load project scenes:", error); @@ -219,26 +199,24 @@ export class ScenesManager { currentSceneId?: string; }): void { const ensuredScenes = ensureMainScene({ scenes }); - const { scenes: scenesWithMainTracks, hasAddedMainTrack } = - this.ensureScenesHaveMainTrack({ scenes: ensuredScenes }); const currentScene = currentSceneId - ? scenesWithMainTracks.find((s) => s.id === currentSceneId) + ? ensuredScenes.find((s) => s.id === currentSceneId) : null; - const fallbackScene = getMainScene({ scenes: scenesWithMainTracks }); + const fallbackScene = getMainScene({ scenes: ensuredScenes }); - this.list = scenesWithMainTracks; + this.list = ensuredScenes; this.active = currentScene || fallbackScene; this.notify(); const hasAddedMainScene = ensuredScenes.length > scenes.length; - if (hasAddedMainScene || hasAddedMainTrack) { + if (hasAddedMainScene) { const activeProject = this.editor.project.getActive(); if (activeProject) { const updatedProject = { ...activeProject, - scenes: scenesWithMainTracks, + scenes: ensuredScenes, metadata: { ...activeProject.metadata, updatedAt: new Date(), @@ -264,6 +242,10 @@ export class ScenesManager { return this.active; } + getActiveSceneOrNull(): TScene | null { + return this.active; + } + getScenes(): TScene[] { return this.list; } @@ -307,7 +289,7 @@ export class ScenesManager { }); } - updateSceneTracks({ tracks }: { tracks: TimelineTrack[] }): void { + updateSceneTracks({ tracks }: { tracks: SceneTracks }): void { if (!this.active) return; const updatedScene: TScene = { @@ -335,29 +317,4 @@ export class ScenesManager { this.editor.project.setActiveProject({ project: updatedProject }); } } - - private ensureScenesHaveMainTrack({ scenes }: { scenes: TScene[] }): { - scenes: TScene[]; - hasAddedMainTrack: boolean; - } { - let hasAddedMainTrack = false; - const ensuredScenes: TScene[] = []; - - for (const scene of scenes) { - const existingTracks = scene.tracks ?? []; - const updatedTracks = ensureMainTrack({ tracks: existingTracks }); - if (updatedTracks !== existingTracks) { - hasAddedMainTrack = true; - ensuredScenes.push({ - ...scene, - tracks: updatedTracks, - updatedAt: new Date(), - }); - } else { - ensuredScenes.push(scene); - } - } - - return { scenes: ensuredScenes, hasAddedMainTrack }; - } } diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 6f262e57..74870a95 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -1,41 +1,47 @@ import type { EditorCore } from "@/core"; import type { ParamValues } from "@/lib/params"; import type { + SceneTracks, TrackType, TimelineTrack, TimelineElement, ClipboardItem, RetimeConfig, } from "@/lib/timeline"; +import { calculateTotalDuration } from "@/lib/timeline"; +import { + findTrackInSceneTracks, + updateElementInSceneTracks, +} from "@/lib/timeline/track-element-update"; +import { + canElementBeHidden, + canElementHaveAudio, +} from "@/lib/timeline/element-utils"; import type { AnimationPath, AnimationInterpolation, AnimationValue, + ScalarCurveKeyframePatch, } from "@/lib/animation/types"; -import { calculateTotalDuration } from "@/lib/timeline"; -import { getLastFrameTime } from "opencut-wasm"; +import { lastFrameTime } from "opencut-wasm"; +import { BatchCommand } from "@/lib/commands"; import { AddTrackCommand, RemoveTrackCommand, ToggleTrackMuteCommand, ToggleTrackVisibilityCommand, InsertElementCommand, - UpdateElementTrimCommand, - UpdateElementDurationCommand, DeleteElementsCommand, DuplicateElementsCommand, - ToggleElementsVisibilityCommand, - ToggleElementsMutedCommand, - UpdateElementCommand, + UpdateElementsCommand, SplitElementsCommand, PasteCommand, - UpdateElementStartTimeCommand, MoveElementCommand, TracksSnapshotCommand, - UpdateElementRetimeCommand, UpsertKeyframeCommand, RemoveKeyframeCommand, RetimeKeyframeCommand, + UpdateScalarKeyframeCurveCommand, AddClipEffectCommand, RemoveClipEffectCommand, UpdateClipEffectParamsCommand, @@ -47,13 +53,12 @@ import { RemoveEffectParamKeyframeCommand, ToggleSourceAudioSeparationCommand, } from "@/lib/commands/timeline"; -import { BatchCommand } from "@/lib/commands"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; export class TimelineManager { private listeners = new Set<() => void>(); private previewOverlay = new Map>(); - private previewTracks: TimelineTrack[] | null = null; + private previewTracks: SceneTracks | null = null; constructor(private editor: EditorCore) {} @@ -80,7 +85,6 @@ export class TimelineManager { startTime, duration, pushHistory = true, - rippleEnabled = false, }: { elementId: string; trimStart: number; @@ -88,44 +92,33 @@ export class TimelineManager { startTime?: number; duration?: number; pushHistory?: boolean; - rippleEnabled?: boolean; }): void { - const command = new UpdateElementTrimCommand({ - elementId, + const trackId = this.findTrackIdForElement({ elementId }); + if (!trackId) { + return; + } + + const nextUpdates: Partial = { trimStart, trimEnd, - startTime, - duration, - rippleEnabled, - }); - if (pushHistory) { - this.editor.command.execute({ command }); - } else { - command.execute(); + }; + if (startTime !== undefined) { + nextUpdates.startTime = startTime; + } + if (duration !== undefined) { + nextUpdates.duration = duration; } - } - updateElementDuration({ - trackId, - elementId, - duration, - pushHistory = true, - }: { - trackId: string; - elementId: string; - duration: number; - pushHistory?: boolean; - }): void { - const command = new UpdateElementDurationCommand({ - trackId, - elementId, - duration, + this.updateElements({ + updates: [ + { + trackId, + elementId, + patch: nextUpdates, + }, + ], + pushHistory, }); - if (pushHistory) { - this.editor.command.execute({ command }); - } else { - command.execute(); - } } updateElementRetime({ @@ -139,30 +132,18 @@ export class TimelineManager { retime?: RetimeConfig; pushHistory?: boolean; }): void { - const command = new UpdateElementRetimeCommand({ - trackId, - elementId, - retime, + this.updateElements({ + updates: [ + { + trackId, + elementId, + patch: { + retime, + }, + }, + ], + pushHistory, }); - if (pushHistory) { - this.editor.command.execute({ command }); - } else { - command.execute(); - } - } - - updateElementStartTime({ - elements, - startTime, - }: { - elements: { trackId: string; elementId: string }[]; - startTime: number; - }): void { - const command = new UpdateElementStartTimeCommand({ - elements, - startTime, - }); - this.editor.command.execute({ command }); } moveElement({ @@ -171,14 +152,12 @@ export class TimelineManager { elementId, newStartTime, createTrack, - rippleEnabled = false, }: { sourceTrackId: string; targetTrackId: string; elementId: string; newStartTime: number; createTrack?: { type: TrackType; index: number }; - rippleEnabled?: boolean; }): void { const command = new MoveElementCommand({ sourceTrackId, @@ -186,7 +165,6 @@ export class TimelineManager { elementId, newStartTime, createTrack, - rippleEnabled, }); this.editor.command.execute({ command }); } @@ -205,36 +183,43 @@ export class TimelineManager { elements, splitTime, retainSide = "both", - rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; splitTime: number; retainSide?: "both" | "left" | "right"; - rippleEnabled?: boolean; }): { trackId: string; elementId: string }[] { const command = new SplitElementsCommand({ elements, splitTime, retainSide, - rippleEnabled, }); this.editor.command.execute({ command }); return command.getRightSideElements(); } getTotalDuration(): number { - return calculateTotalDuration({ tracks: this.getTracks() }); + const activeScene = this.editor.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return 0; + } + + return calculateTotalDuration({ tracks: activeScene.tracks }); } - getLastSeekableTime(): number { + getLastFrameTime(): number { const duration = this.getTotalDuration(); const fps = this.editor.project.getActive()?.settings.fps; if (!fps || duration <= 0) return duration; - return getLastFrameTime({ duration, fps }); + return lastFrameTime({ duration, rate: fps }) ?? duration; } getTrackById({ trackId }: { trackId: string }): TimelineTrack | null { - return this.getTracks().find((track) => track.id === trackId) ?? null; + const activeScene = this.editor.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return null; + } + + return findTrackInSceneTracks({ tracks: activeScene.tracks, trackId }); } getElementsWithTracks({ @@ -273,12 +258,10 @@ export class TimelineManager { deleteElements({ elements, - rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; - rippleEnabled?: boolean; }): void { - const command = new DeleteElementsCommand({ elements, rippleEnabled }); + const command = new DeleteElementsCommand({ elements }); this.editor.command.execute({ command }); } @@ -303,20 +286,17 @@ export class TimelineManager { updates: Array<{ trackId: string; elementId: string; - updates: Partial; + patch: Partial; }>; pushHistory?: boolean; }): void { - const commands = updates.map( - ({ trackId, elementId, updates: elementUpdates }) => - new UpdateElementCommand({ - trackId, - elementId, - updates: elementUpdates, - }), - ); - const command = - commands.length === 1 ? commands[0] : new BatchCommand(commands); + if (updates.length === 0) { + return; + } + + const command = new UpdateElementsCommand({ + updates, + }); if (pushHistory) { this.editor.command.execute({ command }); } else { @@ -549,6 +529,45 @@ export class TimelineManager { this.editor.command.execute({ command }); } + updateKeyframeCurves({ + keyframes, + }: { + keyframes: Array<{ + trackId: string; + elementId: string; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; + }>; + }): void { + if (keyframes.length === 0) { + return; + } + + const commands = keyframes.map( + ({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }) => + new UpdateScalarKeyframeCurveCommand({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }), + ); + const command = + commands.length === 1 ? commands[0] : new BatchCommand(commands); + this.editor.command.execute({ command }); + } + upsertEffectParamKeyframe({ trackId, elementId, @@ -625,14 +644,20 @@ export class TimelineManager { } as Partial; this.previewOverlay.set(elementId, mergedOverlay); } - const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? []; + const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks; + if (!committedTracks) { + return; + } this.previewTracks = this.applyPreviewOverlay(committedTracks); this.notify(); } commitPreview(): void { if (this.previewOverlay.size === 0) return; - const committedTracks = this.editor.scenes.getActiveScene()?.tracks ?? []; + const committedTracks = this.editor.scenes.getActiveSceneOrNull()?.tracks; + if (!committedTracks) { + return; + } const afterTracks = this.previewTracks ?? this.applyPreviewOverlay(committedTracks); const command = new TracksSnapshotCommand(committedTracks, afterTracks); @@ -649,19 +674,32 @@ export class TimelineManager { this.notify(); } - private applyPreviewOverlay(tracks: TimelineTrack[]): TimelineTrack[] { + private applyPreviewOverlay(tracks: SceneTracks): SceneTracks { if (this.previewOverlay.size === 0) return tracks; - return tracks.map((track) => { - const hasOverlay = track.elements.some((el) => - this.previewOverlay.has(el.id), + + const applyTrackOverlay = (track: TTrack): TTrack => { + const hasOverlay = track.elements.some((element) => + this.previewOverlay.has(element.id), ); - if (!hasOverlay) return track; - const newElements = track.elements.map((el) => { - const overlay = this.previewOverlay.get(el.id); - return overlay ? ({ ...el, ...overlay } as TimelineElement) : el; + if (!hasOverlay) { + return track; + } + + const nextElements = track.elements.map((element) => { + const overlay = this.previewOverlay.get(element.id); + return overlay + ? ({ ...element, ...overlay } as TimelineElement) + : element; }); - return { ...track, elements: newElements } as TimelineTrack; - }); + + return { ...track, elements: nextElements } as TTrack; + }; + + return { + overlay: tracks.overlay.map((track) => applyTrackOverlay(track)), + main: applyTrackOverlay(tracks.main), + audio: tracks.audio.map((track) => applyTrackOverlay(track)), + }; } duplicateElements({ @@ -679,8 +717,27 @@ export class TimelineManager { }: { elements: { trackId: string; elementId: string }[]; }): void { - const command = new ToggleElementsVisibilityCommand(elements); - this.editor.command.execute({ command }); + const shouldHide = elements.some(({ trackId, elementId }) => { + const element = this.getElementByRef({ trackId, elementId }); + return element && canElementBeHidden(element) && !element.hidden; + }); + + const nextUpdates = elements.flatMap(({ trackId, elementId }) => { + const element = this.getElementByRef({ trackId, elementId }); + if (!element || !canElementBeHidden(element)) { + return []; + } + + return [ + { + trackId, + elementId, + patch: { hidden: shouldHide }, + }, + ]; + }); + + this.updateElements({ updates: nextUpdates }); } toggleElementsMuted({ @@ -688,17 +745,31 @@ export class TimelineManager { }: { elements: { trackId: string; elementId: string }[]; }): void { - const command = new ToggleElementsMutedCommand(elements); - this.editor.command.execute({ command }); + const shouldMute = elements.some(({ trackId, elementId }) => { + const element = this.getElementByRef({ trackId, elementId }); + return element && canElementHaveAudio(element) && !element.muted; + }); + + const nextUpdates = elements.flatMap(({ trackId, elementId }) => { + const element = this.getElementByRef({ trackId, elementId }); + if (!element || !canElementHaveAudio(element)) { + return []; + } + + return [ + { + trackId, + elementId, + patch: { muted: shouldMute }, + }, + ]; + }); + + this.updateElements({ updates: nextUpdates }); } - getTracks(): TimelineTrack[] { - return this.editor.scenes.getActiveScene()?.tracks ?? []; - } - - getRenderTracks(): TimelineTrack[] { - if (this.previewTracks !== null) return this.previewTracks; - return this.getTracks(); + getPreviewTracks(): SceneTracks | null { + return this.previewTracks ?? this.editor.scenes.getActiveSceneOrNull()?.tracks ?? null; } subscribe(listener: () => void): () => void { @@ -712,7 +783,52 @@ export class TimelineManager { }); } - updateTracks(newTracks: TimelineTrack[]): void { + private getElementByRef({ + trackId, + elementId, + }: { + trackId: string; + elementId: string; + }): TimelineElement | undefined { + return this.getTrackById({ trackId })?.elements.find( + (element) => element.id === elementId, + ); + } + + private findTrackIdForElement({ + elementId, + }: { + elementId: string; + }): string | null { + const activeScene = this.editor.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return null; + } + + if ( + activeScene.tracks.main.elements.some( + (element) => element.id === elementId, + ) + ) { + return activeScene.tracks.main.id; + } + + for (const track of activeScene.tracks.overlay) { + if (track.elements.some((element) => element.id === elementId)) { + return track.id; + } + } + + for (const track of activeScene.tracks.audio) { + if (track.elements.some((element) => element.id === elementId)) { + return track.id; + } + } + + return null; + } + + updateTracks(newTracks: SceneTracks): void { this.previewOverlay.clear(); this.previewTracks = null; this.editor.scenes.updateSceneTracks({ tracks: newTracks }); diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 87aa6b48..be04a7d3 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -5,6 +5,7 @@ import { useTimelineStore } from "@/stores/timeline-store"; import { useActionHandler } from "@/hooks/actions/use-action-handler"; import { useEditor } from "../use-editor"; import { useElementSelection } from "../timeline/element/use-element-selection"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; import { useKeyframeSelection } from "../timeline/element/use-keyframe-selection"; import { getElementsAtTime, @@ -110,10 +111,11 @@ export function useEditorActions() { "frame-step-forward", () => { const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator); editor.playback.seek({ time: Math.min( editor.timeline.getTotalDuration(), - editor.playback.getCurrentTime() + 1 / fps, + editor.playback.getCurrentTime() + ticksPerFrame, ), }); }, @@ -124,8 +126,9 @@ export function useEditorActions() { "frame-step-backward", () => { const fps = editor.project.getActive().settings.fps; + const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator); editor.playback.seek({ - time: Math.max(0, editor.playback.getCurrentTime() - 1 / fps), + time: Math.max(0, editor.playback.getCurrentTime() - ticksPerFrame), }); }, undefined, @@ -176,11 +179,12 @@ export function useEditorActions() { "split", () => { const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; const elementsToSplit = selectedElements.length > 0 ? selectedElements : getElementsAtTime({ - tracks: editor.timeline.getTracks(), + tracks, time: currentTime, }); @@ -198,11 +202,12 @@ export function useEditorActions() { "split-left", () => { const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; const elementsToSplit = selectedElements.length > 0 ? selectedElements : getElementsAtTime({ - tracks: editor.timeline.getTracks(), + tracks, time: currentTime, }); @@ -212,7 +217,6 @@ export function useEditorActions() { elements: elementsToSplit, splitTime: currentTime, retainSide: "right", - rippleEnabled: rippleEditingEnabled, }); if (rippleEditingEnabled && rightSideElements.length > 0) { @@ -231,11 +235,12 @@ export function useEditorActions() { "split-right", () => { const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; const elementsToSplit = selectedElements.length > 0 ? selectedElements : getElementsAtTime({ - tracks: editor.timeline.getTracks(), + tracks, time: currentTime, }); @@ -263,7 +268,6 @@ export function useEditorActions() { } editor.timeline.deleteElements({ elements: selectedElements, - rippleEnabled: rippleEditingEnabled, }); }, undefined, @@ -294,12 +298,7 @@ export function useEditorActions() { null ); })(); - if ( - !canToggleSourceAudio({ - element: selectedElement.element, - mediaAsset, - }) - ) { + if (!canToggleSourceAudio(selectedElement.element, mediaAsset)) { return; } @@ -314,7 +313,12 @@ export function useEditorActions() { useActionHandler( "select-all", () => { - const allElements = editor.timeline.getTracks().flatMap((track) => + const scene = editor.scenes.getActiveScene(); + const allElements = [ + ...scene.tracks.overlay, + scene.tracks.main, + ...scene.tracks.audio, + ].flatMap((track) => track.elements.map((element) => ({ trackId: track.id, elementId: element.id, diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts index 193379ed..98918bea 100644 --- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts +++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts @@ -8,11 +8,11 @@ import { } from "react"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; -import { useTimelineStore } from "@/stores/timeline-store"; import { useElementSelection } from "@/hooks/timeline/element/use-element-selection"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; -import { snapTimeToFrame } from "opencut-wasm"; +import { roundToFrame } from "opencut-wasm"; import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { generateUUID } from "@/utils/id"; @@ -21,6 +21,7 @@ import { registerCanceller } from "@/lib/cancel-interaction"; import type { DropTarget, ElementDragState, + SceneTracks, TimelineElement, TimelineTrack, } from "@/lib/timeline"; @@ -68,7 +69,8 @@ function getClickOffsetTime({ zoomLevel: number; }): number { const clickOffsetX = clientX - elementRect.left; - return clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); + const seconds = clickOffsetX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); + return Math.round(seconds * TICKS_PER_SECOND); } function getVerticalDragDirection({ @@ -100,7 +102,7 @@ function getDragDropTarget({ clientY: number; elementId: string; trackId: string; - tracks: TimelineTrack[]; + tracks: SceneTracks; tracksContainerRef: RefObject; tracksScrollRef: RefObject; headerRef?: RefObject; @@ -112,7 +114,11 @@ function getDragDropTarget({ const scrollContainer = tracksScrollRef.current; if (!containerRect || !scrollContainer) return null; - const sourceTrack = tracks.find(({ id }) => id === trackId); + const sourceTrack = [ + ...tracks.overlay, + tracks.main, + ...tracks.audio, + ].find(({ id }) => id === trackId); const movingElement = sourceTrack?.elements.find( ({ id }) => id === elementId, ); @@ -161,9 +167,13 @@ export function useElementInteraction({ onSnapPointChange, }: UseElementInteractionProps) { const editor = useEditor(); - const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const isShiftHeldRef = useShiftKey(); - const tracks = editor.timeline.getTracks(); + const sceneTracks = editor.scenes.getActiveScene().tracks; + const tracks = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ]; const { isElementSelected, selectElement, @@ -242,7 +252,7 @@ export function useElementInteraction({ const startSnap = snapElementEdge({ targetTime: frameSnappedTime, elementDuration, - tracks, + tracks: sceneTracks, playheadTime, zoomLevel, excludeElementId: movingElement.id, @@ -252,7 +262,7 @@ export function useElementInteraction({ const endSnap = snapElementEdge({ targetTime: frameSnappedTime, elementDuration, - tracks, + tracks: sceneTracks, playheadTime, zoomLevel, excludeElementId: movingElement.id, @@ -299,14 +309,17 @@ export function useElementInteraction({ zoomLevel, scrollLeft, }); - const adjustedTime = Math.max( - 0, - mouseTime - pendingDragRef.current.clickOffsetTime, - ); - const snappedTime = snapTimeToFrame({ time: adjustedTime, fps: activeProject.settings.fps }); - startDrag({ - ...pendingDragRef.current, - initialCurrentTime: snappedTime, + const adjustedTime = Math.max( + 0, + mouseTime - pendingDragRef.current.clickOffsetTime, + ); + const snappedTime = roundToFrame({ + time: adjustedTime, + rate: activeProject.settings.fps, + }) ?? adjustedTime; + startDrag({ + ...pendingDragRef.current, + initialCurrentTime: snappedTime, initialCurrentMouseY: clientY, }); startedDragThisEvent = true; @@ -346,7 +359,7 @@ export function useElementInteraction({ }); const adjustedTime = Math.max(0, mouseTime - dragState.clickOffsetTime); const fps = activeProject.settings.fps; - const frameSnappedTime = snapTimeToFrame({ time: adjustedTime, fps }); + const frameSnappedTime = roundToFrame({ time: adjustedTime, rate: fps }) ?? adjustedTime; const sourceTrack = tracks.find(({ id }) => id === dragState.trackId); const movingElement = sourceTrack?.elements.find( @@ -373,7 +386,7 @@ export function useElementInteraction({ clientY, elementId: dragState.elementId, trackId: dragState.trackId, - tracks, + tracks: sceneTracks, tracksContainerRef, tracksScrollRef, headerRef, @@ -433,7 +446,7 @@ export function useElementInteraction({ clientY, elementId: dragState.elementId, trackId: dragState.trackId, - tracks, + tracks: sceneTracks, tracksContainerRef, tracksScrollRef, headerRef, @@ -457,6 +470,18 @@ export function useElementInteraction({ onSnapPointChange?.(null); return; } + const movingElement = + sourceTrack.elements.find(({ id }) => id === dragState.elementId) ?? null; + if ( + movingElement && + !dropTarget.isNewTrack && + tracks[dropTarget.trackIndex]?.id === dragState.trackId && + snappedTime === movingElement.startTime + ) { + endDrag(); + onSnapPointChange?.(null); + return; + } if (dropTarget.isNewTrack) { const newTrackId = generateUUID(); @@ -467,7 +492,6 @@ export function useElementInteraction({ elementId: dragState.elementId, newStartTime: snappedTime, createTrack: { type: sourceTrack.type, index: dropTarget.trackIndex }, - rippleEnabled: rippleEditingEnabled, }); selectElement({ trackId: newTrackId, elementId: dragState.elementId }); } else { @@ -478,7 +502,6 @@ export function useElementInteraction({ targetTrackId: targetTrack.id, elementId: dragState.elementId, newStartTime: snappedTime, - rippleEnabled: rippleEditingEnabled, }); if (targetTrack.id !== dragState.trackId) { selectElement({ @@ -509,7 +532,6 @@ export function useElementInteraction({ tracksContainerRef, tracksScrollRef, headerRef, - rippleEditingEnabled, selectElement, ]); diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts index 890820d7..bb0d880c 100644 --- a/apps/web/src/hooks/timeline/element/use-element-resize.ts +++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { snapTimeToFrame } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; +import { roundToFrame } from "opencut-wasm"; import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; @@ -45,9 +46,6 @@ export function useTimelineElementResize({ const editor = useEditor(); const isShiftHeldRef = useShiftKey(); const snappingEnabled = useTimelineStore((state) => state.snappingEnabled); - const rippleEditingEnabled = useTimelineStore( - (state) => state.rippleEditingEnabled, - ); const [resizing, setResizing] = useState(null); const [currentTrimStart, setCurrentTrimStart] = useState(element.trimStart); @@ -188,16 +186,17 @@ export function useTimelineElementResize({ ({ clientX }: { clientX: number }) => { if (!resizing) return; - const deltaX = clientX - resizing.startX; - let deltaTime = - deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel); - let resizeSnapPoint: SnapPoint | null = null; + const deltaX = clientX - resizing.startX; + let deltaTime = Math.round( + (deltaX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel)) * TICKS_PER_SECOND, + ); + let resizeSnapPoint: SnapPoint | null = null; - const projectFps = editor.project.getActive().settings.fps; - const minDurationSeconds = 1 / projectFps; + const projectFps = editor.project.getActive().settings.fps; + const minDuration = Math.round(TICKS_PER_SECOND * projectFps.denominator / projectFps.numerator); const shouldSnap = snappingEnabled && !isShiftHeldRef.current; if (shouldSnap) { - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const playheadTime = editor.playback.getCurrentTime(); const snapPoints = findSnapPoints({ tracks, @@ -280,19 +279,19 @@ export function useTimelineElementResize({ const maxAllowed = sourceDuration - resizing.initialTrimEnd - - getVisibleSourceSpanForDuration(minDurationSeconds); + getVisibleSourceSpanForDuration(minDuration); const calculated = resizing.initialTrimStart + getSourceDeltaForClipDelta(deltaTime); if (calculated >= 0 && calculated <= maxAllowed) { - const newTrimStart = snapTimeToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), fps: projectFps }); + const newTrimStart = roundToFrame({ time: Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)), rate: projectFps }) ?? Math.min(maxAllowed, Math.max(minTrimStartForNeighbor, calculated)); const visibleSourceSpan = Math.max( 0, sourceDuration - newTrimStart - resizing.initialTrimEnd, ); - const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps }); + const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan); const trimDelta = resizing.initialDuration - newDuration; - const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + trimDelta, fps: projectFps }); + const newStartTime = roundToFrame({ time: resizing.initialStartTime + trimDelta, rate: projectFps }) ?? resizing.initialStartTime + trimDelta; setCurrentTrimStart(newTrimStart); setCurrentStartTime(newStartTime); @@ -314,8 +313,8 @@ export function useTimelineElementResize({ ) : Math.min(extensionAmount, maxExtension), ); - const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime - actualExtension, fps: projectFps }); - const newDuration = snapTimeToFrame({ time: resizing.initialDuration + actualExtension, fps: projectFps }); + const newStartTime = roundToFrame({ time: resizing.initialStartTime - actualExtension, rate: projectFps }) ?? resizing.initialStartTime - actualExtension; + const newDuration = roundToFrame({ time: resizing.initialDuration + actualExtension, rate: projectFps }) ?? resizing.initialDuration + actualExtension; setCurrentTrimStart(0); setCurrentStartTime(newStartTime); @@ -341,8 +340,8 @@ export function useTimelineElementResize({ 0, sourceDuration - newTrimStart - resizing.initialTrimEnd, ); - const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps }); - const newStartTime = snapTimeToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), fps: projectFps }); + const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan); + const newStartTime = roundToFrame({ time: resizing.initialStartTime + (resizing.initialDuration - newDuration), rate: projectFps }) ?? resizing.initialStartTime + (resizing.initialDuration - newDuration); setCurrentTrimStart(newTrimStart); setCurrentStartTime(newStartTime); @@ -369,7 +368,7 @@ export function useTimelineElementResize({ const extensionNeeded = Math.abs(newTrimEnd); const baseDuration = resizing.initialDuration + resizing.initialTrimEnd; - const newDuration = snapTimeToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), fps: projectFps }); + const newDuration = roundToFrame({ time: Math.min(baseDuration + extensionNeeded, maxAllowedDuration), rate: projectFps }) ?? Math.min(baseDuration + extensionNeeded, maxAllowedDuration); setCurrentDuration(newDuration); setCurrentTrimEnd(0); @@ -379,7 +378,7 @@ export function useTimelineElementResize({ const unclampedDuration = getDurationForVisibleSourceSpan( Math.max(0, sourceDuration - resizing.initialTrimStart), ); - const newDuration = snapTimeToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), fps: projectFps }); + const newDuration = roundToFrame({ time: Math.min(unclampedDuration, maxAllowedDuration), rate: projectFps }) ?? Math.min(unclampedDuration, maxAllowedDuration); setCurrentDuration(newDuration); setCurrentTrimEnd(0); @@ -398,17 +397,17 @@ export function useTimelineElementResize({ const maxTrimEnd = sourceDuration - resizing.initialTrimStart - - getVisibleSourceSpanForDuration(minDurationSeconds); + getVisibleSourceSpanForDuration(minDuration); const clampedTrimEnd = Math.min( maxTrimEnd, Math.max(minTrimEndForNeighbor, newTrimEnd), ); - const finalTrimEnd = snapTimeToFrame({ time: clampedTrimEnd, fps: projectFps }); + const finalTrimEnd = roundToFrame({ time: clampedTrimEnd, rate: projectFps }) ?? clampedTrimEnd; const visibleSourceSpan = Math.max( 0, sourceDuration - resizing.initialTrimStart - finalTrimEnd, ); - const newDuration = snapTimeToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), fps: projectFps }); + const newDuration = roundToFrame({ time: getDurationForVisibleSourceSpan(visibleSourceSpan), rate: projectFps }) ?? getDurationForVisibleSourceSpan(visibleSourceSpan); setCurrentTrimEnd(finalTrimEnd); setCurrentDuration(newDuration); @@ -458,7 +457,6 @@ export function useTimelineElementResize({ trimEnd: finalTrimEnd, startTime: startTimeChanged ? finalStartTime : undefined, duration: durationChanged ? finalDuration : undefined, - rippleEnabled: rippleEditingEnabled, }); } @@ -471,7 +469,6 @@ export function useTimelineElementResize({ element.id, onResizeStateChange, onSnapPointChange, - rippleEditingEnabled, ]); useEffect(() => { diff --git a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts index bbe843f2..24bff26a 100644 --- a/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts +++ b/apps/web/src/hooks/timeline/element/use-keyframe-drag.ts @@ -6,10 +6,12 @@ import { type MouseEvent as ReactMouseEvent, } from "react"; import { useEditor } from "@/hooks/use-editor"; +import { getKeyframeById } from "@/lib/animation"; import { useKeyframeSelection } from "./use-keyframe-selection"; -import { snapTimeToFrame, getSnappedSeekTime } from "opencut-wasm"; +import { roundToFrame, snappedSeekTime } from "opencut-wasm"; import { timelineTimeToSnappedPixels } from "@/lib/timeline"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; import { RetimeKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/retime-keyframe"; import { BatchCommand } from "@/lib/commands"; @@ -84,10 +86,11 @@ export function useKeyframeDrag({ deltaTime: number; }) => { const commands: Command[] = keyframeRefs.flatMap((keyframeRef) => { - const channel = element.animations?.channels[keyframeRef.propertyPath]; - const keyframe = channel?.keyframes.find( - (keyframe) => keyframe.id === keyframeRef.keyframeId, - ); + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath: keyframeRef.propertyPath, + keyframeId: keyframeRef.keyframeId, + }); if (!keyframe) return []; const nextTime = Math.max( 0, @@ -142,9 +145,9 @@ export function useKeyframeDrag({ if (!dragState.isDragging) return; - const startX = mouseDownXRef.current ?? clientX; - const rawDelta = (clientX - startX) / pixelsPerSecond; - const snappedDelta = snapTimeToFrame({ time: rawDelta, fps }); + const startX = mouseDownXRef.current ?? clientX; + const rawDelta = Math.round(((clientX - startX) / pixelsPerSecond) * TICKS_PER_SECOND); + const snappedDelta = roundToFrame({ time: rawDelta, rate: fps }) ?? rawDelta; setDragState((previous) => ({ ...previous, deltaTime: snappedDelta })); }; @@ -253,7 +256,7 @@ export function useKeyframeDrag({ if (wasDrag) return; const duration = editor.timeline.getTotalDuration(); - const seekTime = getSnappedSeekTime({ rawTime: displayedStartTime + indicatorTime, duration, fps }); + const seekTime = snappedSeekTime({ time: displayedStartTime + indicatorTime, duration, rate: fps }) ?? displayedStartTime + indicatorTime; editor.playback.seek({ time: seekTime }); if (event.shiftKey) { diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts index 14d58d9a..3ff1cb21 100644 --- a/apps/web/src/hooks/timeline/use-bookmark-drag.ts +++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts @@ -8,7 +8,7 @@ import { import { useEditor } from "@/hooks/use-editor"; import { useShiftKey } from "@/hooks/use-shift-key"; import { TIMELINE_DRAG_THRESHOLD_PX } from "@/components/editor/panels/timeline/interaction"; -import { snapTimeToFrame } from "opencut-wasm"; +import { roundToFrame } from "opencut-wasm"; import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils"; import { findSnapPoints, @@ -44,7 +44,7 @@ export function useBookmarkDrag({ }: UseBookmarkDragProps) { const editor = useEditor(); const isShiftHeldRef = useShiftKey(); - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const activeScene = editor.scenes.getActiveScene(); const bookmarks = activeScene?.bookmarks ?? []; const playheadTime = editor.playback.getCurrentTime(); @@ -148,7 +148,7 @@ export function useBookmarkDrag({ zoomLevel, scrollLeft, }); - const frameSnappedTime = snapTimeToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), fps: activeProject.settings.fps }); + const frameSnappedTime = roundToFrame({ time: Math.max(0, Math.min(mouseTime, duration)), rate: activeProject.settings.fps }) ?? Math.max(0, Math.min(mouseTime, duration)); const { snappedTime: initialTime } = getSnapResult({ rawTime: frameSnappedTime, excludeBookmarkTime: bookmarkTime, @@ -176,9 +176,9 @@ export function useBookmarkDrag({ scrollLeft, }); const clampedTime = Math.max(0, Math.min(mouseTime, duration)); - const frameSnappedTime = snapTimeToFrame({ time: clampedTime, fps: activeProject.settings.fps }); - const snapResult = getSnapResult({ - rawTime: frameSnappedTime, + const frameSnappedTime = roundToFrame({ time: clampedTime, rate: activeProject.settings.fps }) ?? clampedTime; + const snapResult = getSnapResult({ + rawTime: frameSnappedTime, excludeBookmarkTime: dragState.bookmarkTime, }); diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts index 8e87510c..009f0ff6 100644 --- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts +++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts @@ -3,9 +3,9 @@ import { useEditor } from "@/hooks/use-editor"; import { processMediaAssets } from "@/lib/media/processing"; import { toast } from "sonner"; import { showMediaUploadToast } from "@/lib/media/upload-toast"; -import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { snapTimeToFrame } from "opencut-wasm"; +import { roundToFrame } from "opencut-wasm"; import { buildTextElement, buildGraphicElement, @@ -17,7 +17,6 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; import { BatchCommand } from "@/lib/commands"; import { computeDropTarget } from "@/components/editor/panels/timeline/drop-target"; import { getDragData, hasDragData } from "@/lib/drag-data"; -import { isMainTrack } from "@/lib/timeline/placement"; import type { TrackType, DropTarget, ElementType } from "@/lib/timeline"; import type { MediaDragData, @@ -47,7 +46,7 @@ export function useTimelineDragDrop({ const getSnappedTime = useCallback( ({ time }: { time: number }) => { const projectFps = editor.project.getActive().settings.fps; - return snapTimeToFrame({ time, fps: projectFps }); + return roundToFrame({ time, rate: projectFps }) ?? time; }, [editor], ); @@ -83,14 +82,14 @@ export function useTimelineDragDrop({ elementType === "sticker" || elementType === "effect" ) { - return DEFAULT_NEW_ELEMENT_DURATION_SECONDS; + return DEFAULT_NEW_ELEMENT_DURATION; } if (mediaId) { const mediaAssets = editor.media.getAssets(); const media = mediaAssets.find((m) => m.id === mediaId); - return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS; + return media?.duration ?? DEFAULT_NEW_ELEMENT_DURATION; } - return DEFAULT_NEW_ELEMENT_DURATION_SECONDS; + return DEFAULT_NEW_ELEMENT_DURATION; }, [editor], ); @@ -149,13 +148,13 @@ export function useTimelineDragDrop({ ? (dragData as MediaDragData).targetElementTypes : undefined; - const tracks = editor.timeline.getTracks(); + const sceneTracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); const target = computeDropTarget({ elementType, mouseX, mouseY, - tracks, + tracks: sceneTracks, playheadTime: currentTime, isExternalDrop: isExternal, elementDuration: duration, @@ -230,7 +229,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -267,7 +270,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -305,7 +312,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -331,7 +342,7 @@ export function useTimelineDragDrop({ dragData.mediaType === "audio" ? "audio" : "video"; const duration = - mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS; + mediaAsset.duration ?? DEFAULT_NEW_ELEMENT_DURATION; const element = buildElementFromMedia({ mediaId: mediaAsset.id, mediaType: mediaAsset.type, @@ -352,7 +363,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const track = tracks[target.trackIndex]; if (!track) return; editor.timeline.insertElement({ @@ -380,7 +395,11 @@ export function useTimelineDragDrop({ return; } - const tracks = editor.timeline.getTracks(); + const tracks = [ + ...editor.scenes.getActiveScene().tracks.overlay, + editor.scenes.getActiveScene().tracks.main, + ...editor.scenes.getActiveScene().tracks.audio, + ]; const effectTrack = tracks.find((t) => t.type === "effect"); let trackId: string; @@ -446,17 +465,15 @@ export function useTimelineDragDrop({ const duration = createdAsset.duration ?? - DEFAULT_NEW_ELEMENT_DURATION_SECONDS; - const currentTracks = editor.timeline.getTracks(); + DEFAULT_NEW_ELEMENT_DURATION; + const sceneTracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); - const onlyTrack = currentTracks[0]; const reuseMainTrackId = createdAsset.type !== "audio" && - currentTracks.length === 1 && - !!onlyTrack && - isMainTrack(onlyTrack) && - onlyTrack.elements.length === 0 - ? onlyTrack.id + sceneTracks.overlay.length === 0 && + sceneTracks.audio.length === 0 && + sceneTracks.main.elements.length === 0 + ? sceneTracks.main.id : null; const dropTarget = reuseMainTrackId ? null @@ -464,7 +481,7 @@ export function useTimelineDragDrop({ elementType: createdAsset.type, mouseX, mouseY, - tracks: currentTracks, + tracks: sceneTracks, playheadTime: currentTime, isExternalDrop: true, elementDuration: duration, @@ -488,7 +505,11 @@ export function useTimelineDragDrop({ trackId = addTrackCmd.getTrackId(); editor.command.execute({ command: addTrackCmd }); } else { - trackId = currentTracks[dropTarget.trackIndex]?.id; + trackId = [ + ...sceneTracks.overlay, + sceneTracks.main, + ...sceneTracks.audio, + ][dropTarget.trackIndex]?.id; } } diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts index fce84135..1886222f 100644 --- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts +++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts @@ -1,4 +1,5 @@ -import { getSnappedSeekTime } from "opencut-wasm"; +import { snappedSeekTime } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; import { useEffect, useCallback, useRef } from "react"; import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll"; import { useEditor } from "../use-editor"; @@ -6,6 +7,7 @@ import { useShiftKey } from "@/hooks/use-shift-key"; import { findSnapPoints, snapToNearestPoint } from "@/lib/timeline/snap-utils"; import { getCenteredLineLeft, + timelineTimeToPixels, timelineTimeToSnappedPixels, } from "@/lib/timeline"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; @@ -66,29 +68,29 @@ export function useTimelinePlayhead({ const rulerRect = ruler.getBoundingClientRect(); const relativeMouseX = event.clientX - rulerRect.left; - const timelineContentWidth = - duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + const timelineContentWidth = timelineTimeToPixels({ time: duration, zoomLevel }); - const clampedMouseX = Math.max( - 0, - Math.min(timelineContentWidth, relativeMouseX), - ); + const clampedMouseX = Math.max( + 0, + Math.min(timelineContentWidth, relativeMouseX), + ); - const rawTime = Math.max( - 0, - Math.min( - duration, - clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + clampedMouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); - const framesPerSecond = activeProject.settings.fps; - const frameTime = getSnappedSeekTime({ rawTime, duration, fps: framesPerSecond }); + const rate = activeProject.settings.fps; + const frameTime = snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime; const shouldSnap = snappingEnabled && !isShiftHeldRef.current; const time = (() => { if (!shouldSnap) return frameTime; - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? []; const snapPoints = findSnapPoints({ tracks, @@ -161,7 +163,7 @@ export function useTimelinePlayhead({ getMouseClientX: () => lastMouseXRef.current, rulerScrollRef, tracksScrollRef, - contentWidth: duration * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel, + contentWidth: timelineTimeToPixels({ time: duration, zoomLevel }), }); useEffect(() => { @@ -247,8 +249,10 @@ export function useTimelinePlayhead({ const tracksViewport = tracksScrollRef.current; if (!rulerViewport || !tracksViewport) return; - const playheadPixels = - time * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevelRef.current; + const playheadPixels = timelineTimeToPixels({ + time, + zoomLevel: zoomLevelRef.current, + }); const viewportWidth = rulerViewport.clientWidth; const scrollMinimum = 0; const scrollMaximum = rulerViewport.scrollWidth - viewportWidth; diff --git a/apps/web/src/hooks/timeline/use-timeline-seek.ts b/apps/web/src/hooks/timeline/use-timeline-seek.ts index 1690153e..756ec7ee 100644 --- a/apps/web/src/hooks/timeline/use-timeline-seek.ts +++ b/apps/web/src/hooks/timeline/use-timeline-seek.ts @@ -1,7 +1,8 @@ import { useCallback, useRef } from "react"; import type { MutableRefObject, RefObject } from "react"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { getSnappedSeekTime } from "opencut-wasm"; +import { snappedSeekTime } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; import { useEditor } from "../use-editor"; interface UseTimelineSeekProps { @@ -127,17 +128,18 @@ export function useTimelineSeek({ const mouseX = event.clientX - rect.left; const scrollLeft = scrollContainer.scrollLeft; - const rawTime = Math.max( - 0, - Math.min( - duration, - (mouseX + scrollLeft) / - (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ), - ); + const rawTimeSeconds = Math.max( + 0, + Math.min( + duration / TICKS_PER_SECOND, + (mouseX + scrollLeft) / + (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ), + ); + const rawTime = Math.round(rawTimeSeconds * TICKS_PER_SECOND); - const projectFps = activeProject?.settings.fps || 30; - const time = getSnappedSeekTime({ rawTime, duration, fps: projectFps }); + const rate = activeProject?.settings.fps; + const time = rate ? (snappedSeekTime({ time: rawTime, duration, rate }) ?? rawTime) : rawTime; seek(time); editor.project.setTimelineViewState({ viewState: { @@ -154,7 +156,8 @@ export function useTimelineSeek({ tracksScrollRef, seek, editor, - activeProject?.settings.fps, + activeProject?.settings.fps.numerator, + activeProject?.settings.fps.denominator, ], ); diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts index a1740384..1f79a5b2 100644 --- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts +++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts @@ -13,8 +13,8 @@ import { import { TIMELINE_ZOOM_MAX, TIMELINE_ZOOM_MIN, - BASE_TIMELINE_PIXELS_PER_SECOND, } from "@/lib/timeline/scale"; +import { timelineTimeToPixels } from "@/lib/timeline/pixel-utils"; import { useEditor } from "@/hooks/use-editor"; import { zoomToSlider } from "@/lib/timeline/zoom-utils"; @@ -186,10 +186,8 @@ export function useTimelineZoom({ } if (sliderPercent >= TIMELINE_ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) { - const playheadPixelsBefore = - playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * previousZoom; - const playheadPixelsAfter = - playheadTime * BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + const playheadPixelsBefore = timelineTimeToPixels({ time: playheadTime, zoomLevel: previousZoom }); + const playheadPixelsAfter = timelineTimeToPixels({ time: playheadTime, zoomLevel }); const viewportOffset = playheadPixelsBefore - currentScrollLeft; const newScrollLeft = playheadPixelsAfter - viewportOffset; diff --git a/apps/web/src/hooks/use-element-preview.ts b/apps/web/src/hooks/use-element-preview.ts index d717143d..4f28be83 100644 --- a/apps/web/src/hooks/use-element-preview.ts +++ b/apps/web/src/hooks/use-element-preview.ts @@ -1,5 +1,5 @@ import { useEditor } from "@/hooks/use-editor"; -import type { TimelineElement } from "@/lib/timeline"; +import { findTrackInSceneTracks, type TimelineElement } from "@/lib/timeline"; /** * Subscribes to render tracks and returns the live (preview-aware) version of @@ -18,13 +18,14 @@ export function useElementPreview({ fallback: T; }) { const editor = useEditor(); - useEditor((e) => e.timeline.getRenderTracks()); + useEditor((e) => e.timeline.getPreviewTracks()); + const previewTracks = editor.timeline.getPreviewTracks(); const renderElement = - (editor.timeline - .getRenderTracks() - .find((t) => t.id === trackId) - ?.elements.find((el) => el.id === elementId) as T | undefined) ?? + (findTrackInSceneTracks({ + tracks: previewTracks ?? editor.scenes.getActiveScene().tracks, + trackId, + })?.elements.find((element) => element.id === elementId) as T | undefined) ?? fallback; const previewUpdates = (updates: Partial) => diff --git a/apps/web/src/hooks/use-mask-handles.ts b/apps/web/src/hooks/use-mask-handles.ts index 63fdd674..d2400ee0 100644 --- a/apps/web/src/hooks/use-mask-handles.ts +++ b/apps/web/src/hooks/use-mask-handles.ts @@ -45,7 +45,9 @@ export function useMaskHandles({ null, ); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); const currentTime = useEditor((e) => e.playback.getCurrentTime()); const mediaAssets = useEditor((e) => e.media.getAssets()); const canvasSize = useEditor( diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts index 1cba34c0..36d70ebe 100644 --- a/apps/web/src/hooks/use-paste-media.ts +++ b/apps/web/src/hooks/use-paste-media.ts @@ -1,114 +1,114 @@ -import { useEffect } from "react"; -import { useEditor } from "@/hooks/use-editor"; -import { processMediaAssets } from "@/lib/media/processing"; -import { showMediaUploadToast } from "@/lib/media/upload-toast"; -import { invokeAction } from "@/lib/actions"; -import { buildElementFromMedia } from "@/lib/timeline/element-utils"; -import { AddMediaAssetCommand } from "@/lib/commands/media"; -import { InsertElementCommand } from "@/lib/commands/timeline"; -import { BatchCommand } from "@/lib/commands"; -import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation"; -import { isTypableDOMElement } from "@/utils/browser"; -import type { MediaType } from "@/lib/media/types"; - -const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"]; - -function isMediaMimeType({ type }: { type: string }): boolean { - return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`)); -} - -function extractMediaFilesFromClipboard({ - clipboardData, -}: { - clipboardData: DataTransfer | null; -}): File[] { - if (!clipboardData?.items) return []; - - const files: File[] = []; - for (const item of clipboardData.items) { - if (item.kind !== "file") continue; - if (!isMediaMimeType({ type: item.type })) continue; - - const file = item.getAsFile(); - if (file) files.push(file); - } - return files; -} - -export function usePasteMedia() { - const editor = useEditor(); - - useEffect(() => { - const handlePaste = async (event: ClipboardEvent) => { - const activeElement = document.activeElement as HTMLElement; - - if (activeElement && isTypableDOMElement({ element: activeElement })) { - return; - } - - const files = extractMediaFilesFromClipboard({ - clipboardData: event.clipboardData, - }); - if (files.length === 0) { - event.preventDefault(); - invokeAction("paste-copied"); - return; - } - - event.preventDefault(); - - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - try { - await showMediaUploadToast({ - filesCount: files.length, - promise: async () => { - const processedAssets = await processMediaAssets({ files }); - const startTime = editor.playback.getCurrentTime(); - - for (const asset of processedAssets) { - const addMediaCmd = new AddMediaAssetCommand( - activeProject.metadata.id, - asset, - ); - const assetId = addMediaCmd.getAssetId(); - const duration = - asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS; - const trackType = asset.type === "audio" ? "audio" : "video"; - - const element = buildElementFromMedia({ - mediaId: assetId, - mediaType: asset.type, - name: asset.name, - duration, - startTime, - buffer: - asset.type === "audio" - ? new AudioBuffer({ length: 1, sampleRate: 44100 }) - : undefined, - }); - - const insertCmd = new InsertElementCommand({ - element, - placement: { mode: "auto", trackType }, - }); - const batchCmd = new BatchCommand([addMediaCmd, insertCmd]); - editor.command.execute({ command: batchCmd }); - } - - return { - uploadedCount: processedAssets.length, - assetNames: processedAssets.map((asset) => asset.name), - }; - }, - }); - } catch (error) { - console.error("Failed to paste media:", error); - } - }; - - window.addEventListener("paste", handlePaste); - return () => window.removeEventListener("paste", handlePaste); - }, [editor]); -} +import { useEffect } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { processMediaAssets } from "@/lib/media/processing"; +import { showMediaUploadToast } from "@/lib/media/upload-toast"; +import { invokeAction } from "@/lib/actions"; +import { buildElementFromMedia } from "@/lib/timeline/element-utils"; +import { AddMediaAssetCommand } from "@/lib/commands/media"; +import { InsertElementCommand } from "@/lib/commands/timeline"; +import { BatchCommand } from "@/lib/commands"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; +import { isTypableDOMElement } from "@/utils/browser"; +import type { MediaType } from "@/lib/media/types"; + +const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"]; + +function isMediaMimeType({ type }: { type: string }): boolean { + return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`)); +} + +function extractMediaFilesFromClipboard({ + clipboardData, +}: { + clipboardData: DataTransfer | null; +}): File[] { + if (!clipboardData?.items) return []; + + const files: File[] = []; + for (const item of clipboardData.items) { + if (item.kind !== "file") continue; + if (!isMediaMimeType({ type: item.type })) continue; + + const file = item.getAsFile(); + if (file) files.push(file); + } + return files; +} + +export function usePasteMedia() { + const editor = useEditor(); + + useEffect(() => { + const handlePaste = async (event: ClipboardEvent) => { + const activeElement = document.activeElement as HTMLElement; + + if (activeElement && isTypableDOMElement({ element: activeElement })) { + return; + } + + const files = extractMediaFilesFromClipboard({ + clipboardData: event.clipboardData, + }); + if (files.length === 0) { + event.preventDefault(); + invokeAction("paste-copied"); + return; + } + + event.preventDefault(); + + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + try { + await showMediaUploadToast({ + filesCount: files.length, + promise: async () => { + const processedAssets = await processMediaAssets({ files }); + const startTime = editor.playback.getCurrentTime(); + + for (const asset of processedAssets) { + const addMediaCmd = new AddMediaAssetCommand( + activeProject.metadata.id, + asset, + ); + const assetId = addMediaCmd.getAssetId(); + const duration = + asset.duration ?? DEFAULT_NEW_ELEMENT_DURATION; + const trackType = asset.type === "audio" ? "audio" : "video"; + + const element = buildElementFromMedia({ + mediaId: assetId, + mediaType: asset.type, + name: asset.name, + duration, + startTime, + buffer: + asset.type === "audio" + ? new AudioBuffer({ length: 1, sampleRate: 44100 }) + : undefined, + }); + + const insertCmd = new InsertElementCommand({ + element, + placement: { mode: "auto", trackType }, + }); + const batchCmd = new BatchCommand([addMediaCmd, insertCmd]); + editor.command.execute({ command: batchCmd }); + } + + return { + uploadedCount: processedAssets.length, + assetNames: processedAssets.map((asset) => asset.name), + }; + }, + }); + } catch (error) { + console.error("Failed to paste media:", error); + } + }; + + window.addEventListener("paste", handlePaste); + return () => window.removeEventListener("paste", handlePaste); + }, [editor]); +} diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index 90fa0450..2a3e5c28 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -180,7 +180,7 @@ export function usePreviewInteraction({ ({ clientX, clientY }: React.MouseEvent) => { if (editingText || isMaskMode) return; - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; @@ -238,7 +238,7 @@ export function usePreviewInteraction({ if (isMaskMode) return; if (button !== 0) return; - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const currentTime = editor.playback.getCurrentTime(); const mediaAssets = editor.media.getAssets(); const canvasSize = editor.project.getActive().settings.canvasSize; diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts index af4bb69f..7495a387 100644 --- a/apps/web/src/hooks/use-transform-handles.ts +++ b/apps/web/src/hooks/use-transform-handles.ts @@ -1,619 +1,631 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; -import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; -import { useEditor } from "@/hooks/use-editor"; -import { useShiftKey } from "@/hooks/use-shift-key"; -import { - getVisibleElementsWithBounds, - type ElementWithBounds, -} from "@/lib/preview/element-bounds"; -import { - MIN_SCALE, - SNAP_THRESHOLD_SCREEN_PIXELS, - snapRotation, - snapScale, - snapScaleAxes, - type ScaleEdgePreference, - type SnapLine, -} from "@/lib/preview/preview-snap"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { - getElementLocalTime, - resolveTransformAtTime, - setChannel, -} from "@/lib/animation"; -import type { Transform } from "@/lib/rendering"; -import type { ElementAnimations } from "@/lib/animation/types"; -import { registerCanceller } from "@/lib/cancel-interaction"; - -type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; -type Edge = "right" | "left" | "bottom"; -type HandleType = Corner | Edge | "rotation"; - -function getPreferredEdge({ - edge, -}: { - edge: Edge; -}): ScaleEdgePreference { - return edge === "right" - ? { right: true } - : edge === "left" - ? { left: true } - : { bottom: true }; -} - -interface ScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialDistance: number; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -interface RotationState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialAngle: number; - initialBoundsCx: number; - initialBoundsCy: number; -} - -interface EdgeScaleState { - trackId: string; - elementId: string; - initialTransform: Transform; - initialBoundsCx: number; - initialBoundsCy: number; - baseWidth: number; - baseHeight: number; - edge: Edge; - rotationRad: number; - shouldClearScaleAnimation: boolean; - animationsWithoutScale: ElementAnimations | undefined; -} - -function clampScaleNonZero(scale: number): number { - if (Math.abs(scale) < MIN_SCALE) { - return scale < 0 ? -MIN_SCALE : MIN_SCALE; - } - return scale; -} - -function getCornerDistance({ - bounds, - corner, -}: { - bounds: { - cx: number; - cy: number; - width: number; - height: number; - rotation: number; - }; - corner: Corner; -}): number { - const halfWidth = bounds.width / 2; - const halfHeight = bounds.height / 2; - const angleRad = (bounds.rotation * Math.PI) / 180; - const cos = Math.cos(angleRad); - const sin = Math.sin(angleRad); - - const localX = - corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; - const localY = - corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; - - const rotatedX = localX * cos - localY * sin; - const rotatedY = localX * sin + localY * cos; - return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; -} - -export function useTransformHandles({ - onSnapLinesChange, -}: { - onSnapLinesChange?: OnSnapLinesChange; -}) { - const editor = useEditor(); - const isShiftHeldRef = useShiftKey(); - const viewport = usePreviewViewport(); - const [activeHandle, setActiveHandle] = useState(null); - const scaleStateRef = useRef(null); - const rotationStateRef = useRef(null); - const edgeScaleStateRef = useRef(null); - const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( - null, - ); - - const selectedElements = useEditor((e) => e.selection.getSelectedElements()); - const tracks = useEditor((e) => e.timeline.getRenderTracks()); - const currentTime = useEditor((e) => e.playback.getCurrentTime()); - const currentTimeRef = useRef(currentTime); - currentTimeRef.current = currentTime; - const mediaAssets = useEditor((e) => e.media.getAssets()); - const canvasSize = useEditor( - (e) => e.project.getActive().settings.canvasSize, - ); - - const elementsWithBounds = getVisibleElementsWithBounds({ - tracks, - currentTime, - canvasSize, - mediaAssets, - }); - - const selectedWithBounds: ElementWithBounds | null = - selectedElements.length === 1 - ? (elementsWithBounds.find( - (entry) => - entry.trackId === selectedElements[0].trackId && - entry.elementId === selectedElements[0].elementId, - ) ?? null) - : null; - - const hasVisualSelection = - selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); - - const clearActiveHandleState = useCallback(() => { - scaleStateRef.current = null; - rotationStateRef.current = null; - edgeScaleStateRef.current = null; - setActiveHandle(null); - onSnapLinesChange?.([]); - }, [onSnapLinesChange]); - - const releaseCapturedPointer = useCallback(() => { - const capture = captureRef.current; - if (!capture) return; - - if (capture.element.hasPointerCapture(capture.pointerId)) { - capture.element.releasePointerCapture(capture.pointerId); - } - - captureRef.current = null; - }, []); - - useEffect(() => { - if (!activeHandle) return; - - return registerCanceller({ - fn: () => { - editor.timeline.discardPreview(); - clearActiveHandleState(); - releaseCapturedPointer(); - }, - }); - }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); - - const handleCornerPointerDown = useCallback( - ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const initialDistance = getCornerDistance({ bounds, corner }); - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const shouldClearScaleAnimation = - !!element.animations?.channels["transform.scaleX"] || - !!element.animations?.channels["transform.scaleY"]; - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: setChannel({ - animations: element.animations, - propertyPath: "transform.scaleX", - channel: undefined, - }), - propertyPath: "transform.scaleY", - channel: undefined, - }) - : element.animations; - - scaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialDistance, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(corner); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handleRotationPointerDown = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - const deltaX = position.x - bounds.cx; - const deltaY = position.y - bounds.cy; - const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - - rotationStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialAngle, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - }; - setActiveHandle("rotation"); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds, viewport], - ); - - const handleEdgePointerDown = useCallback( - ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { - if (!selectedWithBounds) return; - event.stopPropagation(); - - const { bounds, trackId, elementId, element } = selectedWithBounds; - if (!isVisualElement(element)) return; - - const localTime = getElementLocalTime({ - timelineTime: currentTimeRef.current, - elementStartTime: element.startTime, - elementDuration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const baseWidth = bounds.width / resolvedTransform.scaleX; - const baseHeight = bounds.height / resolvedTransform.scaleY; - const rotationRad = (bounds.rotation * Math.PI) / 180; - - const propertyPath = - edge === "right" || edge === "left" - ? "transform.scaleX" - : "transform.scaleY"; - const shouldClearScaleAnimation = - !!element.animations?.channels[propertyPath]; - const animationsWithoutScale = shouldClearScaleAnimation - ? setChannel({ - animations: element.animations, - propertyPath, - channel: undefined, - }) - : element.animations; - - edgeScaleStateRef.current = { - trackId, - elementId, - initialTransform: resolvedTransform, - initialBoundsCx: bounds.cx, - initialBoundsCy: bounds.cy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - }; - setActiveHandle(edge); - const captureTarget = event.currentTarget as HTMLElement; - captureTarget.setPointerCapture(event.pointerId); - captureRef.current = { - element: captureTarget, - pointerId: event.pointerId, - }; - }, - [selectedWithBounds], - ); - - const handlePointerMove = useCallback( - ({ event }: { event: React.PointerEvent }) => { - if ( - !scaleStateRef.current && - !rotationStateRef.current && - !edgeScaleStateRef.current - ) - return; - - const position = viewport.screenToCanvas({ - clientX: event.clientX, - clientY: event.clientY, - }); - if (!position) return; - - if ( - scaleStateRef.current && - activeHandle && - activeHandle !== "rotation" - ) { - const { - trackId, - elementId, - initialTransform, - initialDistance, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - shouldClearScaleAnimation, - animationsWithoutScale, - } = scaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentDistance = - Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; - const scaleFactor = currentDistance / initialDistance; - - // Use actual element dimensions (base * current scale) so snap - // computes the correct edges when scaleX ≠ scaleY - const effectiveWidth = baseWidth * initialTransform.scaleX; - const effectiveHeight = baseHeight * initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { snappedScale: snappedFactor, activeLines } = - isShiftHeldRef.current - ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } - : snapScale({ - proposedScale: scaleFactor, - position: initialTransform.position, - baseWidth: effectiveWidth, - baseHeight: effectiveHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - }); - - onSnapLinesChange?.(activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: clampScaleNonZero( - initialTransform.scaleX * snappedFactor, - ), - scaleY: clampScaleNonZero( - initialTransform.scaleY * snappedFactor, - ), - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if ( - edgeScaleStateRef.current && - (activeHandle === "right" || - activeHandle === "left" || - activeHandle === "bottom") - ) { - const { - trackId, - elementId, - initialTransform, - initialBoundsCx, - initialBoundsCy, - baseWidth, - baseHeight, - edge, - rotationRad, - shouldClearScaleAnimation, - animationsWithoutScale, - } = edgeScaleStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const xProjection = - deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); - const yProjection = - -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); - const projection = - edge === "right" - ? xProjection - : edge === "left" - ? -xProjection - : yProjection; - - const baseAxisHalf = - edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; - const proposedScale = clampScaleNonZero(projection / baseAxisHalf); - - const proposedScaleX = - edge === "right" || edge === "left" - ? proposedScale - : initialTransform.scaleX; - const proposedScaleY = - edge === "bottom" ? proposedScale : initialTransform.scaleY; - - const snapThreshold = viewport.screenPixelsToLogicalThreshold({ - screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, - }); - const { x: xSnap, y: ySnap } = isShiftHeldRef.current - ? { - x: { - snappedScale: proposedScaleX, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - y: { - snappedScale: proposedScaleY, - snapDistance: Infinity, - activeLines: [] as SnapLine[], - }, - } - : snapScaleAxes({ - proposedScaleX, - proposedScaleY, - position: initialTransform.position, - baseWidth, - baseHeight, - rotation: initialTransform.rotate, - canvasSize, - snapThreshold, - preferredEdges: getPreferredEdge({ edge }), - }); - - const relevantSnap = - edge === "right" || edge === "left" ? xSnap : ySnap; - onSnapLinesChange?.(relevantSnap.activeLines); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { - ...initialTransform, - scaleX: - edge === "right" || edge === "left" - ? xSnap.snappedScale - : initialTransform.scaleX, - scaleY: - edge === "bottom" - ? ySnap.snappedScale - : initialTransform.scaleY, - }, - ...(shouldClearScaleAnimation && { - animations: animationsWithoutScale, - }), - }, - }, - ], - }); - return; - } - - if (rotationStateRef.current && activeHandle === "rotation") { - const { - trackId, - elementId, - initialTransform, - initialAngle, - initialBoundsCx, - initialBoundsCy, - } = rotationStateRef.current; - - const deltaX = position.x - initialBoundsCx; - const deltaY = position.y - initialBoundsCy; - const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; - let deltaAngle = currentAngle - initialAngle; - if (deltaAngle > 180) deltaAngle -= 360; - if (deltaAngle < -180) deltaAngle += 360; - const newRotate = initialTransform.rotate + deltaAngle; - const { snappedRotation } = isShiftHeldRef.current - ? { snappedRotation: newRotate } - : snapRotation({ proposedRotation: newRotate }); - - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - transform: { ...initialTransform, rotate: snappedRotation }, - }, - }, - ], - }); - } - }, - [ - activeHandle, - canvasSize, - editor, - isShiftHeldRef, - onSnapLinesChange, - viewport, - ], - ); - - const handlePointerUp = useCallback(() => { - if ( - scaleStateRef.current || - rotationStateRef.current || - edgeScaleStateRef.current - ) { - editor.timeline.commitPreview(); - clearActiveHandleState(); - } - releaseCapturedPointer(); - }, - [clearActiveHandleState, editor, releaseCapturedPointer], - ); - - return { - selectedWithBounds, - hasVisualSelection, - activeHandle, - handleCornerPointerDown, - handleEdgePointerDown, - handleRotationPointerDown, - handlePointerMove, - handlePointerUp, - }; -} +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; +import { useEditor } from "@/hooks/use-editor"; +import { useShiftKey } from "@/hooks/use-shift-key"; +import { + getVisibleElementsWithBounds, + type ElementWithBounds, +} from "@/lib/preview/element-bounds"; +import { + MIN_SCALE, + SNAP_THRESHOLD_SCREEN_PIXELS, + snapRotation, + snapScale, + snapScaleAxes, + type ScaleEdgePreference, + type SnapLine, +} from "@/lib/preview/preview-snap"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + getElementLocalTime, + hasKeyframesForPath, + resolveTransformAtTime, + setChannel, +} from "@/lib/animation"; +import type { Transform } from "@/lib/rendering"; +import type { ElementAnimations } from "@/lib/animation/types"; +import { registerCanceller } from "@/lib/cancel-interaction"; + +type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +type Edge = "right" | "left" | "bottom"; +type HandleType = Corner | Edge | "rotation"; + +function getPreferredEdge({ + edge, +}: { + edge: Edge; +}): ScaleEdgePreference { + return edge === "right" + ? { right: true } + : edge === "left" + ? { left: true } + : { bottom: true }; +} + +interface ScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialDistance: number; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +interface RotationState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialAngle: number; + initialBoundsCx: number; + initialBoundsCy: number; +} + +interface EdgeScaleState { + trackId: string; + elementId: string; + initialTransform: Transform; + initialBoundsCx: number; + initialBoundsCy: number; + baseWidth: number; + baseHeight: number; + edge: Edge; + rotationRad: number; + shouldClearScaleAnimation: boolean; + animationsWithoutScale: ElementAnimations | undefined; +} + +function clampScaleNonZero(scale: number): number { + if (Math.abs(scale) < MIN_SCALE) { + return scale < 0 ? -MIN_SCALE : MIN_SCALE; + } + return scale; +} + +function getCornerDistance({ + bounds, + corner, +}: { + bounds: { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; + }; + corner: Corner; +}): number { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfWidth : halfWidth; + const localY = + corner === "top-left" || corner === "top-right" ? -halfHeight : halfHeight; + + const rotatedX = localX * cos - localY * sin; + const rotatedY = localX * sin + localY * cos; + return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1; +} + +export function useTransformHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: OnSnapLinesChange; +}) { + const editor = useEditor(); + const isShiftHeldRef = useShiftKey(); + const viewport = usePreviewViewport(); + const [activeHandle, setActiveHandle] = useState(null); + const scaleStateRef = useRef(null); + const rotationStateRef = useRef(null); + const edgeScaleStateRef = useRef(null); + const captureRef = useRef<{ element: HTMLElement; pointerId: number } | null>( + null, + ); + + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const tracks = useEditor( + (e) => e.timeline.getPreviewTracks() ?? e.scenes.getActiveScene().tracks, + ); + const currentTime = useEditor((e) => e.playback.getCurrentTime()); + const currentTimeRef = useRef(currentTime); + currentTimeRef.current = currentTime; + const mediaAssets = useEditor((e) => e.media.getAssets()); + const canvasSize = useEditor( + (e) => e.project.getActive().settings.canvasSize, + ); + + const elementsWithBounds = getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, + }); + + const selectedWithBounds: ElementWithBounds | null = + selectedElements.length === 1 + ? (elementsWithBounds.find( + (entry) => + entry.trackId === selectedElements[0].trackId && + entry.elementId === selectedElements[0].elementId, + ) ?? null) + : null; + + const hasVisualSelection = + selectedWithBounds !== null && isVisualElement(selectedWithBounds.element); + + const clearActiveHandleState = useCallback(() => { + scaleStateRef.current = null; + rotationStateRef.current = null; + edgeScaleStateRef.current = null; + setActiveHandle(null); + onSnapLinesChange?.([]); + }, [onSnapLinesChange]); + + const releaseCapturedPointer = useCallback(() => { + const capture = captureRef.current; + if (!capture) return; + + if (capture.element.hasPointerCapture(capture.pointerId)) { + capture.element.releasePointerCapture(capture.pointerId); + } + + captureRef.current = null; + }, []); + + useEffect(() => { + if (!activeHandle) return; + + return registerCanceller({ + fn: () => { + editor.timeline.discardPreview(); + clearActiveHandleState(); + releaseCapturedPointer(); + }, + }); + }, [activeHandle, clearActiveHandleState, editor.timeline, releaseCapturedPointer]); + + const handleCornerPointerDown = useCallback( + ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const initialDistance = getCornerDistance({ bounds, corner }); + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations: element.animations, + propertyPath: "transform.scaleX", + }) || + hasKeyframesForPath({ + animations: element.animations, + propertyPath: "transform.scaleY", + }); + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: setChannel({ + animations: element.animations, + propertyPath: "transform.scaleX", + channel: undefined, + }), + propertyPath: "transform.scaleY", + channel: undefined, + }) + : element.animations; + + scaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialDistance, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(corner); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds], + ); + + const handleRotationPointerDown = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const position = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + const deltaX = position.x - bounds.cx; + const deltaY = position.y - bounds.cy; + const initialAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + + rotationStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialAngle, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + }; + setActiveHandle("rotation"); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds, viewport], + ); + + const handleEdgePointerDown = useCallback( + ({ event, edge }: { event: React.PointerEvent; edge: Edge }) => { + if (!selectedWithBounds) return; + event.stopPropagation(); + + const { bounds, trackId, elementId, element } = selectedWithBounds; + if (!isVisualElement(element)) return; + + const localTime = getElementLocalTime({ + timelineTime: currentTimeRef.current, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const baseWidth = bounds.width / resolvedTransform.scaleX; + const baseHeight = bounds.height / resolvedTransform.scaleY; + const rotationRad = (bounds.rotation * Math.PI) / 180; + + const propertyPath = + edge === "right" || edge === "left" + ? "transform.scaleX" + : "transform.scaleY"; + const shouldClearScaleAnimation = + hasKeyframesForPath({ + animations: element.animations, + propertyPath, + }); + const animationsWithoutScale = shouldClearScaleAnimation + ? setChannel({ + animations: element.animations, + propertyPath, + channel: undefined, + }) + : element.animations; + + edgeScaleStateRef.current = { + trackId, + elementId, + initialTransform: resolvedTransform, + initialBoundsCx: bounds.cx, + initialBoundsCy: bounds.cy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + }; + setActiveHandle(edge); + const captureTarget = event.currentTarget as HTMLElement; + captureTarget.setPointerCapture(event.pointerId); + captureRef.current = { + element: captureTarget, + pointerId: event.pointerId, + }; + }, + [selectedWithBounds], + ); + + const handlePointerMove = useCallback( + ({ event }: { event: React.PointerEvent }) => { + if ( + !scaleStateRef.current && + !rotationStateRef.current && + !edgeScaleStateRef.current + ) + return; + + const position = viewport.screenToCanvas({ + clientX: event.clientX, + clientY: event.clientY, + }); + if (!position) return; + + if ( + scaleStateRef.current && + activeHandle && + activeHandle !== "rotation" + ) { + const { + trackId, + elementId, + initialTransform, + initialDistance, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + shouldClearScaleAnimation, + animationsWithoutScale, + } = scaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentDistance = + Math.sqrt(deltaX * deltaX + deltaY * deltaY) || 1; + const scaleFactor = currentDistance / initialDistance; + + // Use actual element dimensions (base * current scale) so snap + // computes the correct edges when scaleX ≠ scaleY + const effectiveWidth = baseWidth * initialTransform.scaleX; + const effectiveHeight = baseHeight * initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { snappedScale: snappedFactor, activeLines } = + isShiftHeldRef.current + ? { snappedScale: scaleFactor, activeLines: [] as SnapLine[] } + : snapScale({ + proposedScale: scaleFactor, + position: initialTransform.position, + baseWidth: effectiveWidth, + baseHeight: effectiveHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + }); + + onSnapLinesChange?.(activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: clampScaleNonZero( + initialTransform.scaleX * snappedFactor, + ), + scaleY: clampScaleNonZero( + initialTransform.scaleY * snappedFactor, + ), + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if ( + edgeScaleStateRef.current && + (activeHandle === "right" || + activeHandle === "left" || + activeHandle === "bottom") + ) { + const { + trackId, + elementId, + initialTransform, + initialBoundsCx, + initialBoundsCy, + baseWidth, + baseHeight, + edge, + rotationRad, + shouldClearScaleAnimation, + animationsWithoutScale, + } = edgeScaleStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const xProjection = + deltaX * Math.cos(rotationRad) + deltaY * Math.sin(rotationRad); + const yProjection = + -deltaX * Math.sin(rotationRad) + deltaY * Math.cos(rotationRad); + const projection = + edge === "right" + ? xProjection + : edge === "left" + ? -xProjection + : yProjection; + + const baseAxisHalf = + edge === "right" || edge === "left" ? baseWidth / 2 : baseHeight / 2; + const proposedScale = clampScaleNonZero(projection / baseAxisHalf); + + const proposedScaleX = + edge === "right" || edge === "left" + ? proposedScale + : initialTransform.scaleX; + const proposedScaleY = + edge === "bottom" ? proposedScale : initialTransform.scaleY; + + const snapThreshold = viewport.screenPixelsToLogicalThreshold({ + screenPixels: SNAP_THRESHOLD_SCREEN_PIXELS, + }); + const { x: xSnap, y: ySnap } = isShiftHeldRef.current + ? { + x: { + snappedScale: proposedScaleX, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + y: { + snappedScale: proposedScaleY, + snapDistance: Infinity, + activeLines: [] as SnapLine[], + }, + } + : snapScaleAxes({ + proposedScaleX, + proposedScaleY, + position: initialTransform.position, + baseWidth, + baseHeight, + rotation: initialTransform.rotate, + canvasSize, + snapThreshold, + preferredEdges: getPreferredEdge({ edge }), + }); + + const relevantSnap = + edge === "right" || edge === "left" ? xSnap : ySnap; + onSnapLinesChange?.(relevantSnap.activeLines); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + scaleX: + edge === "right" || edge === "left" + ? xSnap.snappedScale + : initialTransform.scaleX, + scaleY: + edge === "bottom" + ? ySnap.snappedScale + : initialTransform.scaleY, + }, + ...(shouldClearScaleAnimation && { + animations: animationsWithoutScale, + }), + }, + }, + ], + }); + return; + } + + if (rotationStateRef.current && activeHandle === "rotation") { + const { + trackId, + elementId, + initialTransform, + initialAngle, + initialBoundsCx, + initialBoundsCy, + } = rotationStateRef.current; + + const deltaX = position.x - initialBoundsCx; + const deltaY = position.y - initialBoundsCy; + const currentAngle = (Math.atan2(deltaY, deltaX) * 180) / Math.PI; + let deltaAngle = currentAngle - initialAngle; + if (deltaAngle > 180) deltaAngle -= 360; + if (deltaAngle < -180) deltaAngle += 360; + const newRotate = initialTransform.rotate + deltaAngle; + const { snappedRotation } = isShiftHeldRef.current + ? { snappedRotation: newRotate } + : snapRotation({ proposedRotation: newRotate }); + + editor.timeline.previewElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { ...initialTransform, rotate: snappedRotation }, + }, + }, + ], + }); + } + }, + [ + activeHandle, + canvasSize, + editor, + isShiftHeldRef, + onSnapLinesChange, + viewport, + ], + ); + + const handlePointerUp = useCallback(() => { + if ( + scaleStateRef.current || + rotationStateRef.current || + edgeScaleStateRef.current + ) { + editor.timeline.commitPreview(); + clearActiveHandleState(); + } + releaseCapturedPointer(); + }, + [clearActiveHandleState, editor, releaseCapturedPointer], + ); + + return { + selectedWithBounds, + hasVisualSelection, + activeHandle, + handleCornerPointerDown, + handleEdgePointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + }; +} diff --git a/apps/web/src/lib/animation/__tests__/binding-values.test.ts b/apps/web/src/lib/animation/__tests__/binding-values.test.ts new file mode 100644 index 00000000..c5a354e4 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/binding-values.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { + composeAnimationValue, + createAnimationBinding, +} from "@/lib/animation/binding-values"; + +describe("binding values", () => { + test("formats composed animated colors as hex", () => { + const binding = createAnimationBinding({ + path: "color", + kind: "color", + }); + + expect( + composeAnimationValue({ + binding, + componentValues: { + r: 1, + g: 0, + b: 0, + a: 1, + }, + }), + ).toBe("#ff0000"); + }); +}); diff --git a/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts b/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts new file mode 100644 index 00000000..319493c8 --- /dev/null +++ b/apps/web/src/lib/animation/__tests__/keyframe-query.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, test } from "bun:test"; +import { + getElementKeyframes, + getKeyframeById, + getKeyframeAtTime, +} from "@/lib/animation/keyframe-query"; +import type { + ElementAnimations, + ScalarAnimationKey, +} from "@/lib/animation/types"; + +function createScalarKey({ + id, + time, + value, +}: { + id: string; + time: number; + value: number; +}): ScalarAnimationKey { + return { + id, + time, + value, + segmentToNext: "linear", + tangentMode: "flat", + }; +} + +function buildPositionAnimations({ + xKeys, + yKeys, +}: { + xKeys: ScalarAnimationKey[]; + yKeys: ScalarAnimationKey[]; +}): ElementAnimations { + return { + bindings: { + "transform.position": { + path: "transform.position", + kind: "vector2", + components: [ + { key: "x", channelId: "transform.position:x" }, + { key: "y", channelId: "transform.position:y" }, + ], + }, + }, + channels: { + "transform.position:x": { + kind: "scalar", + keys: xKeys, + }, + "transform.position:y": { + kind: "scalar", + keys: yKeys, + }, + }, + }; +} + +describe("keyframe query", () => { + test("returns keyframes from any component channel", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getElementKeyframes({ animations }).map(({ id, time }) => ({ + id, + time, + })), + ).toEqual([ + { id: "x-1", time: 1 }, + { id: "y-2", time: 2 }, + ]); + }); + + test("finds a keyframe at time on a non-primary component", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getKeyframeAtTime({ + animations, + propertyPath: "transform.position", + time: 2, + }), + ).toMatchObject({ + id: "y-2", + time: 2, + }); + }); + + test("finds a keyframe by id on a non-primary component", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-2", time: 2, value: 20 })], + }); + + expect( + getKeyframeById({ + animations, + propertyPath: "transform.position", + keyframeId: "y-2", + }), + ).toMatchObject({ + id: "y-2", + time: 2, + value: { x: 10, y: 20 }, + }); + }); + + test("prefers the primary component when multiple components share a time", () => { + const animations = buildPositionAnimations({ + xKeys: [createScalarKey({ id: "x-1", time: 1, value: 10 })], + yKeys: [createScalarKey({ id: "y-1", time: 1, value: 20 })], + }); + + expect( + getElementKeyframes({ animations }).map(({ id, time }) => ({ + id, + time, + })), + ).toEqual([{ id: "x-1", time: 1 }]); + expect( + getKeyframeAtTime({ + animations, + propertyPath: "transform.position", + time: 1, + }), + ).toMatchObject({ + id: "x-1", + time: 1, + }); + }); +}); diff --git a/apps/web/src/lib/animation/bezier.ts b/apps/web/src/lib/animation/bezier.ts new file mode 100644 index 00000000..5a9758ed --- /dev/null +++ b/apps/web/src/lib/animation/bezier.ts @@ -0,0 +1,90 @@ +import type { ScalarAnimationKey } from "@/lib/animation/types"; + +const BEZIER_SOLVE_ITERATIONS = 20; + +export function getBezierPoint({ + progress, + p0, + p1, + p2, + p3, +}: { + progress: number; + p0: number; + p1: number; + p2: number; + p3: number; +}) { + const mt = 1 - progress; + return ( + mt * mt * mt * p0 + + 3 * mt * mt * progress * p1 + + 3 * mt * progress * progress * p2 + + progress * progress * progress * p3 + ); +} + +export function getDefaultRightHandle({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + const span = rightKey.time - leftKey.time; + const valueDelta = rightKey.value - leftKey.value; + return { + dt: span / 3, + dv: valueDelta / 3, + }; +} + +export function getDefaultLeftHandle({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + const span = rightKey.time - leftKey.time; + const valueDelta = rightKey.value - leftKey.value; + return { + dt: -span / 3, + dv: -valueDelta / 3, + }; +} + +export function solveBezierProgressForTime({ + time, + leftKey, + rightKey, +}: { + time: number; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + let lower = 0; + let upper = 1; + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + for (let iteration = 0; iteration < BEZIER_SOLVE_ITERATIONS; iteration++) { + const mid = (lower + upper) / 2; + const estimate = getBezierPoint({ + progress: mid, + p0: leftKey.time, + p1: leftKey.time + rightHandle.dt, + p2: rightKey.time + leftHandle.dt, + p3: rightKey.time, + }); + if (estimate < time) { + lower = mid; + } else { + upper = mid; + } + } + + return (lower + upper) / 2; +} diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts new file mode 100644 index 00000000..344fd776 --- /dev/null +++ b/apps/web/src/lib/animation/binding-values.ts @@ -0,0 +1,332 @@ +import { converter, formatHex, formatHex8, parse } from "culori"; +import type { + AnimationBindingComponent, + AnimationBindingOfKind, + AnimationBindingInstance, + AnimationBindingKind, + ColorAnimationBinding, + DiscreteAnimationBinding, + NumberAnimationBinding, + AnimationPath, + AnimationValue, + DiscreteValue, + Vector2AnimationBinding, + VectorValue, +} from "@/lib/animation/types"; +import { clamp } from "@/utils/math"; + +interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +export type AnimationComponentValue = number | DiscreteValue; + +const toRgb = converter("rgb"); + +function srgbToLinear({ value }: { value: number }): number { + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function linearToSrgb({ value }: { value: number }): number { + const clamped = clamp({ value, min: 0, max: 1 }); + return clamped <= 0.0031308 + ? clamped * 12.92 + : 1.055 * Math.pow(clamped, 1 / 2.4) - 0.055; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +export function isVectorValue(value: unknown): value is VectorValue { + return isRecord(value) && typeof value.x === "number" && typeof value.y === "number"; +} + +export function getBindingComponentKeys({ + kind, +}: { + kind: AnimationBindingKind; +}): string[] { + if (kind === "vector2") { + return ["x", "y"]; + } + + if (kind === "color") { + return ["r", "g", "b", "a"]; + } + + return ["value"]; +} + +export function buildBindingChannelId({ + path, + componentKey, +}: { + path: AnimationPath; + componentKey: string; +}): string { + return `${path}:${componentKey}`; +} + +function createBindingComponent({ + path, + key, +}: { + path: AnimationPath; + key: TKey; +}): AnimationBindingComponent { + return { + key, + channelId: buildBindingChannelId({ path, componentKey: key }), + }; +} + +function cloneBindingComponents({ + components, +}: { + components: AnimationBindingComponent[]; +}): AnimationBindingComponent[] { + return components.map((component) => ({ ...component })); +} + +const animationBindingFactories = { + color: ({ path }: { path: AnimationPath }): ColorAnimationBinding => ({ + path, + kind: "color", + colorSpace: "srgb-linear", + components: [ + createBindingComponent({ path, key: "r" }), + createBindingComponent({ path, key: "g" }), + createBindingComponent({ path, key: "b" }), + createBindingComponent({ path, key: "a" }), + ], + }), + vector2: ({ path }: { path: AnimationPath }): Vector2AnimationBinding => ({ + path, + kind: "vector2", + components: [ + createBindingComponent({ path, key: "x" }), + createBindingComponent({ path, key: "y" }), + ], + }), + number: ({ path }: { path: AnimationPath }): NumberAnimationBinding => ({ + path, + kind: "number", + components: [createBindingComponent({ path, key: "value" })], + }), + discrete: ({ path }: { path: AnimationPath }): DiscreteAnimationBinding => ({ + path, + kind: "discrete", + components: [createBindingComponent({ path, key: "value" })], + }), +} satisfies { + [K in AnimationBindingKind]: ({ + path, + }: { + path: AnimationPath; + }) => AnimationBindingOfKind; +}; + +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: TKind; +}): AnimationBindingOfKind; +export function createAnimationBinding({ + path, + kind, +}: { + path: AnimationPath; + kind: AnimationBindingKind; +}): AnimationBindingInstance { + return animationBindingFactories[kind]({ path }); +} + +const animationBindingCloners = { + color: ({ binding }: { binding: ColorAnimationBinding }): ColorAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + vector2: ({ + binding, + }: { + binding: Vector2AnimationBinding; + }): Vector2AnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + number: ({ + binding, + }: { + binding: NumberAnimationBinding; + }): NumberAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), + discrete: ({ + binding, + }: { + binding: DiscreteAnimationBinding; + }): DiscreteAnimationBinding => ({ + ...binding, + components: cloneBindingComponents({ + components: binding.components, + }), + }), +} satisfies { + [K in AnimationBindingKind]: ({ + binding, + }: { + binding: AnimationBindingOfKind; + }) => AnimationBindingOfKind; +}; + +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingOfKind; +}): AnimationBindingOfKind; +export function cloneAnimationBinding({ + binding, +}: { + binding: AnimationBindingInstance; +}): AnimationBindingInstance { + switch (binding.kind) { + case "color": + return animationBindingCloners.color({ binding }); + case "vector2": + return animationBindingCloners.vector2({ binding }); + case "number": + return animationBindingCloners.number({ binding }); + case "discrete": + return animationBindingCloners.discrete({ binding }); + } +} + +export function parseColorToLinearRgba({ + color, +}: { + color: string; +}): LinearRgba | null { + const parsed = parse(color); + const rgb = parsed ? toRgb(parsed) : null; + if (!rgb) { + return null; + } + + return { + r: srgbToLinear({ value: rgb.r ?? 0 }), + g: srgbToLinear({ value: rgb.g ?? 0 }), + b: srgbToLinear({ value: rgb.b ?? 0 }), + a: clamp({ value: rgb.alpha ?? 1, min: 0, max: 1 }), + }; +} + +export function formatLinearRgba({ + color, +}: { + color: LinearRgba; +}): string { + const rgb = { + mode: "rgb", + r: linearToSrgb({ value: color.r }), + g: linearToSrgb({ value: color.g }), + b: linearToSrgb({ value: color.b }), + alpha: clamp({ value: color.a, min: 0, max: 1 }), + } as const; + return rgb.alpha < 1 ? formatHex8(rgb) : formatHex(rgb); +} + +export function decomposeAnimationValue({ + kind, + value, +}: { + kind: AnimationBindingKind; + value: AnimationValue; +}): Record | null { + if (kind === "number") { + return typeof value === "number" ? { value } : null; + } + + if (kind === "vector2") { + return isVectorValue(value) ? { x: value.x, y: value.y } : null; + } + + if (kind === "color") { + if (typeof value !== "string") { + return null; + } + const parsed = parseColorToLinearRgba({ color: value }); + if (!parsed) { + return null; + } + return { + r: parsed.r, + g: parsed.g, + b: parsed.b, + a: parsed.a, + }; + } + + return typeof value === "string" || typeof value === "boolean" + ? { value } + : null; +} + +export function composeAnimationValue({ + binding, + componentValues, +}: { + binding: AnimationBindingInstance; + componentValues: Record; +}): AnimationValue | null { + if (binding.kind === "number") { + const value = componentValues.value; + return typeof value === "number" ? value : null; + } + + if (binding.kind === "vector2") { + const x = componentValues.x; + const y = componentValues.y; + return typeof x === "number" && typeof y === "number" ? { x, y } : null; + } + + if (binding.kind === "color") { + const r = componentValues.r; + const g = componentValues.g; + const b = componentValues.b; + const a = componentValues.a; + if ( + typeof r !== "number" || + typeof g !== "number" || + typeof b !== "number" || + typeof a !== "number" + ) { + return null; + } + return formatLinearRgba({ + color: { + r, + g, + b, + a, + }, + }); + } + + const value = componentValues.value; + return typeof value === "string" || typeof value === "boolean" ? value : null; +} diff --git a/apps/web/src/lib/animation/color-channel.ts b/apps/web/src/lib/animation/color-channel.ts deleted file mode 100644 index c1c05f9a..00000000 --- a/apps/web/src/lib/animation/color-channel.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { - AnimationPropertyPath, - ColorAnimationChannel, - ElementAnimations, -} from "@/lib/animation/types"; - -export function getColorChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): ColorAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "color") { - return undefined; - } - return channel; -} diff --git a/apps/web/src/lib/animation/curve-bridge.ts b/apps/web/src/lib/animation/curve-bridge.ts new file mode 100644 index 00000000..12103c3f --- /dev/null +++ b/apps/web/src/lib/animation/curve-bridge.ts @@ -0,0 +1,81 @@ +import { + getDefaultLeftHandle, + getDefaultRightHandle, +} from "@/lib/animation/bezier"; +import type { + CurveHandle, + NormalizedCubicBezier, + ScalarAnimationKey, +} from "@/lib/animation/types"; + +const VALUE_EPSILON = 1e-6; + +function clamp01({ value }: { value: number }): number { + return Math.max(0, Math.min(1, value)); +} + +export function getNormalizedCubicBezierForScalarSegment({ + leftKey, + rightKey, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}): NormalizedCubicBezier | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + if ( + spanTime === 0 || + Math.abs(spanValue) <= VALUE_EPSILON + ) { + return null; + } + + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + + return [ + clamp01({ value: rightHandle.dt / spanTime }), + rightHandle.dv / spanValue, + clamp01({ value: 1 + leftHandle.dt / spanTime }), + 1 + leftHandle.dv / spanValue, + ]; +} + +export function getCurveHandlesForNormalizedCubicBezier({ + leftKey, + rightKey, + cubicBezier, +}: { + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; + cubicBezier: NormalizedCubicBezier; +}): { + rightHandle: CurveHandle; + leftHandle: CurveHandle; +} | null { + const spanTime = rightKey.time - leftKey.time; + const spanValue = rightKey.value - leftKey.value; + if ( + spanTime === 0 || + Math.abs(spanValue) <= VALUE_EPSILON + ) { + return null; + } + + const [rawX1, y1, rawX2, y2] = cubicBezier; + const x1 = clamp01({ value: rawX1 }); + const x2 = clamp01({ value: rawX2 }); + + return { + rightHandle: { + dt: spanTime * x1, + dv: spanValue * y1, + }, + leftHandle: { + dt: spanTime * (x2 - 1), + dv: spanValue * (y2 - 1), + }, + }; +} diff --git a/apps/web/src/lib/animation/effect-param-channel.ts b/apps/web/src/lib/animation/effect-param-channel.ts index a614c14f..3080e6ff 100644 --- a/apps/web/src/lib/animation/effect-param-channel.ts +++ b/apps/web/src/lib/animation/effect-param-channel.ts @@ -3,15 +3,9 @@ import type { Effect } from "@/lib/effects/types"; import type { ElementAnimations, EffectParamPath, - NumberAnimationChannel, } from "@/lib/animation/types"; -import { - getChannel, - removeKeyframe, - setChannel, - upsertKeyframe, -} from "./keyframes"; -import { getChannelValueAtTime } from "./interpolation"; +import { removeElementKeyframe } from "./keyframes"; +import { resolveAnimationPathValueAtTime } from "./resolve"; export const EFFECT_PARAM_PATH_PREFIX = "effects."; export const EFFECT_PARAM_PATH_SUFFIX = ".params."; @@ -74,64 +68,19 @@ export function resolveEffectParamsAtTime({ for (const [paramKey, staticValue] of Object.entries(effect.params)) { const path = buildEffectParamPath({ effectId: effect.id, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - if (channel && channel.keyframes.length > 0) { - resolved[paramKey] = getChannelValueAtTime({ - channel, - time: localTime, - fallbackValue: staticValue, - }) as number | string | boolean; - } else { - resolved[paramKey] = staticValue; - } + resolved[paramKey] = animations?.bindings[path] + ? resolveAnimationPathValueAtTime({ + animations, + propertyPath: path, + localTime, + fallbackValue: staticValue, + }) + : staticValue; } return resolved; } -const EMPTY_NUMBER_CHANNEL: NumberAnimationChannel = { - valueKind: "number", - keyframes: [], -}; - -export function upsertEffectParamKeyframe({ - animations, - effectId, - paramKey, - time, - value, - interpolation, - keyframeId, -}: { - animations: ElementAnimations | undefined; - effectId: string; - paramKey: string; - time: number; - value: number; - interpolation?: "linear" | "hold"; - keyframeId?: string; -}): ElementAnimations | undefined { - const path = buildEffectParamPath({ effectId, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - const targetChannel = - channel && channel.valueKind === "number" ? channel : EMPTY_NUMBER_CHANNEL; - const updatedChannel = upsertKeyframe({ - channel: targetChannel, - time, - value, - interpolation: interpolation ?? "linear", - keyframeId, - }); - - return ( - setChannel({ - animations, - propertyPath: path, - channel: updatedChannel, - }) ?? { channels: {} } - ); -} - export function removeEffectParamKeyframe({ animations, effectId, @@ -143,12 +92,9 @@ export function removeEffectParamKeyframe({ paramKey: string; keyframeId: string; }): ElementAnimations | undefined { - const path = buildEffectParamPath({ effectId, paramKey }); - const channel = getChannel({ animations, propertyPath: path }); - const updatedChannel = removeKeyframe({ channel, keyframeId }); - return setChannel({ + return removeElementKeyframe({ animations, - propertyPath: path, - channel: updatedChannel, + propertyPath: buildEffectParamPath({ effectId, paramKey }), + keyframeId, }); } diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts new file mode 100644 index 00000000..240b00ed --- /dev/null +++ b/apps/web/src/lib/animation/graph-channels.ts @@ -0,0 +1,95 @@ +import type { + AnimationPath, + ElementAnimations, + ScalarAnimationChannel, + ScalarGraphChannel, + ScalarGraphKeyframeContext, +} from "@/lib/animation/types"; + +function isScalarAnimationChannel( + channel: ElementAnimations["channels"][string], +): channel is ScalarAnimationChannel { + return channel?.kind === "scalar"; +} + +export function getEditableScalarChannels({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): ScalarGraphChannel[] { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return []; + } + + return binding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + if (!isScalarAnimationChannel(channel)) { + return []; + } + + return [ + { + propertyPath, + componentKey: component.key, + channelId: component.channelId, + channel, + }, + ]; + }); +} + +export function getEditableScalarChannel({ + animations, + propertyPath, + componentKey, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; +}): ScalarGraphChannel | null { + return ( + getEditableScalarChannels({ + animations, + propertyPath, + }).find((channel) => channel.componentKey === componentKey) ?? null + ); +} + +export function getScalarKeyframeContext({ + animations, + propertyPath, + componentKey, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; +}): ScalarGraphKeyframeContext | null { + const scalarChannel = getEditableScalarChannel({ + animations, + propertyPath, + componentKey, + }); + if (!scalarChannel) { + return null; + } + + const keyframeIndex = scalarChannel.channel.keys.findIndex( + (keyframe) => keyframe.id === keyframeId, + ); + if (keyframeIndex < 0) { + return null; + } + + return { + ...scalarChannel, + keyframe: scalarChannel.channel.keys[keyframeIndex], + keyframeIndex, + previousKey: scalarChannel.channel.keys[keyframeIndex - 1] ?? null, + nextKey: scalarChannel.channel.keys[keyframeIndex + 1] ?? null, + }; +} diff --git a/apps/web/src/lib/animation/graphic-param-channel.ts b/apps/web/src/lib/animation/graphic-param-channel.ts index 22f78ba5..de595060 100644 --- a/apps/web/src/lib/animation/graphic-param-channel.ts +++ b/apps/web/src/lib/animation/graphic-param-channel.ts @@ -1,77 +1,73 @@ -import type { - ElementAnimations, - GraphicParamPath, -} from "@/lib/animation/types"; -import type { ParamValues } from "@/lib/params"; -import { - getGraphicDefinition, - resolveGraphicParams, -} from "@/lib/graphics"; -import { getChannel } from "./keyframes"; -import { getChannelValueAtTime } from "./interpolation"; - -export const GRAPHIC_PARAM_PATH_PREFIX = "params."; - -export function buildGraphicParamPath({ - paramKey, -}: { - paramKey: string; -}): GraphicParamPath { - return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; -} - -export function isGraphicParamPath( - propertyPath: string, -): propertyPath is GraphicParamPath { - return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); -} - -export function parseGraphicParamPath({ - propertyPath, -}: { - propertyPath: string; -}): { paramKey: string } | null { - if (!isGraphicParamPath(propertyPath)) { - return null; - } - - const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); - return paramKey.length > 0 ? { paramKey } : null; -} - -export function resolveGraphicParamsAtTime({ - element, - localTime, -}: { - element: { - definitionId: string; - params: ParamValues; - animations?: ElementAnimations; - }; - localTime: number; -}): ParamValues { - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const baseParams = resolveGraphicParams(definition, element.params); - const resolved: ParamValues = { ...baseParams }; - - for (const param of definition.params) { - const path = buildGraphicParamPath({ paramKey: param.key }); - const channel = getChannel({ - animations: element.animations, - propertyPath: path, - }); - if (!channel || channel.keyframes.length === 0) { - continue; - } - - resolved[param.key] = getChannelValueAtTime({ - channel, - time: Math.max(0, localTime), - fallbackValue: baseParams[param.key] ?? param.default, - }) as number | string | boolean; - } - - return resolved; -} +import type { + ElementAnimations, + GraphicParamPath, +} from "@/lib/animation/types"; +import type { ParamValues } from "@/lib/params"; +import { + getGraphicDefinition, + resolveGraphicParams, +} from "@/lib/graphics"; +import { resolveAnimationPathValueAtTime } from "./resolve"; + +export const GRAPHIC_PARAM_PATH_PREFIX = "params."; + +export function buildGraphicParamPath({ + paramKey, +}: { + paramKey: string; +}): GraphicParamPath { + return `${GRAPHIC_PARAM_PATH_PREFIX}${paramKey}`; +} + +export function isGraphicParamPath( + propertyPath: string, +): propertyPath is GraphicParamPath { + return propertyPath.startsWith(GRAPHIC_PARAM_PATH_PREFIX); +} + +export function parseGraphicParamPath({ + propertyPath, +}: { + propertyPath: string; +}): { paramKey: string } | null { + if (!isGraphicParamPath(propertyPath)) { + return null; + } + + const paramKey = propertyPath.slice(GRAPHIC_PARAM_PATH_PREFIX.length); + return paramKey.length > 0 ? { paramKey } : null; +} + +export function resolveGraphicParamsAtTime({ + element, + localTime, +}: { + element: { + definitionId: string; + params: ParamValues; + animations?: ElementAnimations; + }; + localTime: number; +}): ParamValues { + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const baseParams = resolveGraphicParams(definition, element.params); + const resolved: ParamValues = { ...baseParams }; + + for (const param of definition.params) { + const path = buildGraphicParamPath({ paramKey: param.key }); + if (!element.animations?.bindings[path]) { + continue; + } + + resolved[param.key] = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath: path, + localTime: Math.max(0, localTime), + fallbackValue: baseParams[param.key] ?? param.default, + }); + } + + return resolved; +} diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 819a59e5..7d2646d9 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -1,71 +1,94 @@ -export { - getChannelValueAtTime, - getNumberChannelValueAtTime, - getVectorChannelValueAtTime, - normalizeChannel, -} from "./interpolation"; - -export { - clampAnimationsToDuration, - cloneAnimations, - getChannel, - removeElementKeyframe, - retimeElementKeyframe, - setChannel, - splitAnimationsAtTime, - upsertElementKeyframe, - upsertPathKeyframe, -} from "./keyframes"; - -export { - getElementLocalTime, - resolveColorAtTime, - resolveNumberAtTime, - resolveOpacityAtTime, - resolveTransformAtTime, -} from "./resolve"; - -export { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getDefaultInterpolationForProperty, - getElementBaseValueForProperty, - isAnimationPropertyPath, - supportsAnimationProperty, - type AnimationPropertyDefinition, - type NumericSpec, - type NumericRange, - withElementBaseValueForProperty, -} from "./property-registry"; - -export { - getElementKeyframes, - getKeyframeAtTime, - hasKeyframesForPath, -} from "./keyframe-query"; - -export { - buildGraphicParamPath, - isGraphicParamPath, - parseGraphicParamPath, - resolveGraphicParamsAtTime, -} from "./graphic-param-channel"; - -export { - isAnimationPath, - resolveAnimationTarget, - getParamValueKind, - getParamDefaultInterpolation, - type AnimationPathDescriptor, -} from "./target-resolver"; - -export { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - type GroupKeyframeRef, -} from "./property-groups"; - -export { - getVectorChannelForPath, - isVectorValue, -} from "./vector-channel"; +export { + getChannelValueAtTime, + getDiscreteChannelValueAtTime, + getScalarChannelValueAtTime, + getScalarSegmentInterpolation, + normalizeChannel, +} from "./interpolation"; + +export { + clampAnimationsToDuration, + cloneAnimations, + getChannel, + removeElementKeyframe, + retimeElementKeyframe, + setBindingComponentChannel, + setChannel, + splitAnimationsAtTime, + updateScalarKeyframeCurve, + upsertElementKeyframe, + upsertPathKeyframe, +} from "./keyframes"; + +export { + getElementLocalTime, + resolveAnimationPathValueAtTime, + resolveColorAtTime, + resolveNumberAtTime, + resolveOpacityAtTime, + resolveTransformAtTime, +} from "./resolve"; + +export { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getDefaultInterpolationForProperty, + getElementBaseValueForProperty, + isAnimationPropertyPath, + supportsAnimationProperty, + type AnimationPropertyDefinition, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; + +export { + getElementKeyframes, + getKeyframeById, + getKeyframeAtTime, + hasKeyframesForPath, +} from "./keyframe-query"; + +export { + getEditableScalarChannel, + getEditableScalarChannels, + getScalarKeyframeContext, +} from "./graph-channels"; + +export { + getCurveHandlesForNormalizedCubicBezier, + getNormalizedCubicBezierForScalarSegment, +} from "./curve-bridge"; + +export { + buildGraphicParamPath, + isGraphicParamPath, + parseGraphicParamPath, + resolveGraphicParamsAtTime, +} from "./graphic-param-channel"; + +export { + buildEffectParamPath, + isEffectParamPath, + parseEffectParamPath, + removeEffectParamKeyframe, + resolveEffectParamsAtTime, +} from "./effect-param-channel"; + +export { + isAnimationPath, + coerceAnimationValueForParam, + resolveAnimationTarget, + getParamValueKind, + getParamDefaultInterpolation, + type AnimationPathDescriptor, +} from "./target-resolver"; + +export { + getGroupKeyframesAtTime, + hasGroupKeyframeAtTime, + type GroupKeyframeRef, +} from "./property-groups"; + +export { + isVectorValue, +} from "./binding-values"; diff --git a/apps/web/src/lib/animation/interpolation.ts b/apps/web/src/lib/animation/interpolation.ts index c5b9ea80..1a3c0fed 100644 --- a/apps/web/src/lib/animation/interpolation.ts +++ b/apps/web/src/lib/animation/interpolation.ts @@ -1,15 +1,20 @@ import type { AnimationChannel, + AnimationInterpolation, AnimationValue, - ColorAnimationChannel, - DiscreteValue, DiscreteAnimationChannel, - NumberAnimationChannel, - VectorAnimationChannel, - VectorValue, + DiscreteValue, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarSegmentType, } from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; +import { clamp } from "@/utils/math"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; function byTimeAscending({ leftTime, @@ -30,77 +35,7 @@ function isWithinTimePair({ leftTime: number; rightTime: number; }): boolean { - return ( - time >= leftTime - TIME_EPSILON_SECONDS && - time <= rightTime + TIME_EPSILON_SECONDS - ); -} - -function clamp01({ value }: { value: number }): number { - return Math.max(0, Math.min(1, value)); -} - -function parseHexChannel({ hex }: { hex: string }): number | null { - const value = Number.parseInt(hex, 16); - return Number.isNaN(value) ? null : value; -} - -function parseHexColor({ - color, -}: { - color: string; -}): { red: number; green: number; blue: number; alpha: number } | null { - const trimmed = color.trim(); - if (!trimmed.startsWith("#")) { - return null; - } - - const rawHex = trimmed.slice(1); - if (rawHex.length === 3 || rawHex.length === 4) { - const [redHex, greenHex, blueHex, alphaHex = "f"] = rawHex.split(""); - const red = parseHexChannel({ hex: `${redHex}${redHex}` }); - const green = parseHexChannel({ hex: `${greenHex}${greenHex}` }); - const blue = parseHexChannel({ hex: `${blueHex}${blueHex}` }); - const alpha = parseHexChannel({ hex: `${alphaHex}${alphaHex}` }); - if (red === null || green === null || blue === null || alpha === null) { - return null; - } - - return { red, green, blue, alpha: alpha / 255 }; - } - - if (rawHex.length === 6 || rawHex.length === 8) { - const red = parseHexChannel({ hex: rawHex.slice(0, 2) }); - const green = parseHexChannel({ hex: rawHex.slice(2, 4) }); - const blue = parseHexChannel({ hex: rawHex.slice(4, 6) }); - const alphaHex = rawHex.length === 8 ? rawHex.slice(6, 8) : "ff"; - const alpha = parseHexChannel({ hex: alphaHex }); - if (red === null || green === null || blue === null || alpha === null) { - return null; - } - - return { red, green, blue, alpha: alpha / 255 }; - } - - return null; -} - -function formatRgbaColor({ - red, - green, - blue, - alpha, -}: { - red: number; - green: number; - blue: number; - alpha: number; -}): string { - const roundedRed = Math.round(red); - const roundedGreen = Math.round(green); - const roundedBlue = Math.round(blue); - const roundedAlpha = Math.round(clamp01({ value: alpha }) * 1000) / 1000; - return `rgba(${roundedRed}, ${roundedGreen}, ${roundedBlue}, ${roundedAlpha})`; + return time >= leftTime && time <= rightTime; } function lerpNumber({ @@ -115,43 +50,99 @@ function lerpNumber({ return leftValue + (rightValue - leftValue) * progress; } -function interpolateColor({ - leftColor, - rightColor, - progress, +function normalizeRightHandle({ + handle, + leftKey, + rightKey, }: { - leftColor: string; - rightColor: string; - progress: number; -}): string { - const leftParsed = parseHexColor({ color: leftColor }); - const rightParsed = parseHexColor({ color: rightColor }); - if (!leftParsed || !rightParsed) { - return progress >= 1 ? rightColor : leftColor; + handle: ScalarAnimationKey["rightHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; } - return formatRgbaColor({ - red: lerpNumber({ - leftValue: leftParsed.red, - rightValue: rightParsed.red, - progress, - }), - green: lerpNumber({ - leftValue: leftParsed.green, - rightValue: rightParsed.green, - progress, - }), - blue: lerpNumber({ - leftValue: leftParsed.blue, - rightValue: rightParsed.blue, - progress, - }), - alpha: lerpNumber({ - leftValue: leftParsed.alpha, - rightValue: rightParsed.alpha, - progress, - }), + const span = Math.max(1, rightKey.time - leftKey.time); + return { + dt: Math.min(span, Math.max(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeLeftHandle({ + handle, + leftKey, + rightKey, +}: { + handle: ScalarAnimationKey["leftHandle"]; + leftKey: ScalarAnimationKey; + rightKey: ScalarAnimationKey; +}) { + if (!handle) { + return undefined; + } + + const span = Math.max(1, rightKey.time - leftKey.time); + return { + dt: Math.max(-span, Math.min(0, handle.dt)), + dv: handle.dv, + }; +} + +function normalizeScalarKey({ + key, +}: { + key: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + ...key, + tangentMode: key.tangentMode ?? "flat", + segmentToNext: key.segmentToNext ?? "linear", + }; +} + +function normalizeScalarChannel({ + channel, +}: { + channel: ScalarAnimationChannel; +}): ScalarAnimationChannel { + const sortedKeys = [...channel.keys] + .map((key) => normalizeScalarKey({ key })) + .sort((leftKey, rightKey) => + byTimeAscending({ + leftTime: leftKey.time, + rightTime: rightKey.time, + }), + ); + const nextKeys = sortedKeys.map((key, index) => { + const previousKey = sortedKeys[index - 1]; + const nextKey = sortedKeys[index + 1]; + return { + ...key, + leftHandle: + previousKey != null + ? normalizeLeftHandle({ + handle: key.leftHandle, + leftKey: previousKey, + rightKey: key, + }) + : undefined, + rightHandle: + nextKey != null + ? normalizeRightHandle({ + handle: key.rightHandle, + leftKey: key, + rightKey: nextKey, + }) + : undefined, + }; }); + + return { + ...channel, + keys: nextKeys, + }; } export function normalizeChannel({ @@ -159,9 +150,15 @@ export function normalizeChannel({ }: { channel: TChannel; }): TChannel { + if (channel.kind === "scalar") { + return normalizeScalarChannel({ + channel, + }) as TChannel; + } + return { ...channel, - keyframes: [...channel.keyframes].sort((leftKeyframe, rightKeyframe) => + keys: [...channel.keys].sort((leftKeyframe, rightKeyframe) => byTimeAscending({ leftTime: leftKeyframe.time, rightTime: rightKeyframe.time, @@ -170,171 +167,154 @@ export function normalizeChannel({ } as TChannel; } -function evaluateChannelValueAtTime< - TKeyframe extends { time: number; value: TValue }, - TValue, ->({ - keyframes, +function extrapolateScalarEdge({ + mode, + edgeKey, + neighborKey, time, - fallbackValue, - getInterpolatedValue, }: { - keyframes: TKeyframe[] | undefined; + mode: "hold" | "linear"; + edgeKey: ScalarAnimationKey; + neighborKey: ScalarAnimationKey | undefined; time: number; - fallbackValue: TValue; - getInterpolatedValue: ({ - leftKeyframe, - rightKeyframe, - progress, - }: { - leftKeyframe: TKeyframe; - rightKeyframe: TKeyframe; - progress: number; - }) => TValue; -}): TValue { - if (!keyframes || keyframes.length === 0) { - return fallbackValue; +}) { + if (mode === "hold" || !neighborKey) { + return edgeKey.value; } - const firstKeyframe = keyframes[0]; - const lastKeyframe = keyframes[keyframes.length - 1]; - if (!firstKeyframe || !lastKeyframe) { - return fallbackValue; + const span = neighborKey.time - edgeKey.time; + if (span === 0) { + return edgeKey.value; } - if (time <= firstKeyframe.time + TIME_EPSILON_SECONDS) { - return firstKeyframe.value; - } - - if (time >= lastKeyframe.time - TIME_EPSILON_SECONDS) { - return lastKeyframe.value; - } - - for ( - let keyframeIndex = 0; - keyframeIndex < keyframes.length - 1; - keyframeIndex++ - ) { - const leftKeyframe = keyframes[keyframeIndex]; - const rightKeyframe = keyframes[keyframeIndex + 1]; - - if (Math.abs(time - rightKeyframe.time) <= TIME_EPSILON_SECONDS) { - return rightKeyframe.value; - } - - const isBetweenPair = isWithinTimePair({ - time, - leftTime: leftKeyframe.time, - rightTime: rightKeyframe.time, - }); - if (!isBetweenPair) { - continue; - } - - const span = rightKeyframe.time - leftKeyframe.time; - if (Math.abs(span) <= TIME_EPSILON_SECONDS) { - return rightKeyframe.value; - } - - const progress = clamp01({ - value: (time - leftKeyframe.time) / span, - }); - - return getInterpolatedValue({ - leftKeyframe, - rightKeyframe, - progress, - }); - } - - return lastKeyframe.value; + return edgeKey.value + ((time - edgeKey.time) / span) * (neighborKey.value - edgeKey.value); } -export function getNumberChannelValueAtTime({ +export function getScalarSegmentInterpolation({ + segment, +}: { + segment: ScalarSegmentType; +}): AnimationInterpolation { + if (segment === "step") { + return "hold"; + } + + return segment === "bezier" ? "bezier" : "linear"; +} + +export function getScalarChannelValueAtTime({ channel, time, fallbackValue, }: { - channel: NumberAnimationChannel | undefined; + channel: ScalarAnimationChannel | undefined; time: number; fallbackValue: number; }): number { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + const normalizedChannel = normalizeChannel({ + channel, + }); + const firstKey = normalizedChannel.keys[0]; + const lastKey = normalizedChannel.keys[normalizedChannel.keys.length - 1]; + if (!firstKey || !lastKey) { + return fallbackValue; + } + + if (time <= firstKey.time) { + if (time < firstKey.time) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.before ?? "hold", + edgeKey: firstKey, + neighborKey: normalizedChannel.keys[1], + time, + }); + } + + return firstKey.value; + } + + if (time >= lastKey.time) { + if (time > lastKey.time) { + return extrapolateScalarEdge({ + mode: normalizedChannel.extrapolation?.after ?? "hold", + edgeKey: lastKey, + neighborKey: normalizedChannel.keys[normalizedChannel.keys.length - 2], + time, + }); + } + + return lastKey.value; + } + + for ( + let keyIndex = 0; + keyIndex < normalizedChannel.keys.length - 1; + keyIndex++ + ) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if (time === rightKey.time) { + return rightKey.value; + } + + if ( + !isWithinTimePair({ + time, + leftTime: leftKey.time, + rightTime: rightKey.time, + }) + ) { + continue; + } + + if (leftKey.segmentToNext === "step") { + return leftKey.value; + } + + const span = rightKey.time - leftKey.time; + if (span === 0) { + return rightKey.value; + } + + const progress = clamp({ + value: (time - leftKey.time) / span, + min: 0, + max: 1, + }); + if (leftKey.segmentToNext === "linear") { return lerpNumber({ - leftValue: leftKeyframe.value, - rightValue: rightKeyframe.value, + leftValue: leftKey.value, + rightValue: rightKey.value, progress, }); - }, - }); + } + + const curveProgress = solveBezierProgressForTime({ + time, + leftKey, + rightKey, + }); + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + return getBezierPoint({ + progress: curveProgress, + p0: leftKey.value, + p1: leftKey.value + rightHandle.dv, + p2: rightKey.value + leftHandle.dv, + p3: rightKey.value, + }); + } + + return lastKey.value; } -export function getColorValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: ColorAnimationChannel | undefined; - time: number; - fallbackValue: string; -}): string { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } - - return interpolateColor({ - leftColor: leftKeyframe.value, - rightColor: rightKeyframe.value, - progress, - }); - }, - }); -} - -export function getVectorChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: VectorAnimationChannel | undefined; - time: number; - fallbackValue: VectorValue; -}): VectorValue { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe, rightKeyframe, progress }) => { - if (leftKeyframe.interpolation === "hold") { - return leftKeyframe.value; - } - - return { - x: - leftKeyframe.value.x + - (rightKeyframe.value.x - leftKeyframe.value.x) * progress, - y: - leftKeyframe.value.y + - (rightKeyframe.value.y - leftKeyframe.value.y) * progress, - }; - }, - }); -} - -function getDiscreteValueAtTime({ +export function getDiscreteChannelValueAtTime({ channel, time, fallbackValue, @@ -343,12 +323,21 @@ function getDiscreteValueAtTime({ time: number; fallbackValue: DiscreteValue; }): DiscreteValue { - return evaluateChannelValueAtTime({ - keyframes: channel?.keyframes, - time, - fallbackValue, - getInterpolatedValue: ({ leftKeyframe }) => leftKeyframe.value, + if (!channel || channel.keys.length === 0) { + return fallbackValue; + } + + const normalizedChannel = normalizeChannel({ + channel, }); + let currentValue = fallbackValue; + for (const key of normalizedChannel.keys) { + if (time < key.time) { + break; + } + currentValue = key.value; + } + return currentValue; } export function getChannelValueAtTime({ @@ -360,50 +349,25 @@ export function getChannelValueAtTime({ time: number; fallbackValue: AnimationValue; }): AnimationValue { - if (!channel || channel.keyframes.length === 0) { + if (!channel || channel.keys.length === 0) { return fallbackValue; } - if (channel.valueKind === "number") { - if (typeof fallbackValue !== "number") { - return fallbackValue; - } - - return getNumberChannelValueAtTime({ - channel, - time, - fallbackValue, - }); - } - - if (channel.valueKind === "color") { - if (typeof fallbackValue !== "string") { - return fallbackValue; - } - - return getColorValueAtTime({ - channel, - time, - fallbackValue, - }); - } - - if (channel.valueKind === "vector") { - if (!isVectorValue(fallbackValue)) { - return fallbackValue; - } - return getVectorChannelValueAtTime({ - channel, - time, - fallbackValue: fallbackValue as VectorValue, - }); + if (channel.kind === "scalar") { + return typeof fallbackValue === "number" + ? getScalarChannelValueAtTime({ + channel, + time, + fallbackValue, + }) + : fallbackValue; } if (typeof fallbackValue !== "string" && typeof fallbackValue !== "boolean") { return fallbackValue; } - return getDiscreteValueAtTime({ + return getDiscreteChannelValueAtTime({ channel, time, fallbackValue, diff --git a/apps/web/src/lib/animation/keyframe-query.ts b/apps/web/src/lib/animation/keyframe-query.ts index 62c8a171..dbcd041c 100644 --- a/apps/web/src/lib/animation/keyframe-query.ts +++ b/apps/web/src/lib/animation/keyframe-query.ts @@ -1,72 +1,294 @@ -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; -import type { - AnimationPath, - ElementAnimations, - ElementKeyframe, -} from "@/lib/animation/types"; -import { isAnimationPath } from "./target-resolver"; - -export function getElementKeyframes({ - animations, -}: { - animations: ElementAnimations | undefined; -}): ElementKeyframe[] { - if (!animations) { - return []; - } - - return Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if ( - !channel || - channel.keyframes.length === 0 || - !isAnimationPath(propertyPath) - ) { - return []; - } - - return channel.keyframes.map((keyframe) => ({ - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - })); - }, - ); -} - -export function hasKeyframesForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; -}): boolean { - const channel = animations?.channels[propertyPath]; - return Boolean(channel && channel.keyframes.length > 0); -} - -export function getKeyframeAtTime({ - animations, - propertyPath, - time, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPath; - time: number; -}): ElementKeyframe | null { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.keyframes.length === 0) return null; - const keyframe = channel.keyframes.find( - (keyframe) => Math.abs(keyframe.time - time) <= TIME_EPSILON_SECONDS, - ); - if (!keyframe) return null; - return { - propertyPath, - id: keyframe.id, - time: keyframe.time, - value: keyframe.value, - interpolation: keyframe.interpolation, - }; -} +import type { + AnimationBindingInstance, + AnimationChannel, + AnimationPath, + ElementAnimations, + ElementKeyframe, +} from "@/lib/animation/types"; +import { + type AnimationComponentValue, + composeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, +} from "./interpolation"; +import { isAnimationPath } from "./target-resolver"; + +function getBindingFallbackValue({ + channel, +}: { + channel: ElementAnimations["channels"][string]; +}) { + if (!channel || channel.keys.length === 0) { + return channel?.kind === "discrete" ? false : 0; + } + + return channel.keys[0].value; +} + +interface BindingKeyframeMatch { + componentIndex: number; + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +} + +function getBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + return binding.components.flatMap((component, componentIndex) => { + const channel = animations.channels[component.channelId]; + if (!channel || channel.keys.length === 0) { + return []; + } + + return channel.keys.map((keyframe) => ({ + componentIndex, + channel, + keyframe, + })); + }); +} + +function getUniqueBindingKeyframeMatches({ + animations, + binding, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; +}): BindingKeyframeMatch[] { + const sortedMatches = getBindingKeyframeMatches({ + animations, + binding, + }).sort( + (leftMatch, rightMatch) => + leftMatch.keyframe.time - rightMatch.keyframe.time || + leftMatch.componentIndex - rightMatch.componentIndex, + ); + const uniqueMatches: BindingKeyframeMatch[] = []; + + for (const match of sortedMatches) { + const previousMatch = uniqueMatches[uniqueMatches.length - 1]; + if ( + !previousMatch || + previousMatch.keyframe.time !== match.keyframe.time + ) { + uniqueMatches.push(match); + continue; + } + + if ( + previousMatch.componentIndex !== 0 && + match.componentIndex === 0 + ) { + uniqueMatches[uniqueMatches.length - 1] = match; + } + } + + return uniqueMatches; +} + +function getPreferredBindingKeyframeMatch({ + matches, +}: { + matches: BindingKeyframeMatch[]; +}): BindingKeyframeMatch | null { + return ( + matches.find((match) => match.componentIndex === 0) ?? + matches[0] ?? + null + ); +} + +function getComposedBindingValueAtTime({ + animations, + binding, + time, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + time: number; +}) { + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time, + fallbackValue: getBindingFallbackValue({ channel }), + }), + ]; + }), + ) as Record; + + return composeAnimationValue({ + binding, + componentValues, + }); +} + +function getKeyframeInterpolation({ + channel, + keyframe, +}: { + channel: AnimationChannel; + keyframe: AnimationChannel["keys"][number]; +}) { + return channel.kind === "scalar" && "segmentToNext" in keyframe + ? getScalarSegmentInterpolation({ segment: keyframe.segmentToNext }) + : "hold"; +} + +function toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, +}: { + animations: ElementAnimations; + binding: AnimationBindingInstance; + propertyPath: AnimationPath; + keyframeMatch: BindingKeyframeMatch; +}): ElementKeyframe | null { + const value = getComposedBindingValueAtTime({ + animations, + binding, + time: keyframeMatch.keyframe.time, + }); + if (value === null) { + return null; + } + + return { + propertyPath, + id: keyframeMatch.keyframe.id, + time: keyframeMatch.keyframe.time, + value, + interpolation: getKeyframeInterpolation({ + channel: keyframeMatch.channel, + keyframe: keyframeMatch.keyframe, + }), + }; +} + +export function getElementKeyframes({ + animations, +}: { + animations: ElementAnimations | undefined; +}): ElementKeyframe[] { + if (!animations) { + return []; + } + + return Object.entries(animations.bindings).flatMap( + ([propertyPath, binding]) => { + if (!binding || !isAnimationPath(propertyPath)) { + return []; + } + + return getUniqueBindingKeyframeMatches({ + animations, + binding, + }).flatMap((keyframeMatch) => { + const keyframe = toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); + if (!keyframe) { + return []; + } + + return [keyframe]; + }); + }, + ); +} + +export function hasKeyframesForPath({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): boolean { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return false; + } + + return binding.components.some((component) => + Boolean(animations?.channels[component.channelId]?.keys.length), + ); +} + +export function getKeyframeAtTime({ + animations, + propertyPath, + time, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + time: number; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => keyframe.time === time), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} + +export function getKeyframeById({ + animations, + propertyPath, + keyframeId, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + keyframeId: string; +}): ElementKeyframe | null { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return null; + } + + const keyframeMatch = getPreferredBindingKeyframeMatch({ + matches: getBindingKeyframeMatches({ + animations, + binding, + }).filter(({ keyframe }) => keyframe.id === keyframeId), + }); + if (!keyframeMatch) { + return null; + } + + return toElementKeyframe({ + animations, + binding, + propertyPath, + keyframeMatch, + }); +} diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 13cdf238..05290c1d 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -1,28 +1,39 @@ import type { + AnimationBindingInstance, + AnimationBindingKind, AnimationChannel, AnimationInterpolation, - AnimationKeyframe, AnimationPath, AnimationPropertyPath, AnimationValue, - AnimationValueKind, - ColorAnimationChannel, DiscreteAnimationChannel, + DiscreteAnimationKey, ElementAnimations, - NumberAnimationChannel, - VectorAnimationChannel, + ScalarAnimationChannel, + ScalarAnimationKey, + ScalarCurveKeyframePatch, + ScalarSegmentType, } from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { generateUUID } from "@/utils/id"; -import { snapToStep } from "@/utils/math"; -import { getChannelValueAtTime, normalizeChannel } from "./interpolation"; +import { + cloneAnimationBinding, + createAnimationBinding, + decomposeAnimationValue, +} from "./binding-values"; +import { + getBezierPoint, + getDefaultLeftHandle, + getDefaultRightHandle, + solveBezierProgressForTime, +} from "./bezier"; +import { + getChannelValueAtTime, + getScalarSegmentInterpolation, + normalizeChannel, +} from "./interpolation"; import { coerceAnimationValueForProperty, - getDefaultInterpolationForProperty, getAnimationPropertyDefinition, - isAnimationPropertyPath, - type NumericRange, } from "./property-registry"; function isNearlySameTime({ @@ -32,35 +43,367 @@ function isNearlySameTime({ leftTime: number; rightTime: number; }): boolean { - return Math.abs(leftTime - rightTime) <= TIME_EPSILON_SECONDS; + return leftTime === rightTime; +} + +function hasChannelKeys({ + channel, +}: { + channel: AnimationChannel | undefined; +}): boolean { + return Boolean(channel && channel.keys.length > 0); } function toAnimation({ - channelEntries, + animations, }: { - channelEntries: Array<[string, AnimationChannel]>; + animations: ElementAnimations; }): ElementAnimations | undefined { - if (channelEntries.length === 0) { + const nextBindings = Object.fromEntries( + Object.entries(animations.bindings).filter(([, binding]) => binding), + ); + const nextChannels = Object.fromEntries( + Object.entries(animations.channels).filter(([, channel]) => + hasChannelKeys({ channel }), + ), + ); + if (Object.keys(nextBindings).length === 0 || Object.keys(nextChannels).length === 0) { return undefined; } return { - channels: Object.fromEntries(channelEntries), + bindings: nextBindings, + channels: nextChannels, }; } -function toChannel({ - keyframes, - valueKind, +function cloneAnimationsState({ + animations, }: { - keyframes: AnimationKeyframe[]; - valueKind: AnimationValueKind; + animations: ElementAnimations | undefined; +}): ElementAnimations { + return { + bindings: { ...(animations?.bindings ?? {}) }, + channels: { ...(animations?.channels ?? {}) }, + }; +} + +function getBindingChannelKind({ + kind, +}: { + kind: AnimationBindingKind; +}): AnimationChannel["kind"] { + return kind === "discrete" ? "discrete" : "scalar"; +} + +function getPrimaryComponent({ + binding, +}: { + binding: AnimationBindingInstance; +}) { + return binding.components[0] ?? null; +} + +function getPrimaryChannelId({ + binding, +}: { + binding: AnimationBindingInstance; +}) { + return getPrimaryComponent({ binding })?.channelId ?? null; +} + +function getScalarSegmentType({ + interpolation, +}: { + interpolation: AnimationInterpolation; +}): ScalarSegmentType { + if (interpolation === "hold") { + return "step"; + } + return interpolation === "bezier" ? "bezier" : "linear"; +} + +function getInterpolationForBinding({ + kind, + interpolation, +}: { + kind: AnimationBindingKind; + interpolation: AnimationInterpolation | undefined; +}): AnimationInterpolation { + if (kind === "discrete") { + return "hold"; + } + + if ( + interpolation === "linear" || + interpolation === "hold" || + interpolation === "bezier" + ) { + return interpolation; + } + + return "linear"; +} + +function createEmptyChannelForBindingKind({ + kind, +}: { + kind: AnimationBindingKind; }): AnimationChannel { + if (kind === "discrete") { + return { + kind: "discrete", + keys: [], + } satisfies DiscreteAnimationChannel; + } + + return { + kind: "scalar", + keys: [], + } satisfies ScalarAnimationChannel; +} + +function createScalarKey({ + id, + time, + value, + interpolation, + previousKey, +}: { + id: string; + time: number; + value: number; + interpolation: AnimationInterpolation; + previousKey?: ScalarAnimationKey; +}): ScalarAnimationKey { + return { + id, + time, + value, + leftHandle: previousKey?.leftHandle, + rightHandle: previousKey?.rightHandle, + segmentToNext: + previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }), + tangentMode: previousKey?.tangentMode ?? "flat", + }; +} + +function createDiscreteKey({ + id, + time, + value, +}: { + id: string; + time: number; + value: string | boolean; +}): DiscreteAnimationKey { + return { + id, + time, + value, + }; +} + +function getBinding({ + animations, + propertyPath, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; +}): AnimationBindingInstance | undefined { + return animations?.bindings[propertyPath]; +} + +function getChannelById({ + animations, + channelId, +}: { + animations: ElementAnimations | undefined; + channelId: string; +}): AnimationChannel | undefined { + return animations?.channels[channelId]; +} + +function getBindingComponent({ + binding, + componentKey, +}: { + binding: AnimationBindingInstance; + componentKey: string; +}) { + return binding.components.find((component) => component.key === componentKey) ?? null; +} + +function getTargetKeyMetadata({ + channel, + time, + keyframeId, +}: { + channel: AnimationChannel | undefined; + time: number; + keyframeId?: string; +}) { + const normalizedChannel = + channel != null ? normalizeChannel({ channel }) : undefined; + const keys = normalizedChannel?.keys ?? []; + if (keyframeId) { + const keyById = keys.find((key) => key.id === keyframeId); + if (keyById) { + return { + id: keyById.id, + time, + }; + } + } + + const keyAtTime = keys.find((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (keyAtTime) { + return { + id: keyAtTime.id, + time: keyAtTime.time, + }; + } + + return { + id: keyframeId ?? generateUUID(), + time, + }; +} + +function upsertDiscreteChannelKey({ + channel, + time, + value, + keyframeId, +}: { + channel: DiscreteAnimationChannel | undefined; + time: number; + value: string | boolean; + keyframeId?: string; +}): DiscreteAnimationChannel { + const normalizedChannel = normalizeChannel({ + channel: channel ?? { kind: "discrete", keys: [] }, + }); + const keys = [...normalizedChannel.keys]; + if (keyframeId) { + const existingIndex = keys.findIndex((key) => key.id === keyframeId); + if (existingIndex >= 0) { + keys[existingIndex] = createDiscreteKey({ + id: keys[existingIndex].id, + time, + value, + }); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); + } + } + + const existingAtTimeIndex = keys.findIndex((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (existingAtTimeIndex >= 0) { + keys[existingAtTimeIndex] = createDiscreteKey({ + id: keys[existingAtTimeIndex].id, + time: keys[existingAtTimeIndex].time, + value, + }); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); + } + + keys.push( + createDiscreteKey({ + id: keyframeId ?? generateUUID(), + time, + value, + }), + ); + return normalizeChannel({ + channel: { kind: "discrete", keys }, + }); +} + +function upsertScalarChannelKey({ + channel, + time, + value, + interpolation, + keyframeId, +}: { + channel: ScalarAnimationChannel | undefined; + time: number; + value: number; + interpolation: AnimationInterpolation; + keyframeId?: string; +}): ScalarAnimationChannel { + const normalizedChannel = normalizeChannel({ + channel: channel ?? { kind: "scalar", keys: [] }, + }); + const keys = [...normalizedChannel.keys]; + if (keyframeId) { + const existingIndex = keys.findIndex((key) => key.id === keyframeId); + if (existingIndex >= 0) { + keys[existingIndex] = createScalarKey({ + id: keys[existingIndex].id, + time, + value, + interpolation, + previousKey: { + ...keys[existingIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + }, + }); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); + } + } + + const existingAtTimeIndex = keys.findIndex((key) => + isNearlySameTime({ leftTime: key.time, rightTime: time }), + ); + if (existingAtTimeIndex >= 0) { + keys[existingAtTimeIndex] = createScalarKey({ + id: keys[existingAtTimeIndex].id, + time: keys[existingAtTimeIndex].time, + value, + interpolation, + previousKey: { + ...keys[existingAtTimeIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + }, + }); + return normalizeChannel({ + channel: { + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, + }); + } + + keys.push( + createScalarKey({ + id: keyframeId ?? generateUUID(), + time, + value, + interpolation, + }), + ); return normalizeChannel({ channel: { - valueKind, - keyframes, - } as AnimationChannel, + kind: "scalar", + keys, + extrapolation: normalizedChannel.extrapolation, + }, }); } @@ -69,173 +412,12 @@ export function getChannel({ propertyPath, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; }): AnimationChannel | undefined { - return animations?.channels[propertyPath]; -} - -function getInterpolationForChannel({ - channel, - interpolation, -}: { - channel: AnimationChannel; - interpolation: AnimationInterpolation | undefined; -}): AnimationInterpolation { - if (channel.valueKind === "discrete") { - return "hold"; - } - - if (interpolation === "linear" || interpolation === "hold") { - return interpolation; - } - - return "linear"; -} - -function buildKeyframe({ - channel, - id, - time, - value, - interpolation, -}: { - channel: AnimationChannel; - id: string; - time: number; - value: AnimationValue; - interpolation: AnimationInterpolation; -}): AnimationKeyframe { - if (channel.valueKind === "number") { - if (typeof value !== "number") { - throw new Error("Number channel keyframes require numeric values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (channel.valueKind === "color") { - if (typeof value !== "string") { - throw new Error("Color channel keyframes require string values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (channel.valueKind === "vector") { - if (!isVectorValue(value)) { - throw new Error("Vector channel keyframes require {x, y} values"); - } - - return { - id, - time, - value, - interpolation: interpolation === "hold" ? "hold" : "linear", - }; - } - - if (typeof value !== "string" && typeof value !== "boolean") { - throw new Error( - "Discrete channel keyframes require boolean or string values", - ); - } - - return { - id, - time, - value, - interpolation: "hold", - }; -} - -function createEmptyChannelForValueKind({ - valueKind, -}: { - valueKind: AnimationValueKind; -}): AnimationChannel { - if (valueKind === "number") { - return { - valueKind: "number", - keyframes: [], - } satisfies NumberAnimationChannel; - } - - if (valueKind === "color") { - return { - valueKind: "color", - keyframes: [], - } satisfies ColorAnimationChannel; - } - - if (valueKind === "vector") { - return { - valueKind: "vector", - keyframes: [], - } satisfies VectorAnimationChannel; - } - - return { - valueKind: "discrete", - keyframes: [], - } satisfies DiscreteAnimationChannel; -} - -function clampNumericRange({ - value, - numericRange, -}: { - value: number; - numericRange: NumericRange | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -function coerceAnimationValueForPath({ - value, - valueKind, - numericRange, -}: { - value: AnimationValue; - valueKind: AnimationValueKind; - numericRange?: NumericRange; -}): AnimationValue | null { - if (valueKind === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return clampNumericRange({ value, numericRange }); - } - - if (valueKind === "color") { - return typeof value === "string" ? value : null; - } - - if (valueKind === "vector") { - return isVectorValue(value) ? value : null; - } - - return typeof value === "string" || typeof value === "boolean" ? value : null; + const binding = getBinding({ animations, propertyPath }); + const primaryChannelId = + binding != null ? getPrimaryChannelId({ binding }) : null; + return primaryChannelId ? animations?.channels[primaryChannelId] : undefined; } export function upsertPathKeyframe({ @@ -245,9 +427,9 @@ export function upsertPathKeyframe({ value, interpolation, keyframeId, - valueKind, + kind, defaultInterpolation, - numericRange, + coerceValue, }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; @@ -255,39 +437,80 @@ export function upsertPathKeyframe({ value: AnimationValue; interpolation?: AnimationInterpolation; keyframeId?: string; - valueKind: AnimationValueKind; + kind: AnimationBindingKind; defaultInterpolation: AnimationInterpolation; - numericRange?: NumericRange; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; }): ElementAnimations | undefined { - const coercedValue = coerceAnimationValueForPath({ - value, - valueKind, - numericRange, - }); + const coercedValue = coerceValue({ value }); if (coercedValue === null) { return animations; } - const channel = getChannel({ animations, propertyPath }); - const targetChannel = - channel && channel.valueKind === valueKind - ? channel - : createEmptyChannelForValueKind({ valueKind }); - const updatedChannel = upsertKeyframe({ - channel: targetChannel, + const nextAnimations = cloneAnimationsState({ animations }); + const existingBinding = getBinding({ + animations, + propertyPath, + }); + const binding = + existingBinding && existingBinding.kind === kind + ? cloneAnimationBinding({ binding: existingBinding }) + : createAnimationBinding({ path: propertyPath, kind }); + const primaryChannel = getChannel({ + animations, + propertyPath, + }); + const targetKey = getTargetKeyMetadata({ + channel: primaryChannel, time, - value: coercedValue, - interpolation: interpolation ?? defaultInterpolation, keyframeId, }); + const componentValues = decomposeAnimationValue({ + kind, + value: coercedValue, + }); + if (!componentValues) { + return animations; + } - return ( - setChannel({ + const nextInterpolation = getInterpolationForBinding({ + kind, + interpolation: interpolation ?? defaultInterpolation, + }); + nextAnimations.bindings[propertyPath] = binding; + for (const component of binding.components) { + const nextValue = componentValues[component.key]; + if (nextValue == null) { + continue; + } + + const currentChannel = getChannelById({ animations, - propertyPath, - channel: updatedChannel, - }) ?? { channels: {} } - ); + channelId: component.channelId, + }); + const targetChannel = + currentChannel?.kind === getBindingChannelKind({ kind }) + ? currentChannel + : createEmptyChannelForBindingKind({ kind }); + nextAnimations.channels[component.channelId] = + targetChannel.kind === "discrete" + ? upsertDiscreteChannelKey({ + channel: targetChannel, + time: targetKey.time, + value: nextValue as string | boolean, + keyframeId: targetKey.id, + }) + : upsertScalarChannelKey({ + channel: targetChannel, + time: targetKey.time, + value: nextValue as number, + interpolation: nextInterpolation, + keyframeId: targetKey.id, + }); + } + + return toAnimation({ + animations: nextAnimations, + }); } export function upsertElementKeyframe({ @@ -321,15 +544,16 @@ export function upsertElementKeyframe({ value: coercedValue, interpolation, keyframeId, - valueKind: propertyDefinition.valueKind, - defaultInterpolation: getDefaultInterpolationForProperty({ - propertyPath, - }), - numericRange: propertyDefinition.numericRange, + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + coerceValue: ({ value: nextValue }) => + coerceAnimationValueForProperty({ + propertyPath, + value: nextValue, + }), }); } - export function upsertKeyframe({ channel, time, @@ -347,61 +571,29 @@ export function upsertKeyframe({ return undefined; } - const currentKeyframes = channel.keyframes; - const nextKeyframes = [...currentKeyframes]; - const nextInterpolation = getInterpolationForChannel({ - channel, - interpolation, - }); - if (keyframeId) { - const keyframeByIdIndex = nextKeyframes.findIndex( - (keyframe) => keyframe.id === keyframeId, - ); - if (keyframeByIdIndex >= 0) { - nextKeyframes[keyframeByIdIndex] = buildKeyframe({ - channel, - id: nextKeyframes[keyframeByIdIndex].id, - time, - value, - interpolation: nextInterpolation, - }); - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }); + if (channel.kind === "discrete") { + if (typeof value !== "string" && typeof value !== "boolean") { + return channel; } - } - const keyframeAtTimeIndex = nextKeyframes.findIndex((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: time }), - ); - if (keyframeAtTimeIndex >= 0) { - nextKeyframes[keyframeAtTimeIndex] = buildKeyframe({ + return upsertDiscreteChannelKey({ channel, - id: nextKeyframes[keyframeAtTimeIndex].id, - time: nextKeyframes[keyframeAtTimeIndex].time, - value, - interpolation: nextInterpolation, - }); - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }); - } - - nextKeyframes.push( - buildKeyframe({ - channel, - id: keyframeId ?? generateUUID(), time, value, - interpolation: nextInterpolation, - }), - ); + keyframeId, + }); + } - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + if (typeof value !== "number") { + return channel; + } + + return upsertScalarChannelKey({ + channel, + time, + value, + interpolation: interpolation ?? "linear", + keyframeId, }); } @@ -416,16 +608,16 @@ export function removeKeyframe({ return undefined; } - const nextKeyframes = channel.keyframes.filter( - (keyframe) => keyframe.id !== keyframeId, - ); - if (nextKeyframes.length === 0) { + const nextKeys = channel.keys.filter((keyframe) => keyframe.id !== keyframeId); + if (nextKeys.length === 0) { return undefined; } - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + return normalizeChannel({ + channel: { + ...channel, + keys: nextKeys, + } as AnimationChannel, }); } @@ -442,22 +634,24 @@ export function retimeKeyframe({ return undefined; } - const keyframeByIdIndex = channel.keyframes.findIndex( + const keyframeByIdIndex = channel.keys.findIndex( (keyframe) => keyframe.id === keyframeId, ); if (keyframeByIdIndex < 0) { return channel; } - const nextKeyframes = [...channel.keyframes]; - nextKeyframes[keyframeByIdIndex] = { - ...nextKeyframes[keyframeByIdIndex], + const nextKeys = [...channel.keys]; + nextKeys[keyframeByIdIndex] = { + ...nextKeys[keyframeByIdIndex], time, }; - return toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, + return normalizeChannel({ + channel: { + ...channel, + keys: nextKeys, + } as AnimationChannel, }); } @@ -467,22 +661,145 @@ export function setChannel({ channel, }: { animations: ElementAnimations | undefined; - propertyPath: string; + propertyPath: AnimationPath; channel: AnimationChannel | undefined; }): ElementAnimations | undefined { - const currentChannels = animations?.channels ?? {}; - - const nextChannelEntries = Object.entries(currentChannels) - .filter(([path]) => path !== propertyPath) - .filter(([, ch]) => ch && ch.keyframes.length > 0) - .map(([path, ch]) => [path, ch] as [string, AnimationChannel]); - - if (channel && channel.keyframes.length > 0) { - nextChannelEntries.push([propertyPath, channel]); + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; } + if (binding.components.length !== 1) { + throw new Error( + `setChannel only supports single-component bindings. Received "${propertyPath}" with ${binding.components.length} components.`, + ); + } + + const primaryComponent = getPrimaryComponent({ binding }); + if (!primaryComponent) { + return animations; + } + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey: primaryComponent.key, + channel, + }); +} + +export function setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + channel: AnimationChannel | undefined; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + if (!channel || !hasChannelKeys({ channel })) { + delete nextAnimations.channels[component.channelId]; + const hasRemainingKeys = binding.components.some((candidate) => + hasChannelKeys({ + channel: nextAnimations.channels[candidate.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; + } + return toAnimation({ + animations: nextAnimations, + }); + } + + nextAnimations.channels[component.channelId] = normalizeChannel({ + channel, + }); return toAnimation({ - channelEntries: nextChannelEntries, + animations: nextAnimations, + }); +} + +export function updateScalarKeyframeCurve({ + animations, + propertyPath, + componentKey, + keyframeId, + patch, +}: { + animations: ElementAnimations | undefined; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; +}): ElementAnimations | undefined { + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const component = getBindingComponent({ + binding, + componentKey, + }); + if (!component) { + return animations; + } + + const channel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (channel?.kind !== "scalar") { + return animations; + } + + const keyframeIndex = channel.keys.findIndex((keyframe) => keyframe.id === keyframeId); + if (keyframeIndex < 0) { + return animations; + } + + const nextKeys = [...channel.keys]; + const currentKey = nextKeys[keyframeIndex]; + nextKeys[keyframeIndex] = { + ...currentKey, + leftHandle: + patch.leftHandle === undefined + ? currentKey.leftHandle + : patch.leftHandle ?? undefined, + rightHandle: + patch.rightHandle === undefined + ? currentKey.rightHandle + : patch.rightHandle ?? undefined, + segmentToNext: patch.segmentToNext ?? currentKey.segmentToNext, + tangentMode: patch.tangentMode ?? currentKey.tangentMode, + }; + + return setBindingComponentChannel({ + animations, + propertyPath, + componentKey, + channel: { + kind: "scalar", + keys: nextKeys, + extrapolation: channel.extrapolation, + }, }); } @@ -497,31 +814,57 @@ export function cloneAnimations({ return undefined; } - const clonedEntries = Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if (!channel || channel.keyframes.length === 0) { - return []; + const nextAnimations = cloneAnimationsState({ animations }); + nextAnimations.bindings = Object.fromEntries( + Object.entries(animations.bindings).map(([path, binding]) => [ + path, + binding ? cloneAnimationBinding({ binding }) : binding, + ]), + ); + nextAnimations.channels = {}; + + for (const binding of Object.values(nextAnimations.bindings)) { + if (!binding) { + continue; + } + + const primaryChannel = getChannelById({ + animations, + channelId: getPrimaryChannelId({ binding }) ?? "", + }); + const keyIdMap = new Map(); + if (primaryChannel) { + for (const key of primaryChannel.keys) { + keyIdMap.set( + key.id, + shouldRegenerateKeyframeIds ? generateUUID() : key.id, + ); + } + } + + for (const component of binding.components) { + const currentChannel = getChannelById({ + animations, + channelId: component.channelId, + }); + if (!currentChannel) { + continue; } - const clonedKeyframes = channel.keyframes.map((keyframe) => ({ - ...keyframe, - id: shouldRegenerateKeyframeIds ? generateUUID() : keyframe.id, - })); - - return [ - [ - propertyPath, - toChannel({ - keyframes: clonedKeyframes, - valueKind: channel.valueKind, - }), - ] as [string, AnimationChannel], - ]; - }, - ); + nextAnimations.channels[component.channelId] = normalizeChannel({ + channel: { + ...currentChannel, + keys: currentChannel.keys.map((key) => ({ + ...key, + id: keyIdMap.get(key.id) ?? key.id, + })), + } as AnimationChannel, + }); + } + } return toAnimation({ - channelEntries: clonedEntries, + animations: nextAnimations, }); } @@ -532,38 +875,310 @@ export function clampAnimationsToDuration({ animations: ElementAnimations | undefined; duration: number; }): ElementAnimations | undefined { - if (!animations) { + if (!animations || duration <= 0) { return undefined; } - const clampedEntries = Object.entries(animations.channels).flatMap( - ([propertyPath, channel]) => { - if (!channel) { - return []; - } + return splitAnimationsAtTime({ + animations, + splitTime: duration, + shouldIncludeSplitBoundary: true, + }).leftAnimations; +} - const nextKeyframes = channel.keyframes.filter( - (keyframe) => keyframe.time >= 0 && keyframe.time <= duration, - ); - if (nextKeyframes.length === 0) { - return []; - } +function lerpPoint({ + left, + right, + progress, +}: { + left: { x: number; y: number }; + right: { x: number; y: number }; + progress: number; +}) { + return { + x: left.x + (right.x - left.x) * progress, + y: left.y + (right.y - left.y) * progress, + }; +} - return [ - [ - propertyPath, - toChannel({ - keyframes: nextKeyframes, - valueKind: channel.valueKind, - }), - ] as [string, AnimationChannel], +function splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: DiscreteAnimationChannel | undefined; + splitTime: number; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + if (!channel || channel.keys.length === 0) { + return { + leftChannel: undefined, + rightChannel: undefined, + }; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); + let rightKeys = normalizedChannel.keys + .filter((key) => key.time >= splitTime) + .map((key) => ({ ...key, time: key.time - splitTime })); + + if (shouldIncludeSplitBoundary) { + const hasBoundaryOnLeft = leftKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: 0 }), + ); + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: normalizedChannel.keys[0].value, + }); + if (!hasBoundaryOnLeft) { + leftKeys = [ + ...leftKeys, + createDiscreteKey({ + id: leftBoundaryId, + time: splitTime, + value: boundaryValue as string | boolean, + }), ]; - }, - ); + } + if (!hasBoundaryOnRight) { + rightKeys = [ + createDiscreteKey({ + id: rightBoundaryId, + time: 0, + value: boundaryValue as string | boolean, + }), + ...rightKeys, + ]; + } + } - return toAnimation({ - channelEntries: clampedEntries, - }); + return { + leftChannel: leftKeys.length + ? normalizeChannel({ channel: { kind: "discrete", keys: leftKeys } }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ channel: { kind: "discrete", keys: rightKeys } }) + : undefined, + }; +} + +function splitScalarChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, +}: { + channel: ScalarAnimationChannel | undefined; + splitTime: number; + leftBoundaryId: string; + rightBoundaryId: string; + shouldIncludeSplitBoundary: boolean; +}) { + if (!channel || channel.keys.length === 0) { + return { + leftChannel: undefined, + rightChannel: undefined, + }; + } + + const normalizedChannel = normalizeChannel({ channel }); + let leftKeys = normalizedChannel.keys.filter((key) => key.time <= splitTime); + let rightKeys = normalizedChannel.keys + .filter((key) => key.time >= splitTime) + .map((key) => ({ ...key, time: key.time - splitTime })); + + const hasBoundaryOnLeft = leftKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: splitTime }), + ); + const hasBoundaryOnRight = rightKeys.some((key) => + isNearlySameTime({ leftTime: key.time, rightTime: 0 }), + ); + if (!shouldIncludeSplitBoundary || (hasBoundaryOnLeft && hasBoundaryOnRight)) { + return { + leftChannel: leftKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + }; + } + + for (let keyIndex = 0; keyIndex < normalizedChannel.keys.length - 1; keyIndex++) { + const leftKey = normalizedChannel.keys[keyIndex]; + const rightKey = normalizedChannel.keys[keyIndex + 1]; + if ( + !( + splitTime > leftKey.time && + splitTime < rightKey.time + ) + ) { + continue; + } + + const boundaryValue = getChannelValueAtTime({ + channel: normalizedChannel, + time: splitTime, + fallbackValue: leftKey.value, + }) as number; + + if (leftKey.segmentToNext === "bezier") { + const rightHandle = + leftKey.rightHandle ?? getDefaultRightHandle({ leftKey, rightKey }); + const leftHandle = + rightKey.leftHandle ?? getDefaultLeftHandle({ leftKey, rightKey }); + const progress = solveBezierProgressForTime({ + time: splitTime, + leftKey, + rightKey, + }); + const p0 = { x: leftKey.time, y: leftKey.value }; + const p1 = { + x: leftKey.time + rightHandle.dt, + y: leftKey.value + rightHandle.dv, + }; + const p2 = { + x: rightKey.time + leftHandle.dt, + y: rightKey.value + leftHandle.dv, + }; + const p3 = { x: rightKey.time, y: rightKey.value }; + const q0 = lerpPoint({ left: p0, right: p1, progress }); + const q1 = lerpPoint({ left: p1, right: p2, progress }); + const q2 = lerpPoint({ left: p2, right: p3, progress }); + const r0 = lerpPoint({ left: q0, right: q1, progress }); + const r1 = lerpPoint({ left: q1, right: q2, progress }); + const splitPoint = lerpPoint({ left: r0, right: r1, progress }); + leftKeys = [ + ...normalizedChannel.keys.filter((key) => key.time < splitTime), + { + ...leftKey, + rightHandle: { + dt: q0.x - p0.x, + dv: q0.y - p0.y, + }, + }, + { + id: leftBoundaryId, + time: splitTime, + value: boundaryValue, + leftHandle: { + dt: r0.x - splitPoint.x, + dv: r0.y - splitPoint.y, + }, + segmentToNext: leftKey.segmentToNext, + tangentMode: leftKey.tangentMode, + }, + ]; + rightKeys = [ + { + id: rightBoundaryId, + time: 0, + value: boundaryValue, + rightHandle: { + dt: r1.x - splitPoint.x, + dv: r1.y - splitPoint.y, + }, + segmentToNext: "bezier", + tangentMode: leftKey.tangentMode, + }, + { + ...rightKey, + time: rightKey.time - splitTime, + leftHandle: { + dt: q2.x - p3.x, + dv: q2.y - p3.y, + }, + }, + ...normalizedChannel.keys + .filter((key) => key.time > rightKey.time) + .map((key) => ({ + ...key, + time: key.time - splitTime, + })), + ]; + } else { + leftKeys = [ + ...leftKeys, + createScalarKey({ + id: leftBoundaryId, + time: splitTime, + value: boundaryValue, + interpolation: "linear", + }), + ]; + rightKeys = [ + createScalarKey({ + id: rightBoundaryId, + time: 0, + value: boundaryValue, + interpolation: getScalarSegmentInterpolation({ + segment: leftKey.segmentToNext, + }), + }), + ...rightKeys, + ]; + } + + return { + leftChannel: normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }), + rightChannel: normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }), + }; + } + + return { + leftChannel: leftKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: leftKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + rightChannel: rightKeys.length + ? normalizeChannel({ + channel: { + kind: "scalar", + keys: rightKeys, + extrapolation: normalizedChannel.extrapolation, + }, + }) + : undefined, + }; } export function splitAnimationsAtTime({ @@ -582,101 +1197,63 @@ export function splitAnimationsAtTime({ return { leftAnimations: undefined, rightAnimations: undefined }; } - const leftChannels: Array<[string, AnimationChannel]> = []; - const rightChannels: Array<[string, AnimationChannel]> = []; + const leftAnimations = cloneAnimationsState({ animations: undefined }); + const rightAnimations = cloneAnimationsState({ animations: undefined }); - for (const [propertyPath, channel] of Object.entries(animations.channels)) { - if (!channel || channel.keyframes.length === 0) { + for (const [propertyPath, binding] of Object.entries(animations.bindings)) { + if (!binding) { continue; } - const normalizedChannel = normalizeChannel({ channel }); - let leftKeyframes = normalizedChannel.keyframes.filter( - (keyframe) => keyframe.time <= splitTime, - ); - let rightKeyframes = normalizedChannel.keyframes - .filter((keyframe) => keyframe.time >= splitTime) - .map((keyframe) => ({ - ...keyframe, - time: keyframe.time - splitTime, - })); + const leftBinding = cloneAnimationBinding({ binding }); + const rightBinding = cloneAnimationBinding({ binding }); + const leftBoundaryId = generateUUID(); + const rightBoundaryId = generateUUID(); + let hasLeftKeys = false; + let hasRightKeys = false; - const hasBoundaryOnLeft = leftKeyframes.some((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: splitTime }), - ); - const hasBoundaryOnRight = rightKeyframes.some((keyframe) => - isNearlySameTime({ leftTime: keyframe.time, rightTime: 0 }), - ); - if ( - shouldIncludeSplitBoundary && - (!hasBoundaryOnLeft || !hasBoundaryOnRight) - ) { - const boundaryValue = getChannelValueAtTime({ - channel: normalizedChannel, - time: splitTime, - fallbackValue: normalizedChannel.keyframes[0].value, + for (const component of binding.components) { + const channel = getChannelById({ + animations, + channelId: component.channelId, }); - const knownPropertyPath = isAnimationPropertyPath(propertyPath) - ? propertyPath - : null; - const boundaryInterpolation = knownPropertyPath - ? getDefaultInterpolationForProperty({ - propertyPath: knownPropertyPath, - }) - : normalizedChannel.valueKind === "discrete" - ? "hold" - : "linear"; - - if (!hasBoundaryOnLeft) { - leftKeyframes = [ - ...leftKeyframes, - buildKeyframe({ - channel: normalizedChannel, - id: generateUUID(), - time: splitTime, - value: boundaryValue, - interpolation: boundaryInterpolation, - }), - ]; + const splitResult = + channel?.kind === "discrete" + ? splitDiscreteChannelAtTime({ + channel, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }) + : splitScalarChannelAtTime({ + channel: channel as ScalarAnimationChannel | undefined, + splitTime, + leftBoundaryId, + rightBoundaryId, + shouldIncludeSplitBoundary, + }); + if (splitResult.leftChannel) { + leftAnimations.channels[component.channelId] = splitResult.leftChannel; + hasLeftKeys = true; } - - if (!hasBoundaryOnRight) { - rightKeyframes = [ - buildKeyframe({ - channel: normalizedChannel, - id: generateUUID(), - time: 0, - value: boundaryValue, - interpolation: boundaryInterpolation, - }), - ...rightKeyframes, - ]; + if (splitResult.rightChannel) { + rightAnimations.channels[component.channelId] = splitResult.rightChannel; + hasRightKeys = true; } } - const leftChannel = leftKeyframes.length - ? toChannel({ - keyframes: leftKeyframes, - valueKind: normalizedChannel.valueKind, - }) - : undefined; - const rightChannel = rightKeyframes.length - ? toChannel({ - keyframes: rightKeyframes, - valueKind: normalizedChannel.valueKind, - }) - : undefined; - if (leftChannel) { - leftChannels.push([propertyPath, leftChannel]); + if (hasLeftKeys) { + leftAnimations.bindings[propertyPath] = leftBinding; } - if (rightChannel) { - rightChannels.push([propertyPath, rightChannel]); + if (hasRightKeys) { + rightAnimations.bindings[propertyPath] = rightBinding; } } return { - leftAnimations: toAnimation({ channelEntries: leftChannels }), - rightAnimations: toAnimation({ channelEntries: rightChannels }), + leftAnimations: toAnimation({ animations: leftAnimations }), + rightAnimations: toAnimation({ animations: rightAnimations }), }; } @@ -689,15 +1266,31 @@ export function removeElementKeyframe({ propertyPath: AnimationPath; keyframeId: string; }): ElementAnimations | undefined { - const channel = getChannel({ animations, propertyPath }); - const updatedChannel = removeKeyframe({ - channel, - keyframeId, - }); - return setChannel({ - animations, - propertyPath, - channel: updatedChannel, + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + for (const component of binding.components) { + nextAnimations.channels[component.channelId] = removeKeyframe({ + channel: nextAnimations.channels[component.channelId], + keyframeId, + }); + } + const hasRemainingKeys = binding.components.some((component) => + hasChannelKeys({ + channel: nextAnimations.channels[component.channelId], + }), + ); + if (!hasRemainingKeys) { + delete nextAnimations.bindings[propertyPath]; + for (const component of binding.components) { + delete nextAnimations.channels[component.channelId]; + } + } + return toAnimation({ + animations: nextAnimations, }); } @@ -712,15 +1305,20 @@ export function retimeElementKeyframe({ keyframeId: string; time: number; }): ElementAnimations | undefined { - const channel = getChannel({ animations, propertyPath }); - const updatedChannel = retimeKeyframe({ - channel, - keyframeId, - time, - }); - return setChannel({ - animations, - propertyPath, - channel: updatedChannel, + const binding = getBinding({ animations, propertyPath }); + if (!binding) { + return animations; + } + + const nextAnimations = cloneAnimationsState({ animations }); + for (const component of binding.components) { + nextAnimations.channels[component.channelId] = retimeKeyframe({ + channel: nextAnimations.channels[component.channelId], + keyframeId, + time, + }); + } + return toAnimation({ + animations: nextAnimations, }); } diff --git a/apps/web/src/lib/animation/number-channel.ts b/apps/web/src/lib/animation/number-channel.ts deleted file mode 100644 index 3e193131..00000000 --- a/apps/web/src/lib/animation/number-channel.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - AnimationPropertyPath, - ElementAnimations, - NumberAnimationChannel, -} from "@/lib/animation/types"; - -export function getNumberChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): NumberAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "number") { - return undefined; - } - - return channel; -} diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts index 6d20e324..ac15805e 100644 --- a/apps/web/src/lib/animation/property-registry.ts +++ b/apps/web/src/lib/animation/property-registry.ts @@ -1,369 +1,377 @@ -import type { - AnimationInterpolation, - AnimationPropertyPath, - AnimationValue, - AnimationValueKind, - DiscreteValue, - VectorValue, -} from "@/lib/animation/types"; -import { isVectorValue } from "./vector-channel"; -import type { TimelineElement } from "@/lib/timeline"; -import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; -import { - CORNER_RADIUS_MAX, - CORNER_RADIUS_MIN, -} from "@/constants/text-constants"; -import { - canElementHaveAudio, - isVisualElement, -} from "@/lib/timeline/element-utils"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { snapToStep } from "@/utils/math"; - -export interface NumericSpec { - min?: number; - max?: number; - step?: number; -} - -export type NumericRange = NumericSpec; - -export interface AnimationPropertyDefinition { - valueKind: AnimationValueKind; - defaultInterpolation: AnimationInterpolation; - numericRange?: NumericSpec; - supportsElement: ({ element }: { element: TimelineElement }) => boolean; - getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; - setValue: ({ - element, - value, - }: { - element: TimelineElement; - value: AnimationValue; - }) => TimelineElement; -} - -const ANIMATION_PROPERTY_REGISTRY: Record< - AnimationPropertyPath, - AnimationPropertyDefinition -> = { - "transform.position": { - valueKind: "vector", - defaultInterpolation: "linear", - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.position : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { - ...element.transform, - position: value as VectorValue, - }, - } - : element, - }, - "transform.scaleX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleX : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleX: value as number }, - } - : element, - }, - "transform.scaleY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.scaleY : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, scaleY: value as number }, - } - : element, - }, - "transform.rotate": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: -360, max: 360, step: 1 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.transform.rotate : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { - ...element, - transform: { ...element.transform, rotate: value as number }, - } - : element, - }, - opacity: { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, max: 1, step: 0.01 }, - supportsElement: ({ element }) => isVisualElement(element), - getValue: ({ element }) => - isVisualElement(element) ? element.opacity : null, - setValue: ({ element, value }) => - isVisualElement(element) - ? { ...element, opacity: value as number } - : element, - }, - volume: { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, - supportsElement: ({ element }) => canElementHaveAudio(element), - getValue: ({ element }) => - canElementHaveAudio(element) ? element.volume ?? 0 : null, - setValue: ({ element, value }) => - canElementHaveAudio(element) - ? { ...element, volume: value as number } - : element, - }, - color: { - valueKind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => (element.type === "text" ? element.color : null), - setValue: ({ element, value }) => - element.type === "text" - ? { ...element, color: value as string } - : element, - }, - "background.color": { - valueKind: "color", - defaultInterpolation: "linear", - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" ? element.background.color : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, color: value as string }, - } - : element, - }, - "background.paddingX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingX: value as number }, - } - : element, - }, - "background.paddingY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: 0, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, paddingY: value as number }, - } - : element, - }, - "background.offsetX": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetX: value as number }, - } - : element, - }, - "background.offsetY": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, offsetY: value as number }, - } - : element, - }, - "background.cornerRadius": { - valueKind: "number", - defaultInterpolation: "linear", - numericRange: { min: CORNER_RADIUS_MIN, max: CORNER_RADIUS_MAX, step: 1 }, - supportsElement: ({ element }) => element.type === "text", - getValue: ({ element }) => - element.type === "text" - ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) - : null, - setValue: ({ element, value }) => - element.type === "text" - ? { - ...element, - background: { ...element.background, cornerRadius: value as number }, - } - : element, - }, -}; - -export function isAnimationPropertyPath( - propertyPath: string, -): propertyPath is AnimationPropertyPath { - return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); -} - -export function getAnimationPropertyDefinition({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationPropertyDefinition { - return ANIMATION_PROPERTY_REGISTRY[propertyPath]; -} - -export function supportsAnimationProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): boolean { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.supportsElement({ element }); -} - -export function getElementBaseValueForProperty({ - element, - propertyPath, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; -}): AnimationValue | null { - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return null; - } - return definition.getValue({ element }); -} - -export function withElementBaseValueForProperty({ - element, - propertyPath, - value, -}: { - element: TimelineElement; - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): TimelineElement { - const coercedValue = coerceAnimationValueForProperty({ propertyPath, value }); - if (coercedValue === null) { - return element; - } - const definition = getAnimationPropertyDefinition({ propertyPath }); - if (!definition.supportsElement({ element })) { - return element; - } - return definition.setValue({ element, value: coercedValue }); -} - -export function getDefaultInterpolationForProperty({ - propertyPath, -}: { - propertyPath: AnimationPropertyPath; -}): AnimationInterpolation { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - return propertyDefinition.defaultInterpolation; -} - -function applyNumericSpec({ - value, - numericRange, -}: { - value: number; - numericRange: NumericSpec | undefined; -}): number { - if (!numericRange) { - return value; - } - - const steppedValue = - numericRange.step != null - ? snapToStep({ value, step: numericRange.step }) - : value; - const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; - const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); -} - -export function coerceAnimationValueForProperty({ - propertyPath, - value, -}: { - propertyPath: AnimationPropertyPath; - value: AnimationValue; -}): AnimationValue | null { - const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); - - if (propertyDefinition.valueKind === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - return applyNumericSpec({ - value, - numericRange: propertyDefinition.numericRange, - }); - } - - if (propertyDefinition.valueKind === "color") { - return typeof value === "string" ? value : null; - } - - if (propertyDefinition.valueKind === "vector") { - return isVectorValue(value) ? value : null; - } - - if (typeof value === "string" || typeof value === "boolean") { - return value as DiscreteValue; - } - - return null; -} +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPropertyPath, + AnimationValue, + VectorValue, +} from "@/lib/animation/types"; +import { isVectorValue, parseColorToLinearRgba } from "./binding-values"; +import type { TimelineElement } from "@/lib/timeline"; +import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; +import { + CORNER_RADIUS_MAX, + CORNER_RADIUS_MIN, +} from "@/constants/text-constants"; +import { + canElementHaveAudio, + isVisualElement, +} from "@/lib/timeline/element-utils"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "@/lib/timeline/audio-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { snapToStep } from "@/utils/math"; + +export interface NumericSpec { + min?: number; + max?: number; + step?: number; +} + +export interface AnimationPropertyDefinition { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + supportsElement: ({ element }: { element: TimelineElement }) => boolean; + getValue: ({ element }: { element: TimelineElement }) => AnimationValue | null; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + setValue: ({ + element, + value, + }: { + element: TimelineElement; + value: AnimationValue; + }) => TimelineElement; +} + +function applyNumericSpec({ + value, + numericRange, +}: { + value: number; + numericRange: NumericSpec | undefined; +}): number { + if (!numericRange) { + return value; + } + + const steppedValue = + numericRange.step != null + ? snapToStep({ value, step: numericRange.step }) + : value; + const minValue = numericRange.min ?? Number.NEGATIVE_INFINITY; + const maxValue = numericRange.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); +} + +function coerceNumberValue({ + value, + numericRange, +}: { + value: AnimationValue; + numericRange?: NumericSpec; +}): number | null { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + return applyNumericSpec({ value, numericRange }); +} + +function coerceColorValue({ + value, +}: { + value: AnimationValue; +}): string | null { + return typeof value === "string" && parseColorToLinearRgba({ color: value }) + ? value + : null; +} + +function createNumberPropertyDefinition({ + numericRange, + supportsElement, + getValue, + setValue, +}: { + numericRange?: NumericSpec; + supportsElement: AnimationPropertyDefinition["supportsElement"]; + getValue: AnimationPropertyDefinition["getValue"]; + setValue: AnimationPropertyDefinition["setValue"]; +}): AnimationPropertyDefinition { + return { + kind: "number", + defaultInterpolation: "linear", + numericRanges: numericRange ? { value: numericRange } : undefined, + supportsElement, + getValue, + coerceValue: ({ value }) => + coerceNumberValue({ + value, + numericRange, + }), + setValue, + }; +} + +const ANIMATION_PROPERTY_REGISTRY: Record< + AnimationPropertyPath, + AnimationPropertyDefinition +> = { + "transform.position": { + kind: "vector2", + defaultInterpolation: "linear", + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.position : null, + coerceValue: ({ value }) => (isVectorValue(value) ? value : null), + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { + ...element.transform, + position: value as VectorValue, + }, + } + : element, + }, + "transform.scaleX": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleX : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleX: value as number }, + } + : element, + }), + "transform.scaleY": createNumberPropertyDefinition({ + numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.scaleY : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, scaleY: value as number }, + } + : element, + }), + "transform.rotate": createNumberPropertyDefinition({ + numericRange: { min: -360, max: 360, step: 1 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.transform.rotate : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { + ...element, + transform: { ...element.transform, rotate: value as number }, + } + : element, + }), + opacity: createNumberPropertyDefinition({ + numericRange: { min: 0, max: 1, step: 0.01 }, + supportsElement: ({ element }) => isVisualElement(element), + getValue: ({ element }) => + isVisualElement(element) ? element.opacity : null, + setValue: ({ element, value }) => + isVisualElement(element) + ? { ...element, opacity: value as number } + : element, + }), + volume: createNumberPropertyDefinition({ + numericRange: { min: VOLUME_DB_MIN, max: VOLUME_DB_MAX, step: 0.01 }, + supportsElement: ({ element }) => canElementHaveAudio(element), + getValue: ({ element }) => + canElementHaveAudio(element) ? element.volume ?? 0 : null, + setValue: ({ element, value }) => + canElementHaveAudio(element) + ? { ...element, volume: value as number } + : element, + }), + color: { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => (element.type === "text" ? element.color : null), + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { ...element, color: value as string } + : element, + }, + "background.color": { + kind: "color", + defaultInterpolation: "linear", + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" ? element.background.color : null, + coerceValue: ({ value }) => coerceColorValue({ value }), + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, color: value as string }, + } + : element, + }, + "background.paddingX": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingX ?? DEFAULTS.text.background.paddingX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingX: value as number }, + } + : element, + }), + "background.paddingY": createNumberPropertyDefinition({ + numericRange: { min: 0, step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.paddingY ?? DEFAULTS.text.background.paddingY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, paddingY: value as number }, + } + : element, + }), + "background.offsetX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetX ?? DEFAULTS.text.background.offsetX) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetX: value as number }, + } + : element, + }), + "background.offsetY": createNumberPropertyDefinition({ + numericRange: { step: 1 }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.offsetY ?? DEFAULTS.text.background.offsetY) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, offsetY: value as number }, + } + : element, + }), + "background.cornerRadius": createNumberPropertyDefinition({ + numericRange: { + min: CORNER_RADIUS_MIN, + max: CORNER_RADIUS_MAX, + step: 1, + }, + supportsElement: ({ element }) => element.type === "text", + getValue: ({ element }) => + element.type === "text" + ? (element.background.cornerRadius ?? CORNER_RADIUS_MIN) + : null, + setValue: ({ element, value }) => + element.type === "text" + ? { + ...element, + background: { ...element.background, cornerRadius: value as number }, + } + : element, + }), +}; + +export function isAnimationPropertyPath( + propertyPath: string, +): propertyPath is AnimationPropertyPath { + return Object.hasOwn(ANIMATION_PROPERTY_REGISTRY, propertyPath); +} + +export function getAnimationPropertyDefinition({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationPropertyDefinition { + return ANIMATION_PROPERTY_REGISTRY[propertyPath]; +} + +export function supportsAnimationProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): boolean { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.supportsElement({ element }); +} + +export function getElementBaseValueForProperty({ + element, + propertyPath, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; +}): AnimationValue | null { + const definition = getAnimationPropertyDefinition({ propertyPath }); + if (!definition.supportsElement({ element })) { + return null; + } + return definition.getValue({ element }); +} + +export function withElementBaseValueForProperty({ + element, + propertyPath, + value, +}: { + element: TimelineElement; + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): TimelineElement { + const definition = getAnimationPropertyDefinition({ propertyPath }); + const coercedValue = definition.coerceValue({ value }); + if (coercedValue === null || !definition.supportsElement({ element })) { + return element; + } + return definition.setValue({ element, value: coercedValue }); +} + +export function getDefaultInterpolationForProperty({ + propertyPath, +}: { + propertyPath: AnimationPropertyPath; +}): AnimationInterpolation { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.defaultInterpolation; +} + +export function coerceAnimationValueForProperty({ + propertyPath, + value, +}: { + propertyPath: AnimationPropertyPath; + value: AnimationValue; +}): AnimationValue | null { + const propertyDefinition = getAnimationPropertyDefinition({ propertyPath }); + return propertyDefinition.coerceValue({ value }); +} diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 5c356f28..67f4a487 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -1,135 +1,176 @@ -import type { - AnimationPropertyPath, - ElementAnimations, -} from "@/lib/animation/types"; -import type { Transform } from "@/lib/rendering"; -import { - getColorValueAtTime, - getNumberChannelValueAtTime, - getVectorChannelValueAtTime, -} from "./interpolation"; -import { getColorChannelForPath } from "./color-channel"; -import { getNumberChannelForPath } from "./number-channel"; -import { getVectorChannelForPath } from "./vector-channel"; - -export function getElementLocalTime({ - timelineTime, - elementStartTime, - elementDuration, -}: { - timelineTime: number; - elementStartTime: number; - elementDuration: number; -}): number { - const localTime = timelineTime - elementStartTime; - if (localTime <= 0) { - return 0; - } - - if (localTime >= elementDuration) { - return elementDuration; - } - - return localTime; -} - -export function resolveTransformAtTime({ - baseTransform, - animations, - localTime, -}: { - baseTransform: Transform; - animations: ElementAnimations | undefined; - localTime: number; -}): Transform { - const safeLocalTime = Math.max(0, localTime); - return { - position: getVectorChannelValueAtTime({ - channel: getVectorChannelForPath({ - animations, - propertyPath: "transform.position", - }), - time: safeLocalTime, - fallbackValue: baseTransform.position, - }), - scaleX: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.scaleX", - }), - time: safeLocalTime, - fallbackValue: baseTransform.scaleX, - }), - scaleY: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.scaleY", - }), - time: safeLocalTime, - fallbackValue: baseTransform.scaleY, - }), - rotate: getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "transform.rotate", - }), - time: safeLocalTime, - fallbackValue: baseTransform.rotate, - }), - }; -} - -export function resolveOpacityAtTime({ - baseOpacity, - animations, - localTime, -}: { - baseOpacity: number; - animations: ElementAnimations | undefined; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ - animations, - propertyPath: "opacity", - }), - time: Math.max(0, localTime), - fallbackValue: baseOpacity, - }); -} - -export function resolveNumberAtTime({ - baseValue, - animations, - propertyPath, - localTime, -}: { - baseValue: number; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; -}): number { - return getNumberChannelValueAtTime({ - channel: getNumberChannelForPath({ animations, propertyPath }), - time: Math.max(0, localTime), - fallbackValue: baseValue, - }); -} - -export function resolveColorAtTime({ - baseColor, - animations, - propertyPath, - localTime, -}: { - baseColor: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; -}): string { - return getColorValueAtTime({ - channel: getColorChannelForPath({ animations, propertyPath }), - time: Math.max(0, localTime), - fallbackValue: baseColor, - }); -} +import type { + AnimationColorPropertyPath, + AnimationNumericPropertyPath, + AnimationPath, + AnimationPropertyPath, + AnimationValueForPath, + ElementAnimations, +} from "@/lib/animation/types"; +import type { Transform } from "@/lib/rendering"; +import { + type AnimationComponentValue, + composeAnimationValue, + decomposeAnimationValue, +} from "./binding-values"; +import { + getChannelValueAtTime, +} from "./interpolation"; + +export function getElementLocalTime({ + timelineTime, + elementStartTime, + elementDuration, +}: { + timelineTime: number; + elementStartTime: number; + elementDuration: number; +}): number { + const localTime = timelineTime - elementStartTime; + if (localTime <= 0) { + return 0; + } + + if (localTime >= elementDuration) { + return elementDuration; + } + + return localTime; +} + +export function resolveTransformAtTime({ + baseTransform, + animations, + localTime, +}: { + baseTransform: Transform; + animations: ElementAnimations | undefined; + localTime: number; +}): Transform { + const safeLocalTime = Math.max(0, localTime); + return { + position: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.position", + localTime: safeLocalTime, + fallbackValue: baseTransform.position, + }), + scaleX: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleX", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleX, + }), + scaleY: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.scaleY", + localTime: safeLocalTime, + fallbackValue: baseTransform.scaleY, + }), + rotate: resolveAnimationPathValueAtTime({ + animations, + propertyPath: "transform.rotate", + localTime: safeLocalTime, + fallbackValue: baseTransform.rotate, + }), + }; +} + +export function resolveOpacityAtTime({ + baseOpacity, + animations, + localTime, +}: { + baseOpacity: number; + animations: ElementAnimations | undefined; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath: "opacity", + localTime: Math.max(0, localTime), + fallbackValue: baseOpacity, + }); +} + +export function resolveNumberAtTime({ + baseValue, + animations, + propertyPath, + localTime, +}: { + baseValue: number; + animations: ElementAnimations | undefined; + propertyPath: AnimationNumericPropertyPath; + localTime: number; +}): number { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseValue, + }); +} + +export function resolveColorAtTime({ + baseColor, + animations, + propertyPath, + localTime, +}: { + baseColor: string; + animations: ElementAnimations | undefined; + propertyPath: AnimationColorPropertyPath; + localTime: number; +}): string { + return resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime: Math.max(0, localTime), + fallbackValue: baseColor, + }); +} + +export function resolveAnimationPathValueAtTime({ + animations, + propertyPath, + localTime, + fallbackValue, +}: { + animations: ElementAnimations | undefined; + propertyPath: TPath; + localTime: number; + fallbackValue: AnimationValueForPath; +}): AnimationValueForPath { + const binding = animations?.bindings[propertyPath]; + if (!binding) { + return fallbackValue; + } + + const fallbackComponents = decomposeAnimationValue({ + kind: binding.kind, + value: fallbackValue, + }); + if (!fallbackComponents) { + return fallbackValue; + } + + const componentValues = Object.fromEntries( + binding.components.map((component) => { + const channel = animations?.channels[component.channelId]; + return [ + component.key, + getChannelValueAtTime({ + channel, + time: localTime, + fallbackValue: + fallbackComponents[component.key] ?? + (channel?.kind === "discrete" ? false : 0), + }), + ]; + }), + ) as Record; + return (composeAnimationValue({ + binding, + componentValues, + }) ?? fallbackValue) as AnimationValueForPath; +} diff --git a/apps/web/src/lib/animation/target-resolver.ts b/apps/web/src/lib/animation/target-resolver.ts index 6675d443..64bb8709 100644 --- a/apps/web/src/lib/animation/target-resolver.ts +++ b/apps/web/src/lib/animation/target-resolver.ts @@ -1,277 +1,285 @@ -import type { - AnimationInterpolation, - AnimationPath, - AnimationValue, - AnimationValueKind, -} from "@/lib/animation/types"; -import { - parseEffectParamPath, - isEffectParamPath, -} from "@/lib/animation/effect-param-channel"; -import { - isGraphicParamPath, - parseGraphicParamPath, -} from "@/lib/animation/graphic-param-channel"; -import type { ParamDefinition } from "@/lib/params"; -import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; -import { getGraphicDefinition } from "@/lib/graphics"; -import type { TimelineElement } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import { snapToStep } from "@/utils/math"; -import { - coerceAnimationValueForProperty, - getAnimationPropertyDefinition, - getElementBaseValueForProperty, - isAnimationPropertyPath, - type NumericSpec, - withElementBaseValueForProperty, -} from "./property-registry"; - -export interface AnimationPathDescriptor { - valueKind: AnimationValueKind; - defaultInterpolation: AnimationInterpolation; - numericRange?: NumericSpec; - getBaseValue(): AnimationValue | null; - setBaseValue(value: AnimationValue): TimelineElement; -} - -export function getParamValueKind({ - param, -}: { - param: ParamDefinition; -}): AnimationValueKind { - if (param.type === "number") { - return "number"; - } - - if (param.type === "color") { - return "color"; - } - - return "discrete"; -} - -export function getParamDefaultInterpolation({ - param, -}: { - param: ParamDefinition; -}): AnimationInterpolation { - return param.type === "number" || param.type === "color" ? "linear" : "hold"; -} - -function getParamNumericRange({ - param, -}: { - param: ParamDefinition; -}): NumericSpec | undefined { - if (param.type !== "number") { - return undefined; - } - - return { - min: param.min, - max: param.max, - step: param.step, - }; -} - -function coerceParamValue({ - param, - value, -}: { - param: ParamDefinition; - value: AnimationValue; -}): number | string | boolean | null { - if (param.type === "number") { - if (typeof value !== "number" || Number.isNaN(value)) { - return null; - } - - const steppedValue = snapToStep({ value, step: param.step }); - const minValue = param.min; - const maxValue = param.max ?? Number.POSITIVE_INFINITY; - return Math.min(maxValue, Math.max(minValue, steppedValue)); - } - - if (param.type === "color") { - return typeof value === "string" ? value : null; - } - - if (param.type === "boolean") { - return typeof value === "boolean" ? value : null; - } - - if (typeof value !== "string") { - return null; - } - - return param.options.some((option) => option.value === value) ? value : null; -} - -function buildGraphicParamDescriptor({ - element, - paramKey, -}: { - element: TimelineElement; - paramKey: string; -}): AnimationPathDescriptor | null { - if (element.type !== "graphic") { - return null; - } - - const definition = getGraphicDefinition({ - definitionId: element.definitionId, - }); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRange: getParamNumericRange({ param }), - getBaseValue: () => element.params[param.key] ?? param.default, - setBaseValue: (value) => { - const coercedValue = coerceParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - params: { - ...element.params, - [param.key]: coercedValue, - }, - }; - }, - }; -} - -function buildEffectParamDescriptor({ - element, - effectId, - paramKey, -}: { - element: TimelineElement; - effectId: string; - paramKey: string; -}): AnimationPathDescriptor | null { - if (!isVisualElement(element)) { - return null; - } - - const effect = element.effects?.find((candidate) => candidate.id === effectId); - if (!effect) { - return null; - } - - registerDefaultEffects(); - const definition = effectsRegistry.get(effect.type); - const param = definition.params.find((candidate) => candidate.key === paramKey); - if (!param) { - return null; - } - - return { - valueKind: getParamValueKind({ param }), - defaultInterpolation: getParamDefaultInterpolation({ param }), - numericRange: getParamNumericRange({ param }), - getBaseValue: () => effect.params[param.key] ?? param.default, - setBaseValue: (value) => { - const coercedValue = coerceParamValue({ param, value }); - if (coercedValue === null) { - return element; - } - - return { - ...element, - effects: - element.effects?.map((candidate) => - candidate.id !== effectId - ? candidate - : { - ...candidate, - params: { - ...candidate.params, - [param.key]: coercedValue, - }, - }, - ) ?? element.effects, - }; - }, - }; -} - -export function isAnimationPath( - propertyPath: string, -): propertyPath is AnimationPath { - return ( - isAnimationPropertyPath(propertyPath) || - isGraphicParamPath(propertyPath) || - isEffectParamPath(propertyPath) - ); -} - -export function resolveAnimationTarget({ - element, - path, -}: { - element: TimelineElement; - path: AnimationPath; -}): AnimationPathDescriptor | null { - if (isAnimationPropertyPath(path)) { - const propertyDefinition = getAnimationPropertyDefinition({ - propertyPath: path, - }); - if (!propertyDefinition.supportsElement({ element })) { - return null; - } - - return { - valueKind: propertyDefinition.valueKind, - defaultInterpolation: propertyDefinition.defaultInterpolation, - numericRange: propertyDefinition.numericRange, - getBaseValue: () => - getElementBaseValueForProperty({ - element, - propertyPath: path, - }), - setBaseValue: (value) => { - const coercedValue = coerceAnimationValueForProperty({ - propertyPath: path, - value, - }); - if (coercedValue === null) { - return element; - } - - return withElementBaseValueForProperty({ - element, - propertyPath: path, - value: coercedValue, - }); - }, - }; - } - - const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); - if (graphicParamTarget) { - return buildGraphicParamDescriptor({ - element, - paramKey: graphicParamTarget.paramKey, - }); - } - - const effectParamTarget = parseEffectParamPath({ propertyPath: path }); - if (effectParamTarget) { - return buildEffectParamDescriptor({ - element, - effectId: effectParamTarget.effectId, - paramKey: effectParamTarget.paramKey, - }); - } - - return null; -} +import type { + AnimationBindingKind, + AnimationInterpolation, + AnimationPath, + AnimationValue, +} from "@/lib/animation/types"; +import { + parseEffectParamPath, + isEffectParamPath, +} from "@/lib/animation/effect-param-channel"; +import { + isGraphicParamPath, + parseGraphicParamPath, +} from "@/lib/animation/graphic-param-channel"; +import type { ParamDefinition } from "@/lib/params"; +import { effectsRegistry, registerDefaultEffects } from "@/lib/effects"; +import { getGraphicDefinition } from "@/lib/graphics"; +import type { TimelineElement } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { snapToStep } from "@/utils/math"; +import { + coerceAnimationValueForProperty, + getAnimationPropertyDefinition, + getElementBaseValueForProperty, + isAnimationPropertyPath, + type NumericSpec, + withElementBaseValueForProperty, +} from "./property-registry"; +import { parseColorToLinearRgba } from "./binding-values"; + +export interface AnimationPathDescriptor { + kind: AnimationBindingKind; + defaultInterpolation: AnimationInterpolation; + numericRanges?: Partial>; + coerceValue: ({ value }: { value: AnimationValue }) => AnimationValue | null; + getBaseValue: () => AnimationValue | null; + setBaseValue: ({ value }: { value: AnimationValue }) => TimelineElement; +} + +export function getParamValueKind({ + param, +}: { + param: ParamDefinition; +}): AnimationBindingKind { + if (param.type === "number") { + return "number"; + } + + if (param.type === "color") { + return "color"; + } + + return "discrete"; +} + +export function getParamDefaultInterpolation({ + param, +}: { + param: ParamDefinition; +}): AnimationInterpolation { + return param.type === "number" || param.type === "color" ? "linear" : "hold"; +} + +function getParamNumericRange({ + param, +}: { + param: ParamDefinition; +}): Partial> | undefined { + if (param.type !== "number") { + return undefined; + } + + return { + value: { + min: param.min, + max: param.max, + step: param.step, + }, + }; +} + +export function coerceAnimationValueForParam({ + param, + value, +}: { + param: ParamDefinition; + value: AnimationValue; +}): number | string | boolean | null { + if (param.type === "number") { + if (typeof value !== "number" || Number.isNaN(value)) { + return null; + } + + const steppedValue = snapToStep({ value, step: param.step }); + const minValue = param.min; + const maxValue = param.max ?? Number.POSITIVE_INFINITY; + return Math.min(maxValue, Math.max(minValue, steppedValue)); + } + + if (param.type === "color") { + return typeof value === "string" ? value : null; + } + + if (param.type === "boolean") { + return typeof value === "boolean" ? value : null; + } + + if (typeof value !== "string") { + return null; + } + + return param.options.some((option) => option.value === value) ? value : null; +} + +function buildGraphicParamDescriptor({ + element, + paramKey, +}: { + element: TimelineElement; + paramKey: string; +}): AnimationPathDescriptor | null { + if (element.type !== "graphic") { + return null; + } + + const definition = getGraphicDefinition({ + definitionId: element.definitionId, + }); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: getParamNumericRange({ param }), + coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), + getBaseValue: () => element.params[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceAnimationValueForParam({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + params: { + ...element.params, + [param.key]: coercedValue, + }, + }; + }, + }; +} + +function buildEffectParamDescriptor({ + element, + effectId, + paramKey, +}: { + element: TimelineElement; + effectId: string; + paramKey: string; +}): AnimationPathDescriptor | null { + if (!isVisualElement(element)) { + return null; + } + + const effect = element.effects?.find((candidate) => candidate.id === effectId); + if (!effect) { + return null; + } + + registerDefaultEffects(); + const definition = effectsRegistry.get(effect.type); + const param = definition.params.find((candidate) => candidate.key === paramKey); + if (!param) { + return null; + } + + return { + kind: getParamValueKind({ param }), + defaultInterpolation: getParamDefaultInterpolation({ param }), + numericRanges: getParamNumericRange({ param }), + coerceValue: ({ value }) => coerceAnimationValueForParam({ param, value }), + getBaseValue: () => effect.params[param.key] ?? param.default, + setBaseValue: ({ value }) => { + const coercedValue = coerceAnimationValueForParam({ param, value }); + if (coercedValue === null) { + return element; + } + + return { + ...element, + effects: + element.effects?.map((candidate) => + candidate.id !== effectId + ? candidate + : { + ...candidate, + params: { + ...candidate.params, + [param.key]: coercedValue, + }, + }, + ) ?? element.effects, + }; + }, + }; +} + +export function isAnimationPath( + propertyPath: string, +): propertyPath is AnimationPath { + return ( + isAnimationPropertyPath(propertyPath) || + isGraphicParamPath(propertyPath) || + isEffectParamPath(propertyPath) + ); +} + +export function resolveAnimationTarget({ + element, + path, +}: { + element: TimelineElement; + path: AnimationPath; +}): AnimationPathDescriptor | null { + if (isAnimationPropertyPath(path)) { + const propertyDefinition = getAnimationPropertyDefinition({ + propertyPath: path, + }); + if (!propertyDefinition.supportsElement({ element })) { + return null; + } + + return { + kind: propertyDefinition.kind, + defaultInterpolation: propertyDefinition.defaultInterpolation, + numericRanges: propertyDefinition.numericRanges, + coerceValue: ({ value }) => + coerceAnimationValueForProperty({ + propertyPath: path, + value, + }), + getBaseValue: () => + getElementBaseValueForProperty({ + element, + propertyPath: path, + }), + setBaseValue: ({ value }) => { + const coercedValue = propertyDefinition.coerceValue({ value }); + if (coercedValue === null) { + return element; + } + + return withElementBaseValueForProperty({ + element, + propertyPath: path, + value: coercedValue, + }); + }, + }; + } + + const graphicParamTarget = parseGraphicParamPath({ propertyPath: path }); + if (graphicParamTarget) { + return buildGraphicParamDescriptor({ + element, + paramKey: graphicParamTarget.paramKey, + }); + } + + const effectParamTarget = parseEffectParamPath({ propertyPath: path }); + if (effectParamTarget) { + return buildEffectParamDescriptor({ + element, + effectId: effectParamTarget.effectId, + paramKey: effectParamTarget.paramKey, + }); + } + + return null; +} diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index ac2e0e78..453723b0 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,119 +1,220 @@ -export const ANIMATION_PROPERTY_PATHS = [ - "transform.position", - "transform.scaleX", - "transform.scaleY", - "transform.rotate", - "opacity", - "volume", - "color", - "background.color", - "background.paddingX", - "background.paddingY", - "background.offsetX", - "background.offsetY", - "background.cornerRadius", -] as const; - -export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; -export type GraphicParamPath = `params.${string}`; -export type EffectParamPath = `effects.${string}.params.${string}`; -export type AnimationPath = - | AnimationPropertyPath - | GraphicParamPath - | EffectParamPath; - -export const ANIMATION_PROPERTY_GROUPS = { - "transform.scale": ["transform.scaleX", "transform.scaleY"], -} as const satisfies Record>; - -export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; - -export type VectorValue = { x: number; y: number }; - -export type AnimationValueKind = "number" | "color" | "discrete" | "vector"; -export type DiscreteValue = boolean | string; -export type AnimationValue = number | string | boolean | VectorValue; - -export type ContinuousKeyframeInterpolation = "linear" | "hold"; -export type DiscreteKeyframeInterpolation = "hold"; -export type AnimationInterpolation = - | ContinuousKeyframeInterpolation - | DiscreteKeyframeInterpolation; - -interface BaseAnimationKeyframe< - TValue extends AnimationValue, - TInterpolation extends AnimationInterpolation, -> { - id: string; - time: number; // relative to element start time - value: TValue; - interpolation: TInterpolation; -} - -export interface NumberKeyframe - extends BaseAnimationKeyframe {} - -export interface ColorKeyframe - extends BaseAnimationKeyframe {} - -export interface DiscreteKeyframe - extends BaseAnimationKeyframe {} - -export interface VectorKeyframe - extends BaseAnimationKeyframe {} - -export type AnimationKeyframe = - | NumberKeyframe - | ColorKeyframe - | DiscreteKeyframe - | VectorKeyframe; - -interface BaseAnimationChannel< - TValueKind extends AnimationValueKind, - TKeyframe extends AnimationKeyframe, -> { - valueKind: TValueKind; - keyframes: TKeyframe[]; -} - -export interface NumberAnimationChannel - extends BaseAnimationChannel<"number", NumberKeyframe> {} - -export interface ColorAnimationChannel - extends BaseAnimationChannel<"color", ColorKeyframe> {} - -export interface DiscreteAnimationChannel - extends BaseAnimationChannel<"discrete", DiscreteKeyframe> {} - -export interface VectorAnimationChannel - extends BaseAnimationChannel<"vector", VectorKeyframe> {} - -export type AnimationChannel = - | NumberAnimationChannel - | ColorAnimationChannel - | DiscreteAnimationChannel - | VectorAnimationChannel; - -export type ElementAnimationChannelMap = Record< - string, - AnimationChannel | undefined ->; - -export interface ElementAnimations { - channels: ElementAnimationChannelMap; -} - -export interface ElementKeyframe { - propertyPath: AnimationPath; - id: string; - time: number; - value: AnimationValue; - interpolation: AnimationInterpolation; -} - -export interface SelectedKeyframeRef { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; -} +import type { ParamValues } from "@/lib/params"; + +export const ANIMATION_PROPERTY_PATHS = [ + "transform.position", + "transform.scaleX", + "transform.scaleY", + "transform.rotate", + "opacity", + "volume", + "color", + "background.color", + "background.paddingX", + "background.paddingY", + "background.offsetX", + "background.offsetY", + "background.cornerRadius", +] as const; + +export type AnimationPropertyPath = (typeof ANIMATION_PROPERTY_PATHS)[number]; +export type GraphicParamPath = `params.${string}`; +export type EffectParamPath = `effects.${string}.params.${string}`; +export type AnimationPath = + | AnimationPropertyPath + | GraphicParamPath + | EffectParamPath; + +export const ANIMATION_PROPERTY_GROUPS = { + "transform.scale": ["transform.scaleX", "transform.scaleY"], +} as const satisfies Record>; + +export type AnimationPropertyGroup = keyof typeof ANIMATION_PROPERTY_GROUPS; + +export type VectorValue = { x: number; y: number }; +export type DiscreteValue = boolean | string; +export type AnimationValue = number | string | boolean | VectorValue; +export interface AnimationPropertyValueMap { + "transform.position": VectorValue; + "transform.scaleX": number; + "transform.scaleY": number; + "transform.rotate": number; + opacity: number; + volume: number; + color: string; + "background.color": string; + "background.paddingX": number; + "background.paddingY": number; + "background.offsetX": number; + "background.offsetY": number; + "background.cornerRadius": number; +} +export type DynamicAnimationPathValue = ParamValues[string]; +export type AnimationValueForPath = + TPath extends AnimationPropertyPath + ? AnimationPropertyValueMap[TPath] + : TPath extends GraphicParamPath | EffectParamPath + ? DynamicAnimationPathValue + : never; +export type AnimationNumericPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath extends number ? K : never; +}[AnimationPropertyPath]; +export type AnimationColorPropertyPath = { + [K in AnimationPropertyPath]: AnimationValueForPath extends string ? K : never; +}[AnimationPropertyPath]; + +export type ContinuousKeyframeInterpolation = "linear" | "hold" | "bezier"; +export type DiscreteKeyframeInterpolation = "hold"; +export type AnimationInterpolation = + | ContinuousKeyframeInterpolation + | DiscreteKeyframeInterpolation; + +export type PrimitiveAnimationChannelKind = "scalar" | "discrete"; +export type AnimationBindingKind = "number" | "vector2" | "color" | "discrete"; +export type ScalarSegmentType = "step" | "linear" | "bezier"; +export type TangentMode = "auto" | "aligned" | "broken" | "flat"; +export type ChannelExtrapolationMode = "hold" | "linear"; + +export interface CurveHandle { + dt: number; + dv: number; +} + +interface BaseAnimationKeyframe { + id: string; + time: number; // relative to element start time + value: TValue; +} + +export interface ScalarAnimationKey extends BaseAnimationKeyframe { + leftHandle?: CurveHandle; + rightHandle?: CurveHandle; + segmentToNext: ScalarSegmentType; + tangentMode: TangentMode; +} + +export interface DiscreteAnimationKey + extends BaseAnimationKeyframe {} + +export type AnimationKeyframe = ScalarAnimationKey | DiscreteAnimationKey; + +export interface ScalarAnimationChannel { + kind: "scalar"; + keys: ScalarAnimationKey[]; + extrapolation?: { + before: ChannelExtrapolationMode; + after: ChannelExtrapolationMode; + }; +} + +export interface DiscreteAnimationChannel { + kind: "discrete"; + keys: DiscreteAnimationKey[]; +} + +export type AnimationChannel = + | ScalarAnimationChannel + | DiscreteAnimationChannel; + +export type ElementAnimationChannelMap = Record< + string, + AnimationChannel | undefined +>; + +export interface AnimationBindingComponent { + key: TKey; + channelId: string; +} + +interface BaseAnimationBinding< + TKind extends AnimationBindingKind, + TComponentKey extends string, +> { + path: AnimationPath; + kind: TKind; + components: AnimationBindingComponent[]; +} + +export interface NumberAnimationBinding + extends BaseAnimationBinding<"number", "value"> {} + +export interface Vector2AnimationBinding + extends BaseAnimationBinding<"vector2", "x" | "y"> {} + +export interface ColorAnimationBinding + extends BaseAnimationBinding<"color", "r" | "g" | "b" | "a"> { + colorSpace: "srgb-linear"; +} + +export interface DiscreteAnimationBinding + extends BaseAnimationBinding<"discrete", "value"> {} + +export type AnimationBindingInstance = + | NumberAnimationBinding + | Vector2AnimationBinding + | ColorAnimationBinding + | DiscreteAnimationBinding; + +export interface AnimationBindingByKind { + number: NumberAnimationBinding; + vector2: Vector2AnimationBinding; + color: ColorAnimationBinding; + discrete: DiscreteAnimationBinding; +} + +export type AnimationBindingOfKind = + AnimationBindingByKind[TKind]; + +export type ElementAnimationBindingMap = Record< + string, + AnimationBindingInstance | undefined +>; + +export interface ElementAnimations { + bindings: ElementAnimationBindingMap; + channels: ElementAnimationChannelMap; +} + +export type NormalizedCubicBezier = [number, number, number, number]; + +export interface ScalarGraphChannelTarget { + propertyPath: AnimationPath; + componentKey: string; + channelId: string; +} + +export interface ScalarGraphChannel extends ScalarGraphChannelTarget { + channel: ScalarAnimationChannel; +} + +export interface ScalarGraphKeyframeRef extends ScalarGraphChannelTarget { + keyframeId: string; +} + +export interface ScalarGraphKeyframeContext extends ScalarGraphChannel { + keyframe: ScalarAnimationKey; + keyframeIndex: number; + previousKey: ScalarAnimationKey | null; + nextKey: ScalarAnimationKey | null; +} + +export interface ScalarCurveKeyframePatch { + leftHandle?: CurveHandle | null; + rightHandle?: CurveHandle | null; + segmentToNext?: ScalarSegmentType; + tangentMode?: TangentMode; +} + +export interface ElementKeyframe { + propertyPath: AnimationPath; + id: string; + time: number; + value: AnimationValue; + interpolation: AnimationInterpolation; +} + +export interface SelectedKeyframeRef { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; +} diff --git a/apps/web/src/lib/animation/vector-channel.ts b/apps/web/src/lib/animation/vector-channel.ts deleted file mode 100644 index f7f65049..00000000 --- a/apps/web/src/lib/animation/vector-channel.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { - AnimationPropertyPath, - ElementAnimations, - VectorAnimationChannel, - VectorValue, -} from "@/lib/animation/types"; - -export function isVectorValue(value: unknown): value is VectorValue { - return ( - typeof value === "object" && - value !== null && - "x" in value && - "y" in value && - typeof (value as VectorValue).x === "number" && - typeof (value as VectorValue).y === "number" - ); -} - -export function getVectorChannelForPath({ - animations, - propertyPath, -}: { - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; -}): VectorAnimationChannel | undefined { - const channel = animations?.channels[propertyPath]; - if (!channel || channel.valueKind !== "vector") { - return undefined; - } - return channel; -} - -export function getVectorChannelValueAtTime({ - channel, - time, - fallbackValue, -}: { - channel: VectorAnimationChannel | undefined; - time: number; - fallbackValue: VectorValue; -}): VectorValue { - if (!channel || channel.keyframes.length === 0) { - return fallbackValue; - } - - const keyframes = [...channel.keyframes].sort((a, b) => a.time - b.time); - const first = keyframes[0]; - const last = keyframes[keyframes.length - 1]; - - if (!first || !last) return fallbackValue; - if (time <= first.time) return first.value; - if (time >= last.time) return last.value; - - for (let i = 0; i < keyframes.length - 1; i++) { - const left = keyframes[i]; - const right = keyframes[i + 1]; - if (time < left.time || time > right.time) continue; - - if (left.interpolation === "hold") return left.value; - - const span = right.time - left.time; - if (span === 0) return right.value; - - const t = (time - left.time) / span; - return { - x: left.value.x + (right.value.x - left.value.x) * t, - y: left.value.y + (right.value.y - left.value.y) * t, - }; - } - - return last.value; -} diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts index 6f54ee72..12e0906e 100644 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ b/apps/web/src/lib/commands/media/add-media-asset.ts @@ -1,19 +1,20 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import { toast } from "sonner"; import type { MediaAsset } from "@/lib/media/types"; import { generateUUID } from "@/utils/id"; import { storageService } from "@/services/storage/service"; +import type { FrameRate } from "opencut-wasm"; import { hasMediaId } from "@/lib/timeline/element-utils"; -import { getHighestImportedVideoFps } from "@/lib/fps/utils"; +import { frameRatesEqual, getHighestImportedVideoFps } from "@/lib/fps/utils"; import { UpdateProjectSettingsCommand } from "@/lib/commands/project"; export class AddMediaAssetCommand extends Command { private assetId: string; private savedAssets: MediaAsset[] | null = null; private createdAsset: MediaAsset | null = null; - private previousProjectFps: number | null = null; - private appliedProjectFps: number | null = null; + private previousProjectFps: FrameRate | null = null; + private appliedProjectFps: FrameRate | null = null; constructor( private projectId: string, @@ -23,7 +24,7 @@ export class AddMediaAssetCommand extends Command { this.assetId = generateUUID(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); this.savedAssets = [...editor.media.getAssets()]; @@ -53,11 +54,15 @@ export class AddMediaAssetCommand extends Command { assets: currentAssets.filter((asset) => asset.id !== this.assetId), }); - const currentTracks = editor.timeline.getTracks(); + const currentTracks = editor.scenes.getActiveScene().tracks; const orphanedElements: Array<{ trackId: string; elementId: string }> = []; - for (const track of currentTracks) { + for (const track of [ + ...currentTracks.overlay, + currentTracks.main, + ...currentTracks.audio, + ]) { for (const element of track.elements) { if (hasMediaId(element) && element.mediaId === this.assetId) { orphanedElements.push({ @@ -80,6 +85,8 @@ export class AddMediaAssetCommand extends Command { }); } }); + + return undefined; } undo(): void { @@ -110,14 +117,15 @@ export class AddMediaAssetCommand extends Command { const activeProject = editor.project.getActiveOrNull(); if (!activeProject) return; - if (activeProject.settings.fps !== this.appliedProjectFps) return; + if (!this.appliedProjectFps || !frameRatesEqual(activeProject.settings.fps, this.appliedProjectFps)) return; const highestRemainingVideoFps = getHighestImportedVideoFps({ mediaAssets: editor.media.getAssets(), }); + const appliedFpsFloat = this.appliedProjectFps.numerator / this.appliedProjectFps.denominator; if ( highestRemainingVideoFps !== null && - highestRemainingVideoFps >= this.appliedProjectFps + highestRemainingVideoFps >= appliedFpsFloat ) { return; } diff --git a/apps/web/src/lib/commands/media/remove-media-asset.ts b/apps/web/src/lib/commands/media/remove-media-asset.ts index 84b395ed..5601a7e3 100644 --- a/apps/web/src/lib/commands/media/remove-media-asset.ts +++ b/apps/web/src/lib/commands/media/remove-media-asset.ts @@ -1,99 +1,103 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { MediaAsset } from "@/lib/media/types"; -import { storageService } from "@/services/storage/service"; -import { videoCache } from "@/services/video-cache/service"; -import { hasMediaId } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class RemoveMediaAssetCommand extends Command { - private savedAssets: MediaAsset[] | null = null; - private savedTracks: TimelineTrack[] | null = null; - private removedAsset: MediaAsset | null = null; - - constructor( - private projectId: string, - private assetId: string, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const assets = editor.media.getAssets(); - - this.savedAssets = [...assets]; - this.savedTracks = editor.timeline.getTracks(); - - this.removedAsset = - assets.find((media) => media.id === this.assetId) ?? null; - - if (!this.removedAsset) { - console.error("Media asset not found:", this.assetId); - return; - } - - if (this.removedAsset.url) { - URL.revokeObjectURL(this.removedAsset.url); - } - if (this.removedAsset.thumbnailUrl) { - URL.revokeObjectURL(this.removedAsset.thumbnailUrl); - } - - videoCache.clearVideo({ mediaId: this.assetId }); - - editor.media.setAssets({ - assets: assets.filter((media) => media.id !== this.assetId), - }); - - const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; - - for (const track of this.savedTracks) { - for (const element of track.elements) { - if (hasMediaId(element) && element.mediaId === this.assetId) { - elementsToRemove.push({ trackId: track.id, elementId: element.id }); - } - } - } - - if (elementsToRemove.length > 0) { - editor.timeline.deleteElements({ elements: elementsToRemove }); - } - - storageService - .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) - .catch((error) => { - console.error("Failed to delete media item:", error); - }); - } - - undo(): void { - const editor = EditorCore.getInstance(); - - if (this.savedAssets && this.removedAsset) { - const restoredAsset: MediaAsset = { - ...this.removedAsset, - url: URL.createObjectURL(this.removedAsset.file), - }; - - editor.media.setAssets({ - assets: this.savedAssets.map((a) => - a.id === this.assetId ? restoredAsset : a, - ), - }); - - storageService - .saveMediaAsset({ - projectId: this.projectId, - mediaAsset: restoredAsset, - }) - .catch((error) => { - console.error("Failed to restore media item on undo:", error); - }); - } - - if (this.savedTracks) { - editor.timeline.updateTracks(this.savedTracks); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { MediaAsset } from "@/lib/media/types"; +import { storageService } from "@/services/storage/service"; +import { videoCache } from "@/services/video-cache/service"; +import { hasMediaId } from "@/lib/timeline/element-utils"; +import type { SceneTracks } from "@/lib/timeline"; + +export class RemoveMediaAssetCommand extends Command { + private savedAssets: MediaAsset[] | null = null; + private savedTracks: SceneTracks | null = null; + private removedAsset: MediaAsset | null = null; + + constructor( + private projectId: string, + private assetId: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const assets = editor.media.getAssets(); + + this.savedAssets = [...assets]; + this.savedTracks = editor.scenes.getActiveScene().tracks; + + this.removedAsset = + assets.find((media) => media.id === this.assetId) ?? null; + + if (!this.removedAsset) { + console.error("Media asset not found:", this.assetId); + return; + } + + if (this.removedAsset.url) { + URL.revokeObjectURL(this.removedAsset.url); + } + if (this.removedAsset.thumbnailUrl) { + URL.revokeObjectURL(this.removedAsset.thumbnailUrl); + } + + videoCache.clearVideo({ mediaId: this.assetId }); + + editor.media.setAssets({ + assets: assets.filter((media) => media.id !== this.assetId), + }); + + const elementsToRemove: Array<{ trackId: string; elementId: string }> = []; + + for (const track of [ + ...this.savedTracks.overlay, + this.savedTracks.main, + ...this.savedTracks.audio, + ]) { + for (const element of track.elements) { + if (hasMediaId(element) && element.mediaId === this.assetId) { + elementsToRemove.push({ trackId: track.id, elementId: element.id }); + } + } + } + + if (elementsToRemove.length > 0) { + editor.timeline.deleteElements({ elements: elementsToRemove }); + } + + storageService + .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) + .catch((error) => { + console.error("Failed to delete media item:", error); + }); + } + + undo(): void { + const editor = EditorCore.getInstance(); + + if (this.savedAssets && this.removedAsset) { + const restoredAsset: MediaAsset = { + ...this.removedAsset, + url: URL.createObjectURL(this.removedAsset.file), + }; + + editor.media.setAssets({ + assets: this.savedAssets.map((a) => + a.id === this.assetId ? restoredAsset : a, + ), + }); + + storageService + .saveMediaAsset({ + projectId: this.projectId, + mediaAsset: restoredAsset, + }) + .catch((error) => { + console.error("Failed to restore media item on undo:", error); + }); + } + + if (this.savedTracks) { + editor.timeline.updateTracks(this.savedTracks); + } + } +} diff --git a/apps/web/src/lib/commands/preview-tracker.ts b/apps/web/src/lib/commands/preview-tracker.ts index 38ef4edd..68c8dee3 100644 --- a/apps/web/src/lib/commands/preview-tracker.ts +++ b/apps/web/src/lib/commands/preview-tracker.ts @@ -1,23 +1,23 @@ -export class PreviewTracker { - private snapshot: T | null = null; - - begin({ state }: { state: T }): void { - if (this.snapshot === null) { - this.snapshot = structuredClone(state); - } - } - - isActive(): boolean { - return this.snapshot !== null; - } - - getSnapshot(): T | null { - return this.snapshot; - } - - end(): T | null { - const snapshot = this.snapshot; - this.snapshot = null; - return snapshot; - } -} +export class PreviewTracker { + private snapshot: T | null = null; + + begin({ state }: { state: T }): void { + if (this.snapshot === null) { + this.snapshot = structuredClone(state); + } + } + + isActive(): boolean { + return this.snapshot !== null; + } + + getSnapshot(): T | null { + return this.snapshot; + } + + end(): T | null { + const snapshot = this.snapshot; + this.snapshot = null; + return snapshot; + } +} diff --git a/apps/web/src/lib/commands/project/update-project-settings.ts b/apps/web/src/lib/commands/project/update-project-settings.ts index 8320095b..32258e12 100644 --- a/apps/web/src/lib/commands/project/update-project-settings.ts +++ b/apps/web/src/lib/commands/project/update-project-settings.ts @@ -1,46 +1,46 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TProject, TProjectSettings } from "@/lib/project/types"; - -export class UpdateProjectSettingsCommand extends Command { - private savedSettings: TProjectSettings | null = null; - private savedUpdatedAt: Date | null = null; - - constructor(private updates: Partial) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - this.savedSettings = activeProject.settings; - this.savedUpdatedAt = activeProject.metadata.updatedAt; - - const updatedProject: TProject = { - ...activeProject, - settings: { ...activeProject.settings, ...this.updates }, - metadata: { ...activeProject.metadata, updatedAt: new Date() }, - }; - - editor.project.setActiveProject({ project: updatedProject }); - editor.save.markDirty(); - } - - undo(): void { - if (!this.savedSettings || !this.savedUpdatedAt) return; - const editor = EditorCore.getInstance(); - const activeProject = editor.project.getActive(); - if (!activeProject) return; - - const updatedProject: TProject = { - ...activeProject, - settings: this.savedSettings, - metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, - }; - - editor.project.setActiveProject({ project: updatedProject }); - editor.save.markDirty(); - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TProject, TProjectSettings } from "@/lib/project/types"; + +export class UpdateProjectSettingsCommand extends Command { + private savedSettings: TProjectSettings | null = null; + private savedUpdatedAt: Date | null = null; + + constructor(private updates: Partial) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + this.savedSettings = activeProject.settings; + this.savedUpdatedAt = activeProject.metadata.updatedAt; + + const updatedProject: TProject = { + ...activeProject, + settings: { ...activeProject.settings, ...this.updates }, + metadata: { ...activeProject.metadata, updatedAt: new Date() }, + }; + + editor.project.setActiveProject({ project: updatedProject }); + editor.save.markDirty(); + } + + undo(): void { + if (!this.savedSettings || !this.savedUpdatedAt) return; + const editor = EditorCore.getInstance(); + const activeProject = editor.project.getActive(); + if (!activeProject) return; + + const updatedProject: TProject = { + ...activeProject, + settings: this.savedSettings, + metadata: { ...activeProject.metadata, updatedAt: this.savedUpdatedAt }, + }; + + editor.project.setActiveProject({ project: updatedProject }); + editor.save.markDirty(); + } +} diff --git a/apps/web/src/lib/commands/scene/create-scene.ts b/apps/web/src/lib/commands/scene/create-scene.ts index 7675570d..1cc72c0f 100644 --- a/apps/web/src/lib/commands/scene/create-scene.ts +++ b/apps/web/src/lib/commands/scene/create-scene.ts @@ -1,40 +1,41 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { buildDefaultScene } from "@/lib/scenes"; - -export class CreateSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private createdScene: TScene | null = null; - - constructor( - private name: string, - private isMain: boolean = false, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedScenes = [...editor.scenes.getScenes()]; - - this.createdScene = buildDefaultScene({ - name: this.name, - isMain: this.isMain, - }); - - const updatedScenes = [...this.savedScenes, this.createdScene]; - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } - - getSceneId(): string { - return this.createdScene?.id ?? ""; - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { buildDefaultScene } from "@/lib/scenes"; + +export class CreateSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private createdScene: TScene | null = null; + + constructor( + private name: string, + private isMain: boolean = false, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedScenes = [...editor.scenes.getScenes()]; + + this.createdScene = buildDefaultScene({ + name: this.name, + isMain: this.isMain, + }); + + const updatedScenes = [...this.savedScenes, this.createdScene]; + editor.scenes.setScenes({ scenes: updatedScenes }); + return undefined; + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } + + getSceneId(): string { + return this.createdScene?.id ?? ""; + } +} diff --git a/apps/web/src/lib/commands/scene/delete-scene.ts b/apps/web/src/lib/commands/scene/delete-scene.ts index da8aac24..ab269b12 100644 --- a/apps/web/src/lib/commands/scene/delete-scene.ts +++ b/apps/web/src/lib/commands/scene/delete-scene.ts @@ -1,59 +1,59 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; - -export class DeleteSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private savedActiveSceneId: string | null = null; - private deletedScene: TScene | null = null; - - constructor(private sceneId: string) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const scenes = editor.scenes.getScenes(); - const activeScene = editor.scenes.getActiveScene(); - - this.savedScenes = [...scenes]; - this.savedActiveSceneId = activeScene?.id ?? null; - - this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; - - if (!this.deletedScene) { - console.error("Scene not found:", this.sceneId); - return; - } - - const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); - if (!canDelete) { - console.error("Cannot delete scene:", reason); - return; - } - - const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); - - const newActiveScene = getFallbackSceneAfterDelete({ - scenes: updatedScenes, - deletedSceneId: this.sceneId, - currentSceneId: activeScene?.id ?? null, - }); - - editor.scenes.setScenes({ - scenes: updatedScenes, - activeSceneId: newActiveScene?.id, - }); - } - - undo(): void { - if (this.savedScenes && this.deletedScene) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ - scenes: this.savedScenes, - activeSceneId: this.savedActiveSceneId ?? undefined, - }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { canDeleteScene, getFallbackSceneAfterDelete } from "@/lib/scenes"; + +export class DeleteSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private savedActiveSceneId: string | null = null; + private deletedScene: TScene | null = null; + + constructor(private sceneId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const scenes = editor.scenes.getScenes(); + const activeScene = editor.scenes.getActiveScene(); + + this.savedScenes = [...scenes]; + this.savedActiveSceneId = activeScene?.id ?? null; + + this.deletedScene = scenes.find((s) => s.id === this.sceneId) ?? null; + + if (!this.deletedScene) { + console.error("Scene not found:", this.sceneId); + return; + } + + const { canDelete, reason } = canDeleteScene({ scene: this.deletedScene }); + if (!canDelete) { + console.error("Cannot delete scene:", reason); + return; + } + + const updatedScenes = scenes.filter((s) => s.id !== this.sceneId); + + const newActiveScene = getFallbackSceneAfterDelete({ + scenes: updatedScenes, + deletedSceneId: this.sceneId, + currentSceneId: activeScene?.id ?? null, + }); + + editor.scenes.setScenes({ + scenes: updatedScenes, + activeSceneId: newActiveScene?.id, + }); + } + + undo(): void { + if (this.savedScenes && this.deletedScene) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ + scenes: this.savedScenes, + activeSceneId: this.savedActiveSceneId ?? undefined, + }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts index b680108d..2b843c99 100644 --- a/apps/web/src/lib/commands/scene/index.ts +++ b/apps/web/src/lib/commands/scene/index.ts @@ -1,7 +1,7 @@ -export { CreateSceneCommand } from "./create-scene"; -export { DeleteSceneCommand } from "./delete-scene"; -export { RenameSceneCommand } from "./rename-scene"; -export { ToggleBookmarkCommand } from "./toggle-bookmark"; -export { RemoveBookmarkCommand } from "./remove-bookmark"; -export { UpdateBookmarkCommand } from "./update-bookmark"; -export { MoveBookmarkCommand } from "./move-bookmark"; +export { CreateSceneCommand } from "./create-scene"; +export { DeleteSceneCommand } from "./delete-scene"; +export { RenameSceneCommand } from "./rename-scene"; +export { ToggleBookmarkCommand } from "./toggle-bookmark"; +export { RemoveBookmarkCommand } from "./remove-bookmark"; +export { UpdateBookmarkCommand } from "./update-bookmark"; +export { MoveBookmarkCommand } from "./move-bookmark"; diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts index 7b5b5aac..103caa74 100644 --- a/apps/web/src/lib/commands/scene/move-bookmark.ts +++ b/apps/web/src/lib/commands/scene/move-bookmark.ts @@ -1,59 +1,59 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class MoveBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - - constructor( - private fromTime: number, - private toTime: number, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - const fromFrameTime = getFrameTime({ - time: this.fromTime, - fps: activeProject.settings.fps, - }); - const toFrameTime = getFrameTime({ - time: this.toTime, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = moveBookmarkInArray({ - bookmarks: activeScene.bookmarks, - fromTime: fromFrameTime, - toTime: toFrameTime, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class MoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private fromTime: number, + private toTime: number, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const fromFrameTime = getFrameTime({ + time: this.fromTime, + fps: activeProject.settings.fps, + }); + const toFrameTime = getFrameTime({ + time: this.toTime, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = moveBookmarkInArray({ + bookmarks: activeScene.bookmarks, + fromTime: fromFrameTime, + toTime: toFrameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/remove-bookmark.ts b/apps/web/src/lib/commands/scene/remove-bookmark.ts index c5f8692c..5eac920f 100644 --- a/apps/web/src/lib/commands/scene/remove-bookmark.ts +++ b/apps/web/src/lib/commands/scene/remove-bookmark.ts @@ -1,59 +1,59 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { - getFrameTime, - removeBookmarkFromArray, -} from "@/lib/timeline/bookmarks"; - -export class RemoveBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - private frameTime: number = 0; - - constructor(private time: number) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - this.frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = removeBookmarkFromArray({ - bookmarks: activeScene.bookmarks, - frameTime: this.frameTime, - }); - - if (updatedBookmarks.length === activeScene.bookmarks.length) { - return; - } - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { + getFrameTime, + removeBookmarkFromArray, +} from "@/lib/timeline/bookmarks"; + +export class RemoveBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + private frameTime: number = 0; + + constructor(private time: number) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + this.frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = removeBookmarkFromArray({ + bookmarks: activeScene.bookmarks, + frameTime: this.frameTime, + }); + + if (updatedBookmarks.length === activeScene.bookmarks.length) { + return; + } + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/rename-scene.ts b/apps/web/src/lib/commands/scene/rename-scene.ts index 554e3940..ccdc85d6 100644 --- a/apps/web/src/lib/commands/scene/rename-scene.ts +++ b/apps/web/src/lib/commands/scene/rename-scene.ts @@ -1,46 +1,46 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; - -export class RenameSceneCommand extends Command { - private savedScenes: TScene[] | null = null; - private previousName: string | null = null; - - constructor( - private sceneId: string, - private newName: string, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const scenes = editor.scenes.getScenes(); - - this.savedScenes = [...scenes]; - - const scene = scenes.find((s) => s.id === this.sceneId); - if (!scene) { - console.error("Scene not found:", this.sceneId); - return; - } - - this.previousName = scene.name; - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: this.sceneId, - updates: { name: this.newName, updatedAt: new Date() }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes && this.previousName !== null) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; + +export class RenameSceneCommand extends Command { + private savedScenes: TScene[] | null = null; + private previousName: string | null = null; + + constructor( + private sceneId: string, + private newName: string, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const scenes = editor.scenes.getScenes(); + + this.savedScenes = [...scenes]; + + const scene = scenes.find((s) => s.id === this.sceneId); + if (!scene) { + console.error("Scene not found:", this.sceneId); + return; + } + + this.previousName = scene.name; + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: this.sceneId, + updates: { name: this.newName, updatedAt: new Date() }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes && this.previousName !== null) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/toggle-bookmark.ts b/apps/web/src/lib/commands/scene/toggle-bookmark.ts index 69fe3213..cb4fa655 100644 --- a/apps/web/src/lib/commands/scene/toggle-bookmark.ts +++ b/apps/web/src/lib/commands/scene/toggle-bookmark.ts @@ -1,52 +1,52 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class ToggleBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - private frameTime: number = 0; - - constructor(private time: number) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - this.frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = toggleBookmarkInArray({ - bookmarks: activeScene.bookmarks, - frameTime: this.frameTime, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, toggleBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class ToggleBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + private frameTime: number = 0; + + constructor(private time: number) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + this.frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = toggleBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime: this.frameTime, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts index 2e320b3d..1d1c7283 100644 --- a/apps/web/src/lib/commands/scene/update-bookmark.ts +++ b/apps/web/src/lib/commands/scene/update-bookmark.ts @@ -1,55 +1,55 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { Bookmark, TScene } from "@/lib/timeline"; -import { updateSceneInArray } from "@/lib/scenes"; -import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; - -export class UpdateBookmarkCommand extends Command { - private savedScenes: TScene[] | null = null; - - constructor( - private time: number, - private updates: Partial>, - ) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - const activeScene = editor.scenes.getActiveScene(); - const activeProject = editor.project.getActive(); - - if (!activeScene || !activeProject) { - return; - } - - const scenes = editor.scenes.getScenes(); - this.savedScenes = [...scenes]; - - const frameTime = getFrameTime({ - time: this.time, - fps: activeProject.settings.fps, - }); - - const updatedBookmarks = updateBookmarkInArray({ - bookmarks: activeScene.bookmarks, - frameTime, - updates: this.updates, - }); - - const updatedScenes = updateSceneInArray({ - scenes, - sceneId: activeScene.id, - updates: { bookmarks: updatedBookmarks }, - }); - - editor.scenes.setScenes({ scenes: updatedScenes }); - } - - undo(): void { - if (this.savedScenes) { - const editor = EditorCore.getInstance(); - editor.scenes.setScenes({ scenes: this.savedScenes }); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { Bookmark, TScene } from "@/lib/timeline"; +import { updateSceneInArray } from "@/lib/scenes"; +import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks"; + +export class UpdateBookmarkCommand extends Command { + private savedScenes: TScene[] | null = null; + + constructor( + private time: number, + private updates: Partial>, + ) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + const activeScene = editor.scenes.getActiveScene(); + const activeProject = editor.project.getActive(); + + if (!activeScene || !activeProject) { + return; + } + + const scenes = editor.scenes.getScenes(); + this.savedScenes = [...scenes]; + + const frameTime = getFrameTime({ + time: this.time, + fps: activeProject.settings.fps, + }); + + const updatedBookmarks = updateBookmarkInArray({ + bookmarks: activeScene.bookmarks, + frameTime, + updates: this.updates, + }); + + const updatedScenes = updateSceneInArray({ + scenes, + sceneId: activeScene.id, + updates: { bookmarks: updatedBookmarks }, + }); + + editor.scenes.setScenes({ scenes: updatedScenes }); + } + + undo(): void { + if (this.savedScenes) { + const editor = EditorCore.getInstance(); + editor.scenes.setScenes({ scenes: this.savedScenes }); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts index 59e1af1c..6ccf96fb 100644 --- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts +++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts @@ -1,21 +1,16 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; import type { - TimelineTrack, + SceneTracks, TimelineElement, ClipboardItem, } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; -import { - applyPlacement, - resolveTrackPlacement, - isMainTrack, - enforceMainTrackStart, -} from "@/lib/timeline/placement"; +import { applyPlacement, resolveTrackPlacement, enforceMainTrackStart } from "@/lib/timeline/placement"; import { cloneAnimations } from "@/lib/animation"; export class PasteCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private pastedElements: { trackId: string; elementId: string }[] = []; private readonly time: number; private readonly clipboardItems: ClipboardItem[]; @@ -33,17 +28,17 @@ export class PasteCommand extends Command { } execute(): CommandResult | undefined { - if (this.clipboardItems.length === 0) return; + if (this.clipboardItems.length === 0) return undefined; const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; this.pastedElements = []; const minStart = Math.min( ...this.clipboardItems.map((item) => item.element.startTime), ); - let updatedTracks = [...this.savedState]; + let updatedTracks = this.savedState; const itemsByTrackId = groupClipboardItemsByTrackId({ clipboardItems: this.clipboardItems, }); @@ -60,9 +55,11 @@ export class PasteCommand extends Command { } const trackType = items[0].trackType; - const sourceTrackIndex = updatedTracks.findIndex( - (track) => track.id === trackId, - ); + const sourceTrackIndex = [ + ...updatedTracks.overlay, + updatedTracks.main, + ...updatedTracks.audio, + ].findIndex((track) => track.id === trackId); const placementResult = resolveTrackPlacement({ tracks: updatedTracks, trackType, @@ -78,8 +75,15 @@ export class PasteCommand extends Command { let elementsForPlacement = elementsToAdd; if (placementResult.kind === "existingTrack") { - const targetTrack = updatedTracks[placementResult.trackIndex]; - if (isMainTrack(targetTrack)) { + const targetTrack = + placementResult.trackIndex < updatedTracks.overlay.length + ? updatedTracks.overlay[placementResult.trackIndex] + : placementResult.trackIndex === updatedTracks.overlay.length + ? updatedTracks.main + : updatedTracks.audio[ + placementResult.trackIndex - updatedTracks.overlay.length - 1 + ]; + if (targetTrack?.id === updatedTracks.main.id) { const earliestElement = elementsToAdd.reduce((earliest, element) => element.startTime < earliest.startTime ? element : earliest, ); @@ -123,6 +127,7 @@ export class PasteCommand extends Command { if (this.pastedElements.length > 0) { return { select: this.pastedElements }; } + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/delete-elements.ts b/apps/web/src/lib/commands/timeline/element/delete-elements.ts index 9177a3f8..8d93c6ba 100644 --- a/apps/web/src/lib/commands/timeline/element/delete-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/delete-elements.ts @@ -1,69 +1,55 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; import { EditorCore } from "@/core"; -import { rippleShiftElements } from "@/lib/timeline"; +import type { TimelineTrack } from "@/lib/timeline"; + +function removeTrackElements({ + track, + elements, +}: { + track: TTrack; + elements: { trackId: string; elementId: string }[]; +}): TTrack { + const nextElements = track.elements.filter( + (element) => + !elements.some( + (target) => + target.trackId === track.id && target.elementId === element.id, + ), + ); + + return { ...track, elements: nextElements } as TTrack; +} export class DeleteElementsCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly elements: { trackId: string; elementId: string }[]; - private readonly rippleEnabled: boolean; constructor({ elements, - rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; - rippleEnabled?: boolean; }) { super(); this.elements = elements; - this.rippleEnabled = rippleEnabled; } - execute(): CommandResult { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = this.savedState.map((track) => { - const elementsToDeleteOnTrack = this.elements.filter( - (target) => target.trackId === track.id, - ); - const hasElementsToDelete = elementsToDeleteOnTrack.length > 0; - - if (!hasElementsToDelete) { - return track; - } - - const deletedElementInfos = elementsToDeleteOnTrack - .map((target) => - track.elements.find((element) => element.id === target.elementId), - ) - .filter((element): element is NonNullable => element !== undefined) - .map((element) => ({ startTime: element.startTime, duration: element.duration })); - - let elements = track.elements.filter( - (element) => - !this.elements.some( - (target) => - target.trackId === track.id && target.elementId === element.id, - ), - ); - - if (this.rippleEnabled && deletedElementInfos.length > 0) { - const sortedByStartDesc = [...deletedElementInfos].sort( - (a, b) => b.startTime - a.startTime, - ); - for (const { startTime, duration } of sortedByStartDesc) { - elements = rippleShiftElements({ - elements, - afterTime: startTime, - shiftAmount: duration, - }); - } - } - - return { ...track, elements } as typeof track; - }); + const updatedTracks: SceneTracks = { + overlay: this.savedState.overlay.map((track) => + removeTrackElements({ track, elements: this.elements }), + ), + main: removeTrackElements({ + track: this.savedState.main, + elements: this.elements, + }), + audio: this.savedState.audio.map((track) => + removeTrackElements({ track, elements: this.elements }), + ), + }; editor.timeline.updateTracks(updatedTracks); diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts index ad31ae9c..ab7a4a25 100644 --- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts @@ -1,5 +1,5 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; import { applyPlacement, resolveTrackPlacement } from "@/lib/timeline/placement"; @@ -11,7 +11,7 @@ interface DuplicateElementsParams { export class DuplicateElementsCommand extends Command { private duplicatedElements: { trackId: string; elementId: string }[] = []; - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private elements: DuplicateElementsParams["elements"]; constructor({ elements }: DuplicateElementsParams) { @@ -21,12 +21,16 @@ export class DuplicateElementsCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; this.duplicatedElements = []; - let updatedTracks = [...this.savedState]; + let updatedTracks = this.savedState; - for (const track of this.savedState) { + for (const track of [ + ...this.savedState.overlay, + this.savedState.main, + ...this.savedState.audio, + ]) { const elementsToDuplicate = this.elements.filter( (elementEntry) => elementEntry.trackId === track.id, ); @@ -91,6 +95,7 @@ export class DuplicateElementsCommand extends Command { select: this.duplicatedElements, }; } + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts index 3d27b345..902ca341 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/add-effect.ts @@ -1,74 +1,75 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; -import { buildDefaultEffectInstance } from "@/lib/effects"; - -function addEffectToElement({ - element, - effectType, -}: { - element: VisualElement; - effectType: string; -}): VisualElement { - const instance = buildDefaultEffectInstance({ effectType }); - const currentEffects = element.effects ?? []; - return { ...element, effects: [...currentEffects, instance] }; -} - -export class AddClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private effectId: string | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectType: string; - - constructor({ - trackId, - elementId, - effectType, - }: { - trackId: string; - elementId: string; - effectType: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectType = effectType; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const updated = addEffectToElement({ - element: element as VisualElement, - effectType: this.effectType, - }); - const effects = updated.effects ?? []; - this.effectId = effects[effects.length - 1]?.id ?? null; - return updated; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getEffectId(): string | null { - return this.effectId; - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; +import { buildDefaultEffectInstance } from "@/lib/effects"; + +function addEffectToElement({ + element, + effectType, +}: { + element: VisualElement; + effectType: string; +}): VisualElement { + const instance = buildDefaultEffectInstance({ effectType }); + const currentEffects = element.effects ?? []; + return { ...element, effects: [...currentEffects, instance] }; +} + +export class AddClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private effectId: string | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectType: string; + + constructor({ + trackId, + elementId, + effectType, + }: { + trackId: string; + elementId: string; + effectType: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectType = effectType; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const updated = addEffectToElement({ + element: element as VisualElement, + effectType: this.effectType, + }); + const effects = updated.effects ?? []; + this.effectId = effects[effects.length - 1]?.id ?? null; + return updated; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getEffectId(): string | null { + return this.effectId; + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/index.ts b/apps/web/src/lib/commands/timeline/element/effects/index.ts index b03c51e6..133ce661 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/index.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/index.ts @@ -1,5 +1,5 @@ -export { AddClipEffectCommand } from "./add-effect"; -export { RemoveClipEffectCommand } from "./remove-effect"; -export { ToggleClipEffectCommand } from "./toggle-effect"; -export { UpdateClipEffectParamsCommand } from "./update-effect-params"; -export { ReorderClipEffectsCommand } from "./reorder-effect"; +export { AddClipEffectCommand } from "./add-effect"; +export { RemoveClipEffectCommand } from "./remove-effect"; +export { ToggleClipEffectCommand } from "./toggle-effect"; +export { UpdateClipEffectParamsCommand } from "./update-effect-params"; +export { ReorderClipEffectsCommand } from "./reorder-effect"; diff --git a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts index 88b445f3..f453f2cc 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/remove-effect.ts @@ -1,65 +1,66 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; - -function removeEffectFromElement({ - element, - effectId, -}: { - element: VisualElement; - effectId: string; -}): VisualElement { - const currentEffects = element.effects ?? []; - const filtered = currentEffects.filter((effect) => effect.id !== effectId); - return { ...element, effects: filtered }; -} - -export class RemoveClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - - constructor({ - trackId, - elementId, - effectId, - }: { - trackId: string; - elementId: string; - effectId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return removeEffectFromElement({ - element: element as VisualElement, - effectId: this.effectId, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; + +function removeEffectFromElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const filtered = currentEffects.filter((effect) => effect.id !== effectId); + return { ...element, effects: filtered }; +} + +export class RemoveClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return removeEffectFromElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts index af211a29..28b7c4e1 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/reorder-effect.ts @@ -1,73 +1,74 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; - -function reorderEffectsOnElement({ - element, - fromIndex, - toIndex, -}: { - element: VisualElement; - fromIndex: number; - toIndex: number; -}): VisualElement { - const effects = [...(element.effects ?? [])]; - const [moved] = effects.splice(fromIndex, 1); - effects.splice(toIndex, 0, moved); - return { ...element, effects }; -} - -export class ReorderClipEffectsCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly fromIndex: number; - private readonly toIndex: number; - - constructor({ - trackId, - elementId, - fromIndex, - toIndex, - }: { - trackId: string; - elementId: string; - fromIndex: number; - toIndex: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.fromIndex = fromIndex; - this.toIndex = toIndex; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return reorderEffectsOnElement({ - element: element as VisualElement, - fromIndex: this.fromIndex, - toIndex: this.toIndex, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; + +function reorderEffectsOnElement({ + element, + fromIndex, + toIndex, +}: { + element: VisualElement; + fromIndex: number; + toIndex: number; +}): VisualElement { + const effects = [...(element.effects ?? [])]; + const [moved] = effects.splice(fromIndex, 1); + effects.splice(toIndex, 0, moved); + return { ...element, effects }; +} + +export class ReorderClipEffectsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly fromIndex: number; + private readonly toIndex: number; + + constructor({ + trackId, + elementId, + fromIndex, + toIndex, + }: { + trackId: string; + elementId: string; + fromIndex: number; + toIndex: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.fromIndex = fromIndex; + this.toIndex = toIndex; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return reorderEffectsOnElement({ + element: element as VisualElement, + fromIndex: this.fromIndex, + toIndex: this.toIndex, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts index 2e55b676..a703cd94 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/toggle-effect.ts @@ -1,67 +1,68 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; - -export function toggleEffectOnElement({ - element, - effectId, -}: { - element: VisualElement; - effectId: string; -}): VisualElement { - const currentEffects = element.effects ?? []; - const updated = currentEffects.map((effect) => - effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect, - ); - return { ...element, effects: updated }; -} - -export class ToggleClipEffectCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - - constructor({ - trackId, - elementId, - effectId, - }: { - trackId: string; - elementId: string; - effectId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - return toggleEffectOnElement({ - element: element as VisualElement, - effectId: this.effectId, - }); - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; + +export function toggleEffectOnElement({ + element, + effectId, +}: { + element: VisualElement; + effectId: string; +}): VisualElement { + const currentEffects = element.effects ?? []; + const updated = currentEffects.map((effect) => + effect.id === effectId ? { ...effect, enabled: !effect.enabled } : effect, + ); + return { ...element, effects: updated }; +} + +export class ToggleClipEffectCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + + constructor({ + trackId, + elementId, + effectId, + }: { + trackId: string; + elementId: string; + effectId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + return toggleEffectOnElement({ + element: element as VisualElement, + effectId: this.effectId, + }); + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts index c3443c22..5e75c1f0 100644 --- a/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts +++ b/apps/web/src/lib/commands/timeline/element/effects/update-effect-params.ts @@ -1,8 +1,8 @@ -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { EditorCore } from "@/core"; -import { isVisualElement, updateElementInTracks } from "@/lib/timeline"; +import { isVisualElement, updateElementInSceneTracks } from "@/lib/timeline"; import type { ParamValues } from "@/lib/params"; -import type { TimelineTrack, VisualElement } from "@/lib/timeline"; +import type { SceneTracks, VisualElement } from "@/lib/timeline"; function updateEffectParamsOnElement({ element, @@ -32,7 +32,7 @@ function updateEffectParamsOnElement({ } export class UpdateClipEffectParamsCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private readonly trackId: string; private readonly elementId: string; private readonly effectId: string; @@ -56,11 +56,11 @@ export class UpdateClipEffectParamsCommand extends Command { this.params = params; } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const updatedTracks = updateElementInTracks({ + const updatedTracks = updateElementInSceneTracks({ tracks: this.savedState, trackId: this.trackId, elementId: this.elementId, @@ -75,6 +75,7 @@ export class UpdateClipEffectParamsCommand extends Command { }); editor.timeline.updateTracks(updatedTracks); + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/index.ts b/apps/web/src/lib/commands/timeline/element/index.ts index 5ce48d55..ff365801 100644 --- a/apps/web/src/lib/commands/timeline/element/index.ts +++ b/apps/web/src/lib/commands/timeline/element/index.ts @@ -1,16 +1,11 @@ -export { InsertElementCommand } from "./insert-element"; -export { DeleteElementsCommand } from "./delete-elements"; -export { DuplicateElementsCommand } from "./duplicate-elements"; -export { UpdateElementTrimCommand } from "./update-element-trim"; -export { UpdateElementDurationCommand } from "./update-element-duration"; -export { UpdateElementStartTimeCommand } from "./update-element-start-time"; -export { SplitElementsCommand } from "./split-elements"; -export { UpdateElementCommand } from "./update-element"; -export { ToggleElementsVisibilityCommand } from "./toggle-elements-visibility"; -export { ToggleElementsMutedCommand } from "./toggle-elements-muted"; -export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; -export { MoveElementCommand } from "./move-elements"; -export * from "./keyframes"; -export * from "./effects"; -export * from "./masks"; -export * from "./retime"; +export { InsertElementCommand } from "./insert-element"; +export { DeleteElementsCommand } from "./delete-elements"; +export { DuplicateElementsCommand } from "./duplicate-elements"; +export { SplitElementsCommand } from "./split-elements"; +export { UpdateElementsCommand } from "./update-elements"; +export { ToggleSourceAudioSeparationCommand } from "./toggle-source-audio-separation"; +export { MoveElementCommand } from "./move-elements"; + +export * from "./keyframes"; +export * from "./effects"; +export * from "./masks"; diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts index 2119d219..0ac11ebd 100644 --- a/apps/web/src/lib/commands/timeline/element/insert-element.ts +++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts @@ -1,264 +1,283 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { - CreateTimelineElement, - TimelineTrack, - TimelineElement, - TrackType, -} from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { requiresMediaId } from "@/lib/timeline/element-utils"; -import type { MediaAsset } from "@/lib/media/types"; -import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation"; -import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; -import { - applyPlacement, - canElementGoOnTrack, - resolveTrackPlacement, - validateElementTrackCompatibility, -} from "@/lib/timeline/placement"; - -type InsertElementPlacement = - | { mode: "explicit"; trackId: string } - | { mode: "auto"; trackType?: TrackType; insertIndex?: number }; - -export interface InsertElementParams { - element: CreateTimelineElement; - placement: InsertElementPlacement; -} - -export class InsertElementCommand extends Command { - private elementId: string; - private savedState: TimelineTrack[] | null = null; - private targetTrackId: string | null = null; - - constructor({ element, placement }: InsertElementParams) { - super(); - this.elementId = generateUUID(); - this.element = element; - this.placement = placement; - } - - private element: CreateTimelineElement; - private placement: InsertElementPlacement; - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - if (!this.savedState) { - console.error("Tracks not available"); - return; - } - - if (!this.validateElementBasics({ element: this.element })) { - return; - } - - const totalElementsInTimeline = this.savedState.reduce( - (total, t) => total + t.elements.length, - 0, - ); - const isFirstElement = totalElementsInTimeline === 0; - - const newElement = this.buildElement({ element: this.element }); - const updateResult = this.applyPlacementResult({ - tracks: this.savedState, - element: newElement, - }); - - if (!updateResult) { - return; - } - - const { updatedTracks, targetTrackId } = updateResult; - this.targetTrackId = targetTrackId; - - const isVisualMedia = - newElement.type === "video" || newElement.type === "image"; - - if (isFirstElement && isVisualMedia) { - const mediaAssets = editor.media.getAssets(); - const activeProject = editor.project.getActive(); - const asset = mediaAssets.find( - (item: MediaAsset) => item.id === newElement.mediaId, - ); - - if (asset?.width && asset?.height) { - const nextCanvasSize = { width: asset.width, height: asset.height }; - const shouldSetOriginalCanvasSize = - !activeProject?.settings.originalCanvasSize; - editor.project.updateSettings({ - settings: { - canvasSize: nextCanvasSize, - ...(shouldSetOriginalCanvasSize - ? { originalCanvasSize: nextCanvasSize } - : {}), - }, - pushHistory: false, - }); - } - - if (asset?.type === "video" && asset?.fps) { - editor.project.updateSettings({ - settings: { fps: asset.fps }, - pushHistory: false, - }); - } - } - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getElementId(): string { - return this.elementId; - } - - getTrackId(): string | null { - return this.targetTrackId; - } - - private buildElement({ - element, - }: { - element: CreateTimelineElement; - }): TimelineElement { - return { - ...element, - id: this.elementId, - startTime: element.startTime, - trimStart: element.trimStart ?? 0, - trimEnd: element.trimEnd ?? 0, - duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS, - } as TimelineElement; - } - - private validateElementBasics({ - element, - }: { - element: CreateTimelineElement; - }): boolean { - if (requiresMediaId({ element }) && !("mediaId" in element)) { - console.error("Element requires mediaId"); - return false; - } - - if ( - element.type === "audio" && - element.sourceType === "library" && - !element.sourceUrl - ) { - console.error("Library audio element must have sourceUrl"); - return false; - } - - if (element.type === "sticker" && !element.stickerId) { - console.error("Sticker element must have stickerId"); - return false; - } - - if (element.type === "graphic") { - registerDefaultGraphics(); - if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) { - console.error("Graphic element must have a valid definitionId"); - return false; - } - } - - if (element.type === "text" && !element.content) { - console.error("Text element must have content"); - return false; - } - - if (element.type === "effect" && !element.effectType) { - console.error("Effect element must have effectType"); - return false; - } - - return true; - } - - private applyPlacementResult({ - tracks, - element, - }: { - tracks: TimelineTrack[]; - element: TimelineElement; - }): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { - const placement = this.placement; - - if ( - placement.mode === "auto" && - placement.trackType && - !canElementGoOnTrack({ - elementType: element.type, - trackType: placement.trackType, - }) - ) { - console.error( - `${element.type} elements cannot be placed on ${placement.trackType} tracks`, - ); - return null; - } - - const placementResult = resolveTrackPlacement({ - tracks, - ...(placement.mode === "auto" && placement.trackType - ? { trackType: placement.trackType } - : { elementType: element.type }), - timeSpans: [ - { - startTime: element.startTime, - duration: element.duration, - }, - ], - strategy: - placement.mode === "explicit" - ? { type: "explicit", trackId: placement.trackId } - : { type: "firstAvailable" }, - }); - if (!placementResult) { - if (placement.mode === "explicit") { - const targetTrack = tracks.find((track) => track.id === placement.trackId); - if (!targetTrack) { - console.error("Track not found:", placement.trackId); - return null; - } - - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); - console.error(validation.errorMessage); - } - - return null; - } - - const elementToPlace = - placementResult.kind === "existingTrack" - ? { - ...element, - startTime: - placementResult.adjustedStartTime ?? element.startTime, - } - : element; - - return applyPlacement({ - tracks, - placementResult, - elements: [elementToPlace], - newTrackInsertIndexOverride: - placement.mode === "auto" && typeof placement.insertIndex === "number" - ? placement.insertIndex - : undefined, - }); - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { + CreateTimelineElement, + SceneTracks, + TimelineTrack, + TimelineElement, + TrackType, +} from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; +import { requiresMediaId } from "@/lib/timeline/element-utils"; +import type { MediaAsset } from "@/lib/media/types"; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; +import { floatToFrameRate } from "@/lib/fps/utils"; +import { graphicsRegistry, registerDefaultGraphics } from "@/lib/graphics"; +import { + applyPlacement, + canElementGoOnTrack, + resolveTrackPlacement, + validateElementTrackCompatibility, +} from "@/lib/timeline/placement"; + +type InsertElementPlacement = + | { mode: "explicit"; trackId: string } + | { mode: "auto"; trackType?: TrackType; insertIndex?: number }; + +export interface InsertElementParams { + element: CreateTimelineElement; + placement: InsertElementPlacement; +} + +export class InsertElementCommand extends Command { + private elementId: string; + private savedState: SceneTracks | null = null; + private targetTrackId: string | null = null; + + constructor({ element, placement }: InsertElementParams) { + super(); + this.elementId = generateUUID(); + this.element = element; + this.placement = placement; + } + + private element: CreateTimelineElement; + private placement: InsertElementPlacement; + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + if (!this.validateElementBasics({ element: this.element })) { + return; + } + + const totalElementsInTimeline = + this.savedState.main.elements.length + + this.savedState.overlay.reduce( + (total, track) => total + track.elements.length, + 0, + ) + + this.savedState.audio.reduce( + (total, track) => total + track.elements.length, + 0, + ); + const isFirstElement = totalElementsInTimeline === 0; + + const newElement = this.buildElement({ element: this.element }); + const updateResult = this.applyPlacementResult({ + tracks: this.savedState, + element: newElement, + }); + + if (!updateResult) { + return; + } + + const { updatedTracks, targetTrackId } = updateResult; + this.targetTrackId = targetTrackId; + + const isVisualMedia = + newElement.type === "video" || newElement.type === "image"; + + if (isFirstElement && isVisualMedia) { + const mediaAssets = editor.media.getAssets(); + const activeProject = editor.project.getActive(); + const asset = mediaAssets.find( + (item: MediaAsset) => item.id === newElement.mediaId, + ); + + if (asset?.width && asset?.height) { + const nextCanvasSize = { width: asset.width, height: asset.height }; + const shouldSetOriginalCanvasSize = + !activeProject?.settings.originalCanvasSize; + editor.project.updateSettings({ + settings: { + canvasSize: nextCanvasSize, + ...(shouldSetOriginalCanvasSize + ? { originalCanvasSize: nextCanvasSize } + : {}), + }, + pushHistory: false, + }); + } + + if (asset?.type === "video" && asset?.fps) { + editor.project.updateSettings({ + settings: { fps: floatToFrameRate(asset.fps) }, + pushHistory: false, + }); + } + } + + editor.timeline.updateTracks(updatedTracks); + + return { + select: [{ trackId: targetTrackId, elementId: this.elementId }], + }; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getElementId(): string { + return this.elementId; + } + + getTrackId(): string | null { + return this.targetTrackId; + } + + private buildElement({ + element, + }: { + element: CreateTimelineElement; + }): TimelineElement { + return { + ...element, + id: this.elementId, + startTime: element.startTime, + trimStart: element.trimStart ?? 0, + trimEnd: element.trimEnd ?? 0, + duration: element.duration ?? DEFAULT_NEW_ELEMENT_DURATION, + } as TimelineElement; + } + + private validateElementBasics({ + element, + }: { + element: CreateTimelineElement; + }): boolean { + if (requiresMediaId({ element }) && !("mediaId" in element)) { + console.error("Element requires mediaId"); + return false; + } + + if ( + element.type === "audio" && + element.sourceType === "library" && + !element.sourceUrl + ) { + console.error("Library audio element must have sourceUrl"); + return false; + } + + if (element.type === "sticker" && !element.stickerId) { + console.error("Sticker element must have stickerId"); + return false; + } + + if (element.type === "graphic") { + registerDefaultGraphics(); + if (!element.definitionId || !graphicsRegistry.has(element.definitionId)) { + console.error("Graphic element must have a valid definitionId"); + return false; + } + } + + if (element.type === "text" && !element.content) { + console.error("Text element must have content"); + return false; + } + + if (element.type === "effect" && !element.effectType) { + console.error("Effect element must have effectType"); + return false; + } + + return true; + } + + private applyPlacementResult({ + tracks, + element, + }: { + tracks: SceneTracks; + element: TimelineElement; + }): { updatedTracks: SceneTracks; targetTrackId: string } | null { + const placement = this.placement; + + if ( + placement.mode === "auto" && + placement.trackType && + !canElementGoOnTrack({ + elementType: element.type, + trackType: placement.trackType, + }) + ) { + console.error( + `${element.type} elements cannot be placed on ${placement.trackType} tracks`, + ); + return null; + } + + const placementResult = resolveTrackPlacement({ + tracks, + ...(placement.mode === "auto" && placement.trackType + ? { trackType: placement.trackType } + : { elementType: element.type }), + timeSpans: [ + { + startTime: element.startTime, + duration: element.duration, + }, + ], + strategy: + placement.mode === "explicit" + ? { type: "explicit", trackId: placement.trackId } + : { type: "firstAvailable" }, + }); + if (!placementResult) { + if (placement.mode === "explicit") { + const targetTrack = + tracks.main.id === placement.trackId + ? tracks.main + : tracks.overlay.find((track) => track.id === placement.trackId) ?? + tracks.audio.find((track) => track.id === placement.trackId); + if (!targetTrack) { + console.error("Track not found:", placement.trackId); + return null; + } + + const validation = validateElementTrackCompatibility({ + element, + track: targetTrack, + }); + console.error(validation.errorMessage); + } + + return null; + } + + const elementToPlace = + placementResult.kind === "existingTrack" + ? { + ...element, + startTime: + placementResult.adjustedStartTime ?? element.startTime, + } + : element; + + const appliedPlacement = applyPlacement({ + tracks, + placementResult, + elements: [elementToPlace], + newTrackInsertIndexOverride: + placement.mode === "auto" && typeof placement.insertIndex === "number" + ? placement.insertIndex + : undefined, + }); + if (!appliedPlacement) { + return null; + } + + return { + updatedTracks: appliedPlacement.updatedTracks, + targetTrackId: appliedPlacement.targetTrackId, + }; + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts index ea19286c..319a827b 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/index.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/index.ts @@ -1,5 +1,6 @@ -export * from "./remove-effect-param-keyframe"; -export * from "./remove-keyframe"; -export * from "./retime-keyframe"; -export * from "./upsert-effect-param-keyframe"; -export * from "./upsert-keyframe"; +export * from "./remove-effect-param-keyframe"; +export * from "./remove-keyframe"; +export * from "./retime-keyframe"; +export * from "./update-scalar-keyframe-curve"; +export * from "./upsert-effect-param-keyframe"; +export * from "./upsert-keyframe"; diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts index aafa3c22..b1ef4aef 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-effect-param-keyframe.ts @@ -1,68 +1,69 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; -import { updateElementInTracks } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class RemoveEffectParamKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - private readonly paramKey: string; - private readonly keyframeId: string; - - constructor({ - trackId, - elementId, - effectId, - paramKey, - keyframeId, - }: { - trackId: string; - elementId: string; - effectId: string; - paramKey: string; - keyframeId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - this.paramKey = paramKey; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const animations = removeEffectParamKeyframe({ - animations: element.animations, - effectId: this.effectId, - paramKey: this.paramKey, - keyframeId: this.keyframeId, - }); - return { ...element, animations }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { removeEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; +import { updateElementInSceneTracks } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { SceneTracks } from "@/lib/timeline"; + +export class RemoveEffectParamKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly keyframeId: string; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + keyframeId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const animations = removeEffectParamKeyframe({ + animations: element.animations, + effectId: this.effectId, + paramKey: this.paramKey, + keyframeId: this.keyframeId, + }); + return { ...element, animations }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts index 56676aa5..c757055d 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/remove-keyframe.ts @@ -1,138 +1,141 @@ -import { EditorCore } from "@/core"; -import { - getChannel, - getChannelValueAtTime, - removeElementKeyframe, - resolveAnimationTarget, -} from "@/lib/animation"; -import { Command } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; - -function sampleValueBeforeRemoval({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: AnimationPath; - keyframeId: string; -}): AnimationValue | null { - const channel = getChannel({ - animations: element.animations, - propertyPath, - }); - const keyframe = channel?.keyframes.find( - (candidate) => candidate.id === keyframeId, - ); - if (!channel || !keyframe) { - return null; - } - - const target = resolveAnimationTarget({ element, path: propertyPath }); - if (!target) { - return null; - } - const baseValue = target.getBaseValue(); - if (baseValue === null) { - return null; - } - - return getChannelValueAtTime({ - channel, - time: keyframe.time, - fallbackValue: baseValue, - }); -} - -function removeKeyframeAndPersist({ - element, - propertyPath, - keyframeId, -}: { - element: TimelineElement; - propertyPath: AnimationPath; - keyframeId: string; -}): TimelineElement { - const target = resolveAnimationTarget({ element, path: propertyPath }); - if (!target) { - return element; - } - - const valueBefore = sampleValueBeforeRemoval({ - element, - propertyPath, - keyframeId, - }); - - const nextAnimations = removeElementKeyframe({ - animations: element.animations, - propertyPath, - keyframeId, - }); - - const isChannelNowEmpty = - getChannel({ animations: nextAnimations, propertyPath }) === undefined; - const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; - - const baseElement = shouldPersistToBase - ? target.setBaseValue(valueBefore) - : element; - - return { ...baseElement, animations: nextAnimations }; -} - -export class RemoveKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly keyframeId: string; - - constructor({ - trackId, - elementId, - propertyPath, - keyframeId, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => - removeKeyframeAndPersist({ - element, - propertyPath: this.propertyPath, - keyframeId: this.keyframeId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { + hasKeyframesForPath, + getKeyframeById, + removeElementKeyframe, + resolveAnimationPathValueAtTime, + resolveAnimationTarget, +} from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { updateElementInSceneTracks } from "@/lib/timeline"; +import type { AnimationPath, AnimationValue } from "@/lib/animation/types"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; + +function sampleValueBeforeRemoval({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: AnimationPath; + keyframeId: string; +}): AnimationValue | null { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return null; + } + const baseValue = target.getBaseValue(); + if (baseValue === null) { + return null; + } + + const keyframe = getKeyframeById({ + animations: element.animations, + propertyPath, + keyframeId, + }); + if (!keyframe) { + return null; + } + + return resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath, + localTime: keyframe.time, + fallbackValue: baseValue, + }); +} + +function removeKeyframeAndPersist({ + element, + propertyPath, + keyframeId, +}: { + element: TimelineElement; + propertyPath: AnimationPath; + keyframeId: string; +}): TimelineElement { + const target = resolveAnimationTarget({ element, path: propertyPath }); + if (!target) { + return element; + } + + const valueBefore = sampleValueBeforeRemoval({ + element, + propertyPath, + keyframeId, + }); + + const nextAnimations = removeElementKeyframe({ + animations: element.animations, + propertyPath, + keyframeId, + }); + + const isChannelNowEmpty = !hasKeyframesForPath({ + animations: nextAnimations, + propertyPath, + }); + const shouldPersistToBase = isChannelNowEmpty && valueBefore !== null; + + const baseElement = shouldPersistToBase + ? target.setBaseValue({ value: valueBefore }) + : element; + + return { ...baseElement, animations: nextAnimations }; +} + +export class RemoveKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly keyframeId: string; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => + removeKeyframeAndPersist({ + element, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts index 7818ed0d..ad7f5651 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/retime-keyframe.ts @@ -1,74 +1,75 @@ -import { EditorCore } from "@/core"; -import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; -import { Command } from "@/lib/commands/base-command"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { AnimationPath } from "@/lib/animation/types"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class RetimeKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly keyframeId: string; - private readonly nextTime: number; - - constructor({ - trackId, - elementId, - propertyPath, - keyframeId, - nextTime, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - keyframeId: string; - nextTime: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.keyframeId = keyframeId; - this.nextTime = nextTime; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => { - if (!resolveAnimationTarget({ element, path: this.propertyPath })) { - return element; - } - - const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); - return { - ...element, - animations: retimeElementKeyframe({ - animations: element.animations, - propertyPath: this.propertyPath, - keyframeId: this.keyframeId, - time: boundedTime, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { resolveAnimationTarget, retimeElementKeyframe } from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { updateElementInSceneTracks } from "@/lib/timeline"; +import type { AnimationPath } from "@/lib/animation/types"; +import type { SceneTracks } from "@/lib/timeline"; + +export class RetimeKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly keyframeId: string; + private readonly nextTime: number; + + constructor({ + trackId, + elementId, + propertyPath, + keyframeId, + nextTime, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + keyframeId: string; + nextTime: number; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.keyframeId = keyframeId; + this.nextTime = nextTime; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + + const boundedTime = Math.max(0, Math.min(this.nextTime, element.duration)); + return { + ...element, + animations: retimeElementKeyframe({ + animations: element.animations, + propertyPath: this.propertyPath, + keyframeId: this.keyframeId, + time: boundedTime, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts new file mode 100644 index 00000000..9a4c2304 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve.ts @@ -0,0 +1,85 @@ +import { EditorCore } from "@/core"; +import { + resolveAnimationTarget, + updateScalarKeyframeCurve, +} from "@/lib/animation"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { updateElementInSceneTracks } from "@/lib/timeline"; +import type { + AnimationPath, + ScalarCurveKeyframePatch, +} from "@/lib/animation/types"; +import type { SceneTracks } from "@/lib/timeline"; + +export class UpdateScalarKeyframeCurveCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly componentKey: string; + private readonly keyframeId: string; + private readonly patch: ScalarCurveKeyframePatch; + + constructor({ + trackId, + elementId, + propertyPath, + componentKey, + keyframeId, + patch, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + componentKey: string; + keyframeId: string; + patch: ScalarCurveKeyframePatch; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.componentKey = componentKey; + this.keyframeId = keyframeId; + this.patch = patch; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + if (!resolveAnimationTarget({ element, path: this.propertyPath })) { + return element; + } + + return { + ...element, + animations: updateScalarKeyframeCurve({ + animations: element.animations, + propertyPath: this.propertyPath, + componentKey: this.componentKey, + keyframeId: this.keyframeId, + patch: this.patch, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts index 8e541b72..f436721a 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-effect-param-keyframe.ts @@ -1,84 +1,104 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { upsertEffectParamKeyframe } from "@/lib/animation/effect-param-channel"; -import { updateElementInTracks } from "@/lib/timeline"; -import { isVisualElement } from "@/lib/timeline/element-utils"; -import type { TimelineTrack } from "@/lib/timeline"; - -export class UpsertEffectParamKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly effectId: string; - private readonly paramKey: string; - private readonly time: number; - private readonly value: number; - private readonly interpolation: "linear" | "hold" | undefined; - private readonly keyframeId: string | undefined; - - constructor({ - trackId, - elementId, - effectId, - paramKey, - time, - value, - interpolation, - keyframeId, - }: { - trackId: string; - elementId: string; - effectId: string; - paramKey: string; - time: number; - value: number; - interpolation?: "linear" | "hold"; - keyframeId?: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.effectId = effectId; - this.paramKey = paramKey; - this.time = time; - this.value = value; - this.interpolation = interpolation; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isVisualElement, - update: (element) => { - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); - const animations = upsertEffectParamKeyframe({ - animations: element.animations, - effectId: this.effectId, - paramKey: this.paramKey, - time: boundedTime, - value: this.value, - interpolation: this.interpolation, - keyframeId: this.keyframeId, - }); - return { ...element, animations }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { + buildEffectParamPath, + resolveAnimationTarget, + upsertPathKeyframe, +} from "@/lib/animation"; +import { updateElementInSceneTracks } from "@/lib/timeline"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import type { AnimationInterpolation } from "@/lib/animation/types"; +import type { SceneTracks } from "@/lib/timeline"; + +export class UpsertEffectParamKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly effectId: string; + private readonly paramKey: string; + private readonly time: number; + private readonly value: number | string | boolean; + private readonly interpolation: AnimationInterpolation | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + effectId, + paramKey, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + effectId: string; + paramKey: string; + time: number; + value: number | string | boolean; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.effectId = effectId; + this.paramKey = paramKey; + this.time = time; + this.value = value; + this.interpolation = interpolation; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isVisualElement, + update: (element) => { + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + const propertyPath = buildEffectParamPath({ + effectId: this.effectId, + paramKey: this.paramKey, + }); + const target = resolveAnimationTarget({ + element, + path: propertyPath, + }); + if (!target) { + return element; + } + + const animations = upsertPathKeyframe({ + animations: element.animations, + propertyPath, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }); + return { ...element, animations }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts index 4b697705..f77e69b8 100644 --- a/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts +++ b/apps/web/src/lib/commands/timeline/element/keyframes/upsert-keyframe.ts @@ -1,95 +1,96 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; -import { updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack } from "@/lib/timeline"; -import type { - AnimationPath, - AnimationInterpolation, - AnimationValue, -} from "@/lib/animation/types"; - -export class UpsertKeyframeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly propertyPath: AnimationPath; - private readonly time: number; - private readonly value: AnimationValue; - private readonly interpolation: AnimationInterpolation | undefined; - private readonly keyframeId: string | undefined; - - constructor({ - trackId, - elementId, - propertyPath, - time, - value, - interpolation, - keyframeId, - }: { - trackId: string; - elementId: string; - propertyPath: AnimationPath; - time: number; - value: AnimationValue; - interpolation?: AnimationInterpolation; - keyframeId?: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.propertyPath = propertyPath; - this.time = time; - this.value = value; - this.interpolation = interpolation; - this.keyframeId = keyframeId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => { - const target = resolveAnimationTarget({ - element, - path: this.propertyPath, - }); - if (!target) { - return element; - } - - const boundedTime = Math.max(0, Math.min(this.time, element.duration)); - return { - ...element, - animations: upsertPathKeyframe({ - animations: element.animations, - propertyPath: this.propertyPath, - time: boundedTime, - value: this.value, - interpolation: this.interpolation, - keyframeId: this.keyframeId, - valueKind: target.valueKind, - defaultInterpolation: target.defaultInterpolation, - numericRange: target.numericRange, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (!this.savedState) { - return; - } - - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { resolveAnimationTarget, upsertPathKeyframe } from "@/lib/animation"; +import { updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; +import type { + AnimationPath, + AnimationInterpolation, + AnimationValue, +} from "@/lib/animation/types"; + +export class UpsertKeyframeCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly propertyPath: AnimationPath; + private readonly time: number; + private readonly value: AnimationValue; + private readonly interpolation: AnimationInterpolation | undefined; + private readonly keyframeId: string | undefined; + + constructor({ + trackId, + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }: { + trackId: string; + elementId: string; + propertyPath: AnimationPath; + time: number; + value: AnimationValue; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.propertyPath = propertyPath; + this.time = time; + this.value = value; + this.interpolation = interpolation; + this.keyframeId = keyframeId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + update: (element) => { + const target = resolveAnimationTarget({ + element, + path: this.propertyPath, + }); + if (!target) { + return element; + } + + const boundedTime = Math.max(0, Math.min(this.time, element.duration)); + return { + ...element, + animations: upsertPathKeyframe({ + animations: element.animations, + propertyPath: this.propertyPath, + time: boundedTime, + value: this.value, + interpolation: this.interpolation, + keyframeId: this.keyframeId, + kind: target.kind, + defaultInterpolation: target.defaultInterpolation, + coerceValue: target.coerceValue, + }), + }; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (!this.savedState) { + return; + } + + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } +} diff --git a/apps/web/src/lib/commands/timeline/element/masks/index.ts b/apps/web/src/lib/commands/timeline/element/masks/index.ts index 5ceec931..acc47184 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/index.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/index.ts @@ -1,2 +1,2 @@ -export { RemoveMaskCommand } from "./remove-mask"; -export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; +export { RemoveMaskCommand } from "./remove-mask"; +export { ToggleMaskInvertedCommand } from "./toggle-mask-inverted"; diff --git a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts index 71c8b413..9f6c5f77 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/remove-mask.ts @@ -1,64 +1,65 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; -import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; - -function removeMaskFromElement({ - element, - maskId, -}: { - element: MaskableElement; - maskId: string; -}): MaskableElement { - const currentMasks = element.masks ?? []; - const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); - return { ...element, masks: filteredMasks }; -} - -export class RemoveMaskCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly maskId: string; - - constructor({ - trackId, - elementId, - maskId, - }: { - trackId: string; - elementId: string; - maskId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.maskId = maskId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isMaskableElement, - update: (element) => - removeMaskFromElement({ - element: element as MaskableElement, - maskId: this.maskId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { SceneTracks, MaskableElement } from "@/lib/timeline"; + +function removeMaskFromElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const filteredMasks = currentMasks.filter((mask) => mask.id !== maskId); + return { ...element, masks: filteredMasks }; +} + +export class RemoveMaskCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + removeMaskFromElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts index cfa02842..2b5c1694 100644 --- a/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts +++ b/apps/web/src/lib/commands/timeline/element/masks/toggle-mask-inverted.ts @@ -1,75 +1,76 @@ -import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; -import { isMaskableElement, updateElementInTracks } from "@/lib/timeline"; -import type { Mask } from "@/lib/masks/types"; -import type { TimelineTrack, MaskableElement } from "@/lib/timeline"; - -export function toggleMaskInvertedOnElement({ - element, - maskId, -}: { - element: MaskableElement; - maskId: string; -}): MaskableElement { - const currentMasks = element.masks ?? []; - const toggleMask = (mask: TMask): TMask => ({ - ...mask, - params: { - ...mask.params, - inverted: !mask.params.inverted, - }, - }); - const updatedMasks = currentMasks.map((mask) => - mask.id !== maskId ? mask : toggleMask(mask), - ); - - return { ...element, masks: updatedMasks }; -} - -export class ToggleMaskInvertedCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly maskId: string; - - constructor({ - trackId, - elementId, - maskId, - }: { - trackId: string; - elementId: string; - maskId: string; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.maskId = maskId; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isMaskableElement, - update: (element) => - toggleMaskInvertedOnElement({ - element: element as MaskableElement, - maskId: this.maskId, - }), - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { isMaskableElement, updateElementInSceneTracks } from "@/lib/timeline"; +import type { Mask } from "@/lib/masks/types"; +import type { SceneTracks, MaskableElement } from "@/lib/timeline"; + +export function toggleMaskInvertedOnElement({ + element, + maskId, +}: { + element: MaskableElement; + maskId: string; +}): MaskableElement { + const currentMasks = element.masks ?? []; + const toggleMask = (mask: TMask): TMask => ({ + ...mask, + params: { + ...mask.params, + inverted: !mask.params.inverted, + }, + }); + const updatedMasks = currentMasks.map((mask) => + mask.id !== maskId ? mask : toggleMask(mask), + ); + + return { ...element, masks: updatedMasks }; +} + +export class ToggleMaskInvertedCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly trackId: string; + private readonly elementId: string; + private readonly maskId: string; + + constructor({ + trackId, + elementId, + maskId, + }: { + trackId: string; + elementId: string; + maskId: string; + }) { + super(); + this.trackId = trackId; + this.elementId = elementId; + this.maskId = maskId; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const updatedTracks = updateElementInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + elementId: this.elementId, + elementPredicate: isMaskableElement, + update: (element) => + toggleMaskInvertedOnElement({ + element: element as MaskableElement, + maskId: this.maskId, + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/element/move-elements.ts b/apps/web/src/lib/commands/timeline/element/move-elements.ts index 6d400964..c1fa6897 100644 --- a/apps/web/src/lib/commands/timeline/element/move-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/move-elements.ts @@ -1,145 +1,186 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { - TimelineTrack, - TimelineElement, - TrackType, -} from "@/lib/timeline"; -import { - buildEmptyTrack, - validateElementTrackCompatibility, - enforceMainTrackStart, -} from "@/lib/timeline/placement"; -import { rippleShiftElements } from "@/lib/timeline/ripple-utils"; - -export class MoveElementCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly sourceTrackId: string; - private readonly targetTrackId: string; - private readonly elementId: string; - private readonly newStartTime: number; - private readonly createTrack: { type: TrackType; index: number } | undefined; - private readonly rippleEnabled: boolean; - - constructor({ - sourceTrackId, - targetTrackId, - elementId, - newStartTime, - createTrack, - rippleEnabled = false, - }: { - sourceTrackId: string; - targetTrackId: string; - elementId: string; - newStartTime: number; - createTrack?: { type: TrackType; index: number }; - rippleEnabled?: boolean; - }) { - super(); - this.sourceTrackId = sourceTrackId; - this.targetTrackId = targetTrackId; - this.elementId = elementId; - this.newStartTime = newStartTime; - this.createTrack = createTrack; - this.rippleEnabled = rippleEnabled; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const sourceTrack = this.savedState.find( - (track) => track.id === this.sourceTrackId, - ); - const element = sourceTrack?.elements.find( - (trackElement) => trackElement.id === this.elementId, - ); - - if (!sourceTrack || !element) { - throw new Error("Source track or element not found"); - } - - let targetTrack = this.savedState.find((track) => track.id === this.targetTrackId); - let tracksToUpdate = this.savedState; - if (!targetTrack && this.createTrack) { - const newTrack = buildEmptyTrack({ - id: this.targetTrackId, - type: this.createTrack.type, - }); - tracksToUpdate = [...this.savedState]; - tracksToUpdate.splice(this.createTrack.index, 0, newTrack); - targetTrack = newTrack; - } - if (!targetTrack) { - throw new Error("Target track not found"); - } - - const validation = validateElementTrackCompatibility({ - element, - track: targetTrack, - }); - - if (!validation.isValid) { - throw new Error(validation.errorMessage); - } - - const adjustedStartTime = enforceMainTrackStart({ - tracks: tracksToUpdate, - targetTrackId: this.targetTrackId, - requestedStartTime: this.newStartTime, - excludeElementId: this.elementId, - }); - - // keyframe times remain clip-local, so moving only changes element startTime. - const movedElement: TimelineElement = { - ...element, - startTime: adjustedStartTime, - }; - - const isSameTrack = this.sourceTrackId === this.targetTrackId; - - const updatedTracks = tracksToUpdate.map((track): TimelineTrack => { - if (isSameTrack && track.id === this.sourceTrackId) { - return { - ...track, - elements: track.elements.map((trackElement) => - trackElement.id === this.elementId ? movedElement : trackElement, - ), - } as typeof track; - } - - if (track.id === this.sourceTrackId) { - const remainingElements = track.elements.filter( - (trackElement) => trackElement.id !== this.elementId, - ); - const shiftedElements = this.rippleEnabled - ? rippleShiftElements({ - elements: remainingElements, - afterTime: element.startTime, - shiftAmount: element.duration, - }) - : remainingElements; - return { ...track, elements: shiftedElements } as typeof track; - } - - if (track.id === this.targetTrackId) { - return { - ...track, - elements: [...track.elements, movedElement], - } as typeof track; - } - - return track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { + SceneTracks, + TimelineElement, + TrackType, + TimelineTrack, +} from "@/lib/timeline"; +import { + buildEmptyTrack, + validateElementTrackCompatibility, + enforceMainTrackStart, +} from "@/lib/timeline/placement"; +import { + findTrackInSceneTracks, + updateTrackInSceneTracks, +} from "@/lib/timeline/track-element-update"; + +export class MoveElementCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly sourceTrackId: string; + private readonly targetTrackId: string; + private readonly elementId: string; + private readonly newStartTime: number; + private readonly createTrack: { type: TrackType; index: number } | undefined; + + constructor({ + sourceTrackId, + targetTrackId, + elementId, + newStartTime, + createTrack, + }: { + sourceTrackId: string; + targetTrackId: string; + elementId: string; + newStartTime: number; + createTrack?: { type: TrackType; index: number }; + }) { + super(); + this.sourceTrackId = sourceTrackId; + this.targetTrackId = targetTrackId; + this.elementId = elementId; + this.newStartTime = newStartTime; + this.createTrack = createTrack; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const sourceTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.sourceTrackId, + }); + const element = sourceTrack?.elements.find( + (trackElement) => trackElement.id === this.elementId, + ); + + if (!sourceTrack || !element) { + throw new Error("Source track or element not found"); + } + + let targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.targetTrackId, + }); + let tracksToUpdate = this.savedState; + if (!targetTrack && this.createTrack) { + const newTrack = buildEmptyTrack({ + id: this.targetTrackId, + type: this.createTrack.type, + }); + tracksToUpdate = insertTrackAtDisplayIndex({ + tracks: this.savedState, + track: newTrack, + insertIndex: this.createTrack.index, + }); + targetTrack = newTrack; + } + if (!targetTrack) { + throw new Error("Target track not found"); + } + + const validation = validateElementTrackCompatibility({ + element, + track: targetTrack, + }); + + if (!validation.isValid) { + throw new Error(validation.errorMessage); + } + + const adjustedStartTime = enforceMainTrackStart({ + tracks: tracksToUpdate, + targetTrackId: this.targetTrackId, + requestedStartTime: this.newStartTime, + excludeElementId: this.elementId, + }); + + // keyframe times remain clip-local, so moving only changes element startTime. + const movedElement: TimelineElement = { + ...element, + startTime: adjustedStartTime, + }; + + const isSameTrack = this.sourceTrackId === this.targetTrackId; + + const updatedTracks = isSameTrack + ? updateTrackInSceneTracks({ + tracks: tracksToUpdate, + trackId: this.sourceTrackId, + update: (track) => ({ + ...track, + elements: track.elements.map((trackElement) => + trackElement.id === this.elementId ? movedElement : trackElement, + ), + }), + }) + : updateTrackInSceneTracks({ + tracks: updateTrackInSceneTracks({ + tracks: tracksToUpdate, + trackId: this.sourceTrackId, + update: (track) => ({ + ...track, + elements: track.elements.filter( + (trackElement) => trackElement.id !== this.elementId, + ), + }), + }), + trackId: this.targetTrackId, + update: (track) => ({ + ...track, + elements: [...track.elements, movedElement], + }), + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} + +function insertTrackAtDisplayIndex({ + tracks, + track, + insertIndex, +}: { + tracks: SceneTracks; + track: TimelineTrack; + insertIndex: number; +}): SceneTracks { + if (track.type === "audio") { + const audioInsertIndex = Math.max( + 0, + Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), + ); + return { + ...tracks, + audio: [ + ...tracks.audio.slice(0, audioInsertIndex), + track, + ...tracks.audio.slice(audioInsertIndex), + ], + }; + } + + const overlayInsertIndex = Math.max( + 0, + Math.min(insertIndex, tracks.overlay.length), + ); + return { + ...tracks, + overlay: [ + ...tracks.overlay.slice(0, overlayInsertIndex), + track, + ...tracks.overlay.slice(overlayInsertIndex), + ], + }; +} diff --git a/apps/web/src/lib/commands/timeline/element/retime/index.ts b/apps/web/src/lib/commands/timeline/element/retime/index.ts deleted file mode 100644 index e9c24c44..00000000 --- a/apps/web/src/lib/commands/timeline/element/retime/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { UpdateElementRetimeCommand } from "./update-element-retime"; diff --git a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts b/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts deleted file mode 100644 index f0574ce7..00000000 --- a/apps/web/src/lib/commands/timeline/element/retime/update-element-retime.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { EditorCore } from "@/core"; -import { clampRetimeRate } from "@/lib/retime/rate"; -import { clampAnimationsToDuration } from "@/lib/animation"; -import { Command } from "@/lib/commands/base-command"; -import { getTimelineDurationForSourceSpan, getSourceSpanAtClipTime } from "@/lib/retime"; -import { isRetimableElement, updateElementInTracks } from "@/lib/timeline"; -import type { RetimeConfig, TimelineTrack } from "@/lib/timeline"; - -function getSourceDuration({ - trimStart, - trimEnd, - duration, - sourceDuration, - retime, -}: { - trimStart: number; - trimEnd: number; - duration: number; - sourceDuration?: number; - retime?: RetimeConfig; -}): number { - if (typeof sourceDuration === "number") { - return sourceDuration; - } - - return ( - trimStart + - getSourceSpanAtClipTime({ - clipTime: duration, - retime, - }) + - trimEnd - ); -} - -export class UpdateElementRetimeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly retime: RetimeConfig | undefined; - - constructor({ - trackId, - elementId, - retime, - }: { - trackId: string; - elementId: string; - retime?: RetimeConfig; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.retime = retime; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - elementPredicate: isRetimableElement, - update: (element) => { - if (!isRetimableElement(element)) { - return element; - } - - const nextRetime = this.retime - ? { - ...this.retime, - rate: clampRetimeRate({ rate: this.retime.rate }), - } - : undefined; - const sourceDuration = getSourceDuration({ - trimStart: element.trimStart, - trimEnd: element.trimEnd, - duration: element.duration, - sourceDuration: element.sourceDuration, - retime: element.retime, - }); - const visibleSourceSpan = Math.max( - 0, - sourceDuration - element.trimStart - element.trimEnd, - ); - const nextDuration = getTimelineDurationForSourceSpan({ - sourceSpan: visibleSourceSpan, - retime: nextRetime, - }); - - return { - ...element, - retime: nextRetime, - duration: nextDuration, - animations: clampAnimationsToDuration({ - animations: element.animations, - duration: nextDuration, - }), - }; - }, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts index e9742906..9799f7e8 100644 --- a/apps/web/src/lib/commands/timeline/element/split-elements.ts +++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts @@ -1,35 +1,31 @@ import { Command, type CommandResult } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { EditorCore } from "@/core"; -import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; +import { isRetimableElement } from "@/lib/timeline"; import { splitAnimationsAtTime } from "@/lib/animation"; import { getSourceSpanAtClipTime } from "@/lib/retime"; export class SplitElementsCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; private rightSideElements: { trackId: string; elementId: string }[] = []; private readonly elements: { trackId: string; elementId: string }[]; private readonly splitTime: number; private readonly retainSide: "both" | "left" | "right"; - private readonly rippleEnabled: boolean; constructor({ elements, splitTime, retainSide = "both", - rippleEnabled = false, }: { elements: { trackId: string; elementId: string }[]; splitTime: number; retainSide?: "both" | "left" | "right"; - rippleEnabled?: boolean; }) { super(); this.elements = elements; this.splitTime = splitTime; this.retainSide = retainSide; - this.rippleEnabled = rippleEnabled; } getRightSideElements(): { trackId: string; elementId: string }[] { @@ -38,10 +34,12 @@ export class SplitElementsCommand extends Command { execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; this.rightSideElements = []; - const updatedTracks = this.savedState.map((track) => { + const splitTrack = ( + track: TTrack, + ): TTrack => { const elementsToSplit = this.elements.filter( (target) => target.trackId === track.id, ); @@ -50,8 +48,6 @@ export class SplitElementsCommand extends Command { return track; } - let leftVisibleDurationForRipple: number | null = null; - let elements = track.elements.flatMap((element) => { const shouldSplit = elementsToSplit.some( (target) => target.elementId === element.id, @@ -106,9 +102,6 @@ export class SplitElementsCommand extends Command { } if (this.retainSide === "right") { - if (this.rippleEnabled && elementsToSplit.length === 1) { - leftVisibleDurationForRipple = leftVisibleDuration; - } const newId = generateUUID(); this.rightSideElements.push({ trackId: track.id, @@ -157,16 +150,14 @@ export class SplitElementsCommand extends Command { ]; }); - if (this.rippleEnabled && leftVisibleDurationForRipple !== null) { - elements = rippleShiftElements({ - elements, - afterTime: this.splitTime, - shiftAmount: leftVisibleDurationForRipple, - }); - } + return { ...track, elements } as TTrack; + }; - return { ...track, elements } as typeof track; - }); + const updatedTracks: SceneTracks = { + overlay: this.savedState.overlay.map((track) => splitTrack(track)), + main: splitTrack(this.savedState.main), + audio: this.savedState.audio.map((track) => splitTrack(track)), + }; editor.timeline.updateTracks(updatedTracks); @@ -175,6 +166,7 @@ export class SplitElementsCommand extends Command { select: this.rightSideElements, }; } + return undefined; } undo(): void { diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts deleted file mode 100644 index 30310806..00000000 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-muted.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { canElementHaveAudio } from "@/lib/timeline/element-utils"; -import { EditorCore } from "@/core"; - -export class ToggleElementsMutedCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private elements: { trackId: string; elementId: string }[]) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const mutableElements = this.elements.filter(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementHaveAudio(element); - }); - - if (mutableElements.length === 0) { - return; - } - - const shouldMute = mutableElements.some(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementHaveAudio(element) && !element.muted; - }); - - const updatedTracks = this.savedState.map((track) => { - const newElements = track.elements.map((element) => { - const shouldUpdate = mutableElements.some( - ({ trackId, elementId }) => - track.id === trackId && element.id === elementId, - ); - return shouldUpdate && - canElementHaveAudio(element) && - element.muted !== shouldMute - ? { ...element, muted: shouldMute } - : element; - }); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts b/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts deleted file mode 100644 index 690f61aa..00000000 --- a/apps/web/src/lib/commands/timeline/element/toggle-elements-visibility.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { canElementBeHidden } from "@/lib/timeline/element-utils"; -import { EditorCore } from "@/core"; - -export class ToggleElementsVisibilityCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private elements: { trackId: string; elementId: string }[]) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const shouldHide = this.elements.some(({ trackId, elementId }) => { - const track = this.savedState?.find((t) => t.id === trackId); - const element = track?.elements.find((e) => e.id === elementId); - return element && canElementBeHidden(element) && !element.hidden; - }); - - const updatedTracks = this.savedState.map((track) => { - const newElements = track.elements.map((element) => { - const shouldUpdate = this.elements.some( - ({ trackId, elementId }) => - track.id === trackId && element.id === elementId, - ); - return shouldUpdate && - canElementBeHidden(element) && - element.hidden !== shouldHide - ? { ...element, hidden: shouldHide } - : element; - }); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts index d0907c5b..36b3b7a7 100644 --- a/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts +++ b/apps/web/src/lib/commands/timeline/element/toggle-source-audio-separation.ts @@ -1,20 +1,22 @@ import { EditorCore } from "@/core"; -import { Command } from "@/lib/commands/base-command"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; import { buildSeparatedAudioElement, canExtractSourceAudio, - canRecoverSourceAudio, + isSourceAudioSeparated, } from "@/lib/timeline/audio-separation"; -import { - applyPlacement, - resolveTrackPlacement, -} from "@/lib/timeline/placement"; -import { updateElementInTracks } from "@/lib/timeline/track-element-update"; -import type { TimelineTrack, VideoElement } from "@/lib/timeline/types"; +import { buildEmptyTrack } from "@/lib/timeline/placement"; +import { updateElementInSceneTracks } from "@/lib/timeline/track-element-update"; +import type { + AudioTrack, + SceneTracks, + TimelineElement, + VideoElement, +} from "@/lib/timeline/types"; import { generateUUID } from "@/utils/id"; export class ToggleSourceAudioSeparationCommand extends Command { - private savedState: TimelineTrack[] | null = null; + private savedState: SceneTracks | null = null; constructor( private readonly params: { @@ -25,26 +27,29 @@ export class ToggleSourceAudioSeparationCommand extends Command { super(); } - execute(): void { + execute(): CommandResult | undefined { const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); + this.savedState = editor.scenes.getActiveScene().tracks; - const trackIndex = this.savedState.findIndex( + const sourceTrack = [ + ...this.savedState.overlay, + this.savedState.main, + ...this.savedState.audio, + ].find( (track) => track.id === this.params.trackId, ); - if (trackIndex < 0) { + if (!sourceTrack) { return; } - - const sourceTrack = this.savedState[trackIndex]; const sourceElement = sourceTrack.elements.find( (element) => element.id === this.params.elementId, - ); - if (!sourceElement) { + ) as TimelineElement | undefined; + if (!sourceElement || sourceElement.type !== "video") { return; } + const videoElement: VideoElement = sourceElement; - if (canRecoverSourceAudio({ element: sourceElement })) { + if (isSourceAudioSeparated({ element: videoElement })) { editor.timeline.updateTracks( updateSourceAudioEnabled({ tracks: this.savedState, @@ -59,49 +64,34 @@ export class ToggleSourceAudioSeparationCommand extends Command { const mediaAsset = editor .media .getAssets() - .find((asset) => - sourceElement.type === "video" ? asset.id === sourceElement.mediaId : false, - ); - if (!canExtractSourceAudio({ element: sourceElement, mediaAsset })) { + .find((asset) => asset.id === videoElement.mediaId); + if (!canExtractSourceAudio(videoElement, mediaAsset)) { return; } - if (sourceElement.duration <= 0) { + if (videoElement.duration <= 0) { return; } const separatedAudioElement = { ...buildSeparatedAudioElement({ - sourceElement, + sourceElement: videoElement, }), id: generateUUID(), }; - const placementResult = resolveTrackPlacement({ - tracks: this.savedState, - trackType: "audio", - timeSpans: [ - { - startTime: separatedAudioElement.startTime, - duration: separatedAudioElement.duration, - }, - ], - strategy: { type: "aboveSource", sourceTrackIndex: trackIndex }, - }); - if (!placementResult) { - return; - } - - const appliedPlacement = applyPlacement({ - tracks: this.savedState, - placementResult, + const newAudioTrack = { + ...buildEmptyTrack({ + id: generateUUID(), + type: "audio", + }), elements: [separatedAudioElement], - }); - if (!appliedPlacement) { - return; - } + } as AudioTrack; editor.timeline.updateTracks( updateSourceAudioEnabled({ - tracks: appliedPlacement.updatedTracks, + tracks: { + ...this.savedState, + audio: [...this.savedState.audio, newAudioTrack], + }, trackId: this.params.trackId, elementId: this.params.elementId, isSourceAudioEnabled: false, @@ -125,12 +115,12 @@ function updateSourceAudioEnabled({ elementId, isSourceAudioEnabled, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackId: string; elementId: string; isSourceAudioEnabled: boolean; -}): TimelineTrack[] { - return updateElementInTracks({ +}): SceneTracks { + return updateElementInSceneTracks({ tracks, trackId, elementId, diff --git a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts b/apps/web/src/lib/commands/timeline/element/update-element-duration.ts deleted file mode 100644 index 4280dd4d..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-duration.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { clampAnimationsToDuration } from "@/lib/animation"; - -export class UpdateElementDurationCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly duration: number; - - constructor({ - trackId, - elementId, - duration, - }: { - trackId: string; - elementId: string; - duration: number; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.duration = duration; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = this.savedState.map((track) => { - if (track.id !== this.trackId) return track; - const newElements = track.elements.map((element) => - element.id === this.elementId - ? { - ...element, - duration: this.duration, - animations: clampAnimationsToDuration({ - animations: element.animations, - duration: this.duration, - }), - } - : element, - ); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts b/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts deleted file mode 100644 index ff81cff2..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-start-time.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { enforceMainTrackStart } from "@/lib/timeline/placement"; - -export class UpdateElementStartTimeCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly elements: { trackId: string; elementId: string }[]; - private readonly startTime: number; - - constructor({ - elements, - startTime, - }: { - elements: { trackId: string; elementId: string }[]; - startTime: number; - }) { - super(); - this.elements = elements; - this.startTime = startTime; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const currentTracks = this.savedState; - const updatedTracks = currentTracks.map((track) => { - const hasElementsToUpdate = this.elements.some( - (elementEntry) => elementEntry.trackId === track.id, - ); - - if (!hasElementsToUpdate) { - return track; - } - - const newElements = track.elements.map((element) => { - const shouldUpdate = this.elements.some( - (elementEntry) => - elementEntry.elementId === element.id && - elementEntry.trackId === track.id, - ); - if (!shouldUpdate) { - return element; - } - - const baseStartTime = Math.max(0, this.startTime); - const adjustedStartTime = enforceMainTrackStart({ - tracks: currentTracks, - targetTrackId: track.id, - requestedStartTime: baseStartTime, - excludeElementId: element.id, - }); - - return { ...element, startTime: adjustedStartTime }; - }); - return { ...track, elements: newElements } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts b/apps/web/src/lib/commands/timeline/element/update-element-trim.ts deleted file mode 100644 index 13ee66dd..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element-trim.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { clampAnimationsToDuration } from "@/lib/animation"; -import { isRetimableElement, rippleShiftElements } from "@/lib/timeline"; -import { enforceMainTrackStart } from "@/lib/timeline/placement"; - -export class UpdateElementTrimCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly elementId: string; - private readonly trimStart: number; - private readonly trimEnd: number; - private readonly startTime: number | undefined; - private readonly duration: number | undefined; - private readonly rippleEnabled: boolean; - - constructor({ - elementId, - trimStart, - trimEnd, - startTime, - duration, - rippleEnabled = false, - }: { - elementId: string; - trimStart: number; - trimEnd: number; - startTime?: number; - duration?: number; - rippleEnabled?: boolean; - }) { - super(); - this.elementId = elementId; - this.trimStart = trimStart; - this.trimEnd = trimEnd; - this.startTime = startTime; - this.duration = duration; - this.rippleEnabled = rippleEnabled; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = this.savedState.map((track) => { - const targetElement = track.elements.find( - (element) => element.id === this.elementId, - ); - if (!targetElement) return track; - - const nextDuration = this.duration ?? targetElement.duration; - const requestedStartTime = this.startTime ?? targetElement.startTime; - const nextStartTime = enforceMainTrackStart({ - tracks: this.savedState ?? [], - targetTrackId: track.id, - requestedStartTime, - excludeElementId: this.elementId, - }); - - const oldEndTime = targetElement.startTime + targetElement.duration; - const newEndTime = nextStartTime + nextDuration; - const shiftAmount = oldEndTime - newEndTime; - - const updatedElement = { - ...targetElement, - trimStart: this.trimStart, - trimEnd: this.trimEnd, - startTime: nextStartTime, - duration: nextDuration, - ...(isRetimableElement(targetElement) - ? { retime: targetElement.retime } - : {}), - animations: clampAnimationsToDuration({ - animations: targetElement.animations, - duration: nextDuration, - }), - }; - - if (this.rippleEnabled && Math.abs(shiftAmount) > 0) { - const shiftedOthers = rippleShiftElements({ - elements: track.elements.filter( - (element) => element.id !== this.elementId, - ), - afterTime: oldEndTime, - shiftAmount, - }); - return { - ...track, - elements: track.elements.map((element) => - element.id === this.elementId - ? updatedElement - : (shiftedOthers.find((shifted) => shifted.id === element.id) ?? - element), - ), - } as typeof track; - } - - return { - ...track, - elements: track.elements.map((element) => - element.id === this.elementId ? updatedElement : element, - ), - } as typeof track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/update-element.ts b/apps/web/src/lib/commands/timeline/element/update-element.ts deleted file mode 100644 index 494fa1f6..00000000 --- a/apps/web/src/lib/commands/timeline/element/update-element.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { updateElementInTracks } from "@/lib/timeline"; - -export class UpdateElementCommand extends Command { - private savedState: TimelineTrack[] | null = null; - private readonly trackId: string; - private readonly elementId: string; - private readonly updates: Partial; - - constructor({ - trackId, - elementId, - updates, - }: { - trackId: string; - elementId: string; - updates: Partial; - }) { - super(); - this.trackId = trackId; - this.elementId = elementId; - this.updates = updates; - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const updatedTracks = updateElementInTracks({ - tracks: this.savedState, - trackId: this.trackId, - elementId: this.elementId, - update: (element) => ({ ...element, ...this.updates }) as TimelineElement, - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} diff --git a/apps/web/src/lib/commands/timeline/element/update-elements.ts b/apps/web/src/lib/commands/timeline/element/update-elements.ts new file mode 100644 index 00000000..54d1ca88 --- /dev/null +++ b/apps/web/src/lib/commands/timeline/element/update-elements.ts @@ -0,0 +1,75 @@ +import { EditorCore } from "@/core"; +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; +import { + findTrackInSceneTracks, + updateElementInSceneTracks, +} from "@/lib/timeline"; +import { applyElementUpdate } from "@/lib/timeline/update-pipeline"; + +export class UpdateElementsCommand extends Command { + private savedState: SceneTracks | null = null; + private readonly updates: Array<{ + trackId: string; + elementId: string; + patch: Partial; + }>; + + constructor({ + updates, + }: { + updates: Array<{ + trackId: string; + elementId: string; + patch: Partial; + }>; + }) { + super(); + this.updates = updates; + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + let updatedTracks = this.savedState; + + for (const updateEntry of this.updates) { + const currentTrack = findTrackInSceneTracks({ + tracks: updatedTracks, + trackId: updateEntry.trackId, + }); + const currentElement = currentTrack?.elements.find( + (element) => element.id === updateEntry.elementId, + ); + if (!currentTrack || !currentElement) { + continue; + } + + const nextElement = applyElementUpdate({ + element: currentElement, + patch: updateEntry.patch, + context: { + tracks: updatedTracks, + trackId: updateEntry.trackId, + }, + }); + + updatedTracks = updateElementInSceneTracks({ + tracks: updatedTracks, + trackId: updateEntry.trackId, + elementId: updateEntry.elementId, + update: () => nextElement, + }); + } + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts index d0bfd5e9..cb285d94 100644 --- a/apps/web/src/lib/commands/timeline/index.ts +++ b/apps/web/src/lib/commands/timeline/index.ts @@ -1,5 +1,5 @@ -export * from "./track"; -export * from "./element"; -export * from "./clipboard"; - -export { TracksSnapshotCommand } from "./tracks-snapshot"; +export * from "./track"; +export * from "./element"; +export * from "./clipboard"; + +export { TracksSnapshotCommand } from "./tracks-snapshot"; diff --git a/apps/web/src/lib/commands/timeline/track/add-track.ts b/apps/web/src/lib/commands/timeline/track/add-track.ts index 104c0462..66b9c348 100644 --- a/apps/web/src/lib/commands/timeline/track/add-track.ts +++ b/apps/web/src/lib/commands/timeline/track/add-track.ts @@ -1,53 +1,118 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TrackType, TimelineTrack } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; -import { EditorCore } from "@/core"; -import { - buildEmptyTrack, - getDefaultInsertIndexForTrack, -} from "@/lib/timeline/placement"; - -export class AddTrackCommand extends Command { - private trackId: string; - private savedState: TimelineTrack[] | null = null; - - constructor( - private type: TrackType, - private index?: number, - ) { - super(); - this.trackId = generateUUID(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const newTrack: TimelineTrack = buildEmptyTrack({ - id: this.trackId, - type: this.type, - }); - - const updatedTracks = [...(this.savedState || [])]; - const insertIndex = - this.index ?? - getDefaultInsertIndexForTrack({ - tracks: updatedTracks, - trackType: this.type, - }); - updatedTracks.splice(insertIndex, 0, newTrack); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } - - getTrackId(): string { - return this.trackId; - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { SceneTracks, TrackType } from "@/lib/timeline"; +import { generateUUID } from "@/utils/id"; +import { EditorCore } from "@/core"; +import { + buildEmptyTrack, + getDefaultInsertIndexForTrack, +} from "@/lib/timeline/placement"; + +export class AddTrackCommand extends Command { + private trackId: string; + private savedState: SceneTracks | null = null; + + constructor( + private type: TrackType, + private index?: number, + ) { + super(); + this.trackId = generateUUID(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const insertIndex = + this.index ?? + getDefaultInsertIndexForTrack({ + tracks: this.savedState, + trackType: this.type, + }); + + const updatedTracks = + this.type === "audio" + ? buildAudioTrackState({ + tracks: this.savedState, + insertIndex, + trackId: this.trackId, + }) + : buildOverlayTrackState({ + tracks: this.savedState, + insertIndex, + trackId: this.trackId, + trackType: this.type, + }); + + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } + + getTrackId(): string { + return this.trackId; + } +} + +function buildAudioTrackState({ + tracks, + insertIndex, + trackId, +}: { + tracks: SceneTracks; + insertIndex: number; + trackId: string; +}): SceneTracks { + const audioInsertIndex = Math.max( + 0, + insertIndex - tracks.overlay.length - 1, + ); + const newTrack = buildEmptyTrack({ + id: trackId, + type: "audio", + }); + return { + ...tracks, + audio: [ + ...tracks.audio.slice(0, audioInsertIndex), + newTrack, + ...tracks.audio.slice(audioInsertIndex), + ], + }; +} + +function buildOverlayTrackState({ + tracks, + insertIndex, + trackId, + trackType, +}: { + tracks: SceneTracks; + insertIndex: number; + trackId: string; + trackType: Exclude; +}): SceneTracks { + const overlayInsertIndex = Math.min(insertIndex, tracks.overlay.length); + const newTrack = + trackType === "video" + ? buildEmptyTrack({ id: trackId, type: "video" }) + : trackType === "text" + ? buildEmptyTrack({ id: trackId, type: "text" }) + : trackType === "graphic" + ? buildEmptyTrack({ id: trackId, type: "graphic" }) + : buildEmptyTrack({ id: trackId, type: "effect" }); + return { + ...tracks, + overlay: [ + ...tracks.overlay.slice(0, overlayInsertIndex), + newTrack, + ...tracks.overlay.slice(overlayInsertIndex), + ], + }; +} diff --git a/apps/web/src/lib/commands/timeline/track/remove-track.ts b/apps/web/src/lib/commands/timeline/track/remove-track.ts index f35edbe4..5fcaa898 100644 --- a/apps/web/src/lib/commands/timeline/track/remove-track.ts +++ b/apps/web/src/lib/commands/timeline/track/remove-track.ts @@ -1,35 +1,30 @@ -import { Command } from "@/lib/commands/base-command"; -import { EditorCore } from "@/core"; -import type { TimelineTrack } from "@/lib/timeline"; -import { getMainTrack } from "@/lib/timeline/placement"; - -export class RemoveTrackCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - const mainTrack = getMainTrack({ tracks: this.savedState }); - if (mainTrack?.id === targetTrack?.id) { - return; - } - const updatedTracks = this.savedState.filter( - (track) => track.id !== this.trackId, - ); - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import { EditorCore } from "@/core"; +import type { SceneTracks } from "@/lib/timeline"; + +export class RemoveTrackCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + const updatedTracks: SceneTracks = { + ...this.savedState, + overlay: this.savedState.overlay.filter((track) => track.id !== this.trackId), + audio: this.savedState.audio.filter((track) => track.id !== this.trackId), + }; + editor.timeline.updateTracks(updatedTracks); + return undefined; + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts index 0094f1de..7ffe2fd7 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-mute.ts @@ -1,39 +1,41 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { canTrackHaveAudio } from "@/lib/timeline"; - -export class ToggleTrackMuteCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - if (!targetTrack) { - return; - } - - const updatedTracks = this.savedState.map((track) => - track.id === this.trackId && canTrackHaveAudio(track) - ? { ...track, muted: !track.muted } - : track, - ); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { SceneTracks } from "@/lib/timeline"; +import { EditorCore } from "@/core"; +import { canTrackHaveAudio, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline"; + +export class ToggleTrackMuteCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + }); + if (!targetTrack) { + return; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + update: (track) => + canTrackHaveAudio(track) ? { ...track, muted: !track.muted } : track, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts index 6d74e238..2292c83a 100644 --- a/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts +++ b/apps/web/src/lib/commands/timeline/track/toggle-track-visibility.ts @@ -1,40 +1,45 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; -import { canTrackBeHidden } from "@/lib/timeline"; - -export class ToggleTrackVisibilityCommand extends Command { - private savedState: TimelineTrack[] | null = null; - - constructor(private trackId: string) { - super(); - } - - execute(): void { - const editor = EditorCore.getInstance(); - this.savedState = editor.timeline.getTracks(); - - const targetTrack = this.savedState.find( - (track) => track.id === this.trackId, - ); - if (!targetTrack) { - return; - } - - const updatedTracks = this.savedState.map((track) => { - if (track.id === this.trackId && canTrackBeHidden(track)) { - return { ...track, hidden: !track.hidden }; - } - return track; - }); - - editor.timeline.updateTracks(updatedTracks); - } - - undo(): void { - if (this.savedState) { - const editor = EditorCore.getInstance(); - editor.timeline.updateTracks(this.savedState); - } - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { SceneTracks } from "@/lib/timeline"; +import { EditorCore } from "@/core"; +import { canTrackBeHidden, findTrackInSceneTracks, updateTrackInSceneTracks } from "@/lib/timeline"; + +export class ToggleTrackVisibilityCommand extends Command { + private savedState: SceneTracks | null = null; + + constructor(private trackId: string) { + super(); + } + + execute(): CommandResult | undefined { + const editor = EditorCore.getInstance(); + this.savedState = editor.scenes.getActiveScene().tracks; + + const targetTrack = findTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + }); + if (!targetTrack) { + return; + } + + const updatedTracks = updateTrackInSceneTracks({ + tracks: this.savedState, + trackId: this.trackId, + update: (track) => { + if (canTrackBeHidden(track)) { + return { ...track, hidden: !track.hidden }; + } + return track; + }, + }); + + editor.timeline.updateTracks(updatedTracks); + } + + undo(): void { + if (this.savedState) { + const editor = EditorCore.getInstance(); + editor.timeline.updateTracks(this.savedState); + } + } +} diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts index d397431c..daed6a9e 100644 --- a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts +++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts @@ -1,20 +1,21 @@ -import { Command } from "@/lib/commands/base-command"; -import type { TimelineTrack } from "@/lib/timeline"; -import { EditorCore } from "@/core"; - -export class TracksSnapshotCommand extends Command { - constructor( - private before: TimelineTrack[], - private after: TimelineTrack[], - ) { - super(); - } - - execute(): void { - EditorCore.getInstance().timeline.updateTracks(this.after); - } - - undo(): void { - EditorCore.getInstance().timeline.updateTracks(this.before); - } -} +import { Command, type CommandResult } from "@/lib/commands/base-command"; +import type { SceneTracks } from "@/lib/timeline"; +import { EditorCore } from "@/core"; + +export class TracksSnapshotCommand extends Command { + constructor( + private before: SceneTracks, + private after: SceneTracks, + ) { + super(); + } + + execute(): CommandResult | undefined { + EditorCore.getInstance().timeline.updateTracks(this.after); + return undefined; + } + + undo(): void { + EditorCore.getInstance().timeline.updateTracks(this.before); + } +} diff --git a/apps/web/src/lib/env/web.ts b/apps/web/src/lib/env/web.ts index 591b36ad..aa051615 100644 --- a/apps/web/src/lib/env/web.ts +++ b/apps/web/src/lib/env/web.ts @@ -23,11 +23,6 @@ const webEnvSchema = z.object({ MARBLE_WORKSPACE_KEY: z.string(), FREESOUND_CLIENT_ID: z.string(), FREESOUND_API_KEY: z.string(), - CLOUDFLARE_ACCOUNT_ID: z.string(), - R2_ACCESS_KEY_ID: z.string(), - R2_SECRET_ACCESS_KEY: z.string(), - R2_BUCKET_NAME: z.string(), - MODAL_TRANSCRIPTION_URL: z.url(), }); export type WebEnv = z.infer; diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index 68d2bc7f..f397ac4b 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -1,3 +1,4 @@ +import type { FrameRate } from "opencut-wasm"; import { EXPORT_MIME_TYPES } from "@/constants/export-constants"; export const EXPORT_QUALITY_VALUES = [ @@ -15,7 +16,7 @@ export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number]; export interface ExportOptions { format: ExportFormat; quality: ExportQuality; - fps?: number; + fps?: FrameRate; includeAudio?: boolean; } diff --git a/apps/web/src/lib/fps/__tests__/fps.test.ts b/apps/web/src/lib/fps/__tests__/fps.test.ts index fd082bb2..10eed8d9 100644 --- a/apps/web/src/lib/fps/__tests__/fps.test.ts +++ b/apps/web/src/lib/fps/__tests__/fps.test.ts @@ -36,16 +36,16 @@ describe("getRaisedProjectFpsForImportedMedia", () => { test("raises the project fps to match a higher-fps import", () => { expect( getRaisedProjectFpsForImportedMedia({ - currentFps: 30, + currentFps: { numerator: 30, denominator: 1 }, importedAssets: [{ type: "video", fps: 60 }], }), - ).toBe(60); + ).toEqual({ numerator: 60, denominator: 1 }); }); test("does not lower the project fps for lower-fps imports", () => { expect( getRaisedProjectFpsForImportedMedia({ - currentFps: 60, + currentFps: { numerator: 60, denominator: 1 }, importedAssets: [{ type: "video", fps: 10 }], }), ).toBeNull(); @@ -54,7 +54,7 @@ describe("getRaisedProjectFpsForImportedMedia", () => { test("ignores non-video imports", () => { expect( getRaisedProjectFpsForImportedMedia({ - currentFps: 30, + currentFps: { numerator: 30, denominator: 1 }, importedAssets: [ { type: "image", fps: 60 }, { type: "audio", fps: 120 }, diff --git a/apps/web/src/lib/fps/constants.ts b/apps/web/src/lib/fps/constants.ts index 100c043a..30c3d2bc 100644 --- a/apps/web/src/lib/fps/constants.ts +++ b/apps/web/src/lib/fps/constants.ts @@ -1,3 +1,5 @@ +import type { FrameRate } from "opencut-wasm"; + export const FPS_PRESETS = [ { value: "24", label: "24 fps" }, { value: "25", label: "25 fps" }, @@ -6,4 +8,4 @@ export const FPS_PRESETS = [ { value: "120", label: "120 fps" }, ] as const; -export const DEFAULT_FPS = 30; +export const DEFAULT_FPS: FrameRate = { numerator: 30, denominator: 1 }; diff --git a/apps/web/src/lib/fps/utils.ts b/apps/web/src/lib/fps/utils.ts index 8e3221a1..932788c4 100644 --- a/apps/web/src/lib/fps/utils.ts +++ b/apps/web/src/lib/fps/utils.ts @@ -1,7 +1,61 @@ +import type { FrameRate } from "opencut-wasm"; import type { MediaAsset } from "@/lib/media/types"; type MediaAssetFpsInput = Pick; +const STANDARD_FRAME_RATES: Array<{ value: number; rate: FrameRate }> = [ + { value: 24_000 / 1_001, rate: { numerator: 24_000, denominator: 1_001 } }, + { value: 24, rate: { numerator: 24, denominator: 1 } }, + { value: 25, rate: { numerator: 25, denominator: 1 } }, + { value: 30_000 / 1_001, rate: { numerator: 30_000, denominator: 1_001 } }, + { value: 30, rate: { numerator: 30, denominator: 1 } }, + { value: 48, rate: { numerator: 48, denominator: 1 } }, + { value: 50, rate: { numerator: 50, denominator: 1 } }, + { value: 60_000 / 1_001, rate: { numerator: 60_000, denominator: 1_001 } }, + { value: 60, rate: { numerator: 60, denominator: 1 } }, + { value: 120, rate: { numerator: 120, denominator: 1 } }, +]; + +const STANDARD_FRAME_RATE_TOLERANCE = 0.01; + +export function frameRateToFloat(rate: FrameRate): number { + return rate.numerator / rate.denominator; +} + +export function frameRatesEqual(a: FrameRate, b: FrameRate): boolean { + return a.numerator === b.numerator && a.denominator === b.denominator; +} + +export function floatToFrameRate(fps: number): FrameRate { + const standard = STANDARD_FRAME_RATES.find( + (candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE, + ); + if (standard) return standard.rate; + + if (Number.isInteger(fps)) { + return { numerator: fps, denominator: 1 }; + } + + const ARBITRARY_DENOMINATOR = 1_000_000; + const scaledNumerator = Math.round(fps * ARBITRARY_DENOMINATOR); + const divisor = gcd(scaledNumerator, ARBITRARY_DENOMINATOR); + return { + numerator: scaledNumerator / divisor, + denominator: ARBITRARY_DENOMINATOR / divisor, + }; +} + +function gcd(left: number, right: number): number { + let a = Math.abs(left); + let b = Math.abs(right); + while (b !== 0) { + const remainder = a % b; + a = b; + b = remainder; + } + return a || 1; +} + export function getHighestImportedVideoFps({ mediaAssets, }: { @@ -24,16 +78,18 @@ export function getRaisedProjectFpsForImportedMedia({ currentFps, importedAssets, }: { - currentFps: number; + currentFps: FrameRate; importedAssets: MediaAssetFpsInput[]; -}): number | null { +}): FrameRate | null { const highestImportedVideoFps = getHighestImportedVideoFps({ mediaAssets: importedAssets, }); - if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFps) { + const currentFpsFloat = frameRateToFloat(currentFps); + + if (highestImportedVideoFps === null || highestImportedVideoFps <= currentFpsFloat) { return null; } - return highestImportedVideoFps; + return floatToFrameRate(highestImportedVideoFps); } diff --git a/apps/web/src/lib/masks/__tests__/snap.test.ts b/apps/web/src/lib/masks/__tests__/snap.test.ts index 41093c88..72b2ca80 100644 --- a/apps/web/src/lib/masks/__tests__/snap.test.ts +++ b/apps/web/src/lib/masks/__tests__/snap.test.ts @@ -1,235 +1,237 @@ -import { describe, expect, test } from "bun:test"; -import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; -import { getMaskSnapGeometry } from "@/lib/masks/geometry"; -import { snapMaskInteraction } from "@/lib/masks/snap"; -import type { ElementBounds } from "@/lib/preview/element-bounds"; -import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; - -const bounds: ElementBounds = { - cx: 200, - cy: 150, - width: 200, - height: 100, - rotation: 0, -}; - -const canvasSize = { - width: 400, - height: 300, -}; - -const snapThreshold = { - x: 8, - y: 8, -}; - -function buildSplitParams( - overrides: Partial = {}, -): SplitMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - centerX: 0, - centerY: 0, - rotation: 0, - ...overrides, - }; -} - -function buildRectangleParams( - overrides: Partial = {}, -): RectangleMaskParams { - return { - feather: 0, - inverted: false, - strokeColor: "#ffffff", - strokeWidth: 0, - centerX: 0, - centerY: 0, - width: 0.4, - height: 0.2, - rotation: 0, - scale: 1, - ...overrides, - }; -} - -function sortSegment( - segment: [{ x: number; y: number }, { x: number; y: number }], -): [{ x: number; y: number }, { x: number; y: number }] { - return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ - { x: number; y: number }, - { x: number; y: number }, - ]; -} - -describe("mask geometry", () => { - test("resolves split mask center from centerX and centerY", () => { - expect( - getMaskSnapGeometry({ - params: buildSplitParams({ - centerX: 0.25, - centerY: -0.5, - rotation: 45, - }), - bounds, - }), - ).toEqual({ - position: { x: 50, y: -50 }, - size: { width: 0, height: 0 }, - rotation: 45, - }); - }); - - test("resolves box mask center and size from centerX and centerY", () => { - expect( - getMaskSnapGeometry({ - params: buildRectangleParams({ - centerX: -0.25, - centerY: 0.5, - width: 0.5, - height: 0.6, - rotation: 30, - }), - bounds, - }), - ).toEqual({ - position: { x: -50, y: 50 }, - size: { width: 100, height: 60 }, - rotation: 30, - }); - }); - - test("returns a vertical split stroke segment for rotation 0", () => { - const segment = getSplitMaskStrokeSegment({ - resolvedParams: buildSplitParams(), - width: bounds.width, - height: bounds.height, - }); - - expect(segment).not.toBeNull(); - if (!segment) { - throw new Error("Expected split stroke segment for rotation 0"); - } - expect(sortSegment(segment)).toEqual([ - { x: bounds.width / 2, y: 0 }, - { x: bounds.width / 2, y: bounds.height }, - ]); - }); - - test("returns a horizontal split stroke segment for rotation 90", () => { - const segment = getSplitMaskStrokeSegment({ - resolvedParams: buildSplitParams({ rotation: 90 }), - width: bounds.width, - height: bounds.height, - }); - - expect(segment).not.toBeNull(); - if (!segment) { - throw new Error("Expected split stroke segment for rotation 90"); - } - expect(sortSegment(segment)).toEqual([ - { x: 0, y: bounds.height / 2 }, - { x: bounds.width, y: bounds.height / 2 }, - ]); - }); -}); - -describe("mask snapping", () => { - test("snaps split mask movement using the shared position pipeline", () => { - const result = snapMaskInteraction({ - handleId: "position", - startParams: buildSplitParams({ - centerX: 0.03, - centerY: -0.04, - }), - proposedParams: buildSplitParams({ - centerX: 0.03, - centerY: -0.04, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.centerX).toBe(0); - expect(result.params.centerY).toBe(0); - expect(result.activeLines).toEqual([ - { type: "vertical", position: 0 }, - { type: "horizontal", position: 0 }, - ]); - }); - - test("snaps box mask movement against element center and edges", () => { - const result = snapMaskInteraction({ - handleId: "position", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - centerX: 0.29, - centerY: 0.03, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.centerX).toBeCloseTo(0.3); - expect(result.params.centerY).toBe(0); - expect(result.activeLines).toEqual([ - { type: "vertical", position: 100 }, - { type: "horizontal", position: 0 }, - ]); - }); - - test("snaps mask rotation through the shared rotation path", () => { - const result = snapMaskInteraction({ - handleId: "rotation", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - rotation: 88, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.rotation).toBe(90); - expect(result.activeLines).toEqual([]); - }); - - test("snaps edge resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "right", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - width: 0.98, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.width).toBe(1); - expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); - }); - - test("snaps corner resize for box masks", () => { - const result = snapMaskInteraction({ - handleId: "bottom-right", - startParams: buildRectangleParams(), - proposedParams: buildRectangleParams({ - width: 0.99, - height: 0.495, - }), - bounds, - canvasSize, - snapThreshold, - }); - - expect(result.params.width).toBe(1); - expect(result.params.height).toBe(0.5); - expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); - }); -}); +import { describe, expect, test } from "bun:test"; +import { getSplitMaskStrokeSegment } from "@/lib/masks/definitions/split"; +import { getMaskSnapGeometry } from "@/lib/masks/geometry"; +import { snapMaskInteraction } from "@/lib/masks/snap"; +import type { ElementBounds } from "@/lib/preview/element-bounds"; +import type { RectangleMaskParams, SplitMaskParams } from "@/lib/masks/types"; + +const bounds: ElementBounds = { + cx: 200, + cy: 150, + width: 200, + height: 100, + rotation: 0, +}; + +const canvasSize = { + width: 400, + height: 300, +}; + +const snapThreshold = { + x: 8, + y: 8, +}; + +function buildSplitParams( + overrides: Partial = {}, +): SplitMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + rotation: 0, + ...overrides, + }; +} + +function buildRectangleParams( + overrides: Partial = {}, +): RectangleMaskParams { + return { + feather: 0, + inverted: false, + strokeColor: "#ffffff", + strokeWidth: 0, + strokeAlign: "center", + centerX: 0, + centerY: 0, + width: 0.4, + height: 0.2, + rotation: 0, + scale: 1, + ...overrides, + }; +} + +function sortSegment( + segment: [{ x: number; y: number }, { x: number; y: number }], +): [{ x: number; y: number }, { x: number; y: number }] { + return [...segment].sort((a, b) => (a.x === b.x ? a.y - b.y : a.x - b.x)) as [ + { x: number; y: number }, + { x: number; y: number }, + ]; +} + +describe("mask geometry", () => { + test("resolves split mask center from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildSplitParams({ + centerX: 0.25, + centerY: -0.5, + rotation: 45, + }), + bounds, + }), + ).toEqual({ + position: { x: 50, y: -50 }, + size: { width: 0, height: 0 }, + rotation: 45, + }); + }); + + test("resolves box mask center and size from centerX and centerY", () => { + expect( + getMaskSnapGeometry({ + params: buildRectangleParams({ + centerX: -0.25, + centerY: 0.5, + width: 0.5, + height: 0.6, + rotation: 30, + }), + bounds, + }), + ).toEqual({ + position: { x: -50, y: 50 }, + size: { width: 100, height: 60 }, + rotation: 30, + }); + }); + + test("returns a vertical split stroke segment for rotation 0", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams(), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 0"); + } + expect(sortSegment(segment)).toEqual([ + { x: bounds.width / 2, y: 0 }, + { x: bounds.width / 2, y: bounds.height }, + ]); + }); + + test("returns a horizontal split stroke segment for rotation 90", () => { + const segment = getSplitMaskStrokeSegment({ + resolvedParams: buildSplitParams({ rotation: 90 }), + width: bounds.width, + height: bounds.height, + }); + + expect(segment).not.toBeNull(); + if (!segment) { + throw new Error("Expected split stroke segment for rotation 90"); + } + expect(sortSegment(segment)).toEqual([ + { x: 0, y: bounds.height / 2 }, + { x: bounds.width, y: bounds.height / 2 }, + ]); + }); +}); + +describe("mask snapping", () => { + test("snaps split mask movement using the shared position pipeline", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + proposedParams: buildSplitParams({ + centerX: 0.03, + centerY: -0.04, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBe(0); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 0 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps box mask movement against element center and edges", () => { + const result = snapMaskInteraction({ + handleId: "position", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + centerX: 0.29, + centerY: 0.03, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.centerX).toBeCloseTo(0.3); + expect(result.params.centerY).toBe(0); + expect(result.activeLines).toEqual([ + { type: "vertical", position: 100 }, + { type: "horizontal", position: 0 }, + ]); + }); + + test("snaps mask rotation through the shared rotation path", () => { + const result = snapMaskInteraction({ + handleId: "rotation", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + rotation: 88, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.rotation).toBe(90); + expect(result.activeLines).toEqual([]); + }); + + test("snaps edge resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.98, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); + + test("snaps corner resize for box masks", () => { + const result = snapMaskInteraction({ + handleId: "bottom-right", + startParams: buildRectangleParams(), + proposedParams: buildRectangleParams({ + width: 0.99, + height: 0.495, + }), + bounds, + canvasSize, + snapThreshold, + }); + + expect(result.params.width).toBe(1); + expect(result.params.height).toBe(0.5); + expect(result.activeLines).toEqual([{ type: "vertical", position: 100 }]); + }); +}); diff --git a/apps/web/src/lib/media/audio.ts b/apps/web/src/lib/media/audio.ts index d1025aa9..a70a6fa1 100644 --- a/apps/web/src/lib/media/audio.ts +++ b/apps/web/src/lib/media/audio.ts @@ -2,7 +2,7 @@ import type { AudioElement, LibraryAudioElement, RetimeConfig, - TimelineTrack, + SceneTracks, } from "@/lib/timeline"; import { shouldMaintainPitch } from "@/lib/retime/rate"; import type { MediaAsset } from "@/lib/media/types"; @@ -21,6 +21,8 @@ import { mediaSupportsAudio } from "@/lib/media/media-utils"; import { getSourceTimeAtClipTime, renderRetimedBuffer } from "@/lib/retime"; import { Input, ALL_FORMATS, BlobSource, AudioBufferSink } from "mediabunny"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; + const MAX_AUDIO_CHANNELS = 2; const EXPORT_SAMPLE_RATE = 44100; const COARSE_SAMPLE_COUNT = 2048; @@ -87,16 +89,17 @@ export async function collectAudioElements({ mediaAssets, audioContext, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; audioContext: AudioContext; }): Promise { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const mediaMap = new Map( mediaAssets.map((media) => [media.id, media]), ); const pendingElements: Array> = []; - for (const track of tracks) { + for (const track of orderedTracks) { if (canTrackHaveAudio(track) && track.muted) continue; for (const element of track.elements) { @@ -122,10 +125,10 @@ export async function collectAudioElements({ return { timelineElement: element, buffer: audioBuffer, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, + startTime: element.startTime / TICKS_PER_SECOND, + duration: element.duration / TICKS_PER_SECOND, + trimStart: element.trimStart / TICKS_PER_SECOND, + trimEnd: element.trimEnd / TICKS_PER_SECOND, volume: resolveEffectiveAudioGain({ element, trackMuted: isTrackMuted, @@ -152,10 +155,10 @@ export async function collectAudioElements({ return { timelineElement: element, buffer: audioBuffer, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, + startTime: element.startTime / TICKS_PER_SECOND, + duration: element.duration / TICKS_PER_SECOND, + trimStart: element.trimStart / TICKS_PER_SECOND, + trimEnd: element.trimEnd / TICKS_PER_SECOND, volume: resolveEffectiveAudioGain({ element, trackMuted: isTrackMuted, @@ -337,16 +340,16 @@ async function fetchLibraryAudioSource({ type: "audio/mpeg", }); - return { - timelineElement: element, - file, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - volume, - retime: element.retime, - }; + return { + timelineElement: element, + file, + startTime: element.startTime / TICKS_PER_SECOND, + duration: element.duration / TICKS_PER_SECOND, + trimStart: element.trimStart / TICKS_PER_SECOND, + trimEnd: element.trimEnd / TICKS_PER_SECOND, + volume, + retime: element.retime, + }; } catch (error) { console.warn("Failed to fetch library audio:", error); return null; @@ -404,10 +407,10 @@ function collectMediaAudioSource({ return { timelineElement: element, file: mediaAsset.file, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, + startTime: element.startTime / TICKS_PER_SECOND, + duration: element.duration / TICKS_PER_SECOND, + trimStart: element.trimStart / TICKS_PER_SECOND, + trimEnd: element.trimEnd / TICKS_PER_SECOND, volume, retime: element.retime, }; @@ -429,10 +432,10 @@ function collectMediaAudioClip({ id: element.id, sourceKey: mediaAsset.id, file: mediaAsset.file, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, + startTime: element.startTime / TICKS_PER_SECOND, + duration: element.duration / TICKS_PER_SECOND, + trimStart: element.trimStart / TICKS_PER_SECOND, + trimEnd: element.trimEnd / TICKS_PER_SECOND, volume, muted, retime: element.retime, @@ -443,16 +446,17 @@ export async function collectAudioMixSources({ tracks, mediaAssets, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; }): Promise { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const audioMixSources: AudioMixSource[] = []; const mediaMap = new Map( mediaAssets.map((asset) => [asset.id, asset]), ); const pendingLibrarySources: Array> = []; - for (const track of tracks) { + for (const track of orderedTracks) { if (canTrackHaveAudio(track) && track.muted) continue; for (const element of track.elements) { @@ -505,16 +509,17 @@ export async function collectAudioClips({ tracks, mediaAssets, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; }): Promise { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const clips: AudioClipSource[] = []; const mediaMap = new Map( mediaAssets.map((asset) => [asset.id, asset]), ); const pendingLibraryClips: Array> = []; - for (const track of tracks) { + for (const track of orderedTracks) { const isTrackMuted = canTrackHaveAudio(track) && track.muted; for (const element of track.elements) { @@ -585,7 +590,7 @@ export async function createTimelineAudioBuffer({ sampleRate = EXPORT_SAMPLE_RATE, audioContext, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; duration: number; sampleRate?: number; @@ -602,7 +607,8 @@ export async function createTimelineAudioBuffer({ if (audioElements.length === 0) return null; const outputChannels = 2; - const outputLength = Math.ceil(duration * sampleRate); + const durationSeconds = duration / TICKS_PER_SECOND; + const outputLength = Math.ceil(durationSeconds * sampleRate); const outputBuffer = context.createBuffer( outputChannels, outputLength, diff --git a/apps/web/src/lib/media/mediabunny.ts b/apps/web/src/lib/media/mediabunny.ts index dbb6ecad..a313c6f0 100644 --- a/apps/web/src/lib/media/mediabunny.ts +++ b/apps/web/src/lib/media/mediabunny.ts @@ -1,6 +1,6 @@ import { Input, ALL_FORMATS, BlobSource } from "mediabunny"; import { createTimelineAudioBuffer } from "@/lib/media/audio"; -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; export async function getVideoInfo({ @@ -48,7 +48,7 @@ export const extractTimelineAudio = async ({ totalDuration, onProgress, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; totalDuration: number; onProgress?: (progress: number) => void; diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts index 380a008c..40145d02 100644 --- a/apps/web/src/lib/preview/element-bounds.ts +++ b/apps/web/src/lib/preview/element-bounds.ts @@ -1,6 +1,5 @@ -import type { TimelineTrack, TimelineElement } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; -import { isMainTrack } from "@/lib/timeline/placement"; import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/constants/sticker-constants"; import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/lib/graphics"; import { measureTextElement } from "@/lib/text/measure-element"; @@ -266,18 +265,15 @@ export function getVisibleElementsWithBounds({ canvasSize, mediaAssets, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; currentTime: number; canvasSize: { width: number; height: number }; mediaAssets: MediaAsset[]; }): ElementWithBounds[] { const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - const visibleTracks = tracks.filter( - (track) => !("hidden" in track && track.hidden), - ); const orderedTracks = [ - ...visibleTracks.filter((track) => !isMainTrack(track)), - ...visibleTracks.filter((track) => isMainTrack(track)), + ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), + ...(!tracks.main.hidden ? [tracks.main] : []), ].reverse(); const result: ElementWithBounds[] = []; diff --git a/apps/web/src/lib/project/types.ts b/apps/web/src/lib/project/types.ts index 8f45db9e..0a454e07 100644 --- a/apps/web/src/lib/project/types.ts +++ b/apps/web/src/lib/project/types.ts @@ -1,53 +1,54 @@ -import type { TScene } from "@/lib/timeline/types"; - -export type TBackground = - | { - type: "color"; - color: string; - } - | { - type: "blur"; - blurIntensity: number; - }; - -export interface TCanvasSize { - width: number; - height: number; -} - -export interface TProjectMetadata { - id: string; - name: string; - thumbnail?: string; - duration: number; - createdAt: Date; - updatedAt: Date; -} - -export interface TProjectSettings { - fps: number; - canvasSize: TCanvasSize; - canvasSizeMode?: "preset" | "custom"; - lastCustomCanvasSize?: TCanvasSize | null; - originalCanvasSize?: TCanvasSize | null; - background: TBackground; -} - -export interface TTimelineViewState { - zoomLevel: number; - scrollLeft: number; - playheadTime: number; -} - -export interface TProject { - metadata: TProjectMetadata; - scenes: TScene[]; - currentSceneId: string; - settings: TProjectSettings; - version: number; - timelineViewState?: TTimelineViewState; -} - -export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration"; -export type TSortOrder = "asc" | "desc"; -export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`; +import type { FrameRate } from "opencut-wasm"; +import type { TScene } from "@/lib/timeline/types"; + +export type TBackground = + | { + type: "color"; + color: string; + } + | { + type: "blur"; + blurIntensity: number; + }; + +export interface TCanvasSize { + width: number; + height: number; +} + +export interface TProjectMetadata { + id: string; + name: string; + thumbnail?: string; + duration: number; + createdAt: Date; + updatedAt: Date; +} + +export interface TProjectSettings { + fps: FrameRate; + canvasSize: TCanvasSize; + canvasSizeMode?: "preset" | "custom"; + lastCustomCanvasSize?: TCanvasSize | null; + originalCanvasSize?: TCanvasSize | null; + background: TBackground; +} + +export interface TTimelineViewState { + zoomLevel: number; + scrollLeft: number; + playheadTime: number; +} + +export interface TProject { + metadata: TProjectMetadata; + scenes: TScene[]; + currentSceneId: string; + settings: TProjectSettings; + version: number; + timelineViewState?: TTimelineViewState; +} + +export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration"; +export type TSortOrder = "asc" | "desc"; +export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`; diff --git a/apps/web/src/lib/ripple/apply.ts b/apps/web/src/lib/ripple/apply.ts new file mode 100644 index 00000000..f96e4600 --- /dev/null +++ b/apps/web/src/lib/ripple/apply.ts @@ -0,0 +1,77 @@ +import type { SceneTracks, TimelineTrack } from "@/lib/timeline/types"; +import { rippleShiftElements } from "./shift"; + +export interface RippleAdjustment { + trackId: string; + afterTime: number; + shiftAmount: number; +} + +export function applyRippleAdjustments({ + tracks, + adjustments, +}: { + tracks: SceneTracks; + adjustments: RippleAdjustment[]; +}): SceneTracks { + if (adjustments.length === 0) { + return tracks; + } + + const adjustmentsByTrack = new Map(); + for (const adjustment of adjustments) { + const trackAdjustments = adjustmentsByTrack.get(adjustment.trackId) ?? []; + trackAdjustments.push(adjustment); + adjustmentsByTrack.set(adjustment.trackId, trackAdjustments); + } + + return { + overlay: tracks.overlay.map((track) => + applyTrackRippleAdjustments({ + track, + adjustments: adjustmentsByTrack.get(track.id) ?? [], + }), + ), + main: applyTrackRippleAdjustments({ + track: tracks.main, + adjustments: adjustmentsByTrack.get(tracks.main.id) ?? [], + }), + audio: tracks.audio.map((track) => + applyTrackRippleAdjustments({ + track, + adjustments: adjustmentsByTrack.get(track.id) ?? [], + }), + ), + }; +} + +function applyTrackRippleAdjustments< + TElement extends TimelineTrack["elements"][number], + TTrack extends TimelineTrack & { elements: TElement[] }, +>({ + track, + adjustments, +}: { + track: TTrack; + adjustments: RippleAdjustment[]; +}): TTrack { + if (adjustments.length === 0) { + return track; + } + + const sortedAdjustments = [...adjustments].sort( + (firstAdjustment, secondAdjustment) => + secondAdjustment.afterTime - firstAdjustment.afterTime, + ); + + let elements: TElement[] = track.elements; + for (const adjustment of sortedAdjustments) { + elements = rippleShiftElements({ + elements, + afterTime: adjustment.afterTime, + shiftAmount: adjustment.shiftAmount, + }); + } + + return { ...track, elements }; +} diff --git a/apps/web/src/lib/ripple/diff.ts b/apps/web/src/lib/ripple/diff.ts new file mode 100644 index 00000000..49238738 --- /dev/null +++ b/apps/web/src/lib/ripple/diff.ts @@ -0,0 +1,279 @@ +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline/types"; +import type { RippleAdjustment } from "./apply"; + +interface Interval { + startTime: number; + endTime: number; +} + +interface ElementSpan extends Interval { + id: string; +} + +export function computeRippleAdjustments({ + beforeTracks, + afterTracks, +}: { + beforeTracks: SceneTracks; + afterTracks: SceneTracks; +}): RippleAdjustment[] { + const beforeTrackList = [ + ...beforeTracks.overlay, + beforeTracks.main, + ...beforeTracks.audio, + ]; + const afterTrackList = [ + ...afterTracks.overlay, + afterTracks.main, + ...afterTracks.audio, + ]; + const afterTracksById = new Map(afterTrackList.map((track) => [track.id, track])); + const allAfterElementIds = new Set( + afterTrackList.flatMap((track) => track.elements.map((element) => element.id)), + ); + + return beforeTrackList.flatMap((beforeTrack): RippleAdjustment[] => + computeTrackRippleAdjustments({ + trackId: beforeTrack.id, + beforeElements: beforeTrack.elements, + afterElements: afterTracksById.get(beforeTrack.id)?.elements ?? [], + allAfterElementIds, + }), + ); +} + +function computeTrackRippleAdjustments({ + trackId, + beforeElements, + afterElements, + allAfterElementIds, +}: { + trackId: string; + beforeElements: TimelineElement[]; + afterElements: TimelineElement[]; + allAfterElementIds: Set; +}): RippleAdjustment[] { + const beforeElementsById = buildElementSpanMap({ elements: beforeElements }); + const afterElementsById = buildElementSpanMap({ elements: afterElements }); + const { vacatedIntervals, joinedIntervals } = collectTrackIntervals({ + beforeElementsById, + afterElementsById, + allAfterElementIds, + }); + const freedIntervals = subtractIntervalSets({ + sourceIntervals: vacatedIntervals, + overlappingIntervals: joinedIntervals, + }); + + return buildAdjustments({ trackId, intervals: freedIntervals }); +} + +function buildElementSpanMap({ + elements, +}: { + elements: TimelineElement[]; +}): Map { + return new Map( + elements.map((element) => [ + element.id, + { + id: element.id, + startTime: element.startTime, + endTime: element.startTime + element.duration, + }, + ]), + ); +} + +function collectTrackIntervals({ + beforeElementsById, + afterElementsById, + allAfterElementIds, +}: { + beforeElementsById: Map; + afterElementsById: Map; + allAfterElementIds: Set; +}): { + vacatedIntervals: Interval[]; + joinedIntervals: Interval[]; +} { + const vacatedIntervals: Interval[] = []; + const joinedIntervals: Interval[] = []; + + for (const beforeElement of beforeElementsById.values()) { + const afterElement = afterElementsById.get(beforeElement.id); + if (!afterElement) { + const wasMovedToAnotherTrack = allAfterElementIds.has(beforeElement.id); + if (!wasMovedToAnotherTrack) { + pushInterval({ + intervals: vacatedIntervals, + startTime: beforeElement.startTime, + endTime: beforeElement.endTime, + }); + } + continue; + } + + if (beforeElement.endTime > afterElement.endTime) { + pushInterval({ + intervals: vacatedIntervals, + startTime: afterElement.endTime, + endTime: beforeElement.endTime, + }); + } + } + + for (const afterElement of afterElementsById.values()) { + if (beforeElementsById.has(afterElement.id)) { + continue; + } + + pushInterval({ + intervals: joinedIntervals, + startTime: afterElement.startTime, + endTime: afterElement.endTime, + }); + } + + return { + vacatedIntervals: normalizeIntervals({ intervals: vacatedIntervals }), + joinedIntervals: normalizeIntervals({ intervals: joinedIntervals }), + }; +} + +function buildAdjustments({ + trackId, + intervals, +}: { + trackId: string; + intervals: Interval[]; +}): RippleAdjustment[] { + return intervals.flatMap((interval): RippleAdjustment[] => { + const shiftAmount = interval.endTime - interval.startTime; + if (shiftAmount <= 0) { + return []; + } + + return [ + { + trackId, + afterTime: interval.endTime, + shiftAmount, + }, + ]; + }); +} + +function subtractIntervalSets({ + sourceIntervals, + overlappingIntervals, +}: { + sourceIntervals: Interval[]; + overlappingIntervals: Interval[]; +}): Interval[] { + const normalizedSourceIntervals = normalizeIntervals({ + intervals: sourceIntervals, + }); + const normalizedOverlappingIntervals = normalizeIntervals({ + intervals: overlappingIntervals, + }); + + return normalizedSourceIntervals.flatMap((sourceInterval) => + subtractSingleInterval({ + sourceInterval, + overlappingIntervals: normalizedOverlappingIntervals, + }), + ); +} + +function normalizeIntervals({ + intervals, +}: { + intervals: Interval[]; +}): Interval[] { + const validIntervals: Interval[] = []; + for (const interval of intervals) { + pushInterval({ + intervals: validIntervals, + startTime: interval.startTime, + endTime: interval.endTime, + }); + } + + const sortedIntervals = validIntervals.sort( + (leftInterval, rightInterval) => + leftInterval.startTime - rightInterval.startTime, + ); + + if (sortedIntervals.length === 0) { + return []; + } + + const mergedIntervals: Interval[] = [{ ...sortedIntervals[0] }]; + for (const interval of sortedIntervals.slice(1)) { + const previousInterval = mergedIntervals[mergedIntervals.length - 1]; + if (interval.startTime <= previousInterval.endTime) { + previousInterval.endTime = Math.max( + previousInterval.endTime, + interval.endTime, + ); + continue; + } + + mergedIntervals.push({ ...interval }); + } + + return mergedIntervals; +} + +function subtractSingleInterval({ + sourceInterval, + overlappingIntervals, +}: { + sourceInterval: Interval; + overlappingIntervals: Interval[]; +}): Interval[] { + let remainingIntervals: Interval[] = [{ ...sourceInterval }]; + + for (const overlappingInterval of overlappingIntervals) { + remainingIntervals = remainingIntervals.flatMap((remainingInterval) => { + if ( + overlappingInterval.endTime <= remainingInterval.startTime || + overlappingInterval.startTime >= remainingInterval.endTime + ) { + return [remainingInterval]; + } + + const nextIntervals: Interval[] = []; + pushInterval({ + intervals: nextIntervals, + startTime: remainingInterval.startTime, + endTime: overlappingInterval.startTime, + }); + pushInterval({ + intervals: nextIntervals, + startTime: overlappingInterval.endTime, + endTime: remainingInterval.endTime, + }); + return nextIntervals; + }); + + if (remainingIntervals.length === 0) { + return []; + } + } + + return remainingIntervals; +} + +function pushInterval({ + intervals, + startTime, + endTime, +}: { intervals: Interval[]; startTime: number; endTime: number }): void { + if (endTime <= startTime) { + return; + } + + intervals.push({ startTime, endTime }); +} diff --git a/apps/web/src/lib/ripple/index.ts b/apps/web/src/lib/ripple/index.ts new file mode 100644 index 00000000..5a14de64 --- /dev/null +++ b/apps/web/src/lib/ripple/index.ts @@ -0,0 +1,4 @@ +export type { RippleAdjustment } from "./apply"; +export { applyRippleAdjustments } from "./apply"; +export { computeRippleAdjustments } from "./diff"; +export { rippleShiftElements } from "./shift"; diff --git a/apps/web/src/lib/ripple/shift.ts b/apps/web/src/lib/ripple/shift.ts new file mode 100644 index 00000000..03ab4a67 --- /dev/null +++ b/apps/web/src/lib/ripple/shift.ts @@ -0,0 +1,17 @@ +import type { TimelineElement } from "@/lib/timeline/types"; + +export function rippleShiftElements({ + elements, + afterTime, + shiftAmount, +}: { + elements: TElement[]; + afterTime: number; + shiftAmount: number; +}): TElement[] { + return elements.map((element) => + element.startTime >= afterTime + ? ({ ...element, startTime: element.startTime - shiftAmount } as TElement) + : element, + ); +} diff --git a/apps/web/src/lib/scenes.ts b/apps/web/src/lib/scenes.ts index 1b5b293c..91285275 100644 --- a/apps/web/src/lib/scenes.ts +++ b/apps/web/src/lib/scenes.ts @@ -1,7 +1,7 @@ import type { TScene } from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { calculateTotalDuration } from "@/lib/timeline"; -import { ensureMainTrack } from "@/lib/timeline/placement"; +import { MAIN_TRACK_NAME } from "@/lib/timeline/placement/main-track"; export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null { return scenes.find((scene) => scene.isMain) || null; @@ -23,12 +23,22 @@ export function buildDefaultScene({ name: string; isMain: boolean; }): TScene { - const tracks = ensureMainTrack({ tracks: [] }); return { id: generateUUID(), name, isMain, - tracks, + tracks: { + overlay: [], + main: { + id: generateUUID(), + name: MAIN_TRACK_NAME, + type: "video", + elements: [], + muted: false, + hidden: false, + }, + audio: [], + }, bookmarks: [], createdAt: new Date(), updatedAt: new Date(), @@ -81,7 +91,7 @@ export function getProjectDurationFromScenes({ scenes: TScene[]; }): number { const mainScene = getMainScene({ scenes }) ?? scenes[0] ?? null; - if (!mainScene?.tracks || !Array.isArray(mainScene.tracks)) { + if (!mainScene?.tracks) { return 0; } diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts index 32d0d80e..75fa7f09 100644 --- a/apps/web/src/lib/text/layout.ts +++ b/apps/web/src/lib/text/layout.ts @@ -54,32 +54,19 @@ export function getMetricDescent({ export function measureTextBlock({ lineMetrics, lineHeightPx, - fallbackFontSize, }: { lineMetrics: TextMetrics[]; lineHeightPx: number; - fallbackFontSize: number; }): TextBlockMeasurement { - let top = Number.POSITIVE_INFINITY; - let bottom = Number.NEGATIVE_INFINITY; let maxWidth = 0; - for (let index = 0; index < lineMetrics.length; index++) { - const metrics = lineMetrics[index]; - const lineY = index * lineHeightPx; - top = Math.min( - top, - lineY - getMetricAscent({ metrics, fallbackFontSize }), - ); - bottom = Math.max( - bottom, - lineY + getMetricDescent({ metrics, fallbackFontSize }), - ); + for (const metrics of lineMetrics) { maxWidth = Math.max(maxWidth, metrics.width); } - const height = bottom - top; - const visualCenterOffset = (top + bottom) / 2; + const lineCount = lineMetrics.length; + const height = lineCount * lineHeightPx; + const visualCenterOffset = ((lineCount - 1) * lineHeightPx) / 2; return { visualCenterOffset, height, maxWidth }; } diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts index d2a4a84d..77efe1b8 100644 --- a/apps/web/src/lib/text/measure-element.ts +++ b/apps/web/src/lib/text/measure-element.ts @@ -72,7 +72,6 @@ export function measureTextElement({ const block = measureTextBlock({ lineMetrics, lineHeightPx, - fallbackFontSize: scaledFontSize, }); const bg = element.background; diff --git a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts index 173b6402..f2b17d1a 100644 --- a/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts +++ b/apps/web/src/lib/timeline/audio-separation/__tests__/index.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import type { AudioElement, VideoElement } from "@/lib/timeline"; +import { upsertElementKeyframe } from "@/lib/animation"; +import type { UploadAudioElement, VideoElement } from "@/lib/timeline"; import { buildSeparatedAudioElement, doesElementHaveEnabledAudio, @@ -25,32 +26,7 @@ describe("audio separation", () => { volume: -6, muted: true, retime: { rate: 1.25, maintainPitch: true }, - animations: { - channels: { - volume: { - valueKind: "number", - keyframes: [ - { - id: "volume-keyframe", - time: 2, - value: -12, - interpolation: "linear", - }, - ], - }, - opacity: { - valueKind: "number", - keyframes: [ - { - id: "opacity-keyframe", - time: 1, - value: 0.5, - interpolation: "linear", - }, - ], - }, - }, - }, + animations: buildAnimations(), }); const separatedAudioElement = buildSeparatedAudioElement({ @@ -71,40 +47,23 @@ describe("audio separation", () => { muted: true, retime: { rate: 1.25, maintainPitch: true }, }); - expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([ + expect(Object.keys(separatedAudioElement.animations?.bindings ?? {})).toEqual([ "volume", ]); + expect(Object.keys(separatedAudioElement.animations?.channels ?? {})).toEqual([ + "volume:value", + ]); expect( - separatedAudioElement.animations?.channels.volume?.keyframes[0]?.id, + separatedAudioElement.animations?.channels["volume:value"]?.keys[0]?.id, ).not.toBe("volume-keyframe"); }); test("skips source audio collection when the source clip is separated", () => { - const mediaAsset = { - id: "media-1", - type: "video", - name: "Clip", - size: 1, - lastModified: 1, - file: new File(["video"], "clip.mp4", { type: "video/mp4" }), - url: "blob:clip", - hasAudio: true, - }; + const mediaAsset = { hasAudio: true }; const videoElement = buildVideoElement({ isSourceAudioEnabled: false, }); - const audioElement = { - id: "audio-1", - type: "audio", - sourceType: "upload", - mediaId: "audio-media-1", - name: "Detached audio", - duration: 5, - startTime: 0, - trimStart: 0, - trimEnd: 0, - volume: 0, - } as AudioElement; + const audioElement = buildAudioElement(); expect( doesElementHaveEnabledAudio({ @@ -145,3 +104,41 @@ function buildVideoElement( ...overrides, }; } + +function buildAudioElement( + overrides: Partial = {}, +): UploadAudioElement { + return { + id: "audio-1", + type: "audio", + sourceType: "upload", + mediaId: "audio-media-1", + name: "Detached audio", + duration: 5, + startTime: 0, + trimStart: 0, + trimEnd: 0, + volume: 0, + ...overrides, + } satisfies UploadAudioElement; +} + +function buildAnimations() { + const withVolume = upsertElementKeyframe({ + animations: undefined, + propertyPath: "volume", + time: 2, + value: -12, + interpolation: "linear", + keyframeId: "volume-keyframe", + }); + + return upsertElementKeyframe({ + animations: withVolume, + propertyPath: "opacity", + time: 1, + value: 0.5, + interpolation: "linear", + keyframeId: "opacity-keyframe", + }); +} diff --git a/apps/web/src/lib/timeline/audio-separation/index.ts b/apps/web/src/lib/timeline/audio-separation/index.ts index b92cb79b..fe3c1ca4 100644 --- a/apps/web/src/lib/timeline/audio-separation/index.ts +++ b/apps/web/src/lib/timeline/audio-separation/index.ts @@ -1,4 +1,4 @@ -import { cloneAnimations, getChannel } from "@/lib/animation"; +import { cloneAnimations } from "@/lib/animation"; import type { ElementAnimations } from "@/lib/animation/types"; import type { MediaAsset } from "@/lib/media/types"; import { DEFAULTS } from "@/lib/timeline/defaults"; @@ -9,6 +9,8 @@ import type { VideoElement, } from "../types"; +type MediaAudioState = Pick; + export function isSourceAudioEnabled({ element, }: { @@ -25,13 +27,10 @@ export function isSourceAudioSeparated({ return !isSourceAudioEnabled({ element }); } -export function canExtractSourceAudio({ - element, - mediaAsset, -}: { - element: TimelineElement; - mediaAsset: MediaAsset | null | undefined; -}): element is VideoElement { +export function canExtractSourceAudio( + element: TimelineElement, + mediaAsset: MediaAudioState | null | undefined, +): element is VideoElement { return ( element.type === "video" && isSourceAudioEnabled({ element }) && @@ -40,25 +39,17 @@ export function canExtractSourceAudio({ ); } -export function canRecoverSourceAudio({ - element, -}: { - element: TimelineElement; -}): element is VideoElement { +export function canRecoverSourceAudio( + element: TimelineElement, +): element is VideoElement { return element.type === "video" && isSourceAudioSeparated({ element }); } -export function canToggleSourceAudio({ - element, - mediaAsset, -}: { - element: TimelineElement; - mediaAsset: MediaAsset | null | undefined; -}): element is VideoElement { - return ( - canRecoverSourceAudio({ element }) || - canExtractSourceAudio({ element, mediaAsset }) - ); +export function canToggleSourceAudio( + element: TimelineElement, + mediaAsset: MediaAudioState | null | undefined, +): element is VideoElement { + return canRecoverSourceAudio(element) || canExtractSourceAudio(element, mediaAsset); } export function doesElementHaveEnabledAudio({ @@ -66,7 +57,7 @@ export function doesElementHaveEnabledAudio({ mediaAsset, }: { element: AudioElement | VideoElement; - mediaAsset?: MediaAsset | null; + mediaAsset?: MediaAudioState | null; }): boolean { if (element.type === "audio") { return true; @@ -117,16 +108,27 @@ function cloneVolumeAnimations({ }: { animations: ElementAnimations | undefined; }): ElementAnimations | undefined { - const volumeChannel = getChannel({ animations, propertyPath: "volume" }); - if (!volumeChannel) { + const volumeBinding = animations?.bindings.volume; + if (!volumeBinding) { + return undefined; + } + + const subsetChannels = Object.fromEntries( + volumeBinding.components.flatMap((component) => { + const channel = animations?.channels[component.channelId]; + return channel ? [[component.channelId, channel] as const] : []; + }), + ); + if (Object.keys(subsetChannels).length === 0) { return undefined; } return cloneAnimations({ animations: { - channels: { - volume: volumeChannel, + bindings: { + volume: volumeBinding, }, + channels: subsetChannels, }, shouldRegenerateKeyframeIds: true, }); diff --git a/apps/web/src/lib/timeline/audio-state.ts b/apps/web/src/lib/timeline/audio-state.ts index 28e381c2..9625c482 100644 --- a/apps/web/src/lib/timeline/audio-state.ts +++ b/apps/web/src/lib/timeline/audio-state.ts @@ -1,100 +1,102 @@ -import { hasKeyframesForPath } from "@/lib/animation/keyframe-query"; -import { resolveNumberAtTime } from "@/lib/animation/resolve"; -import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; -import type { TimelineElement } from "./types"; -const DEFAULT_STEP_SECONDS = 1 / 60; - -export type AudioCapableElement = Extract< - TimelineElement, - { type: "audio" | "video" } ->; - -export function clampDb(value: number): number { - if (!Number.isFinite(value)) { - return 0; - } - - return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value)); -} - -export function dBToLinear(db: number): number { - return 10 ** (clampDb(db) / 20); -} - -export function hasAnimatedVolume({ - element, -}: { - element: AudioCapableElement; -}): boolean { - return hasKeyframesForPath({ - animations: element.animations, - propertyPath: "volume", - }); -} - -export function resolveEffectiveAudioGain({ - element, - trackMuted = false, - localTime, -}: { - element: AudioCapableElement; - trackMuted?: boolean; - localTime: number; -}): number { - if (trackMuted || element.muted === true) { - return 0; - } - - const resolvedDb = resolveNumberAtTime({ - baseValue: element.volume ?? 0, - animations: element.animations, - propertyPath: "volume", - localTime, - }); - - return dBToLinear(resolvedDb); -} - -export function buildAudioGainAutomation({ - element, - trackMuted = false, - fromLocalTime, - toLocalTime, - stepSeconds = DEFAULT_STEP_SECONDS, -}: { - element: AudioCapableElement; - trackMuted?: boolean; - fromLocalTime: number; - toLocalTime: number; - stepSeconds?: number; -}): Array<{ localTime: number; gain: number }> { - const startTime = Math.max(0, fromLocalTime); - const endTime = Math.max(startTime, toLocalTime); - const safeStep = - Number.isFinite(stepSeconds) && stepSeconds > 0 - ? stepSeconds - : DEFAULT_STEP_SECONDS; - const points: Array<{ localTime: number; gain: number }> = []; - - for (let localTime = startTime; localTime < endTime; localTime += safeStep) { - points.push({ - localTime, - gain: resolveEffectiveAudioGain({ - element, - trackMuted, - localTime, - }), - }); - } - - points.push({ - localTime: endTime, - gain: resolveEffectiveAudioGain({ - element, - trackMuted, - localTime: endTime, - }), - }); - - return points; -} +import { hasKeyframesForPath } from "@/lib/animation/keyframe-query"; +import { resolveNumberAtTime } from "@/lib/animation/resolve"; +import { VOLUME_DB_MAX, VOLUME_DB_MIN } from "./audio-constants"; +import type { TimelineElement } from "./types"; +const DEFAULT_STEP_SECONDS = 1 / 60; + +export type AudioCapableElement = Extract< + TimelineElement, + { type: "audio" | "video" } +>; + +export function clampDb(value: number): number { + if (!Number.isFinite(value)) { + return 0; + } + + return Math.min(VOLUME_DB_MAX, Math.max(VOLUME_DB_MIN, value)); +} + +export function dBToLinear(db: number): number { + return 10 ** (clampDb(db) / 20); +} + +export function hasAnimatedVolume({ + element, +}: { + element: AudioCapableElement; +}): boolean { + return hasKeyframesForPath({ + animations: element.animations, + propertyPath: "volume", + }); +} + +import { TICKS_PER_SECOND } from "@/lib/wasm"; + +export function resolveEffectiveAudioGain({ + element, + trackMuted = false, + localTime, +}: { + element: AudioCapableElement; + trackMuted?: boolean; + localTime: number; +}): number { + if (trackMuted || element.muted === true) { + return 0; + } + + const resolvedDb = resolveNumberAtTime({ + baseValue: element.volume ?? 0, + animations: element.animations, + propertyPath: "volume", + localTime: Math.round(localTime * TICKS_PER_SECOND), + }); + + return dBToLinear(resolvedDb); +} + +export function buildAudioGainAutomation({ + element, + trackMuted = false, + fromLocalTime, + toLocalTime, + stepSeconds = DEFAULT_STEP_SECONDS, +}: { + element: AudioCapableElement; + trackMuted?: boolean; + fromLocalTime: number; + toLocalTime: number; + stepSeconds?: number; +}): Array<{ localTime: number; gain: number }> { + const startTime = Math.max(0, fromLocalTime); + const endTime = Math.max(startTime, toLocalTime); + const safeStep = + Number.isFinite(stepSeconds) && stepSeconds > 0 + ? stepSeconds + : DEFAULT_STEP_SECONDS; + const points: Array<{ localTime: number; gain: number }> = []; + + for (let localTime = startTime; localTime < endTime; localTime += safeStep) { + points.push({ + localTime, + gain: resolveEffectiveAudioGain({ + element, + trackMuted, + localTime, + }), + }); + } + + points.push({ + localTime: endTime, + gain: resolveEffectiveAudioGain({ + element, + trackMuted, + localTime: endTime, + }), + }); + + return points; +} diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts index 70d1b5ee..1fbf17bf 100644 --- a/apps/web/src/lib/timeline/bookmarks.ts +++ b/apps/web/src/lib/timeline/bookmarks.ts @@ -1,8 +1,7 @@ import type { Bookmark } from "@/lib/timeline"; +import type { FrameRate } from "opencut-wasm"; import { roundToFrame } from "opencut-wasm"; -export const BOOKMARK_TIME_EPSILON = 0.001; - function bookmarkTimeEqual({ bookmarkTime, frameTime, @@ -10,7 +9,7 @@ function bookmarkTimeEqual({ bookmarkTime: number; frameTime: number; }): boolean { - return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON; + return bookmarkTime === frameTime; } export function findBookmarkIndex({ @@ -112,9 +111,9 @@ export function getFrameTime({ fps, }: { time: number; - fps: number; + fps: FrameRate; }): number { - return roundToFrame({ time, fps }); + return roundToFrame({ time, rate: fps }) ?? time; } export function getBookmarkAtTime({ @@ -141,9 +140,6 @@ export function getBookmarksActiveAtTime({ bookmark.duration != null && bookmark.duration > 0 ? start + bookmark.duration : start; - return ( - time >= start - BOOKMARK_TIME_EPSILON && - time <= end + BOOKMARK_TIME_EPSILON - ); + return time >= start && time <= end; }); } diff --git a/apps/web/src/lib/timeline/creation.ts b/apps/web/src/lib/timeline/creation.ts index 335ab912..a339d806 100644 --- a/apps/web/src/lib/timeline/creation.ts +++ b/apps/web/src/lib/timeline/creation.ts @@ -1 +1,3 @@ -export const DEFAULT_NEW_ELEMENT_DURATION_SECONDS = 5; +import { TICKS_PER_SECOND } from "@/lib/wasm"; + +export const DEFAULT_NEW_ELEMENT_DURATION = 5 * TICKS_PER_SECOND; diff --git a/apps/web/src/lib/timeline/defaults.ts b/apps/web/src/lib/timeline/defaults.ts index bc968a3c..cf452800 100644 --- a/apps/web/src/lib/timeline/defaults.ts +++ b/apps/web/src/lib/timeline/defaults.ts @@ -1,77 +1,77 @@ -import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation"; -import type { TTimelineViewState } from "@/lib/project/types"; -import type { BlendMode, Transform } from "@/lib/rendering"; -import type { TextElement } from "./types"; - -const defaultTransform: Transform = { - scaleX: 1, - scaleY: 1, - position: { x: 0, y: 0 }, - rotate: 0, -}; - -const defaultOpacity = 1; -const defaultBlendMode: BlendMode = "normal"; -const defaultVolume = 0; - -const defaultTextLetterSpacing = 0; -const defaultTextLineHeight = 1.2; - -const defaultTextBackground = { - enabled: false, - color: "#000000", - cornerRadius: 0, - paddingX: 30, - paddingY: 42, - offsetX: 0, - offsetY: 0, -}; - -const defaultTextElement: Omit = { - type: "text", - name: "Text", - content: "Default text", - fontSize: 15, - fontFamily: "Arial", - color: "#ffffff", - background: { ...defaultTextBackground }, - textAlign: "center", - fontWeight: "normal", - fontStyle: "normal", - textDecoration: "none", - letterSpacing: defaultTextLetterSpacing, - lineHeight: defaultTextLineHeight, - duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS, - startTime: 0, - trimStart: 0, - trimEnd: 0, - transform: { - ...defaultTransform, - position: { ...defaultTransform.position }, - }, - opacity: defaultOpacity, -}; - -const defaultTimelineViewState: TTimelineViewState = { - zoomLevel: 1, - scrollLeft: 0, - playheadTime: 0, -}; - -export const DEFAULTS = { - element: { - transform: defaultTransform, - opacity: defaultOpacity, - blendMode: defaultBlendMode, - volume: defaultVolume, - }, - text: { - letterSpacing: defaultTextLetterSpacing, - lineHeight: defaultTextLineHeight, - background: defaultTextBackground, - element: defaultTextElement, - }, - timeline: { - viewState: defaultTimelineViewState, - }, -}; +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; +import type { TTimelineViewState } from "@/lib/project/types"; +import type { BlendMode, Transform } from "@/lib/rendering"; +import type { TextElement } from "./types"; + +const defaultTransform: Transform = { + scaleX: 1, + scaleY: 1, + position: { x: 0, y: 0 }, + rotate: 0, +}; + +const defaultOpacity = 1; +const defaultBlendMode: BlendMode = "normal"; +const defaultVolume = 0; + +const defaultTextLetterSpacing = 0; +const defaultTextLineHeight = 1.2; + +const defaultTextBackground = { + enabled: false, + color: "#000000", + cornerRadius: 0, + paddingX: 30, + paddingY: 42, + offsetX: 0, + offsetY: 0, +}; + +const defaultTextElement: Omit = { + type: "text", + name: "Text", + content: "Default text", + fontSize: 15, + fontFamily: "Arial", + color: "#ffffff", + background: { ...defaultTextBackground }, + textAlign: "center", + fontWeight: "normal", + fontStyle: "normal", + textDecoration: "none", + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + duration: DEFAULT_NEW_ELEMENT_DURATION, + startTime: 0, + trimStart: 0, + trimEnd: 0, + transform: { + ...defaultTransform, + position: { ...defaultTransform.position }, + }, + opacity: defaultOpacity, +}; + +const defaultTimelineViewState: TTimelineViewState = { + zoomLevel: 1, + scrollLeft: 0, + playheadTime: 0, +}; + +export const DEFAULTS = { + element: { + transform: defaultTransform, + opacity: defaultOpacity, + blendMode: defaultBlendMode, + volume: defaultVolume, + }, + text: { + letterSpacing: defaultTextLetterSpacing, + lineHeight: defaultTextLineHeight, + background: defaultTextBackground, + element: defaultTextElement, + }, + timeline: { + viewState: defaultTimelineViewState, + }, +}; diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts index 8efeab79..35d00613 100644 --- a/apps/web/src/lib/timeline/drag-utils.ts +++ b/apps/web/src/lib/timeline/drag-utils.ts @@ -1,19 +1,21 @@ -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; - -export function getMouseTimeFromClientX({ - clientX, - containerRect, - zoomLevel, - scrollLeft, -}: { - clientX: number; - containerRect: DOMRect; - zoomLevel: number; - scrollLeft: number; -}): number { - const mouseX = clientX - containerRect.left + scrollLeft; - return Math.max( - 0, - mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), - ); -} +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; + +export function getMouseTimeFromClientX({ + clientX, + containerRect, + zoomLevel, + scrollLeft, +}: { + clientX: number; + containerRect: DOMRect; + zoomLevel: number; + scrollLeft: number; +}): number { + const mouseX = clientX - containerRect.left + scrollLeft; + const seconds = Math.max( + 0, + mouseX / (BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel), + ); + return Math.round(seconds * TICKS_PER_SECOND); +} diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 96588903..e56acd3c 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -1,418 +1,420 @@ -import { DEFAULT_NEW_ELEMENT_DURATION_SECONDS } from "@/lib/timeline/creation"; -import { - MASKABLE_ELEMENT_TYPES, - RETIMABLE_ELEMENT_TYPES, - VISUAL_ELEMENT_TYPES, - type CreateEffectElement, - type CreateGraphicElement, - type CreateTimelineElement, - type CreateVideoElement, - type CreateImageElement, - type CreateStickerElement, - type CreateUploadAudioElement, - type CreateLibraryAudioElement, - type TextBackground, - type TextElement, - type TimelineElement, - type TimelineTrack, - type AudioElement, - type VideoElement, - type ImageElement, - type MaskableElement, - type RetimableElement, - type VisualElement, - type UploadAudioElement, -} from "@/lib/timeline"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import type { MediaType } from "@/lib/media/types"; -import { buildDefaultEffectInstance } from "@/lib/effects"; -import { buildDefaultGraphicInstance } from "@/lib/graphics"; -import type { ParamValues } from "@/lib/params"; -import { capitalizeFirstLetter } from "@/utils/string"; - -export function canElementHaveAudio( - element: TimelineElement, -): element is AudioElement | VideoElement { - return element.type === "audio" || element.type === "video"; -} - -export function isVisualElement( - element: TimelineElement, -): element is VisualElement { - return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type); -} - -export function isMaskableElement( - element: TimelineElement, -): element is MaskableElement { - return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); -} - -export function isRetimableElement( - element: TimelineElement, -): element is RetimableElement { - return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); -} - -export function canElementBeHidden( - element: TimelineElement, -): element is VisualElement { - return isVisualElement(element); -} - -export function hasElementEffects({ - element, -}: { - element: TimelineElement; -}): boolean { - return isVisualElement(element) && (element.effects?.length ?? 0) > 0; -} - -export function hasMediaId( - element: TimelineElement, -): element is UploadAudioElement | VideoElement | ImageElement { - return "mediaId" in element; -} - -export function requiresMediaId({ - element, -}: { - element: CreateTimelineElement; -}): boolean { - return ( - element.type === "video" || - element.type === "image" || - (element.type === "audio" && element.sourceType === "upload") - ); -} - -function buildTextBackground( - raw: Partial | undefined, -): TextBackground { - const color = raw?.color ?? DEFAULTS.text.element.background.color; - const enabled = raw?.enabled ?? color !== "transparent"; - return { - enabled, - color, - cornerRadius: raw?.cornerRadius, - paddingX: raw?.paddingX, - paddingY: raw?.paddingY, - offsetX: raw?.offsetX, - offsetY: raw?.offsetY, - }; -} - -export function buildTextElement({ - raw, - startTime, -}: { - raw: Partial>; - startTime: number; -}): CreateTimelineElement { - const t = raw as Partial; - - return { - type: "text", - name: t.name ?? DEFAULTS.text.element.name, - content: t.content ?? DEFAULTS.text.element.content, - duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS, - startTime, - trimStart: 0, - trimEnd: 0, - fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, - fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, - color: t.color ?? DEFAULTS.text.element.color, - background: buildTextBackground(t.background), - textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign, - fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight, - fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle, - textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration, - letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing, - lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight, - transform: t.transform ?? DEFAULTS.text.element.transform, - opacity: t.opacity ?? DEFAULTS.text.element.opacity, - blendMode: t.blendMode ?? DEFAULTS.element.blendMode, - }; -} - -export function buildEffectElement({ - effectType, - startTime, - duration, -}: { - effectType: string; - startTime: number; - duration?: number; -}): CreateEffectElement { - const instance = buildDefaultEffectInstance({ effectType }); - return { - type: "effect", - name: capitalizeFirstLetter({ string: instance.type }), - effectType, - params: instance.params, - duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION_SECONDS, - startTime, - trimStart: 0, - trimEnd: 0, - }; -} - -export function buildStickerElement({ - stickerId, - name, - startTime, - intrinsicWidth, - intrinsicHeight, -}: { - stickerId: string; - name?: string; - startTime: number; - intrinsicWidth?: number; - intrinsicHeight?: number; -}): CreateStickerElement { - const stickerNameFromId = - stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId; - return { - type: "sticker", - name: name ?? stickerNameFromId, - stickerId, - intrinsicWidth, - intrinsicHeight, - duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS, - startTime, - trimStart: 0, - trimEnd: 0, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - }; -} - -export function buildGraphicElement({ - definitionId, - name, - startTime, - params, -}: { - definitionId: string; - name?: string; - startTime: number; - params?: Partial; -}): CreateGraphicElement { - const instance = buildDefaultGraphicInstance({ definitionId }); - return { - type: "graphic", - name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), - definitionId: instance.definitionId, - params: { ...instance.params, ...(params ?? {}) } as ParamValues, - duration: DEFAULT_NEW_ELEMENT_DURATION_SECONDS, - startTime, - trimStart: 0, - trimEnd: 0, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - }; -} - -function buildVideoElement({ - mediaId, - name, - duration, - startTime, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; -}): CreateVideoElement { - return { - type: "video", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - muted: false, - isSourceAudioEnabled: true, - hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - volume: DEFAULTS.element.volume, - }; -} - -function buildImageElement({ - mediaId, - name, - duration, - startTime, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; -}): CreateImageElement { - return { - type: "image", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - hidden: false, - transform: { - ...DEFAULTS.element.transform, - position: { ...DEFAULTS.element.transform.position }, - }, - opacity: DEFAULTS.element.opacity, - blendMode: DEFAULTS.element.blendMode, - }; -} - -function buildUploadAudioElement({ - mediaId, - name, - duration, - startTime, - buffer, -}: { - mediaId: string; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateUploadAudioElement { - const element: CreateUploadAudioElement = { - type: "audio", - sourceType: "upload", - mediaId, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, - }; - if (buffer) { - element.buffer = buffer; - } - return element; -} - -export function buildElementFromMedia({ - mediaId, - mediaType, - name, - duration, - startTime, - buffer, -}: { - mediaId: string; - mediaType: MediaType; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateTimelineElement { - switch (mediaType) { - case "audio": - return buildUploadAudioElement({ - mediaId, - name, - duration, - startTime, - buffer, - }); - case "video": - return buildVideoElement({ mediaId, name, duration, startTime }); - case "image": - return buildImageElement({ mediaId, name, duration, startTime }); - } -} - -export function buildLibraryAudioElement({ - sourceUrl, - name, - duration, - startTime, - buffer, -}: { - sourceUrl: string; - name: string; - duration: number; - startTime: number; - buffer?: AudioBuffer; -}): CreateLibraryAudioElement { - const element: CreateLibraryAudioElement = { - type: "audio", - sourceType: "library", - sourceUrl, - name, - duration, - startTime, - trimStart: 0, - trimEnd: 0, - sourceDuration: duration, - volume: DEFAULTS.element.volume, - muted: false, - }; - if (buffer) { - element.buffer = buffer; - } - return element; -} - -export function getElementsAtTime({ - tracks, - time, -}: { - tracks: TimelineTrack[]; - time: number; -}): { trackId: string; elementId: string }[] { - const result: { trackId: string; elementId: string }[] = []; - - for (const track of tracks) { - for (const element of track.elements) { - const elementStart = element.startTime; - const elementEnd = element.startTime + element.duration; - - if (time > elementStart && time < elementEnd) { - result.push({ trackId: track.id, elementId: element.id }); - } - } - } - - return result; -} - -export function getElementFontFamilies({ - tracks, -}: { - tracks: TimelineTrack[]; -}): string[] { - const families = new Set(); - for (const track of tracks) { - for (const element of track.elements) { - if (element.type === "text" && element.fontFamily) { - families.add(element.fontFamily); - } - } - } - return [...families]; -} +import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; +import { + MASKABLE_ELEMENT_TYPES, + RETIMABLE_ELEMENT_TYPES, + VISUAL_ELEMENT_TYPES, + type CreateEffectElement, + type CreateGraphicElement, + type CreateTimelineElement, + type CreateVideoElement, + type CreateImageElement, + type CreateStickerElement, + type CreateUploadAudioElement, + type CreateLibraryAudioElement, + type TextBackground, + type TextElement, + type SceneTracks, + type TimelineElement, + type TimelineTrack, + type AudioElement, + type VideoElement, + type ImageElement, + type MaskableElement, + type RetimableElement, + type VisualElement, + type UploadAudioElement, +} from "@/lib/timeline"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import type { MediaType } from "@/lib/media/types"; +import { buildDefaultEffectInstance } from "@/lib/effects"; +import { buildDefaultGraphicInstance } from "@/lib/graphics"; +import type { ParamValues } from "@/lib/params"; +import { capitalizeFirstLetter } from "@/utils/string"; + +export function canElementHaveAudio( + element: TimelineElement, +): element is AudioElement | VideoElement { + return element.type === "audio" || element.type === "video"; +} + +export function isVisualElement( + element: TimelineElement, +): element is VisualElement { + return (VISUAL_ELEMENT_TYPES as readonly string[]).includes(element.type); +} + +export function isMaskableElement( + element: TimelineElement, +): element is MaskableElement { + return (MASKABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); +} + +export function isRetimableElement( + element: TimelineElement, +): element is RetimableElement { + return (RETIMABLE_ELEMENT_TYPES as readonly string[]).includes(element.type); +} + +export function canElementBeHidden( + element: TimelineElement, +): element is VisualElement { + return isVisualElement(element); +} + +export function hasElementEffects({ + element, +}: { + element: TimelineElement; +}): boolean { + return isVisualElement(element) && (element.effects?.length ?? 0) > 0; +} + +export function hasMediaId( + element: TimelineElement, +): element is UploadAudioElement | VideoElement | ImageElement { + return "mediaId" in element; +} + +export function requiresMediaId({ + element, +}: { + element: CreateTimelineElement; +}): boolean { + return ( + element.type === "video" || + element.type === "image" || + (element.type === "audio" && element.sourceType === "upload") + ); +} + +function buildTextBackground( + raw: Partial | undefined, +): TextBackground { + const color = raw?.color ?? DEFAULTS.text.element.background.color; + const enabled = raw?.enabled ?? color !== "transparent"; + return { + enabled, + color, + cornerRadius: raw?.cornerRadius, + paddingX: raw?.paddingX, + paddingY: raw?.paddingY, + offsetX: raw?.offsetX, + offsetY: raw?.offsetY, + }; +} + +export function buildTextElement({ + raw, + startTime, +}: { + raw: Partial>; + startTime: number; +}): CreateTimelineElement { + const t = raw as Partial; + + return { + type: "text", + name: t.name ?? DEFAULTS.text.element.name, + content: t.content ?? DEFAULTS.text.element.content, + duration: t.duration ?? DEFAULT_NEW_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + fontSize: t.fontSize ?? DEFAULTS.text.element.fontSize, + fontFamily: t.fontFamily ?? DEFAULTS.text.element.fontFamily, + color: t.color ?? DEFAULTS.text.element.color, + background: buildTextBackground(t.background), + textAlign: t.textAlign ?? DEFAULTS.text.element.textAlign, + fontWeight: t.fontWeight ?? DEFAULTS.text.element.fontWeight, + fontStyle: t.fontStyle ?? DEFAULTS.text.element.fontStyle, + textDecoration: t.textDecoration ?? DEFAULTS.text.element.textDecoration, + letterSpacing: t.letterSpacing ?? DEFAULTS.text.element.letterSpacing, + lineHeight: t.lineHeight ?? DEFAULTS.text.element.lineHeight, + transform: t.transform ?? DEFAULTS.text.element.transform, + opacity: t.opacity ?? DEFAULTS.text.element.opacity, + blendMode: t.blendMode ?? DEFAULTS.element.blendMode, + }; +} + +export function buildEffectElement({ + effectType, + startTime, + duration, +}: { + effectType: string; + startTime: number; + duration?: number; +}): CreateEffectElement { + const instance = buildDefaultEffectInstance({ effectType }); + return { + type: "effect", + name: capitalizeFirstLetter({ string: instance.type }), + effectType, + params: instance.params, + duration: duration ?? DEFAULT_NEW_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + }; +} + +export function buildStickerElement({ + stickerId, + name, + startTime, + intrinsicWidth, + intrinsicHeight, +}: { + stickerId: string; + name?: string; + startTime: number; + intrinsicWidth?: number; + intrinsicHeight?: number; +}): CreateStickerElement { + const stickerNameFromId = + stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId; + return { + type: "sticker", + name: name ?? stickerNameFromId, + stickerId, + intrinsicWidth, + intrinsicHeight, + duration: DEFAULT_NEW_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + }; +} + +export function buildGraphicElement({ + definitionId, + name, + startTime, + params, +}: { + definitionId: string; + name?: string; + startTime: number; + params?: Partial; +}): CreateGraphicElement { + const instance = buildDefaultGraphicInstance({ definitionId }); + return { + type: "graphic", + name: name ?? capitalizeFirstLetter({ string: instance.definitionId }), + definitionId: instance.definitionId, + params: { ...instance.params, ...(params ?? {}) } as ParamValues, + duration: DEFAULT_NEW_ELEMENT_DURATION, + startTime, + trimStart: 0, + trimEnd: 0, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + }; +} + +function buildVideoElement({ + mediaId, + name, + duration, + startTime, +}: { + mediaId: string; + name: string; + duration: number; + startTime: number; +}): CreateVideoElement { + return { + type: "video", + mediaId, + name, + duration, + startTime, + trimStart: 0, + trimEnd: 0, + sourceDuration: duration, + muted: false, + isSourceAudioEnabled: true, + hidden: false, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + volume: DEFAULTS.element.volume, + }; +} + +function buildImageElement({ + mediaId, + name, + duration, + startTime, +}: { + mediaId: string; + name: string; + duration: number; + startTime: number; +}): CreateImageElement { + return { + type: "image", + mediaId, + name, + duration, + startTime, + trimStart: 0, + trimEnd: 0, + hidden: false, + transform: { + ...DEFAULTS.element.transform, + position: { ...DEFAULTS.element.transform.position }, + }, + opacity: DEFAULTS.element.opacity, + blendMode: DEFAULTS.element.blendMode, + }; +} + +function buildUploadAudioElement({ + mediaId, + name, + duration, + startTime, + buffer, +}: { + mediaId: string; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; +}): CreateUploadAudioElement { + const element: CreateUploadAudioElement = { + type: "audio", + sourceType: "upload", + mediaId, + name, + duration, + startTime, + trimStart: 0, + trimEnd: 0, + sourceDuration: duration, + volume: DEFAULTS.element.volume, + muted: false, + }; + if (buffer) { + element.buffer = buffer; + } + return element; +} + +export function buildElementFromMedia({ + mediaId, + mediaType, + name, + duration, + startTime, + buffer, +}: { + mediaId: string; + mediaType: MediaType; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; +}): CreateTimelineElement { + switch (mediaType) { + case "audio": + return buildUploadAudioElement({ + mediaId, + name, + duration, + startTime, + buffer, + }); + case "video": + return buildVideoElement({ mediaId, name, duration, startTime }); + case "image": + return buildImageElement({ mediaId, name, duration, startTime }); + } +} + +export function buildLibraryAudioElement({ + sourceUrl, + name, + duration, + startTime, + buffer, +}: { + sourceUrl: string; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; +}): CreateLibraryAudioElement { + const element: CreateLibraryAudioElement = { + type: "audio", + sourceType: "library", + sourceUrl, + name, + duration, + startTime, + trimStart: 0, + trimEnd: 0, + sourceDuration: duration, + volume: DEFAULTS.element.volume, + muted: false, + }; + if (buffer) { + element.buffer = buffer; + } + return element; +} + +export function getElementsAtTime({ + tracks, + time, +}: { + tracks: SceneTracks; + time: number; +}): { trackId: string; elementId: string }[] { + const result: { trackId: string; elementId: string }[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + + for (const track of orderedTracks) { + for (const element of track.elements) { + const elementStart = element.startTime; + const elementEnd = element.startTime + element.duration; + + if (time > elementStart && time < elementEnd) { + result.push({ trackId: track.id, elementId: element.id }); + } + } + } + + return result; +} + +export function getElementFontFamilies({ + tracks, +}: { + tracks: SceneTracks; +}): string[] { + const families = new Set(); + for (const track of [...tracks.overlay, tracks.main, ...tracks.audio]) { + for (const element of track.elements) { + if (element.type === "text" && element.fontFamily) { + families.add(element.fontFamily); + } + } + } + return [...families]; +} diff --git a/apps/web/src/lib/timeline/index.ts b/apps/web/src/lib/timeline/index.ts index 469b916e..5ee079b1 100644 --- a/apps/web/src/lib/timeline/index.ts +++ b/apps/web/src/lib/timeline/index.ts @@ -1,4 +1,4 @@ -import type { TimelineTrack } from "./types"; +import type { SceneTracks } from "./types"; export * from "./types"; export * from "./drag"; @@ -8,17 +8,17 @@ export * from "./element-utils"; export * from "./audio-separation"; export * from "./zoom-utils"; export * from "./ruler-utils"; -export * from "./ripple-utils"; export * from "./pixel-utils"; export function calculateTotalDuration({ tracks, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; }): number { - if (tracks.length === 0) return 0; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + if (orderedTracks.length === 0) return 0; - const trackEndTimes = tracks.map((track) => + const trackEndTimes = orderedTracks.map((track) => track.elements.reduce((maxEnd, element) => { const elementEnd = element.startTime + element.duration; return Math.max(maxEnd, elementEnd); diff --git a/apps/web/src/lib/timeline/pixel-utils.ts b/apps/web/src/lib/timeline/pixel-utils.ts index e93b5ab5..10f258aa 100644 --- a/apps/web/src/lib/timeline/pixel-utils.ts +++ b/apps/web/src/lib/timeline/pixel-utils.ts @@ -1,79 +1,80 @@ -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; - -export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2; - -function getDevicePixelRatio({ - devicePixelRatio, -}: { - devicePixelRatio?: number; -}): number { - if ( - typeof devicePixelRatio === "number" && - Number.isFinite(devicePixelRatio) && - devicePixelRatio > 0 - ) { - return devicePixelRatio; - } - - if (typeof window === "undefined") { - return 1; - } - - if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) { - return window.devicePixelRatio; - } - - return 1; -} - -export function getTimelinePixelsPerSecond({ - zoomLevel, -}: { - zoomLevel: number; -}): number { - return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; -} - -export function timelineTimeToPixels({ - time, - zoomLevel, -}: { - time: number; - zoomLevel: number; -}): number { - return time * getTimelinePixelsPerSecond({ zoomLevel }); -} - -export function snapPixelToDeviceGrid({ - pixel, - devicePixelRatio, -}: { - pixel: number; - devicePixelRatio?: number; -}): number { - const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio }); - return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio; -} - -export function timelineTimeToSnappedPixels({ - time, - zoomLevel, - devicePixelRatio, -}: { - time: number; - zoomLevel: number; - devicePixelRatio?: number; -}): number { - const rawPixel = timelineTimeToPixels({ time, zoomLevel }); - return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio }); -} - -export function getCenteredLineLeft({ - centerPixel, - lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX, -}: { - centerPixel: number; - lineWidthPx?: number; -}): number { - return centerPixel - lineWidthPx / 2; -} +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; + +export const TIMELINE_INDICATOR_LINE_WIDTH_PX = 2; + +function getDevicePixelRatio({ + devicePixelRatio, +}: { + devicePixelRatio?: number; +}): number { + if ( + typeof devicePixelRatio === "number" && + Number.isFinite(devicePixelRatio) && + devicePixelRatio > 0 + ) { + return devicePixelRatio; + } + + if (typeof window === "undefined") { + return 1; + } + + if (Number.isFinite(window.devicePixelRatio) && window.devicePixelRatio > 0) { + return window.devicePixelRatio; + } + + return 1; +} + +export function getTimelinePixelsPerSecond({ + zoomLevel, +}: { + zoomLevel: number; +}): number { + return BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; +} + +export function timelineTimeToPixels({ + time, + zoomLevel, +}: { + time: number; + zoomLevel: number; +}): number { + return (time / TICKS_PER_SECOND) * getTimelinePixelsPerSecond({ zoomLevel }); +} + +export function snapPixelToDeviceGrid({ + pixel, + devicePixelRatio, +}: { + pixel: number; + devicePixelRatio?: number; +}): number { + const safeDevicePixelRatio = getDevicePixelRatio({ devicePixelRatio }); + return Math.round(pixel * safeDevicePixelRatio) / safeDevicePixelRatio; +} + +export function timelineTimeToSnappedPixels({ + time, + zoomLevel, + devicePixelRatio, +}: { + time: number; + zoomLevel: number; + devicePixelRatio?: number; +}): number { + const rawPixel = timelineTimeToPixels({ time, zoomLevel }); + return snapPixelToDeviceGrid({ pixel: rawPixel, devicePixelRatio }); +} + +export function getCenteredLineLeft({ + centerPixel, + lineWidthPx = TIMELINE_INDICATOR_LINE_WIDTH_PX, +}: { + centerPixel: number; + lineWidthPx?: number; +}): number { + return centerPixel - lineWidthPx / 2; +} diff --git a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts index e067fb16..4d39f5d8 100644 --- a/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts +++ b/apps/web/src/lib/timeline/placement/__tests__/resolve.test.ts @@ -4,6 +4,8 @@ import type { AudioTrack, GraphicElement, GraphicTrack, + OverlayTrack, + SceneTracks, TextElement, TextTrack, TimelineTrack, @@ -134,53 +136,45 @@ type BuildTrackParams = id: string; type: "audio"; elements?: AudioTrack["elements"]; - isMain?: boolean; } | { id: string; type: "graphic"; elements?: GraphicTrack["elements"]; - isMain?: boolean; } | { id: string; type: "text"; elements?: TextTrack["elements"]; - isMain?: boolean; } | { id: string; type: "video"; elements?: VideoTrack["elements"]; - isMain?: boolean; }; function buildTrack(params: { id: string; type: "audio"; elements?: AudioTrack["elements"]; - isMain?: boolean; }): AudioTrack; function buildTrack(params: { id: string; type: "graphic"; elements?: GraphicTrack["elements"]; - isMain?: boolean; }): GraphicTrack; function buildTrack(params: { id: string; type: "text"; elements?: TextTrack["elements"]; - isMain?: boolean; }): TextTrack; function buildTrack(params: { id: string; type: "video"; elements?: VideoTrack["elements"]; - isMain?: boolean; }): VideoTrack; function buildTrack(params: BuildTrackParams): TimelineTrack { - const { id, type, isMain = false } = params; + const { id, type } = params; switch (type) { case "audio": @@ -213,7 +207,6 @@ function buildTrack(params: BuildTrackParams): TimelineTrack { type: "video", name: id, elements: params.elements ?? [], - isMain, muted: false, hidden: false, }; @@ -234,9 +227,32 @@ function buildTimeSpan({ return { startTime, duration, excludeElementId }; } +function buildSceneTracks({ + overlay = [], + main, + audio = [], +}: { + overlay?: Array; + main?: VideoTrack; + audio?: Array; +}): SceneTracks { + return { + overlay, + main: + main ?? + buildTrack({ + id: "video-main", + type: "video", + }), + audio, + }; +} + describe("resolveTrackPlacement", () => { test("explicit returns the requested compatible track", () => { - const tracks = [buildTrack({ id: "text-1", type: "text" })]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + }); expect( resolveTrackPlacement({ @@ -254,7 +270,9 @@ describe("resolveTrackPlacement", () => { }); test("explicit rejects missing and incompatible tracks", () => { - const tracks = [buildTrack({ id: "video-1", type: "video", isMain: true })]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-1", type: "video" }), + }); expect( resolveTrackPlacement({ @@ -276,16 +294,18 @@ describe("resolveTrackPlacement", () => { }); test("firstAvailable picks the first compatible track without overlap", () => { - const tracks = [ - buildTrack({ - id: "text-1", - type: "text", - elements: [ - buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ id: "text-2", type: "text" }), - ]; + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "text-1", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ id: "text-2", type: "text" }), + ], + }); expect( resolveTrackPlacement({ @@ -303,21 +323,22 @@ describe("resolveTrackPlacement", () => { }); test("firstAvailable creates a new track when all compatible tracks are full", () => { - const tracks = [ - buildTrack({ - id: "graphic-1", - type: "graphic", - elements: [ - buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "graphic-1", + type: "graphic", + elements: [ + buildElement({ id: "a", type: "graphic", startTime: 0, duration: 5 }), + ], + }), + ], + main: buildTrack({ id: "video-main", type: "video", - isMain: true, }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -329,13 +350,15 @@ describe("resolveTrackPlacement", () => { ).toEqual({ kind: "newTrack", trackType: "graphic", - insertIndex: 1, + insertIndex: 0, insertPosition: null, }); }); test("preferIndex uses the preferred track when it fits", () => { - const tracks = [buildTrack({ id: "audio-1", type: "audio" })]; + const tracks = buildSceneTracks({ + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -349,18 +372,18 @@ describe("resolveTrackPlacement", () => { }, }), ).toEqual({ - kind: "existingTrack", - trackId: "audio-1", - trackIndex: 0, + kind: "newTrack", trackType: "audio", + insertIndex: 1, + insertPosition: "below", }); }); test("preferIndex creates a new overlay track above the main track", () => { - const tracks = [ - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -382,11 +405,11 @@ describe("resolveTrackPlacement", () => { }); test("preferIndex keeps audio tracks below the main track", () => { - const tracks = [ - buildTrack({ id: "text-1", type: "text" }), - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -409,17 +432,19 @@ describe("resolveTrackPlacement", () => { }); test("aboveSource tries the track above source, then any compatible track", () => { - const tracks = [ - buildTrack({ id: "text-top", type: "text" }), - buildTrack({ - id: "text-middle", - type: "text", - elements: [ - buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ id: "text-source", type: "text" }), - ]; + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ id: "text-top", type: "text" }), + buildTrack({ + id: "text-middle", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ id: "text-source", type: "text" }), + ], + }); expect( resolveTrackPlacement({ @@ -436,23 +461,25 @@ describe("resolveTrackPlacement", () => { }); }); - test("aboveSource creates a new track near the source when none fit", () => { - const tracks = [ - buildTrack({ - id: "text-top", - type: "text", - elements: [ - buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), - ], - }), - buildTrack({ - id: "text-source", - type: "text", - elements: [ - buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }), - ], - }), - ]; + test("aboveSource creates a new overlay track in the overlay zone when none fit", () => { + const tracks = buildSceneTracks({ + overlay: [ + buildTrack({ + id: "text-top", + type: "text", + elements: [ + buildElement({ id: "a", type: "text", startTime: 0, duration: 5 }), + ], + }), + buildTrack({ + id: "text-source", + type: "text", + elements: [ + buildElement({ id: "b", type: "text", startTime: 0, duration: 5 }), + ], + }), + ], + }); expect( resolveTrackPlacement({ @@ -464,16 +491,16 @@ describe("resolveTrackPlacement", () => { ).toEqual({ kind: "newTrack", trackType: "text", - insertIndex: 1, + insertIndex: 0, insertPosition: null, }); }); test("alwaysNew honors highest and default insertion rules", () => { - const tracks = [ - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ @@ -505,16 +532,18 @@ describe("resolveTrackPlacement", () => { }); test("batch time spans reject tracks when any span overlaps", () => { - const tracks = [ - buildTrack({ - id: "audio-1", - type: "audio", - elements: [ - buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), - buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), - ], - }), - ]; + const tracks = buildSceneTracks({ + audio: [ + buildTrack({ + id: "audio-1", + type: "audio", + elements: [ + buildElement({ id: "a", type: "audio", startTime: 0, duration: 2 }), + buildElement({ id: "b", type: "audio", startTime: 5, duration: 2 }), + ], + }), + ], + }); expect( resolveTrackPlacement({ @@ -534,10 +563,10 @@ describe("resolveTrackPlacement", () => { }); }); - test("handles empty timelines, single tracks, and track-type derivation", () => { + test("handles main-only timelines, single tracks, and track-type derivation", () => { expect( resolveTrackPlacement({ - tracks: [], + tracks: buildSceneTracks({}), elementType: "video", timeSpans: [buildTimeSpan({ startTime: 0, duration: 3 })], strategy: { @@ -551,12 +580,14 @@ describe("resolveTrackPlacement", () => { kind: "newTrack", trackType: "video", insertIndex: 0, - insertPosition: null, + insertPosition: "above", }); expect( resolveTrackPlacement({ - tracks: [buildTrack({ id: "audio-1", type: "audio" })], + tracks: buildSceneTracks({ + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }), elementType: "audio", timeSpans: [], strategy: { type: "alwaysNew", position: "default" }, @@ -564,22 +595,21 @@ describe("resolveTrackPlacement", () => { ).toEqual({ kind: "newTrack", trackType: "audio", - insertIndex: 1, + insertIndex: 2, insertPosition: null, }); }); test("existingTrack on main video includes adjustedStartTime when start snaps", () => { - const tracks = [ - buildTrack({ + const tracks = buildSceneTracks({ + main: buildTrack({ id: "video-main", type: "video", - isMain: true, elements: [ buildElement({ id: "a", type: "video", startTime: 5, duration: 5 }), ], }), - ]; + }); expect( resolveTrackPlacement({ @@ -598,11 +628,11 @@ describe("resolveTrackPlacement", () => { }); test("preferIndex uses vertical drag direction when hovered track is incompatible", () => { - const tracks = [ - buildTrack({ id: "text-1", type: "text" }), - buildTrack({ id: "video-main", type: "video", isMain: true }), - buildTrack({ id: "audio-1", type: "audio" }), - ]; + const tracks = buildSceneTracks({ + overlay: [buildTrack({ id: "text-1", type: "text" })], + main: buildTrack({ id: "video-main", type: "video" }), + audio: [buildTrack({ id: "audio-1", type: "audio" })], + }); expect( resolveTrackPlacement({ diff --git a/apps/web/src/lib/timeline/placement/apply.ts b/apps/web/src/lib/timeline/placement/apply.ts index 4ff5d50d..639b1b42 100644 --- a/apps/web/src/lib/timeline/placement/apply.ts +++ b/apps/web/src/lib/timeline/placement/apply.ts @@ -1,7 +1,18 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + OverlayTrack, + SceneTracks, + TextTrack, + TimelineElement, + TimelineTrack, + VideoTrack, +} from "@/lib/timeline"; import { generateUUID } from "@/utils/id"; import { buildEmptyTrack } from "./track-factory"; import type { PlacementResult } from "./types"; +import { updateTrackInSceneTracks } from "@/lib/timeline/track-element-update"; export function applyPlacement({ tracks, @@ -9,37 +20,139 @@ export function applyPlacement({ elements, newTrackInsertIndexOverride, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; placementResult: PlacementResult; elements: TimelineElement[]; newTrackInsertIndexOverride?: number; -}): { updatedTracks: TimelineTrack[]; targetTrackId: string } | null { +}): { updatedTracks: SceneTracks; targetTrackId: string } | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; if (placementResult.kind === "existingTrack") { - const targetTrack = tracks[placementResult.trackIndex]; + const targetTrack = orderedTracks[placementResult.trackIndex]; if (!targetTrack) { return null; } - const updatedTracks = tracks.map((track, trackIndex) => - trackIndex === placementResult.trackIndex - ? { - ...track, - elements: [...track.elements, ...elements], - } - : track, - ) as TimelineTrack[]; + const updatedTracks = updateTrackInSceneTracks({ + tracks, + trackId: targetTrack.id, + update: (track) => ({ + ...track, + elements: [...track.elements, ...elements], + }), + }); return { updatedTracks, targetTrackId: targetTrack.id }; } const newTrackId = generateUUID(); - const newTrack = { - ...buildEmptyTrack({ id: newTrackId, type: placementResult.trackType }), - elements, - } as TimelineTrack; const insertIndex = newTrackInsertIndexOverride ?? placementResult.insertIndex; - const updatedTracks = [...tracks]; - updatedTracks.splice(insertIndex, 0, newTrack); + const updatedTracks = + placementResult.trackType === "audio" + ? { + ...tracks, + audio: insertIntoAudioTracks({ + tracks, + insertIndex, + track: buildPlacedAudioTrack({ + id: newTrackId, + elements, + }), + }), + } + : { + ...tracks, + overlay: insertIntoOverlayTracks({ + tracks, + insertIndex, + track: buildPlacedOverlayTrack({ + id: newTrackId, + type: placementResult.trackType, + elements, + }), + }), + }; return { updatedTracks, targetTrackId: newTrackId }; } + +function insertIntoOverlayTracks({ + tracks, + insertIndex, + track, +}: { + tracks: SceneTracks; + insertIndex: number; + track: OverlayTrack; +}): OverlayTrack[] { + const normalizedInsertIndex = Math.max( + 0, + Math.min(insertIndex, tracks.overlay.length), + ); + const nextTracks = [...tracks.overlay]; + nextTracks.splice(normalizedInsertIndex, 0, track); + return nextTracks; +} + +function insertIntoAudioTracks({ + tracks, + insertIndex, + track, +}: { + tracks: SceneTracks; + insertIndex: number; + track: AudioTrack; +}): AudioTrack[] { + const audioInsertIndex = Math.max( + 0, + Math.min(insertIndex - tracks.overlay.length - 1, tracks.audio.length), + ); + const nextTracks = [...tracks.audio]; + nextTracks.splice(audioInsertIndex, 0, track); + return nextTracks; +} + +function buildPlacedAudioTrack({ + id, + elements, +}: { + id: string; + elements: TimelineElement[]; +}): AudioTrack { + return { + ...buildEmptyTrack({ id, type: "audio" }), + elements: elements as AudioTrack["elements"], + }; +} + +function buildPlacedOverlayTrack({ + id, + type, + elements, +}: { + id: string; + type: Exclude; + elements: TimelineElement[]; +}): OverlayTrack { + switch (type) { + case "video": + return { + ...buildEmptyTrack({ id, type: "video" }), + elements: elements as VideoTrack["elements"], + }; + case "text": + return { + ...buildEmptyTrack({ id, type: "text" }), + elements: elements as TextTrack["elements"], + }; + case "graphic": + return { + ...buildEmptyTrack({ id, type: "graphic" }), + elements: elements as GraphicTrack["elements"], + }; + case "effect": + return { + ...buildEmptyTrack({ id, type: "effect" }), + elements: elements as EffectTrack["elements"], + }; + } +} diff --git a/apps/web/src/lib/timeline/placement/index.ts b/apps/web/src/lib/timeline/placement/index.ts index 091acc33..530cb69f 100644 --- a/apps/web/src/lib/timeline/placement/index.ts +++ b/apps/web/src/lib/timeline/placement/index.ts @@ -1,13 +1,7 @@ export { applyPlacement } from "./apply"; export { canElementGoOnTrack, validateElementTrackCompatibility } from "./compatibility"; export { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack } from "./insert-index"; -export { - enforceMainTrackStart, - ensureMainTrack, - getEarliestMainTrackElement, - getMainTrack, - isMainTrack, -} from "./main-track"; +export { MAIN_TRACK_NAME, enforceMainTrackStart, getEarliestMainTrackElement } from "./main-track"; export { resolveTrackPlacement } from "./resolve"; export { buildEmptyTrack } from "./track-factory"; export type { diff --git a/apps/web/src/lib/timeline/placement/insert-index.ts b/apps/web/src/lib/timeline/placement/insert-index.ts index fecb893a..04fa5a4a 100644 --- a/apps/web/src/lib/timeline/placement/insert-index.ts +++ b/apps/web/src/lib/timeline/placement/insert-index.ts @@ -1,44 +1,32 @@ -import type { TrackType, TimelineTrack } from "@/lib/timeline"; -import { isMainTrack } from "./main-track"; +import type { SceneTracks, TrackType } from "@/lib/timeline"; export function getDefaultInsertIndexForTrack({ tracks, trackType, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; }): number { if (trackType === "audio") { - return tracks.length; + return tracks.overlay.length + 1 + tracks.audio.length; } if (trackType === "effect") { return 0; } - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); - if (mainTrackIndex >= 0) { - return mainTrackIndex; - } - - const firstAudioTrackIndex = tracks.findIndex((track) => track.type === "audio"); - if (firstAudioTrackIndex >= 0) { - return firstAudioTrackIndex; - } - - return tracks.length; + return tracks.overlay.length; } export function getHighestInsertIndexForTrack({ tracks, trackType, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; }): number { - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); if (trackType === "audio") { - return mainTrackIndex >= 0 ? mainTrackIndex + 1 : tracks.length; + return tracks.overlay.length + 1; } return 0; @@ -50,12 +38,13 @@ export function resolvePreferredNewTrackPlacement({ preferredIndex, direction, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; preferredIndex: number; direction: "above" | "below"; }): { insertIndex: number; insertPosition: "above" | "below" | null } { - if (tracks.length === 0) { + const trackCount = tracks.overlay.length + 1 + tracks.audio.length; + if (trackCount === 0) { return { insertIndex: 0, insertPosition: trackType === "audio" ? "below" : null, @@ -64,9 +53,9 @@ export function resolvePreferredNewTrackPlacement({ const safePreferredIndex = Math.min( Math.max(preferredIndex, 0), - tracks.length - 1, + trackCount - 1, ); - const mainTrackIndex = tracks.findIndex((track) => isMainTrack(track)); + const mainTrackIndex = tracks.overlay.length; if (trackType === "audio") { if (safePreferredIndex <= mainTrackIndex) { diff --git a/apps/web/src/lib/timeline/placement/main-track.ts b/apps/web/src/lib/timeline/placement/main-track.ts index 1783352c..7e206517 100644 --- a/apps/web/src/lib/timeline/placement/main-track.ts +++ b/apps/web/src/lib/timeline/placement/main-track.ts @@ -1,55 +1,14 @@ -import type { TimelineElement, TimelineTrack, VideoTrack } from "@/lib/timeline"; -import { generateUUID } from "@/utils/id"; +import type { SceneTracks, TimelineElement, VideoTrack } from "@/lib/timeline"; -const MAIN_TRACK_NAME = "Main Track"; - -export function isMainTrack(track: TimelineTrack): track is VideoTrack { - return track.type === "video" && track.isMain === true; -} - -export function getMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): VideoTrack | null { - return tracks.find((track) => isMainTrack(track)) ?? null; -} - -export function ensureMainTrack({ - tracks, -}: { - tracks: TimelineTrack[]; -}): TimelineTrack[] { - if (tracks.some((track) => isMainTrack(track))) { - return tracks; - } - - return [ - { - id: generateUUID(), - name: MAIN_TRACK_NAME, - type: "video", - elements: [], - muted: false, - isMain: true, - hidden: false, - }, - ...tracks, - ]; -} +export const MAIN_TRACK_NAME = "Main Track"; export function getEarliestMainTrackElement({ - tracks, + mainTrack, excludeElementId, }: { - tracks: TimelineTrack[]; + mainTrack: VideoTrack; excludeElementId?: string; }): TimelineElement | null { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack) { - return null; - } - const elements = mainTrack.elements.filter((element) => { return !excludeElementId || element.id !== excludeElementId; }); @@ -70,18 +29,17 @@ export function enforceMainTrackStart({ requestedStartTime, excludeElementId, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; targetTrackId: string; requestedStartTime: number; excludeElementId?: string; }): number { - const mainTrack = getMainTrack({ tracks }); - if (!mainTrack || mainTrack.id !== targetTrackId) { + if (tracks.main.id !== targetTrackId) { return requestedStartTime; } const earliestElement = getEarliestMainTrackElement({ - tracks, + mainTrack: tracks.main, excludeElementId, }); if (!earliestElement) { diff --git a/apps/web/src/lib/timeline/placement/resolve.ts b/apps/web/src/lib/timeline/placement/resolve.ts index 35718250..67943912 100644 --- a/apps/web/src/lib/timeline/placement/resolve.ts +++ b/apps/web/src/lib/timeline/placement/resolve.ts @@ -1,4 +1,4 @@ -import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TrackType, TimelineTrack } from "@/lib/timeline"; import { getDefaultInsertIndexForTrack, getHighestInsertIndexForTrack, @@ -15,7 +15,7 @@ import type { } from "./types"; type ResolveTrackPlacementParams = PlacementSubject & { - tracks: TimelineTrack[]; + tracks: SceneTracks; timeSpans: PlacementTimeSpan[]; strategy: PlacementStrategy; }; @@ -28,7 +28,7 @@ function buildExistingTrackResult({ }: { track: TimelineTrack; trackIndex: number; - tracks: TimelineTrack[]; + tracks: SceneTracks; timeSpans: PlacementTimeSpan[]; }): PlacementResult { const firstSpan = timeSpans[0]; @@ -90,7 +90,7 @@ function resolveAlwaysNewTrack({ trackType, position, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackType: TrackType; position: "highest" | "default"; }): PlacementResult { @@ -134,6 +134,7 @@ export function resolveTrackPlacement({ tracks, ...placement }: ResolveTrackPlacementParams): PlacementResult | null { + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; const trackType = "trackType" in placement ? placement.trackType @@ -143,30 +144,35 @@ export function resolveTrackPlacement({ const { timeSpans, strategy } = placement; if (strategy.type === "explicit") { - const trackIndex = tracks.findIndex( + const trackIndex = orderedTracks.findIndex( (track) => track.id === strategy.trackId, ); if (trackIndex < 0) { return null; } - const track = tracks[trackIndex]; + const track = orderedTracks[trackIndex]; if (track.type !== trackType) { return null; } - return buildExistingTrackResult({ track, trackIndex, tracks, timeSpans }); + return buildExistingTrackResult({ + track, + trackIndex, + tracks, + timeSpans, + }); } if (strategy.type === "firstAvailable") { const existingTrackIndex = findFirstAvailableTrackIndex({ - tracks, + tracks: orderedTracks, trackType, timeSpans, }); if (existingTrackIndex >= 0) { return buildExistingTrackResult({ - track: tracks[existingTrackIndex], + track: orderedTracks[existingTrackIndex], trackIndex: existingTrackIndex, tracks, timeSpans, @@ -176,12 +182,12 @@ export function resolveTrackPlacement({ return resolveAlwaysNewTrack({ tracks, trackType, - position: "default", + position: "highest", }); } if (strategy.type === "preferIndex") { - const preferredTrack = tracks[strategy.trackIndex]; + const preferredTrack = orderedTracks[strategy.trackIndex]; const isPreferredTrackCompatible = !!preferredTrack && preferredTrack.type === trackType; const canUseExistingTrack = @@ -220,7 +226,7 @@ export function resolveTrackPlacement({ if (strategy.type === "aboveSource") { const aboveTrackIndex = strategy.sourceTrackIndex - 1; - const aboveTrack = tracks[aboveTrackIndex]; + const aboveTrack = orderedTracks[aboveTrackIndex]; if ( aboveTrack && aboveTrack.type === trackType && @@ -238,26 +244,23 @@ export function resolveTrackPlacement({ } const firstAvailableTrackIndex = findFirstAvailableTrackIndex({ - tracks, + tracks: orderedTracks, trackType, timeSpans, }); if (firstAvailableTrackIndex >= 0) { return buildExistingTrackResult({ - track: tracks[firstAvailableTrackIndex], + track: orderedTracks[firstAvailableTrackIndex], trackIndex: firstAvailableTrackIndex, tracks, timeSpans, }); } - const insertIndex = - strategy.sourceTrackIndex >= 0 - ? strategy.sourceTrackIndex - : getHighestInsertIndexForTrack({ - tracks, - trackType, - }); + const insertIndex = getHighestInsertIndexForTrack({ + tracks, + trackType, + }); return buildNewTrackResult({ trackType, diff --git a/apps/web/src/lib/timeline/placement/track-factory.ts b/apps/web/src/lib/timeline/placement/track-factory.ts index ee056261..b73c5155 100644 --- a/apps/web/src/lib/timeline/placement/track-factory.ts +++ b/apps/web/src/lib/timeline/placement/track-factory.ts @@ -1,6 +1,69 @@ import { DEFAULT_TRACK_NAMES } from "@/lib/timeline/tracks"; -import type { TrackType, TimelineTrack } from "@/lib/timeline"; +import type { + AudioTrack, + EffectTrack, + GraphicTrack, + TextTrack, + TrackType, + TimelineTrack, + VideoTrack, +} from "@/lib/timeline"; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "video"; + name?: string; +}): VideoTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "text"; + name?: string; +}): TextTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "audio"; + name?: string; +}): AudioTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "graphic"; + name?: string; +}): GraphicTrack; +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: "effect"; + name?: string; +}): EffectTrack; + +export function buildEmptyTrack({ + id, + type, + name, +}: { + id: string; + type: TrackType; + name?: string; +}): TimelineTrack; export function buildEmptyTrack({ id, type, @@ -21,7 +84,6 @@ export function buildEmptyTrack({ elements: [], hidden: false, muted: false, - isMain: false, }; case "text": return { diff --git a/apps/web/src/lib/timeline/ripple-utils.ts b/apps/web/src/lib/timeline/ripple-utils.ts deleted file mode 100644 index 5e17e9a3..00000000 --- a/apps/web/src/lib/timeline/ripple-utils.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { TimelineElement } from "@/lib/timeline"; - -export function rippleShiftElements({ - elements, - afterTime, - shiftAmount, -}: { - elements: TimelineElement[]; - afterTime: number; - shiftAmount: number; -}): TimelineElement[] { - return elements.map((element) => - element.startTime >= afterTime - ? { ...element, startTime: element.startTime - shiftAmount } - : element, - ); -} diff --git a/apps/web/src/lib/timeline/ruler-utils.ts b/apps/web/src/lib/timeline/ruler-utils.ts index 732d9ea4..ea2982aa 100644 --- a/apps/web/src/lib/timeline/ruler-utils.ts +++ b/apps/web/src/lib/timeline/ruler-utils.ts @@ -1,4 +1,6 @@ +import type { FrameRate } from "opencut-wasm"; import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { frameRateToFloat } from "@/lib/fps/utils"; /** * frame intervals for labels - starts at 2 so there's always at least @@ -54,15 +56,16 @@ export function getRulerConfig({ fps, }: { zoomLevel: number; - fps: number; + fps: FrameRate; }): RulerConfig { + const fpsFloat = frameRateToFloat(fps); const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - const pixelsPerFrame = pixelsPerSecond / fps; + const pixelsPerFrame = pixelsPerSecond / fpsFloat; const labelIntervalSeconds = findOptimalInterval({ pixelsPerFrame, pixelsPerSecond, - fps, + fps: fpsFloat, minSpacingPx: MIN_LABEL_SPACING_PX, frameIntervals: LABEL_FRAME_INTERVALS, }); @@ -70,7 +73,7 @@ export function getRulerConfig({ const rawTickIntervalSeconds = findOptimalInterval({ pixelsPerFrame, pixelsPerSecond, - fps, + fps: fpsFloat, minSpacingPx: MIN_TICK_SPACING_PX, frameIntervals: TICK_FRAME_INTERVALS, }); @@ -81,7 +84,7 @@ export function getRulerConfig({ labelIntervalSeconds, pixelsPerFrame, pixelsPerSecond, - fps, + fps: fpsFloat, }); return { labelIntervalSeconds, tickIntervalSeconds }; @@ -197,13 +200,13 @@ export function formatRulerLabel({ fps, }: { timeInSeconds: number; - fps: number; + fps: FrameRate; }): string { if (isSecondBoundary({ timeInSeconds })) { return formatTimestamp({ timeInSeconds }); } - const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps }); + const frameWithinSecond = getFrameWithinSecond({ timeInSeconds, fps: frameRateToFloat(fps) }); return `${frameWithinSecond}f`; } diff --git a/apps/web/src/lib/timeline/snap-utils.ts b/apps/web/src/lib/timeline/snap-utils.ts index ef5d6e11..c1768deb 100644 --- a/apps/web/src/lib/timeline/snap-utils.ts +++ b/apps/web/src/lib/timeline/snap-utils.ts @@ -1,172 +1,173 @@ -import type { Bookmark, TimelineTrack } from "@/lib/timeline"; -import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; -import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks"; -import { getElementKeyframes } from "@/lib/animation"; - -export interface SnapPoint { - time: number; - type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe"; - elementId?: string; - trackId?: string; -} - -export interface SnapResult { - snappedTime: number; - snapPoint: SnapPoint | null; - snapDistance: number; -} - -const DEFAULT_SNAP_THRESHOLD_PX = 10; - -export function findSnapPoints({ - tracks, - playheadTime, - excludeElementId, - bookmarks = [], - excludeBookmarkTime, - enableElementSnapping = true, - enablePlayheadSnapping = true, - enableBookmarkSnapping = true, - enableKeyframeSnapping = true, -}: { - tracks: Array; - playheadTime: number; - excludeElementId?: string; - bookmarks?: Array; - excludeBookmarkTime?: number; - enableElementSnapping?: boolean; - enablePlayheadSnapping?: boolean; - enableBookmarkSnapping?: boolean; - enableKeyframeSnapping?: boolean; -}): SnapPoint[] { - const snapPoints: SnapPoint[] = []; - - for (const track of tracks) { - for (const element of track.elements) { - if (element.id === excludeElementId) continue; - - if (enableElementSnapping) { - snapPoints.push( - { - time: element.startTime, - type: "element-start", - elementId: element.id, - trackId: track.id, - }, - { - time: element.startTime + element.duration, - type: "element-end", - elementId: element.id, - trackId: track.id, - }, - ); - } - - if (enableKeyframeSnapping) { - for (const keyframe of getElementKeyframes({ - animations: element.animations, - })) { - snapPoints.push({ - time: element.startTime + keyframe.time, - type: "keyframe", - elementId: element.id, - trackId: track.id, - }); - } - } - } - } - - if (enablePlayheadSnapping) { - snapPoints.push({ time: playheadTime, type: "playhead" }); - } - - if (enableBookmarkSnapping) { - for (const bookmark of bookmarks) { - if ( - excludeBookmarkTime != null && - Math.abs(bookmark.time - excludeBookmarkTime) < BOOKMARK_TIME_EPSILON - ) { - continue; - } - snapPoints.push({ time: bookmark.time, type: "bookmark" }); - } - } - - return snapPoints; -} - -export function snapToNearestPoint({ - targetTime, - snapPoints, - zoomLevel, - snapThreshold = DEFAULT_SNAP_THRESHOLD_PX, -}: { - targetTime: number; - snapPoints: Array; - zoomLevel: number; - snapThreshold?: number; -}): SnapResult { - const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; - const thresholdInSeconds = snapThreshold / pixelsPerSecond; - - let closestSnapPoint: SnapPoint | null = null; - let closestDistance = Infinity; - - for (const snapPoint of snapPoints) { - const distance = Math.abs(targetTime - snapPoint.time); - if (distance < thresholdInSeconds && distance < closestDistance) { - closestDistance = distance; - closestSnapPoint = snapPoint; - } - } - - return { - snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, - snapPoint: closestSnapPoint, - snapDistance: closestDistance, - }; -} - -export function snapElementEdge({ - targetTime, - elementDuration, - tracks, - playheadTime, - zoomLevel, - excludeElementId, - snapToStart = true, - bookmarks = [], -}: { - targetTime: number; - elementDuration: number; - tracks: Array; - playheadTime: number; - zoomLevel: number; - excludeElementId?: string; - snapToStart?: boolean; - bookmarks?: Array; -}): SnapResult { - const snapPoints = findSnapPoints({ - tracks, - playheadTime, - excludeElementId, - bookmarks, - }); - - const effectiveTargetTime = snapToStart - ? targetTime - : targetTime + elementDuration; - - const snapResult = snapToNearestPoint({ - targetTime: effectiveTargetTime, - snapPoints, - zoomLevel, - }); - - if (!snapToStart && snapResult.snapPoint) { - snapResult.snappedTime = snapResult.snappedTime - elementDuration; - } - - return snapResult; -} +import type { Bookmark, SceneTracks } from "@/lib/timeline"; +import { BASE_TIMELINE_PIXELS_PER_SECOND } from "@/lib/timeline/scale"; +import { getElementKeyframes } from "@/lib/animation"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; + +export interface SnapPoint { + time: number; + type: "element-start" | "element-end" | "playhead" | "bookmark" | "keyframe"; + elementId?: string; + trackId?: string; +} + +export interface SnapResult { + snappedTime: number; + snapPoint: SnapPoint | null; + snapDistance: number; +} + +const DEFAULT_SNAP_THRESHOLD_PX = 10; + +export function findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + bookmarks = [], + excludeBookmarkTime, + enableElementSnapping = true, + enablePlayheadSnapping = true, + enableBookmarkSnapping = true, + enableKeyframeSnapping = true, +}: { + tracks: SceneTracks; + playheadTime: number; + excludeElementId?: string; + bookmarks?: Array; + excludeBookmarkTime?: number; + enableElementSnapping?: boolean; + enablePlayheadSnapping?: boolean; + enableBookmarkSnapping?: boolean; + enableKeyframeSnapping?: boolean; +}): SnapPoint[] { + const snapPoints: SnapPoint[] = []; + const orderedTracks = [...tracks.overlay, tracks.main, ...tracks.audio]; + + for (const track of orderedTracks) { + for (const element of track.elements) { + if (element.id === excludeElementId) continue; + + if (enableElementSnapping) { + snapPoints.push( + { + time: element.startTime, + type: "element-start", + elementId: element.id, + trackId: track.id, + }, + { + time: element.startTime + element.duration, + type: "element-end", + elementId: element.id, + trackId: track.id, + }, + ); + } + + if (enableKeyframeSnapping) { + for (const keyframe of getElementKeyframes({ + animations: element.animations, + })) { + snapPoints.push({ + time: element.startTime + keyframe.time, + type: "keyframe", + elementId: element.id, + trackId: track.id, + }); + } + } + } + } + + if (enablePlayheadSnapping) { + snapPoints.push({ time: playheadTime, type: "playhead" }); + } + + if (enableBookmarkSnapping) { + for (const bookmark of bookmarks) { + if ( + excludeBookmarkTime != null && + bookmark.time === excludeBookmarkTime + ) { + continue; + } + snapPoints.push({ time: bookmark.time, type: "bookmark" }); + } + } + + return snapPoints; +} + +export function snapToNearestPoint({ + targetTime, + snapPoints, + zoomLevel, + snapThreshold = DEFAULT_SNAP_THRESHOLD_PX, +}: { + targetTime: number; + snapPoints: Array; + zoomLevel: number; + snapThreshold?: number; +}): SnapResult { + const pixelsPerSecond = BASE_TIMELINE_PIXELS_PER_SECOND * zoomLevel; + const thresholdInTicks = (snapThreshold / pixelsPerSecond) * TICKS_PER_SECOND; + + let closestSnapPoint: SnapPoint | null = null; + let closestDistance = Infinity; + + for (const snapPoint of snapPoints) { + const distance = Math.abs(targetTime - snapPoint.time); + if (distance < thresholdInTicks && distance < closestDistance) { + closestDistance = distance; + closestSnapPoint = snapPoint; + } + } + + return { + snappedTime: closestSnapPoint ? closestSnapPoint.time : targetTime, + snapPoint: closestSnapPoint, + snapDistance: closestDistance, + }; +} + +export function snapElementEdge({ + targetTime, + elementDuration, + tracks, + playheadTime, + zoomLevel, + excludeElementId, + snapToStart = true, + bookmarks = [], +}: { + targetTime: number; + elementDuration: number; + tracks: SceneTracks; + playheadTime: number; + zoomLevel: number; + excludeElementId?: string; + snapToStart?: boolean; + bookmarks?: Array; +}): SnapResult { + const snapPoints = findSnapPoints({ + tracks, + playheadTime, + excludeElementId, + bookmarks, + }); + + const effectiveTargetTime = snapToStart + ? targetTime + : targetTime + elementDuration; + + const snapResult = snapToNearestPoint({ + targetTime: effectiveTargetTime, + snapPoints, + zoomLevel, + }); + + if (!snapToStart && snapResult.snapPoint) { + snapResult.snappedTime = snapResult.snappedTime - elementDuration; + } + + return snapResult; +} diff --git a/apps/web/src/lib/timeline/track-element-update.ts b/apps/web/src/lib/timeline/track-element-update.ts index 19765923..f1c2028e 100644 --- a/apps/web/src/lib/timeline/track-element-update.ts +++ b/apps/web/src/lib/timeline/track-element-update.ts @@ -1,33 +1,147 @@ -import type { TimelineElement, TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineElement, TimelineTrack } from "@/lib/timeline"; -export function updateElementInTracks({ +export function findTrackInSceneTracks({ + tracks, + trackId, +}: { + tracks: SceneTracks; + trackId: string; +}): TimelineTrack | null { + if (tracks.main.id === trackId) { + return tracks.main; + } + + return ( + tracks.overlay.find((track) => track.id === trackId) ?? + tracks.audio.find((track) => track.id === trackId) ?? + null + ); +} + +export function updateTrackInSceneTracks({ + tracks, + trackId, + update, +}: { + tracks: SceneTracks; + trackId: string; + update: (track: TTrack) => TTrack; +}): SceneTracks { + if (tracks.main.id === trackId) { + return { + ...tracks, + main: update(tracks.main), + }; + } + + const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId); + if (overlayTrackIndex >= 0) { + return { + ...tracks, + overlay: tracks.overlay.map((track, index) => + index === overlayTrackIndex ? update(track) : track, + ), + }; + } + + const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId); + if (audioTrackIndex >= 0) { + return { + ...tracks, + audio: tracks.audio.map((track, index) => + index === audioTrackIndex ? update(track) : track, + ), + }; + } + + return tracks; +} + +function updateElementInTrack({ + track, + elementId, + update, + elementPredicate, +}: { + track: TTrack; + elementId: string; + update: (element: TimelineElement) => TimelineElement; + elementPredicate?: (element: TimelineElement) => boolean; +}): TTrack { + const nextElements = track.elements.map((element) => { + if (element.id !== elementId) { + return element; + } + if (elementPredicate && !elementPredicate(element)) { + return element; + } + return update(element); + }); + + return { + ...track, + elements: nextElements, + } as TTrack; +} + +export function updateElementInSceneTracks({ tracks, trackId, elementId, update, elementPredicate, }: { - tracks: TimelineTrack[]; + tracks: SceneTracks; trackId: string; elementId: string; update: (element: TimelineElement) => TimelineElement; elementPredicate?: (element: TimelineElement) => boolean; -}): TimelineTrack[] { - return tracks.map((track) => { - if (track.id !== trackId) { - return track; - } +}): SceneTracks { + if (tracks.main.id === trackId) { + return { + ...tracks, + main: updateElementInTrack({ + track: tracks.main, + elementId, + update, + elementPredicate, + }), + }; + } - const nextElements = track.elements.map((element) => { - if (element.id !== elementId) { - return element; - } - if (elementPredicate && !elementPredicate(element)) { - return element; - } - return update(element); - }); + const overlayTrackIndex = tracks.overlay.findIndex((track) => track.id === trackId); + if (overlayTrackIndex >= 0) { + return { + ...tracks, + overlay: tracks.overlay.map((track, index) => + index === overlayTrackIndex + ? updateElementInTrack({ + track, + elementId, + update, + elementPredicate, + }) + : track, + ), + }; + } - return { ...track, elements: nextElements } as TimelineTrack; - }); + const audioTrackIndex = tracks.audio.findIndex((track) => track.id === trackId); + if (audioTrackIndex >= 0) { + return { + ...tracks, + audio: tracks.audio.map((track, index) => + index === audioTrackIndex + ? updateElementInTrack({ + track, + elementId, + update, + elementPredicate, + }) + : track, + ), + }; + } + + return tracks; } diff --git a/apps/web/src/lib/timeline/types.ts b/apps/web/src/lib/timeline/types.ts index ebdc4c23..1b2c7c9b 100644 --- a/apps/web/src/lib/timeline/types.ts +++ b/apps/web/src/lib/timeline/types.ts @@ -20,7 +20,7 @@ export interface TScene { id: string; name: string; isMain: boolean; - tracks: TimelineTrack[]; + tracks: SceneTracks; bookmarks: Bookmark[]; createdAt: Date; updatedAt: Date; @@ -36,7 +36,6 @@ interface BaseTrack { export interface VideoTrack extends BaseTrack { type: "video"; elements: (VideoElement | ImageElement)[]; - isMain: boolean; muted: boolean; hidden: boolean; } @@ -72,6 +71,14 @@ export type TimelineTrack = | GraphicTrack | EffectTrack; +export type OverlayTrack = VideoTrack | TextTrack | GraphicTrack | EffectTrack; + +export interface SceneTracks { + overlay: OverlayTrack[]; + main: VideoTrack; + audio: AudioTrack[]; +} + export interface RetimeConfig { rate: number; maintainPitch?: boolean; @@ -286,7 +293,7 @@ export interface ComputeDropTargetParams { elementType: ElementType; mouseX: number; mouseY: number; - tracks: TimelineTrack[]; + tracks: SceneTracks; playheadTime: number; isExternalDrop: boolean; elementDuration: number; diff --git a/apps/web/src/lib/timeline/update-pipeline.ts b/apps/web/src/lib/timeline/update-pipeline.ts new file mode 100644 index 00000000..2be73441 --- /dev/null +++ b/apps/web/src/lib/timeline/update-pipeline.ts @@ -0,0 +1,211 @@ +import { clampAnimationsToDuration } from "@/lib/animation"; +import { + clampRetimeRate, + getSourceSpanAtClipTime, + getTimelineDurationForSourceSpan, +} from "@/lib/retime"; +import type { RetimeConfig, SceneTracks, TimelineElement } from "@/lib/timeline"; +import { isRetimableElement } from "@/lib/timeline"; + +type ElementUpdateField = keyof TimelineElement | string; + +export interface ElementUpdateContext { + tracks: SceneTracks; + trackId: string; +} + +interface ElementUpdateRuleResult { + element: TimelineElement; + changedFields?: ElementUpdateField[]; +} + +interface ElementUpdateRuleParams { + element: TimelineElement; + originalElement: TimelineElement; + patch: Partial; + context: ElementUpdateContext; +} + +interface ElementUpdateRule { + triggers: ElementUpdateField[]; + apply: (params: ElementUpdateRuleParams) => ElementUpdateRuleResult; +} + +const deriveRules: ElementUpdateRule[] = [ + { + triggers: ["retime"], + apply: ({ element, originalElement, patch }) => { + if (!("retime" in patch) || !isRetimableElement(element)) { + return { element }; + } + + const nextRetime = patch.retime + ? { + ...patch.retime, + rate: clampRetimeRate({ rate: patch.retime.rate }), + } + : undefined; + + const sourceDuration = getSourceDuration({ + trimStart: originalElement.trimStart, + trimEnd: originalElement.trimEnd, + duration: originalElement.duration, + sourceDuration: isRetimableElement(originalElement) + ? originalElement.sourceDuration + : undefined, + retime: isRetimableElement(originalElement) + ? originalElement.retime + : undefined, + }); + const visibleSourceSpan = Math.max( + 0, + sourceDuration - element.trimStart - element.trimEnd, + ); + const nextDuration = getTimelineDurationForSourceSpan({ + sourceSpan: visibleSourceSpan, + retime: nextRetime, + }); + + return { + element: { + ...element, + retime: nextRetime, + duration: nextDuration, + }, + changedFields: ["retime", "duration"], + }; + }, + }, +]; + +const enforceRules: ElementUpdateRule[] = [ + { + triggers: ["duration"], + apply: ({ element }) => ({ + element: { + ...element, + animations: clampAnimationsToDuration({ + animations: element.animations, + duration: element.duration, + }), + }, + }), + }, + { + triggers: ["startTime"], + apply: ({ element, context }) => { + const requestedStartTime = Math.max(0, element.startTime); + if (context.trackId !== context.tracks.main.id) { + return { + element: { + ...element, + startTime: requestedStartTime, + }, + }; + } + + const earliestElement = context.tracks.main.elements + .filter((candidate) => candidate.id !== element.id) + .reduce((earliest, candidate) => { + if (!earliest || candidate.startTime < earliest.startTime) { + return candidate; + } + return earliest; + }, null); + + return { + element: { + ...element, + startTime: + !earliestElement || requestedStartTime <= earliestElement.startTime + ? 0 + : requestedStartTime, + }, + }; + }, + }, +]; + +export function applyElementUpdate({ + element, + patch, + context, +}: { + element: TimelineElement; + patch: Partial; + context: ElementUpdateContext; +}): TimelineElement { + let nextElement = { ...element, ...patch } as TimelineElement; + const changedFields = new Set( + Object.keys(patch) as ElementUpdateField[], + ); + + for (const rule of deriveRules) { + if (!shouldApplyRule({ rule, changedFields })) { + continue; + } + + const result = rule.apply({ + element: nextElement, + originalElement: element, + patch, + context, + }); + nextElement = result.element; + for (const field of result.changedFields ?? []) { + changedFields.add(field); + } + } + + for (const rule of enforceRules) { + if (!shouldApplyRule({ rule, changedFields })) { + continue; + } + + nextElement = rule.apply({ + element: nextElement, + originalElement: element, + patch, + context, + }).element; + } + + return nextElement; +} + +function shouldApplyRule({ + rule, + changedFields, +}: { + rule: ElementUpdateRule; + changedFields: Set; +}): boolean { + return rule.triggers.some((trigger) => changedFields.has(trigger)); +} + +function getSourceDuration({ + trimStart, + trimEnd, + duration, + sourceDuration, + retime, +}: { + trimStart: number; + trimEnd: number; + duration: number; + sourceDuration?: number; + retime?: RetimeConfig; +}): number { + if (typeof sourceDuration === "number") { + return sourceDuration; + } + + return ( + trimStart + + getSourceSpanAtClipTime({ + clipTime: duration, + retime, + }) + + trimEnd + ); +} diff --git a/apps/web/src/lib/timeline/zoom-utils.ts b/apps/web/src/lib/timeline/zoom-utils.ts index c06957d0..680f8d0d 100644 --- a/apps/web/src/lib/timeline/zoom-utils.ts +++ b/apps/web/src/lib/timeline/zoom-utils.ts @@ -2,6 +2,7 @@ import { BASE_TIMELINE_PIXELS_PER_SECOND, TIMELINE_ZOOM_MAX, } from "@/lib/timeline/scale"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; const PADDING_MAX_RATIO = 0.75; const PADDING_MIN_RATIO = 0.15; @@ -14,12 +15,12 @@ export function getTimelineZoomMin({ duration: number; containerWidth: number | null | undefined; }): number { - const safeDuration = Math.max(duration, 1); + const safeDurationSeconds = Math.max(duration / TICKS_PER_SECOND, 1); const safeContainerWidth = containerWidth ?? 1000; const contentRatioAtMinZoom = 1 - PADDING_MAX_RATIO; const availableWidth = safeContainerWidth * contentRatioAtMinZoom; const zoomToFit = - availableWidth / (safeDuration * BASE_TIMELINE_PIXELS_PER_SECOND); + availableWidth / (safeDurationSeconds * BASE_TIMELINE_PIXELS_PER_SECOND); return Math.min(TIMELINE_ZOOM_MAX, zoomToFit); } diff --git a/apps/web/src/lib/wasm/constants.ts b/apps/web/src/lib/wasm/constants.ts new file mode 100644 index 00000000..ababe6b3 --- /dev/null +++ b/apps/web/src/lib/wasm/constants.ts @@ -0,0 +1,3 @@ +import { TICKS_PER_SECOND as _TICKS_PER_SECOND } from "opencut-wasm"; + +export const TICKS_PER_SECOND = _TICKS_PER_SECOND(); diff --git a/apps/web/src/lib/wasm/index.ts b/apps/web/src/lib/wasm/index.ts new file mode 100644 index 00000000..b04bfcf7 --- /dev/null +++ b/apps/web/src/lib/wasm/index.ts @@ -0,0 +1 @@ +export * from "./constants"; diff --git a/apps/web/src/services/renderer/canvas-renderer.ts b/apps/web/src/services/renderer/canvas-renderer.ts index 840d8b52..b142d97a 100644 --- a/apps/web/src/services/renderer/canvas-renderer.ts +++ b/apps/web/src/services/renderer/canvas-renderer.ts @@ -1,9 +1,11 @@ +import type { FrameRate } from "opencut-wasm"; +import { frameRateToFloat } from "@/lib/fps/utils"; import type { BaseNode } from "./nodes/base-node"; export type CanvasRendererParams = { width: number; height: number; - fps: number; + fps: FrameRate; }; export class CanvasRenderer { @@ -11,7 +13,7 @@ export class CanvasRenderer { context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D; width: number; height: number; - fps: number; + fps: FrameRate; constructor({ width, height, fps }: CanvasRendererParams) { this.width = width; diff --git a/apps/web/src/services/renderer/nodes/blur-background-node.ts b/apps/web/src/services/renderer/nodes/blur-background-node.ts index c67a5113..4cb45b33 100644 --- a/apps/web/src/services/renderer/nodes/blur-background-node.ts +++ b/apps/web/src/services/renderer/nodes/blur-background-node.ts @@ -1,5 +1,5 @@ -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { buildGaussianBlurPasses, intensityToSigma } from "@/lib/effects/definitions/blur"; +import { mediaTimeToSeconds } from "opencut-wasm"; import { getSourceTimeAtClipTime } from "@/lib/retime"; import { videoCache } from "@/services/video-cache/service"; import type { RetimeConfig } from "@/lib/timeline"; @@ -39,9 +39,7 @@ export class BlurBackgroundNode extends BaseNode { private isInRange({ time }: { time: number }): boolean { const localTime = time - this.params.timeOffset; - return ( - localTime >= -TIME_EPSILON_SECONDS && localTime < this.params.duration - ); + return localTime >= 0 && localTime < this.params.duration; } private getSourceLocalTime({ time }: { time: number }): number { @@ -61,10 +59,11 @@ export class BlurBackgroundNode extends BaseNode { time: number; }): Promise { if (this.params.mediaType === "video") { + const sourceTimeTicks = this.getSourceLocalTime({ time }); const frame = await videoCache.getFrameAt({ mediaId: this.params.mediaId, file: this.params.file, - time: this.getSourceLocalTime({ time }), + time: mediaTimeToSeconds({ time: sourceTimeTicks }), }); if (!frame) { diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 3bb836ec..4f225567 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -1,37 +1,39 @@ -import type { CanvasRenderer } from "../canvas-renderer"; -import { VisualNode, type VisualNodeParams } from "./visual-node"; -import { videoCache } from "@/services/video-cache/service"; - -export interface VideoNodeParams extends VisualNodeParams { - url: string; - file: File; - mediaId: string; -} - -export class VideoNode extends VisualNode { - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { - await super.render({ renderer, time }); - - if (!this.isInRange({ time })) { - return; - } - - const videoTime = this.getSourceLocalTime({ time }); - - const frame = await videoCache.getFrameAt({ - mediaId: this.params.mediaId, - file: this.params.file, - time: videoTime, - }); - - if (frame) { - this.renderVisual({ - renderer, - source: frame.canvas, - sourceWidth: frame.canvas.width, - sourceHeight: frame.canvas.height, - timelineTime: time, - }); - } - } -} +import type { CanvasRenderer } from "../canvas-renderer"; +import { mediaTimeToSeconds } from "opencut-wasm"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; +import { videoCache } from "@/services/video-cache/service"; + +export interface VideoNodeParams extends VisualNodeParams { + url: string; + file: File; + mediaId: string; +} + +export class VideoNode extends VisualNode { + async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { + await super.render({ renderer, time }); + + if (!this.isInRange({ time })) { + return; + } + + const videoTimeTicks = this.getSourceLocalTime({ time }); + const videoTimeSeconds = mediaTimeToSeconds({ time: videoTimeTicks }); + + const frame = await videoCache.getFrameAt({ + mediaId: this.params.mediaId, + file: this.params.file, + time: videoTimeSeconds, + }); + + if (frame) { + this.renderVisual({ + renderer, + source: frame.canvas, + sourceWidth: frame.canvas.width, + sourceHeight: frame.canvas.height, + timelineTime: time, + }); + } + } +} diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 82093da4..4a5fcdc6 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -12,7 +12,6 @@ import { resolveTransformAtTime, } from "@/lib/animation"; import { resolveEffectParamsAtTime } from "@/lib/animation/effect-param-channel"; -import { TIME_EPSILON_SECONDS } from "@/constants/animation-constants"; import { effectsRegistry, resolveEffectPasses } from "@/lib/effects"; import { masksRegistry } from "@/lib/masks"; import { getSourceTimeAtClipTime } from "@/lib/retime"; @@ -58,7 +57,7 @@ export abstract class VisualNode< protected isInRange({ time }: { time: number }): boolean { const localTime = time - this.params.timeOffset; return ( - localTime >= -TIME_EPSILON_SECONDS && + localTime >= 0 && localTime < this.params.duration ); } diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 76b97021..22d941be 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -1,4 +1,4 @@ -import type { TimelineTrack } from "@/lib/timeline"; +import type { SceneTracks, TimelineTrack } from "@/lib/timeline"; import type { MediaAsset } from "@/lib/media/types"; import { RootNode } from "./nodes/root-node"; import { VideoNode } from "./nodes/video-node"; @@ -12,7 +12,6 @@ import { EffectLayerNode } from "./nodes/effect-layer-node"; import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/lib/project/types"; import { DEFAULT_BACKGROUND_BLUR_INTENSITY } from "@/lib/background/constants"; -import { isMainTrack } from "@/lib/timeline/placement"; const PREVIEW_MAX_IMAGE_SIZE = 2048; @@ -209,7 +208,7 @@ function buildBlurBackgroundNodes({ export type BuildSceneParams = { canvasSize: TCanvasSize; - tracks: TimelineTrack[]; + tracks: SceneTracks; mediaAssets: MediaAsset[]; duration: number; background: TBackground; @@ -227,19 +226,12 @@ export function buildScene({ const rootNode = new RootNode({ duration }); const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); - const visibleTracks = tracks.filter( - (track) => !("hidden" in track && track.hidden), - ); - - const orderedTracksTopToBottom = [ - ...visibleTracks.filter((track) => !isMainTrack(track)), - ...visibleTracks.filter((track) => isMainTrack(track)), + const visibleTracks = [ + ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), + ...(!tracks.main.hidden ? [tracks.main] : []), ]; - - const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); - const mainTrack = orderedTracksBottomToTop.find((track) => - isMainTrack(track), - ); + const orderedTracksBottomToTop = visibleTracks.slice().reverse(); + const mainTrack = tracks.main.hidden ? undefined : tracks.main; const allNodes = buildTrackNodes({ tracks: orderedTracksBottomToTop, diff --git a/apps/web/src/services/renderer/scene-exporter.ts b/apps/web/src/services/renderer/scene-exporter.ts index 2dbb7cdd..0c2ee17f 100644 --- a/apps/web/src/services/renderer/scene-exporter.ts +++ b/apps/web/src/services/renderer/scene-exporter.ts @@ -1,163 +1,170 @@ -import EventEmitter from "eventemitter3"; - -import { - Output, - Mp4OutputFormat, - WebMOutputFormat, - BufferTarget, - CanvasSource, - AudioBufferSource, - QUALITY_LOW, - QUALITY_MEDIUM, - QUALITY_HIGH, - QUALITY_VERY_HIGH, -} from "mediabunny"; -import type { RootNode } from "./nodes/root-node"; -import type { ExportFormat, ExportQuality } from "@/lib/export"; -import { CanvasRenderer } from "./canvas-renderer"; - -type ExportParams = { - width: number; - height: number; - fps: number; - format: ExportFormat; - quality: ExportQuality; - shouldIncludeAudio?: boolean; - audioBuffer?: AudioBuffer; -}; - -const qualityMap = { - low: QUALITY_LOW, - medium: QUALITY_MEDIUM, - high: QUALITY_HIGH, - very_high: QUALITY_VERY_HIGH, -}; - -export type SceneExporterEvents = { - progress: [progress: number]; - complete: [buffer: ArrayBuffer]; - error: [error: Error]; - cancelled: []; -}; - -export class SceneExporter extends EventEmitter { - private renderer: CanvasRenderer; - private format: ExportFormat; - private quality: ExportQuality; - private shouldIncludeAudio: boolean; - private audioBuffer?: AudioBuffer; - - private isCancelled = false; - - constructor({ - width, - height, - fps, - format, - quality, - shouldIncludeAudio, - audioBuffer, - }: ExportParams) { - super(); - this.renderer = new CanvasRenderer({ - width, - height, - fps, - }); - - this.format = format; - this.quality = quality; - this.shouldIncludeAudio = shouldIncludeAudio ?? false; - this.audioBuffer = audioBuffer; - } - - cancel(): void { - this.isCancelled = true; - } - - async export({ - rootNode, - }: { - rootNode: RootNode; - }): Promise { - const { fps } = this.renderer; - const frameCount = Math.ceil(rootNode.duration * fps); - - const outputFormat = - this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); - - const output = new Output({ - format: outputFormat, - target: new BufferTarget(), - }); - - const videoSource = new CanvasSource(this.renderer.canvas, { - codec: this.format === "webm" ? "vp9" : "avc", - bitrate: qualityMap[this.quality], - }); - - output.addVideoTrack(videoSource, { frameRate: fps }); - - let audioSource: AudioBufferSource | null = null; - if (this.shouldIncludeAudio && this.audioBuffer) { - let audioCodec: "aac" | "opus" = - this.format === "webm" ? "opus" : "aac"; - - if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") { - const { supported } = await AudioEncoder.isConfigSupported({ - codec: "mp4a.40.2", - sampleRate: this.audioBuffer.sampleRate, - numberOfChannels: this.audioBuffer.numberOfChannels, - bitrate: 192000, - }); - if (!supported) audioCodec = "opus"; - } - - audioSource = new AudioBufferSource({ - codec: audioCodec, - bitrate: qualityMap[this.quality], - }); - output.addAudioTrack(audioSource); - } - - await output.start(); - - if (audioSource && this.audioBuffer) { - await audioSource.add(this.audioBuffer); - audioSource.close(); - } - - for (let i = 0; i < frameCount; i++) { - if (this.isCancelled) { - await output.cancel(); - this.emit("cancelled"); - return null; - } - - const time = i / fps; - await this.renderer.render({ node: rootNode, time }); - await videoSource.add(time, 1 / fps); - - this.emit("progress", i / frameCount); - } - - if (this.isCancelled) { - await output.cancel(); - this.emit("cancelled"); - return null; - } - - videoSource.close(); - await output.finalize(); - this.emit("progress", 1); - - const buffer = output.target.buffer; - if (!buffer) { - this.emit("error", new Error("Failed to export video")); - return null; - } - - this.emit("complete", buffer); - return buffer; - } -} +import EventEmitter from "eventemitter3"; + +import { + Output, + Mp4OutputFormat, + WebMOutputFormat, + BufferTarget, + CanvasSource, + AudioBufferSource, + QUALITY_LOW, + QUALITY_MEDIUM, + QUALITY_HIGH, + QUALITY_VERY_HIGH, +} from "mediabunny"; +import type { FrameRate } from "opencut-wasm"; +import { mediaTimeToSeconds } from "opencut-wasm"; +import { TICKS_PER_SECOND } from "@/lib/wasm"; +import { frameRateToFloat } from "@/lib/fps/utils"; +import type { RootNode } from "./nodes/root-node"; +import type { ExportFormat, ExportQuality } from "@/lib/export"; +import { CanvasRenderer } from "./canvas-renderer"; + +type ExportParams = { + width: number; + height: number; + fps: FrameRate; + format: ExportFormat; + quality: ExportQuality; + shouldIncludeAudio?: boolean; + audioBuffer?: AudioBuffer; +}; + +const qualityMap = { + low: QUALITY_LOW, + medium: QUALITY_MEDIUM, + high: QUALITY_HIGH, + very_high: QUALITY_VERY_HIGH, +}; + +export type SceneExporterEvents = { + progress: [progress: number]; + complete: [buffer: ArrayBuffer]; + error: [error: Error]; + cancelled: []; +}; + +export class SceneExporter extends EventEmitter { + private renderer: CanvasRenderer; + private format: ExportFormat; + private quality: ExportQuality; + private shouldIncludeAudio: boolean; + private audioBuffer?: AudioBuffer; + + private isCancelled = false; + + constructor({ + width, + height, + fps, + format, + quality, + shouldIncludeAudio, + audioBuffer, + }: ExportParams) { + super(); + this.renderer = new CanvasRenderer({ + width, + height, + fps, + }); + + this.format = format; + this.quality = quality; + this.shouldIncludeAudio = shouldIncludeAudio ?? false; + this.audioBuffer = audioBuffer; + } + + cancel(): void { + this.isCancelled = true; + } + + async export({ + rootNode, + }: { + rootNode: RootNode; + }): Promise { + const fps = this.renderer.fps; + const fpsFloat = frameRateToFloat(fps); + const ticksPerFrame = Math.round(TICKS_PER_SECOND * fps.denominator / fps.numerator); + const frameCount = Math.floor(rootNode.duration / ticksPerFrame); + + const outputFormat = + this.format === "webm" ? new WebMOutputFormat() : new Mp4OutputFormat(); + + const output = new Output({ + format: outputFormat, + target: new BufferTarget(), + }); + + const videoSource = new CanvasSource(this.renderer.canvas, { + codec: this.format === "webm" ? "vp9" : "avc", + bitrate: qualityMap[this.quality], + }); + + output.addVideoTrack(videoSource, { frameRate: fpsFloat }); + + let audioSource: AudioBufferSource | null = null; + if (this.shouldIncludeAudio && this.audioBuffer) { + let audioCodec: "aac" | "opus" = + this.format === "webm" ? "opus" : "aac"; + + if (audioCodec === "aac" && typeof AudioEncoder !== "undefined") { + const { supported } = await AudioEncoder.isConfigSupported({ + codec: "mp4a.40.2", + sampleRate: this.audioBuffer.sampleRate, + numberOfChannels: this.audioBuffer.numberOfChannels, + bitrate: 192000, + }); + if (!supported) audioCodec = "opus"; + } + + audioSource = new AudioBufferSource({ + codec: audioCodec, + bitrate: qualityMap[this.quality], + }); + output.addAudioTrack(audioSource); + } + + await output.start(); + + if (audioSource && this.audioBuffer) { + await audioSource.add(this.audioBuffer); + audioSource.close(); + } + + for (let i = 0; i < frameCount; i++) { + if (this.isCancelled) { + await output.cancel(); + this.emit("cancelled"); + return null; + } + + const timeTicks = i * ticksPerFrame; + const timeSeconds = mediaTimeToSeconds({ time: timeTicks }); + await this.renderer.render({ node: rootNode, time: timeTicks }); + await videoSource.add(timeSeconds, 1 / fpsFloat); + + this.emit("progress", i / frameCount); + } + + if (this.isCancelled) { + await output.cancel(); + this.emit("cancelled"); + return null; + } + + videoSource.close(); + await output.finalize(); + this.emit("progress", 1); + + const buffer = output.target.buffer; + if (!buffer) { + this.emit("error", new Error("Failed to export video")); + return null; + } + + this.emit("complete", buffer); + return buffer; + } +} diff --git a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts index 7a4dd641..7a0617a7 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v1-to-v2.test.ts @@ -4,7 +4,7 @@ import { DEFAULT_BACKGROUND_COLOR, } from "@/lib/background/constants"; import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants"; -import { DEFAULT_FPS } from "@/lib/fps/constants"; +const DEFAULT_FPS = 30; import type { MediaAssetData } from "@/services/storage/types"; import { getProjectId, transformProjectV1ToV2 } from "../transformers/v1-to-v2"; import { diff --git a/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts new file mode 100644 index 00000000..18b815b0 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v21-to-v22.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV21ToV22 } from "../transformers/v21-to-v22"; + +describe("V21 to V22 Migration", () => { + test("migrates legacy animation channels to bindings and component channels", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v21-animations", + version: 21, + scenes: [ + { + id: "scene-1", + tracks: [ + { + id: "track-1", + elements: [ + { + id: "element-1", + type: "text", + animations: { + channels: { + opacity: { + valueKind: "number", + keyframes: [ + { + id: "opacity-1", + time: 1, + value: 0.5, + interpolation: "linear", + }, + ], + }, + "transform.position": { + valueKind: "vector", + keyframes: [ + { + id: "position-1", + time: 2, + value: { x: 10, y: 20 }, + interpolation: "hold", + }, + ], + }, + color: { + valueKind: "color", + keyframes: [ + { + id: "color-1", + time: 3, + value: "#ff0000", + interpolation: "linear", + }, + ], + }, + "effects.effect-1.params.enabled": { + valueKind: "discrete", + keyframes: [ + { + id: "enabled-1", + time: 4, + value: true, + interpolation: "hold", + }, + ], + }, + }, + }, + }, + ], + }, + ], + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(22); + + const scenes = result.project.scenes as Array>; + const tracks = scenes[0].tracks as Array>; + const elements = tracks[0].elements as Array>; + const animations = elements[0].animations as Record; + const bindings = animations.bindings as Record>; + const channels = animations.channels as Record>; + + expect(bindings.opacity).toEqual({ + path: "opacity", + kind: "number", + components: [{ key: "value", channelId: "opacity:value" }], + }); + expect(bindings["transform.position"]).toEqual({ + path: "transform.position", + kind: "vector2", + components: [ + { key: "x", channelId: "transform.position:x" }, + { key: "y", channelId: "transform.position:y" }, + ], + }); + expect(bindings.color).toEqual({ + path: "color", + kind: "color", + colorSpace: "srgb-linear", + components: [ + { key: "r", channelId: "color:r" }, + { key: "g", channelId: "color:g" }, + { key: "b", channelId: "color:b" }, + { key: "a", channelId: "color:a" }, + ], + }); + expect(bindings["effects.effect-1.params.enabled"]).toEqual({ + path: "effects.effect-1.params.enabled", + kind: "discrete", + components: [ + { + key: "value", + channelId: "effects.effect-1.params.enabled:value", + }, + ], + }); + + expect(channels["opacity:value"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "opacity-1", + time: 1, + value: 0.5, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["transform.position:x"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "position-1", + time: 2, + value: 10, + segmentToNext: "step", + tangentMode: "flat", + }, + ], + }); + expect(channels["transform.position:y"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "position-1", + time: 2, + value: 20, + segmentToNext: "step", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:r"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 1, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:g"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 0, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:b"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 0, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["color:a"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "color-1", + time: 3, + value: 1, + segmentToNext: "linear", + tangentMode: "flat", + }, + ], + }); + expect(channels["effects.effect-1.params.enabled:value"]).toEqual({ + kind: "discrete", + keys: [ + { + id: "enabled-1", + time: 4, + value: true, + }, + ], + }); + }); + + test("skips projects already on v22", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v22", + version: 22, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v22"); + }); + + test("skips projects not on v21", () => { + const result = transformProjectV21ToV22({ + project: { + id: "project-v20", + version: 20, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v21"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts new file mode 100644 index 00000000..5b764b48 --- /dev/null +++ b/apps/web/src/services/storage/migrations/__tests__/v22-to-v23.test.ts @@ -0,0 +1,192 @@ +import { describe, expect, test } from "bun:test"; +import { transformProjectV22ToV23 } from "../transformers/v22-to-v23"; + +describe("V22 to V23 Migration", () => { + test("converts project time values from seconds to ticks and fps to a frame-rate object", () => { + const result = transformProjectV22ToV23({ + project: { + id: "project-v22-time", + version: 22, + metadata: { + id: "project-v22-time", + name: "Project", + duration: 15.5, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + settings: { + fps: 29.97, + canvasSize: { width: 1920, height: 1080 }, + background: { type: "color", color: "#000000" }, + }, + timelineViewState: { + zoomLevel: 1, + scrollLeft: 120, + playheadTime: 1.25, + }, + scenes: [ + { + id: "scene-1", + bookmarks: [ + { time: 2.5, duration: 0.75, note: "Marker", color: "#ff0000" }, + { time: 4.5 }, + ], + tracks: [ + { + id: "track-1", + type: "video", + elements: [ + { + id: "element-1", + type: "video", + startTime: 1.25, + duration: 5.5, + trimStart: 0.25, + trimEnd: 0.5, + sourceDuration: 6.25, + animations: { + bindings: { + opacity: { + path: "opacity", + kind: "number", + components: [ + { + key: "value", + channelId: "opacity:value", + }, + ], + }, + }, + channels: { + "opacity:value": { + kind: "scalar", + keys: [ + { + id: "key-1", + time: 0.5, + value: 1, + segmentToNext: "bezier", + tangentMode: "flat", + rightHandle: { + dt: 0.25, + dv: 0.2, + }, + }, + { + id: "key-2", + time: 1.0, + value: 0.4, + segmentToNext: "linear", + tangentMode: "flat", + leftHandle: { + dt: -0.125, + dv: -0.1, + }, + }, + ], + }, + }, + }, + }, + ], + }, + ], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ], + }, + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(23); + + const metadata = result.project.metadata as Record; + expect(metadata.duration).toBe(1_860_000); + + const settings = result.project.settings as Record; + expect(settings.fps).toEqual({ numerator: 30_000, denominator: 1_001 }); + + const timelineViewState = result.project.timelineViewState as Record< + string, + unknown + >; + expect(timelineViewState.playheadTime).toBe(150_000); + expect(timelineViewState.scrollLeft).toBe(120); + + const scenes = result.project.scenes as Array>; + const scene = scenes[0]; + expect(scene.bookmarks).toEqual([ + { + time: 300_000, + duration: 90_000, + note: "Marker", + color: "#ff0000", + }, + { time: 540_000 }, + ]); + + const tracks = scene.tracks as Array>; + const elements = tracks[0].elements as Array>; + const element = elements[0]; + expect(element.startTime).toBe(150_000); + expect(element.duration).toBe(660_000); + expect(element.trimStart).toBe(30_000); + expect(element.trimEnd).toBe(60_000); + expect(element.sourceDuration).toBe(750_000); + + const animations = element.animations as Record; + const channels = animations.channels as Record>; + expect(channels["opacity:value"]).toEqual({ + kind: "scalar", + keys: [ + { + id: "key-1", + time: 60_000, + value: 1, + segmentToNext: "bezier", + tangentMode: "flat", + rightHandle: { + dt: 30_000, + dv: 0.2, + }, + }, + { + id: "key-2", + time: 120_000, + value: 0.4, + segmentToNext: "linear", + tangentMode: "flat", + leftHandle: { + dt: -15_000, + dv: -0.1, + }, + }, + ], + }); + }); + + test("skips projects already on v23", () => { + const result = transformProjectV22ToV23({ + project: { + id: "project-v23", + version: 23, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v23"); + }); + + test("skips projects not on v22", () => { + const result = transformProjectV22ToV23({ + project: { + id: "project-v21", + version: 21, + }, + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("not v22"); + }); +}); diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts index 66e87713..b05d1837 100644 --- a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts +++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts @@ -1,92 +1,94 @@ -import { describe, expect, test } from "bun:test"; -import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; -import { v5Project } from "./fixtures"; - -describe("V5 to V6 Migration", () => { - test("converts number bookmarks to Bookmark objects", async () => { - const result = transformProjectV5ToV6({ - project: v5Project as Parameters< - typeof transformProjectV5ToV6 - >[0]["project"], - }); - - expect(result.skipped).toBe(false); - expect(result.project.version).toBe(6); - - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ - { time: 2.0 }, - { time: 5.5 }, - { time: 12.0 }, - ]); - - const introScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[1]; - expect(introScene.bookmarks).toEqual([]); - }); - - test("skips projects that are already v6", () => { - const result = transformProjectV5ToV6({ - project: { - ...v5Project, - version: 6, - scenes: [ - { - ...(v5Project as { scenes: unknown[] }).scenes[0], - bookmarks: [{ time: 2 }, { time: 5 }], - }, - ], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("already v6"); - }); - - test("skips projects with no id", () => { - const result = transformProjectV5ToV6({ - project: { - version: 5, - scenes: [], - } as Parameters[0]["project"], - }); - - expect(result.skipped).toBe(true); - expect(result.reason).toBe("no project id"); - }); - - test("preserves existing Bookmark objects with note, color, duration", () => { - const projectWithRichBookmarks = { - ...v5Project, - version: 5, - scenes: [ - { - ...(v5Project as { scenes: Array> }) - .scenes[0], - bookmarks: [ - { time: 1, note: "Intro", color: "#ef4444" }, - { time: 5.5, duration: 2 }, - ], - }, - ], - }; - - const result = transformProjectV5ToV6({ - project: projectWithRichBookmarks as Parameters< - typeof transformProjectV5ToV6 - >[0]["project"], - }); - - expect(result.skipped).toBe(false); - const mainScene = ( - result.project.scenes as Array<{ bookmarks: unknown[] }> - )[0]; - expect(mainScene.bookmarks).toEqual([ - { time: 1, note: "Intro", color: "#ef4444" }, - { time: 5.5, duration: 2 }, - ]); - }); -}); +import { describe, expect, test } from "bun:test"; +import { transformProjectV5ToV6 } from "../transformers/v5-to-v6"; +import { v5Project } from "./fixtures"; + +describe("V5 to V6 Migration", () => { + test("converts number bookmarks to Bookmark objects", async () => { + const result = transformProjectV5ToV6({ + project: v5Project as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + expect(result.project.version).toBe(6); + + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 2.0 }, + { time: 5.5 }, + { time: 12.0 }, + ]); + + const introScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[1]; + expect(introScene.bookmarks).toEqual([]); + }); + + test("skips projects that are already v6", () => { + const firstScene = (v5Project as { scenes: Array> }) + .scenes[0]; + const result = transformProjectV5ToV6({ + project: { + ...v5Project, + version: 6, + scenes: [ + { + ...firstScene, + bookmarks: [{ time: 2 }, { time: 5 }], + }, + ], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("already v6"); + }); + + test("skips projects with no id", () => { + const result = transformProjectV5ToV6({ + project: { + version: 5, + scenes: [], + } as Parameters[0]["project"], + }); + + expect(result.skipped).toBe(true); + expect(result.reason).toBe("no project id"); + }); + + test("preserves existing Bookmark objects with note, color, duration", () => { + const projectWithRichBookmarks = { + ...v5Project, + version: 5, + scenes: [ + { + ...(v5Project as { scenes: Array> }) + .scenes[0], + bookmarks: [ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ], + }, + ], + }; + + const result = transformProjectV5ToV6({ + project: projectWithRichBookmarks as Parameters< + typeof transformProjectV5ToV6 + >[0]["project"], + }); + + expect(result.skipped).toBe(false); + const mainScene = ( + result.project.scenes as Array<{ bookmarks: unknown[] }> + )[0]; + expect(mainScene.bookmarks).toEqual([ + { time: 1, note: "Intro", color: "#ef4444" }, + { time: 5.5, duration: 2 }, + ]); + }); +}); diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index ea596435..b7ee37f5 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -20,10 +20,13 @@ import { V17toV18Migration } from "./v17-to-v18"; import { V18toV19Migration } from "./v18-to-v19"; import { V19toV20Migration } from "./v19-to-v20"; import { V20toV21Migration } from "./v20-to-v21"; +import { V21toV22Migration } from "./v21-to-v22"; +import { V22toV23Migration } from "./v22-to-v23"; +import { V23toV24Migration } from "./v23-to-v24"; export { runStorageMigrations } from "./runner"; export type { MigrationProgress } from "./runner"; -export const CURRENT_PROJECT_VERSION = 21; +export const CURRENT_PROJECT_VERSION = 24; export const migrations = [ new V0toV1Migration(), @@ -47,4 +50,7 @@ export const migrations = [ new V18toV19Migration(), new V19toV20Migration(), new V20toV21Migration(), + new V21toV22Migration(), + new V22toV23Migration(), + new V23toV24Migration(), ]; diff --git a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts index a2c72478..6313c311 100644 --- a/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts +++ b/apps/web/src/services/storage/migrations/transformers/v1-to-v2.ts @@ -3,7 +3,7 @@ import { DEFAULT_BACKGROUND_COLOR, } from "@/lib/background/constants"; import { DEFAULT_CANVAS_SIZE } from "@/lib/canvas/constants"; -import { DEFAULT_FPS } from "@/lib/fps/constants"; +const DEFAULT_FPS = 30; import { IndexedDBAdapter } from "@/services/storage/indexeddb-adapter"; import type { MediaAssetData } from "@/services/storage/types"; import type { MigrationResult, ProjectRecord } from "./types"; diff --git a/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts new file mode 100644 index 00000000..5212caf6 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v21-to-v22.ts @@ -0,0 +1,545 @@ +import { converter, parse } from "culori"; +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +const COLOR_COMPONENT_KEYS = ["r", "g", "b", "a"] as const; +type LegacyInterpolation = "linear" | "hold"; + +const toRgb = converter("rgb"); + +interface LinearRgba { + r: number; + g: number; + b: number; + a: number; +} + +function srgbToLinear({ value }: { value: number }): number { + return value <= 0.04045 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); +} + +function parseColorToLinearRgba({ + color, +}: { + color: string; +}): LinearRgba | null { + const parsed = parse(color); + const rgb = parsed ? toRgb(parsed) : null; + if (!rgb) { + return null; + } + + return { + r: srgbToLinear({ value: rgb.r ?? 0 }), + g: srgbToLinear({ value: rgb.g ?? 0 }), + b: srgbToLinear({ value: rgb.b ?? 0 }), + a: Math.max(0, Math.min(1, rgb.alpha ?? 1)), + }; +} + +interface LegacyScalarKeyframe { + id: string; + time: number; + value: number; + interpolation: LegacyInterpolation; +} + +interface LegacyDiscreteKeyframe { + id: string; + time: number; + value: string | boolean; +} + +interface LegacyVectorValue { + x: number; + y: number; +} + +interface LegacyVectorKeyframe { + id: string; + time: number; + value: LegacyVectorValue; + interpolation: LegacyInterpolation; +} + +interface MigratedAnimationChannel { + binding: ProjectRecord; + channels: Record; +} + +export function transformProjectV21ToV22({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 22) { + return { project, skipped: true, reason: "already v22" }; + } + if (version !== 21) { + return { project, skipped: true, reason: "not v21" }; + } + + return { + project: { + ...migrateProjectAnimations({ project }), + version: 22, + }, + skipped: false, + }; +} + +function migrateProjectAnimations({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const scenes = project.scenes; + if (!Array.isArray(scenes)) { + return project; + } + + return { + ...project, + scenes: scenes.map((scene) => migrateSceneAnimations({ scene })), + }; +} + +function migrateSceneAnimations({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const tracks = scene.tracks; + if (!Array.isArray(tracks)) { + return scene; + } + + return { + ...scene, + tracks: tracks.map((track) => migrateTrackAnimations({ track })), + }; +} + +function migrateTrackAnimations({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + const elements = track.elements; + if (!Array.isArray(elements)) { + return track; + } + + return { + ...track, + elements: elements.map((element) => migrateElementAnimations({ element })), + }; +} + +function migrateElementAnimations({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const animations = element.animations; + if (!isRecord(animations)) { + return element; + } + + if (isRecord(animations.bindings)) { + return element; + } + + const migratedAnimations = migrateLegacyAnimations({ animations }); + if (!migratedAnimations) { + const { animations: _unusedAnimations, ...elementWithoutAnimations } = element; + return elementWithoutAnimations; + } + + return { + ...element, + animations: migratedAnimations, + }; +} + +function migrateLegacyAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord | null { + const legacyChannels = animations.channels; + if (!isRecord(legacyChannels)) { + return null; + } + + const nextBindings: Record = {}; + const nextChannels: Record = {}; + + for (const [propertyPath, channel] of Object.entries(legacyChannels)) { + const migratedChannel = migrateLegacyChannel({ + propertyPath, + channel, + }); + if (!migratedChannel) { + continue; + } + + nextBindings[propertyPath] = migratedChannel.binding; + Object.assign(nextChannels, migratedChannel.channels); + } + + if (Object.keys(nextBindings).length === 0) { + return null; + } + + return { + bindings: nextBindings, + channels: nextChannels, + }; +} + +function migrateLegacyChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: unknown; +}): MigratedAnimationChannel | null { + if (!isRecord(channel)) { + return null; + } + + switch (channel.valueKind) { + case "number": + return migrateNumberChannel({ propertyPath, channel }); + case "discrete": + return migrateDiscreteChannel({ propertyPath, channel }); + case "vector": + return migrateVectorChannel({ propertyPath, channel }); + case "color": + return migrateColorChannel({ propertyPath, channel }); + default: + return null; + } +} + +function migrateNumberChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: (value): value is number => + typeof value === "number" && Number.isFinite(value), + }); + if (legacyKeys.length === 0) { + return null; + } + + return { + binding: { + path: propertyPath, + kind: "number", + components: [ + { + key: "value", + channelId: buildChannelId({ + propertyPath, + componentKey: "value", + }), + }, + ], + }, + channels: { + [buildChannelId({ propertyPath, componentKey: "value" })]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => toScalarKeyframe({ keyframe })), + }, + }, + }; +} + +function migrateDiscreteChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyDiscreteKeyframes({ channel }); + if (legacyKeys.length === 0) { + return null; + } + + return { + binding: { + path: propertyPath, + kind: "discrete", + components: [ + { + key: "value", + channelId: buildChannelId({ + propertyPath, + componentKey: "value", + }), + }, + ], + }, + channels: { + [buildChannelId({ propertyPath, componentKey: "value" })]: { + kind: "discrete", + keys: legacyKeys.map((keyframe) => ({ + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + })), + }, + }, + }; +} + +function migrateVectorChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: isLegacyVectorValue, + }); + if (legacyKeys.length === 0) { + return null; + } + + const xChannelId = buildChannelId({ propertyPath, componentKey: "x" }); + const yChannelId = buildChannelId({ propertyPath, componentKey: "y" }); + + return { + binding: { + path: propertyPath, + kind: "vector2", + components: [ + { key: "x", channelId: xChannelId }, + { key: "y", channelId: yChannelId }, + ], + }, + channels: { + [xChannelId]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + ...keyframe, + value: keyframe.value.x, + }, + }), + ), + }, + [yChannelId]: { + kind: "scalar", + keys: legacyKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + ...keyframe, + value: keyframe.value.y, + }, + }), + ), + }, + }, + }; +} + +function migrateColorChannel({ + propertyPath, + channel, +}: { + propertyPath: string; + channel: ProjectRecord; +}): MigratedAnimationChannel | null { + const legacyKeys = getLegacyScalarKeyframes({ + channel, + isValidValue: (value): value is string => typeof value === "string", + }); + if (legacyKeys.length === 0) { + return null; + } + + const colorKeys = legacyKeys.flatMap((keyframe) => { + const linearRgba = parseColorToLinearRgba({ color: keyframe.value }); + if (!linearRgba) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + interpolation: keyframe.interpolation, + values: linearRgba, + }, + ]; + }); + if (colorKeys.length === 0) { + return null; + } + + const channels = Object.fromEntries( + COLOR_COMPONENT_KEYS.map((componentKey) => [ + buildChannelId({ propertyPath, componentKey }), + { + kind: "scalar", + keys: colorKeys.map((keyframe) => + toScalarKeyframe({ + keyframe: { + id: keyframe.id, + time: keyframe.time, + value: keyframe.values[componentKey], + interpolation: keyframe.interpolation, + }, + }), + ), + }, + ]), + ); + + return { + binding: { + path: propertyPath, + kind: "color", + colorSpace: "srgb-linear", + components: COLOR_COMPONENT_KEYS.map((componentKey) => ({ + key: componentKey, + channelId: buildChannelId({ propertyPath, componentKey }), + })), + }, + channels, + }; +} + +function getLegacyScalarKeyframes({ + channel, + isValidValue, +}: { + channel: ProjectRecord; + isValidValue: (value: unknown) => value is TValue; +}): Array<{ + id: string; + time: number; + value: TValue; + interpolation: LegacyInterpolation; +}> { + const keyframes = channel.keyframes; + if (!Array.isArray(keyframes)) { + return []; + } + + return keyframes.flatMap((keyframe) => { + if (!isRecord(keyframe)) { + return []; + } + + if ( + typeof keyframe.id !== "string" || + typeof keyframe.time !== "number" || + !Number.isFinite(keyframe.time) || + !isValidValue(keyframe.value) + ) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + interpolation: + keyframe.interpolation === "hold" ? "hold" : "linear", + }, + ]; + }); +} + +function getLegacyDiscreteKeyframes({ + channel, +}: { + channel: ProjectRecord; +}): LegacyDiscreteKeyframe[] { + const keyframes = channel.keyframes; + if (!Array.isArray(keyframes)) { + return []; + } + + return keyframes.flatMap((keyframe) => { + if (!isRecord(keyframe)) { + return []; + } + + if ( + typeof keyframe.id !== "string" || + typeof keyframe.time !== "number" || + !Number.isFinite(keyframe.time) || + (typeof keyframe.value !== "string" && typeof keyframe.value !== "boolean") + ) { + return []; + } + + return [ + { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + }, + ]; + }); +} + +function isLegacyVectorValue(value: unknown): value is LegacyVectorValue { + return ( + isRecord(value) && + typeof value.x === "number" && + Number.isFinite(value.x) && + typeof value.y === "number" && + Number.isFinite(value.y) + ); +} + +function toScalarKeyframe({ + keyframe, +}: { + keyframe: LegacyScalarKeyframe; +}): ProjectRecord { + return { + id: keyframe.id, + time: keyframe.time, + value: keyframe.value, + segmentToNext: + keyframe.interpolation === "hold" ? "step" : "linear", + tangentMode: "flat", + }; +} + +function buildChannelId({ + propertyPath, + componentKey, +}: { + propertyPath: string; + componentKey: string; +}) { + return `${propertyPath}:${componentKey}`; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts b/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts new file mode 100644 index 00000000..cde1294e --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v22-to-v23.ts @@ -0,0 +1,335 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +import { TICKS_PER_SECOND } from "@/lib/wasm"; +const ARBITRARY_FPS_DENOMINATOR = 1_000_000; +const STANDARD_FRAME_RATES = [ + { value: 24_000 / 1_001, numerator: 24_000, denominator: 1_001 }, + { value: 24, numerator: 24, denominator: 1 }, + { value: 25, numerator: 25, denominator: 1 }, + { value: 30_000 / 1_001, numerator: 30_000, denominator: 1_001 }, + { value: 30, numerator: 30, denominator: 1 }, + { value: 48, numerator: 48, denominator: 1 }, + { value: 50, numerator: 50, denominator: 1 }, + { value: 60_000 / 1_001, numerator: 60_000, denominator: 1_001 }, + { value: 60, numerator: 60, denominator: 1 }, + { value: 120, numerator: 120, denominator: 1 }, +] as const; +const STANDARD_FRAME_RATE_TOLERANCE = 0.01; + +export function transformProjectV22ToV23({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 23) { + return { project, skipped: true, reason: "already v23" }; + } + if (version !== 22) { + return { project, skipped: true, reason: "not v22" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 23, + }, + skipped: false, + }; +} + +function migrateProject({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + const nextProject = { ...project }; + + if (isRecord(project.metadata)) { + nextProject.metadata = migrateMetadata({ metadata: project.metadata }); + } + + if (isRecord(project.settings)) { + nextProject.settings = migrateSettings({ settings: project.settings }); + } + + if (isRecord(project.timelineViewState)) { + nextProject.timelineViewState = migrateTimelineViewState({ + timelineViewState: project.timelineViewState, + }); + } + + if (Array.isArray(project.scenes)) { + nextProject.scenes = project.scenes.map((scene) => migrateScene({ scene })); + } + + return nextProject; +} + +function migrateMetadata({ + metadata, +}: { + metadata: ProjectRecord; +}): ProjectRecord { + return migrateTimeFields({ + record: metadata, + keys: ["duration"], + }); +} + +function migrateSettings({ + settings, +}: { + settings: ProjectRecord; +}): ProjectRecord { + const nextSettings = { ...settings }; + if ("fps" in settings) { + nextSettings.fps = migrateFrameRate({ fps: settings.fps }); + } + return nextSettings; +} + +function migrateTimelineViewState({ + timelineViewState, +}: { + timelineViewState: ProjectRecord; +}): ProjectRecord { + return migrateTimeFields({ + record: timelineViewState, + keys: ["playheadTime"], + }); +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene)) { + return scene; + } + + const nextScene = { ...scene }; + + if (Array.isArray(scene.bookmarks)) { + nextScene.bookmarks = scene.bookmarks.map((bookmark) => + migrateBookmark({ bookmark }), + ); + } + + if (Array.isArray(scene.tracks)) { + nextScene.tracks = scene.tracks.map((track) => migrateTrack({ track })); + } + + return nextScene; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + if (!Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element)) { + return element; + } + + const nextElement = migrateTimeFields({ + record: element, + keys: ["duration", "startTime", "trimStart", "trimEnd", "sourceDuration"], + }); + + if (isRecord(element.animations)) { + nextElement.animations = migrateAnimations({ + animations: element.animations, + }); + } + + return nextElement; +} + +function migrateAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord { + if (!isRecord(animations.channels)) { + return animations; + } + + return { + ...animations, + channels: Object.fromEntries( + Object.entries(animations.channels).map(([channelId, channel]) => [ + channelId, + migrateAnimationChannel({ channel }), + ]), + ), + }; +} + +function migrateAnimationChannel({ channel }: { channel: unknown }): unknown { + if (!isRecord(channel)) { + return channel; + } + + if (!Array.isArray(channel.keys)) { + return channel; + } + + return { + ...channel, + keys: channel.keys.map((keyframe) => migrateAnimationKeyframe({ keyframe })), + }; +} + +function migrateAnimationKeyframe({ + keyframe, +}: { + keyframe: unknown; +}): unknown { + if (!isRecord(keyframe)) { + return keyframe; + } + + const nextKeyframe = migrateTimeFields({ + record: keyframe, + keys: ["time"], + }); + + if (isRecord(keyframe.leftHandle)) { + nextKeyframe.leftHandle = migrateCurveHandle({ + handle: keyframe.leftHandle, + }); + } + + if (isRecord(keyframe.rightHandle)) { + nextKeyframe.rightHandle = migrateCurveHandle({ + handle: keyframe.rightHandle, + }); + } + + return nextKeyframe; +} + +function migrateCurveHandle({ + handle, +}: { + handle: ProjectRecord; +}): ProjectRecord { + return migrateTimeFields({ + record: handle, + keys: ["dt"], + }); +} + +function migrateBookmark({ bookmark }: { bookmark: unknown }): unknown { + if (!isRecord(bookmark)) { + return bookmark; + } + + return migrateTimeFields({ + record: bookmark, + keys: ["time", "duration"], + }); +} + +function migrateTimeFields({ + record, + keys, +}: { + record: ProjectRecord; + keys: string[]; +}): ProjectRecord { + const nextRecord = { ...record }; + + for (const key of keys) { + if (!(key in record)) { + continue; + } + + nextRecord[key] = migrateTimeValue({ value: record[key] }); + } + + return nextRecord; +} + +function migrateTimeValue({ value }: { value: unknown }): unknown { + if (typeof value !== "number" || !Number.isFinite(value)) { + return value; + } + + return secondsToTicks({ value }); +} + +function secondsToTicks({ value }: { value: number }): number { + return Math.round(value * TICKS_PER_SECOND); +} + +function migrateFrameRate({ fps }: { fps: unknown }): unknown { + if (isRecord(fps)) { + return fps; + } + + if (typeof fps !== "number" || !Number.isFinite(fps) || fps <= 0) { + return fps; + } + + const standardFrameRate = STANDARD_FRAME_RATES.find( + (candidate) => Math.abs(fps - candidate.value) <= STANDARD_FRAME_RATE_TOLERANCE, + ); + if (standardFrameRate) { + return { + numerator: standardFrameRate.numerator, + denominator: standardFrameRate.denominator, + }; + } + + if (Number.isInteger(fps)) { + return { numerator: fps, denominator: 1 }; + } + + const scaledNumerator = Math.round(fps * ARBITRARY_FPS_DENOMINATOR); + const divisor = greatestCommonDivisor({ + left: scaledNumerator, + right: ARBITRARY_FPS_DENOMINATOR, + }); + + return { + numerator: scaledNumerator / divisor, + denominator: ARBITRARY_FPS_DENOMINATOR / divisor, + }; +} + +function greatestCommonDivisor({ + left, + right, +}: { + left: number; + right: number; +}): number { + let nextLeft = Math.abs(left); + let nextRight = Math.abs(right); + + while (nextRight !== 0) { + const remainder = nextLeft % nextRight; + nextLeft = nextRight; + nextRight = remainder; + } + + return nextLeft || 1; +} diff --git a/apps/web/src/services/storage/migrations/transformers/v23-to-v24.ts b/apps/web/src/services/storage/migrations/transformers/v23-to-v24.ts new file mode 100644 index 00000000..710d4dc1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v23-to-v24.ts @@ -0,0 +1,113 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV23ToV24({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 24) { + return { project, skipped: true, reason: "already v24" }; + } + if (version !== 23) { + return { project, skipped: true, reason: "not v23" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 24, + }, + skipped: false, + }; +} + +function migrateProject({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + if (!Array.isArray(project.scenes)) { + return project; + } + + return { + ...project, + scenes: project.scenes.map((scene) => migrateScene({ scene })), + }; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene) || !Array.isArray(scene.tracks)) { + return scene; + } + + const mainTrack = findMainTrack({ tracks: scene.tracks }); + if (!mainTrack) { + return scene; + } + + return { + ...scene, + tracks: { + overlay: scene.tracks + .filter((track) => track !== mainTrack) + .map((track) => migrateTrack({ track })) + .filter( + (track): track is ProjectRecord => + isRecord(track) && track.type !== "audio", + ), + main: migrateTrack({ track: mainTrack }), + audio: scene.tracks + .map((track) => migrateTrack({ track })) + .filter( + (track): track is ProjectRecord => + isRecord(track) && track.type === "audio", + ), + }, + }; +} + +function findMainTrack({ + tracks, +}: { + tracks: unknown[]; +}): ProjectRecord | null { + for (const track of tracks) { + if (!isRecord(track)) { + continue; + } + if (track.type === "video" && track.isMain === true) { + return track; + } + } + + for (const track of tracks) { + if (!isRecord(track)) { + continue; + } + if (track.type === "video") { + return track; + } + } + + return null; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track)) { + return track; + } + + const nextTrack = { ...track }; + delete nextTrack.isMain; + return nextTrack; +} diff --git a/apps/web/src/services/storage/migrations/v21-to-v22.ts b/apps/web/src/services/storage/migrations/v21-to-v22.ts new file mode 100644 index 00000000..212109a5 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v21-to-v22.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV21ToV22 } from "./transformers/v21-to-v22"; + +export class V21toV22Migration extends StorageMigration { + from = 21; + to = 22; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV21ToV22({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v22-to-v23.ts b/apps/web/src/services/storage/migrations/v22-to-v23.ts new file mode 100644 index 00000000..cd352e45 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v22-to-v23.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV22ToV23 } from "./transformers/v22-to-v23"; + +export class V22toV23Migration extends StorageMigration { + from = 22; + to = 23; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV22ToV23({ project }); + } +} diff --git a/apps/web/src/services/storage/migrations/v23-to-v24.ts b/apps/web/src/services/storage/migrations/v23-to-v24.ts new file mode 100644 index 00000000..fe952bb1 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v23-to-v24.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV23ToV24 } from "./transformers/v23-to-v24"; + +export class V23toV24Migration extends StorageMigration { + from = 23; + to = 24; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV23ToV24({ project }); + } +} diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts index 3a014894..1aea0297 100644 --- a/apps/web/src/services/storage/service.ts +++ b/apps/web/src/services/storage/service.ts @@ -21,7 +21,7 @@ import { migrations, runStorageMigrations, } from "@/services/storage/migrations"; -import type { Bookmark, TimelineTrack, TScene } from "@/lib/timeline"; +import type { Bookmark, SceneTracks, TScene } from "@/lib/timeline"; function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] { if (!Array.isArray(raw)) return []; @@ -116,18 +116,18 @@ class StorageService { private stripAudioBuffers({ tracks, }: { - tracks: TimelineTrack[]; - }): TimelineTrack[] { - return tracks.map((track) => { - if (track.type !== "audio") return track; - return { + tracks: SceneTracks; + }): SceneTracks { + return { + ...tracks, + audio: tracks.audio.map((track) => ({ ...track, elements: track.elements.map((element) => { const { buffer: _buffer, ...rest } = element; return rest; }), - }; - }); + })), + }; } async saveProject({ project }: { project: TProject }): Promise { @@ -178,11 +178,7 @@ class StorageService { id: scene.id, name: scene.name, isMain: scene.isMain, - tracks: (scene.tracks ?? []).map((track) => - track.type === "video" - ? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional - : track, - ), + tracks: scene.tracks, bookmarks: normalizeBookmarks({ raw: scene.bookmarks }), createdAt: new Date(scene.createdAt), updatedAt: new Date(scene.updatedAt), diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index b32d77bc..e7622240 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -215,7 +215,7 @@ export const useSoundsStore = create((set, get) => ({ try { const editor = EditorCore.getInstance(); const currentTime = editor.playback.getCurrentTime(); - const tracks = editor.timeline.getTracks(); + const tracks = editor.scenes.getActiveScene().tracks; const response = await fetch(audioUrl); if (!response.ok) @@ -225,7 +225,7 @@ export const useSoundsStore = create((set, get) => ({ const audioContext = new AudioContext(); const buffer = await audioContext.decodeAudioData(arrayBuffer); - const audioTrack = tracks.find((t) => t.type === "audio"); + const audioTrack = tracks.audio[0]; let trackId: string; if (audioTrack) { diff --git a/bun.lock b/bun.lock index ef674788..c8eceb53 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "devDependencies": { "turbo": "^2.8.20", - "typescript": "5.8.3", + "typescript": "^6.0.2", }, }, "apps/web": { @@ -54,7 +54,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.1.2", + "opencut-wasm": "^0.2.3", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", @@ -1359,7 +1359,7 @@ "onnxruntime-web": ["onnxruntime-web@1.22.0-dev.20250409-89f8206ba4", "", { "dependencies": { "flatbuffers": "^25.1.24", "guid-typescript": "^1.0.9", "long": "^5.2.3", "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", "platform": "^1.3.6", "protobufjs": "^7.2.4" } }, "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ=="], - "opencut-wasm": ["opencut-wasm@0.1.2", "", {}, "sha512-3OZ7JYwFNFijqQIFBgrqzyD+0V5yG4KPJtRPR76UcqM3Ai3y2GXdp7IlDL7NG3IoQ4C5yX2LzKl2pr4FbDvuFQ=="], + "opencut-wasm": ["opencut-wasm@0.2.3", "", {}, "sha512-SaLe2fgvLK+EcW7qqggmcfXaQwo6SOh83/h3dtEEbdqEka7gn1PGJECKnTZq4OWhkNEgf8SKJy/HiJOQvyZC+Q=="], "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], @@ -1601,7 +1601,7 @@ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], - "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], @@ -1723,6 +1723,8 @@ "@node-minify/core/mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "@opencut/web/typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "@opennextjs/aws/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], diff --git a/docker-compose.yml b/docker-compose.yml index a20c2654..0c4983b3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,12 +70,6 @@ services: - MARBLE_WORKSPACE_KEY=${MARBLE_WORKSPACE_KEY:-placeholder} - FREESOUND_CLIENT_ID=${FREESOUND_CLIENT_ID} - FREESOUND_API_KEY=${FREESOUND_API_KEY} - # Transcription (Optional - leave blank to disable auto-captions) - - CLOUDFLARE_ACCOUNT_ID=${CLOUDFLARE_ACCOUNT_ID:-placeholder} - - R2_ACCESS_KEY_ID=${R2_ACCESS_KEY_ID:-placeholder} - - R2_SECRET_ACCESS_KEY=${R2_SECRET_ACCESS_KEY:-placeholder} - - R2_BUCKET_NAME=${R2_BUCKET_NAME:-opencut-transcription} - - MODAL_TRANSCRIPTION_URL=${MODAL_TRANSCRIPTION_URL:-http://localhost:0} depends_on: db: condition: service_healthy diff --git a/package.json b/package.json index 2b77a0d3..22504650 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ }, "devDependencies": { "turbo": "^2.8.20", - "typescript": "5.8.3" + "typescript": "^6.0.2" }, "trustedDependencies": [ "@tailwindcss/oxide" diff --git a/rust/crates/bridge/src/bridge.rs b/rust/crates/bridge/src/bridge.rs index 52dee731..99b00615 100644 --- a/rust/crates/bridge/src/bridge.rs +++ b/rust/crates/bridge/src/bridge.rs @@ -1,10 +1,36 @@ use proc_macro::TokenStream; use quote::quote; -use syn::{ItemFn, parse_macro_input}; +use syn::{FnArg, Item, ItemConst, ItemFn, parse_macro_input}; #[proc_macro_attribute] pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream { - let function = parse_macro_input!(item as ItemFn); + match parse_macro_input!(item as Item) { + Item::Fn(function) => export_fn(function), + Item::Const(constant) => export_const(constant), + other => syn::Error::new_spanned(other, "#[export] only supports fn and const items") + .to_compile_error() + .into(), + } +} + +fn export_fn(function: ItemFn) -> TokenStream { + let param_count = function + .sig + .inputs + .iter() + .filter(|arg| matches!(arg, FnArg::Typed(_))) + .count(); + + if param_count > 1 { + return syn::Error::new_spanned( + &function.sig.inputs, + "#[export] functions must accept a single options struct, not positional arguments. \ + Wrap parameters in a struct: `fn foo(FooOptions { a, b }: FooOptions)`", + ) + .to_compile_error() + .into(); + } + let js_name = snake_to_camel(&function.sig.ident.to_string()); quote! { @@ -14,6 +40,26 @@ pub fn export(_attr: TokenStream, item: TokenStream) -> TokenStream { .into() } +fn export_const(constant: ItemConst) -> TokenStream { + let js_name = constant.ident.to_string(); + let const_ident = &constant.ident; + let getter_ident = syn::Ident::new( + &format!("__const_{}", constant.ident.to_string().to_lowercase()), + constant.ident.span(), + ); + + quote! { + #constant + + #[cfg(feature = "wasm")] + #[::wasm_bindgen::prelude::wasm_bindgen(js_name = #js_name)] + pub fn #getter_ident() -> f64 { + #const_ident as f64 + } + } + .into() +} + fn snake_to_camel(name: &str) -> String { let mut camel = String::with_capacity(name.len()); let mut should_uppercase_next = false; diff --git a/rust/crates/effects/Cargo.toml b/rust/crates/effects/Cargo.toml new file mode 100644 index 00000000..5b6e2a1e --- /dev/null +++ b/rust/crates/effects/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "effects" +version = "0.1.0" +edition = "2024" + +[lib] +path = "src/effects.rs" +crate-type = ["rlib"] + +[dependencies] +bytemuck = { version = "1.25.0", features = ["derive"] } +gpu = { version = "0.1.0", path = "../gpu" } +thiserror = "2.0.18" +wgpu = "29.0.1" diff --git a/rust/crates/effects/src/effects.rs b/rust/crates/effects/src/effects.rs new file mode 100644 index 00000000..c6d750bb --- /dev/null +++ b/rust/crates/effects/src/effects.rs @@ -0,0 +1,5 @@ +mod pipeline; +mod types; + +pub use pipeline::{ApplyEffectsOptions, EffectPipeline, EffectsError}; +pub use types::{EffectPass, UniformValue}; diff --git a/rust/crates/effects/src/pipeline.rs b/rust/crates/effects/src/pipeline.rs new file mode 100644 index 00000000..58692bde --- /dev/null +++ b/rust/crates/effects/src/pipeline.rs @@ -0,0 +1,306 @@ +use std::collections::HashMap; + +use bytemuck::{Pod, Zeroable}; +use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext}; +use thiserror::Error; +use wgpu::util::DeviceExt; + +use crate::{EffectPass, UniformValue}; + +const GAUSSIAN_BLUR_SHADER_ID: &str = "gaussian-blur"; +const GAUSSIAN_BLUR_SHADER_SOURCE: &str = include_str!("shaders/gaussian_blur.wgsl"); + +pub struct ApplyEffectsOptions<'a> { + pub source: &'a wgpu::Texture, + pub width: u32, + pub height: u32, + pub passes: &'a [EffectPass], +} + +pub struct EffectPipeline { + uniform_bind_group_layout: wgpu::BindGroupLayout, + pipelines: HashMap, +} + +#[derive(Debug, Error)] +pub enum EffectsError { + #[error("At least one effect pass is required")] + MissingEffectPasses, + #[error("Unknown effect shader '{shader}'")] + UnknownEffectShader { shader: String }, + #[error("Missing uniform '{uniform}' for shader '{shader}'")] + MissingUniform { shader: String, uniform: String }, + #[error("Uniform '{uniform}' for shader '{shader}' must be a number")] + InvalidNumberUniform { shader: String, uniform: String }, + #[error( + "Uniform '{uniform}' for shader '{shader}' must be a vector of length {expected_length}" + )] + InvalidVectorUniform { + shader: String, + uniform: String, + expected_length: usize, + }, + #[error("Shader '{shader}' does not support uniform '{uniform}'")] + UnsupportedUniform { shader: String, uniform: String }, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct EffectUniformBuffer { + resolution: [f32; 2], + direction: [f32; 2], + scalars: [f32; 4], +} + +impl EffectPipeline { + pub fn new(context: &GpuContext) -> Self { + let uniform_bind_group_layout = + context + .device() + .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("effects-uniform-bind-group-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }); + let vertex_shader_module = + context + .device() + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("effects-fullscreen-shader"), + source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()), + }); + let gaussian_blur_shader_module = + context + .device() + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("effects-gaussian-blur-shader"), + source: wgpu::ShaderSource::Wgsl(GAUSSIAN_BLUR_SHADER_SOURCE.into()), + }); + let pipeline_layout = + context + .device() + .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("effects-pipeline-layout"), + bind_group_layouts: &[ + Some(context.texture_sampler_bind_group_layout()), + Some(&uniform_bind_group_layout), + ], + immediate_size: 0, + }); + let gaussian_blur_pipeline = + context + .device() + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("effects-gaussian-blur-pipeline"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vertex_shader_module, + entry_point: Some("vertex_main"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::<[f32; 2]>() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }], + }], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &gaussian_blur_shader_module, + entry_point: Some("fragment_main"), + targets: &[Some(wgpu::ColorTargetState { + format: GPU_TEXTURE_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let pipelines = + HashMap::from([(GAUSSIAN_BLUR_SHADER_ID.to_string(), gaussian_blur_pipeline)]); + + Self { + uniform_bind_group_layout, + pipelines, + } + } + + pub fn apply( + &self, + context: &GpuContext, + ApplyEffectsOptions { + source, + width, + height, + passes, + }: ApplyEffectsOptions<'_>, + ) -> Result { + let mut current_texture: Option = None; + + for pass in passes { + let input_texture = current_texture.as_ref().unwrap_or(source); + let output_texture = + context.create_render_texture(width, height, "effects-pass-output"); + let input_view = input_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default()); + let texture_bind_group = + context + .device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("effects-texture-bind-group"), + layout: context.texture_sampler_bind_group_layout(), + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&input_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(context.linear_sampler()), + }, + ], + }); + let uniform_buffer = + context + .device() + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("effects-uniform-buffer"), + contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + let uniform_bind_group = + context + .device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("effects-uniform-bind-group"), + layout: &self.uniform_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform_buffer.as_entire_binding(), + }], + }); + let pipeline = self.pipelines.get(&pass.shader).ok_or_else(|| { + EffectsError::UnknownEffectShader { + shader: pass.shader.clone(), + } + })?; + let mut encoder = + context + .device() + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("effects-command-encoder"), + }); + + { + let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("effects-render-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &output_view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + occlusion_query_set: None, + timestamp_writes: None, + multiview_mask: None, + }); + render_pass.set_pipeline(pipeline); + render_pass.set_vertex_buffer(0, context.fullscreen_quad().slice(..)); + render_pass.set_bind_group(0, &texture_bind_group, &[]); + render_pass.set_bind_group(1, &uniform_bind_group, &[]); + render_pass.draw(0..6, 0..1); + } + + context.queue().submit([encoder.finish()]); + current_texture = Some(output_texture); + } + + current_texture.ok_or(EffectsError::MissingEffectPasses) + } +} + +fn pack_effect_uniforms( + pass: &EffectPass, + width: u32, + height: u32, +) -> Result { + let shader = pass.shader.as_str(); + let sigma = read_number_uniform(pass, "u_sigma")?; + let step = read_number_uniform(pass, "u_step")?; + let direction = read_vec2_uniform(pass, "u_direction")?; + + for uniform in pass.uniforms.keys() { + if uniform == "u_sigma" || uniform == "u_step" || uniform == "u_direction" { + continue; + } + return Err(EffectsError::UnsupportedUniform { + shader: shader.to_string(), + uniform: uniform.clone(), + }); + } + + Ok(EffectUniformBuffer { + resolution: [width as f32, height as f32], + direction, + scalars: [sigma, step, 0.0, 0.0], + }) +} + +fn read_number_uniform(pass: &EffectPass, uniform: &str) -> Result { + let Some(value) = pass.uniforms.get(uniform) else { + return Err(EffectsError::MissingUniform { + shader: pass.shader.clone(), + uniform: uniform.to_string(), + }); + }; + match value { + UniformValue::Number(value) => Ok(*value), + UniformValue::Vector(_) => Err(EffectsError::InvalidNumberUniform { + shader: pass.shader.clone(), + uniform: uniform.to_string(), + }), + } +} + +fn read_vec2_uniform(pass: &EffectPass, uniform: &str) -> Result<[f32; 2], EffectsError> { + let Some(value) = pass.uniforms.get(uniform) else { + return Err(EffectsError::MissingUniform { + shader: pass.shader.clone(), + uniform: uniform.to_string(), + }); + }; + let UniformValue::Vector(values) = value else { + return Err(EffectsError::InvalidVectorUniform { + shader: pass.shader.clone(), + uniform: uniform.to_string(), + expected_length: 2, + }); + }; + if values.len() != 2 { + return Err(EffectsError::InvalidVectorUniform { + shader: pass.shader.clone(), + uniform: uniform.to_string(), + expected_length: 2, + }); + } + Ok([values[0], values[1]]) +} diff --git a/rust/crates/gpu/src/shaders/gaussian_blur.wgsl b/rust/crates/effects/src/shaders/gaussian_blur.wgsl similarity index 100% rename from rust/crates/gpu/src/shaders/gaussian_blur.wgsl rename to rust/crates/effects/src/shaders/gaussian_blur.wgsl diff --git a/rust/crates/effects/src/types.rs b/rust/crates/effects/src/types.rs new file mode 100644 index 00000000..bd013195 --- /dev/null +++ b/rust/crates/effects/src/types.rs @@ -0,0 +1,13 @@ +use std::collections::HashMap; + +#[derive(Clone, Debug)] +pub struct EffectPass { + pub shader: String, + pub uniforms: HashMap, +} + +#[derive(Clone, Debug)] +pub enum UniformValue { + Number(f32), + Vector(Vec), +} diff --git a/rust/crates/gpu/Cargo.toml b/rust/crates/gpu/Cargo.toml index d68e8a65..4be1e8d6 100644 --- a/rust/crates/gpu/Cargo.toml +++ b/rust/crates/gpu/Cargo.toml @@ -7,3 +7,7 @@ edition = "2024" bytemuck = { version = "1.25.0", features = ["derive"] } thiserror = "2.0.18" wgpu = "29.0.1" + +[features] +default = [] +wasm = [] diff --git a/rust/crates/gpu/src/context.rs b/rust/crates/gpu/src/context.rs index 702f65ba..1526a67d 100644 --- a/rust/crates/gpu/src/context.rs +++ b/rust/crates/gpu/src/context.rs @@ -1,12 +1,8 @@ use wgpu::util::DeviceExt; -use crate::{ - GPU_TEXTURE_FORMAT, GpuError, - effect_pipeline::{ApplyEffectsOptions, apply_effects}, - mask_feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline}, - sdf_pipeline::SdfPipeline, - shader_registry::ShaderRegistry, -}; +use crate::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuError}; + +const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl"); const FULLSCREEN_QUAD_POSITIONS: [[f32; 2]; 6] = [ [-1.0, -1.0], @@ -25,9 +21,8 @@ pub struct GpuContext { fullscreen_quad: wgpu::Buffer, linear_sampler: wgpu::Sampler, nearest_sampler: wgpu::Sampler, - shader_registry: ShaderRegistry, - sdf_pipeline: SdfPipeline, - mask_feather_pipeline: MaskFeatherPipeline, + texture_sampler_bind_group_layout: wgpu::BindGroupLayout, + blit_pipeline: wgpu::RenderPipeline, } impl GpuContext { @@ -77,9 +72,74 @@ impl GpuContext { mipmap_filter: wgpu::MipmapFilterMode::Nearest, ..Default::default() }); - let shader_registry = ShaderRegistry::new(&device); - let sdf_pipeline = SdfPipeline::new(&device); - let mask_feather_pipeline = MaskFeatherPipeline::new(&device); + let texture_sampler_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("gpu-texture-sampler-bind-group-layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let vertex_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpu-fullscreen-shader"), + source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()), + }); + let blit_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpu-blit-shader"), + source: wgpu::ShaderSource::Wgsl(BLIT_SHADER_SOURCE.into()), + }); + let blit_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("gpu-blit-pipeline-layout"), + bind_group_layouts: &[Some(&texture_sampler_bind_group_layout)], + immediate_size: 0, + }); + let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("gpu-blit-pipeline"), + layout: Some(&blit_pipeline_layout), + vertex: wgpu::VertexState { + module: &vertex_shader_module, + entry_point: Some("vertex_main"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::<[f32; 2]>() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + format: wgpu::VertexFormat::Float32x2, + offset: 0, + shader_location: 0, + }], + }], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &blit_shader_module, + entry_point: Some("fragment_main"), + targets: &[Some(wgpu::ColorTargetState { + format: GPU_TEXTURE_FORMAT, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); Ok(Self { instance, @@ -89,26 +149,11 @@ impl GpuContext { fullscreen_quad, linear_sampler, nearest_sampler, - shader_registry, - sdf_pipeline, - mask_feather_pipeline, + texture_sampler_bind_group_layout, + blit_pipeline, }) } - pub fn apply_effects( - &self, - options: ApplyEffectsOptions<'_>, - ) -> Result { - apply_effects(self, options) - } - - pub fn apply_mask_feather( - &self, - options: ApplyMaskFeatherOptions<'_>, - ) -> wgpu::Texture { - self.mask_feather_pipeline.apply_mask_feather(self, options) - } - pub fn create_render_texture( &self, width: u32, @@ -162,12 +207,8 @@ impl GpuContext { &self.nearest_sampler } - pub fn shader_registry(&self) -> &ShaderRegistry { - &self.shader_registry - } - - pub fn sdf_pipeline(&self) -> &SdfPipeline { - &self.sdf_pipeline + pub fn texture_sampler_bind_group_layout(&self) -> &wgpu::BindGroupLayout { + &self.texture_sampler_bind_group_layout } pub fn render_texture_to_surface( @@ -202,7 +243,7 @@ impl GpuContext { .create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("gpu-blit-bind-group"), - layout: self.shader_registry.effect_texture_bind_group_layout(), + layout: &self.texture_sampler_bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, @@ -237,7 +278,7 @@ impl GpuContext { timestamp_writes: None, multiview_mask: None, }); - render_pass.set_pipeline(self.shader_registry.blit_pipeline()); + render_pass.set_pipeline(&self.blit_pipeline); render_pass.set_vertex_buffer(0, self.fullscreen_quad.slice(..)); render_pass.set_bind_group(0, &bind_group, &[]); render_pass.draw(0..6, 0..1); @@ -247,4 +288,50 @@ impl GpuContext { surface_texture.present(); Ok(()) } + + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + pub fn import_offscreen_canvas_texture( + &self, + canvas: &wgpu::web_sys::OffscreenCanvas, + width: u32, + height: u32, + label: &'static str, + ) -> wgpu::Texture { + let texture = self.create_render_texture(width, height, label); + self.queue.copy_external_image_to_texture( + &wgpu::CopyExternalImageSourceInfo { + source: wgpu::ExternalImageSource::OffscreenCanvas(canvas.clone()), + origin: wgpu::Origin2d::ZERO, + flip_y: true, + }, + wgpu::CopyExternalImageDestInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + color_space: wgpu::PredefinedColorSpace::Srgb, + premultiplied_alpha: false, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + texture + } + + #[cfg(all(feature = "wasm", target_arch = "wasm32"))] + pub fn render_texture_to_offscreen_canvas( + &self, + texture: &wgpu::Texture, + canvas: &wgpu::web_sys::OffscreenCanvas, + width: u32, + height: u32, + ) -> Result<(), GpuError> { + let surface = self + .instance + .create_surface(wgpu::SurfaceTarget::OffscreenCanvas(canvas.clone()))?; + self.render_texture_to_surface(texture, &surface, width, height) + } } diff --git a/rust/crates/gpu/src/effect_pipeline.rs b/rust/crates/gpu/src/effect_pipeline.rs deleted file mode 100644 index c600551a..00000000 --- a/rust/crates/gpu/src/effect_pipeline.rs +++ /dev/null @@ -1,194 +0,0 @@ -use std::collections::HashMap; - -use bytemuck::{Pod, Zeroable}; -use wgpu::util::DeviceExt; - -use crate::{GpuError, context::GpuContext}; - -pub struct ApplyEffectsOptions<'a> { - pub source: &'a wgpu::Texture, - pub width: u32, - pub height: u32, - pub passes: &'a [EffectPass], -} - -#[derive(Clone, Debug)] -pub struct EffectPass { - pub shader: String, - pub uniforms: HashMap, -} - -#[derive(Clone, Debug)] -pub enum UniformValue { - Number(f32), - Vector(Vec), -} - -#[repr(C)] -#[derive(Clone, Copy, Pod, Zeroable)] -struct EffectUniformBuffer { - resolution: [f32; 2], - direction: [f32; 2], - scalars: [f32; 4], -} - -pub fn apply_effects( - context: &GpuContext, - ApplyEffectsOptions { - source, - width, - height, - passes, - }: ApplyEffectsOptions<'_>, -) -> Result { - let mut current_texture: Option = None; - - for pass in passes { - let input_texture = current_texture.as_ref().unwrap_or(source); - let output_texture = - context.create_render_texture(width, height, "gpu-effect-pass-output"); - let input_view = input_texture.create_view(&wgpu::TextureViewDescriptor::default()); - let output_view = output_texture.create_view(&wgpu::TextureViewDescriptor::default()); - let texture_bind_group = context - .device() - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("gpu-effect-texture-bind-group"), - layout: context - .shader_registry() - .effect_texture_bind_group_layout(), - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(&input_view), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::Sampler(context.linear_sampler()), - }, - ], - }); - let uniform_buffer = context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("gpu-effect-uniform-buffer"), - contents: bytemuck::bytes_of(&pack_effect_uniforms(pass, width, height)?), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); - let uniform_bind_group = context - .device() - .create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("gpu-effect-uniform-bind-group"), - layout: context - .shader_registry() - .effect_uniform_bind_group_layout(), - entries: &[wgpu::BindGroupEntry { - binding: 0, - resource: uniform_buffer.as_entire_binding(), - }], - }); - let pipeline = context.shader_registry().get_effect_pipeline(&pass.shader)?; - let mut encoder = context - .device() - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("gpu-effect-command-encoder"), - }); - - { - let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("gpu-effect-render-pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &output_view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), - store: wgpu::StoreOp::Store, - }, - })], - depth_stencil_attachment: None, - occlusion_query_set: None, - timestamp_writes: None, - multiview_mask: None, - }); - render_pass.set_pipeline(pipeline); - render_pass.set_vertex_buffer(0, context.fullscreen_quad().slice(..)); - render_pass.set_bind_group(0, &texture_bind_group, &[]); - render_pass.set_bind_group(1, &uniform_bind_group, &[]); - render_pass.draw(0..6, 0..1); - } - - context.queue().submit([encoder.finish()]); - current_texture = Some(output_texture); - } - - current_texture.ok_or_else(|| GpuError::UnknownEffectShader { - shader: "missing-effect-pass".to_string(), - }) -} - -fn pack_effect_uniforms( - pass: &EffectPass, - width: u32, - height: u32, -) -> Result { - let shader = pass.shader.as_str(); - let sigma = read_number_uniform(pass, "u_sigma")?; - let step = read_number_uniform(pass, "u_step")?; - let direction = read_vec2_uniform(pass, "u_direction")?; - - for uniform in pass.uniforms.keys() { - if uniform == "u_sigma" || uniform == "u_step" || uniform == "u_direction" { - continue; - } - return Err(GpuError::UnsupportedUniform { - shader: shader.to_string(), - uniform: uniform.clone(), - }); - } - - Ok(EffectUniformBuffer { - resolution: [width as f32, height as f32], - direction, - scalars: [sigma, step, 0.0, 0.0], - }) -} - -fn read_number_uniform(pass: &EffectPass, uniform: &str) -> Result { - let Some(value) = pass.uniforms.get(uniform) else { - return Err(GpuError::MissingUniform { - shader: pass.shader.clone(), - uniform: uniform.to_string(), - }); - }; - match value { - UniformValue::Number(value) => Ok(*value), - UniformValue::Vector(_) => Err(GpuError::InvalidNumberUniform { - shader: pass.shader.clone(), - uniform: uniform.to_string(), - }), - } -} - -fn read_vec2_uniform(pass: &EffectPass, uniform: &str) -> Result<[f32; 2], GpuError> { - let Some(value) = pass.uniforms.get(uniform) else { - return Err(GpuError::MissingUniform { - shader: pass.shader.clone(), - uniform: uniform.to_string(), - }); - }; - let UniformValue::Vector(values) = value else { - return Err(GpuError::InvalidVectorUniform { - shader: pass.shader.clone(), - uniform: uniform.to_string(), - expected_length: 2, - }); - }; - if values.len() != 2 { - return Err(GpuError::InvalidVectorUniform { - shader: pass.shader.clone(), - uniform: uniform.to_string(), - expected_length: 2, - }); - } - Ok([values[0], values[1]]) -} diff --git a/rust/crates/gpu/src/lib.rs b/rust/crates/gpu/src/lib.rs index c53e63ab..b6ca6c1c 100644 --- a/rust/crates/gpu/src/lib.rs +++ b/rust/crates/gpu/src/lib.rs @@ -1,17 +1,12 @@ mod context; -mod effect_pipeline; -mod mask_feather; -mod sdf_pipeline; -mod shader_registry; use thiserror::Error; -pub use wgpu; pub use context::GpuContext; -pub use effect_pipeline::{ApplyEffectsOptions, EffectPass, UniformValue}; -pub use mask_feather::ApplyMaskFeatherOptions; +pub use wgpu; pub const GPU_TEXTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm; +pub const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl"); #[derive(Debug, Error)] pub enum GpuError { @@ -23,18 +18,4 @@ pub enum GpuError { CreateSurface(#[from] wgpu::CreateSurfaceError), #[error("The output surface does not support the required texture format")] UnsupportedSurfaceFormat, - #[error("Unknown effect shader '{shader}'")] - UnknownEffectShader { shader: String }, - #[error("Missing uniform '{uniform}' for shader '{shader}'")] - MissingUniform { shader: String, uniform: String }, - #[error("Uniform '{uniform}' for shader '{shader}' must be a number")] - InvalidNumberUniform { shader: String, uniform: String }, - #[error("Uniform '{uniform}' for shader '{shader}' must be a vector of length {expected_length}")] - InvalidVectorUniform { - shader: String, - uniform: String, - expected_length: usize, - }, - #[error("Shader '{shader}' does not support uniform '{uniform}'")] - UnsupportedUniform { shader: String, uniform: String }, } diff --git a/rust/crates/gpu/src/shader_registry.rs b/rust/crates/gpu/src/shader_registry.rs deleted file mode 100644 index b6af173e..00000000 --- a/rust/crates/gpu/src/shader_registry.rs +++ /dev/null @@ -1,183 +0,0 @@ -use std::collections::HashMap; - -use crate::{GPU_TEXTURE_FORMAT, GpuError}; - -const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl"); -const GAUSSIAN_BLUR_SHADER_SOURCE: &str = include_str!("shaders/gaussian_blur.wgsl"); -const BLIT_SHADER_SOURCE: &str = include_str!("shaders/blit.wgsl"); -pub const GAUSSIAN_BLUR_SHADER_ID: &str = "gaussian-blur"; - -pub struct ShaderRegistry { - effect_texture_bind_group_layout: wgpu::BindGroupLayout, - effect_uniform_bind_group_layout: wgpu::BindGroupLayout, - effect_pipelines: HashMap, - blit_pipeline: wgpu::RenderPipeline, -} - -impl ShaderRegistry { - pub fn new(device: &wgpu::Device) -> Self { - let effect_texture_bind_group_layout = - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("gpu-effect-texture-bind-group-layout"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - multisampled: false, - view_dimension: wgpu::TextureViewDimension::D2, - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let effect_uniform_bind_group_layout = - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("gpu-effect-uniform-bind-group-layout"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }], - }); - - let vertex_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("gpu-fullscreen-shader"), - source: wgpu::ShaderSource::Wgsl(FULLSCREEN_SHADER_SOURCE.into()), - }); - let gaussian_blur_shader_module = - device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("gpu-gaussian-blur-shader"), - source: wgpu::ShaderSource::Wgsl(GAUSSIAN_BLUR_SHADER_SOURCE.into()), - }); - let blit_shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("gpu-blit-shader"), - source: wgpu::ShaderSource::Wgsl(BLIT_SHADER_SOURCE.into()), - }); - let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("gpu-effect-pipeline-layout"), - bind_group_layouts: &[ - Some(&effect_texture_bind_group_layout), - Some(&effect_uniform_bind_group_layout), - ], - immediate_size: 0, - }); - let blit_pipeline_layout = - device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("gpu-blit-pipeline-layout"), - bind_group_layouts: &[Some(&effect_texture_bind_group_layout)], - immediate_size: 0, - }); - let gaussian_blur_pipeline = - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("gpu-gaussian-blur-pipeline"), - layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { - module: &vertex_shader_module, - entry_point: Some("vertex_main"), - buffers: &[wgpu::VertexBufferLayout { - array_stride: std::mem::size_of::<[f32; 2]>() as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[wgpu::VertexAttribute { - format: wgpu::VertexFormat::Float32x2, - offset: 0, - shader_location: 0, - }], - }], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &gaussian_blur_shader_module, - entry_point: Some("fragment_main"), - targets: &[Some(wgpu::ColorTargetState { - format: GPU_TEXTURE_FORMAT, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }), - primitive: wgpu::PrimitiveState::default(), - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("gpu-blit-pipeline"), - layout: Some(&blit_pipeline_layout), - vertex: wgpu::VertexState { - module: &vertex_shader_module, - entry_point: Some("vertex_main"), - buffers: &[wgpu::VertexBufferLayout { - array_stride: std::mem::size_of::<[f32; 2]>() as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[wgpu::VertexAttribute { - format: wgpu::VertexFormat::Float32x2, - offset: 0, - shader_location: 0, - }], - }], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }, - fragment: Some(wgpu::FragmentState { - module: &blit_shader_module, - entry_point: Some("fragment_main"), - targets: &[Some(wgpu::ColorTargetState { - format: GPU_TEXTURE_FORMAT, - blend: None, - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }), - primitive: wgpu::PrimitiveState::default(), - depth_stencil: None, - multisample: wgpu::MultisampleState::default(), - multiview_mask: None, - cache: None, - }); - - let effect_pipelines = HashMap::from([( - GAUSSIAN_BLUR_SHADER_ID.to_string(), - gaussian_blur_pipeline, - )]); - - Self { - effect_texture_bind_group_layout, - effect_uniform_bind_group_layout, - effect_pipelines, - blit_pipeline, - } - } - - pub fn get_effect_pipeline(&self, shader: &str) -> Result<&wgpu::RenderPipeline, GpuError> { - self.effect_pipelines - .get(shader) - .ok_or_else(|| GpuError::UnknownEffectShader { - shader: shader.to_string(), - }) - } - - pub fn effect_texture_bind_group_layout(&self) -> &wgpu::BindGroupLayout { - &self.effect_texture_bind_group_layout - } - - pub fn effect_uniform_bind_group_layout(&self) -> &wgpu::BindGroupLayout { - &self.effect_uniform_bind_group_layout - } - - pub fn blit_pipeline(&self) -> &wgpu::RenderPipeline { - &self.blit_pipeline - } -} diff --git a/rust/crates/masks/Cargo.toml b/rust/crates/masks/Cargo.toml new file mode 100644 index 00000000..58e10712 --- /dev/null +++ b/rust/crates/masks/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "masks" +version = "0.1.0" +edition = "2024" + +[lib] +path = "src/masks.rs" +crate-type = ["rlib"] + +[dependencies] +bytemuck = { version = "1.25.0", features = ["derive"] } +gpu = { version = "0.1.0", path = "../gpu" } +wgpu = "29.0.1" diff --git a/rust/crates/gpu/src/mask_feather.rs b/rust/crates/masks/src/feather.rs similarity index 88% rename from rust/crates/gpu/src/mask_feather.rs rename to rust/crates/masks/src/feather.rs index 029b59ed..e58572f4 100644 --- a/rust/crates/gpu/src/mask_feather.rs +++ b/rust/crates/masks/src/feather.rs @@ -1,9 +1,9 @@ use bytemuck::{Pod, Zeroable}; +use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext}; use wgpu::util::DeviceExt; -use crate::{GPU_TEXTURE_FORMAT, context::GpuContext}; +use crate::SdfPipeline; -const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl"); const JFA_DISTANCE_SHADER_SOURCE: &str = include_str!("shaders/jfa_distance.wgsl"); pub struct ApplyMaskFeatherOptions<'a> { @@ -14,6 +14,7 @@ pub struct ApplyMaskFeatherOptions<'a> { } pub struct MaskFeatherPipeline { + sdf_pipeline: SdfPipeline, inside_texture_bind_group_layout: wgpu::BindGroupLayout, outside_texture_bind_group_layout: wgpu::BindGroupLayout, uniform_bind_group_layout: wgpu::BindGroupLayout, @@ -29,7 +30,8 @@ struct DistanceUniformBuffer { } impl MaskFeatherPipeline { - pub fn new(device: &wgpu::Device) -> Self { + pub fn new(context: &GpuContext) -> Self { + let device = context.device(); let inside_texture_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("gpu-mask-distance-inside-layout"), @@ -140,6 +142,7 @@ impl MaskFeatherPipeline { }); Self { + sdf_pipeline: SdfPipeline::new(context), inside_texture_bind_group_layout, outside_texture_bind_group_layout, uniform_bind_group_layout, @@ -157,11 +160,10 @@ impl MaskFeatherPipeline { feather, }: ApplyMaskFeatherOptions<'_>, ) -> wgpu::Texture { - let sdf = context - .sdf_pipeline() + let sdf = self + .sdf_pipeline .compute_signed_distance_field(context, mask, width, height); - let output_texture = - context.create_render_texture(width, height, "gpu-mask-feather-output"); + let output_texture = context.create_render_texture(width, height, "masks-feather-output"); let inside_view = sdf .inside_texture .create_view(&wgpu::TextureViewDescriptor::default()); @@ -201,17 +203,18 @@ impl MaskFeatherPipeline { }, ], }); - let uniform_buffer = context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("gpu-mask-distance-uniform-buffer"), - contents: bytemuck::bytes_of(&DistanceUniformBuffer { - resolution: [width as f32, height as f32], - feather_half: feather / 2.0, - _padding: 0.0, - }), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); + let uniform_buffer = + context + .device() + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("gpu-mask-distance-uniform-buffer"), + contents: bytemuck::bytes_of(&DistanceUniformBuffer { + resolution: [width as f32, height as f32], + feather_half: feather / 2.0, + _padding: 0.0, + }), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); let uniform_bind_group = context .device() .create_bind_group(&wgpu::BindGroupDescriptor { @@ -222,11 +225,12 @@ impl MaskFeatherPipeline { resource: uniform_buffer.as_entire_binding(), }], }); - let mut encoder = context - .device() - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("gpu-mask-distance-command-encoder"), - }); + let mut encoder = + context + .device() + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gpu-mask-distance-command-encoder"), + }); { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { diff --git a/rust/crates/masks/src/masks.rs b/rust/crates/masks/src/masks.rs new file mode 100644 index 00000000..2bf33342 --- /dev/null +++ b/rust/crates/masks/src/masks.rs @@ -0,0 +1,5 @@ +mod feather; +mod sdf; + +pub use feather::{ApplyMaskFeatherOptions, MaskFeatherPipeline}; +pub use sdf::{SdfPipeline, SignedDistanceFieldTextures}; diff --git a/rust/crates/gpu/src/sdf_pipeline.rs b/rust/crates/masks/src/sdf.rs similarity index 93% rename from rust/crates/gpu/src/sdf_pipeline.rs rename to rust/crates/masks/src/sdf.rs index d2612fe1..0e7ec01a 100644 --- a/rust/crates/gpu/src/sdf_pipeline.rs +++ b/rust/crates/masks/src/sdf.rs @@ -1,9 +1,7 @@ use bytemuck::{Pod, Zeroable}; +use gpu::{FULLSCREEN_SHADER_SOURCE, GPU_TEXTURE_FORMAT, GpuContext}; use wgpu::util::DeviceExt; -use crate::{GPU_TEXTURE_FORMAT, context::GpuContext}; - -const FULLSCREEN_SHADER_SOURCE: &str = include_str!("shaders/fullscreen.wgsl"); const JFA_INIT_SHADER_SOURCE: &str = include_str!("shaders/jfa_init.wgsl"); const JFA_STEP_SHADER_SOURCE: &str = include_str!("shaders/jfa_step.wgsl"); @@ -36,7 +34,8 @@ struct JfaStepUniformBuffer { } impl SdfPipeline { - pub fn new(device: &wgpu::Device) -> Self { + pub fn new(context: &GpuContext) -> Self { + let device = context.device(); let texture_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("gpu-sdf-texture-bind-group-layout"), @@ -213,13 +212,14 @@ impl SdfPipeline { }, ], }); - let uniform_buffer = context - .device() - .create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("gpu-sdf-uniform-buffer"), - contents: uniform_buffer_bytes, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); + let uniform_buffer = + context + .device() + .create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("gpu-sdf-uniform-buffer"), + contents: uniform_buffer_bytes, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); let uniform_bind_group = context .device() .create_bind_group(&wgpu::BindGroupDescriptor { @@ -230,11 +230,12 @@ impl SdfPipeline { resource: uniform_buffer.as_entire_binding(), }], }); - let mut encoder = context - .device() - .create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("gpu-sdf-command-encoder"), - }); + let mut encoder = + context + .device() + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gpu-sdf-command-encoder"), + }); { let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { diff --git a/rust/crates/gpu/src/shaders/jfa_distance.wgsl b/rust/crates/masks/src/shaders/jfa_distance.wgsl similarity index 100% rename from rust/crates/gpu/src/shaders/jfa_distance.wgsl rename to rust/crates/masks/src/shaders/jfa_distance.wgsl diff --git a/rust/crates/gpu/src/shaders/jfa_init.wgsl b/rust/crates/masks/src/shaders/jfa_init.wgsl similarity index 100% rename from rust/crates/gpu/src/shaders/jfa_init.wgsl rename to rust/crates/masks/src/shaders/jfa_init.wgsl diff --git a/rust/crates/gpu/src/shaders/jfa_step.wgsl b/rust/crates/masks/src/shaders/jfa_step.wgsl similarity index 100% rename from rust/crates/gpu/src/shaders/jfa_step.wgsl rename to rust/crates/masks/src/shaders/jfa_step.wgsl diff --git a/rust/crates/time/Cargo.toml b/rust/crates/time/Cargo.toml index bf66e4e2..070695dc 100644 --- a/rust/crates/time/Cargo.toml +++ b/rust/crates/time/Cargo.toml @@ -5,13 +5,14 @@ edition = "2024" [lib] path = "src/time.rs" -crate-type = ["rlib", "cdylib"] +crate-type = ["rlib"] [dependencies] bridge = { version = "0.1.0", path = "../bridge" } +num-traits = "0.2.19" serde = { version = "1", features = ["derive"] } tsify-next = { version = "0.5", optional = true } wasm-bindgen = { version = "0.2.115", optional = true } [features] -wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"] \ No newline at end of file +wasm = ["dep:wasm-bindgen", "dep:tsify-next", "tsify-next/js"] diff --git a/rust/crates/time/src/frame_rate.rs b/rust/crates/time/src/frame_rate.rs new file mode 100644 index 00000000..7af10649 --- /dev/null +++ b/rust/crates/time/src/frame_rate.rs @@ -0,0 +1,121 @@ +use serde::{Deserialize, Serialize}; + +use crate::media_time::TICKS_PER_SECOND; + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +pub struct FrameRate { + pub numerator: u32, + pub denominator: u32, +} + +impl FrameRate { + pub const FPS_23_976: Self = Self { + numerator: 24_000, + denominator: 1_001, + }; + pub const FPS_24: Self = Self { + numerator: 24, + denominator: 1, + }; + pub const FPS_25: Self = Self { + numerator: 25, + denominator: 1, + }; + pub const FPS_29_97: Self = Self { + numerator: 30_000, + denominator: 1_001, + }; + pub const FPS_30: Self = Self { + numerator: 30, + denominator: 1, + }; + pub const FPS_48: Self = Self { + numerator: 48, + denominator: 1, + }; + pub const FPS_50: Self = Self { + numerator: 50, + denominator: 1, + }; + pub const FPS_59_94: Self = Self { + numerator: 60_000, + denominator: 1_001, + }; + pub const FPS_60: Self = Self { + numerator: 60, + denominator: 1, + }; + pub const FPS_120: Self = Self { + numerator: 120, + denominator: 1, + }; + + pub const fn new(numerator: u32, denominator: u32) -> Self { + Self { + numerator, + denominator, + } + } + + pub const fn is_valid(self) -> bool { + self.numerator > 0 && self.denominator > 0 + } + + pub fn as_f64(self) -> Option { + if !self.is_valid() { + return None; + } + + Some(f64::from(self.numerator) / f64::from(self.denominator)) + } + + pub fn frame_number_upper_bound(self) -> Option { + if !self.is_valid() { + return None; + } + + Some(self.numerator.div_ceil(self.denominator)) + } + + pub fn ticks_per_frame(self) -> Option { + if !self.is_valid() { + return None; + } + + let tick_numerator = TICKS_PER_SECOND.checked_mul(i64::from(self.denominator))?; + let tick_denominator = i64::from(self.numerator); + if tick_numerator % tick_denominator != 0 { + return None; + } + + Some(tick_numerator / tick_denominator) + } +} + +#[cfg(test)] +mod tests { + use super::FrameRate; + + #[test] + fn resolves_ticks_per_standard_frame_rate() { + assert_eq!(FrameRate::FPS_23_976.ticks_per_frame(), Some(5_005)); + assert_eq!(FrameRate::FPS_24.ticks_per_frame(), Some(5_000)); + assert_eq!(FrameRate::FPS_25.ticks_per_frame(), Some(4_800)); + assert_eq!(FrameRate::FPS_29_97.ticks_per_frame(), Some(4_004)); + assert_eq!(FrameRate::FPS_30.ticks_per_frame(), Some(4_000)); + assert_eq!(FrameRate::FPS_48.ticks_per_frame(), Some(2_500)); + assert_eq!(FrameRate::FPS_50.ticks_per_frame(), Some(2_400)); + assert_eq!(FrameRate::FPS_59_94.ticks_per_frame(), Some(2_002)); + assert_eq!(FrameRate::FPS_60.ticks_per_frame(), Some(2_000)); + assert_eq!(FrameRate::FPS_120.ticks_per_frame(), Some(1_000)); + } + + #[test] + fn rejects_invalid_or_unsupported_rates() { + assert_eq!(FrameRate::new(0, 1).ticks_per_frame(), None); + assert_eq!(FrameRate::new(1, 0).ticks_per_frame(), None); + assert_eq!(FrameRate::new(7, 3).ticks_per_frame(), None); + } +} diff --git a/rust/crates/time/src/media_time.rs b/rust/crates/time/src/media_time.rs new file mode 100644 index 00000000..11d268e5 --- /dev/null +++ b/rust/crates/time/src/media_time.rs @@ -0,0 +1,428 @@ +use std::ops::{Add, Div, Mul, Neg, Sub}; + +use bridge::export; +use num_traits::ToPrimitive; +use serde::{Deserialize, Serialize}; + +use crate::frame_rate::FrameRate; + +#[export] +pub const TICKS_PER_SECOND: i64 = 120_000; +const TICKS_PER_SECOND_F64: f64 = TICKS_PER_SECOND as f64; + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct MediaTime(i64); + +impl MediaTime { + pub const ZERO: Self = Self(0); + pub const ONE_TICK: Self = Self(1); + + pub const fn from_ticks(ticks: i64) -> Self { + Self(ticks) + } + + pub const fn as_ticks(self) -> i64 { + self.0 + } + + pub fn from_seconds_f64(seconds: f64) -> Option { + if !seconds.is_finite() { + return None; + } + + let ticks = (seconds * TICKS_PER_SECOND_F64).round().to_i64()?; + Some(Self(ticks)) + } + + pub fn to_seconds_f64(self) -> f64 { + self.0.to_f64().unwrap_or(0.0) / TICKS_PER_SECOND_F64 + } + + pub fn from_frame(frame: i64, rate: FrameRate) -> Option { + let ticks_per_frame = rate.ticks_per_frame()?; + Some(Self(frame.checked_mul(ticks_per_frame)?)) + } + + pub fn to_frame_round(self, rate: FrameRate) -> Option { + let ticks_per_frame = rate.ticks_per_frame()?; + let remainder = self.0.rem_euclid(ticks_per_frame); + let floor = self.0.div_euclid(ticks_per_frame); + if remainder * 2 >= ticks_per_frame { + Some(floor + 1) + } else { + Some(floor) + } + } + + pub fn to_frame_floor(self, rate: FrameRate) -> Option { + let ticks_per_frame = rate.ticks_per_frame()?; + Some(self.0.div_euclid(ticks_per_frame)) + } + + pub fn round_to_frame(self, rate: FrameRate) -> Option { + Self::from_frame(self.to_frame_round(rate)?, rate) + } + + pub fn floor_to_frame(self, rate: FrameRate) -> Option { + let ticks_per_frame = rate.ticks_per_frame()?; + Some(Self(self.0.div_euclid(ticks_per_frame) * ticks_per_frame)) + } + + pub fn is_frame_aligned(self, rate: FrameRate) -> Option { + let ticks_per_frame = rate.ticks_per_frame()?; + Some(self.0.rem_euclid(ticks_per_frame) == 0) + } + + pub fn last_frame_time(self, rate: FrameRate) -> Option { + if self <= Self::ZERO { + return Some(Self::ZERO); + } + + let last_inclusive_tick = self.0.checked_sub(1).unwrap_or(0); + Self::from_ticks(last_inclusive_tick).floor_to_frame(rate) + } + + pub fn snapped_seek_time(self, duration: Self, rate: FrameRate) -> Option { + let snapped = self.round_to_frame(rate)?; + Some(snapped.clamp(Self::ZERO, duration)) + } + + pub fn clamp(self, min: Self, max: Self) -> Self { + Self(self.0.clamp(min.0, max.0)) + } + + pub fn min(self, other: Self) -> Self { + Self(self.0.min(other.0)) + } + + pub fn max(self, other: Self) -> Self { + Self(self.0.max(other.0)) + } +} + +impl Add for MediaTime { + type Output = Self; + + fn add(self, rhs: Self) -> Self::Output { + Self(self.0 + rhs.0) + } +} + +impl Sub for MediaTime { + type Output = Self; + + fn sub(self, rhs: Self) -> Self::Output { + Self(self.0 - rhs.0) + } +} + +impl Neg for MediaTime { + type Output = Self; + + fn neg(self) -> Self::Output { + Self(-self.0) + } +} + +impl Mul for MediaTime { + type Output = Self; + + fn mul(self, rhs: i64) -> Self::Output { + Self(self.0 * rhs) + } +} + +impl Div for MediaTime { + type Output = Self; + + fn div(self, rhs: i64) -> Self::Output { + Self(self.0 / rhs) + } +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeFromSecondsOptions { + pub seconds: f64, +} + +#[export] +pub fn media_time_from_seconds( + MediaTimeFromSecondsOptions { seconds }: MediaTimeFromSecondsOptions, +) -> Option { + MediaTime::from_seconds_f64(seconds) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeToSecondsOptions { + pub time: MediaTime, +} + +#[export] +pub fn media_time_to_seconds(MediaTimeToSecondsOptions { time }: MediaTimeToSecondsOptions) -> f64 { + time.to_seconds_f64() +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeFromFrameOptions { + pub frame: i64, + pub rate: FrameRate, +} + +#[export] +pub fn media_time_from_frame( + MediaTimeFromFrameOptions { frame, rate }: MediaTimeFromFrameOptions, +) -> Option { + MediaTime::from_frame(frame, rate) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeToFrameOptions { + pub time: MediaTime, + pub rate: FrameRate, +} + +#[export] +pub fn media_time_to_frame( + MediaTimeToFrameOptions { time, rate }: MediaTimeToFrameOptions, +) -> Option { + time.to_frame_round(rate) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RoundToFrameOptions { + pub time: MediaTime, + pub rate: FrameRate, +} + +#[export] +pub fn round_to_frame( + RoundToFrameOptions { time, rate }: RoundToFrameOptions, +) -> Option { + time.round_to_frame(rate) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FloorToFrameOptions { + pub time: MediaTime, + pub rate: FrameRate, +} + +#[export] +pub fn floor_to_frame( + FloorToFrameOptions { time, rate }: FloorToFrameOptions, +) -> Option { + time.floor_to_frame(rate) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IsFrameAlignedOptions { + pub time: MediaTime, + pub rate: FrameRate, +} + +#[export] +pub fn is_frame_aligned( + IsFrameAlignedOptions { time, rate }: IsFrameAlignedOptions, +) -> Option { + time.is_frame_aligned(rate) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LastFrameTimeOptions { + pub duration: MediaTime, + pub rate: FrameRate, +} + +#[export] +pub fn last_frame_time( + LastFrameTimeOptions { duration, rate }: LastFrameTimeOptions, +) -> Option { + duration.last_frame_time(rate) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnappedSeekTimeOptions { + pub time: MediaTime, + pub duration: MediaTime, + pub rate: FrameRate, +} + +#[export] +pub fn snapped_seek_time( + SnappedSeekTimeOptions { + time, + duration, + rate, + }: SnappedSeekTimeOptions, +) -> Option { + time.snapped_seek_time(duration, rate) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeAddOptions { + pub lhs: MediaTime, + pub rhs: MediaTime, +} + +#[export] +pub fn media_time_add(MediaTimeAddOptions { lhs, rhs }: MediaTimeAddOptions) -> MediaTime { + lhs + rhs +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeSubOptions { + pub lhs: MediaTime, + pub rhs: MediaTime, +} + +#[export] +pub fn media_time_sub(MediaTimeSubOptions { lhs, rhs }: MediaTimeSubOptions) -> MediaTime { + lhs - rhs +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeMinOptions { + pub lhs: MediaTime, + pub rhs: MediaTime, +} + +#[export] +pub fn media_time_min(MediaTimeMinOptions { lhs, rhs }: MediaTimeMinOptions) -> MediaTime { + lhs.min(rhs) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeMaxOptions { + pub lhs: MediaTime, + pub rhs: MediaTime, +} + +#[export] +pub fn media_time_max(MediaTimeMaxOptions { lhs, rhs }: MediaTimeMaxOptions) -> MediaTime { + lhs.max(rhs) +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaTimeClampOptions { + pub time: MediaTime, + pub min: MediaTime, + pub max: MediaTime, +} + +#[export] +pub fn media_time_clamp( + MediaTimeClampOptions { time, min, max }: MediaTimeClampOptions, +) -> MediaTime { + time.clamp(min, max) +} + +#[cfg(test)] +mod tests { + use crate::frame_rate::FrameRate; + + use super::{MediaTime, TICKS_PER_SECOND}; + + #[test] + fn converts_between_seconds_and_ticks() { + assert_eq!( + MediaTime::from_seconds_f64(1.5), + Some(MediaTime::from_ticks(180_000)) + ); + assert_eq!(MediaTime::from_ticks(180_000).to_seconds_f64(), 1.5); + assert_eq!(TICKS_PER_SECOND, 120_000); + } + + #[test] + fn rejects_non_finite_seconds() { + assert_eq!(MediaTime::from_seconds_f64(f64::NAN), None); + assert_eq!(MediaTime::from_seconds_f64(f64::INFINITY), None); + assert_eq!(MediaTime::from_seconds_f64(f64::NEG_INFINITY), None); + } + + #[test] + fn snaps_to_the_nearest_frame() { + let rate = FrameRate::FPS_30; + let time = MediaTime::from_seconds_f64(1.26).unwrap(); + + assert_eq!(time.to_frame_round(rate), Some(38)); + assert_eq!( + time.round_to_frame(rate), + Some(MediaTime::from_ticks(152_000)) + ); + } + + #[test] + fn floors_to_frame() { + let rate = FrameRate::FPS_30; + let ticks_per_frame = 4_000; + let time = MediaTime::from_ticks(ticks_per_frame * 5 + 1); + + assert_eq!(time.to_frame_floor(rate), Some(5)); + assert_eq!(time.to_frame_round(rate), Some(5)); + + let almost_next = MediaTime::from_ticks(ticks_per_frame * 5 + ticks_per_frame / 2); + assert_eq!(almost_next.to_frame_floor(rate), Some(5)); + assert_eq!(almost_next.to_frame_round(rate), Some(6)); + } + + #[test] + fn computes_last_frame_time_and_snapped_seek_time() { + let rate = FrameRate::new(5, 1); + let duration = MediaTime::from_seconds_f64(10.0).unwrap(); + + assert_eq!( + duration.last_frame_time(rate), + Some(MediaTime::from_seconds_f64(9.8).unwrap()), + ); + assert_eq!( + MediaTime::from_seconds_f64(10.0) + .unwrap() + .snapped_seek_time(duration, rate), + Some(MediaTime::from_seconds_f64(10.0).unwrap()), + ); + } +} diff --git a/rust/crates/time/src/time.rs b/rust/crates/time/src/time.rs index 84b69327..3d45c47f 100644 --- a/rust/crates/time/src/time.rs +++ b/rust/crates/time/src/time.rs @@ -1,422 +1,19 @@ -use bridge::export; -use serde::{Deserialize, Serialize}; +mod frame_rate; +mod media_time; +mod timecode; -const SECONDS_PER_HOUR: f64 = 3600.0; -const SECONDS_PER_MINUTE: f64 = 60.0; -const CENTISECONDS_PER_SECOND: f64 = 100.0; - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))] -#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] -pub enum TimeCodeFormat { - #[serde(rename = "MM:SS")] - MmSs, - #[serde(rename = "HH:MM:SS")] - HhMmSs, - #[serde(rename = "HH:MM:SS:CS")] - HhMmSsCs, - #[serde(rename = "HH:MM:SS:FF")] - HhMmSsFf, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RoundToFrameOptions { - pub time: f64, - pub fps: f64, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FormatTimeCodeOptions { - pub time_in_seconds: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub format: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fps: Option, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct ParseTimeCodeOptions { - pub time_code: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub format: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fps: Option, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GuessTimeCodeFormatOptions { - pub time_code: String, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct TimeToFrameOptions { - pub time: f64, - pub fps: f64, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct FrameToTimeOptions { - pub frame: f64, - pub fps: f64, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct SnapTimeToFrameOptions { - pub time: f64, - pub fps: f64, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetSnappedSeekTimeOptions { - pub raw_time: f64, - pub duration: f64, - pub fps: f64, -} - -#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] -#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct GetLastFrameTimeOptions { - pub duration: f64, - pub fps: f64, -} - -#[export] -pub fn round_to_frame(RoundToFrameOptions { time, fps }: RoundToFrameOptions) -> f64 { - (time * fps).round() / fps -} - -#[export] -pub fn format_time_code( - FormatTimeCodeOptions { - time_in_seconds, - format, - fps, - }: FormatTimeCodeOptions, -) -> Option { - let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs); - let hours = (time_in_seconds / SECONDS_PER_HOUR).floor() as u64; - let minutes = ((time_in_seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE).floor() as u64; - let seconds = (time_in_seconds % SECONDS_PER_MINUTE).floor() as u64; - let centiseconds = ((time_in_seconds % 1.0) * CENTISECONDS_PER_SECOND).floor() as u64; - - match format { - TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")), - TimeCodeFormat::HhMmSs => Some(format!("{hours:02}:{minutes:02}:{seconds:02}")), - TimeCodeFormat::HhMmSsCs => Some(format!( - "{hours:02}:{minutes:02}:{seconds:02}:{centiseconds:02}", - )), - TimeCodeFormat::HhMmSsFf => { - let fps = fps?; - if fps <= 0.0 { - return None; - } - - let frames = ((time_in_seconds % 1.0) * fps).floor() as u64; - Some(format!( - "{hours:02}:{minutes:02}:{seconds:02}:{frames:02}", - )) - } - } -} - -#[export] -pub fn parse_time_code( - ParseTimeCodeOptions { - time_code, - format, - fps, - }: ParseTimeCodeOptions, -) -> Option { - if time_code.trim().is_empty() { - return None; - } - - let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs); - let parts = time_code - .trim() - .split(':') - .map(|part| part.parse::().ok()) - .collect::>>()?; - - match format { - TimeCodeFormat::MmSs => { - let [minutes, seconds] = parts.as_slice() else { - return None; - }; - if *seconds >= SECONDS_PER_MINUTE as u32 { - return None; - } - - Some((*minutes as f64 * SECONDS_PER_MINUTE) + *seconds as f64) - } - TimeCodeFormat::HhMmSs => { - let [hours, minutes, seconds] = parts.as_slice() else { - return None; - }; - if *minutes >= SECONDS_PER_MINUTE as u32 || *seconds >= SECONDS_PER_MINUTE as u32 { - return None; - } - - Some( - (*hours as f64 * SECONDS_PER_HOUR) - + (*minutes as f64 * SECONDS_PER_MINUTE) - + *seconds as f64, - ) - } - TimeCodeFormat::HhMmSsCs => { - let [hours, minutes, seconds, centiseconds] = parts.as_slice() else { - return None; - }; - if *minutes >= SECONDS_PER_MINUTE as u32 - || *seconds >= SECONDS_PER_MINUTE as u32 - || *centiseconds >= CENTISECONDS_PER_SECOND as u32 - { - return None; - } - - Some( - (*hours as f64 * SECONDS_PER_HOUR) - + (*minutes as f64 * SECONDS_PER_MINUTE) - + *seconds as f64 - + (*centiseconds as f64 / CENTISECONDS_PER_SECOND), - ) - } - TimeCodeFormat::HhMmSsFf => { - let fps = fps?; - if fps <= 0.0 { - return None; - } - - let [hours, minutes, seconds, frames] = parts.as_slice() else { - return None; - }; - if *minutes >= SECONDS_PER_MINUTE as u32 - || *seconds >= SECONDS_PER_MINUTE as u32 - || *frames as f64 >= fps - { - return None; - } - - Some( - (*hours as f64 * SECONDS_PER_HOUR) - + (*minutes as f64 * SECONDS_PER_MINUTE) - + *seconds as f64 - + (*frames as f64 / fps), - ) - } - } -} - -#[export] -pub fn guess_time_code_format( - GuessTimeCodeFormatOptions { time_code }: GuessTimeCodeFormatOptions, -) -> Option { - if time_code.trim().is_empty() { - return None; - } - - let part_count = time_code - .split(':') - .try_fold(0usize, |count, part| { - part.parse::().ok().map(|_| count + 1) - })?; - - match part_count { - 2 => Some(TimeCodeFormat::MmSs), - 3 => Some(TimeCodeFormat::HhMmSs), - 4 => Some(TimeCodeFormat::HhMmSsFf), - _ => None, - } -} - -#[export] -pub fn time_to_frame(TimeToFrameOptions { time, fps }: TimeToFrameOptions) -> f64 { - (time * fps).round() -} - -#[export] -pub fn frame_to_time(FrameToTimeOptions { frame, fps }: FrameToTimeOptions) -> f64 { - frame / fps -} - -#[export] -pub fn snap_time_to_frame(SnapTimeToFrameOptions { time, fps }: SnapTimeToFrameOptions) -> f64 { - if fps <= 0.0 { - return time; - } - - frame_to_time(FrameToTimeOptions { - frame: time_to_frame(TimeToFrameOptions { time, fps }), - fps, - }) -} - -#[export] -pub fn get_snapped_seek_time( - GetSnappedSeekTimeOptions { - raw_time, - duration, - fps, - }: GetSnappedSeekTimeOptions, -) -> f64 { - let snapped_time = snap_time_to_frame(SnapTimeToFrameOptions { time: raw_time, fps }); - let last_frame = get_last_frame_time(GetLastFrameTimeOptions { duration, fps }); - snapped_time.clamp(0.0, last_frame) -} - -#[export] -pub fn get_last_frame_time( - GetLastFrameTimeOptions { duration, fps }: GetLastFrameTimeOptions, -) -> f64 { - if duration <= 0.0 { - return 0.0; - } - - if fps <= 0.0 { - return duration; - } - - let frame_offset = 1.0 / fps; - (duration - frame_offset).max(0.0) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rounds_to_the_nearest_frame() { - assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.24, fps: 10.0 }), 1.2); - assert_eq!(round_to_frame(RoundToFrameOptions { time: 1.26, fps: 10.0 }), 1.3); - } - - #[test] - fn formats_default_time_codes() { - assert_eq!( - format_time_code(FormatTimeCodeOptions { - time_in_seconds: 3723.45, - format: None, - fps: None, - }), - Some("01:02:03:44".to_string()), - ); - assert_eq!( - format_time_code(FormatTimeCodeOptions { - time_in_seconds: 65.0, - format: Some(TimeCodeFormat::MmSs), - fps: None, - }), - Some("01:05".to_string()), - ); - } - - #[test] - fn formats_frame_based_time_codes() { - assert_eq!( - format_time_code(FormatTimeCodeOptions { - time_in_seconds: 1.5, - format: Some(TimeCodeFormat::HhMmSsFf), - fps: Some(30.0), - }), - Some("00:00:01:15".to_string()), - ); - assert_eq!( - format_time_code(FormatTimeCodeOptions { - time_in_seconds: 1.5, - format: Some(TimeCodeFormat::HhMmSsFf), - fps: None, - }), - None, - ); - } - - #[test] - fn parses_time_codes() { - assert_eq!( - parse_time_code(ParseTimeCodeOptions { - time_code: "01:05".to_string(), - format: Some(TimeCodeFormat::MmSs), - fps: None, - }), - Some(65.0), - ); - assert_eq!( - parse_time_code(ParseTimeCodeOptions { - time_code: "00:00:01:15".to_string(), - format: Some(TimeCodeFormat::HhMmSsFf), - fps: Some(30.0), - }), - Some(1.5), - ); - assert_eq!( - parse_time_code(ParseTimeCodeOptions { - time_code: "00:00:01:30".to_string(), - format: Some(TimeCodeFormat::HhMmSsFf), - fps: Some(30.0), - }), - None, - ); - } - - #[test] - fn guesses_time_code_formats() { - assert_eq!( - guess_time_code_format(GuessTimeCodeFormatOptions { time_code: "01:05".to_string() }), - Some(TimeCodeFormat::MmSs), - ); - assert_eq!( - guess_time_code_format(GuessTimeCodeFormatOptions { - time_code: "00:00:01".to_string(), - }), - Some(TimeCodeFormat::HhMmSs), - ); - assert_eq!( - guess_time_code_format(GuessTimeCodeFormatOptions { - time_code: "00:00:01:15".to_string(), - }), - Some(TimeCodeFormat::HhMmSsFf), - ); - } - - #[test] - fn snaps_and_clamps_seek_time() { - assert_eq!(time_to_frame(TimeToFrameOptions { time: 1.26, fps: 10.0 }), 13.0); - assert_eq!(frame_to_time(FrameToTimeOptions { frame: 13.0, fps: 10.0 }), 1.3); - assert_eq!(snap_time_to_frame(SnapTimeToFrameOptions { time: 1.26, fps: 10.0 }), 1.3); - assert_eq!(get_last_frame_time(GetLastFrameTimeOptions { duration: 10.0, fps: 5.0 }), 9.8); - assert_eq!( - get_snapped_seek_time(GetSnappedSeekTimeOptions { - raw_time: 10.0, - duration: 10.0, - fps: 5.0, - }), - 9.8, - ); - } -} +pub use frame_rate::FrameRate; +pub use media_time::{ + FloorToFrameOptions, IsFrameAlignedOptions, LastFrameTimeOptions, MediaTime, + MediaTimeAddOptions, MediaTimeClampOptions, MediaTimeFromFrameOptions, + MediaTimeFromSecondsOptions, MediaTimeMaxOptions, MediaTimeMinOptions, MediaTimeSubOptions, + MediaTimeToFrameOptions, MediaTimeToSecondsOptions, RoundToFrameOptions, + SnappedSeekTimeOptions, TICKS_PER_SECOND, floor_to_frame, is_frame_aligned, last_frame_time, + media_time_add, media_time_clamp, media_time_from_frame, media_time_from_seconds, + media_time_max, media_time_min, media_time_sub, media_time_to_frame, media_time_to_seconds, + round_to_frame, snapped_seek_time, +}; +pub use timecode::{ + FormatTimecodeOptions, GuessTimecodeFormatOptions, ParseTimecodeOptions, TimeCodeFormat, + format_timecode, guess_timecode_format, parse_timecode, +}; diff --git a/rust/crates/time/src/timecode.rs b/rust/crates/time/src/timecode.rs new file mode 100644 index 00000000..0eb484d3 --- /dev/null +++ b/rust/crates/time/src/timecode.rs @@ -0,0 +1,287 @@ +use bridge::export; +use serde::{Deserialize, Serialize}; + +use crate::{ + frame_rate::FrameRate, + media_time::{MediaTime, TICKS_PER_SECOND}, +}; + +const SECONDS_PER_HOUR: i64 = 3_600; +const SECONDS_PER_MINUTE: i64 = 60; +const CENTISECONDS_PER_SECOND: i64 = 100; +const TICKS_PER_CENTISECOND: i64 = TICKS_PER_SECOND / CENTISECONDS_PER_SECOND; + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi, into_wasm_abi))] +#[derive(Serialize, Deserialize, Clone, Copy, Debug, Eq, PartialEq)] +pub enum TimeCodeFormat { + #[serde(rename = "MM:SS")] + MmSs, + #[serde(rename = "HH:MM:SS")] + HhMmSs, + #[serde(rename = "HH:MM:SS:CS")] + HhMmSsCs, + #[serde(rename = "HH:MM:SS:FF")] + HhMmSsFf, +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FormatTimecodeOptions { + pub time: MediaTime, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate: Option, +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ParseTimecodeOptions { + pub time_code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate: Option, +} + +#[cfg_attr(feature = "wasm", derive(tsify_next::Tsify))] +#[cfg_attr(feature = "wasm", tsify(from_wasm_abi))] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GuessTimecodeFormatOptions { + pub time_code: String, +} + +#[export] +pub fn guess_timecode_format( + GuessTimecodeFormatOptions { time_code }: GuessTimecodeFormatOptions, +) -> Option { + if time_code.trim().is_empty() { + return None; + } + + let part_count = time_code + .trim() + .split(':') + .try_fold(0usize, |count, part| { + part.parse::().ok().map(|_| count + 1) + })?; + + match part_count { + 2 => Some(TimeCodeFormat::MmSs), + 3 => Some(TimeCodeFormat::HhMmSs), + 4 => Some(TimeCodeFormat::HhMmSsFf), + _ => None, + } +} + +#[export] +pub fn format_timecode( + FormatTimecodeOptions { time, format, rate }: FormatTimecodeOptions, +) -> Option { + let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs); + let total_ticks = u64::try_from(time.as_ticks().max(0)).ok()?; + let ticks_per_second = u64::try_from(TICKS_PER_SECOND).ok()?; + let total_seconds = total_ticks / ticks_per_second; + let hour_ticks = u64::try_from(SECONDS_PER_HOUR).ok()? * ticks_per_second; + let minute_ticks = u64::try_from(SECONDS_PER_MINUTE).ok()? * ticks_per_second; + let seconds_per_minute = u64::try_from(SECONDS_PER_MINUTE).ok()?; + let ticks_per_centisecond = u64::try_from(TICKS_PER_CENTISECOND).ok()?; + + let hours = total_ticks / hour_ticks; + let minutes = (total_ticks % hour_ticks) / minute_ticks; + let seconds = total_seconds % seconds_per_minute; + let second_ticks = total_ticks % ticks_per_second; + let centiseconds = second_ticks / ticks_per_centisecond; + + match format { + TimeCodeFormat::MmSs => Some(format!("{minutes:02}:{seconds:02}")), + TimeCodeFormat::HhMmSs => Some(format!("{hours:02}:{minutes:02}:{seconds:02}")), + TimeCodeFormat::HhMmSsCs => Some(format!( + "{hours:02}:{minutes:02}:{seconds:02}:{centiseconds:02}" + )), + TimeCodeFormat::HhMmSsFf => { + let rate = rate?; + let ticks_per_frame = rate.ticks_per_frame()?; + let frames = second_ticks / u64::try_from(ticks_per_frame).ok()?; + Some(format!("{hours:02}:{minutes:02}:{seconds:02}:{frames:02}")) + } + } +} + +#[export] +pub fn parse_timecode( + ParseTimecodeOptions { + time_code, + format, + rate, + }: ParseTimecodeOptions, +) -> Option { + if time_code.trim().is_empty() { + return None; + } + + let format = format.unwrap_or(TimeCodeFormat::HhMmSsCs); + let parts = time_code + .trim() + .split(':') + .map(|part| part.parse::().ok()) + .collect::>>()?; + + match format { + TimeCodeFormat::MmSs => { + let [minutes, seconds] = parts.as_slice() else { + return None; + }; + if i64::from(*seconds) >= SECONDS_PER_MINUTE { + return None; + } + + Some(MediaTime::from_ticks( + (i64::from(*minutes) * SECONDS_PER_MINUTE + i64::from(*seconds)) * TICKS_PER_SECOND, + )) + } + TimeCodeFormat::HhMmSs => { + let [hours, minutes, seconds] = parts.as_slice() else { + return None; + }; + if i64::from(*minutes) >= SECONDS_PER_MINUTE + || i64::from(*seconds) >= SECONDS_PER_MINUTE + { + return None; + } + + Some(MediaTime::from_ticks( + (i64::from(*hours) * SECONDS_PER_HOUR + + i64::from(*minutes) * SECONDS_PER_MINUTE + + i64::from(*seconds)) + * TICKS_PER_SECOND, + )) + } + TimeCodeFormat::HhMmSsCs => { + let [hours, minutes, seconds, centiseconds] = parts.as_slice() else { + return None; + }; + if i64::from(*minutes) >= SECONDS_PER_MINUTE + || i64::from(*seconds) >= SECONDS_PER_MINUTE + || i64::from(*centiseconds) >= CENTISECONDS_PER_SECOND + { + return None; + } + + Some(MediaTime::from_ticks( + (i64::from(*hours) * SECONDS_PER_HOUR + + i64::from(*minutes) * SECONDS_PER_MINUTE + + i64::from(*seconds)) + * TICKS_PER_SECOND + + i64::from(*centiseconds) * TICKS_PER_CENTISECOND, + )) + } + TimeCodeFormat::HhMmSsFf => { + let rate = rate?; + let frame_upper_bound = rate.frame_number_upper_bound()?; + let [hours, minutes, seconds, frames] = parts.as_slice() else { + return None; + }; + if i64::from(*minutes) >= SECONDS_PER_MINUTE + || i64::from(*seconds) >= SECONDS_PER_MINUTE + || *frames >= frame_upper_bound + { + return None; + } + + Some( + MediaTime::from_ticks( + (i64::from(*hours) * SECONDS_PER_HOUR + + i64::from(*minutes) * SECONDS_PER_MINUTE + + i64::from(*seconds)) + * TICKS_PER_SECOND, + ) + MediaTime::from_frame(i64::from(*frames), rate)?, + ) + } + } +} + +#[cfg(test)] +mod tests { + use crate::frame_rate::FrameRate; + use crate::media_time::MediaTime; + + use super::{FormatTimecodeOptions, GuessTimecodeFormatOptions, ParseTimecodeOptions}; + use super::{TimeCodeFormat, format_timecode, guess_timecode_format, parse_timecode}; + + #[test] + fn formats_default_and_frame_timecodes() { + assert_eq!( + format_timecode(FormatTimecodeOptions { + time: MediaTime::from_seconds_f64(3723.45).unwrap(), + format: None, + rate: None, + }), + Some("01:02:03:45".to_string()), + ); + assert_eq!( + format_timecode(FormatTimecodeOptions { + time: MediaTime::from_seconds_f64(1.5).unwrap(), + format: Some(TimeCodeFormat::HhMmSsFf), + rate: Some(FrameRate::FPS_30), + }), + Some("00:00:01:15".to_string()), + ); + } + + #[test] + fn parses_timecodes() { + assert_eq!( + parse_timecode(ParseTimecodeOptions { + time_code: "01:05".to_string(), + format: Some(TimeCodeFormat::MmSs), + rate: None, + }), + Some(MediaTime::from_seconds_f64(65.0).unwrap()), + ); + assert_eq!( + parse_timecode(ParseTimecodeOptions { + time_code: "00:00:01:15".to_string(), + format: Some(TimeCodeFormat::HhMmSsFf), + rate: Some(FrameRate::FPS_30), + }), + Some(MediaTime::from_seconds_f64(1.5).unwrap()), + ); + assert_eq!( + parse_timecode(ParseTimecodeOptions { + time_code: "00:00:01:30".to_string(), + format: Some(TimeCodeFormat::HhMmSsFf), + rate: Some(FrameRate::FPS_30), + }), + None, + ); + } + + #[test] + fn guesses_timecode_formats() { + assert_eq!( + guess_timecode_format(GuessTimecodeFormatOptions { + time_code: "01:05".to_string(), + }), + Some(TimeCodeFormat::MmSs), + ); + assert_eq!( + guess_timecode_format(GuessTimecodeFormatOptions { + time_code: "00:00:01".to_string(), + }), + Some(TimeCodeFormat::HhMmSs), + ); + assert_eq!( + guess_timecode_format(GuessTimecodeFormatOptions { + time_code: "00:00:01:15".to_string(), + }), + Some(TimeCodeFormat::HhMmSsFf), + ); + } +} diff --git a/rust/wasm/Cargo.toml b/rust/wasm/Cargo.toml index 06be926f..88f92a5c 100644 --- a/rust/wasm/Cargo.toml +++ b/rust/wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "opencut-wasm" -version = "0.1.2" +version = "0.2.3" edition = "2024" description = "Shared video editor logic compiled to WebAssembly" repository = "https://github.com/opencut/opencut" @@ -11,11 +11,19 @@ path = "src/wasm.rs" crate-type = ["cdylib", "rlib"] [dependencies] -gpu = { version = "0.1.0", path = "../crates/gpu" } +bridge = { version = "0.1.0", path = "../crates/bridge" } +effects = { version = "0.1.0", path = "../crates/effects" } +gpu = { version = "0.1.0", path = "../crates/gpu", features = ["wasm"] } js-sys = "0.3.93" +masks = { version = "0.1.0", path = "../crates/masks" } +num-traits = "0.2.19" serde = { version = "1.0.228", features = ["derive"] } serde-wasm-bindgen = "0.6.5" time = { version = "0.1.0", path = "../crates/time", features = ["wasm"] } wasm-bindgen = "0.2.116" wasm-bindgen-futures = "0.4.66" web-sys = { version = "0.3.93", features = ["OffscreenCanvas", "HtmlCanvasElement", "CanvasRenderingContext2d", "Document", "Window"] } + +[features] +default = ["wasm"] +wasm = [] diff --git a/rust/wasm/README.md b/rust/wasm/README.md index e90c58c2..8147bb08 100644 --- a/rust/wasm/README.md +++ b/rust/wasm/README.md @@ -11,7 +11,10 @@ npm install opencut-wasm ## Usage ```ts -import { formatTimeCode } from "opencut-wasm"; +import { formatTimecode, mediaTimeFromSeconds } from "opencut-wasm"; + +const ticks = mediaTimeFromSeconds(1.5); +const label = formatTimecode({ ticks }); ``` All exports are documented in the [TypeScript definitions](./opencut_wasm.d.ts). diff --git a/rust/wasm/src/effects.rs b/rust/wasm/src/effects.rs new file mode 100644 index 00000000..1f5c2947 --- /dev/null +++ b/rust/wasm/src/effects.rs @@ -0,0 +1,101 @@ +#![cfg(target_arch = "wasm32")] + +use effects::{ApplyEffectsOptions, EffectPass, UniformValue}; +use gpu::wgpu; +use js_sys::Object; +use serde::Deserialize; +use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; + +use crate::gpu::{ + import_canvas_texture, read_offscreen_canvas_property, read_serde_property, read_u32_property, + render_texture_to_canvas, with_gpu_runtime, +}; + +struct ApplyEffectPassesOptions { + source: wgpu::web_sys::OffscreenCanvas, + width: u32, + height: u32, + passes: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EffectPassInput { + shader: String, + uniforms: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EffectUniformInput { + name: String, + value: Vec, +} + +#[wasm_bindgen(js_name = applyEffectPasses)] +pub fn apply_effect_passes(options: JsValue) -> Result { + let ApplyEffectPassesOptions { + source, + width, + height, + passes, + } = parse_apply_effect_passes_options(options)?; + + with_gpu_runtime(|runtime| { + let source_texture = import_canvas_texture( + &runtime.context, + &source, + width, + height, + "effects-input-texture", + ); + let effect_passes = map_effect_passes(passes); + let result_texture = runtime + .effects + .apply( + &runtime.context, + ApplyEffectsOptions { + source: &source_texture, + width, + height, + passes: &effect_passes, + }, + ) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + render_texture_to_canvas(&runtime.context, &result_texture, width, height) + }) +} + +fn map_effect_passes(effect_passes: Vec) -> Vec { + effect_passes + .into_iter() + .map(|pass| EffectPass { + shader: pass.shader, + uniforms: pass + .uniforms + .into_iter() + .map(|uniform| { + let value = if uniform.value.len() == 1 { + UniformValue::Number(uniform.value[0]) + } else { + UniformValue::Vector(uniform.value) + }; + (uniform.name, value) + }) + .collect(), + }) + .collect() +} + +fn parse_apply_effect_passes_options(value: JsValue) -> Result { + let object: Object = value + .dyn_into() + .map_err(|_| JsValue::from_str("applyEffectPasses expects an options object"))?; + + Ok(ApplyEffectPassesOptions { + source: read_offscreen_canvas_property(&object, "source")?, + width: read_u32_property(&object, "width")?, + height: read_u32_property(&object, "height")?, + passes: read_serde_property(&object, "passes")?, + }) +} diff --git a/rust/wasm/src/gpu.rs b/rust/wasm/src/gpu.rs new file mode 100644 index 00000000..70f3a241 --- /dev/null +++ b/rust/wasm/src/gpu.rs @@ -0,0 +1,123 @@ +#![cfg(target_arch = "wasm32")] + +use std::cell::RefCell; + +use effects::EffectPipeline; +use gpu::{GpuContext, wgpu}; +use js_sys::{Object, Reflect}; +use masks::MaskFeatherPipeline; +use serde::Deserialize; +use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; + +pub(crate) struct GpuRuntime { + pub(crate) context: GpuContext, + pub(crate) effects: EffectPipeline, + pub(crate) masks: MaskFeatherPipeline, +} + +thread_local! { + static GPU_RUNTIME: RefCell> = const { RefCell::new(None) }; +} + +#[wasm_bindgen(js_name = initializeGpu)] +pub async fn initialize_gpu() -> Result<(), JsValue> { + if GPU_RUNTIME.with(|runtime| runtime.borrow().is_some()) { + return Ok(()); + } + + let context = GpuContext::new() + .await + .map_err(|error| JsValue::from_str(&error.to_string()))?; + let effects = EffectPipeline::new(&context); + let masks = MaskFeatherPipeline::new(&context); + + GPU_RUNTIME.with(|runtime| { + runtime.replace(Some(GpuRuntime { + context, + effects, + masks, + })); + }); + + Ok(()) +} + +pub(crate) fn with_gpu_runtime( + action: impl FnOnce(&GpuRuntime) -> Result, +) -> Result { + GPU_RUNTIME.with(|runtime| { + let borrow = runtime.borrow(); + let Some(gpu_runtime) = borrow.as_ref() else { + return Err(JsValue::from_str( + "GPU context not initialized. Call initializeGpu() first.", + )); + }; + action(gpu_runtime) + }) +} + +pub(crate) fn import_canvas_texture( + context: &GpuContext, + canvas: &wgpu::web_sys::OffscreenCanvas, + width: u32, + height: u32, + label: &'static str, +) -> wgpu::Texture { + context.import_offscreen_canvas_texture(canvas, width, height, label) +} + +pub(crate) fn render_texture_to_canvas( + context: &GpuContext, + texture: &wgpu::Texture, + width: u32, + height: u32, +) -> Result { + let canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?; + context + .render_texture_to_offscreen_canvas(texture, &canvas, width, height) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + Ok(canvas) +} + +pub(crate) fn read_property(object: &Object, name: &str) -> Result { + Reflect::get(object, &JsValue::from_str(name)) + .map_err(|_| JsValue::from_str(&format!("Missing property '{name}'"))) +} + +pub(crate) fn read_offscreen_canvas_property( + object: &Object, + name: &str, +) -> Result { + read_property(object, name)? + .dyn_into::() + .map_err(|_| JsValue::from_str(&format!("Property '{name}' must be an OffscreenCanvas"))) +} + +pub(crate) fn read_u32_property(object: &Object, name: &str) -> Result { + let value = read_property(object, name)?; + let Some(number) = value.as_f64() else { + return Err(JsValue::from_str(&format!( + "Property '{name}' must be a number" + ))); + }; + Ok(number as u32) +} + +pub(crate) fn read_f32_property(object: &Object, name: &str) -> Result { + let value = read_property(object, name)?; + let Some(number) = value.as_f64() else { + return Err(JsValue::from_str(&format!( + "Property '{name}' must be a number" + ))); + }; + Ok(number as f32) +} + +pub(crate) fn read_serde_property(object: &Object, name: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let value = read_property(object, name)?; + serde_wasm_bindgen::from_value(value) + .map_err(|error| JsValue::from_str(&format!("Invalid property '{name}': {error}"))) +} diff --git a/rust/wasm/src/gpu_bridge.rs b/rust/wasm/src/gpu_bridge.rs deleted file mode 100644 index 85db0c8b..00000000 --- a/rust/wasm/src/gpu_bridge.rs +++ /dev/null @@ -1,256 +0,0 @@ -#![cfg(target_arch = "wasm32")] - -use std::cell::RefCell; - -use gpu::{EffectPass, GpuContext, UniformValue, wgpu}; -use js_sys::{Object, Reflect}; -use serde::Deserialize; -use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; - -thread_local! { - static GPU_CONTEXT: RefCell> = const { RefCell::new(None) }; -} - -struct ApplyEffectPassesOptions { - source: wgpu::web_sys::OffscreenCanvas, - width: u32, - height: u32, - passes: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct EffectPassInput { - shader: String, - uniforms: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct EffectUniformInput { - name: String, - value: Vec, -} - -struct ApplyMaskFeatherOptions { - mask: wgpu::web_sys::OffscreenCanvas, - width: u32, - height: u32, - feather: f32, -} - -#[wasm_bindgen(js_name = initializeGpu)] -pub async fn initialize_gpu() -> Result<(), JsValue> { - if GPU_CONTEXT.with(|context| context.borrow().is_some()) { - return Ok(()); - } - - let context = GpuContext::new() - .await - .map_err(|error| JsValue::from_str(&error.to_string()))?; - GPU_CONTEXT.with(|gpu_context| { - gpu_context.replace(Some(context)); - }); - Ok(()) -} - -#[wasm_bindgen(js_name = applyEffectPasses)] -pub fn apply_effect_passes( - options: JsValue, -) -> Result { - let ApplyEffectPassesOptions { - source, - width, - height, - passes, - } = parse_apply_effect_passes_options(options)?; - - with_gpu_context(|context| { - let source_texture = import_canvas_texture(context, &source, width, height)?; - let effect_passes = map_effect_passes(passes); - let result_texture = context - .apply_effects(gpu::ApplyEffectsOptions { - source: &source_texture, - width, - height, - passes: &effect_passes, - }) - .map_err(|error| JsValue::from_str(&error.to_string()))?; - let output_canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?; - let surface = context - .instance() - .create_surface(wgpu::SurfaceTarget::OffscreenCanvas(output_canvas.clone())) - .map_err(|error| JsValue::from_str(&error.to_string()))?; - - context - .render_texture_to_surface(&result_texture, &surface, width, height) - .map_err(|error| JsValue::from_str(&error.to_string()))?; - Ok(output_canvas) - }) -} - -#[wasm_bindgen(js_name = applyMaskFeather)] -pub fn apply_mask_feather( - options: JsValue, -) -> Result { - let ApplyMaskFeatherOptions { - mask, - width, - height, - feather, - } = parse_apply_mask_feather_options(options)?; - - with_gpu_context(|context| { - let mask_texture = import_canvas_texture(context, &mask, width, height)?; - let result_texture = context.apply_mask_feather(gpu::ApplyMaskFeatherOptions { - mask: &mask_texture, - width, - height, - feather, - }); - let output_canvas = wgpu::web_sys::OffscreenCanvas::new(width, height)?; - let surface = context - .instance() - .create_surface(wgpu::SurfaceTarget::OffscreenCanvas(output_canvas.clone())) - .map_err(|error| JsValue::from_str(&error.to_string()))?; - - context - .render_texture_to_surface(&result_texture, &surface, width, height) - .map_err(|error| JsValue::from_str(&error.to_string()))?; - Ok(output_canvas) - }) -} - -fn with_gpu_context( - action: impl FnOnce(&GpuContext) -> Result, -) -> Result { - GPU_CONTEXT.with(|context| { - let borrow = context.borrow(); - let Some(gpu_context) = borrow.as_ref() else { - return Err(JsValue::from_str( - "GPU context not initialized. Call initializeGpu() first.", - )); - }; - action(gpu_context) - }) -} - -fn import_canvas_texture( - context: &GpuContext, - canvas: &wgpu::web_sys::OffscreenCanvas, - width: u32, - height: u32, -) -> Result { - let texture = context.create_render_texture(width, height, "gpu-bridge-input-texture"); - context.queue().copy_external_image_to_texture( - &wgpu::CopyExternalImageSourceInfo { - source: wgpu::ExternalImageSource::OffscreenCanvas(canvas.clone()), - origin: wgpu::Origin2d::ZERO, - flip_y: true, - }, - wgpu::CopyExternalImageDestInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - color_space: wgpu::PredefinedColorSpace::Srgb, - premultiplied_alpha: false, - }, - wgpu::Extent3d { - width, - height, - depth_or_array_layers: 1, - }, - ); - Ok(texture) -} - -fn map_effect_passes(effect_passes: Vec) -> Vec { - effect_passes - .into_iter() - .map(|pass| EffectPass { - shader: pass.shader, - uniforms: pass - .uniforms - .into_iter() - .map(|uniform| { - let value = if uniform.value.len() == 1 { - UniformValue::Number(uniform.value[0]) - } else { - UniformValue::Vector(uniform.value) - }; - (uniform.name, value) - }) - .collect(), - }) - .collect() -} - -fn parse_apply_effect_passes_options(value: JsValue) -> Result { - let object: Object = value - .dyn_into() - .map_err(|_| JsValue::from_str("applyEffectPasses expects an options object"))?; - - Ok(ApplyEffectPassesOptions { - source: read_offscreen_canvas_property(&object, "source")?, - width: read_u32_property(&object, "width")?, - height: read_u32_property(&object, "height")?, - passes: read_serde_property(&object, "passes")?, - }) -} - -fn parse_apply_mask_feather_options(value: JsValue) -> Result { - let object: Object = value - .dyn_into() - .map_err(|_| JsValue::from_str("applyMaskFeather expects an options object"))?; - - Ok(ApplyMaskFeatherOptions { - mask: read_offscreen_canvas_property(&object, "mask")?, - width: read_u32_property(&object, "width")?, - height: read_u32_property(&object, "height")?, - feather: read_f32_property(&object, "feather")?, - }) -} - -fn read_property(object: &Object, name: &str) -> Result { - Reflect::get(object, &JsValue::from_str(name)) - .map_err(|_| JsValue::from_str(&format!("Missing property '{name}'"))) -} - -fn read_offscreen_canvas_property( - object: &Object, - name: &str, -) -> Result { - read_property(object, name)? - .dyn_into::() - .map_err(|_| JsValue::from_str(&format!("Property '{name}' must be an OffscreenCanvas"))) -} - -fn read_u32_property(object: &Object, name: &str) -> Result { - let value = read_property(object, name)?; - let Some(number) = value.as_f64() else { - return Err(JsValue::from_str(&format!( - "Property '{name}' must be a number" - ))); - }; - Ok(number as u32) -} - -fn read_f32_property(object: &Object, name: &str) -> Result { - let value = read_property(object, name)?; - let Some(number) = value.as_f64() else { - return Err(JsValue::from_str(&format!( - "Property '{name}' must be a number" - ))); - }; - Ok(number as f32) -} - -fn read_serde_property(object: &Object, name: &str) -> Result -where - T: for<'de> Deserialize<'de>, -{ - let value = read_property(object, name)?; - serde_wasm_bindgen::from_value(value) - .map_err(|error| JsValue::from_str(&format!("Invalid property '{name}': {error}"))) -} diff --git a/rust/wasm/src/masks.rs b/rust/wasm/src/masks.rs new file mode 100644 index 00000000..e4f30e21 --- /dev/null +++ b/rust/wasm/src/masks.rs @@ -0,0 +1,60 @@ +#![cfg(target_arch = "wasm32")] + +use gpu::wgpu; +use js_sys::Object; +use wasm_bindgen::{JsCast, JsValue, prelude::wasm_bindgen}; + +use crate::gpu::{ + import_canvas_texture, read_f32_property, read_offscreen_canvas_property, read_u32_property, + render_texture_to_canvas, with_gpu_runtime, +}; + +struct ApplyMaskFeatherOptions { + mask: wgpu::web_sys::OffscreenCanvas, + width: u32, + height: u32, + feather: f32, +} + +#[wasm_bindgen(js_name = applyMaskFeather)] +pub fn apply_mask_feather(options: JsValue) -> Result { + let ApplyMaskFeatherOptions { + mask, + width, + height, + feather, + } = parse_apply_mask_feather_options(options)?; + + with_gpu_runtime(|runtime| { + let mask_texture = import_canvas_texture( + &runtime.context, + &mask, + width, + height, + "masks-input-texture", + ); + let result_texture = runtime.masks.apply_mask_feather( + &runtime.context, + masks::ApplyMaskFeatherOptions { + mask: &mask_texture, + width, + height, + feather, + }, + ); + render_texture_to_canvas(&runtime.context, &result_texture, width, height) + }) +} + +fn parse_apply_mask_feather_options(value: JsValue) -> Result { + let object: Object = value + .dyn_into() + .map_err(|_| JsValue::from_str("applyMaskFeather expects an options object"))?; + + Ok(ApplyMaskFeatherOptions { + mask: read_offscreen_canvas_property(&object, "mask")?, + width: read_u32_property(&object, "width")?, + height: read_u32_property(&object, "height")?, + feather: read_f32_property(&object, "feather")?, + }) +} diff --git a/rust/wasm/src/wasm.rs b/rust/wasm/src/wasm.rs index 0a78a314..27ece766 100644 --- a/rust/wasm/src/wasm.rs +++ b/rust/wasm/src/wasm.rs @@ -1,6 +1,14 @@ #[cfg(target_arch = "wasm32")] -mod gpu_bridge; +mod effects; +#[cfg(target_arch = "wasm32")] +mod gpu; +#[cfg(target_arch = "wasm32")] +mod masks; #[cfg(target_arch = "wasm32")] -pub use gpu_bridge::*; +pub use effects::*; +#[cfg(target_arch = "wasm32")] +pub use gpu::*; +#[cfg(target_arch = "wasm32")] +pub use masks::*; pub use time::*; diff --git a/turbo.json b/turbo.json index 244b2f95..f1431ec3 100644 --- a/turbo.json +++ b/turbo.json @@ -17,12 +17,7 @@ "UPSTASH_REDIS_REST_TOKEN", "MARBLE_WORKSPACE_KEY", "FREESOUND_CLIENT_ID", - "FREESOUND_API_KEY", - "CLOUDFLARE_ACCOUNT_ID", - "R2_ACCESS_KEY_ID", - "R2_SECRET_ACCESS_KEY", - "R2_BUCKET_NAME", - "MODAL_TRANSCRIPTION_URL" + "FREESOUND_API_KEY" ] }, "check-types": {