Commit 0aa3bd46 authored by yaoke.yk's avatar yaoke.yk

角色设定分镜ui调整

parent ea37c382
...@@ -303,6 +303,39 @@ export function CharacterGeneration() { ...@@ -303,6 +303,39 @@ export function CharacterGeneration() {
}); });
}; };
const handleGenerateFromModal = async () => {
if (!editingChar.name?.trim()) return;
const draft: Partial<Character> = {
...editingChar,
status: editingChar.status ?? "draft",
imagePrompt: editingChar.imagePrompt?.trim() || buildChineseCharacterPrompt(editingChar),
};
setSaving(true);
setGeneratingImageId(editingChar.id ? String(editingChar.id) : "draft");
try {
const saved = await saveCharacter.mutateAsync(draft);
const generated = await generateImage.mutateAsync(String(saved.id));
revokeImagePreviews(imagePreviews);
setEditingChar({
...generated,
imagePrompt: generated.imagePrompt ?? draft.imagePrompt,
});
setImagePreviews({
front: getCharacterViewUrl(generated, "front"),
side: getCharacterViewUrl(generated, "side"),
back: getCharacterViewUrl(generated, "back"),
});
setPendingFiles(emptyPendingFiles());
fileInputRefs.current.front && (fileInputRefs.current.front.value = "");
} catch (error) {
alert(`三视图生成失败: ${(error as Error).message}`);
} finally {
setSaving(false);
setGeneratingImageId(null);
}
};
const handleSaveState = async () => { const handleSaveState = async () => {
if (!editingCharacterId || !stateDraft.name.trim()) return; if (!editingCharacterId || !stateDraft.name.trim()) return;
setSavingState(true); setSavingState(true);
...@@ -679,14 +712,43 @@ export function CharacterGeneration() { ...@@ -679,14 +712,43 @@ export function CharacterGeneration() {
</div> </div>
)} )}
</div> </div>
{imagePreviews.front && ( {imagePreviews.front ? (
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRefs.current.front?.click()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<Upload className="h-3.5 w-3.5" />
更换图片
</button>
<button
type="button"
onClick={handleGenerateFromModal}
disabled={!editingChar.name?.trim() || saving || uploadImage.isPending || generateImage.isPending}
className="inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{generatingImageId === (editingChar.id ? String(editingChar.id) : "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
AI重新生成
</button>
</div>
) : (
<button <button
type="button" type="button"
onClick={() => fileInputRefs.current.front?.click()} onClick={handleGenerateFromModal}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted" disabled={!editingChar.name?.trim() || saving || uploadImage.isPending || generateImage.isPending}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
> >
<Upload className="h-3.5 w-3.5" /> {generatingImageId === (editingChar.id ? String(editingChar.id) : "draft") ? (
更换图片 <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{editingChar.id ? "AI生成三视图" : "保存并AI生成"}
</button> </button>
)} )}
<input <input
......
...@@ -116,6 +116,26 @@ export function SceneGeneration() { ...@@ -116,6 +116,26 @@ export function SceneGeneration() {
}); });
}; };
const handleGenerateFromModal = async () => {
if (!editingScene.name?.trim()) return;
setSaving(true);
setGeneratingImageId(editingScene.id ? String(editingScene.id) : "draft");
try {
const saved = await saveScene.mutateAsync({ ...editingScene, status: editingScene.status ?? "draft" });
const generated = await generateImage.mutateAsync(String(saved.id));
revokeObjectPreview(imagePreview);
setEditingScene({ ...generated });
setImagePreview(generated.imageUrl ?? null);
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
} catch (e) {
alert("图片生成失败: " + (e as Error).message);
} finally {
setSaving(false);
setGeneratingImageId(null);
}
};
const handleDelete = (scene: Scene) => { const handleDelete = (scene: Scene) => {
if (!confirm(`确定要删除场景「${scene.name}」吗?`)) return; if (!confirm(`确定要删除场景「${scene.name}」吗?`)) return;
deleteScene.mutate(scene.id, { onError: (e) => alert("删除失败: " + (e as Error).message) }); deleteScene.mutate(scene.id, { onError: (e) => alert("删除失败: " + (e as Error).message) });
...@@ -404,14 +424,43 @@ export function SceneGeneration() { ...@@ -404,14 +424,43 @@ export function SceneGeneration() {
</div> </div>
)} )}
</div> </div>
{imagePreview && ( {imagePreview ? (
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<Upload className="h-3.5 w-3.5" />
更换图片
</button>
<button
type="button"
onClick={handleGenerateFromModal}
disabled={!editingScene.name?.trim() || saving || uploadImage.isPending || generateImage.isPending}
className="inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
>
{generatingImageId === (editingScene.id ? String(editingScene.id) : "draft") ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
AI重新生成
</button>
</div>
) : (
<button <button
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={handleGenerateFromModal}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted" disabled={!editingScene.name?.trim() || saving || uploadImage.isPending || generateImage.isPending}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs text-primary hover:bg-primary/10 disabled:opacity-50"
> >
<Upload className="h-3.5 w-3.5" /> {generatingImageId === (editingScene.id ? String(editingScene.id) : "draft") ? (
更换图片 <Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
{editingScene.id ? "AI生成场景图" : "保存并AI生成"}
</button> </button>
)} )}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} /> <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
......
...@@ -49,6 +49,7 @@ const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value; ...@@ -49,6 +49,7 @@ const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value;
const MAX_CHARACTERS = 5; const MAX_CHARACTERS = 5;
const MAX_PROPS = 5; const MAX_PROPS = 5;
const PROMPT_PREVIEW_LIMIT = 80; const PROMPT_PREVIEW_LIMIT = 80;
type VideoGenerationMode = "reference" | "frames";
// 分镜编辑器草稿存到 localStorage,按 (project, episode, storyboard) 分 key。 // 分镜编辑器草稿存到 localStorage,按 (project, episode, storyboard) 分 key。
// 跨页面切换/刷新都不会丢;展开分镜时优先用草稿,没草稿才回退到上一次任务。 // 跨页面切换/刷新都不会丢;展开分镜时优先用草稿,没草稿才回退到上一次任务。
...@@ -58,6 +59,7 @@ const sbDraftKey = (pid: string, eid: string, sbId: string) => ...@@ -58,6 +59,7 @@ const sbDraftKey = (pid: string, eid: string, sbId: string) =>
`${SB_DRAFT_KEY_PREFIX}:${pid}:${eid}:${sbId}`; `${SB_DRAFT_KEY_PREFIX}:${pid}:${eid}:${sbId}`;
interface SbDraft { interface SbDraft {
generationMode?: VideoGenerationMode;
shortDescription: string; shortDescription: string;
characterKeys: string[]; characterKeys: string[];
sceneKey: string; sceneKey: string;
...@@ -321,6 +323,7 @@ export function StoryboardWorkspace() { ...@@ -321,6 +323,7 @@ export function StoryboardWorkspace() {
const [lastFrameImageKey, setLastFrameImageKey] = useState<string>(""); const [lastFrameImageKey, setLastFrameImageKey] = useState<string>("");
const [lastFrameImageUrl, setLastFrameImageUrl] = useState<string>(""); const [lastFrameImageUrl, setLastFrameImageUrl] = useState<string>("");
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [generationMode, setGenerationMode] = useState<VideoGenerationMode>("reference");
const [shortDescription, setShortDescription] = useState<string>(""); const [shortDescription, setShortDescription] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>(""); const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [selectedDuration, setSelectedDuration] = useState(DEFAULT_VIDEO_DURATION); const [selectedDuration, setSelectedDuration] = useState(DEFAULT_VIDEO_DURATION);
...@@ -392,6 +395,7 @@ export function StoryboardWorkspace() { ...@@ -392,6 +395,7 @@ export function StoryboardWorkspace() {
setFirstFrameImageUrl(""); setFirstFrameImageUrl("");
setLastFrameImageKey(""); setLastFrameImageKey("");
setLastFrameImageUrl(""); setLastFrameImageUrl("");
setGenerationMode("reference");
setShortDescription(""); setShortDescription("");
setActiveVideoSbId(null); setActiveVideoSbId(null);
lastSyncedExpandedIdRef.current = null; lastSyncedExpandedIdRef.current = null;
...@@ -429,6 +433,7 @@ export function StoryboardWorkspace() { ...@@ -429,6 +433,7 @@ export function StoryboardWorkspace() {
setFirstFrameImageUrl(draft.firstFrameImageUrl || expanded.firstFrameImageUrl || ""); setFirstFrameImageUrl(draft.firstFrameImageUrl || expanded.firstFrameImageUrl || "");
setLastFrameImageKey(draft.lastFrameImageKey || expanded.lastFrameImageKey || ""); setLastFrameImageKey(draft.lastFrameImageKey || expanded.lastFrameImageKey || "");
setLastFrameImageUrl(draft.lastFrameImageUrl || expanded.lastFrameImageUrl || ""); setLastFrameImageUrl(draft.lastFrameImageUrl || expanded.lastFrameImageUrl || "");
setGenerationMode(draft.generationMode ?? "reference");
setFreeformPrompt(draft.freeformPrompt); setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration)); setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedRatio(draft.selectedRatio); setSelectedRatio(draft.selectedRatio);
...@@ -465,6 +470,7 @@ export function StoryboardWorkspace() { ...@@ -465,6 +470,7 @@ export function StoryboardWorkspace() {
setFirstFrameImageUrl(expanded.firstFrameImageUrl ?? ""); setFirstFrameImageUrl(expanded.firstFrameImageUrl ?? "");
setLastFrameImageKey(expanded.lastFrameImageKey ?? ""); setLastFrameImageKey(expanded.lastFrameImageKey ?? "");
setLastFrameImageUrl(expanded.lastFrameImageUrl ?? ""); setLastFrameImageUrl(expanded.lastFrameImageUrl ?? "");
setGenerationMode("reference");
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? "")); setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
if (typeof lastTask?.videoDuration === "number") { if (typeof lastTask?.videoDuration === "number") {
setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration)); setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
...@@ -485,6 +491,7 @@ export function StoryboardWorkspace() { ...@@ -485,6 +491,7 @@ export function StoryboardWorkspace() {
return; return;
} }
saveSbDraft(sbDraftKey(pid, eid, expandedId), { saveSbDraft(sbDraftKey(pid, eid, expandedId), {
generationMode,
shortDescription, shortDescription,
characterKeys, characterKeys,
sceneKey, sceneKey,
...@@ -501,7 +508,7 @@ export function StoryboardWorkspace() { ...@@ -501,7 +508,7 @@ export function StoryboardWorkspace() {
}); });
}, [ }, [
expandedId, pid, eid, expandedId, pid, eid,
shortDescription, characterKeys, sceneKey, propKeys, generationMode, shortDescription, characterKeys, sceneKey, propKeys,
firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl, firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
freeformPrompt, freeformPrompt,
selectedDuration, selectedRatio, selectedModel, audioOn, selectedDuration, selectedRatio, selectedModel, audioOn,
...@@ -635,11 +642,12 @@ export function StoryboardWorkspace() { ...@@ -635,11 +642,12 @@ export function StoryboardWorkspace() {
const promptHasContent = freeformPrompt.trim().length > 0; const promptHasContent = freeformPrompt.trim().length > 0;
const frameReferenceCount = (firstFrameImageKey ? 1 : 0) + (lastFrameImageKey ? 1 : 0); const frameReferenceCount = (firstFrameImageKey ? 1 : 0) + (lastFrameImageKey ? 1 : 0);
// 至少一张参考图(角色/场景/道具/首尾帧任一)+ 提示词非空 const referenceImageCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length;
const hasAnyReferenceImage = characterKeys.length > 0 || !!sceneKey || propKeys.length > 0 || frameReferenceCount > 0; const hasRequiredImages = generationMode === "frames"
const canGenerate = !!expanded && hasAnyReferenceImage ? !!firstFrameImageKey && !!lastFrameImageKey
&& promptHasContent && !generateStructured.isPending; : referenceImageCount > 0;
const referenceCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length + frameReferenceCount; const canGenerate = !!expanded && hasRequiredImages && promptHasContent && !generateStructured.isPending;
const referenceCount = generationMode === "frames" ? frameReferenceCount : referenceImageCount;
const handleGenerate = async () => { const handleGenerate = async () => {
if (!expanded || !canGenerate) return; if (!expanded || !canGenerate) return;
...@@ -663,11 +671,11 @@ export function StoryboardWorkspace() { ...@@ -663,11 +671,11 @@ export function StoryboardWorkspace() {
await generateStructured.mutateAsync({ await generateStructured.mutateAsync({
episodeId: expanded.episodeId, episodeId: expanded.episodeId,
storyboardId: expanded.id, storyboardId: expanded.id,
characterImageKeys: characterKeys, characterImageKeys: generationMode === "reference" ? characterKeys : [],
sceneImageKey: sceneKey, sceneImageKey: generationMode === "reference" ? sceneKey : "",
propImageKeys: propKeys.length > 0 ? propKeys : null, propImageKeys: generationMode === "reference" && propKeys.length > 0 ? propKeys : null,
firstFrameImageKey: firstFrameImageKey || null, firstFrameImageKey: generationMode === "frames" ? firstFrameImageKey : null,
lastFrameImageKey: lastFrameImageKey || null, lastFrameImageKey: generationMode === "frames" ? lastFrameImageKey : null,
styleImageKey: styleKey || null, styleImageKey: styleKey || null,
freeformPrompt: freeformPrompt.trim(), freeformPrompt: freeformPrompt.trim(),
duration: selectedDuration, duration: selectedDuration,
...@@ -716,6 +724,10 @@ export function StoryboardWorkspace() { ...@@ -716,6 +724,10 @@ export function StoryboardWorkspace() {
const currentEpisodeSummary = currentEpisode?.summary const currentEpisodeSummary = currentEpisode?.summary
|| "当前项目还没有可用分集。生成分集后,可以在这里继续生成分镜。"; || "当前项目还没有可用分集。生成分集后,可以在这里继续生成分镜。";
const selectedScene = sceneKey ? sceneByKey.get(sceneKey) ?? null : null; const selectedScene = sceneKey ? sceneByKey.get(sceneKey) ?? null : null;
const generationModeLabel = generationMode === "frames" ? "首尾帧生成" : "参考图生成";
const generateRequirementText = generationMode === "frames"
? "需要:上传首帧和尾帧,并填写提示词"
: "需要:至少 1 张参考图(角色/场景/道具)+ 提示词";
return ( return (
<div className="h-full flex overflow-hidden bg-[#f7f8fb] text-[#111827]"> <div className="h-full flex overflow-hidden bg-[#f7f8fb] text-[#111827]">
...@@ -814,35 +826,12 @@ export function StoryboardWorkspace() { ...@@ -814,35 +826,12 @@ export function StoryboardWorkspace() {
{/* ─── Column 2: Storyboard accordion list (top: prompt summary, then cards) ─── */} {/* ─── Column 2: Storyboard accordion list (top: prompt summary, then cards) ─── */}
<div className="flex flex-col flex-shrink-0 overflow-hidden rounded-lg border border-[#eef1f6] bg-[#fbfcff]" style={{ width: 394 }}> <div className="flex flex-col flex-shrink-0 overflow-hidden rounded-lg border border-[#eef1f6] bg-[#fbfcff]" style={{ width: 394 }}>
{/* Top: 分镜表 / 备注描述 */}
<div className="px-3 py-3 border-b border-[#edf0f5] bg-white flex-shrink-0 relative">
<div className="flex items-center justify-between mb-1.5">
<span className="text-sm font-semibold text-[#111827]">
{expanded ? `分镜信息${String(expanded.sequenceNum).padStart(3, "0")}` : "分镜信息"}
</span>
{(promptPreviewText || expanded) && (
<button
onClick={() => setShowPromptDetails(true)}
className="text-xs text-[#7657ff] hover:text-[#5e42df] flex items-center gap-0.5 font-medium transition-colors"
title="查看完整内容"
>
<Info className="w-3 h-3" />
详情
</button>
)}
</div>
<div className="mb-2 text-xs font-medium text-[#344054]">分镜描述</div>
<p className={`min-h-[96px] rounded-md border border-[#d9dee8] bg-white px-3 py-2 text-xs leading-relaxed ${shortDescription ? "text-[#111827]" : "text-[#98a2b3]"}`}>
{shortDescription || "暂无分镜描述"}
</p>
</div>
{/* Action row: new + AI generate */} {/* Action row: new + AI generate */}
<div className="h-10 px-3 border-b border-[#edf0f5] flex items-center gap-2 flex-shrink-0 bg-white"> <div className="h-11 px-3 border-b border-[#edf0f5] flex items-center gap-2.5 flex-shrink-0 bg-white">
<button <button
onClick={handleCreate} onClick={handleCreate}
disabled={createSb.isPending || !eid} disabled={createSb.isPending || !eid}
className="flex-1 flex items-center justify-center gap-1.5 py-1.5 rounded-md border border-[#dde3ee] bg-white text-xs font-medium text-[#111827] transition hover:border-[#b9c3d6] disabled:opacity-50" className="flex-1 flex items-center justify-center gap-1.5 px-4 py-2 rounded-md border border-[#dde3ee] bg-white text-xs font-medium text-[#111827] transition hover:border-[#b9c3d6] disabled:opacity-50"
> >
{createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />} {createSb.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : <Plus className="w-3 h-3" />}
新建分镜 新建分镜
...@@ -851,7 +840,7 @@ export function StoryboardWorkspace() { ...@@ -851,7 +840,7 @@ export function StoryboardWorkspace() {
onClick={handleGenerateAll} onClick={handleGenerateAll}
disabled={generateSbs.isPending || !eid} disabled={generateSbs.isPending || !eid}
title="AI批量生成分镜" title="AI批量生成分镜"
className={`p-2 rounded-md text-[#475467] hover:bg-[#f6f9ff] hover:border-[#7657ff]/30 hover:text-[#7657ff] transition-colors disabled:opacity-50 ${CONTROL_SURFACE}`} className={`flex h-8 w-11 items-center justify-center rounded-md text-[#475467] hover:bg-[#f6f9ff] hover:border-[#7657ff]/30 hover:text-[#7657ff] transition-colors disabled:opacity-50 ${CONTROL_SURFACE}`}
> >
{generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />} {generateSbs.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Wand2 className="w-4 h-4" />}
</button> </button>
...@@ -900,6 +889,8 @@ export function StoryboardWorkspace() { ...@@ -900,6 +889,8 @@ export function StoryboardWorkspace() {
onSelectScene={setSceneKey} onSelectScene={setSceneKey}
propKeys={expandedId === sb.id ? propKeys : []} propKeys={expandedId === sb.id ? propKeys : []}
onTogglePropKey={togglePropKey} onTogglePropKey={togglePropKey}
generationMode={expandedId === sb.id ? generationMode : "reference"}
onGenerationModeChange={setGenerationMode}
firstFrameImageKey={expandedId === sb.id ? firstFrameImageKey : sb.firstFrameImageKey ?? ""} firstFrameImageKey={expandedId === sb.id ? firstFrameImageKey : sb.firstFrameImageKey ?? ""}
firstFrameImageUrl={expandedId === sb.id ? firstFrameImageUrl : sb.firstFrameImageUrl ?? ""} firstFrameImageUrl={expandedId === sb.id ? firstFrameImageUrl : sb.firstFrameImageUrl ?? ""}
lastFrameImageKey={expandedId === sb.id ? lastFrameImageKey : sb.lastFrameImageKey ?? ""} lastFrameImageKey={expandedId === sb.id ? lastFrameImageKey : sb.lastFrameImageKey ?? ""}
...@@ -935,12 +926,14 @@ export function StoryboardWorkspace() { ...@@ -935,12 +926,14 @@ export function StoryboardWorkspace() {
</div> </div>
) : ( ) : (
<> <>
<div className="flex items-start gap-1.5 px-3 py-2.5 rounded-xl bg-white border border-[#1c64ff]/15 shadow-[0_4px_14px_rgba(28,100,255,0.06)]"> <div className="flex items-start gap-1.5 rounded-md bg-[#f8fafc] px-2.5 py-1.5">
<Plus className="w-3.5 h-3.5 text-[#98a2b3] mt-0.5 flex-shrink-0" /> <Info className="w-3.5 h-3.5 text-[#98a2b3] mt-0.5 flex-shrink-0" />
<p className="text-[11px] text-[#98a2b3] leading-relaxed"> <p className="text-[11px] text-[#98a2b3] leading-relaxed">
{referenceCount > 0 {generationMode === "frames"
? `已预选 ${referenceCount} 个参考素材,可继续手工调整` ? `已上传 ${frameReferenceCount}/2 张关键帧,将按首帧、尾帧顺序参与生成`
: "可手工选择参考素材;AI 批量生成会自动预选可用角色/场景"} : referenceCount > 0
? `已预选 ${referenceCount} 个参考素材,可继续手工调整`
: "可手工选择参考素材;AI 批量生成会自动预选可用角色/场景"}
</p> </p>
</div> </div>
<textarea <textarea
...@@ -1012,7 +1005,7 @@ export function StoryboardWorkspace() { ...@@ -1012,7 +1005,7 @@ export function StoryboardWorkspace() {
<button <button
onClick={handleGenerate} onClick={handleGenerate}
disabled={!canGenerate || generatingVideoId === expanded?.id} disabled={!canGenerate || generatingVideoId === expanded?.id}
title={!canGenerate ? "需要:至少 1 张参考图(角色/场景/道具/首尾帧任一)+ 提示词" : ""} title={!canGenerate ? generateRequirementText : ""}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-[#7657ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(118,87,255,0.24)] transition-all duration-150 hover:bg-[#6747f4] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap" className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-[#7657ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(118,87,255,0.24)] transition-all duration-150 hover:bg-[#6747f4] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
> >
{generatingVideoId === expanded?.id ? ( {generatingVideoId === expanded?.id ? (
...@@ -1020,7 +1013,7 @@ export function StoryboardWorkspace() { ...@@ -1020,7 +1013,7 @@ export function StoryboardWorkspace() {
) : ( ) : (
<Play className="w-3.5 h-3.5" /> <Play className="w-3.5 h-3.5" />
)} )}
生成视频{videoCredits != null ? ` (${videoCredits.toLocaleString()}积分)` : ""} {generationModeLabel}视频{videoCredits != null ? ` (${videoCredits.toLocaleString()}积分)` : ""}
</button> </button>
</div> </div>
</div> </div>
...@@ -1275,6 +1268,8 @@ interface StoryboardCardProps { ...@@ -1275,6 +1268,8 @@ interface StoryboardCardProps {
onSelectScene: (key: string) => void; onSelectScene: (key: string) => void;
propKeys: string[]; propKeys: string[];
onTogglePropKey: (key: string) => void; onTogglePropKey: (key: string) => void;
generationMode: VideoGenerationMode;
onGenerationModeChange: (mode: VideoGenerationMode) => void;
firstFrameImageKey: string; firstFrameImageKey: string;
firstFrameImageUrl: string; firstFrameImageUrl: string;
lastFrameImageKey: string; lastFrameImageKey: string;
...@@ -1289,6 +1284,10 @@ interface StoryboardCardProps { ...@@ -1289,6 +1284,10 @@ interface StoryboardCardProps {
function StoryboardCard(props: StoryboardCardProps) { function StoryboardCard(props: StoryboardCardProps) {
const { sb, isExpanded, dotClass, onToggle, onDelete } = props; const { sb, isExpanded, dotClass, onToggle, onDelete } = props;
const [descCollapsed, setDescCollapsed] = useState(false); const [descCollapsed, setDescCollapsed] = useState(false);
const modeOptions = [
{ value: "reference", label: "参考图生成", icon: Sparkles },
{ value: "frames", label: "首尾帧生成", icon: ImageIcon },
] as const;
return ( return (
<div className={`my-2 rounded-lg border transition-all duration-200 ${ <div className={`my-2 rounded-lg border transition-all duration-200 ${
...@@ -1308,6 +1307,30 @@ function StoryboardCard(props: StoryboardCardProps) { ...@@ -1308,6 +1307,30 @@ function StoryboardCard(props: StoryboardCardProps) {
{isExpanded && ( {isExpanded && (
<div className="px-3 pb-3 space-y-3 border-t border-[#f1f3f7]"> <div className="px-3 pb-3 space-y-3 border-t border-[#f1f3f7]">
<div className="pt-3">
<div className="grid grid-cols-2 gap-1 rounded-lg border border-[#dfe3ee] bg-[#f8fafc] p-1">
{modeOptions.map((option) => {
const Icon = option.icon;
const active = props.generationMode === option.value;
return (
<button
key={option.value}
type="button"
onClick={() => props.onGenerationModeChange(option.value)}
className={`flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-[11px] font-medium transition-all ${
active
? "bg-white text-[#7657ff] shadow-sm ring-1 ring-[#7657ff]/20"
: "text-muted-foreground hover:bg-white/70 hover:text-[#475467]"
}`}
>
<Icon className="h-3.5 w-3.5" />
{option.label}
</button>
);
})}
</div>
</div>
{/* 分镜描述 */} {/* 分镜描述 */}
<div> <div>
<button <button
...@@ -1330,41 +1353,44 @@ function StoryboardCard(props: StoryboardCardProps) { ...@@ -1330,41 +1353,44 @@ function StoryboardCard(props: StoryboardCardProps) {
)} )}
</div> </div>
{/* 出镜角色 */} {props.generationMode === "reference" ? (
<CharacterRowList <>
characterKeys={props.characterKeys} {/* 出镜角色 */}
keyToCharacterRef={props.keyToCharacterRef} <CharacterRowList
characterRefOptions={props.characterRefOptions} characterKeys={props.characterKeys}
onRemove={props.onRemoveCharacter} keyToCharacterRef={props.keyToCharacterRef}
onSwap={props.onSwapCharacter} characterRefOptions={props.characterRefOptions}
onAdd={props.onAddCharacter} onRemove={props.onRemoveCharacter}
/> onSwap={props.onSwapCharacter}
onAdd={props.onAddCharacter}
{/* 分镜场景 */} />
<SceneSection
scenes={props.scenes} {/* 分镜场景 */}
selectedKey={props.sceneKey} <SceneSection
onSelect={props.onSelectScene} scenes={props.scenes}
/> selectedKey={props.sceneKey}
onSelect={props.onSelectScene}
{/* 场景道具 */} />
<PropGrid
scenes={props.scenes} {/* 场景道具 */}
sceneKey={props.sceneKey} <PropGrid
selectedKeys={props.propKeys} scenes={props.scenes}
onToggle={props.onTogglePropKey} sceneKey={props.sceneKey}
/> selectedKeys={props.propKeys}
onToggle={props.onTogglePropKey}
{/* 首尾帧图 */} />
<FrameReferenceSection </>
firstFrameImageKey={props.firstFrameImageKey} ) : (
firstFrameImageUrl={props.firstFrameImageUrl} <FrameReferenceSection
lastFrameImageKey={props.lastFrameImageKey} firstFrameImageKey={props.firstFrameImageKey}
lastFrameImageUrl={props.lastFrameImageUrl} firstFrameImageUrl={props.firstFrameImageUrl}
onUpload={props.onUploadFrame} lastFrameImageKey={props.lastFrameImageKey}
onClear={props.onClearFrame} lastFrameImageUrl={props.lastFrameImageUrl}
uploading={props.frameUploading} onUpload={props.onUploadFrame}
/> onClear={props.onClearFrame}
uploading={props.frameUploading}
/>
)}
{/* 删除按钮 */} {/* 删除按钮 */}
<div className="pt-1 flex justify-end"> <div className="pt-1 flex justify-end">
...@@ -1564,23 +1590,20 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) { ...@@ -1564,23 +1590,20 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
)} )}
</button> </button>
{/* Bottom row: scene name + role label + actions menu */} {/* Bottom row: selected scene text + actions menu */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="flex-1 min-w-0 px-2.5 py-1.5 rounded-md border border-[#dfe3ee] bg-white text-xs text-[#111827] truncate"> <div className="flex-1 min-w-0 px-1 py-1.5 text-xs text-[#111827] truncate">
{selected?.name || <span className="text-muted-foreground">请选择场景</span>} {selected?.name || <span className="text-muted-foreground">未选择场景</span>}
</div>
<div className="px-2.5 py-1.5 rounded-md border border-[#dfe3ee] bg-white/80 text-xs text-muted-foreground flex-shrink-0">
主场景图
</div> </div>
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<button <button
onClick={() => setMenuOpen((v) => !v)} onClick={() => setMenuOpen((v) => !v)}
disabled={!selected} disabled={usable.length === 0}
className="p-1.5 rounded-md border border-[#dfe3ee] bg-white text-muted-foreground hover:bg-[#f6f9ff] hover:text-[#7657ff] disabled:opacity-40 transition-colors" className="p-1.5 rounded-md border border-[#dfe3ee] bg-white text-muted-foreground hover:bg-[#f6f9ff] hover:text-[#7657ff] disabled:opacity-40 transition-colors"
> >
<MoreHorizontal className="w-3.5 h-3.5" /> <MoreHorizontal className="w-3.5 h-3.5" />
</button> </button>
{menuOpen && selected && ( {menuOpen && usable.length > 0 && (
<> <>
<button <button
className="fixed inset-0 z-10 cursor-default" className="fixed inset-0 z-10 cursor-default"
...@@ -1591,14 +1614,16 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) { ...@@ -1591,14 +1614,16 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
onClick={() => { setShowPicker(true); setMenuOpen(false); }} onClick={() => { setShowPicker(true); setMenuOpen(false); }}
className="w-full px-3 py-1.5 text-left text-xs text-foreground hover:bg-[#f6f9ff]" className="w-full px-3 py-1.5 text-left text-xs text-foreground hover:bg-[#f6f9ff]"
> >
更换场景 场景图
</button>
<button
onClick={() => { onSelect(""); setMenuOpen(false); }}
className="w-full px-3 py-1.5 text-left text-xs text-destructive hover:bg-red-50"
>
清除选择
</button> </button>
{selected && (
<button
onClick={() => { onSelect(""); setMenuOpen(false); }}
className="w-full px-3 py-1.5 text-left text-xs text-destructive hover:bg-red-50"
>
清除选择
</button>
)}
</div> </div>
</> </>
)} )}
...@@ -1610,38 +1635,42 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) { ...@@ -1610,38 +1635,42 @@ function SceneSection({ scenes, selectedKey, onSelect }: SceneSectionProps) {
{usable.length === 0 ? ( {usable.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic text-center py-2">先到「场景」页创建场景</p> <p className="text-[11px] text-muted-foreground italic text-center py-2">先到「场景」页创建场景</p>
) : ( ) : (
<div className="grid grid-cols-3 gap-2"> <>
{usable.map((s) => { <div className="mb-2 flex items-center justify-between">
const active = selectedKey === s.imageTosKey; <span className="text-[11px] font-medium text-[#344054]">选择一个场景图</span>
return ( <span className="text-[10px] text-muted-foreground">单选</span>
<button </div>
key={s.id} <div className="grid grid-cols-3 gap-2">
onClick={() => { {usable.map((s) => {
onSelect(active ? "" : s.imageTosKey!); const active = selectedKey === s.imageTosKey;
if (!active) setShowPicker(false); return (
}} <button
title={s.name} key={s.id}
className={`relative aspect-square rounded-md overflow-hidden border-2 ${ onClick={() => {
active ? "border-[#1c64ff] ring-2 ring-[#1c64ff]/25" : "border-slate-200 hover:border-[#1c64ff]/40" onSelect(s.imageTosKey!);
}`} setShowPicker(false);
> }}
{s.imageUrl ? ( title={s.name}
<img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" /> className={`relative aspect-square rounded-md overflow-hidden border-2 text-left transition ${
) : ( active ? "border-[#1c64ff] ring-2 ring-[#1c64ff]/25" : "border-slate-200 hover:border-[#1c64ff]/40"
<div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div> }`}
)} >
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-1 pt-3 pb-0.5"> {s.imageUrl ? (
<span className="text-[10px] text-white truncate block">{s.name}</span> <img src={s.imageUrl} alt={s.name} className="w-full h-full object-cover" />
</div> ) : (
{active && ( <div className="w-full h-full flex items-center justify-center text-[9px] text-muted-foreground">无图</div>
<div className="absolute top-1 right-1 w-4 h-4 rounded-full bg-[#1c64ff] flex items-center justify-center shadow-sm"> )}
<Check className="w-2.5 h-2.5 text-white" /> <div className="absolute left-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-white/90 shadow-sm ring-1 ring-black/10">
<span className={`h-2.5 w-2.5 rounded-full ${active ? "bg-[#1c64ff]" : "bg-transparent ring-1 ring-slate-400"}`} />
</div> </div>
)} <div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-1 pt-3 pb-0.5">
</button> <span className="text-[10px] text-white truncate block">{s.name}</span>
); </div>
})} </button>
</div> );
})}
</div>
</>
)} )}
</div> </div>
)} )}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment