diff --git a/docs/DATADOG.md b/docs/DATADOG.md index 99b7662a5..ea94b72dc 100644 --- a/docs/DATADOG.md +++ b/docs/DATADOG.md @@ -47,6 +47,7 @@ its own, so we can see which browser and device combinations fail. | `export_failed` | Terminal | the context, plus `engine`, `durationMs`, `stage` (`engine` / `encode`), `category`, and `stall_reason` for stalls | | `export_cancelled` | Terminal: the user cancelled | the context, plus `engine`, `durationMs`, `stage`, `stuck` (true when progress hadn't changed for 60 s: the user gave up on a hang, and monitors count it as a failure) | | `engine_fallback` | The multi-threaded engine stalled; the same export restarts automatically on the single-threaded engine. Not terminal: the attempt's outcome follows | the context, plus `from: multi`, `to: single`, `stall_reason` | +| `feature_used` | An editing tool was used (anonymous counts) | `feature` (`export_profile`, `safe_area`), `action` (`apply`/`save`/`rename`/`delete`, `show`/`hide`), and for profiles `profile`: a built-in slug or `custom` (never a profile's name). Defined in `src/lib/featureTelemetry.ts` | | `ffmpeg_load_success` | A worker loaded the engine (once per worker) | `engine` (`multi` / `single`, what actually loaded), `engine_requested`, `single_reason` (`not_isolated` / `after_stall` / `mt_load_failed`, single-threaded only), `load_number` (`first` / `repeat`), `durationMs` | | `ffmpeg_load_failure` | A worker failed to load (not sent when the user cancelled) | `engine_requested`, `load_number`, `category` (`network` / `integrity` / `wasm` / `worker_script` / `unknown`), `durationMs` | | `export` (duration vital) | With `export_succeeded`; encoding time only | the context, plus `engine` | diff --git a/docs/EXPORT_PROFILES.md b/docs/EXPORT_PROFILES.md new file mode 100644 index 000000000..109f486f8 --- /dev/null +++ b/docs/EXPORT_PROFILES.md @@ -0,0 +1,127 @@ +# Export profiles + +A profile is a named, reusable **output shape**: size, framing, container format and quality. Applying one changes how the video is encoded, and nothing about what is in it. + +## Data model + +```ts +interface ProfileSettings { + preset: string; // a PRESETS id, or "custom" + customWidth: number; // used when preset === "custom"; 16–7680 + customHeight: number; // used when preset === "custom"; 16–7680 + framing: "fit" | "fill"; // letterbox or crop to the output shape + format: "mp4" | "webm" | "mkv" | "gif"; + quality: number; // CRF 18–30 (not used for GIF) +} + +interface ExportProfile { + id: string; // "builtin:" or "user:" + name: string; // 1–60 characters, unique (case-insensitive) + builtIn: boolean; + settings: ProfileSettings; +} +``` + +**What a profile never contains.** +- Trim points, rotation, speed, audio settings, colour adjustments, text or image overlays. +- The source video, or anything about it. + +Applying a profile therefore can't change any of those. The one exception is the existing editor rule that GIF has no audio, which turns `keepAudio` off when `format` becomes `gif`, exactly as choosing GIF by hand does. Undo restores it. + +**Output size.** The encoded size comes from `resolveOutputSize` in `src/lib/ffmpegArgs.ts`, the function the FFmpeg worker uses. It takes the preset's size or the custom size, rounded to even numbers. The UI, the tests and the export all read that function. + +### Built-in profiles + +Named by shape and size, not by platform, so Reframe doesn't imply endorsement. Each maps to an existing preset, so the size and framing controls show the matching preset as selected. + +| Profile | Preset | Output | Aspect | Format | Quality (CRF) | Framing | +|---|---|---|---|---|---|---| +| Landscape 1080p | `landscape-16-9` | 1920 × 1080 | 16 : 9 | MP4 | 23 | Fit | +| Landscape 720p | `twitter-hd` | 1280 × 720 | 16 : 9 | MP4 | 23 | Fit | +| Square 1080 | `square-1-1` | 1080 × 1080 | 1 : 1 | MP4 | 23 | Fit | +| Portrait 4 : 5 | `instagram-4-5` | 1080 × 1350 | 4 : 5 | MP4 | 23 | Fit | +| Portrait 9 : 16 | `vertical-9-16` | 1080 × 1920 | 9 : 16 | MP4 | 23 | Fit | + +Built-ins use **Fit**, so applying one never crops the picture without the user choosing to. Switching to Fill shows the crop in the preview before export. + +### Storage + +- **Where.** User profiles live in `localStorage` under `reframe:export-profiles`, as `{ schemaVersion: 1, profiles: ExportProfile[] }`. Built-ins are code, not stored. +- **Limits.** At most 30 user profiles. +- **Validation.** Every read goes through a strict parser, like project files: unknown keys are dropped, a wrong type or out-of-range value rejects the profile, and an unknown preset id or format rejects it too. +- **Upgrading and failures.** + - A newer `schemaVersion` is never overwritten. + - Older versions go through a `MIGRATIONS` table. + - If storage is unavailable or full, profiles still work for the session and the UI says they couldn't be saved. + +### Format rules (validated, shown in the UI) + +| Combination | Handling | +|---|---| +| GIF + audio | Audio is dropped (existing rule); the profile notes "GIF has no audio" | +| GIF + quality | CRF is ignored by the GIF encoder; the profile notes "Quality isn't used for GIF" | +| GIF wider or taller than 1080 px | Allowed, with a warning: a 6-second 1080p GIF is about 130 MB | +| Custom size | 16–7680 px per side; odd values are rounded to even at export, and the UI shows the rounded size | + +## State transitions + +``` + apply(profile) undo + EditState ─────────────────▶ EditState' ─────────────▶ EditState + (recipe) one history recipe with only (exact previous + entry preset/custom size/ snapshot) + framing/format/quality + replaced (+ GIF audio rule) + + profiles ── save(name) ──▶ profiles + {user profile from current output settings} + profiles ── rename(id) ──▶ profiles with that profile's name changed + profiles ── delete(id) ──▶ profiles without it (built-ins can't be renamed or deleted) +``` + +- **Applying is an edit.** It is recorded in the edit history as one step, never coalesced, because profiles are discrete choices. Applying a profile that matches the current settings records nothing. +- **Managing profiles is not an edit.** Saving, renaming and deleting don't touch the video's settings or the undo history. +- **The "current profile" is derived, not stored.** A profile shows as active when the current output settings equal its settings. After a manual change, none shows as active. + +## Preview-only aids + +- **Framing.** The existing framing overlay shows letterbox bars (Fit) or the crop (Fill) for the current output shape. +- **Safe-area guides.** A toggle draws 90% and 80% inset rectangles inside the output frame. They are local UI state in `VideoPreview`, not part of `EditState`, so they can't reach the export. + +## Fixed alongside: the Fill preview showed the wrong crop + +The old preview overlay assumed a 16:9 source and dimmed the top and bottom when a taller output was set to Fill. FFmpeg actually crops the left and right in that case: from a 16:9 clip, a 9:16 output keeps a vertical strip about 32% of the source's width. + +`src/lib/framing.ts` now computes the frame from the real source aspect ratio, with the same scale-to-cover-and-crop rule as `ffmpegArgs.ts`, and `exportProfiles.test.ts` pins the values. + +## Tests + +- **Unit** (`src/lib/tests/exportProfiles.test.ts`): + - built-in sizes; + - every built-in profile × MP4/WebM/MKV/GIF through the real `buildArguments` / `buildGifArguments`, checking scale, pad or crop, CRF, and no audio or CRF for GIF; + - applying a profile changes only the output fields; + - validation, storage parsing, migration, limits, names and format notes; + - output-frame geometry. +- **Hook and UI** (`src/hooks/tests/exportProfiles.test.tsx`): + - one undo step that keeps trims and overlays; + - export arguments after applying a profile; + - no-op re-apply; + - the GIF audio rule with undo; + - persistence, a newer schema left untouched, and storage-full fallback; + - apply, save, rename and delete in the component. +- **Browser** (`test/e2e/profiles.spec.ts`, Playwright, both engines), using the real UI on a production build: + + | Profile | Framing | Format | Result | + |---|---|---|---| + | Square 1080 | Fill | MP4 | 1080 × 1080 | + | Portrait 9 : 16 | Fit | WebM | 1080 × 1920 | + | Landscape 720p | Fill | MKV | 1280 × 720 | + | Landscape 720p | Fit | GIF | 1280 × 720, 58.6 MB | + + Each run also checks that the active profile is marked and that the safe-area guides can be shown. + +## Out of scope for this change + +- Batch export to several profiles. +- Before/after preview (`ComparisonPreview` already exists). +- Syncing or sharing profiles. +- GIF frame rate and size controls (the next candidate). diff --git a/docs/PROJECTS.md b/docs/PROJECTS.md index 9a35af1dd..4b9d0fab0 100644 --- a/docs/PROJECTS.md +++ b/docs/PROJECTS.md @@ -129,13 +129,6 @@ Documents from a newer schema than the page knows are refused with "saved by a n A change to `EditRecipe` fields needs a project schema bump, because the validator requires every field. -## Not done yet: export profiles +## Export profiles -Reusable export profiles were scoped out of this change so undo/redo and recovery could land as one coherent slice. The suggested next slice: - -- **Profiles.** A profile is a named subset of `EditState["recipe"]`: preset or custom size, framing, format, quality and audio options. It is stored in its own localStorage key with its own schema version, and never includes trims, overlays or media. -- **Built-in profiles.** Landscape 1920×1080, square 1080×1080 and portrait 1080×1920, plus 1080×1350. Label them by shape and size, not by platform name, to avoid implying endorsement. -- **Applying a profile.** Applying one is one undoable `updateEdit` step. Manual controls stay as they are. -- **Command-builder tests.** A test runs every profile through the worker's `buildArguments`/`buildVideoFilter` and asserts the scale and pad output matches the declared dimensions. -- **Safe-area guides.** A CSS overlay on the preview only. It is not part of `EditState`, so it can't reach the export. -- **Batch export, if added.** Encode one profile at a time through the existing worker, never concurrently, and report each output's success or failure separately. +Implemented separately; see [EXPORT_PROFILES.md](EXPORT_PROFILES.md). Applying a profile is one undo step and changes only the output fields of `EditState`. Profiles are stored apart from projects, and a project file doesn't include them. diff --git a/src/components/ExportProfiles.tsx b/src/components/ExportProfiles.tsx new file mode 100644 index 000000000..6d470bfce --- /dev/null +++ b/src/components/ExportProfiles.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { useState } from "react"; +import { Check, Pencil, Trash2 } from "lucide-react"; +import { + BUILT_IN_PROFILES, + MAX_PROFILE_NAME, + profileMatches, + profileNotes, + profileOutputSize, + settingsFromRecipe, + type ExportProfile, + type ProfileChange, + type ProfileSettings, +} from "@/lib/exportProfiles"; +import type { EditRecipe } from "@/lib/types"; +import type { ProfileStoreStatus } from "@/hooks/useExportProfiles"; + +interface Props { + recipe: EditRecipe; + userProfiles: ExportProfile[]; + status: ProfileStoreStatus; + onApply: (settings: ProfileSettings) => void; + onSave: (name: string, settings: ProfileSettings) => ProfileChange; + onRename: (id: string, name: string) => ProfileChange; + onDelete: (id: string) => ProfileChange; +} + +const describe = (p: ExportProfile) => { + const { width, height } = profileOutputSize(p.settings); + return `${width} × ${height} · ${p.settings.format.toUpperCase()} · ${p.settings.framing === "fit" ? "Fit" : "Fill"}`; +}; + +/** Pick, save, rename and delete export profiles. Applying one is a single undo step. */ +export default function ExportProfiles({ recipe, userProfiles, status, onApply, onSave, onRename, onDelete }: Props) { + const [name, setName] = useState(""); + const [renamingId, setRenamingId] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [message, setMessage] = useState<{ tone: "ok" | "error"; text: string } | null>(null); + + const current = settingsFromRecipe(recipe); + const notes = profileNotes(current); + + const report = (change: ProfileChange, ok: string) => + setMessage(change.ok ? { tone: "ok", text: ok } : { tone: "error", text: change.error }); + + const row = (p: ExportProfile) => { + const active = profileMatches(recipe, p.settings); + if (renamingId === p.id) { + return ( +
  • +
    { + e.preventDefault(); + const change = onRename(p.id, renameValue); + report(change, "Profile renamed."); + if (change.ok) setRenamingId(null); + }} + > + + setRenameValue(e.target.value)} + className="field min-w-0 flex-1 text-sm" + /> + + +
    +
  • + ); + } + return ( +
  • + + {!p.builtIn && + (confirmDeleteId === p.id ? ( + + + + + ) : ( + <> + + + + ))} +
  • + ); + }; + + return ( +
    +
      + {BUILT_IN_PROFILES.map(row)} +
    + + {userProfiles.length > 0 && ( +
    +

    Your profiles

    +
      + {userProfiles.map(row)} +
    +
    + )} + +
    { + e.preventDefault(); + const change = onSave(name, current); + report(change, `Saved “${name.trim()}” with the current size, framing, format and quality.`); + if (change.ok) setName(""); + }} + > + +
    + setName(e.target.value)} + disabled={status.readOnly} + className="field min-w-0 flex-1 text-sm" + /> + +
    +

    + Profiles keep size, framing, format and quality only, never trims, edits or the video. They are saved in + this browser. +

    +
    + + {notes.length > 0 && ( +
      + {notes.map((n) => ( +
    • {n}
    • + ))} +
    + )} + +
    + {status.message &&

    {status.message}

    } + {message &&

    {message.text}

    } +
    +
    + ); +} diff --git a/src/components/FramingControl.tsx b/src/components/FramingControl.tsx index 1fa1bb036..6c31f4851 100644 --- a/src/components/FramingControl.tsx +++ b/src/components/FramingControl.tsx @@ -21,6 +21,8 @@ export default function FramingControl({ recipe, onChange }: Props) { key={mode} title={mode === "fit" ? "Fit: Adds black bars (letterbox) to fill empty space" : "Fill: Crops the video to fill the entire frame"} onClick={() => onChange({ framing: mode })} + // Explicit: the two-line content gave the button no name in Chrome. + aria-label={mode === "fit" ? "Fit, letterbox or pillarbox" : "Fill, crop to frame"} aria-pressed={active} className="choice flex min-h-[44px] min-w-[44px] flex-1 items-center gap-3 px-3 py-2.5 text-left" > diff --git a/src/components/VideoEditor.tsx b/src/components/VideoEditor.tsx index 16a6f6afa..044aef59d 100644 --- a/src/components/VideoEditor.tsx +++ b/src/components/VideoEditor.tsx @@ -19,6 +19,12 @@ import ExportOverlay, { type ExportSummary } from "./ExportOverlay"; import DownloadResult from "./DownloadResult"; import ImageOverlay from "./ImageOverlay"; import AdjustmentsControl, { hasAdjustments } from "./AdjustmentsControl"; +import ExportProfiles from "./ExportProfiles"; +import { useExportProfiles } from "@/hooks/useExportProfiles"; +import { BUILT_IN_PROFILES, profileMatches } from "@/lib/exportProfiles"; +import { resolveOutputSize } from "@/lib/ffmpegArgs"; +import { trackFeature } from "@/lib/featureTelemetry"; +import { deepEqual } from "@/lib/editHistory"; import { getPresetById } from "@/lib/presets"; import { pickThumbnailInterval } from "@/lib/thumbnails"; @@ -36,6 +42,7 @@ import { Download, ChevronDown, Keyboard, + Bookmark, } from "lucide-react"; import OnboardingTour from "./OnboardingTour"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; @@ -224,6 +231,7 @@ export default function VideoEditor() { cancelExport, reset, resetSettings, + applyExportProfile, videoRef, seekTo, overlayFile, @@ -250,6 +258,7 @@ export default function VideoEditor() { deleteSavedData, } = useVideoEditor(); + const exportProfiles = useExportProfiles(); const [shortcutsOpen, setShortcutsOpen] = useState(false); const toggleShortcuts = useCallback(() => setShortcutsOpen((v) => !v), []); @@ -272,6 +281,7 @@ export default function VideoEditor() { const [shareCopied, setShareCopied] = useState(false); const [selectedTextId, setSelectedTextId] = useState(null); const [openSections, setOpenSections] = useState({ + profile: false, resize: true, trim: false, rotation: false, @@ -336,10 +346,12 @@ export default function VideoEditor() { const output = useMemo(() => { const preset = getPresetById(recipe.preset); - const width = - recipe.preset === "custom" ? recipe.customWidth : (preset?.width ?? recipe.customWidth); - const height = - recipe.preset === "custom" ? recipe.customHeight : (preset?.height ?? recipe.customHeight); + // The size FFmpeg actually encodes (even-rounded), not the raw input. + const { width, height } = resolveOutputSize({ + preset: recipe.preset, + customWidth: recipe.customWidth, + customHeight: recipe.customHeight, + }); const summary: ExportSummary = { format: recipe.format.toUpperCase(), resolution: `${width} × ${height}`, @@ -682,6 +694,45 @@ export default function VideoEditor() { )}
    + } + title="Profile" + summary={ + [...BUILT_IN_PROFILES, ...exportProfiles.profiles].find((p) => profileMatches(recipe, p.settings)) + ?.name ?? "Custom settings" + } + isOpen={openSections.profile} + onToggle={() => toggleSection("profile")} + > + { + applyExportProfile(settings); + const builtIn = BUILT_IN_PROFILES.find((p) => deepEqual(p.settings, settings)); + // Only the built-in slug or "custom": never a profile's name. + trackFeature("export_profile", "apply", { profile: builtIn?.id ?? "custom" }); + }} + onSave={(name, settings) => { + const change = exportProfiles.save(name, settings); + if (change.ok) trackFeature("export_profile", "save"); + return change; + }} + onRename={(id, name) => { + const change = exportProfiles.rename(id, name); + if (change.ok) trackFeature("export_profile", "rename"); + return change; + }} + onDelete={(id) => { + const change = exportProfiles.remove(id); + if (change.ok) trackFeature("export_profile", "delete"); + return change; + }} + /> + + } diff --git a/src/components/VideoPreview.tsx b/src/components/VideoPreview.tsx index e3e55442a..63d247be9 100644 --- a/src/components/VideoPreview.tsx +++ b/src/components/VideoPreview.tsx @@ -3,11 +3,12 @@ import { useEffect, useRef, useState, useCallback, RefObject } from "react"; import { EditRecipe, TextOverlay, TimelineTrack, MultiTrackEditorState } from "@/lib/types"; -import { getPresetById } from "@/lib/presets"; +import { outputFrame, SAFE_AREAS } from "@/lib/framing"; import { cn } from "@/lib/utils"; -import { Camera, Columns2, Grid3x3, Frame } from "lucide-react"; +import { Camera, Columns2, Grid3x3, Frame, SquareDashed } from "lucide-react"; import ComparisonPreview from "./ComparisonPreview"; import DraggableTextOverlays from "./DraggableTextOverlays"; +import { trackFeature } from "@/lib/featureTelemetry"; interface Props { file: File | null; @@ -37,6 +38,9 @@ export default function VideoPreview({ const [showOverlay, setShowOverlay] = useState(false); const [showComparison, setShowComparison] = useState(false); const [showGridOverlay, setShowGridOverlay] = useState(false); + // Preview-only: never part of the edit, so it can't reach the export. + const [showSafeArea, setShowSafeArea] = useState(false); + const [sourceRatio, setSourceRatio] = useState(16 / 9); const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0, @@ -211,49 +215,10 @@ export default function VideoPreview({ return () => observer.disconnect(); }, [file]); - const overlay = (() => { - if (!recipe || !showOverlay) return null; - - const preset = recipe.preset === "custom" - ? { width: recipe.customWidth, height: recipe.customHeight } - : getPresetById(recipe.preset); - - if (!preset) return null; - - // Preview container is 16:9 - const containerW = 16; - const containerH = 9; - const containerRatio = containerW / containerH; // 1.777… - const outputRatio = preset.width / preset.height; - - if (recipe.framing === "fit") { - // Letterbox: the output video fits entirely inside 16:9, padded with bars. - if (outputRatio > containerRatio) { - // Wider output → pillarbox bars on top & bottom - const contentH = (containerRatio / outputRatio) * 100; - const barH = (100 - contentH) / 2; - return { mode: "fit", barTop: `${barH}%`, barBottom: `${barH}%`, barLeft: "0", barRight: "0" }; - } else { - // Taller output → letterbox bars on left & right - const contentW = (outputRatio / containerRatio) * 100; - const barW = (100 - contentW) / 2; - return { mode: "fit", barTop: "0", barBottom: "0", barLeft: `${barW}%`, barRight: `${barW}%` }; - } - } else { - // Fill / crop: the output fills the entire 16:9 preview — show a box representing what survives the crop. - if (outputRatio < containerRatio) { - // Output is taller → crops top & bottom - const visibleH = (outputRatio / containerRatio) * 100; - const cropH = (100 - visibleH) / 2; - return { mode: "fill", barTop: `${cropH}%`, barBottom: `${cropH}%`, barLeft: "0", barRight: "0" }; - } else { - // Output is wider → crops left & right - const visibleW = (containerRatio / outputRatio) * 100; - const cropW = (100 - visibleW) / 2; - return { mode: "fill", barTop: "0", barBottom: "0", barLeft: `${cropW}%`, barRight: `${cropW}%` }; - } - } - })(); + // The output frame inside the viewer, shared by the framing overlay and + // the safe-area guides (src/lib/framing.ts). + const frame = recipe ? outputFrame(recipe, sourceRatio) : null; + const overlay = showOverlay ? frame : null; if (!file) return null; @@ -313,7 +278,11 @@ export default function VideoPreview({ ? `brightness(${1 + (recipe.brightness ?? 0)}) contrast(${recipe.contrast ?? 1}) saturate(${recipe.saturation ?? 1})` : undefined, }} - onLoadedData={() => setIsLoading(false)} + onLoadedData={(e) => { + const { videoWidth, videoHeight } = e.currentTarget; + if (videoWidth > 0 && videoHeight > 0) setSourceRatio(videoWidth / videoHeight); + setIsLoading(false); + }} playsInline muted={!recipe?.keepAudio} > @@ -363,25 +332,25 @@ export default function VideoPreview({ {overlay.mode === "fit" ? ( // Letterbox: the bars the output will add <> -
    -
    -
    -
    +
    +
    +
    +
    ) : ( // Fill/crop: dim what will be cropped, outline what survives <> -
    -
    -
    -
    +
    +
    +
    +
    @@ -389,6 +358,22 @@ export default function VideoPreview({
    )} + {/* Safe-area guides inside the output frame (preview only) */} + {showSafeArea && frame && ( + + )} + {/* 3x3 Grid Overlay */} {showGridOverlay && (