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:
co-authored by
Cursor
parent
2817c8b777
commit
b5ae86839e
@@ -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)}`) +
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user