diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 2f5dfb5..4be4a17 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1069,8 +1069,12 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = " elif kind == BaseAssetGroup.Kind.PERSON: gen_prompt = build_person_frontal_prompt(prompt) # 人物立绘:包成「电商真人模特/正面全身/纯色背景」统一模板 else: - # 场景图:默认模板只是 {场景描述} 本身(零回归);可在 admin「提示词」页加全局场景风格 + # 场景图 = 空镜。不论 admin「提示词」页的 scene 模板写没写,都强制叠加「无真人」约束: + # 否则模型常自作主张往场景里塞真人模特,下游用这张场景图生成视频时被火山以 + # 「输入图像可能包含真实人物」拒绝(ZWQ#13)。已含「真人」字样则不重复叠加。 gen_prompt = render_prompt("scene", "{场景描述}", 场景描述=(prompt or "").strip()) + if "真人" not in gen_prompt: + gen_prompt = f"{gen_prompt},空镜场景、只有环境与陈设,画面中不要出现任何真人 / 人物 / 模特 / 人脸 / 人手,干净构图" payload = { "model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt, "kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "", @@ -2069,6 +2073,13 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE) count = max(1, min(int(count or 1), 12)) ref_ids = [str(r) for r in (reference_image_ids or []) if r] + # 带参考图的图片创作必须走 image_edit(gpt-image)才真的还原上传素材——火山 Seedream 的图生图只松散 + # 借色调、不锁主体(与三视图/模特上身图同理,那两条流程也强制 image_edit)。当前模型不支持 image_edit + # 就自动切到 gpt-image-2,否则用户选了火山却传了参考图 → 生成完全不参考素材(用户实测的根因)。 + if ref_ids and not hasattr(build_provider(model_config), "image_edit"): + alt = resolve_image_model("gpt-image") + if alt is not None: + model_config = alt # 本次提交 = 一组(模特上身图组 / 平台套图组):同一 batch_id 串起这批图,前端可成组展示。 batch_id = str(uuid.uuid4()) tasks: list[AITask] = [] diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index b88d9fe..b74ce86 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -270,6 +270,26 @@ class StandaloneImageReferenceTests(TestCase): self.assertIn("严格保留", used_prompt) prov.image_generation.assert_not_called() + def test_refs_force_image_edit_model_when_volcano_selected(self): + """用户选了火山 Seedream(只有图生图、不会真锁主体)却传了参考图时,必须自动切到支持 + image_edit 的 gpt-image-2——否则参考图形同虚设。回归保护「选火山+传参考图=完全不参考」。""" + from apps.ai.models import ModelProvider + + vp = ModelProvider.objects.create(name="volcengine", display_name="火山") + ModelConfig.objects.create(provider=vp, name="seedream-4", display_name="Seedream", capability=ModelConfig.Capability.IMAGE) + self._patch_provider() + ref = Asset.objects.create( + team=self.team, created_by=self.user, name="背心", asset_type=Asset.Type.IMAGE, + source=Asset.Source.UPLOAD, category=Asset.Category.UPLOAD, + ) + AssetFile.objects.create(asset=ref, object_key="r.png", bucket="b", content_type="image/png", preview_url="http://x/ref.png", is_primary=True) + tasks = enqueue_standalone_images( + team=self.team, user=self.user, prompt="不同场景", mode="image", count=1, + image_model="volcano", reference_image_ids=[str(ref.id)], + ) + # 任务最终落在支持 image_edit 的模型(gpt-image),而不是用户选的火山 Seedream + self.assertIn("gpt-image", tasks[0].model_config.name) + class ImageConversationTests(TestCase): """图片创作「对话」实体:CRUD + 团队隔离 + 软删 + 生图自动归属对话 + 任务回填。""" diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index daa440a..216bf3f 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -624,6 +624,30 @@ class ProjectApiTests(TestCase): self.assertEqual(clip.trim_start_ms, 0) self.assertIsNone(clip.trim_end_ms) + def test_video_done_settles_even_if_stage_stuck_at_storyboard(self): + """ZWQ#15:项目 current_stage 还停在 storyboard,但视频段都出片并采用 → 必须收口成 completed, + 而不是永远卡在「3/4 故事板」。修复前 settle_video_completion 因 current_stage != VIDEO 直接返回 → 卡死。""" + project = Project.objects.create( + team=self.team, created_by=self.user, product=self.product, name="P", + current_stage=ProjectStage.Stage.STORYBOARD, status=Project.Status.STORYBOARDING, + ) + seg = VideoSegment.objects.create(project=project, sort_order=0, target_duration_seconds=15) + asset = Asset.objects.create( + team=self.team, created_by=self.user, name="clip", + asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP, + ) + ver = VideoSegmentVersion.objects.create(video_segment=seg, asset=asset) + + res = self.client.post( + f"/api/projects/{project.id}/adopt-video-version/", + {"video_segment_id": str(seg.id), "version_id": str(ver.id)}, + format="json", + ) + self.assertEqual(res.status_code, 200) + project.refresh_from_db() + self.assertEqual(project.current_stage, ProjectStage.Stage.VIDEO) + self.assertEqual(project.status, Project.Status.COMPLETED) + def _make_audio_model(self): return ModelConfig.objects.create( provider=self.provider, name="doubao-voice-bigtts", display_name="豆包语音合成", diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 3b02824..ccd0a8b 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -160,6 +160,16 @@ def promote_base_asset_stage_if_ready(project: Project) -> bool: VIDEO_IS_FINAL_STAGE = True +def _ensure_video_stage(project: Project) -> None: + """幂等地把项目推进到「视频」阶段(生成视频片段时调用)。current_stage 没到 VIDEO 时, + settle_video_completion 会直接返回 → 全段出片也不收口、卡在故事板格(ZWQ#15)。""" + if project.current_stage != ProjectStage.Stage.VIDEO: + project.current_stage = ProjectStage.Stage.VIDEO + if project.status != Project.Status.COMPLETED: + project.status = Project.Status.VIDEOING + project.save(update_fields=["current_stage", "status", "updated_at"]) + + def settle_video_completion(project: Project) -> bool: """视频阶段收口(幂等): · 全部片段已出片(SUCCEEDED + 有采用版)→ 项目 COMPLETED、VIDEO 阶段标成功; @@ -745,7 +755,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): timeline.clips.filter(asset_id__in=segment_asset_ids).exclude(asset_id=version.asset_id).update( asset_id=version.asset_id, trim_start_ms=0, trim_end_ms=None ) - # 采用历史版后该段为已出片 → 若全部段就绪,收口项目为 completed + # 采用历史版后该段为已出片 → 先确保在视频阶段,再收口项目为 completed(否则卡 3/4,ZWQ#15) + _ensure_video_stage(project) settle_video_completion(project) return Response(ProjectSerializer(project).data) @@ -856,6 +867,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30) except Exception: # noqa: BLE001 logger.error("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True) + # 生成视频即代表已进入视频阶段:某些路径(跳过故事板/异常)可能没把 current_stage 推到 VIDEO, + # 而 settle_video_completion 只在 current_stage==VIDEO 时收口 → 不补推的话,全段出片后项目会永远停在 + # 「3/4 故事板」、永不 completed(ZWQ#15)。这里幂等补推。 + _ensure_video_stage(project) # 重跑某段 → 该段回到在制;若项目此前已 completed,退回 videoing settle_video_completion(project) return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED) @@ -867,7 +882,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): version = poll_video_segment(video_segment=segment, user=request.user) if version is None: return Response({"status": segment.status}, status=status.HTTP_202_ACCEPTED) - # 这一段刚出片 → 若是最后一段,收口整个项目为 completed(视频是末阶段) + # 这一段刚出片 → 先确保已在视频阶段(否则 settle 不收口,卡 3/4),再收口整个项目为 completed + _ensure_video_stage(project) settle_video_completion(project) return Response(VideoSegmentVersionSerializer(version).data) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index a7e807a..f63e1ad 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -436,12 +436,16 @@ export function App() { // 用 ref 而非 loading state,避免闭包拿到旧值;同步置位,任何同一 tick 的二次点击都拦得住。 const actionInFlightRef = useRef(false); - async function action(work: () => Promise, successText: string, opts?: { liteRefresh?: boolean }): Promise { - if (actionInFlightRef.current) { - setNotice({ type: "error", text: "操作进行中,请稍候…" }); - return null; + async function action(work: () => Promise, successText: string, opts?: { liteRefresh?: boolean; concurrent?: boolean }): Promise { + // concurrent=true:可并行的长任务(图片生成/重跑——工作台明确支持多批并行),不参与全局单飞锁, + // 否则「正在生成时点另一张重跑」会被单飞锁拒成 null → 该批被标「失败」(PMC#8)。 + if (!opts?.concurrent) { + if (actionInFlightRef.current) { + setNotice({ type: "error", text: "操作进行中,请稍候…" }); + return null; + } + actionInFlightRef.current = true; } - actionInFlightRef.current = true; setLoading(true); setNotice(null); try { @@ -459,7 +463,7 @@ export function App() { return null; } finally { setLoading(false); - actionInFlightRef.current = false; + if (!opts?.concurrent) actionInFlightRef.current = false; } } @@ -501,14 +505,17 @@ export function App() { if (res) setUser(res); } - function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[] }) { + function generateImages(payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; reference_product?: boolean; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) { // 异步生图:提交后立刻拿到任务列表,前端轮询直到出图。慢的 ARK 出图在 Celery worker 里跑—— // Web 层不被 ~30s 请求占住 → 健康探针不饿死 → 根治"几张图整站 502";且提交成功后浏览器关掉/断网, // worker 仍会把图生成并落库(扣费/退费在 worker 内闭环),重开素材库即可见。 + const { onSubmitted, ...apiPayload } = payload; // onSubmitted 是本地回调,不发后端 return action(async () => { - const { tasks, conversation_id } = await api.submitGenerateImage(payload); + const { tasks, conversation_id } = await api.submitGenerateImage(apiPayload); const ids = tasks.map((t) => t.id); if (ids.length === 0) throw new Error("未能提交生成任务"); + // 把刚提交的任务 id 回传给工作台,让它按「批」各自落盘 pending → 切走再回来每一批都能恢复并续轮询(PMC#5/#10) + onSubmitted?.(ids); // 提交成功即落盘:刷新页面也能恢复"生成中"并继续轮询(任务在 worker 里跑,关掉浏览器也不丢) // 记下本批所属商品(id+名),恢复在途批次时用它当导航头,而不是用「当前选中商品」(切走再回会显示错名) const batchProduct = products.find((p) => p.id === payload.product_id); @@ -516,7 +523,8 @@ export function App() { const res = await pollImageTasks(payload.mode, ids); // 回传后端归属/新建的对话 id,供工作室把它登记进左栏会话列表并设为 active return { ...res, conversation_id }; - }, "图片已生成"); + // concurrent:生图支持多批并行,不占全局单飞锁(否则并发重跑被拒成 null→标失败,PMC#8) + }, "图片已生成", { concurrent: true }); } // 轮询一批已提交的生图任务直到全部出图/超时;每出一张就回写本地,刷新后可恢复。 diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index 7935529..f11a5b9 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -697,16 +697,18 @@ position: relative; aspect-ratio: var(--ratio, 1 / 1); border-radius: var(--r-md); - overflow: hidden; + /* 不在此裁切:否则「更多」气泡(.gen-bubble)作为后代会被 overflow:hidden 切掉。 + 改为给图片/占位各自圆角,卡片保持圆角的同时让气泡能溢出展示。 */ cursor: pointer; } -.image-workbench .gen-image .placeholder { position: absolute; inset: 0; } +.image-workbench .gen-image .placeholder { position: absolute; inset: 0; border-radius: var(--r-md); overflow: hidden; } /* 生成结果真图 · 填满 .gen-image(比例由容器 aspect-ratio 控制) */ .image-workbench .gen-image-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; + border-radius: var(--r-md); background: var(--black-alpha-4); } /* 右上浮层按钮组(§4.18 .gen-image-actions) */ diff --git a/core/frontend/src/components/actor-library.tsx b/core/frontend/src/components/actor-library.tsx index d16e682..eb85a13 100644 --- a/core/frontend/src/components/actor-library.tsx +++ b/core/frontend/src/components/actor-library.tsx @@ -134,21 +134,39 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic if (r) setTriReady(true); } finally { setBusy(false); } } - // 保存人物:写名字(若有改名能力)→ 刷新 → 关工作台 → 切「我的演员」tab + // 保存人物:写名字(若有改名能力)→ 加入资产库 → 刷新 → 关工作台 → 切「我的演员」tab async function saveActor() { if (!picked) return; setBusy(true); try { const name = actorName.trim(); if (name && name !== picked.name && onRename) await onRename(picked.id, name); + // 关键:AI 生成/上传得到的立绘默认 in_library=False(工作台暂存),不入「我的演员」列表(只列已入库 person)。 + // 「保存人物」必须把它加入资产库,否则保存后「我的演员」始终 0 个演员(ZWQ#6)。 + await api.setAssetLibrary(picked.id, true).catch(() => undefined); onRefresh(); - void reload(); // 新保存的人物即时进「我的演员」列表(自取,不依赖父级全局 assets) + await reload(); // 新保存的人物即时进「我的演员」列表(自取,不依赖父级全局 assets) setStudio(false); setCandidates([]); setPicked(null); setActorName(""); setTriReady(false); setTab("mine"); } finally { setBusy(false); } } + // 删除「我的演员」(软删 person 资产)· 两步确认,3s 自动撤销(ZWQ#8) + const [delArmId, setDelArmId] = useState(null); + const [delBusyId, setDelBusyId] = useState(null); + async function deleteActor(id: string) { + setDelBusyId(id); + try { + await api.deleteAsset(id); + onRefresh(); + await reload(); + } catch { /* 删除失败保留卡片,可重试 */ } finally { + setDelBusyId(null); + setDelArmId(null); + } + } + if (!open) return null; return createPortal(
{ if (e.target === e.currentTarget) onClose(); }}> @@ -262,8 +280,21 @@ export function ActorLibrary({ open, mode, initialStudio, assets, onClose, onPic
{pageList.map((a) => { const url = previewOf(a); + // 「我的演员」(非模特库预设)可删:右上角垃圾桶,两步确认(ZWQ#8) + const canDelete = tab === "mine" && !isPreset(a); return ( -
+
+ {canDelete && (delArmId === a.id ? ( + + + + + ) : ( + + ))}
{ if (mode === "replace") { void onPick?.(a.id, a.name); } else if (url) { setPreview({ src: url, name: a.name }); } }} diff --git a/core/frontend/src/routes/ai-tools.tsx b/core/frontend/src/routes/ai-tools.tsx index 2aa7eef..9b19f57 100644 --- a/core/frontend/src/routes/ai-tools.tsx +++ b/core/frontend/src/routes/ai-tools.tsx @@ -510,9 +510,10 @@ const MODE_META: Record< cover: { title: "平台套图", tag: "[ PLATFORM · KIT ]", - desc: "选择平台模板,一键生成主图 / 封面 / 详情套图。", + desc: "选择平台模板,一键生成电商平台头图 / 主图。", ratio: "4:5", - promptTemplate: (title) => `${title},电商平台套图,统一视觉,主图 + 详情排版` + // YYX#4:只出平台头图,去掉「详情排版」 + promptTemplate: (title) => `${title},电商平台头图,统一视觉,主图特写` } }; @@ -582,6 +583,8 @@ type GenBatch = { platformIds?: string[]; /** 该批次提交的参考图(图片创作:用户上传作生成参考),用于批次头回显「参考了哪些图」 */ refs?: { name: string; url: string }[]; + /** 该批次已提交的生图任务 id:落盘后切走再回来可据此对每一批各自续轮询(PMC#5/#10) */ + pendingIds?: string[]; }; export function ImageWorkbenchPage({ @@ -600,7 +603,7 @@ export function ImageWorkbenchPage({ modelConfigs: ModelConfig[]; onBack: () => void; navigate?: (page: Page) => void; - onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[] }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>; + onGenerate: (payload: { prompt: string; mode?: "image" | "model" | "cover"; count?: number; product_id?: string; model_id?: string; ratio?: string; image_model?: string; conversation_id?: string; reference_image_ids?: string[]; onSubmitted?: (taskIds: string[]) => void }) => Promise<{ assets: Asset[]; conversation_id?: string } | null>; onResume?: (mode: "image" | "model" | "cover", ids: string[]) => Promise<{ assets: Asset[] } | null>; /** 行19:从商品页带入的初始商品 id(可选);未传则回退到第一个商品 */ initialProductId?: string; @@ -732,14 +735,10 @@ export function ImageWorkbenchPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [productId, mode]); - // 模特:单选(再点同一张取消,点别张替换);平台:多选(toggle 进/出数组) + // 模特 / 平台都单选(YYX#1:平台只能单选):再点同一张取消,点别张替换 function togglePick(id: string, name?: string) { - if (mode === "cover") { - setPickedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); - return; - } setPickedIds((prev) => (prev[0] === id ? [] : [id])); - setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || "")); + if (mode !== "cover") setPickedModelName((prev) => (pickedIds[0] === id ? "" : name || "")); } // 单图就地反馈(§4.18):显示 1.5s 后自动撤掉 function flashFeedback(key: string) { @@ -770,8 +769,12 @@ export function ImageWorkbenchPage({ const batchKey = `airshelf:imgwb:batches:${mode}`; function persistBatches(list: GenBatch[]) { try { - // 只持久化已出图/失败的批次结果 + 元信息(generating 占位不落盘,避免刷新后卡死) - const slim = list.map((b) => ({ ...b, status: b.status === "generating" ? ("done" as const) : b.status })); + // 已出图/失败的批次照常落盘;仍在跑(generating)但已拿到 pendingIds 的批次也落盘(保留 generating + pendingIds), + // 这样切走再回来每一批都能按各自的 pendingIds 续轮询,不再「数量少 / 只恢复一批」(PMC#5/#10)。 + // 还没拿到 pendingIds 的占位批次(刚点、尚未提交成功)不落盘,避免恢复出一个永远空转的壳。 + const slim = list + .filter((b) => b.status !== "generating" || (b.pendingIds && b.pendingIds.length)) + .map((b) => ({ ...b, refs: undefined })); // refs 含本地 blob url,不落盘 localStorage.setItem(batchKey, JSON.stringify({ batches: slim, ts: Date.now() })); } catch { /* localStorage 不可用时静默降级 */ @@ -955,7 +958,9 @@ export function ImageWorkbenchPage({ referenceImageIds = ids.filter((x): x is string => !!x); } // 带上当前对话 id(空则后端自动开一条并回传);conversation_id 用 ref 取最新值,避免闭包旧值 - const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds }); + // onSubmitted:提交成功拿到任务 id 即把本批 pendingIds 落盘 → 切走再回来本批能各自续轮询(PMC#5/#10) + const result = await onGenerate({ prompt: opts.prompt, mode, count: opts.count, product_id: opts.productId, model_id: opts.modelId, ratio: opts.ratio, image_model: genModel, conversation_id: activeConvRef.current || undefined, reference_image_ids: referenceImageIds, + onSubmitted: (taskIds) => setBatches((prev) => { const next = prev.map((b) => (b.id === batchId ? { ...b, pendingIds: taskIds } : b)); persistBatches(next); return next; }) }); // 首发新对话:把后端建的对话登记进左栏并设为 active;刷新列表拿到真标题/计数 const convId = result?.conversation_id; if (convId && convId !== activeConvRef.current) { @@ -1002,7 +1007,7 @@ export function ImageWorkbenchPage({ // 平台名拼进提示词,让各平台套图按其版式倾向出图(后端无 platform 字段,靠 prompt 表达) for (const pid of pickedIds) { const platName = PLATFORM_OPTIONS.find((p) => p.id === pid)?.name; - void startBatch({ ...base, prompt: platName ? `${base.prompt},${platName}平台套图版式` : base.prompt, platformIds: [pid] }); + void startBatch({ ...base, prompt: platName ? `${base.prompt},${platName}平台头图版式` : base.prompt, platformIds: [pid] }); } return; } @@ -1116,55 +1121,39 @@ export function ImageWorkbenchPage({ 再读 App 写的单批 `airshelf:imgwb:{mode}`(仍在跑的任务),对其继续轮询补一个恢复批次。 */ useEffect(() => { let cancelled = false; - // image 模式的历史现由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架 + // image 模式的历史由后端对话(loadConversations → loadConvBatches)驱动,跳过本地残留回显,避免双源打架 if (mode === "image") return; - // 1) 多批次结果回显 + // 模特/平台套图:从多批次残留恢复「全部」批次 —— 已出图/失败的直接回显;仍在跑(带 pendingIds)的 + // 恢复成 generating 并各自按本批 pendingIds 续轮询。这样切走再回来,多批/多平台都在,不再「数量少 / 分类丢」(PMC#5/#10)。 try { const raw = localStorage.getItem(batchKey); if (raw) { const saved = JSON.parse(raw) as { batches?: GenBatch[]; ts?: number }; if (!(saved.ts && Date.now() - saved.ts > 60 * 60 * 1000)) { - const restored = (saved.batches || []).filter((b) => b.results && b.results.length); - if (restored.length) setBatches(restored.map((b) => ({ ...b, status: "done" as const }))); + const all = saved.batches || []; + if (all.length) { + setBatches(all); + if (onResume) { + for (const b of all) { + if (b.status !== "generating" || !(b.pendingIds && b.pendingIds.length)) continue; + onResume(mode, b.pendingIds).then((res) => { + if (cancelled) return; + setBatches((prev) => { + const next = prev.map((x) => + x.id === b.id ? { ...x, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || x.results } : x + ); + persistBatches(next); + return next; + }); + }); + } + } + } } } } catch { /* 残留解析失败则忽略 */ } - // 2) App 单批槽:仍在跑的任务继续轮询(无法多批恢复,见回报) - try { - const raw = localStorage.getItem(`airshelf:imgwb:${mode}`); - if (!raw) return; - const saved = JSON.parse(raw) as { pending?: string[]; results?: Asset[]; count?: number; ts?: number; productId?: string; productTitle?: string }; - if (saved.ts && Date.now() - saved.ts > 60 * 60 * 1000) return; - const pending = saved.pending || []; - if (pending.length && onResume) { - const batchId = `b-resume-${Date.now()}`; - setBatches((prev) => { - // 已由多批次残留恢复过结果就不重复加占位 - if (prev.some((b) => b.results.length) && !(saved.results && saved.results.length)) return prev; - return [ - ...prev, - // 商品归属用生成时落盘的 saved.productId/Title,绝不用「当前选中商品」——切走再回来当前选中可能已变, - // 那正是导航头显示错名(如把耳机包批次显示成蓝牙耳机)的根因。 - { id: batchId, prompt: meta.promptTemplate(saved.productTitle || product?.title || "商品"), ratio: meta.ratio, count: saved.count || candidateCount, status: "generating" as const, results: saved.results || [], adopted: false, ts: Date.now(), productId: saved.productId, productTitle: saved.productTitle } - ]; - }); - onResume(mode, pending) - .then((res) => { - if (cancelled) return; - setBatches((prev) => { - const next = prev.map((b) => - b.id === batchId ? { ...b, status: res?.assets ? ("done" as const) : ("failed" as const), results: res?.assets || b.results } : b - ); - persistBatches(next); - return next; - }); - }); - } - } catch { - /* 残留解析失败则忽略 */ - } return () => { cancelled = true; }; @@ -1172,7 +1161,18 @@ export function ImageWorkbenchPage({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [mode]); - const hasResults = batches.length > 0; + // YYX#3:工作台数据按商品隔离 —— 模特/平台模式右侧结果只展示当前选中商品的批次 + // (旧批次无 productId 则全局可见,兜底);image 模式按对话维度,沿用全部 batches。 + // YYX#12:右上搜索框(gridQuery)在平台套图下用来筛「工作台图片」(按提示词匹配),不再清空左侧平台网格。 + const productBatches = useMemo(() => { + let list = mode === "image" ? batches : batches.filter((b) => !b.productId || b.productId === productId); + if (mode === "cover" && gridQuery.trim()) { + const q = gridQuery.trim().toLowerCase(); + list = list.filter((b) => (b.prompt || "").toLowerCase().includes(q)); + } + return list; + }, [batches, productId, mode, gridQuery]); + const hasResults = productBatches.length > 0; /* ── 单个批次的结果网格 · §4.18 gen-card 三件套 ── 每张 .gen-image 内:① 右上 .gen-image-actions(再生成 / 下载 / 更多→加入·删除) @@ -1187,14 +1187,19 @@ export function ImageWorkbenchPage({ className="gen-images" style={{ "--cols": cols, "--ratio": ratioBatchVar } as React.CSSProperties} > - {(batch.results.length - ? batch.results.map((asset, index) => ({ key: asset.id, index, url: asset.files?.[0]?.preview_url, assetId: asset.id, inLib: !!asset.in_library })) - : Array.from({ length: batch.count }).map((_, index) => ({ key: `ph-${index}`, index, url: undefined as string | undefined, assetId: "", inLib: false })) - ).map(({ key, index, url, assetId, inLib }) => { + {/* YYX#5:渲染满 count 个格子 —— 已出图的格用真图;非生成中又缺图的格 = 该张失败, + 给失败占位符 + 单张重跑,不再因「部分失败」整批不显示失败格。 */} + {Array.from({ length: Math.max(batch.count, batch.results.length) }).map((_, index) => { + const asset = batch.results[index]; + const url = asset?.files?.[0]?.preview_url; + const assetId = asset?.id || ""; + const inLib = !!asset?.in_library; + const failed = !generating && !asset; // 非生成中且该格无图 → 这张失败了 + const key = assetId || `ph-${index}`; const cellKey = `${batch.id}:${assetId || key}`; const showFeedback = feedbackKey === `${batch.id}:${assetId}` && !!assetId; return ( -
+
{url ? ( {`${meta.title} setPreview({ src: url, name: `${meta.title} #${index + 1}` })} /> ) : ( @@ -1203,6 +1208,16 @@ export function ImageWorkbenchPage({ + ) : failed ? ( + /* YYX#5:这张失败了 —— 占位提示 + 单张重跑 */ + + + 这张生成失败 + + ) : ( {batch.ratio} · #{index + 1} )} @@ -1679,21 +1694,7 @@ export function ImageWorkbenchPage({ {product?.title || "未选择 · 请在左侧商品空间选一个"}
- {/* 生图模型选择(默认火山,可切 gpt-image-2;选择写入 localStorage) */} -
- -
- {GEN_MODEL_OPTIONS.map((o) => ( -
{ setGenModel(o.value); setGenModelOpen(false); }}> - {o.label} -
- ))} -
-
+ {/* YYX#10:生图模型选择已移到下方提示词输入框内(胶囊靠左置底),此处不再放 */} {/* P0④:商品库全屏选择器入口(多选商品,各起一批) — 临时屏蔽 */} {/*
- {PLATFORM_OPTIONS.filter((item) => !gridQuery || item.name.toLowerCase().includes(gridQuery.toLowerCase())).map((item) => ( + {/* YYX#12:平台网格恒显全部,右上搜索只筛右侧结果图,不再把平台过滤没 */} + {PLATFORM_OPTIONS.map((item) => (
diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 8a61d31..6dd4cdd 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -583,6 +583,20 @@ export function PipelinePage(props: { if (rm) undoTagEdit(rm.id); else queueRemoveTag(kind, value); } const tagEditVerb = (e: TagEdit) => `${e.op === "add" ? "添加" : "删除"}${e.kind === "cast" ? "人物" : "场景"}:${e.value}`; + // 直接删除一个「待生成」seed 标签(还没出参考图的角色/场景):立即从 cast/scenes 移除并落库, + // 不走「整体重写」的待删队列——给 seed 卡一个能用的删除入口(ZWQ#9)。assetKind: person↔cast / scene↔scene。 + function deleteSeedTag(assetKind: "person" | "scene", tag: string) { + if (assetKind === "person") { + const next = castTags.filter((t) => t !== tag); + setCastTags(next); + void onSaveProjectMeta?.({ cast: next }); + } else { + const next = sceneTags.filter((t) => t !== tag); + setSceneTags(next); + void onSaveProjectMeta?.({ scenes: next }); + } + } + const [seedDelArmed, setSeedDelArmed] = useState(null); // 行34 · 「添加分镜」插入的本地空白可编辑卡(尚未落库;失焦有内容才真正 onAddShot) type DraftShot = { id: string; afterId: string | null; narration: string; visual: string }; const [draftShots, setDraftShots] = useState([]); @@ -598,7 +612,14 @@ export function PipelinePage(props: { const [assetPromptDraft, setAssetPromptDraft] = useState>({}); // 新增场景:先落「占位草稿卡」(标题 + 可编辑提示词),用户编辑完点「AI 生成」才真正出图; // 出图成功后从草稿列表移除(生成结果会作为实体卡出现在下方)。不直接生成,避免一点就出一张默认图。 - const [sceneDrafts, setSceneDrafts] = useState>([]); + // 持久化到 localStorage(按项目隔离):新增场景没点 AI 生成就刷新页面也不会丢(ZWQ#10)。 + const sceneDraftsKey = `airshelf:scenedrafts:${project.id}`; + const [sceneDrafts, setSceneDrafts] = useState>(() => { + try { const raw = localStorage.getItem(sceneDraftsKey); return raw ? (JSON.parse(raw) as Array<{ id: string; title: string; prompt: string }>) : []; } catch { return []; } + }); + useEffect(() => { + try { localStorage.setItem(sceneDraftsKey, JSON.stringify(sceneDrafts)); } catch { /* localStorage 不可用时静默降级 */ } + }, [sceneDrafts, sceneDraftsKey]); // 行39 · 商品三视图:点「AI 生成三视图」展开卡片右侧预览面板(对齐原版 prod-preview,非弹窗) // 每次生成=一个 product group=一版三视图;预览态(triPreviewId)只切主图,采用态存 metadata.product_tri_group const [triPanelOpen, setTriPanelOpen] = useState(false); @@ -1067,7 +1088,9 @@ export function PipelinePage(props: { // V1 雪藏拼接导出:已完成项目落到第 4 阶段(视频),不落到被藏的第 5(V2 恢复改回 5) const projectStage = project.status === "completed" ? 4 : Math.max(1, (stageOrder as readonly string[]).indexOf(project.current_stage) + 1); const initHash = typeof location !== "undefined" ? location.hash.match(/#stage-(\d)/) : null; - const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : 1); + // 进入项目默认落到「项目当前进行到的阶段」(视频在生成/已生成 → 直接到第4阶段),而不是恒显第1阶段(ZWQ#16)。 + // 仍尊重地址栏 #stage-N(分享某阶段链接 / 浏览器前进后退)。 + const [viewStage, setViewStage] = useState(initHash ? Number(initHash[1]) : projectStage); const [navigated, setNavigated] = useState(Boolean(initHash)); // 火山人像审核状态(真人资产绿/红盾):本地覆盖 map(轮询刷新),回退 asset.review_status const [reviews, setReviews] = useState>({}); @@ -3019,6 +3042,17 @@ export function PipelinePage(props: { const busy = isBusy(seedKey) || isBusy(`${seedKey}:tri`) || pendingHas(kind, tag); return (
+ {/* 待生成 seed 卡也可删除(还没出参考图也有垃圾桶):两步确认,直接从脚本标签里移除(ZWQ#9) */} + {seedDelArmed === seedKey ? ( + + + + + ) : ( + + )}
openSeedDetail(kind, tag, promptValue)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openSeedDetail(kind, tag, promptValue); } }}> {busy @@ -4036,8 +4070,16 @@ export function PipelinePage(props: { {portraitUrl && }