feat(ui): preview plays the cut clip to clip on the playhead clock, with scrubber, stop/play, timecode and Save frame

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 22:23:34 +03:00
co-authored by Claude Sonnet 5.5
parent 175c1a7643
commit ba5e37ea88
4 changed files with 105 additions and 27 deletions
+4 -2
View File
@@ -277,7 +277,7 @@ define("ruler", "default", ({ marks = [], total = 1, label } = {}) => {
* load" have to look different, and a bare <img> with a broken src looks like
* neither.
*/
define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError } = {}) => {
define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError, controls = true, loop = true } = {}) => {
const stage = el("div", { cls: "cx-viewer-stage" });
let current = null;
let currentKind = kind;
@@ -295,7 +295,7 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
return;
}
const media = nextKind === "video"
? el("video", { cls: "cx-viewer-media", attrs: { src: current, controls: "", loop: "", playsinline: "" } })
? el("video", { cls: "cx-viewer-media", attrs: { src: current, controls: controls ? "" : undefined, loop: loop ? "" : undefined, playsinline: "" } })
: el("img", { cls: "cx-viewer-media", attrs: { src: current, alt: "" } });
mediaEl = media;
// A failed load is reported rather than left as a broken icon: the file is
@@ -322,6 +322,8 @@ define("viewer", "media", ({ src, kind = "image", empty = "Nothing yet", onError
// caller had one to give. Null for anything shown by URL alone, which is
// not enough to name a file back to the server with.
get file() { return currentFile; },
/** The <video>/<img> element on screen, or null. */
get element() { return mediaEl; },
setValue: (next) => show(next, kind),
setSource: (next, nextKind, file) => show(next, nextKind || kind, file),
/**
-7
View File
@@ -8,13 +8,6 @@ const menu = (label) => c.button.menu({ label, tone: "ghost" }); // ine
const items = {
"menubar.right": () => [c.chip.neutral({ label: "saved" }), c.chip.good({ label: "ComfyUI live", dot: true })],
preview: () => [
c.progress.bar({ value: 0, label: "Playback position" }),
c.toolbar.default({ items: [c.iconButton.sm({ icon: "⏹", label: "Stop", disabled: true }), c.iconButton.sm({ icon: "▶", label: "Play", disabled: true }),
c.text.sm({ text: "00:00:00:00" }), c.select.sm({ label: "Save frame to", disabled: true, options: [{ value: "", label: "— save to Media bin —" }], value: "" }),
btn("📌 Save frame")] }),
],
"timeline.actions": () => [btn("⚡ Auto Montage")],
"timeline.toolbar": () => [btn("⊟ Separate audio"), btn("Remove audio")],
};
+85 -18
View File
@@ -1,32 +1,99 @@
// The monitor: plays the render of the selected scene.
// 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 { viewUrl } from "../../shell/run.js";
import { seconds } from "../../shell/scenes.js";
import { segments, totalSeconds, isVideoClip } from "../../shell/scenes.js";
const start = (sc, r, open) => (r.inSec || 0) + (sc.source_in || 0); // where this clip begins in its render
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(":"); };
// A clip with a length of its own (cut, trimmed) stops there; one that follows the project plays its render out.
const end = (sc, r, open) => ((sc.frames_mode || "project") === "project" ? "" : `,${start(sc, r, open) + seconds(sc, open)}`);
/** Where a clip's picture comes from: {url, from} (`from` = seconds into the file), or null when there is none yet. */
export function sourceOf(sc, open) {
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];
return r && r.media ? { url: viewUrl(r.media), from: (r.inSec || 0) + (sc.source_in || 0) } : null;
}
export default {
id: "preview",
mount: "preview",
needs: ["project"],
needs: ["project", "selection", "playhead", "api"],
setup({ host, app }) {
const p = app.project;
const viewer = c.viewer.media({ kind: "video", empty: "" });
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" });
host.append(viewer.node, empty.node);
let shown = null;
const draw = () => {
const sc = p.selected, media = sc && p.project && (p.project.scene_renders || {})[sc.id];
const src = media && media.media ? `${viewUrl(media.media)}#t=${start(sc, media, p.project)}${end(sc, media, p.project)}` : ""; // a split clip starts where its half does
if (src === shown) return; // a keystroke elsewhere must not restart the video
shown = src;
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 });
host.append(viewer.node, empty.node, scrub.node, c.toolbar.default({ items: [stop, play, time,
c.select.sm({ label: "Save frame to", options: [{ value: "", label: "— save to Media bin —" }], value: "" }), frame] }).node);
let playing = false, shown = null, seg = null, from = 0, quiet = false;
const open = () => p.project;
const segAt = (t) => (open() ? segments(open()).find((s) => s.kind === "scene" && t >= s.start - 1e-6 && t < s.start + s.dur - 1e-6) : null);
const total = () => (open() ? totalSeconds(open()) : 0);
function show(next) {
const src = next && sourceOf(next.scene, open());
const key = src ? `${next.id}|${src.url}|${src.from}` : "";
if (key === shown) return;
shown = key; from = src ? src.from : 0;
viewer.node.hidden = !src; empty.node.hidden = Boolean(src);
viewer.setSource(src || null, "video", src ? media.media : null);
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);
const v = viewer.element;
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);
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)) head.set(head.at + 0.04);
if (head.at >= total()) { pause(); head.set(Math.min(head.at, total())); }
}, 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 } = 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");
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();
};
draw();
return app.on(draw);
sync();
const off = [head.on(sync), app.on((what) => (what === "select" ? onSelect() : sync()))];
return () => { clearInterval(walk); off.forEach((f) => f()); };
},
};
+16
View File
@@ -0,0 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
import { timecode, sourceOf } from "./index.js";
test("timecode is hours:minutes:seconds:frames at the project rate", () => {
assert.equal(timecode(0, 25), "00:00:00:00");
assert.equal(timecode(3725.5, 25), "01:02:05:12");
});
test("a clip's picture: a render from where the clip starts in it, a bin clip by its ref, nothing when not made yet", () => {
const open = { scene_renders: { a: { media: { filename: "a.mp4", subfolder: "", type: "output" }, inSec: 1 } } };
assert.deepEqual(sourceOf({ id: "a", source_in: 0.5 }, open), { url: "/view?filename=a.mp4&subfolder=&type=output", from: 1.5 });
assert.deepEqual(sourceOf({ id: "v", source: { type: "video", media_ref: "m 1" }, source_in: 2 }, open), { url: "/funpack/api/media/m%201/file", from: 2 });
assert.equal(sourceOf({ id: "b" }, open), null);
assert.equal(sourceOf({ id: "v", source: { type: "video" } }, open), null);
});