Revert "Fix timeline/generate drift and stale chain preview segments."
This reverts commit 4cc1c206738523e7bb03dca847c42a11738808e4.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user