From fc0a801e8829b623d62137a3f7cbdec38c6d555a Mon Sep 17 00:00:00 2001 From: cashmere Date: Sun, 26 Jul 2026 21:06:33 +0200 Subject: feat: freeze frame at playhead Enable the timeline toolbar freeze-frame button: capture the composited canvas as PNG, add it as an image media asset, split the video element at the playhead, shift the right part by the freeze duration, and insert a 3-second image element in the gap. Adds RendererManager.captureFrame(), reused by createSnapshot(). Also adds the Nix devshell (flake.nix/flake.lock: bun + nodejs) and AGENTS.md repository guidelines. --- apps/web/src/actions/definitions.ts | 4 + apps/web/src/actions/use-editor-actions.ts | 92 +++++++++++++++++++++- apps/web/src/core/managers/renderer-manager.ts | 35 +++++++- apps/web/src/media/processing.ts | 2 +- .../src/timeline/components/timeline-toolbar.tsx | 18 ++++- 5 files changed, 142 insertions(+), 9 deletions(-) (limited to 'apps/web') diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts index 986510ec..eed9c378 100644 --- a/apps/web/src/actions/definitions.ts +++ b/apps/web/src/actions/definitions.ts @@ -102,6 +102,10 @@ export const ACTIONS = { description: "Extract or recover source audio", category: "editing", }, + "freeze-frame": { + description: "Freeze frame at playhead", + category: "editing", + }, "select-all": { description: "Select all elements", category: "selection", diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index e5d3a23b..41408635 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -16,7 +16,7 @@ import { ZERO_MEDIA_TIME, } from "@/wasm"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; -import { getElementsAtTime, hasMediaId } from "@/timeline"; +import { getElementsAtTime, hasMediaId, buildElementFromMedia } from "@/timeline"; import { cancelInteraction } from "@/editor/cancel-interaction"; import { invokeAction } from "@/actions"; import { canToggleSourceAudio } from "@/timeline/audio-separation"; @@ -26,6 +26,8 @@ import { type ScopeEntry, } from "@/selection/scope"; import { useCommittedRef } from "@/hooks/use-committed-ref"; +import { toast } from "sonner"; +import { generateImageThumbnail } from "@/media/processing"; export function useEditorActions() { const editor = useEditor(); @@ -372,6 +374,94 @@ export function useEditorActions() { undefined, ); + useActionHandler( + "freeze-frame", + async () => { + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.scenes.getActiveScene().tracks; + const candidates = + selectedElements.length > 0 + ? selectedElements + : getElementsAtTime({ tracks, time: currentTime }); + + const target = editor.timeline + .getElementsWithTracks({ elements: candidates }) + .find( + ({ element }) => + element.type === "video" && + currentTime > element.startTime && + currentTime < element.startTime + element.duration, + ); + if (!target) { + return; + } + + const activeProject = editor.project.getActive(); + if (!activeProject) { + return; + } + + const blob = await editor.renderer.captureFrame(); + if (!blob) { + toast.error("Failed to capture frame"); + return; + } + + const file = new File([blob], "freeze-frame.png", { + type: "image/png", + }); + const { thumbnailUrl } = await generateImageThumbnail({ + imageFile: file, + }); + const asset = await editor.media.addMediaAsset({ + projectId: activeProject.metadata.id, + asset: { + file, + name: "Freeze frame", + type: "image", + url: URL.createObjectURL(file), + thumbnailUrl, + width: activeProject.settings.canvasSize.width, + height: activeProject.settings.canvasSize.height, + }, + }); + if (!asset) { + return; + } + + const freezeDuration = mediaTimeFromSeconds({ seconds: 3 }); + const rightSideElements = editor.timeline.splitElements({ + elements: [ + { trackId: target.track.id, elementId: target.element.id }, + ], + splitTime: currentTime, + }); + + if (rightSideElements.length > 0) { + editor.timeline.moveElements({ + moves: rightSideElements.map((element) => ({ + sourceTrackId: element.trackId, + targetTrackId: element.trackId, + elementId: element.elementId, + newStartTime: addMediaTime({ a: currentTime, b: freezeDuration }), + })), + }); + } + + editor.timeline.insertElement({ + element: buildElementFromMedia({ + mediaId: asset.id, + mediaType: "image", + name: "Freeze frame", + duration: freezeDuration, + startTime: currentTime, + }), + placement: { mode: "explicit", trackId: target.track.id }, + }); + }, + undefined, + ); + useActionHandler( "select-all", () => { diff --git a/apps/web/src/core/managers/renderer-manager.ts b/apps/web/src/core/managers/renderer-manager.ts index 7984655c..38d1e57f 100644 --- a/apps/web/src/core/managers/renderer-manager.ts +++ b/apps/web/src/core/managers/renderer-manager.ts @@ -78,18 +78,18 @@ export class RendererManager { } } - private async createSnapshot(): Promise { + async captureFrame(): Promise { try { const renderTree = this.getRenderTree(); const activeProject = this.editor.project.getActive(); if (!renderTree || !activeProject) { - return { success: false, error: "No project or scene to capture" }; + return null; } const duration = this.editor.timeline.getTotalDuration(); if (duration === 0) { - return { success: false, error: "Project is empty" }; + return null; } const { canvasSize, fps } = activeProject.settings; @@ -114,9 +114,36 @@ export class RendererManager { targetCanvas: tempCanvas, }); - const blob = await new Promise((resolve) => { + return await new Promise((resolve) => { tempCanvas.toBlob((result) => resolve(result), "image/png"); }); + } catch (error) { + console.error("Frame capture failed:", error); + return null; + } + } + + private async createSnapshot(): Promise { + try { + const renderTree = this.getRenderTree(); + const activeProject = this.editor.project.getActive(); + + if (!renderTree || !activeProject) { + return { success: false, error: "No project or scene to capture" }; + } + + const duration = this.editor.timeline.getTotalDuration(); + if (duration === 0) { + return { success: false, error: "Project is empty" }; + } + + const { fps } = activeProject.settings; + const renderTime = Math.min( + this.editor.playback.getCurrentTime(), + this.editor.timeline.getLastFrameTime(), + ); + + const blob = await this.captureFrame(); if (!blob) { return { success: false, error: "Failed to create image" }; diff --git a/apps/web/src/media/processing.ts b/apps/web/src/media/processing.ts index 34ccadbc..21d4f87c 100644 --- a/apps/web/src/media/processing.ts +++ b/apps/web/src/media/processing.ts @@ -39,7 +39,7 @@ const getStorageLimitDescription = ({ })} is safely available in browser storage.`; }; -async function generateImageThumbnail({ +export async function generateImageThumbnail({ imageFile, }: { imageFile: File; diff --git a/apps/web/src/timeline/components/timeline-toolbar.tsx b/apps/web/src/timeline/components/timeline-toolbar.tsx index 22acd447..6208ef27 100644 --- a/apps/web/src/timeline/components/timeline-toolbar.tsx +++ b/apps/web/src/timeline/components/timeline-toolbar.tsx @@ -95,6 +95,18 @@ function ToolbarLeftSection() { const isCurrentlyBookmarked = useEditor((e) => e.scenes.isBookmarked({ time: e.playback.getCurrentTime() }), ); + const canFreezeFrame = useEditor((e) => { + const time = e.playback.getCurrentTime(); + const { overlay, main } = e.scenes.getActiveScene().tracks; + return [...overlay, main].some((track) => + track.elements.some( + (element) => + element.type === "video" && + time > element.startTime && + time < element.startTime + element.duration, + ), + ); + }); const selectedElement = selectedElements.length === 1 ? (editor.timeline.getElementsWithTracks({ @@ -185,9 +197,9 @@ function ToolbarLeftSection() { } - tooltip="Freeze frame (coming soon)" - disabled={true} - onClick={({ event: _event }) => {}} + tooltip="Freeze frame" + disabled={!canFreezeFrame} + onClick={({ event }) => handleAction({ action: "freeze-frame", event })} />