Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/DATADOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
127 changes: 127 additions & 0 deletions docs/EXPORT_PROFILES.md
Original file line number Diff line number Diff line change
@@ -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:<slug>" or "user:<random>"
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).
11 changes: 2 additions & 9 deletions docs/PROJECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
205 changes: 205 additions & 0 deletions src/components/ExportProfiles.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(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 (
<li key={p.id}>
<form
className="flex items-center gap-2"
onSubmit={(e) => {
e.preventDefault();
const change = onRename(p.id, renameValue);
report(change, "Profile renamed.");
if (change.ok) setRenamingId(null);
}}
>
<label className="sr-only" htmlFor={`rename-${p.id}`}>
New name for {p.name}
</label>
<input
id={`rename-${p.id}`}
value={renameValue}
maxLength={MAX_PROFILE_NAME}
onChange={(e) => setRenameValue(e.target.value)}
className="field min-w-0 flex-1 text-sm"
/>
<button type="submit" className="btn btn-secondary btn-sm">
Save
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setRenamingId(null)}>
Cancel
</button>
</form>
</li>
);
}
return (
<li key={p.id} className="flex items-stretch gap-1">
<button
type="button"
aria-pressed={active}
aria-label={`${p.name}, ${describe(p).replace(/ · /g, ", ")}`}
onClick={() => {
onApply(p.settings);
setMessage({ tone: "ok", text: `Applied “${p.name}”. Undo restores the previous settings.` });
}}
className="btn btn-ghost btn-sm min-w-0 flex-1 justify-start text-left"
>
<span className="flex min-w-0 flex-1 flex-col items-start">
<span className="truncate font-medium">{p.name}</span>
<span className="tabular text-2xs text-[var(--muted)]">{describe(p)}</span>
</span>
{active && <Check size={14} aria-hidden="true" className="shrink-0 text-[var(--accent-text)]" />}
</button>
{!p.builtIn &&
(confirmDeleteId === p.id ? (
<span className="flex items-center gap-1">
<button
type="button"
className="btn btn-danger btn-sm"
onClick={() => {
report(onDelete(p.id), `Deleted “${p.name}”.`);
setConfirmDeleteId(null);
}}
>
Delete
</button>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => setConfirmDeleteId(null)}>
Keep
</button>
</span>
) : (
<>
<button
type="button"
className="btn btn-ghost btn-icon btn-sm"
aria-label={`Rename ${p.name}`}
onClick={() => {
setRenamingId(p.id);
setRenameValue(p.name);
}}
>
<Pencil size={13} aria-hidden="true" />
</button>
<button
type="button"
className="btn btn-ghost btn-icon btn-sm"
aria-label={`Delete ${p.name}`}
onClick={() => setConfirmDeleteId(p.id)}
>
<Trash2 size={13} aria-hidden="true" />
</button>
</>
))}
</li>
);
};

return (
<div className="space-y-4">
<ul aria-label="Built-in profiles" className="space-y-1">
{BUILT_IN_PROFILES.map(row)}
</ul>

{userProfiles.length > 0 && (
<div>
<p className="mb-1 px-1 text-2xs text-[var(--muted)]">Your profiles</p>
<ul aria-label="Your profiles" className="space-y-1">
{userProfiles.map(row)}
</ul>
</div>
)}

<form
className="space-y-2 px-1"
onSubmit={(e) => {
e.preventDefault();
const change = onSave(name, current);
report(change, `Saved “${name.trim()}” with the current size, framing, format and quality.`);
if (change.ok) setName("");
}}
>
<label htmlFor="profile-name" className="block text-xs text-[var(--text-secondary)]">
Save the current output settings as a profile
</label>
<div className="flex gap-2">
<input
id="profile-name"
value={name}
maxLength={MAX_PROFILE_NAME}
placeholder="Profile name"
onChange={(e) => setName(e.target.value)}
disabled={status.readOnly}
className="field min-w-0 flex-1 text-sm"
/>
<button type="submit" className="btn btn-secondary btn-sm" disabled={status.readOnly || !name.trim()}>
Save profile
</button>
</div>
<p className="text-2xs text-[var(--muted)]">
Profiles keep size, framing, format and quality only, never trims, edits or the video. They are saved in
this browser.
</p>
</form>

{notes.length > 0 && (
<ul className="callout callout-info flex-col items-stretch gap-1 text-xs" aria-label="Notes about the current output">
{notes.map((n) => (
<li key={n}>{n}</li>
))}
</ul>
)}

<div aria-live="polite" className="px-1 text-xs">
{status.message && <p className="text-[var(--warning)]">{status.message}</p>}
{message && <p className={message.tone === "error" ? "text-[var(--error)]" : "text-[var(--text-secondary)]"}>{message.text}</p>}
</div>
</div>
);
}
2 changes: 2 additions & 0 deletions src/components/FramingControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
Expand Down
Loading
Loading