From e837e7504379e100ff3b56282577ec67bb88bec9 Mon Sep 17 00:00:00 2001 From: cashmere Date: Tue, 28 Jul 2026 19:41:14 +0200 Subject: feat: ripple-extend resize, timeline filmstrip, export hardening Yesterday's verified web session (gates green: 316 tests, tsc, build): - ripple-extend pushes right-hand chain on static-element resize - per-position video filmstrip thumbnails with zoom-adaptive density - fps-aware export bitrate, 2s keyframes, even-dim export canvas - freeze frame uses fixed 3s duration, prompt removed --- .../actions/__tests__/freeze-frame-store.test.ts | 98 ------------------ .../src/actions/components/freeze-frame-dialog.tsx | 109 --------------------- apps/web/src/actions/freeze-frame-store.ts | 72 -------------- apps/web/src/actions/use-editor-actions.ts | 12 +-- 4 files changed, 3 insertions(+), 288 deletions(-) delete mode 100644 apps/web/src/actions/__tests__/freeze-frame-store.test.ts delete mode 100644 apps/web/src/actions/components/freeze-frame-dialog.tsx delete mode 100644 apps/web/src/actions/freeze-frame-store.ts (limited to 'apps/web/src/actions') diff --git a/apps/web/src/actions/__tests__/freeze-frame-store.test.ts b/apps/web/src/actions/__tests__/freeze-frame-store.test.ts deleted file mode 100644 index 94e1566e..00000000 --- a/apps/web/src/actions/__tests__/freeze-frame-store.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { beforeEach, describe, expect, test } from "bun:test"; -import { - DEFAULT_FREEZE_DURATION_SECONDS, - isValidFreezeDuration, - MAX_FREEZE_DURATION_SECONDS, - useFreezeFrameStore, -} from "../freeze-frame-store"; - -function resetStore() { - useFreezeFrameStore.setState({ - isOpen: false, - lastDurationSeconds: DEFAULT_FREEZE_DURATION_SECONDS, - resolver: null, - }); -} - -describe("freeze-frame store", () => { - beforeEach(() => { - resetStore(); - }); - - describe("isValidFreezeDuration", () => { - test("accepts positive durations up to the max, including decimals", () => { - expect(isValidFreezeDuration({ seconds: 3 })).toBe(true); - expect(isValidFreezeDuration({ seconds: 2.5 })).toBe(true); - expect(isValidFreezeDuration({ seconds: 0.1 })).toBe(true); - expect( - isValidFreezeDuration({ seconds: MAX_FREEZE_DURATION_SECONDS }), - ).toBe(true); - }); - - test("rejects non-positive, over-max, and non-finite durations", () => { - expect(isValidFreezeDuration({ seconds: 0 })).toBe(false); - expect(isValidFreezeDuration({ seconds: -1 })).toBe(false); - expect(isValidFreezeDuration({ seconds: 600.1 })).toBe(false); - expect(isValidFreezeDuration({ seconds: Number.NaN })).toBe(false); - expect(isValidFreezeDuration({ seconds: Number.POSITIVE_INFINITY })).toBe( - false, - ); - }); - }); - - describe("requestDuration", () => { - test("opens the dialog and resolves with the confirmed seconds", async () => { - const promise = useFreezeFrameStore.getState().requestDuration(); - expect(useFreezeFrameStore.getState().isOpen).toBe(true); - - useFreezeFrameStore.getState().confirmDuration({ seconds: 5 }); - - await expect(promise).resolves.toBe(5); - expect(useFreezeFrameStore.getState().isOpen).toBe(false); - }); - - test("remembers the confirmed duration as the last-used value", async () => { - const promise = useFreezeFrameStore.getState().requestDuration(); - useFreezeFrameStore.getState().confirmDuration({ seconds: 5 }); - await promise; - - expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe(5); - }); - - test("resolves null on cancel and keeps the last-used duration", async () => { - useFreezeFrameStore.setState({ lastDurationSeconds: 7 }); - - const promise = useFreezeFrameStore.getState().requestDuration(); - expect(useFreezeFrameStore.getState().isOpen).toBe(true); - - useFreezeFrameStore.getState().cancelDuration(); - - await expect(promise).resolves.toBeNull(); - expect(useFreezeFrameStore.getState().isOpen).toBe(false); - expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe(7); - }); - - test("ignores an invalid confirm and leaves the dialog open", () => { - useFreezeFrameStore.getState().requestDuration(); - - useFreezeFrameStore.getState().confirmDuration({ seconds: 0 }); - useFreezeFrameStore.getState().confirmDuration({ seconds: 601 }); - - expect(useFreezeFrameStore.getState().isOpen).toBe(true); - expect(useFreezeFrameStore.getState().lastDurationSeconds).toBe( - DEFAULT_FREEZE_DURATION_SECONDS, - ); - }); - - test("a new request supersedes a pending one by cancelling it", async () => { - const first = useFreezeFrameStore.getState().requestDuration(); - const second = useFreezeFrameStore.getState().requestDuration(); - - await expect(first).resolves.toBeNull(); - expect(useFreezeFrameStore.getState().isOpen).toBe(true); - - useFreezeFrameStore.getState().confirmDuration({ seconds: 2 }); - await expect(second).resolves.toBe(2); - }); - }); -}); diff --git a/apps/web/src/actions/components/freeze-frame-dialog.tsx b/apps/web/src/actions/components/freeze-frame-dialog.tsx deleted file mode 100644 index a6a7e0ae..00000000 --- a/apps/web/src/actions/components/freeze-frame-dialog.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { - isValidFreezeDuration, - MAX_FREEZE_DURATION_SECONDS, - useFreezeFrameStore, -} from "@/actions/freeze-frame-store"; - -export function FreezeFrameDialog() { - const isOpen = useFreezeFrameStore((state) => state.isOpen); - const cancelDuration = useFreezeFrameStore((state) => state.cancelDuration); - - return ( - { - if (!open) { - cancelDuration(); - } - }} - > - - - Freeze frame - - How long should the frozen frame stay on screen? - - - {isOpen && } - - - ); -} - -function FreezeFrameForm() { - const lastDurationSeconds = useFreezeFrameStore( - (state) => state.lastDurationSeconds, - ); - const confirmDuration = useFreezeFrameStore((state) => state.confirmDuration); - const cancelDuration = useFreezeFrameStore((state) => state.cancelDuration); - const [value, setValue] = useState(String(lastDurationSeconds)); - - const seconds = Number(value); - const showError = value.trim() !== "" && !isValidFreezeDuration({ seconds }); - const canConfirm = isValidFreezeDuration({ seconds }); - - const handleConfirm = () => { - if (canConfirm) { - confirmDuration({ seconds }); - } - }; - - return ( - <> - - - setValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleConfirm(); - } - }} - placeholder="e.g. 2.5" - /> - {showError && ( -

