Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
"eventemitter3": "^5.0.1",
"feed": "^5.1.0",
"framer-motion": "^11.13.1",
"input-otp": "^1.4.1",
"lucide-react": "^0.468.0",
"mediabunny": "^1.11.2",
"motion": "^12.18.1",
"nanoid": "^5.1.5",
"next": "^15.4.5",
Expand All @@ -67,6 +69,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
"vaul": "^1.1.1",
"zod": "^3.25.67",
"zustand": "^5.0.2"
Expand Down
4 changes: 3 additions & 1 deletion apps/web/src/app/editor/[project_id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import {
import { MediaPanel } from "../../../components/editor/media-panel";
import { PropertiesPanel } from "../../../components/editor/properties-panel";
import { Timeline } from "../../../components/editor/timeline";
import { PreviewPanel } from "../../../components/editor/preview-panel";
import { EditorHeader } from "@/components/editor-header";
import { usePanelStore } from "@/stores/panel-store";
import { useProjectStore } from "@/stores/project-store";
import { EditorProvider } from "@/components/editor-provider";
import { usePlaybackControls } from "@/hooks/use-playback-controls";
import { Onboarding } from "@/components/onboarding";
import { CanvasPreviewPanel as PreviewPanel } from "@/components/editor/renderer/canvas-preview-panel";
// import { PreviewPanel } from "@/components/editor/preview-panel";

export default function Editor() {
const {
Expand Down Expand Up @@ -275,6 +276,7 @@ export default function Editor() {
onResize={setPreviewPanel}
className="min-w-0 min-h-0 flex-1"
>
{/* <PreviewPanel /> */}
<PreviewPanel />
</ResizablePanel>
</ResizablePanelGroup>
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/components/editor-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { useRouter } from "next/navigation";
import { FaDiscord } from "react-icons/fa6";
import { useTheme } from "next-themes";
import { PanelPresetSelector } from "./panel-preset-selector";
import { ExportButton } from "./export-button";
// import { ExportButton } from "./export-button";
import { ExportDialog } from "./editor/renderer/export-dialog";

export function EditorHeader() {
const { activeProject, renameProject, deleteProject } = useProjectStore();
Expand Down Expand Up @@ -116,7 +117,9 @@ export function EditorHeader() {
<nav className="flex items-center gap-2">
<PanelPresetSelector />
<KeyboardShortcutsHelp />
<ExportButton />
<ExportDialog>
<Button>Export</Button>
</ExportDialog>
<Button
size="icon"
variant="text"
Expand Down
104 changes: 104 additions & 0 deletions apps/web/src/components/editor/renderer/canvas-preview-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useCallback, useMemo, useRef } from "react";
import useDeepCompareEffect from "use-deep-compare-effect";

import { useRafLoop } from "@/hooks/use-raf-loop";
import { SceneNode } from "@/lib/renderer/nodes/scene-node";
import { SceneRenderer } from "@/lib/renderer/scene-renderer";
import { useMediaStore } from "@/stores/media-store";
import { usePlaybackStore } from "@/stores/playback-store";
import { useRendererStore } from "@/stores/renderer-store";
import { useTimelineStore } from "@/stores/timeline-store";
import { useProjectStore } from "@/stores/project-store";
import { buildScene } from "@/lib/renderer/build-scene";

// TODO: get preview size in a better way
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO comment indicates unfinished work for getting preview size in a better way.

View Details
📝 Patch Details
diff --git a/apps/web/src/components/editor/renderer/canvas-preview-panel.tsx b/apps/web/src/components/editor/renderer/canvas-preview-panel.tsx
index 79b932c..1a0502d 100644
--- a/apps/web/src/components/editor/renderer/canvas-preview-panel.tsx
+++ b/apps/web/src/components/editor/renderer/canvas-preview-panel.tsx
@@ -8,15 +8,14 @@ import { useMediaStore } from "@/stores/media-store";
 import { usePlaybackStore } from "@/stores/playback-store";
 import { useRendererStore } from "@/stores/renderer-store";
 import { useTimelineStore } from "@/stores/timeline-store";
-import { useProjectStore } from "@/stores/project-store";
+import { useProjectStore, DEFAULT_CANVAS_SIZE } from "@/stores/project-store";
 import { buildScene } from "@/lib/renderer/build-scene";
 
-// TODO: get preview size in a better way
 function usePreviewSize() {
   const { activeProject } = useProjectStore();
   return {
-    width: activeProject?.canvasSize?.width || 600,
-    height: activeProject?.canvasSize?.height || 320,
+    width: activeProject?.canvasSize?.width || DEFAULT_CANVAS_SIZE.width,
+    height: activeProject?.canvasSize?.height || DEFAULT_CANVAS_SIZE.height,
   };
 }
 

Analysis

The code contains a TODO comment indicating that the current method of getting preview size needs improvement. This suggests the current implementation may not be robust or may have limitations that could affect functionality.

The current implementation falls back to hardcoded values (600x320) if the active project doesn't have canvas size defined, which may not be appropriate for all use cases and could lead to incorrect rendering dimensions.

Action needed: Address the TODO by implementing a more robust preview size determination method that handles edge cases properly.

function usePreviewSize() {
const { activeProject } = useProjectStore();
return {
width: activeProject?.canvasSize?.width || 600,
height: activeProject?.canvasSize?.height || 320,
};
}

function RendererSceneController() {
const setScene = useRendererStore((s) => s.setScene);

const tracks = useTimelineStore((s) => s.tracks);
const mediaItems = useMediaStore((s) => s.mediaItems);

const getTotalDuration = useTimelineStore((s) => s.getTotalDuration);
const { width, height } = usePreviewSize();

useDeepCompareEffect(() => {
const scene = buildScene({
tracks,
mediaItems,
duration: getTotalDuration(),
canvasSize: {
width,
height,
},
});

setScene(scene);
}, [tracks, mediaItems, getTotalDuration]);

return null;
}

function PreviewCanvas() {
const ref = useRef<HTMLCanvasElement>(null);
const lastFrameRef = useRef(0);
const lastSceneRef = useRef<SceneNode | null>(null);
const renderingRef = useRef(false);

const { width, height } = usePreviewSize();

const renderer = useMemo(() => {
return new SceneRenderer({
width,
height,
fps: 30, // TODO: get fps from project
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO comment indicates FPS should be retrieved from project settings instead of being hardcoded.

View Details
📝 Patch Details
diff --git a/apps/web/src/components/editor/renderer/canvas-preview-panel.tsx b/apps/web/src/components/editor/renderer/canvas-preview-panel.tsx
index 79b932c..6ec4d5e 100644
--- a/apps/web/src/components/editor/renderer/canvas-preview-panel.tsx
+++ b/apps/web/src/components/editor/renderer/canvas-preview-panel.tsx
@@ -20,6 +20,11 @@ function usePreviewSize() {
   };
 }
 
+function useProjectFps() {
+  const { activeProject } = useProjectStore();
+  return activeProject?.fps || 30;
+}
+
 function RendererSceneController() {
   const setScene = useRendererStore((s) => s.setScene);
 
@@ -53,14 +58,15 @@ function PreviewCanvas() {
   const renderingRef = useRef(false);
 
   const { width, height } = usePreviewSize();
+  const fps = useProjectFps();
 
   const renderer = useMemo(() => {
     return new SceneRenderer({
       width,
       height,
-      fps: 30, // TODO: get fps from project
+      fps,
     });
-  }, [width, height]);
+  }, [width, height, fps]);
 
   const scene = useRendererStore((s) => s.scene);
 

Analysis

The renderer is initialized with a hardcoded FPS value of 30, but there's a TODO comment indicating this should be retrieved from the project settings. This could lead to mismatched frame rates between the preview and the actual project configuration.

Hardcoding the FPS could cause issues where the preview renders at a different frame rate than intended, potentially affecting timing calculations and the accuracy of the preview relative to the final export.

Action needed: Address the TODO by implementing proper FPS retrieval from project settings to ensure consistency between preview and export.

});
}, [width, height]);

const scene = useRendererStore((s) => s.scene);

const render = useCallback(() => {
if (ref.current && scene && !renderingRef.current) {
const time = usePlaybackStore.getState().currentTime;
const frame = Math.floor(time * renderer.fps);

if (frame !== lastFrameRef.current || scene !== lastSceneRef.current) {
renderingRef.current = true;
lastSceneRef.current = scene;
lastFrameRef.current = frame;
renderer.renderToCanvas(scene, frame, ref.current).then(() => {
renderingRef.current = false;
});
}
}
}, [renderer, scene, width, height]);

useRafLoop(render);

return (
<canvas
ref={ref}
width={width}
height={height}
className="max-w-full max-h-full block border"
/>
);
}

export function CanvasPreviewPanel() {
return (
<div className="h-full w-full flex flex-col min-h-0 min-w-0 bg-panel rounded-sm relative">
<div className="flex flex-1 items-center justify-center min-h-0 min-w-0 p-2">
<PreviewCanvas />
<RendererSceneController />
</div>
</div>
);
}
138 changes: 138 additions & 0 deletions apps/web/src/components/editor/renderer/export-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { useState } from "react";
import { SceneExporter } from "@/lib/renderer/scene-exporter";

import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";

import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { buildScene } from "@/lib/renderer/build-scene";
import { useTimelineStore } from "@/stores/timeline-store";
import { useMediaStore } from "@/stores/media-store";
import { useProjectStore } from "@/stores/project-store";

function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
}

function ExportProgress({ progress }: { progress: number }) {
return (
<div className="w-full flex flex-col text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">Rendering video...</div>
<div className="text-xs text-muted-foreground">
{Math.round(progress * 100)}%
</div>
</div>
<Progress value={progress * 100} className="mt-2" />
</div>
);
}

export function ExportDialog({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);

const [exporter, setExporter] = useState<SceneExporter | null>(null);

const handleOpenChange = (open: boolean) => {
if (isExporting) {
return;
}

setIsOpen(open);
setError(null);
setProgress(0);
};

const handleExport = async () => {
setProgress(0);
setIsExporting(true);

const project = useProjectStore.getState().activeProject;

const width = project?.canvasSize.width ?? 640;
const height = project?.canvasSize.height ?? 720;
const fps = project?.fps ?? 30;

const scene = buildScene({
tracks: useTimelineStore.getState().tracks,
mediaItems: useMediaStore.getState().mediaItems,
duration: useTimelineStore.getState().getTotalDuration(),
canvasSize: {
width,
height,
},
});

const exporter = new SceneExporter({
width,
height,
fps,
});

setExporter(exporter);

exporter.on("progress", (progress) => {
setProgress(progress);
});

exporter.on("complete", (blob) => {
downloadBlob(blob, `export-${Date.now()}.mp4`);
setIsOpen(false);
});

exporter.on("error", (error) => {
setError(error.message);
});

await exporter.export(scene);
setIsExporting(false);
setExporter(null);
};

const handleCancel = () => {
exporter?.cancel();
setIsExporting(false);
setIsOpen(false);
};

return (
<Dialog modal open={isOpen} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Export Video</DialogTitle>
</DialogHeader>
<DialogDescription>Export the scene as a video file.</DialogDescription>
<div className="min-h-16 text-sm flex items-end">
{isExporting && <ExportProgress progress={progress} />}
{error && <div className="text-red-500">{error}</div>}
</div>
<DialogFooter>
{isExporting && (
<Button variant="outline" onClick={handleCancel}>
Cancel
</Button>
)}
<Button disabled={isExporting} onClick={handleExport}>
Export
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
20 changes: 20 additions & 0 deletions apps/web/src/hooks/use-raf-loop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { useEffect, useRef } from "react";

export function useRafLoop(callback: (time: number) => void) {
const requestRef = useRef<number>(0);
const previousTimeRef = useRef<number>(0);

useEffect(() => {
const loop = (time: number) => {
if (previousTimeRef.current !== undefined) {
const deltaTime = time - previousTimeRef.current;
callback(deltaTime);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The callback receives deltaTime but the canvas preview expects the callback to receive no arguments, causing incorrect time calculations in the renderer.

View Details

Analysis

The useRafLoop hook calls the callback with deltaTime (the difference between current and previous frame times), but the render callback in canvas-preview-panel.tsx expects no arguments and gets the current time from usePlaybackStore.getState().currentTime.

This mismatch means the render function signature doesn't match what useRafLoop provides. The callback should either:

  1. Receive the actual requestAnimationFrame time instead of deltaTime:

    callback(time); // instead of callback(deltaTime)
  2. Or the canvas preview should adapt to use deltaTime for frame calculations

Currently, the render function ignores the deltaTime parameter entirely and fetches time independently, which could lead to timing inconsistencies between the RAF loop and the playback store state.

}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(loop);
};

requestRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(requestRef.current);
}, [callback]);
}
Loading