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:
co-authored by
Claude Sonnet 5.5
parent
175c1a7643
commit
ba5e37ea88
@@ -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),
|
||||
/**
|
||||
|
||||
@@ -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")],
|
||||
};
|
||||
|
||||
@@ -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()); };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user