import type { TimelineTrack } from "@/types/timeline"; import type { MediaAsset } from "@/types/assets"; import { RootNode } from "./nodes/root-node"; import { VideoNode } from "./nodes/video-node"; import { ImageNode } from "./nodes/image-node"; import { TextNode } from "./nodes/text-node"; import { StickerNode } from "./nodes/sticker-node"; import { ColorNode } from "./nodes/color-node"; import { CompositeEffectNode } from "./nodes/composite-effect-node"; import { EffectLayerNode } from "./nodes/effect-layer-node"; import type { BaseNode } from "./nodes/base-node"; import type { TBackground, TCanvasSize } from "@/types/project"; import { DEFAULT_BLUR_INTENSITY } from "@/constants/project-constants"; import { isMainTrack } from "@/lib/timeline"; const PREVIEW_MAX_IMAGE_SIZE = 2048; const BLUR_BACKGROUND_ZOOM_SCALE = 1.4; function getVisibleSortedElements({ track, }: { track: TimelineTrack; }) { return track.elements .filter((element) => !("hidden" in element && element.hidden)) .slice() .sort((a, b) => { if (a.startTime !== b.startTime) return a.startTime - b.startTime; return a.id.localeCompare(b.id); }); } function buildTrackNodes({ tracks, mediaMap, canvasSize, isPreview, }: { tracks: TimelineTrack[]; mediaMap: Map; canvasSize: TCanvasSize; isPreview?: boolean; }): BaseNode[] { const nodes: BaseNode[] = []; for (const track of tracks) { const elements = getVisibleSortedElements({ track }); for (const element of elements) { if (element.type === "effect") { nodes.push( new EffectLayerNode({ effectType: element.effectType, effectParams: element.params, timeOffset: element.startTime, duration: element.duration, }), ); continue; } if (element.type === "video" || element.type === "image") { const mediaAsset = mediaMap.get(element.mediaId); if (!mediaAsset?.file || !mediaAsset?.url) { continue; } if (mediaAsset.type === "video") { nodes.push( new VideoNode({ mediaId: mediaAsset.id, url: mediaAsset.url, file: mediaAsset.file, duration: element.duration, timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, effects: element.effects, }), ); } if (mediaAsset.type === "image") { nodes.push( new ImageNode({ url: mediaAsset.url, duration: element.duration, timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, effects: element.effects, ...(isPreview && { maxSourceSize: PREVIEW_MAX_IMAGE_SIZE, }), }), ); } } if (element.type === "text") { nodes.push( new TextNode({ ...element, canvasCenter: { x: canvasSize.width / 2, y: canvasSize.height / 2 }, canvasHeight: canvasSize.height, textBaseline: "middle", effects: element.effects, }), ); } if (element.type === "sticker") { nodes.push( new StickerNode({ stickerId: element.stickerId, duration: element.duration, timeOffset: element.startTime, trimStart: element.trimStart, trimEnd: element.trimEnd, transform: element.transform, animations: element.animations, opacity: element.opacity, blendMode: element.blendMode, effects: element.effects, }), ); } } } return nodes; } export type BuildSceneParams = { canvasSize: TCanvasSize; tracks: TimelineTrack[]; mediaAssets: MediaAsset[]; duration: number; background: TBackground; isPreview?: boolean; }; export function buildScene({ canvasSize, tracks, mediaAssets, duration, background, isPreview, }: BuildSceneParams) { const rootNode = new RootNode({ duration }); const mediaMap = new Map(mediaAssets.map((m) => [m.id, m])); const visibleTracks = tracks.filter( (track) => !("hidden" in track && track.hidden), ); const orderedTracksTopToBottom = [ ...visibleTracks.filter((track) => !isMainTrack(track)), ...visibleTracks.filter((track) => isMainTrack(track)), ]; const orderedTracksBottomToTop = orderedTracksTopToBottom.slice().reverse(); const allNodes = buildTrackNodes({ tracks: orderedTracksBottomToTop, mediaMap, canvasSize, isPreview, }); if (background.type === "blur") { rootNode.add( new CompositeEffectNode({ contentNodes: allNodes.filter( (node) => !(node instanceof EffectLayerNode), ), effectType: "blur", effectParams: { intensity: background.blurIntensity ?? DEFAULT_BLUR_INTENSITY, }, scale: BLUR_BACKGROUND_ZOOM_SCALE, }), ); } else if (background.type === "color" && background.color !== "transparent") { rootNode.add(new ColorNode({ color: background.color })); } for (const node of allNodes) { rootNode.add(node); } return rootNode; }