mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 00:05:39 +00:00
Fix gap annotation compositing and late audio sync; retire temporal blur
This commit is contained in:
@@ -35,6 +35,15 @@ afterEach(() => {
|
||||
|
||||
describe("source preview playback ownership", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "sub-second offset",
|
||||
muted: false,
|
||||
playing: true,
|
||||
rate: 1,
|
||||
time: 0.5,
|
||||
delay: 0,
|
||||
plays: true,
|
||||
},
|
||||
{
|
||||
name: "playing clip",
|
||||
muted: false,
|
||||
@@ -145,5 +154,6 @@ describe("source preview playback ownership", () => {
|
||||
harness.effects.at(-1)?.();
|
||||
await Promise.resolve();
|
||||
expect(audio.play).toHaveBeenCalledTimes(plays ? 1 : 0);
|
||||
if (plays) expect(audio.currentTime).toBeCloseTo(time - delay / 1000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,6 +239,7 @@ export function useAudioPreviewSync({
|
||||
sourceAudioResourceVersion,
|
||||
);
|
||||
latestAudio.load();
|
||||
lastSourceAudioSyncTimeRef.current = null;
|
||||
setSourceLoadVersion((version) => version + 1);
|
||||
} catch (error) {
|
||||
const latestAudio = existing.get(audioPath);
|
||||
|
||||
@@ -17,9 +17,6 @@ type PersistedEditorControls = Pick<
|
||||
| "backgroundBlur"
|
||||
| "zoomMotionBlur"
|
||||
| "zoomMotionBlurTuning"
|
||||
| "zoomTemporalMotionBlur"
|
||||
| "zoomMotionBlurSampleCount"
|
||||
| "zoomMotionBlurShutterFraction"
|
||||
| "connectZooms"
|
||||
| "zoomInDurationMs"
|
||||
| "zoomInOverlapMs"
|
||||
@@ -108,9 +105,6 @@ export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
|
||||
backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur,
|
||||
zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: DEFAULT_EDITOR_CONTROLS.zoomMotionBlurShutterFraction,
|
||||
connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms,
|
||||
zoomInDurationMs: DEFAULT_EDITOR_CONTROLS.zoomInDurationMs,
|
||||
zoomInOverlapMs: DEFAULT_EDITOR_CONTROLS.zoomInOverlapMs,
|
||||
@@ -378,9 +372,6 @@ function normalizeEditorControls(
|
||||
backgroundBlur: normalized.backgroundBlur,
|
||||
zoomMotionBlur: normalized.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: normalized.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: normalized.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: normalized.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: normalized.zoomMotionBlurShutterFraction,
|
||||
connectZooms: normalized.connectZooms,
|
||||
zoomInDurationMs: normalized.zoomInDurationMs,
|
||||
zoomInOverlapMs: normalized.zoomInOverlapMs,
|
||||
|
||||
@@ -42,9 +42,6 @@ export function buildExportRenderOptions({
|
||||
backgroundBlur: appearance.backgroundBlur,
|
||||
zoomMotionBlur: appearance.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: appearance.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: appearance.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: appearance.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: appearance.zoomMotionBlurShutterFraction,
|
||||
connectZooms: appearance.connectZooms,
|
||||
zoomInDurationMs: appearance.zoomInDurationMs,
|
||||
zoomInOverlapMs: appearance.zoomInOverlapMs,
|
||||
|
||||
@@ -26,9 +26,6 @@ export function useEditorPreferencesPersistence({
|
||||
backgroundBlur: appearance.backgroundBlur,
|
||||
zoomMotionBlur: appearance.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: appearance.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: appearance.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: appearance.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: appearance.zoomMotionBlurShutterFraction,
|
||||
autoApplyFreshRecordingAutoZooms: appearance.autoApplyFreshRecordingAutoZooms,
|
||||
connectZooms: appearance.connectZooms,
|
||||
zoomInDurationMs: appearance.zoomInDurationMs,
|
||||
@@ -81,9 +78,6 @@ export function useEditorPreferencesPersistence({
|
||||
appearance.backgroundBlur,
|
||||
appearance.zoomMotionBlur,
|
||||
appearance.zoomMotionBlurTuning,
|
||||
appearance.zoomTemporalMotionBlur,
|
||||
appearance.zoomMotionBlurSampleCount,
|
||||
appearance.zoomMotionBlurShutterFraction,
|
||||
appearance.autoApplyFreshRecordingAutoZooms,
|
||||
appearance.connectZooms,
|
||||
appearance.zoomInDurationMs,
|
||||
|
||||
@@ -43,9 +43,6 @@ export function useVideoEditorPresets({
|
||||
backgroundBlur: appearance.backgroundBlur,
|
||||
zoomMotionBlur: appearance.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: { ...appearance.zoomMotionBlurTuning },
|
||||
zoomTemporalMotionBlur: appearance.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: appearance.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: appearance.zoomMotionBlurShutterFraction,
|
||||
connectZooms: appearance.connectZooms,
|
||||
zoomInDurationMs: appearance.zoomInDurationMs,
|
||||
zoomInOverlapMs: appearance.zoomInOverlapMs,
|
||||
@@ -111,9 +108,6 @@ export function useVideoEditorPresets({
|
||||
appearance.setBackgroundBlur(snapshot.backgroundBlur);
|
||||
appearance.setZoomMotionBlur(snapshot.zoomMotionBlur);
|
||||
appearance.setZoomMotionBlurTuning({ ...snapshot.zoomMotionBlurTuning });
|
||||
appearance.setZoomTemporalMotionBlur(snapshot.zoomTemporalMotionBlur);
|
||||
appearance.setZoomMotionBlurSampleCount(snapshot.zoomMotionBlurSampleCount);
|
||||
appearance.setZoomMotionBlurShutterFraction(snapshot.zoomMotionBlurShutterFraction);
|
||||
appearance.setConnectZooms(snapshot.connectZooms);
|
||||
appearance.setZoomInDurationMs(snapshot.zoomInDurationMs);
|
||||
appearance.setZoomInOverlapMs(snapshot.zoomInOverlapMs);
|
||||
|
||||
@@ -68,9 +68,6 @@ export function useProjectLibraryController({
|
||||
zoomInOverlapMs,
|
||||
zoomMotionBlur,
|
||||
zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction,
|
||||
zoomOutDurationMs,
|
||||
zoomOutEasing,
|
||||
zoomClassicMode,
|
||||
@@ -156,9 +153,6 @@ export function useProjectLibraryController({
|
||||
backgroundBlur,
|
||||
zoomMotionBlur,
|
||||
zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction,
|
||||
connectZooms,
|
||||
zoomInDurationMs,
|
||||
zoomInOverlapMs,
|
||||
@@ -327,9 +321,6 @@ export function useProjectLibraryController({
|
||||
zoomInOverlapMs,
|
||||
zoomMotionBlur,
|
||||
zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction,
|
||||
zoomOutDurationMs,
|
||||
zoomOutEasing,
|
||||
zoomRegions,
|
||||
|
||||
@@ -131,9 +131,6 @@ export function useProjectLifecycle(input: Input) {
|
||||
appearance.setBackgroundBlur(editor.backgroundBlur);
|
||||
appearance.setZoomMotionBlur(editor.zoomMotionBlur);
|
||||
appearance.setZoomMotionBlurTuning({ ...editor.zoomMotionBlurTuning });
|
||||
appearance.setZoomTemporalMotionBlur(editor.zoomTemporalMotionBlur);
|
||||
appearance.setZoomMotionBlurSampleCount(editor.zoomMotionBlurSampleCount);
|
||||
appearance.setZoomMotionBlurShutterFraction(editor.zoomMotionBlurShutterFraction);
|
||||
appearance.setConnectZooms(editor.connectZooms);
|
||||
appearance.setZoomInDurationMs(editor.zoomInDurationMs);
|
||||
appearance.setZoomInOverlapMs(editor.zoomInOverlapMs);
|
||||
|
||||
@@ -79,9 +79,6 @@ export function useProjectSnapshotModel({
|
||||
backgroundBlur: appearance.backgroundBlur,
|
||||
zoomMotionBlur: appearance.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: appearance.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: appearance.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: appearance.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: appearance.zoomMotionBlurShutterFraction,
|
||||
connectZooms: appearance.connectZooms,
|
||||
zoomInDurationMs: appearance.zoomInDurationMs,
|
||||
zoomInOverlapMs: appearance.zoomInOverlapMs,
|
||||
@@ -144,9 +141,6 @@ export function useProjectSnapshotModel({
|
||||
appearance.backgroundBlur,
|
||||
appearance.zoomMotionBlur,
|
||||
appearance.zoomMotionBlurTuning,
|
||||
appearance.zoomTemporalMotionBlur,
|
||||
appearance.zoomMotionBlurSampleCount,
|
||||
appearance.zoomMotionBlurShutterFraction,
|
||||
appearance.connectZooms,
|
||||
appearance.zoomInDurationMs,
|
||||
appearance.zoomInOverlapMs,
|
||||
|
||||
@@ -34,6 +34,19 @@ describe("resolveVideoUrl", () => {
|
||||
});
|
||||
|
||||
describe("normalizeProjectEditor", () => {
|
||||
it("ignores retired temporal blur fields when opening an older project", () => {
|
||||
const savedEditor = {
|
||||
zoomMotionBlur: 0.6,
|
||||
zoomTemporalMotionBlur: 0.35,
|
||||
zoomMotionBlurSampleCount: 13,
|
||||
zoomMotionBlurShutterFraction: 0.94,
|
||||
};
|
||||
const normalized = normalizeProjectEditor(savedEditor);
|
||||
expect(normalized.zoomMotionBlur).toBe(0.6);
|
||||
for (const field of ["zoomTemporalMotionBlur", "zoomMotionBlurSampleCount", "zoomMotionBlurShutterFraction"]) {
|
||||
expect(normalized).not.toHaveProperty(field);
|
||||
}
|
||||
});
|
||||
it("defaults to 8% on macOS and square corners elsewhere", () => {
|
||||
expect(getDefaultBorderRadiusPercent("MacIntel")).toBe(8);
|
||||
expect(getDefaultBorderRadiusPercent("Win32")).toBe(0);
|
||||
|
||||
@@ -10,14 +10,6 @@ import type {
|
||||
GifSizePreset,
|
||||
} from "@/lib/exporter";
|
||||
import { isValidMp4FrameRate } from "@/lib/exporter/types";
|
||||
import {
|
||||
TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT,
|
||||
TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION,
|
||||
TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT,
|
||||
TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION,
|
||||
TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT,
|
||||
TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION,
|
||||
} from "@/lib/exporter/temporalMotionBlur";
|
||||
import { DEFAULT_WALLPAPER_PATH } from "@/lib/wallpapers";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { CURSOR_MOTION_PRESETS, resolveCursorMotionPresetId } from "./cursorMotionPresets";
|
||||
@@ -104,9 +96,6 @@ export interface ProjectEditorState {
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur: number;
|
||||
zoomMotionBlurTuning: ZoomMotionBlurTuning;
|
||||
zoomTemporalMotionBlur: number;
|
||||
zoomMotionBlurSampleCount: number | null;
|
||||
zoomMotionBlurShutterFraction: number | null;
|
||||
connectZooms: boolean;
|
||||
zoomInDurationMs: number;
|
||||
zoomInOverlapMs: number;
|
||||
@@ -354,27 +343,6 @@ export function validateProjectData(candidate: unknown): candidate is EditorProj
|
||||
}
|
||||
|
||||
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||
const normalizeTemporalBlurSampleCount = (value: unknown): number => {
|
||||
if (!isFiniteNumber(value)) {
|
||||
return TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT;
|
||||
}
|
||||
|
||||
const roundedValue = Math.round(value);
|
||||
const clampedValue = clamp(
|
||||
roundedValue,
|
||||
TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT,
|
||||
TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT,
|
||||
);
|
||||
|
||||
if (clampedValue % 2 === 1) {
|
||||
return clampedValue;
|
||||
}
|
||||
|
||||
return clampedValue >= TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT
|
||||
? clampedValue - 1
|
||||
: clampedValue + 1;
|
||||
};
|
||||
|
||||
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
||||
const legacyMotionBlurEnabled = (editor as Partial<{ motionBlurEnabled: boolean }>)
|
||||
.motionBlurEnabled;
|
||||
@@ -415,11 +383,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
? clamp(rawZoomMotionBlurTuning.zoomSafeZoneRadiusPx, 0, 80)
|
||||
: DEFAULT_ZOOM_MOTION_BLUR_TUNING.zoomSafeZoneRadiusPx,
|
||||
};
|
||||
const normalizedZoomTemporalMotionBlur = isFiniteNumber(
|
||||
(editor as Partial<ProjectEditorState>).zoomTemporalMotionBlur,
|
||||
)
|
||||
? clamp((editor as Partial<ProjectEditorState>).zoomTemporalMotionBlur as number, 0, 2)
|
||||
: normalizedZoomMotionBlur;
|
||||
const normalizedBackgroundBlur = isFiniteNumber(
|
||||
(editor as Partial<ProjectEditorState>).backgroundBlur,
|
||||
)
|
||||
@@ -427,18 +390,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
: legacyShowBlur
|
||||
? 2
|
||||
: 0;
|
||||
const normalizedZoomMotionBlurSampleCount = normalizeTemporalBlurSampleCount(
|
||||
(editor as Partial<ProjectEditorState>).zoomMotionBlurSampleCount,
|
||||
);
|
||||
const normalizedZoomMotionBlurShutterFraction = isFiniteNumber(
|
||||
(editor as Partial<ProjectEditorState>).zoomMotionBlurShutterFraction,
|
||||
)
|
||||
? clamp(
|
||||
(editor as Partial<ProjectEditorState>).zoomMotionBlurShutterFraction as number,
|
||||
TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION,
|
||||
TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION,
|
||||
)
|
||||
: TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION;
|
||||
const normalizedZoomInDurationMs = isFiniteNumber(editor.zoomInDurationMs)
|
||||
? clamp(editor.zoomInDurationMs, 60, 4000)
|
||||
: DEFAULT_MOTION_PRESET.zoomInDurationMs;
|
||||
@@ -915,9 +866,6 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
|
||||
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,
|
||||
|
||||
@@ -31,15 +31,6 @@ export function useAppearanceState(preferences: EditorPreferences) {
|
||||
const [zoomMotionBlurTuning, setZoomMotionBlurTuning] = useState<ZoomMotionBlurTuning>(
|
||||
preferences.zoomMotionBlurTuning ?? DEFAULT_ZOOM_MOTION_BLUR_TUNING,
|
||||
);
|
||||
const [zoomTemporalMotionBlur, setZoomTemporalMotionBlur] = useState(
|
||||
preferences.zoomTemporalMotionBlur,
|
||||
);
|
||||
const [zoomMotionBlurSampleCount, setZoomMotionBlurSampleCount] = useState<number | null>(
|
||||
preferences.zoomMotionBlurSampleCount,
|
||||
);
|
||||
const [zoomMotionBlurShutterFraction, setZoomMotionBlurShutterFraction] = useState<
|
||||
number | null
|
||||
>(preferences.zoomMotionBlurShutterFraction);
|
||||
const [autoApplyFreshRecordingAutoZooms, setAutoApplyFreshRecordingAutoZooms] = useState(
|
||||
preferences.autoApplyFreshRecordingAutoZooms,
|
||||
);
|
||||
@@ -135,12 +126,6 @@ export function useAppearanceState(preferences: EditorPreferences) {
|
||||
setZoomMotionBlur,
|
||||
zoomMotionBlurTuning,
|
||||
setZoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur,
|
||||
setZoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount,
|
||||
setZoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction,
|
||||
setZoomMotionBlurShutterFraction,
|
||||
autoApplyFreshRecordingAutoZooms,
|
||||
setAutoApplyFreshRecordingAutoZooms,
|
||||
connectZooms,
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "Unschärfe",
|
||||
"zoomMotionBlur": "Zoom-Bewegungsunschärfe",
|
||||
"temporalZoomMotionBlur": "Zeitliche Zoom-Bewegungsunschärfe",
|
||||
"temporalZoomMotionBlurDescription": "Steuert das Verschlussfenster und die Bildausschnitte, die vom neueren Zoom-Bewegungsunschärfe-Durchlauf verwendet werden.",
|
||||
"zoomMotionBlurSamples": "Unschärfe-Samples",
|
||||
"zoomMotionBlurShutter": "Verschluss",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "Zooms verbinden",
|
||||
"connectZoomsDescription": "Füge aufeinanderfolgende Zoom-Bereiche zu einer kontinuierlichen Kamerabewegung zusammen.",
|
||||
|
||||
@@ -52,10 +52,6 @@
|
||||
},
|
||||
"backgroundBlur": "Blur",
|
||||
"zoomMotionBlur": "Zoom Motion Blur",
|
||||
"temporalZoomMotionBlur": "Temporal Zoom Blur",
|
||||
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
|
||||
"zoomMotionBlurSamples": "Blur Samples",
|
||||
"zoomMotionBlurShutter": "Shutter",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "Connect Zooms",
|
||||
"connectZoomsDescription": "Smooth consecutive zoom regions into a continuous camera move.",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "Desenfoque",
|
||||
"zoomMotionBlur": "Desenfoque de movimiento del zoom",
|
||||
"temporalZoomMotionBlur": "Temporal Zoom Blur",
|
||||
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
|
||||
"zoomMotionBlurSamples": "Blur Samples",
|
||||
"zoomMotionBlurShutter": "Shutter",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "Conectar zooms",
|
||||
"connectZoomsDescription": "Suaviza regiones de zoom consecutivas convirtiéndolas en un movimiento continuo de cámara.",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "Flou",
|
||||
"zoomMotionBlur": "Flou de mouvement du zoom",
|
||||
"temporalZoomMotionBlur": "Temporal Zoom Blur",
|
||||
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
|
||||
"zoomMotionBlurSamples": "Blur Samples",
|
||||
"zoomMotionBlurShutter": "Shutter",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "Relier les zooms",
|
||||
"connectZoomsDescription": "Lisse les zones de zoom consécutives pour créer un mouvement de caméra continu.",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "Sfocatura",
|
||||
"zoomMotionBlur": "Motion blur dello zoom",
|
||||
"temporalZoomMotionBlur": "Sfocatura zoom temporale",
|
||||
"temporalZoomMotionBlurDescription": "Controlla la finestra dell'otturatore e i campioni di frame usati dal nuovo passaggio di sfocatura zoom.",
|
||||
"zoomMotionBlurSamples": "Campioni di sfocatura",
|
||||
"zoomMotionBlurShutter": "Otturatore",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "Connetti zoom",
|
||||
"connectZoomsDescription": "Unisce regioni di zoom consecutive in un movimento di camera continuo.",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "블러",
|
||||
"zoomMotionBlur": "확대 모션 블러",
|
||||
"temporalZoomMotionBlur": "Temporal Zoom Blur",
|
||||
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
|
||||
"zoomMotionBlurSamples": "Blur Samples",
|
||||
"zoomMotionBlurShutter": "Shutter",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "확대 구간 연결",
|
||||
"connectZoomsDescription": "연속된 확대 구간을 하나의 부드러운 카메라 이동으로 연결합니다.",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "Vervaging",
|
||||
"zoomMotionBlur": "Zoom-bewegingsonscherpte",
|
||||
"temporalZoomMotionBlur": "Temporal Zoom Blur",
|
||||
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
|
||||
"zoomMotionBlurSamples": "Blur Samples",
|
||||
"zoomMotionBlurShutter": "Shutter",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "Zooms verbinden",
|
||||
"connectZoomsDescription": "Maak opeenvolgende zoomgebieden vloeiend tot een doorlopende camerabeweging.",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "Desfoque",
|
||||
"zoomMotionBlur": "Desfoque de movimento do zoom",
|
||||
"temporalZoomMotionBlur": "Temporal Zoom Blur",
|
||||
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
|
||||
"zoomMotionBlurSamples": "Blur Samples",
|
||||
"zoomMotionBlurShutter": "Shutter",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "Conectar zooms",
|
||||
"connectZoomsDescription": "Suaviza regiões de zoom consecutivas em um movimento contínuo de câmera.",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "Размытие",
|
||||
"zoomMotionBlur": "Размытие при зуме",
|
||||
"temporalZoomMotionBlur": "Временное размытие зума",
|
||||
"temporalZoomMotionBlurDescription": "Настройка плавности зума по времени и кадрам.",
|
||||
"zoomMotionBlurSamples": "Образцы размытия",
|
||||
"zoomMotionBlurShutter": "Выдержка",
|
||||
"auto": "Авто",
|
||||
"connectZooms": "Связать зумы",
|
||||
"connectZoomsDescription": "Плавный переход между зумами.",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "模糊",
|
||||
"zoomMotionBlur": "缩放运动模糊",
|
||||
"temporalZoomMotionBlur": "Temporal Zoom Blur",
|
||||
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
|
||||
"zoomMotionBlurSamples": "Blur Samples",
|
||||
"zoomMotionBlurShutter": "Shutter",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "连接缩放",
|
||||
"connectZoomsDescription": "将连续的缩放区域平滑连接为一次连续的镜头移动。",
|
||||
|
||||
@@ -46,10 +46,6 @@
|
||||
},
|
||||
"backgroundBlur": "模糊",
|
||||
"zoomMotionBlur": "縮放動態模糊",
|
||||
"temporalZoomMotionBlur": "Temporal Zoom Blur",
|
||||
"temporalZoomMotionBlurDescription": "Control the shutter window and frame samples used by the newer zoom blur pass.",
|
||||
"zoomMotionBlurSamples": "Blur Samples",
|
||||
"zoomMotionBlurShutter": "Shutter",
|
||||
"auto": "Auto",
|
||||
"connectZooms": "連接縮放",
|
||||
"connectZoomsDescription": "將連續的縮放區域平滑串接成一段連續的鏡頭移動。",
|
||||
|
||||
@@ -75,9 +75,7 @@ import { renderAnnotations } from "./annotationRenderer";
|
||||
import { renderCaptions } from "./captionRenderer";
|
||||
import { ForwardFrameSource } from "./forwardFrameSource";
|
||||
import { resolveMediaElementSource } from "./localMediaSource";
|
||||
import { buildTemporalSamplePlanUs, getTemporalMotionBlurConfig } from "./temporalMotionBlur";
|
||||
|
||||
const TEMPORAL_ZOOM_MOTION_BLUR_ENABLED = false;
|
||||
|
||||
interface FrameRenderConfig {
|
||||
timelineEffects?: boolean;
|
||||
@@ -91,9 +89,6 @@ interface FrameRenderConfig {
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur?: number;
|
||||
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
|
||||
zoomTemporalMotionBlur?: number;
|
||||
zoomMotionBlurSampleCount?: number | null;
|
||||
zoomMotionBlurShutterFraction?: number | null;
|
||||
connectZooms?: boolean;
|
||||
zoomInDurationMs?: number;
|
||||
zoomInOverlapMs?: number;
|
||||
@@ -197,11 +192,6 @@ interface LayoutCache {
|
||||
};
|
||||
}
|
||||
|
||||
interface RenderSnapshot {
|
||||
timeMs: number;
|
||||
sceneTransform: { scale: number; x: number; y: number };
|
||||
}
|
||||
|
||||
function createAnimationState(): AnimationState {
|
||||
return {
|
||||
scale: 1,
|
||||
@@ -245,8 +235,6 @@ export class FrameRenderer {
|
||||
private shadowCtx: CanvasRenderingContext2D | null = null;
|
||||
private compositeCanvas: HTMLCanvasElement | null = null;
|
||||
private compositeCtx: CanvasRenderingContext2D | null = null;
|
||||
private temporalAccumulationCanvas: HTMLCanvasElement | null = null;
|
||||
private temporalAccumulationCtx: CanvasRenderingContext2D | null = null;
|
||||
private backgroundForwardFrameSource: ForwardFrameSource | null = null;
|
||||
private backgroundForwardFrameSourceUrl: string | null = null;
|
||||
private backgroundForwardFrameDurationSec: number | null = null;
|
||||
@@ -470,19 +458,6 @@ export class FrameRenderer {
|
||||
throw new Error("Failed to get 2D context for composite canvas");
|
||||
}
|
||||
|
||||
this.temporalAccumulationCanvas = document.createElement("canvas");
|
||||
this.temporalAccumulationCanvas.width = this.config.width;
|
||||
this.temporalAccumulationCanvas.height = this.config.height;
|
||||
this.temporalAccumulationCtx = configureHighQuality2DContext(
|
||||
this.temporalAccumulationCanvas.getContext("2d", {
|
||||
willReadFrequently: true,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!this.temporalAccumulationCtx) {
|
||||
throw new Error("Failed to get 2D context for temporal accumulation canvas");
|
||||
}
|
||||
|
||||
// Setup shadow canvas if needed
|
||||
if (this.config.showShadow) {
|
||||
this.shadowCanvas = document.createElement("canvas");
|
||||
@@ -1407,7 +1382,7 @@ export class FrameRenderer {
|
||||
videoFrame: VideoFrame | null,
|
||||
timestamp: number,
|
||||
cursorTimestamp = timestamp,
|
||||
frameDurationUs?: number,
|
||||
_frameDurationUs?: number,
|
||||
backgroundTimelineTimestamp = timestamp,
|
||||
): Promise<void> {
|
||||
if (!this.app || !this.videoContainer || !this.cameraContainer || !this.compositeCtx) {
|
||||
@@ -1449,62 +1424,6 @@ export class FrameRenderer {
|
||||
return;
|
||||
}
|
||||
|
||||
const temporalSnapshot =
|
||||
TEMPORAL_ZOOM_MOTION_BLUR_ENABLED &&
|
||||
(this.config.zoomTemporalMotionBlur ?? 0) > 0 &&
|
||||
typeof frameDurationUs === "number" &&
|
||||
frameDurationUs > 0
|
||||
? await this.renderTemporalMotionBlurFrame(
|
||||
timestamp,
|
||||
cursorTimestamp,
|
||||
backgroundTimelineTimestamp,
|
||||
frameDurationUs,
|
||||
layoutCache,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (temporalSnapshot) {
|
||||
if (
|
||||
this.config.annotationRegions &&
|
||||
this.config.annotationRegions.length > 0 &&
|
||||
this.compositeCtx
|
||||
) {
|
||||
const scaleX = this.config.width / BASE_PREVIEW_WIDTH;
|
||||
const scaleY = this.config.height / BASE_PREVIEW_HEIGHT;
|
||||
const scaleFactor = (scaleX + scaleY) / 2;
|
||||
|
||||
await renderAnnotations(
|
||||
this.compositeCtx,
|
||||
this.config.annotationRegions,
|
||||
this.config.width,
|
||||
this.config.height,
|
||||
temporalSnapshot.timeMs,
|
||||
scaleFactor,
|
||||
undefined,
|
||||
temporalSnapshot.sceneTransform,
|
||||
this.layoutCache?.maskRect,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.config.autoCaptions &&
|
||||
this.config.autoCaptions.length > 0 &&
|
||||
this.config.autoCaptionSettings &&
|
||||
this.compositeCtx
|
||||
) {
|
||||
renderCaptions(
|
||||
this.compositeCtx,
|
||||
this.config.autoCaptions,
|
||||
this.config.autoCaptionSettings,
|
||||
this.config.width,
|
||||
this.config.height,
|
||||
timestamp / 1000,
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.webcamForwardFrameSource || this.webcamVideoElement) {
|
||||
const targetTime = Math.max(0, this.currentVideoTime);
|
||||
await this.syncWebcamFrame(targetTime);
|
||||
@@ -1757,136 +1676,6 @@ export class FrameRenderer {
|
||||
);
|
||||
}
|
||||
|
||||
private async renderSceneSample(
|
||||
timestamp: number,
|
||||
cursorTimestamp: number,
|
||||
backgroundTimelineTimestamp: number,
|
||||
layoutCache: LayoutCache,
|
||||
useVelocityMotionBlur: boolean,
|
||||
): Promise<RenderSnapshot> {
|
||||
if (!this.app || !this.cameraContainer) {
|
||||
throw new Error("Renderer not initialized");
|
||||
}
|
||||
|
||||
this.currentVideoTime = timestamp / 1_000_000;
|
||||
|
||||
if (this.webcamForwardFrameSource || this.webcamVideoElement) {
|
||||
await this.syncWebcamFrame(Math.max(0, this.currentVideoTime));
|
||||
}
|
||||
|
||||
if (this.backgroundForwardFrameSource || this.backgroundVideoElement) {
|
||||
await this.syncBackgroundFrame(Math.max(0, backgroundTimelineTimestamp / 1_000_000));
|
||||
}
|
||||
|
||||
const timeMs = this.config.timelineEffects
|
||||
? backgroundTimelineTimestamp / 1000
|
||||
: timestamp / 1000;
|
||||
const cursorTimeMs = cursorTimestamp / 1000;
|
||||
|
||||
if (this.cursorOverlay) {
|
||||
this.cursorOverlay.update(
|
||||
this.config.cursorTelemetry ?? [],
|
||||
cursorTimeMs,
|
||||
layoutCache.maskRect,
|
||||
this.config.showCursor ?? true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
this.updateAnimationState(timeMs, cursorTimeMs);
|
||||
|
||||
applyZoomTransform({
|
||||
cameraContainer: this.cameraContainer,
|
||||
zoomBlurFilter: this.zoomBlurFilter,
|
||||
motionBlurFilter: this.motionBlurFilter,
|
||||
stageSize: layoutCache.stageSize,
|
||||
baseMask: layoutCache.maskRect,
|
||||
zoomScale: this.animationState.scale,
|
||||
zoomProgress: this.animationState.progress,
|
||||
focusX: this.animationState.focusX,
|
||||
focusY: this.animationState.focusY,
|
||||
isPlaying: true,
|
||||
motionBlurAmount: useVelocityMotionBlur ? (this.config.zoomMotionBlur ?? 0) : 0,
|
||||
motionBlurTuning: this.config.zoomMotionBlurTuning,
|
||||
transformOverride: {
|
||||
scale: this.animationState.appliedScale,
|
||||
x: this.animationState.x,
|
||||
y: this.animationState.y,
|
||||
},
|
||||
motionBlurState: this.motionBlurState,
|
||||
frameTimeMs: timeMs,
|
||||
});
|
||||
|
||||
this.app.renderer.render(this.app.stage);
|
||||
this.compositeWithShadows();
|
||||
|
||||
return {
|
||||
timeMs,
|
||||
sceneTransform: {
|
||||
scale: this.animationState.appliedScale,
|
||||
x: this.animationState.x,
|
||||
y: this.animationState.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async renderTemporalMotionBlurFrame(
|
||||
timestamp: number,
|
||||
cursorTimestamp: number,
|
||||
backgroundTimelineTimestamp: number,
|
||||
frameDurationUs: number,
|
||||
layoutCache: LayoutCache,
|
||||
): Promise<RenderSnapshot | null> {
|
||||
if (!this.compositeCanvas || !this.compositeCtx || !this.temporalAccumulationCtx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const blurConfig = getTemporalMotionBlurConfig(this.config.zoomTemporalMotionBlur, {
|
||||
sampleCount: this.config.zoomMotionBlurSampleCount,
|
||||
shutterFraction: this.config.zoomMotionBlurShutterFraction,
|
||||
});
|
||||
if (!blurConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const samplePlan = buildTemporalSamplePlanUs(frameDurationUs, blurConfig);
|
||||
|
||||
this.temporalAccumulationCtx.clearRect(0, 0, this.config.width, this.config.height);
|
||||
|
||||
let centerSnapshot: RenderSnapshot | null = null;
|
||||
let lastSnapshot: RenderSnapshot | null = null;
|
||||
|
||||
for (const { offsetUs: sampleOffsetUs, weight } of samplePlan) {
|
||||
const sampleTimestamp = Math.max(0, timestamp + sampleOffsetUs);
|
||||
const sampleCursorTimestamp = Math.max(0, cursorTimestamp + sampleOffsetUs);
|
||||
const sampleBackgroundTimelineTimestamp = Math.max(
|
||||
0,
|
||||
backgroundTimelineTimestamp + sampleOffsetUs,
|
||||
);
|
||||
const snapshot = await this.renderSceneSample(
|
||||
sampleTimestamp,
|
||||
sampleCursorTimestamp,
|
||||
sampleBackgroundTimelineTimestamp,
|
||||
layoutCache,
|
||||
false,
|
||||
);
|
||||
lastSnapshot = snapshot;
|
||||
if (Math.abs(sampleOffsetUs) < 0.0001) {
|
||||
centerSnapshot = snapshot;
|
||||
}
|
||||
|
||||
this.temporalAccumulationCtx.save();
|
||||
this.temporalAccumulationCtx.globalCompositeOperation = "lighter";
|
||||
this.temporalAccumulationCtx.globalAlpha = weight;
|
||||
this.temporalAccumulationCtx.drawImage(this.compositeCanvas, 0, 0);
|
||||
this.temporalAccumulationCtx.restore();
|
||||
}
|
||||
this.compositeCtx.clearRect(0, 0, this.config.width, this.config.height);
|
||||
this.compositeCtx.drawImage(this.temporalAccumulationCanvas!, 0, 0);
|
||||
|
||||
return centerSnapshot ?? lastSnapshot;
|
||||
}
|
||||
|
||||
private compositeWithShadows(includeWebcam = true): void {
|
||||
if (!this.compositeCanvas || !this.compositeCtx || !this.app) return;
|
||||
|
||||
@@ -2202,8 +1991,6 @@ export class FrameRenderer {
|
||||
this.shadowCtx = null;
|
||||
this.compositeCanvas = null;
|
||||
this.compositeCtx = null;
|
||||
this.temporalAccumulationCanvas = null;
|
||||
this.temporalAccumulationCtx = null;
|
||||
this.backgroundCtx = null;
|
||||
this.closeBackgroundDecodedFrame();
|
||||
this.backgroundForwardFrameSource?.cancel();
|
||||
|
||||
@@ -46,9 +46,6 @@ interface GifExporterConfig {
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur?: number;
|
||||
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
|
||||
zoomTemporalMotionBlur?: number;
|
||||
zoomMotionBlurSampleCount?: number | null;
|
||||
zoomMotionBlurShutterFraction?: number | null;
|
||||
connectZooms?: boolean;
|
||||
zoomInDurationMs?: number;
|
||||
zoomInOverlapMs?: number;
|
||||
@@ -149,9 +146,6 @@ export function buildGifFrameRendererConfig(
|
||||
backgroundBlur: config.backgroundBlur,
|
||||
zoomMotionBlur: config.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: config.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: config.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: config.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: config.zoomMotionBlurShutterFraction,
|
||||
connectZooms: config.connectZooms,
|
||||
zoomInDurationMs: config.zoomInDurationMs,
|
||||
zoomInOverlapMs: config.zoomInOverlapMs,
|
||||
|
||||
@@ -202,28 +202,32 @@ function createRenderer() {
|
||||
});
|
||||
}
|
||||
|
||||
it("renders the background during a timeline gap without source layers", async () => {
|
||||
it("bypasses blur annotation compositing during gaps and clears stale composite frames", async () => {
|
||||
const renderer = createRenderer();
|
||||
const camera = { visible: true };
|
||||
const output = createMockCanvas();
|
||||
output.width = 1920;
|
||||
output.height = 1080;
|
||||
const annotations = vi.fn();
|
||||
const renderOutput = vi.fn(async () => {});
|
||||
const canvas = createMockCanvas();
|
||||
const render = vi.fn();
|
||||
const compose = vi.fn();
|
||||
const webcam = { visible: true };
|
||||
const captions = { visible: true };
|
||||
Object.assign(renderer, {
|
||||
app: {},
|
||||
app: { canvas, render },
|
||||
videoContainer: {},
|
||||
videoMaskGraphics: {},
|
||||
cameraContainer: camera,
|
||||
ensureExportCompositeCanvas: () => ({ canvas: output, context: output.context }),
|
||||
updateAnnotationLayer: annotations,
|
||||
renderOutput,
|
||||
webcamRootContainer: webcam,
|
||||
captionContainer: captions,
|
||||
hasActiveBlurAnnotations: () => true,
|
||||
composeBlurAnnotationFrame: compose,
|
||||
outputCanvasOverride: createMockCanvas(),
|
||||
});
|
||||
await renderer.renderFrame(null, 0, 0, 33333, 1500000);
|
||||
expect(camera.visible).toBe(false);
|
||||
expect(output.context.fillRect).not.toHaveBeenCalled();
|
||||
expect(annotations).not.toHaveBeenCalled();
|
||||
expect(renderOutput).toHaveBeenCalledWith(1500);
|
||||
expect(webcam.visible).toBe(false);
|
||||
expect(captions.visible).toBe(false);
|
||||
expect(compose).not.toHaveBeenCalled();
|
||||
expect(render).toHaveBeenCalledOnce();
|
||||
expect(renderer.getCanvas()).toBe(canvas);
|
||||
});
|
||||
|
||||
describe("ModernFrameRenderer Pixi lifecycle", () => {
|
||||
@@ -813,9 +817,7 @@ describe("ModernFrameRenderer webcam export fallback", () => {
|
||||
});
|
||||
|
||||
describe("ModernFrameRenderer frame sequencing", () => {
|
||||
it.each([
|
||||
0, 1,
|
||||
])("resumes visual layers after a background gap with temporal blur set to %s", async (temporalBlur) => {
|
||||
it("resumes visual layers after a background gap", async () => {
|
||||
const renderer = createRenderer();
|
||||
const sceneCanvas = createMockCanvas();
|
||||
const gapCanvas = createMockCanvas();
|
||||
@@ -834,7 +836,6 @@ describe("ModernFrameRenderer frame sequencing", () => {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
timelineEffects: true,
|
||||
zoomTemporalMotionBlur: temporalBlur,
|
||||
zoomMotionBlur: 0.5,
|
||||
},
|
||||
app: { canvas: sceneCanvas, render },
|
||||
|
||||
@@ -108,9 +108,6 @@ interface FrameRenderConfig {
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur?: number;
|
||||
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
|
||||
zoomTemporalMotionBlur?: number;
|
||||
zoomMotionBlurSampleCount?: number | null;
|
||||
zoomMotionBlurShutterFraction?: number | null;
|
||||
connectZooms?: boolean;
|
||||
zoomInDurationMs?: number;
|
||||
zoomInOverlapMs?: number;
|
||||
@@ -2867,7 +2864,9 @@ export class FrameRenderer {
|
||||
}
|
||||
if (this.webcamRootContainer) this.webcamRootContainer.visible = false;
|
||||
if (this.captionContainer) this.captionContainer.visible = false;
|
||||
await this.renderOutput(backgroundTimelineTimestamp / 1000);
|
||||
// Gap frames must bypass canvas annotation compositing as well as Pixi layers.
|
||||
this.outputCanvasOverride = null;
|
||||
this.app.render();
|
||||
return;
|
||||
}
|
||||
if (this.captionContainer) this.captionContainer.visible = true;
|
||||
|
||||
@@ -104,9 +104,6 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur?: number;
|
||||
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
|
||||
zoomTemporalMotionBlur?: number;
|
||||
zoomMotionBlurSampleCount?: number | null;
|
||||
zoomMotionBlurShutterFraction?: number | null;
|
||||
connectZooms?: boolean;
|
||||
zoomInDurationMs?: number;
|
||||
zoomInOverlapMs?: number;
|
||||
@@ -615,9 +612,6 @@ export class ModernVideoExporter {
|
||||
backgroundBlur: this.config.backgroundBlur,
|
||||
zoomMotionBlur: this.config.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: this.config.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
|
||||
connectZooms: this.config.connectZooms,
|
||||
zoomInDurationMs: this.config.zoomInDurationMs,
|
||||
zoomInOverlapMs: this.config.zoomInOverlapMs,
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildTemporalSampleOffsetsUs,
|
||||
buildTemporalSamplePlanUs,
|
||||
getTemporalMotionBlurConfig,
|
||||
} from "./temporalMotionBlur";
|
||||
|
||||
describe("temporalMotionBlur", () => {
|
||||
it("disables temporal blur for zero strength", () => {
|
||||
expect(getTemporalMotionBlurConfig(0)).toBeNull();
|
||||
expect(getTemporalMotionBlurConfig(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("uses an odd number of centered samples", () => {
|
||||
const config = getTemporalMotionBlurConfig(1);
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.sampleCount).toBe(5);
|
||||
expect(config?.sampleCount % 2).toBe(1);
|
||||
expect(config?.shutterFraction).toBeGreaterThan(0.24);
|
||||
});
|
||||
|
||||
it("caps the automatic sample budget to keep exports responsive", () => {
|
||||
const config = getTemporalMotionBlurConfig(2);
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.sampleCount).toBe(5);
|
||||
});
|
||||
|
||||
it("builds symmetric shutter offsets around the frame center", () => {
|
||||
const offsets = buildTemporalSampleOffsetsUs(33_333.333, {
|
||||
sampleCount: 5,
|
||||
shutterFraction: 0.9,
|
||||
});
|
||||
|
||||
expect(offsets).toHaveLength(5);
|
||||
expect(offsets[2]).toBeCloseTo(0, 6);
|
||||
expect(offsets[0]).toBeCloseTo(-offsets[4], 6);
|
||||
expect(offsets[1]).toBeCloseTo(-offsets[3], 6);
|
||||
});
|
||||
|
||||
it("accepts explicit shutter and odd sample overrides", () => {
|
||||
const config = getTemporalMotionBlurConfig(0.35, {
|
||||
sampleCount: 60,
|
||||
shutterFraction: 3,
|
||||
});
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.sampleCount).toBe(61);
|
||||
expect(config?.shutterFraction).toBeCloseTo(3, 6);
|
||||
});
|
||||
|
||||
it("allows experimental multi-frame shutter windows", () => {
|
||||
const offsets = buildTemporalSampleOffsetsUs(33_333.333, {
|
||||
sampleCount: 61,
|
||||
shutterFraction: 3,
|
||||
});
|
||||
|
||||
expect(offsets).toHaveLength(61);
|
||||
expect(offsets[30]).toBeCloseTo(0, 6);
|
||||
expect(offsets[0]).toBeCloseTo(-50_000, 0);
|
||||
expect(offsets[60]).toBeCloseTo(50_000, 0);
|
||||
});
|
||||
|
||||
it("builds normalized sample weights with a center bias", () => {
|
||||
const config = getTemporalMotionBlurConfig(1.2);
|
||||
expect(config).not.toBeNull();
|
||||
|
||||
const plan = buildTemporalSamplePlanUs(33_333.333, config!);
|
||||
const totalWeight = plan.reduce((sum, sample) => sum + sample.weight, 0);
|
||||
const centerSample = plan[Math.floor(plan.length / 2)];
|
||||
const edgeSample = plan[0];
|
||||
|
||||
expect(totalWeight).toBeCloseTo(1, 6);
|
||||
expect(centerSample?.weight ?? 0).toBeGreaterThan(edgeSample?.weight ?? 0);
|
||||
expect(plan.map((sample) => sample.offsetUs)).toContain(0);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
interface TemporalMotionBlurConfig {
|
||||
sampleCount: number;
|
||||
shutterFraction: number;
|
||||
weightCurvePower: number;
|
||||
}
|
||||
|
||||
interface TemporalMotionBlurOverrides {
|
||||
sampleCount?: number | null;
|
||||
shutterFraction?: number | null;
|
||||
}
|
||||
|
||||
interface TemporalMotionBlurSample {
|
||||
offsetUs: number;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
const MIN_BLUR_AMOUNT = 0.001;
|
||||
export const TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION = 0.18;
|
||||
export const TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION = 3;
|
||||
export const TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT = 3;
|
||||
export const TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT = 61;
|
||||
export const TEMPORAL_MOTION_BLUR_DEFAULT_SAMPLE_COUNT = 13;
|
||||
export const TEMPORAL_MOTION_BLUR_DEFAULT_SHUTTER_FRACTION = 0.94;
|
||||
const TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION = 0.24;
|
||||
const TEMPORAL_MOTION_BLUR_AUTO_MAX_SHUTTER_FRACTION = 0.62;
|
||||
const TEMPORAL_MOTION_BLUR_AUTO_MAX_SAMPLE_COUNT = 5;
|
||||
const TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR = 0.22;
|
||||
const MAX_BLUR_AMOUNT = 2;
|
||||
|
||||
function normalizeTemporalMotionBlurSampleCount(value: number | null | undefined): number | null {
|
||||
if (!Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roundedValue = Math.round(value ?? 0);
|
||||
const clampedValue = Math.min(
|
||||
TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT,
|
||||
Math.max(TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT, roundedValue),
|
||||
);
|
||||
|
||||
if (clampedValue % 2 === 1) {
|
||||
return clampedValue;
|
||||
}
|
||||
|
||||
return clampedValue >= TEMPORAL_MOTION_BLUR_MAX_SAMPLE_COUNT
|
||||
? clampedValue - 1
|
||||
: clampedValue + 1;
|
||||
}
|
||||
|
||||
export function getTemporalMotionBlurConfig(
|
||||
amount: number | null | undefined,
|
||||
overrides: TemporalMotionBlurOverrides = {},
|
||||
): TemporalMotionBlurConfig | null {
|
||||
const resolvedAmount = Number.isFinite(amount) ? Math.max(0, amount ?? 0) : 0;
|
||||
if (resolvedAmount < MIN_BLUR_AMOUNT) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedAmount = Math.min(1, resolvedAmount / MAX_BLUR_AMOUNT);
|
||||
const sampleStepCount = Math.round(
|
||||
normalizedAmount *
|
||||
((TEMPORAL_MOTION_BLUR_AUTO_MAX_SAMPLE_COUNT - TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT) /
|
||||
2),
|
||||
);
|
||||
const defaultSampleCount = TEMPORAL_MOTION_BLUR_MIN_SAMPLE_COUNT + sampleStepCount * 2;
|
||||
const sampleCount =
|
||||
normalizeTemporalMotionBlurSampleCount(overrides.sampleCount) ?? defaultSampleCount;
|
||||
const shutterFraction = Number.isFinite(overrides.shutterFraction)
|
||||
? Math.min(
|
||||
TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION,
|
||||
Math.max(
|
||||
TEMPORAL_MOTION_BLUR_MIN_SHUTTER_FRACTION,
|
||||
overrides.shutterFraction ?? TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION,
|
||||
),
|
||||
)
|
||||
: TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION +
|
||||
normalizedAmount *
|
||||
(TEMPORAL_MOTION_BLUR_AUTO_MAX_SHUTTER_FRACTION -
|
||||
TEMPORAL_MOTION_BLUR_AUTO_MIN_SHUTTER_FRACTION);
|
||||
|
||||
return {
|
||||
sampleCount,
|
||||
shutterFraction,
|
||||
weightCurvePower: 1.2 + normalizedAmount * 0.9,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildTemporalSampleOffsetsUs(
|
||||
frameDurationUs: number,
|
||||
config: TemporalMotionBlurConfig,
|
||||
): number[] {
|
||||
const safeFrameDurationUs = Math.max(1, frameDurationUs);
|
||||
const safeSampleCount = Math.max(1, Math.floor(config.sampleCount));
|
||||
if (safeSampleCount === 1) {
|
||||
return [0];
|
||||
}
|
||||
|
||||
const shutterWindowUs =
|
||||
safeFrameDurationUs *
|
||||
Math.max(0, Math.min(TEMPORAL_MOTION_BLUR_MAX_SHUTTER_FRACTION, config.shutterFraction));
|
||||
const startOffsetUs = -shutterWindowUs / 2;
|
||||
const stepUs = shutterWindowUs / (safeSampleCount - 1);
|
||||
|
||||
return Array.from({ length: safeSampleCount }, (_, index) => startOffsetUs + stepUs * index);
|
||||
}
|
||||
|
||||
export function buildTemporalSamplePlanUs(
|
||||
frameDurationUs: number,
|
||||
config: TemporalMotionBlurConfig,
|
||||
): TemporalMotionBlurSample[] {
|
||||
const offsetsUs = buildTemporalSampleOffsetsUs(frameDurationUs, config);
|
||||
if (offsetsUs.length === 1) {
|
||||
return [{ offsetUs: 0, weight: 1 }];
|
||||
}
|
||||
|
||||
const centerIndex = (offsetsUs.length - 1) / 2;
|
||||
const rawWeights = offsetsUs.map((_offsetUs, index) => {
|
||||
const normalizedDistance = Math.abs(index - centerIndex) / Math.max(1, centerIndex);
|
||||
const taperedWeight = Math.cos(normalizedDistance * (Math.PI / 2));
|
||||
return (
|
||||
TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR +
|
||||
(1 - TEMPORAL_MOTION_BLUR_WEIGHT_FLOOR) *
|
||||
Math.pow(Math.max(0, taperedWeight), config.weightCurvePower)
|
||||
);
|
||||
});
|
||||
const totalWeight = rawWeights.reduce((sum, weight) => sum + weight, 0) || 1;
|
||||
|
||||
return offsetsUs.map((offsetUs, index) => ({
|
||||
offsetUs,
|
||||
weight: rawWeights[index]! / totalWeight,
|
||||
}));
|
||||
}
|
||||
|
||||
export type { TemporalMotionBlurConfig, TemporalMotionBlurOverrides, TemporalMotionBlurSample };
|
||||
@@ -55,9 +55,6 @@ interface VideoExporterConfig extends ExportConfig {
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur?: number;
|
||||
zoomMotionBlurTuning?: ZoomMotionBlurTuning;
|
||||
zoomTemporalMotionBlur?: number;
|
||||
zoomMotionBlurSampleCount?: number | null;
|
||||
zoomMotionBlurShutterFraction?: number | null;
|
||||
connectZooms?: boolean;
|
||||
zoomInDurationMs?: number;
|
||||
zoomInOverlapMs?: number;
|
||||
@@ -228,9 +225,6 @@ export class VideoExporter {
|
||||
backgroundBlur: this.config.backgroundBlur,
|
||||
zoomMotionBlur: this.config.zoomMotionBlur,
|
||||
zoomMotionBlurTuning: this.config.zoomMotionBlurTuning,
|
||||
zoomTemporalMotionBlur: this.config.zoomTemporalMotionBlur,
|
||||
zoomMotionBlurSampleCount: this.config.zoomMotionBlurSampleCount,
|
||||
zoomMotionBlurShutterFraction: this.config.zoomMotionBlurShutterFraction,
|
||||
connectZooms: this.config.connectZooms,
|
||||
zoomInDurationMs: this.config.zoomInDurationMs,
|
||||
zoomInOverlapMs: this.config.zoomInOverlapMs,
|
||||
|
||||
Reference in New Issue
Block a user