From 9c3b788b47cc639ee463dd07e1a4cce91e6376cc Mon Sep 17 00:00:00 2001 From: cashmere Date: Mon, 27 Jul 2026 01:21:29 +0200 Subject: feat: wire versioning/transfer/voice-over actions and header UI Shared wiring for the six roadmap slices in one commit: action ids and handlers for create-checkpoint, toggle-versions-panel, export-project-archive, and voice-over; the freeze-frame handler awaits the duration prompt before capture; editor header gains the versions popover and export menu item (and loses the feedback popover and Discord item, part of the branding strip). --- apps/web/src/actions/definitions.ts | 16 +++ apps/web/src/actions/use-editor-actions.ts | 125 ++++++++++++++++++++++- apps/web/src/components/editor/editor-header.tsx | 26 ++--- 3 files changed, 149 insertions(+), 18 deletions(-) (limited to 'apps/web') diff --git a/apps/web/src/actions/definitions.ts b/apps/web/src/actions/definitions.ts index eed9c378..f6c05b7d 100644 --- a/apps/web/src/actions/definitions.ts +++ b/apps/web/src/actions/definitions.ts @@ -152,6 +152,22 @@ export const ACTIONS = { category: "assets", args: { projectId: "string", assetIds: "string[]" }, }, + "voice-over": { + description: "Record voice-over at playhead", + category: "editing", + }, + "create-checkpoint": { + description: "Create named checkpoint", + category: "history", + }, + "toggle-versions-panel": { + description: "Toggle versions panel", + category: "history", + }, + "export-project-archive": { + description: "Export project as single file (.ocp)", + category: "controls", + }, } as const satisfies Record; export type TAction = keyof typeof ACTIONS; diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index 598e73b0..c99f65e2 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -28,6 +28,7 @@ import { import { useCommittedRef } from "@/hooks/use-committed-ref"; import { toast } from "sonner"; import { generateImageThumbnail } from "@/media/processing"; +import { useFreezeFrameStore } from "@/actions/freeze-frame-store"; import { AddMediaAssetCommand, BatchCommand, @@ -35,6 +36,16 @@ import { ShiftSplitRemainderCommand, SplitElementsCommand, } from "@/commands"; +import { useVersionsStore } from "@/versions/versions-store"; +import { + cancelVoiceOverRecording, + getFileExtensionForMimeType, + getVoiceOverErrorMessage, + startVoiceOverRecording, + stopVoiceOverRecording, +} from "@/voiceover/recorder"; +import type { VoiceOverRecordingResult } from "@/voiceover/recorder"; +import { useVoiceOverStore } from "@/voiceover/voiceover-store"; export function useEditorActions() { const editor = useEditor(); @@ -46,6 +57,8 @@ export function useEditorActions() { const toggleSnapping = useTimelineStore((s) => s.toggleSnapping); const rippleEditingEnabled = useTimelineStore((s) => s.rippleEditingEnabled); const toggleRippleEditing = useTimelineStore((s) => s.toggleRippleEditing); + const openCheckpointDialog = useVersionsStore((s) => s.openCheckpointDialog); + const toggleVersionsPanel = useVersionsStore((s) => s.togglePanel); const hasTimelineSelection = selectedElements.length > 0 || selectedKeyframes.length > 0 || @@ -408,6 +421,13 @@ export function useEditorActions() { return; } + const freezeDurationSeconds = await useFreezeFrameStore + .getState() + .requestDuration(); + if (freezeDurationSeconds === null) { + return; + } + const blob = await editor.renderer.captureFrame(); if (!blob) { toast.error("Failed to capture frame"); @@ -421,7 +441,9 @@ export function useEditorActions() { imageFile: file, }); - const freezeDuration = mediaTimeFromSeconds({ seconds: 3 }); + const freezeDuration = mediaTimeFromSeconds({ + seconds: freezeDurationSeconds, + }); const addMediaCommand = new AddMediaAssetCommand({ projectId: activeProject.metadata.id, asset: { @@ -611,4 +633,105 @@ export function useEditorActions() { }, undefined, ); + + useActionHandler( + "create-checkpoint", + () => { + openCheckpointDialog(); + }, + undefined, + ); + + useActionHandler( + "toggle-versions-panel", + () => { + toggleVersionsPanel(); + }, + undefined, + ); + + useActionHandler( + "voice-over", + async () => { + const voiceOverState = useVoiceOverStore.getState(); + + if (voiceOverState.status === "idle") { + try { + await startVoiceOverRecording(); + } catch (error) { + toast.error(getVoiceOverErrorMessage({ error })); + return; + } + useVoiceOverStore.getState().startRecording({ + playheadTime: editor.playback.getCurrentTime(), + }); + return; + } + + useVoiceOverStore.getState().stopRecording(); + + const activeProject = editor.project.getActive(); + if (!activeProject) { + cancelVoiceOverRecording(); + return; + } + + let recording: VoiceOverRecordingResult; + try { + recording = await stopVoiceOverRecording(); + } catch (error) { + toast.error(getVoiceOverErrorMessage({ error })); + return; + } + + if (recording.blob.size === 0) { + toast.error("Voice-over recording is empty"); + return; + } + + const file = new File( + [recording.blob], + `voice-over.${getFileExtensionForMimeType({ mimeType: recording.mimeType })}`, + { type: recording.mimeType }, + ); + const addMediaCommand = new AddMediaAssetCommand({ + projectId: activeProject.metadata.id, + asset: { + file, + name: "Voice over", + type: "audio", + url: URL.createObjectURL(file), + duration: recording.durationSeconds, + }, + }); + const insertCommand = new InsertElementCommand({ + element: buildElementFromMedia({ + mediaId: addMediaCommand.getAssetId(), + mediaType: "audio", + name: "Voice over", + duration: mediaTimeFromSeconds({ + seconds: recording.durationSeconds, + }), + startTime: + voiceOverState.playheadStartTime ?? + editor.playback.getCurrentTime(), + }), + placement: { mode: "auto", trackType: "audio" }, + }); + editor.command.execute({ + command: new BatchCommand([addMediaCommand, insertCommand]), + }); + }, + undefined, + ); + + useActionHandler( + "export-project-archive", + () => { + const projectId = editor.project.getActiveOrNull()?.metadata.id; + if (!projectId) return; + void editor.project.exportArchive({ id: projectId }); + }, + undefined, + ); } diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx index 6404dc01..37a37a68 100644 --- a/apps/web/src/components/editor/editor-header.tsx +++ b/apps/web/src/components/editor/editor-header.tsx @@ -6,24 +6,21 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "../ui/dropdown-menu"; -import Link from "next/link"; import { RenameProjectDialog } from "@/project/components/rename-project-dialog"; import { DeleteProjectDialog } from "@/project/components/delete-project-dialog"; import { useRouter } from "next/navigation"; -import { FaDiscord } from "react-icons/fa6"; import { ExportButton } from "./export-button"; -import { FeedbackPopover } from "@/feedback/components/feedback-popover"; +import { VersionsButton } from "@/versions/components/versions-popover"; import { ThemeToggle } from "../theme-toggle"; import { DEFAULT_LOGO_URL } from "@/site/brand"; -import { SOCIAL_LINKS } from "@/site/social"; import { toast } from "sonner"; import { useEditor } from "@/editor/use-editor"; -import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons"; +import { CommandIcon, Download01Icon, Logout05Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { ShortcutsDialog } from "@/actions/components/shortcuts-dialog"; +import { invokeAction } from "@/actions"; import Image from "next/image"; import { cn } from "@/utils/ui"; @@ -35,7 +32,7 @@ export function EditorHeader() { @@ -137,16 +134,11 @@ function ProjectDropdown() { Shortcuts - - - }> - - Discord - + invokeAction("export-project-archive")} + icon={} + > + Export project -- cgit v1.3.1