// The monitor and its transport: plays the cut, clip after clip, wherever the playhead is. The playhead is the one // clock: playing moves it, seeking (scrubber, ruler, picking a clip) moves it, and the picture follows it. import { composer as c } from "../../composer/composer.js"; import { segments, totalSeconds, isVideoClip, isSubclip, isPicture } from "../../shell/scenes.js"; import { viewUrl } from "../../shell/run.js"; const pad = (n) => String(Math.floor(n)).padStart(2, "0"); export const timecode = (sec, fps) => { const s = Math.max(0, sec), w = Math.floor(s); return [pad(w / 3600), pad((w % 3600) / 60), pad(w % 60), pad((s - w) * (fps || 25))].join(":"); }; export { isPicture }; /** Where a clip's picture comes from: {url, from} (`from` = seconds into the file), or null when there is none yet. * A generated clip is played from the server's own trimmed copy, never the raw render: ComfyUI's saver writes the index at * the END of the file, which a browser cannot seek in (a deep seek stalls or blacks the monitor). */ export function sourceOf(sc, open, dur) { if (isVideoClip(sc)) return sc.source && sc.source.media_ref ? { url: `/fun/api/media/${encodeURIComponent(sc.source.media_ref)}/file`, from: sc.source_in || 0 } : null; const r = (open.scene_renders || {})[sc.id]; if (!(r && r.media)) return null; if (isPicture(r.media.filename)) return { url: viewUrl(r.media), from: 0, picture: true }; // a picture file is shown as it is, never cut as a clip const q = new URLSearchParams({ filename: r.media.filename || "", subfolder: r.media.subfolder || "", type: r.media.type || "output", render_in: String(r.inSec || 0), src_in: String(sc.source_in || 0), dur: String(dur) }); if (sc.effects && sc.effects.reverse) q.set("rev", "1"); return { url: `/fun/api/m/render/projects/${encodeURIComponent(open.id)}/preview-segment/${encodeURIComponent(sc.id)}?${q}`, from: 0 }; } /** The clip on screen at `t`: a clip owns [start, end), and at the very end of the cut the last one keeps its final frame. */ export function segmentAt(open, t) { const all = segments(open).filter((s) => s.kind === "scene"); return all.find((s) => t >= s.start - 1e-6 && t < s.start + s.dur - 1e-6) || (t > 0 && t >= totalSeconds(open) - 1e-6 ? all.find((s) => Math.abs(s.start + s.dur - t) < 1e-3) : null) || null; } export default { id: "preview", mount: "preview", needs: ["project", "selection", "playhead", "api"], setup({ host, app }) { const p = app.project, head = app.playhead, sel = app.selection; const viewer = c.viewer.media({ kind: "video", empty: "", controls: false, loop: false }); const empty = c.emptyState.default({ icon: "🎬", title: "No render yet", hint: "Use Generate in the timeline header" }); const scrub = c.slider.sm({ label: "Playback position", min: 0, max: 1, step: 0.01, onChange: (v) => head.set(v) }); const stop = c.iconButton.sm({ icon: "⏹", label: "Stop", onClick: () => { pause(); head.set(0); } }); const play = c.iconButton.sm({ icon: "▶", label: "Play", onClick: () => (playing ? pause() : start()) }); const time = c.text.sm({ text: timecode(0, 25) }); const frame = c.button.sm({ label: "📌 Save frame", tone: "ghost", disabled: true, onClick: saveFrame }); let target = "", anchorKey = ""; const anchor = c.region.stack({ gap: "none" }); // the "save frame to" choice: the bin, or a scene's i2v anchor function drawAnchor() { const scenes = open() ? open().scenes.filter((s) => !s.excluded && !isVideoClip(s) && !isSubclip(s)) : []; // only a generated scene can start from a picture const key = scenes.map((s) => `${s.id}|${(s.text || "").slice(0, 18)}`).join(",") + `|${target}`; if (key === anchorKey) return; anchorKey = key; if (!scenes.some((s) => s.id === target)) { target = ""; frame.setLabel("📌 Save frame"); } const pick = c.select.sm({ label: "Save frame to", value: target, onChange: (v) => { target = v; frame.setLabel(v ? "📌 Use as anchor" : "📌 Save frame"); }, options: [{ value: "", label: "— save to Media bin —" }, ...scenes.map((s, i) => ({ value: s.id, label: `anchor → Scene ${i + 1}${s.text ? `: ${s.text.slice(0, 18)}` : ""}` }))] }); anchor.set([pick]); } host.append(viewer.node, empty.node, scrub.node, c.toolbar.default({ items: [stop, play, time, anchor, frame] }).node); let playing = false, shown = null, seg = null, from = 0, quiet = false; const open = () => p.project; const video = () => (viewer.element && viewer.element.tagName === "VIDEO" ? viewer.element : null); // null while a picture is on screen const segAt = (t) => (open() ? segmentAt(open(), t) : null); const total = () => (open() ? totalSeconds(open()) : 0); function show(next) { const src = next && sourceOf(next.scene, open(), next.dur); const key = src ? `${next.id}|${src.url}` : ""; if (key === shown) return; shown = key; from = src ? src.from : 0; viewer.node.hidden = !src; empty.node.hidden = Boolean(src); frame.setDisabled(!src); viewer.setSource(src ? (src.picture ? src.url : `${src.url}#t=${src.from}`) : null, src && src.picture ? "image" : "video"); const v = viewer.element; if (!v || v.tagName !== "VIDEO") return; // a picture has no clock of its own: the playhead's does the timing v.addEventListener("timeupdate", () => onTime(v)); v.addEventListener("ended", () => { if (playing && seg) head.set(seg.start + seg.dur); }); if (playing) v.play().catch(() => {}); } function onTime(v) { if (!playing || !seg || v !== viewer.element) return; const at = seg.start + v.currentTime - from; quiet = true; head.set(Math.min(at, seg.start + seg.dur)); quiet = false; if (at >= seg.start + seg.dur - 0.02) head.set(seg.start + seg.dur); // this clip is done: the next one takes over } function sync() { seg = segAt(head.at); show(seg); head.playing = playing; drawAnchor(); const v = video(); if (v && seg) { // the clip's own sound: its volume, nothing when moved to a lane of its own or dropped by the project const sc = seg.scene, vol = sc.audio_separated || (open() || {}).keep_original_audio === false ? 0 : Math.max(0, Math.min(1, sc.audio_volume != null ? +sc.audio_volume : 1)); if (Number.isFinite(vol) && v.volume !== vol) v.volume = vol; } if (v && seg && !quiet) { const want = from + head.at - seg.start; if (Math.abs(v.currentTime - want) > 0.25) v.currentTime = want; } scrub.setMax(total() || 1); scrub.setValue(head.at); time.setText(timecode(head.at, open() && open().frame_rate)); play.node.firstChild.textContent = playing ? "⏸" : "▶"; play.node.title = playing ? "Pause" : "Play"; if (playing && video()) video().paused && video().play().catch(() => {}); if (!playing && video()) video().pause(); } // Gaps and clips with no picture have nothing to play, so the clock itself walks the playhead through them. const walk = setInterval(() => { if (!playing) return; const v = video(); if (seg && v && !v.paused) onTime(v); // timeupdate is too slow to catch a short clip's end else head.set(head.at + 0.04); if (head.at >= total()) { head.set(Math.min(head.at, total())); pause(); } // back on the end first: past it there is no clip, and the picture would drop and reload }, 40); function start() { if (!total()) return; if (head.at >= total() - 0.02) head.set(0); playing = true; sync(); } function pause() { playing = false; sync(); } async function saveFrame() { const blob = await viewer.captureFrame(); if (!blob) return c.toast.warn({ text: "No frame to save yet." }); const { problems, media } = await app.api.uploadMedia([new File([blob], `${(open() || {}).name || "frame"}-${timecode(head.at, 25).replaceAll(":", "-")}.png`, { type: "image/png" })]); if (problems && problems.length) return c.toast.warn({ text: problems.join(" ") }); app.say("media"); const made = media && media[media.length - 1]; if (target && made && open() && open().scenes.some((s) => s.id === target)) { // as the chosen scene's i2v anchor p.setScene(target, "source_image", made.id); return c.toast.good({ text: "Frame saved, and set as that scene's anchor." }); } c.toast.good({ text: "Frame saved to the media bin." }); } // Picking a clip puts the playhead at its start, unless the playhead is already inside it. const onSelect = () => { const s = open() && segments(open()).find((x) => x.kind === "scene" && x.id === sel.focus); if (s && !(head.at >= s.start && head.at < s.start + s.dur)) head.set(s.start); else sync(); }; sync(); app.monitor.look = (look) => viewer.setLook(look); // the effects feature draws on the picture through this const off = [head.on(sync), app.on((what) => { if (what === "select") onSelect(); else if (what === "play.toggle") (playing ? pause() : start()); else if (what === "play.start") start(); else if (what === "play.pause") pause(); else if (what === "preview.refresh") { shown = ""; sync(); } // show the clip again from its file else if (what === "open") { pause(); head.set(0); } // another project: nothing carries over else sync(); })]; return () => { clearInterval(walk); off.forEach((f) => f()); delete app.monitor.look; viewer.destroy(); }; }, };