fix: 修复自由创作首尾帧上传人脸图不通过问题

This commit is contained in:
hh
2026-07-09 11:39:54 +08:00
parent 5c9e62a165
commit a82f90d0e0
11 changed files with 357 additions and 100 deletions
+3
View File
@@ -718,6 +718,9 @@ export const api = {
uploadFreeAsset(groupId: string, formData: FormData) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-groups/${groupId}/assets/`, { method: "POST", body: formData });
},
quickUploadFreeAsset(formData: FormData) {
return request<{ group: FreeAssetGroup; asset: FreeAssetItem }>("/api/assets/free-assets/quick-upload/", { method: "POST", body: formData });
},
renameFreeAsset(id: string, name: string) {
return request<{ asset: FreeAssetItem }>(`/api/assets/free-assets/${id}/`, { method: "PATCH", body: JSON.stringify({ name }) });
},
@@ -27,7 +27,9 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState("");
const [uploading, setUploading] = useState(false);
const [quickUploading, setQuickUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const quickFileRef = useRef<HTMLInputElement>(null);
useBodyScrollLock(open);
const loadGroups = useCallback(async () => {
@@ -109,6 +111,23 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
}
};
const quickUploadImage = async (file: File) => {
setQuickUploading(true);
try {
const form = new FormData();
form.append("file", file);
const data = await api.quickUploadFreeAsset(form);
setActiveGroup(data.group);
setAssets([data.asset]);
notify("success", "图片已上传到默认素材组,审核中");
void loadGroupDetail(data.group);
} catch (error) {
notify("error", error instanceof Error ? error.message : "上传失败");
} finally {
setQuickUploading(false);
}
};
const renameAsset = async (item: FreeAssetItem) => {
const name = window.prompt("素材名称(用于 @ 引用)", item.name);
if (!name || name.trim() === item.name) return;
@@ -191,6 +210,17 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
{!activeGroup ? (
<>
<div className="fc-lib-toolbar">
<input
ref={quickFileRef}
type="file"
hidden
accept="image/jpeg,image/png,image/webp"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void quickUploadImage(file);
}}
/>
{creating ? (
<div className="fc-lib-create">
<input
@@ -205,7 +235,13 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setCreating(false)}></button>
</div>
) : (
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> </button>
<>
<button type="button" className="btn btn-sm btn-primary" disabled={quickUploading} onClick={() => quickFileRef.current?.click()}>
<Upload size={13} /> {quickUploading ? "上传中…" : "上传图片"}
</button>
<button type="button" className="btn btn-sm" onClick={() => setCreating(true)}><FolderPlus size={13} /> </button>
<span className="mono fc-lib-hint">// 自动进入默认素材组审核</span>
</>
)}
</div>
{groups.length === 0 && !loading ? (
@@ -28,27 +28,33 @@ function RefThumb({ item, onRemove }: { item: LocalRef; onRemove: () => void })
);
}
function KeyframeSlot({ role, item, onPick, onRemove }: {
function KeyframeSlot({ role, item, onPickLibrary, onRemove }: {
role: "first_frame" | "last_frame";
item: LocalRef | undefined;
onPick: () => void;
onPickLibrary: () => void;
onRemove: () => void;
}) {
const label = role === "first_frame" ? "首帧" : "尾帧(可选)";
if (!item) {
return (
<button type="button" className="fc-kf-slot" onClick={onPick}>
<IconKitSvg name="images" size={20} />
<span>{label}</span>
</button>
<div className="fc-kf-slot-wrap">
<button type="button" className="fc-kf-slot" onClick={onPickLibrary}>
<IconKitSvg name="images" size={20} />
<span>{label}</span>
</button>
</div>
);
}
return (
<div className={`fc-kf-slot filled${item.uploading ? " uploading" : ""}`}>
<img src={item.thumb_url || item.url} alt={label} />
{item.uploading && <span className="spinner" />}
<span className="fc-kf-tag mono">{role === "first_frame" ? "首" : "尾"}</span>
<div className="fc-kf-slot-wrap">
<button type="button" className={`fc-kf-slot filled${item.uploading ? " uploading" : ""}`} title={`从人物素材库替换${label}`} onClick={onPickLibrary}>
<img src={item.thumb_url || item.url} alt={label} />
{item.uploading && <span className="spinner" />}
<span className="fc-kf-tag mono">{role === "first_frame" ? "首" : "尾"}</span>
</button>
<button type="button" className="fc-ref-x" aria-label={`删除${label}`} onClick={onRemove}>×</button>
<button type="button" className="fc-kf-lib" title={`从人物素材库替换${label}`} aria-label={`从人物素材库替换${label}`} onClick={onPickLibrary}>
</button>
</div>
);
}
@@ -74,7 +80,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
onResolutionChange: (resolution: string) => void;
onDurationChange: (duration: number) => void;
onSeedChange: (seed: number) => void;
onOpenLibrary: () => void;
onOpenLibrary: (role?: "first_frame" | "last_frame") => void;
onClear: () => void;
onSend: () => void;
}) {
@@ -125,7 +131,7 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
<IconKitSvg name="images" size={18} />
<span>+</span>
</button>
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={onOpenLibrary}>
<button type="button" className="fc-add fc-lib" title="人物素材库" onClick={() => onOpenLibrary()}>
<IconKitSvg name="users" size={18} />
</button>
{refs.map((item) => (
@@ -134,16 +140,16 @@ export function FreeInputBar({ mode, model, ratio, resolution, duration, seed, r
</div>
) : (
<div className="fc-keyframes">
<KeyframeSlot role="first_frame" item={firstFrame} onPick={() => pickFiles("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} />
<KeyframeSlot role="first_frame" item={firstFrame} onPickLibrary={() => onOpenLibrary("first_frame")} onRemove={() => firstFrame && onRemoveRef(firstFrame.key)} />
<span className="fc-kf-arrow mono"></span>
<KeyframeSlot role="last_frame" item={lastFrame} onPick={() => pickFiles("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
<KeyframeSlot role="last_frame" item={lastFrame} onPickLibrary={() => onOpenLibrary("last_frame")} onRemove={() => lastFrame && onRemoveRef(lastFrame.key)} />
</div>
)}
<PromptInput
ref={promptRef}
refs={refs}
onSubmit={onSend}
onOpenLibrary={onOpenLibrary}
onOpenLibrary={mode === "universal" ? () => onOpenLibrary() : undefined}
onTextChange={setHasPrompt}
placeholder={mode === "keyframe" ? "描述首尾帧之间的运动与变化…" : "描述你想生成的视频,@ 可引用参考素材…"}
/>
@@ -25,7 +25,7 @@ type Props = {
placeholder?: string;
disabled?: boolean;
onSubmit: () => void;
onOpenLibrary: () => void;
onOpenLibrary?: () => void;
onTextChange?: (hasText: boolean) => void;
};
@@ -247,7 +247,7 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
thumb: r.thumb_url || (r.type === "image" ? r.url : ""),
kind: "ref" as const
})),
{ key: "__library__", label: "从素材库选择…", kind: "library" as const }
...(onOpenLibrary ? [{ key: "__library__", label: "从素材库选择…", kind: "library" as const }] : [])
];
const pickMenuItem = (item: (typeof menuItems)[number]) => {
@@ -256,7 +256,7 @@ export const PromptInput = forwardRef<PromptInputHandle, Props>(function PromptI
const trigger = findTrigger();
trigger?.range.deleteContents();
closeMenu();
onOpenLibrary();
onOpenLibrary?.();
return;
}
insertChipAtTrigger(item.label, item.thumb);
+19 -3
View File
@@ -220,10 +220,11 @@
align-items: center;
justify-content: center;
}
.fc-ref:hover .fc-ref-x, .fc-kf-slot:hover .fc-ref-x { display: inline-flex; }
.fc-ref:hover .fc-ref-x, .fc-kf-slot-wrap:hover .fc-ref-x { display: inline-flex; }
/* 首尾帧(keyframe) */
.fc-keyframes { display: flex; align-items: center; gap: 8px; }
.fc-kf-slot-wrap { position: relative; width: 88px; height: 66px; flex: 0 0 auto; }
.fc-kf-slot {
position: relative;
width: 88px;
@@ -242,10 +243,25 @@
overflow: hidden;
transition: border-color 0.2s, color 0.2s;
}
.fc-kf-slot-wrap > .fc-kf-slot { width: 100%; height: 100%; }
.fc-kf-slot:hover { border-color: var(--heat-40); color: var(--heat); }
.fc-kf-slot.filled { border-style: solid; border-color: var(--border-faint); cursor: default; }
.fc-kf-slot.filled { border-style: solid; border-color: var(--border-faint); }
.fc-kf-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fc-kf-slot .spinner { position: absolute; }
.fc-kf-lib {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: none;
border-radius: var(--r-md);
background: transparent;
color: transparent;
cursor: pointer;
opacity: 0;
z-index: 1;
}
.fc-kf-slot-wrap > .fc-ref-x { z-index: 2; }
.fc-kf-tag {
position: absolute;
left: 3px;
@@ -488,7 +504,7 @@
.fc-player-actions { display: flex; gap: 8px; margin-top: 4px; flex-wrap: wrap; }
/* —— 素材库弹窗 —— */
.fc-lib-modal { width: min(720px, 92vw); }
.fc-lib-modal { width: min(720px, 92vw); max-width: min(720px, 92vw); }
.fc-lib-body { max-height: 60vh; overflow-y: auto; }
.fc-lib-back {
display: inline-flex;
+40 -6
View File
@@ -91,6 +91,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
// —— 弹窗 ——
const [detailId, setDetailId] = useState<string | null>(null);
const [libraryOpen, setLibraryOpen] = useState(false);
const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null);
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
// —— 轮询/进度 ——
@@ -441,15 +442,48 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
}
}, [deleteTarget, detailId, stopPolling, notify]);
// 素材库选中 → 注入输入条 + 插 mention chip
const openLibrary = useCallback((role?: "first_frame" | "last_frame") => {
if (mode === "keyframe" && !role) {
notify("info", "请点击首帧或尾帧槽位上的人物素材库入口");
return;
}
setLibraryTargetRole(role || null);
setLibraryOpen(true);
}, [mode, notify]);
const closeLibrary = useCallback(() => {
setLibraryOpen(false);
setLibraryTargetRole(null);
}, []);
// 素材库选中 → 全能参考插 mention;首尾帧写入对应槽位
const handleLibraryPick = useCallback((ref: FreeVideoRef) => {
if (mode === "keyframe") { notify("info", "首尾帧模式请直接上传图片"); return; }
if (mode === "keyframe") {
if (!libraryTargetRole) {
notify("info", "请先指定首帧或尾帧槽位");
return;
}
if (ref.type !== "image") {
notify("error", "首尾帧仅支持图片素材");
return;
}
const local: LocalRef = {
...ref,
key: nextRefKey(),
role: libraryTargetRole,
label: undefined,
source: "library"
};
setRefs((prev) => [...prev.filter((r) => r.role !== libraryTargetRole), local]);
setLibraryTargetRole(null);
return;
}
let label = ref.label || "素材";
let n = 2;
while (refs.some((r) => r.label === label)) label = `${ref.label}${n++}`;
setRefs((prev) => [...prev, { ...ref, label, key: nextRefKey() }]);
window.setTimeout(() => promptRef.current?.insertMention({ label, thumb: ref.thumb_url || (ref.type === "image" ? ref.url : "") }), 0);
}, [mode, refs, notify]);
}, [mode, refs, libraryTargetRole, notify]);
const clearInput = useCallback(() => {
promptRef.current?.clear();
@@ -471,7 +505,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
</div>
</div>
<div className="actions">
<button type="button" className="btn" onClick={() => setLibraryOpen(true)}>
<button type="button" className="btn" onClick={() => openLibrary()}>
<Users size={14} />
</button>
</div>
@@ -525,7 +559,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
onResolutionChange={setResolution}
onDurationChange={setDuration}
onSeedChange={setSeed}
onOpenLibrary={() => setLibraryOpen(true)}
onOpenLibrary={openLibrary}
onClear={clearInput}
onSend={() => void handleSend()}
/>
@@ -545,7 +579,7 @@ export function FreeCreatePage({ modelConfigs, onNotify }: {
/>
)}
<AssetLibraryModal open={libraryOpen} onClose={() => setLibraryOpen(false)} onPick={handleLibraryPick} notify={notify} />
<AssetLibraryModal open={libraryOpen} onClose={closeLibrary} onPick={handleLibraryPick} notify={notify} />
<ConfirmModal
open={deleteTarget !== null}