From 347f661abbc9c0960750383d26cca05846f0ee44 Mon Sep 17 00:00:00 2001 From: wiiiii123 Date: Thu, 7 May 2026 13:56:10 +0700 Subject: [PATCH 1/3] fix(export): preserve container duration on large stream mismatch --- src/lib/exporter/modernVideoExporter.ts | 15 +++++++++++++-- src/lib/exporter/videoExporter.ts | 15 +++++++++++++-- src/lib/mediaTiming.test.ts | 9 +++++++++ src/lib/mediaTiming.ts | 25 +++++++++++++++++++++---- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/lib/exporter/modernVideoExporter.ts b/src/lib/exporter/modernVideoExporter.ts index aabbbea0..750edaac 100644 --- a/src/lib/exporter/modernVideoExporter.ts +++ b/src/lib/exporter/modernVideoExporter.ts @@ -14,6 +14,7 @@ import type { ZoomTransitionEasing, } from "@/components/video-editor/types"; import { extensionHost } from "@/lib/extensions"; +import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; import { normalizeLightningRuntimePlatform } from "./backendPolicy"; import { buildEditedTrackSourceSegments, classifyEditedTrackStrategy } from "./editedTrackStrategy"; @@ -787,7 +788,12 @@ export class ModernVideoExporter { ) { const sourceDurationMs = Math.max( 0, - Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000), + Math.round( + getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }) * 1000, + ), ); const trimRegions = this.config.trimRegions ?? []; const strategy = @@ -846,7 +852,12 @@ export class ModernVideoExporter { if ((this.config.trimRegions ?? []).length > 0) { const sourceDurationMs = Math.max( 0, - Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000), + Math.round( + getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }) * 1000, + ), ); const trimSegments = this.buildNativeTrimSegments(sourceDurationMs); if (trimSegments.length === 0) { diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts index 47926eb7..1e459181 100644 --- a/src/lib/exporter/videoExporter.ts +++ b/src/lib/exporter/videoExporter.ts @@ -13,6 +13,7 @@ import type { ZoomRegion, ZoomTransitionEasing, } from "@/components/video-editor/types"; +import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; import { AudioProcessor, isAacAudioEncodingSupported } from "./audioEncoder"; import { buildEditedTrackSourceSegments, classifyEditedTrackStrategy } from "./editedTrackStrategy"; import { @@ -544,7 +545,12 @@ export class VideoExporter { ) { const sourceDurationMs = Math.max( 0, - Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000), + Math.round( + getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }) * 1000, + ), ); const trimRegions = this.config.trimRegions ?? []; const strategy = @@ -603,7 +609,12 @@ export class VideoExporter { if ((this.config.trimRegions ?? []).length > 0) { const sourceDurationMs = Math.max( 0, - Math.round((videoInfo.streamDuration ?? videoInfo.duration) * 1000), + Math.round( + getEffectiveVideoStreamDurationSeconds({ + duration: videoInfo.duration, + streamDuration: videoInfo.streamDuration, + }) * 1000, + ), ); const trimSegments = this.buildNativeTrimSegments(sourceDurationMs); if (trimSegments.length === 0) { diff --git a/src/lib/mediaTiming.test.ts b/src/lib/mediaTiming.test.ts index a6f20479..28b865bf 100644 --- a/src/lib/mediaTiming.test.ts +++ b/src/lib/mediaTiming.test.ts @@ -111,6 +111,15 @@ describe("getEffectiveVideoStreamDurationSeconds", () => { ).toBe(11.2); }); + it("uses the container duration when the video stream is much shorter", () => { + expect( + getEffectiveVideoStreamDurationSeconds({ + duration: 60, + streamDuration: 40, + }), + ).toBe(60); + }); + it("falls back to the container duration when stream duration is missing", () => { expect( getEffectiveVideoStreamDurationSeconds({ diff --git a/src/lib/mediaTiming.ts b/src/lib/mediaTiming.ts index d9dcf673..1e8a840c 100644 --- a/src/lib/mediaTiming.ts +++ b/src/lib/mediaTiming.ts @@ -81,12 +81,29 @@ export function getEffectiveVideoStreamDurationSeconds({ duration?: number | null; streamDuration?: number | null; }): number { - if (Number.isFinite(streamDuration) && (streamDuration ?? 0) > 0) { - return Math.max(0, streamDuration ?? 0); + const safeDuration = + Number.isFinite(duration) && (duration ?? 0) > 0 ? Math.max(0, duration ?? 0) : 0; + const safeStreamDuration = + Number.isFinite(streamDuration) && (streamDuration ?? 0) > 0 + ? Math.max(0, streamDuration ?? 0) + : 0; + + if (safeDuration > 0 && safeStreamDuration > 0) { + const gapSeconds = safeDuration - safeStreamDuration; + const largeMismatchThresholdSeconds = Math.max(2, safeDuration * 0.1); + if (gapSeconds > largeMismatchThresholdSeconds) { + return safeDuration; + } + + return safeStreamDuration; } - if (Number.isFinite(duration) && (duration ?? 0) > 0) { - return Math.max(0, duration ?? 0); + if (safeStreamDuration > 0) { + return safeStreamDuration; + } + + if (safeDuration > 0) { + return safeDuration; } return 0; From 19241b26b64a2262a12fd7d25e8d7e054feea0cd Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Fri, 8 May 2026 14:27:30 +1000 Subject: [PATCH 2/3] Polish timeline markers and normalize motion presets --- src/components/video-editor/SettingsPanel.tsx | 33 +++---- src/components/video-editor/audio.test.ts | 55 ++++++++++++ .../video-editor/cursorMotionPresets.ts | 46 +++++++++- .../video-editor/projectPersistence.ts | 86 +++++++++++-------- .../video-editor/timeline/TimelineEditor.tsx | 3 +- 5 files changed, 168 insertions(+), 55 deletions(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 5e594d95..c0492821 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -45,7 +45,11 @@ import { useI18n, useScopedT } from "../../contexts/I18nContext"; import type { AppLocale } from "../../i18n/config"; import { SUPPORTED_LOCALES } from "../../i18n/config"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; -import { CURSOR_MOTION_PRESETS, type CursorMotionPresetId } from "./cursorMotionPresets"; +import { + CURSOR_MOTION_PRESETS, + getMatchingCursorMotionPresetId, + type CursorMotionPresetId, +} from "./cursorMotionPresets"; import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences"; import { SliderControl } from "./SliderControl"; import { KeyboardShortcutsDialog } from "./TutorialHelp"; @@ -1515,21 +1519,18 @@ export function SettingsPanel({ const activeMotionPresetId = useMemo(() => { return ( - MOTION_PRESET_ORDER.find((presetId) => { - const preset = CURSOR_MOTION_PRESETS[presetId]; - return ( - preset.zoomInDurationMs === zoomInDurationMs && - preset.zoomOutDurationMs === zoomOutDurationMs && - preset.cursorSize === cursorSize && - preset.cursorSmoothing === cursorSmoothing && - preset.cursorSpringStiffnessMultiplier === cursorSpringStiffnessMultiplier && - preset.cursorSpringDampingMultiplier === cursorSpringDampingMultiplier && - preset.cursorSpringMassMultiplier === cursorSpringMassMultiplier && - preset.cursorMotionBlur === cursorMotionBlur && - preset.cursorClickBounce === cursorClickBounce && - preset.cursorClickBounceDuration === cursorClickBounceDuration - ); - }) ?? null + getMatchingCursorMotionPresetId({ + zoomInDurationMs, + zoomOutDurationMs, + cursorSize, + cursorSmoothing, + cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier, + cursorSpringMassMultiplier, + cursorMotionBlur, + cursorClickBounce, + cursorClickBounceDuration, + }) ?? "focused" ); }, [ cursorClickBounce, diff --git a/src/components/video-editor/audio.test.ts b/src/components/video-editor/audio.test.ts index 60a46f8a..b0510396 100644 --- a/src/components/video-editor/audio.test.ts +++ b/src/components/video-editor/audio.test.ts @@ -1,5 +1,6 @@ import * as fc from "fast-check"; import { describe, expect, it } from "vitest"; +import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets"; import { fromFileUrl, normalizeProjectEditor, toFileUrl } from "./projectPersistence"; describe("Audio path handling", () => { @@ -235,3 +236,57 @@ describe("Audio region normalization", () => { }); }); }); + +describe("Motion preset normalization", () => { + it("preserves the smooth preset when the saved values match it exactly", () => { + const smooth = CURSOR_MOTION_PRESETS.smooth; + const result = normalizeProjectEditor({ + zoomInDurationMs: smooth.zoomInDurationMs, + zoomOutDurationMs: smooth.zoomOutDurationMs, + cursorSize: smooth.cursorSize, + cursorSmoothing: smooth.cursorSmoothing, + cursorSpringStiffnessMultiplier: smooth.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: smooth.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: smooth.cursorSpringMassMultiplier, + cursorMotionBlur: smooth.cursorMotionBlur, + cursorClickBounce: smooth.cursorClickBounce, + cursorClickBounceDuration: smooth.cursorClickBounceDuration, + } as any); + + expect(result.zoomInDurationMs).toBe(smooth.zoomInDurationMs); + expect(result.zoomOutDurationMs).toBe(smooth.zoomOutDurationMs); + expect(result.cursorSpringStiffnessMultiplier).toBe( + smooth.cursorSpringStiffnessMultiplier, + ); + expect(result.cursorSpringDampingMultiplier).toBe(smooth.cursorSpringDampingMultiplier); + }); + + it("falls back to focused when the saved values do not match a supported preset", () => { + const focused = CURSOR_MOTION_PRESETS.focused; + const result = normalizeProjectEditor({ + zoomInDurationMs: 275, + zoomOutDurationMs: 410, + cursorSize: 3.25, + cursorSmoothing: 0.52, + cursorSpringStiffnessMultiplier: 1.1, + cursorSpringDampingMultiplier: 1.05, + cursorSpringMassMultiplier: 1.6, + cursorMotionBlur: 0.9, + cursorClickBounce: 2.25, + cursorClickBounceDuration: 280, + } as any); + + expect(result.zoomInDurationMs).toBe(focused.zoomInDurationMs); + expect(result.zoomOutDurationMs).toBe(focused.zoomOutDurationMs); + expect(result.cursorSize).toBe(focused.cursorSize); + expect(result.cursorSmoothing).toBe(focused.cursorSmoothing); + expect(result.cursorSpringStiffnessMultiplier).toBe( + focused.cursorSpringStiffnessMultiplier, + ); + expect(result.cursorSpringDampingMultiplier).toBe(focused.cursorSpringDampingMultiplier); + expect(result.cursorSpringMassMultiplier).toBe(focused.cursorSpringMassMultiplier); + expect(result.cursorMotionBlur).toBe(focused.cursorMotionBlur); + expect(result.cursorClickBounce).toBe(focused.cursorClickBounce); + expect(result.cursorClickBounceDuration).toBe(focused.cursorClickBounceDuration); + }); +}); diff --git a/src/components/video-editor/cursorMotionPresets.ts b/src/components/video-editor/cursorMotionPresets.ts index 0338fddf..8563d40e 100644 --- a/src/components/video-editor/cursorMotionPresets.ts +++ b/src/components/video-editor/cursorMotionPresets.ts @@ -18,6 +18,19 @@ export interface CursorMotionPreset { cursorClickBounceDuration: number; } +export interface CursorMotionPresetSelectionInput { + zoomInDurationMs: number; + zoomOutDurationMs: number; + cursorSize: number; + cursorSmoothing: number; + cursorSpringStiffnessMultiplier: number; + cursorSpringDampingMultiplier: number; + cursorSpringMassMultiplier: number; + cursorMotionBlur: number; + cursorClickBounce: number; + cursorClickBounceDuration: number; +} + const SHARED_CURSOR_PRESET_VALUES = { cursorSize: 2.5, cursorSmoothing: 0.67, @@ -48,4 +61,35 @@ export const CURSOR_MOTION_PRESETS: Record): Pro ? "tahoe-inverted" : editor.cursorStyle : DEFAULT_CURSOR_STYLE; - - return { - wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : DEFAULT_WALLPAPER_PATH, - shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67, - backgroundBlur: normalizedBackgroundBlur, - zoomMotionBlur: normalizedZoomMotionBlur, - zoomMotionBlurTuning: normalizedZoomMotionBlurTuning, - zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur, - zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount, - zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction, - connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true, + const normalizedMotionValues = { zoomInDurationMs: normalizedZoomInDurationMs, - zoomInOverlapMs: normalizedZoomInOverlapMs, zoomOutDurationMs: normalizedZoomOutDurationMs, - connectedZoomGapMs: normalizedConnectedZoomGapMs, - connectedZoomDurationMs: normalizedConnectedZoomDurationMs, - zoomInEasing: normalizeZoomTransitionEasing(editor.zoomInEasing, DEFAULT_ZOOM_IN_EASING), - zoomOutEasing: normalizeZoomTransitionEasing(editor.zoomOutEasing, DEFAULT_ZOOM_OUT_EASING), - connectedZoomEasing: normalizeZoomTransitionEasing( - editor.connectedZoomEasing, - DEFAULT_CONNECTED_ZOOM_EASING, - ), - showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true, - loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false, - cursorStyle: normalizedCursorStyle, cursorSize: isFiniteNumber(editor.cursorSize) ? clamp(editor.cursorSize, 0.5, 10) : DEFAULT_MOTION_PRESET.cursorSize, @@ -837,18 +815,6 @@ export function normalizeProjectEditor(editor: Partial): Pro cursorSpringMassMultiplier: isFiniteNumber(editor.cursorSpringMassMultiplier) ? clamp(editor.cursorSpringMassMultiplier, 0.25, 3) : DEFAULT_MOTION_PRESET.cursorSpringMassMultiplier, - cameraSpringStiffnessMultiplier: isFiniteNumber(editor.cameraSpringStiffnessMultiplier) - ? clamp(editor.cameraSpringStiffnessMultiplier, 0.25, 3) - : 1, - cameraSpringDampingMultiplier: isFiniteNumber(editor.cameraSpringDampingMultiplier) - ? clamp(editor.cameraSpringDampingMultiplier, 0.25, 3) - : 1, - cameraSpringMassMultiplier: isFiniteNumber(editor.cameraSpringMassMultiplier) - ? clamp(editor.cameraSpringMassMultiplier, 0.25, 3) - : 1, - zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS, - zoomClassicMode: - typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false, cursorMotionBlur: isFiniteNumber((editor as Partial).cursorMotionBlur) ? clamp((editor as Partial).cursorMotionBlur as number, 0, 2) : DEFAULT_MOTION_PRESET.cursorMotionBlur, @@ -864,6 +830,54 @@ export function normalizeProjectEditor(editor: Partial): Pro 500, ) : DEFAULT_MOTION_PRESET.cursorClickBounceDuration, + }; + const normalizedMotionPreset = + CURSOR_MOTION_PRESETS[resolveCursorMotionPresetId(normalizedMotionValues)]; + + return { + wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : DEFAULT_WALLPAPER_PATH, + shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67, + backgroundBlur: normalizedBackgroundBlur, + zoomMotionBlur: normalizedZoomMotionBlur, + zoomMotionBlurTuning: normalizedZoomMotionBlurTuning, + zoomTemporalMotionBlur: normalizedZoomTemporalMotionBlur, + zoomMotionBlurSampleCount: normalizedZoomMotionBlurSampleCount, + zoomMotionBlurShutterFraction: normalizedZoomMotionBlurShutterFraction, + connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true, + zoomInDurationMs: normalizedMotionPreset.zoomInDurationMs, + zoomInOverlapMs: normalizedZoomInOverlapMs, + zoomOutDurationMs: normalizedMotionPreset.zoomOutDurationMs, + connectedZoomGapMs: normalizedConnectedZoomGapMs, + connectedZoomDurationMs: normalizedConnectedZoomDurationMs, + zoomInEasing: normalizeZoomTransitionEasing(editor.zoomInEasing, DEFAULT_ZOOM_IN_EASING), + zoomOutEasing: normalizeZoomTransitionEasing(editor.zoomOutEasing, DEFAULT_ZOOM_OUT_EASING), + connectedZoomEasing: normalizeZoomTransitionEasing( + editor.connectedZoomEasing, + DEFAULT_CONNECTED_ZOOM_EASING, + ), + showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true, + loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false, + cursorStyle: normalizedCursorStyle, + cursorSize: normalizedMotionPreset.cursorSize, + cursorSmoothing: normalizedMotionPreset.cursorSmoothing, + cursorSpringStiffnessMultiplier: normalizedMotionPreset.cursorSpringStiffnessMultiplier, + cursorSpringDampingMultiplier: normalizedMotionPreset.cursorSpringDampingMultiplier, + cursorSpringMassMultiplier: normalizedMotionPreset.cursorSpringMassMultiplier, + cameraSpringStiffnessMultiplier: isFiniteNumber(editor.cameraSpringStiffnessMultiplier) + ? clamp(editor.cameraSpringStiffnessMultiplier, 0.25, 3) + : 1, + cameraSpringDampingMultiplier: isFiniteNumber(editor.cameraSpringDampingMultiplier) + ? clamp(editor.cameraSpringDampingMultiplier, 0.25, 3) + : 1, + cameraSpringMassMultiplier: isFiniteNumber(editor.cameraSpringMassMultiplier) + ? clamp(editor.cameraSpringMassMultiplier, 0.25, 3) + : 1, + zoomSmoothness: DEFAULT_ZOOM_SMOOTHNESS, + zoomClassicMode: + typeof editor.zoomClassicMode === "boolean" ? editor.zoomClassicMode : false, + cursorMotionBlur: normalizedMotionPreset.cursorMotionBlur, + cursorClickBounce: normalizedMotionPreset.cursorClickBounce, + cursorClickBounceDuration: normalizedMotionPreset.cursorClickBounceDuration, cursorSway: isFiniteNumber((editor as Partial).cursorSway) ? clamp((editor as Partial).cursorSway as number, 0, 2) : DEFAULT_CURSOR_SWAY, diff --git a/src/components/video-editor/timeline/TimelineEditor.tsx b/src/components/video-editor/timeline/TimelineEditor.tsx index 740d01cb..f58353a2 100644 --- a/src/components/video-editor/timeline/TimelineEditor.tsx +++ b/src/components/video-editor/timeline/TimelineEditor.tsx @@ -585,7 +585,7 @@ function ClipMarkerOverlay({ videoDurationMs }: { videoDurationMs: number }) { bottom: "7.5%", [sideProperty]: `${offset}px`, background: - "linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.10) 35%, rgba(255,255,255,0.10) 65%, transparent 100%)", + "linear-gradient(to bottom, transparent 0%, rgba(255,255,255,0.32) 35%, rgba(255,255,255,0.32) 65%, transparent 100%)", }} /> ))} @@ -858,7 +858,6 @@ function Timeline({ onMouseMove={handleTimelineMouseMove} onMouseLeave={handleTimelineMouseLeave} > -
Date: Fri, 8 May 2026 14:33:56 +1000 Subject: [PATCH 3/3] make default lang system lang --- src/contexts/I18nContext.tsx | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/contexts/I18nContext.tsx b/src/contexts/I18nContext.tsx index 90c462de..ebca62da 100644 --- a/src/contexts/I18nContext.tsx +++ b/src/contexts/I18nContext.tsx @@ -207,6 +207,29 @@ function normalizeLocale(locale: string | null | undefined): AppLocale { return DEFAULT_LOCALE; } +function getSystemLocale(): AppLocale { + if (typeof navigator === "undefined") { + return DEFAULT_LOCALE; + } + + const preferredLocales = Array.isArray(navigator.languages) + ? navigator.languages + : [navigator.language]; + + for (const locale of preferredLocales) { + if (typeof locale !== "string" || locale.trim().length === 0) { + continue; + } + + const normalized = normalizeLocale(locale); + if (normalized !== DEFAULT_LOCALE || locale.toLowerCase().startsWith(DEFAULT_LOCALE)) { + return normalized; + } + } + + return DEFAULT_LOCALE; +} + function getInitialLocale(): AppLocale { if (typeof window === "undefined") { return DEFAULT_LOCALE; @@ -217,9 +240,7 @@ function getInitialLocale(): AppLocale { return normalizeLocale(storedLocale); } - // Product default must be English on first launch unless user explicitly - // selected another locale and we persisted it in localStorage. - return DEFAULT_LOCALE; + return getSystemLocale(); } function getMessageValue(source: unknown, key: string): string | undefined {