auto-save 2026-05-18 07:05 (~8)

This commit is contained in:
2026-05-18 07:06:00 +08:00
parent d72bf62a97
commit 5fde9f3e22
8 changed files with 164 additions and 35 deletions

View File

@@ -1,18 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"message": "Codex 会话活跃 · 最近命令codex · 1 项未提交变更 · 最近提交auto-save 2026-05-15 16:49 (~4)",
"ts": "2026-05-15T08:54:48Z",
"type": "session-heartbeat"
},
{
"files_changed": 1,
"hash": "0d57081",
"message": "auto-save 2026-05-15 16:55 (~1)",
"ts": "2026-05-15T16:55:21+08:00",
"type": "commit"
},
{
"files_changed": 3,
"hash": "c53d27d",
@@ -3254,6 +3241,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 1 项未提交变更 · 最近提交fix: use image edits for gpt references",
"files_changed": 1
},
{
"ts": "2026-05-18T07:00:37+08:00",
"type": "commit",
"message": "auto-save 2026-05-18 07:00 (~2)",
"hash": "d72bf62",
"files_changed": 2
},
{
"ts": "2026-05-17T23:03:44Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 7 项未提交变更 · 最近提交auto-save 2026-05-18 07:00 (~2)",
"files_changed": 7
}
]
}

View File

@@ -59,6 +59,7 @@
- `REWRITE_MODEL`:通用改写/分镜描述模型,默认 `gemini-2.5-pro`
- `AUDIO_REWRITE_MODEL`:后续音频口播改写模型,默认跟随 `REWRITE_MODEL`;当前第一步不默认调用口播改写,只保留原文案和声音分析
- `AUDIO_PRODUCT_BRIEF`:音频口播改写时注入的 SKG 产品卖点
- `PRODUCT_VIEW_MODEL`:同一产品素材池的视角标注/自动识别模型;当前按项目要求强制使用 `gpt-image-2`
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`OpenAI 兼容生图网关;当前所有生图入口一律强制使用 `gpt-image-2`,不做其他图片模型 fallback
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2`
- `VOICE_PROVIDER`:配音通道,当前固定使用 `azure_openai`

View File

@@ -18,6 +18,7 @@ LOCAL_ASR_MODEL=mlx-community/whisper-tiny
LOCAL_ASR_TIMEOUT_SECONDS=180
TRANSLATE_MODEL=gemini-2.5-flash
REWRITE_MODEL=gemini-2.5-pro
PRODUCT_VIEW_MODEL=gpt-image-2
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
IMAGE_API_KEY=
IMAGE_MODEL=gpt-image-2

View File

