diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx index d171956..d70735b 100644 --- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx +++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx @@ -51,6 +51,7 @@ import { ArrowTurnBackwardIcon, Edit02Icon, AiVoiceGeneratorIcon, + SnowIcon, } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { uppercase } from "@/utils/string"; @@ -198,6 +199,20 @@ export function TimelineElement({ > {t("Split")} + {element.type === "video" && ( + } + onClick={(event) => { + event.stopPropagation(); + invokeAction("freeze-frame", { + trackId: track.id, + elementId: element.id, + }); + }} + > + {t("Freeze frame")} + + )} {element.type === "video" && selectedElements.length === 1 && ( = selected.element.startTime && + currentTime < selected.element.startTime + selected.element.duration; const handleAction = ({ action, @@ -127,9 +137,11 @@ function ToolbarLeftSection() { } - tooltip={t('Coming soon')} - disabled={true} - onClick={({ event: _event }) => {}} + tooltip={t("Freeze frame")} + disabled={!canFreezeFrame} + onClick={({ event }) => + handleAction({ action: "freeze-frame", event }) + } /> onClick({ event })} className={cn( "rounded-sm", diff --git a/apps/web/src/hooks/actions/use-editor-actions.ts b/apps/web/src/hooks/actions/use-editor-actions.ts index 3b061cd..a449de1 100644 --- a/apps/web/src/hooks/actions/use-editor-actions.ts +++ b/apps/web/src/hooks/actions/use-editor-actions.ts @@ -1,5 +1,6 @@ "use client"; +import { useRef } from "react"; import { useTimelineStore } from "@/stores/timeline-store"; import { useMediaPreviewStore } from "@/stores/media-preview-store"; import { useActionHandler } from "@/hooks/actions/use-action-handler"; @@ -11,12 +12,27 @@ import { toast } from "sonner"; import { i18next } from "@/lib/i18n"; import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants"; import { getExportFileExtension, getExportMimeType } from "@/lib/export"; +import { extractVideoFrame } from "@/lib/media/processing"; +import { + buildImageElement, + findAvailableVideoTrackAbove, + getVisualSourceTime, +} from "@/lib/timeline/element-utils"; +import { + AddMediaAssetCommand, + AddTrackCommand, + BatchCommand, + type Command, + InsertElementCommand, +} from "@/lib/commands"; +import { storageService } from "@/services/storage/service"; export function useEditorActions() { const editor = useEditor(); const activeProject = editor.project.getActive(); const { selectedElements, setElementSelection } = useElementSelection(); const { clipboard, setClipboard, toggleSnapping } = useTimelineStore(); + const freezeFrameInFlight = useRef(false); useActionHandler( "toggle-play", @@ -337,6 +353,176 @@ export function useEditorActions() { undefined, ); + useActionHandler( + "freeze-frame", + (args) => { + if (freezeFrameInFlight.current) return; + + const sourceRef = + args ?? + (selectedElements.length === 1 ? selectedElements[0] : undefined); + if (!sourceRef) { + toast.warning(i18next.t("Select one video to freeze")); + return; + } + + const [source] = editor.timeline.getElementsWithTracks({ + elements: [sourceRef], + }); + if ( + !source || + source.element.type !== "video" || + source.track.type !== "video" + ) { + toast.warning(i18next.t("Select one video to freeze")); + return; + } + const sourceElement = source.element; + const sourceTrack = source.track; + + const currentTime = editor.playback.getCurrentTime(); + if ( + currentTime < sourceElement.startTime || + currentTime >= sourceElement.startTime + sourceElement.duration + ) { + toast.warning(i18next.t("Move the playhead inside the video")); + return; + } + + const sourceAsset = editor.media + .getAssets() + .find((asset) => asset.id === sourceElement.mediaId); + if (!sourceAsset) { + toast.error(i18next.t("Source video is unavailable")); + return; + } + + freezeFrameInFlight.current = true; + const toastId = "freeze-frame"; + toast.loading(i18next.t("Creating freeze frame"), { id: toastId }); + + (async () => { + let assetId: string | undefined; + let objectUrl: string | undefined; + let batchCommand: BatchCommand | undefined; + let commandStarted = false; + let committed = false; + + try { + const sourceTime = getVisualSourceTime({ + timelineTime: currentTime, + startTime: sourceElement.startTime, + duration: sourceElement.duration, + trimStart: sourceElement.trimStart, + playbackRate: sourceElement.playbackRate, + reversed: sourceElement.reversed, + }); + const sourceName = sourceElement.name.replace(/\.[^/.]+$/, ""); + const { file, width, height } = await extractVideoFrame({ + videoFile: sourceAsset.file, + timeInSeconds: sourceTime, + fileName: `${sourceName}-freeze-${currentTime.toFixed(3)}.png`, + }); + + objectUrl = URL.createObjectURL(file); + const asset = { + name: file.name, + type: "image" as const, + file, + url: objectUrl, + width, + height, + }; + const addMediaCommand = new AddMediaAssetCommand( + activeProject.metadata.id, + asset, + true, + ); + assetId = addMediaCommand.getAssetId(); + await storageService.saveMediaAsset({ + projectId: activeProject.metadata.id, + mediaAsset: { ...asset, id: assetId }, + }); + + const tracks = editor.timeline.getTracks(); + const sourceTrackIndex = tracks.findIndex( + (track) => track.id === sourceTrack.id, + ); + if (sourceTrackIndex < 0) + throw new Error("Source track is unavailable"); + + const duration = 3; + let targetTrackId = findAvailableVideoTrackAbove({ + tracks, + sourceTrackId: sourceTrack.id, + startTime: currentTime, + endTime: currentTime + duration, + }); + const commands: Command[] = [addMediaCommand]; + + if (!targetTrackId) { + const addTrackCommand = new AddTrackCommand( + "video", + sourceTrackIndex, + ); + targetTrackId = addTrackCommand.getTrackId(); + commands.push(addTrackCommand); + } + + const imageElement = buildImageElement({ + mediaId: assetId, + name: file.name, + duration, + startTime: currentTime, + }); + imageElement.transform = { + ...sourceElement.transform, + position: { ...sourceElement.transform.position }, + }; + imageElement.opacity = sourceElement.opacity; + + const insertCommand = new InsertElementCommand({ + element: imageElement, + placement: { mode: "explicit", trackId: targetTrackId }, + }); + commands.push(insertCommand); + batchCommand = new BatchCommand(commands); + commandStarted = true; + editor.command.execute({ command: batchCommand }); + committed = true; + + setElementSelection({ + elements: [ + { + trackId: targetTrackId, + elementId: insertCommand.getElementId(), + }, + ], + }); + toast.success(i18next.t("Freeze frame created"), { id: toastId }); + } catch (error) { + console.error("Failed to create freeze frame:", error); + if (commandStarted && !committed) batchCommand?.undo(); + if (!committed && assetId) { + await storageService + .deleteMediaAsset({ + projectId: activeProject.metadata.id, + id: assetId, + }) + .catch(() => undefined); + } + if (!committed && objectUrl) URL.revokeObjectURL(objectUrl); + toast.error(i18next.t("Failed to create freeze frame"), { + id: toastId, + }); + } finally { + freezeFrameInFlight.current = false; + } + })(); + }, + undefined, + ); + useActionHandler( "paste-copied", () => { diff --git a/apps/web/src/lib/actions/definitions.ts b/apps/web/src/lib/actions/definitions.ts index 2933576..d47d08c 100644 --- a/apps/web/src/lib/actions/definitions.ts +++ b/apps/web/src/lib/actions/definitions.ts @@ -99,6 +99,11 @@ export const ACTIONS = { description: "Export selected clip", category: "editing", }, + "freeze-frame": { + description: "Freeze frame", + category: "editing", + args: { trackId: "string", elementId: "string" }, + }, "paste-copied": { description: "Paste elements at playhead", category: "editing", diff --git a/apps/web/src/lib/actions/types.ts b/apps/web/src/lib/actions/types.ts index 508bf10..5d03bcb 100644 --- a/apps/web/src/lib/actions/types.ts +++ b/apps/web/src/lib/actions/types.ts @@ -8,6 +8,7 @@ export type TActionArgsMap = { "seek-backward": { seconds: number } | undefined; "jump-forward": { seconds: number } | undefined; "jump-backward": { seconds: number } | undefined; + "freeze-frame": { trackId: string; elementId: string } | undefined; }; type TKeysWithValueUndefined = { diff --git a/apps/web/src/lib/commands/media/add-media-asset.ts b/apps/web/src/lib/commands/media/add-media-asset.ts index ddcca42..cfd358e 100644 --- a/apps/web/src/lib/commands/media/add-media-asset.ts +++ b/apps/web/src/lib/commands/media/add-media-asset.ts @@ -6,12 +6,13 @@ import { storageService } from "@/services/storage/service"; export class AddMediaAssetCommand extends Command { private assetId: string; - private savedAssets: MediaAsset[] | null = null; private createdAsset: MediaAsset | null = null; + private storageOperation = Promise.resolve(); constructor( private projectId: string, private asset: Omit, + private skipNextSave = false, ) { super(); this.assetId = generateUUID(); @@ -19,40 +20,53 @@ export class AddMediaAssetCommand extends Command { execute(): void { const editor = EditorCore.getInstance(); - this.savedAssets = [...editor.media.getAssets()]; - - this.createdAsset = { - ...this.asset, - id: this.assetId, - }; + if (!this.createdAsset) { + this.createdAsset = { ...this.asset, id: this.assetId }; + } + const createdAsset = this.createdAsset; editor.media.setAssets({ - assets: [...this.savedAssets, this.createdAsset], + assets: [ + ...editor.media.getAssets().filter(({ id }) => id !== this.assetId), + createdAsset, + ], }); - storageService - .saveMediaAsset({ - projectId: this.projectId, - mediaAsset: this.createdAsset, - }) + if (this.skipNextSave) { + this.skipNextSave = false; + return; + } + + this.storageOperation = this.storageOperation + .then(() => + storageService.saveMediaAsset({ + projectId: this.projectId, + mediaAsset: createdAsset, + }), + ) .catch((error) => { console.error("Failed to save media item:", error); }); } undo(): void { - if (this.savedAssets) { - const editor = EditorCore.getInstance(); - editor.media.setAssets({ assets: this.savedAssets }); - - if (this.createdAsset) { - storageService - .deleteMediaAsset({ projectId: this.projectId, id: this.assetId }) - .catch((error) => { - console.error("Failed to delete media item on undo:", error); - }); - } - } + if (!this.createdAsset) return; + + const editor = EditorCore.getInstance(); + editor.media.setAssets({ + assets: editor.media.getAssets().filter(({ id }) => id !== this.assetId), + }); + + this.storageOperation = this.storageOperation + .then(() => + storageService.deleteMediaAsset({ + projectId: this.projectId, + id: this.assetId, + }), + ) + .catch((error) => { + console.error("Failed to delete media item on undo:", error); + }); } getAssetId(): string { diff --git a/apps/web/src/lib/media/processing.ts b/apps/web/src/lib/media/processing.ts index fbe29ac..09797a4 100644 --- a/apps/web/src/lib/media/processing.ts +++ b/apps/web/src/lib/media/processing.ts @@ -106,6 +106,57 @@ export async function generateThumbnail({ } } +export async function extractVideoFrame({ + videoFile, + timeInSeconds, + fileName, +}: { + videoFile: File; + timeInSeconds: number; + fileName: string; +}): Promise<{ file: File; width: number; height: number }> { + const input = new Input({ + source: new BlobSource(videoFile), + formats: ALL_FORMATS, + }); + const videoTrack = await input.getPrimaryVideoTrack(); + + if (!videoTrack) throw new Error("No video track found in the file"); + if (!(await videoTrack.canDecode())) { + throw new Error("Video codec not supported for decoding"); + } + + const frame = await new VideoSampleSink(videoTrack).getSample(timeInSeconds); + if (!frame) throw new Error("Could not get frame at specified time"); + + try { + const width = videoTrack.displayWidth; + const height = videoTrack.displayHeight; + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) throw new Error("Could not get canvas context"); + + frame.draw(context, 0, 0, width, height); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob( + (value) => + value ? resolve(value) : reject(new Error("Could not encode PNG")), + "image/png", + ); + }); + + return { + file: new File([blob], fileName, { type: "image/png" }), + width, + height, + }; + } finally { + frame.close(); + } +} + export async function generateImageThumbnail({ imageFile, }: { diff --git a/apps/web/src/lib/timeline/__tests__/freeze-frame.test.ts b/apps/web/src/lib/timeline/__tests__/freeze-frame.test.ts new file mode 100644 index 0000000..ff21f16 --- /dev/null +++ b/apps/web/src/lib/timeline/__tests__/freeze-frame.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { EditorCore } from "@/core"; +import { AddMediaAssetCommand } from "@/lib/commands/media/add-media-asset"; +import { storageService } from "@/services/storage/service"; +import type { MediaAsset } from "@/types/assets"; +import type { TimelineTrack, VideoTrack } from "@/types/timeline"; +import { + findAvailableVideoTrackAbove, + getVisualSourceTime, +} from "../element-utils"; + +const videoTrack = ({ + id, + elements = [], + isMain = false, + hidden = false, +}: { + id: string; + elements?: VideoTrack["elements"]; + isMain?: boolean; + hidden?: boolean; +}): VideoTrack => ({ + id, + name: id, + type: "video", + elements, + isMain, + muted: false, + hidden, +}); + +const imageAsset = (id: string): MediaAsset => ({ + id, + name: `${id}.png`, + type: "image", + file: new File([id], `${id}.png`, { type: "image/png" }), + size: id.length, + lastModified: 0, +}); + +describe("freeze frame timeline decisions", () => { + test("maps the playhead to the displayed source time", () => { + const cases = [ + { trimStart: 0, playbackRate: 1, reversed: false, expected: 2 }, + { trimStart: 3, playbackRate: 1, reversed: false, expected: 5 }, + { trimStart: 3, playbackRate: 2, reversed: false, expected: 7 }, + { trimStart: 3, playbackRate: 2, reversed: true, expected: 15 }, + ]; + + for (const { expected, ...params } of cases) { + expect( + getVisualSourceTime({ + timelineTime: 7, + startTime: 5, + duration: 8, + ...params, + }), + ).toBe(expected); + } + }); + + test("keeps the reversed first frame inside the source range", () => { + expect( + getVisualSourceTime({ + timelineTime: 5, + startTime: 5, + duration: 8, + trimStart: 3, + playbackRate: 2, + reversed: true, + }), + ).toBe(19 - 1e-6); + }); + + test("undo removes only the generated asset", async () => { + const previousWindow = globalThis.window; + Object.assign(globalThis, { window: globalThis }); + const editor = EditorCore.getInstance(); + const previousAssets = editor.media.getAssets(); + const deleteMediaAsset = spyOn( + storageService, + "deleteMediaAsset", + ).mockResolvedValue(); + + try { + editor.media.setAssets({ assets: [imageAsset("existing")] }); + const command = new AddMediaAssetCommand( + "project", + imageAsset("freeze"), + true, + ); + command.execute(); + editor.media.setAssets({ + assets: [...editor.media.getAssets(), imageAsset("later")], + }); + + command.undo(); + + expect(editor.media.getAssets().map(({ id }) => id)).toEqual([ + "existing", + "later", + ]); + await Promise.resolve(); + expect(deleteMediaAsset).toHaveBeenCalledWith({ + projectId: "project", + id: command.getAssetId(), + }); + } finally { + editor.media.setAssets({ assets: previousAssets }); + deleteMediaAsset.mockRestore(); + Object.assign(globalThis, { window: previousWindow }); + } + }); + + test("chooses the nearest non-overlapping video track above the source", () => { + const tracks: TimelineTrack[] = [ + videoTrack({ id: "far-free" }), + videoTrack({ + id: "occupied", + elements: [ + { + id: "existing", + type: "image", + mediaId: "image", + name: "Existing", + startTime: 5, + duration: 1, + trimStart: 0, + trimEnd: 0, + hidden: false, + transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, + opacity: 1, + }, + ], + }), + { + id: "text", + name: "Text", + type: "text", + elements: [], + hidden: false, + }, + videoTrack({ id: "nearest-free" }), + videoTrack({ id: "main", isMain: true }), + videoTrack({ id: "source" }), + ]; + + expect( + findAvailableVideoTrackAbove({ + tracks, + sourceTrackId: "source", + startTime: 4, + endTime: 7, + }), + ).toBe("nearest-free"); + }); + + test("skips hidden video tracks above the source", () => { + expect( + findAvailableVideoTrackAbove({ + tracks: [ + videoTrack({ id: "visible" }), + videoTrack({ id: "hidden", hidden: true }), + videoTrack({ id: "source" }), + ], + sourceTrackId: "source", + startTime: 4, + endTime: 7, + }), + ).toBe("visible"); + }); + + test("requires a new track when every video track above overlaps", () => { + const occupied = videoTrack({ + id: "occupied", + elements: [ + { + id: "existing", + type: "image", + mediaId: "image", + name: "Existing", + startTime: 3, + duration: 6, + trimStart: 0, + trimEnd: 0, + hidden: false, + transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 }, + opacity: 1, + }, + ], + }); + + expect( + findAvailableVideoTrackAbove({ + tracks: [occupied, videoTrack({ id: "source" })], + sourceTrackId: "source", + startTime: 4, + endTime: 7, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts index 55cdca6..0808d8b 100644 --- a/apps/web/src/lib/timeline/element-utils.ts +++ b/apps/web/src/lib/timeline/element-utils.ts @@ -114,6 +114,56 @@ export function wouldElementOverlap({ }); } +export function findAvailableVideoTrackAbove({ + tracks, + sourceTrackId, + startTime, + endTime, +}: { + tracks: TimelineTrack[]; + sourceTrackId: string; + startTime: number; + endTime: number; +}): string | null { + const sourceIndex = tracks.findIndex((track) => track.id === sourceTrackId); + + for (let index = sourceIndex - 1; index >= 0; index--) { + const track = tracks[index]; + if ( + track.type === "video" && + !track.isMain && + !track.hidden && + !wouldElementOverlap({ elements: track.elements, startTime, endTime }) + ) { + return track.id; + } + } + + return null; +} + +export function getVisualSourceTime({ + timelineTime, + startTime, + duration, + trimStart, + playbackRate = 1, + reversed = false, +}: { + timelineTime: number; + startTime: number; + duration: number; + trimStart: number; + playbackRate?: number; + reversed?: boolean; +}): number { + const elapsed = timelineTime - startTime; + if (!reversed) return trimStart + elapsed * playbackRate; + + const sourceTime = trimStart + playbackRate * (duration - elapsed); + return elapsed === 0 ? Math.max(trimStart, sourceTime - 1e-6) : sourceTime; +} + export function buildTextElement({ raw, startTime, diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts index 3b0f4bb..68bbd9c 100644 --- a/apps/web/src/services/renderer/nodes/visual-node.ts +++ b/apps/web/src/services/renderer/nodes/visual-node.ts @@ -1,5 +1,6 @@ import type { CanvasRenderer } from "../canvas-renderer"; import { BaseNode } from "./base-node"; +import { getVisualSourceTime } from "@/lib/timeline/element-utils"; import type { Transform } from "@/types/timeline"; const VISUAL_EPSILON = 1 / 1000; @@ -19,12 +20,14 @@ export abstract class VisualNode< Params extends VisualNodeParams = VisualNodeParams, > extends BaseNode { protected getLocalTime(time: number): number { - const rate = this.params.playbackRate ?? 1; - const elapsed = time - this.params.timeOffset; - if (this.params.reversed) { - return this.params.trimStart + rate * (this.params.duration - elapsed); - } - return this.params.trimStart + elapsed * rate; + return getVisualSourceTime({ + timelineTime: time, + startTime: this.params.timeOffset, + duration: this.params.duration, + trimStart: this.params.trimStart, + playbackRate: this.params.playbackRate, + reversed: this.params.reversed, + }); } protected isInRange(time: number): boolean { diff --git a/docs/superpowers/plans/2026-08-28-freeze-frame.md b/docs/superpowers/plans/2026-08-28-freeze-frame.md new file mode 100644 index 0000000..ae1d2c0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-freeze-frame.md @@ -0,0 +1,155 @@ +# Freeze Frame Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Capture the selected video's displayed source frame as a reusable PNG and insert a three-second image clip on the nearest available video track above it as one undoable edit. + +**Architecture:** Reuse the existing media asset, `ImageElement`, Mediabunny, and command batch paths. Put only the two non-trivial decisions in existing timeline domain utilities: source-time mapping and target-track lookup. The action owns validation, capture, persistence, batch construction, feedback, and selection. + +**Tech Stack:** TypeScript, React, Bun test, Mediabunny, existing EditorCore managers and commands. + +--- + +### Task 1: Protect frame-time and track-placement semantics + +**Files:** +- Create: `apps/web/src/lib/timeline/__tests__/freeze-frame.test.ts` +- Modify: `apps/web/src/lib/timeline/element-utils.ts` +- Modify: `apps/web/src/services/renderer/nodes/visual-node.ts` + +**Step 1: Write the failing tests** + +Add literal expectations for forward, trimmed, speed-adjusted, and reversed source times. Add track fixtures proving the nearest free video track above is chosen, occupied/non-video tracks are skipped, and `null` is returned when a new track is required. + +```ts +expect(getVisualSourceTime({ timelineTime: 7, startTime: 5, duration: 8, trimStart: 3, playbackRate: 2 })).toBe(7); +expect(findAvailableVideoTrackAbove({ tracks, sourceTrackId: "source", startTime: 4, endTime: 7 })).toBe("nearest-free"); +``` + +**Step 2: Run the focused test and verify RED** + +Run: `bun test apps/web/src/lib/timeline/__tests__/freeze-frame.test.ts` + +Expected: FAIL because both exports do not exist. + +**Step 3: Add the minimum domain functions** + +In `element-utils.ts`, add: + +```ts +export function getVisualSourceTime({ timelineTime, startTime, duration, trimStart, playbackRate = 1, reversed = false }: ...): number; +export function findAvailableVideoTrackAbove({ tracks, sourceTrackId, startTime, endTime }: ...): string | null; +``` + +The track search walks from `sourceIndex - 1` toward index `0`, filters to video tracks, and reuses `wouldElementOverlap`. + +Replace `VisualNode.getLocalTime`'s duplicate formula with `getVisualSourceTime`, mapping `timeOffset` to `startTime`. + +**Step 4: Run the focused test and verify GREEN** + +Run: `bun test apps/web/src/lib/timeline/__tests__/freeze-frame.test.ts` + +Expected: PASS. + +### Task 2: Add native-resolution PNG extraction + +**Files:** +- Modify: `apps/web/src/lib/media/processing.ts` + +**Step 1: Add one browser-facing extraction function** + +Reuse the existing Mediabunny `Input`, `VideoSampleSink`, decode validation, and frame cleanup. Draw the returned sample to a canvas sized to `videoTrack.displayWidth` by `displayHeight`, encode it with `canvas.toBlob(..., "image/png")`, and return `{ file, width, height }`. + +```ts +export async function extractVideoFrame({ videoFile, timeInSeconds, fileName }: ...): Promise<{ file: File; width: number; height: number }>; +``` + +Keep thumbnail generation unchanged; do not resize or encode JPEG for freeze frames. + +**Step 2: Type-check through the web build after action integration** + +The function depends on real browser canvas and Mediabunny decoding, so verify it through the application build and manual browser capture rather than a synthetic decoder mock. + +### Task 3: Implement the atomic freeze-frame action + +**Files:** +- Modify: `apps/web/src/lib/actions/definitions.ts` +- Modify: `apps/web/src/lib/actions/types.ts` +- Modify: `apps/web/src/hooks/actions/use-editor-actions.ts` + +**Step 1: Define one optional-argument action** + +Add `freeze-frame` in `ACTIONS` and map its arguments to: + +```ts +"freeze-frame": { trackId: string; elementId: string } | undefined; +``` + +Explicit arguments select the context-menu source; absent arguments require exactly one selected video. + +**Step 2: Validate and capture before mutation** + +In `useEditorActions`, add a `useRef(false)` in-flight guard. Resolve the source, require `startTime <= playhead < startTime + duration`, require the source media `File`, calculate local time with `getVisualSourceTime`, and call `extractVideoFrame`. Show one loading toast and replace it with success, warning, or error feedback. + +**Step 3: Persist and commit one command batch** + +Create `AddMediaAssetCommand` first to reserve the media ID, pre-save that exact asset through `storageService.saveMediaAsset`, then build: + +```ts +new BatchCommand([ + addMediaCommand, + ...(targetTrackId ? [] : [addTrackCommand]), + insertElementCommand, +]); +``` + +Use `findAvailableVideoTrackAbove`; if it returns `null`, insert an `AddTrackCommand("video", sourceTrackIndex)` immediately above the source. Build a normal three-second image element, copying the source transform and opacity, and insert explicitly into the chosen track. On synchronous commit failure, delete the pre-saved asset and revoke its object URL. On success, select the inserted element. Undo/redo then reuse the command-retained `File` and IDs without re-decoding. + +### Task 4: Wire both user entry points + +**Files:** +- Modify: `apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx` +- Modify: `apps/web/src/components/editor/panels/timeline/timeline-element.tsx` + +**Step 1: Enable the snowflake toolbar action** + +Subscribe to element selection, resolve exactly one selected video, and enable the existing `SnowIcon` button only while the playhead is inside it. Invoke `freeze-frame` without arguments and label the tooltip `Freeze frame`. + +**Step 2: Add the video context-menu item** + +For every video element, add a `Freeze frame` item with `SnowIcon`. Invoke the same action with the clicked `{ trackId, elementId }`, independent of current selection. + +### Task 5: Verify the complete change + +**Files:** +- Verify all modified files + +**Step 1: Run focused and complete tests** + +Run: + +```bash +bun test apps/web/src/lib/timeline/__tests__/freeze-frame.test.ts +bun test +``` + +Expected: all pass with no warnings or unhandled errors. + +**Step 2: Run static verification** + +Run: + +```bash +bun run lint:web +bun run build:web +``` + +Expected: both exit successfully. + +**Step 3: Inspect the final diff** + +Run `git diff --check`, `git status --short`, and review the diff for source-video mutation, new dependencies, placeholder code, and accidental unrelated changes. + +**Step 4: Manual browser verification when a usable local project is available** + +Verify toolbar and context-menu captures, native PNG asset creation, three-second placement, copied transform/opacity, stretch behavior, undo/redo, and no partial output after a forced extraction/storage failure. If runtime state or a fixture is unavailable, report this separately from the verified static results. diff --git a/docs/superpowers/specs/2026-08-28-freeze-frame-design.md b/docs/superpowers/specs/2026-08-28-freeze-frame-design.md new file mode 100644 index 0000000..8215296 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-freeze-frame-design.md @@ -0,0 +1,174 @@ +# Freeze Frame Design Spec + +## Overview + +Add a freeze-frame action that turns the frame shown by one video clip at the +playhead into a reusable image asset and automatically inserts a three-second +image clip on the timeline. + +The implementation reuses the existing media asset, `ImageElement`, timeline, +preview, and export paths. It does not add a freeze-frame element type. + +## Goals + +- Capture the displayed source frame at the playhead at the video's native + resolution. +- Save the result as a normal reusable image asset. +- Insert a three-second image clip aligned with the playhead. +- Let the image clip be extended without a source-duration limit. +- Leave the source video unchanged. +- Treat asset creation and timeline insertion as one undoable operation. + +## Non-goals + +- Splitting, trimming, moving, or extending the source video. +- Ripple-editing later clips. +- Capturing the composited preview canvas, subtitles, or other tracks. +- Adding a dedicated freeze-frame renderer or timeline element type. +- Deduplicating repeated captures of the same frame. + +## User Interaction + +### Entry points + +Both entry points invoke a single `freeze-frame` action: + +- The Freeze item in a video clip's context menu passes the clicked track and + element as the source. +- The snowflake toolbar button uses the currently selected video clip. + +The toolbar action is available only when exactly one video clip is selected. +For either entry point, the playhead must satisfy +`startTime <= playhead < startTime + duration`. Otherwise the action does not +run and gives validation feedback. + +While a capture is running, another freeze request is ignored and the loading +toast remains visible. Success selects the inserted image clip without opening +or switching the media panel. Failure replaces the loading toast with an error +and leaves no new asset or clip. + +### Result + +- The generated clip starts at the playhead and lasts three seconds. +- It copies the source video's transform and opacity, including position, + scale, rotation, and flips. The generated asset itself remains an untransformed + source frame. +- The source video is not split, moved, trimmed, or otherwise changed. +- Repeating the action later creates another independent image asset. + +## Frame Selection + +The frame timestamp must match the renderer's existing local-time semantics. +For timeline time `t`: + +```text +elapsed = t - video.startTime +rate = video.playbackRate ?? 1 + +forward: video.trimStart + elapsed * rate +reverse: video.trimStart + rate * (video.duration - elapsed) +``` + +The calculation should live in shared timeline domain logic and be used by +both the video renderer and freeze-frame capture so trim, speed changes, and +reverse playback cannot drift between preview and capture. + +Use the existing Mediabunny frame decoding path to obtain the sample displayed +at that timestamp. Draw it at its native dimensions and encode it as a lossless +PNG. Do not reuse the existing thumbnail output because thumbnails are reduced +to 1280x720 and JPEG quality 0.8. + +The resulting `File` and `MediaAsset` use: + +- MIME type `image/png` +- native frame width and height +- a name based on the source clip and capture timestamp +- no `ephemeral` flag, so the asset remains visible and reusable in the media + library + +## Timeline Placement + +The target interval is `[playhead, playhead + 3 seconds)`. + +Starting with the track immediately above the source track, search upward for +the nearest video track whose elements do not overlap that interval. Insert the +image there. Tracks below the source are not candidates. + +If no existing video track above is available, create a non-main video track +immediately above the source track and insert the image into it. This preserves +the requested visual stacking and never shifts existing elements in time. + +The inserted value is a regular `ImageElement` built through the existing image +element helper, with: + +- `mediaId` referencing the generated asset +- `startTime` equal to the playhead +- `duration` equal to three seconds +- zero trims +- source transform and opacity copied onto the image +- `hidden` set to false + +Existing image resize behavior already has no media-duration ceiling, so no +special extension logic is required. + +## Action and Command Flow + +```text +context menu / snowflake button + -> freeze-frame action + -> validate source and playhead + -> decode native frame and create PNG File + -> persist the asset + -> execute one command batch + add image asset + optionally add video track above source + insert ImageElement + -> select inserted element +``` + +Frame decoding and the initial storage write finish before timeline state is +mutated. If either fails, the command is never committed. + +The media addition, optional track addition, and element insertion are grouped +under one history entry using the existing command system. Undo removes the +clip, any track created solely for it, and the generated asset. Redo restores +the same asset and element IDs from the retained `File`; it does not decode the +video again. + +If a synchronous command commit unexpectedly fails after persistence, delete +the newly persisted asset before reporting the error. + +## Rendering and Export + +No new rendering behavior is required. Preview and export already resolve +`ImageElement.mediaId` and render an image for the element's timeline duration. +Because stretching changes only the image element duration, a single generated +asset can fill an arbitrarily long gap without duplicating image data. + +## Validation + +Add one focused test module covering the non-trivial domain decisions: + +- local frame time for normal, trimmed, speed-adjusted, and reversed video +- nearest non-overlapping video track above the source, including creation of a + new track when none is available + +Then verify manually that both UI entry points create the same result, the +source video remains unchanged, the image can be stretched, preview/export show +the still frame, one undo removes both outputs, redo restores them, and a forced +decode/storage failure leaves no partial result. + +## Alternatives Rejected + +### Dedicated freeze-frame element + +Keeping a video reference and source timestamp in a new element would require +changes across timeline types, preview, export, serialization, and media +generation. It adds no value after a reusable image asset has already been +requested. + +### Frozen range inside a video element + +A frozen range fits a hold-frame effect but not a standalone image asset or an +independent clip intended to fill arbitrary gaps. It would also couple the +result to the source video's lifetime.