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 <cursoragent@cursor.com>
This commit is contained in:
digital-garbage
2026-06-11 13:29:48 +03:00
co-authored by Cursor
parent d1f175869a
commit 1fa74696a0
5 changed files with 136 additions and 34 deletions
+7
View File
@@ -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); });
+22 -6
View File
@@ -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) + "%";
+65 -14
View File
@@ -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,
+9
View File
@@ -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);
+33 -14
View File
@@ -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);
}