Files
olivv-cs-ComfyUI-FunPack/app/shell/timeline.js
T
digital-garbageandClaude Sonnet 5 a600fe3e29 fix: real timeline -- full-width layout, playhead reentrancy, panel overlap
Round 1 + round 2 of adversarial review on the single-track timeline
(2092c8b) found and fixed:

- Clip position now stays absolute (seconds*pxPerSecond) instead of the
  MIN_CLIP_PX floor cascading into every later clip -- a floored short
  clip no longer pushes everything after it out of sync with the ruler.
- pendingSeek is cleared unconditionally right after project.select(),
  closing a gap where a no-op select() (clicking within the already
  selected clip) left it dangling for a later, unrelated draw to
  wrongly replay.
- draw() now always resyncs the playhead to the current scene's start
  on any non-track-click selection change, rather than only when the
  old value fell outside the new scene's span -- the old check let a
  stale playhead survive a wholesale project switch whenever it
  coincidentally still fit the new project's first scene.
- The timeline moved from the centre column's own inner split to an
  outer split above/below the whole three-column workspace, matching
  v4's actual layout (full-width timeline under resizable columns,
  not squeezed inside one of them).
- That restructuring put the Properties column under real height
  pressure for the first time, exposing a latent bug: .cx-stack's
  base rule set min-block-size:0 on every stack, letting a plain
  grouping stack (not meant to scroll) get flex-crushed below its own
  content height, so its overflow painted over the next row ("Settings"
  over "Loosen the starting image"). Removed from the base rule --
  .cx-stack-fill already carries it for the one case that should
  actually give up height to scroll.
- Documented, rather than "fixed" by reintroducing the rejected
  cascading-position bug: several under-floor clips in a row still
  visually occlude each other (later DOM paints over earlier). Click-
  seek still resolves correctly since it reads time, not paint order.
- Guarded framesOf() against a negative/NaN scene.length the same way
  fps() already guards its own input.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:29:44 +03:00

