feat: stream subject packs by generation batch
This commit is contained in:
4
RULES.md
4
RULES.md
@@ -11,11 +11,11 @@
|
|||||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
- 第一冲刺:步骤 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)
|
- 平台: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`
|
- 主站 / 前端:`https://marketing.skg.com`
|
||||||
- API / 后端:`https://marketing.skg.com/api`
|
- API / 后端:`https://marketing.skg.com/api`
|
||||||
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||||
|
|||||||
253
api/main.py
253
api/main.py
@@ -289,6 +289,7 @@ AssetSize = Literal["source", "1024", "1536", "2048"]
|
|||||||
AssetQuality = Literal["hd"]
|
AssetQuality = Literal["hd"]
|
||||||
SubjectKind = Literal["object", "living"]
|
SubjectKind = Literal["object", "living"]
|
||||||
SubjectView = str
|
SubjectView = str
|
||||||
|
SubjectAssetStatus = Literal["queued", "in_progress", "completed", "failed"]
|
||||||
SceneMode = Literal["remove_subject", "similar", "style"]
|
SceneMode = Literal["remove_subject", "similar", "style"]
|
||||||
SceneStyle = Literal["source", "premium_product", "clean_studio", "warm_lifestyle", "cinematic"]
|
SceneStyle = Literal["source", "premium_product", "clean_studio", "warm_lifestyle", "cinematic"]
|
||||||
SceneAssetRole = Literal["scene", "first_frame", "last_frame"]
|
SceneAssetRole = Literal["scene", "first_frame", "last_frame"]
|
||||||
@@ -462,6 +463,13 @@ class SubjectAsset(BaseModel):
|
|||||||
size: AssetSize = "source"
|
size: AssetSize = "source"
|
||||||
source_frame_indices: list[int] = Field(default_factory=list)
|
source_frame_indices: list[int] = Field(default_factory=list)
|
||||||
ai_completed: bool = True
|
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
|
created_at: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
@@ -1371,6 +1379,26 @@ async def lifespan(_: FastAPI):
|
|||||||
audio_script=audio_script,
|
audio_script=audio_script,
|
||||||
message="服务重启 · 上次音频处理已中断,可重新处理",
|
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
|
JOBS[p.name] = job
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -4793,6 +4821,11 @@ class GenerateSubjectAssetsReq(BaseModel):
|
|||||||
subject_profile: SubjectProfilePreference | None = None
|
subject_profile: SubjectProfilePreference | None = None
|
||||||
prompt: str = ""
|
prompt: str = ""
|
||||||
replace_views: bool = False
|
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:
|
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
|
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)
|
@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:
|
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[] 证据提交。"""
|
如果传入 source_frame_indices 或内置 character_id,则把多张参考图作为独立 image[] 证据提交。"""
|
||||||
import time as _time
|
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:
|
if not el:
|
||||||
raise HTTPException(404, "element not found")
|
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()]
|
source_indices = _subject_source_indices(req, idx)
|
||||||
if idx not in source_indices:
|
|
||||||
source_indices = [idx] + source_indices
|
|
||||||
source_indices = list(dict.fromkeys(source_indices))[:12]
|
|
||||||
|
|
||||||
similar_mode = req.reconstruction_mode == "similar"
|
similar_mode = req.reconstruction_mode == "similar"
|
||||||
character_reference_paths: list[Path] = []
|
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
|
tmp_focus: Path | None = None
|
||||||
model_src: Path | list[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()]
|
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 = (
|
source_subject_clause = (
|
||||||
f"Source video role brief from selected keyframes: {source_subject_brief}. "
|
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. "
|
"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,
|
quality=req.quality,
|
||||||
size=req.size,
|
size=req.size,
|
||||||
source_frame_indices=source_indices,
|
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(),
|
created_at=_time.time(),
|
||||||
))
|
))
|
||||||
finally:
|
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 []
|
current_assets = e.subject_assets or []
|
||||||
if req.replace_views:
|
if req.replace_views:
|
||||||
replaced_views = {asset.view for asset in generated}
|
replaced_views = {asset.view for asset in generated}
|
||||||
|
replace_pack_id = (req.pack_id or "").strip()
|
||||||
for old_asset in current_assets:
|
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)
|
_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
|
final_assets = current_assets + generated
|
||||||
e.subject_assets = final_assets
|
e.subject_assets = final_assets
|
||||||
if req.subject_kind == "living":
|
if req.subject_kind == "living":
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -892,7 +892,12 @@ export default function Home() {
|
|||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress")
|
||||||
const runningAudio = item.audio_script?.status === "rewriting"
|
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)
|
.map((item) => item.id)
|
||||||
|
|
||||||
@@ -913,7 +918,14 @@ export default function Home() {
|
|||||||
}, [
|
}, [
|
||||||
job?.id,
|
job?.id,
|
||||||
job?.status,
|
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()))
|
const [pinnedNodes, setPinnedNodes] = useState<Set<string>>(() => new Set(loadNodePins()))
|
||||||
|
|||||||
@@ -214,6 +214,20 @@ type ResolvedSubjectProfile = {
|
|||||||
promptSummary: string
|
promptSummary: string
|
||||||
payload: SubjectProfilePreference
|
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 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 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"
|
type WorkflowStepId = "input" | "source" | "audio" | "visual" | "subject" | "product" | "script" | "scene" | "video"
|
||||||
@@ -1119,9 +1133,46 @@ function buildSimilarSubjectPrompt(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function subjectAssetUrl(job: Job, asset: SubjectAsset) {
|
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 })
|
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) {
|
function characterPreviewImage(character?: { primary_image?: string; images?: Array<{ id: string; view?: string; filename: string; label?: string }> } | null) {
|
||||||
if (!character?.images?.length) return null
|
if (!character?.images?.length) return null
|
||||||
return character.images.find((image) => image.id === character.primary_image)
|
return character.images.find((image) => image.id === character.primary_image)
|
||||||
@@ -3156,6 +3207,7 @@ function SourceSubjectPipeline({
|
|||||||
const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false)
|
const [cartoonStyleOpen, setCartoonStyleOpen] = useState(false)
|
||||||
const [subjectBusyFor, setSubjectBusyFor] = useState<{ jobId: string; jobLabel: string; mode: SubjectReconstructionMode; viewCount: number; sourceCount: number; profileLabel: string } | null>(null)
|
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 [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
|
||||||
|
const [expandedSubjectPackKey, setExpandedSubjectPackKey] = useState<string | null>(null)
|
||||||
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
|
const [lastSubjectProfile, setLastSubjectProfile] = useState<ResolvedSubjectProfile | null>(null)
|
||||||
const subjectBusy = !!subjectBusyFor
|
const subjectBusy = !!subjectBusyFor
|
||||||
const selectedSubjectViews = RECONSTRUCTION_SUBJECT_VIEW_VALUES
|
const selectedSubjectViews = RECONSTRUCTION_SUBJECT_VIEW_VALUES
|
||||||
@@ -3182,25 +3234,65 @@ function SourceSubjectPipeline({
|
|||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}, [frames])
|
}, [frames])
|
||||||
const visibleActorAssets = useMemo(() => {
|
const subjectAssetPacks = useMemo<SubjectAssetPack[]>(() => {
|
||||||
const items: Array<{ frame: KeyFrame; element: KeyElement; mode: SubjectReconstructionMode; asset: SubjectAsset }> = []
|
const packs = new Map<string, SubjectAssetPack>()
|
||||||
for (const source of actorSources) {
|
for (const source of actorSources) {
|
||||||
const latestByView = new Map<string, SubjectAsset>()
|
|
||||||
for (const asset of source.element.subject_assets ?? []) {
|
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)
|
const current = latestByView.get(asset.view)
|
||||||
if (!current || (asset.created_at || 0) >= (current.created_at || 0)) latestByView.set(asset.view, asset)
|
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 })
|
const assets = subjectAssetPackSortAssets([...latestByView.values()])
|
||||||
}
|
const completed = assets.filter((asset) => subjectAssetStatus(asset) === "completed").length
|
||||||
return items.sort((a, b) => {
|
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 mi = RECONSTRUCTION_MODES.findIndex((item) => item.value === a.mode)
|
||||||
const mj = RECONSTRUCTION_MODES.findIndex((item) => item.value === b.mode)
|
const mj = RECONSTRUCTION_MODES.findIndex((item) => item.value === b.mode)
|
||||||
if (mi !== mj) return mi - mj
|
if (mi !== mj) return mi - mj
|
||||||
const ai = SUBJECT_VIEW_ORDER.indexOf(a.asset.view)
|
return (b.createdAt || 0) - (a.createdAt || 0)
|
||||||
const bi = SUBJECT_VIEW_ORDER.indexOf(b.asset.view)
|
|
||||||
return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi)
|
|
||||||
})
|
})
|
||||||
}, [actorSources])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setConversionFrameIndicesByMode({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })
|
setConversionFrameIndicesByMode({ ...EMPTY_RECONSTRUCTION_FRAME_MAP })
|
||||||
@@ -3210,8 +3302,15 @@ function SourceSubjectPipeline({
|
|||||||
setSubjectAssetBusy(null)
|
setSubjectAssetBusy(null)
|
||||||
setActiveDropMode(null)
|
setActiveDropMode(null)
|
||||||
setCartoonStyleOpen(false)
|
setCartoonStyleOpen(false)
|
||||||
|
setExpandedSubjectPackKey(null)
|
||||||
}, [job.id])
|
}, [job.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedSubjectPackKey && !subjectAssetPacks.some((pack) => pack.key === expandedSubjectPackKey)) {
|
||||||
|
setExpandedSubjectPackKey(null)
|
||||||
|
}
|
||||||
|
}, [expandedSubjectPackKey, subjectAssetPacks])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setConversionFrameIndicesByMode((current) => {
|
setConversionFrameIndicesByMode((current) => {
|
||||||
const next = {} as Record<SubjectReconstructionMode, number[]>
|
const next = {} as Record<SubjectReconstructionMode, number[]>
|
||||||
@@ -3233,6 +3332,10 @@ function SourceSubjectPipeline({
|
|||||||
toast.warning("主体套图正在生成中,完成后再重生。")
|
toast.warning("主体套图正在生成中,完成后再重生。")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (runningActorModes.has(mode)) {
|
||||||
|
toast.warning(`${reconstructionModeConfig(mode).label}还有主体图正在逐张生成。`)
|
||||||
|
return
|
||||||
|
}
|
||||||
const sourceFrames = sourceIndices
|
const sourceFrames = sourceIndices
|
||||||
.map((index) => frames.find((frame) => frame.index === index))
|
.map((index) => frames.find((frame) => frame.index === index))
|
||||||
.filter((frame): frame is KeyFrame => !!frame)
|
.filter((frame): frame is KeyFrame => !!frame)
|
||||||
@@ -3288,10 +3391,18 @@ function SourceSubjectPipeline({
|
|||||||
views: selectedSubjectViews,
|
views: selectedSubjectViews,
|
||||||
subject_profile: requestProfile?.payload ?? null,
|
subject_profile: requestProfile?.payload ?? null,
|
||||||
prompt: buildSimilarSubjectPrompt(subjectStyle, userDirection, null, requestProfile),
|
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)
|
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) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
onJobUpdate(await getJob(requestJobId))
|
onJobUpdate(await getJob(requestJobId))
|
||||||
@@ -3359,9 +3470,13 @@ function SourceSubjectPipeline({
|
|||||||
requestProfile,
|
requestProfile,
|
||||||
),
|
),
|
||||||
replace_views: true,
|
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)
|
onJobUpdate(updated)
|
||||||
toast.success("已重新生成这张主体元素")
|
toast.success("已提交重生,这张主体元素会生成完成后替换")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("主体元素重生失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("主体元素重生失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3497,6 +3612,7 @@ function SourceSubjectPipeline({
|
|||||||
const canGenerate = mode === "custom"
|
const canGenerate = mode === "custom"
|
||||||
? Boolean(reconstructionDirections.custom.trim() || modeFrames.length)
|
? Boolean(reconstructionDirections.custom.trim() || modeFrames.length)
|
||||||
: modeFrames.length > 0
|
: modeFrames.length > 0
|
||||||
|
const modeRunning = runningActorModes.has(mode)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={mode}
|
key={mode}
|
||||||
@@ -3606,11 +3722,11 @@ function SourceSubjectPipeline({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void generateSubjectPack(mode)}
|
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"
|
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 || modeRunning ? <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 ? "逐张生成中" : `生成${modeConfig.label} 6视图`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -3628,7 +3744,7 @@ function SourceSubjectPipeline({
|
|||||||
<div className="mb-2 flex items-center justify-between gap-2">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="主体元素" />
|
<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">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-[410px] rounded-md border border-white/10 bg-black/32 p-2 2xl:min-h-[500px]">
|
<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>
|
<span className="mt-1 block text-cyan-50/58">主体设定:{subjectBusyFor.profileLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{visibleActorAssets.length ? (
|
{subjectAssetPacks.length ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{RECONSTRUCTION_MODES.map((modeConfig) => {
|
{activeSubjectPack ? (
|
||||||
const items = visibleActorAssets.filter((item) => item.mode === modeConfig.value)
|
<div className="rounded-md border border-[#d6b36a]/28 bg-[#d6b36a]/[0.07] p-2">
|
||||||
if (!items.length) return null
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
return (
|
<div className="min-w-0">
|
||||||
<div key={modeConfig.value} className="space-y-1.5">
|
<div className="truncate text-[11px] font-semibold text-white">{activeSubjectPack.label}</div>
|
||||||
<div className="flex items-center justify-between gap-2 text-[10px] text-white/44">
|
<div className="mt-0.5 text-[9.5px] text-white/42">
|
||||||
<span>{modeConfig.label}</span>
|
{reconstructionModeConfig(activeSubjectPack.mode).label} · {subjectAssetPackSummary(activeSubjectPack)}
|
||||||
<span>{items.length} 张</span>
|
</div>
|
||||||
</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="删除这一张"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</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>
|
||||||
)
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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,
|
subject_profile: requestProfile.payload,
|
||||||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
|
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
|
||||||
replace_views: true,
|
replace_views: true,
|
||||||
|
pack_label: `主体视图 ${new Date().toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })}`,
|
||||||
|
pack_mode: "realistic",
|
||||||
})
|
})
|
||||||
onJobUpdate(updated)
|
onJobUpdate(updated)
|
||||||
toast.success(`相似主体 ${selectedSubjectViews.length} 张高清白底图已生成`)
|
toast.success(`相似主体已提交:${selectedSubjectViews.length} 张会逐张出来`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try {
|
try {
|
||||||
onJobUpdate(await getJob(requestJobId))
|
onJobUpdate(await getJob(requestJobId))
|
||||||
@@ -4060,9 +4216,13 @@ function SourceReferenceBuildPanel({
|
|||||||
subject_profile: requestProfile.payload,
|
subject_profile: requestProfile.payload,
|
||||||
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
|
prompt: buildSimilarSubjectPrompt(subjectStyle, subjectDirection, selectedTemplatePrompt, requestProfile),
|
||||||
replace_views: true,
|
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)
|
onJobUpdate(updated)
|
||||||
toast.success("已重新生成这张主体视图")
|
toast.success("已提交重生,这张主体视图会生成完成后替换")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("单张主体视图重生失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("单张主体视图重生失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -4335,16 +4495,22 @@ function SourceReferenceBuildPanel({
|
|||||||
<div className="mb-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
|
<div className="mb-2 grid grid-cols-[repeat(auto-fill,minmax(96px,1fr))] gap-2">
|
||||||
{visibleActorAssets.map((asset) => {
|
{visibleActorAssets.map((asset) => {
|
||||||
const busyMode = subjectAssetBusy?.endsWith(asset.id) ? subjectAssetBusy.split(":")[0] : ""
|
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 (
|
return (
|
||||||
<MediaAssetTile
|
<MediaAssetTile
|
||||||
key={asset.id}
|
key={asset.id}
|
||||||
src={subjectAssetUrl(job, asset)}
|
src={mediaUrl || undefined}
|
||||||
href={subjectAssetUrl(job, asset)}
|
href={mediaUrl || undefined}
|
||||||
alt={asset.label || asset.view}
|
alt={asset.label || asset.view}
|
||||||
label={asset.label || asset.view || "主体视图预览"}
|
label={asset.label || asset.view || "主体视图预览"}
|
||||||
meta={asset.width && asset.height ? `${asset.width}x${asset.height}` : undefined}
|
meta={subjectAssetStatusLabel(asset)}
|
||||||
className="aspect-[9/16] w-20 bg-white 2xl:w-24"
|
className={`aspect-[9/16] w-20 2xl:w-24 ${running ? "bg-black/40" : failed ? "bg-rose-950/30" : "bg-white"}`}
|
||||||
objectFit="contain"
|
objectFit="contain"
|
||||||
|
busy={running}
|
||||||
|
emptyText={failed ? "失败" : running ? "生成中" : undefined}
|
||||||
title={asset.label || asset.view}
|
title={asset.label || asset.view}
|
||||||
actions={[{
|
actions={[{
|
||||||
key: "regen",
|
key: "regen",
|
||||||
@@ -4352,12 +4518,12 @@ function SourceReferenceBuildPanel({
|
|||||||
icon: <RefreshCw className="h-3 w-3" />,
|
icon: <RefreshCw className="h-3 w-3" />,
|
||||||
tone: "cyan",
|
tone: "cyan",
|
||||||
busy: busyMode === "regen",
|
busy: busyMode === "regen",
|
||||||
disabled: !!subjectAssetBusy,
|
disabled: !!subjectAssetBusy || running,
|
||||||
onClick: () => void regenerateSubjectAsset(asset),
|
onClick: () => void regenerateSubjectAsset(asset),
|
||||||
}]}
|
}]}
|
||||||
onDelete={() => void deleteActorAsset(asset)}
|
onDelete={() => void deleteActorAsset(asset)}
|
||||||
deleting={busyMode === "delete"}
|
deleting={busyMode === "delete"}
|
||||||
deleteDisabled={!!subjectAssetBusy}
|
deleteDisabled={!!subjectAssetBusy || running}
|
||||||
deleteLabel="删除这一张"
|
deleteLabel="删除这一张"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -701,6 +701,7 @@ export type AssetBackground = "white" | "black"
|
|||||||
export type AssetSize = "source" | "1024" | "1536" | "2048"
|
export type AssetSize = "source" | "1024" | "1536" | "2048"
|
||||||
export type SubjectKind = "object" | "living"
|
export type SubjectKind = "object" | "living"
|
||||||
export type SubjectView = string
|
export type SubjectView = string
|
||||||
|
export type SubjectAssetStatus = "queued" | "in_progress" | "completed" | "failed"
|
||||||
export type SceneMode = "remove_subject" | "similar" | "style"
|
export type SceneMode = "remove_subject" | "similar" | "style"
|
||||||
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"
|
export type SceneStyle = "source" | "premium_product" | "clean_studio" | "warm_lifestyle" | "cinematic"
|
||||||
export type SceneAssetRole = "scene" | "first_frame" | "last_frame"
|
export type SceneAssetRole = "scene" | "first_frame" | "last_frame"
|
||||||
@@ -754,6 +755,13 @@ export interface SubjectAsset {
|
|||||||
size: AssetSize
|
size: AssetSize
|
||||||
source_frame_indices?: number[]
|
source_frame_indices?: number[]
|
||||||
ai_completed?: boolean
|
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
|
created_at: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1500,6 +1508,10 @@ export async function generateSubjectAssets(
|
|||||||
subject_profile?: SubjectProfilePreference | null
|
subject_profile?: SubjectProfilePreference | null
|
||||||
prompt?: string
|
prompt?: string
|
||||||
replace_views?: boolean
|
replace_views?: boolean
|
||||||
|
pack_id?: string
|
||||||
|
pack_label?: string
|
||||||
|
pack_mode?: string
|
||||||
|
pack_created_at?: number
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<Job> {
|
): Promise<Job> {
|
||||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/elements/${elementId}/subject-assets`, {
|
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,
|
subject_profile: body.subject_profile ?? null,
|
||||||
prompt: body.prompt ?? "",
|
prompt: body.prompt ?? "",
|
||||||
replace_views: body.replace_views ?? false,
|
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) {
|
if (!res.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user