feat: full studio build -- light theme, canvas thumbnails, i18n (fa/en)
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable;
|
||||
}
|
||||
|
||||
export function useCanvasKeyboard() {
|
||||
const selectedLayerId = useStudioStore((state) => state.selectedLayerId);
|
||||
const deleteLayer = useStudioStore((state) => state.deleteLayer);
|
||||
const copyLayer = useStudioStore((state) => state.copyLayer);
|
||||
const pasteLayer = useStudioStore((state) => state.pasteLayer);
|
||||
const undo = useStudioStore((state) => state.undo);
|
||||
const redo = useStudioStore((state) => state.redo);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
|
||||
if (event.key === "Delete" || event.key === "Backspace") {
|
||||
if (!selectedLayerId) return;
|
||||
event.preventDefault();
|
||||
deleteLayer(selectedLayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const mod = event.ctrlKey || event.metaKey;
|
||||
if (!mod) return;
|
||||
|
||||
if (event.key === "c" || event.key === "C") {
|
||||
if (!selectedLayerId) return;
|
||||
event.preventDefault();
|
||||
copyLayer(selectedLayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "v" || event.key === "V") {
|
||||
event.preventDefault();
|
||||
pasteLayer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "z" || event.key === "Z") {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
redo();
|
||||
} else {
|
||||
undo();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "y" || event.key === "Y") {
|
||||
event.preventDefault();
|
||||
redo();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [
|
||||
selectedLayerId,
|
||||
deleteLayer,
|
||||
copyLayer,
|
||||
pasteLayer,
|
||||
undo,
|
||||
redo,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect, useRef } from "react";
|
||||
|
||||
import { playSceneTransition } from "@/lib/scene-transitions";
|
||||
import {
|
||||
getProjectDuration,
|
||||
getSceneAtTime,
|
||||
} from "@/lib/studio-timeline";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
import type { SceneTransition } from "@/lib/studio-types";
|
||||
|
||||
const PLAYBACK_TICK_MS = 100;
|
||||
const PLAYBACK_STEP_SECONDS = 0.1;
|
||||
|
||||
function getOutgoingTransition(
|
||||
scenes: { id: string; transitionType?: SceneTransition }[],
|
||||
fromSceneId: string
|
||||
): SceneTransition {
|
||||
const scene = scenes.find((item) => item.id === fromSceneId);
|
||||
return scene?.transitionType ?? "none";
|
||||
}
|
||||
|
||||
export function useCanvasPreviewPlayback(
|
||||
canvasWrapperRef: RefObject<HTMLDivElement | null>
|
||||
) {
|
||||
const isPlaying = useStudioStore((state) => state.isPlaying);
|
||||
const transitioningRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlaying) return;
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void (async () => {
|
||||
if (transitioningRef.current) return;
|
||||
|
||||
const state = useStudioStore.getState();
|
||||
const max = getProjectDuration(state.scenes);
|
||||
const next = state.currentTime + PLAYBACK_STEP_SECONDS;
|
||||
|
||||
if (next >= max) {
|
||||
const lastScene = state.scenes[state.scenes.length - 1];
|
||||
useStudioStore.setState({
|
||||
currentTime: max,
|
||||
isPlaying: false,
|
||||
activeSceneId: lastScene?.id ?? state.activeSceneId,
|
||||
selectedLayerId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextScene = getSceneAtTime(state.scenes, next);
|
||||
const sceneChanged =
|
||||
nextScene !== undefined && nextScene.id !== state.activeSceneId;
|
||||
|
||||
if (sceneChanged) {
|
||||
const transition = getOutgoingTransition(
|
||||
state.scenes,
|
||||
state.activeSceneId
|
||||
);
|
||||
const wrapper = canvasWrapperRef.current;
|
||||
|
||||
if (wrapper && transition !== "none") {
|
||||
transitioningRef.current = true;
|
||||
try {
|
||||
await playSceneTransition(wrapper, transition, () => {
|
||||
useStudioStore.setState({
|
||||
currentTime: next,
|
||||
activeSceneId: nextScene.id,
|
||||
selectedLayerId: null,
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
transitioningRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
useStudioStore.setState({
|
||||
currentTime: next,
|
||||
activeSceneId: nextScene.id,
|
||||
selectedLayerId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
useStudioStore.setState({ currentTime: next });
|
||||
})();
|
||||
}, PLAYBACK_TICK_MS);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [isPlaying, canvasWrapperRef]);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface ContainerSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useContainerSize<T extends HTMLElement = HTMLDivElement>() {
|
||||
const ref = useRef<T>(null);
|
||||
const [size, setSize] = useState<ContainerSize>({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const updateSize = () => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
setSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
|
||||
updateSize();
|
||||
const observer = new ResizeObserver(updateSize);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return { ref, width: size.width, height: size.height };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function useDebouncedValue<T>(value: T, delay = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import { fetchProject, patchProjectSceneData } from "@/lib/project-api";
|
||||
import { isDevProjectId } from "@/lib/project-ids";
|
||||
import { isImageSceneDataEmpty } from "@/lib/image-scene-data";
|
||||
import { useImageEditorStore } from "@/lib/image-editor-store";
|
||||
import {
|
||||
PROJECT_SAVE_DEBOUNCE_MS,
|
||||
PROJECT_SAVED_DISPLAY_MS,
|
||||
type ProjectSaveStatus,
|
||||
} from "@/lib/project-save-status";
|
||||
|
||||
export interface UseImageProjectPersistenceResult {
|
||||
projectName: string;
|
||||
setProjectName: (name: string) => void;
|
||||
saveStatus: ProjectSaveStatus;
|
||||
retrySave: () => void;
|
||||
}
|
||||
|
||||
export function useImageProjectPersistence(
|
||||
projectId: string | undefined
|
||||
): UseImageProjectPersistenceResult {
|
||||
const layers = useImageEditorStore((state) => state.layers);
|
||||
const canvasWidth = useImageEditorStore((state) => state.canvasWidth);
|
||||
const canvasHeight = useImageEditorStore((state) => state.canvasHeight);
|
||||
const adjustments = useImageEditorStore((state) => state.adjustments);
|
||||
const activeFilterPreset = useImageEditorStore(
|
||||
(state) => state.activeFilterPreset
|
||||
);
|
||||
const hydrateFromSceneData = useImageEditorStore(
|
||||
(state) => state.hydrateFromSceneData
|
||||
);
|
||||
const getSceneDataForSave = useImageEditorStore(
|
||||
(state) => state.getSceneDataForSave
|
||||
);
|
||||
|
||||
const [projectName, setProjectName] = useState("Untitled image");
|
||||
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("idle");
|
||||
|
||||
const skipSaveRef = useRef(true);
|
||||
const lastSavedPayloadRef = useRef<string | null>(null);
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const persistPayload = useMemo(
|
||||
() => JSON.stringify(getSceneDataForSave()),
|
||||
[layers, canvasWidth, canvasHeight, adjustments, activeFilterPreset]
|
||||
);
|
||||
|
||||
const debouncedPayload = useDebouncedValue(
|
||||
persistPayload,
|
||||
PROJECT_SAVE_DEBOUNCE_MS
|
||||
);
|
||||
|
||||
const clearSavedTimer = useCallback(() => {
|
||||
if (savedTimerRef.current) {
|
||||
clearTimeout(savedTimerRef.current);
|
||||
savedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const performSave = useCallback(
|
||||
async (payloadJson: string) => {
|
||||
if (!projectId || isDevProjectId(projectId)) return;
|
||||
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||
await patchProjectSceneData(projectId, scene_data);
|
||||
lastSavedPayloadRef.current = payloadJson;
|
||||
setSaveStatus("saved");
|
||||
clearSavedTimer();
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus((current) => (current === "saved" ? "idle" : current));
|
||||
}, PROJECT_SAVED_DISPLAY_MS);
|
||||
} catch {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
},
|
||||
[projectId, clearSavedTimer]
|
||||
);
|
||||
|
||||
const retrySave = useCallback(() => {
|
||||
void performSave(persistPayload);
|
||||
}, [performSave, persistPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || isDevProjectId(projectId)) return;
|
||||
|
||||
let cancelled = false;
|
||||
skipSaveRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { project } = await fetchProject(projectId);
|
||||
if (cancelled) return;
|
||||
|
||||
setProjectName(project.name);
|
||||
|
||||
if (!isImageSceneDataEmpty(project.scene_data)) {
|
||||
hydrateFromSceneData(project.scene_data);
|
||||
}
|
||||
|
||||
lastSavedPayloadRef.current = JSON.stringify(
|
||||
useImageEditorStore.getState().getSceneDataForSave()
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
skipSaveRef.current = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, hydrateFromSceneData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || isDevProjectId(projectId) || skipSaveRef.current) return;
|
||||
if (persistPayload === lastSavedPayloadRef.current) return;
|
||||
setSaveStatus("pending");
|
||||
}, [projectId, persistPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || isDevProjectId(projectId) || skipSaveRef.current) return;
|
||||
if (debouncedPayload === lastSavedPayloadRef.current) return;
|
||||
void performSave(debouncedPayload);
|
||||
}, [projectId, debouncedPayload, performSave]);
|
||||
|
||||
useEffect(() => clearSavedTimer, [clearSavedTimer]);
|
||||
|
||||
return { projectName, setProjectName, saveStatus, retrySave };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/** Matches Tailwind `md` breakpoint (768px); mobile is strictly below */
|
||||
export const MOBILE_MEDIA_QUERY = "(max-width: 767px)";
|
||||
|
||||
export interface UseIsMobileResult {
|
||||
/** True when viewport width is under 768px */
|
||||
isMobile: boolean;
|
||||
/** False during SSR and until `matchMedia` runs on the client */
|
||||
isReady: boolean;
|
||||
}
|
||||
|
||||
export function useIsMobile(): UseIsMobileResult {
|
||||
const [state, setState] = useState<UseIsMobileResult>({
|
||||
isMobile: false,
|
||||
isReady: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia(MOBILE_MEDIA_QUERY);
|
||||
|
||||
const sync = (): void => {
|
||||
setState({ isMobile: mediaQuery.matches, isReady: true });
|
||||
};
|
||||
|
||||
sync();
|
||||
mediaQuery.addEventListener("change", sync);
|
||||
return () => mediaQuery.removeEventListener("change", sync);
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
||||
import {
|
||||
isDevelopmentEnv,
|
||||
loadLocalProject,
|
||||
saveLocalProject,
|
||||
} from "@/lib/dev-project-storage";
|
||||
import {
|
||||
fetchProject,
|
||||
isProjectNotFoundError,
|
||||
patchProjectSceneData,
|
||||
} from "@/lib/project-api";
|
||||
import { isDevProjectId } from "@/lib/project-ids";
|
||||
import {
|
||||
PROJECT_SAVE_DEBOUNCE_MS,
|
||||
PROJECT_SAVED_DISPLAY_MS,
|
||||
type ProjectSaveStatus,
|
||||
} from "@/lib/project-save-status";
|
||||
import { isVideoSceneDataEmpty } from "@/lib/studio-scene-data";
|
||||
import { useStudioStore } from "@/lib/studio-store";
|
||||
|
||||
export interface UseStudioProjectPersistenceResult {
|
||||
projectName: string;
|
||||
setProjectName: (name: string) => void;
|
||||
saveStatus: ProjectSaveStatus;
|
||||
usingLocalStorage: boolean;
|
||||
retrySave: () => void;
|
||||
}
|
||||
|
||||
function applyLocalSnapshot(
|
||||
projectId: string,
|
||||
hydrateFromSceneData: (sceneData: Record<string, unknown>) => boolean,
|
||||
setProjectName: (name: string) => void
|
||||
): string | null {
|
||||
const local = loadLocalProject(projectId);
|
||||
if (!local) return null;
|
||||
|
||||
if (local.name) {
|
||||
setProjectName(local.name);
|
||||
}
|
||||
|
||||
if (!isVideoSceneDataEmpty(local.scene_data)) {
|
||||
hydrateFromSceneData(local.scene_data);
|
||||
}
|
||||
|
||||
return JSON.stringify(local.scene_data);
|
||||
}
|
||||
|
||||
export function useStudioProjectPersistence(
|
||||
projectId: string
|
||||
): UseStudioProjectPersistenceResult {
|
||||
const scenes = useStudioStore((state) => state.scenes);
|
||||
const activeSceneId = useStudioStore((state) => state.activeSceneId);
|
||||
const currentTime = useStudioStore((state) => state.currentTime);
|
||||
const pxPerSecond = useStudioStore((state) => state.pxPerSecond);
|
||||
const audioFileName = useStudioStore((state) => state.audioFileName);
|
||||
const audioSrc = useStudioStore((state) => state.audioSrc);
|
||||
const audioVolume = useStudioStore((state) => state.audioVolume);
|
||||
const sceneBackgroundColor = useStudioStore(
|
||||
(state) => state.sceneBackgroundColor
|
||||
);
|
||||
const sceneAccentColor = useStudioStore((state) => state.sceneAccentColor);
|
||||
const hydrateFromSceneData = useStudioStore(
|
||||
(state) => state.hydrateFromSceneData
|
||||
);
|
||||
const getSceneDataForSave = useStudioStore((state) => state.getSceneDataForSave);
|
||||
|
||||
const [projectName, setProjectName] = useState(
|
||||
() => `Project ${projectId.slice(0, 8)}`
|
||||
);
|
||||
const [saveStatus, setSaveStatus] = useState<ProjectSaveStatus>("idle");
|
||||
const [usingLocalStorage, setUsingLocalStorage] = useState(false);
|
||||
|
||||
const skipSaveRef = useRef(true);
|
||||
const lastSavedPayloadRef = useRef<string | null>(null);
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const projectNameRef = useRef(projectName);
|
||||
|
||||
useEffect(() => {
|
||||
projectNameRef.current = projectName;
|
||||
}, [projectName]);
|
||||
|
||||
const persistPayload = useMemo(
|
||||
() => JSON.stringify(getSceneDataForSave()),
|
||||
[
|
||||
scenes,
|
||||
activeSceneId,
|
||||
currentTime,
|
||||
pxPerSecond,
|
||||
audioFileName,
|
||||
audioSrc,
|
||||
audioVolume,
|
||||
sceneBackgroundColor,
|
||||
sceneAccentColor,
|
||||
]
|
||||
);
|
||||
|
||||
const debouncedPayload = useDebouncedValue(
|
||||
persistPayload,
|
||||
PROJECT_SAVE_DEBOUNCE_MS
|
||||
);
|
||||
|
||||
const clearSavedTimer = useCallback(() => {
|
||||
if (savedTimerRef.current) {
|
||||
clearTimeout(savedTimerRef.current);
|
||||
savedTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markSaved = useCallback(
|
||||
(payloadJson: string, local: boolean) => {
|
||||
lastSavedPayloadRef.current = payloadJson;
|
||||
setSaveStatus(local ? "local" : "saved");
|
||||
clearSavedTimer();
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus((current) =>
|
||||
current === "saved" || current === "local" ? "idle" : current
|
||||
);
|
||||
}, PROJECT_SAVED_DISPLAY_MS);
|
||||
},
|
||||
[clearSavedTimer]
|
||||
);
|
||||
|
||||
const saveToLocalStorage = useCallback(
|
||||
(payloadJson: string) => {
|
||||
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||
saveLocalProject(projectId, {
|
||||
scene_data,
|
||||
name: projectNameRef.current,
|
||||
});
|
||||
setUsingLocalStorage(true);
|
||||
lastSavedPayloadRef.current = payloadJson;
|
||||
setSaveStatus("local");
|
||||
clearSavedTimer();
|
||||
savedTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus((current) => (current === "local" ? "idle" : current));
|
||||
}, PROJECT_SAVED_DISPLAY_MS);
|
||||
},
|
||||
[projectId, clearSavedTimer]
|
||||
);
|
||||
|
||||
const performSave = useCallback(
|
||||
async (payloadJson: string) => {
|
||||
if (isDevProjectId(projectId)) return;
|
||||
|
||||
setSaveStatus("saving");
|
||||
try {
|
||||
const scene_data = JSON.parse(payloadJson) as Record<string, unknown>;
|
||||
await patchProjectSceneData(projectId, scene_data);
|
||||
setUsingLocalStorage(false);
|
||||
markSaved(payloadJson, false);
|
||||
} catch (error) {
|
||||
if (isDevelopmentEnv() && isProjectNotFoundError(error)) {
|
||||
saveToLocalStorage(payloadJson);
|
||||
return;
|
||||
}
|
||||
setSaveStatus("error");
|
||||
}
|
||||
},
|
||||
[projectId, markSaved, saveToLocalStorage]
|
||||
);
|
||||
|
||||
const retrySave = useCallback(() => {
|
||||
void performSave(persistPayload);
|
||||
}, [performSave, persistPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevProjectId(projectId)) return;
|
||||
|
||||
let cancelled = false;
|
||||
skipSaveRef.current = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const { project } = await fetchProject(projectId);
|
||||
if (cancelled) return;
|
||||
|
||||
setUsingLocalStorage(false);
|
||||
setProjectName(project.name);
|
||||
|
||||
if (!isVideoSceneDataEmpty(project.scene_data)) {
|
||||
hydrateFromSceneData(project.scene_data);
|
||||
}
|
||||
|
||||
lastSavedPayloadRef.current = JSON.stringify(
|
||||
useStudioStore.getState().getSceneDataForSave()
|
||||
);
|
||||
} catch (error) {
|
||||
if (cancelled) return;
|
||||
|
||||
if (isDevelopmentEnv() && isProjectNotFoundError(error)) {
|
||||
const savedPayload = applyLocalSnapshot(
|
||||
projectId,
|
||||
hydrateFromSceneData,
|
||||
setProjectName
|
||||
);
|
||||
if (savedPayload) {
|
||||
setUsingLocalStorage(true);
|
||||
lastSavedPayloadRef.current = JSON.stringify(
|
||||
useStudioStore.getState().getSceneDataForSave()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setSaveStatus("error");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
skipSaveRef.current = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId, hydrateFromSceneData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevProjectId(projectId) || skipSaveRef.current) return;
|
||||
if (persistPayload === lastSavedPayloadRef.current) return;
|
||||
setSaveStatus("pending");
|
||||
}, [projectId, persistPayload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDevProjectId(projectId) || skipSaveRef.current) return;
|
||||
if (debouncedPayload === lastSavedPayloadRef.current) return;
|
||||
void performSave(debouncedPayload);
|
||||
}, [projectId, debouncedPayload, performSave]);
|
||||
|
||||
useEffect(() => clearSavedTimer, [clearSavedTimer]);
|
||||
|
||||
return {
|
||||
projectName,
|
||||
setProjectName,
|
||||
saveStatus,
|
||||
usingLocalStorage,
|
||||
retrySave,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const THUMBNAIL_INTERVAL_SECONDS = 2;
|
||||
|
||||
export function useVideoThumbnails(
|
||||
videoUrl: string | null,
|
||||
duration: number
|
||||
) {
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoUrl || duration <= 0) {
|
||||
setThumbnails([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const video = document.createElement("video");
|
||||
video.src = videoUrl;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "auto";
|
||||
|
||||
const captureFrames = async () => {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadedmetadata = () => resolve();
|
||||
video.onerror = () => reject(new Error("Failed to load video"));
|
||||
});
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const thumbWidth = 80;
|
||||
const thumbHeight = Math.round(
|
||||
(video.videoHeight / video.videoWidth) * thumbWidth
|
||||
);
|
||||
canvas.width = thumbWidth;
|
||||
canvas.height = thumbHeight || 45;
|
||||
|
||||
const times: number[] = [];
|
||||
for (
|
||||
let t = 0;
|
||||
t < duration;
|
||||
t += THUMBNAIL_INTERVAL_SECONDS
|
||||
) {
|
||||
times.push(t);
|
||||
}
|
||||
if (times[times.length - 1] !== duration - 0.01) {
|
||||
times.push(Math.max(0, duration - 0.05));
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
for (const time of times) {
|
||||
if (cancelled) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
video.currentTime = time;
|
||||
video.onseeked = () => resolve();
|
||||
});
|
||||
context.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
results.push(canvas.toDataURL("image/jpeg", 0.65));
|
||||
}
|
||||
|
||||
if (!cancelled) setThumbnails(results);
|
||||
};
|
||||
|
||||
captureFrames().catch(() => {
|
||||
if (!cancelled) setThumbnails([]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
};
|
||||
}, [videoUrl, duration]);
|
||||
|
||||
return thumbnails;
|
||||
}
|
||||
Reference in New Issue
Block a user