Scene settings: pause, source trim and Source removed (the timeline decides: clips drop anywhere with magnetic edges), Video input is project-only
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tw9fjV2UVj2u3F3mbnYrQW
This commit is contained in:
@@ -6,6 +6,8 @@
|
||||
|
||||
- **Simpler Project and Scene settings.** The rarely used groups (Video input, Source trim, Final render size, Split preview) are folded under "More" and stay open once opened.
|
||||
- **"Start shots" is gone.** A scene with no picture and no references is made from the prompt alone; with references it starts from them in the way the model supports (image, video or reference to video).
|
||||
- **Scene settings lost what the timeline already does.** "Pause after this clip" is gone: drag a clip along the video lane and it lands where you drop it, with its edges sticking to neighbouring clips, the playhead and zero (Shift holds it free); the pause before it follows and what comes after moves along. "Source trim" is gone too (the clip's edges trim it, Alt on the left edge slides what plays), and so is the scene's "Source" choice, which is now decided by what the scene has.
|
||||
- **Video input is the project's only.** The per-scene copy and the "Reference videos use" choice are gone; scenes' old own settings are no longer read.
|
||||
- **Continuity moved into Project settings** as "Pass the last frame of previous scene as reference into next scene". The Continuity page left Settings; "Don't continue from a fade to black" is no longer a switch, a shot after a clip that ends dark always starts without a picture.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// clip: { id, start, dur, title?, head?: [text], badge?, body?, thumbs?: [url],
|
||||
// actions?: [{ label, title, onClick }], selected?, focus?, excluded?, ghost?, rating?, trim? }
|
||||
// callbacks: onSeek(sec) onSelect(id, { additive, range }) onTrim(id, "in"|"out", deltaSec) onReorder(id, index) onMove(id, deltaSec)
|
||||
// onNudge(id, startSec): a `nudge` lane's clip dropped at the same place in the order but another time (the pause before it follows)
|
||||
|
||||
import { define } from "../internals/register.js";
|
||||
import { el } from "../internals/el.js";
|
||||
@@ -23,7 +24,7 @@ const clock = (s) => {
|
||||
return `${String(Math.floor(whole / 60)).padStart(2, "0")}:${String(whole % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: initial = [], onSeek, onSelect, onTrim, onReorder, onMove } = {}) => {
|
||||
define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: initial = [], onSeek, onSelect, onTrim, onReorder, onMove, onNudge } = {}) => {
|
||||
let px = pxPerSecond;
|
||||
let lanes = initial;
|
||||
let playhead = 0;
|
||||
@@ -55,6 +56,18 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init
|
||||
const anchorsFor = (clip) => [0, playhead, ...lanes.flatMap((l) => l.clips.filter((c) => c.id !== clip.id).flatMap((c) => [c.start, c.start + c.dur]))];
|
||||
const snapped = (clip, edges, dxSec, event) => (event && event.shiftKey ? dxSec : snapDelta(dxSec, edges, anchorsFor(clip), SNAP_PX / px));
|
||||
|
||||
// Where a clip dropped on a `nudge` lane starts: its left edge, or its right edge, sticks to a neighbour's edge, the playhead or zero.
|
||||
const landing = (clip, dxSec, event) => {
|
||||
const want = Math.max(0, clip.start + dxSec);
|
||||
if (event && event.shiftKey) return want;
|
||||
let best = null;
|
||||
for (const a of anchorsFor(clip)) for (const edge of [0, clip.dur]) {
|
||||
const off = a - (want + edge);
|
||||
if (Math.abs(off) < SNAP_PX / px && (best === null || Math.abs(off) < Math.abs(best))) best = off;
|
||||
}
|
||||
return best === null ? want : Math.max(0, want + best);
|
||||
};
|
||||
|
||||
function clipNode(lane, clip) {
|
||||
const parts = [];
|
||||
if (clip.head || clip.actions) {
|
||||
@@ -113,6 +126,7 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init
|
||||
if (moved === null) return;
|
||||
if (Math.abs(dx) >= MOVE_THRESHOLD_PX && (lane.reorder || lane.move)) moved = true;
|
||||
if (moved && lane.move) dx = snapped(clip, [clip.start, clip.start + clip.dur], dx / px, event) * px;
|
||||
if (moved && lane.nudge) dx = (landing(clip, dx / px, event) - clip.start) * px;
|
||||
if (moved) cell.style.transform = `translateX(${dx}px)`;
|
||||
},
|
||||
onEnd: ({ dx, event, cancelled }) => {
|
||||
@@ -127,6 +141,7 @@ define("timeline", "stage", ({ pxPerSecond = 80, label = "Timeline", lanes: init
|
||||
const mid = clip.start + clip.dur / 2 + dx / px;
|
||||
const others = lane.clips.filter((c) => c.id !== clip.id);
|
||||
const index = others.filter((c) => c.start + c.dur / 2 < mid).length;
|
||||
if (lane.nudge && onNudge && index === others.filter((c) => c.start + c.dur / 2 < clip.start + clip.dur / 2).length) { onNudge(clip.id, landing(clip, dx / px, event)); draw(); return; } // same place in the order: only the time moved
|
||||
if (onReorder) onReorder(clip.id, index);
|
||||
draw();
|
||||
},
|
||||
|
||||
@@ -47,11 +47,10 @@ export default {
|
||||
host.append(menu.node);
|
||||
|
||||
app.clipTags.push(tags);
|
||||
const section = { key: (sc) => `${sc.audio_volume}|${sc.audio_separated}|${sc.gap_after_sec}`, rows: (sc) => [
|
||||
c.field.default({ label: "Pause after this clip (s)", hint: "Black and silence before the next clip.", control: c.number.md({ label: "Pause after this clip", value: sc.gap_after_sec || 0, min: 0, max: 60, step: 0.1, onChange: (v) => p.setScene(sc.id, "gap_after_sec", v) }) }),
|
||||
const section = { key: (sc) => `${sc.audio_volume}|${sc.audio_separated}`, rows: (sc) => [
|
||||
c.label.section({ text: "Sound" }),
|
||||
c.field.default({ label: "Original sound volume", hint: sc.audio_separated ? "This clip's sound is on its own audio track: set the volume there." : "How loud this clip's own sound is in the render.",
|
||||
control: c.slider.readout({ label: "Original sound volume", value: sc.audio_volume != null ? sc.audio_volume : 1, min: 0, max: 1, step: 0.05, precision: 2, disabled: Boolean(sc.audio_separated), onCommit: (v) => p.setScene(sc.id, "audio_volume", v) }) })] };
|
||||
c.card.default({ children: [c.field.default({ label: "Original sound volume", hint: sc.audio_separated ? "This clip's sound is on its own audio track: set the volume there." : "How loud this clip's own sound is in the render.",
|
||||
control: c.slider.readout({ label: "Original sound volume", value: sc.audio_volume != null ? sc.audio_volume : 1, min: 0, max: 1, step: 0.05, precision: 2, disabled: Boolean(sc.audio_separated), onCommit: (v) => p.setScene(sc.id, "audio_volume", v) }) })] })] };
|
||||
app.sceneSections.push(section);
|
||||
return () => {
|
||||
for (const [list, x] of [[app.clipTags, tags], [app.sceneSections, section]]) { const i = list.indexOf(x); if (i >= 0) list.splice(i, 1); }
|
||||
|
||||
@@ -48,7 +48,7 @@ export async function buildInputs({ project, scene, slots, expand, nodeInfo, fra
|
||||
// wired input cannot be overridden per run), and that output is the first frame alone until `full` is set.
|
||||
if (takesVideo && startId && kinds.get(startId) === "video") {
|
||||
put(source.slot, "full", true);
|
||||
const use = activeVideoInput(project, scene);
|
||||
const use = activeVideoInput(project);
|
||||
if (use) put(source.slot, "prepare", videoInputJson(use, project, scene));
|
||||
}
|
||||
// A project saved before the preset said so still has this shape: a picture-resizing slot is enough to know a stand-in fits.
|
||||
@@ -88,7 +88,7 @@ export async function buildInputs({ project, scene, slots, expand, nodeInfo, fra
|
||||
}
|
||||
for (const [id, fields] of Object.entries(overrides)) raw[id] = { ...raw[id], ...fields };
|
||||
// A reference video is got ready as the project (or the scene) says; with nothing switched on it loads as before.
|
||||
const prepare = activeVideoInput(project, scene);
|
||||
const prepare = activeVideoInput(project);
|
||||
if (prepare) for (const [id, fields] of Object.entries(overrides)) if (fields.full && fields.media_id && kinds.get(fields.media_id) === "video") raw[id].prepare = videoInputJson(prepare, project, scene);
|
||||
// Nothing to feed the model (no picture or video where the pipeline looks for one): a role can say what to change so the
|
||||
// clip is made from the prompt alone -- bypass the anchor, run the whole schedule -- and a plain picture stands in.
|
||||
|
||||
@@ -126,23 +126,20 @@ test("a video reference reaches the model's video input from a preset that says
|
||||
learn([]);
|
||||
});
|
||||
|
||||
test("a reference video is prepared as the project says, else as the scene says, and untouched when nothing is on", async () => {
|
||||
test("a reference video is prepared as the project says, and untouched when it is off", async () => {
|
||||
const { learn } = await import("../../shell/bin.js");
|
||||
learn([{ id: "vid", name: "v.mp4", kind: "video" }]);
|
||||
const s = [{ id: "ref1", node: "Loader", roles: [{ at: "assets.reference_1", input: "media_id", wireTo: { slot: "r2v", input: "ref_images.ref_image_0" } }] }, { id: "r2v", node: "Model", inputs: {} }];
|
||||
const info = { Model: { inputs: { "ref_videos.ref_video_0": "IMAGE" }, outputs: [] }, Loader: { inputs: {}, outputs: ["IMAGE", "IMAGE", "AUDIO"] } };
|
||||
const run = async (project, scene) => (await buildInputs({ project: { video: {}, frame_rate: 24, ...project }, scene: { text: "x", references: ["vid"], ...scene }, slots: s, expand: async () => null, nodeInfo: async (c) => info[c] })).inputs.ref1;
|
||||
assert.equal((await run({}, {})).prepare, undefined);
|
||||
const scene = { video_input: { on: true, skip_first: 5, every_nth: 2 } };
|
||||
const sc = JSON.parse((await run({}, scene)).prepare);
|
||||
assert.equal(sc.skip_first, 5);
|
||||
assert.equal(sc.every_nth, 2);
|
||||
assert.equal(sc.fps, 24); // "the project's FPS"
|
||||
const both = { video_input: { on: true, skip_first: 9, fps_from: "source" } };
|
||||
assert.equal(JSON.parse((await run(both, scene)).prepare).skip_first, 9); // auto: the project's wins when on
|
||||
assert.equal(JSON.parse((await run(both, scene)).prepare).fps, 0);
|
||||
assert.equal(JSON.parse((await run({ ...both, video_input_from: "scene" }, scene)).prepare).skip_first, 5);
|
||||
assert.equal((await run({ video_input_from: "project", video_input: { on: false } }, scene)).prepare, undefined);
|
||||
const on = { video_input: { on: true, skip_first: 5, every_nth: 2 } };
|
||||
const got = JSON.parse((await run(on, {})).prepare);
|
||||
assert.equal(got.skip_first, 5);
|
||||
assert.equal(got.every_nth, 2);
|
||||
assert.equal(got.fps, 24); // "the project's FPS"
|
||||
assert.equal(JSON.parse((await run({ video_input: { on: true, fps_from: "source" } }, {})).prepare).fps, 0);
|
||||
assert.equal((await run({}, { video_input: { on: true, skip_first: 9 } })).prepare, undefined); // a scene's old own settings are not read
|
||||
learn([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -48,15 +48,17 @@ test("typing in the Prompt box, then pressing a button in the panel, does not re
|
||||
host.remove();
|
||||
});
|
||||
|
||||
test("the scene's Video input card switches on, shows its controls, and says when the project's settings win", async () => {
|
||||
test("the project's Video input card switches on and shows its controls, and the scene has none of its own", async () => {
|
||||
let fire;
|
||||
const calls = [];
|
||||
const doc = { id: "P", frame_rate: 25, num_frames_per_scene: 49, scenes: [{ id: "a", text: "x" }], scene_renders: {} };
|
||||
const p = { project: doc, get scenes() { return doc.scenes; }, get selected() { return doc.scenes[0]; }, selectedId: "a",
|
||||
setText: () => {}, setScene: (id, key, value) => { calls.push([key, value]); doc.scenes[0][key] = value; fire("change"); }, edit: (fn) => fn(doc) };
|
||||
const p = { project: doc, video: {}, get scenes() { return doc.scenes; }, get selected() { return doc.scenes[0]; }, selectedId: "a",
|
||||
setText: () => {}, setScene: () => {}, setField: (k, v) => { calls.push([k, v]); doc[k] = v; fire("change"); }, setVideo: () => {}, edit: (fn) => fn(doc) };
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
hub.setup({ host, app: { dom: createDom(), project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
|
||||
assert.ok(!host.textContent.includes("Video input"), "nothing about it on the Scene tab");
|
||||
[...host.querySelectorAll("[role=radio]")].find((b) => b.textContent === "Project").click();
|
||||
assert.ok(host.textContent.includes("Video input"));
|
||||
assert.ok(!host.textContent.includes("Every Nth frame"));
|
||||
const sw = [...host.querySelectorAll("input[role=switch]")].find((i) => i.closest(".cx-toggle-row").textContent.includes("Prepare video inputs"));
|
||||
@@ -64,8 +66,6 @@ test("the scene's Video input card switches on, shows its controls, and says whe
|
||||
assert.equal(calls[0][0], "video_input");
|
||||
assert.equal(calls[0][1].on, true);
|
||||
assert.ok(host.textContent.includes("Every Nth frame") && host.textContent.includes("Skip first") && host.textContent.includes("Cut last"));
|
||||
doc.video_input = { on: true }; fire("change");
|
||||
assert.ok(host.textContent.includes("project's video input settings are used"));
|
||||
host.remove();
|
||||
});
|
||||
|
||||
@@ -133,11 +133,12 @@ test("a group opened in the panel stays open when the panel redraws", async () =
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
hub.setup({ host, app: { dom: createDom(), project: p, on: (fn) => { fire = fn; return () => {}; }, api: {}, say: () => {} } });
|
||||
[...host.querySelectorAll("[role=radio]")].find((b) => b.textContent === "Project").click();
|
||||
const group = () => [...host.querySelectorAll("details")].find((d) => d.textContent.includes("Video input"));
|
||||
assert.equal(group().open, false, "closed to begin with");
|
||||
group().open = true;
|
||||
group().dispatchEvent(new window.Event("toggle"));
|
||||
doc.scenes[0].excluded = true; fire("change");
|
||||
doc.name = "renamed"; fire("change");
|
||||
assert.equal(group().open, true);
|
||||
host.remove();
|
||||
});
|
||||
|
||||
@@ -4,10 +4,7 @@ import { namesOf, pick } from "./mediapick.js";
|
||||
import { effFrames, effFps, genUnitId, isSubclip, seconds, snapFrames } from "../../shell/scenes.js";
|
||||
import { gridOf } from "../../shell/frame_grid.js";
|
||||
import { restoreToPlan } from "../../shell/edits.js";
|
||||
import { sceneVideoInput } from "./videoinput.js";
|
||||
import { fold } from "./fold.js";
|
||||
|
||||
const SOURCES = [{ value: "image", label: "Image · i2v anchor" }, { value: "carry", label: "From generated frame" }, { value: "video", label: "Video clip" }];
|
||||
const MODES = [{ value: "project", label: "Project default" }, { value: "timeline", label: "Timeline trim" }, { value: "custom", label: "Custom" }];
|
||||
|
||||
/** A field that holds media from the bin: what is chosen, Browse to change it, ✕ to clear it. */
|
||||
@@ -36,16 +33,6 @@ function drift(sc, open) {
|
||||
return c.hint.default({ text: Math.abs(shown - plan) > 0.05 ? `Rendered ≈ ${shown.toFixed(2)}s · plan ${plan.toFixed(2)}s — Regenerate to apply` : `Duration ≈ ${plan.toFixed(2)}s · trim on the timeline` });
|
||||
}
|
||||
|
||||
/** The slip: where in the rendered file this clip starts and how much of it plays. Only a clip that has a render has one. */
|
||||
function trimRows(p, sc, open) {
|
||||
if (!((open.scene_renders || {})[sc.id] || {}).media) return [];
|
||||
return [fold("scene-trim", "Source trim", [card(c.field.row({ fields: [
|
||||
c.field.default({ label: "Source in (s)", control: c.number.md({ label: "Source in", value: sc.source_in || 0, min: 0, step: 0.05, onChange: (v) => p.setScene(sc.id, "source_in", v) }) }),
|
||||
c.field.default({ label: "Source dur (s, 0 = full)", control: c.number.md({ label: "Source duration", value: sc.source_dur != null ? sc.source_dur : 0, min: 0, step: 0.05,
|
||||
snap: (v, was) => (v > 0 && v < 0.1 ? (v < was ? 0 : 0.1) : v), onChange: (v) => p.setScene(sc.id, "source_dur", v > 0 ? v : null) }) })] }), // 0 = full; nothing shorter than 0.1 s
|
||||
c.button.sm({ label: "Reset source trim", tone: "ghost", disabled: !sc.source_in && sc.source_dur == null, onClick: () => { p.setScene(sc.id, "source_in", 0); p.setScene(sc.id, "source_dur", null); } }))])];
|
||||
}
|
||||
|
||||
/** A group of fields on one rounded card. */
|
||||
export const card = (...children) => c.card.default({ children: children.flat().filter(Boolean) });
|
||||
|
||||
@@ -57,9 +44,7 @@ export function sceneRows(p, app) {
|
||||
...(sc.removed_from_plan ? [c.banner.info({ text: "Removed from plan — its generated clip stays on the timeline.", action: { label: "Restore to plan", onClick: () => p.edit((pr) => restoreToPlan(pr, sc.id)) } })] : []),
|
||||
...(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." })] : [card(
|
||||
c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }),
|
||||
c.field.default({ label: "Source", control: c.select.md({ label: "Source", options: SOURCES, value: (sc.source || {}).type || "carry",
|
||||
onChange: (v) => p.setScene(sc.id, "source", { ...(sc.source || {}), type: v }) }) }))]),
|
||||
c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }))]),
|
||||
c.label.section({ text: "Pictures" }),
|
||||
card(mediaField(app, p, sc, { label: "Aspect ratio source", hint: "The scene's picture (or a video's first frame). Its shape sets this scene's aspect ratio (the project's Width/Height set the scale). A model that starts from a picture, such as LTX image-to-video, also begins the clip from it; the prompt enhancer can read it.",
|
||||
key: "source_image", multiple: false, kinds: ["image", "video"], empty: "— choose aspect ratio source —" }),
|
||||
@@ -71,9 +56,6 @@ export function sceneRows(p, app) {
|
||||
] }),
|
||||
drift(sc, open),
|
||||
c.button.md({ label: "Generate this scene", tone: "primary", onClick: () => app.say("generate.scene") })),
|
||||
c.label.section({ text: "More" }),
|
||||
...trimRows(p, sc, open),
|
||||
...sceneVideoInput(p, sc),
|
||||
card(c.checkboxRow.default({ label: "Exclude from full generation", hint: "Generate leaves this scene out.", checked: Boolean(sc.excluded), onChange: (v) => p.setScene(sc.id, "excluded", v) })),
|
||||
c.banner.info({ text: "Made with the project's pipeline and Engine settings", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }),
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { composer as c } from "../../composer/composer.js";
|
||||
import { card } from "./scene.js";
|
||||
import { fold } from "./fold.js";
|
||||
import { BLANK, FITS, FROM, RATES, activeVideoInput } from "../../shell/video_input.js";
|
||||
import { BLANK, FITS, RATES } from "../../shell/video_input.js";
|
||||
|
||||
/** `use`: the settings shown; `save(next)` stores them. `size`: the project's width/height, offered as a one-tap target. */
|
||||
function settingsCard(use, save, size) {
|
||||
@@ -32,24 +32,5 @@ function settingsCard(use, save, size) {
|
||||
export function projectVideoInput(p) {
|
||||
const open = p.project, use = { ...BLANK, ...(open.video_input || {}) };
|
||||
const v = open.video || {}, size = [v.width || v.target_width || open.width, v.height || v.target_height || open.height];
|
||||
return [
|
||||
fold("project-video-input", "Video input", [
|
||||
card(c.field.default({ label: "Reference videos use", hint: "Where the preparation comes from. Automatic: the project's when it is on, otherwise each scene's own.",
|
||||
control: c.select.md({ label: "Reference videos use", options: FROM, value: open.video_input_from || "auto", onChange: (x) => p.setField("video_input_from", x) }) })),
|
||||
settingsCard(use, (next) => p.setField("video_input", next), size[0] && size[1] ? size : null)], use.on ? "On" : "Off"),
|
||||
];
|
||||
}
|
||||
|
||||
export function sceneVideoInput(p, sc) {
|
||||
const open = p.project, use = { ...BLANK, ...(sc.video_input || {}) }, live = activeVideoInput(open, sc);
|
||||
const from = open.video_input_from || "auto";
|
||||
return [
|
||||
fold("scene-video-input", "Video input", [
|
||||
from === "project" || (from === "auto" && (open.video_input || {}).on)
|
||||
? c.hint.default({ text: "The project's video input settings are used; this scene's own are ignored. Change that in Project settings." })
|
||||
: null,
|
||||
settingsCard(use, (next) => p.setScene(sc.id, "video_input", next), null),
|
||||
live ? null : c.hint.default({ text: "Switched off: reference videos load whole at 24 fps." }),
|
||||
], live ? "On" : "Off"),
|
||||
];
|
||||
return [fold("project-video-input", "Video input", [settingsCard(use, (next) => p.setField("video_input", next), size[0] && size[1] ? size : null)], use.on ? "On" : "Off")];
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ const stage = {
|
||||
const dur = segments(p.project).find((s) => s.id === id).dur;
|
||||
edit((pr) => edge === "out" ? edits.resize(pr, id, dur + d, gridFor(app)) : d > 0 && edits.trimLeft(pr, id, d, gridFor(app))); // the left edge only cuts in
|
||||
},
|
||||
onNudge: (id, sec) => edit((pr) => edits.nudge(pr, id, sec)),
|
||||
onReorder: (id, index) => edit((pr) => { // the stage counts ghosts and pauses too; the cut counts scenes
|
||||
const all = segments(pr).filter((s) => s.id !== id);
|
||||
return edits.reorder(pr, id, all.slice(0, index).filter((s) => s.kind === "scene").length, sceneIds(pr));
|
||||
|
||||
@@ -17,7 +17,7 @@ export function mood(rating) {
|
||||
export function videoLane(p, picked, focus, actions = () => [], ghostActions = () => [], tags = () => []) {
|
||||
const renders = p.scene_renders || {};
|
||||
return {
|
||||
id: "video", label: "Video", kind: "video", reorder: true, slip: true,
|
||||
id: "video", label: "Video", kind: "video", reorder: true, nudge: true, slip: true,
|
||||
clips: segments(p).map((seg) => {
|
||||
if (seg.kind === "gap") return { id: seg.id, start: seg.start, dur: seg.dur, title: "pause", ghost: true };
|
||||
if (seg.kind === "ghost") return { id: seg.id, start: seg.start, dur: seg.dur, title: first(seg.ghost.text) || "removed", ghost: true, actions: ghostActions(seg.ghost) };
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Each mutates in place and answers truthy when it changed something. They know what a scene IS (gen units,
|
||||
// ghosts, the cut order) and nothing about the screen.
|
||||
import { trackFor } from "./audio.js";
|
||||
import { segments } from "./scenes.js";
|
||||
import {
|
||||
genUnitId, isVideoClip, isSubclip, effFps, effFrames, seconds, snapFrames, LTX_GRID,
|
||||
} from "./scenes.js";
|
||||
@@ -105,6 +106,24 @@ export function reorder(p, id, to, order) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Start a clip at `startSec` on the timeline: the pause before it (kept on the clip's predecessor) grows or shrinks, and what follows
|
||||
* moves along. A clip cannot start before the one in front of it ends, and the first clip stays at zero. */
|
||||
export function nudge(p, id, startSec) {
|
||||
const segs = segments(p), at = segs.findIndex((s) => s.id === id && s.kind === "scene");
|
||||
if (at < 0) return false;
|
||||
let i = at - 1;
|
||||
while (i >= 0 && segs[i].kind === "gap") i--; // the pause already in front of it
|
||||
const held = i >= 0 ? segs.slice(0, i + 1).reverse().find((s) => s.kind === "scene") : null; // the pause lives on the last scene before it (ghosts sit between)
|
||||
if (!held) return false;
|
||||
const before = segs[at - 1] && segs[at - 1].kind === "gap" ? segs[at - 1].dur : 0;
|
||||
const ends = segs[at].start - before;
|
||||
const gap = Math.round(Math.max(0, startSec - ends) * 100) / 100;
|
||||
const sc = p.scenes.find((x) => x.id === held.id);
|
||||
if (!sc || (Number(sc.gap_after_sec) || 0) === gap) return false;
|
||||
sc.gap_after_sec = gap;
|
||||
return true;
|
||||
}
|
||||
|
||||
const modeToTimeline = (sc) => { if ((sc.frames_mode || "project") === "project") sc.frames_mode = "timeline"; };
|
||||
|
||||
/** Run for `sec` (from dragging an edge): whole grid steps, at least one step in the direction of the drag. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { addScene, removeScene, removeFromPlan, restoreToPlan, reorder, resize, trimLeft, slip, split } from "../edits.js";
|
||||
import { addScene, removeScene, removeFromPlan, restoreToPlan, reorder, resize, trimLeft, slip, split, nudge } from "../edits.js";
|
||||
import { seconds, segments } from "../scenes.js";
|
||||
|
||||
const mk = (extra = {}) => ({
|
||||
@@ -94,3 +94,17 @@ test("clearReferences empties every scene's own list, keeps the pins, and says h
|
||||
assert.equal(clearReferences(p), 2);
|
||||
assert.deepEqual([p.scenes.map((s) => s.references || []), p.fixed_references, clearReferences(p)], [[[], [], []], { a: 1 }, 0]);
|
||||
});
|
||||
|
||||
test("nudge: a clip dropped later leaves a pause before it, dropped back it closes, and the first clip stays at zero", () => {
|
||||
const p = { frame_rate: 25, num_frames_per_scene: 50, scenes: [{ id: "a" }, { id: "b" }, { id: "c" }], scene_renders: {} };
|
||||
const at = (id) => segments(p).find((s) => s.id === id).start;
|
||||
assert.equal(at("b"), 2);
|
||||
assert.equal(nudge(p, "b", 3.5), true);
|
||||
assert.equal(p.scenes[0].gap_after_sec, 1.5);
|
||||
assert.equal(at("b"), 3.5);
|
||||
assert.equal(at("c"), 5.5); // what follows moves along
|
||||
assert.equal(nudge(p, "b", 3.5), false); // nothing changed
|
||||
assert.equal(nudge(p, "b", 1), true); // cannot start before the clip in front of it ends
|
||||
assert.equal(p.scenes[0].gap_after_sec, 0);
|
||||
assert.equal(nudge(p, "a", 4), false); // the first clip has nothing before it to hold a pause
|
||||
});
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
// How a picked reference video is got ready for the model (the loader choices: size, crop, rate, skip, cut, every Nth, cap).
|
||||
// The project keeps one set and every scene its own; `video_input_from` says whose is used.
|
||||
// The project keeps the one set.
|
||||
import { effFps } from "./scenes.js";
|
||||
|
||||
export const FROM = [{ value: "auto", label: "Automatic (the project's when on, else the scene's)" }, { value: "project", label: "The project's" }, { value: "scene", label: "Each scene's own" }];
|
||||
export const FITS = [{ value: "crop", label: "Fill (crop the overflow)" }, { value: "pad", label: "Fit (black bars)" }, { value: "stretch", label: "Stretch" }];
|
||||
export const RATES = [{ value: "project", label: "The project's FPS" }, { value: "source", label: "The video's own" }, { value: "custom", label: "A number" }];
|
||||
|
||||
export const BLANK = { on: false, fit: "crop", fps_from: "project", fps: 24, skip_first: 0, cut_last: 0, every_nth: 1, frame_cap: 0, width: 0, height: 0, multiple_of: 32, crop_left: 0, crop_right: 0, crop_top: 0, crop_bottom: 0 };
|
||||
|
||||
/** The settings that apply to this scene, or null when none is switched on (the video then loads as it always did). */
|
||||
export function activeVideoInput(project, scene) {
|
||||
const own = { ...BLANK, ...((project || {}).video_input || {}) }, mine = { ...BLANK, ...((scene || {}).video_input || {}) };
|
||||
const from = (project || {}).video_input_from || "auto";
|
||||
const use = from === "project" ? own : from === "scene" ? mine : own.on ? own : mine;
|
||||
/** The settings that apply, or null when they are not switched on (the video then loads as it always did). */
|
||||
export function activeVideoInput(project) {
|
||||
const use = { ...BLANK, ...((project || {}).video_input || {}) };
|
||||
return use.on ? use : null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user