auto-save 2026-05-13 10:10 (~3)
This commit is contained in:
@@ -601,6 +601,9 @@ function ImageGenCard({ job, frame, onJobUpdate }: {
|
||||
const basePrompt = frame.description?.suggested_prompt ?? "(尚未识别 · 点关键帧打开 lightbox 先识别)"
|
||||
const [editablePrompt, setEditablePrompt] = useState(basePrompt)
|
||||
const [showPrompt, setShowPrompt] = useState(false)
|
||||
const [previewGenId, setPreviewGenId] = useState<string | null>(null)
|
||||
const [previewMounted, setPreviewMounted] = useState(false)
|
||||
useEffect(() => setPreviewMounted(true), [])
|
||||
|
||||
// 当 vision 识别完成后更新默认 prompt
|
||||
useEffect(() => {
|
||||
@@ -760,7 +763,7 @@ function ImageGenCard({ job, frame, onJobUpdate }: {
|
||||
{generating ? "生成中…(约 5-15 秒)" : `⚡ 生成 1 张${gens.length > 0 ? "(再来一张)" : ""}`}
|
||||
</button>
|
||||
|
||||
{/* 生成结果网格 */}
|
||||
{/* 生成结果网格 — 按视频原比例 + 点击放大 */}
|
||||
{gens.length > 0 && (
|
||||
<div className="mt-2.5">
|
||||
<div className="text-[10px] text-[var(--text-faint)] uppercase tracking-widest mb-1.5">
|
||||
@@ -768,31 +771,95 @@ function ImageGenCard({ job, frame, onJobUpdate }: {
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{gens.map((g) => (
|
||||
<button
|
||||
<div
|
||||
key={g.id}
|
||||
onClick={() => handleSelectGen(g.id, g.selected)}
|
||||
title={`${g.mode} · ${g.model}\n${g.prompt}\n${g.selected ? "已选用(点取消)" : "点击选用"}`}
|
||||
className={`relative aspect-square rounded-md overflow-hidden border-2 transition ${
|
||||
className={`relative rounded-md overflow-hidden border-2 transition ${
|
||||
g.selected
|
||||
? "border-emerald-400 ring-2 ring-emerald-400/40"
|
||||
: "border-white/15 hover:border-white/40"
|
||||
}`}
|
||||
style={{ aspectRatio: job.height > 0 ? `${job.width}/${job.height}` : "1/1" }}
|
||||
>
|
||||
<img
|
||||
src={generatedImageUrl(job.id, frame.index, g.id)}
|
||||
alt={`gen ${g.id}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{g.selected && (
|
||||
<div className="absolute top-1 right-1 bg-emerald-500 text-white rounded-full p-0.5">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewGenId(g.id)}
|
||||
title={`${g.mode} · ${g.model}\n${g.prompt}`}
|
||||
className="absolute inset-0 w-full h-full cursor-zoom-in"
|
||||
>
|
||||
<img
|
||||
src={generatedImageUrl(job.id, frame.index, g.id)}
|
||||
alt={`gen ${g.id}`}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
{/* 右上角独立选用按钮(总显示,selected=绿) */}
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSelectGen(g.id, g.selected) }}
|
||||
title={g.selected ? "已选用 · 点取消" : "点击选用"}
|
||||
className={`absolute top-1 right-1 h-5 w-5 rounded-full inline-flex items-center justify-center transition shadow-md ${
|
||||
g.selected
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-400"
|
||||
: "bg-black/60 backdrop-blur text-white/60 hover:bg-emerald-500 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 大图预览 modal */}
|
||||
{previewGenId && previewMounted && createPortal(
|
||||
(() => {
|
||||
const g = gens.find((x) => x.id === previewGenId)
|
||||
if (!g) return null
|
||||
return (
|
||||
<div
|
||||
onClick={() => setPreviewGenId(null)}
|
||||
className="fixed inset-0 z-[200] bg-black/85 backdrop-blur-xl flex items-center justify-center p-6 cursor-zoom-out"
|
||||
style={{ animation: "drawer-in 0.18s cubic-bezier(0.32, 0.72, 0, 1)" }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative max-w-full max-h-full flex flex-col gap-2 cursor-default"
|
||||
>
|
||||
<img
|
||||
src={generatedImageUrl(job.id, frame.index, g.id)}
|
||||
alt={`gen ${g.id}`}
|
||||
className="block rounded-lg object-contain"
|
||||
style={{ maxHeight: "calc(100vh - 140px)", maxWidth: "calc(100vw - 80px)" }}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div className="text-white/70 text-[11px] font-mono truncate">
|
||||
分镜 {frame.index + 1} · {g.mode === "edit" ? "i2i" : "text"} · {g.model}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
onClick={() => handleSelectGen(g.id, g.selected)}
|
||||
className={`text-[11.5px] px-3 py-1.5 rounded-md inline-flex items-center gap-1.5 transition ${
|
||||
g.selected
|
||||
? "bg-emerald-500 text-white hover:bg-emerald-400"
|
||||
: "bg-white/10 text-white hover:bg-white/20"
|
||||
}`}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{g.selected ? "已选用" : "选用此图"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPreviewGenId(null)}
|
||||
className="text-[11.5px] px-2.5 py-1.5 rounded-md bg-white/10 text-white hover:bg-white/20 inline-flex items-center gap-1"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})(),
|
||||
document.body
|
||||
)}
|
||||
</KanbanCard>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Mic, Languages, FileEdit, Sparkles, Film, FileVideo, Loader2, Plus,
|
||||
} from "lucide-react"
|
||||
import { NodeShell, type NodeStatus, type NodeKind } from "./node-shell"
|
||||
import { type Job, frameUrl, videoUrl } from "@/lib/api"
|
||||
import { type Job, frameUrl, videoUrl, generatedImageUrl } from "@/lib/api"
|
||||
|
||||
export interface NodeData {
|
||||
job: Job | null // 当前 active job
|
||||
@@ -534,26 +534,128 @@ export function RewriteNode({ selected }: any) {
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
8. ImageGenNode (placeholder)
|
||||
8. ImageGenNode — 显示 selected frames 的代表生成图
|
||||
============================================================ */
|
||||
export function ImageGenNode({ selected }: any) {
|
||||
const IMAGEGEN_WIDTH = 320
|
||||
|
||||
export function ImageGenNode({ data, selected }: any) {
|
||||
const d: NodeData = data
|
||||
const job = d?.job
|
||||
const selectedIdxs = Array.from(d?.selectedFrames ?? []).sort((a: number, b: number) => a - b) as number[]
|
||||
|
||||
// 每个 selected frame 取一张代表图:优先 selected gen,否则最新一张
|
||||
const previews = selectedIdxs
|
||||
.map((idx) => {
|
||||
const f = job?.frames.find((x) => x.index === idx)
|
||||
if (!f || !f.generated_images || f.generated_images.length === 0) return null
|
||||
const sel = f.generated_images.find((g) => g.selected)
|
||||
const pick = sel ?? f.generated_images[f.generated_images.length - 1]
|
||||
return { frameIdx: idx, gen: pick, hasSelected: !!sel, total: f.generated_images.length }
|
||||
})
|
||||
.filter((p): p is { frameIdx: number; gen: NonNullable<ReturnType<typeof Object>>; hasSelected: boolean; total: number } => p !== null)
|
||||
|
||||
const totalGens = job?.frames.reduce((sum, f) => sum + (f.generated_images?.length ?? 0), 0) ?? 0
|
||||
const selectedCount = previews.filter((p) => p.hasSelected).length
|
||||
|
||||
const status: NodeStatus = !job ? "pending" : totalGens > 0 ? "done" : "pending"
|
||||
const aspect = job && job.height > 0 ? `${job.width}/${job.height}` : "9/16"
|
||||
|
||||
return (
|
||||
<NodeShell
|
||||
type="ai" status="pending"
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
title="生图 · Image Gen"
|
||||
subtitle="STEP 8 · nano-banana / GPT"
|
||||
selected={selected}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-[10.5px] text-[var(--text-faint)]">
|
||||
nano-banana-pro<br /><span className="text-[var(--text-strong)] text-[11px]">Gemini 3 Image</span>
|
||||
<div className="relative" style={{ width: IMAGEGEN_WIDTH }}>
|
||||
{/* 节点上方:每帧 1 张缩略图 */}
|
||||
{previews.length > 0 && job && (
|
||||
<div
|
||||
className="absolute left-0 right-0 grid grid-cols-5 gap-1.5"
|
||||
style={{ bottom: "calc(100% + 12px)" }}
|
||||
>
|
||||
{previews.map((p) => (
|
||||
<button
|
||||
key={p.frameIdx}
|
||||
onClick={(e) => { e.stopPropagation(); d.onExpandFrame(p.frameIdx) }}
|
||||
title={`分镜 ${p.frameIdx + 1} · ${p.total} 张${p.hasSelected ? " · 已选用" : ""}`}
|
||||
className={`group relative rounded-md border transition shadow-lg hover:-translate-y-0.5 ${
|
||||
p.hasSelected
|
||||
? "border-emerald-400 ring-2 ring-emerald-400/60"
|
||||
: "border-pink-300/40 dark:border-pink-300/30"
|
||||
}`}
|
||||
style={{ aspectRatio: aspect }}
|
||||
>
|
||||
<img
|
||||
src={generatedImageUrl(job.id, p.frameIdx, p.gen.id)}
|
||||
alt={`gen ${p.gen.id}`}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
{p.total > 1 && (
|
||||
<div className="absolute top-0 left-0 bg-black/70 text-white text-[8.5px] font-mono px-1 py-0.5 leading-none rounded-tl-md rounded-br">
|
||||
{p.total}
|
||||
</div>
|
||||
)}
|
||||
{p.hasSelected && (
|
||||
<div className="absolute inset-0 bg-emerald-400/15 rounded-md pointer-events-none" />
|
||||
)}
|
||||
|
||||
{/* Hover 大图预览 */}
|
||||
<div
|
||||
className="pointer-events-none absolute opacity-0 group-hover:opacity-100 scale-95 group-hover:scale-100 transition-all duration-200 z-[60]"
|
||||
style={{
|
||||
bottom: "calc(100% + 10px)",
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
transformOrigin: "bottom center",
|
||||
}}
|
||||
>
|
||||
<div className="rounded-2xl overflow-hidden border border-white/25 bg-black" style={{ boxShadow: "0 40px 100px -20px rgba(0,0,0,0.85), 0 0 0 1px rgba(255,255,255,0.06)" }}>
|
||||
<img
|
||||
src={generatedImageUrl(job.id, p.frameIdx, p.gen.id)}
|
||||
alt={`preview ${p.gen.id}`}
|
||||
className="block"
|
||||
style={{
|
||||
width: IMAGEGEN_WIDTH * 2,
|
||||
maxWidth: "min(720px, 80vw)",
|
||||
height: "auto",
|
||||
maxHeight: "82vh",
|
||||
objectFit: "contain",
|
||||
}}
|
||||
/>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-black/70 backdrop-blur-md">
|
||||
<span className="text-white text-[12.5px] font-medium">分镜 {p.frameIdx + 1} · 生成图</span>
|
||||
<span className="text-white/60 text-[11px] font-mono">{p.gen.mode === "edit" ? "i2i" : "text"} · {p.total} 张</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-[10.5px] text-[var(--text-faint)]">
|
||||
GPT Image<br /><span className="text-[var(--text-strong)] text-[11px]">OpenAI</span>
|
||||
</div>
|
||||
</div>
|
||||
</NodeShell>
|
||||
)}
|
||||
|
||||
<NodeShell
|
||||
type="ai" status={status}
|
||||
icon={<Sparkles className="h-4 w-4" />}
|
||||
title="生图 · Image Gen"
|
||||
subtitle={`STEP 6 · nano-banana ${totalGens > 0 ? `· ${totalGens} 张` : ""}`}
|
||||
width={IMAGEGEN_WIDTH}
|
||||
selected={selected}
|
||||
>
|
||||
{totalGens > 0 ? (
|
||||
<div className="text-[11.5px] leading-relaxed text-[var(--text-soft)]">
|
||||
已生成 <span className="text-[var(--text-strong)] font-medium">{totalGens}</span> 张 · 选用 {selectedCount}/{previews.length}
|
||||
<br />
|
||||
<span className="text-[10.5px] text-[var(--text-faint)]">
|
||||
上方缩略图点击展开分镜 · 在 sidebar「生图」节点调参再生成
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<div className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-[10.5px] text-[var(--text-faint)]">
|
||||
nano-banana-pro<br /><span className="text-[var(--text-strong)] text-[11px]">Gemini 3 Image</span>
|
||||
</div>
|
||||
<div className="rounded-md border border-dashed border-black/15 dark:border-white/10 px-2 py-1.5 text-[10.5px] text-[var(--text-faint)]">
|
||||
GPT Image<br /><span className="text-[var(--text-strong)] text-[11px]">OpenAI</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user