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); }