Harden generate against tunnel drops and missing i2v anchors.

Retry save and queue requests on transient HTTP 530/502 errors, block generate
when the project failed to save, and return a clear error if anchor media is gone.
This commit is contained in:
digital-garbage
2026-06-11 21:31:52 +03:00
parent d058dcc46d
commit 87f04ffac6
4 changed files with 92 additions and 7 deletions
+14 -2
View File
@@ -360,8 +360,20 @@ def continuity_media_refs(full: Project, target: Project) -> list[str]:
return list(dict.fromkeys(refs))
@dataclass
class GuideEntry:
def scene_anchor_media_refs(target: Project) -> list[str]:
"""Media-bin ids required as i2v anchors on generative scenes."""
refs: list[str] = []
for sc in target.scenes:
if sc.excluded or is_video_clip(sc):
continue
src = sc.source
if not src:
continue
stype = source_type(sc)
ref = src.media_ref
if ref and stype in ("image", "generated_frame", "mixed"):
refs.append(ref)
return list(dict.fromkeys(refs))
"""Optional per-scene guide (only used when project guide_settings.stack_enabled)."""
enabled: bool = True
source: str = "template" # "template" | "scene" | "image"
+48 -5
View File
@@ -730,7 +730,7 @@
state.saving = true;
_notifySaveChip();
try {
const saved = await API.saveProject(snapshot.id, snapshot);
const saved = await _retryOnTunnel(() => API.saveProject(snapshot.id, snapshot));
// If the user edited again while this save was in flight (e.g. dropped a new
// anchor), do NOT replace local state with the older snapshot we just wrote.
if (_localDirty) scheduleSave();
@@ -1690,6 +1690,31 @@
return /HTTP 53[0-9]|HTTP 52[24]|HTTP 502|Failed to fetch|NetworkError|Load failed|fetch/i.test(m);
}
async function _retryOnTunnel(fn, attempts) {
attempts = attempts || 8;
let last;
for (let i = 0; i < attempts; i++) {
try {
return await fn();
} catch (e) {
last = e;
if (!_isTransientTunnelError(e) || i >= attempts - 1) throw e;
await _sleep(1500 + i * 500);
}
}
throw last;
}
async function _flushSaveForGenerate() {
await flushSave();
if (_localDirty) {
throw new Error(
"Could not save your latest edits before generate (tunnel may have dropped). "
+ "Wait for the save indicator to clear, then try again."
);
}
}
function _sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
@@ -2130,7 +2155,10 @@
_markGenInFlight(sceneIds);
set({ gen: { state: "queuing", promptId: null, media: [], msg: `${prefix}: queuing…`, step: 0, maxStep: 0 } });
try {
const r = await API.generate(state.project.id, onlyScene || null, onlyScene ? null : sceneIds, !!resetSession);
const r = await _retryOnTunnel(
() => API.generate(state.project.id, onlyScene || null, onlyScene ? null : sceneIds, !!resetSession),
10,
);
if (!r.prompt_id) { set({ gen: { ...state.gen, state: "error", msg: "No prompt id returned." } }); return false; }
_queueRenderPrompts(sceneIds);
if (r.validation && state.project) {
@@ -2161,7 +2189,12 @@
async function generate(onlyScene) {
if (!state.project) return;
await flushSave();
try {
await _flushSaveForGenerate();
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
return;
}
if (!onlyScene) return generateMontage();
const reset = _resetSessionPending; _resetSessionPending = false;
if (reset) state.resetSessionArmed = false;
@@ -2175,7 +2208,12 @@
// session reset applies to the FIRST run only.
async function generateMontage() {
if (!state.project) return;
await flushSave();
try {
await _flushSaveForGenerate();
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
return;
}
const runs = _runs();
if (!runs.length) { set({ gen: { state: "error", promptId: null, media: [], msg: "No active scenes to generate." } }); return; }
const reset = _resetSessionPending; _resetSessionPending = false;
@@ -2197,7 +2235,12 @@
set({ gen: { state: "error", promptId: null, media: [], msg: "No scenes selected." } });
return;
}
await flushSave();
try {
await _flushSaveForGenerate();
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: _friendlyGenError(e.message) } });
return;
}
const runs = _runsForSceneIds(ids);
if (!runs.length) {
set({ gen: { state: "error", promptId: null, media: [], msg: "No generatable runs in the selection." } });
+18
View File
@@ -434,6 +434,17 @@ def _missing_continuity_media(full: Project, target: Project) -> list[str]:
return missing
def _missing_scene_anchor_media(target: Project) -> list[str]:
"""i2v anchor images referenced on scenes but missing from the media bin."""
from .backend.timeline import scene_anchor_media_refs
missing: list[str] = []
for ref in scene_anchor_media_refs(target):
if not media.path_for(ref):
missing.append(ref)
return missing
def _attach_scene_anchors(samp: dict, media_pack: Optional[dict], target: Project) -> dict:
"""Mixed-source i2v anchors (Img2Video starting latents), separate from guides."""
import json
@@ -1174,6 +1185,13 @@ if web is not None and PromptServer is not None:
{"detail": f"Missing media for continuity or guide stack ({ids}). Re-upload or clear the reference in Engine settings / Scene inspector."},
status=400,
)
missing_anchors = _missing_scene_anchor_media(target)
if missing_anchors:
ids = ", ".join(missing_anchors)
return web.json_response(
{"detail": f"Missing i2v anchor image in Media bin ({ids}). Re-save the frame or pick another anchor."},
status=400,
)
# V1 uniform chain: if trimmed scenes all agree on a frame count, use it.
# This makes the timeline trim handle actually affect generation length,
# provided the user has linked EmptyLTXVLatent.num_frames → Project Frames
+12
View File
@@ -380,6 +380,18 @@ def test_stale_identity_pin_ignored_when_auto_off():
assert continuity_media_refs(p, p) == []
def test_scene_anchor_media_refs():
p = _project(
scenes=[
{"text": "a", "source": {"type": "generated_frame", "media_ref": "frame1"}},
{"text": "b", "source": {"type": "carry"}},
],
)
from movie_editor.backend.timeline import scene_anchor_media_refs
assert scene_anchor_media_refs(p) == ["frame1"]
def test_scene_character_media_refs(monkeypatch):
char_map = {"c1": {"id": "c1", "face_ref": "f1", "body_ref": "b1"}}
monkeypatch.setattr(