feat(ui): generation progress + clock, Selected (N), frame/I/O/Cmd-comma keys, timeline follows playback, audio track window (volume, start, split, remove), Engine settings link

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 03:14:54 +03:00
co-authored by Claude Sonnet 5.5
parent 53752f3018
commit 45a3d8ac4c
13 changed files with 146 additions and 21 deletions
+5 -1
View File
@@ -155,7 +155,11 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init
timeAt,
setLanes(next = []) { lanes = next; draw(); },
setZoom(next) { px = Math.max(4, next); draw(); },
setPlayhead(seconds) { playhead = seconds; head.style.insetInlineStart = `${Math.max(0, seconds) * px}px`; },
/** `follow`: scroll along when the playhead leaves the visible part (while playing). */
setPlayhead(seconds, follow = false) {
playhead = seconds; head.style.insetInlineStart = `${Math.max(0, seconds) * px}px`;
if (follow) { const x = Math.max(0, seconds) * px + gutter.offsetWidth, w = node.clientWidth; if (x > node.scrollLeft + w - 24 || x < node.scrollLeft + gutter.offsetWidth) node.scrollLeft = Math.max(0, x - w * 0.2); }
},
destroy() { disposers.forEach((d) => d()); if (observer) observer.disconnect(); node.remove(); },
};
});
+49
View File
@@ -0,0 +1,49 @@
// The audio lanes laid over the cut (files from the bin, and a clip's separated sound): drawn on the timeline, slid and trimmed there,
// and a window for each lane's volume, start, split and removal.
import { composer as c } from "../../composer/composer.js";
import { moveLane, trimLane, removeTrack, splitTrack } from "../../shell/audio.js";
const ID = "t:";
export const tracksLane = (p) => (p.audio_tracks || []).length ? {
id: "tracks", label: "Audio", kind: "audio", move: true,
clips: p.audio_tracks.map((t) => ({ id: ID + t.id, start: t.start_sec || 0, dur: t.pinned_dur ?? t.source_dur ?? 1, title: t.label || "Audio", trim: t.kind === "separated" ? t.pinned_dur != null : t.source_dur != null })),
} : null;
export default {
id: "audio-tracks",
mount: "timeline.toolbar",
needs: ["project", "playhead"],
setup({ app }) {
const p = app.project;
const edit = (fn) => p.edit(fn);
function open(id) {
const t = (p.project.audio_tracks || []).find((x) => x.id === id);
if (!t) return;
const separated = t.kind === "separated";
let volume = t.volume != null ? t.volume : 1, start = t.start_sec || 0;
const body = c.region.stack({ gap: "sm", children: [
c.hint.default({ text: separated ? "This clip's own sound. It follows its clip; slide or trim it on the timeline." : "A sound over the cut. Slide and trim it on the timeline." }),
c.field.default({ label: "Volume", control: c.slider.readout({ label: "Volume", value: volume, min: 0, max: 2, step: 0.05, precision: 2, onCommit: (v) => { volume = v; } }) }),
separated ? null : c.field.default({ label: "Starts at (s)", control: c.number.md({ label: "Starts at", value: start, min: 0, step: 0.1, onChange: (v) => { start = v; } }) }),
].filter(Boolean) });
const win = c.modal.generic({ title: t.label || "Audio track", size: "sm", body });
win.setFooter({ actions: [
c.button.sm({ label: "Remove", tone: "danger", onClick: async () => { if (await c.modal.dialogue({ title: "Remove audio track", message: separated ? "Remove this track? The clip gets its sound back." : "Remove this audio track?", tone: "danger", confirmLabel: "Remove" }).result) { edit((pr) => removeTrack(pr, id)); win.close("done"); } } }),
separated ? null : c.button.sm({ label: "Split at playhead", tone: "ghost", onClick: () => { if (edit((pr) => splitTrack(pr, id, app.playhead.at))) win.close("done"); else c.toast.warn({ text: "Put the playhead inside the track to split it." }); } }),
c.button.sm({ label: "Cancel", tone: "ghost", onClick: () => win.close("cancel") }),
c.button.sm({ label: "Save", tone: "primary", onClick: () => { edit((pr) => { const x = pr.audio_tracks.find((y) => y.id === id); if (!x) return false; x.volume = volume; if (!separated) x.start_sec = Math.max(0, start); return true; }); win.close("done"); } }),
].filter(Boolean) });
}
const entry = {
owns: (id) => id.startsWith(ID),
lanes: (pr) => [tracksLane(pr)].filter(Boolean),
select: (id) => open(id.slice(ID.length)),
move: (id, d) => edit((pr) => moveLane(pr, id.slice(ID.length), d)),
trim: (id, edge, d) => edit((pr) => trimLane(pr, id.slice(ID.length), edge, d)),
};
app.timelineLanes.push(entry);
app.say("timeline.lanes");
return () => { const i = app.timelineLanes.indexOf(entry); if (i >= 0) app.timelineLanes.splice(i, 1); app.say("timeline.lanes"); };
},
};
+1 -1
View File
@@ -14,7 +14,7 @@ const rig = (phase, extra = {}) => {
waitForTerminal: () => Object.assign(Promise.resolve("done"), { cancel() {} }), ...extra };
const project = { project: doc, get scenes() { return doc.scenes; }, selected: null,
edit: (fn) => fn(doc), editFor: async (_id, fn) => fn(doc) };
const app = { project, lastRun: { n: 0 }, pipeline: { slots: () => [] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
const app = { project, selection: { ids: [] }, lastRun: { n: 0 }, pipeline: { slots: () => [] }, generate: g, api: { newTasteGeneration: () => Promise.resolve({}) }, on: () => () => {} };
const host = document.createElement("div");
hub.setup({ host, app });
return { handlers, doc, host };
+6 -5
View File
@@ -16,7 +16,7 @@ export const renderFor = (sc, p, media, unitSec, promptId) => ({ media, ...(prom
export default {
id: "generate",
mount: "timeline.actions",
needs: ["project", "pipeline", "generate", "api"],
needs: ["project", "pipeline", "generate", "api", "selection"],
setup({ host, app }) {
const p = app.project, g = app.generate;
let said = false;
@@ -79,19 +79,20 @@ export default {
if (!made && !stopped && !said) tell("Nothing to generate: every scene is left out or is a video clip.");
} finally { busy = false; draw(); }
}
const pickedScenes = () => { const ids = new Set(app.selection.ids); const hit = p.scenes.filter((s) => ids.has(s.id)); return hit.length ? hit : p.selected ? [p.selected] : []; }; // every picked clip, else the one in focus
const unitsOf = (scenes) => [...new Set(scenes.map(genUnitId))];
const put = inPlace(host);
let drawn = "";
function draw() {
const open = p.project;
const key = `${busy}|${held}|${g.state().phase}|${Boolean(open)}|${Boolean(p.selected)}`;
const picked = open ? pickedScenes().length : 0, key = `${busy}|${held}|${g.state().phase}|${Boolean(open)}|${picked}`;
if (key === drawn) return; // progress ticks must not rebuild the buttons
drawn = key;
const working = busy || held || g.state().phase === QUEUED || g.state().phase === RUNNING; // also a run this page did not start
const go = c.button.sm({ label: working ? "Generating…" : "▶ Generate", tone: "primary", disabled: working || !open,
onClick: () => runUnits(unitsOf(p.scenes)) });
const one = c.button.sm({ label: "Selected", disabled: working || !p.selected, onClick: () => runUnits(unitsOf([p.selected])) });
const one = c.button.sm({ label: picked > 1 ? `Selected (${picked})` : "Selected", disabled: working || !picked, onClick: () => runUnits(unitsOf(pickedScenes())) });
const stop = c.button.sm({ label: "■ Stop", tone: "danger", onClick: () => { stopped = true; g.cancel(); } });
stop.node.hidden = !working;
put(go.node, one.node, stop.node);
@@ -129,9 +130,9 @@ export default {
});
draw();
const offRun = g.subscribe(draw);
const offApp = app.on((what) => { if (what === "generate.selected" && !busy && p.selected) runUnits(unitsOf([p.selected])); else draw(); });
const offApp = app.on((what) => { if (what === "generate.selected" && !busy && pickedScenes().length) runUnits(unitsOf(pickedScenes())); else draw(); });
const offers = [offer(app, { id: "generate-all", label: "Generate", icon: "▶", run: () => { if (!busy && p.project) runUnits(unitsOf(p.scenes)); } }),
offer(app, { id: "generate-selected", label: "Generate selected", icon: "▶", run: () => { if (!busy && p.selected) runUnits(unitsOf([p.selected])); } })];
offer(app, { id: "generate-selected", label: "Generate selected", icon: "▶", run: () => { if (!busy && pickedScenes().length) runUnits(unitsOf(pickedScenes())); } })];
return () => { offRun(); offApp(); offers.forEach((f) => f()); };
},
};
+38
View File
@@ -0,0 +1,38 @@
// What a run is doing, beside Generate: the step it is on, a bar, and how long it has been going (this page's own clock).
import { composer as c } from "../../composer/composer.js";
import { QUEUED, RUNNING } from "../../shell/run.js";
export const elapsed = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return `${String(Math.floor(s / 60)).padStart(2, "0")}m ${String(s % 60).padStart(2, "0")}s`; };
export default {
id: "generate-progress",
mount: "timeline.actions",
needs: ["generate"],
setup({ host, app }) {
const g = app.generate;
const bar = c.progress.bar({ value: 0, max: 100, label: "Generating" });
const text = c.text.sm({ text: "" });
const wrap = document.createElement("span");
wrap.style.cssText = "display:inline-flex;align-items:center;gap:8px;min-inline-size:0";
bar.node.style.inlineSize = "90px";
wrap.append(bar.node, text.node);
wrap.hidden = true;
host.append(wrap);
let since = 0;
const draw = () => {
const s = g.state(), working = s.phase === QUEUED || s.phase === RUNNING;
if (working && !since) since = Date.now();
if (!working) since = 0;
wrap.hidden = !working;
if (!working) { return; }
const pr = s.progress;
bar.setValue(pr && pr.max ? (pr.value / pr.max) * 100 : 0);
text.setText(`${s.phase === QUEUED ? "Queued" : pr && pr.max ? `Step ${pr.value}/${pr.max}` : "Running"} · ${elapsed(Date.now() - since)}`);
};
const off = g.subscribe(draw);
const timer = setInterval(() => { if (since) draw(); }, 1000); // the clock ticks between ComfyUI's messages
draw();
return () => { off(); clearInterval(timer); wrap.remove(); };
},
};
@@ -0,0 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import { elapsed } from "./progress.js";
test("elapsed reads as minutes and seconds", () => {
assert.equal(elapsed(0), "00m 00s");
assert.equal(elapsed(72_900), "01m 12s");
assert.equal(elapsed(-5), "00m 00s");
});
+1 -1
View File
@@ -31,7 +31,7 @@ export function sceneRows(p, app) {
if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })];
const cuts = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)).length > 1;
return [
c.banner.info({ text: "Generating with FunPack Studio + Chain Sampler", action: { label: "Engine settings →", onClick() {} } }),
c.banner.info({ text: "Generating with FunPack Studio + Chain Sampler", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }),
...(cuts ? [c.hint.default({ text: "This scene has editorial cuts — Generate regens the whole uncut scene." })] : []),
...(isSubclip(sc) ? [c.hint.default({ text: "This is a cut: its prompt and source belong to the first part." })] : [
c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }),
+10
View File
@@ -1,5 +1,6 @@
// v4's editing keys: undo/redo, delete the selected scene, +/- zoom the timeline, space/K/L play and pause, J back a second, S split. Scene keys do nothing in Simple mode, where no timeline shows.
import * as edits from "../../shell/edits.js";
import { segments } from "../../shell/scenes.js";
export default {
id: "keys",
@@ -8,6 +9,12 @@ export default {
setup({ app }) {
const p = app.project, sel = app.selection;
const scene = (fn) => () => (app.mode.now === "simple" ? false : fn());
const frame = () => (p.project && p.project.frame_rate) || 25;
const edge = (which) => { // I / O: the playhead to the picked clip's in / out point
const s = p.project && segments(p.project).find((x) => x.kind === "scene" && x.id === sel.focus);
if (!s) return false;
app.playhead.set(which === "start" ? s.start : s.start + s.dur);
};
const remove = scene(() => sel.ids.length ? sel.ids.forEach((id) => p.edit((pr) => edits.removeScene(pr, id))) : false);
const off = [
app.keys.bind("mod+z", () => p.undo()),
@@ -19,6 +26,9 @@ export default {
app.keys.bind(" ", (e) => (e.target.closest && e.target.closest("button, a, [role=slider]") ? false : app.say("play.toggle"))), app.keys.bind("k", () => app.say("play.pause")), app.keys.bind("l", () => app.say("play.start")),
app.keys.bind("j", () => app.playhead.set(app.playhead.at - 1)),
app.keys.bind("s", scene(() => app.say("split"))),
app.keys.bind("arrowleft", scene(() => app.playhead.set(app.playhead.at - 1 / frame()))), app.keys.bind("arrowright", scene(() => app.playhead.set(app.playhead.at + 1 / frame()))),
app.keys.bind("i", scene(() => edge("start"))), app.keys.bind("o", scene(() => edge("end"))),
app.keys.bind("mod+,", () => (app.openSettings ? app.openSettings() : false)),
];
return () => off.forEach((f) => f());
},
+4 -6
View File
@@ -5,8 +5,7 @@ import { composer as c } from "../../composer/composer.js";
import { segments, totalSeconds, clock } from "../../shell/scenes.js";
import { inPlace } from "../../shell/place.js";
import * as edits from "../../shell/edits.js";
import { moveLane, trimLane } from "../../shell/audio.js";
import { videoLane, audioLane, tracksLane, framesAt } from "./lanes.js";
import { videoLane, audioLane, framesAt } from "./lanes.js";
const ZOOM = [20, 40, 80, 160, 320];
const sceneIds = (p) => segments(p).filter((s) => s.kind === "scene").map((s) => s.id);
@@ -25,11 +24,10 @@ const stage = {
pxPerSecond: ZOOM[zoom],
onSeek: (sec) => head.set(sec),
onSelect: (id, how) => { const own = owner(id); if (own) own.select(id); else sel.pick(id, how, sceneIds(p.project)); },
onMove: (id, d) => { const own = owner(id); if (own) own.move(id, d); else id.startsWith("t:") && edit((pr) => moveLane(pr, id.slice(2), d)); },
onMove: (id, d) => { const own = owner(id); if (own) own.move(id, d); },
onTrim: (id, edge, d) => {
const own = owner(id);
if (own) return own.trim(id, edge, d);
if (id.startsWith("t:")) return edit((pr) => trimLane(pr, id.slice(2), edge, d));
const dur = segments(p.project).find((s) => s.id === id).dur;
edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d) : d > 0 && edits.trimLeft(pr, id, d)); // the left edge only cuts in
},
@@ -44,14 +42,14 @@ const stage = {
const ghostActions = (g) => [{ label: "✕", title: "Remove from the timeline",
onClick: () => edit((pr) => { pr.scene_ghosts = (pr.scene_ghosts || []).filter((x) => x.id !== g.id); return true; }) }];
const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions, (sc) => (app.clipTags || []).flatMap((f) => { try { return f(sc); } catch { return []; } })), audioLane(open, sel.ids), tracksLane(open), ...extra.flatMap((e) => { try { return e.lanes(open); } catch { return []; } })].filter(Boolean) : []); }; // a feature's lanes failing must not stop the baseline's
const draw = () => { const open = p.project; view.setLanes(open ? [videoLane(open, sel.ids, sel.focus, clipActions, ghostActions, (sc) => (app.clipTags || []).flatMap((f) => { try { return f(sc); } catch { return []; } })), audioLane(open, sel.ids), ...extra.flatMap((e) => { try { return e.lanes(open); } catch { return []; } })].filter(Boolean) : []); }; // a feature's lanes failing must not stop the baseline's
const setZoom = (z) => { zoom = Math.min(ZOOM.length - 1, Math.max(0, z)); view.setZoom(ZOOM[zoom]); };
host.append(view.node);
const off = [app.on((what) => {
if (what === "zoom.in") setZoom(zoom + 1); else if (what === "zoom.out") setZoom(zoom - 1);
else if (what === "zoom.fit") { const total = p.project ? totalSeconds(p.project) : 0; const fit = ZOOM.filter((z) => z * (total + 2) <= view.node.clientWidth); setZoom(fit.length ? ZOOM.indexOf(fit[fit.length - 1]) : 0); }
else draw();
}), head.on((sec) => view.setPlayhead(sec))];
}), head.on((sec) => view.setPlayhead(sec, head.playing))];
draw();
return () => { off.forEach((f) => f()); view.destroy(); };
},
-6
View File
@@ -31,9 +31,3 @@ export const audioLane = (p, picked) => ({
id: "audio", label: "Original", kind: "audio",
clips: segments(p).filter((s) => s.kind === "scene").map((s, i) => ({ id: `a:${s.id}`, start: s.start, dur: s.dur, title: `S${i + 1}`, selected: picked.includes(s.id), excluded: Boolean(s.scene.excluded) })),
});
/** Audio lanes added to the project (a clip's separated sound, or a file laid over the cut), when there are any. */
export const tracksLane = (p) => (p.audio_tracks || []).length ? {
id: "tracks", label: "Audio", kind: "audio", move: true,
clips: p.audio_tracks.map((t) => ({ id: `t:${t.id}`, start: t.start_sec || 0, dur: t.pinned_dur ?? t.source_dur ?? 1, title: t.label || "Audio", trim: t.kind === "separated" ? t.pinned_dur != null : t.source_dur != null })),
} : null;
+2
View File
@@ -24,6 +24,7 @@ export default [
"./features/wheel/index.js",
"./features/status/index.js",
"./features/audio/index.js",
"./features/audio/tracks.js",
"./features/imports/index.js",
"./features/clipfx/index.js",
"./features/overlays/index.js",
@@ -32,6 +33,7 @@ export default [
"./features/preview/fx.js",
"./features/montage/index.js",
"./features/generate/warn.js",
"./features/generate/progress.js",
"./features/composer/index.js",
"./features/settings/index.js",
"./features/welcome/index.js",
+12
View File
@@ -101,3 +101,15 @@ export function trimLane(p, id, edge, deltaSec) {
t.offset_sec = (t.offset_sec || 0) + d;
return true;
}
/** Cut a file lane in two at `atSec` (timeline seconds); both halves keep playing what they did. -> the new (later) lane, or false. */
export function splitTrack(p, id, atSec) {
const t = (p.audio_tracks || []).find((x) => x.id === id);
if (!t || t.kind === "separated" || t.source_dur == null) return false;
const start = t.start_sec || 0, cut = atSec - start;
if (!(cut > 0.05 && cut < t.source_dur - 0.05)) return false;
const tail = { ...t, id: newId(), start_sec: atSec, source_in_sec: (t.source_in_sec || 0) + cut, source_dur: t.source_dur - cut };
t.source_dur = cut;
p.audio_tracks = [...p.audio_tracks, tail];
return tail;
}
+9 -1
View File
@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { hasEmbeddedAudio, moveLane, removeTrack, trimLane, separate, syncSeparated, trackFor } from "../audio.js";
import { hasEmbeddedAudio, moveLane, removeTrack, splitTrack, trimLane, separate, syncSeparated, trackFor } from "../audio.js";
const media = { filename: "a.mp4", subfolder: "", type: "output" };
const proj = () => ({ num_frames_per_scene: 50, frame_rate: 25, scenes: [{ id: "a", text: "" }, { id: "b", text: "", audio_volume: 0.5 }, { id: "v", source: { type: "video", media_ref: "m1" }, source_dur: 3 }],
@@ -138,3 +138,11 @@ test("a file lane slides and trims on its own numbers", () => {
trimLane(p, "f", "out", -9);
assert.equal(p.audio_tracks[0].source_dur, 0.1);
});
test("splitTrack cuts a file lane in two where the playhead is; separated lanes and cuts at the edge are refused", () => {
const p = { audio_tracks: [{ id: "f", kind: "overlay", media_ref: "m", start_sec: 1, source_in_sec: 2, source_dur: 4, volume: 0.5 }, { id: "s", kind: "separated", pinned_dur: 3 }] };
assert.equal(splitTrack(p, "f", 1.01), false);
const tail = splitTrack(p, "f", 3);
assert.deepEqual([p.audio_tracks[0].source_dur, tail.start_sec, tail.source_in_sec, tail.source_dur, tail.volume], [2, 3, 4, 2, 0.5]);
assert.equal(splitTrack(p, "s", 1), false);
});