feat: add YouTube Shorts export preset

Adds an export preset registry and the first preset entry — YouTube Shorts — so users with projectType='shorts' get sensible export defaults aligned with YouTube's Shorts encoding recommendations.

- New types TExportPresetId and TExportPreset
- YOUTUBE_SHORTS_EXPORT_PRESET (mp4, very_high quality, audio on)
- EXPORT_PRESETS registry + getExportPresetById helper
- getDefaultExportOptions({projectType}) resolves Shorts vs standard defaults
- DEFAULT_EXPORT_OPTIONS export preserved for back-compat
- Unit tests cover preset shape, registry exhaustiveness, default resolution
This commit is contained in:
kseungyong 2026-05-18 17:55:18 +09:00
parent 7131f88bf4
commit 5913abb52c
4 changed files with 139 additions and 9 deletions

View File

@ -33,7 +33,7 @@ import {
SectionTitle,
} from "@/components/section";
import { useEditor } from "@/editor/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/export/defaults";
import { getDefaultExportOptions } from "@/export/defaults";
function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
@ -101,14 +101,13 @@ function ExportPopover({
const activeProject = useEditor((e) => e.project.getActive());
const exportState = useEditor((e) => e.project.getExportState());
const { isExporting, progress, result: exportResult } = exportState;
const [format, setFormat] = useState<ExportFormat>(
DEFAULT_EXPORT_OPTIONS.format,
);
const [quality, setQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_OPTIONS.quality,
);
const initialOptions = getDefaultExportOptions({
projectType: activeProject?.metadata?.projectType,
});
const [format, setFormat] = useState<ExportFormat>(initialOptions.format);
const [quality, setQuality] = useState<ExportQuality>(initialOptions.quality);
const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
initialOptions.includeAudio ?? true,
);
const handleExport = async () => {

View File

@ -0,0 +1,64 @@
import { describe, expect, test } from "bun:test";
import {
EXPORT_PRESETS,
EXPORT_PRESET_IDS,
YOUTUBE_SHORTS_EXPORT_PRESET,
getExportPresetById,
} from "@/export";
import {
DEFAULT_EXPORT_OPTIONS,
getDefaultExportOptions,
} from "@/export/defaults";
describe("YOUTUBE_SHORTS_EXPORT_PRESET", () => {
test("has the expected encoding shape", () => {
expect(YOUTUBE_SHORTS_EXPORT_PRESET.options).toMatchObject({
format: "mp4",
quality: "very_high",
includeAudio: true,
});
});
test("has descriptive label and description", () => {
expect(YOUTUBE_SHORTS_EXPORT_PRESET.label).toMatch(/\S/);
expect(YOUTUBE_SHORTS_EXPORT_PRESET.description).toMatch(/\S/);
});
});
describe("EXPORT_PRESETS registry", () => {
test("contains youtube-shorts", () => {
const ids = EXPORT_PRESETS.map((p) => p.id);
expect(ids).toContain("youtube-shorts");
});
test("every registered preset's id is in EXPORT_PRESET_IDS", () => {
for (const preset of EXPORT_PRESETS) {
expect(EXPORT_PRESET_IDS).toContain(preset.id);
}
});
test("getExportPresetById resolves youtube-shorts", () => {
const preset = getExportPresetById({ id: "youtube-shorts" });
expect(preset).toBeDefined();
expect(preset?.id).toBe("youtube-shorts");
});
});
describe("getDefaultExportOptions", () => {
test("returns Shorts preset options when projectType is shorts", () => {
const opts = getDefaultExportOptions({ projectType: "shorts" });
expect(opts).toEqual(YOUTUBE_SHORTS_EXPORT_PRESET.options);
});
test("returns DEFAULT_EXPORT_OPTIONS when projectType is standard", () => {
const opts = getDefaultExportOptions({ projectType: "standard" });
expect(opts).toEqual(DEFAULT_EXPORT_OPTIONS);
});
test("returns DEFAULT_EXPORT_OPTIONS when projectType is undefined", () => {
const opts = getDefaultExportOptions({});
expect(opts).toEqual(DEFAULT_EXPORT_OPTIONS);
});
});

View File

@ -1,7 +1,26 @@
import type { ExportOptions } from "./index";
import type { TProjectType } from "@/project/types";
import { YOUTUBE_SHORTS_EXPORT_PRESET, type ExportOptions } from "./index";
export const DEFAULT_EXPORT_OPTIONS = {
format: "mp4",
quality: "high",
includeAudio: true,
} satisfies ExportOptions;
/**
* Resolve the export options that should be pre-filled when opening
* the export dialog for a given project. Projects with intent === "shorts"
* get the YouTube Shorts preset's options; everything else uses
* DEFAULT_EXPORT_OPTIONS. Undefined projectType (back-compat for older
* stored projects) is treated as "standard".
*/
export function getDefaultExportOptions({
projectType,
}: {
projectType?: TProjectType;
}): ExportOptions {
if (projectType === "shorts") {
return { ...YOUTUBE_SHORTS_EXPORT_PRESET.options };
}
return { ...DEFAULT_EXPORT_OPTIONS };
}

View File

@ -20,6 +20,54 @@ export interface ExportOptions {
includeAudio?: boolean;
}
// ─── Export presets ─────────────────────────────────────────────────────────
export const EXPORT_PRESET_IDS = ["youtube-shorts"] as const;
/**
* Discriminator for export presets. "youtube-shorts" applies the
* YouTube-recommended encoding shape for 1080×1920 vertical short videos.
* New presets will be added here as their UI surface lands.
*/
export type TExportPresetId = (typeof EXPORT_PRESET_IDS)[number];
export interface TExportPreset {
id: TExportPresetId;
label: string;
description: string;
options: ExportOptions;
}
/**
* YouTube Shorts encoding preset. Targets YouTube's recommended
* encoding shape: H.264 MP4, very_high quality (the bitrate mapping
* lives downstream in the renderer/WASM layer), audio included.
* FPS is intentionally not set projects keep their own FPS, which
* for Shorts is typically 30 or 60.
*/
export const YOUTUBE_SHORTS_EXPORT_PRESET: TExportPreset = {
id: "youtube-shorts",
label: "YouTube Shorts",
description: "1080×1920 vertical, H.264 MP4 at very_high quality, AAC audio",
options: {
format: "mp4",
quality: "very_high",
includeAudio: true,
},
};
export const EXPORT_PRESETS: readonly TExportPreset[] = [
YOUTUBE_SHORTS_EXPORT_PRESET,
];
export function getExportPresetById({
id,
}: {
id: TExportPresetId;
}): TExportPreset | undefined {
return EXPORT_PRESETS.find((p) => p.id === id);
}
export interface ExportResult {
success: boolean;
buffer?: ArrayBuffer;