diff options
Diffstat (limited to 'apps/web/src/preview')
| -rw-r--r-- | apps/web/src/preview/components/index.tsx | 100 | ||||
| -rw-r--r-- | apps/web/src/preview/components/text-edit-overlay.tsx | 2 | ||||
| -rw-r--r-- | apps/web/src/preview/components/toolbar.tsx | 11 | ||||
| -rw-r--r-- | apps/web/src/preview/controllers/transform-handle-controller.ts | 2 | ||||
| -rw-r--r-- | apps/web/src/preview/element-bounds.ts | 616 |
5 files changed, 372 insertions, 359 deletions
diff --git a/apps/web/src/preview/components/index.tsx b/apps/web/src/preview/components/index.tsx index d3cb2a49..e8f5c572 100644 --- a/apps/web/src/preview/components/index.tsx +++ b/apps/web/src/preview/components/index.tsx @@ -132,7 +132,7 @@ function PreviewCanvas({ isVisible: boolean; }) => void; }) { - const canvasRef = useRef<HTMLCanvasElement>(null); + const canvasMountRef = useRef<HTMLDivElement>(null); const viewportRef = useRef<HTMLDivElement>(null); const lastFrameRef = useRef(-1); const lastSceneRef = useRef<RootNode | null>(null); @@ -158,35 +158,51 @@ function PreviewCanvas({ }); }, [nativeWidth, nativeHeight, activeProject.settings.fps]); + // Mount the compositor's output canvas directly into the preview. wgpu + // renders straight into this element, so there is no intermediate copy — + // the container div owns positioning/styling, the canvas itself fills it. + useEffect(() => { + const mount = canvasMountRef.current; + if (!mount) return; + const outputCanvas = renderer.getOutputCanvas(); + outputCanvas.style.display = "block"; + outputCanvas.style.width = "100%"; + outputCanvas.style.height = "100%"; + mount.appendChild(outputCanvas); + return () => { + if (outputCanvas.parentElement === mount) { + mount.removeChild(outputCanvas); + } + }; + }, [renderer]); + const render = useCallback(() => { - if (canvasRef.current && renderTree && !renderingRef.current) { - const renderTime = Math.min( - editor.playback.getCurrentTime(), - editor.timeline.getLastFrameTime(), - ); - const ticksPerFrame = Math.round( - (TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator, - ); - const frame = Math.floor(renderTime / ticksPerFrame); + if (!renderTree || renderingRef.current) return; - if ( - frame !== lastFrameRef.current || - renderTree !== lastSceneRef.current - ) { - renderingRef.current = true; - lastSceneRef.current = renderTree; - lastFrameRef.current = frame; - renderer - .renderToCanvas({ - node: renderTree, - time: renderTime, - targetCanvas: canvasRef.current, - }) - .then(() => { - renderingRef.current = false; - }); - } + const renderTime = Math.min( + editor.playback.getCurrentTime(), + editor.timeline.getLastFrameTime(), + ); + const ticksPerFrame = Math.round( + (TICKS_PER_SECOND * renderer.fps.denominator) / renderer.fps.numerator, + ); + const frame = Math.floor(renderTime / ticksPerFrame); + + if ( + frame === lastFrameRef.current && + renderTree === lastSceneRef.current + ) { + return; } + + renderingRef.current = true; + lastSceneRef.current = renderTree; + lastFrameRef.current = frame; + renderer + .render({ node: renderTree, time: renderTime }) + .then(() => { + renderingRef.current = false; + }); }, [renderer, renderTree, editor.playback, editor.timeline]); useRafLoop(render); @@ -286,22 +302,20 @@ function PreviewCanvas({ ref={viewportRef} className="relative flex size-full min-h-0 min-w-0 items-center justify-center overflow-hidden" > - <canvas - ref={canvasRef} - width={nativeWidth} - height={nativeHeight} - className="absolute block border" - style={{ - left: viewport.sceneLeft, - top: viewport.sceneTop, - width: viewport.sceneWidth, - height: viewport.sceneHeight, - background: - activeProject.settings.background.type === "blur" - ? "transparent" - : activeProject?.settings.background.color, - }} - /> + <div + ref={canvasMountRef} + className="absolute block border" + style={{ + left: viewport.sceneLeft, + top: viewport.sceneTop, + width: viewport.sceneWidth, + height: viewport.sceneHeight, + background: + activeProject.settings.background.type === "blur" + ? "transparent" + : activeProject?.settings.background.color, + }} + /> <PreviewOverlayLayer instances={overlayInstances} plane="under-interaction" diff --git a/apps/web/src/preview/components/text-edit-overlay.tsx b/apps/web/src/preview/components/text-edit-overlay.tsx index bc27c724..1d599b3b 100644 --- a/apps/web/src/preview/components/text-edit-overlay.tsx +++ b/apps/web/src/preview/components/text-edit-overlay.tsx @@ -7,8 +7,8 @@ import type { TextElement } from "@/timeline"; import { DEFAULTS } from "@/timeline/defaults"; import { getElementLocalTime, - resolveTransformAtTime, } from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import { resolveTextLayout } from "@/text/primitives"; export function TextEditOverlay({ diff --git a/apps/web/src/preview/components/toolbar.tsx b/apps/web/src/preview/components/toolbar.tsx index 857b43e2..1a5ce1bc 100644 --- a/apps/web/src/preview/components/toolbar.tsx +++ b/apps/web/src/preview/components/toolbar.tsx @@ -8,12 +8,10 @@ import { EditableTimecode } from "@/components/editable-timecode"; import { Button } from "@/components/ui/button"; import { FullScreenIcon, - GridTableIcon, PauseIcon, PlayIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; -import { getGuideById } from "@/guides"; import { Separator } from "@/components/ui/separator"; import { Select, @@ -33,9 +31,6 @@ export function PreviewToolbar({ }: { onToggleFullscreen: () => void; }) { - const activeGuide = usePreviewStore((state) => state.activeGuide); - const activeGuideDefinition = getGuideById(activeGuide); - return ( <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5"> <TimecodeDisplay /> @@ -93,7 +88,11 @@ function TimecodeDisplay() { /> <span className="text-muted-foreground px-2 font-mono text-xs">/</span> <span className="text-muted-foreground font-mono text-xs"> - {formatTimecode({ time: totalDuration, format: "HH:MM:SS:FF", rate: fps })} + {formatTimecode({ + time: totalDuration, + format: "HH:MM:SS:FF", + rate: fps, + })} </span> </div> ); diff --git a/apps/web/src/preview/controllers/transform-handle-controller.ts b/apps/web/src/preview/controllers/transform-handle-controller.ts index e9d251e3..67f677f6 100644 --- a/apps/web/src/preview/controllers/transform-handle-controller.ts +++ b/apps/web/src/preview/controllers/transform-handle-controller.ts @@ -20,11 +20,11 @@ import { isVisualElement } from "@/timeline/element-utils"; import { getElementLocalTime, hasKeyframesForPath, - resolveTransformAtTime, setChannel, } from "@/animation"; import type { ElementAnimations } from "@/animation/types"; import type { Transform } from "@/rendering"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; import type { ElementRef, SceneTracks, diff --git a/apps/web/src/preview/element-bounds.ts b/apps/web/src/preview/element-bounds.ts index ac556181..0afc6e4e 100644 --- a/apps/web/src/preview/element-bounds.ts +++ b/apps/web/src/preview/element-bounds.ts @@ -1,308 +1,308 @@ -import type { SceneTracks, TimelineElement } from "@/timeline";
-import type { MediaAsset } from "@/media/types";
-import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size";
-import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics";
-import { measureTextElement } from "@/text/measure-element";
-import {
- getElementLocalTime,
- resolveTransformAtTime,
-} from "@/animation";
-
-export interface ElementBounds {
- cx: number;
- cy: number;
- width: number;
- height: number;
- rotation: number;
-}
-
-export interface ElementWithBounds {
- trackId: string;
- elementId: string;
- element: TimelineElement;
- bounds: ElementBounds;
-}
-
-function getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth,
- sourceHeight,
- transform,
-}: {
- canvasWidth: number;
- canvasHeight: number;
- sourceWidth: number;
- sourceHeight: number;
- transform: {
- scaleX: number;
- scaleY: number;
- position: { x: number; y: number };
- rotate: number;
- };
-}): ElementBounds {
- const containScale = Math.min(
- canvasWidth / sourceWidth,
- canvasHeight / sourceHeight,
- );
- const scaledWidth = sourceWidth * containScale * transform.scaleX;
- const scaledHeight = sourceHeight * containScale * transform.scaleY;
- const cx = canvasWidth / 2 + transform.position.x;
- const cy = canvasHeight / 2 + transform.position.y;
-
- return {
- cx,
- cy,
- width: scaledWidth,
- height: scaledHeight,
- rotation: transform.rotate,
- };
-}
-
-function getTransformedRectBounds({
- canvasWidth,
- canvasHeight,
- rect,
- transform,
-}: {
- canvasWidth: number;
- canvasHeight: number;
- rect: { left: number; top: number; width: number; height: number };
- transform: {
- scaleX: number;
- scaleY: number;
- position: { x: number; y: number };
- rotate: number;
- };
-}): ElementBounds {
- const localCenterX = rect.left + rect.width / 2;
- const localCenterY = rect.top + rect.height / 2;
- const scaledCenterX = localCenterX * transform.scaleX;
- const scaledCenterY = localCenterY * transform.scaleY;
- const rotationRad = (transform.rotate * Math.PI) / 180;
- const cos = Math.cos(rotationRad);
- const sin = Math.sin(rotationRad);
- return {
- cx:
- canvasWidth / 2 +
- transform.position.x +
- scaledCenterX * cos -
- scaledCenterY * sin,
- cy:
- canvasHeight / 2 +
- transform.position.y +
- scaledCenterX * sin +
- scaledCenterY * cos,
- width: rect.width * transform.scaleX,
- height: rect.height * transform.scaleY,
- rotation: transform.rotate,
- };
-}
-
-/**
- * Bounds policy: bounds reflect base content geometry (text glyphs + background,
- * sticker/image/video content area) and base transform. Post-effect spill (blur,
- * glow) and mask-clipped regions are intentionally excluded — handles manipulate
- * the canonical element geometry, not visual effect output.
- */
-function getElementBounds({
- element,
- canvasSize,
- mediaAsset,
- localTime,
-}: {
- element: TimelineElement;
- canvasSize: { width: number; height: number };
- mediaAsset?: MediaAsset | null;
- localTime: number;
-}): ElementBounds | null {
- if (element.type === "audio" || element.type === "effect") return null;
- if ("hidden" in element && element.hidden) return null;
-
- const { width: canvasWidth, height: canvasHeight } = canvasSize;
-
- if (element.type === "video" || element.type === "image") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- const sourceWidth = mediaAsset?.width ?? canvasWidth;
- const sourceHeight = mediaAsset?.height ?? canvasHeight;
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth,
- sourceHeight,
- transform,
- });
- }
-
- if (element.type === "sticker") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK,
- sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK,
- transform,
- });
- }
-
- if (element.type === "graphic") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
- return getVisualElementBounds({
- canvasWidth,
- canvasHeight,
- sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE,
- sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE,
- transform,
- });
- }
-
- if (element.type === "text") {
- const transform = resolveTransformAtTime({
- baseTransform: element.transform,
- animations: element.animations,
- localTime,
- });
-
- const canvas = document.createElement("canvas");
- const ctx = canvas.getContext("2d");
- if (!ctx) return null;
-
- const measured = measureTextElement({
- element,
- canvasHeight,
- localTime,
- ctx,
- });
-
- return getTransformedRectBounds({
- canvasWidth,
- canvasHeight,
- rect: measured.visualRect,
- transform,
- });
- }
-
- return null;
-}
-
-export const ROTATION_HANDLE_OFFSET = 24;
-
-export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
-export type Edge = "right" | "left" | "bottom";
-
-export function getCornerPosition({
- bounds,
- corner,
-}: {
- bounds: ElementBounds;
- corner: Corner;
-}): { x: number; y: number } {
- const halfW = bounds.width / 2;
- const halfH = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localX =
- corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
- const localY =
- corner === "top-left" || corner === "top-right" ? -halfH : halfH;
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getEdgeHandlePosition({
- bounds,
- edge,
-}: {
- bounds: ElementBounds;
- edge: Edge;
-}): { x: number; y: number } {
- const halfWidth = bounds.width / 2;
- const halfHeight = bounds.height / 2;
- const angleRad = (bounds.rotation * Math.PI) / 180;
- const cos = Math.cos(angleRad);
- const sin = Math.sin(angleRad);
- const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0;
- const localY = edge === "bottom" ? halfHeight : 0;
- return {
- x: bounds.cx + (localX * cos - localY * sin),
- y: bounds.cy + (localX * sin + localY * cos),
- };
-}
-
-export function getVisibleElementsWithBounds({
- tracks,
- currentTime,
- canvasSize,
- mediaAssets,
-}: {
- tracks: SceneTracks;
- currentTime: number;
- canvasSize: { width: number; height: number };
- mediaAssets: MediaAsset[];
-}): ElementWithBounds[] {
- const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
- const orderedTracks = [
- ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)),
- ...(!tracks.main.hidden ? [tracks.main] : []),
- ].reverse();
-
- const result: ElementWithBounds[] = [];
-
- for (const track of orderedTracks) {
- const elements = track.elements
- .filter((element) => !("hidden" in element && element.hidden))
- .filter(
- (element) =>
- currentTime >= element.startTime &&
- currentTime < element.startTime + element.duration,
- )
- .slice()
- .sort((a, b) => {
- if (a.startTime !== b.startTime) return a.startTime - b.startTime;
- return a.id.localeCompare(b.id);
- });
-
- for (const element of elements) {
- const localTime = getElementLocalTime({
- timelineTime: currentTime,
- elementStartTime: element.startTime,
- elementDuration: element.duration,
- });
- const mediaAsset =
- element.type === "video" || element.type === "image"
- ? mediaMap.get(element.mediaId)
- : undefined;
- const bounds = getElementBounds({
- element,
- canvasSize,
- mediaAsset,
- localTime,
- });
- if (bounds) {
- result.push({
- trackId: track.id,
- elementId: element.id,
- element,
- bounds,
- });
- }
- }
- }
-
- return result;
-}
+import type { SceneTracks, TimelineElement } from "@/timeline"; +import type { MediaAsset } from "@/media/types"; +import { STICKER_INTRINSIC_SIZE_FALLBACK } from "@/stickers/intrinsic-size"; +import { DEFAULT_GRAPHIC_SOURCE_SIZE } from "@/graphics"; +import { measureTextElement } from "@/text/measure-element"; +import { + getElementLocalTime, +} from "@/animation"; +import { resolveTransformAtTime } from "@/rendering/animation-values"; + +export interface ElementBounds { + cx: number; + cy: number; + width: number; + height: number; + rotation: number; +} + +export interface ElementWithBounds { + trackId: string; + elementId: string; + element: TimelineElement; + bounds: ElementBounds; +} + +function getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + sourceWidth: number; + sourceHeight: number; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const containScale = Math.min( + canvasWidth / sourceWidth, + canvasHeight / sourceHeight, + ); + const scaledWidth = sourceWidth * containScale * transform.scaleX; + const scaledHeight = sourceHeight * containScale * transform.scaleY; + const cx = canvasWidth / 2 + transform.position.x; + const cy = canvasHeight / 2 + transform.position.y; + + return { + cx, + cy, + width: scaledWidth, + height: scaledHeight, + rotation: transform.rotate, + }; +} + +function getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect, + transform, +}: { + canvasWidth: number; + canvasHeight: number; + rect: { left: number; top: number; width: number; height: number }; + transform: { + scaleX: number; + scaleY: number; + position: { x: number; y: number }; + rotate: number; + }; +}): ElementBounds { + const localCenterX = rect.left + rect.width / 2; + const localCenterY = rect.top + rect.height / 2; + const scaledCenterX = localCenterX * transform.scaleX; + const scaledCenterY = localCenterY * transform.scaleY; + const rotationRad = (transform.rotate * Math.PI) / 180; + const cos = Math.cos(rotationRad); + const sin = Math.sin(rotationRad); + return { + cx: + canvasWidth / 2 + + transform.position.x + + scaledCenterX * cos - + scaledCenterY * sin, + cy: + canvasHeight / 2 + + transform.position.y + + scaledCenterX * sin + + scaledCenterY * cos, + width: rect.width * transform.scaleX, + height: rect.height * transform.scaleY, + rotation: transform.rotate, + }; +} + +/** + * Bounds policy: bounds reflect base content geometry (text glyphs + background, + * sticker/image/video content area) and base transform. Post-effect spill (blur, + * glow) and mask-clipped regions are intentionally excluded — handles manipulate + * the canonical element geometry, not visual effect output. + */ +function getElementBounds({ + element, + canvasSize, + mediaAsset, + localTime, +}: { + element: TimelineElement; + canvasSize: { width: number; height: number }; + mediaAsset?: MediaAsset | null; + localTime: number; +}): ElementBounds | null { + if (element.type === "audio" || element.type === "effect") return null; + if ("hidden" in element && element.hidden) return null; + + const { width: canvasWidth, height: canvasHeight } = canvasSize; + + if (element.type === "video" || element.type === "image") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + const sourceWidth = mediaAsset?.width ?? canvasWidth; + const sourceHeight = mediaAsset?.height ?? canvasHeight; + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth, + sourceHeight, + transform, + }); + } + + if (element.type === "sticker") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: element.intrinsicWidth ?? STICKER_INTRINSIC_SIZE_FALLBACK, + sourceHeight: element.intrinsicHeight ?? STICKER_INTRINSIC_SIZE_FALLBACK, + transform, + }); + } + + if (element.type === "graphic") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + return getVisualElementBounds({ + canvasWidth, + canvasHeight, + sourceWidth: DEFAULT_GRAPHIC_SOURCE_SIZE, + sourceHeight: DEFAULT_GRAPHIC_SOURCE_SIZE, + transform, + }); + } + + if (element.type === "text") { + const transform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + const measured = measureTextElement({ + element, + canvasHeight, + localTime, + ctx, + }); + + return getTransformedRectBounds({ + canvasWidth, + canvasHeight, + rect: measured.visualRect, + transform, + }); + } + + return null; +} + +export const ROTATION_HANDLE_OFFSET = 24; + +export type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right"; +export type Edge = "right" | "left" | "bottom"; + +export function getCornerPosition({ + bounds, + corner, +}: { + bounds: ElementBounds; + corner: Corner; +}): { x: number; y: number } { + const halfW = bounds.width / 2; + const halfH = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = + corner === "top-left" || corner === "bottom-left" ? -halfW : halfW; + const localY = + corner === "top-left" || corner === "top-right" ? -halfH : halfH; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getEdgeHandlePosition({ + bounds, + edge, +}: { + bounds: ElementBounds; + edge: Edge; +}): { x: number; y: number } { + const halfWidth = bounds.width / 2; + const halfHeight = bounds.height / 2; + const angleRad = (bounds.rotation * Math.PI) / 180; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + const localX = edge === "right" ? halfWidth : edge === "left" ? -halfWidth : 0; + const localY = edge === "bottom" ? halfHeight : 0; + return { + x: bounds.cx + (localX * cos - localY * sin), + y: bounds.cy + (localX * sin + localY * cos), + }; +} + +export function getVisibleElementsWithBounds({ + tracks, + currentTime, + canvasSize, + mediaAssets, +}: { + tracks: SceneTracks; + currentTime: number; + canvasSize: { width: number; height: number }; + mediaAssets: MediaAsset[]; +}): ElementWithBounds[] { + const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); + const orderedTracks = [ + ...tracks.overlay.filter((track) => !("hidden" in track && track.hidden)), + ...(!tracks.main.hidden ? [tracks.main] : []), + ].reverse(); + + const result: ElementWithBounds[] = []; + + for (const track of orderedTracks) { + const elements = track.elements + .filter((element) => !("hidden" in element && element.hidden)) + .filter( + (element) => + currentTime >= element.startTime && + currentTime < element.startTime + element.duration, + ) + .slice() + .sort((a, b) => { + if (a.startTime !== b.startTime) return a.startTime - b.startTime; + return a.id.localeCompare(b.id); + }); + + for (const element of elements) { + const localTime = getElementLocalTime({ + timelineTime: currentTime, + elementStartTime: element.startTime, + elementDuration: element.duration, + }); + const mediaAsset = + element.type === "video" || element.type === "image" + ? mediaMap.get(element.mediaId) + : undefined; + const bounds = getElementBounds({ + element, + canvasSize, + mediaAsset, + localTime, + }); + if (bounds) { + result.push({ + trackId: track.id, + elementId: element.id, + element, + bounds, + }); + } + } + } + + return result; +} |
