diff --git a/app/ui/features/clipfx/index.js b/app/ui/features/clipfx/index.js index fb7ab3fc..5ed1db62 100644 --- a/app/ui/features/clipfx/index.js +++ b/app/ui/features/clipfx/index.js @@ -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) }) })] }; diff --git a/app/ui/features/inspector/index.js b/app/ui/features/inspector/index.js index 48c8dc80..c71c89f0 100644 --- a/app/ui/features/inspector/index.js +++ b/app/ui/features/inspector/index.js @@ -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; diff --git a/app/ui/features/inspector/scene.js b/app/ui/features/inspector/scene.js index f794832d..b5fa5d45 100644 --- a/app/ui/features/inspector/scene.js +++ b/app/ui/features/inspector/scene.js @@ -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) }), ]; } diff --git a/app/ui/features/preview/index.js b/app/ui/features/preview/index.js index f5f0d11e..dd652038 100644 --- a/app/ui/features/preview/index.js +++ b/app/ui/features/preview/index.js @@ -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.