Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
9.0 KiB
JavaScript
144 lines
9.0 KiB
JavaScript
// 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 } from "../../shell/scenes.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(":"); };
|
|
|
|
/** 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: `/funpack/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;
|
|
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: `/funpack/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 = { node: document.createElement("span") }; // 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.node.replaceChildren(pick.node);
|
|
}
|
|
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 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.url}#t=${src.from}` : null, "video");
|
|
const v = viewer.element;
|
|
if (!v) return;
|
|
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 = viewer.element;
|
|
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.input.max = String(total() || 1);
|
|
scrub.setValue(head.at);
|
|
scrub.input.style.setProperty("--fill", `${Math.min(100, (head.at / (total() || 1)) * 100)}%`); // the slider drew its fill for 0..1
|
|
time.setText(timecode(head.at, open() && open().frame_rate));
|
|
play.node.firstChild.textContent = playing ? "⏸" : "▶"; play.node.title = playing ? "Pause" : "Play";
|
|
if (playing && viewer.element) viewer.element.paused && viewer.element.play().catch(() => {});
|
|
if (!playing && viewer.element) viewer.element.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 = viewer.element;
|
|
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();
|
|
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()); viewer.destroy(); };
|
|
},
|
|
};
|