From 1fa74696a01186ef8e4b00b3dfd64d0ddc21455b Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Thu, 11 Jun 2026 13:29:48 +0300 Subject: [PATCH] Keep previous render visible when i2v anchor changes. Replacing an anchor no longer deletes sceneRenders. The render snapshot now records source type and media ref so preview, timeline, inspector, and rating can warn that the i2v image changed while still showing the previous generation. Co-authored-by: Cursor --- movie_editor/frontend/inspector.js | 7 +++ movie_editor/frontend/player.js | 28 ++++++++--- movie_editor/frontend/store.js | 79 ++++++++++++++++++++++++------ movie_editor/frontend/styles.css | 9 ++++ movie_editor/frontend/timeline.js | 47 ++++++++++++------ 5 files changed, 136 insertions(+), 34 deletions(-) diff --git a/movie_editor/frontend/inspector.js b/movie_editor/frontend/inspector.js index 86475410..4903be17 100644 --- a/movie_editor/frontend/inspector.js +++ b/movie_editor/frontend/inspector.js @@ -171,6 +171,13 @@ warn.append(el("div", "render-prompt-text", promptMismatch.rendered || "(empty)")); body.append(warn); } + const anchorMismatch = S.renderAnchorMismatch ? S.renderAnchorMismatch(scene.id) : null; + if (anchorMismatch) { + const warn = el("div", "render-prompt-warn render-anchor-warn"); + warn.append(el("div", "render-prompt-warn-title", "i2v image changed · showing previous generation")); + warn.append(el("div", "render-prompt-text", anchorMismatch.renderedLabel || "(none)")); + body.append(warn); + } const src = el("select"); SRC.forEach(([v, label]) => { const o = el("option", null, label); o.value = v; if ((root.source?.type) === v) o.selected = true; src.append(o); }); diff --git a/movie_editor/frontend/player.js b/movie_editor/frontend/player.js index 3b8c8b74..bbe13752 100644 --- a/movie_editor/frontend/player.js +++ b/movie_editor/frontend/player.js @@ -119,15 +119,29 @@ } function _showRenderPrompt(sceneId) { if (!_promptEl) return; - const mismatch = sceneId && S.renderPromptMismatch ? S.renderPromptMismatch(sceneId) : null; - if (!mismatch) { + const promptMismatch = sceneId && S.renderPromptMismatch ? S.renderPromptMismatch(sceneId) : null; + const anchorMismatch = sceneId && S.renderAnchorMismatch ? S.renderAnchorMismatch(sceneId) : null; + if (!promptMismatch && !anchorMismatch) { _promptEl.style.display = "none"; _promptEl.textContent = ""; + _promptEl.classList.remove("anchor-stale"); return; } _promptEl.style.display = ""; - _promptEl.title = "Timeline prompt was edited after generation — rate against this text"; - _promptEl.textContent = `Generated with: ${mismatch.rendered}`; + if (anchorMismatch && !promptMismatch) { + _promptEl.classList.add("anchor-stale"); + _promptEl.title = `Generated with i2v image: ${anchorMismatch.renderedLabel}`; + _promptEl.textContent = "i2v image changed · showing previous generation"; + } else { + _promptEl.classList.remove("anchor-stale"); + if (anchorMismatch && promptMismatch) { + _promptEl.title = `i2v: ${anchorMismatch.renderedLabel} · prompt: ${promptMismatch.rendered}`; + _promptEl.textContent = `i2v image changed · generated with: ${promptMismatch.rendered}`; + } else { + _promptEl.title = "Timeline prompt was edited after generation - rate against this text"; + _promptEl.textContent = `Generated with: ${promptMismatch.rendered}`; + } + } } // ── video pool management ───────────────────────────────────────────────────── @@ -588,12 +602,14 @@ const sc = seg.scene; const stype = sc.source?.type || "empty"; const rendered = !!((st.sceneRenders || {})[sc.id] || {}).media; + const stale = rendered && S.renderIsStale?.(sc.id); cls += rendered ? " rendered" : " pending"; + if (stale) cls += " stale"; if (stype === "mixed") { cls += " mixed"; title = "Mixed · anchor + prior guides"; } else if (stype === "carry") { cls += " carry"; title = "Carry · prior guides only"; } const idx = (p.scenes || []).indexOf(sc) + 1; - if (!title) title = `Scene ${idx}${rendered ? " (rendered)" : " (not rendered)"}`; - else title = `Scene ${idx} · ${title}${rendered ? "" : " · not rendered"}`; + if (!title) title = `Scene ${idx}${rendered ? (stale ? " (previous generation)" : " (rendered)") : " (not rendered)"}`; + else title = `Scene ${idx} · ${title}${rendered ? (stale ? " · previous generation" : "") : " · not rendered"}`; } const chip = el("div", cls); chip.style.width = (d / _totalSecCur * 100).toFixed(3) + "%"; diff --git a/movie_editor/frontend/store.js b/movie_editor/frontend/store.js index 9a9c0adb..0574d48c 100644 --- a/movie_editor/frontend/store.js +++ b/movie_editor/frontend/store.js @@ -335,16 +335,28 @@ || (patch.source.type != null && next.type !== (b.type || "carry")); } - // Dropping or picking a new i2v anchor must drop the cached render and rating — - // otherwise preview keeps the old video and Studio still refines from the prior rating. - function _invalidateSceneAfterAnchorChange(target) { + // Dropping or picking a new i2v anchor keeps the cached render for preview/rating but + // stamps the pre-change source on the snapshot so UI can warn (like prompt mismatch). + function _stampRenderSourceBeforeAnchorChange(target) { if (!target || !state.project) return; const uid = genUnitId(target); + const root = genUnitRoot(uid) || target; + const src = root?.source || {}; + const sourceType = src.type || "carry"; + const mediaRef = src.media_ref || null; + const text = (root?.text || "").trim(); + const anchor = (state.project.anchor || "").trim(); (state.project.scenes || []).filter((sc) => genUnitId(sc) === uid).forEach((sc) => { - delete state.sceneRenders[sc.id]; + const r = state.sceneRenders[sc.id]; + if (!r?.media) return; + const rp = r.renderPrompt || {}; + r.renderPrompt = { + text: rp.text ?? text, + anchor: rp.anchor ?? anchor, + sourceType: rp.sourceType ?? sourceType, + mediaRef: rp.mediaRef !== undefined ? rp.mediaRef : mediaRef, + }; }); - const root = genUnitRoot(uid); - if (root?.rating) root.rating = ""; } // Source lives on the gen-unit root; editorial subclips must mirror it for UI + saves. @@ -361,7 +373,7 @@ const t = _patchSceneTarget(id, patch); if (!t) return; const charsChanged = patch.character_ids != null; const anchorChanged = _anchorPatchChanged(t, patch); - if (anchorChanged) _invalidateSceneAfterAnchorChange(t); + if (anchorChanged) _stampRenderSourceBeforeAnchorChange(t); const merged = { ...patch }; if (merged.source) merged.source = { ...(t.source || {}), ...merged.source }; if (!quiet && !window.EditorHistory?.isApplying()) _historyRecord(); @@ -382,7 +394,7 @@ function patchSceneQuiet(id, patch) { const t = _patchSceneTarget(id, patch) || scene(id); if (!t) return; const anchorChanged = _anchorPatchChanged(t, patch); - if (anchorChanged) _invalidateSceneAfterAnchorChange(t); + if (anchorChanged) _stampRenderSourceBeforeAnchorChange(t); const merged = { ...patch }; if (merged.source) merged.source = { ...(t.source || {}), ...merged.source }; Object.assign(t, merged); @@ -1071,17 +1083,27 @@ const sc = scene(sceneId); if (!sc) return null; const root = genUnitRoot(genUnitId(sc)); + const src = root?.source || sc.source || {}; return { text: (root?.text || "").trim(), anchor: (state.project?.anchor || "").trim(), + sourceType: src.type || "carry", + mediaRef: src.media_ref || null, }; } - function _queueRenderPrompts(sceneIds) { - for (const id of sceneIds || []) { - const snap = _snapshotRenderPrompt(id); - if (snap) _queuedRenderPrompts[id] = snap; - } + function _snapshotRenderSource(sceneId) { + const sc = scene(sceneId); + if (!sc) return null; + const root = genUnitRoot(genUnitId(sc)); + const src = root?.source || sc.source || {}; + return { sourceType: src.type || "carry", mediaRef: src.media_ref || null }; + } + + function renderMediaLabel(mediaRef) { + if (!mediaRef) return "(none)"; + const m = (state.mediaBin || []).find((x) => x.id === mediaRef); + return m?.name || mediaRef; } function renderPromptForScene(sceneId) { @@ -1097,6 +1119,35 @@ return { current, rendered, anchor: snap.anchor || "" }; } + function renderAnchorMismatch(sceneId) { + const snap = renderPromptForScene(sceneId); + if (!snap) return null; + const current = _snapshotRenderSource(sceneId); + if (!current) return null; + const renderedType = snap.sourceType || "carry"; + const renderedRef = snap.mediaRef || null; + if (renderedType === current.sourceType && renderedRef === current.mediaRef) return null; + return { + renderedType, + renderedRef, + renderedLabel: renderMediaLabel(renderedRef), + currentType: current.sourceType, + currentRef: current.mediaRef, + currentLabel: renderMediaLabel(current.mediaRef), + }; + } + + function renderIsStale(sceneId) { + return !!(renderPromptMismatch(sceneId) || renderAnchorMismatch(sceneId)); + } + + function _queueRenderPrompts(sceneIds) { + for (const id of sceneIds || []) { + const snap = _snapshotRenderPrompt(id); + if (snap) _queuedRenderPrompts[id] = snap; + } + } + function _recordSegment(mediaList, targetSceneIds) { if (!mediaList || !mediaList.length || !targetSceneIds || !targetSceneIds.length) return; const primary = mediaList.find((m) => m.kind === "videos" || m.kind === "gifs") || mediaList[0]; @@ -1624,7 +1675,7 @@ patchProject, patchProjectQuiet, patchScene, patchSceneQuiet, flushSave, selectScene, addScene, removeScene, dismissGhost, moveScene, moveSceneTo, scene, sceneCharacterIds, toggleSceneCharacter, genUnitId, isGenSubclip, genUnitRoot, genUnitSceneIds, - renderPromptForScene, renderPromptMismatch, + renderPromptForScene, renderPromptMismatch, renderAnchorMismatch, renderMediaLabel, renderIsStale, buildPreviewSegments, previewTotalSec, segmentDurationSec, addAudioTrack, updateAudioTrack, removeAudioTrack, resizeScene, splitScene, snapFrames, setSourceTrim, trimSceneLeft, slipScene, diff --git a/movie_editor/frontend/styles.css b/movie_editor/frontend/styles.css index 398790e5..fc6918be 100644 --- a/movie_editor/frontend/styles.css +++ b/movie_editor/frontend/styles.css @@ -370,6 +370,7 @@ input[type=number] { font-family: var(--font-mono); } .pm-minibar { height: 14px; flex: 0 0 auto; display: flex; position: relative; background: var(--ink-2); border-top: 1px solid var(--line); cursor: col-resize; user-select: none; } .pm-chip { height: 100%; flex-shrink: 0; background: var(--ink-3); border-right: 1px solid var(--ink-2); transition: opacity .1s; } .pm-chip.rendered { background: linear-gradient(180deg, var(--teal), #3ab3a8); opacity: .55; } +.pm-chip.rendered.stale { background: linear-gradient(180deg, var(--amber-2), #3ab3a8); opacity: .62; } .pm-chip.pending { background: repeating-linear-gradient(-45deg, var(--ink-3), var(--ink-3) 4px, #1a1a1a 4px, #1a1a1a 8px); opacity: .45; } .pm-chip.ghost { background: repeating-linear-gradient(-45deg, #2a2418, #2a2418 4px, #3d3420 4px, #3d3420 8px); opacity: .65; } .pm-chip.mixed { background: linear-gradient(180deg, color-mix(in srgb, var(--amber) 55%, #3ab3a8), #2a6a62); opacity: .72; } @@ -386,6 +387,7 @@ input[type=number] { font-family: var(--font-mono); } border-radius: 10px; padding: 6px 10px; pointer-events: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.pm-render-prompt.anchor-stale { color: #c8fff4; border-color: color-mix(in srgb, var(--teal-soft) 45%, var(--line)); } .pm-needle { position: absolute; top: 0; bottom: 0; width: 2px; background: #fff; opacity: .85; transform: translateX(-50%); pointer-events: none; z-index: 2; } /* transport bar */ @@ -670,6 +672,13 @@ body.tl-reordering { cursor: grabbing; user-select: none; } } .clip.has-media .clip-gen-prompt { position: relative; z-index: 1; } .clip.prompt-mismatch { border-color: color-mix(in srgb, var(--amber) 45%, var(--line)); } +.clip.anchor-mismatch { border-color: color-mix(in srgb, var(--teal-soft) 42%, var(--line)); } +.clip.stale-render.rendered::after { color: var(--amber-2); text-shadow: 0 0 5px rgba(224, 162, 58, .7); } +.clip-gen-anchor .clip-gen-prompt-label { color: var(--teal-soft); } +.clip-gen-anchor .clip-gen-prompt-text { color: #c8fff4; } +.tl-render-anchor { color: #c8fff4; border-color: color-mix(in srgb, var(--teal-soft) 40%, var(--line)); } +.render-anchor-warn { border-color: color-mix(in srgb, var(--teal-soft) 35%, var(--line)); } +.render-anchor-warn .render-prompt-warn-title { color: var(--teal-soft); } .clip-actions { position: absolute; top: 5px; right: 8px; display: flex; gap: 2px; opacity: 0; transition: opacity .12s; } .clip:hover .clip-actions { opacity: 1; } .ic-btn { width: 18px; height: 18px; padding: 0; font-size: 11px; line-height: 1; border: 1px solid var(--line); diff --git a/movie_editor/frontend/timeline.js b/movie_editor/frontend/timeline.js index 5c342d57..2a50f37b 100644 --- a/movie_editor/frontend/timeline.js +++ b/movie_editor/frontend/timeline.js @@ -321,6 +321,7 @@ const clip = el("div", "clip" + clipSelClass(st, scene.id) + (scene.excluded ? " excluded" : "") + (hasRender(st, scene.id) ? " rendered" : (!scene.excluded ? " pending" : "")) + + (hasRender(st, scene.id) && S.renderIsStale?.(scene.id) ? " stale-render" : "") + (unitCuts > 1 ? " gen-cut" : "") + (subclip ? " subclip" : "") + (srcType === "mixed" ? " src-mixed" : srcType === "carry" ? " src-carry" : srcType === "image" ? " src-image" : "")); clip.dataset.sceneId = scene.id; @@ -439,16 +440,27 @@ clip.append(tail); } - if (hasRender(st, scene.id) && S.renderPromptMismatch) { - const mismatch = S.renderPromptMismatch(scene.id); - if (mismatch) { - clip.classList.add("prompt-mismatch"); - const gen = el("div", "clip-gen-prompt"); - gen.title = "Timeline prompt was edited after generation — rate against this text"; - gen.append(el("span", "clip-gen-prompt-label", "Generated with")); - gen.append(el("span", "clip-gen-prompt-text", mismatch.rendered || "(empty)")); + if (hasRender(st, scene.id)) { + const anchorMismatch = S.renderAnchorMismatch ? S.renderAnchorMismatch(scene.id) : null; + if (anchorMismatch) { + clip.classList.add("anchor-mismatch"); + const gen = el("div", "clip-gen-prompt clip-gen-anchor"); + gen.title = "i2v anchor changed after generation - preview shows the previous render"; + gen.append(el("span", "clip-gen-prompt-label", "i2v image changed")); + gen.append(el("span", "clip-gen-prompt-text", `Showing previous generation (${anchorMismatch.renderedLabel})`)); clip.append(gen); } + if (S.renderPromptMismatch) { + const mismatch = S.renderPromptMismatch(scene.id); + if (mismatch) { + clip.classList.add("prompt-mismatch"); + const gen = el("div", "clip-gen-prompt"); + gen.title = "Timeline prompt was edited after generation - rate against this text"; + gen.append(el("span", "clip-gen-prompt-label", "Generated with")); + gen.append(el("span", "clip-gen-prompt-text", mismatch.rendered || "(empty)")); + clip.append(gen); + } + } } const actions = el("div", "clip-actions"); @@ -824,12 +836,15 @@ const sc = S.scene(st.selectedSceneId); if (!sc) return wrap; const sceneNo = p.scenes.indexOf(sc) + 1; - const mismatch = S.renderPromptMismatch ? S.renderPromptMismatch(sc.id) : null; + const promptMismatch = S.renderPromptMismatch ? S.renderPromptMismatch(sc.id) : null; + const anchorMismatch = S.renderAnchorMismatch ? S.renderAnchorMismatch(sc.id) : null; const rlabel = el("span", "tl-keys", `★ Scene ${sceneNo}`); const rsel = el("select", "tl-rating"); - rsel.title = mismatch - ? "Rate against the generated prompt - timeline text was edited after this render" - : "Rate this scene's render - FunPack Studio refines from it on next generation"; + rsel.title = anchorMismatch + ? "i2v image changed - rate the previous generation shown in preview" + : promptMismatch + ? "Rate against the generated prompt - timeline text was edited after this render" + : "Rate this scene's render - FunPack Studio refines from it on next generation"; rsel.append(new Option("- rate -", "")); (st.ratingLabels || []).forEach((l) => { const o = new Option(l, l); @@ -839,8 +854,12 @@ rsel.onchange = () => S.setSceneRating(sc.id, rsel.value); wrap.append(rlabel); wrap.append(rsel); - if (mismatch) { - const hint = el("span", "tl-render-prompt", mismatch.rendered); + if (anchorMismatch) { + const hint = el("span", "tl-render-prompt tl-render-anchor", "i2v image changed · showing previous generation"); + hint.title = `Generated with ${anchorMismatch.renderedLabel}`; + wrap.append(hint); + } else if (promptMismatch) { + const hint = el("span", "tl-render-prompt", promptMismatch.rendered); hint.title = "Generated with this prompt - use it when rating (timeline text differs)"; wrap.append(hint); }