diff --git a/app/ui/composer/internals/zlayer.js b/app/ui/composer/internals/zlayer.js index 1233e9a2..3e0a675b 100644 --- a/app/ui/composer/internals/zlayer.js +++ b/app/ui/composer/internals/zlayer.js @@ -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", diff --git a/app/ui/composer/tests/zlayer.test.js b/app/ui/composer/tests/zlayer.test.js index ca2c0728..669c4b07 100644 --- a/app/ui/composer/tests/zlayer.test.js +++ b/app/ui/composer/tests/zlayer.test.js @@ -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", () => { diff --git a/app/ui/features/generate/index.js b/app/ui/features/generate/index.js index 627583be..da957b68 100644 --- a/app/ui/features/generate/index.js +++ b/app/ui/features/generate/index.js @@ -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(); diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index 9757c979..ccf21250 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -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; diff --git a/app/ui/features/inspector/project.js b/app/ui/features/inspector/project.js index 91ef7f70..f5d385c5 100644 --- a/app/ui/features/inspector/project.js +++ b/app/ui/features/inspector/project.js @@ -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) }), ] }) }), ]; diff --git a/app/ui/features/render/clips.js b/app/ui/features/render/clips.js index 0a7eccfb..3797ae29 100644 --- a/app/ui/features/render/clips.js +++ b/app/ui/features/render/clips.js @@ -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 }); diff --git a/app/ui/features/render/clips.test.js b/app/ui/features/render/clips.test.js index 13d1e446..9c688d74 100644 --- a/app/ui/features/render/clips.test.js +++ b/app/ui/features/render/clips.test.js @@ -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]); +}); diff --git a/app/ui/shell/audio.js b/app/ui/shell/audio.js index 6662dfb8..eec7a11c 100644 --- a/app/ui/shell/audio.js +++ b/app/ui/shell/audio.js @@ -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) } } diff --git a/app/ui/shell/edits.js b/app/ui/shell/edits.js index 3793614e..a20d74c1 100644 --- a/app/ui/shell/edits.js +++ b/app/ui/shell/edits.js @@ -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; } diff --git a/app/ui/shell/tests/audio.test.js b/app/ui/shell/tests/audio.test.js index 2d649250..11e31641 100644 --- a/app/ui/shell/tests/audio.test.js +++ b/app/ui/shell/tests/audio.test.js @@ -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); +});