Skip to content

Exclude archived candidates from the rendered video - #199

Merged
christophervoelpel merged 9 commits into
mainfrom
fix/archived-candidate-never-renders
Sep 23, 2026
Merged

christophervoelpel merged 9 commits into
mainfrom
fix/archived-candidate-never-renders

Conversation

@christophervoelpel

@christophervoelpel christophervoelpel commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Archived candidates cannot be selected or included in rendered output. Archiving the selected candidate clears its selection; attempting to select an archived candidate leaves the scene's selection, prompt and reference image unchanged and does not persist an update. Archived drawer cards no longer advertise selection through their click handler or selected styling; Restore remains available.

The shared render-clip resolver returns not-selected for an archived candidate, so the same protection covers rendering and composition. Archive cleanup also evicts the candidate reference preview from the thumbnail cache (SM-17).

Validation

  • Storyboard, dictation and archived-candidate render suites: 95 passed; lint and spec typecheck passed.
  • The archived-selection regression failed before the guard and passed after it. Tests verify no selection/prompt/reference mutation or persistence, render exclusion and an enabled Restore control.
  • The archived drawer was tested through Angular component rendering; no live-cloud browser scenario was run.

Fixes SM-1 (HIGH) and SM-17 (LOW).

When a user archives the selected storyboard candidate, the candidate card
is hidden from the storyboard UI, but its index previously remained in
scene.selectedCandidateIndex. Because resolveSceneRenderClip lacked an
archive check, the hidden candidate was still fed into the combine workflow
and rendered into the exported video.

This implements a two-layer defense:
1. In Storyboard.toggleArchive (storyboard.ts), when archiving the candidate
   currently pointed to by scene.selectedCandidateIndex, clear the selection
   by setting scene.selectedCandidateIndex = undefined. Additionally, evict
   candidate.referenceImage?.preview?.path along with the high quality
   thumbnail and reference image paths from the thumbnail cache (SM-17).
2. As a defense-in-depth safety net, in resolveSceneRenderClip (config.ts),
   check candidate.isArchived and return {state: 'not-selected'}. Returning
   'not-selected' drops the unselected/archived scene from the rendered
   composition without disabling the Render button for the entire project,
   which returning 'invalid' would do.

Added regression tests in archived-candidate-render.spec.ts covering clip
resolution states, combine workflow submission filtering, and toggleArchive
selection clearing and cache eviction.
@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

1 similar comment
@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Review: merge after small fixes — but the live half of the bug is still open

Multi-agent review (reviewer → independent critique agent re-verifying each claim against the code).

The 12-line production guard is well-placed. resolveSceneRenderClip is the only producer for getCombineScenesArrangements, exportScene, findTransitionContractViolation, the composition playlist/filmstrip and output-video, and the backend gets explicit file paths — so there is no second selection path to cover. Verified by call-site survey. Returning 'not-selected' rather than 'invalid' is the right call and well-commented: 'invalid' would throw RenderContractError and kill rendering for the entire project. The two guards are not duplication — storyboard.ts fixes forward state, config.ts repairs already-persisted bad state.

Important

The archived candidate can still be re-selected — and re-selecting it mutates saved scene state

ui/src/app/storyboard/storyboard.html:432-436 still has (click)="selectCandidate(scene, $index)" on the archived drawer card, and selectCandidate (storyboard.ts:1066-1075) has no isArchived guard. So the toggleArchive fix that clears selectedCandidateIndex is trivially undone by one click.

It's worse than a silent drop. selectCandidate also overwrites scene.prompt and scene.referenceImage from the archived candidate and persists it. The user sees the card go .selected, and the scene then vanishes from both the render and the composition filmstrip with no explanation.

The config.ts guard means this isn't a correctness blocker for the render output — nothing archived leaks in — but it is the hole this PR describes as closed.

One-line fix at the source:

if (scene.candidates![index].isArchived) return;

That makes the template (click) harmless and removes the need to touch the HTML at all. Worth one test for select-then-render.

The spec file: recommend deleting it and folding ~40 lines into the existing suites

