Files
olivv-cs-ComfyUI-FunPack/app/shell/timeline.js
T
digital-garbageandClaude Opus 5 9dcd94c156 app, core: a project of scenes, and the timeline that draws it
The app could run a pipeline and had nowhere to keep what it was making. A
project is an ordered list of scenes, one JSON file each, and it is the only
part of the app that has to survive a reload.

A scene owns its prompt text and the result it produced, and nothing else. v4's
Scene grew three dozen fields; each belongs to the feature that needs it. Per
scene length and fps are deliberately absent — a regenerate uses the project's
values, so a scene cropped on the timeline comes back whole.

The prompt box edits the current scene rather than holding its own copy, so
what is on screen and what a run uses cannot disagree.

app: the region toggles are named buttons in the timeline bar

v4's arrangement. The kit's fallback is one glyph on a rail at each edge of the
window, which is right for a kit that cannot know what its panels are called and
wrong here, where they have names and a bar to put them in.

A pressed toggle was styled only inside a button group, so a standalone one
carried aria-pressed and looked identical either way.

gallery.strip is selectable like its siblings: a filmstrip is the one shape
whose whole job is saying which of these you are on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:03:19 +03:00

116 lines
3.7 KiB
JavaScript

// The timeline: the project's scenes, in the order they play.
//
// It draws and it dispatches. The project itself lives in projects.js, so this
// file holds no scene state of its own -- a timeline that kept its own copy is
// how "what is on screen" and "what a run uses" become two questions.
//
// One scene is always current, and the prompt box on the main window edits THAT
// scene. v4 had one prompt for the whole project and split it by markers in the
// text, which meant the text was both the content and the structure: editing a
// scene could re-cut every other one.
import { composer } from "../composer/composer.js";
const EMPTY = {
icon: "▭",
title: "Nothing on the timeline",
hint: "What you generate lands here, in the order it plays.",
};
/**
* createTimeline({ project, onSelect }) -> { node, draw, destroy }
*
* `project` is the store from projects.js. `onSelect` fires whenever the current
* scene changes, including when a removal moves it -- the prompt box follows it.
*/
export function createTimeline({ project, onSelect } = {}) {
const host = composer.region.stack({ gap: "sm", fill: true });
const empty = composer.emptyState.default(EMPTY);
let strip = null;
// Rebuilt on every draw, so a stale handler cannot act on a scene that has
// moved. The buttons that act on "the current scene" read it at click time
// rather than closing over it for the same reason.
const controls = composer.toolbar.default({
label: "Scenes",
items: [
composer.button.sm({ label: "Add scene", onClick: () => add() }),
composer.button.sm({ label: "Remove", onClick: () => remove() }),
composer.iconButton.sm({ icon: "◀", label: "Move earlier", onClick: () => move(-1) }),
composer.iconButton.sm({ icon: "▶", label: "Move later", onClick: () => move(1) }),
],
});
function add() {
project.addScene();
announce();
}
function remove() {
const id = project.selectedId;
// The last scene stays. A project with no scenes has nowhere to type, and
// the Add button would be the only thing on the timeline that did anything.
if (!id || project.scenes.length <= 1) return;
project.removeScene(id);
announce();
}
function move(by) {
if (project.selectedId) project.move(project.selectedId, by);
}
function announce() {
if (onSelect) onSelect(project.selected || null);
}
function items() {
return project.scenes.map((scene, i) => ({
id: scene.id,
// The number is what a scene is called before it has any text, and the
// text is what it is called after -- a strip of "Scene 1..8" is a strip
// nobody can read their own project off.
label: scene.text ? `${i + 1}. ${scene.text}` : `Scene ${i + 1}`,
badge: String(i + 1),
thumb: scene.result || null,
icon: "▦",
}));
}
function draw() {
const scenes = project.scenes;
if (!scenes.length) {
host.set([empty]);
strip = null;
return;
}
if (!strip) {
strip = composer.gallery.strip({
label: "Scenes",
items: items(),
selection: project.selectedId ? [project.selectedId] : [],
onActivate: (item) => { project.select(item.id); announce(); },
});
host.set([controls, strip]);
return;
}
// setItems then setValue: setValue redraws against whatever items are
// current, so seeding the selection first marks a row that is about to be
// replaced and the strip comes back with nothing on.
strip.setItems(items());
strip.setValue(project.selectedId ? [project.selectedId] : []);
}
draw();
return {
node: host.node,
draw,
destroy() {
if (strip) strip.destroy();
controls.destroy();
empty.destroy();
host.destroy();
},
};
}