feat(ui): save frame as a scene's anchor, restore-to-plan, source trim fields, render-drift hint, pause after a clip

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-10-04 03:16:37 +03:00
co-authored by Claude Sonnet 5.5
parent 45a3d8ac4c
commit 5d7972d2ef
4 changed files with 43 additions and 5 deletions
+2 -1
View File
@@ -46,7 +46,8 @@ export default {
host.append(menu.node);
app.clipTags.push(tags);
const section = { key: (sc) => `${sc.audio_volume}|${sc.audio_separated}`, rows: (sc) => [
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) }) }),
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) }) })] };
+1 -1
View File
@@ -26,7 +26,7 @@ export default {
const sections = app.sceneSections || []; // extra rows other features put under the scene's own: { rows(scene, project), key(scene, project) }
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 === "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, sc.source_image, (sc.references || []).join(), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`;
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, sc.source_image, (sc.references || []).join(), sc.source_in, sc.source_dur, sc.removed_from_plan, ((open.scene_renders || {})[sc.id] || {}).durationSec, Boolean(((open.scene_renders || {})[sc.id] || {}).media), bin.version, ...sections.map((s) => { try { return s.key(sc, open); } catch { return ""; } })] : ""}`;
titled();
if (!force && next === key) return;
key = next;
+20 -1
View File
@@ -1,7 +1,8 @@
// The Scene tab: this clip's prompt, where it starts from, how long it runs.
import { composer as c } from "../../composer/composer.js";
import { namesOf, pick } from "./mediapick.js";
import { effFrames, effFps, genUnitId, isSubclip } from "../../shell/scenes.js";
import { effFrames, effFps, genUnitId, isSubclip, seconds } from "../../shell/scenes.js";
import { restoreToPlan } from "../../shell/edits.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" }];
@@ -26,12 +27,28 @@ const lengthField = (p, sc, label, modeKey, valueKey, shown) => {
] }) });
};
/** "Rendered ≈ Xs · plan Ys — Regenerate to apply" when the render was made at another length than the plan now says. */
function drift(sc, open) {
const plan = effFrames(sc, open) / effFps(sc, open), shown = seconds(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 [c.label.section({ text: "Source trim (slip)" }), 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, onChange: (v) => p.setScene(sc.id, "source_dur", v > 0 ? Math.max(0.1, v) : null) }) })] }),
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); } })];
}
export function sceneRows(p, app) {
const sc = p.selected, open = p.project;
if (!sc) return [c.emptyState.default({ icon: "▭", title: "No scene", hint: "Add one on the timeline." })];
const cuts = p.scenes.filter((s) => genUnitId(s) === genUnitId(sc)).length > 1;
return [
c.banner.info({ text: "Generating with FunPack Studio + Chain Sampler", action: { label: "Engine settings →", onClick: () => app.openSettings && app.openSettings("engine") } }),
...(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." })] : [
c.field.default({ label: "Prompt", control: c.textarea.md({ label: "Prompt", value: sc.text || "", rows: 4, onInput: (v) => p.setText(sc.id, v) }) }),
@@ -46,7 +63,9 @@ export function sceneRows(p, app) {
lengthField(p, sc, "Frames", "frames_mode", "frames", effFrames(sc, open)),
lengthField(p, sc, "FPS", "fps_mode", "fps", effFps(sc, open)),
] }),
drift(sc, open),
c.button.sm({ label: "Generate this scene", tone: "primary", onClick: () => app.say("generate.selected") }),
...trimRows(p, sc, open),
c.checkbox.default({ label: "Exclude from full generation", checked: Boolean(sc.excluded), onChange: (v) => p.setScene(sc.id, "excluded", v) }),
];
}
+20 -2
View File
@@ -31,8 +31,20 @@ export default {
const play = c.iconButton.sm({ icon: "▶", label: "Play", onClick: () => (playing ? pause() : start()) });
const time = c.text.sm({ text: timecode(0, 25) });
const frame = c.button.sm({ label: "📌 Save frame", tone: "ghost", disabled: true, onClick: saveFrame });
let target = "", anchorKey = "";
const anchor = { node: document.createElement("span") }; // the "save frame to" choice: the bin, or a scene's i2v anchor
function drawAnchor() {
const scenes = open() ? open().scenes.filter((s) => !s.excluded) : [];
const key = scenes.map((s) => `${s.id}|${(s.text || "").slice(0, 18)}`).join(",") + `|${target}`;
if (key === anchorKey) return;
anchorKey = key;
if (!scenes.some((s) => s.id === target)) target = "";
const pick = c.select.sm({ label: "Save frame to", value: target, onChange: (v) => { target = v; frame.setLabel(v ? "📌 Use as anchor" : "📌 Save frame"); },
options: [{ value: "", label: "— save to Media bin —" }, ...scenes.map((s, i) => ({ value: s.id, label: `anchor → Scene ${i + 1}${s.text ? `: ${s.text.slice(0, 18)}` : ""}` }))] });
anchor.node.replaceChildren(pick.node);
}
host.append(viewer.node, empty.node, scrub.node, c.toolbar.default({ items: [stop, play, time,
c.select.sm({ label: "Save frame to", options: [{ value: "", label: "— save to Media bin —" }], value: "" }), frame] }).node);
anchor, frame] }).node);
let playing = false, shown = null, seg = null, from = 0, quiet = false;
const open = () => p.project;
@@ -63,6 +75,7 @@ export default {
seg = segAt(head.at);
show(seg);
head.playing = playing;
drawAnchor();
const v = viewer.element;
if (v && seg) { // the clip's own sound: its volume, nothing when moved to a lane of its own or dropped by the project
const sc = seg.scene, vol = sc.audio_separated || (open() || {}).keep_original_audio === false ? 0 : Math.max(0, Math.min(1, sc.audio_volume != null ? +sc.audio_volume : 1));
@@ -93,9 +106,14 @@ export default {
async function saveFrame() {
const blob = await viewer.captureFrame();
if (!blob) return c.toast.warn({ text: "No frame to save yet." });
const { problems } = await app.api.uploadMedia([new File([blob], `${(open() || {}).name || "frame"}-${timecode(head.at, 25).replaceAll(":", "-")}.png`, { type: "image/png" })]);
const { problems, media } = await app.api.uploadMedia([new File([blob], `${(open() || {}).name || "frame"}-${timecode(head.at, 25).replaceAll(":", "-")}.png`, { type: "image/png" })]);
if (problems && problems.length) return c.toast.warn({ text: problems.join(" ") });
app.say("media");
const made = media && media[media.length - 1];
if (target && made && open() && open().scenes.some((s) => s.id === target)) { // as the chosen scene's i2v anchor
p.setScene(target, "source", { type: "generated_frame", media_ref: made.id });
return c.toast.good({ text: "Frame saved, and set as that scene's anchor." });
}
c.toast.good({ text: "Frame saved to the media bin." });
}
// Picking a clip puts the playhead at its start, unless the playhead is already inside it.