feat: stream subject packs by generation batch

This commit is contained in:
2026-05-19 21:31:47 +08:00
parent 47299396dc
commit 00df9d01fe
6 changed files with 531 additions and 81 deletions

View File

@@ -11,11 +11,11 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层只保留真人重构、卡通重构、元素重构、自主描述四个入口,每个入口最多拖入 3 张参考帧,拖入只加入参考队列,不自动生成;用户放好参考和文字后点击生成,右侧主体元素区按重构类型分组展示全新 6 视图主体。这四类都属于参考重构,不抠图、不复制原人、不复刻原画面。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
- 当前产品方向2026-05-19 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层只保留真人重构、卡通重构、元素重构、自主描述四个入口,每个入口最多拖入 3 张参考帧,拖入只加入参考队列,不自动生成;用户放好参考和文字后点击生成,右侧主体元素区按每次生成的套图文件夹展示全新 6 视图主体,当前套图在最上层展开,其他套图顺位进入下方可滚动列表,同一重构方向允许保留多套。这四类都属于参考重构,不抠图、不复制原人、不复刻原画面。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik
- 发布状态已部署并验证2026-05-19转换层为真人重构 / 卡通重构 / 元素重构 / 自主描述四个入口,每个入口最多 3 张参考帧;拖入只加入参考队列,点击生成后固定生成全新 6 视图,主体元素按重构类型分组展示;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,右侧三栏主体管线:竖向参考帧池 + 转换层 + 主体元素,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览 + 转换层多参考滚动,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true`
- 发布状态已部署并验证2026-05-19主体元素改为按套图文件夹分组展示,主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;转换层为真人重构 / 卡通重构 / 元素重构 / 自主描述四个入口,每个入口最多 3 张参考帧;拖入只加入参考队列,点击生成后固定生成全新 6 视图;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,右侧三栏主体管线:竖向参考帧池 + 转换层 + 主体元素,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览 + 转换层多参考滚动,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true`
- 主站 / 前端:`https://marketing.skg.com`
- API / 后端:`https://marketing.skg.com/api`
- 代码仓库 / Gitea`https://git.kang-kang.com/kangwan/20260512-skg-tk`

View File

