Fix timeline trim updates and default scene length to project settings.
Scene trims now refresh the timeline, inherit project frames/FPS until edited, and tour FAQ scroll no longer collapses open answers.
This commit is contained in:
@@ -507,8 +507,8 @@ class Scene:
|
||||
# "project" → ignore the per-scene value, use the project global.
|
||||
# "timeline" → use the per-scene value; the timeline trim handle writes it live.
|
||||
# "custom" → use the per-scene value; the timeline trim handle is locked.
|
||||
# Defaults: length follows the timeline (trim-derived), fps follows the project.
|
||||
frames_mode: str = "timeline"
|
||||
# Defaults: length and fps follow project until trimmed (timeline) or set in inspector (custom).
|
||||
frames_mode: str = "project"
|
||||
fps_mode: str = "project"
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
@@ -549,7 +549,7 @@ class Scene:
|
||||
audio_separated=bool(d.get("audio_separated", False)),
|
||||
frames=d.get("frames"),
|
||||
fps=d.get("fps"),
|
||||
frames_mode=str(d.get("frames_mode") or "timeline"),
|
||||
frames_mode=str(d.get("frames_mode") or "project"),
|
||||
fps_mode=str(d.get("fps_mode") or "project"),
|
||||
width=d.get("width"),
|
||||
height=d.get("height"),
|
||||
@@ -570,14 +570,14 @@ class Scene:
|
||||
return d
|
||||
|
||||
def eff_frames(self, project: "Project") -> int:
|
||||
if self.frames_mode == "project" or self.frames is None:
|
||||
return project.num_frames_per_scene
|
||||
return self.frames
|
||||
if self.frames is not None:
|
||||
return self.frames
|
||||
return project.num_frames_per_scene
|
||||
|
||||
def eff_fps(self, project: "Project") -> int:
|
||||
if self.fps_mode == "project" or self.fps is None:
|
||||
return project.frame_rate
|
||||
return self.fps
|
||||
if self.fps is not None:
|
||||
return self.fps
|
||||
return project.frame_rate
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -94,11 +94,13 @@
|
||||
frames: { label: "Frames", projKey: "num_frames_per_scene", snap: (v) => S.snapFrames(v) },
|
||||
fps: { label: "FPS", projKey: "frame_rate", snap: (v) => Math.max(1, v) },
|
||||
};
|
||||
const LEN_DEFAULT_MODE = { frames: "timeline", fps: "project" };
|
||||
const LEN_DEFAULT_MODE = { frames: "project", fps: "project" };
|
||||
|
||||
function modeOf(scene, kind) { return scene[kind + "_mode"] || LEN_DEFAULT_MODE[kind]; }
|
||||
function effOf(scene, kind) {
|
||||
const m = LEN_META[kind], p = S.get().project;
|
||||
if (kind === "frames" && S.sceneEffFrames) return S.sceneEffFrames(scene, p);
|
||||
if (kind === "fps" && S.sceneEffFps) return S.sceneEffFps(scene, p);
|
||||
return modeOf(scene, kind) !== "project" && scene[kind] != null ? scene[kind] : p[m.projKey];
|
||||
}
|
||||
|
||||
@@ -113,8 +115,8 @@
|
||||
.forEach(([v, l]) => { const o = el("option", null, l); o.value = v; if (v === mode) o.selected = true; sel.append(o); });
|
||||
sel.onchange = () => {
|
||||
const patch = { [kind + "_mode"]: sel.value };
|
||||
// Seed the per-scene value from the current effective value when leaving "project".
|
||||
if (sel.value !== "project" && scene[kind] == null) patch[kind] = effOf(scene, kind);
|
||||
if (sel.value === "project") patch[kind] = null;
|
||||
else if (scene[kind] == null) patch[kind] = effOf(scene, kind);
|
||||
S.patchScene(scene.id, patch);
|
||||
};
|
||||
wrap.append(sel);
|
||||
|
||||
@@ -780,18 +780,59 @@
|
||||
return _commitPromise;
|
||||
}
|
||||
|
||||
function _sceneHasManualSource(sc) {
|
||||
if (!sc?.source) return false;
|
||||
if (isVideoClip(sc)) return true;
|
||||
const t = sc.source.type;
|
||||
if (t === "image" || t === "v2v" || t === "video") return true;
|
||||
return t === "mixed" && !!sc.source.media_ref;
|
||||
}
|
||||
|
||||
function _propagateProjectSettingsToScenes(before, patch) {
|
||||
const p = state.project;
|
||||
if (!p) return;
|
||||
const touches = ["num_frames_per_scene", "frame_rate", "width", "height"].some((k) => k in patch);
|
||||
if (!touches) return;
|
||||
for (const sc of p.scenes || []) {
|
||||
if (_sceneHasManualSource(sc)) continue;
|
||||
if (patch.num_frames_per_scene != null && (sc.frames_mode || "project") === "project") {
|
||||
sc.frames = null;
|
||||
}
|
||||
if (patch.frame_rate != null && (sc.fps_mode || "project") === "project") {
|
||||
sc.fps = null;
|
||||
}
|
||||
if (patch.width != null && (sc.width == null || sc.width === before.width)) sc.width = null;
|
||||
if (patch.height != null && (sc.height == null || sc.height === before.height)) sc.height = null;
|
||||
}
|
||||
}
|
||||
|
||||
function patchProject(patch) {
|
||||
if (!state.project) return;
|
||||
_historyRecord();
|
||||
const before = {
|
||||
num_frames_per_scene: state.project.num_frames_per_scene,
|
||||
frame_rate: state.project.frame_rate,
|
||||
width: state.project.width,
|
||||
height: state.project.height,
|
||||
};
|
||||
Object.assign(state.project, patch);
|
||||
_propagateProjectSettingsToScenes(before, patch);
|
||||
if (_patchAffectsCombinedPrompt(patch)) syncGlobalPromptFromTimeline();
|
||||
notify();
|
||||
scheduleSave();
|
||||
}
|
||||
function patchProjectQuiet(patch) {
|
||||
if (!state.project) return;
|
||||
const before = {
|
||||
num_frames_per_scene: state.project.num_frames_per_scene,
|
||||
frame_rate: state.project.frame_rate,
|
||||
width: state.project.width,
|
||||
height: state.project.height,
|
||||
};
|
||||
Object.assign(state.project, patch);
|
||||
_propagateProjectSettingsToScenes(before, patch);
|
||||
if (_patchAffectsCombinedPrompt(patch)) syncGlobalPromptFromTimeline();
|
||||
if (["num_frames_per_scene", "frame_rate", "width", "height"].some((k) => k in patch)) notify();
|
||||
scheduleSaveSilent();
|
||||
}
|
||||
|
||||
@@ -997,7 +1038,7 @@
|
||||
if (!state.project) return;
|
||||
_historyRecord();
|
||||
const srcType = window.PipelineCaps?.defaultSceneSourceType(state) || "carry";
|
||||
const s = { text: "", transition_to_next: "", source: { type: srcType }, excluded: false };
|
||||
const s = { text: "", transition_to_next: "", source: { type: srcType }, excluded: false, frames_mode: "project", fps_mode: "project" };
|
||||
state.project.scenes.push(s);
|
||||
window.Timeline?.requestAutoFit?.();
|
||||
notify(); scheduleSave(); // server assigns id; reselect after commit
|
||||
@@ -1351,17 +1392,31 @@
|
||||
// LTX-valid frame counts are 8k+1 (so (frames-1) % 8 === 0); snap to the nearest.
|
||||
function snapFrames(n) { return Math.max(9, Math.round((Math.round(n) - 1) / 8) * 8 + 1); }
|
||||
|
||||
function sceneEffFrames(sc, project) {
|
||||
const p = project || state.project;
|
||||
if (!sc || !p) return p?.num_frames_per_scene || 97;
|
||||
return sc.frames != null ? sc.frames : p.num_frames_per_scene;
|
||||
}
|
||||
|
||||
function sceneEffFps(sc, project) {
|
||||
const p = project || state.project;
|
||||
if (!sc || !p) return p?.frame_rate || 25;
|
||||
return sc.fps != null ? sc.fps : p.frame_rate;
|
||||
}
|
||||
|
||||
function trimSceneLeft(id, trimSec) {
|
||||
if (!state.project) return;
|
||||
const s = scene(id); if (!s || s.frames_mode === "custom") return;
|
||||
const fps = (s.fps_mode !== "project" && s.fps != null) ? s.fps : state.project.frame_rate;
|
||||
const curFrames = s.frames != null ? s.frames : state.project.num_frames_per_scene;
|
||||
const trimFrames = snapFrames(Math.max(0.05, trimSec) * fps);
|
||||
if (trimFrames >= curFrames - 8) return;
|
||||
const fps = sceneEffFps(s);
|
||||
const curFrames = sceneEffFrames(s);
|
||||
const trimFrames = Math.max(1, Math.round(Math.max(0.05, trimSec) * fps));
|
||||
const nextFrames = snapFrames(Math.max(9, curFrames - trimFrames));
|
||||
if (nextFrames >= curFrames) return;
|
||||
_historyRecord();
|
||||
s.frames = snapFrames(curFrames - trimFrames);
|
||||
s.source_in = (s.source_in || 0) + trimFrames / fps;
|
||||
if (s.frames_mode == null || s.frames_mode === "project") s.frames_mode = "timeline";
|
||||
const actualTrimFrames = curFrames - nextFrames;
|
||||
s.frames = nextFrames;
|
||||
s.source_in = (s.source_in || 0) + actualTrimFrames / fps;
|
||||
if ((s.frames_mode || "project") === "project") s.frames_mode = "timeline";
|
||||
notify();
|
||||
scheduleSave();
|
||||
}
|
||||
@@ -1383,23 +1438,22 @@
|
||||
function sceneDurationSec(sc) {
|
||||
const p = state.project; if (!p || !sc) return 0;
|
||||
if (isVideoClip(sc) && sc.source_dur != null) return sc.source_dur;
|
||||
const fps = (sc.fps_mode !== "project" && sc.fps != null ? sc.fps : p.frame_rate) || 25;
|
||||
const frames = (sc.frames_mode !== "project" && sc.frames != null ? sc.frames : p.num_frames_per_scene) || 1;
|
||||
const fps = sceneEffFps(sc, p) || 25;
|
||||
const frames = sceneEffFrames(sc, p) || 1;
|
||||
return frames / fps;
|
||||
}
|
||||
|
||||
// Resize a clip by setting its frame count from a target duration (seconds) × fps.
|
||||
// Uses silent save so the drag handle isn't interrupted by a re-render mid-drag.
|
||||
function resizeScene(id, durationSec) {
|
||||
if (!state.project) return;
|
||||
const s = scene(id); if (!s) return;
|
||||
if (s.frames_mode === "custom") return;
|
||||
_historyRecord();
|
||||
const fps = (s.fps_mode !== "project" && s.fps != null) ? s.fps : state.project.frame_rate;
|
||||
const fps = sceneEffFps(s);
|
||||
s.frames = snapFrames(Math.max(1, durationSec) * fps);
|
||||
// Dragging the trim handle opts the scene into timeline-driven length.
|
||||
if (s.frames_mode == null || s.frames_mode === "project") s.frames_mode = "timeline";
|
||||
if ((s.frames_mode || "project") === "project") s.frames_mode = "timeline";
|
||||
_syncSeparatedAudioTracks();
|
||||
notify();
|
||||
scheduleSaveSilent();
|
||||
}
|
||||
|
||||
@@ -1411,8 +1465,8 @@
|
||||
const arr = state.project.scenes;
|
||||
const i = arr.findIndex((s) => s.id === id); if (i < 0) return;
|
||||
const s = arr[i];
|
||||
const effFps = (s.fps_mode !== "project" && s.fps != null) ? s.fps : state.project.frame_rate;
|
||||
let frames = s.frames != null ? s.frames : state.project.num_frames_per_scene;
|
||||
const effFps = sceneEffFps(s);
|
||||
let frames = sceneEffFrames(s);
|
||||
if (isVideoClip(s) && s.source_dur != null) frames = snapFrames(s.source_dur * effFps);
|
||||
const cut = snapFrames(atFrames != null ? atFrames : frames / 2);
|
||||
if (cut <= 9 || cut >= frames) return;
|
||||
@@ -3442,7 +3496,7 @@
|
||||
bringOverlayToFront, sendOverlayToBack, bringOverlayForward, sendOverlayBackward,
|
||||
addImageOverlay, addTextOverlay, updateOverlayTrack, removeOverlayTrack, removeSelectedOverlay,
|
||||
isOverlayAudioTrack, isSeparatedAudioTrack,
|
||||
resizeScene, splitScene, snapFrames, setSourceTrim, trimSceneLeft, slipScene,
|
||||
resizeScene, splitScene, snapFrames, sceneEffFrames, sceneEffFps, setSourceTrim, trimSceneLeft, slipScene,
|
||||
applyEnginePreset, ENGINE_PRESETS, undo, redo,
|
||||
refreshPreview, syncFromPreview, applyGlobalPromptQuiet, scheduleGlobalPromptApply, buildGlobalPromptFromTimeline, syncGlobalPromptFromTimeline, generate, generateMontage, generateSelected, selectedSceneCount, renderFinal, exportSelected, saveSelectedToMediaBin, clipSaveableToMediaBin, interrupt, loadModels, loadImageTargets, setModelInput, setModelLink, clearNotice,
|
||||
setConditioningSlot, setSamplerSlot, setSamplerInput, setSamplerInputNow, unsetSamplerInput, setStudioInput, setStudioInputNow,
|
||||
|
||||
@@ -133,8 +133,8 @@
|
||||
let tlFps = 25;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
const sFps = (sc, p) => ((sc.fps_mode !== "project" && sc.fps != null) ? sc.fps : p.frame_rate) || 25;
|
||||
const sFrames = (sc, p) => ((sc.frames_mode !== "project" && sc.frames != null) ? sc.frames : p.num_frames_per_scene) || 1;
|
||||
const sFps = (sc, p) => (S.sceneEffFps ? S.sceneEffFps(sc, p) : p.frame_rate) || 25;
|
||||
const sFrames = (sc, p) => (S.sceneEffFrames ? S.sceneEffFrames(sc, p) : p.num_frames_per_scene) || 1;
|
||||
const sDur = (sc, p) => (S.sceneDurationSec ? S.sceneDurationSec(sc) : (sFrames(sc, p) / sFps(sc, p)));
|
||||
|
||||
function snapPx(x, anchors, threshold) {
|
||||
@@ -731,6 +731,9 @@
|
||||
|
||||
// right-edge trim → new duration → frames recomputed (duration × fps).
|
||||
const locked = scene.frames_mode === "custom";
|
||||
const baseDur = sDur(scene, p);
|
||||
const baseLeftPx = leftPx;
|
||||
const baseWidthPx = widthPx;
|
||||
const leftHandle = el("div", "clip-trim clip-trim-left" + (locked ? " locked" : ""));
|
||||
leftHandle.title = locked ? "Length locked (custom mode)" : "Drag to trim start · Alt+drag to slip source when rendered";
|
||||
if (!locked) {
|
||||
@@ -741,13 +744,22 @@
|
||||
const anchors = timelineSnapAnchorsPx(st, p);
|
||||
let finalDelta = 0;
|
||||
coalescedDrag(e, (dx) => {
|
||||
const snappedLeft = snapPx(leftPx + dx, anchors, 10);
|
||||
finalDelta = (snappedLeft - leftPx) / pxPerSec;
|
||||
const snappedLeft = snapPx(baseLeftPx + dx, anchors, 10);
|
||||
finalDelta = (snappedLeft - baseLeftPx) / pxPerSec;
|
||||
if (finalDelta > 0) {
|
||||
clip.style.left = snappedLeft + "px";
|
||||
clip.style.width = Math.max(8, (baseDur - finalDelta) * pxPerSec) + "px";
|
||||
} else {
|
||||
clip.style.left = baseLeftPx + "px";
|
||||
clip.style.width = baseWidthPx + "px";
|
||||
}
|
||||
tip.textContent = e.altKey && hasRender(st, scene.id)
|
||||
? `slip ${finalDelta >= 0 ? "+" : ""}${finalDelta.toFixed(2)}s`
|
||||
: `trim ${(-finalDelta).toFixed(2)}s`;
|
||||
: `trim ${Math.max(0, finalDelta).toFixed(2)}s`;
|
||||
}, () => {
|
||||
clip.classList.remove("trimming"); tip.remove();
|
||||
clip.style.left = baseLeftPx + "px";
|
||||
clip.style.width = baseWidthPx + "px";
|
||||
if (e.altKey && hasRender(st, scene.id)) {
|
||||
if (Math.abs(finalDelta) > 0.02) S.slipScene(scene.id, finalDelta);
|
||||
} else if (finalDelta > 0.02) {
|
||||
@@ -760,25 +772,25 @@
|
||||
|
||||
const handle = el("div", "clip-trim" + (locked ? " locked" : ""));
|
||||
handle.title = locked
|
||||
? "Length is Custom — change it in the inspector (set Frames to Inherit timeline to trim here)"
|
||||
? "Length is Custom — change it in the inspector (set Frames to Inherit project to trim here)"
|
||||
: "Drag to trim · length = duration × fps";
|
||||
const baseDur = sDur(scene, p);
|
||||
if (locked) { clip.append(handle); return clip; }
|
||||
handle.addEventListener("mousedown", (e) => {
|
||||
e.stopPropagation();
|
||||
clip.classList.add("trimming");
|
||||
const tip = el("div", "trim-tip"); clip.append(tip);
|
||||
const anchors = timelineSnapAnchorsPx(st, p);
|
||||
let finalDur = baseDur;
|
||||
coalescedDrag(e, (dx) => {
|
||||
const rightPx = snapPx(leftPx + baseDur * pxPerSec + dx, anchors, 10);
|
||||
finalDur = Math.max(0.1, (rightPx - leftPx) / pxPerSec);
|
||||
const rightPx = snapPx(baseLeftPx + baseDur * pxPerSec + dx, anchors, 10);
|
||||
finalDur = Math.max(0.1, (rightPx - baseLeftPx) / pxPerSec);
|
||||
const w = finalDur * pxPerSec;
|
||||
clip.style.width = Math.max(w, 8) + "px";
|
||||
const fps = sFps(scene, p);
|
||||
tip.textContent = `${timecode(finalDur, fps)} · ${S.snapFrames(finalDur * fps)}f`;
|
||||
}, () => {
|
||||
clip.classList.remove("trimming"); tip.remove();
|
||||
S.resizeScene(scene.id, finalDur);
|
||||
if (Math.abs(finalDur - baseDur) > 0.02) S.resizeScene(scene.id, finalDur);
|
||||
});
|
||||
});
|
||||
clip.append(handle);
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
effects: {},
|
||||
audio_volume: 1,
|
||||
audio_separated: false,
|
||||
frames_mode: "timeline",
|
||||
frames_mode: "project",
|
||||
fps_mode: "project",
|
||||
source: { type: "empty", media_ref: null },
|
||||
rating: "good",
|
||||
@@ -145,7 +145,7 @@
|
||||
effects: {},
|
||||
audio_volume: 1,
|
||||
audio_separated: true,
|
||||
frames_mode: "timeline",
|
||||
frames_mode: "project",
|
||||
fps_mode: "project",
|
||||
source: { type: "video", media_ref: "tour-media-video" },
|
||||
rating: "",
|
||||
@@ -166,7 +166,7 @@
|
||||
effects: {},
|
||||
audio_volume: 1,
|
||||
audio_separated: false,
|
||||
frames_mode: "timeline",
|
||||
frames_mode: "project",
|
||||
fps_mode: "project",
|
||||
source: { type: "carry", media_ref: null },
|
||||
rating: "",
|
||||
@@ -591,8 +591,16 @@
|
||||
banner.append(exitBtn);
|
||||
this.root.append(banner);
|
||||
document.body.appendChild(this.root);
|
||||
window.addEventListener("resize", this._onResize = () => this.renderStep());
|
||||
window.addEventListener("scroll", this._onScroll = () => this.renderStep(), true);
|
||||
window.addEventListener("resize", this._onResize = () => {
|
||||
const step = STEPS[this.step];
|
||||
if (step?.type === "faq") {
|
||||
this.positionCardCenter();
|
||||
this.card.style.maxWidth = "640px";
|
||||
return;
|
||||
}
|
||||
this.renderStep();
|
||||
});
|
||||
window.addEventListener("scroll", this._onScroll = () => this.reposition(), true);
|
||||
this.renderStep();
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user