fix(ui): separated clips are quiet in the render, split/remove keep audio lanes honest, new render clears its rating, run id travels, taste generation after first queue, Project tab redraw; modal rung above floating windows

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-03 23:09:51 +03:00
co-authored by Claude Sonnet 5.5
parent b888f5127c
commit 80f7b5fa66
10 changed files with 52 additions and 12 deletions
+3 -2
View File
@@ -11,7 +11,8 @@
// and "move to the front" has nowhere to go.
// Order is the contract; the numbers are derived. Autocomplete sits ABOVE modal
// on purpose: it opens from a field that is often inside one.
// on purpose: it opens from a field that is often inside one. A modal sits above
// every floating window: nothing behind it may be used until it is closed.
export const LADDER = [
"local",
"dropline",
@@ -20,8 +21,8 @@ export const LADDER = [
"popover",
"backdrop",
"wizard",
"modal",
"floatingWindow",
"modal",
"toast",
"autocomplete",
"tour",
+5 -1
View File
@@ -17,6 +17,10 @@ test("autocomplete outranks modal", () => {
assert.ok(baseOf("autocomplete") > baseOf("modal"));
});
test("a modal covers floating windows: nothing behind it is usable until it closes", () => {
assert.ok(baseOf("modal") > baseOf("floatingWindow"));
});
test("an unknown rung is refused rather than guessed", () => {
assert.throws(() => baseOf("somewhere"), RangeError);
});
@@ -25,7 +29,7 @@ test("peers stack within their rung and never reach the next one", () => {
const a = claim("modal");
const b = claim("modal");
assert.ok(b.z > a.z);
assert.ok(b.z < baseOf("floatingWindow"));
assert.ok(b.z < baseOf("toast"));
});
test("releasing frees the slot for reuse", () => {
+4 -3
View File
@@ -31,7 +31,8 @@ export default {
const media = { filename: image.filename, subfolder: image.subfolder || "", type: image.type || "output" };
group.forEach((s) => {
(pr.scene_renders ||= {})[s.id] = renderFor(s, pr, media, secs, promptId);
if (!isVideoClip(s)) s.source_in = 0; // a fresh render was made at this length: an earlier trim's window no longer applies
if (!isVideoClip(s)) s.source_in = 0;
if (!(s.cut_offset_frames > 0)) s.rating = ""; // a new render has not been rated yet // a fresh render was made at this length: an earlier trim's window no longer applies
});
return true;
}).catch(() => tell("The result could not be saved to its project."));
@@ -42,7 +43,6 @@ export default {
const pid = p.project.id;
let made = 0;
said = false;
app.api.newTasteGeneration().catch(() => {}); // ratings of the last run can no longer be paired with what it learned
try {
for (const unit of units) {
if (stopped) break;
@@ -64,6 +64,7 @@ export default {
break;
}
if (stopped) g.cancel(); // Stop landed while this one was being queued
if (!made) app.api.newTasteGeneration().catch(() => {}); // a run really started: the last run's unrated clips can no longer be paired with what it learned
made += 1;
const end = await done;
if (end === g.CANCELLED) break;
@@ -99,7 +100,7 @@ export default {
const unit = genUnitId(sc);
const finish = (end) => {
const im = g.run.state.images;
if (end === g.DONE && im.length) record(projectId, unit, im[im.length - 1]);
if (end === g.DONE && im.length) record(projectId, unit, im[im.length - 1], undefined, g.run.state.promptId);
else if (end === g.FAILED) tell((g.run.state.error && g.run.state.error.message) || "A run from before the reload failed.");
else if (end !== g.CANCELLED) tell("A run from before the reload ended without a result that could be attached. Generate again.");
busy = false; draw();
+1 -1
View File
@@ -24,7 +24,7 @@ export default {
};
function draw(force) { // not on every keystroke: that would rebuild the box being typed in
const sc = p.selected, open = p.project;
const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length] : ""}`;
const next = `${tab}|${open && open.id}|${p.selectedId}|${tab === "project" && open ? [open.postfix_enabled !== false, open.generation_mode] : ""}|${tab === "scene" && sc && open ? [effFrames(sc, open), effFps(sc, open), p.scenes.indexOf(sc), sc.frames_mode, sc.fps_mode, p.scenes.length] : ""}`;
titled();
if (!force && next === key) return;
key = next;
-1
View File
@@ -58,7 +58,6 @@ export function projectRows(p, slots, api) {
c.toggle.default({ label: "Postfix", hint: "Appended to every scene (a style or quality tag, say).", checked: open.postfix_enabled !== false, onChange: (v) => p.setField("postfix_enabled", v) }),
c.textarea.md({ label: "Postfix", value: p.postfix, rows: 2, disabled: open.postfix_enabled === false, onInput: (v) => p.setPostfix(v) }),
c.collapsible.default({ label: "ADVANCED PROJECT SETTINGS", body: c.region.stack({ gap: "sm", children: [
c.field.default({ label: "Max scenes", control: c.number.md({ label: "Max scenes", min: 1, max: 10000, step: 1, precision: 0, value: open.max_scenes || 8, onChange: (v) => p.setField("max_scenes", v) }) }),
c.collapsible.default({ label: "Split preview (generation prompt)", body: splitPreview(p, api) }),
] }) }),
];
+2 -1
View File
@@ -9,7 +9,8 @@ export function clipSpecs(p, only) { // `only`: a Set of scene ids to keep (th
for (const seg of segments(p)) {
if (seg.kind !== "scene" || (!only && seg.scene.excluded && !seg.scene.removed_from_plan)) continue; // removed from the plan still plays; a clip picked by hand is wanted even if left out of the full run
if (only && !only.has(seg.scene.id)) continue;
const sc = seg.scene, r = renders[sc.id], base = { scene_id: sc.id, dur: seg.dur, fps: effFps(sc, p), gap_after: Math.max(0, +sc.gap_after_sec || 0) };
const sc = seg.scene, r = renders[sc.id], base = { scene_id: sc.id, dur: seg.dur, fps: effFps(sc, p), gap_after: Math.max(0, +sc.gap_after_sec || 0),
volume: sc.audio_separated ? 0 : sc.audio_volume != null ? sc.audio_volume : 1 }; // a clip whose sound went to its own lane is quiet here
if (isVideoClip(sc)) {
if (!(sc.source && sc.source.media_ref)) { missing += 1; continue; }
clips.push({ ...base, bin_media_ref: sc.source.media_ref, in: sc.source_in || 0 });
+6
View File
@@ -24,3 +24,9 @@ test("only the picked clips are kept, still in timeline order, and unpicked ones
test("a clip picked by hand is exported even when it is excluded from the full run", () => {
assert.deepEqual(clipSpecs(p, new Set(["c"])).clips.map((c) => c.scene_id), ["c"]);
});
test("a clip whose sound was separated is quiet in the render; others keep their volume", () => {
const q = { num_frames_per_scene: 50, frame_rate: 25, scenes: [{ id: "a", audio_separated: true, audio_volume: 0 }, { id: "b", audio_volume: 0.4 }, { id: "c" }],
scene_renders: { a: { media: { filename: "a" } }, b: { media: { filename: "b" } }, c: { media: { filename: "c" } } } };
assert.deepEqual(clipSpecs(q).clips.map((x) => x.volume), [0, 0.4, 1]);
});
+6 -2
View File
@@ -45,10 +45,14 @@ export function removeTrack(p, id) {
export function syncSeparated(p) {
const tracks = p.audio_tracks || [];
if (!tracks.some((t) => t.kind === "separated")) return;
const starts = new Map(segments(p).filter((s) => s.kind === "scene").map((s) => [s.id, s.start]));
const segs = segments(p).filter((s) => s.kind === "scene");
const starts = new Map(segs.map((s) => [s.id, s.start])), durs = new Map(segs.map((s) => [s.id, s.dur]));
for (const t of tracks) {
if (t.kind !== "separated" || !starts.has(t.scene_id)) continue;
const sc = p.scenes.find((s) => s.id === t.scene_id);
if (!sc.excluded && Math.abs((t.start_sec || 0) - starts.get(t.scene_id)) > 0.001) t.start_sec = starts.get(t.scene_id);
if (sc.excluded) continue;
if (Math.abs((t.start_sec || 0) - starts.get(t.scene_id)) > 0.001) t.start_sec = starts.get(t.scene_id);
const room = durs.get(t.scene_id);
if (t.pinned_dur != null && t.pinned_dur > room + 0.001) { t.pinned_dur = room; t.source_dur = room; } // the sound is never longer than its picture (a cut or a trim shortened it)
}
}
+4 -1
View File
@@ -1,6 +1,7 @@
// Changes to a project's scenes, as plain functions on the open project: `project.edit((p) => addScene(p))`.
// 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 {
genUnitId, isVideoClip, isSubclip, effFps, effFrames, seconds, snapFrames, LTX_GRID,
} from "./scenes.js";
@@ -48,6 +49,7 @@ export function removeScene(p, id, { pending = false } = {}) {
mates.filter((s) => s.id !== id && (s.cut_offset_frames || 0) > (sc.cut_offset_frames || 0))
.forEach((s) => { s.cut_offset_frames = Math.max(0, s.cut_offset_frames - removedFrames); });
}
p.audio_tracks = (p.audio_tracks || []).filter((t) => !(t.kind === "separated" && t.scene_id === id)); // its sound goes with it: a lane with no clip could not be reached
const anchor = at > 0 ? p.scenes[at - 1].id : null;
p.scene_ghosts = (p.scene_ghosts || []).map((g) => (g.afterSceneId === id ? { ...g, afterSceneId: anchor } : g));
ghostOrDrop(p, sc, anchor, pending);
@@ -166,6 +168,7 @@ export function split(p, id, atFrames, grid = LTX_GRID) {
second.frames = snapFrames(frames - cut, "round", grid);
second.text = "";
second.rating = "";
if (sc.audio_separated) { const lane = trackFor(p, sc.id); second.audio_separated = false; second.audio_volume = lane && lane.volume != null ? lane.volume : 1; } // the lane stays with the first half; the second plays its own sound
sc.gen_unit_id = unit;
// The seam between the halves is an internal hard cut: the outgoing edge now belongs to the second half.
sc.frames = cut;
@@ -189,7 +192,7 @@ export function split(p, id, atFrames, grid = LTX_GRID) {
const r = renders[id];
if (r && r.media) {
delete r.durationSec; // a split is an explicit re-length: both halves go back to plan layout
renders[second.id] = { media: r.media, inSec: (r.inSec || 0) + cutSec, renderPrompt: r.renderPrompt ? { ...r.renderPrompt } : null };
renders[second.id] = { media: r.media, inSec: (r.inSec || 0) + cutSec, renderPrompt: r.renderPrompt ? { ...r.renderPrompt } : null, ...(r.promptId ? { promptId: r.promptId } : {}) };
}
return second;
}
+21
View File
@@ -48,3 +48,24 @@ test("a separated lane follows its clip when the cut changes, and an untouched p
syncSeparated(p);
assert.equal(JSON.stringify(p), before);
});
import { removeScene, split } from "../edits.js";
test("splitting a separated clip leaves the lane with the first half, which it is cut to; the second half gets its own sound back", () => {
const p = proj();
p.num_frames_per_scene = 97;
separate(p, "a");
assert.ok(split(p, "a", 49));
const lane = p.audio_tracks[0];
syncSeparated(p);
const [first, second] = [p.scenes[0], p.scenes[1]];
assert.deepEqual([first.audio_separated, second.audio_separated, second.audio_volume], [true, false, 1]);
assert.ok(lane.pinned_dur <= 49 / 25 + 0.01, "the lane is no longer than the first half");
});
test("removing a separated clip removes its lane (a lane with no clip could not be reached)", () => {
const p = proj();
separate(p, "b");
removeScene(p, "b");
assert.equal(p.audio_tracks.length, 0);
});