@@ -289,6 +289,7 @@ AssetSize = Literal["source", "1024", "1536", "2048"]
AssetQuality = Literal["hd"]
SubjectKind = Literal["object", "living"]
SubjectView = str
SubjectAssetStatus = Literal["queued", "in_progress", "completed", "failed"]
SceneMode = Literal["remove_subject", "similar", "style"]
SceneStyle = Literal["source", "premium_product", "clean_studio", "warm_lifestyle", "cinematic"]
SceneAssetRole = Literal["scene", "first_frame", "last_frame"]
@@ -462,6 +463,13 @@ class SubjectAsset(BaseModel):
size: AssetSize = "source"
source_frame_indices: list[int] = Field(default_factory=list)
ai_completed: bool = True
status: SubjectAssetStatus = "completed"
progress: int = 100
error: str = ""
pack_id: str = ""
pack_label: str = ""
pack_mode: str = ""
pack_created_at: float = 0.0
created_at: float = 0.0
@@ -1371,6 +1379,26 @@ async def lifespan(_: FastAPI):
audio_script=audio_script,
message="服务重启 · 上次音频处理已中断,可重新处理",
)
subject_generation_interrupted = False
recovered_frames = []
for f in job.frames:
for e in f.elements or []:
recovered_assets = []
for asset in e.subject_assets or []:
if asset.status in {"queued", "in_progress"}:
recovered_assets.append(asset.model_copy(update={
"status": "failed",
"progress": 100,
"error": "服务重启 · 上次主体生成已中断,可重新生成",
"ai_completed": False,
}))
subject_generation_interrupted = True
else:
recovered_assets.append(asset)
e.subject_assets = recovered_assets
recovered_frames.append(f)
if subject_generation_interrupted:
update(job, frames=recovered_frames, message="服务重启 · 上次主体生成已中断,可重新生成")
JOBS[p.name] = job
except Exception:
pass
@@ -4793,6 +4821,11 @@ class GenerateSubjectAssetsReq(BaseModel):
subject_profile: SubjectProfilePreference | None = None
prompt: str = ""
replace_views: bool = False
source_subject_brief: str = ""
pack_id: str = ""
pack_label: str = ""
pack_mode: str = ""
pack_created_at: float = 0.0
def _subject_profile_prompt_clause(profile: SubjectProfilePreference | None) -> str:
@@ -5252,8 +5285,195 @@ def cutout_element(job_id: str, idx: int, element_id: str) -> Job:
return job
def _subject_source_indices(req: GenerateSubjectAssetsReq, idx: int) -> list[int]:
source_indices = [int(x) for x in (req.source_frame_indices or [idx]) if isinstance(x, int) or str(x).isdigit()]
if idx not in source_indices:
source_indices = [idx] + source_indices
return list(dict.fromkeys(source_indices))[:12]
def _normalize_subject_pack_id(value: str, idx: int, element_id: str) -> str:
cleaned = "".join(ch for ch in (value or "").strip() if ch.isalnum() or ch in {"_", "-"})
return cleaned[:96] or f"subject_pack_{idx:03d}_{element_id}_{uuid.uuid4().hex[:8]}"
def _update_subject_asset_status(
job_id: str,
idx: int,
element_id: str,
asset_id: str,
*,
status: SubjectAssetStatus,
progress: int,
error: str = "",
message: str = "",
) -> None:
job = JOBS.get(job_id)
if not job:
return
new_frames = []
for f in job.frames:
if f.index == idx:
for e in f.elements:
if e.id == element_id:
updated_assets = []
for asset in e.subject_assets or []:
if asset.id == asset_id:
updated_assets.append(asset.model_copy(update={
"status": status,
"progress": max(0, min(100, int(progress))),
"error": error,
"ai_completed": status == "completed",
}))
else:
updated_assets.append(asset)
e.subject_assets = updated_assets
new_frames.append(f)
update(job, frames=new_frames, message=message or job.message, error=error if status == "failed" else job.error)
def _subject_assets_background_worker(
job_id: str,
idx: int,
element_id: str,
req: GenerateSubjectAssetsReq,
queued: list[tuple[SubjectView, str, str]],
) -> None:
if req.reconstruction_mode == "similar" and not req.source_subject_brief.strip():
try:
req.source_subject_brief = _describe_source_subject(job_id, _subject_source_indices(req, idx))
except Exception as e:
print(f"[subject assets] source brief failed job={job_id} error={e}", flush=True)
for position, (view, view_label, placeholder_id) in enumerate(queued, start=1):
_update_subject_asset_status(
job_id,
idx,
element_id,
placeholder_id,
status="in_progress",
progress=10,
message=f"主体资产生成中 · {view_label} · {position}/{len(queued)}",
)
one_req = req.model_copy(deep=True)
one_req.views = [view]
one_req.replace_views = True
try:
_generate_subject_assets_sync(job_id, idx, element_id, one_req)
except HTTPException as e:
detail = str(e.detail)
_update_subject_asset_status(
job_id,
idx,
element_id,
placeholder_id,
status="failed",
progress=100,
error=detail,
message=f"主体资产生成失败 · {view_label}",
)
except Exception as e:
detail = str(e)
_update_subject_asset_status(
job_id,
idx,
element_id,
placeholder_id,
status="failed",
progress=100,
error=detail,
message=f"主体资产生成失败 · {view_label}",
)
@app.post("/jobs/{job_id}/frames/{idx}/elements/{element_id}/subject-assets", response_model=Job)
def generate_subject_assets(job_id: str, idx: int, element_id: str, req: GenerateSubjectAssetsReq) -> Job:
"""提交主体多视角生成任务,立即返回占位卡;后台逐张生成并逐张写回。"""
job = JOBS.get(job_id)
if not job:
raise HTTPException(404, "job not found")
frame = _find_frame(job, idx)
el = next((e for e in frame.elements if e.id == element_id), None)
if not el:
raise HTTPException(404, "element not found")
views = _subject_view_labels(req.subject_kind, req.views)
source_indices = _subject_source_indices(req, idx)
target_views = {view for view, _label in views}
now = time.time()
explicit_pack_id = bool((req.pack_id or "").strip())
pack_id = _normalize_subject_pack_id(req.pack_id, idx, element_id)
pack_label = (req.pack_label or "").strip()[:120] or f"{el.name_zh} · 主体套图"
pack_mode = (req.pack_mode or "").strip()[:40] or req.subject_style
pack_created_at = req.pack_created_at or now
placeholders: list[SubjectAsset] = []
queued: list[tuple[SubjectView, str, str]] = []
for view, view_label in views:
asset_id = f"subject_{idx:03d}_{element_id}_{view}_{uuid.uuid4().hex[:8]}"
placeholders.append(SubjectAsset(
id=asset_id,
view=view,
label=f"{el.name_zh} · {view_label}",
url="",
width=0,
height=0,
background=req.background,
quality=req.quality,
size=req.size,
source_frame_indices=source_indices,
ai_completed=False,
status="queued",
progress=0,
error="",
pack_id=pack_id,
pack_label=pack_label,
pack_mode=pack_mode,
pack_created_at=pack_created_at,
created_at=now,
))
queued.append((view, view_label, asset_id))
new_frames = []
for f in job.frames:
if f.index == idx:
for e in f.elements:
if e.id == element_id:
e.subject_kind = req.subject_kind
e.cutout_background = req.background
current_assets = e.subject_assets or []
if req.replace_views:
for old_asset in current_assets:
should_replace = old_asset.view in target_views and (
old_asset.pack_id == pack_id if explicit_pack_id else True
)
if should_replace and old_asset.url:
_delete_subject_asset_file(job_id, old_asset.id)
current_assets = [
asset for asset in current_assets
if not (
asset.view in target_views and (
asset.pack_id == pack_id if explicit_pack_id else True
)
)
]
e.subject_assets = current_assets + placeholders
new_frames.append(f)
update(job, frames=new_frames, message=f"主体资产已提交 · {el.name_zh} · {len(placeholders)} 张逐张生成中", error="")
worker_req = req.model_copy(deep=True)
worker_req.views = [view for view, _label in views]
worker_req.pack_id = pack_id
worker_req.pack_label = pack_label
worker_req.pack_mode = pack_mode
worker_req.pack_created_at = pack_created_at
threading.Thread(
target=_subject_assets_background_worker,
args=(job_id, idx, element_id, worker_req, queued),
daemon=True,
).start()
return job
def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: GenerateSubjectAssetsReq) -> Job:
"""为一个主体生成多视角资产包。
如果传入 source_frame_indices 或内置 character_id则把多张参考图作为独立 image[] 证据提交。"""
import time as _time
@@ -5265,10 +5485,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
if not el:
raise HTTPException(404, "element not found")
source_indices = [int(x) for x in (req.source_frame_indices or [idx]) if isinstance(x, int) or str(x).isdigit()]
if idx not in source_indices:
source_indices = [idx] + source_indices
source_indices = list(dict.fromkeys(source_indices))[:12]
source_indices = _subject_source_indices(req, idx)
similar_mode = req.reconstruction_mode == "similar"
character_reference_paths: list[Path] = []
@@ -5311,7 +5528,11 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
tmp_focus: Path | None = None
model_src: Path | list[Path] | None = None
frame_reference_paths = [p for p in (_source_frame_path(job_id, i) for i in source_indices) if p.exists()]
source_subject_brief = _describe_source_subject(job_id, source_indices) if similar_mode else ""
source_subject_brief = (
_ensure_english(req.source_subject_brief.strip())
if similar_mode and req.source_subject_brief.strip()
else (_describe_source_subject(job_id, source_indices) if similar_mode else "")
)
source_subject_clause = (
f"Source video role brief from selected keyframes: {source_subject_brief}. "
"Use this brief to preserve role category, creator-ad energy, camera readability, and broad styling, while creating a new non-identical subject. "
@@ -5484,6 +5705,13 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
quality=req.quality,
size=req.size,
source_frame_indices=source_indices,
status="completed",
progress=100,
error="",
pack_id=req.pack_id,
pack_label=req.pack_label,
pack_mode=req.pack_mode,
pack_created_at=req.pack_created_at or _time.time(),
created_at=_time.time(),
))
finally:
@@ -5509,10 +5737,21 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat
current_assets = e.subject_assets or []
if req.replace_views:
replaced_views = {asset.view for asset in generated}
replace_pack_id = (req.pack_id or "").strip()
for old_asset in current_assets:
if old_asset.view in replaced_views:
should_replace = old_asset.view in replaced_views and (
old_asset.pack_id == replace_pack_id if replace_pack_id else True
)
if should_replace:
_delete_subject_asset_file(job_id, old_asset.id)
current_assets = [asset for asset in current_assets if asset.view not in replaced_views]
current_assets = [
asset for asset in current_assets
if not (
asset.view in replaced_views and (
asset.pack_id == replace_pack_id if replace_pack_id else True
)
)
]
final_assets = current_assets + generated
e.subject_assets = final_assets
if req.subject_kind == "living":

