diff options
| author | cashmere <cashmere@autistici.org> | 2026-07-26 21:06:33 +0200 |
|---|---|---|
| committer | cashmere <cashmere@autistici.org> | 2026-07-26 21:06:33 +0200 |
| commit | fc0a801e8829b623d62137a3f7cbdec38c6d555a (patch) | |
| tree | b4c9a0458e92eaf44d3331ce3b0ea3ff906a9d15 /apps/web/src/actions | |
| parent | cf5e79e919144200294fb9fed22a222592a0aeea (diff) | |
| download | opencut-fc0a801e8829b623d62137a3f7cbdec38c6d555a.tar.gz | |
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.
Diffstat (limited to 'apps/web/src/actions')
| -rw-r--r-- | apps/web/src/actions/definitions.ts | 4 | ||||
| -rw-r--r-- | apps/web/src/actions/use-editor-actions.ts | 92 |
2 files changed, 95 insertions, 1 deletions
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(); @@ -373,6 +375,94 @@ export function useEditorActions() { ); 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", () => { const scene = editor.scenes.getActiveScene(); |