- Enter a duration greater than 0 and at most{" "} - {MAX_FREEZE_DURATION_SECONDS} seconds. -

- )} -
- - - - - - - ); -} diff --git a/apps/web/src/actions/freeze-frame-store.ts b/apps/web/src/actions/freeze-frame-store.ts deleted file mode 100644 index 17102780..00000000 --- a/apps/web/src/actions/freeze-frame-store.ts +++ /dev/null @@ -1,72 +0,0 @@ -"use client"; - -import { create } from "zustand"; -import { persist } from "zustand/middleware"; - -export const DEFAULT_FREEZE_DURATION_SECONDS = 3; -export const MAX_FREEZE_DURATION_SECONDS = 600; - -export function isValidFreezeDuration({ - seconds, -}: { - seconds: number; -}): boolean { - return ( - Number.isFinite(seconds) && - seconds > 0 && - seconds <= MAX_FREEZE_DURATION_SECONDS - ); -} - -type DurationResolver = ({ seconds }: { seconds: number | null }) => void; - -interface FreezeFrameState { - isOpen: boolean; - lastDurationSeconds: number; - resolver: DurationResolver | null; - requestDuration: () => Promise; - confirmDuration: ({ seconds }: { seconds: number }) => void; - cancelDuration: () => void; -} - -export const useFreezeFrameStore = create()( - persist( - (set, get) => ({ - isOpen: false, - lastDurationSeconds: DEFAULT_FREEZE_DURATION_SECONDS, - resolver: null, - - requestDuration: () => - new Promise((resolve) => { - // A new invocation supersedes any pending one: resolve it as - // cancelled so its handler aborts instead of hanging. - get().resolver?.({ seconds: null }); - set({ - isOpen: true, - resolver: ({ seconds }) => resolve(seconds), - }); - }), - confirmDuration: ({ seconds }) => { - if (!isValidFreezeDuration({ seconds })) { - return; - } - get().resolver?.({ seconds }); - set({ - isOpen: false, - resolver: null, - lastDurationSeconds: seconds, - }); - }, - cancelDuration: () => { - get().resolver?.({ seconds: null }); - set({ isOpen: false, resolver: null }); - }, - }), - { - name: "freeze-frame-store", - partialize: (state) => ({ - lastDurationSeconds: state.lastDurationSeconds, - }), - }, - ), -); diff --git a/apps/web/src/actions/use-editor-actions.ts b/apps/web/src/actions/use-editor-actions.ts index c99f65e2..ca16e989 100644 --- a/apps/web/src/actions/use-editor-actions.ts +++ b/apps/web/src/actions/use-editor-actions.ts @@ -28,7 +28,6 @@ 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, @@ -47,6 +46,8 @@ import { import type { VoiceOverRecordingResult } from "@/voiceover/recorder"; import { useVoiceOverStore } from "@/voiceover/voiceover-store"; +const FREEZE_DURATION_SECONDS = 3; + export function useEditorActions() { const editor = useEditor(); const { selectedElements, setElementSelection } = useElementSelection(); @@ -421,13 +422,6 @@ 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"); @@ -442,7 +436,7 @@ export function useEditorActions() { }); const freezeDuration = mediaTimeFromSeconds({ - seconds: freezeDurationSeconds, + seconds: FREEZE_DURATION_SECONDS, }); const addMediaCommand = new AddMediaAssetCommand({ projectId: activeProject.metadata.id, -- cgit v1.3.1