archived-candidate-render.spec.ts is 382 of this PR's 397 added lines, and most of it already exists elsewhere:

  • Lines 211-278 — a 68-line Storyboard TestBed duplicating the one at storyboard.spec.ts:207-216 (same ThumbnailCacheService / CandidateVideoCacheService mocks). Also Feature Envy: a services/config/ file standing up a storyboard harness.
  • Lines 342-386 — a near-copy of the existing eviction test at storyboard.spec.ts:972-1012 ('invalidates a candidate cache entry when archiving'), which is the same test with a real TestBed. The only genuinely new assertion is referenceImage.preview.path.
  • Lines 158-172 — expect(startSpy).not.toHaveBeenCalled() is vacuous: combineScenes catches every error into a snackbar (remix-engine.ts:2123-2129), so any unrelated failure makes it pass. It does fail against old code today, but it will rot silently.
  • Lines 76-82 — expect(...).not.toBe('invalid') is strictly subsumed by the toBe('not-selected') assertion above it. Keep the comment, drop the test.
  • Lines 281-340 — two 28-line scene literals identical except for the toggled index.
  • Line 1 — the file opens with /* eslint-disable @typescript-eslint/no-explicit-any */ and no Apache header; every sibling spec has the header first. Nothing enforces this (no CI step in ui-tests.yml, no header/notice plugin in ui/eslint.config.js), so it's a convention nit rather than a gate — and moot if the file goes.

Suggested shape: keep ~3 resolveSceneRenderClip assertions and put them alongside the existing config specs; add the preview.path assertion (and one for selectedCandidateIndex) to the existing storyboard.spec.ts eviction test. ~40 lines instead of 382, with coverage unchanged.

Explicitly not recommended

  • Don't write a scene-literal builder or a second Storyboard TestBed. Both problems disappear once the duplicate file does; adding helpers to a file that should be deleted is the wrong direction.
  • Don't replace the vacuous not.toHaveBeenCalled() with a snackbar-text assertion. This spec is about resolveSceneRenderClip; the snackbar path is already exercised by remix-engine's own tests. Just delete the assertion.

Verified, no action needed

selectedCandidateIndex = undefined does persist correctly — the save is a full-document PATCH (config.ts:1283-1296), so JSON.stringify dropping the key clears it server-side. No deleteField needed.

One caveat on method: this review is by inspection, not execution — I did not run the suite.

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Consolidated Review

Verdict: Merge after one small fix. This note reconciles the 19:50 comment on this PR against head e72d24d, and every item below was re-verified by tracing callers and running the tests and probes under Evidence.

Do before merge

  1. Add a one-line isArchived guard to selectCandidate in ui/src/app/storyboard/storyboard.ts:974, for example if (scene.candidates![index].isArchived) return;, plus one test. Evidence: ui/src/app/storyboard/storyboard.html:432-436 still binds (click)="selectCandidate(scene, $index)" on the archived drawer card with no disabling, and selectCandidate unconditionally sets scene.selectedCandidateIndex, overwrites scene.prompt and scene.referenceImage, and persists via updateScenes. A direct probe calling selectCandidate on an archived-only candidate reproduced this: index, prompt, and reference image flipped to the archived values and updateProjectConfig fired. This undoes the toggleArchive cleanup this PR just added and contradicts the new spec file's own claim that the selection is guarded. Acceptance: one test asserting selectCandidate is a no-op on an archived candidate, plus a manual check that clicking an archived card no longer changes the selection. It is a UX hole rather than a render leak: the config.ts guard still keeps the archived candidate out of the render, but the card shows as selected and the scene then disappears from the render and filmstrip without explanation, and the prompt and reference image are overwritten and persisted.

Optional, does not block

  • Fold archived-candidate-render.spec.ts down from 382 to roughly 40 lines: it duplicates the Storyboard TestBed already at storyboard.spec.ts:207-216 and near-copies the eviction test at storyboard.spec.ts:972-1012, adding only the referenceImage.preview.path assertion. Keep that assertion and the resolveSceneRenderClip cases, move them into the existing suites.
  • The expect(startSpy).not.toHaveBeenCalled() assertion (line 158-172) is imprecise: on pre-fix behavior it times out after five seconds instead of failing cleanly, because startCombineScenesWorkflow and the unmocked pollWorkflow actually run. Worth tightening later, not a blocking fix.
  • The expect(...).not.toBe('invalid') assertion (line 76-82) is redundant with the toBe('not-selected') assertion directly above it. Drop it during consolidation.
  • No Apache header and an eslint-disable at the top of the new spec file; moot once the file is folded into existing suites.

