From e50a817b97b87baa0cd8fdde0fdb5d607143c1c3 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 8 Feb 2026 22:21:17 +0100 Subject: [PATCH] add transform + few fixes feat: transform feat: batch command fix: slider undo fix: timeline zoom perf refactor: make updateElement support multiple --- .../editor/panels/preview/index.tsx | 34 ++- .../preview/preview-interaction-overlay.tsx | 20 ++ .../panels/properties/text-properties.tsx | 268 +++++++++++++----- .../src/components/editor/timeline/index.tsx | 1 + .../editor/timeline/timeline-ruler.tsx | 26 +- .../web/src/core/managers/timeline-manager.ts | 27 +- .../src/hooks/timeline/use-scroll-position.ts | 52 ++++ apps/web/src/hooks/use-preview-interaction.ts | 177 ++++++++++++ apps/web/src/lib/commands/batch-command.ts | 25 ++ apps/web/src/lib/commands/index.ts | 1 + .../src/services/renderer/nodes/image-node.ts | 71 +---- .../services/renderer/nodes/sticker-node.ts | 53 +--- .../src/services/renderer/nodes/video-node.ts | 67 +---- .../services/renderer/nodes/visual-node.ts | 67 +++++ .../src/services/renderer/scene-builder.ts | 4 + 15 files changed, 631 insertions(+), 262 deletions(-) create mode 100644 apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx create mode 100644 apps/web/src/hooks/timeline/use-scroll-position.ts create mode 100644 apps/web/src/hooks/use-preview-interaction.ts create mode 100644 apps/web/src/lib/commands/batch-command.ts create mode 100644 apps/web/src/services/renderer/nodes/visual-node.ts diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx index 5ed9db00..c579b15b 100644 --- a/apps/web/src/components/editor/panels/preview/index.tsx +++ b/apps/web/src/components/editor/panels/preview/index.tsx @@ -8,6 +8,7 @@ import { CanvasRenderer } from "@/services/renderer/canvas-renderer"; import type { RootNode } from "@/services/renderer/nodes/root-node"; import { buildScene } from "@/services/renderer/scene-builder"; import { getLastFrameTime } from "@/lib/time"; +import { PreviewInteractionOverlay } from "./preview-interaction-overlay"; function usePreviewSize() { const editor = useEditor(); @@ -57,7 +58,7 @@ export function PreviewPanel() { } function PreviewCanvas() { - const ref = useRef(null); + const canvasRef = useRef(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef(null); const renderingRef = useRef(false); @@ -76,7 +77,7 @@ function PreviewCanvas() { const renderTree = editor.renderer.getRenderTree(); const render = useCallback(() => { - if (ref.current && renderTree && !renderingRef.current) { + if (canvasRef.current && renderTree && !renderingRef.current) { const time = editor.playback.getCurrentTime(); const lastFrameTime = getLastFrameTime({ duration: renderTree.duration, @@ -96,7 +97,7 @@ function PreviewCanvas() { .renderToCanvas({ node: renderTree, time: renderTime, - targetCanvas: ref.current, + targetCanvas: canvasRef.current, }) .then(() => { renderingRef.current = false; @@ -108,17 +109,20 @@ function PreviewCanvas() { useRafLoop(render); return ( - +
+ + +
); } 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 new file mode 100644 index 00000000..c13379f0 --- /dev/null +++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx @@ -0,0 +1,20 @@ +import { usePreviewInteraction } from "@/hooks/use-preview-interaction"; +import { cn } from "@/utils/ui"; + +export function PreviewInteractionOverlay({ + canvasRef, +}: { + canvasRef: React.RefObject; +}) { + const { onPointerDown, onPointerMove, onPointerUp } = + usePreviewInteraction({ canvasRef }); + + return ( +
+ ); +} diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx index 43c1eb26..35230c75 100644 --- a/apps/web/src/components/editor/panels/properties/text-properties.tsx +++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx @@ -50,6 +50,8 @@ export function TextProperties({ ); const lastSelectedColor = useRef(DEFAULT_COLOR); + const initialFontSizeRef = useRef(null); + const initialOpacityRef = useRef(null); const handleFontSizeChange = ({ value }: { value: string }) => { setFontSizeInput(value); @@ -59,10 +61,8 @@ export function TextProperties({ const fontSize = Number.isNaN(parsed) ? element.fontSize : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { fontSize }, + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { fontSize } }], }); } }; @@ -73,10 +73,8 @@ export function TextProperties({ ? element.fontSize : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE }); setFontSizeInput(fontSize.toString()); - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { fontSize }, + editor.timeline.updateElements({ + updates: [{ trackId, elementId: element.id, updates: { fontSize } }], }); }; @@ -88,10 +86,14 @@ export function TextProperties({ const opacityPercent = Number.isNaN(parsed) ? Math.round(element.opacity * 100) : clamp({ value: parsed, min: 0, max: 100 }); - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }, + ], }); } }; @@ -102,10 +104,14 @@ export function TextProperties({ ? Math.round(element.opacity * 100) : clamp({ value: parsed, min: 0, max: 100 }); setOpacityInput(opacityPercent.toString()); - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { opacity: opacityPercent / 100 }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: opacityPercent / 100 }, + }, + ], }); }; @@ -113,10 +119,14 @@ export function TextProperties({ if (color !== "transparent") { lastSelectedColor.current = color; } - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { backgroundColor: color }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: color }, + }, + ], }); }; @@ -126,10 +136,14 @@ export function TextProperties({ isTransparent: boolean; }) => { const newColor = isTransparent ? "transparent" : lastSelectedColor.current; - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { backgroundColor: newColor }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { backgroundColor: newColor }, + }, + ], }); }; @@ -154,10 +168,14 @@ export function TextProperties({ defaultValue={element.content} className="bg-panel-accent min-h-20" onChange={(e) => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { content: e.target.value }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { content: e.target.value }, + }, + ], }) } /> @@ -167,10 +185,14 @@ export function TextProperties({ - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { fontFamily: value }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontFamily: value }, + }, + ], }) } /> @@ -186,13 +208,19 @@ export function TextProperties({ } size="sm" onClick={() => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { - fontWeight: - element.fontWeight === "bold" ? "normal" : "bold", - }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + fontWeight: + element.fontWeight === "bold" + ? "normal" + : "bold", + }, + }, + ], }) } className="h-8 px-3 font-bold" @@ -205,15 +233,19 @@ export function TextProperties({ } size="sm" onClick={() => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { - fontStyle: - element.fontStyle === "italic" - ? "normal" - : "italic", - }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + fontStyle: + element.fontStyle === "italic" + ? "normal" + : "italic", + }, + }, + ], }) } className="h-8 px-3 italic" @@ -228,15 +260,19 @@ export function TextProperties({ } size="sm" onClick={() => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "underline" - ? "none" - : "underline", - }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "underline" + ? "none" + : "underline", + }, + }, + ], }) } className="h-8 px-3 underline" @@ -251,15 +287,19 @@ export function TextProperties({ } size="sm" onClick={() => - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { - textDecoration: - element.textDecoration === "line-through" - ? "none" - : "line-through", - }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + textDecoration: + element.textDecoration === "line-through" + ? "none" + : "line-through", + }, + }, + ], }) } className="h-8 px-3 line-through" @@ -279,13 +319,48 @@ export function TextProperties({ max={MAX_FONT_SIZE} step={1} onValueChange={([value]) => { - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { fontSize: value }, + if (initialFontSizeRef.current === null) { + initialFontSizeRef.current = element.fontSize; + } + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: value }, + }, + ], + pushHistory: false, }); setFontSizeInput(value.toString()); }} + onValueCommit={([value]) => { + if (initialFontSizeRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { + fontSize: initialFontSizeRef.current, + }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { fontSize: value }, + }, + ], + pushHistory: true, + }); + initialFontSizeRef.current = null; + } + }} className="w-full" /> { - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { color: `#${color}` }, + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { color: `#${color}` }, + }, + ], }); }} containerRef={containerRef} @@ -330,13 +409,46 @@ export function TextProperties({ max={100} step={1} onValueChange={([value]) => { - editor.timeline.updateElement({ - trackId, - elementId: element.id, - updates: { opacity: value / 100 }, + if (initialOpacityRef.current === null) { + initialOpacityRef.current = element.opacity; + } + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }, + ], + pushHistory: false, }); setOpacityInput(value.toString()); }} + onValueCommit={([value]) => { + if (initialOpacityRef.current !== null) { + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: initialOpacityRef.current }, + }, + ], + pushHistory: false, + }); + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId: element.id, + updates: { opacity: value / 100 }, + }, + ], + pushHistory: true, + }); + initialOpacityRef.current = null; + } + }} className="w-full" /> ; + tracksScrollRef: React.RefObject; handleWheel: (e: React.WheelEvent) => void; handleTimelineContentClick: (e: React.MouseEvent) => void; handleRulerTrackingMouseDown: (e: React.MouseEvent) => void; @@ -19,6 +21,7 @@ export function TimelineRuler({ zoomLevel, dynamicTimelineWidth, rulerRef, + tracksScrollRef, handleWheel, handleTimelineContentClick, handleRulerTrackingMouseDown, @@ -37,8 +40,29 @@ export function TimelineRuler({ }); const tickCount = Math.ceil(effectiveDuration / tickIntervalSeconds) + 1; + const { scrollLeft, viewportWidth } = useScrollPosition({ + scrollRef: tracksScrollRef, + }); + + const bufferPx = 200; + const visibleStartTime = Math.max( + 0, + (scrollLeft - bufferPx) / pixelsPerSecond, + ); + const visibleEndTime = + (scrollLeft + viewportWidth + bufferPx) / pixelsPerSecond; + + const startTickIndex = Math.max( + 0, + Math.floor(visibleStartTime / tickIntervalSeconds), + ); + const endTickIndex = Math.min( + tickCount - 1, + Math.ceil(visibleEndTime / tickIntervalSeconds), + ); + const timelineTicks: Array = []; - for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) { + for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) { const time = tickIndex * tickIntervalSeconds; if (time > effectiveDuration) break; diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts index 22cba52d..ad9f7792 100644 --- a/apps/web/src/core/managers/timeline-manager.ts +++ b/apps/web/src/core/managers/timeline-manager.ts @@ -24,6 +24,7 @@ import { UpdateElementStartTimeCommand, MoveElementCommand, } from "@/lib/commands/timeline"; +import { BatchCommand } from "@/lib/commands"; import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element"; export class TimelineManager { @@ -198,17 +199,27 @@ export class TimelineManager { this.editor.command.execute({ command }); } - updateElement({ - trackId, - elementId, + updateElements({ updates, + pushHistory = true, }: { - trackId: string; - elementId: string; - updates: Partial>; + updates: Array<{ + trackId: string; + elementId: string; + updates: Partial>; + }>; + pushHistory?: boolean; }): void { - const command = new UpdateElementCommand(trackId, elementId, updates); - this.editor.command.execute({ command }); + const commands = updates.map( + ({ trackId, elementId, updates: elementUpdates }) => + new UpdateElementCommand(trackId, elementId, elementUpdates), + ); + const command = commands.length === 1 ? commands[0] : new BatchCommand(commands); + if (pushHistory) { + this.editor.command.execute({ command }); + } else { + command.execute(); + } } duplicateElements({ diff --git a/apps/web/src/hooks/timeline/use-scroll-position.ts b/apps/web/src/hooks/timeline/use-scroll-position.ts new file mode 100644 index 00000000..ad2c6374 --- /dev/null +++ b/apps/web/src/hooks/timeline/use-scroll-position.ts @@ -0,0 +1,52 @@ +import { useEffect, useState, useRef } from "react"; + +interface UseScrollPositionReturn { + scrollLeft: number; + viewportWidth: number; +} + +export function useScrollPosition({ + scrollRef, +}: { + scrollRef: React.RefObject; +}): UseScrollPositionReturn { + const [scrollLeft, setScrollLeft] = useState(0); + const [viewportWidth, setViewportWidth] = useState(0); + const rafIdRef = useRef(null); + + useEffect(() => { + const scrollElement = scrollRef.current; + if (!scrollElement) return; + + const updatePosition = () => { + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + + rafIdRef.current = requestAnimationFrame(() => { + setScrollLeft(scrollElement.scrollLeft); + setViewportWidth(scrollElement.clientWidth); + rafIdRef.current = null; + }); + }; + + const resizeObserver = new ResizeObserver(() => { + updatePosition(); + }); + + updatePosition(); + + scrollElement.addEventListener("scroll", updatePosition, { passive: true }); + resizeObserver.observe(scrollElement); + + return () => { + scrollElement.removeEventListener("scroll", updatePosition); + resizeObserver.disconnect(); + if (rafIdRef.current !== null) { + cancelAnimationFrame(rafIdRef.current); + } + }; + }, [scrollRef]); + + return { scrollLeft, viewportWidth }; +} diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts new file mode 100644 index 00000000..6aeb0c9b --- /dev/null +++ b/apps/web/src/hooks/use-preview-interaction.ts @@ -0,0 +1,177 @@ +import { useCallback, useRef, useState } from "react"; +import { useEditor } from "@/hooks/use-editor"; +import { useSyncExternalStore } from "react"; +import type { Transform, TimelineTrack } from "@/types/timeline"; + +interface DragState { + startX: number; + startY: number; + tracksSnapshot: TimelineTrack[]; + elements: Array<{ + trackId: string; + elementId: string; + initialTransform: Transform; + }>; +} + +export function usePreviewInteraction({ + canvasRef, +}: { + canvasRef: React.RefObject; +}) { + const editor = useEditor(); + const [isDragging, setIsDragging] = useState(false); + const dragStateRef = useRef(null); + + const selectedElements = useSyncExternalStore( + (listener) => editor.selection.subscribe(listener), + () => editor.selection.getSelectedElements(), + ); + + const getCanvasCoordinates = useCallback( + ({ clientX, clientY }: { clientX: number; clientY: number }) => { + if (!canvasRef.current) return { x: 0, y: 0 }; + + const rect = canvasRef.current.getBoundingClientRect(); + const logicalWidth = canvasRef.current.width; + const logicalHeight = canvasRef.current.height; + const scaleX = logicalWidth / rect.width; + const scaleY = logicalHeight / rect.height; + + const canvasX = (clientX - rect.left) * scaleX; + const canvasY = (clientY - rect.top) * scaleY; + + return { x: canvasX, y: canvasY }; + }, + [canvasRef], + ); + + const handlePointerDown = useCallback( + (event: React.PointerEvent) => { + if (selectedElements.length === 0) return; + + const elementsWithTracks = editor.timeline.getElementsWithTracks({ + elements: selectedElements, + }); + + const draggableElements = elementsWithTracks.filter( + ({ element }) => + element.type === "video" || + element.type === "image" || + element.type === "text" || + element.type === "sticker", + ); + + if (draggableElements.length === 0) return; + + const startPos = getCanvasCoordinates({ + clientX: event.clientX, + clientY: event.clientY, + }); + + dragStateRef.current = { + startX: startPos.x, + startY: startPos.y, + tracksSnapshot: editor.timeline.getTracks(), + elements: draggableElements.map(({ track, element }) => ({ + trackId: track.id, + elementId: element.id, + initialTransform: (element as { transform: Transform }).transform, + })), + }; + + setIsDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + }, + [selectedElements, editor, getCanvasCoordinates], + ); + + const handlePointerMove = useCallback( + (event: React.PointerEvent) => { + if (!dragStateRef.current || !isDragging) return; + + const currentPos = getCanvasCoordinates({ + clientX: event.clientX, + clientY: event.clientY, + }); + + const deltaX = currentPos.x - dragStateRef.current.startX; + const deltaY = currentPos.y - dragStateRef.current.startY; + + for (const { trackId, elementId, initialTransform } of dragStateRef + .current.elements) { + const newPosition = { + x: initialTransform.position.x + deltaX, + y: initialTransform.position.y + deltaY, + }; + + editor.timeline.updateElements({ + updates: [ + { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + position: newPosition, + }, + }, + }, + ], + pushHistory: false, + }); + } + }, + [isDragging, getCanvasCoordinates, editor], + ); + + const handlePointerUp = useCallback( + (event: React.PointerEvent) => { + if (!dragStateRef.current || !isDragging) return; + + const currentPos = getCanvasCoordinates({ + clientX: event.clientX, + clientY: event.clientY, + }); + + const deltaX = currentPos.x - dragStateRef.current.startX; + const deltaY = currentPos.y - dragStateRef.current.startY; + + // revert to pre-drag state so the command captures the correct undo snapshot + editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot); + + const updates = dragStateRef.current.elements.map( + ({ trackId, elementId, initialTransform }) => { + const newPosition = { + x: initialTransform.position.x + deltaX, + y: initialTransform.position.y + deltaY, + }; + + return { + trackId, + elementId, + updates: { + transform: { + ...initialTransform, + position: newPosition, + }, + }, + }; + }, + ); + + editor.timeline.updateElements({ updates }); + + dragStateRef.current = null; + setIsDragging(false); + event.currentTarget.releasePointerCapture(event.pointerId); + }, + [isDragging, getCanvasCoordinates, editor], + ); + + return { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + }; +} diff --git a/apps/web/src/lib/commands/batch-command.ts b/apps/web/src/lib/commands/batch-command.ts new file mode 100644 index 00000000..05b51d38 --- /dev/null +++ b/apps/web/src/lib/commands/batch-command.ts @@ -0,0 +1,25 @@ +import { Command } from "./base-command"; + +export class BatchCommand extends Command { + constructor(private commands: Command[]) { + super(); + } + + execute(): void { + for (const command of this.commands) { + command.execute(); + } + } + + undo(): void { + for (const command of [...this.commands].reverse()) { + command.undo(); + } + } + + redo(): void { + for (const command of this.commands) { + command.execute(); + } + } +} diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts index 6700087b..03de07c4 100644 --- a/apps/web/src/lib/commands/index.ts +++ b/apps/web/src/lib/commands/index.ts @@ -1,4 +1,5 @@ export { Command } from "./base-command"; +export { BatchCommand } from "./batch-command"; export * from "./timeline"; export * from "./media"; diff --git a/apps/web/src/services/renderer/nodes/image-node.ts b/apps/web/src/services/renderer/nodes/image-node.ts index 5405b60b..49e84ebe 100644 --- a/apps/web/src/services/renderer/nodes/image-node.ts +++ b/apps/web/src/services/renderer/nodes/image-node.ts @@ -1,22 +1,11 @@ import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode } from "./base-node"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; -const IMAGE_EPSILON = 1 / 1000; - -export interface ImageNodeParams { +export interface ImageNodeParams extends VisualNodeParams { url: string; - duration: number; - timeOffset: number; - trimStart: number; - trimEnd: number; - x?: number; - y?: number; - width?: number; - height?: number; - opacity?: number; } -export class ImageNode extends BaseNode { +export class ImageNode extends VisualNode { private image?: HTMLImageElement; private readyPromise: Promise; @@ -36,18 +25,6 @@ export class ImageNode extends BaseNode { }); } - private getImageTime(time: number) { - return time - this.params.timeOffset + this.params.trimStart; - } - - private isInRange(time: number) { - const imageTime = this.getImageTime(time); - return ( - imageTime >= this.params.trimStart - IMAGE_EPSILON && - imageTime < this.params.trimStart + this.params.duration - ); - } - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); @@ -61,40 +38,14 @@ export class ImageNode extends BaseNode { return; } - renderer.context.save(); + const mediaW = this.image.naturalWidth || renderer.width; + const mediaH = this.image.naturalHeight || renderer.height; - if (this.params.opacity !== undefined) { - renderer.context.globalAlpha = this.params.opacity; - } - - if ( - this.params.x !== undefined && - this.params.y !== undefined && - this.params.width !== undefined && - this.params.height !== undefined - ) { - renderer.context.drawImage( - this.image, - this.params.x, - this.params.y, - this.params.width, - this.params.height, - ); - } else { - const mediaW = this.image.naturalWidth || renderer.width; - const mediaH = this.image.naturalHeight || renderer.height; - const containScale = Math.min( - renderer.width / mediaW, - renderer.height / mediaH, - ); - const drawW = mediaW * containScale; - const drawH = mediaH * containScale; - const drawX = (renderer.width - drawW) / 2; - const drawY = (renderer.height - drawH) / 2; - - renderer.context.drawImage(this.image, drawX, drawY, drawW, drawH); - } - - renderer.context.restore(); + this.renderVisual({ + renderer, + source: this.image, + sourceWidth: mediaW, + sourceHeight: mediaH, + }); } } diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts index 5af13a6f..9842320e 100644 --- a/apps/web/src/services/renderer/nodes/sticker-node.ts +++ b/apps/web/src/services/renderer/nodes/sticker-node.ts @@ -1,21 +1,12 @@ import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode, type BaseNodeParams } from "./base-node"; -import type { Transform } from "@/types/timeline"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; -const STICKER_EPSILON = 1 / 1000; - -export type StickerNodeParams = BaseNodeParams & { +export interface StickerNodeParams extends VisualNodeParams { iconName: string; - duration: number; - timeOffset: number; - trimStart: number; - trimEnd: number; - transform: Transform; - opacity: number; color?: string; -}; +} -export class StickerNode extends BaseNode { +export class StickerNode extends VisualNode { private image?: HTMLImageElement; private readyPromise: Promise; @@ -40,18 +31,6 @@ export class StickerNode extends BaseNode { }); } - private getStickerTime(time: number) { - return time - this.params.timeOffset + this.params.trimStart; - } - - private isInRange(time: number) { - const stickerTime = this.getStickerTime(time); - return ( - stickerTime >= this.params.trimStart - STICKER_EPSILON && - stickerTime < this.params.trimStart + this.params.duration - ); - } - async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); @@ -65,23 +44,11 @@ export class StickerNode extends BaseNode { return; } - const { transform, opacity } = this.params; - const size = 200 * transform.scale; - const x = renderer.width / 2 + transform.position.x - size / 2; - const y = renderer.height / 2 + transform.position.y - size / 2; - - renderer.context.save(); - renderer.context.globalAlpha = opacity; - - if (transform.rotate !== 0) { - const centerX = x + size / 2; - const centerY = y + size / 2; - renderer.context.translate(centerX, centerY); - renderer.context.rotate((transform.rotate * Math.PI) / 180); - renderer.context.translate(-centerX, -centerY); - } - - renderer.context.drawImage(this.image, x, y, size, size); - renderer.context.restore(); + this.renderVisual({ + renderer, + source: this.image, + sourceWidth: 200, + sourceHeight: 200, + }); } } diff --git a/apps/web/src/services/renderer/nodes/video-node.ts b/apps/web/src/services/renderer/nodes/video-node.ts index 841eb459..8e2ddccd 100644 --- a/apps/web/src/services/renderer/nodes/video-node.ts +++ b/apps/web/src/services/renderer/nodes/video-node.ts @@ -1,37 +1,14 @@ import type { CanvasRenderer } from "../canvas-renderer"; -import { BaseNode } from "./base-node"; +import { VisualNode, type VisualNodeParams } from "./visual-node"; import { videoCache } from "@/services/video-cache/service"; -const VIDEO_EPSILON = 1 / 1000; - -export interface VideoNodeParams { +export interface VideoNodeParams extends VisualNodeParams { url: string; file: File; mediaId: string; - duration: number; - timeOffset: number; - trimStart: number; - trimEnd: number; - x?: number; - y?: number; - width?: number; - height?: number; - opacity?: number; } -export class VideoNode extends BaseNode { - private getVideoTime(time: number) { - return time - this.params.timeOffset + this.params.trimStart; - } - - private isInRange(time: number) { - const videoTime = this.getVideoTime(time); - return ( - videoTime >= this.params.trimStart - VIDEO_EPSILON && - videoTime < this.params.trimStart + this.params.duration - ); - } - +export class VideoNode extends VisualNode { async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) { await super.render({ renderer, time }); @@ -39,7 +16,7 @@ export class VideoNode extends BaseNode { return; } - const videoTime = this.getVideoTime(time); + const videoTime = this.getLocalTime(time); const frame = await videoCache.getFrameAt({ mediaId: this.params.mediaId, file: this.params.file, @@ -47,36 +24,12 @@ export class VideoNode extends BaseNode { }); if (frame) { - renderer.context.save(); - - if (this.params.opacity !== undefined) { - renderer.context.globalAlpha = this.params.opacity; - } - - if ( - this.params.x !== undefined && - this.params.y !== undefined && - this.params.width !== undefined && - this.params.height !== undefined - ) { - renderer.context.drawImage( - frame.canvas, - this.params.x, - this.params.y, - this.params.width, - this.params.height, - ); - } else { - renderer.context.drawImage( - frame.canvas, - 0, - 0, - renderer.width, - renderer.height, - ); - } - - renderer.context.restore(); + this.renderVisual({ + renderer, + source: frame.canvas, + sourceWidth: frame.canvas.width, + sourceHeight: frame.canvas.height, + }); } } } diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts new file mode 100644 index 00000000..9c6c88ca --- /dev/null +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -0,0 +1,67 @@ +import type { CanvasRenderer } from "../canvas-renderer"; +import { BaseNode } from "./base-node"; +import type { Transform } from "@/types/timeline"; + +const VISUAL_EPSILON = 1 / 1000; + +export interface VisualNodeParams { + duration: number; + timeOffset: number; + trimStart: number; + trimEnd: number; + transform: Transform; + opacity: number; +} + +export abstract class VisualNode< + Params extends VisualNodeParams = VisualNodeParams, +> extends BaseNode { + protected getLocalTime(time: number): number { + return time - this.params.timeOffset + this.params.trimStart; + } + + protected isInRange(time: number): boolean { + const localTime = this.getLocalTime(time); + return ( + localTime >= this.params.trimStart - VISUAL_EPSILON && + localTime < this.params.trimStart + this.params.duration + ); + } + + protected renderVisual({ + renderer, + source, + sourceWidth, + sourceHeight, + }: { + renderer: CanvasRenderer; + source: CanvasImageSource; + sourceWidth: number; + sourceHeight: number; + }): void { + renderer.context.save(); + + const { transform, opacity } = this.params; + const containScale = Math.min( + renderer.width / sourceWidth, + renderer.height / sourceHeight, + ); + const scaledWidth = sourceWidth * containScale * transform.scale; + const scaledHeight = sourceHeight * containScale * transform.scale; + const x = renderer.width / 2 + transform.position.x - scaledWidth / 2; + const y = renderer.height / 2 + transform.position.y - scaledHeight / 2; + + renderer.context.globalAlpha = opacity; + + if (transform.rotate !== 0) { + const centerX = x + scaledWidth / 2; + const centerY = y + scaledHeight / 2; + renderer.context.translate(centerX, centerY); + renderer.context.rotate((transform.rotate * Math.PI) / 180); + renderer.context.translate(-centerX, -centerY); + } + + renderer.context.drawImage(source, x, y, scaledWidth, scaledHeight); + renderer.context.restore(); + } +} diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts index 5d33524e..d1b8217e 100644 --- a/apps/web/src/services/renderer/scene-builder.ts +++ b/apps/web/src/services/renderer/scene-builder.ts @@ -64,6 +64,8 @@ export function buildScene(params: BuildSceneParams) { timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, + transform: element.transform, + opacity: element.opacity, }), ); } @@ -75,6 +77,8 @@ export function buildScene(params: BuildSceneParams) { timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, + transform: element.transform, + opacity: element.opacity, }), ); }