完善视频提炼和视频复刻完善提交测试
This commit is contained in:
@@ -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 } };
|
||||
}
|
||||
Reference in New Issue
Block a user