summaryrefslogtreecommitdiff
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/src/actions/definitions.ts4
-rw-r--r--apps/web/src/actions/use-editor-actions.ts92
-rw-r--r--apps/web/src/core/managers/renderer-manager.ts35
-rw-r--r--apps/web/src/media/processing.ts2
-rw-r--r--apps/web/src/timeline/components/timeline-toolbar.tsx18
5 files changed, 142 insertions, 9 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();
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<SnapshotResult> {
+ async captureFrame(): Promise<Blob | null> {
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<Blob | null>((resolve) => {
+ return await new Promise<Blob | null>((resolve) => {
tempCanvas.toBlob((result) => resolve(result), "image/png");
});
+ } catch (error) {
+ console.error("Frame capture failed:", error);
+ return null;
+ }
+ }
+
+ private async createSnapshot(): Promise<SnapshotResult> {
+ 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() {
<ToolbarButton
icon={<HugeiconsIcon icon={SnowIcon} />}
- tooltip="Freeze frame (coming soon)"
- disabled={true}
- onClick={({ event: _event }) => {}}
+ tooltip="Freeze frame"
+ disabled={!canFreezeFrame}
+ onClick={({ event }) => handleAction({ action: "freeze-frame", event })}
/>
<ToolbarButton