Revert "Fix timeline/generate drift and stale chain preview segments."

This reverts commit 4cc1c206738523e7bb03dca847c42a11738808e4.
This commit is contained in:
digital-garbage
2026-06-13 02:40:31 +03:00
parent 77b6257698
commit 4647cd87b2
6 changed files with 23 additions and 220 deletions
+2 -8
View File
@@ -130,13 +130,8 @@
gitCheckout: (branch) => j("POST", API("/git/checkout"), { branch }),
// generate (a single scene, or an explicit run of scene ids = one chain request)
generate: (id, onlyScene, sceneIds, resetSession, timelineSnapshot) =>
j("POST", API(`/projects/${id}/generate`), {
only_scene: onlyScene || null,
scene_ids: sceneIds || null,
reset_session: !!resetSession,
timeline_snapshot: timelineSnapshot || null,
}),
generate: (id, onlyScene, sceneIds, resetSession) =>
j("POST", API(`/projects/${id}/generate`), { only_scene: onlyScene || null, scene_ids: sceneIds || null, reset_session: !!resetSession }),
status: (id, promptId) => j("GET", API(`/projects/${id}/status/${promptId}`)),
progress: () => j("GET", API("/progress")),
livePreview: () => j("GET", API("/live-preview")),
@@ -161,7 +156,6 @@
type: m.type || "output",
render_in: String(spec.renderIn != null ? spec.renderIn : 0),
});
if (spec.cacheBust != null) q.set("v", String(spec.cacheBust));
u += "?" + q.toString();
}
return u;
+2 -9
View File
@@ -158,8 +158,7 @@
const sc = S.scene(c.sceneId);
const sourceIn = sc?.source_in || 0;
const renderIn = Math.max(0, (c.inSec || 0) - sourceIn);
const cacheBust = (st && st._previewGenToken) || Date.now();
c.streamUrl = API.previewSegmentUrl(pid, c.sceneId, { media: c.media, renderIn, cacheBust });
c.streamUrl = API.previewSegmentUrl(pid, c.sceneId, { media: c.media, renderIn });
}
}
}
@@ -922,8 +921,6 @@
}
if (g.livePreviewUrl) {
_ensureLivePreviewDom();
const canvas = document.querySelector(".pm-canvas");
if (canvas && !canvas.classList.contains("gen-live-preview")) canvas.classList.add("gen-live-preview");
if (_livePreviewLayerEl) _livePreviewLayerEl.style.display = "";
if (_livePreviewEl && _livePreviewEl.src !== g.livePreviewUrl) _livePreviewEl.src = g.livePreviewUrl;
if (_livePreviewEl) _livePreviewEl.style.display = "";
@@ -1098,9 +1095,6 @@
// Rebuilding the monitor DOM during playback detaches <video> nodes and kills multi-scene
// chain preview mid-stream (often visible from scene 3 onward once store ticks catch up).
// Still mount live preview during generation so sampling frames stay visible while playing.
const genActive = ["queuing", "running", "pending"].includes(st.gen?.state);
if (genActive && _livePreviewConfigured(st)) _ensureLivePreviewDom();
if (_scrubDepth > 0 || _playing) {
_ensureStageMounted();
return;
@@ -1114,8 +1108,7 @@
const previewOverlay = previewAsset && (previewAsset.kind === "image" || previewAsset.kind === "audio" || previewAsset.kind === "video");
// ── canvas (video + placeholder) ────────────────────────────────────────
const genLive = ["queuing", "running", "pending"].includes(gen.state) && _livePreviewConfigured(st);
const canvas = el("div", "pm-canvas" + (previewOverlay ? " media-previewing" : "") + (genLive ? " gen-live-preview" : ""));
const canvas = el("div", "pm-canvas" + (previewOverlay ? " media-previewing" : ""));
_slateEl = el("div", "pm-slate");
_slateEl.style.display = "none";
_slateEl.append(el("div", "pm-slate-title"));
+11 -55
View File
@@ -828,7 +828,6 @@
_syncEditorStateToProject();
snapshot.scene_renders = state.project.scene_renders;
snapshot.scene_ghosts = state.project.scene_ghosts;
snapshot.models = state.project.models;
const previewKeyBefore = _promptPreviewKey(snapshot);
const selBefore = JSON.stringify(state.selectedSceneIds || []);
const metaBefore = { name: snapshot.name, scene_count: (snapshot.scenes || []).length };
@@ -840,12 +839,7 @@
// 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();
else {
state.project = saved;
// Keep in-memory renders if a run finished while this save was in flight.
state.project.scene_renders = JSON.parse(JSON.stringify(state.sceneRenders || {}));
state.project.scene_ghosts = JSON.parse(JSON.stringify(state.sceneGhosts || []));
}
else state.project = saved;
state.saving = false;
_pruneSelection();
const selChanged = JSON.stringify(state.selectedSceneIds || []) !== selBefore;
@@ -2412,8 +2406,6 @@
[/Node registry unavailable/i, "ComfyUI is offline or still starting up — wait a moment and try again."],
[/HTTP 53[0-9]|HTTP 52[24]|HTTP 502|Failed to fetch|NetworkError|Load failed/i,
"Cloudflare tunnel lost contact with the vast.ai instance. GPU work may still be running — wait, refresh, and check ComfyUI output. If it keeps failing, use vast.ai direct port access or restart the tunnel."],
[/Timeline on the server|Timeline did not save|does not match the editor/i,
"Timeline did not save before generate. Wait for the save indicator to clear, then try again."],
];
function _isTransientTunnelError(err) {
@@ -2441,24 +2433,7 @@
return flushSave();
}
function _generationTimelineSnap(project, sceneIds) {
if (!project) return { anchor: "", scenes: [] };
const ids = sceneIds && sceneIds.length ? new Set(sceneIds) : null;
const scenes = (project.scenes || [])
.filter((s) => !s.excluded && isGenerativeScene(s))
.filter((s) => !ids || ids.has(s.id))
.map((s) => ({
id: s.id,
text: s.text || "",
source: {
type: (s.source && s.source.type) || "carry",
media_ref: (s.source && s.source.media_ref) || null,
},
}));
return { anchor: (project.anchor || "").trim(), scenes };
}
async function _flushSaveForGenerate(sceneIds) {
async function _flushSaveForGenerate() {
await flushPendingEdits();
if (_localDirty) {
throw new Error(
@@ -2466,16 +2441,6 @@
+ "Wait for the save indicator to clear, then try again."
);
}
const localSnap = _generationTimelineSnap(state.project, sceneIds);
const saved = await _retryOnTunnel(() => API.getProject(state.project.id));
const serverSnap = _generationTimelineSnap(saved, sceneIds);
if (JSON.stringify(localSnap) !== JSON.stringify(serverSnap)) {
throw new Error(
"Timeline did not save before generate. Wait for the save indicator to clear, then try again."
);
}
state._previewGenToken = Date.now();
return localSnap;
}
function _sleep(ms) {
@@ -2786,10 +2751,8 @@
if (recordedSceneIds.length) {
_validateRendersToken++;
state.notice = "";
state._previewGenToken = Date.now();
_syncEditorStateToProject();
scheduleSaveSilent();
notify();
}
}
@@ -2937,15 +2900,8 @@
_markGenInFlight(sceneIds);
set({ gen: { state: "queuing", promptId: null, media: [], msg: `${prefix}: queuing…`, step: 0, maxStep: 0, livePreviewUrl: null, liveScene: -1, liveSceneCount: 0 } });
try {
const timelineSnapshot = _generationTimelineSnap(state.project, sceneIds);
const r = await _retryOnTunnel(
() => API.generate(
state.project.id,
onlyScene || null,
onlyScene ? null : sceneIds,
!!resetSession,
timelineSnapshot,
),
() => 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; }
@@ -2979,17 +2935,17 @@
async function generate(onlyScene) {
if (!state.project) return;
if (!onlyScene) return generateMontage();
const sc = scene(onlyScene);
const ids = sc ? genUnitSceneIds(genUnitId(sc)) : [onlyScene];
try {
await _flushSaveForGenerate(ids);
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;
const sc = scene(onlyScene);
const ids = sc ? genUnitSceneIds(genUnitId(sc)) : [onlyScene];
await _generateRun(ids, onlyScene, "Generating scene", reset);
}
@@ -2998,13 +2954,13 @@
// session reset applies to the FIRST run only.
async function generateMontage() {
if (!state.project) return;
const runs = _runs();
try {
await _flushSaveForGenerate(runs.flat());
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;
if (reset) state.resetSessionArmed = false;
@@ -3025,13 +2981,13 @@
set({ gen: { state: "error", promptId: null, media: [], msg: "No scenes selected." } });
return;
}
const runs = _runsForSceneIds(ids);
try {
await _flushSaveForGenerate(runs.flat());
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." } });
return;
-2
View File
@@ -448,8 +448,6 @@ input[type=number] { font-family: var(--font-mono); }
.gen-readout .pulse { width: 8px; height: 8px; border-radius: 50%; background: var(--amber); animation: pulse 1s infinite; flex: 0 0 auto; }
.gen-readout.error { color: var(--danger); }
.gen-readout.error .pulse { background: var(--danger); animation: none; }
.pm-canvas.gen-live-preview .pm-stage { opacity: 0.12; }
.pm-canvas.gen-live-preview .pm-live-preview { z-index: 4; }
.pm-live-preview { position: absolute; inset: 12px 12px 52px 12px; display: flex; align-items: center; justify-content: center; pointer-events: none; z-index: 3; }
.pm-live-preview-img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 6px; border: 1px solid var(--line); box-shadow: 0 8px 32px #000a; background: #0006; }
.pm-live-preview-badge { position: absolute; top: 8px; left: 8px; font-family: var(--font-mono); font-size: 11px; color: var(--amber-2); background: #000b; border: 1px solid var(--line); padding: 3px 8px; border-radius: 12px; }
+7 -78
View File
@@ -200,33 +200,10 @@ def _scene_playback_clip_spec(
_preview_segment_cache: dict[str, str] = {}
def _invalidate_preview_segment_cache(pid: str) -> None:
"""Drop cached ffmpeg trims when a project re-generates or source files change."""
import os
prefix = f"{pid}:"
for key in list(_preview_segment_cache.keys()):
if not key.startswith(prefix):
continue
cached = _preview_segment_cache.pop(key, None)
if cached:
try:
os.unlink(cached)
except OSError:
pass
def _source_mtime(path: str) -> int:
import os
try:
return int(os.path.getmtime(path))
except OSError:
return 0
def _preview_segment_cache_key(pid: str, scene_id: str, clip: dict, *, src_mtime: int = 0) -> str:
def _preview_segment_cache_key(pid: str, scene_id: str, clip: dict) -> str:
return (
f"{pid}:{scene_id}:{clip.get('filename')}:{clip.get('subfolder') or ''}:"
f"{clip.get('type') or 'output'}:{clip.get('in')}:{clip.get('dur')}:mt{int(src_mtime or 0)}"
f"{clip.get('type') or 'output'}:{clip.get('in')}:{clip.get('dur')}"
)
@@ -359,40 +336,6 @@ def _parse_prompt_variants(prompt: str, seed: int) -> tuple[dict, dict]:
)
def _timeline_snapshot_mismatch(snapshot: Optional[dict], target: Project) -> Optional[str]:
"""Return a short label when the saved project does not match the editor snapshot."""
from .backend.timeline import is_generative_scene, source_type
if not isinstance(snapshot, dict):
return None
exp_by_id: dict[str, dict] = {}
for raw in snapshot.get("scenes") or []:
if isinstance(raw, dict) and raw.get("id"):
exp_by_id[str(raw["id"])] = raw
if not exp_by_id:
return None
exp_anchor = str(snapshot.get("anchor") or "").strip()
if exp_anchor != str(getattr(target, "anchor", None) or "").strip():
return "project anchor"
for sc in target.scenes:
if sc.excluded or not is_generative_scene(sc):
continue
sid = str(sc.id)
if sid not in exp_by_id:
continue
exp = exp_by_id[sid]
if str(exp.get("text") or "") != str(sc.text or ""):
return f"scene {sid} prompt"
exp_src = exp.get("source") if isinstance(exp.get("source"), dict) else {}
st = source_type(sc)
if str(exp_src.get("type") or "carry") != st:
return f"scene {sid} source mode"
ref = getattr(sc.source, "media_ref", None) if sc.source else None
if (exp_src.get("media_ref") or None) != ref:
return f"scene {sid} anchor image"
return None
def _project_models(p: Optional[Project]) -> dict:
"""The project's own pipeline config, or the global default when it has none yet."""
m = getattr(p, "models", None)
@@ -1421,23 +1364,13 @@ if web is not None and PromptServer is not None:
# --- API: generate / status / result ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/generate")
async def _generate(req):
pid = req.match_info["pid"]
_invalidate_preview_segment_cache(pid)
p = _project_or_404(pid)
p = _project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
scene_ids = body.get("scene_ids")
if scene_ids:
target = _segment(p, scene_ids)
else:
target = _solo(p, body.get("only_scene"))
mismatch = _timeline_snapshot_mismatch(body.get("timeline_snapshot"), target)
if mismatch:
return web.json_response({
"detail": (
f"Timeline on the server does not match the editor ({mismatch}). "
"Wait for save to finish, reload the project, then generate again."
),
}, status=409)
try:
validation = bridge.validate_generation_prompt(p, target)
except Exception as e: # noqa: BLE001
@@ -1714,15 +1647,10 @@ if web is not None and PromptServer is not None:
clip = _scene_playback_clip_spec(proj, scene_id, render_override=render_override)
except KeyError as e:
return web.json_response({"detail": str(e)}, status=404)
try:
src_path = _resolve_clip_src_path(clip)
except FileNotFoundError as e:
return web.json_response({"detail": str(e)}, status=400)
src_mtime = _source_mtime(src_path)
key = _preview_segment_cache_key(pid, scene_id, clip, src_mtime=src_mtime)
key = _preview_segment_cache_key(pid, scene_id, clip)
cached = _preview_segment_cache.get(key)
if cached and os.path.isfile(cached):
return web.FileResponse(cached, headers={"Cache-Control": "private, max-age=60"})
return web.FileResponse(cached, headers={"Cache-Control": "private, max-age=3600"})
try:
import folder_paths
tempdir = folder_paths.get_temp_directory()
@@ -1734,13 +1662,14 @@ if web is not None and PromptServer is not None:
f"funpack_preview_{pid[:8]}_{scene_id}_{int(_time.time() * 1000)}.mp4",
)
try:
src_path = _resolve_clip_src_path(clip)
_ffmpeg_trim_clip(src_path, out_path, clip.get("in"), clip.get("dur"))
except FileNotFoundError as e:
return web.json_response({"detail": str(e)}, status=400)
except RuntimeError as e:
return web.json_response({"detail": str(e)}, status=503)
_preview_segment_cache[key] = out_path
return web.FileResponse(out_path, headers={"Cache-Control": "private, max-age=60"})
return web.FileResponse(out_path, headers={"Cache-Control": "private, max-age=3600"})
# --- API: export one clip (ffmpeg trim from a chain output using in/dur) ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/export-clip")
+1 -68
View File
@@ -356,71 +356,4 @@ def test_build_render_filter_blank_canvas():
)
assert "[0:v]format=yuv420p,setsar=1[vbase]" in filt
assert "[aout]" in filt
assert has_audio is True
def test_timeline_snapshot_mismatch_detects_stale_anchor():
from movie_editor.backend.timeline import Scene, SceneSource
from movie_editor.server import _timeline_snapshot_mismatch
p = Project(
name="t",
anchor="new anchor",
scenes=[
Scene(id="s1", text="scene one", source=SceneSource(type="image", media_ref="img_b")),
Scene(id="s2", text="scene two", source=SceneSource(type="carry")),
],
)
snap = {
"anchor": "new anchor",
"scenes": [
{"id": "s1", "text": "scene one", "source": {"type": "image", "media_ref": "img_a"}},
{"id": "s2", "text": "scene two", "source": {"type": "carry", "media_ref": None}},
],
}
assert _timeline_snapshot_mismatch(snap, p) == "scene s1 anchor image"
def test_timeline_snapshot_mismatch_none_when_aligned():
from movie_editor.backend.timeline import Scene, SceneSource
from movie_editor.server import _timeline_snapshot_mismatch
p = Project(
name="t",
anchor="anchor",
scenes=[
Scene(id="s1", text="one", source=SceneSource(type="image", media_ref="img_a")),
Scene(id="s2", text="two", source=SceneSource(type="carry")),
],
)
snap = {
"anchor": "anchor",
"scenes": [
{"id": "s1", "text": "one", "source": {"type": "image", "media_ref": "img_a"}},
{"id": "s2", "text": "two", "source": {"type": "carry", "media_ref": None}},
],
}
assert _timeline_snapshot_mismatch(snap, p) is None
def test_preview_segment_cache_key_includes_source_mtime():
from movie_editor.server import _preview_segment_cache_key
clip = {"filename": "chain.mp4", "subfolder": "", "type": "temp", "in": 0.0, "dur": 2.6}
k1 = _preview_segment_cache_key("p1", "s1", clip, src_mtime=100)
k2 = _preview_segment_cache_key("p1", "s1", clip, src_mtime=200)
assert k1 != k2
def test_invalidate_preview_segment_cache_drops_project_entries(tmp_path, monkeypatch):
from movie_editor import server
stale = tmp_path / "stale.mp4"
stale.write_bytes(b"x")
server._preview_segment_cache.clear()
server._preview_segment_cache["p1:s1:chain.mp4:::temp:0:2.6:mt1"] = str(stale)
server._preview_segment_cache["p2:s1:other.mp4:::temp:0:1:mt1"] = str(tmp_path / "keep.mp4")
server._invalidate_preview_segment_cache("p1")
assert not any(k.startswith("p1:") for k in server._preview_segment_cache)
assert any(k.startswith("p2:") for k in server._preview_segment_cache)
assert not stale.exists()
assert has_audio is True