mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
refactor not done
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,279 @@
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { MediaFile } from "@/types/media";
|
||||
import type { BlurIntensity } from "@/types/project";
|
||||
import { videoCache } from "./video-cache";
|
||||
import { drawCssBackground } from "./canvas-gradients";
|
||||
|
||||
export interface RenderContext {
|
||||
ctx: CanvasRenderingContext2D;
|
||||
time: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
tracks: TimelineTrack[];
|
||||
mediaFiles: MediaFile[];
|
||||
backgroundColor?: string;
|
||||
backgroundType?: "color" | "blur";
|
||||
blurIntensity?: BlurIntensity;
|
||||
projectCanvasSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
const imageElementCache = new Map<string, HTMLImageElement>();
|
||||
|
||||
async function getImageElement(
|
||||
mediaItem: MediaFile
|
||||
): Promise<HTMLImageElement> {
|
||||
const cacheKey = mediaItem.id;
|
||||
const cached = imageElementCache.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error("Image load failed"));
|
||||
img.src = mediaItem.url || URL.createObjectURL(mediaItem.file);
|
||||
});
|
||||
imageElementCache.set(cacheKey, img);
|
||||
return img;
|
||||
}
|
||||
|
||||
export async function renderTimelineFrame({
|
||||
ctx,
|
||||
time,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
backgroundColor,
|
||||
backgroundType,
|
||||
blurIntensity,
|
||||
projectCanvasSize,
|
||||
}: RenderContext): Promise<void> {
|
||||
// Background
|
||||
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
|
||||
if (
|
||||
backgroundColor &&
|
||||
backgroundColor !== "transparent" &&
|
||||
!backgroundColor.includes("gradient")
|
||||
) {
|
||||
ctx.fillStyle = backgroundColor;
|
||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
||||
}
|
||||
|
||||
// If backgroundColor is a CSS gradient string, draw it
|
||||
if (backgroundColor && backgroundColor.includes("gradient")) {
|
||||
drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor);
|
||||
}
|
||||
|
||||
const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
|
||||
const scaleY = projectCanvasSize
|
||||
? canvasHeight / projectCanvasSize.height
|
||||
: 1;
|
||||
const idToMedia = new Map(mediaFiles.map((m) => [m.id, m] as const));
|
||||
const active: Array<{
|
||||
track: TimelineTrack;
|
||||
element: TimelineTrack["elements"][number];
|
||||
mediaItem: MediaFile | null;
|
||||
}> = [];
|
||||
|
||||
for (let t = tracks.length - 1; t >= 0; t -= 1) {
|
||||
const track = tracks[t];
|
||||
for (const element of track.elements) {
|
||||
if (element.hidden) continue;
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
if (time >= elementStart && time < elementEnd) {
|
||||
let mediaItem: MediaFile | null = null;
|
||||
if (element.type === "media") {
|
||||
mediaItem =
|
||||
element.mediaId === "test"
|
||||
? null
|
||||
: idToMedia.get(element.mediaId) || null;
|
||||
}
|
||||
active.push({ track, element, mediaItem });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If background is set to blur, draw the active media as a blurred cover layer first
|
||||
if (backgroundType === "blur") {
|
||||
const blurPx = Math.max(0, blurIntensity ?? 8);
|
||||
// Find a suitable media element (video/image) among active elements
|
||||
const bgCandidate = active.find(({ element, mediaItem }) => {
|
||||
return (
|
||||
element.type === "media" &&
|
||||
mediaItem !== null &&
|
||||
(mediaItem.type === "video" || mediaItem.type === "image")
|
||||
);
|
||||
});
|
||||
if (bgCandidate && bgCandidate.mediaItem) {
|
||||
const { element, mediaItem } = bgCandidate;
|
||||
try {
|
||||
if (mediaItem.type === "video") {
|
||||
const localTime = time - element.startTime + element.trimStart;
|
||||
const frame = await videoCache.getFrameAt(
|
||||
mediaItem.id,
|
||||
mediaItem.file,
|
||||
Math.max(0, localTime)
|
||||
);
|
||||
if (frame) {
|
||||
const mediaW = Math.max(1, mediaItem.width || canvasWidth);
|
||||
const mediaH = Math.max(1, mediaItem.height || canvasHeight);
|
||||
const coverScale = Math.max(
|
||||
canvasWidth / mediaW,
|
||||
canvasHeight / mediaH
|
||||
);
|
||||
const drawW = mediaW * coverScale;
|
||||
const drawH = mediaH * coverScale;
|
||||
const drawX = (canvasWidth - drawW) / 2;
|
||||
const drawY = (canvasHeight - drawH) / 2;
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blurPx}px)`;
|
||||
ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
|
||||
ctx.restore();
|
||||
}
|
||||
} else if (mediaItem.type === "image") {
|
||||
const img = await getImageElement(mediaItem);
|
||||
const mediaW = Math.max(
|
||||
1,
|
||||
mediaItem.width || img.naturalWidth || canvasWidth
|
||||
);
|
||||
const mediaH = Math.max(
|
||||
1,
|
||||
mediaItem.height || img.naturalHeight || canvasHeight
|
||||
);
|
||||
const coverScale = Math.max(
|
||||
canvasWidth / mediaW,
|
||||
canvasHeight / mediaH
|
||||
);
|
||||
const drawW = mediaW * coverScale;
|
||||
const drawH = mediaH * coverScale;
|
||||
const drawX = (canvasWidth - drawW) / 2;
|
||||
const drawY = (canvasHeight - drawH) / 2;
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${blurPx}px)`;
|
||||
ctx.drawImage(img, drawX, drawY, drawW, drawH);
|
||||
ctx.restore();
|
||||
}
|
||||
} catch {
|
||||
// Ignore background blur failures; foreground will still render
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const { element, mediaItem } of active) {
|
||||
if (element.type === "media" && mediaItem) {
|
||||
if (mediaItem.type === "video") {
|
||||
try {
|
||||
const localTime = time - element.startTime + element.trimStart;
|
||||
|
||||
const frame = await videoCache.getFrameAt(
|
||||
mediaItem.id,
|
||||
mediaItem.file,
|
||||
localTime
|
||||
);
|
||||
if (!frame) continue;
|
||||
|
||||
const mediaW = Math.max(1, mediaItem.width || canvasWidth);
|
||||
const mediaH = Math.max(1, mediaItem.height || canvasHeight);
|
||||
const containScale = Math.min(
|
||||
canvasWidth / mediaW,
|
||||
canvasHeight / mediaH
|
||||
);
|
||||
const drawW = mediaW * containScale;
|
||||
const drawH = mediaH * containScale;
|
||||
const drawX = (canvasWidth - drawW) / 2;
|
||||
const drawY = (canvasHeight - drawH) / 2;
|
||||
|
||||
ctx.drawImage(frame.canvas, drawX, drawY, drawW, drawH);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Failed to render video frame for ${mediaItem.name}:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
if (mediaItem.type === "image") {
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error("Image load failed"));
|
||||
img.src = mediaItem.url || URL.createObjectURL(mediaItem.file);
|
||||
});
|
||||
const mediaW = Math.max(
|
||||
1,
|
||||
mediaItem.width || img.naturalWidth || canvasWidth
|
||||
);
|
||||
const mediaH = Math.max(
|
||||
1,
|
||||
mediaItem.height || img.naturalHeight || canvasHeight
|
||||
);
|
||||
const containScale = Math.min(
|
||||
canvasWidth / mediaW,
|
||||
canvasHeight / mediaH
|
||||
);
|
||||
const drawW = mediaW * containScale;
|
||||
const drawH = mediaH * containScale;
|
||||
const drawX = (canvasWidth - drawW) / 2;
|
||||
const drawY = (canvasHeight - drawH) / 2;
|
||||
ctx.drawImage(img, drawX, drawY, drawW, drawH);
|
||||
}
|
||||
}
|
||||
if (element.type === "text") {
|
||||
const text = element;
|
||||
const posX = canvasWidth / 2 + text.x * scaleX;
|
||||
const posY = canvasHeight / 2 + text.y * scaleY;
|
||||
ctx.save();
|
||||
ctx.translate(posX, posY);
|
||||
ctx.rotate((text.rotation * Math.PI) / 180);
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, text.opacity));
|
||||
const px = text.fontSize * scaleX;
|
||||
const weight = text.fontWeight === "bold" ? "bold " : "";
|
||||
const style = text.fontStyle === "italic" ? "italic " : "";
|
||||
ctx.font = `${style}${weight}${px}px ${text.fontFamily}`;
|
||||
ctx.fillStyle = text.color;
|
||||
ctx.textAlign = text.textAlign as CanvasTextAlign;
|
||||
ctx.textBaseline = "middle";
|
||||
const metrics = ctx.measureText(text.content);
|
||||
const hasBoxMetrics =
|
||||
"actualBoundingBoxAscent" in metrics &&
|
||||
"actualBoundingBoxDescent" in metrics;
|
||||
const ascent = hasBoxMetrics
|
||||
? (
|
||||
metrics as TextMetrics & {
|
||||
actualBoundingBoxAscent: number;
|
||||
actualBoundingBoxDescent: number;
|
||||
}
|
||||
).actualBoundingBoxAscent
|
||||
: px * 0.8;
|
||||
const descent = hasBoxMetrics
|
||||
? (
|
||||
metrics as TextMetrics & {
|
||||
actualBoundingBoxAscent: number;
|
||||
actualBoundingBoxDescent: number;
|
||||
}
|
||||
).actualBoundingBoxDescent
|
||||
: px * 0.2;
|
||||
const textW = metrics.width;
|
||||
const textH = ascent + descent;
|
||||
const padX = 8 * scaleX;
|
||||
const padY = 4 * scaleX;
|
||||
if (text.backgroundColor) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = text.backgroundColor;
|
||||
let bgLeft = -textW / 2;
|
||||
if (ctx.textAlign === "left") bgLeft = 0;
|
||||
if (ctx.textAlign === "right") bgLeft = -textW;
|
||||
ctx.fillRect(
|
||||
bgLeft - padX,
|
||||
-textH / 2 - padY,
|
||||
textW + padX * 2,
|
||||
textH + padY * 2
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.fillText(text.content, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import { useRef, useCallback } from "react";
|
||||
import {
|
||||
TimelineTrack,
|
||||
TimelineElement,
|
||||
MediaElement,
|
||||
TextElement,
|
||||
} from "@/types/timeline";
|
||||
import { MediaFile } from "@/types/media";
|
||||
import { TProject } from "@/types/project";
|
||||
|
||||
interface CachedFrame {
|
||||
imageData: ImageData;
|
||||
timelineHash: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface FrameCacheOptions {
|
||||
maxCacheSize?: number; // Maximum number of cached frames
|
||||
cacheResolution?: number; // Frames per second to cache at
|
||||
}
|
||||
|
||||
// Shared singleton cache across hook instances (HMR-safe)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const __frameCacheGlobal: any = globalThis as any;
|
||||
const __sharedFrameCache: Map<number, CachedFrame> =
|
||||
__frameCacheGlobal.__sharedFrameCache ?? new Map<number, CachedFrame>();
|
||||
__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache;
|
||||
|
||||
export function useFrameCache(options: FrameCacheOptions = {}) {
|
||||
const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps
|
||||
|
||||
const frameCacheRef = useRef(__sharedFrameCache);
|
||||
|
||||
// Generate a hash of the timeline state that affects rendering
|
||||
const getTimelineHash = useCallback(
|
||||
(
|
||||
time: number,
|
||||
tracks: TimelineTrack[],
|
||||
mediaFiles: MediaFile[],
|
||||
activeProject: TProject | null,
|
||||
sceneId?: string
|
||||
): string => {
|
||||
// Get elements that are active at this time
|
||||
const activeElements: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
startTime: number;
|
||||
duration: number;
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
mediaId?: string;
|
||||
// Text-specific properties
|
||||
content?: string;
|
||||
fontSize?: number;
|
||||
fontFamily?: string;
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
rotation?: number;
|
||||
opacity?: number;
|
||||
}> = [];
|
||||
|
||||
for (const track of tracks) {
|
||||
if (track.muted) continue;
|
||||
|
||||
for (const element of track.elements) {
|
||||
// Check if element has hidden property (some elements might not have it)
|
||||
const isHidden = "hidden" in element ? element.hidden : false;
|
||||
if (isHidden) continue;
|
||||
|
||||
const elementStart = element.startTime;
|
||||
const elementEnd =
|
||||
element.startTime +
|
||||
(element.duration - element.trimStart - element.trimEnd);
|
||||
|
||||
if (time >= elementStart && time < elementEnd) {
|
||||
if (element.type === "media") {
|
||||
const mediaElement = element as MediaElement;
|
||||
activeElements.push({
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
mediaId: mediaElement.mediaId,
|
||||
});
|
||||
} else if (element.type === "text") {
|
||||
const textElement = element as TextElement;
|
||||
activeElements.push({
|
||||
id: element.id,
|
||||
type: element.type,
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
trimStart: element.trimStart,
|
||||
trimEnd: element.trimEnd,
|
||||
content: textElement.content,
|
||||
fontSize: textElement.fontSize,
|
||||
fontFamily: textElement.fontFamily,
|
||||
color: textElement.color,
|
||||
backgroundColor: textElement.backgroundColor,
|
||||
x: textElement.x,
|
||||
y: textElement.y,
|
||||
rotation: textElement.rotation,
|
||||
opacity: textElement.opacity,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include project settings that affect rendering
|
||||
const projectState = {
|
||||
backgroundColor: activeProject?.backgroundColor,
|
||||
backgroundType: activeProject?.backgroundType,
|
||||
blurIntensity: activeProject?.blurIntensity,
|
||||
canvasSize: activeProject?.canvasSize,
|
||||
};
|
||||
|
||||
const hash = {
|
||||
activeElements,
|
||||
projectState,
|
||||
sceneId,
|
||||
time: Math.floor(time * cacheResolution) / cacheResolution,
|
||||
};
|
||||
return JSON.stringify(hash);
|
||||
},
|
||||
[cacheResolution]
|
||||
);
|
||||
|
||||
// Check if a frame is cached and valid
|
||||
const isFrameCached = useCallback(
|
||||
(
|
||||
time: number,
|
||||
tracks: TimelineTrack[],
|
||||
mediaFiles: MediaFile[],
|
||||
activeProject: TProject | null,
|
||||
sceneId?: string
|
||||
): boolean => {
|
||||
const frameKey = Math.floor(time * cacheResolution);
|
||||
const cached = frameCacheRef.current.get(frameKey);
|
||||
|
||||
if (!cached) return false;
|
||||
|
||||
const currentHash = getTimelineHash(
|
||||
time,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
sceneId
|
||||
);
|
||||
return cached.timelineHash === currentHash;
|
||||
},
|
||||
[getTimelineHash, cacheResolution]
|
||||
);
|
||||
|
||||
// Get cached frame if available and valid
|
||||
const getCachedFrame = useCallback(
|
||||
(
|
||||
time: number,
|
||||
tracks: TimelineTrack[],
|
||||
mediaFiles: MediaFile[],
|
||||
activeProject: TProject | null,
|
||||
sceneId?: string
|
||||
): ImageData | null => {
|
||||
const frameKey = Math.floor(time * cacheResolution);
|
||||
const cached = frameCacheRef.current.get(frameKey);
|
||||
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentHash = getTimelineHash(
|
||||
time,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
sceneId
|
||||
);
|
||||
console.log(cached.timelineHash === currentHash);
|
||||
if (cached.timelineHash !== currentHash) {
|
||||
// Cache is stale, remove it
|
||||
console.log(
|
||||
"Cache miss - hash mismatch:",
|
||||
JSON.stringify({
|
||||
cachedHash: cached.timelineHash.slice(0, 100),
|
||||
currentHash: currentHash.slice(0, 100),
|
||||
})
|
||||
);
|
||||
frameCacheRef.current.delete(frameKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.imageData;
|
||||
},
|
||||
[getTimelineHash, cacheResolution]
|
||||
);
|
||||
|
||||
// Cache a rendered frame
|
||||
const cacheFrame = useCallback(
|
||||
(
|
||||
time: number,
|
||||
imageData: ImageData,
|
||||
tracks: TimelineTrack[],
|
||||
mediaFiles: MediaFile[],
|
||||
activeProject: TProject | null,
|
||||
sceneId?: string
|
||||
): void => {
|
||||
const frameKey = Math.floor(time * cacheResolution);
|
||||
const timelineHash = getTimelineHash(
|
||||
time,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
sceneId
|
||||
);
|
||||
|
||||
// Enforce cache size limit (LRU eviction)
|
||||
if (frameCacheRef.current.size >= maxCacheSize) {
|
||||
// Remove oldest entries
|
||||
const entries = Array.from(frameCacheRef.current.entries());
|
||||
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
|
||||
|
||||
// Remove oldest 20% of entries
|
||||
const toRemove = Math.floor(entries.length * 0.2);
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
frameCacheRef.current.delete(entries[i][0]);
|
||||
}
|
||||
}
|
||||
|
||||
frameCacheRef.current.set(frameKey, {
|
||||
imageData,
|
||||
timelineHash,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
},
|
||||
[getTimelineHash, cacheResolution, maxCacheSize]
|
||||
);
|
||||
|
||||
// Clear cache when timeline changes significantly
|
||||
const invalidateCache = useCallback(() => {
|
||||
frameCacheRef.current.clear();
|
||||
}, []);
|
||||
|
||||
// Get render status for timeline indicator
|
||||
const getRenderStatus = useCallback(
|
||||
(
|
||||
time: number,
|
||||
tracks: TimelineTrack[],
|
||||
mediaFiles: MediaFile[],
|
||||
activeProject: TProject | null,
|
||||
sceneId?: string
|
||||
): "cached" | "not-cached" => {
|
||||
return isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)
|
||||
? "cached"
|
||||
: "not-cached";
|
||||
},
|
||||
[isFrameCached]
|
||||
);
|
||||
|
||||
// Pre-render frames around current time
|
||||
const preRenderNearbyFrames = useCallback(
|
||||
async (
|
||||
currentTime: number,
|
||||
tracks: TimelineTrack[],
|
||||
mediaFiles: MediaFile[],
|
||||
activeProject: TProject | null,
|
||||
renderFunction: (time: number) => Promise<ImageData>,
|
||||
sceneId?: string,
|
||||
range: number = 3 // seconds
|
||||
) => {
|
||||
const framesToPreRender: number[] = [];
|
||||
|
||||
// Calculate frames to pre-render (around current time)
|
||||
for (
|
||||
let offset = -range;
|
||||
offset <= range;
|
||||
offset += 1 / cacheResolution
|
||||
) {
|
||||
const time = currentTime + offset;
|
||||
if (time < 0) continue;
|
||||
|
||||
if (!isFrameCached(time, tracks, mediaFiles, activeProject, sceneId)) {
|
||||
framesToPreRender.push(time);
|
||||
}
|
||||
}
|
||||
|
||||
// Expand to full 1-second buckets to avoid fragmented tiny cache regions
|
||||
const secondsToPreRender = new Set<number>();
|
||||
for (const t of framesToPreRender) {
|
||||
secondsToPreRender.add(Math.floor(t));
|
||||
}
|
||||
|
||||
const expandedTimes: number[] = [];
|
||||
for (const s of secondsToPreRender) {
|
||||
for (let k = 0; k < cacheResolution; k++) {
|
||||
const t = s + k / cacheResolution;
|
||||
if (t < 0) continue;
|
||||
if (!isFrameCached(t, tracks, mediaFiles, activeProject, sceneId)) {
|
||||
expandedTimes.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort forward-first near currentTime to improve perceived responsiveness
|
||||
expandedTimes.sort((a, b) => {
|
||||
const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6;
|
||||
const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6;
|
||||
return da - db;
|
||||
});
|
||||
|
||||
// Cap total scheduled renders to avoid jank (e.g., up to 90 frames)
|
||||
const CAP = Math.max(30, Math.min(90, cacheResolution * 3));
|
||||
const toSchedule = expandedTimes.slice(0, CAP);
|
||||
|
||||
// Pre-render during idle time
|
||||
for (const time of toSchedule) {
|
||||
requestIdleCallback(async () => {
|
||||
try {
|
||||
const imageData = await renderFunction(time);
|
||||
cacheFrame(
|
||||
time,
|
||||
imageData,
|
||||
tracks,
|
||||
mediaFiles,
|
||||
activeProject,
|
||||
sceneId
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn(`Pre-render failed for time ${time}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
[isFrameCached, cacheFrame, cacheResolution]
|
||||
);
|
||||
|
||||
return {
|
||||
isFrameCached,
|
||||
getCachedFrame,
|
||||
cacheFrame,
|
||||
invalidateCache,
|
||||
getRenderStatus,
|
||||
preRenderNearbyFrames,
|
||||
cacheSize: frameCacheRef.current.size,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user