summaryrefslogtreecommitdiff
path: root/apps/web/src/actions
diff options
context:
space:
mode:
authorcashmere <cashmere@autistici.org>2026-07-26 21:06:33 +0200
committercashmere <cashmere@autistici.org>2026-07-26 21:06:33 +0200
commitfc0a801e8829b623d62137a3f7cbdec38c6d555a (patch)
treeb4c9a0458e92eaf44d3331ce3b0ea3ff906a9d15 /apps/web/src/actions
parentcf5e79e919144200294fb9fed22a222592a0aeea (diff)
downloadopencut-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.ts4
-rw-r--r--apps/web/src/actions/use-editor-actions.ts92
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();