From 0c7fe14b7932429910d898dcd5bf7f055d3ebdc9 Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:33:57 +0300 Subject: [PATCH] Movie Editor: generated renders are immutable, full-length (no truncation by plan changes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A render never recorded the length it was actually made at — sceneDurationSec recomputed from the LIVE plan every time, so changing project/scene length retroactively truncated already-generated videos and made their tail unreachable in the preview, player, and final export alike. Now each render freezes its real length at generation time (durationSec). A generated scene in project (default) frames mode plays/lays-out/exports at that frozen length; the project/scene default only drives the NEXT generation. Explicit per-scene length (timeline/custom), slip-trim, and splits still win (a split clears the frozen length so both halves revert to plan layout). - store: renderDurationSec + _planDurationSec; sceneDurationSec is render-aware; _recordSegment freezes durationSec on scene renders and ghosts; trim/resize measure from the shown length; segmentDurationSec delegates ghost length to _ghostDurationSec (de-dupes the inline calc). - inspector: scene duration hint shows 'Rendered ~Xs - plan Ys - Regenerate to apply' when a render is frozen at an older plan length. Centralized in sceneDurationSec so timeline, player, audio offsets/durations and the final stitch all agree. No-op in the common case (plan == render). Renders made BEFORE this change have no frozen length and stay plan-driven until regenerated. Co-Authored-By: Claude Opus 4.8 --- movie_editor/frontend/inspector.js | 9 +++++- movie_editor/frontend/store.js | 50 +++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 13 deletions(-) diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 44bc3a0b..adf137c9 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -275,7 +275,14 @@ } const effFrames = effOf(scene, "frames"), effFps = effOf(scene, "fps") || 1; - body.append(el("div", "insp-hint", `Duration ≈ ${(effFrames / effFps).toFixed(2)}s · trim on timeline · Convert to video on the timeline toolbar · generation splits: global prompt or Outgoing seam below`)); + const planDur = effFrames / effFps; + const shownDur = S.sceneDurationSec ? S.sceneDurationSec(scene) : planDur; + const lenDrift = Math.abs(shownDur - planDur) > 0.05; // render frozen at an older plan length + body.append(el("div", "insp-hint", + (lenDrift + ? `Rendered ≈ ${shownDur.toFixed(2)}s · plan ${planDur.toFixed(2)}s — Regenerate to apply` + : `Duration ≈ ${planDur.toFixed(2)}s`) + + ` · trim on timeline · Convert to video on the timeline toolbar · generation splits: global prompt or Outgoing seam below`)); renderOutgoingSeam(st, scene); diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 66317315..f07a22e6 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -286,6 +286,7 @@ state.sceneRenders[id] = { media: ghost.media, inSec: ghost.inSec || 0, + durationSec: ghost.durationSec != null ? ghost.durationSec : undefined, }; } return sc; @@ -1359,10 +1360,7 @@ if (isVideoClip(sc) && sc.source_dur != null) return sc.source_dur; return sceneDurationSec(sc); } - const g = seg.ghost; - const fps = (g.fps_mode !== "project" && g.fps != null) ? g.fps : p.frame_rate; - const frames = (g.frames_mode !== "project" && g.frames != null) ? g.frames : p.num_frames_per_scene; - return (frames || 1) / (fps || 25); + return _ghostDurationSec(seg.ghost); } // Preview/timeline layout: live scenes interleaved with removed-scene ghosts. @@ -1493,7 +1491,7 @@ return; } const fps = sceneEffFps(s); - const curFrames = sceneEffFrames(s); + const curFrames = Math.max(1, Math.round(sceneDurationSec(s) * fps)); // trim from what's shown const trimFrames = Math.max(1, Math.round(trim * fps)); const rawNext = curFrames - trimFrames; if (rawNext < 9) return; @@ -1522,8 +1520,16 @@ function redo() { window.EditorHistory?.redo(window.Store); } - // A scene's duration in seconds (respecting per-scene frames/fps modes). - function sceneDurationSec(sc) { + // Length (s) this scene was ACTUALLY rendered at, frozen at generation time. Null until + // generated (or for old renders saved before this was captured — they fall back to plan). + function renderDurationSec(sceneId) { + const r = state.sceneRenders[sceneId]; + return r && r.durationSec != null ? r.durationSec : null; + } + + // The PLANNED length (s) from the per-scene frames/fps modes — what the next Generate will + // produce. Ignores any existing render. Use this for "what will generate", not playback. + function _planDurationSec(sc) { const p = state.project; if (!p || !sc) return 0; if (isVideoClip(sc) && sc.source_dur != null) return sc.source_dur; const fps = sceneEffFps(sc, p) || 25; @@ -1531,6 +1537,21 @@ return frames / fps; } + // A scene's duration in seconds for layout / playback / trimming. A generated render is + // IMMUTABLE: once made it keeps the length it was rendered at, so changing the project or + // scene default can never truncate it or make its tail unreachable — regenerate or trim to + // change it. An explicit per-scene length (timeline/custom mode), a slip-trim duration, or + // a split (which clears the frozen length) all take precedence and fall back to the plan. + function sceneDurationSec(sc) { + if (!sc) return 0; + const rd = renderDurationSec(sc.id); + if (rd != null && !isVideoClip(sc) && sc.source_dur == null + && (sc.frames_mode || "project") === "project") { + return rd; + } + return _planDurationSec(sc); + } + // Insert or adjust a black/silent pause after this clip (seconds). function setSceneGapAfter(id, sec) { if (!state.project) return; @@ -1555,7 +1576,7 @@ s.source_dur = target; s.frames = snapFrames(target * fps); } else { - const curFrames = sceneEffFrames(s); + const curFrames = Math.max(1, Math.round(sceneDurationSec(s) * fps)); // resize from what's shown const nextFrames = _framesFromDuration(target, fps, curFrames); if (nextFrames === curFrames) return; s.frames = nextFrames; @@ -1608,6 +1629,7 @@ // at the first half's out-point (its in-point + first-half duration). const r = state.sceneRenders[id]; if (r && r.media) { + delete r.durationSec; // a split is an explicit re-length: both halves revert to plan layout state.sceneRenders[second.id] = { media: r.media, inSec: (r.inSec || 0) + cutSec, @@ -2509,6 +2531,8 @@ } function _ghostDurationSec(g) { + if (!g) return 0; + if (g.durationSec != null) return g.durationSec; // frozen real length once rendered const p = state.project; const fps = (g.fps_mode !== "project" && g.fps != null) ? g.fps : p.frame_rate; const frames = (g.frames_mode !== "project" && g.frames != null) ? g.frames : p.num_frames_per_scene; @@ -2688,10 +2712,11 @@ const ghost = ghosts[gi]; const sameUnit = lastChain && genUnitId(lastChain) === genUnitId(ghost); const inSec = sameUnit ? sourceEnd : _recordInSec(lastChain, sourceEnd, ghost, sceneLayout, chainSceneIdx, sourceSpanSec); - ghosts[gi] = { ...ghost, media: primary, inSec, pendingGen: false }; + const ghostDur = _ghostDurationSec(ghost); // freeze before any project change can shrink it + ghosts[gi] = { ...ghost, media: primary, inSec, durationSec: ghostDur, pendingGen: false }; state.sceneGhosts = ghosts; completedGhostIds.push(id); - sourceEnd = inSec + _ghostDurationSec(ghost); + sourceEnd = inSec + ghostDur; lastChain = ghost; if (!sameUnit) chainSceneIdx += 1; } @@ -2703,11 +2728,12 @@ const inSec = sameUnit ? sourceEnd : _recordInSec(lastChain, sourceEnd, sc, sceneLayout, chainSceneIdx, sourceSpanSec); const renderPrompt = _queuedRenderPrompts[id] || _snapshotRenderPrompt(id); delete _queuedRenderPrompts[id]; - state.sceneRenders[id] = { media: primary, inSec, renderPrompt }; + const realDur = _planDurationSec(sc); // freeze the length this render was actually made at + state.sceneRenders[id] = { media: primary, inSec, renderPrompt, durationSec: realDur }; recordedSceneIds.push(id); const root = genUnitRoot(genUnitId(sc)); if (root && root.rating) { root.rating = ""; clearedRating = true; } - sourceEnd = inSec + sceneDurationSec(sc); + sourceEnd = inSec + realDur; lastChain = sc; if (!sameUnit) chainSceneIdx += 1; }