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>
257 lines
11 KiB
JavaScript
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();
|
|
},
|
|
};
|
|
}
|