feat(canvas): accelerate local previews and harden edge flows
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
|
||||
type DebouncedCallback<Args extends unknown[]> = ((...args: Args) => void) & {
|
||||
flush: () => void;
|
||||
cancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Debounced callback — ruft `callback` erst auf, wenn `delay` ms
|
||||
* ohne erneuten Aufruf vergangen sind. Perfekt für Auto-Save.
|
||||
@@ -7,9 +12,10 @@ import { useRef, useCallback, useEffect } from "react";
|
||||
export function useDebouncedCallback<Args extends unknown[]>(
|
||||
callback: (...args: Args) => void,
|
||||
delay: number,
|
||||
): (...args: Args) => void {
|
||||
): DebouncedCallback<Args> {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const callbackRef = useRef(callback);
|
||||
const argsRef = useRef<Args | null>(null);
|
||||
|
||||
// Callback-Ref aktuell halten ohne neu zu rendern
|
||||
useEffect(() => {
|
||||
@@ -23,15 +29,49 @@ export function useDebouncedCallback<Args extends unknown[]>(
|
||||
};
|
||||
}, []);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
|
||||
argsRef.current = null;
|
||||
}, []);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (!timeoutRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = null;
|
||||
|
||||
const args = argsRef.current;
|
||||
argsRef.current = null;
|
||||
if (args) {
|
||||
callbackRef.current(...args);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const debouncedFn = useCallback(
|
||||
(...args: Args) => {
|
||||
argsRef.current = args;
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callbackRef.current(...args);
|
||||
timeoutRef.current = null;
|
||||
const nextArgs = argsRef.current;
|
||||
argsRef.current = null;
|
||||
if (nextArgs) {
|
||||
callbackRef.current(...nextArgs);
|
||||
}
|
||||
}, delay);
|
||||
},
|
||||
[delay],
|
||||
);
|
||||
|
||||
return debouncedFn;
|
||||
const debouncedCallback = debouncedFn as DebouncedCallback<Args>;
|
||||
debouncedCallback.flush = flush;
|
||||
debouncedCallback.cancel = cancel;
|
||||
|
||||
return debouncedCallback;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { hashPipeline, type PipelineStep } from "@/lib/image-pipeline/contracts";
|
||||
import { emptyHistogram, type HistogramData } from "@/lib/image-pipeline/histogram";
|
||||
import {
|
||||
getLastBackendDiagnostics,
|
||||
isPipelineAbortError,
|
||||
renderPreviewWithWorkerFallback,
|
||||
type PreviewRenderResult,
|
||||
@@ -19,50 +18,11 @@ type UsePipelinePreviewOptions = {
|
||||
previewScale?: number;
|
||||
maxPreviewWidth?: number;
|
||||
maxDevicePixelRatio?: number;
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
const PREVIEW_RENDER_DEBOUNCE_MS = 48;
|
||||
|
||||
type PreviewLatencyTrace = {
|
||||
sequence: number;
|
||||
changedAtMs: number;
|
||||
nodeType: string;
|
||||
origin: string;
|
||||
};
|
||||
|
||||
function readPreviewLatencyTrace(): PreviewLatencyTrace | null {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const debugGlobals = globalThis as typeof globalThis & {
|
||||
__LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean;
|
||||
__LEMONSPACE_LAST_PREVIEW_TRACE__?: PreviewLatencyTrace;
|
||||
};
|
||||
|
||||
if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return debugGlobals.__LEMONSPACE_LAST_PREVIEW_TRACE__ ?? null;
|
||||
}
|
||||
|
||||
function logPreviewLatency(event: string, payload: Record<string, unknown>): void {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
return;
|
||||
}
|
||||
|
||||
const debugGlobals = globalThis as typeof globalThis & {
|
||||
__LEMONSPACE_DEBUG_PREVIEW_LATENCY__?: boolean;
|
||||
};
|
||||
|
||||
if (debugGlobals.__LEMONSPACE_DEBUG_PREVIEW_LATENCY__ !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.info("[Preview latency]", event, payload);
|
||||
}
|
||||
|
||||
function computePreviewWidth(
|
||||
nodeWidth: number,
|
||||
previewScale: number,
|
||||
@@ -121,6 +81,14 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
return Math.max(1, options.maxDevicePixelRatio);
|
||||
}, [options.maxDevicePixelRatio]);
|
||||
|
||||
const debounceMs = useMemo(() => {
|
||||
if (typeof options.debounceMs !== "number" || !Number.isFinite(options.debounceMs)) {
|
||||
return PREVIEW_RENDER_DEBOUNCE_MS;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.round(options.debounceMs));
|
||||
}, [options.debounceMs]);
|
||||
|
||||
const previewWidth = useMemo(
|
||||
() => computePreviewWidth(options.nodeWidth, previewScale, maxPreviewWidth, maxDevicePixelRatio),
|
||||
[maxDevicePixelRatio, maxPreviewWidth, options.nodeWidth, previewScale],
|
||||
@@ -161,23 +129,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
const currentRun = runIdRef.current + 1;
|
||||
runIdRef.current = currentRun;
|
||||
const abortController = new AbortController();
|
||||
const effectStartedAtMs = performance.now();
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
const requestStartedAtMs = performance.now();
|
||||
const trace = readPreviewLatencyTrace();
|
||||
|
||||
logPreviewLatency("request-start", {
|
||||
currentRun,
|
||||
pipelineHash,
|
||||
previewWidth,
|
||||
includeHistogram: options.includeHistogram !== false,
|
||||
debounceWaitMs: requestStartedAtMs - effectStartedAtMs,
|
||||
sinceChangeMs: trace ? requestStartedAtMs - trace.changedAtMs : null,
|
||||
sourceNodeType: trace?.nodeType ?? null,
|
||||
sourceOrigin: trace?.origin ?? null,
|
||||
});
|
||||
|
||||
setIsRendering(true);
|
||||
setError(null);
|
||||
void renderPreviewWithWorkerFallback({
|
||||
@@ -200,20 +153,8 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
return;
|
||||
}
|
||||
context.putImageData(result.imageData, 0, 0);
|
||||
const paintedAtMs = performance.now();
|
||||
setHistogram(result.histogram);
|
||||
setPreviewAspectRatio(result.width / result.height);
|
||||
|
||||
logPreviewLatency("paint-end", {
|
||||
currentRun,
|
||||
pipelineHash,
|
||||
previewWidth,
|
||||
imageWidth: result.width,
|
||||
imageHeight: result.height,
|
||||
requestDurationMs: paintedAtMs - requestStartedAtMs,
|
||||
sinceChangeMs: trace ? paintedAtMs - trace.changedAtMs : null,
|
||||
diagnostics: getLastBackendDiagnostics(),
|
||||
});
|
||||
})
|
||||
.catch((renderError: unknown) => {
|
||||
if (runIdRef.current !== currentRun) return;
|
||||
@@ -231,7 +172,6 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
pipelineHash,
|
||||
previewWidth,
|
||||
includeHistogram: options.includeHistogram,
|
||||
diagnostics: getLastBackendDiagnostics(),
|
||||
error: renderError,
|
||||
});
|
||||
}
|
||||
@@ -242,13 +182,13 @@ export function usePipelinePreview(options: UsePipelinePreviewOptions): {
|
||||
if (runIdRef.current !== currentRun) return;
|
||||
setIsRendering(false);
|
||||
});
|
||||
}, PREVIEW_RENDER_DEBOUNCE_MS);
|
||||
}, debounceMs);
|
||||
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
abortController.abort();
|
||||
};
|
||||
}, [options.includeHistogram, pipelineHash, previewWidth]);
|
||||
}, [debounceMs, options.includeHistogram, pipelineHash, previewWidth]);
|
||||
|
||||
return {
|
||||
canvasRef,
|
||||
|
||||
Reference in New Issue
Block a user