@@ -4624,6 +4624,8 @@ class CopyCharacterLibraryAssetReq(BaseModel):
class GenerateProductAngleAssetReq(BaseModel):
source_ref: dict
source_refs: list[dict] = Field(default_factory=list)
source_notes: list[str] = Field(default_factory=list)
target_view: str
note: str = ""
@@ -5016,7 +5018,7 @@ def product_view_batch_prompt(indices: list[int]) -> str:
def analyze_product_view(ref_path: Path, index: int) -> dict:
if not LLM_API_KEY:
if not (IMAGE_API_KEY if PRODUCT_VIEW_MODEL == GPT_IMAGE_MODEL else LLM_API_KEY):
return fallback_product_view(index)
img_b64 = base64.b64encode(ref_path.read_bytes()).decode("ascii")
prompt = (
@@ -5029,8 +5031,8 @@ def analyze_product_view(ref_path: Path, index: int) -> dict:
"{\"view\":\"front|left_45|right_45|side_thickness|inner_contacts|back_bottom\",\"background\":\"white|black|simple|complex|unknown\",\"use_tags\":[\"hero_packshot\"],\"orientation\":{\"product_left\":\"图中哪一侧/不可见/不确定\",\"product_right\":\"图中哪一侧/不可见/不确定\",\"top\":\"图中哪一侧/不可见/不确定\",\"bottom\":\"图中哪一侧/不可见/不确定\",\"inner_side\":\"图中哪一侧/是否可见\",\"outer_side\":\"图中哪一侧/是否可见\",\"opening_direction\":\"U形开口朝图中哪一侧/不可见/不确定\"},\"landmarks\":[\"U形开口\"],\"note\":\"中文备注\",\"risk\":\"\",\"confidence\":0.86}."
)
try:
resp = llm().chat.completions.create(
model=VISION_MODEL,
resp = product_view_llm().chat.completions.create(
model=PRODUCT_VIEW_MODEL,
messages=[{"role": "user", "content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}},
@@ -5050,7 +5052,7 @@ def analyze_product_view(ref_path: Path, index: int) -> dict:
def analyze_product_views_batch(paths_by_index: list[tuple[int, Path]]) -> dict[int, dict]:
if not LLM_API_KEY:
if not (IMAGE_API_KEY if PRODUCT_VIEW_MODEL == GPT_IMAGE_MODEL else LLM_API_KEY):
return {index: fallback_product_view(index) for index, _path in paths_by_index}
results: dict[int, dict] = {}
for start in range(0, len(paths_by_index), PRODUCT_VIEW_BATCH_SIZE):
@@ -5062,8 +5064,8 @@ def analyze_product_views_batch(paths_by_index: list[tuple[int, Path]]) -> dict[
content.append({"type": "text", "text": f"Image index {index}"})
content.append({"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{img_b64}"}})
try:
resp = llm().chat.completions.create(
model=VISION_MODEL,
resp = product_view_llm().chat.completions.create(
model=PRODUCT_VIEW_MODEL,
messages=[{"role": "user", "content": content}],
response_format={"type": "json_object"},
temperature=0.05,
@@ -5121,18 +5123,68 @@ def analyze_product_views(job_id: str, req: AnalyzeProductViewsReq) -> dict:
return {"items": items, "missing_views": missing}
def _make_product_angle_reference_sheet(paths: list[Path], out_path: Path) -> Path:
thumbs: list[Image.Image] = []
for path in paths[:6]:
try:
img = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
img.thumbnail((520, 520), Image.Resampling.LANCZOS)
cell = Image.new("RGB", (560, 560), (255, 255, 255))
cell.paste(img, ((560 - img.width) // 2, (560 - img.height) // 2))
thumbs.append(cell)
except Exception:
continue
if not thumbs:
raise RuntimeError("no usable product reference images")
cols = 3 if len(thumbs) > 2 else len(thumbs)
rows = (len(thumbs) + cols - 1) // cols
sheet = Image.new("RGB", (cols * 560, rows * 560), (245, 245, 245))
for i, thumb in enumerate(thumbs):
sheet.paste(thumb, ((i % cols) * 560, (i // cols) * 560))
out_path.parent.mkdir(parents=True, exist_ok=True)
sheet.save(out_path, "JPEG", quality=94)
return out_path
@app.post("/jobs/{job_id}/assets/product-angle")
def generate_product_angle_asset(job_id: str, req: GenerateProductAngleAssetReq) -> dict:
if job_id not in JOBS:
raise HTTPException(404, "job not found")
source_path = storyboard_ref_path(job_id, req.source_ref)
if not source_path or not source_path.exists():
raw_refs = [req.source_ref] + list(req.source_refs or [])
source_paths: list[Path] = []
seen_paths: set[str] = set()
for ref in raw_refs:
ref_path = storyboard_ref_path(job_id, ref)
if ref_path and ref_path.exists():
key = str(ref_path)
if key not in seen_paths:
seen_paths.add(key)
source_paths.append(ref_path)
if len(source_paths) >= 6:
break
if not source_paths:
raise HTTPException(404, "source product image not found")
source_path = source_paths[0]
model_src = source_path
sheet_tmp: Path | None = None
if len(source_paths) > 1:
sheet_tmp = job_dir(job_id) / "tmp" / f"product_angle_refs_{uuid.uuid4().hex[:8]}.jpg"
model_src = _make_product_angle_reference_sheet(source_paths, sheet_tmp)
target_view = (req.target_view or "目标视角").strip()
note = (req.note or "").strip()
source_notes = [re.sub(r"\s+", " ", str(item)).strip()[:180] for item in (req.source_notes or []) if str(item).strip()]
source_note_clause = (
"Uploaded reference notes from the operator/view recognizer: "
+ " | ".join(source_notes[:6])
+ ". "
if source_notes
else ""
)
prompt = (
"Use the reference image as the same SKG neck-and-shoulder wearable massage product. "
"Use the reference image or reference board as evidence for the same SKG neck-and-shoulder wearable massage product. "
"If a reference board is provided, all panels are the same product from uploaded views; do not output a board, collage, or multiple products. "
f"Generate a clean product-only white-background reference image in this missing view: {target_view}. "
+ source_note_clause
"Preserve the exact product identity: white U-shaped wearable neck and shoulder massager that sits around the neck, asymmetric wearer-left and wearer-right details, side buttons, inner metal massage contacts, opening width, material, thickness, curvature, and real shoulder-neck wearing scale. "
"Use product coordinates: wearer-left/right are the user's body left/right when worn, top is near chin/upper neck, bottom is near collarbone/shoulders, inner side touches skin, outer side is the shell/buttons. "
"Do not mirror both sides into identical shapes; keep visible left/right asymmetry and believable shoulder-neck wearable proportions. "
@@ -5142,9 +5194,15 @@ def generate_product_angle_asset(job_id: str, req: GenerateProductAngleAssetReq)
)
models = [GPT_IMAGE_MODEL]
try:
img_bytes, _mode = _image_edit_call(source_path, prompt, models=models, fallback_text=False, max_attempts=5, max_side=1280)
img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=5, max_side=1600)
except RuntimeError as e:
raise HTTPException(_image_error_status(e), f"product angle generation failed: {e}")
finally:
if sheet_tmp and sheet_tmp.exists():
try:
sheet_tmp.unlink()
except OSError:
pass
asset_id = f"product_angle_{uuid.uuid4().hex[:10]}"
out_path = job_dir(job_id) / "assets" / f"{asset_id}.jpg"
_normalize_asset_image(img_bytes, out_path, source_path, "1024", "white", square=True, fill_subject=True)

View File

@@ -23,6 +23,7 @@ ASR_MODEL=whisper-1
ASR_FALLBACK_MODEL=gemini-2.5-flash
TRANSLATE_MODEL=gemini-2.5-flash
REWRITE_MODEL=gemini-2.5-pro
PRODUCT_VIEW_MODEL=gpt-image-2
IMAGE_BASE_URL=https://ai.skg.com/ezlink/v1
IMAGE_API_KEY=
IMAGE_MODEL=gpt-image-2

View File

@@ -870,7 +870,7 @@ ProductRefStateItem {
</thead>
<tbody>
<tr><td>网页登录</td><td><code>POST /auth/login</code><code>GET /auth/check</code><code>POST /auth/logout</code></td><td><code>web/app/login/page.tsx</code>、Nginx <code>auth_request</code></td><td>登录页提交账号密码到 <code>/api/auth/login</code>,后端设置 HttpOnly 会话 Cookie生产 Nginx 对工作台和 <code>/api/</code><code>/auth/check</code> 做统一校验,未登录页面跳 <code>/login/</code>API 返回 JSON 401。</td></tr>
<tr><td>运行配置 / 模型标注</td><td><code>GET /health</code></td><td><code>getRuntimeHealth</code><code>ModelTrace</code></td><td>返回 <code>models</code>ASR、本机 ASR、ASR fallback、翻译、改写、Vision、GPT 图像模型、主体 6 视图 GPT 图像模型、Azure OpenAI TTS、视频别名和 Seedance 服务商。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。</td></tr>
<tr><td>运行配置 / 模型标注</td><td><code>GET /health</code></td><td><code>getRuntimeHealth</code><code>ModelTrace</code></td><td>返回 <code>models</code>ASR、本机 ASR、ASR fallback、翻译、改写、通用 Vision、产品视角识别 <code>product_view</code>GPT 图像模型、主体 6 视图 GPT 图像模型、Azure OpenAI TTS、视频别名和 Seedance 服务商。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。</td></tr>
<tr><td>历史列表</td><td><code>GET /jobs</code></td><td><code>listJobs</code></td><td>所有 job 精简列表id/url/status/thumbnail/mtime…按 state.json mtime 倒序。前端 URL 无 <code>?job=</code> 时拉它回填全部历史;带 <code>limit</code> 可截断。</td></tr>
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载;前端“开始”队列会在 downloaded 后自动触发音频解析。</td></tr>
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4然后同样进入下载完成状态当前上传后也加入第一步队列下载完成后自动解析音频。</td></tr>
@@ -893,8 +893,8 @@ ProductRefStateItem {
<tr><td>产品图库</td><td><code>GET /product-library/skg</code></td><td><code>listProductLibrary</code></td><td>读取内置 SKG 白底图库 manifest返回产品标题、品类、尺寸、白底评分和预览图 URL。</td></tr>
<tr><td>产品图入库到 job</td><td><code>POST /jobs/{id}/assets</code><code>POST /jobs/{id}/assets/product-library</code></td><td><code>uploadStoryboardAsset</code><code>copyProductLibraryAsset</code></td><td>上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 <code>ImageRef.asset_meta</code> 里返回转换动作和风险;黑底/白底背景本身不强行转换。注意该接口只写图片文件,产品素材池列表另由 <code>PUT /jobs/{id}/product-refs</code> 持久化。</td></tr>
<tr><td>产品素材池保存</td><td><code>PUT /jobs/{id}/product-refs</code></td><td><code>saveProductRefs</code></td><td>把当前 job 的产品素材池列表、识别视角、用途标签、方向、结构点、备注、AI 补图和删除结果保存到 <code>Job.product_refs</code> / <code>state.json</code>。前端上传、识别完成、补角度、编辑备注和删除时都会同步保存;刷新页面或热更新后从 job 恢复,不再要求重新上传和重新识别。</td></tr>
<tr><td>产品视角识别</td><td><code>POST /jobs/{id}/assets/product-views/analyze</code></td><td><code>analyzeProductViews</code></td><td>读取同一产品素材池,按批次把多张图一次性提交给视觉模型,不限制只看前 6 张;识别对象被固定为套在脖子上的 U 形肩颈按摩仪。返回 <code>view</code><code>background</code><code>use_tags</code><code>orientation</code><code>landmarks</code>、中文备注、生成风险和置信度;<code>orientation</code> 明确佩戴者左/右、上/下、内外侧和开口方向对应图中哪边,避免把图片左右误当产品左右。前端不再要求用户手动选择视角,也不做不同产品身份判断。</td></tr>
<tr><td>产品缺角度补图</td><td><code>POST /jobs/{id}/assets/product-angle</code></td><td><code>generateProductAngleAsset</code></td><td>用当前产品白底图作为参考,通过图像模型自动补全缺失视角,输出新的 <code>ImageRef(kind="asset")</code>Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例;图生图通过 <code>/images/edits</code> multipart 提交参考图,不再把 <code>image</code> 当 JSON 参数塞进 <code>/images/generations</code>;遇到 <code>gpt-image-2</code> 上游 429 / saturated 会按退避节奏重试,最终仍失败时返回 503 和可读提示。前端只在自动补图失败时暴露重试入口。</td></tr>
<tr><td>产品视角识别</td><td><code>POST /jobs/{id}/assets/product-views/analyze</code></td><td><code>analyzeProductViews</code></td><td>读取同一产品素材池,按批次把多张图一次性提交给 <code>PRODUCT_VIEW_MODEL=gpt-image-2</code> 做视角标注,不限制只看前 6 张;识别对象被固定为套在脖子上的 U 形肩颈按摩仪。返回 <code>view</code><code>background</code><code>use_tags</code><code>orientation</code><code>landmarks</code>、中文备注、生成风险和置信度;<code>orientation</code> 明确佩戴者左/右、上/下、内外侧和开口方向对应图中哪边,避免把图片左右误当产品左右。前端不再要求用户手动选择视角,也不做不同产品身份判断。</td></tr>
<tr><td>产品缺角度补图</td><td><code>POST /jobs/{id}/assets/product-angle</code></td><td><code>generateProductAngleAsset</code></td><td>用当前同一产品素材池作为参考,通过 <code>gpt-image-2</code> 自动补全缺失视角,输出新的 <code>ImageRef(kind="asset")</code>前端不再固定传第一张图,而是按目标视角给已上传/已标注参考图打分,优先选择真实上传图、目标相邻视角、侧厚/触点/底部对应用途标签和低风险高置信图,最多传 6 张;后端把这些参考图拼成同产品参考板,再通过 <code>/images/edits</code> multipart 提交 <code>gpt-image-2</code>。Prompt 会约束白底产品图、左右非对称、厚度、内侧触点和肩颈真实佩戴比例,并禁止输出拼图/多产品;遇到上游 429 / saturated 会按退避节奏重试,最终仍失败时返回 503 和可读提示。</td></tr>
<tr><td>角色库</td><td><code>GET /character-library/skg</code></td><td><code>listCharacterLibrary</code></td><td>读取内置 5 个透明骨架人角色 manifest每个角色含正面、左右 45 度、侧面、背面、半身近景和背部特写 7 张参考图。</td></tr>
<tr><td>角色图入库到 job</td><td><code>POST /jobs/{id}/assets/character-library</code></td><td><code>copyCharacterLibraryAssets</code></td><td>把所选角色的 7 张参考图复制为当前 job asset返回 <code>subject_images</code>,产品融合生成视频时作为人物身份参考图提交。</td></tr>
<tr><td>产品融合引导图</td><td><code>POST /jobs/{id}/product-fusion/guide</code></td><td><code>createProductFusionGuide</code></td><td>旧流程兼容接口:读取产品图和白底人物图,按 <code>product_region</code> 合成位置引导图。当前内置角色 + 产品 + 描述流程不再主动调用它。</td></tr>
@@ -1004,6 +1004,19 @@ ProductRefStateItem {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-18 · 产品视角识别切到 gpt-image-2 并重做补图参考选择</h3>
<span class="tag violet">API</span>
<span class="tag rose">UI</span>
<span class="tag cyan">Workflow</span>
</header>
<div class="body">
<p><strong>问题:</strong>同一产品素材池的视角标注仍显示通用 Vision 模型;缺角度补图固定拿第一张产品图作为参考,少侧面或内侧时容易用错误视角硬推,生成结果偏离产品真实结构。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增 <code>PRODUCT_VIEW_MODEL=gpt-image-2</code><code>analyze_product_view</code> / <code>analyze_product_views_batch</code> 改用该模型并在 <code>/health</code> 返回 <code>models.product_view</code><code>generateProductAngleAsset</code> 前端请求新增 <code>source_refs</code><code>source_notes</code><code>AudioStoryboardPlanPanel</code> 按目标视角给产品图打分,优先真实上传图、相邻视角、用途标签、置信度和低风险图,最多传 6 张。后端把多张参考图拼成同产品参考板,再用 <code>gpt-image-2</code> 生成目标角度,避免只照抄第一张。</p>
<p><strong>影响:</strong><code>api/main.py</code><code>web/lib/api.ts</code><code>web/components/ad-recreation-board.tsx</code><code>RULES.md</code><code>api/.env.example</code><code>deploy/.env.production.example</code><code>docs/source-analysis.html</code>。后续补产品角度必须从同一产品素材池里挑多张证据图,不要再默认第一张。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-18 · gpt-image-2 图生图改用 edits 并处理上游饱和</h3>

View File

@@ -368,10 +368,10 @@ function audioModelTrace(models?: RuntimeModels): ModelTraceSpec {
function productModelTrace(models?: RuntimeModels): ModelTraceSpec {
return {
title: "产品视角识别 / 补图",
model: modelList([models?.vision, models?.image]),
model: modelList([models?.product_view, models?.image]),
chain: [
`批量视角识别:${modelValue(models?.vision)} 一次读取同一产品多张图,标注视角、左右、上下、用途和风险`,
`缺角度补图:${imageModelChain(models)} 按同一肩颈按摩仪结构补齐缺失视角`,
`批量视角识别:${modelValue(models?.product_view)} 一次读取同一产品多张图,标注视角、左右、上下、用途和风险`,
`缺角度补图:${imageModelChain(models)} 读取最相关的多张已上传参考图,按同一肩颈按摩仪结构补齐缺失视角`,
"前端只保存标注和 AI 补图结果;后续生成视频时每条最多挑 6 张相关产品图",
],
note: "上传产品图、重新识别、缺视角重试都会使用这组模型链路。",
@@ -620,6 +620,55 @@ function createProductRefItem(
}
}
const PRODUCT_ANGLE_REFERENCE_PRIORITY: Record<string, string[]> = {
front: ["front", "left_45", "right_45", "side_thickness", "inner_contacts", "back_bottom"],
left_45: ["left_45", "front", "side_thickness", "right_45", "inner_contacts", "back_bottom"],
right_45: ["right_45", "front", "side_thickness", "left_45", "inner_contacts", "back_bottom"],
side_thickness: ["side_thickness", "left_45", "right_45", "front", "inner_contacts", "back_bottom"],
inner_contacts: ["inner_contacts", "side_thickness", "front", "left_45", "right_45", "back_bottom"],
back_bottom: ["back_bottom", "side_thickness", "inner_contacts", "left_45", "right_45", "front"],
}
function productAngleReferenceScore(item: ProductRefItem, targetView: string) {
const priority = PRODUCT_ANGLE_REFERENCE_PRIORITY[targetView] ?? PRODUCT_VIEW_SLOTS.map((slot) => slot.value)
const rank = priority.indexOf(item.view)
let score = rank === -1 ? 0 : 90 - rank * 12
if (item.source === "upload" || item.source === "library") score += 28
if (item.source === "ai") score -= 18
if (item.confidence) score += Math.round(item.confidence * 14)
if (item.useTags.includes("asymmetry")) score += 8
if (targetView === "side_thickness" && item.useTags.includes("side_thickness")) score += 16
if (targetView === "inner_contacts" && item.useTags.includes("inner_contact")) score += 16
if (targetView === "back_bottom" && item.useTags.includes("back_bottom")) score += 16
if (item.risk) score -= 10
return score
}
function selectProductAngleReferenceItems(items: ProductRefItem[], targetView: string) {
const unique = new Map<string, ProductRefItem>()
for (const item of items) {
if (!unique.has(item.id)) unique.set(item.id, item)
}
return [...unique.values()]
.sort((a, b) => productAngleReferenceScore(b, targetView) - productAngleReferenceScore(a, targetView))
.slice(0, 6)
}
function productAngleSourceNotes(items: ProductRefItem[]) {
return items.map((item, index) => {
const parts = [
`ref${index + 1}`,
`view=${productViewLabel(item.view)}`,
`source=${item.source}`,
item.note ? `note=${item.note}` : "",
formatProductOrientation(item.orientation),
item.landmarks?.length ? `landmarks=${item.landmarks.join("/")}` : "",
item.risk ? `risk=${item.risk}` : "",
].filter(Boolean)
return parts.join("")
})
}
function normalizeStoredProductItem(item: ProductRefItem, index: number): ProductRefItem {
const ref = { ...item.ref, asset_meta: item.ref.asset_meta ?? item.assetMeta }
const restored = createProductRefItem(
@@ -1960,10 +2009,13 @@ function AudioStoryboardPlanPanel({
for (const slot of missing) {
setProductAngleBusy(slot.value)
try {
const references = selectProductAngleReferenceItems(working, slot.value)
const ref = await generateProductAngleAsset(job.id, {
source_ref: working[0].ref,
source_ref: references[0].ref,
source_refs: references.map((item) => item.ref),
source_notes: productAngleSourceNotes(references),
target_view: slot.label,
note: slot.hint,
note: `${slot.hint};请综合这些同产品参考图补目标视角,不要只照抄某一张。`,
})
working = [
...working,
@@ -2117,13 +2169,15 @@ function AudioStoryboardPlanPanel({
const generateMissingProductAngle = async (slot: typeof PRODUCT_VIEW_SLOTS[number]) => {
if (!job || !productItems.length) return
const source = productItems[0]
const references = selectProductAngleReferenceItems(productItems, slot.value)
setProductAngleBusy(slot.value)
try {
const ref = await generateProductAngleAsset(job.id, {
source_ref: source.ref,
source_ref: references[0].ref,
source_refs: references.map((item) => item.ref),
source_notes: productAngleSourceNotes(references),
target_view: slot.label,
note: slot.hint,
note: `${slot.hint};请综合这些同产品参考图补目标视角,不要只照抄某一张。`,
})
setProductItems((prev) => {
const next = [...prev, createProductRefItem(ref, prev.length, "ai", slot.value, `AI 补齐:${slot.hint}`, "white", undefined, undefined, undefined, "", 1)]

View File

@@ -153,6 +153,7 @@ export interface RuntimeModels {
rewrite?: string
audio_rewrite?: string
vision?: string
product_view?: string
image?: string
image_base_url?: string
image_fallbacks?: string[]
@@ -224,7 +225,7 @@ export async function uploadStoryboardAsset(jobId: string, file: File): Promise<
export async function generateProductAngleAsset(
jobId: string,
body: { source_ref: ImageRef; target_view: string; note?: string },
body: { source_ref: ImageRef; source_refs?: ImageRef[]; source_notes?: string[]; target_view: string; note?: string },
): Promise<ImageRef> {
const res = await fetch(`${API_BASE}/jobs/${jobId}/assets/product-angle`, {
method: "POST",