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.