-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Canvas renderer and exporter #574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5435b86
1c00b4f
2091387
056ed26
4316834
4614118
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Detailsdiff --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);
AnalysisThe 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> | ||
); | ||
} |
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> | ||
); | ||
} |
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); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The callback receives View DetailsAnalysisThe This mismatch means the
Currently, the |
||
} | ||
previousTimeRef.current = time; | ||
requestRef.current = requestAnimationFrame(loop); | ||
}; | ||
|
||
requestRef.current = requestAnimationFrame(loop); | ||
return () => cancelAnimationFrame(requestRef.current); | ||
}, [callback]); | ||
} |
There was a problem hiding this comment.
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
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.