From cea46c2bd9efe41ca7a79359b0390b9d590d7d07 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Fri, 28 Aug 2026 15:16:10 +0100 Subject: [PATCH 1/6] feat(schema): add audio layers and audio assets to the document model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audio regions (voiceover / background music) join zoom/annotation as clip-anchored timeline modifiers: {clipId, sourceStartSec, sourceEndSec} is the source of truth, startMs/endMs the derived ruler cache, so layers travel with their clip through reorder/trim (rederiveRegionMs). Assets gain an "audio" kind — additive, no schema-version bump, and only video imports claim the primary-asset slot. RegionKind gains "audio"; removal cascades like every other kind. --- .../ai-edition/EditorEmptyState.test.tsx | 3 + .../ExportDialog.showInFolder.test.tsx | 1 + .../ai-edition/ExportDialog.test.ts | 1 + .../ai-edition/WebcamOverlay.test.tsx | 1 + .../ai-edition/document/outputFormat.test.ts | 1 + src/lib/ai-edition/document/timeline.test.ts | 1 + src/lib/ai-edition/document/timeline.ts | 11 ++- .../ai-edition/document/transcribe.test.ts | 1 + src/lib/ai-edition/schema/index.test.ts | 57 +++++++++++- src/lib/ai-edition/schema/index.ts | 46 +++++++++- .../ai-edition/store/editorSettings.test.ts | 1 + src/lib/ai-edition/store/projectStore.test.ts | 1 + .../ai-edition/store/undo.modalGuard.test.tsx | 1 + src/lib/ai-edition/store/useCaptions.test.ts | 1 + .../store/useEditorSettings.test.ts | 1 + src/lib/ai-edition/store/useTimeline.test.ts | 88 +++++++++++++++++++ .../ai-edition/transcription/status.test.ts | 1 + src/native/sceneDescription.test.ts | 1 + 18 files changed, 213 insertions(+), 5 deletions(-) diff --git a/src/components/ai-edition/EditorEmptyState.test.tsx b/src/components/ai-edition/EditorEmptyState.test.tsx index acb513e4b..09364bb74 100644 --- a/src/components/ai-edition/EditorEmptyState.test.tsx +++ b/src/components/ai-edition/EditorEmptyState.test.tsx @@ -53,6 +53,7 @@ const sampleDoc = vi.hoisted( }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }), ); @@ -178,6 +179,7 @@ describe("EditorEmptyState (new editor)", () => { "proj_test", "/tmp/recording.mp4", "recording.mp4", + undefined, ); }); }); @@ -205,6 +207,7 @@ describe("EditorEmptyState (new editor)", () => { "proj_test", "/tmp/recording.mp4", "recording.mp4", + undefined, ); }); }); diff --git a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx index 835d9c8fd..f08d03362 100644 --- a/src/components/ai-edition/ExportDialog.showInFolder.test.tsx +++ b/src/components/ai-edition/ExportDialog.showInFolder.test.tsx @@ -75,6 +75,7 @@ const DOC: AxcutDocument = { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; diff --git a/src/components/ai-edition/ExportDialog.test.ts b/src/components/ai-edition/ExportDialog.test.ts index 3aa8d85b5..41557e7ec 100644 --- a/src/components/ai-edition/ExportDialog.test.ts +++ b/src/components/ai-edition/ExportDialog.test.ts @@ -57,6 +57,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; } diff --git a/src/components/ai-edition/WebcamOverlay.test.tsx b/src/components/ai-edition/WebcamOverlay.test.tsx index bdd36a558..51581165d 100644 --- a/src/components/ai-edition/WebcamOverlay.test.tsx +++ b/src/components/ai-edition/WebcamOverlay.test.tsx @@ -74,6 +74,7 @@ function makeDocument(): AxcutDocument { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; } diff --git a/src/lib/ai-edition/document/outputFormat.test.ts b/src/lib/ai-edition/document/outputFormat.test.ts index 4ceac442f..6064b7c60 100644 --- a/src/lib/ai-edition/document/outputFormat.test.ts +++ b/src/lib/ai-edition/document/outputFormat.test.ts @@ -67,6 +67,7 @@ function doc(assets: AxcutAsset[], clips: AxcutClip[]): AxcutDocument { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; } diff --git a/src/lib/ai-edition/document/timeline.test.ts b/src/lib/ai-edition/document/timeline.test.ts index a561dd2f9..c4b173b5e 100644 --- a/src/lib/ai-edition/document/timeline.test.ts +++ b/src/lib/ai-edition/document/timeline.test.ts @@ -57,6 +57,7 @@ function makeDoc(overrides: Partial = {}): AxcutDocument { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, ...overrides, }; diff --git a/src/lib/ai-edition/document/timeline.ts b/src/lib/ai-edition/document/timeline.ts index 232bcb6ee..ee74abefc 100644 --- a/src/lib/ai-edition/document/timeline.ts +++ b/src/lib/ai-edition/document/timeline.ts @@ -17,7 +17,7 @@ import { createId } from "./ids"; * exist" has exactly one definition. `trim` is a source-time cut; the rest are pill-merged * effects (zoom / speed / annotation / camera-fullscreen). Clips are removed via * {@link removeClip}, not here — deleting a clip reflows the whole timeline. */ -export type RegionKind = "zoom" | "trim" | "annotation" | "speed" | "cameraFullscreen"; +export type RegionKind = "zoom" | "trim" | "annotation" | "speed" | "cameraFullscreen" | "audio"; /** Length a clip is given before its media has been probed. Lives here, in the pure * document layer, because that layer decides which clips are still waiting for a real @@ -259,6 +259,10 @@ function mapAllRegionCollections( document.annotations as unknown as StoredRegion[], "ann", ) as unknown as AxcutDocument["annotations"], + audioRanges: fn( + document.audioRanges as unknown as StoredRegion[], + "aud", + ) as unknown as AxcutDocument["audioRanges"], legacyEditor: legacy && (speedRegions || cameraFullscreenRegions) ? { @@ -879,6 +883,11 @@ export function removeRegion(document: AxcutDocument, kind: RegionKind, id: stri }; case "annotation": return { ...document, annotations: dropPillById(document.annotations, id) }; + case "audio": + return { + ...document, + audioRanges: dropPillById(document.audioRanges, id) as AxcutDocument["audioRanges"], + }; case "trim": return { ...document, diff --git a/src/lib/ai-edition/document/transcribe.test.ts b/src/lib/ai-edition/document/transcribe.test.ts index 72dc8d00b..da74ca338 100644 --- a/src/lib/ai-edition/document/transcribe.test.ts +++ b/src/lib/ai-edition/document/transcribe.test.ts @@ -49,6 +49,7 @@ function makeDoc(): AxcutDocument { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; } diff --git a/src/lib/ai-edition/schema/index.test.ts b/src/lib/ai-edition/schema/index.test.ts index bbba94fd8..90fb18872 100644 --- a/src/lib/ai-edition/schema/index.test.ts +++ b/src/lib/ai-edition/schema/index.test.ts @@ -3,6 +3,7 @@ import { migrateRawDocumentToCurrent } from "../document/migrate"; import { annotationRegionSchema, assetSchema, + audioRegionSchema, axcutSchemaVersion, clipSchema, createEmptyDocument, @@ -73,15 +74,65 @@ describe("axcut-schema v7", () => { ).toThrow(); }); - it("assetSchema requires kind = 'video'", () => { + it("assetSchema accepts kind 'video' and 'audio' but rejects anything else", () => { expect(() => assetSchema.parse({ id: "asset_1", - kind: "audio", + kind: "image", label: "x", - originalPath: "/x.mp4", + originalPath: "/x.png", }), ).toThrow(); + const audioAsset = assetSchema.parse({ + id: "asset_2", + kind: "audio", + label: "music", + originalPath: "/m.mp3", + }); + expect(audioAsset.kind).toBe("audio"); + expect(audioAsset.cameraTrack).toBeNull(); + }); + + it("audioRegionSchema defaults payload fields and validates monotonicity", () => { + const region = audioRegionSchema.parse({ + id: "aud_1", + startMs: 0, + endMs: 2000, + assetId: "asset_2", + kind: "voiceover", + }); + expect(region).toMatchObject({ + offsetMs: 0, + gainDb: 0, + loop: false, + fadeInMs: 0, + fadeOutMs: 0, + muted: false, + origin: "user", + }); + expect(() => + audioRegionSchema.parse({ + id: "aud_2", + startMs: 3000, + endMs: 2000, + assetId: "asset_2", + kind: "music", + }), + ).toThrow(); + }); + + it("documentSchema defaults audioRanges to [] when absent", () => { + const doc = documentSchema.parse({ + schemaVersion: axcutSchemaVersion, + project: { + id: "proj_1", + title: "Test", + createdAt: "2026-06-26T10:00:00Z", + updatedAt: "2026-06-26T10:00:00Z", + }, + timeline: {}, + }); + expect(doc.audioRanges).toEqual([]); }); it("trimRangeSchema carries assetId and origin", () => { diff --git a/src/lib/ai-edition/schema/index.ts b/src/lib/ai-edition/schema/index.ts index dd6936fde..f4d0007bc 100644 --- a/src/lib/ai-edition/schema/index.ts +++ b/src/lib/ai-edition/schema/index.ts @@ -148,9 +148,19 @@ export const assetTranscriptionFailureSchema = z.object({ at: isoDateSchema.optional(), }); +// Audio-only media imported as an edit layer (voiceover / background music). +// Same shape as a video asset minus the video-specific links — an audio asset +// never appears as a timeline CLIP (the ruler's clip row is video only); it is +// referenced by `audioRanges` regions, which place it on the timeline. +export const assetAudioKindSchema = z.literal("audio"); + export const assetSchema = z.object({ id: z.string().min(1), - kind: z.literal("video"), + // "video" for every asset written before audio layers existed — the enum is + // additive, so an older build parsing a new document simply sees the one + // new value it doesn't know and drops the key on save (same window as + // `cameraTrack.width` above). + kind: z.enum(["video", "audio"]), label: z.string().min(1), originalPath: z.string().min(1), proxyPath: z.string().optional(), @@ -471,6 +481,33 @@ export const zoomRegionSchema = endGteStart( "startMs", ); +// Audio layers (voiceover / background music) placed on the timeline. Same +// v5 clip-anchor contract as zoom/annotation: `{clipId, sourceStartSec, +// sourceEndSec}` is the source of truth, `startMs`/`endMs` the derived ruler +// cache, so a layer travels with the clip it covers through reorder/trim. +// `offsetMs` skips INTO the audio file (start the music at its chorus); the +// region's own span is where it plays on the timeline. Music longer than its +// span is trimmed to the span unless `loop` is set, in which case it repeats. +export const audioRegionSchema = endGteStart( + z.object({ + id: z.string().min(1), + startMs: z.number().nonnegative(), + endMs: z.number().nonnegative(), + ...clipAnchorShape, + assetId: z.string().min(1), + kind: z.enum(["voiceover", "music"]), + offsetMs: z.number().int().nonnegative().default(0), + gainDb: z.number().min(-60).max(12).default(0), + loop: z.boolean().default(false), + fadeInMs: z.number().int().nonnegative().default(0), + fadeOutMs: z.number().int().nonnegative().default(0), + muted: z.boolean().default(false), + origin: z.enum(["system", "agent", "user"]).default("user"), + }), + "endMs", + "startMs", +); + // Legacy OpenScreen appearance / export settings that the v3 schema doesn't // normalize into the timeline / assets model. They are applied at export time // by the existing pipeline (see technical-documentation/architecture/document-model.md). @@ -503,6 +540,10 @@ const documentSchemaShape = z.object({ }), annotations: z.array(annotationRegionSchema).default([]), zoomRanges: z.array(zoomRegionSchema).default([]), + // Audio layers. Additive with a default, so documents written before audio + // layers existed load unchanged (absent key → []) and no schema-version + // bump is needed — same convention as `cameraTrack.width`. + audioRanges: z.array(audioRegionSchema).default([]), legacyEditor: legacyEditorSchema.nullable().default(null), }); @@ -943,6 +984,8 @@ export type AxcutTimeline = z.infer; export type AxcutTimelineOperation = z.infer; export type AxcutAnnotationRegion = z.infer; export type AxcutZoomRegion = z.infer; +export type AxcutAudioRegion = z.infer; +export type AxcutAudioLayerKind = AxcutAudioRegion["kind"]; export type AxcutCameraTrack = z.infer; export type AxcutLegacyEditor = z.infer; export type AxcutDocument = z.infer; @@ -979,6 +1022,7 @@ export function createEmptyDocument( }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }); } diff --git a/src/lib/ai-edition/store/editorSettings.test.ts b/src/lib/ai-edition/store/editorSettings.test.ts index 07982f47a..fa1b9b1b9 100644 --- a/src/lib/ai-edition/store/editorSettings.test.ts +++ b/src/lib/ai-edition/store/editorSettings.test.ts @@ -29,6 +29,7 @@ const baseDoc: AxcutDocument = { }, annotations: [], zoomRanges: [], + audioRanges: [], transcripts: [], transcript: null, legacyEditor: null, diff --git a/src/lib/ai-edition/store/projectStore.test.ts b/src/lib/ai-edition/store/projectStore.test.ts index a46a6b535..b21222e60 100644 --- a/src/lib/ai-edition/store/projectStore.test.ts +++ b/src/lib/ai-edition/store/projectStore.test.ts @@ -62,6 +62,7 @@ const sampleDoc = { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; diff --git a/src/lib/ai-edition/store/undo.modalGuard.test.tsx b/src/lib/ai-edition/store/undo.modalGuard.test.tsx index f54b12389..5c6251347 100644 --- a/src/lib/ai-edition/store/undo.modalGuard.test.tsx +++ b/src/lib/ai-edition/store/undo.modalGuard.test.tsx @@ -35,6 +35,7 @@ function doc(title: string): AxcutDocument { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; } diff --git a/src/lib/ai-edition/store/useCaptions.test.ts b/src/lib/ai-edition/store/useCaptions.test.ts index b4063d68c..78ffa2a12 100644 --- a/src/lib/ai-edition/store/useCaptions.test.ts +++ b/src/lib/ai-edition/store/useCaptions.test.ts @@ -71,6 +71,7 @@ const docA: AxcutDocument = { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; diff --git a/src/lib/ai-edition/store/useEditorSettings.test.ts b/src/lib/ai-edition/store/useEditorSettings.test.ts index bd47cb452..a5b76b512 100644 --- a/src/lib/ai-edition/store/useEditorSettings.test.ts +++ b/src/lib/ai-edition/store/useEditorSettings.test.ts @@ -74,6 +74,7 @@ const docA: AxcutDocument = { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; diff --git a/src/lib/ai-edition/store/useTimeline.test.ts b/src/lib/ai-edition/store/useTimeline.test.ts index 78854e817..03a0462dd 100644 --- a/src/lib/ai-edition/store/useTimeline.test.ts +++ b/src/lib/ai-edition/store/useTimeline.test.ts @@ -110,6 +110,7 @@ const sampleDoc: AxcutDocument = { }, annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; @@ -547,6 +548,93 @@ describe("useTimeline.addCameraFullscreen (camera gate)", () => { }); }); +describe("useTimeline.addAudioRegion", () => { + beforeEach(() => { + useProjectStore.getState().clear(); + for (const mock of Object.values(bridgeMocks)) mock.mockReset(); + bridgeMocks.save.mockImplementation(async (doc: typeof sampleDoc) => ({ + success: true, + document: doc, + })); + useProjectStore.setState({ + projectId: "proj_test", + document: { + ...sampleDoc, + assets: [ + ...sampleDoc.assets, + { + id: "asset_music", + kind: "audio", + label: "bgm.mp3", + originalPath: "/tmp/bgm.mp3", + durationSec: 120, + cameraTrack: null, + }, + ], + }, + currentTimeSec: 1, + revision: 1, + status: "ready", + error: null, + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("places an anchored layer at the playhead with the given span", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.addAudioRegion("music", "asset_music", 5); + }); + const layers = useProjectStore.getState().document?.audioRanges ?? []; + expect(layers).toHaveLength(1); + expect(layers[0]).toMatchObject({ + assetId: "asset_music", + kind: "music", + startMs: 1000, + endMs: 6000, + gainDb: 0, + loop: false, + muted: false, + // Anchored to the covered clip, like every other modifier kind. + clipId: "clip_a", + }); + // Auto-selected so its inspector opens, matching addAnnotation. + expect(result.current.selection).toEqual({ kind: "audio", id: layers[0]?.id }); + }); + + it("refuses an asset that is not in the document", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.addAudioRegion("voiceover", "asset_missing", 3); + }); + expect(useProjectStore.getState().document?.audioRanges ?? []).toHaveLength(0); + }); + + it("updates payload fields group-wide and moves spans", async () => { + const { result } = renderTimeline(); + await act(async () => { + await result.current.addAudioRegion("voiceover", "asset_music", 5); + }); + const id = useProjectStore.getState().document?.audioRanges[0]?.id ?? ""; + expect(id).toBeTruthy(); + await act(async () => { + await result.current.updateAudioRegion(id, { gainDb: 6, loop: true, fadeInMs: 250 }); + await result.current.updateAudioSpan(id, 2000, 9000); + }); + const layer = useProjectStore.getState().document?.audioRanges[0]; + expect(layer).toMatchObject({ + gainDb: 6, + loop: true, + fadeInMs: 250, + startMs: 2000, + endMs: 9000, + }); + }); +}); + describe("useTimeline.addAnnotation", () => { beforeEach(() => { useProjectStore.getState().clear(); diff --git a/src/lib/ai-edition/transcription/status.test.ts b/src/lib/ai-edition/transcription/status.test.ts index 00261712e..4b88b94ff 100644 --- a/src/lib/ai-edition/transcription/status.test.ts +++ b/src/lib/ai-edition/transcription/status.test.ts @@ -243,6 +243,7 @@ describe("transcriptRelevantAssetIds", () => { transcripts: [], annotations: [], zoomRanges: [], + audioRanges: [], legacyEditor: null, }; diff --git a/src/native/sceneDescription.test.ts b/src/native/sceneDescription.test.ts index 570ae71ee..d15b85af3 100644 --- a/src/native/sceneDescription.test.ts +++ b/src/native/sceneDescription.test.ts @@ -91,6 +91,7 @@ function makeDoc( }, annotations: overrides.annotations ?? [], zoomRanges: overrides.zoomRanges ?? [], + audioRanges: overrides.audioRanges ?? [], legacyEditor: overrides.legacyEditor ?? null, }; } From d74baa360fdb1dee66bdc26c65efd58d308075f8 Mon Sep 17 00:00:00 2001 From: Ola Adebayo Date: Fri, 28 Aug 2026 15:16:20 +0100 Subject: [PATCH 2/6] feat(electron): import audio assets and persist recorded voiceovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DocumentService.addAsset accepts audio files (mp3/wav/m4a/ogg/flac/…) with an explicit caller-side kind hint, so an ambiguous container (a recorded voiceover is .webm, like a screen recording) is filed as audio instead of video. New IPC: open-audio-file-picker (audio filters) and save-recorded-voiceover (writes the MediaRecorder blob under the recordings dir). read-binary-file's approval gate now covers audio extensions so the export post-pass can read layer assets. Removing an asset drops the layers that played it. --- electron/ai-edition/document-service.test.ts | 40 ++++++++- electron/ai-edition/document-service.ts | 55 ++++++++++-- electron/electron-env.d.ts | 13 +++ electron/ipc/handlers.ts | 87 ++++++++++++++++++- electron/ipc/nativeBridge.ts | 1 + .../services/aiEditionService.ts | 9 +- electron/preload.ts | 6 ++ src/native/browserShim.ts | 31 +++++-- src/native/client.ts | 4 +- src/native/contracts.ts | 2 +- 10 files changed, 228 insertions(+), 20 deletions(-) diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 6cbdb97c9..5a4640883 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -266,13 +266,29 @@ describe("DocumentService", () => { expect(path.isAbsolute(updated.assets[0]?.originalPath ?? "")).toBe(true); }); - it("rejects unsupported video extensions", async () => { + it("rejects unsupported media extensions", async () => { const doc = await service.createProject("P"); await expect( - service.addAsset(doc.project.id, { path: "/tmp/audio.mp3" }), + service.addAsset(doc.project.id, { path: "/tmp/notes.txt" }), ).rejects.toBeInstanceOf(ProjectFileError); }); + it("appends an audio asset with kind 'audio' without claiming primaryAssetId", async () => { + const doc = await service.createProject("P"); + const updated = await service.addAsset(doc.project.id, { path: "/tmp/music.mp3" }); + expect(updated.assets).toHaveLength(1); + expect(updated.assets[0]?.kind).toBe("audio"); + expect(updated.project.primaryAssetId).toBeUndefined(); + }); + + it("an audio asset does not displace the primary video", async () => { + const doc = await service.createProject("P"); + const withVideo = await service.addAsset(doc.project.id, { path: "/tmp/a.mp4" }); + const after = await service.addAsset(withVideo.project.id, { path: "/tmp/b.mp3" }); + expect(after.project.primaryAssetId).toBe(withVideo.project.primaryAssetId); + expect(after.assets.at(-1)?.kind).toBe("audio"); + }); + it("preserves primaryAssetId when adding a second asset", async () => { const doc = await service.createProject("P"); const first = await service.addAsset(doc.project.id, { path: "/tmp/a.mp4" }); @@ -342,6 +358,25 @@ describe("DocumentService", () => { }, ], }, + audioRanges: [ + { + id: "aud_1", + clipId: "clip_1", + sourceStartSec: 0, + sourceEndSec: 1, + startMs: 0, + endMs: 1000, + assetId, + kind: "music", + offsetMs: 0, + gainDb: 0, + loop: false, + fadeInMs: 0, + fadeOutMs: 0, + muted: false, + origin: "user", + }, + ], }); const after = await service.removeAsset(docWithTimeline.project.id, assetId); @@ -349,6 +384,7 @@ describe("DocumentService", () => { expect(after.timeline.clips).toHaveLength(0); expect(after.timeline.trimRanges).toHaveLength(0); expect(after.zoomRanges).toHaveLength(0); + expect(after.audioRanges).toHaveLength(0); expect((after.legacyEditor as { speedRegions: unknown[] }).speedRegions).toHaveLength(0); expect(after.project.primaryAssetId).toBeUndefined(); }); diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index 3c93e3bc0..b969f89bd 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -38,6 +38,13 @@ export interface ProjectSummary { export interface AddAssetInput { path: string; label?: string; + /** + * What the caller KNOWS the file is. Optional: without it the extension + * decides — which misclassifies ambiguous containers (a recorded voiceover + * is `.webm`, the same extension as a screen recording). Callers that know + * (the audio-layer import flow) say so. + */ + kind?: "video" | "audio"; } export class DocumentNotFoundError extends Error { @@ -67,9 +74,27 @@ const SUPPORTED_VIDEO_EXTENSIONS = new Set([ ".wmv", ]); -function isSupportedVideoPath(filePath: string): boolean { +// Audio-only media for editor audio layers (voiceover / background music). +// Anything decodeAudioData / an