summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/actions/definitions.ts16
-rw-r--r--apps/web/src/actions/use-editor-actions.ts125
-rw-r--r--apps/web/src/components/editor/editor-header.tsx26
3 files changed, 149 insertions, 18 deletions
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<string, TActionBaseDefinition>;
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() {
<EditableProjectName />
</div>
<nav className="flex items-center gap-2">
- <FeedbackPopover />
+ <VersionsButton />
<ExportButton />
<ThemeToggle />
</nav>
@@ -137,16 +134,11 @@ function ProjectDropdown() {
Shortcuts
</DropdownMenuItem>
- <DropdownMenuSeparator />
-
- <DropdownMenuItem asChild icon={<FaDiscord className="size-4!" />}>
- <Link
- href={SOCIAL_LINKS.discord}
- target="_blank"
- rel="noopener noreferrer"
- >
- Discord
- </Link>
+ <DropdownMenuItem
+ onClick={() => invokeAction("export-project-archive")}
+ icon={<HugeiconsIcon icon={Download01Icon} />}
+ >
+ Export project
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>