auto-save 2026-05-13 09:59 (~4)
This commit is contained in:
@@ -1164,6 +1164,19 @@
|
|||||||
"message": "auto-save 2026-05-13 09:48 (~1)",
|
"message": "auto-save 2026-05-13 09:48 (~1)",
|
||||||
"hash": "e012c07",
|
"hash": "e012c07",
|
||||||
"files_changed": 1
|
"files_changed": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T09:54:21+08:00",
|
||||||
|
"type": "commit",
|
||||||
|
"message": "auto-save 2026-05-13 09:54 (~3)",
|
||||||
|
"hash": "2472fb2",
|
||||||
|
"files_changed": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ts": "2026-05-13T01:57:36Z",
|
||||||
|
"type": "session-heartbeat",
|
||||||
|
"message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 09:54 (~3)",
|
||||||
|
"files_changed": 2
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
35
api/main.py
35
api/main.py
@@ -641,6 +641,7 @@ class GenerateReq(BaseModel):
|
|||||||
negative_prompt: str = "" # ✗ 不需要的元素(负向)
|
negative_prompt: str = "" # ✗ 不需要的元素(负向)
|
||||||
model: str = "" # 留空用 IMAGE_MODEL 默认
|
model: str = "" # 留空用 IMAGE_MODEL 默认
|
||||||
mode: str = "edit" # "edit" 带参考图,"text" 纯文字
|
mode: str = "edit" # "edit" 带参考图,"text" 纯文字
|
||||||
|
from_selected: bool = False # True 时优先用 frame.selected 的生成图作 reference(迭代),否则原关键帧
|
||||||
|
|
||||||
|
|
||||||
@app.post("/jobs/{job_id}/frames/{idx}/generate", response_model=Job)
|
@app.post("/jobs/{job_id}/frames/{idx}/generate", response_model=Job)
|
||||||
@@ -656,6 +657,17 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
if not frame_path.exists():
|
if not frame_path.exists():
|
||||||
raise HTTPException(404, "frame file missing")
|
raise HTTPException(404, "frame file missing")
|
||||||
|
|
||||||
|
# 决定 i2i 参考图:from_selected=True 且存在 selected 生成图 → 用它(迭代);否则原关键帧
|
||||||
|
reference_path = frame_path
|
||||||
|
reference_source = "keyframe"
|
||||||
|
if req.from_selected:
|
||||||
|
sel = next((g for g in frame.generated_images if g.selected), None)
|
||||||
|
if sel:
|
||||||
|
sel_path = job_dir(job_id) / "gen" / f"{idx:03d}_{sel.id}.jpg"
|
||||||
|
if sel_path.exists():
|
||||||
|
reference_path = sel_path
|
||||||
|
reference_source = f"gen:{sel.id[:6]}"
|
||||||
|
|
||||||
full_prompt = req.prompt.strip()
|
full_prompt = req.prompt.strip()
|
||||||
if req.extra_prompt.strip():
|
if req.extra_prompt.strip():
|
||||||
full_prompt = f"{full_prompt}. Include: {req.extra_prompt.strip()}"
|
full_prompt = f"{full_prompt}. Include: {req.extra_prompt.strip()}"
|
||||||
@@ -673,14 +685,18 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
|
|
||||||
img_b64: str | None = None
|
img_b64: str | None = None
|
||||||
if req.mode == "edit":
|
if req.mode == "edit":
|
||||||
img_b64 = b64lib.b64encode(frame_path.read_bytes()).decode("ascii")
|
img_b64 = b64lib.b64encode(reference_path.read_bytes()).decode("ascii")
|
||||||
|
|
||||||
MAX_ATTEMPTS = 3
|
# 尝试 i2i 最多 3 次,全失败时降级 text-only 再试 1 次
|
||||||
|
plan: list[str] = ([req.mode] * 3) if req.mode == "edit" else [req.mode]
|
||||||
|
if req.mode == "edit":
|
||||||
|
plan.append("text") # i2i 都失败时自动降级
|
||||||
resp_data: dict = {}
|
resp_data: dict = {}
|
||||||
last_err = ""
|
last_err = ""
|
||||||
for attempt in range(MAX_ATTEMPTS):
|
effective_mode = req.mode
|
||||||
|
for attempt, current_mode in enumerate(plan):
|
||||||
try:
|
try:
|
||||||
if req.mode == "edit":
|
if current_mode == "edit":
|
||||||
data_uri = f"data:image/jpeg;base64,{img_b64}"
|
data_uri = f"data:image/jpeg;base64,{img_b64}"
|
||||||
# OpenAI SDK 不直接支持 image 参数,用底层 httpx
|
# OpenAI SDK 不直接支持 image 参数,用底层 httpx
|
||||||
with httpx.Client(timeout=120) as client:
|
with httpx.Client(timeout=120) as client:
|
||||||
@@ -705,6 +721,7 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
resp_data = resp.model_dump() if hasattr(resp, "model_dump") else {"data": [{"b64_json": resp.data[0].b64_json}]}
|
resp_data = resp.model_dump() if hasattr(resp, "model_dump") else {"data": [{"b64_json": resp.data[0].b64_json}]}
|
||||||
|
|
||||||
if resp_data.get("data"):
|
if resp_data.get("data"):
|
||||||
|
effective_mode = current_mode
|
||||||
break
|
break
|
||||||
err_obj = resp_data.get("error") or {}
|
err_obj = resp_data.get("error") or {}
|
||||||
last_err = f"empty data · {err_obj.get('code', '')} · {str(err_obj.get('message', ''))[:200]}"
|
last_err = f"empty data · {err_obj.get('code', '')} · {str(err_obj.get('message', ''))[:200]}"
|
||||||
@@ -722,13 +739,15 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
last_err = f"{type(e).__name__}: {e}"
|
last_err = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
if attempt < MAX_ATTEMPTS - 1:
|
if attempt < len(plan) - 1:
|
||||||
print(f"[image gen retry {attempt + 1}/{MAX_ATTEMPTS}] {last_err}", flush=True)
|
next_mode = plan[attempt + 1]
|
||||||
|
tag = f"fallback → {next_mode}" if next_mode != current_mode else f"retry {attempt + 1}/{len(plan)}"
|
||||||
|
print(f"[image gen {tag}] {last_err}", flush=True)
|
||||||
_time.sleep(1.5 * (attempt + 1))
|
_time.sleep(1.5 * (attempt + 1))
|
||||||
|
|
||||||
data_arr = resp_data.get("data", [])
|
data_arr = resp_data.get("data", [])
|
||||||
if not data_arr:
|
if not data_arr:
|
||||||
raise HTTPException(500, f"image gen failed after {MAX_ATTEMPTS} attempts: {last_err}")
|
raise HTTPException(500, f"image gen failed after {len(plan)} attempts: {last_err}")
|
||||||
|
|
||||||
item = data_arr[0]
|
item = data_arr[0]
|
||||||
b64 = item.get("b64_json")
|
b64 = item.get("b64_json")
|
||||||
@@ -745,7 +764,7 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
|||||||
id=gen_id,
|
id=gen_id,
|
||||||
prompt=full_prompt,
|
prompt=full_prompt,
|
||||||
model=model,
|
model=model,
|
||||||
mode=req.mode,
|
mode=effective_mode,
|
||||||
url=f"/jobs/{job_id}/frames/{idx}/gen/{gen_id}.jpg",
|
url=f"/jobs/{job_id}/frames/{idx}/gen/{gen_id}.jpg",
|
||||||
selected=False,
|
selected=False,
|
||||||
created_at=_time.time(),
|
created_at=_time.time(),
|
||||||
|
|||||||
@@ -80,14 +80,13 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
|||||||
const isSelected = selected.has(f.index)
|
const isSelected = selected.has(f.index)
|
||||||
const desc = f.description
|
const desc = f.description
|
||||||
|
|
||||||
const handleGenerateMat = async () => {
|
const handleGenerateNext = async () => {
|
||||||
if (activeIndex === null || !f) return
|
if (activeIndex === null || !f) return
|
||||||
const base = f.description?.suggested_prompt?.trim()
|
const base = f.description?.suggested_prompt?.trim()
|
||||||
if (!base) {
|
if (!base) {
|
||||||
toast.error("请先识别此分镜(右上角『识别』按钮)")
|
toast.error("请先识别此分镜(右上角『识别』按钮)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 自动选用此帧 → ImageGenCard 才会渲染
|
|
||||||
if (!selected.has(f.index)) onToggleSelect(f.index)
|
if (!selected.has(f.index)) onToggleSelect(f.index)
|
||||||
|
|
||||||
const extraEn = customs.filter((c) => c.en).map((c) => c.en).join(", ")
|
const extraEn = customs.filter((c) => c.en).map((c) => c.en).join(", ")
|
||||||
@@ -99,9 +98,10 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
|||||||
negative_prompt: "watermark, username text, social media handle, platform logo, overlay text, captions",
|
negative_prompt: "watermark, username text, social media handle, platform logo, overlay text, captions",
|
||||||
model: "gemini-3-pro-image-preview",
|
model: "gemini-3-pro-image-preview",
|
||||||
mode: "edit",
|
mode: "edit",
|
||||||
|
from_selected: true, // 优先用上一轮 selected 的生成图作 reference(迭代)
|
||||||
})
|
})
|
||||||
onJobUpdate?.(updated)
|
onJobUpdate?.(updated)
|
||||||
toast.success(`分镜 ${f.index + 1} 垫图生成完成 → 「生图」节点查看`)
|
toast.success(`分镜 ${f.index + 1} 生成完成 → 「生图」节点查看`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error("生图失败:" + (e instanceof Error ? e.message : String(e)))
|
toast.error("生图失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -340,19 +340,21 @@ export function FrameLightbox({ jobId, frames, activeIndex, selected, onClose, o
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleGenerateMat}
|
onClick={handleGenerateNext}
|
||||||
disabled={generating || !desc?.suggested_prompt}
|
disabled={generating || !desc?.suggested_prompt}
|
||||||
className="mt-2 w-full text-[12px] py-2 rounded-md bg-gradient-to-r from-rose-500 to-pink-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5 font-semibold transition"
|
className="mt-2 w-full text-[12px] py-2 rounded-md bg-gradient-to-r from-rose-500 to-pink-500 text-white hover:opacity-95 disabled:opacity-40 disabled:cursor-not-allowed inline-flex items-center justify-center gap-1.5 font-semibold transition"
|
||||||
title={!desc?.suggested_prompt ? "先识别此分镜" : `合成元素到关键帧场景(${customs.length} 条自定义 + 识别结果)`}
|
title={!desc?.suggested_prompt ? "先识别此分镜" : `结合 ${customs.length} 条提取元素与已选图,生成下一张`}
|
||||||
>
|
>
|
||||||
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Wand2 className="h-3.5 w-3.5" />}
|
||||||
{generating ? "生成中…(5-15 秒)" : "⚡ 生成垫图"}
|
{generating ? "生成中…(5-15 秒)" : "⚡ 生成下一张"}
|
||||||
</button>
|
</button>
|
||||||
{!desc?.suggested_prompt && (
|
{!desc?.suggested_prompt ? (
|
||||||
<div className="mt-1 text-[10px] text-white/40 text-center">先识别此分镜,再生成垫图</div>
|
<div className="mt-1 text-[10px] text-white/40 text-center">先识别此分镜,再生成</div>
|
||||||
)}
|
) : (
|
||||||
{desc?.suggested_prompt && customs.length === 0 && (
|
<div className="mt-1 text-[10px] text-white/40 text-center">
|
||||||
<div className="mt-1 text-[10px] text-white/35 text-center">未加自定义元素 · 将仅按识别结果生成</div>
|
基于:{f.generated_images?.some((g) => g.selected) ? "上一轮已选生成图" : "原关键帧"}
|
||||||
|
{customs.length > 0 && ` + ${customs.length} 条提取元素`}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export async function translateText(text: string, target: "en" | "zh" = "en"): P
|
|||||||
export async function generateImage(
|
export async function generateImage(
|
||||||
jobId: string,
|
jobId: string,
|
||||||
frameIdx: number,
|
frameIdx: number,
|
||||||
body: { prompt: string; extra_prompt?: string; negative_prompt?: string; model?: string; mode?: "edit" | "text" },
|
body: { prompt: string; extra_prompt?: string; negative_prompt?: string; model?: string; mode?: "edit" | "text"; from_selected?: boolean },
|
||||||
): Promise<Job> {
|
): Promise<Job> {
|
||||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/generate`, {
|
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/generate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
Reference in New Issue
Block a user