diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index e738fb68..651549dd 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -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( - DEFAULT_EXPORT_OPTIONS.format, - ); - const [quality, setQuality] = useState( - DEFAULT_EXPORT_OPTIONS.quality, - ); + const initialOptions = getDefaultExportOptions({ + projectType: activeProject?.metadata?.projectType, + }); + const [format, setFormat] = useState(initialOptions.format); + const [quality, setQuality] = useState(initialOptions.quality); const [shouldIncludeAudio, setShouldIncludeAudio] = useState( - DEFAULT_EXPORT_OPTIONS.includeAudio ?? true, + initialOptions.includeAudio ?? true, ); const handleExport = async () => { diff --git a/apps/web/src/export/__tests__/presets.test.ts b/apps/web/src/export/__tests__/presets.test.ts new file mode 100644 index 00000000..f694424e --- /dev/null +++ b/apps/web/src/export/__tests__/presets.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/export/defaults.ts b/apps/web/src/export/defaults.ts index 0247f8a8..97a336b6 100644 --- a/apps/web/src/export/defaults.ts +++ b/apps/web/src/export/defaults.ts @@ -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 }; +} diff --git a/apps/web/src/export/index.ts b/apps/web/src/export/index.ts index c5c522b3..5ff0624c 100644 --- a/apps/web/src/export/index.ts +++ b/apps/web/src/export/index.ts @@ -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;