add transform + few fixes

feat: transform
feat: batch command
fix: slider undo
fix: timeline zoom perf
refactor: make updateElement support multiple
This commit is contained in:
Maze Winther 2026-02-08 22:21:17 +01:00
parent 484ce47867
commit e50a817b97
15 changed files with 631 additions and 262 deletions

View File

@ -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<HTMLCanvasElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(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 (
<canvas
ref={ref}
width={width}
height={height}
className="block max-h-full max-w-full border"
style={{
background:
activeProject.settings.background.type === "blur"
? "transparent"
: activeProject?.settings.background.color,
}}
/>
<div className="relative">
<canvas
ref={canvasRef}
width={width}
height={height}
className="block max-h-full max-w-full border"
style={{
background:
activeProject.settings.background.type === "blur"
? "transparent"
: activeProject?.settings.background.color,
}}
/>
<PreviewInteractionOverlay canvasRef={canvasRef} />
</div>
);
}

View File

@ -0,0 +1,20 @@
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
import { cn } from "@/utils/ui";
export function PreviewInteractionOverlay({
canvasRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
}) {
const { onPointerDown, onPointerMove, onPointerUp } =
usePreviewInteraction({ canvasRef });
return (
<div
className={cn("absolute inset-0 pointer-events-auto")}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
/>
);
}

View File

@ -50,6 +50,8 @@ export function TextProperties({
);
const lastSelectedColor = useRef(DEFAULT_COLOR);
const initialFontSizeRef = useRef<number | null>(null);
const initialOpacityRef = useRef<number | null>(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({
<FontPicker
defaultValue={element.fontFamily}
onValueChange={(value: FontFamily) =>
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"
/>
<Input
@ -310,10 +385,14 @@ export function TextProperties({
string: (element.color || "FFFFFF").replace("#", ""),
})}
onChange={(color) => {
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"
/>
<Input

View File

@ -357,6 +357,7 @@ export function Timeline() {
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
rulerRef={rulerRef}
tracksScrollRef={tracksScrollRef}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}

View File

@ -3,12 +3,14 @@ import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { DEFAULT_FPS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import { getRulerConfig, shouldShowLabel } from "@/lib/timeline/ruler-utils";
import { useScrollPosition } from "@/hooks/timeline/use-scroll-position";
import { TimelineTick } from "./timeline-tick";
interface TimelineRulerProps {
zoomLevel: number;
dynamicTimelineWidth: number;
rulerRef: React.Ref<HTMLDivElement>;
tracksScrollRef: React.RefObject<HTMLElement | null>;
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<JSX.Element> = [];
for (let tickIndex = 0; tickIndex < tickCount; tickIndex += 1) {
for (let tickIndex = startTickIndex; tickIndex <= endTickIndex; tickIndex += 1) {
const time = tickIndex * tickIntervalSeconds;
if (time > effectiveDuration) break;

View File

@ -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<Record<string, unknown>>;
updates: Array<{
trackId: string;
elementId: string;
updates: Partial<Record<string, unknown>>;
}>;
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({

View File

@ -0,0 +1,52 @@
import { useEffect, useState, useRef } from "react";
interface UseScrollPositionReturn {
scrollLeft: number;
viewportWidth: number;
}
export function useScrollPosition({
scrollRef,
}: {
scrollRef: React.RefObject<HTMLElement | null>;
}): UseScrollPositionReturn {
const [scrollLeft, setScrollLeft] = useState(0);
const [viewportWidth, setViewportWidth] = useState(0);
const rafIdRef = useRef<number | null>(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 };
}

View File

@ -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<HTMLCanvasElement | null>;
}) {
const editor = useEditor();
const [isDragging, setIsDragging] = useState(false);
const dragStateRef = useRef<DragState | null>(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,
};
}

View File

@ -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();
}
}
}

View File

@ -1,4 +1,5 @@
export { Command } from "./base-command";
export { BatchCommand } from "./batch-command";
export * from "./timeline";
export * from "./media";

View File

@ -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<ImageNodeParams> {
export class ImageNode extends VisualNode<ImageNodeParams> {
private image?: HTMLImageElement;
private readyPromise: Promise<void>;
@ -36,18 +25,6 @@ export class ImageNode extends BaseNode<ImageNodeParams> {
});
}
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<ImageNodeParams> {
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,
});
}
}

View File

@ -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<StickerNodeParams> {
export class StickerNode extends VisualNode<StickerNodeParams> {
private image?: HTMLImageElement;
private readyPromise: Promise<void>;
@ -40,18 +31,6 @@ export class StickerNode extends BaseNode<StickerNodeParams> {
});
}
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<StickerNodeParams> {
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,
});
}
}

View File

@ -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<VideoNodeParams> {
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<VideoNodeParams> {
async render({ renderer, time }: { renderer: CanvasRenderer; time: number }) {
await super.render({ renderer, time });
@ -39,7 +16,7 @@ export class VideoNode extends BaseNode<VideoNodeParams> {
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<VideoNodeParams> {
});
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,
});
}
}
}

View File

@ -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<Params> {
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();
}
}

View File

@ -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,
}),
);
}