Fix inspector Ken Burns crash and harden tunnel generation/render.

Use st.project for zoom frame math, retry status polls on transient Cloudflare errors, and run final ffmpeg stitch as a polled background job.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
digital-garbage
2026-06-11 18:44:07 +03:00
co-authored by Cursor
parent 2817c8b777
commit b5ae86839e
4 changed files with 203 additions and 117 deletions
+1
View File
@@ -111,6 +111,7 @@
log: (limit) => j("GET", API("/log" + (limit ? `?limit=${limit}` : ""))),
interrupt: () => j("POST", API("/interrupt")),
renderFinal: (id, clips) => j("POST", API(`/projects/${id}/render`), { clips }),
renderFinalStatus: (id, jobId) => j("GET", API(`/projects/${id}/render/${encodeURIComponent(jobId)}`)),
exportClip: (id, clip) => j("POST", API(`/projects/${id}/export-clip`), { clip }),
resultUrl: (id, m) =>
API(`/projects/${id}/result?filename=${encodeURIComponent(m.filename)}`) +
+3 -3
View File
@@ -272,9 +272,9 @@
[["none", "None"], ["in", "Zoom in"], ["out", "Zoom out"]].forEach(([v, label]) => {
const o = el("option", null, label); o.value = v; if ((fx.zoom || "none") === v) o.selected = true; zoom.append(o);
});
const effFps = (scene.fps_mode !== "project" && scene.fps != null) ? scene.fps : (p.frame_rate || 25);
const effFps = (scene.fps_mode !== "project" && scene.fps != null) ? scene.fps : (st.project.frame_rate || 25);
const effDur = S.sceneDurationSec ? S.sceneDurationSec(scene) : (
((scene.frames_mode !== "project" && scene.frames != null) ? scene.frames : p.num_frames_per_scene) / effFps
((scene.frames_mode !== "project" && scene.frames != null) ? scene.frames : st.project.num_frames_per_scene) / effFps
);
const effFrames = Math.max(1, Math.round(effDur * effFps));
const defaultZoomLen = Math.min(25, Math.max(1, effFrames));
@@ -312,7 +312,7 @@
zLen.oninput = () => patchFxObj({ zoom_frames: parseInt(zLen.value || "1", 10) }, true);
more.append(field("Ramp length (frames)", zLen));
more.append(el("div", "insp-hint",
"Zoom runs only during the ramp window — before/after holds steady at the start/end scale."));
"Zoom runs only during the ramp window - before/after holds steady at the start/end scale."));
}
const lenRow = el("div", "fields-row");
+55 -3
View File
@@ -1681,8 +1681,19 @@
[/\.clip.*no source/i, "Missing text encoder — add a CLIP loader in Models."],
[/not installed/i, "A configured node class is not installed in ComfyUI — check Models for details."],
[/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."],
];
function _isTransientTunnelError(err) {
const m = String(err?.message || err || "");
return /HTTP 53[0-9]|HTTP 52[24]|HTTP 502|Failed to fetch|NetworkError|Load failed|fetch/i.test(m);
}
function _sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function _friendlyGenError(raw) {
// "Generation blocked — …" already names the exact input(s); pass it through so the
// message is actionable instead of a vague "a required input has no source".
@@ -1993,6 +2004,7 @@
return new Promise((resolve) => {
_clearGenTimers();
let pendingStreak = 0;
let transientStreak = 0;
// Faster step-progress poll (sampler current/total steps).
progressTimer = setInterval(async () => {
if (_interrupted) return;
@@ -2017,6 +2029,7 @@
}
try {
const s = await API.status(state.project.id, promptId);
transientStreak = 0;
if (s.state === "error") {
_clearGenTimers();
const msg = s.error ? `ComfyUI error: ${s.error}` : "Generation failed inside ComfyUI — check the ComfyUI terminal for details.";
@@ -2041,14 +2054,50 @@
updateGenProgress({ state: s.state, msg: `${prefix} ${_elapsed()}${step}` });
}
} catch (e) {
if (_isTransientTunnelError(e)) {
transientStreak++;
if (transientStreak < 120) {
updateGenProgress({
msg: `${prefix} ${_elapsed()} · tunnel reconnect (${transientStreak})…`,
});
return;
}
}
_clearGenTimers();
set({ gen: { ...state.gen, state: "error", msg: e.message } });
set({ gen: { ...state.gen, state: "error", msg: _friendlyGenError(e.message) } });
resolve(false);
}
}, 2000);
});
}
async function _pollRenderJob(jobId, clipCount) {
let transientStreak = 0;
for (;;) {
if (_interrupted) throw new Error("Interrupted.");
try {
const st = await API.renderFinalStatus(state.project.id, jobId);
transientStreak = 0;
if (st.state === "done") return st;
if (st.state === "error") throw new Error(st.detail || "Render failed");
updateGenProgress({ msg: `Stitching ${clipCount} clip(s)…` });
} catch (e) {
if (_isTransientTunnelError(e)) {
transientStreak++;
if (transientStreak < 120) {
updateGenProgress({
msg: `Stitching ${clipCount} clip(s) · tunnel reconnect (${transientStreak})…`,
});
await _sleep(2000);
continue;
}
}
throw e;
}
await _sleep(2000);
}
}
// Toggle a pending Studio session reset — applied to the FIRST run of the next
// generation. Clicking again disarms it (in case of a mis-click).
let _resetSessionPending = false;
@@ -2381,7 +2430,10 @@
await flushSave(); // audio tracks / keep-original live on the project — render reads it from disk
set({ gen: { state: "running", promptId: null, media: [], msg: `Stitching ${clips.length} clip(s)…` } });
try {
const r = await API.renderFinal(state.project.id, clips);
const queued = await API.renderFinal(state.project.id, clips);
const r = queued?.job_id
? await _pollRenderJob(queued.job_id, clips.length)
: queued;
// Play the stitched file across the whole timeline (single source, in-point 0).
const order = state.project.scenes.filter((s) => !s.excluded);
state.sceneRenders = {};
@@ -2393,7 +2445,7 @@
const name = (state.project.name || "montage").replace(/[^\w.-]+/g, "_") + `_final_${_stamp()}.mp4`;
await _saveBlobAs(API.resultUrl(state.project.id, r.media), name);
} catch (e) {
set({ gen: { state: "error", promptId: null, media: [], msg: "Render failed: " + e.message } });
set({ gen: { state: "error", promptId: null, media: [], msg: "Render failed: " + _friendlyGenError(e.message) } });
}
}
+144 -111
View File
@@ -578,6 +578,124 @@ def _build_render_filter(clips: list, tracks: Optional[list] = None,
return ";".join(parts), True
class RenderJobError(Exception):
pass
_render_jobs: dict[str, dict] = {}
def _ffmpeg_stitch_final(proj, clips: list) -> dict:
"""Blocking ffmpeg stitch — run via asyncio.to_thread from the render job handler."""
import os
import shutil
import subprocess
import time as _time
if not clips:
raise RenderJobError("Nothing to render — no generated clips.")
ff = shutil.which("ffmpeg")
if not ff:
raise RenderJobError("ffmpeg not found on PATH — install it to render the final video.")
try:
import folder_paths
outdir = folder_paths.get_output_directory()
tempdir = folder_paths.get_temp_directory()
except Exception as e: # noqa: BLE001
raise RenderJobError(f"Output directory unavailable: {e}") from e
def _resolve(c):
if c.get("bin_media_ref"):
mp = media.path_for(c.get("bin_media_ref") or "")
if mp is None:
return ""
return str(mp)
base = outdir if c.get("type", "output") == "output" else tempdir
return os.path.join(base, c.get("subfolder", ""), c.get("filename", ""))
paths = [_resolve(c) for c in clips]
missing = [p for p in paths if not os.path.isfile(p)]
if missing:
raise RenderJobError(f"{len(missing)} clip file(s) not found on disk — regenerate then render.")
keep_original = bool(getattr(proj, "keep_original_audio", True))
clip_by_scene = {c["scene_id"]: c for c in clips if c.get("scene_id")}
media_tracks: list = []
separated_tracks: list = []
for t in (getattr(proj, "audio_tracks", None) or []):
is_sep = t.get("kind") == "separated" or (
t.get("scene_id") and not t.get("media_ref") and t.get("kind") != "overlay"
)
if is_sep:
sid = t.get("scene_id")
pinned = t.get("pinned_media") if isinstance(t.get("pinned_media"), dict) else None
pth = None
if pinned and pinned.get("filename"):
pth = _resolve_comfy_media_path(
pinned.get("filename", ""),
pinned.get("subfolder", ""),
pinned.get("type", "output"),
)
c = clip_by_scene.get(sid) if sid else None
if not pth or not os.path.isfile(pth):
if not c:
continue
pth = _resolve(c)
if not os.path.isfile(pth):
continue
src_in = t.get("pinned_in_sec", t.get("source_in_sec"))
if src_in is None:
src_in = (c.get("in") if c else None) or 0
src_dur = t.get("pinned_dur", t.get("source_dur"))
if src_dur is None:
src_dur = (c.get("dur") if c else None) or 0
separated_tracks.append({
"path": pth,
"source_in": float(src_in),
"source_dur": float(src_dur),
"start_sec": t.get("start_sec") or 0,
"volume": t.get("volume", 1.0),
})
continue
mp = media.path_for(t.get("media_ref") or "")
if mp is None:
continue
media_tracks.append({"path": str(mp), "start_sec": t.get("start_sec") or 0, "volume": t.get("volume", 1.0)})
out_name = f"funpack_final_{int(_time.time())}.mp4"
out_path = os.path.join(tempdir, out_name)
cmd = [ff, "-y"]
for c, pth in zip(clips, paths):
inn, dur = c.get("in"), c.get("dur")
if inn is not None:
cmd += ["-ss", f"{float(inn):.3f}"]
if dur is not None:
cmd += ["-t", f"{float(dur):.3f}"]
cmd += ["-i", pth]
n = len(clips)
for t in media_tracks:
cmd += ["-i", t["path"]]
for t in separated_tracks:
cmd += ["-ss", f"{t['source_in']:.3f}", "-t", f"{t['source_dur']:.3f}", "-i", t["path"]]
tracks = media_tracks + separated_tracks
filt, has_audio = _build_render_filter(clips, tracks=tracks, keep_original=keep_original, base_input=n)
cmd += ["-filter_complex", filt, "-map", "[vout]"]
if has_audio:
cmd += ["-map", "[aout]", "-c:a", "aac", "-b:a", "192k"]
cmd += [
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
tail = (proc.stderr or "")[-1000:]
raise RenderJobError(f"ffmpeg render failed: {tail}")
return {
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": n,
}
def _segment(p: Project, scene_ids: list) -> Project:
"""Project clone holding only `scene_ids` (in their project order). Used to
generate one chain run: its first scene supplies the i2v anchor, the rest are
@@ -1126,124 +1244,39 @@ if web is not None and PromptServer is not None:
raise web.HTTPBadGateway(reason=f"could not fetch result: {e}")
return web.Response(body=data, content_type=ctype.split(";")[0])
# --- API: final render (hard-cut concat of kept clips, with per-clip in/out) ---
# --- API: final render (async job — ffmpeg can exceed Cloudflare tunnel timeouts) ---
import asyncio
import uuid as _uuid
async def _run_render_job(job_id: str, proj, clips: list) -> None:
_render_jobs[job_id] = {"state": "running"}
try:
result = await asyncio.to_thread(_ffmpeg_stitch_final, proj, clips)
_render_jobs[job_id] = {"state": "done", **result}
except RenderJobError as e:
_render_jobs[job_id] = {"state": "error", "detail": str(e)}
except Exception as e: # noqa: BLE001
_render_jobs[job_id] = {"state": "error", "detail": f"Render failed: {e}"}
@routes.post(UI_PREFIX + "/api/projects/{pid}/render")
async def _render_final(req):
proj = _project_or_404(req.match_info["pid"])
body = await req.json() if req.can_read_body else {}
clips = body.get("clips") or [] # ordered [{filename, subfolder, type, in?, dur?, volume?}]
clips = body.get("clips") or []
if not clips:
return web.json_response({"detail": "Nothing to render — no generated clips."}, status=400)
import os
import shutil
import subprocess
import time as _time
ff = shutil.which("ffmpeg")
if not ff:
return web.json_response({"detail": "ffmpeg not found on PATH — install it to render the final video."}, status=503)
try:
import folder_paths
outdir = folder_paths.get_output_directory()
tempdir = folder_paths.get_temp_directory()
except Exception as e: # noqa: BLE001
return web.json_response({"detail": f"Output directory unavailable: {e}"}, status=500)
job_id = _uuid.uuid4().hex
_render_jobs[job_id] = {"state": "queued"}
asyncio.create_task(_run_render_job(job_id, proj, clips))
return web.json_response({"job_id": job_id})
def _resolve(c):
if c.get("bin_media_ref"):
mp = media.path_for(c.get("bin_media_ref") or "")
if mp is None:
return ""
return str(mp)
base = outdir if c.get("type", "output") == "output" else tempdir
return os.path.join(base, c.get("subfolder", ""), c.get("filename", ""))
paths = [_resolve(c) for c in clips]
missing = [p for p in paths if not os.path.isfile(p)]
if missing:
return web.json_response({"detail": f"{len(missing)} clip file(s) not found on disk — regenerate then render."}, status=400)
# Inserted audio tracks (project-level): resolve each media asset to a file. Skip any
# that no longer exist on disk rather than failing the whole render.
keep_original = bool(getattr(proj, "keep_original_audio", True))
clip_by_scene = {c["scene_id"]: c for c in clips if c.get("scene_id")}
media_tracks: list = []
separated_tracks: list = []
for t in (getattr(proj, "audio_tracks", None) or []):
is_sep = t.get("kind") == "separated" or (
t.get("scene_id") and not t.get("media_ref") and t.get("kind") != "overlay"
)
if is_sep:
sid = t.get("scene_id")
pinned = t.get("pinned_media") if isinstance(t.get("pinned_media"), dict) else None
pth = None
if pinned and pinned.get("filename"):
pth = _resolve_comfy_media_path(
pinned.get("filename", ""),
pinned.get("subfolder", ""),
pinned.get("type", "output"),
)
c = clip_by_scene.get(sid) if sid else None
if not pth or not os.path.isfile(pth):
if not c:
continue
pth = _resolve(c)
if not os.path.isfile(pth):
continue
src_in = t.get("pinned_in_sec", t.get("source_in_sec"))
if src_in is None:
src_in = (c.get("in") if c else None) or 0
src_dur = t.get("pinned_dur", t.get("source_dur"))
if src_dur is None:
src_dur = (c.get("dur") if c else None) or 0
separated_tracks.append({
"path": pth,
"source_in": float(src_in),
"source_dur": float(src_dur),
"start_sec": t.get("start_sec") or 0,
"volume": t.get("volume", 1.0),
})
continue
mp = media.path_for(t.get("media_ref") or "")
if mp is None:
continue
media_tracks.append({"path": str(mp), "start_sec": t.get("start_sec") or 0, "volume": t.get("volume", 1.0)})
# The final render is ephemeral (temp dir) — persist it via the Export Save dialog.
out_name = f"funpack_final_{int(_time.time())}.mp4"
out_path = os.path.join(tempdir, out_name)
# Per-clip in/out via -ss/-t before each input, then a filter graph that applies
# per-clip video effects + seam transitions and the audio mix (per-clip volume,
# original-audio toggle, inserted tracks). Re-encode keeps/mixes the audio.
cmd = [ff, "-y"]
for c, pth in zip(clips, paths):
inn, dur = c.get("in"), c.get("dur")
if inn is not None:
cmd += ["-ss", f"{float(inn):.3f}"]
if dur is not None:
cmd += ["-t", f"{float(dur):.3f}"]
cmd += ["-i", pth]
n = len(clips)
for t in media_tracks: # inserted-audio inputs follow the clip inputs (indices n..)
cmd += ["-i", t["path"]]
for t in separated_tracks: # detached clip audio (trimmed per track)
cmd += ["-ss", f"{t['source_in']:.3f}", "-t", f"{t['source_dur']:.3f}", "-i", t["path"]]
tracks = media_tracks + separated_tracks
filt, has_audio = _build_render_filter(clips, tracks=tracks, keep_original=keep_original, base_input=n)
cmd += ["-filter_complex", filt, "-map", "[vout]"]
if has_audio:
cmd += ["-map", "[aout]", "-c:a", "aac", "-b:a", "192k"]
cmd += [
"-c:v", "libx264", "-pix_fmt", "yuv420p",
"-movflags", "+faststart", out_path,
]
proc = subprocess.run(cmd, capture_output=True, text=True)
if proc.returncode != 0:
tail = (proc.stderr or "")[-1000:]
return web.json_response({"detail": f"ffmpeg render failed: {tail}"}, status=500)
return web.json_response({
"media": {"filename": out_name, "subfolder": "", "type": "temp", "kind": "videos"},
"clips": n,
})
@routes.get(UI_PREFIX + "/api/projects/{pid}/render/{job_id}")
async def _render_job_status(req):
_project_or_404(req.match_info["pid"])
job = _render_jobs.get(req.match_info["job_id"])
if not job:
return web.json_response({"detail": "Render job not found."}, status=404)
return web.json_response(job)
# --- API: export one clip (ffmpeg trim from a chain output using in/dur) ---
@routes.post(UI_PREFIX + "/api/projects/{pid}/export-clip")