Rejected or superseded, do not re-litigate

  • The 19:50 comment's main finding (re-selecting an archived candidate) is confirmed and is the only required change; nothing in it was refuted.
  • The two guards (storyboard.ts and config.ts) are not duplicative. A call-site survey of resolveSceneRenderClip (output-video.ts:150, composition.ts:118, remix-engine.ts:2358 and 2400) confirms it is the sole producer reaching render, export, and composition, so the guards are complementary, not overlapping. No change needed.
  • selectedCandidateIndex = undefined persisting correctly through the full-document PATCH is confirmed: structuredClone preserves the undefined key, JSON.stringify drops it on the wire, and the backend's set() write, not a merge, leaves the field absent server-side. No deleteField sentinel needed.

Evidence

  • npm test -- --watch=false --include archived-candidate-render.spec.ts --include storyboard.spec.ts --include config-mediated.spec.ts: 3 files, 136 tests passed.
  • Probe reusing the file's own TestBed to call selectCandidate on an archived-only candidate: 1 passed, reproduced the overwrite and the persistence call.
  • Guard temporarily disabled and the suite re-run: 2 of 8 failed, one by timeout, confirming both the guard's necessity and the imprecision of the not.toHaveBeenCalled() assertion.
  • npm run typecheck:spec: passed. git diff --check origin/main..HEAD: no whitespace errors.
  • Not run: rendered-browser click-through of the archived drawer, backend Python suite (diff touches only ui/).

Way forward

@christophervoelpel

Copy link
Copy Markdown
Collaborator Author

Follow-up on Consolidated Review

I addressed the archived-selection blocker from the Consolidated Review on head d301198990da.

  • selectCandidate now returns without changing selection, prompt, reference image, or persistence when the candidate is archived.
  • Archived drawer cards no longer present themselves as selectable, while Restore remains enabled.
  • The shared render-clip resolver continues to exclude archived candidates, and archive cleanup evicts the candidate reference preview.

Verification: the focused storyboard, dictation, and archived-candidate render suites passed with 95 tests; lint and spec typecheck passed. The regression reproduced before the guard and passed afterward, including render exclusion and enabled Restore coverage. The archived drawer was checked through Angular component rendering; no live-cloud browser scenario was run.

Current-head GitHub CI is complete: Python 3.11/3.12/3.13, UI build/lint/tests, deploy checks and security scans passed. Conditional zizmor jobs were skipped.

Comment thread ui/src/app/storyboard/storyboard.ts Outdated
Comment on lines 1159 to 1162
if (candidate.isArchived && scene.selectedCandidateIndex === index) {
scene.selectedCandidateIndex = undefined;
}
this.updateScenes();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting scene.selectedCandidateIndex = undefined here has three issues that need to be addressed together:

  1. Unpersisted / Reverted Selection (this.updateScenes() without scene): toggleArchive now mutates a top-level property on scene (scene.selectedCandidateIndex = undefined), but line 1162 calls this.updateScenes() with no arguments. Inside updateScenes(scene?) (storyboard.ts:1125), const updatedScene = scene || this.selectedScene() falls back to this.selectedScene(), which returns a shallow clone ({...s}) of the scene from projectConfig (storyboard.ts:244)—or null if selectedSceneId() is unset. If scene passed to toggleArchive is not the exact memoized clone instance cached by selectedScene(), this.updateScenes() overwrites the storyboard entry with the stale clone where selectedCandidateIndex is still index (or drops the save when selectedScene() is null). Pass this.updateScenes(scene) like selectCandidate and updateScenePrompt do.
  2. Stale Playback / Trim Duration State: When selectedCandidateIndex becomes undefined and <video #mainVideo> leaves the DOM, trimEnd() (storyboard.ts:515) falls back to this.videoDuration(), leaving the Trim End input displaying the archived candidate's duration unless isVideoPlaying, videoDuration, and currentPlaybackTime are reset.
  3. Ripple Effect in RemixEngineService.attachCandidates (ui/src/app/services/remix-engine/remix-engine.ts:1799-1806) & Storyboard.selectedCandidate (storyboard.ts:250-259): When a user archives candidates[0] (selectedCandidateIndex becomes undefined) and then clicks Generate Candidates or Edit with a prompt to generate candidates[1], attachCandidates evaluates:
    selectedCandidateIndex:
      selectedCandidateIndex !== undefined &&
      Number.isInteger(selectedCandidateIndex) &&
      selectedCandidateIndex >= 0 &&
      selectedCandidateIndex < candidates.length
        ? selectedCandidateIndex
        : 0,
    Because s.selectedCandidateIndex is now undefined, attachCandidates falls back to : 0 — re-selecting the archived candidates[0] instead of the newly generated candidates[1]! Because Storyboard.selectedCandidate (storyboard.ts:258) returns scene.candidates[scene.selectedCandidateIndex] without checking candidate.isArchived, the Storyboard preview player immediately loads the archived candidates[0] while resolveSceneRenderClip (config.ts:472) sees candidates[0].isArchived === true and excludes the scene from the Composition playlist and render.
    • In remix-engine.ts:1799-1806, verify !candidates[selectedCandidateIndex]?.isArchived and fall back to candidates.findIndex(c => !c.isArchived) (or undefined if all are archived).
    • In storyboard.ts:258 (selectedCandidate), return candidate?.isArchived ? undefined : candidate.
