diff --git a/movie_editor/backend/timeline.py b/movie_editor/backend/timeline.py index fea196b4..7bfa8886 100644 --- a/movie_editor/backend/timeline.py +++ b/movie_editor/backend/timeline.py @@ -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 diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 37b4fb67..afa169ef 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -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); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 6dddc8c4..12271178 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -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, diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 185c6c50..18b1c56d 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -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); diff --git a/movie_editor/frontend/tour.js b/movie_editor/frontend/tour.js index 9c0e619c..a99efac4 100644 --- a/movie_editor/frontend/tour.js +++ b/movie_editor/frontend/tour.js @@ -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(); },