The surfaces v4 has that v5 did not. Each announces itself and each can be absent without the rest noticing, which is the point of the exercise: the frontend should read as v4, and nothing in it should be load-bearing for anything else. * SCENE AND PROJECT, in the column v4 keeps them in, which now says which scene it is about. Length on the timeline is the scene's own CROP and says outright that a regenerate reads the project's -- the rule that mixing the two is how a per-scene value quietly becomes everyone's. * RATINGS on a scene, and a clip carries its rating as a colour on its edge. Stored, not yet read: nothing in v5 learns from them yet. * A RULER, and clips as wide as they are long, so the strip reads as time rather than a row of equal boxes. Both ends bounded -- one long clip must not become the whole strip, and one short one must stay hittable. * THE PICKER WHEEL, on the middle button anywhere. It shows whatever announced itself to actions.js, so a part of the app that grew a new action is in the wheel without the wheel being told, and a part that failed to load is simply not among them. v4's three pinned buttons had a fixed idea of what could go in them. * THE WAY IN, and it closes -- on Escape, on the backdrop, on its own button. v4's could only be completed: a page refresh was the only way out, which makes it a trap rather than a wizard. Dismissing is a real answer. * File > rename, and the listing moves with it rather than going stale until a reload. Two things the kit refused, correctly, and now nobody has to know: a wheel of fewer than two items throws (there is nothing to aim between), so the shell does not offer one; and a strip cell with no ceiling took the whole strip.
152 lines
5.0 KiB
JavaScript
152 lines
5.0 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);
|
|
}
|
|
|
|
/** Frames this clip runs for: its own crop, or the project's length. */
|
|
const lengthOf = (scene) => scene.length || project.video.length || 1;
|
|
|
|
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: "▦",
|
|
// A clip as wide as it is long, so the strip reads as time rather than as
|
|
// a row of equal boxes. Bounded: one very long scene beside several short
|
|
// ones must not squeeze the rest to nothing.
|
|
weight: lengthOf(scene),
|
|
rating: scene.rating || null,
|
|
}));
|
|
}
|
|
|
|
/** Where each scene starts, in frames. The ruler is drawn from this. */
|
|
function marks() {
|
|
let at = 0;
|
|
return project.scenes.map((scene) => {
|
|
const start = at;
|
|
at += lengthOf(scene);
|
|
return { id: scene.id, start, length: lengthOf(scene) };
|
|
});
|
|
}
|
|
|
|
// The ruler. Its ticks are the scene boundaries rather than a fixed interval:
|
|
// what a person looks for on this timeline is where one scene becomes the
|
|
// next, and at 24fps a tick per second is a picket fence.
|
|
const ruler = composer.ruler.default({ label: "Scenes" });
|
|
|
|
function drawRuler() {
|
|
const spans = marks();
|
|
const total = spans.reduce((sum, m) => sum + m.length, 0) || 1;
|
|
ruler.set(spans.map((m, i) => ({
|
|
at: m.start / total,
|
|
label: `${i + 1}`,
|
|
hint: `${m.start}`,
|
|
})), total);
|
|
}
|
|
|
|
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, ruler, strip]);
|
|
drawRuler();
|
|
return;
|
|
}
|
|
drawRuler();
|
|
// 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();
|
|
ruler.destroy();
|
|
controls.destroy();
|
|
empty.destroy();
|
|
host.destroy();
|
|
},
|
|
};
|
|
}
|