257 lines
11 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.
//
// A real single-track timeline: clips as wide as they play for, a ruler in
// seconds, and a playhead -- composer's `track.default` does the drawing, this
// file only turns scenes into seconds and seconds back into a selected scene.
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.",
};
// How wide a second is, in pixels -- what "zoom" changes. Persisted per
// browser like the media bin's own view choice: a per-viewer convenience, not
// state a run depends on, so nothing breaks if storage is unavailable.
const ZOOM_KEY = "funpack.timeline.zoom";
const ZOOM_LEVELS = { sm: 20, md: 40, lg: 80 };
// No module declares an `fps` role today -- the one pipeline that ships
// (minimax_h3) hands CreateVideo a literal 24.0 rather than a project.video
// input, so there is nothing to read it FROM yet. This mirrors that literal
// rather than inventing a setting nothing produces.
// ponytail: becomes a real per-pipeline value the day a module declares a
// role at project.video.fps, the same way length/target_width/target_height
// already are -- read here with a fallback so that day needs no change here.
const FPS_FALLBACK = 24;
function recallZoom() {
try { return ZOOM_LEVELS[window.localStorage.getItem(ZOOM_KEY)] ? window.localStorage.getItem(ZOOM_KEY) : "md"; }
catch { return "md"; }
}
function rememberZoom(level) {
try { window.localStorage.setItem(ZOOM_KEY, level); } catch { /* private mode */ }
}
/**
* 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 track = null;
let pxPerSecond = ZOOM_LEVELS[recallZoom()];
// The playhead's own position, in seconds -- decoupled from which scene is
// selected so a click partway through a clip reads as that exact second,
// not snapped to whichever scene it landed in. Re-synced to the selected
// scene's start whenever the selection changes from somewhere OTHER than a
// track click (Add/Remove/Move, a selection made elsewhere, or a different
// project opened entirely) -- see draw().
let playhead = 0;
// What the LAST seek asked for, consumed by the very next draw(). project.
// select() -- called from onSeek below -- fires its onChange synchronously
// (boot.js wires it straight to this timeline's own draw()), so a draw can
// run in the middle of onSeek's own body, before onSeek gets back around to
// setting the playhead itself. Without this, that reentrant draw reads the
// OLD `playhead` value against the NEWLY selected scene, and a seek that
// lands exactly on a clip's boundary -- clamped to the very end of the
// track, for one -- fails the "inside the span" check and gets silently
// snapped back to that scene's start. Recording the seek here lets the
// reentrant draw trust the number just clicked, exactly, instead of
// re-deriving (and getting wrong) where the playhead belongs.
let pendingSeek = 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) }),
],
// Zoom changes seconds->pixels, not the durations behind them -- a bigger
// track to work with, not a different one.
trailing: [composer.segmented.sm({
label: "Zoom",
value: recallZoom(),
options: [{ value: "sm", label: "S" }, { value: "md", label: "M" }, { value: "lg", label: "L" }],
onChange: (level) => {
rememberZoom(level);
pxPerSecond = ZOOM_LEVELS[level] || ZOOM_LEVELS.md;
if (track) track.setZoom(pxPerSecond);
},
})],
});
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);
}
/** Frames this clip runs for: its own crop, or the project's length. A
* negative or NaN length (corrupted project data -- nothing in the UI
* produces one) would otherwise pass through as truthy and break the
* contiguous, non-decreasing spans the click-seek math in track.js relies
* on, the same guard `fps()` below already uses for the same reason. */
const framesOf = (scene) => (scene.length > 0 ? scene.length : 0) || project.video.length || 1;
const fps = () => (Number(project.video.fps) > 0 ? Number(project.video.fps) : FPS_FALLBACK);
const secondsOf = (scene) => framesOf(scene) / fps();
/** Where each scene starts, in seconds -- contiguous, so a click anywhere
* on the track belongs to exactly one of them. */
function spans() {
let at = 0;
return project.scenes.map((scene) => {
const start = at;
const duration = secondsOf(scene);
at += duration;
return { scene, start, duration };
});
}
function items() {
return project.scenes.map((scene, i) => {
const { start, duration } = spans()[i];
return {
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 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: "▦",
start, duration,
rating: scene.rating || null,
excluded: Boolean(scene.excluded),
};
});
}
function draw() {
const scenes = project.scenes;
if (!scenes.length) {
host.set([empty]);
track = null;
return;
}
const built = items();
const current = built.find((i) => i.id === project.selectedId);
if (current) {
if (pendingSeek && pendingSeek.id === current.id) {
// Trust the seek just clicked exactly, rather than re-deriving it --
// this draw may be running INSIDE onSeek's own body (project.select's
// onChange fires synchronously), before onSeek gets back around to
// setting the playhead itself, and a seek clamped to the very edge of
// the track would otherwise fail a span check and snap back to the
// scene's start.
playhead = pendingSeek.seconds;
} else {
// Anything else that changed the selection -- Add, Remove, Move, a
// pick made elsewhere, a different project opened entirely -- only
// moved the selection; this is what pulls the playhead back onto it.
// Always, not just when the old value falls outside the new scene's
// span: two scenes from two different projects can share a span by
// pure coincidence (even the same id, if one is ever reused), and
// trusting "still numerically inside" as a proxy for "still the same
// seek" is exactly what lets a stale playhead survive a project it
// has nothing to do with.
playhead = current.start;
}
}
pendingSeek = null;
if (!track) {
track = composer.track.default({
label: "Timeline",
items: built,
selection: project.selectedId ? [project.selectedId] : [],
playhead,
pxPerSecond,
onSeek: (seconds, item) => {
playhead = seconds;
if (item) pendingSeek = { id: item.id, seconds };
// select() no-ops, without announcing, when the click landed
// inside the scene already current -- the common case for a pure
// seek, and when it does no reentrant draw() happens at all. The
// playhead still needs to move, so it is set directly rather than
// only through whatever redraw a real selection change would have
// triggered. Cleared right after, unconditionally: a no-op select
// leaves no reentrant draw to consume pendingSeek, and without this
// it can survive to be wrongly replayed by a LATER, unrelated draw
// whose current scene happens to share this one's id.
if (item) project.select(item.id);
pendingSeek = null;
announce();
track.setPlayhead(playhead);
track.setValue(project.selectedId ? [project.selectedId] : []);
},
// Ids, resolved to a position HERE, now -- not carried from whenever
// the drag started. A remove or an undo can redraw the track while a
// drag is still in flight (the mouse held down is a different input
// channel from the keyboard shortcut that triggers one), and a
// position captured at dragstart would then name whatever has since
// taken that slot, not what was actually picked up.
onReorder: (draggedId, targetId) => {
const from = project.scenes.findIndex((s) => s.id === draggedId);
const to = project.scenes.findIndex((s) => s.id === targetId);
if (from < 0 || to < 0 || from === to) return;
project.move(draggedId, to - from);
announce();
},
});
host.set([controls, track]);
return;
}
track.setItems(built);
track.setValue(project.selectedId ? [project.selectedId] : []);
track.setPlayhead(playhead);
}
draw();
return {
node: host.node,
draw,
destroy() {
if (track) track.destroy();
controls.destroy();
empty.destroy();
host.destroy();
},
};
}