diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-13 04:41:33 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-13 05:08:29 +0200 |
| commit | 7d4e166678c35d48b22b8d1d05639f981a956a8b (patch) | |
| tree | eb596d4f4609053524224169c247db24052db353 /apps | |
| parent | cad88ee4d1c9e06e7529b88ef5e5d3fdf3f89149 (diff) | |
| download | opencut-7d4e166678c35d48b22b8d1d05639f981a956a8b.tar.gz | |
fix: text measurement, rotation handle, video cache races, playback sync
Made-with: Cursor
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/package.json | 4 | ||||
| -rw-r--r-- | apps/web/src/app/editor/[project_id]/page.tsx | 255 | ||||
| -rw-r--r-- | apps/web/src/app/layout.tsx | 7 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx | 220 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx | 363 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/preview/transform-handles.tsx | 285 | ||||
| -rw-r--r-- | apps/web/src/components/providers/editor-provider.tsx | 3 | ||||
| -rw-r--r-- | apps/web/src/core/managers/playback-manager.ts | 124 | ||||
| -rw-r--r-- | apps/web/src/core/managers/renderer-manager.ts | 11 | ||||
| -rw-r--r-- | apps/web/src/core/managers/timeline-manager.ts | 60 | ||||
| -rw-r--r-- | apps/web/src/hooks/use-preview-interaction.ts | 18 | ||||
| -rw-r--r-- | apps/web/src/lib/background/constants.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/lib/text/layout.ts | 51 | ||||
| -rw-r--r-- | apps/web/src/lib/text/measure-element.ts | 289 | ||||
| -rw-r--r-- | apps/web/src/services/video-cache/service.ts | 16 |
15 files changed, 914 insertions, 798 deletions
diff --git a/apps/web/package.json b/apps/web/package.json index 7ee5fe1a..06d9babd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -19,7 +19,7 @@ }, "dependencies": { "@hello-pangea/dnd": "^18.0.1", - "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/core-free-icons": "^3.3.0", "@hugeicons/react": "^1.1.6", "@huggingface/transformers": "^3.8.1", "@opennextjs/cloudflare": "^1.18.0", @@ -52,7 +52,7 @@ "nanoid": "^5.1.5", "next": "16.1.3", "next-themes": "^0.4.4", - "opencut-wasm": "^0.2.3", + "opencut-wasm": "file:../../rust/wasm/pkg", "pg": "^8.16.2", "postgres": "^3.4.5", "radix-ui": "^1.4.3", diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 38a25d4c..8456d624 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -1,113 +1,142 @@ -"use client";
-
-import { useParams } from "next/navigation";
-import {
- ResizablePanelGroup,
- ResizablePanel,
- ResizableHandle,
-} from "@/components/ui/resizable";
-import { AssetsPanel } from "@/components/editor/panels/assets";
-import { PropertiesPanel } from "@/components/editor/panels/properties";
-import { Timeline } from "@/components/editor/panels/timeline";
-import { PreviewPanel } from "@/components/editor/panels/preview";
-import { EditorHeader } from "@/components/editor/editor-header";
-import { EditorProvider } from "@/components/providers/editor-provider";
-import { Onboarding } from "@/components/editor/onboarding";
-import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
-import { usePanelStore } from "@/stores/panel-store";
-import { usePasteMedia } from "@/hooks/use-paste-media";
-import { MobileGate } from "@/components/editor/mobile-gate";
-
-export default function Editor() {
- const params = useParams();
- const projectId = params.project_id as string;
-
- return (
- <MobileGate>
- <EditorProvider projectId={projectId}>
- <div className="bg-background flex h-screen w-screen flex-col overflow-hidden">
- <EditorHeader />
- <div className="min-h-0 min-w-0 flex-1">
- <EditorLayout />
- </div>
- <Onboarding />
- <MigrationDialog />
- </div>
- </EditorProvider>
- </MobileGate>
- );
-}
-
-function EditorLayout() {
- usePasteMedia();
- const { panels, setPanel } = usePanelStore();
-
- return (
- <ResizablePanelGroup
- direction="vertical"
- className="size-full gap-[0.18rem]"
- onLayout={(sizes) => {
- setPanel("mainContent", sizes[0] ?? panels.mainContent);
- setPanel("timeline", sizes[1] ?? panels.timeline);
- }}
- >
- <ResizablePanel
- defaultSize={panels.mainContent}
- minSize={30}
- maxSize={85}
- className="min-h-0"
- >
- <ResizablePanelGroup
- direction="horizontal"
- className="size-full gap-[0.19rem] px-3"
- onLayout={(sizes) => {
- setPanel("tools", sizes[0] ?? panels.tools);
- setPanel("preview", sizes[1] ?? panels.preview);
- setPanel("properties", sizes[2] ?? panels.properties);
- }}
- >
- <ResizablePanel
- defaultSize={panels.tools}
- minSize={15}
- maxSize={40}
- className="min-w-0"
- >
- <AssetsPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.preview}
- minSize={30}
- className="min-h-0 min-w-0 flex-1"
- >
- <PreviewPanel />
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.properties}
- minSize={15}
- maxSize={40}
- className="min-w-0"
- >
- <PropertiesPanel />
- </ResizablePanel>
- </ResizablePanelGroup>
- </ResizablePanel>
-
- <ResizableHandle withHandle />
-
- <ResizablePanel
- defaultSize={panels.timeline}
- minSize={15}
- maxSize={70}
- className="min-h-0 px-3 pb-3"
- >
- <Timeline />
- </ResizablePanel>
- </ResizablePanelGroup>
- );
-}
+"use client"; + +import { useParams } from "next/navigation"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { AssetsPanel } from "@/components/editor/panels/assets"; +import { PropertiesPanel } from "@/components/editor/panels/properties"; +import { Timeline } from "@/components/editor/panels/timeline"; +import { PreviewPanel } from "@/components/editor/panels/preview"; +import { EditorHeader } from "@/components/editor/editor-header"; +import { EditorProvider } from "@/components/providers/editor-provider"; +import { Onboarding } from "@/components/editor/onboarding"; +import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; +import { usePanelStore } from "@/stores/panel-store"; +import { usePasteMedia } from "@/hooks/use-paste-media"; +import { MobileGate } from "@/components/editor/mobile-gate"; +import { useState } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { Button } from "@/components/ui/button"; + +export default function Editor() { + const params = useParams(); + const projectId = params.project_id as string; + + return ( + <MobileGate> + <EditorProvider projectId={projectId}> + <div className="bg-background flex h-screen w-screen flex-col overflow-hidden"> + <DegradedRendererBanner /> + <EditorHeader /> + <div className="min-h-0 min-w-0 flex-1"> + <EditorLayout /> + </div> + <Onboarding /> + <MigrationDialog /> + </div> + </EditorProvider> + </MobileGate> + ); +} + +function DegradedRendererBanner() { + const isDegraded = useEditor((e) => e.renderer.isDegraded); + const [dismissed, setDismissed] = useState(false); + if (!isDegraded || dismissed) return null; + + return ( + <div className="bg-accent border-b h-9 flex items-center justify-center gap-2 text-xs text-muted-foreground"> + <span> + For the best experience, open OpenCut in Chrome. + </span> + <Button + variant="text" + size="icon" + className="p-0 w-auto [&_svg]:size-3.5" + onClick={() => setDismissed(true)} + aria-label="Dismiss" + > + <HugeiconsIcon icon={Cancel01Icon} /> + </Button> + </div> + ); +} + +function EditorLayout() { + usePasteMedia(); + const { panels, setPanel } = usePanelStore(); + + return ( + <ResizablePanelGroup + direction="vertical" + className="size-full gap-[0.18rem]" + onLayout={(sizes) => { + setPanel("mainContent", sizes[0] ?? panels.mainContent); + setPanel("timeline", sizes[1] ?? panels.timeline); + }} + > + <ResizablePanel + defaultSize={panels.mainContent} + minSize={30} + maxSize={85} + className="min-h-0" + > + <ResizablePanelGroup + direction="horizontal" + className="size-full gap-[0.19rem] px-3" + onLayout={(sizes) => { + setPanel("tools", sizes[0] ?? panels.tools); + setPanel("preview", sizes[1] ?? panels.preview); + setPanel("properties", sizes[2] ?? panels.properties); + }} + > + <ResizablePanel + defaultSize={panels.tools} + minSize={15} + maxSize={40} + className="min-w-0" + > + <AssetsPanel /> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={panels.preview} + minSize={30} + className="min-h-0 min-w-0 flex-1" + > + <PreviewPanel /> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={panels.properties} + minSize={15} + maxSize={40} + className="min-w-0" + > + <PropertiesPanel /> + </ResizablePanel> + </ResizablePanelGroup> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + defaultSize={panels.timeline} + minSize={15} + maxSize={70} + className="min-h-0 px-3 pb-3" + > + <Timeline /> + </ResizablePanel> + </ResizablePanelGroup> + ); +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index b65e028d..d10dc9d3 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -35,13 +35,6 @@ export default function RootLayout({ crossOrigin="anonymous" strategy="beforeInteractive" /> - - {/* code to figma */} - {/* <script - dangerouslySetInnerHTML={{ - __html: `(function(){var s=document.createElement('script');s.src='https://mcp.figma.com/mcp/html-to-design/capture.js';document.head.appendChild(s);})();`, - }} - /> */} </> )} </head> diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx index 515cb91a..6ee4b584 100644 --- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -1,117 +1,103 @@ -import { useState } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
-import type { SnapLine } from "@/lib/preview/preview-snap";
-import { TransformHandles } from "./transform-handles";
-import { MaskHandles } from "./mask-handles";
-import { SnapGuides } from "./snap-guides";
-import { TextEditOverlay } from "./text-edit-overlay";
-import { usePropertiesStore } from "../properties/stores/properties-store";
-import { useEditor } from "@/hooks/use-editor";
-
-export function PreviewInteractionOverlay() {
- const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const selectedElements = useEditor((e) => e.selection.getSelectedElements());
- const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType);
-
- const selectedRef =
- selectedElements.length === 1 ? selectedElements[0] : null;
- const activeTrack = selectedRef
- ? editor.timeline.getTrackById({ trackId: selectedRef.trackId })
- : null;
- const activeElement =
- activeTrack?.elements.find(
- (element) => element.id === selectedRef?.elementId,
- ) ?? null;
- const isMaskMode = activeElement
- ? activeTabPerType[activeElement.type] === "masks"
- : false;
-
- const {
- onPointerDown,
- onPointerMove,
- onPointerUp,
- onDoubleClick,
- editingText,
- commitTextEdit,
- } = usePreviewInteraction({
- onSnapLinesChange: setSnapLines,
- isMaskMode,
- });
-
- const handlePointerDown = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerDown({ event })) {
- return;
- }
-
- onPointerDown(event);
- };
-
- const handlePointerMove = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerMove({ event })) {
- return;
- }
-
- onPointerMove(event);
- };
-
- const handlePointerUp = (event: React.PointerEvent) => {
- if (viewport.handlePanPointerUp({ event })) {
- return;
- }
-
- onPointerUp(event);
- };
-
- const handleContextMenu = (e: React.MouseEvent) => {
- const x = e.nativeEvent.offsetX;
- const y = e.nativeEvent.offsetY;
- const outsideCanvas =
- x < viewport.sceneLeft ||
- x > viewport.sceneLeft + viewport.sceneWidth ||
- y < viewport.sceneTop ||
- y > viewport.sceneTop + viewport.sceneHeight;
- if (outsideCanvas) {
- e.stopPropagation();
- }
- };
-
- return (
- <div className="absolute inset-0">
- <div
- className="absolute inset-0 pointer-events-auto"
- role="application"
- aria-label="Preview canvas"
- style={{
- cursor: viewport.isPanning
- ? "grabbing"
- : viewport.canPan
- ? "default"
- : undefined,
- }}
- onPointerDown={handlePointerDown}
- onPointerMove={handlePointerMove}
- onPointerUp={handlePointerUp}
- onPointerCancel={handlePointerUp}
- onDoubleClick={onDoubleClick}
- onDragStart={(e) => e.preventDefault()}
- onContextMenu={handleContextMenu}
- />
- {editingText ? (
- <TextEditOverlay
- trackId={editingText.trackId}
- elementId={editingText.elementId}
- element={editingText.element}
- onCommit={commitTextEdit}
- />
- ) : isMaskMode ? (
- <MaskHandles onSnapLinesChange={setSnapLines} />
- ) : (
- <TransformHandles onSnapLinesChange={setSnapLines} />
- )}
- <SnapGuides lines={snapLines} />
- </div>
- );
-}
+import { useState } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; +import type { SnapLine } from "@/lib/preview/preview-snap"; +import { TransformHandles } from "./transform-handles"; +import { MaskHandles } from "./mask-handles"; +import { SnapGuides } from "./snap-guides"; +import { TextEditOverlay } from "./text-edit-overlay"; +import { usePropertiesStore } from "../properties/stores/properties-store"; +import { useEditor } from "@/hooks/use-editor"; + +export function PreviewInteractionOverlay() { + const [snapLines, setSnapLines] = useState<SnapLine[]>([]); + const editor = useEditor(); + const viewport = usePreviewViewport(); + const selectedElements = useEditor((e) => e.selection.getSelectedElements()); + const activeTabPerType = usePropertiesStore((s) => s.activeTabPerType); + + const selectedRef = + selectedElements.length === 1 ? selectedElements[0] : null; + const activeTrack = selectedRef + ? editor.timeline.getTrackById({ trackId: selectedRef.trackId }) + : null; + const activeElement = + activeTrack?.elements.find( + (element) => element.id === selectedRef?.elementId, + ) ?? null; + const isMaskMode = activeElement + ? activeTabPerType[activeElement.type] === "masks" + : false; + + const { + onPointerDown, + onPointerMove, + onPointerUp, + onDoubleClick, + editingText, + commitTextEdit, + } = usePreviewInteraction({ + onSnapLinesChange: setSnapLines, + isMaskMode, + }); + + const handlePointerDown = (event: React.PointerEvent) => { + if (viewport.handlePanPointerDown({ event })) { + return; + } + + onPointerDown(event); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + if (viewport.handlePanPointerMove({ event })) { + return; + } + + onPointerMove(event); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + if (viewport.handlePanPointerUp({ event })) { + return; + } + + onPointerUp(event); + }; + + return ( + <div className="absolute inset-0"> + <div + className="absolute inset-0 pointer-events-auto" + role="application" + aria-label="Preview canvas" + style={{ + cursor: viewport.isPanning + ? "grabbing" + : viewport.canPan + ? "default" + : undefined, + }} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerUp} + onPointerCancel={handlePointerUp} + onDoubleClick={onDoubleClick} + onDragStart={(e) => e.preventDefault()} + /> + {editingText ? ( + <TextEditOverlay + trackId={editingText.trackId} + elementId={editingText.elementId} + element={editingText.element} + onCommit={commitTextEdit} + /> + ) : isMaskMode ? ( + <MaskHandles onSnapLinesChange={setSnapLines} /> + ) : ( + <TransformHandles onSnapLinesChange={setSnapLines} /> + )} + <SnapGuides lines={snapLines} /> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx index aff77f0f..78114b27 100644 --- a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx +++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx @@ -1,212 +1,151 @@ -"use client";
-
-import { useCallback, useEffect, useRef } from "react";
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useEditor } from "@/hooks/use-editor";
-import type { TextElement } from "@/lib/timeline";
-import {
- FONT_SIZE_SCALE_REFERENCE,
-} from "@/constants/text-constants";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import {
- getMetricAscent,
- getMetricDescent,
- setCanvasLetterSpacing,
-} from "@/lib/text/layout";
-
-let cachedCanvas: HTMLCanvasElement | null = null;
-
-function getMeasurementContext(): CanvasRenderingContext2D | null {
- if (!cachedCanvas) cachedCanvas = document.createElement("canvas");
- return cachedCanvas.getContext("2d");
-}
-
-/**
- * Uses fontBoundingBox metrics (which CSS uses for line box layout) rather than
- * actualBoundingBox to model where the browser places the baseline within the
- * line box, then measures actual glyph bounds to find the visual center.
- */
-function measureCSSVisualCenterOffset({
- lines,
- fontString,
- letterSpacingPx,
- lineHeightPx,
- displayFontSize,
-}: {
- lines: string[];
- fontString: string;
- letterSpacingPx: number;
- lineHeightPx: number;
- displayFontSize: number;
-}): number {
- const ctx = getMeasurementContext();
- if (!ctx) return 0;
-
- ctx.font = fontString;
- ctx.textBaseline = "alphabetic";
- setCanvasLetterSpacing({ ctx, letterSpacingPx });
-
- const probe = ctx.measureText("M");
- const fontAscent = probe.fontBoundingBoxAscent ?? displayFontSize * 0.8;
- const fontDescent = probe.fontBoundingBoxDescent ?? displayFontSize * 0.2;
- const halfLeading = (lineHeightPx - fontAscent - fontDescent) / 2;
-
- let visualTop = Number.POSITIVE_INFINITY;
- let visualBottom = Number.NEGATIVE_INFINITY;
-
- for (let i = 0; i < lines.length; i++) {
- const metrics = ctx.measureText(lines[i] || " ");
- const baseline = i * lineHeightPx + halfLeading + fontAscent;
- visualTop = Math.min(
- visualTop,
- baseline - getMetricAscent({ metrics, fallbackFontSize: displayFontSize }),
- );
- visualBottom = Math.max(
- visualBottom,
- baseline + getMetricDescent({ metrics, fallbackFontSize: displayFontSize }),
- );
- }
-
- const cssBlockHeight = lines.length * lineHeightPx;
- return (visualTop + visualBottom) / 2 - cssBlockHeight / 2;
-}
-
-export function TextEditOverlay({
- trackId,
- elementId,
- element,
- onCommit,
-}: {
- trackId: string;
- elementId: string;
- element: TextElement;
- onCommit: () => void;
-}) {
- const editor = useEditor();
- const viewport = usePreviewViewport();
- const divRef = useRef<HTMLDivElement>(null);
-
- useEffect(() => {
- const div = divRef.current;
- if (!div) return;
- div.focus();
- const range = document.createRange();
- range.selectNodeContents(div);
- const selection = window.getSelection();
- selection?.removeAllRanges();
- selection?.addRange(range);
- }, []);
-
- const handleInput = useCallback(() => {
- const div = divRef.current;
- if (!div) return;
- const text = div.innerText;
- editor.timeline.previewElements({
- updates: [{ trackId, elementId, updates: { content: text } }],
- });
- }, [editor.timeline, trackId, elementId]);
-
- const handleKeyDown = useCallback(
- ({ event }: { event: React.KeyboardEvent }) => {
- const { key } = event;
- if (key === "Escape") {
- event.preventDefault();
- onCommit();
- return;
- }
- },
- [onCommit],
- );
-
- const canvasSize = editor.project.getActive().settings.canvasSize;
-
- if (!canvasSize) return null;
-
- const { x: posX, y: posY } = viewport.positionToOverlay({
- positionX: element.transform.position.x,
- positionY: element.transform.position.y,
- });
-
- const { x: displayScaleX } = viewport.getDisplayScale();
-
- const displayFontSize =
- element.fontSize *
- (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
- displayScaleX;
-
- const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight;
- const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const displayLetterSpacing = (element.letterSpacing ?? 0) * displayScaleX;
- const lineHeightPx = displayFontSize * lineHeight;
- const lines = (element.content || "").split("\n");
- const fontString = `${fontStyle} ${fontWeight} ${displayFontSize}px "${element.fontFamily}", sans-serif`;
-
- const cssVisualCenterOffset = measureCSSVisualCenterOffset({
- lines,
- fontString,
- letterSpacingPx: displayLetterSpacing,
- lineHeightPx,
- displayFontSize,
- });
-
- const bg = element.background;
- const shouldShowBackground =
- bg.enabled && bg.color && bg.color !== "transparent";
- const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
- const displayPaddingX = shouldShowBackground
- ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) *
- fontSizeRatio *
- displayScaleX
- : 0;
- const displayPaddingY = shouldShowBackground
- ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) *
- fontSizeRatio *
- displayScaleX
- : 0;
-
- return (
- <div
- className="absolute"
- style={{
- left: posX,
- top: posY - cssVisualCenterOffset,
- transform: `translate(-50%, -50%) scale(${element.transform.scaleX}, ${element.transform.scaleY}) rotate(${element.transform.rotate}deg)`,
- transformOrigin: "center center",
- }}
- >
- {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
- <div
- ref={divRef}
- contentEditable
- suppressContentEditableWarning
- tabIndex={0}
- role="textbox"
- aria-label="Edit text"
- className="cursor-text select-text outline-none whitespace-pre"
- style={{
- fontSize: displayFontSize,
- fontFamily: element.fontFamily,
- fontWeight,
- fontStyle,
- textAlign: element.textAlign,
- letterSpacing: `${displayLetterSpacing}px`,
- lineHeight,
- color: element.color,
- backgroundColor: shouldShowBackground ? bg.color : "transparent",
- minHeight: lineHeightPx,
- textDecoration: element.textDecoration ?? "none",
- padding: shouldShowBackground
- ? `${displayPaddingY}px ${displayPaddingX}px`
- : 0,
- minWidth: 1,
- }}
- onInput={handleInput}
- onBlur={onCommit}
- onKeyDown={(event) => handleKeyDown({ event })}
- >
- {element.content || ""}
- </div>
- </div>
- );
-}
+"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useEditor } from "@/hooks/use-editor"; +import type { TextElement } from "@/lib/timeline"; +import { + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { + getElementLocalTime, + resolveTransformAtTime, +} from "@/lib/animation"; + +export function TextEditOverlay({ + trackId, + elementId, + element, + onCommit, +}: { + trackId: string; + elementId: string; + element: TextElement; + onCommit: () => void; +}) { + const editor = useEditor(); + const viewport = usePreviewViewport(); + const divRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const div = divRef.current; + if (!div) return; + div.focus(); + const range = document.createRange(); + range.selectNodeContents(div); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }, []); + + const handleInput = useCallback(() => { + const div = divRef.current; + if (!div) return; + const text = div.innerText; + editor.timeline.previewElements({ + updates: [{ trackId, elementId, updates: { content: text } }], + }); + }, [editor.timeline, trackId, elementId]); + + const handleKeyDown = useCallback( + ({ event }: { event: React.KeyboardEvent }) => { + const { key } = event; + if (key === "Escape") { + event.preventDefault(); + onCommit(); + return; + } + }, + [onCommit], + ); + + const canvasSize = editor.project.getActive().settings.canvasSize; + + if (!canvasSize) return null; + + const currentTime = editor.playback.getCurrentTime(); + const localTime = getElementLocalTime({ + timelineTime: currentTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const { x: posX, y: posY } = viewport.positionToOverlay({ + positionX: transform.position.x, + positionY: transform.position.y, + }); + + const { x: displayScaleX } = viewport.getDisplayScale(); + + const scaledFontSize = + element.fontSize * (canvasSize.height / FONT_SIZE_SCALE_REFERENCE); + + const lineHeight = element.lineHeight ?? DEFAULTS.text.lineHeight; + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const canvasLetterSpacing = element.letterSpacing ?? 0; + const lineHeightPx = scaledFontSize * lineHeight; + + const bg = element.background; + const shouldShowBackground = + bg.enabled && bg.color && bg.color !== "transparent"; + const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const canvasPaddingX = shouldShowBackground + ? (bg.paddingX ?? DEFAULTS.text.background.paddingX) * fontSizeRatio + : 0; + const canvasPaddingY = shouldShowBackground + ? (bg.paddingY ?? DEFAULTS.text.background.paddingY) * fontSizeRatio + : 0; + + return ( + <div + className="absolute" + style={{ + left: posX, + top: posY, + transform: `translate(-50%, -50%) scale(${transform.scaleX * displayScaleX}, ${transform.scaleY * displayScaleX}) rotate(${transform.rotate}deg)`, + transformOrigin: "center center", + }} + > + {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */} + <div + ref={divRef} + contentEditable + suppressContentEditableWarning + tabIndex={0} + role="textbox" + aria-label="Edit text" + className="cursor-text select-text outline-none whitespace-pre" + style={{ + fontSize: scaledFontSize, + fontFamily: element.fontFamily, + fontWeight, + fontStyle, + textAlign: element.textAlign, + letterSpacing: `${canvasLetterSpacing}px`, + lineHeight, + color: "transparent", + caretColor: element.color, + backgroundColor: shouldShowBackground ? bg.color : "transparent", + minHeight: lineHeightPx, + textDecoration: element.textDecoration ?? "none", + padding: shouldShowBackground + ? `${canvasPaddingY}px ${canvasPaddingX}px` + : 0, + minWidth: 1, + }} + onInput={handleInput} + onBlur={onCommit} + onKeyDown={(event) => handleKeyDown({ event })} + > + {element.content || ""} + </div> + </div> + ); +} diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx index 141359c8..2b81b557 100644 --- a/apps/web/src/components/editor/panels/preview/transform-handles.tsx +++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx @@ -1,140 +1,145 @@ -"use client";
-
-import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport";
-import { useTransformHandles } from "@/hooks/use-transform-handles";
-import { isVisualElement } from "@/lib/timeline/element-utils";
-import {
- getCornerPosition,
- getEdgeHandlePosition,
- getRotationHandlePosition,
- type Corner,
- type Edge,
-} from "@/lib/preview/element-bounds";
-import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction";
-import {
- BoundingBoxOutline,
- CornerHandle,
- EdgeHandle,
- IconHandle,
- getResizeCursor,
-} from "./handle-primitives";
-import { Rotate01Icon } from "@hugeicons/core-free-icons";
-
-const CORNERS: Corner[] = [
- "top-left",
- "top-right",
- "bottom-left",
- "bottom-right",
-];
-const EDGES: Edge[] = ["right", "left", "bottom"];
-
-export function TransformHandles({
- onSnapLinesChange,
-}: {
- onSnapLinesChange?: OnSnapLinesChange;
-}) {
- const viewport = usePreviewViewport();
- const {
- selectedWithBounds,
- hasVisualSelection,
- handleCornerPointerDown,
- handleEdgePointerDown,
- handleRotationPointerDown,
- handlePointerMove,
- handlePointerUp,
- } = useTransformHandles({ onSnapLinesChange });
-
- if (!hasVisualSelection || !selectedWithBounds) return null;
-
- const { bounds, element } = selectedWithBounds;
- if (!isVisualElement(element)) return null;
-
- const displayScale = viewport.getDisplayScale();
-
- const toOverlay = ({
- canvasX,
- canvasY,
- }: {
- canvasX: number;
- canvasY: number;
- }) =>
- viewport.canvasToOverlay({
- canvasX,
- canvasY,
- });
-
- const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
- const outlineWidth = Math.abs(bounds.width) * displayScale.x;
- const outlineHeight = Math.abs(bounds.height) * displayScale.y;
-
- const rotationHandleCanvas = getRotationHandlePosition({ bounds });
- const rotationHandleScreen = toOverlay({
- canvasX: rotationHandleCanvas.x,
- canvasY: rotationHandleCanvas.y,
- });
-
- const onPointerMove = (event: React.PointerEvent) =>
- handlePointerMove({ event });
- const onPointerUp = () => handlePointerUp();
-
- return (
- <div
- className="pointer-events-none absolute inset-0 overflow-hidden"
- aria-hidden
- >
- <BoundingBoxOutline
- center={center}
- outlineWidth={outlineWidth}
- outlineHeight={outlineHeight}
- rotation={bounds.rotation}
- />
- {CORNERS.map((corner) => {
- const cornerPosition = getCornerPosition({ bounds, corner });
- const screen = toOverlay({
- canvasX: cornerPosition.x,
- canvasY: cornerPosition.y,
- });
- const angleDeg =
- Math.atan2(screen.y - center.y, screen.x - center.x) *
- (180 / Math.PI);
- return (
- <CornerHandle
- key={corner}
- cursor={getResizeCursor({ angleDeg })}
- screen={screen}
- onPointerDown={(event) =>
- handleCornerPointerDown({ event, corner })
- }
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- })}
- {EDGES.map((edge) => {
- const edgePosition = getEdgeHandlePosition({ bounds, edge });
- const screen = toOverlay({
- canvasX: edgePosition.x,
- canvasY: edgePosition.y,
- });
- return (
- <EdgeHandle
- key={edge}
- edge={edge}
- screen={screen}
- rotation={bounds.rotation}
- onPointerDown={(event) => handleEdgePointerDown({ event, edge })}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- );
- })}
- <IconHandle
- icon={Rotate01Icon}
- screen={rotationHandleScreen}
- onPointerDown={(event) => handleRotationPointerDown({ event })}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
- </div>
- );
-}
+"use client"; + +import { usePreviewViewport } from "@/components/editor/panels/preview/preview-viewport"; +import { useTransformHandles } from "@/hooks/use-transform-handles"; +import { isVisualElement } from "@/lib/timeline/element-utils"; +import { + getCornerPosition, + getEdgeHandlePosition, + ROTATION_HANDLE_OFFSET, + type Corner, + type Edge, +} from "@/lib/preview/element-bounds"; +import type { OnSnapLinesChange } from "@/hooks/use-preview-interaction"; +import { + BoundingBoxOutline, + CornerHandle, + EdgeHandle, + IconHandle, + getResizeCursor, +} from "./handle-primitives"; +import { Rotate01Icon } from "@hugeicons/core-free-icons"; + +const CORNERS: Corner[] = [ + "top-left", + "top-right", + "bottom-left", + "bottom-right", +]; +const EDGES: Edge[] = ["right", "left", "bottom"]; + +export function TransformHandles({ + onSnapLinesChange, +}: { + onSnapLinesChange?: OnSnapLinesChange; +}) { + const viewport = usePreviewViewport(); + const { + selectedWithBounds, + hasVisualSelection, + handleCornerPointerDown, + handleEdgePointerDown, + handleRotationPointerDown, + handlePointerMove, + handlePointerUp, + } = useTransformHandles({ onSnapLinesChange }); + + if (!hasVisualSelection || !selectedWithBounds) return null; + + const { bounds, element } = selectedWithBounds; + if (!isVisualElement(element)) return null; + + const displayScale = viewport.getDisplayScale(); + + const toOverlay = ({ + canvasX, + canvasY, + }: { + canvasX: number; + canvasY: number; + }) => + viewport.canvasToOverlay({ + canvasX, + canvasY, + }); + + const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy }); + const outlineWidth = Math.abs(bounds.width) * displayScale.x; + const outlineHeight = Math.abs(bounds.height) * displayScale.y; + + const rotationAngleRad = (bounds.rotation * Math.PI) / 180; + const topCenterLocalY = -bounds.height / 2; + const topCenterScreen = toOverlay({ + canvasX: bounds.cx - topCenterLocalY * Math.sin(rotationAngleRad), + canvasY: bounds.cy + topCenterLocalY * Math.cos(rotationAngleRad), + }); + const rotationHandleScreen = { + x: topCenterScreen.x + Math.sin(rotationAngleRad) * ROTATION_HANDLE_OFFSET, + y: topCenterScreen.y - Math.cos(rotationAngleRad) * ROTATION_HANDLE_OFFSET, + }; + + const onPointerMove = (event: React.PointerEvent) => + handlePointerMove({ event }); + const onPointerUp = () => handlePointerUp(); + + return ( + <div + className="pointer-events-none absolute inset-0 overflow-hidden" + aria-hidden + > + <BoundingBoxOutline + center={center} + outlineWidth={outlineWidth} + outlineHeight={outlineHeight} + rotation={bounds.rotation} + /> + {CORNERS.map((corner) => { + const cornerPosition = getCornerPosition({ bounds, corner }); + const screen = toOverlay({ + canvasX: cornerPosition.x, + canvasY: cornerPosition.y, + }); + const angleDeg = + Math.atan2(screen.y - center.y, screen.x - center.x) * + (180 / Math.PI); + return ( + <CornerHandle + key={corner} + cursor={getResizeCursor({ angleDeg })} + screen={screen} + onPointerDown={(event) => + handleCornerPointerDown({ event, corner }) + } + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + {EDGES.map((edge) => { + const edgePosition = getEdgeHandlePosition({ bounds, edge }); + const screen = toOverlay({ + canvasX: edgePosition.x, + canvasY: edgePosition.y, + }); + return ( + <EdgeHandle + key={edge} + edge={edge} + screen={screen} + rotation={bounds.rotation} + onPointerDown={(event) => handleEdgePointerDown({ event, edge })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + ); + })} + <IconHandle + icon={Rotate01Icon} + screen={rotationHandleScreen} + onPointerDown={(event) => handleRotationPointerDown({ event })} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + /> + </div> + ); +} diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx index 12b64853..cac954ba 100644 --- a/apps/web/src/components/providers/editor-provider.tsx +++ b/apps/web/src/components/providers/editor-provider.tsx @@ -10,7 +10,7 @@ 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"; +import { initializeGpuRenderer, isGpuAvailable } from "@/services/renderer/gpu-renderer"; interface EditorProviderProps { projectId: string; @@ -36,6 +36,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) { try { setIsLoading(true); await initializeGpuRenderer(); + editor.renderer.setDegraded(!isGpuAvailable()); await editor.project.loadProject({ id: projectId }); if (cancelled) return; diff --git a/apps/web/src/core/managers/playback-manager.ts b/apps/web/src/core/managers/playback-manager.ts index 47909bfe..341a0eae 100644 --- a/apps/web/src/core/managers/playback-manager.ts +++ b/apps/web/src/core/managers/playback-manager.ts @@ -13,24 +13,32 @@ export class PlaybackManager { private playbackTimer: number | null = null; private playbackStartWallTime = 0; private playbackStartTime = 0; + private timelineScopeBound = false; - constructor(private editor: EditorCore) { - this.editor.timeline.subscribe(() => { - const maxTime = this.editor.timeline.getTotalDuration(); - if (this.currentTime > maxTime && maxTime > 0) { - this.currentTime = maxTime; - this.notify(); - } - }); + constructor(private editor: EditorCore) {} + + bindTimelineScope(): void { + if (this.timelineScopeBound) { + return; + } + + const reconcile = () => { + this.reconcileTimelineScope(); + }; + this.editor.timeline.subscribe(reconcile); + this.editor.scenes.subscribe(reconcile); + this.timelineScopeBound = true; + this.reconcileTimelineScope(); } play(): void { const maxTime = this.editor.timeline.getTotalDuration(); + if (maxTime <= 0) { + return; + } - if (maxTime > 0) { - if (this.currentTime >= maxTime) { - this.seek({ time: 0 }); - } + if (this.currentTime >= maxTime) { + this.seek({ time: 0 }); } this.isPlaying = true; @@ -53,19 +61,13 @@ export class PlaybackManager { } seek({ time }: { time: number }): void { - const maxTime = this.editor.timeline.getTotalDuration(); - this.currentTime = Math.max(0, Math.min(maxTime, time)); + this.currentTime = this.clampTimeToTimeline(time); if (this.isPlaying) { this.playbackStartWallTime = performance.now(); this.playbackStartTime = this.currentTime; } this.notify(); - - window.dispatchEvent( - new CustomEvent("playback-seek", { - detail: { time: this.currentTime }, - }), - ); + this.dispatchSeekEvent(this.currentTime); } setVolume({ volume }: { volume: number }): void { @@ -131,6 +133,29 @@ export class PlaybackManager { return () => this.listeners.delete(listener); } + private reconcileTimelineScope(): void { + const maxTime = this.editor.timeline.getTotalDuration(); + const nextTime = this.clampTimeToTimeline(this.currentTime); + const shouldPause = this.isPlaying && nextTime >= maxTime; + const timeChanged = nextTime !== this.currentTime; + + if (!timeChanged && !shouldPause) { + return; + } + + if (shouldPause) { + this.isPlaying = false; + this.stopTimer(); + } + + this.currentTime = nextTime; + this.notify(); + + if (timeChanged) { + this.dispatchSeekEvent(this.currentTime); + } + } + private notify(): void { this.listeners.forEach((fn) => { fn(); @@ -158,31 +183,54 @@ export class PlaybackManager { if (!this.isPlaying) return; 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 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) { + if (newTime >= maxTime) { this.pause(); this.currentTime = maxTime; this.notify(); - - window.dispatchEvent( - new CustomEvent("playback-seek", { - detail: { time: maxTime }, - }), - ); - } else { - this.currentTime = newTime; - - window.dispatchEvent( - new CustomEvent("playback-update", { - detail: { time: newTime }, - }), - ); + this.dispatchSeekEvent(maxTime); + return; } + this.currentTime = newTime; + this.dispatchUpdateEvent(newTime); this.playbackTimer = requestAnimationFrame(this.updateTime); }; + + private clampTimeToTimeline(time: number): number { + const maxTime = this.editor.timeline.getTotalDuration(); + return Math.max(0, Math.min(maxTime, time)); + } + + private dispatchSeekEvent(time: number): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent("playback-seek", { + detail: { time }, + }), + ); + } + + private dispatchUpdateEvent(time: number): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent("playback-update", { + detail: { time }, + }), + ); + } } diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index eec4bf37..495292e6 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -15,10 +15,21 @@ type SnapshotResult = export class RendererManager { private renderTree: RootNode | null = null; + private _isDegraded = false; private listeners = new Set<() => void>(); constructor(private editor: EditorCore) {} + get isDegraded(): boolean { + return this._isDegraded; + } + + setDegraded(degraded: boolean): void { + if (this._isDegraded === degraded) return; + this._isDegraded = degraded; + this.notify(); + } + setRenderTree({ renderTree }: { renderTree: RootNode | null }): void { this.renderTree = renderTree; this.notify(); diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 74870a95..70075b98 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -5,7 +5,6 @@ import type { TrackType, TimelineTrack, TimelineElement, - ClipboardItem, RetimeConfig, } from "@/lib/timeline"; import { calculateTotalDuration } from "@/lib/timeline"; @@ -23,6 +22,11 @@ import type { AnimationValue, ScalarCurveKeyframePatch, } from "@/lib/animation/types"; +import { + getElementLocalTime, + resolveAnimationTarget, + resolveAnimationPathValueAtTime, +} from "@/lib/animation"; import { lastFrameTime } from "opencut-wasm"; import { BatchCommand } from "@/lib/commands"; import { @@ -35,7 +39,6 @@ import { DuplicateElementsCommand, UpdateElementsCommand, SplitElementsCommand, - PasteCommand, MoveElementCommand, TracksSnapshotCommand, UpsertKeyframeCommand, @@ -244,18 +247,6 @@ export class TimelineManager { return result; } - pasteAtTime({ - time, - clipboardItems, - }: { - time: number; - clipboardItems: ClipboardItem[]; - }): { trackId: string; elementId: string }[] { - const command = new PasteCommand({ time, clipboardItems }); - this.editor.command.execute({ command }); - return command.getPastedElements(); - } - deleteElements({ elements, }: { @@ -492,6 +483,45 @@ export class TimelineManager { return; } + // Pre-sample values at playhead for each (element, property) pair. + // This preserves "what you see is what you get" when all keyframes are deleted. + const playheadTime = this.editor.playback.getCurrentTime(); + const valueAtPlayheadMap = new Map<string, AnimationValue | null>(); + + for (const { trackId, elementId, propertyPath } of keyframes) { + const key = `${elementId}:${propertyPath}`; + if (valueAtPlayheadMap.has(key)) { + continue; + } + + const element = this.getElementByRef({ trackId, elementId }); + if (!element) { + valueAtPlayheadMap.set(key, null); + continue; + } + + const localTime = getElementLocalTime({ + timelineTime: playheadTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + + const target = resolveAnimationTarget({ element, path: propertyPath }); + const baseValue = target?.getBaseValue() ?? null; + if (baseValue === null) { + valueAtPlayheadMap.set(key, null); + continue; + } + + const value = resolveAnimationPathValueAtTime({ + animations: element.animations, + propertyPath, + localTime, + fallbackValue: baseValue, + }); + valueAtPlayheadMap.set(key, value); + } + const commands = keyframes.map( ({ trackId, elementId, propertyPath, keyframeId }) => new RemoveKeyframeCommand({ @@ -499,6 +529,8 @@ export class TimelineManager { elementId, propertyPath, keyframeId, + valueAtPlayhead: + valueAtPlayheadMap.get(`${elementId}:${propertyPath}`) ?? null, }), ); const command = diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts index 2a3e5c28..cfb3d527 100644 --- a/apps/web/src/hooks/use-preview-interaction.ts +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -134,15 +134,6 @@ export function usePreviewInteraction({ const current = editingTextRef.current; if (!current) return; editingTextRef.current = null; - editor.timeline.previewElements({ - updates: [ - { - trackId: current.trackId, - elementId: current.elementId, - updates: { opacity: current.originalOpacity }, - }, - ], - }); editor.timeline.commitPreview(); setEditingText(null); }, [editor.timeline]); @@ -207,15 +198,6 @@ export function usePreviewInteraction({ if (!hit || hit.element.type !== "text") return; const textElement = hit.element as TextElement; - editor.timeline.previewElements({ - updates: [ - { - trackId: hit.trackId, - elementId: hit.elementId, - updates: { opacity: 0 }, - }, - ], - }); setEditingText({ trackId: hit.trackId, elementId: hit.elementId, diff --git a/apps/web/src/lib/background/constants.ts b/apps/web/src/lib/background/constants.ts index 654154a5..b682c260 100644 --- a/apps/web/src/lib/background/constants.ts +++ b/apps/web/src/lib/background/constants.ts @@ -2,9 +2,9 @@ export const BACKGROUND_BLUR_INTENSITY_PRESETS: Array<{ label: string; value: number; }> = [ - { label: "Light", value: 10 }, - { label: "Medium", value: 50 }, - { label: "Heavy", value: 100 }, + { label: "Light", value: 100 }, + { label: "Medium", value: 200 }, + { label: "Heavy", value: 500 }, ] as const; export const DEFAULT_BACKGROUND_BLUR_INTENSITY = 10; diff --git a/apps/web/src/lib/text/layout.ts b/apps/web/src/lib/text/layout.ts index 75fa7f09..a3badfdc 100644 --- a/apps/web/src/lib/text/layout.ts +++ b/apps/web/src/lib/text/layout.ts @@ -14,15 +14,18 @@ export interface TextBlockMeasurement { maxWidth: number; } -type CanvasContext = +export type TextCanvasContext = | CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +const TEXT_DECORATION_THICKNESS_RATIO = 0.07; +const STRIKETHROUGH_VERTICAL_RATIO = 0.35; + export function setCanvasLetterSpacing({ ctx, letterSpacingPx, }: { - ctx: CanvasContext; + ctx: TextCanvasContext; letterSpacingPx: number; }): void { if ("letterSpacing" in ctx) { @@ -177,3 +180,47 @@ export function getTextVisualRect({ height: bottom - top, }; } + +export function drawTextDecoration({ + ctx, + textDecoration, + lineWidth, + lineY, + metrics, + scaledFontSize, + textAlign, +}: { + ctx: TextCanvasContext; + textDecoration: string; + lineWidth: number; + lineY: number; + metrics: TextMetrics; + scaledFontSize: number; + textAlign: CanvasTextAlign; +}): void { + if (textDecoration === "none" || !textDecoration) return; + + const thickness = Math.max( + 1, + scaledFontSize * TEXT_DECORATION_THICKNESS_RATIO, + ); + const ascent = getMetricAscent({ metrics, fallbackFontSize: scaledFontSize }); + const descent = getMetricDescent({ + metrics, + fallbackFontSize: scaledFontSize, + }); + + let xStart = -lineWidth / 2; + if (textAlign === "left") xStart = 0; + if (textAlign === "right") xStart = -lineWidth; + + if (textDecoration === "underline") { + const underlineY = lineY + descent + thickness; + ctx.fillRect(xStart, underlineY, lineWidth, thickness); + } + + if (textDecoration === "line-through") { + const strikeY = lineY - (ascent - descent) * STRIKETHROUGH_VERTICAL_RATIO; + ctx.fillRect(xStart, strikeY, lineWidth, thickness); + } +} diff --git a/apps/web/src/lib/text/measure-element.ts b/apps/web/src/lib/text/measure-element.ts index 77efe1b8..99b8a7ba 100644 --- a/apps/web/src/lib/text/measure-element.ts +++ b/apps/web/src/lib/text/measure-element.ts @@ -1,131 +1,158 @@ -import {
- CORNER_RADIUS_MIN,
- FONT_SIZE_SCALE_REFERENCE,
-} from "@/constants/text-constants";
-import { resolveNumberAtTime } from "@/lib/animation";
-import { DEFAULTS } from "@/lib/timeline/defaults";
-import type { TextBackground, TextElement } from "@/lib/timeline";
-import {
- measureTextBlock,
- setCanvasLetterSpacing,
- getTextVisualRect,
- type TextBlockMeasurement,
-} from "./layout";
-
-export interface ResolvedTextBackground extends TextBackground {
- paddingX: number;
- paddingY: number;
- offsetX: number;
- offsetY: number;
- cornerRadius: number;
-}
-
-export interface MeasuredTextElement {
- scaledFontSize: number;
- fontString: string;
- letterSpacing: number;
- lineHeightPx: number;
- lines: string[];
- lineMetrics: TextMetrics[];
- block: TextBlockMeasurement;
- fontSizeRatio: number;
- resolvedBackground: ResolvedTextBackground;
- visualRect: { left: number; top: number; width: number; height: number };
-}
-
-/**
- * Shared text measurement used by both the renderer and preview bounds.
- * Accepts the canvas context to measure on so callers can reuse an existing
- * context (e.g. the renderer's) rather than creating a throwaway canvas.
- * The context state is preserved via save/restore.
- */
-export function measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
-}: {
- element: TextElement;
- canvasHeight: number;
- localTime: number;
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
-}): MeasuredTextElement {
- const scaledFontSize =
- element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
- const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
- const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
- const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
- const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
- const letterSpacing = element.letterSpacing ?? 0;
- const lineHeightPx =
- scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight);
- const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize;
- const lines = element.content.split("\n");
-
- ctx.save();
- ctx.font = fontString;
- ctx.textBaseline = "middle";
- setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing });
- const lineMetrics = lines.map((line) => ctx.measureText(line));
- ctx.restore();
-
- const block = measureTextBlock({
- lineMetrics,
- lineHeightPx,
- });
-
- const bg = element.background;
- const resolvedBackground: ResolvedTextBackground = {
- ...bg,
- paddingX: resolveNumberAtTime({
- baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX,
- animations: element.animations,
- propertyPath: "background.paddingX",
- localTime,
- }),
- paddingY: resolveNumberAtTime({
- baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY,
- animations: element.animations,
- propertyPath: "background.paddingY",
- localTime,
- }),
- offsetX: resolveNumberAtTime({
- baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX,
- animations: element.animations,
- propertyPath: "background.offsetX",
- localTime,
- }),
- offsetY: resolveNumberAtTime({
- baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY,
- animations: element.animations,
- propertyPath: "background.offsetY",
- localTime,
- }),
- cornerRadius: resolveNumberAtTime({
- baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN,
- animations: element.animations,
- propertyPath: "background.cornerRadius",
- localTime,
- }),
- };
-
- const visualRect = getTextVisualRect({
- textAlign: element.textAlign,
- block,
- background: resolvedBackground,
- fontSizeRatio,
- });
-
- return {
- scaledFontSize,
- fontString,
- letterSpacing,
- lineHeightPx,
- lines,
- lineMetrics,
- block,
- fontSizeRatio,
- resolvedBackground,
- visualRect,
- };
-}
+import { + CORNER_RADIUS_MIN, + FONT_SIZE_SCALE_REFERENCE, +} from "@/constants/text-constants"; +import { resolveNumberAtTime } from "@/lib/animation"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import type { TextBackground, TextElement } from "@/lib/timeline"; +import { + measureTextBlock, + setCanvasLetterSpacing, + getTextVisualRect, + type TextBlockMeasurement, +} from "./layout"; + +export interface ResolvedTextBackground extends TextBackground { + paddingX: number; + paddingY: number; + offsetX: number; + offsetY: number; + cornerRadius: number; +} + +export interface MeasuredTextElement { + scaledFontSize: number; + fontString: string; + letterSpacing: number; + lineHeightPx: number; + lines: string[]; + lineMetrics: TextMetrics[]; + block: TextBlockMeasurement; + fontSizeRatio: number; + resolvedBackground: ResolvedTextBackground; + visualRect: { left: number; top: number; width: number; height: number }; +} + +let textMeasurementContext: + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D + | null = null; + +export function getTextMeasurementContext(): + | CanvasRenderingContext2D + | OffscreenCanvasRenderingContext2D { + if (textMeasurementContext) { + return textMeasurementContext; + } + + if (typeof OffscreenCanvas !== "undefined") { + const canvas = new OffscreenCanvas(1, 1); + const context = canvas.getContext("2d"); + if (context) { + textMeasurementContext = context; + return context; + } + } + + if (typeof document !== "undefined") { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (context) { + textMeasurementContext = context; + return context; + } + } + + throw new Error("Failed to create text measurement context"); +} + +export function measureTextElement({ + element, + canvasHeight, + localTime, + ctx, +}: { + element: TextElement; + canvasHeight: number; + localTime: number; + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; +}): MeasuredTextElement { + const scaledFontSize = + element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE); + const fontWeight = element.fontWeight === "bold" ? "bold" : "normal"; + const fontStyle = element.fontStyle === "italic" ? "italic" : "normal"; + const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`; + const fontString = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`; + const letterSpacing = element.letterSpacing ?? 0; + const lineHeightPx = + scaledFontSize * (element.lineHeight ?? DEFAULTS.text.lineHeight); + const fontSizeRatio = element.fontSize / DEFAULTS.text.element.fontSize; + const lines = element.content.split("\n"); + + ctx.save(); + ctx.font = fontString; + ctx.textBaseline = "middle"; + setCanvasLetterSpacing({ ctx, letterSpacingPx: letterSpacing }); + const lineMetrics = lines.map((line) => ctx.measureText(line)); + ctx.restore(); + + const block = measureTextBlock({ + lineMetrics, + lineHeightPx, + }); + + const bg = element.background; + const resolvedBackground: ResolvedTextBackground = { + ...bg, + paddingX: resolveNumberAtTime({ + baseValue: bg.paddingX ?? DEFAULTS.text.background.paddingX, + animations: element.animations, + propertyPath: "background.paddingX", + localTime, + }), + paddingY: resolveNumberAtTime({ + baseValue: bg.paddingY ?? DEFAULTS.text.background.paddingY, + animations: element.animations, + propertyPath: "background.paddingY", + localTime, + }), + offsetX: resolveNumberAtTime({ + baseValue: bg.offsetX ?? DEFAULTS.text.background.offsetX, + animations: element.animations, + propertyPath: "background.offsetX", + localTime, + }), + offsetY: resolveNumberAtTime({ + baseValue: bg.offsetY ?? DEFAULTS.text.background.offsetY, + animations: element.animations, + propertyPath: "background.offsetY", + localTime, + }), + cornerRadius: resolveNumberAtTime({ + baseValue: bg.cornerRadius ?? CORNER_RADIUS_MIN, + animations: element.animations, + propertyPath: "background.cornerRadius", + localTime, + }), + }; + + const visualRect = getTextVisualRect({ + textAlign: element.textAlign, + block, + background: resolvedBackground, + fontSizeRatio, + }); + + return { + scaledFontSize, + fontString, + letterSpacing, + lineHeightPx, + lines, + lineMetrics, + block, + fontSizeRatio, + resolvedBackground, + visualRect, + }; +} diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts index 36441981..e5f3991d 100644 --- a/apps/web/src/services/video-cache/service.ts +++ b/apps/web/src/services/video-cache/service.ts @@ -19,6 +19,7 @@ interface VideoSinkData { export class VideoCache { private sinks = new Map<string, VideoSinkData>(); private initPromises = new Map<string, Promise<void>>(); + private frameChain = new Map<string, Promise<unknown>>(); async getFrameAt({ mediaId, @@ -34,6 +35,21 @@ export class VideoCache { const sinkData = this.sinks.get(mediaId); if (!sinkData) return null; + const previous = this.frameChain.get(mediaId) ?? Promise.resolve(); + const current = previous.then(() => + this.resolveFrame({ sinkData, time }), + ); + this.frameChain.set(mediaId, current.catch(() => {})); + return current; + } + + private async resolveFrame({ + sinkData, + time, + }: { + sinkData: VideoSinkData; + time: number; + }): Promise<WrappedCanvas | null> { if (sinkData.nextFrame && sinkData.nextFrame.timestamp <= time) { sinkData.currentFrame = sinkData.nextFrame; sinkData.nextFrame = null; |
