diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index aec3257a..482fbfdb 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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)}`) + diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index c39580b2..c7bca10b 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -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"); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index e552e768..1efc3db2 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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) } }); } } diff --git a/movie_editor/server.py b/movie_editor/server.py index 4a007591..d6b32675 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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")