返回 JoyAI-Echo
useFirstFrameImage.ts
根目录 / echo_longvideo / Director_Agent / webui / src / hooks / useFirstFrameImage.ts
1 import { useCallback, useEffect, useRef, useState } from "react";
2
3 import type { VideoSize } from "@/components/thread/AspectRatioPicker";
4 import { cropImageToVideoSize } from "@/lib/cropImageToVideoSize";
5
6 const ACCEPT_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
7
8 export type FirstFrameData = {
9 /** Object URL of the *original* image (preview / lightbox). */
10 previewUrl: string;
11 /** Cropped blob sized to the current videoSize (upload / send). */
12 croppedBlob: Blob;
13 width: number;
14 height: number;
15 name: string;
16 };
17
18 export type FirstFrameRejectReason = "unsupported_type" | "decode_failed";
19
20 export type UseFirstFrameImageApi = {
21 value: FirstFrameData | null;
22 cropping: boolean;
23 setFile: (file: File) => Promise<boolean>;
24 clear: () => void;
25 };
26
27 function isAcceptedImage(file: File): boolean {
28 if (ACCEPT_TYPES.has(file.type)) return true;
29 // Some browsers omit type for dragged files; sniff by extension.
30 const lower = file.name.toLowerCase();
31 return (
32 lower.endsWith(".png") ||
33 lower.endsWith(".jpg") ||
34 lower.endsWith(".jpeg") ||
35 lower.endsWith(".webp")
36 );
37 }
38
39 /** Cropped upload blob is always JPEG; keep FormData filename in sync. */
40 function toJpegUploadName(name: string | undefined): string {
41 const trimmed = name?.trim();
42 if (!trimmed) return "first-frame.jpg";
43 const base = trimmed.replace(/\.[^.]+$/, "");
44 return `${base || "first-frame"}.jpg`;
45 }
46
47 /** Manage a single first-frame reference image with aspect-aware canvas crop. */
48 export function useFirstFrameImage(
49 videoSize: VideoSize,
50 onReject?: (reason: FirstFrameRejectReason) => void,
51 ): UseFirstFrameImageApi {
52 const [value, setValue] = useState<FirstFrameData | null>(null);
53 const [cropping, setCropping] = useState(false);
54 const originalRef = useRef<File | null>(null);
55 const previewUrlRef = useRef<string | null>(null);
56 const cropGenRef = useRef(0);
57 const onRejectRef = useRef(onReject);
58 onRejectRef.current = onReject;
59
60 const revokePreview = useCallback(() => {
61 if (previewUrlRef.current) {
62 URL.revokeObjectURL(previewUrlRef.current);
63 previewUrlRef.current = null;
64 }
65 }, []);
66
67 const clear = useCallback(() => {
68 cropGenRef.current += 1;
69 originalRef.current = null;
70 revokePreview();
71 setValue(null);
72 setCropping(false);
73 }, [revokePreview]);
74
75 const runCrop = useCallback(
76 async (file: File, previewUrl: string) => {
77 const gen = ++cropGenRef.current;
78 setCropping(true);
79 try {
80 const cropped = await cropImageToVideoSize(file, videoSize);
81 if (gen !== cropGenRef.current) return;
82 setValue({
83 previewUrl,
84 croppedBlob: cropped.blob,
85 width: cropped.width,
86 height: cropped.height,
87 name: toJpegUploadName(file.name),
88 });
89 } catch {
90 if (gen !== cropGenRef.current) return;
91 onRejectRef.current?.("decode_failed");
92 originalRef.current = null;
93 revokePreview();
94 setValue(null);
95 } finally {
96 if (gen === cropGenRef.current) setCropping(false);
97 }
98 },
99 [revokePreview, videoSize],
100 );
101
102 const setFile = useCallback(
103 async (file: File): Promise<boolean> => {
104 if (!isAcceptedImage(file)) {
105 onRejectRef.current?.("unsupported_type");
106 return false;
107 }
108 revokePreview();
109 const previewUrl = URL.createObjectURL(file);
110 previewUrlRef.current = previewUrl;
111 originalRef.current = file;
112 await runCrop(file, previewUrl);
113 return originalRef.current === file;
114 },
115 [revokePreview, runCrop],
116 );
117
118 // Re-crop when the user changes aspect ratio while an image is selected.
119 useEffect(() => {
120 const file = originalRef.current;
121 const previewUrl = previewUrlRef.current;
122 if (!file || !previewUrl) return;
123 void runCrop(file, previewUrl);
124 }, [videoSize.width, videoSize.height, runCrop]);
125
126 useEffect(() => () => revokePreview(), [revokePreview]);
127
128 return { value, cropping, setFile, clear };
129 }
130
130 lines TYPESCRIPT