File diff suppressed because one or more lines are too long

View File

@@ -892,7 +892,12 @@ export default function Home() {
.filter((item) => {
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
const runningAudio = item.audio_script?.status === "rewriting"
return runningVideo || runningAudio || !TERMINAL.includes(item.status)
const runningSubject = item.frames.some((frame) =>
frame.elements?.some((element) =>
element.subject_assets?.some((asset) => asset.status === "queued" || asset.status === "in_progress"),
),
)
return runningVideo || runningAudio || runningSubject || !TERMINAL.includes(item.status)
})
.map((item) => item.id)
@@ -913,7 +918,14 @@ export default function Home() {
}, [
job?.id,
job?.status,
jobs.map((item) => `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}`).join("|"),
jobs.map((item) => {
const subjectState = item.frames.flatMap((frame) =>
frame.elements?.flatMap((element) =>
element.subject_assets?.map((asset) => `${asset.id}:${asset.status ?? "completed"}:${asset.progress ?? 100}:${asset.url ?? ""}`) ?? [],
) ?? [],
).join(",")
return `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}:${subjectState}`
}).join("|"),
])
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))

View File

@@ -214,6 +214,20 @@ type ResolvedSubjectProfile = {
promptSummary: string
payload: SubjectProfilePreference
}
type SubjectAssetPack = {
key: string
id: string
label: string
mode: SubjectReconstructionMode
frame: KeyFrame
element: KeyElement
createdAt: number
assets: SubjectAsset[]
total: number
completed: number
failed: number
running: boolean
}
type StoryboardVisualMode = NonNullable<StoryboardScene["visual_mode"]>
type RowPlanPatch = Partial<Pick<AudioStoryboardRow, "visualMode" | "needsProduct" | "needsSubject" | "subjectDescription" | "subjectDescriptionZh" | "skgCopy" | "skgCopyZh" | "sceneOneLine" | "sceneOneLineZh" | "actionOneLine" | "actionOneLineZh" | "visualPlan" | "visualPlanZh" | "firstFramePlan" | "firstFramePlanZh" | "lastFramePlan" | "lastFramePlanZh" | "productIntegration" | "productIntegrationZh" | "productPlacement" | "productPlacementZh">>
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
@@ -1119,9 +1133,46 @@ function buildSimilarSubjectPrompt(
}
function subjectAssetUrl(job: Job, asset: SubjectAsset) {
if (!asset.url && asset.status && asset.status !== "completed") return ""
return apiAssetUrl(asset.url) || resolveImageRefUrl(job.id, { kind: "asset", frame_idx: 0, element_id: asset.id })
}
function subjectAssetStatus(asset: SubjectAsset) {
return asset.status ?? (asset.url ? "completed" : "completed")
}
function subjectAssetIsRunning(asset: SubjectAsset) {
const status = subjectAssetStatus(asset)
return status === "queued" || status === "in_progress"
}
function subjectAssetStatusLabel(asset: SubjectAsset) {
const status = subjectAssetStatus(asset)
if (status === "queued") return "排队中"
if (status === "in_progress") return `生成中 ${asset.progress ?? 0}%`
if (status === "failed") return "失败"
return asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined
}
function subjectAssetPackKey(frame: KeyFrame, element: KeyElement, asset: SubjectAsset) {
return `${frame.index}:${element.id}:${asset.pack_id || `legacy-${element.id}`}`
}
function subjectAssetPackSortAssets(assets: SubjectAsset[]) {
return [...assets].sort((a, b) => {
const ai = SUBJECT_VIEW_ORDER.indexOf(a.view)
const bi = SUBJECT_VIEW_ORDER.indexOf(b.view)
if (ai !== bi) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
return (a.created_at || 0) - (b.created_at || 0)
})
}
function subjectAssetPackSummary(pack: SubjectAssetPack) {
if (pack.running) return `${pack.completed}/${pack.total} 生成中`
if (pack.failed) return `${pack.completed}/${pack.total} · 失败 ${pack.failed}`
return `${pack.completed || pack.total}`
}
function characterPreviewImage(character?: { primary_image?: string; images?: Array<{ id: string; view?: string; filename: string; label?: string }> } | null) {
if (!character?.images?.length) return null
return character.images.find((image) => image.id === character.primary_image)
@@ -3156,6 +3207,7 @@ function SourceSubjectPipeline({
const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false)
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string } | null>(null)
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
const [expandedSubjectPackKey, setExpandedSubjectPackKey] = useState<string | null>(null)
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
const subjectBusy = !!subjectBusyFor
const selectedSubjectViews = RECONSTRUCTION_SUBJECT_VIEW_VALUES
@@ -3182,25 +3234,65 @@ function SourceSubjectPipeline({
}
return items
}, [frames])
const visibleActorAssets = useMemo(() => {
const items: Array<{ frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode; asset: SubjectAsset }> = []
const subjectAssetPacks = useMemo<SubjectAssetPack[]>(() => {
const packs = new Map<string, SubjectAssetPack>()
for (const source of actorSources) {
const latestByView = new Map<string, SubjectAsset>()
for (const asset of source.element.subject_assets ?? []) {
const key = subjectAssetPackKey(source.frame, source.element, asset)
const rawMode = asset.pack_mode as SubjectReconstructionMode | undefined
const packMode = rawMode && RECONSTRUCTION_MODES.some((item) => item.value === rawMode) ? rawMode : source.mode
const createdAt = asset.pack_created_at || asset.created_at || 0
const existing = packs.get(key)
if (existing) {
existing.assets.push(asset)
existing.createdAt = Math.min(existing.createdAt || createdAt, createdAt)
} else {
packs.set(key, {
key,
id: asset.pack_id || key,
label: asset.pack_label || `${reconstructionModeConfig(packMode).label}套图`,
mode: packMode,
frame: source.frame,
element: source.element,
createdAt,
assets: [asset],
total: 0,
completed: 0,
failed: 0,
running: false,
})
}
}
}
return [...packs.values()].map((pack) => {
const latestByView = new Map<string, SubjectAsset>()
for (const asset of pack.assets) {
const current = latestByView.get(asset.view)
if (!current || (asset.created_at || 0) >= (current.created_at || 0)) latestByView.set(asset.view, asset)
}
for (const asset of latestByView.values()) items.push({ ...source, asset })
}
return items.sort((a, b) => {
const assets = subjectAssetPackSortAssets([...latestByView.values()])
const completed = assets.filter((asset) => subjectAssetStatus(asset) === "completed").length
const failed = assets.filter((asset) => subjectAssetStatus(asset) === "failed").length
const running = assets.some(subjectAssetIsRunning)
return { ...pack, assets, total: assets.length, completed, failed, running }
}).sort((a, b) => {
const mi = RECONSTRUCTION_MODES.findIndex((item) => item.value === a.mode)
const mj = RECONSTRUCTION_MODES.findIndex((item) => item.value === b.mode)
if (mi !== mj) return mi - mj
const ai = SUBJECT_VIEW_ORDER.indexOf(a.asset.view)
const bi = SUBJECT_VIEW_ORDER.indexOf(b.asset.view)
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
return (b.createdAt || 0) - (a.createdAt || 0)
})
}, [actorSources])
const activeSubjectPack = useMemo(
() => subjectAssetPacks.find((pack) => pack.key === expandedSubjectPackKey) ?? subjectAssetPacks[0] ?? null,
[expandedSubjectPackKey, subjectAssetPacks],
)
const runningActorModes = useMemo(() => {
const next = new Set<SubjectReconstructionMode>()
for (const pack of subjectAssetPacks) {
if (pack.running) next.add(pack.mode)
}
return next
}, [subjectAssetPacks])
useEffect(() => {
setConversionFrameIndicesByMode({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })
@@ -3210,8 +3302,15 @@ function SourceSubjectPipeline({
setSubjectAssetBusy(null)
setActiveDropMode(null)
setCartoonStyleOpen(false)
setExpandedSubjectPackKey(null)
}, [job.id])
useEffect(() => {
if (expandedSubjectPackKey && !subjectAssetPacks.some((pack) => pack.key === expandedSubjectPackKey)) {
setExpandedSubjectPackKey(null)
}
}, [expandedSubjectPackKey, subjectAssetPacks])
useEffect(() => {
setConversionFrameIndicesByMode((current) => {
const next = {} as Record<SubjectReconstructionMode, number[]>
@@ -3233,6 +3332,10 @@ function SourceSubjectPipeline({
toast.warning("主体套图正在生成中,完成后再重生。")
return
}
if (runningActorModes.has(mode)) {
toast.warning(`${reconstructionModeConfig(mode).label}还有主体图正在逐张生成。`)
return
}
const sourceFrames = sourceIndices
.map((index) => frames.find((frame) => frame.index === index))
.filter((frame): frame is KeyFrame => !!frame)
@@ -3288,10 +3391,18 @@ function SourceSubjectPipeline({
views: selectedSubjectViews,
subject_profile: requestProfile?.payload ?? null,
prompt: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
replace_views: true,
replace_views: false,
pack_label: `${reconstructionModeConfig(mode).label} ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
pack_mode: mode,
})
onJobUpdate(updated)
toast.success(`${reconstructionModeConfig(mode).label}已生成:${selectedSubjectViews.length}`)
const updatedFrame = updated.frames.find((frame) => frame.index === baseFrame.index)
const updatedElement = updatedFrame?.elements?.find((item) => item.id === element.id)
const newestAsset = [...(updatedElement?.subject_assets ?? [])].sort((a, b) => (b.pack_created_at || b.created_at || 0) - (a.pack_created_at || a.created_at || 0))[0]
if (updatedFrame && updatedElement && newestAsset) {
setExpandedSubjectPackKey(subjectAssetPackKey(updatedFrame, updatedElement, newestAsset))
}
toast.success(`${reconstructionModeConfig(mode).label}已提交:${selectedSubjectViews.length} 张会逐张出来`)
} catch (e) {
try {
onJobUpdate(await getJob(requestJobId))
@@ -3359,9 +3470,13 @@ function SourceSubjectPipeline({
requestProfile,
),
replace_views: true,
pack_id: asset.pack_id ?? "",
pack_label: asset.pack_label ?? "",
pack_mode: asset.pack_mode ?? mode,
pack_created_at: asset.pack_created_at ?? asset.created_at ?? 0,
})
onJobUpdate(updated)
toast.success("已重新生成这张主体元素")
toast.success("已提交重生,这张主体元素会生成完成后替换")
} catch (e) {
toast.error("主体元素重生失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -3497,6 +3612,7 @@ function SourceSubjectPipeline({
const canGenerate = mode === "custom"
? Boolean(reconstructionDirections.custom.trim() || modeFrames.length)
: modeFrames.length > 0
const modeRunning = runningActorModes.has(mode)
return (
<div
key={mode}
@@ -3606,11 +3722,11 @@ function SourceSubjectPipeline({
<button
type="button"
onClick={() => void generateSubjectPack(mode)}
disabled={subjectBusy || !canGenerate}
disabled={subjectBusy || modeRunning || !canGenerate}
className="skg-primary-action mt-2 inline-flex h-8 w-full items-center justify-center gap-1 px-3 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{subjectBusyFor?.mode === mode ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{subjectBusyFor?.mode === mode ? `生成中 · ${subjectBusyFor.sourceCount || "描述"} 参考` : `生成${modeConfig.label} 6视图`}
{subjectBusyFor?.mode === mode || modeRunning ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
{subjectBusyFor?.mode === mode || modeRunning ? "逐张生成中" : `生成${modeConfig.label} 6视图`}
</button>
</div>
)
@@ -3628,7 +3744,7 @@ function SourceSubjectPipeline({
<div className="mb-2 flex items-center justify-between gap-2">
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="主体元素" />
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[10px] text-white/42">
{visibleActorAssets.length ? `${visibleActorAssets.length} ` : "待生成"}
{subjectAssetPacks.length ? `${subjectAssetPacks.length} ` : "待生成"}
</span>
</div>
<div className="min-h-[410px] rounded-md border border-white/10 bg-black/32 p-2 2xl:min-h-[500px]">
@@ -3638,52 +3754,90 @@ function SourceSubjectPipeline({
<span className="mt-1 block text-cyan-50/58">{subjectBusyFor.profileLabel}</span>
</div>
) : null}
{visibleActorAssets.length ? (
<div className="space-y-3">
{RECONSTRUCTION_MODES.map((modeConfig) => {
const items = visibleActorAssets.filter((item) => item.mode === modeConfig.value)
if (!items.length) return null
return (
<div key={modeConfig.value} className="space-y-1.5">
<div className="flex items-center justify-between gap-2 text-[10px] text-white/44">
<span>{modeConfig.label}</span>
<span>{items.length} </span>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2 2xl:grid-cols-[repeat(auto-fill,minmax(88px,1fr))]">
{items.map((item) => {
const { asset } = item
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
return (
<MediaAssetTile
key={asset.id}
src={subjectAssetUrl(job, asset)}
href={subjectAssetUrl(job, asset)}
alt={asset.label || asset.view}
label={asset.label || subjectViewLabel(asset.view)}
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
className="aspect-[9/16] bg-white"
objectFit="contain"
title={asset.label || subjectViewLabel(asset.view)}
actions={[{
key: "regen",
label: "重新生成这一张",
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy || subjectBusy,
onClick: () => void regenerateSubjectAsset(item),
}]}
onDelete={() => void deleteActorAsset(item)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy || subjectBusy}
deleteLabel="删除这一张"
/>
)
})}
{subjectAssetPacks.length ? (
<div className="space-y-2">
{activeSubjectPack ? (
<div className="rounded-md border border-[#d6b36a]/28 bg-[#d6b36a]/[0.07] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[11px] font-semibold text-white">{activeSubjectPack.label}</div>
<div className="mt-0.5 text-[9.5px] text-white/42">
{reconstructionModeConfig(activeSubjectPack.mode).label} · {subjectAssetPackSummary(activeSubjectPack)}
</div>
</div>
<span className="shrink-0 rounded-full border border-white/10 bg-black/35 px-2 py-0.5 text-[9px] text-white/46">
{activeSubjectPack.assets.length}
</span>
</div>
)
})}
<div className="grid max-h-[300px] grid-cols-[repeat(auto-fill,minmax(76px,1fr))] gap-2 overflow-y-auto pr-0.5 2xl:grid-cols-[repeat(auto-fill,minmax(88px,1fr))]">
{activeSubjectPack.assets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
const status = subjectAssetStatus(asset)
const running = subjectAssetIsRunning(asset)
const failed = status === "failed"
const mediaUrl = subjectAssetUrl(job, asset)
const item = { frame: activeSubjectPack.frame, element: activeSubjectPack.element, mode: activeSubjectPack.mode, asset }
return (
<MediaAssetTile
key={asset.id}
src={mediaUrl || undefined}
href={mediaUrl || undefined}
alt={asset.label || asset.view}
label={asset.label || subjectViewLabel(asset.view)}
meta={subjectAssetStatusLabel(asset)}
className={`aspect-[9/16] ${running ? "bg-black/40" : failed ? "bg-rose-950/30" : "bg-white"}`}
objectFit="contain"
busy={running}
emptyText={failed ? "失败" : running ? "生成中" : undefined}
title={asset.label || subjectViewLabel(asset.view)}
actions={[{
key: "regen",
label: "重新生成这一张",
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy || subjectBusy || running,
onClick: () => void regenerateSubjectAsset(item),
}]}
onDelete={() => void deleteActorAsset(item)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy || subjectBusy || running}
deleteLabel="删除这一张"
/>
)
})}
</div>
</div>
) : null}
<div className="max-h-32 overflow-auto rounded-md border border-white/10 bg-black/24 p-1.5">
<div className="grid grid-cols-[repeat(auto-fill,minmax(112px,1fr))] gap-1.5">
{subjectAssetPacks.map((pack, index) => {
const active = activeSubjectPack?.key === pack.key
return (
<button
key={pack.key}
type="button"
onClick={() => setExpandedSubjectPackKey(pack.key)}
className={`min-w-0 rounded-md border px-2 py-1.5 text-left transition ${
active
? "border-[#d6b36a]/70 bg-[#d6b36a]/14 text-white"
: "border-white/10 bg-black/28 text-white/58 hover:border-white/22 hover:text-white"
}`}
title={pack.label}
>
<div className="flex items-center gap-1.5">
<Package className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 truncate text-[10px] font-semibold">{pack.label || `套图 ${index + 1}`}</span>
</div>
<div className="mt-1 flex items-center justify-between gap-2 text-[9px] text-white/38">
<span>{reconstructionModeConfig(pack.mode).label}</span>
<span className="font-mono">{subjectAssetPackSummary(pack)}</span>
</div>
</button>
)
})}
</div>
</div>
</div>
) : (
<div className="flex h-40 items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34">
@@ -4022,9 +4176,11 @@ function SourceReferenceBuildPanel({
subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
pack_label: `主体视图 ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
pack_mode: "realistic",
})
onJobUpdate(updated)
toast.success(`相似主体 ${selectedSubjectViews.length}高清白底图已生成`)
toast.success(`相似主体已提交:${selectedSubjectViews.length}会逐张出来`)
} catch (e) {
try {
onJobUpdate(await getJob(requestJobId))
@@ -4060,9 +4216,13 @@ function SourceReferenceBuildPanel({
subject_profile: requestProfile.payload,
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
replace_views: true,
pack_id: asset.pack_id ?? "",
pack_label: asset.pack_label ?? "",
pack_mode: asset.pack_mode ?? "realistic",
pack_created_at: asset.pack_created_at ?? asset.created_at ?? 0,
})
onJobUpdate(updated)
toast.success("已重新生成这张主体视图")
toast.success("已提交重生,这张主体视图会生成完成后替换")
} catch (e) {
toast.error("单张主体视图重生失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
@@ -4335,16 +4495,22 @@ function SourceReferenceBuildPanel({
<div className="mb-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
{visibleActorAssets.map((asset) => {
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
const status = subjectAssetStatus(asset)
const running = subjectAssetIsRunning(asset)
const failed = status === "failed"
const mediaUrl = subjectAssetUrl(job, asset)
return (
<MediaAssetTile
key={asset.id}
src={subjectAssetUrl(job, asset)}
href={subjectAssetUrl(job, asset)}
src={mediaUrl || undefined}
href={mediaUrl || undefined}
alt={asset.label || asset.view}
label={asset.label || asset.view || "主体视图预览"}
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
className="aspect-[9/16] w-20 bg-white 2xl:w-24"
meta={subjectAssetStatusLabel(asset)}
className={`aspect-[9/16] w-20 2xl:w-24 ${running ? "bg-black/40" : failed ? "bg-rose-950/30" : "bg-white"}`}
objectFit="contain"
busy={running}
emptyText={failed ? "失败" : running ? "生成中" : undefined}
title={asset.label || asset.view}
actions={[{
key: "regen",
@@ -4352,12 +4518,12 @@ function SourceReferenceBuildPanel({
icon: <RefreshCw className="h-3 w-3" />,
tone: "cyan",
busy: busyMode === "regen",
disabled: !!subjectAssetBusy,
disabled: !!subjectAssetBusy || running,
onClick: () => void regenerateSubjectAsset(asset),
}]}
onDelete={() => void deleteActorAsset(asset)}
deleting={busyMode === "delete"}
deleteDisabled={!!subjectAssetBusy}
deleteDisabled={!!subjectAssetBusy || running}
deleteLabel="删除这一张"
/>
)

View File

@@ -701,6 +701,7 @@ export type AssetBackground = "white" | "black"
export type AssetSize = "source" | "1024" | "1536" | "2048"
export type SubjectKind = "object" | "living"
export type SubjectView = string
export type SubjectAssetStatus = "queued" | "in_progress" | "completed" | "failed"
export type SceneMode = "remove_subject" | "similar" | "style"
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"
export type SceneAssetRole = "scene" | "first_frame" | "last_frame"
@@ -754,6 +755,13 @@ export interface SubjectAsset {
size: AssetSize
source_frame_indices?: number[]
ai_completed?: boolean
status?: SubjectAssetStatus
progress?: number
error?: string
pack_id?: string
pack_label?: string
pack_mode?: string
pack_created_at?: number
created_at: number
}
@@ -1500,6 +1508,10 @@ export async function generateSubjectAssets(
subject_profile?: SubjectProfilePreference | null
prompt?: string
replace_views?: boolean
pack_id?: string
pack_label?: string
pack_mode?: string
pack_created_at?: number
} = {},
): Promise<Job> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets`, {
@@ -1519,6 +1531,10 @@ export async function generateSubjectAssets(
subject_profile: body.subject_profile ?? null,
prompt: body.prompt ?? "",
replace_views: body.replace_views ?? false,
pack_id: body.pack_id ?? "",
pack_label: body.pack_label ?? "",
pack_mode: body.pack_mode ?? "",
pack_created_at: body.pack_created_at ?? 0,
}),
})
if (!res.ok) {