Suggested change
if (candidate.isArchived && scene.selectedCandidateIndex === index) {
scene.selectedCandidateIndex = undefined;
}
this.updateScenes();
if (candidate.isArchived && scene.selectedCandidateIndex === index) {
scene.selectedCandidateIndex = undefined;
this.isVideoPlaying.set(false);
this.videoDuration.set(0);
this.currentPlaybackTime.set(0);
}
this.updateScenes(scene);

Comment thread ui/src/app/storyboard/storyboard.ts Outdated
Comment on lines 1170 to 1178

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When candidates are created (remix-engine.ts:1724), newCandidate.referenceImage is initialized as a shallow clone of scene.referenceImage ({...params.referenceImage}). As a result, candidate.referenceImage?.path and candidate.referenceImage?.preview?.path share the exact same GCS object paths as:

  1. The scene's own scene.referenceImage (which remains active on the scene in the Right Sidebar and Top Filmstrip when a candidate is archived), and
  2. Other active (non-archived) candidates generated in the same run.

Unconditionally calling thumbnailCache.invalidateCandidate(projectId, path) for candidate.referenceImage?.path and candidate.referenceImage?.preview?.path deletes those entries from CacheStorage (scene-machine-thumbnails-v1) and bumps candidateVersions (media-cache-engine.ts:153-166) while scene.referenceImage and active sibling candidates are still using them. Filtering out paths that remain referenced by scene.referenceImage or any non-archived candidate in scene.candidates ensures only unreferenced thumbnails/previews are evicted.

Suggested change
const projectId = this.config.projectConfig.value().id;
const activePaths = new Set<string>();
if (scene.referenceImage?.path) {
activePaths.add(scene.referenceImage.path);
}
if (scene.referenceImage?.preview?.path) {
activePaths.add(scene.referenceImage.preview.path);
}
for (const c of scene.candidates) {
if (!c.isArchived) {
if (c.highQualityThumbnail?.path) {
activePaths.add(c.highQualityThumbnail.path);
}
if (c.referenceImage?.path) {
activePaths.add(c.referenceImage.path);
}
if (c.referenceImage?.preview?.path) {
activePaths.add(c.referenceImage.preview.path);
}
}
}
for (const path of [
candidate.highQualityThumbnail?.path,
candidate.referenceImage?.path,
candidate.referenceImage?.preview?.path,
]) {
if (path && !activePaths.has(path))
void this.thumbnailCache.invalidateCandidate(projectId, path);
}
}

@gps-readability-bot

Copy link
Copy Markdown

Still need readability approvals from:

@christophervoelpel
christophervoelpel force-pushed the fix/archived-candidate-never-renders branch from d51226f to 4397a7b Compare September 23, 2026 09:06
Comment on lines 115 to 152
private sceneRenderClips = computed(() =>
this.scenes().map(scene => ({
scene,
resolution: resolveSceneRenderClip(scene),
})),
this.scenes().map((scene, index, allScenes) => {
const resolution = resolveSceneRenderClip(scene);
const prevScene = index > 0 ? allScenes[index - 1] : undefined;
const prevNotReady =
prevScene !== undefined &&
resolveSceneRenderClip(prevScene).state !== 'ready';
const effectiveScene =
prevNotReady &&
(scene.transition !== undefined ||
scene.transitionOverlap !== undefined)
? {
...scene,
transition: undefined,
transitionOverlap: undefined,
}
: scene;
return {
scene: effectiveScene,
resolution,
};
}),
);

