fix: 修复自由创作首尾帧上传人脸图不通过问题
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user