完善视频提炼和视频复刻完善提交测试

This commit is contained in:
Azmat@qq.com
2026-08-28 10:15:15 +08:00
parent 52c5cdae1d
commit 849b14a0f6
33 changed files with 1475 additions and 208 deletions
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react";
import { api } from "../../api";
import { useFileDrop } from "../use-file-drop";
import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types";
import { useBodyScrollLock } from "../overlays";
@@ -131,6 +132,17 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
}
};
// 素材库弹窗:整块 body 都能接住拖进来的文件。在组内传素材,在组外快速建图。
const libDrop = useFileDrop(
(files) => {
const file = files[0];
if (!file) return;
if (activeGroup) void uploadAsset(file);
else void quickUploadImage(file);
},
{ disabled: uploading }
);
const quickUploadImage = async (file: File) => {
setQuickUploading(true);
try {
@@ -278,7 +290,11 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
</div>
<button className="x modal-x" type="button" onClick={onClose} aria-label="关闭"><X size={14} /></button>
</div>
<div className="modal-b fc-lib-body" onClick={() => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}>
<div
className={`modal-b fc-lib-body${libDrop.dragging ? " is-dragover" : ""}`}
{...libDrop.dropProps}
onClick={() => { setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}
>
{!activeGroup ? (
<>
<div className="fc-lib-toolbar">
@@ -137,7 +137,7 @@ function probeImage(file: File): Promise<FileCheck> {
});
}
function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
function probeMedia(file: File, kind: "video" | "audio", maxSeconds = MAX_VIDEO_TOTAL_SECONDS): Promise<FileCheck> {
return new Promise((resolve) => {
const url = URL.createObjectURL(file);
const el = document.createElement(kind);
@@ -146,24 +146,34 @@ function probeMedia(file: File, kind: "video" | "audio"): Promise<FileCheck> {
URL.revokeObjectURL(url);
const duration = el.duration;
if (!isFinite(duration)) resolve({ ok: true, type: kind });
else if (duration < 2 - 0.05 || duration > MAX_VIDEO_TOTAL_SECONDS + VIDEO_DURATION_SLACK) {
resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-15 秒之间` });
} else resolve({ ok: true, type: kind, duration: Math.min(MAX_VIDEO_TOTAL_SECONDS, Math.round(duration * 10) / 10) });
else if (duration < 2 - 0.05 || duration > maxSeconds + VIDEO_DURATION_SLACK) {
resolve({ ok: false, error: `${kind === "video" ? "视频" : "音频"}时长需在 2-${maxSeconds} 秒之间` });
} else resolve({ ok: true, type: kind, duration: Math.min(maxSeconds, Math.round(duration * 10) / 10) });
};
el.onerror = () => { URL.revokeObjectURL(url); resolve({ ok: false, error: "媒体文件解析失败,请更换文件" }); };
el.src = url;
});
}
export async function checkRefFile(file: File): Promise<FileCheck> {
/** 参考素材前置校验。
* 默认按自由创作那套(15 秒 / 50MB)——参考视频要直传火山,是火山的硬限制。
* 视频复刻·商品这类「视频只喂给提炼模型、不进火山」的入口,用 options 放宽。 */
export async function checkRefFile(
file: File,
options?: { maxSeconds?: number; maxVideoBytes?: number }
): Promise<FileCheck> {
const type = (file.type || "").toLowerCase();
const maxSeconds = options?.maxSeconds ?? MAX_VIDEO_TOTAL_SECONDS;
const maxVideoBytes = options?.maxVideoBytes ?? VIDEO_MAX_BYTES;
if (IMAGE_TYPES.includes(type)) {
if (file.size > IMAGE_MAX_BYTES) return { ok: false, error: "图片大小不能超过 30MB" };
return probeImage(file);
}
if (VIDEO_TYPES.includes(type)) {
if (file.size > VIDEO_MAX_BYTES) return { ok: false, error: "视频大小不能超过 50MB" };
return probeMedia(file, "video");
if (file.size > maxVideoBytes) {
return { ok: false, error: `视频大小不能超过 ${Math.round(maxVideoBytes / 1024 / 1024)}MB` };
}
return probeMedia(file, "video", maxSeconds);
}
if (AUDIO_TYPES.includes(type)) {
if (file.size > AUDIO_MAX_BYTES) return { ok: false, error: "音频大小不能超过 15MB" };
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { CSSProperties } from "react";
import { api } from "../api";
import { useFileDrop } from "./use-file-drop";
import type { Asset, ModelEntity } from "../types";
import { useBodyScrollLock, MediaLightbox } from "./overlays";
@@ -94,6 +95,11 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
} finally { setBusy(false); }
}
// 本地上传 → 直接作为选中立绘进右侧栏
const candidateDrop = useFileDrop(
(files) => { void uploadCandidate(files[0]); },
{ disabled: busy, accept: (f) => f.type.startsWith("image/") }
);
async function uploadCandidate(file?: File | null) {
if (!file) return;
setBusy(true);
@@ -156,9 +162,9 @@ export function ModelLibrary({ open, mode, initialStudio, onClose, onPick, onGen
) : (
<div className="actorlib-studio-upload">
<div className="muted mono" style={{ fontSize: 12, marginBottom: 6, letterSpacing: ".04em" }}>1 上传本地模特形象图 → 右侧命名并保存</div>
<div className="actorlib-drop" role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
<div className={`actorlib-drop${candidateDrop.dragging ? " is-dragover" : ""}`} {...candidateDrop.dropProps} role="button" tabIndex={0} onClick={() => fileRef.current?.click()} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); fileRef.current?.click(); } }}>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><path d="M17 8l-5-5-5 5" /><path d="M12 3v12" /></svg>
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : "点击选择本地模特图片上传"}</div>
<div style={{ marginTop: 10, fontSize: 13 }}>{busy ? "上传中…" : candidateDrop.dragging ? "松开即可上传" : "点击或拖拽本地模特图片上传"}</div>
<div className="mono" style={{ fontSize: 12, color: "var(--black-alpha-48)", marginTop: 4 }}>JPG / PNG / WEBP</div>
</div>
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => { void uploadCandidate(e.target.files?.[0]); e.currentTarget.value = ""; }} />
@@ -0,0 +1,53 @@
import { useCallback, useRef, useState } from "react";
/** 全站上传区共用的拖拽投放。各页只管拿 dragging 加自己的高亮类,逻辑不再各写一份。 */
export function useFileDrop(
onFiles: (files: File[]) => void,
options?: { disabled?: boolean; accept?: (file: File) => boolean }
) {
const [dragging, setDragging] = useState(false);
// 拖过子元素时浏览器会连发 dragenter/dragleave,只看 leave 会让高亮疯狂闪。用进出计数兜住。
const depth = useRef(0);
const disabled = Boolean(options?.disabled);
const accept = options?.accept;
const reset = useCallback(() => {
depth.current = 0;
setDragging(false);
}, []);
const hasFiles = (event: React.DragEvent) =>
Array.from(event.dataTransfer?.types || []).includes("Files");
const onDragEnter = useCallback((event: React.DragEvent) => {
if (disabled || !hasFiles(event)) return;
event.preventDefault();
depth.current += 1;
setDragging(true);
}, [disabled]);
const onDragOver = useCallback((event: React.DragEvent) => {
if (disabled || !hasFiles(event)) return;
// 不 preventDefault 浏览器会把文件当导航打开,drop 根本不触发
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
}, [disabled]);
const onDragLeave = useCallback((event: React.DragEvent) => {
if (disabled) return;
event.preventDefault();
depth.current = Math.max(0, depth.current - 1);
if (depth.current === 0) setDragging(false);
}, [disabled]);
const onDrop = useCallback((event: React.DragEvent) => {
if (disabled) return;
event.preventDefault();
reset();
const files = Array.from(event.dataTransfer?.files || []);
const picked = accept ? files.filter(accept) : files;
if (picked.length) onFiles(picked);
}, [disabled, accept, onFiles, reset]);
return { dragging, dropProps: { onDragEnter, onDragOver, onDragLeave, onDrop } };
}