mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 07:16:02 +00:00
merge main into beta/pr410-native-gpu-export-test
# Conflicts: # src/lib/exporter/modernVideoExporter.ts
This commit is contained in:
@@ -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,
|
||||
type CursorMotionPresetId,
|
||||
getMatchingCursorMotionPresetId,
|
||||
} from "./cursorMotionPresets";
|
||||
import { loadEditorPreferences, saveEditorPreferences } from "./editorPreferences";
|
||||
import { SliderControl } from "./SliderControl";
|
||||
import { KeyboardShortcutsDialog } from "./TutorialHelp";
|
||||
@@ -1486,9 +1490,7 @@ export function SettingsPanel({
|
||||
onCameraSpringDampingMultiplierChange?.(
|
||||
initialEditorPreferences.cameraSpringDampingMultiplier,
|
||||
);
|
||||
onCameraSpringMassMultiplierChange?.(
|
||||
initialEditorPreferences.cameraSpringMassMultiplier,
|
||||
);
|
||||
onCameraSpringMassMultiplierChange?.(initialEditorPreferences.cameraSpringMassMultiplier);
|
||||
onZoomInDurationMsChange?.(initialEditorPreferences.zoomInDurationMs);
|
||||
onZoomOutDurationMsChange?.(initialEditorPreferences.zoomOutDurationMs);
|
||||
onZoomClassicModeChange?.(false);
|
||||
@@ -1515,21 +1517,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,
|
||||
@@ -3003,21 +3002,21 @@ export function SettingsPanel({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{showDevMotionControls
|
||||
? tSettings(
|
||||
"effects.exportBlurMovedToDev",
|
||||
"Export blur tuning is available in Settings > Dev.",
|
||||
)
|
||||
: tSettings(
|
||||
"effects.exportBlurLocked",
|
||||
"Export blur is fixed for this build.",
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] font-medium text-foreground">
|
||||
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
|
||||
</div>
|
||||
<div className="rounded-lg border border-foreground/10 bg-foreground/[0.03] px-3 py-2">
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{showDevMotionControls
|
||||
? tSettings(
|
||||
"effects.exportBlurMovedToDev",
|
||||
"Export blur tuning is available in Settings > Dev.",
|
||||
)
|
||||
: tSettings(
|
||||
"effects.exportBlurLocked",
|
||||
"Export blur is fixed for this build.",
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] font-medium text-foreground">
|
||||
{`${TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT} samples · ${Math.round(TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION * 100)}% shutter`}
|
||||
</div>
|
||||
</div>
|
||||
{selectedZoomId && (
|
||||
<Button
|
||||
|
||||
@@ -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,55 @@ 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<CursorMotionPresetId, CursorMotionPre
|
||||
cursorSpringStiffnessMultiplier: 0.92,
|
||||
cursorSpringDampingMultiplier: 1.36,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export function getMatchingCursorMotionPresetId(
|
||||
values: CursorMotionPresetSelectionInput,
|
||||
): CursorMotionPresetId | null {
|
||||
for (const presetId of Object.keys(CURSOR_MOTION_PRESETS) as CursorMotionPresetId[]) {
|
||||
const preset = CURSOR_MOTION_PRESETS[presetId];
|
||||
if (
|
||||
preset.zoomInDurationMs === values.zoomInDurationMs &&
|
||||
preset.zoomOutDurationMs === values.zoomOutDurationMs &&
|
||||
preset.cursorSize === values.cursorSize &&
|
||||
preset.cursorSmoothing === values.cursorSmoothing &&
|
||||
preset.cursorSpringStiffnessMultiplier === values.cursorSpringStiffnessMultiplier &&
|
||||
preset.cursorSpringDampingMultiplier === values.cursorSpringDampingMultiplier &&
|
||||
preset.cursorSpringMassMultiplier === values.cursorSpringMassMultiplier &&
|
||||
preset.cursorMotionBlur === values.cursorMotionBlur &&
|
||||
preset.cursorClickBounce === values.cursorClickBounce &&
|
||||
preset.cursorClickBounceDuration === values.cursorClickBounceDuration
|
||||
) {
|
||||
return presetId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveCursorMotionPresetId(
|
||||
values: CursorMotionPresetSelectionInput,
|
||||
fallback: CursorMotionPresetId = "focused",
|
||||
): CursorMotionPresetId {
|
||||
return getMatchingCursorMotionPresetId(values) ?? fallback;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import {
|
||||
DEFAULT_EDITOR_PREFERENCES,
|
||||
EDITOR_PREFERENCES_STORAGE_KEY,
|
||||
EDITOR_PRESETS_STORAGE_KEY,
|
||||
loadEditorPresets,
|
||||
loadEditorPreferences,
|
||||
loadEditorPresets,
|
||||
normalizeEditorPreferences,
|
||||
saveEditorPresets,
|
||||
saveEditorPreferences,
|
||||
saveEditorPresets,
|
||||
} from "./editorPreferences";
|
||||
import { DEFAULT_AUTO_CAPTION_SETTINGS } from "./types";
|
||||
|
||||
@@ -223,11 +223,11 @@ describe("editorPreferences", () => {
|
||||
showCursor: false,
|
||||
loopCursor: true,
|
||||
cursorStyle: "figma",
|
||||
cursorSize: 3,
|
||||
cursorSmoothing: 1.25,
|
||||
cursorMotionBlur: 0.5,
|
||||
cursorClickBounce: 2.25,
|
||||
cursorClickBounceDuration: 350,
|
||||
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
|
||||
cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing,
|
||||
cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur,
|
||||
cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce,
|
||||
cursorClickBounceDuration: DEFAULT_EDITOR_PREFERENCES.cursorClickBounceDuration,
|
||||
cursorSway: 1.5,
|
||||
borderRadius: 18,
|
||||
padding: { top: 30, right: 30, bottom: 30, left: 30, linked: true },
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from "@/lib/exporter/temporalMotionBlur";
|
||||
import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { CURSOR_MOTION_PRESETS } from "./cursorMotionPresets";
|
||||
import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets";
|
||||
import {
|
||||
type AnnotationRegion,
|
||||
type AudioRegion,
|
||||
@@ -797,31 +797,9 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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<ProjectEditorState>): 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<ProjectEditorState>).cursorMotionBlur)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
|
||||
: DEFAULT_MOTION_PRESET.cursorMotionBlur,
|
||||
@@ -864,6 +830,54 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): 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<ProjectEditorState>).cursorSway)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorSway as number, 0, 2)
|
||||
: DEFAULT_CURSOR_SWAY,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(to_right,hsl(var(--foreground)/0.03)_1px,transparent_1px)] bg-[length:20px_100%] pointer-events-none" />
|
||||
<TimelineAxis videoDurationMs={videoDurationMs} currentTimeMs={currentTimeMs} />
|
||||
<PlaybackCursor
|
||||
currentTimeMs={currentTimeMs}
|
||||
@@ -1236,12 +1235,7 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
onSelectAnnotation?.(null);
|
||||
onSelectAudio?.(null);
|
||||
setSelectAllBlocksActive(false);
|
||||
}, [
|
||||
onSelectAnnotation,
|
||||
onSelectAudio,
|
||||
onSelectClip,
|
||||
onSelectZoom,
|
||||
]);
|
||||
}, [onSelectAnnotation, onSelectAudio, onSelectClip, onSelectZoom]);
|
||||
|
||||
const hasAnyTimelineBlocks =
|
||||
zoomRegions.length > 0 ||
|
||||
@@ -1479,7 +1473,9 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
const activeClip =
|
||||
clipRegions.length === 0
|
||||
? { startMs: 0, endMs: totalMs }
|
||||
: clipRegions.find((clip) => startPos >= clip.startMs && startPos < clip.endMs);
|
||||
: clipRegions.find(
|
||||
(clip) => startPos >= clip.startMs && startPos < clip.endMs,
|
||||
);
|
||||
if (!activeClip) {
|
||||
return false;
|
||||
}
|
||||
@@ -1487,7 +1483,9 @@ const TimelineEditor = forwardRef<TimelineEditorHandle, TimelineEditorProps>(
|
||||
const sorted = [...zoomRegions].sort((a, b) => a.startMs - b.startMs);
|
||||
const nextRegion = sorted.find((region) => region.startMs > startPos);
|
||||
const gapToNextClipEdge = activeClip.endMs - startPos;
|
||||
const gapToNextRegion = nextRegion ? nextRegion.startMs - startPos : gapToNextClipEdge;
|
||||
const gapToNextRegion = nextRegion
|
||||
? nextRegion.startMs - startPos
|
||||
: gapToNextClipEdge;
|
||||
const availableDuration = Math.min(gapToNextClipEdge, gapToNextRegion);
|
||||
|
||||
const isOverlapping = sorted.some(
|
||||
|
||||
@@ -154,7 +154,7 @@ const messages: Record<AppLocale, LocaleBundle> = {
|
||||
shortcuts: zhCNShortcuts,
|
||||
extensions: zhCNExtensions,
|
||||
},
|
||||
"zh-TW": {
|
||||
"zh-TW": {
|
||||
common: zhTWCommon,
|
||||
launch: zhTWLaunch,
|
||||
editor: zhTWEditor,
|
||||
@@ -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 {
|
||||
|
||||
@@ -1172,7 +1172,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 canUsePrimaryAudioFiltergraph =
|
||||
@@ -1234,7 +1239,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) {
|
||||
|
||||
@@ -14,6 +14,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 {
|
||||
@@ -191,8 +192,7 @@ export class VideoExporter {
|
||||
const shouldUseFfmpegAudioFallback =
|
||||
!useNativeEncoder &&
|
||||
audioPlan.audioMode !== "none" &&
|
||||
(shouldUsePitchPreservingFfmpegAudio ||
|
||||
!(await isAacAudioEncodingSupported()));
|
||||
(shouldUsePitchPreservingFfmpegAudio || !(await isAacAudioEncodingSupported()));
|
||||
|
||||
if (!useNativeEncoder) {
|
||||
await this.initializeEncoder();
|
||||
@@ -564,7 +564,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 canUsePrimaryAudioFiltergraph =
|
||||
@@ -575,17 +580,16 @@ export class VideoExporter {
|
||||
typeof primaryAudioSourceSampleRate === "number" &&
|
||||
Number.isFinite(primaryAudioSourceSampleRate) &&
|
||||
primaryAudioSourceSampleRate > 0;
|
||||
const strategy =
|
||||
canUsePrimaryAudioFiltergraph
|
||||
? classifyEditedTrackStrategy({
|
||||
primaryAudioSourcePath,
|
||||
sourceDurationMs,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
audioRegions,
|
||||
sourceAudioFallbackPaths,
|
||||
})
|
||||
: "offline-render-fallback";
|
||||
const strategy = canUsePrimaryAudioFiltergraph
|
||||
? classifyEditedTrackStrategy({
|
||||
primaryAudioSourcePath,
|
||||
sourceDurationMs,
|
||||
trimRegions,
|
||||
speedRegions,
|
||||
audioRegions,
|
||||
sourceAudioFallbackPaths,
|
||||
})
|
||||
: "offline-render-fallback";
|
||||
|
||||
if (strategy === "filtergraph-fast-path") {
|
||||
const audioSourcePath = primaryAudioSourcePath;
|
||||
@@ -626,7 +630,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) {
|
||||
|
||||
@@ -128,6 +128,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({
|
||||
|
||||
+21
-4
@@ -94,12 +94,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;
|
||||
|
||||
Reference in New Issue
Block a user