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:
digital-garbage
2026-06-12 16:13:22 +03:00
parent 6b17e1208a
commit d82d7306c6
5 changed files with 120 additions and 44 deletions
+9 -9
View File
@@ -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
+5 -3
View File
@@ -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);
+71 -17
View File
@@ -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,
+22 -10
View File
@@ -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);
+13 -5
View File
@@ -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();
},