filmstripScenes = computed(() =>
this.sceneRenderClips()
.filter(({resolution}) => resolution.state === 'ready')
.map(({scene}) => scene),
.map(({scene}, index) =>
index === 0 &&
(scene.transition || scene.transitionOverlap !== undefined)
? {
...scene,
transition: undefined,
transitionOverlap: undefined,
}
: scene,
),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In sceneRenderClips, effectiveScene strips transition and transitionOverlap whenever the immediately preceding scene in this.scenes() (allScenes[index - 1]) is not 'ready' (prevNotReady === true).

However, filmstripScenes filters out non-ready scenes, and composition.html:269 (@if (i > 0)) still renders a clickable .transition-icon ((click)="openTransitionModal(i)") between every adjacent pair in filmstripScenes(). If the storyboard is [Scene 0 (ready), Scene 1 (empty / archived), Scene 2 (ready)]:

  1. filmstripScenes() renders [Scene 0, Scene 2] with a + transition button between them.
  2. Clicking + and saving a transition in onTransitionSelected (composition.ts:783-794) writes transition and transitionOverlap onto Scene 2 in projectConfig.
  3. sceneRenderClips immediately strips transition: undefined from Scene 2 because Scene 1 is 'not-selected', so the button in the UI remains + (add_circle_outline) as if Save failed, while the hidden transition remains persisted in Firestore and unexpectedly activates if Scene 1 later generates a candidate.

Exposing canTransitionFromPrev: index > 0 && !prevNotReady on filmstripScenes (and checking @if (i > 0 && scene.canTransitionFromPrev) in composition.html:269) keeps the Composition transition controls aligned with prevNotReady.

Suggested change
private sceneRenderClips = computed(() =>
this.scenes().map(scene => ({
scene,
resolution: resolveSceneRenderClip(scene),
})),
this.scenes().map((scene, index, allScenes) => {
const resolution = resolveSceneRenderClip(scene);
const prevScene = index > 0 ? allScenes[index - 1] : undefined;
const prevNotReady =
prevScene !== undefined &&
resolveSceneRenderClip(prevScene).state !== 'ready';
const effectiveScene =
prevNotReady &&
(scene.transition !== undefined ||
scene.transitionOverlap !== undefined)
? {
...scene,
transition: undefined,
transitionOverlap: undefined,
}
: scene;
return {
scene: effectiveScene,
resolution,
};
}),
);
filmstripScenes = computed(() =>
this.sceneRenderClips()
.filter(({resolution}) => resolution.state === 'ready')
.map(({scene}) => scene),
.map(({scene}, index) =>
index === 0 &&
(scene.transition || scene.transitionOverlap !== undefined)
? {
...scene,
transition: undefined,
transitionOverlap: undefined,
}
: scene,
),
);
private sceneRenderClips = computed(() =>
this.scenes().map((scene, index, allScenes) => {
const resolution = resolveSceneRenderClip(scene);
const prevScene = index > 0 ? allScenes[index - 1] : undefined;
const prevNotReady =
prevScene !== undefined &&
resolveSceneRenderClip(prevScene).state !== 'ready';
const canTransitionFromPrev = index > 0 && !prevNotReady;
const effectiveScene =
!canTransitionFromPrev &&
(scene.transition !== undefined ||
scene.transitionOverlap !== undefined)
? {
...scene,
transition: undefined,
transitionOverlap: undefined,
}
: scene;
return {
scene: effectiveScene,
resolution,
canTransitionFromPrev,
};
}),
);
filmstripScenes = computed(() =>
this.sceneRenderClips()
.filter(({resolution}) => resolution.state === 'ready')
.map(({scene, canTransitionFromPrev}, index) =>
index === 0 &&
(scene.transition || scene.transitionOverlap !== undefined)
? {
...scene,
transition: undefined,
transitionOverlap: undefined,
canTransitionFromPrev: false,
}
: {
...scene,
canTransitionFromPrev: index > 0 && canTransitionFromPrev,
},
),
);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 51d3b95 — exposed canTransitionFromPrev on filmstripScenes() and guarded the .transition-icon button in composition.html with @if (i > 0 && scene.canTransitionFromPrev).

Comment on lines +1280 to 1289
const projectId = this.config.projectConfig.value().id;
for (const path of [
candidate.highQualityThumbnail?.path,
candidate.referenceImage?.path,
candidate.referenceImage?.preview?.path,
]) {
if (path) {
void this.thumbnailCache.invalidateCandidate(projectId, path);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When multiple candidates are generated in the same run (numberOfCandidates > 1) or edited from an existing candidate (editCandidate), every candidate receives a shallow clone of the same referenceImage ({...params.referenceImage} in remix-engine.ts:1724).

When the user archives candidates[0] and line 1256 auto-selects candidates[1] via this.selectCandidate(scene, nextActiveIndex), scene.referenceImage and candidates[1].referenceImage still reference the exact same referenceImage.path and referenceImage.preview.path. Running thumbnailCache.invalidateCandidate(projectId, path) unconditionally at lines 1280–1289 deletes the cached blob from CacheStorage (scene-machine-thumbnails-v1) and bumps candidateVersions for the reference image that scene.referenceImage and candidates[1] are actively displaying.

Suggested change
const projectId = this.config.projectConfig.value().id;
for (const path of [
candidate.highQualityThumbnail?.path,
candidate.referenceImage?.path,
candidate.referenceImage?.preview?.path,
]) {
if (path) {
void this.thumbnailCache.invalidateCandidate(projectId, path);
}
}
const projectId = this.config.projectConfig.value().id;
const activePaths = new Set<string>();
if (scene.referenceImage?.path) {
activePaths.add(scene.referenceImage.path);
}
if (scene.referenceImage?.preview?.path) {
activePaths.add(scene.referenceImage.preview.path);
}
for (const c of scene.candidates) {
if (!c.isArchived) {
if (c.highQualityThumbnail?.path) {
activePaths.add(c.highQualityThumbnail.path);
}
if (c.referenceImage?.path) {
activePaths.add(c.referenceImage.path);
}
if (c.referenceImage?.preview?.path) {
activePaths.add(c.referenceImage.preview.path);
}
}
}
for (const path of [
candidate.highQualityThumbnail?.path,
candidate.referenceImage?.path,
candidate.referenceImage?.preview?.path,
]) {
if (path && !activePaths.has(path)) {
void this.thumbnailCache.invalidateCandidate(projectId, path);
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 51d3b95 — collected activePaths across scene.referenceImage and unarchived candidates (!c.isArchived) before invalidating thumbnail paths in toggleArchive.

Comment thread ui/src/app/services/config/config.ts Outdated
Comment on lines +1056 to +1075
if (scene.selectedCandidateIndex === undefined) {
return scene;
}
const selected = scene.candidates?.[scene.selectedCandidateIndex];
if (selected && !selected.isArchived) {
return scene;
}
const nextActiveIndex =
scene.candidates?.findIndex(c => !c.isArchived) ?? -1;
const updatedScene: GeneratedScene = {...scene};
if (nextActiveIndex >= 0 && scene.candidates) {
const activeCandidate = scene.candidates[nextActiveIndex];
updatedScene.selectedCandidateIndex = nextActiveIndex;
updatedScene.prompt = activeCandidate.prompt;
if (activeCandidate.referenceImage) {
updatedScene.referenceImage = activeCandidate.referenceImage;
} else {
delete updatedScene.referenceImage;
}
} else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Returning early whenever scene.selectedCandidateIndex === undefined (line 1056) skips normalization for scenes that already have scene.candidates (for example, projects saved when archiving the selected candidate set selectedCandidateIndex = undefined without selecting nextActiveIndex or clearing scene ingredients):

  • If scene.candidates contains an active (!c.isArchived) candidate, selectedCandidateIndex remains undefined (state: 'not-selected') and the loop at lines 1086–1099 deletes scene's and scene[i + 1]'s transitions.
  • If scene.candidates contains only archived candidates, stale prompt, referenceImage, lowQualityThumbnail, and highQualityThumbnail fields are left on scene.

Only scenes with no candidates ((scene.candidates?.length ?? 0) === 0, i.e., draft scenes before candidate generation) should return early when selectedCandidateIndex === undefined.

Suggested change
if (scene.selectedCandidateIndex === undefined) {
return scene;
}
const selected = scene.candidates?.[scene.selectedCandidateIndex];
if (selected && !selected.isArchived) {
return scene;
}
const nextActiveIndex =
scene.candidates?.findIndex(c => !c.isArchived) ?? -1;
const updatedScene: GeneratedScene = {...scene};
if (nextActiveIndex >= 0 && scene.candidates) {
const activeCandidate = scene.candidates[nextActiveIndex];
updatedScene.selectedCandidateIndex = nextActiveIndex;
updatedScene.prompt = activeCandidate.prompt;
if (activeCandidate.referenceImage) {
updatedScene.referenceImage = activeCandidate.referenceImage;
} else {
delete updatedScene.referenceImage;
}
} else {
const hasCandidates = (scene.candidates?.length ?? 0) > 0;
if (scene.selectedCandidateIndex === undefined && !hasCandidates) {
return scene;
}
const selected =
scene.selectedCandidateIndex !== undefined
? scene.candidates?.[scene.selectedCandidateIndex]
: undefined;
if (selected && !selected.isArchived) {
return scene;
}
const nextActiveIndex =
scene.candidates?.findIndex(c => !c.isArchived) ?? -1;
const updatedScene: GeneratedScene = {...scene};
if (nextActiveIndex >= 0 && scene.candidates) {
const activeCandidate = scene.candidates[nextActiveIndex];
updatedScene.selectedCandidateIndex = nextActiveIndex;
updatedScene.prompt = activeCandidate.prompt;
if (activeCandidate.referenceImage) {
updatedScene.referenceImage = activeCandidate.referenceImage;
} else {
delete updatedScene.referenceImage;
}
delete updatedScene.lowQualityThumbnail;
delete updatedScene.highQualityThumbnail;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 51d3b95 — normalizeLoadedProject now only returns early when selectedCandidateIndex === undefined && !hasCandidates, normalizing scenes that have candidates while clearing scene-level thumbnails when an active candidate is selected.

Comment on lines 510 to 517
thumbnailPersistForScene(
scene: GeneratedScene | ProvidedVideoScene,
): boolean {
if (!this.config.isGeneratedScene(scene)) return true;
const selected = scene.candidates?.[scene.selectedCandidateIndex ?? 0];
return !selected?.isArchived;
if (scene.selectedCandidateIndex === undefined) return false;
const selected = scene.candidates?.[scene.selectedCandidateIndex];
return !!selected && !selected.isArchived;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thumbnailPersistForScene(scene) is bound to [thumbnailImagePersist]="thumbnailPersistForScene(scene)" on the Right Sidebar Reference Image preview (storyboard.html:873).

Returning false unconditionally whenever scene.selectedCandidateIndex === undefined (line 514) disables CacheStorage persistence for every newly generated storyboard scene with a product referenceImage and every scene where a user uploads a reference image before generating video candidates. Because archiving or moving the last active candidate deletes scene.referenceImage, returning !!scene.referenceImage when selectedCandidateIndex === undefined keeps empty/all-archived scenes unpersisted while allowing active reference images on ungenerated scenes to use CacheStorage.

Suggested change
thumbnailPersistForScene(
scene: GeneratedScene | ProvidedVideoScene,
): boolean {
if (!this.config.isGeneratedScene(scene)) return true;
const selected = scene.candidates?.[scene.selectedCandidateIndex ?? 0];
return !selected?.isArchived;
if (scene.selectedCandidateIndex === undefined) return false;
const selected = scene.candidates?.[scene.selectedCandidateIndex];
return !!selected && !selected.isArchived;
}
thumbnailPersistForScene(
scene: GeneratedScene | ProvidedVideoScene,
): boolean {
if (!this.config.isGeneratedScene(scene)) return true;
if (scene.selectedCandidateIndex === undefined) {
return !!scene.referenceImage;
}
const selected = scene.candidates?.[scene.selectedCandidateIndex];
return !!selected && !selected.isArchived;
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 51d3b95 — thumbnailPersistForScene now returns !!scene.referenceImage when selectedCandidateIndex === undefined.

@gps-readability-bot

Copy link
Copy Markdown

Readability approvals granted:

Still need readability approvals from:

@christophervoelpel
christophervoelpel merged commit 1ef6fd8 into main Sep 23, 2026
13 checks passed
@christophervoelpel
christophervoelpel deleted the fix/archived-candidate-never-renders branch September 23, 2026 13:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants