(null);
const [elapsed, setElapsed] = useState(0);
@@ -245,11 +248,43 @@ export function LaunchWindow() {
+
+
+
+
+ {countdownDelay > 0 ? `${countdownDelay}s` : t('recording.noDelay')}
+
+
+
+ {[0, 3, 5, 10].map((delay) => (
+ setCountdownDelay(delay)}
+ className={`text-xs cursor-pointer ${
+ countdownDelay === delay ? "text-white font-medium" : "text-white/60"
+ }`}
+ >
+ {delay === 0 ? t('recording.noDelay') : `${delay}s`}
+
+ ))}
+
+
+
{recording ? (
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index 2c983e26..f8b9b216 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -559,7 +559,7 @@ export function SettingsPanel({
{exportFormat === 'mp4' && (
-
+
onExportQualityChange?.('medium')}
className={cn(
@@ -850,6 +850,15 @@ export function SettingsPanel({
>
{tSettings('export.quality.medium')}
+ onExportQualityChange?.('high')}
+ className={cn(
+ "rounded-md transition-all text-[10px] font-medium",
+ exportQuality === 'high' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
+ )}
+ >
+ {tSettings('export.quality.high')}
+
onExportQualityChange?.('source')}
className={cn(
@@ -857,7 +866,7 @@ export function SettingsPanel({
exportQuality === 'source' ? "bg-white text-black" : "text-slate-400 hover:text-slate-200"
)}
>
- {tSettings('export.quality.high')}
+ {tSettings('export.quality.original')}
)}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index f410b42b..34bec02c 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -5,84 +5,72 @@ import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
import { toast } from "sonner";
import { Toaster } from "@/components/ui/sonner";
import { useI18n } from "@/contexts/I18nContext";
+import { useShortcuts } from "@/contexts/ShortcutsContext";
import { SUPPORTED_LOCALES } from "@/i18n/config";
import type { AppLocale } from "@/i18n/config";
-import { useShortcuts } from "@/contexts/ShortcutsContext";
-import { getAssetPath } from "@/lib/assetPath";
-import {
- calculateOutputDimensions,
- type ExportFormat,
- type ExportProgress,
- type ExportQuality,
- type ExportSettings,
- GIF_SIZE_PRESETS,
- GifExporter,
- type GifFrameRate,
- type GifSizePreset,
- VideoExporter,
-} from "@/lib/exporter";
-import { matchesShortcut } from "@/lib/shortcuts";
-import { DEFAULT_WALLPAPER_RELATIVE_PATH, WALLPAPER_PATHS } from "@/lib/wallpapers";
-import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
-import { ExportDialog } from "./ExportDialog";
-import PlaybackControls from "./PlaybackControls";
-import {
- createProjectData,
- deriveNextId,
- fromFileUrl,
- normalizeProjectEditor,
- toFileUrl,
- validateProjectData,
-} from "./projectPersistence";
-import { SettingsPanel } from "./SettingsPanel";
-import TimelineEditor from "./timeline/TimelineEditor";
-import {
- detectInteractionCandidates,
- normalizeCursorTelemetry,
-} from "./timeline/zoomSuggestionUtils";
-import {
- type AnnotationRegion,
- type CropRegion,
- type CursorTelemetryPoint,
- clampFocusToDepth,
- DEFAULT_ANNOTATION_POSITION,
- DEFAULT_ANNOTATION_SIZE,
- DEFAULT_ANNOTATION_STYLE,
- DEFAULT_CROP_REGION,
- DEFAULT_CURSOR_CLICK_BOUNCE,
- DEFAULT_CURSOR_MOTION_BLUR,
- DEFAULT_CURSOR_SIZE,
- DEFAULT_CURSOR_SMOOTHING,
- DEFAULT_FIGURE_DATA,
- DEFAULT_PLAYBACK_SPEED,
- DEFAULT_ZOOM_DEPTH,
- DEFAULT_ZOOM_MOTION_BLUR,
- type FigureData,
- type PlaybackSpeed,
- type SpeedRegion,
- type TrimRegion,
- type ZoomDepth,
- type ZoomFocus,
- type ZoomRegion,
-} from "./types";
+
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
+import PlaybackControls from "./PlaybackControls";
+import TimelineEditor from "./timeline/TimelineEditor";
+import { SettingsPanel } from "./SettingsPanel";
+import { ExportDialog } from "./ExportDialog";
+import { DEFAULT_WALLPAPER_RELATIVE_PATH, WALLPAPER_PATHS } from "@/lib/wallpapers";
import {
- buildLoopedCursorTelemetry,
- getDisplayedTimelineWindowMs,
-} from "./videoPlayback/cursorLoopTelemetry";
+ createProjectData,
+ deriveNextId,
+ fromFileUrl,
+ normalizeProjectEditor,
+ toFileUrl,
+ validateProjectData,
+} from "./projectPersistence";
+
+import {
+ DEFAULT_CURSOR_CLICK_BOUNCE,
+ DEFAULT_CURSOR_MOTION_BLUR,
+ DEFAULT_CURSOR_SIZE,
+ DEFAULT_CURSOR_SMOOTHING,
+ DEFAULT_ZOOM_DEPTH,
+ DEFAULT_ZOOM_MOTION_BLUR,
+ clampFocusToDepth,
+ DEFAULT_CROP_REGION,
+ DEFAULT_ANNOTATION_POSITION,
+ DEFAULT_ANNOTATION_SIZE,
+ DEFAULT_ANNOTATION_STYLE,
+ DEFAULT_FIGURE_DATA,
+ DEFAULT_PLAYBACK_SPEED,
+ type ZoomDepth,
+ type ZoomFocus,
+ type ZoomRegion,
+ type CursorTelemetryPoint,
+ type TrimRegion,
+ type AnnotationRegion,
+ type CropRegion,
+ type FigureData,
+ type SpeedRegion,
+ type AudioRegion,
+ type PlaybackSpeed,
+} from "./types";
+import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
+import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
+import { getAssetPath } from "@/lib/assetPath";
+import { matchesShortcut } from "@/lib/shortcuts";
+import { detectInteractionCandidates, normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils";
+import { buildLoopedCursorTelemetry, getDisplayedTimelineWindowMs } from "./videoPlayback/cursorLoopTelemetry";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
const LOOP_CURSOR_END_WINDOW_MS = 670;
type EditorHistorySnapshot = {
- zoomRegions: ZoomRegion[];
- trimRegions: TrimRegion[];
- speedRegions: SpeedRegion[];
- annotationRegions: AnnotationRegion[];
- selectedZoomId: string | null;
- selectedTrimId: string | null;
- selectedSpeedId: string | null;
- selectedAnnotationId: string | null;
+ zoomRegions: ZoomRegion[];
+ trimRegions: TrimRegion[];
+ speedRegions: SpeedRegion[];
+ annotationRegions: AnnotationRegion[];
+ audioRegions: AudioRegion[];
+ selectedZoomId: string | null;
+ selectedTrimId: string | null;
+ selectedSpeedId: string | null;
+ selectedAnnotationId: string | null;
+ selectedAudioId: string | null;
};
type PendingExportSave = {
@@ -142,6 +130,8 @@ export default function VideoEditor() {
const [selectedSpeedId, setSelectedSpeedId] = useState
(null);
const [annotationRegions, setAnnotationRegions] = useState([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState(null);
+ const [audioRegions, setAudioRegions] = useState([]);
+ const [selectedAudioId, setSelectedAudioId] = useState(null);
const [isExporting, setIsExporting] = useState(false);
const [exportProgress, setExportProgress] = useState(null);
const [exportError, setExportError] = useState(null);
@@ -160,6 +150,7 @@ export default function VideoEditor() {
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
const nextSpeedIdRef = useRef(1);
+ const nextAudioIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
const nextAnnotationIdRef = useRef(1);
@@ -178,10 +169,12 @@ export default function VideoEditor() {
trimRegions: JSON.parse(JSON.stringify(snapshot.trimRegions)),
speedRegions: JSON.parse(JSON.stringify(snapshot.speedRegions)),
annotationRegions: JSON.parse(JSON.stringify(snapshot.annotationRegions)),
+ audioRegions: JSON.parse(JSON.stringify(snapshot.audioRegions)),
selectedZoomId: snapshot.selectedZoomId,
selectedTrimId: snapshot.selectedTrimId,
selectedSpeedId: snapshot.selectedSpeedId,
selectedAnnotationId: snapshot.selectedAnnotationId,
+ selectedAudioId: snapshot.selectedAudioId,
};
}, []);
@@ -191,20 +184,24 @@ export default function VideoEditor() {
trimRegions,
speedRegions,
annotationRegions,
+ audioRegions,
selectedZoomId,
selectedTrimId,
selectedSpeedId,
selectedAnnotationId,
+ selectedAudioId,
};
}, [
zoomRegions,
trimRegions,
speedRegions,
annotationRegions,
+ audioRegions,
selectedZoomId,
selectedTrimId,
selectedSpeedId,
selectedAnnotationId,
+ selectedAudioId,
]);
const applyHistorySnapshot = useCallback((snapshot: EditorHistorySnapshot) => {
@@ -214,15 +211,18 @@ export default function VideoEditor() {
setTrimRegions(cloned.trimRegions);
setSpeedRegions(cloned.speedRegions);
setAnnotationRegions(cloned.annotationRegions);
+ setAudioRegions(cloned.audioRegions);
setSelectedZoomId(cloned.selectedZoomId);
setSelectedTrimId(cloned.selectedTrimId);
setSelectedSpeedId(cloned.selectedSpeedId);
setSelectedAnnotationId(cloned.selectedAnnotationId);
+ setSelectedAudioId(cloned.selectedAudioId);
nextZoomIdRef.current = deriveNextId("zoom", cloned.zoomRegions.map((region) => region.id));
nextTrimIdRef.current = deriveNextId("trim", cloned.trimRegions.map((region) => region.id));
nextSpeedIdRef.current = deriveNextId("speed", cloned.speedRegions.map((region) => region.id));
nextAnnotationIdRef.current = deriveNextId("annotation", cloned.annotationRegions.map((region) => region.id));
+ nextAudioIdRef.current = deriveNextId("audio", cloned.audioRegions.map((region) => region.id));
nextAnnotationZIndexRef.current =
cloned.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1;
}, [cloneSnapshot]);
@@ -292,6 +292,7 @@ export default function VideoEditor() {
setTrimRegions(normalizedEditor.trimRegions);
setSpeedRegions(normalizedEditor.speedRegions);
setAnnotationRegions(normalizedEditor.annotationRegions);
+ setAudioRegions(normalizedEditor.audioRegions);
setAspectRatio(normalizedEditor.aspectRatio);
setExportQuality(normalizedEditor.exportQuality);
setExportFormat(normalizedEditor.exportFormat);
@@ -303,10 +304,12 @@ export default function VideoEditor() {
setSelectedTrimId(null);
setSelectedSpeedId(null);
setSelectedAnnotationId(null);
+ setSelectedAudioId(null);
nextZoomIdRef.current = deriveNextId("zoom", normalizedEditor.zoomRegions.map((region) => region.id));
nextTrimIdRef.current = deriveNextId("trim", normalizedEditor.trimRegions.map((region) => region.id));
nextSpeedIdRef.current = deriveNextId("speed", normalizedEditor.speedRegions.map((region) => region.id));
+ nextAudioIdRef.current = deriveNextId("audio", normalizedEditor.audioRegions.map((region) => region.id));
nextAnnotationIdRef.current = deriveNextId(
"annotation",
normalizedEditor.annotationRegions.map((region) => region.id),
@@ -343,6 +346,7 @@ export default function VideoEditor() {
trimRegions,
speedRegions,
annotationRegions,
+ audioRegions,
aspectRatio,
exportQuality,
exportFormat,
@@ -371,6 +375,7 @@ export default function VideoEditor() {
zoomRegions,
trimRegions,
speedRegions,
+ audioRegions,
annotationRegions,
aspectRatio,
exportQuality,
@@ -480,6 +485,7 @@ export default function VideoEditor() {
trimRegions,
speedRegions,
annotationRegions,
+ audioRegions,
aspectRatio,
exportQuality,
exportFormat,
@@ -814,7 +820,10 @@ export default function VideoEditor() {
const handleSelectZoom = useCallback((id: string | null) => {
setSelectedZoomId(id);
- if (id) setSelectedTrimId(null);
+ if (id) {
+ setSelectedTrimId(null);
+ setSelectedAudioId(null);
+ }
}, []);
const handleSelectTrim = useCallback((id: string | null) => {
@@ -822,6 +831,7 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedAnnotationId(null);
+ setSelectedAudioId(null);
}
}, []);
@@ -830,6 +840,7 @@ export default function VideoEditor() {
if (id) {
setSelectedZoomId(null);
setSelectedTrimId(null);
+ setSelectedAudioId(null);
}
}, []);
@@ -952,6 +963,7 @@ export default function VideoEditor() {
setSelectedZoomId(null);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
+ setSelectedAudioId(null);
}
}, []);
@@ -991,6 +1003,54 @@ export default function VideoEditor() {
}
}, [selectedSpeedId]);
+ const handleSelectAudio = useCallback((id: string | null) => {
+ setSelectedAudioId(id);
+ if (id) {
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ setSelectedSpeedId(null);
+ }
+ }, []);
+
+ const handleAudioAdded = useCallback((span: Span, audioPath: string) => {
+ const id = `audio-${nextAudioIdRef.current++}`;
+ const newRegion: AudioRegion = {
+ id,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ audioPath,
+ volume: 1,
+ };
+ setAudioRegions((prev) => [...prev, newRegion]);
+ setSelectedAudioId(id);
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ setSelectedSpeedId(null);
+ }, []);
+
+ const handleAudioSpanChange = useCallback((id: string, span: Span) => {
+ setAudioRegions((prev) =>
+ prev.map((region) =>
+ region.id === id
+ ? {
+ ...region,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ }
+ : region,
+ ),
+ );
+ }, []);
+
+ const handleAudioDelete = useCallback((id: string) => {
+ setAudioRegions((prev) => prev.filter((region) => region.id !== id));
+ if (selectedAudioId === id) {
+ setSelectedAudioId(null);
+ }
+ }, [selectedAudioId]);
+
const handleSpeedChange = useCallback((speed: PlaybackSpeed) => {
if (!selectedSpeedId) return;
setSpeedRegions((prev) =>
@@ -1210,6 +1270,78 @@ export default function VideoEditor() {
}
}, [selectedSpeedId, speedRegions]);
+ useEffect(() => {
+ if (selectedAudioId && !audioRegions.some((region) => region.id === selectedAudioId)) {
+ setSelectedAudioId(null);
+ }
+ }, [selectedAudioId, audioRegions]);
+
+ // Audio playback sync: manage Audio elements that play in sync with video
+ const audioElementsRef = useRef>(new Map());
+
+ useEffect(() => {
+ const existing = audioElementsRef.current;
+ const currentIds = new Set(audioRegions.map(r => r.id));
+
+ // Remove old audio elements
+ for (const [id, audio] of existing) {
+ if (!currentIds.has(id)) {
+ audio.pause();
+ audio.src = '';
+ existing.delete(id);
+ }
+ }
+
+ // Create/update audio elements
+ for (const region of audioRegions) {
+ let audio = existing.get(region.id);
+ if (!audio) {
+ audio = new Audio();
+ audio.preload = 'auto';
+ existing.set(region.id, audio);
+ }
+ const expectedSrc = toFileUrl(region.audioPath);
+ if (audio.src !== expectedSrc) {
+ audio.src = expectedSrc;
+ }
+ audio.volume = Math.max(0, Math.min(1, region.volume));
+ }
+
+ return () => {
+ for (const audio of existing.values()) {
+ audio.pause();
+ audio.src = '';
+ }
+ existing.clear();
+ };
+ }, [audioRegions]);
+
+ // Sync audio playback with video currentTime and isPlaying state
+ useEffect(() => {
+ for (const region of audioRegions) {
+ const audio = audioElementsRef.current.get(region.id);
+ if (!audio) continue;
+
+ const currentTimeMs = currentTime * 1000;
+ const isInRegion = currentTimeMs >= region.startMs && currentTimeMs < region.endMs;
+
+ if (isPlaying && isInRegion) {
+ const audioOffset = (currentTimeMs - region.startMs) / 1000;
+ // Only seek if significantly out of sync (> 200ms)
+ if (Math.abs(audio.currentTime - audioOffset) > 0.2) {
+ audio.currentTime = audioOffset;
+ }
+ if (audio.paused) {
+ audio.play().catch(() => {});
+ }
+ } else {
+ if (!audio.paused) {
+ audio.pause();
+ }
+ }
+ }
+ }, [isPlaying, currentTime, audioRegions]);
+
const showExportSuccessToast = useCallback((filePath: string) => {
toast.success(`Exported successfully to ${filePath}`, {
action: {
@@ -1393,12 +1525,21 @@ export default function VideoEditor() {
bitrate = 80_000_000;
}
} else {
- // Use quality-based target resolution
- const targetHeight = quality === 'medium' ? 720 : 1080;
+ // Use source-relative quality scaling.
+ // "source" is handled above; this branch maps the remaining tiers.
+ const qualityScale =
+ quality === 'medium' ? 0.6 : quality === 'good' ? 0.75 : 0.9;
+ const maxWidth = Math.max(2, Math.floor((sourceWidth * qualityScale) / 2) * 2);
+ const maxHeight = Math.max(2, Math.floor((sourceHeight * qualityScale) / 2) * 2);
+ const maxAspect = maxWidth / maxHeight;
- // Calculate dimensions maintaining aspect ratio
- exportHeight = Math.floor(targetHeight / 2) * 2;
- exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
+ if (aspectRatioValue >= maxAspect) {
+ exportWidth = maxWidth;
+ exportHeight = Math.max(2, Math.floor((exportWidth / aspectRatioValue) / 2) * 2);
+ } else {
+ exportHeight = maxHeight;
+ exportWidth = Math.max(2, Math.floor((exportHeight * aspectRatioValue) / 2) * 2);
+ }
// Adjust bitrate for lower resolutions
const totalPixels = exportWidth * exportHeight;
@@ -1437,6 +1578,7 @@ export default function VideoEditor() {
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
+ audioRegions,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
@@ -1746,6 +1888,12 @@ export default function VideoEditor() {
onSpeedDelete={handleSpeedDelete}
selectedSpeedId={selectedSpeedId}
onSelectSpeed={handleSelectSpeed}
+ audioRegions={audioRegions}
+ onAudioAdded={handleAudioAdded}
+ onAudioSpanChange={handleAudioSpanChange}
+ onAudioDelete={handleAudioDelete}
+ selectedAudioId={selectedAudioId}
+ onSelectAudio={handleSelectAudio}
annotationRegions={annotationRegions}
onAnnotationAdded={handleAnnotationAdded}
onAnnotationSpanChange={handleAnnotationSpanChange}
diff --git a/src/components/video-editor/audio.test.ts b/src/components/video-editor/audio.test.ts
new file mode 100644
index 00000000..c4578df8
--- /dev/null
+++ b/src/components/video-editor/audio.test.ts
@@ -0,0 +1,208 @@
+import { describe, it, expect } from "vitest";
+import * as fc from "fast-check";
+import { toFileUrl, fromFileUrl, normalizeProjectEditor } from "./projectPersistence";
+
+describe("Audio path handling", () => {
+ describe("toFileUrl produces valid file:// URLs for audio paths", () => {
+ it("should handle Unix absolute paths", () => {
+ expect(toFileUrl("/Users/music/song.mp3")).toBe("file:///Users/music/song.mp3");
+ });
+
+ it("should handle Windows drive paths", () => {
+ expect(toFileUrl("C:/Users/music/song.mp3")).toBe("file:///C:/Users/music/song.mp3");
+ });
+
+ it("should handle backslash Windows paths", () => {
+ const result = toFileUrl("C:\\Users\\music\\song.mp3");
+ expect(result).toMatch(/^file:\/\//);
+ expect(result).toContain("C:");
+ expect(result).toContain("song.mp3");
+ });
+
+ it("should encode spaces in path segments", () => {
+ const result = toFileUrl("/Users/my music/my song.mp3");
+ expect(result).toContain("my%20music");
+ expect(result).toContain("my%20song.mp3");
+ });
+
+ it("should encode special characters like spaces", () => {
+ const result = toFileUrl("/Users/music/song file.mp3");
+ expect(result).toContain("song%20file.mp3");
+ // Result should be a valid file:// URL
+ expect(result).toMatch(/^file:\/\//);
+ });
+
+ it("should roundtrip through fromFileUrl for simple paths", () => {
+ const paths = [
+ "/Users/music/song.mp3",
+ "/tmp/audio.wav",
+ "/home/user/my-file.aac",
+ "/data/recordings/track_01.flac",
+ ];
+ for (const originalPath of paths) {
+ const fileUrl = toFileUrl(originalPath);
+ const recovered = fromFileUrl(fileUrl);
+ expect(recovered).toBe(originalPath);
+ }
+ });
+
+ it("should roundtrip paths with spaces through fromFileUrl", () => {
+ const originalPath = "/Users/my user/my music/song file.mp3";
+ const fileUrl = toFileUrl(originalPath);
+ const recovered = fromFileUrl(fileUrl);
+ expect(recovered).toBe(originalPath);
+ });
+ });
+});
+
+describe("Audio region normalization", () => {
+ describe("volume is clamped to [0, 1]", () => {
+ it("should clamp volume > 1 down to 1", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: 5 },
+ ],
+ } as any);
+ expect(result.audioRegions[0].volume).toBe(1);
+ });
+
+ it("should clamp negative volume to 0", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: -0.5 },
+ ],
+ } as any);
+ expect(result.audioRegions[0].volume).toBe(0);
+ });
+
+ it("should preserve valid volume values in [0, 1]", () => {
+ fc.assert(
+ fc.property(
+ fc.double({ min: 0, max: 1, noNaN: true }),
+ (volume) => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume },
+ ],
+ } as any);
+ expect(result.audioRegions[0].volume).toBeCloseTo(volume, 10);
+ },
+ ),
+ );
+ });
+
+ it("should default to 1 when volume is NaN", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: NaN },
+ ],
+ } as any);
+ expect(result.audioRegions[0].volume).toBe(1);
+ });
+
+ it("should default to 1 when volume is undefined", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3" },
+ ],
+ } as any);
+ expect(result.audioRegions[0].volume).toBe(1);
+ });
+ });
+
+ describe("startMs and endMs boundaries", () => {
+ it("should clamp negative startMs to 0", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: -500, endMs: 1000, audioPath: "/test.mp3", volume: 1 },
+ ],
+ } as any);
+ expect(result.audioRegions[0].startMs).toBe(0);
+ });
+
+ it("should ensure endMs > startMs when endMs < startMs", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 1000, endMs: 500, audioPath: "/test.mp3", volume: 1 },
+ ],
+ } as any);
+ expect(result.audioRegions[0].endMs).toBeGreaterThan(result.audioRegions[0].startMs);
+ });
+
+ it("should handle equal startMs and endMs by ensuring minimum gap", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 1000, endMs: 1000, audioPath: "/test.mp3", volume: 1 },
+ ],
+ } as any);
+ expect(result.audioRegions[0].endMs).toBeGreaterThan(result.audioRegions[0].startMs);
+ });
+
+ it("should preserve valid startMs/endMs for arbitrary non-negative values", () => {
+ fc.assert(
+ fc.property(
+ fc.nat({ max: 100000 }),
+ fc.integer({ min: 1, max: 100000 }),
+ (startMs, duration) => {
+ const endMs = startMs + duration;
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs, endMs, audioPath: "/test.mp3", volume: 0.5 },
+ ],
+ } as any);
+ expect(result.audioRegions[0].startMs).toBe(startMs);
+ expect(result.audioRegions[0].endMs).toBe(endMs);
+ },
+ ),
+ );
+ });
+ });
+
+ describe("audioPath normalization", () => {
+ it("should preserve a valid string path", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/Users/music/song.mp3", volume: 1 },
+ ],
+ } as any);
+ expect(result.audioRegions[0].audioPath).toBe("/Users/music/song.mp3");
+ });
+
+ it("should default to empty string for missing audioPath", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { id: "audio-1", startMs: 0, endMs: 1000, volume: 1 },
+ ],
+ } as any);
+ expect(result.audioRegions[0].audioPath).toBe("");
+ });
+
+ it("should filter out regions without a valid id", () => {
+ const result = normalizeProjectEditor({
+ audioRegions: [
+ { startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: 1 },
+ { id: "audio-1", startMs: 0, endMs: 1000, audioPath: "/test.mp3", volume: 1 },
+ ],
+ } as any);
+ expect(result.audioRegions).toHaveLength(1);
+ expect(result.audioRegions[0].id).toBe("audio-1");
+ });
+ });
+
+ describe("empty or missing audioRegions", () => {
+ it("should return empty array when audioRegions is undefined", () => {
+ const result = normalizeProjectEditor({} as any);
+ expect(result.audioRegions).toEqual([]);
+ });
+
+ it("should return empty array when audioRegions is not an array", () => {
+ const result = normalizeProjectEditor({ audioRegions: "invalid" } as any);
+ expect(result.audioRegions).toEqual([]);
+ });
+
+ it("should return empty array when audioRegions is null", () => {
+ const result = normalizeProjectEditor({ audioRegions: null } as any);
+ expect(result.audioRegions).toEqual([]);
+ });
+ });
+});
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
index be25b8fd..af4e1aff 100644
--- a/src/components/video-editor/projectPersistence.ts
+++ b/src/components/video-editor/projectPersistence.ts
@@ -18,6 +18,7 @@ import {
type CropRegion,
type SpeedRegion,
type TrimRegion,
+ type AudioRegion,
type ZoomRegion,
} from "./types";
@@ -42,6 +43,7 @@ export interface ProjectEditorState {
trimRegions: TrimRegion[];
speedRegions: SpeedRegion[];
annotationRegions: AnnotationRegion[];
+ audioRegions: AudioRegion[];
aspectRatio: AspectRatio;
exportQuality: ExportQuality;
exportFormat: ExportFormat;
@@ -289,6 +291,25 @@ export function normalizeProjectEditor(editor: Partial): Pro
})
: [];
+ const normalizedAudioRegions: AudioRegion[] = Array.isArray((editor as Partial).audioRegions)
+ ? ((editor as Partial).audioRegions as AudioRegion[])
+ .filter((region): region is AudioRegion => Boolean(region && typeof region.id === "string"))
+ .map((region) => {
+ const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
+ const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
+ const startMs = Math.max(0, Math.min(rawStart, rawEnd));
+ const endMs = Math.max(startMs + 1, rawEnd);
+
+ return {
+ id: region.id,
+ startMs,
+ endMs,
+ audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
+ volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
+ };
+ })
+ : [];
+
const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x : DEFAULT_CROP_REGION.x;
const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y : DEFAULT_CROP_REGION.y;
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width : DEFAULT_CROP_REGION.width;
@@ -331,12 +352,19 @@ export function normalizeProjectEditor(editor: Partial): Pro
trimRegions: normalizedTrimRegions,
speedRegions: normalizedSpeedRegions,
annotationRegions: normalizedAnnotationRegions,
+ audioRegions: normalizedAudioRegions,
aspectRatio:
typeof editor.aspectRatio === "string" &&
(validAspectRatios.has(editor.aspectRatio as AspectRatio) || isCustomAspectRatio(editor.aspectRatio))
? (editor.aspectRatio as AspectRatio)
: "16:9",
- exportQuality: editor.exportQuality === "medium" || editor.exportQuality === "source" ? editor.exportQuality : "good",
+ exportQuality:
+ editor.exportQuality === "medium" ||
+ editor.exportQuality === "good" ||
+ editor.exportQuality === "high" ||
+ editor.exportQuality === "source"
+ ? editor.exportQuality
+ : "good",
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
gifFrameRate:
editor.gifFrameRate === 15 ||
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index 2870fc92..50670f4c 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -1,21 +1,20 @@
import type { Span } from "dnd-timeline";
import { useItem } from "dnd-timeline";
-import { Gauge, MessageSquare, Scissors, ZoomIn } from "lucide-react";
+import { Gauge, MessageSquare, Music, Scissors, ZoomIn } from "lucide-react";
import { useMemo } from "react";
-import { useScopedT } from "@/contexts/I18nContext";
import { cn } from "@/lib/utils";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
- id: string;
- span: Span;
- rowId: string;
- children: React.ReactNode;
- isSelected?: boolean;
- onSelect?: () => void;
- zoomDepth?: number;
- speedValue?: number;
- variant?: "zoom" | "trim" | "annotation" | "speed";
+ id: string;
+ span: Span;
+ rowId: string;
+ children: React.ReactNode;
+ isSelected?: boolean;
+ onSelect?: () => void;
+ zoomDepth?: number;
+ speedValue?: number;
+ variant?: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio';
}
// Map zoom depth to multiplier labels
@@ -49,26 +48,36 @@ export default function Item({
variant = "zoom",
children,
}: ItemProps) {
- const t = useScopedT("timeline");
const { setNodeRef, attributes, listeners, itemStyle, itemContentStyle } = useItem({
id,
span,
data: { rowId },
});
- const isZoom = variant === "zoom";
- const isTrim = variant === "trim";
- const isSpeed = variant === "speed";
+ const isZoom = variant === 'zoom';
+ const isTrim = variant === 'trim';
+ const isSpeed = variant === 'speed';
+ const isAudio = variant === 'audio';
- const glassClass = isZoom
- ? glassStyles.glassGreen
- : isTrim
- ? glassStyles.glassRed
- : isSpeed
- ? glassStyles.glassAmber
- : glassStyles.glassYellow;
+ const glassClass = isZoom
+ ? glassStyles.glassGreen
+ : isTrim
+ ? glassStyles.glassRed
+ : isSpeed
+ ? glassStyles.glassAmber
+ : isAudio
+ ? glassStyles.glassPurple
+ : glassStyles.glassYellow;
- const endCapColor = isZoom ? "#2563EB" : isTrim ? "#ef4444" : isSpeed ? "#d97706" : "#B4A046";
+ const endCapColor = isZoom
+ ? '#2563EB'
+ : isTrim
+ ? '#ef4444'
+ : isSpeed
+ ? '#d97706'
+ : isAudio
+ ? '#a855f7'
+ : '#B4A046';
const timeLabel = useMemo(
() => `${formatMs(span.start)} – ${formatMs(span.end)}`,
@@ -78,93 +87,88 @@ export default function Item({
const MIN_ITEM_PX = 6;
const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
- return (
- onSelect?.()}
- className="group"
- >
-
-
{
- event.stopPropagation();
- onSelect?.();
- }}
- >
-
-
- {/* Content */}
-
-
- {isZoom ? (
- <>
-
-
- {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
-
- >
- ) : isTrim ? (
- <>
-
-
- {t("trim.label", undefined, { index: "" }).trim()}
-
- >
- ) : isSpeed ? (
- <>
-
-
- {speedValue !== undefined ? `${speedValue}×` : t("speed.label")}
-
- >
- ) : (
- <>
-
-
- {children}
-
- >
- )}
-
-
- {timeLabel}
-
-
-
-
-
- );
+ return (
+ onSelect?.()}
+ className="group"
+ >
+
+
{
+ event.stopPropagation();
+ onSelect?.();
+ }}
+ >
+
+
+ {/* Content */}
+
+
+ {isZoom ? (
+ <>
+
+
+ {ZOOM_LABELS[zoomDepth] || `${zoomDepth}×`}
+
+ >
+ ) : isTrim ? (
+ <>
+
+
+ Trim
+
+ >
+ ) : isSpeed ? (
+ <>
+
+
+ {speedValue !== undefined ? `${speedValue}×` : 'Speed'}
+
+ >
+ ) : isAudio ? (
+ <>
+
+
+ {children}
+
+ >
+ ) : (
+ <>
+
+
+ {children}
+
+ >
+ )}
+
+
+ {timeLabel}
+
+
+
+
+
+ );
}
diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css
index 23754429..9c96ea22 100644
--- a/src/components/video-editor/timeline/ItemGlass.module.css
+++ b/src/components/video-editor/timeline/ItemGlass.module.css
@@ -102,6 +102,32 @@
z-index: 10;
}
+.glassPurple {
+ position: relative;
+ border-radius: 8px;
+ -corner-smoothing: antialiased;
+ background: rgba(168, 85, 247, 0.15);
+ border: 1px solid rgba(168, 85, 247, 0.3);
+ box-shadow: 0 2px 12px 0 rgba(168, 85, 247, 0.1) inset;
+ margin: 2px 0;
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.glassPurple:hover {
+ background: rgba(168, 85, 247, 0.25);
+ border-color: rgba(168, 85, 247, 0.5);
+ box-shadow: 0 4px 20px 0 rgba(168, 85, 247, 0.2) inset;
+}
+
+.glassPurple.selected {
+ background: rgba(168, 85, 247, 0.35);
+ border-color: #a855f7;
+ box-shadow: 0 0 0 1px #a855f7, 0 4px 20px 0 rgba(168, 85, 247, 0.3) inset;
+ z-index: 10;
+}
+
.zoomEndCap {
position: absolute;
top: 0;
@@ -120,7 +146,9 @@
.glassYellow:hover .zoomEndCap,
.glassYellow.selected .zoomEndCap,
.glassAmber:hover .zoomEndCap,
-.glassAmber.selected .zoomEndCap {
+.glassAmber.selected .zoomEndCap,
+.glassPurple:hover .zoomEndCap,
+.glassPurple.selected .zoomEndCap {
opacity: 1;
}
diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx
index 13ce0719..27d02dce 100644
--- a/src/components/video-editor/timeline/TimelineEditor.tsx
+++ b/src/components/video-editor/timeline/TimelineEditor.tsx
@@ -1,18 +1,5 @@
-import type { Range, Span } from "dnd-timeline";
-import { useTimelineContext } from "dnd-timeline";
-import {
- Check,
- ChevronDown,
- Gauge,
- MessageSquare,
- Plus,
- Scissors,
- WandSparkles,
- ZoomIn,
-} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type WheelEvent } from "react";
-import { toast } from "sonner";
-import { v4 as uuidv4 } from "uuid";
+import { useTimelineContext } from "dnd-timeline";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
@@ -20,66 +7,71 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { Plus, Scissors, ZoomIn, MessageSquare, ChevronDown, Check, Gauge, WandSparkles, Music } from "lucide-react";
+import { toast } from "sonner";
+import { cn } from "@/lib/utils";
+import { v4 as uuidv4 } from 'uuid';
import { useShortcuts } from "@/contexts/ShortcutsContext";
import { matchesShortcut } from "@/lib/shortcuts";
-import { cn } from "@/lib/utils";
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
import { formatShortcut } from "@/utils/platformUtils";
import { TutorialHelp } from "../TutorialHelp";
-import type {
- AnnotationRegion,
- CursorTelemetryPoint,
- SpeedRegion,
- TrimRegion,
- ZoomFocus,
- ZoomRegion,
-} from "../types";
+import TimelineWrapper from "./TimelineWrapper";
+import Row from "./Row";
import Item from "./Item";
import KeyframeMarkers from "./KeyframeMarkers";
-import Row from "./Row";
-import TimelineWrapper from "./TimelineWrapper";
+import type { Range, Span } from "dnd-timeline";
+import type { ZoomRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint, ZoomFocus } from "../types";
+import { toFileUrl } from "../projectPersistence";
import { detectInteractionCandidates, normalizeCursorTelemetry } from "./zoomSuggestionUtils";
const ZOOM_ROW_ID = "row-zoom";
const TRIM_ROW_ID = "row-trim";
const ANNOTATION_ROW_ID = "row-annotation";
const SPEED_ROW_ID = "row-speed";
+const AUDIO_ROW_ID = "row-audio";
const FALLBACK_RANGE_MS = 1000;
const TARGET_MARKER_COUNT = 12;
const SUGGESTION_SPACING_MS = 1800;
interface TimelineEditorProps {
- videoDuration: number;
- currentTime: number;
- onSeek?: (time: number) => void;
- cursorTelemetry?: CursorTelemetryPoint[];
- zoomRegions: ZoomRegion[];
- onZoomAdded: (span: Span) => void;
- onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
- onZoomSpanChange: (id: string, span: Span) => void;
- onZoomDelete: (id: string) => void;
- selectedZoomId: string | null;
- onSelectZoom: (id: string | null) => void;
- trimRegions?: TrimRegion[];
- onTrimAdded?: (span: Span) => void;
- onTrimSpanChange?: (id: string, span: Span) => void;
- onTrimDelete?: (id: string) => void;
- selectedTrimId?: string | null;
- onSelectTrim?: (id: string | null) => void;
- annotationRegions?: AnnotationRegion[];
- onAnnotationAdded?: (span: Span) => void;
- onAnnotationSpanChange?: (id: string, span: Span) => void;
- onAnnotationDelete?: (id: string) => void;
- selectedAnnotationId?: string | null;
- onSelectAnnotation?: (id: string | null) => void;
- speedRegions?: SpeedRegion[];
- onSpeedAdded?: (span: Span) => void;
- onSpeedSpanChange?: (id: string, span: Span) => void;
- onSpeedDelete?: (id: string) => void;
- selectedSpeedId?: string | null;
- onSelectSpeed?: (id: string | null) => void;
- aspectRatio: AspectRatio;
- onAspectRatioChange: (aspectRatio: AspectRatio) => void;
+ videoDuration: number;
+ currentTime: number;
+ onSeek?: (time: number) => void;
+ cursorTelemetry?: CursorTelemetryPoint[];
+ zoomRegions: ZoomRegion[];
+ onZoomAdded: (span: Span) => void;
+ onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
+ onZoomSpanChange: (id: string, span: Span) => void;
+ onZoomDelete: (id: string) => void;
+ selectedZoomId: string | null;
+ onSelectZoom: (id: string | null) => void;
+ trimRegions?: TrimRegion[];
+ onTrimAdded?: (span: Span) => void;
+ onTrimSpanChange?: (id: string, span: Span) => void;
+ onTrimDelete?: (id: string) => void;
+ selectedTrimId?: string | null;
+ onSelectTrim?: (id: string | null) => void;
+ annotationRegions?: AnnotationRegion[];
+ onAnnotationAdded?: (span: Span) => void;
+ onAnnotationSpanChange?: (id: string, span: Span) => void;
+ onAnnotationDelete?: (id: string) => void;
+ selectedAnnotationId?: string | null;
+ onSelectAnnotation?: (id: string | null) => void;
+ speedRegions?: SpeedRegion[];
+ onSpeedAdded?: (span: Span) => void;
+ onSpeedSpanChange?: (id: string, span: Span) => void;
+ onSpeedDelete?: (id: string) => void;
+ selectedSpeedId?: string | null;
+ onSelectSpeed?: (id: string | null) => void;
+ audioRegions?: AudioRegion[];
+ onAudioAdded?: (span: Span, audioPath: string) => void;
+ onAudioSpanChange?: (id: string, span: Span) => void;
+ onAudioDelete?: (id: string) => void;
+ selectedAudioId?: string | null;
+ onSelectAudio?: (id: string | null) => void;
+ aspectRatio: AspectRatio;
+ onAspectRatioChange: (aspectRatio: AspectRatio) => void;
}
interface TimelineScaleConfig {
@@ -89,13 +81,13 @@ interface TimelineScaleConfig {
}
interface TimelineRenderItem {
- id: string;
- rowId: string;
- span: Span;
- label: string;
- zoomDepth?: number;
- speedValue?: number;
- variant: "zoom" | "trim" | "annotation" | "speed";
+ id: string;
+ rowId: string;
+ span: Span;
+ label: string;
+ zoomDepth?: number;
+ speedValue?: number;
+ variant: 'zoom' | 'trim' | 'annotation' | 'speed' | 'audio';
}
const SCALE_CANDIDATES = [
@@ -443,33 +435,37 @@ function TimelineAxis({
}
function Timeline({
- items,
- videoDurationMs,
- currentTimeMs,
- onSeek,
- onSelectZoom,
- onSelectTrim,
- onSelectAnnotation,
- onSelectSpeed,
- selectedZoomId,
- selectedTrimId,
- selectedAnnotationId,
- selectedSpeedId,
- keyframes = [],
+ items,
+ videoDurationMs,
+ currentTimeMs,
+ onSeek,
+ onSelectZoom,
+ onSelectTrim,
+ onSelectAnnotation,
+ onSelectSpeed,
+ onSelectAudio,
+ selectedZoomId,
+ selectedTrimId,
+ selectedAnnotationId,
+ selectedSpeedId,
+ selectedAudioId,
+ keyframes = [],
}: {
- items: TimelineRenderItem[];
- videoDurationMs: number;
- currentTimeMs: number;
- onSeek?: (time: number) => void;
- onSelectZoom?: (id: string | null) => void;
- onSelectTrim?: (id: string | null) => void;
- onSelectAnnotation?: (id: string | null) => void;
- onSelectSpeed?: (id: string | null) => void;
- selectedZoomId: string | null;
- selectedTrimId?: string | null;
- selectedAnnotationId?: string | null;
- selectedSpeedId?: string | null;
- keyframes?: { id: string; time: number }[];
+ items: TimelineRenderItem[];
+ videoDurationMs: number;
+ currentTimeMs: number;
+ onSeek?: (time: number) => void;
+ onSelectZoom?: (id: string | null) => void;
+ onSelectTrim?: (id: string | null) => void;
+ onSelectAnnotation?: (id: string | null) => void;
+ onSelectSpeed?: (id: string | null) => void;
+ onSelectAudio?: (id: string | null) => void;
+ selectedZoomId: string | null;
+ selectedTrimId?: string | null;
+ selectedAnnotationId?: string | null;
+ selectedSpeedId?: string | null;
+ selectedAudioId?: string | null;
+ keyframes?: { id: string; time: number }[];
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef(null);
@@ -486,12 +482,13 @@ function Timeline({
(e: React.MouseEvent) => {
if (!onSeek || videoDurationMs <= 0) return;
- // Only clear selection if clicking on empty space (not on items)
- // This is handled by event propagation - items stop propagation
- onSelectZoom?.(null);
- onSelectTrim?.(null);
- onSelectAnnotation?.(null);
- onSelectSpeed?.(null);
+ // Only clear selection if clicking on empty space (not on items)
+ // This is handled by event propagation - items stop propagation
+ onSelectZoom?.(null);
+ onSelectTrim?.(null);
+ onSelectAnnotation?.(null);
+ onSelectSpeed?.(null);
+ onSelectAudio?.(null);
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -502,25 +499,14 @@ function Timeline({
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
const timeInSeconds = absoluteMs / 1000;
- onSeek(timeInSeconds);
- },
- [
- onSeek,
- onSelectZoom,
- onSelectTrim,
- onSelectAnnotation,
- onSelectSpeed,
- videoDurationMs,
- sidebarWidth,
- range.start,
- pixelsToValue,
- ],
- );
+ onSeek(timeInSeconds);
+ }, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, onSelectAudio, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
- const zoomItems = items.filter((item) => item.rowId === ZOOM_ROW_ID);
- const trimItems = items.filter((item) => item.rowId === TRIM_ROW_ID);
- const annotationItems = items.filter((item) => item.rowId === ANNOTATION_ROW_ID);
- const speedItems = items.filter((item) => item.rowId === SPEED_ROW_ID);
+ const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
+ const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
+ const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
+ const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID);
+ const audioItems = items.filter(item => item.rowId === AUDIO_ROW_ID);
return (
-
- {speedItems.map((item) => (
- - onSelectSpeed?.(item.id)}
- variant="speed"
- speedValue={item.speedValue}
- >
- {item.label}
-
- ))}
-
-
- );
+
+ {speedItems.map((item) => (
+ - onSelectSpeed?.(item.id)}
+ variant="speed"
+ speedValue={item.speedValue}
+ >
+ {item.label}
+
+ ))}
+
+
+
+ {audioItems.map((item) => (
+ - onSelectAudio?.(item.id)}
+ variant="audio"
+ >
+ {item.label}
+
+ ))}
+
+
+ );
}
export default function TimelineEditor({
- videoDuration,
- currentTime,
- onSeek,
- cursorTelemetry = [],
- zoomRegions,
- onZoomAdded,
- onZoomSuggested,
- onZoomSpanChange,
- onZoomDelete,
- selectedZoomId,
- onSelectZoom,
- trimRegions = [],
- onTrimAdded,
- onTrimSpanChange,
- onTrimDelete,
- selectedTrimId,
- onSelectTrim,
- annotationRegions = [],
- onAnnotationAdded,
- onAnnotationSpanChange,
- onAnnotationDelete,
- selectedAnnotationId,
- onSelectAnnotation,
- speedRegions = [],
- onSpeedAdded,
- onSpeedSpanChange,
- onSpeedDelete,
- selectedSpeedId,
- onSelectSpeed,
- aspectRatio,
- onAspectRatioChange,
+ videoDuration,
+ currentTime,
+ onSeek,
+ cursorTelemetry = [],
+ zoomRegions,
+ onZoomAdded,
+ onZoomSuggested,
+ onZoomSpanChange,
+ onZoomDelete,
+ selectedZoomId,
+ onSelectZoom,
+ trimRegions = [],
+ onTrimAdded,
+ onTrimSpanChange,
+ onTrimDelete,
+ selectedTrimId,
+ onSelectTrim,
+ annotationRegions = [],
+ onAnnotationAdded,
+ onAnnotationSpanChange,
+ onAnnotationDelete,
+ selectedAnnotationId,
+ onSelectAnnotation,
+ speedRegions = [],
+ onSpeedAdded,
+ onSpeedSpanChange,
+ onSpeedDelete,
+ selectedSpeedId,
+ onSelectSpeed,
+ audioRegions = [],
+ onAudioAdded,
+ onAudioSpanChange,
+ onAudioDelete,
+ selectedAudioId,
+ onSelectAudio,
+ aspectRatio,
+ onAspectRatioChange,
}: TimelineEditorProps) {
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
@@ -749,6 +757,12 @@ export default function TimelineEditor({
onSelectSpeed(null);
}, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
+ const deleteSelectedAudio = useCallback(() => {
+ if (!selectedAudioId || !onAudioDelete || !onSelectAudio) return;
+ onAudioDelete(selectedAudioId);
+ onSelectAudio(null);
+ }, [selectedAudioId, onAudioDelete, onSelectAudio]);
+
useEffect(() => {
setRange(createInitialRange(totalMs));
}, [totalMs]);
@@ -759,9 +773,11 @@ export default function TimelineEditor({
const zoomRegionsRef = useRef(zoomRegions);
const trimRegionsRef = useRef(trimRegions);
const speedRegionsRef = useRef(speedRegions);
+ const audioRegionsRef = useRef(audioRegions);
zoomRegionsRef.current = zoomRegions;
trimRegionsRef.current = trimRegions;
speedRegionsRef.current = speedRegions;
+ audioRegionsRef.current = audioRegions;
useEffect(() => {
if (totalMs === 0 || safeMinDurationMs <= 0) {
@@ -803,8 +819,20 @@ export default function TimelineEditor({
onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
+
+ audioRegionsRef.current.forEach((region) => {
+ const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
+ const minEnd = clampedStart + safeMinDurationMs;
+ const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
+ const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
+ const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
+
+ if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
+ onAudioSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
+ }
+ });
// Only re-run when the timeline scale changes, not on every region edit
- }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]);
+ }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAudioSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Determine which row the item belongs to
@@ -812,13 +840,14 @@ export default function TimelineEditor({
const isTrimItem = trimRegions.some(r => r.id === excludeId);
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
const isSpeedItem = speedRegions.some(r => r.id === excludeId);
+ const isAudioItem = audioRegions.some(r => r.id === excludeId);
if (isAnnotationItem) {
return false;
}
// Helper to check overlap against a specific set of regions
- const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => {
+ const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion | AudioRegion)[]) => {
return regions.some((region) => {
if (region.id === excludeId) return false;
// True overlap: regions actually intersect (not just adjacent)
@@ -838,8 +867,12 @@ export default function TimelineEditor({
return checkOverlap(speedRegions);
}
+ if (isAudioItem) {
+ return checkOverlap(audioRegions);
+ }
+
return false;
- }, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
+ }, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions]);
// Keep newly added timeline regions at the original short default instead of
// scaling them with the full recording length.
@@ -1023,6 +1056,52 @@ export default function TimelineEditor({
onSpeedAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
+ const handleAddAudio = useCallback(async () => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAudioAdded) {
+ return;
+ }
+
+ const result = await (window as any).electronAPI.openAudioFilePicker();
+ if (!result?.success || !result.path) {
+ return;
+ }
+
+ // Load the audio file to get its full duration
+ const audioDurationMs = await new Promise((resolve) => {
+ const audio = new Audio(toFileUrl(result.path));
+ audio.addEventListener('loadedmetadata', () => {
+ resolve(Math.round(audio.duration * 1000));
+ });
+ audio.addEventListener('error', () => {
+ resolve(0);
+ });
+ });
+
+ if (audioDurationMs <= 0) {
+ toast.error("Could not read audio file", {
+ description: "The selected file may be corrupted or in an unsupported format.",
+ });
+ return;
+ }
+
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ const sorted = [...audioRegions].sort((a, b) => a.startMs - b.startMs);
+ const nextRegion = sorted.find(region => region.startMs > startPos);
+ const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
+
+ const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
+ if (isOverlapping || gapToNext <= 0) {
+ toast.error("Cannot place audio here", {
+ description: "Audio region already exists at this location or not enough space available.",
+ });
+ return;
+ }
+
+ // Use full audio duration, but clamp to available gap and video length
+ const actualDuration = Math.min(audioDurationMs, gapToNext, totalMs - startPos);
+ onAudioAdded({ start: startPos, end: startPos + actualDuration }, result.path);
+ }, [videoDuration, totalMs, currentTimeMs, audioRegions, onAudioAdded]);
+
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
@@ -1096,12 +1175,14 @@ export default function TimelineEditor({
deleteSelectedAnnotation();
} else if (selectedSpeedId) {
deleteSelectedSpeed();
+ } else if (selectedAudioId) {
+ deleteSelectedAudio();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
+ }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, deleteSelectedAudio, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, selectedAudioId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
const clampedRange = useMemo(() => {
if (totalMs === 0) {
@@ -1163,16 +1244,28 @@ export default function TimelineEditor({
variant: 'speed',
}));
- return [...zooms, ...trims, ...annotations, ...speeds];
- }, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
+ const audios: TimelineRenderItem[] = audioRegions.map((region) => {
+ const fileName = region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || 'Audio';
+ return {
+ id: region.id,
+ rowId: AUDIO_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: fileName,
+ variant: 'audio',
+ };
+ });
+
+ return [...zooms, ...trims, ...annotations, ...speeds, ...audios];
+ }, [zoomRegions, trimRegions, annotationRegions, speedRegions, audioRegions]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
- return [...zooms, ...trims, ...speeds];
- }, [zoomRegions, trimRegions, speedRegions]);
+ const audios = audioRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
+ return [...zooms, ...trims, ...speeds, ...audios];
+ }, [zoomRegions, trimRegions, speedRegions, audioRegions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
// Check if it's a zoom, trim, speed, or annotation item
@@ -1184,8 +1277,10 @@ export default function TimelineEditor({
onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some(r => r.id === id)) {
onAnnotationSpanChange?.(id, span);
+ } else if (audioRegions.some(r => r.id === id)) {
+ onAudioSpanChange?.(id, span);
}
- }, [zoomRegions, trimRegions, speedRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange]);
+ }, [zoomRegions, trimRegions, speedRegions, annotationRegions, audioRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange, onAudioSpanChange]);
const panTimelineRange = useCallback((deltaMs: number) => {
if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) {
@@ -1297,6 +1392,15 @@ export default function TimelineEditor({
>
+
+
+
@@ -1407,10 +1511,12 @@ export default function TimelineEditor({
onSelectTrim={onSelectTrim}
onSelectAnnotation={onSelectAnnotation}
onSelectSpeed={onSelectSpeed}
+ onSelectAudio={onSelectAudio}
selectedZoomId={selectedZoomId}
selectedTrimId={selectedTrimId}
selectedAnnotationId={selectedAnnotationId}
selectedSpeedId={selectedSpeedId}
+ selectedAudioId={selectedAudioId}
keyframes={keyframes}
/>
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index fa782ce0..46f66f79 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -137,6 +137,14 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
+export interface AudioRegion {
+ id: string;
+ startMs: number;
+ endMs: number;
+ audioPath: string;
+ volume: number;
+}
+
export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
export interface SpeedRegion {
diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts
index 974a0844..3b8f29d6 100644
--- a/src/components/video-editor/videoPlayback/cursorRenderer.ts
+++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts
@@ -167,6 +167,7 @@ function getAvailableCursorKeys(): CursorAssetKey[] {
export async function preloadCursorAssets() {
if (!cursorAssetsPromise) {
cursorAssetsPromise = (async () => {
+ const isLinux = typeof navigator !== 'undefined' && /linux/i.test(navigator.platform);
let systemCursors: Record = {};
try {
@@ -182,7 +183,9 @@ export async function preloadCursorAssets() {
SUPPORTED_CURSOR_KEYS.map(async (key) => {
const systemAsset = systemCursors[key];
const uploadedAsset = uploadedCursorAssets[key];
- const assetUrl = uploadedAsset?.url ?? systemAsset?.dataUrl;
+ const assetUrl = isLinux
+ ? uploadedAsset?.url
+ : uploadedAsset?.url ?? systemAsset?.dataUrl;
if (!assetUrl) {
console.warn(`[CursorRenderer] No cursor image for: ${key}`);
diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx
index 77670801..dda55641 100644
--- a/src/contexts/I18nContext.tsx
+++ b/src/contexts/I18nContext.tsx
@@ -123,7 +123,9 @@ function getInitialLocale(): AppLocale {
return storedLocale
}
- return normalizeLocale(window.navigator.language)
+ // Product default must be English on first launch unless user explicitly
+ // selected another locale and we persisted it in localStorage.
+ return DEFAULT_LOCALE
}
function getMessageValue(source: unknown, key: string): string | undefined {
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index 53ae6720..4765bd40 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -28,6 +28,7 @@ const MIC_GAIN_BOOST = 1.4;
type UseScreenRecorderReturn = {
recording: boolean;
+ countdownActive: boolean;
toggleRecording: () => void;
preparePermissions: (options?: { startup?: boolean }) => Promise;
isMacOS: boolean;
@@ -37,15 +38,19 @@ type UseScreenRecorderReturn = {
setMicrophoneDeviceId: (deviceId: string | undefined) => void;
systemAudioEnabled: boolean;
setSystemAudioEnabled: (enabled: boolean) => void;
+ countdownDelay: number;
+ setCountdownDelay: (delay: number) => void;
};
export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
const [starting, setStarting] = useState(false);
+ const [countdownActive, setCountdownActive] = useState(false);
const [isMacOS, setIsMacOS] = useState(false);
const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined);
const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
+ const [countdownDelay, setCountdownDelayState] = useState(3);
const mediaRecorder = useRef(null);
const stream = useRef(null);
const screenStream = useRef(null);
@@ -57,6 +62,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
const wgcRecording = useRef(false);
const startInFlight = useRef(false);
const hasPromptedForReselect = useRef(false);
+ const countdownDelayLoaded = useRef(false);
const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => {
const platform = await window.electronAPI.getPlatform();
@@ -194,6 +200,23 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
})();
}, []);
+ useEffect(() => {
+ if (countdownDelayLoaded.current) return;
+ countdownDelayLoaded.current = true;
+
+ void (async () => {
+ const result = await window.electronAPI.getCountdownDelay();
+ if (result.success && typeof result.delay === "number") {
+ setCountdownDelayState(result.delay);
+ }
+ })();
+ }, []);
+
+ const setCountdownDelay = useCallback((delay: number) => {
+ setCountdownDelayState(delay);
+ void window.electronAPI.setCountdownDelay(delay);
+ }, []);
+
useEffect(() => {
let cleanup: (() => void) | undefined;
@@ -548,16 +571,35 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
}
};
- const toggleRecording = () => {
- if (starting) {
+ const toggleRecording = async () => {
+ if (starting || countdownActive) {
return;
}
- recording ? stopRecording.current() : startRecording();
+ if (recording) {
+ stopRecording.current();
+ return;
+ }
+
+ // Start recording with optional countdown
+ if (countdownDelay > 0) {
+ setCountdownActive(true);
+ try {
+ const result = await window.electronAPI.startCountdown(countdownDelay);
+ if (!result.success || result.cancelled) {
+ return;
+ }
+ } finally {
+ setCountdownActive(false);
+ }
+ }
+
+ startRecording();
};
return {
recording,
+ countdownActive,
toggleRecording,
preparePermissions,
isMacOS,
@@ -567,6 +609,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
setMicrophoneDeviceId,
systemAudioEnabled,
setSystemAudioEnabled,
+ countdownDelay,
+ setCountdownDelay,
};
}
diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json
index 4829693f..bb709f98 100644
--- a/src/i18n/locales/en/launch.json
+++ b/src/i18n/locales/en/launch.json
@@ -4,6 +4,8 @@
"enableSystemAudio": "Enable system audio",
"disableMicrophone": "Disable microphone",
"enableMicrophone": "Enable microphone",
+ "countdownDelay": "Countdown delay",
+ "noDelay": "No delay",
"record": "Record",
"recordingFolder": "Recording folder: {{path}}",
"chooseRecordingsFolder": "Choose recordings folder",
diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json
index 46a76248..b0d7121d 100644
--- a/src/i18n/locales/en/settings.json
+++ b/src/i18n/locales/en/settings.json
@@ -48,7 +48,8 @@
"quality": {
"low": "Low",
"medium": "Medium",
- "high": "High"
+ "high": "High",
+ "original": "Original"
},
"loop": "Loop",
"outputDimensions": "Output: {{dimensions}}px",
diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json
index 0bd6d05e..e8390ad3 100644
--- a/src/i18n/locales/es/launch.json
+++ b/src/i18n/locales/es/launch.json
@@ -4,6 +4,8 @@
"enableSystemAudio": "Activar audio del sistema",
"disableMicrophone": "Desactivar micrófono",
"enableMicrophone": "Activar micrófono",
+ "countdownDelay": "Retraso de cuenta regresiva",
+ "noDelay": "Sin retraso",
"record": "Grabar",
"recordingFolder": "Carpeta de grabaciones: {{path}}",
"chooseRecordingsFolder": "Elegir carpeta de grabaciones",
diff --git a/src/i18n/locales/es/settings.json b/src/i18n/locales/es/settings.json
index 1eb012fd..9f35964d 100644
--- a/src/i18n/locales/es/settings.json
+++ b/src/i18n/locales/es/settings.json
@@ -48,7 +48,8 @@
"quality": {
"low": "Baja",
"medium": "Media",
- "high": "Alta"
+ "high": "Alta",
+ "original": "Original"
},
"loop": "Bucle",
"outputDimensions": "Salida: {{dimensions}}px",
diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json
index 9ac43f3a..dd2aca81 100644
--- a/src/i18n/locales/zh-CN/launch.json
+++ b/src/i18n/locales/zh-CN/launch.json
@@ -4,6 +4,8 @@
"enableSystemAudio": "启用系统音频",
"disableMicrophone": "禁用麦克风",
"enableMicrophone": "启用麦克风",
+ "countdownDelay": "倒计时延迟",
+ "noDelay": "无延迟",
"record": "录制",
"recordingFolder": "录制文件夹:{{path}}",
"chooseRecordingsFolder": "选择录制文件夹",
diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json
index 310a8cc3..f3854608 100644
--- a/src/i18n/locales/zh-CN/settings.json
+++ b/src/i18n/locales/zh-CN/settings.json
@@ -48,7 +48,8 @@
"quality": {
"low": "低",
"medium": "中",
- "high": "高"
+ "high": "高",
+ "original": "原始"
},
"loop": "循环",
"outputDimensions": "输出:{{dimensions}}px",
diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts
index 09407801..bd1c20cd 100644
--- a/src/lib/exporter/audioEncoder.ts
+++ b/src/lib/exporter/audioEncoder.ts
@@ -1,5 +1,6 @@
import { WebDemuxer } from 'web-demuxer'
-import type { SpeedRegion, TrimRegion } from '@/components/video-editor/types'
+import type { SpeedRegion, TrimRegion, AudioRegion } from '@/components/video-editor/types'
+import { toFileUrl } from '@/components/video-editor/projectPersistence'
import type { VideoMuxer } from './muxer'
const AUDIO_BITRATE = 128_000
@@ -21,6 +22,7 @@ export class AudioProcessor {
trimRegions?: TrimRegion[],
speedRegions?: SpeedRegion[],
readEndSec?: number,
+ audioRegions?: AudioRegion[],
): Promise {
const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : []
const sortedSpeedRegions = speedRegions
@@ -28,13 +30,17 @@ export class AudioProcessor {
.filter((region) => region.endMs - region.startMs > MIN_SPEED_REGION_DELTA_MS)
.sort((a, b) => a.startMs - b.startMs)
: []
+ const sortedAudioRegions = audioRegions
+ ? [...audioRegions].sort((a, b) => a.startMs - b.startMs)
+ : []
- // Speed edits must use timeline playback to preserve pitch.
- if (sortedSpeedRegions.length > 0) {
- const renderedAudioBlob = await this.renderPitchPreservedTimelineAudio(
+ // When audio regions or speed edits are present, use AudioContext mixing path.
+ if (sortedSpeedRegions.length > 0 || sortedAudioRegions.length > 0) {
+ const renderedAudioBlob = await this.renderMixedTimelineAudio(
videoUrl,
sortedTrims,
sortedSpeedRegions,
+ sortedAudioRegions,
)
if (!this.cancelled) {
await this.muxRenderedAudioBlob(renderedAudioBlob, muxer)
@@ -42,7 +48,7 @@ export class AudioProcessor {
}
}
- // No speed edits: keep the original demux/decode/encode path with trim timestamp remap.
+ // No speed edits or audio regions: keep the original demux/decode/encode path with trim timestamp remap.
await this.processTrimOnlyAudio(demuxer, muxer, sortedTrims, readEndSec)
}
@@ -158,12 +164,13 @@ export class AudioProcessor {
}
}
- // Speed-aware path that mirrors preview semantics (trim skipping + playbackRate regions)
- // and preserves pitch through browser media playback behavior.
- private async renderPitchPreservedTimelineAudio(
+ // Renders mixed audio: original video audio (with speed/trim) + external audio regions.
+ // Uses AudioContext to mix all sources into a single recorded stream.
+ private async renderMixedTimelineAudio(
videoUrl: string,
trimRegions: TrimRegion[],
speedRegions: SpeedRegion[],
+ audioRegions: AudioRegion[],
): Promise {
const media = document.createElement('audio')
media.src = videoUrl
@@ -184,10 +191,41 @@ export class AudioProcessor {
}
const audioContext = new AudioContext()
- const sourceNode = audioContext.createMediaElementSource(media)
const destinationNode = audioContext.createMediaStreamDestination()
+
+ // Connect original video audio
+ const sourceNode = audioContext.createMediaElementSource(media)
sourceNode.connect(destinationNode)
+ // Prepare external audio region elements
+ const audioRegionElements: {
+ media: HTMLAudioElement
+ sourceNode: MediaElementAudioSourceNode
+ gainNode: GainNode
+ region: AudioRegion
+ }[] = []
+
+ for (const region of audioRegions) {
+ const audioEl = document.createElement('audio')
+ audioEl.src = toFileUrl(region.audioPath)
+ audioEl.preload = 'auto'
+ try {
+ await this.waitForLoadedMetadata(audioEl)
+ } catch {
+ console.warn('[AudioProcessor] Failed to load audio region:', region.audioPath)
+ continue
+ }
+ if (this.cancelled) throw new Error('Export cancelled')
+
+ const regionSource = audioContext.createMediaElementSource(audioEl)
+ const gainNode = audioContext.createGain()
+ gainNode.gain.value = Math.max(0, Math.min(1, region.volume))
+ regionSource.connect(gainNode)
+ gainNode.connect(destinationNode)
+
+ audioRegionElements.push({ media: audioEl, sourceNode: regionSource, gainNode, region })
+ }
+
const { recorder, recordedBlobPromise } = this.startAudioRecording(destinationNode.stream)
let rafId: number | null = null
@@ -211,7 +249,7 @@ export class AudioProcessor {
const onError = () => {
cleanup()
- reject(new Error('Failed while rendering speed-adjusted audio timeline'))
+ reject(new Error('Failed while rendering mixed audio timeline'))
}
const onEnded = () => {
@@ -246,6 +284,26 @@ export class AudioProcessor {
}
}
+ // Sync external audio regions with the video timeline position
+ for (const entry of audioRegionElements) {
+ const { media: audioEl, region } = entry
+ const isInRegion = currentTimeMs >= region.startMs && currentTimeMs < region.endMs
+
+ if (isInRegion) {
+ const audioOffset = (currentTimeMs - region.startMs) / 1000
+ if (audioEl.paused) {
+ audioEl.currentTime = audioOffset
+ audioEl.play().catch(() => {})
+ } else if (Math.abs(audioEl.currentTime - audioOffset) > 0.3) {
+ audioEl.currentTime = audioOffset
+ }
+ } else {
+ if (!audioEl.paused) {
+ audioEl.pause()
+ }
+ }
+ }
+
if (!media.paused && !media.ended) {
rafId = requestAnimationFrame(tick)
} else {
@@ -263,6 +321,13 @@ export class AudioProcessor {
cancelAnimationFrame(rafId)
}
media.pause()
+ for (const entry of audioRegionElements) {
+ entry.media.pause()
+ entry.sourceNode.disconnect()
+ entry.gainNode.disconnect()
+ entry.media.src = ''
+ entry.media.load()
+ }
if (recorder.state !== 'inactive') {
recorder.stop()
}
diff --git a/src/lib/exporter/types.ts b/src/lib/exporter/types.ts
index fa0d4eaa..812d850d 100644
--- a/src/lib/exporter/types.ts
+++ b/src/lib/exporter/types.ts
@@ -27,7 +27,7 @@ export interface VideoFrameData {
duration: number; // in microseconds
}
-export type ExportQuality = 'medium' | 'good' | 'source';
+export type ExportQuality = 'medium' | 'good' | 'high' | 'source';
// GIF Export Types
export type ExportFormat = 'mp4' | 'gif';
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index 9eee91ea..ab2a053e 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -3,7 +3,7 @@ import { AudioProcessor } from './audioEncoder';
import { StreamingVideoDecoder } from './streamingDecoder';
import { FrameRenderer } from './frameRenderer';
import { VideoMuxer } from './muxer';
-import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
+import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
@@ -27,6 +27,7 @@ interface VideoExporterConfig extends ExportConfig {
cursorSmoothing?: number;
cursorMotionBlur?: number;
cursorClickBounce?: number;
+ audioRegions?: AudioRegion[];
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
@@ -94,7 +95,8 @@ export class VideoExporter {
// Initialize video encoder
await this.initializeEncoder();
- const hasAudio = videoInfo.hasAudio;
+ const hasAudioRegions = (this.config.audioRegions ?? []).length > 0;
+ const hasAudio = videoInfo.hasAudio || hasAudioRegions;
// Initialize muxer
this.muxer = new VideoMuxer(this.config, hasAudio);
@@ -148,15 +150,17 @@ export class VideoExporter {
if (hasAudio && !this.cancelled) {
const demuxer = this.streamingDecoder.getDemuxer();
- if (demuxer) {
+ if (demuxer || hasAudioRegions) {
this.audioProcessor = new AudioProcessor();
await this.awaitWithWindowsTimeout(
this.audioProcessor.process(
- demuxer,
+ demuxer!,
this.muxer!,
this.config.videoUrl,
this.config.trimRegions,
this.config.speedRegions,
+ undefined,
+ this.config.audioRegions,
),
'audio processing',
);
@@ -254,6 +258,14 @@ export class VideoExporter {
this.chunkCount = 0;
let videoDescription: Uint8Array | undefined;
+ // Ordered from most capable to most compatible. avc1.PPCCLL where PP=profile, CC=constraints, LL=level.
+ // High 5.1 → Main 5.1 → Baseline 5.1 → Main 3.1 → Baseline 3.1
+ const CODEC_FALLBACK_LIST = this.config.codec
+ ? [this.config.codec]
+ : ['avc1.640033', 'avc1.4d4033', 'avc1.420033', 'avc1.4d401f', 'avc1.42001f'];
+
+ let resolvedCodec: string | null = null;
+
this.encoder = new VideoEncoder({
output: (chunk, meta) => {
// Capture decoder config metadata from encoder output
@@ -284,7 +296,7 @@ export class VideoExporter {
const metadata: EncodedVideoChunkMetadata = {
decoderConfig: {
- codec: this.config.codec || 'avc1.640033',
+ codec: resolvedCodec ?? (this.config.codec || 'avc1.640033'),
codedWidth: this.config.width,
codedHeight: this.config.height,
description: this.videoDescription,
@@ -303,44 +315,52 @@ export class VideoExporter {
this.encodeQueue--;
},
error: (error) => {
- console.error('[VideoExporter] Encoder error:', error);
- // Stop export encoding failed
+ console.error(
+ `[VideoExporter] Encoder error (codec: ${resolvedCodec}, ${this.config.width}x${this.config.height}):`,
+ error,
+ );
+ // Stop export — encoding failed
this.cancelled = true;
},
});
- const codec = this.config.codec || 'avc1.640033';
-
- const encoderConfig: VideoEncoderConfig = {
- codec,
+ const baseConfig: Omit = {
width: this.config.width,
height: this.config.height,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
- latencyMode: 'quality', // Changed from 'realtime' to 'quality' for better throughput
+ latencyMode: 'quality',
bitrateMode: 'variable',
- hardwareAcceleration: 'prefer-hardware',
};
- // Check hardware support first
- const hardwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
-
- if (hardwareSupport.supported) {
- // Use hardware encoding
- console.log('[VideoExporter] Using hardware acceleration');
- this.encoder.configure(encoderConfig);
- } else {
- // Fall back to software encoding
- console.log('[VideoExporter] Hardware not supported, using software encoding');
- encoderConfig.hardwareAcceleration = 'prefer-software';
-
- const softwareSupport = await VideoEncoder.isConfigSupported(encoderConfig);
- if (!softwareSupport.supported) {
- throw new Error('Video encoding not supported on this system');
+ for (const candidateCodec of CODEC_FALLBACK_LIST) {
+ const hwConfig: VideoEncoderConfig = { ...baseConfig, codec: candidateCodec, hardwareAcceleration: 'prefer-hardware' };
+ const hwSupport = await VideoEncoder.isConfigSupported(hwConfig);
+ if (hwSupport.supported) {
+ resolvedCodec = candidateCodec;
+ console.log(`[VideoExporter] Using hardware acceleration with codec ${candidateCodec}`);
+ this.encoder.configure(hwConfig);
+ return;
}
- this.encoder.configure(encoderConfig);
+ const swConfig: VideoEncoderConfig = { ...baseConfig, codec: candidateCodec, hardwareAcceleration: 'prefer-software' };
+ const swSupport = await VideoEncoder.isConfigSupported(swConfig);
+ if (swSupport.supported) {
+ resolvedCodec = candidateCodec;
+ console.log(`[VideoExporter] Using software encoding with codec ${candidateCodec}`);
+ this.encoder.configure(swConfig);
+ return;
+ }
+
+ console.warn(`[VideoExporter] Codec ${candidateCodec} not supported (${this.config.width}x${this.config.height}), trying next…`);
}
+
+ throw new Error(
+ `Video encoding not supported on this system. ` +
+ `Tried codecs: ${CODEC_FALLBACK_LIST.join(', ')} at ${this.config.width}x${this.config.height}. ` +
+ `Your browser or hardware may not support H.264 encoding at this resolution. ` +
+ `Try exporting at a lower quality setting.`,
+ );
}
cancel(): void {