From e72d24d3d835d3f68832685b280120c475056459 Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:28:22 +0000 Subject: [PATCH 01/11] Exclude archived candidates from the rendered video 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. --- .../config/archived-candidate-render.spec.ts | 382 ++++++++++++++++++ ui/src/app/services/config/config.ts | 8 + ui/src/app/storyboard/storyboard.ts | 7 + 3 files changed, 397 insertions(+) create mode 100644 ui/src/app/services/config/archived-candidate-render.spec.ts diff --git a/ui/src/app/services/config/archived-candidate-render.spec.ts b/ui/src/app/services/config/archived-candidate-render.spec.ts new file mode 100644 index 00000000..c1ebfe98 --- /dev/null +++ b/ui/src/app/services/config/archived-candidate-render.spec.ts @@ -0,0 +1,382 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Regression tests for the archived-candidate render leak. + * + * Archiving a candidate hides its card in the storyboard. Before the fix, the + * scene's `selectedCandidateIndex` still pointed at the archived candidate and + * `resolveSceneRenderClip` had no `isArchived` check, so the hidden clip was + * still submitted to the combine workflow and appeared in the final video. + * + * Two independent guards are asserted here, because either one alone would + * still leave a way in: + * 1. `toggleArchive` clears the selection (storyboard.ts). + * 2. `resolveSceneRenderClip` refuses an archived candidate (config.ts). + */ +import {HttpClient} from '@angular/common/http'; +import {signal, type WritableSignal} from '@angular/core'; +import {TestBed} from '@angular/core/testing'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {of} from 'rxjs'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {ClientMediaService} from '../client-media/client-media'; +import { + ConfigService, + resolveSceneRenderClip, + type GeneratedScene, +} from './config'; +import {MediaService} from '../media/media'; +import {RemixEngineService} from '../remix-engine/remix-engine'; +import {Router} from '@angular/router'; +import {Subject} from 'rxjs'; +import {MatDialog} from '@angular/material/dialog'; +import {CandidateVideoCacheService} from '../media/candidate-video-cache'; +import {ThumbnailCacheService} from '../media/thumbnail-cache'; +import {ImagePreviewService} from '../image-preview/image-preview'; +import {ImageImportService} from '../image-import/image-import'; +import {Storyboard} from '../../storyboard/storyboard'; + +/** A scene whose selected candidate is archived, but otherwise renderable. */ +function archivedSelectedScene(): GeneratedScene { + return { + id: 'scene-archived', + type: 'generated', + name: 'Archived but selected', + selectedCandidateIndex: 0, + candidates: [ + { + video: {path: 'videos/archived.mp4', url: ''}, + durationSeconds: 10, + isArchived: true, + }, + ], + } as any; +} + +describe('archived candidates are never rendered', () => { + it('resolveSceneRenderClip reports "not-selected" for an archived selected candidate', () => { + expect(resolveSceneRenderClip(archivedSelectedScene()).state).toBe( + 'not-selected', + ); + }); + + it('resolveSceneRenderClip still resolves an ACTIVE selected candidate', () => { + const scene = archivedSelectedScene(); + scene.candidates![0].isArchived = false; + const resolution = resolveSceneRenderClip(scene); + expect(resolution.state).toBe('ready'); + expect((resolution as any).clip.video.path).toBe('videos/archived.mp4'); + }); + + it('reports "not-selected" rather than "invalid" so the Render button stays enabled', () => { + // 'invalid' would disable rendering for the whole project; an archived + // candidate should only drop its own scene out of the playlist. + expect(resolveSceneRenderClip(archivedSelectedScene()).state).not.toBe( + 'invalid', + ); + }); + + describe('end-to-end through the combine workflow', () => { + let service: RemixEngineService; + let projectConfigSignal: WritableSignal; + let globalConfigSignal: WritableSignal; + let mediaServiceMock: any; + + beforeEach(() => { + vi.clearAllMocks(); + globalConfigSignal = signal({ + gcpProject: 'mock-project', + gcpLocation: 'mock-location', + gcsBucket: 'mock-bucket', + tasksQueuePrefix: 'mock-queue', + veoLocation: 'mock-veo-loc', + duration: 5, + veoModel: 'mock-veo', + numberOfCandidates: 2, + generateAudio: true, + }); + projectConfigSignal = signal({ + id: 'project-1', + resolution: '720p', + aspectRatio: '16:9', + numberOfCandidates: 2, + candidateDurationSeconds: 5, + generateAudio: true, + model: 'mock-veo', + storyboard: [], + }); + mediaServiceMock = { + signUrl: vi.fn(), + signUrls: vi.fn().mockResolvedValue(new Map()), + upload: vi.fn(), + getBlob: vi.fn(), + resolve: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + RemixEngineService, + { + provide: ConfigService, + useValue: { + globalConfig: {value: globalConfigSignal}, + projectConfig: {value: projectConfigSignal}, + isGeneratedScene: vi.fn((s: any) => s?.type === 'generated'), + isProvidedVideoScene: vi.fn((s: any) => s?.type === 'video'), + updateProjectConfig: vi.fn(), + addRenderRun: vi.fn(), + setPendingRender: vi.fn(), + flushPendingSave: vi.fn(), + videoEditModels: vi.fn().mockReturnValue([]), + canEditCandidates: vi.fn().mockReturnValue(false), + audioLocked: vi.fn().mockReturnValue(false), + resolveVideoLocation: vi.fn().mockReturnValue('mock-veo-loc'), + }, + }, + {provide: HttpClient, useValue: {post: vi.fn(), get: vi.fn()}}, + { + provide: ClientMediaService, + useValue: { + generateLowQualityThumbnail: vi.fn(), + generateHighQualityThumbnail: vi.fn(), + toBase64: vi.fn(), + toFile: vi.fn(), + }, + }, + {provide: MediaService, useValue: mediaServiceMock}, + {provide: MatSnackBar, useValue: {open: vi.fn()}}, + ], + }); + service = TestBed.inject(RemixEngineService); + TestBed.tick(); + }); + + it('does not submit an archived candidate to the combine workflow', async () => { + projectConfigSignal.set({ + ...projectConfigSignal(), + storyboard: [archivedSelectedScene()], + audioTracks: [], + visualOverlays: [], + }); + const startSpy = vi + .spyOn(service, 'startCombineScenesWorkflow') + .mockResolvedValue(of({executionId: 'render-exec-id'}) as any); + + await service.combineScenes(); + + expect(startSpy).not.toHaveBeenCalled(); + }); + + it('still submits an ACTIVE candidate', async () => { + const scene = archivedSelectedScene(); + scene.candidates![0].isArchived = false; + projectConfigSignal.set({ + ...projectConfigSignal(), + storyboard: [scene], + audioTracks: [], + visualOverlays: [], + }); + const startSpy = vi + .spyOn(service, 'startCombineScenesWorkflow') + .mockResolvedValue(of({executionId: 'render-exec-id'}) as any); + vi.spyOn(service, 'pollWorkflow').mockResolvedValue({ + sink: {output: {'0': {video: [{file: 'renders/output.mp4'}]}}}, + } as any); + mediaServiceMock.signUrl.mockResolvedValue( + 'https://signed.example/renders/output.mp4', + ); + + await service.combineScenes(); + + expect(startSpy).toHaveBeenCalledWith( + [ + { + file_type: 'video', + file_path: 'videos/archived.mp4', + start_time: 0, + skip_time: 0, + duration: 10, + include_audio: true, + }, + ], + false, + ); + }); + }); + + describe('toggleArchive', () => { + let storyboardComponent: Storyboard; + let projectConfigSignal: WritableSignal; + let candidateVideoCacheMock: any; + let thumbnailCacheMock: any; + + beforeEach(async () => { + vi.clearAllMocks(); + projectConfigSignal = signal({ + id: 'project-1', + storyboard: [], + }); + candidateVideoCacheMock = { + invalidateCandidate: vi.fn(), + }; + thumbnailCacheMock = { + invalidateCandidate: vi.fn(), + acquire: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [Storyboard], + providers: [ + { + provide: ConfigService, + useValue: { + projectConfig: {value: projectConfigSignal}, + globalConfig: {value: () => ({})}, + updateProjectConfig: vi.fn((partial: any) => { + projectConfigSignal.update(c => ({...c, ...partial})); + }), + isGeneratedScene: (s: any) => s?.type === 'generated', + isProvidedVideoScene: (s: any) => s?.type === 'video', + }, + }, + { + provide: RemixEngineService, + useValue: { + setForegroundScene: vi.fn(), + clearForegroundScene: vi.fn(), + }, + }, + {provide: MatDialog, useValue: {open: vi.fn()}}, + {provide: ClientMediaService, useValue: {}}, + {provide: ImagePreviewService, useValue: {create: vi.fn()}}, + {provide: ImageImportService, useValue: {}}, + {provide: MatSnackBar, useValue: {open: vi.fn()}}, + {provide: HttpClient, useValue: {}}, + { + provide: MediaService, + useValue: {resolve: vi.fn(), getCachedUrl: vi.fn()}, + }, + { + provide: CandidateVideoCacheService, + useValue: candidateVideoCacheMock, + }, + {provide: ThumbnailCacheService, useValue: thumbnailCacheMock}, + {provide: Router, useValue: {events: new Subject()}}, + ], + }) + .overrideComponent(Storyboard, { + set: {template: ''}, + }) + .compileComponents(); + + const fixture = TestBed.createComponent(Storyboard); + storyboardComponent = fixture.componentInstance; + }); + + it('clears selectedCandidateIndex when the selected candidate is archived', () => { + const scene: GeneratedScene = { + id: 'scene-1', + type: 'generated', + name: 'Scene 1', + prompt: 'test prompt', + selectedCandidateIndex: 0, + candidates: [ + { + video: {path: 'videos/c0.mp4', url: ''}, + durationSeconds: 5, + isArchived: false, + }, + { + video: {path: 'videos/c1.mp4', url: ''}, + durationSeconds: 5, + isArchived: false, + }, + ] as any, + }; + projectConfigSignal.set({ + id: 'project-1', + storyboard: [scene], + }); + + storyboardComponent.toggleArchive(new Event('click'), scene, 0); + + expect(scene.candidates![0].isArchived).toBe(true); + expect(scene.selectedCandidateIndex).toBeUndefined(); + }); + + it('leaves selectedCandidateIndex alone when a DIFFERENT candidate is archived (control)', () => { + const scene: GeneratedScene = { + id: 'scene-1', + type: 'generated', + name: 'Scene 1', + prompt: 'test prompt', + selectedCandidateIndex: 0, + candidates: [ + { + video: {path: 'videos/c0.mp4', url: ''}, + durationSeconds: 5, + isArchived: false, + }, + { + video: {path: 'videos/c1.mp4', url: ''}, + durationSeconds: 5, + isArchived: false, + }, + ] as any, + }; + projectConfigSignal.set({ + id: 'project-1', + storyboard: [scene], + }); + + storyboardComponent.toggleArchive(new Event('click'), scene, 1); + + expect(scene.candidates![1].isArchived).toBe(true); + expect(scene.selectedCandidateIndex).toBe(0); + }); + + it('evicts video, thumbnail, referenceImage, and preview path from caches on archive', () => { + const scene: GeneratedScene = { + id: 'scene-1', + type: 'generated', + name: 'Scene 1', + prompt: 'test prompt', + selectedCandidateIndex: 0, + candidates: [ + { + video: {path: 'videos/c0.mp4', url: ''}, + highQualityThumbnail: {path: 'thumbs/hq0.jpg', url: ''}, + referenceImage: { + path: 'refs/ref0.jpg', + url: '', + preview: {path: 'previews/prev0.jpg', url: ''}, + }, + durationSeconds: 5, + isArchived: false, + }, + ] as any, + }; + projectConfigSignal.set({ + id: 'project-1', + storyboard: [scene], + }); + + storyboardComponent.toggleArchive(new Event('click'), scene, 0); + + expect(candidateVideoCacheMock.invalidateCandidate).toHaveBeenCalledWith( + 'project-1', + 'videos/c0.mp4', + ); + expect(thumbnailCacheMock.invalidateCandidate).toHaveBeenCalledWith( + 'project-1', + 'thumbs/hq0.jpg', + ); + expect(thumbnailCacheMock.invalidateCandidate).toHaveBeenCalledWith( + 'project-1', + 'refs/ref0.jpg', + ); + expect(thumbnailCacheMock.invalidateCandidate).toHaveBeenCalledWith( + 'project-1', + 'previews/prev0.jpg', + ); + }); + }); +}); diff --git a/ui/src/app/services/config/config.ts b/ui/src/app/services/config/config.ts index 379f2015..3f9e98d9 100644 --- a/ui/src/app/services/config/config.ts +++ b/ui/src/app/services/config/config.ts @@ -464,6 +464,14 @@ export function resolveSceneRenderClip( if (!candidate) { return {state: 'invalid'}; } + // An archived candidate must never reach the render workflow, even while + // it is still the selected index. Archiving removes the card from the + // storyboard, so reporting 'not-selected' keeps the rendered output + // consistent with what the user can still see. 'invalid' would be wrong + // here: that state disables the Render button for the whole project. + if (candidate.isArchived) { + return {state: 'not-selected'}; + } video = candidate.video; sourceDuration = candidate.durationSeconds; trim = candidate.trim; diff --git a/ui/src/app/storyboard/storyboard.ts b/ui/src/app/storyboard/storyboard.ts index e9ae97b0..7c43caf3 100644 --- a/ui/src/app/storyboard/storyboard.ts +++ b/ui/src/app/storyboard/storyboard.ts @@ -1150,6 +1150,12 @@ export class Storyboard { if (scene.candidates && scene.candidates[index]) { const candidate = scene.candidates[index]; candidate.isArchived = !candidate.isArchived; + // Archiving the candidate that is currently selected must also clear the + // selection. Leaving the index in place keeps the archived clip in the + // composition playlist and renders it into the final video. + if (candidate.isArchived && scene.selectedCandidateIndex === index) { + scene.selectedCandidateIndex = undefined; + } this.updateScenes(); if (candidate.isArchived && candidate.video?.path) { void this.candidateVideoCache.invalidateCandidate( @@ -1162,6 +1168,7 @@ export class Storyboard { for (const path of [ candidate.highQualityThumbnail?.path, candidate.referenceImage?.path, + candidate.referenceImage?.preview?.path, ]) { if (path) void this.thumbnailCache.invalidateCandidate(projectId, path); From b1972ffe1dfa8b439c93569bfc1fca236d3e8232 Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:39:30 +0000 Subject: [PATCH 02/11] Guard setup inputs on the real project load error and hydration state The rxResource loader catches HTTP load errors and records them in projectLoadErrorValue rather than re-throwing, causing projectConfig.error() to remain permanently undefined. Consequently, the !projectConfig.error() conjunct in setupInputsLoaded was a dead guard. Switch the check to !this.projectLoadError(), guarding setup input readiness on the actual error signal. Additionally, add an explicit setupInputsHydrated signal tracking whether the full project input config was actually hydrated by a successful full GET. This closes the narrow data-loss path where a spurious 404 on Setup navigation with an unsettled editor save returns the local project without inputConfig; with setupInputsHydrated gating setupInputsLoaded, Setup will not prematurely synthesize a blank inputConfig or issue a destructive full-replacement PATCH /api/projects/:id. Cover with regression tests in config-mediated.spec.ts and setup.spec.ts asserting setupInputsLoaded is false on load errors and in the 404 unsettled-save edge case, and verifying that no full-replacement PATCH is issued. --- .../services/config/config-mediated.spec.ts | 125 ++++++++++++++++++ ui/src/app/services/config/config.ts | 30 ++++- ui/src/app/setup/setup.spec.ts | 46 +++++++ 3 files changed, 195 insertions(+), 6 deletions(-) diff --git a/ui/src/app/services/config/config-mediated.spec.ts b/ui/src/app/services/config/config-mediated.spec.ts index 41130736..3f62d09f 100644 --- a/ui/src/app/services/config/config-mediated.spec.ts +++ b/ui/src/app/services/config/config-mediated.spec.ts @@ -1075,6 +1075,131 @@ describe('ConfigService (mediated data plane)', () => { errorSpy.mockRestore(); }); + it('leaves projectConfig.error() undefined after load failure, setting projectLoadError instead', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + return throwError(() => new HttpErrorResponse({status: 500})); + }); + + service.loadProjectConfig('proj-err', 'full'); + await vi.waitFor(() => { + expect(service.projectLoadError()).toBe(true); + }); + + // rxResource catches the error and degrades to DEFAULT_PROJECT_CONFIG, + // so projectConfig.error() is permanently undefined. + expect(service.projectConfig.error()).toBeUndefined(); + expect(service.setupInputsError()).toBe(true); + expect(service.setupInputsLoaded()).toBe(false); + errorSpy.mockRestore(); + }); + + it('evaluates setupInputsLoaded to false when projectLoadError is set even if id matches', () => { + service.projectConfig.value.set({ + ...service.projectConfig.value(), + id: 'proj-matched', + }); + service.loadProjectConfig('proj-matched', 'full'); + expect(service.setupInputsLoaded()).toBe(true); + + // When a load error is recorded, setupInputsLoaded must be false even though + // value().id === projectId() is true. + (service as any).projectLoadErrorValue.set(new Error('load failed')); + expect(service.projectLoadError()).toBe(true); + expect(service.setupInputsLoaded()).toBe(false); + }); + + it('evaluates setupInputsLoaded to false on 404 with in-flight autosave to prevent destructive full PATCH', async () => { + const editorProject = { + ...service.projectConfig.value(), + id: 'proj-hydrate', + inputConfig: undefined, + }; + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + if (url.includes('/api/projects/proj-hydrate')) { + if (url.endsWith('?view=editor')) { + return of(editorProject); + } + return throwError(() => new HttpErrorResponse({status: 404})); + } + return of({}); + }); + + service.loadProjectConfig('proj-hydrate', 'editor'); + await vi.waitFor(() => { + expect(service.projectConfig.value().id).toBe('proj-hydrate'); + }); + markPersisted('proj-hydrate'); + + const inFlightPatch = new Subject(); + httpClientMock.patch.mockReturnValue(inFlightPatch); + service.updateProjectConfig({name: 'in-flight'}); + service.saveNow(); + + service.loadProjectConfig('proj-hydrate', 'full'); + await vi.waitFor(() => { + expect(httpClientMock.get).toHaveBeenCalledWith( + '/api/projects/proj-hydrate', + ); + }); + await Promise.resolve(); + await Promise.resolve(); + + // On 404 with localProjectAtLoad, the loader preserves local latestSource so the user + // is not disrupted, but setupInputsLoaded MUST be false because full server inputs were not hydrated. + expect(service.setupInputsLoaded()).toBe(false); + expect(service.projectConfig.value().id).toBe('proj-hydrate'); + expect(service.projectConfig.value().inputConfig).toBeUndefined(); + expect(httpClientMock.patch).not.toHaveBeenCalledWith( + '/api/projects/proj-hydrate', + expect.anything(), + ); + }); + + it('navigates home and resets project on 404 without local copy', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + return throwError(() => new HttpErrorResponse({status: 404})); + }); + + service.loadProjectConfig('non-existent', 'full'); + await vi.waitFor(() => { + expect(httpClientMock.get).toHaveBeenCalledWith( + '/api/projects/non-existent', + ); + }); + await Promise.resolve(); + await Promise.resolve(); + + expect(service.setupInputsLoaded()).toBe(false); + expect(service.projectConfig.value().id).toBe(''); + errorSpy.mockRestore(); + }); + + it('yields setupInputsLoaded === true on normal successful load', async () => { + const fullProject = { + ...service.projectConfig.value(), + id: 'proj-ok', + inputConfig: {products: [], composition: 'ok'}, + }; + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + if (url === '/api/projects/proj-ok') return of(fullProject); + return of({}); + }); + + service.loadProjectConfig('proj-ok', 'full'); + await vi.waitFor(() => { + expect(service.projectConfig.value().id).toBe('proj-ok'); + }); + + expect(service.projectLoadError()).toBe(false); + expect(service.setupInputsLoaded()).toBe(true); + }); + it('keeps a locally created full project ready after leaving another project', () => { service.projectConfig.value.set({ ...service.projectConfig.value(), diff --git a/ui/src/app/services/config/config.ts b/ui/src/app/services/config/config.ts index 3f9e98d9..64fc7430 100644 --- a/ui/src/app/services/config/config.ts +++ b/ui/src/app/services/config/config.ts @@ -636,6 +636,7 @@ export class ConfigService { private projectId = signal(null); private projectView = signal('full'); private projectLoadErrorValue = signal(undefined); + private setupInputsHydrated = signal(false); /** * Mediated mode only: ids known to exist server-side (loaded via GET or * already POSTed). First save of a new project goes through @@ -938,6 +939,7 @@ export class ConfigService { loader: async ({params, abortSignal}) => { if (params.projectId === null) { this.projectLoadErrorValue.set(undefined); + this.setupInputsHydrated.set(false); return {...this.DEFAULT_PROJECT_CONFIG()}; } const isCurrentLoad = () => @@ -946,6 +948,7 @@ export class ConfigService { this.projectView() === params.view; if (isCurrentLoad()) { this.projectLoadErrorValue.set(undefined); + this.setupInputsHydrated.set(false); } const localProjectAtLoad = this.projectWithUnsettledSave( params.projectId, @@ -959,6 +962,9 @@ export class ConfigService { if (isCurrentLoad()) { this.projectLoadErrorValue.set(undefined); this.persistedProjectIds.add(params.projectId); + if (params.view === 'full') { + this.setupInputsHydrated.set(true); + } } if (localProjectAtLoad) { const latest = @@ -1015,14 +1021,24 @@ export class ConfigService { readonly setupInputsError = computed( () => this.projectView() === 'full' && this.projectLoadError(), ); - readonly setupInputsLoaded = computed( - () => + /** + * Setup may expose its input fields only when the current project's + * inputConfig came from a successful full server load, or the project was + * created locally. + */ + readonly setupInputsLoaded = computed(() => { + const isLocalNewProject = + this.projectId() === null && !!this.projectConfig.value().id; + const isHydratedServerProject = + this.setupInputsHydrated() && + this.projectConfig.value().id === this.projectId(); + return ( this.projectView() === 'full' && !this.projectConfig.isLoading() && - !this.projectConfig.error() && - (this.projectConfig.value().id === this.projectId() || - (this.projectId() === null && !!this.projectConfig.value().id)), - ); + !this.projectLoadError() && + (isLocalNewProject || isHydratedServerProject) + ); + }); private normalizeLoadedProject(data: ProjectConfig): ProjectConfig { if (data.renderRuns) { @@ -1427,6 +1443,7 @@ export class ConfigService { // would otherwise be silently dropped once the config resets (the // post-reset emission has id === '' / shouldSave === false). this.flushPendingSave(); + this.setupInputsHydrated.set(false); this.projectId.set(null); this.projectConfig.set({...this.DEFAULT_PROJECT_CONFIG()}); this.shouldSave = false; @@ -1446,6 +1463,7 @@ export class ConfigService { // Not persisted yet: the first autosave POSTs /api/projects, where the // server stamps createdBy from the verified identity. Left undefined here. this.persistedProjectIds.delete(uuid); + this.setupInputsHydrated.set(true); const project = { ...this.DEFAULT_PROJECT_CONFIG(), id: uuid, diff --git a/ui/src/app/setup/setup.spec.ts b/ui/src/app/setup/setup.spec.ts index 5ee3f4e5..804acf23 100644 --- a/ui/src/app/setup/setup.spec.ts +++ b/ui/src/app/setup/setup.spec.ts @@ -123,6 +123,52 @@ describe('Setup full-load failure', () => { fixture.nativeElement.querySelector('.setup-container'), ).not.toBeNull(); }); + + it('does not synthesize blank inputConfig or issue full PATCH on 404 with unsettled save', async () => { + ( + config as unknown as {persistedProjectIds: Set} + ).persistedProjectIds.add('proj-unsettled'); + + config.loadProjectConfig('proj-unsettled', 'editor'); + TestBed.tick(); + const editorReq = http.expectOne( + '/api/projects/proj-unsettled?view=editor', + ); + editorReq.flush({ + id: 'proj-unsettled', + name: 'Editor Project', + aspectRatio: '16:9', + resolution: '720p', + candidateDurationSeconds: 4, + generateAudio: false, + numberOfCandidates: 1, + model: 'veo-default', + inputConfig: undefined, + storyboard: [], + audioTracks: [], + visualOverlays: [], + }); + TestBed.tick(); + await fixture.whenStable(); + + config.updateProjectConfig({name: 'In-Flight'}); + config.saveNow(); + const inFlightPatch = http.expectOne('/api/projects/proj-unsettled/editor'); + + config.loadProjectConfig('proj-unsettled', 'full'); + TestBed.tick(); + const fullGet = http.expectOne('/api/projects/proj-unsettled'); + fullGet.flush('Not found', {status: 404, statusText: 'Not Found'}); + TestBed.tick(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(config.setupInputsLoaded()).toBe(false); + expect(config.projectConfig.value().inputConfig).toBeUndefined(); + http.expectNone('/api/projects/proj-unsettled'); + + inFlightPatch.flush({}); + }); }); describe('Setup image upload', () => { From 2dec9bee5c324856cbb1381b6c227e4d9fceada9 Mon Sep 17 00:00:00 2001 From: Christopher Voelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:52:42 +0200 Subject: [PATCH 03/11] Offer Setup recovery when full inputs cannot be hydrated --- .../services/config/config-mediated.spec.ts | 7 ++-- ui/src/app/services/config/config.ts | 8 +++- ui/src/app/setup/setup.html | 1 + ui/src/app/setup/setup.spec.ts | 37 ++++++++++++++++++- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/ui/src/app/services/config/config-mediated.spec.ts b/ui/src/app/services/config/config-mediated.spec.ts index 3f62d09f..5ab4f6ef 100644 --- a/ui/src/app/services/config/config-mediated.spec.ts +++ b/ui/src/app/services/config/config-mediated.spec.ts @@ -1152,10 +1152,8 @@ describe('ConfigService (mediated data plane)', () => { expect(service.setupInputsLoaded()).toBe(false); expect(service.projectConfig.value().id).toBe('proj-hydrate'); expect(service.projectConfig.value().inputConfig).toBeUndefined(); - expect(httpClientMock.patch).not.toHaveBeenCalledWith( - '/api/projects/proj-hydrate', - expect.anything(), - ); + expect(service.setupInputsError()).toBe(true); + expect(service.projectLoadError()).toBe(false); }); it('navigates home and resets project on 404 without local copy', async () => { @@ -1176,6 +1174,7 @@ describe('ConfigService (mediated data plane)', () => { expect(service.setupInputsLoaded()).toBe(false); expect(service.projectConfig.value().id).toBe(''); + expect(TestBed.inject(Router).navigate).toHaveBeenCalledWith(['/']); errorSpy.mockRestore(); }); diff --git a/ui/src/app/services/config/config.ts b/ui/src/app/services/config/config.ts index 64fc7430..1cde01a8 100644 --- a/ui/src/app/services/config/config.ts +++ b/ui/src/app/services/config/config.ts @@ -1019,7 +1019,13 @@ export class ConfigService { (!!this.projectLoadErrorValue() || !!this.projectConfig.error()), ); readonly setupInputsError = computed( - () => this.projectView() === 'full' && this.projectLoadError(), + () => + this.projectView() === 'full' && + (this.projectLoadError() || + (this.projectId() !== null && + !this.projectConfig.isLoading() && + this.projectConfig.value().id === this.projectId() && + !this.setupInputsHydrated())), ); /** * Setup may expose its input fields only when the current project's diff --git a/ui/src/app/setup/setup.html b/ui/src/app/setup/setup.html index 77337ce9..b22d94c9 100644 --- a/ui/src/app/setup/setup.html +++ b/ui/src/app/setup/setup.html @@ -20,6 +20,7 @@ + Back to projects } @else if (setupLoading() || !setupReady()) {
diff --git a/ui/src/app/setup/setup.spec.ts b/ui/src/app/setup/setup.spec.ts index 804acf23..e2e72f9e 100644 --- a/ui/src/app/setup/setup.spec.ts +++ b/ui/src/app/setup/setup.spec.ts @@ -124,7 +124,23 @@ describe('Setup full-load failure', () => { ).not.toBeNull(); }); - it('does not synthesize blank inputConfig or issue full PATCH on 404 with unsettled save', async () => { + it('does not show an error before a project load or while the load is pending', () => { + expect(config.setupInputsError()).toBe(false); + expect(fixture.nativeElement.textContent).not.toContain( + 'Could not load this project', + ); + config.loadProjectConfig('pending-project', 'full'); + expect(config.setupInputsError()).toBe(false); + TestBed.tick(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('mat-spinner')).not.toBeNull(); + expect(config.setupInputsError()).toBe(false); + http + .expectOne('/api/projects/pending-project') + .flush('failed', {status: 500, statusText: 'Server Error'}); + }); + + it('offers recovery without blank inputs or full PATCH after both load and pending save return 404', async () => { ( config as unknown as {persistedProjectIds: Set} ).persistedProjectIds.add('proj-unsettled'); @@ -156,7 +172,9 @@ describe('Setup full-load failure', () => { const inFlightPatch = http.expectOne('/api/projects/proj-unsettled/editor'); config.loadProjectConfig('proj-unsettled', 'full'); + expect(config.setupInputsError()).toBe(false); TestBed.tick(); + expect(config.setupInputsError()).toBe(false); const fullGet = http.expectOne('/api/projects/proj-unsettled'); fullGet.flush('Not found', {status: 404, statusText: 'Not Found'}); TestBed.tick(); @@ -167,7 +185,22 @@ describe('Setup full-load failure', () => { expect(config.projectConfig.value().inputConfig).toBeUndefined(); http.expectNone('/api/projects/proj-unsettled'); - inFlightPatch.flush({}); + inFlightPatch.flush('Not found', {status: 404, statusText: 'Not Found'}); + TestBed.tick(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(config.projectLoadError()).toBe(false); + expect(config.setupInputsError()).toBe(true); + expect(fixture.nativeElement.querySelector('mat-spinner')).toBeNull(); + expect(fixture.nativeElement.textContent).toContain( + 'Could not load this project', + ); + const home = fixture.nativeElement.querySelector('a[href="/"]'); + expect(home?.textContent).toContain('Back to projects'); + expect(fixture.nativeElement.querySelector('.setup-container')).toBeNull(); + expect(config.projectConfig.value().inputConfig).toBeUndefined(); + http.expectNone('/api/projects/proj-unsettled'); }); }); From d301198990da0b9cc67f9d83df779ddb51b85d4e Mon Sep 17 00:00:00 2001 From: Christopher Voelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Mon, 21 Sep 2026 22:55:25 +0200 Subject: [PATCH 04/11] Prevent selection of archived candidates --- ui/src/app/storyboard/storyboard.html | 2 - ui/src/app/storyboard/storyboard.scss | 6 +++ ui/src/app/storyboard/storyboard.spec.ts | 60 ++++++++++++++++++++++++ ui/src/app/storyboard/storyboard.ts | 5 +- 4 files changed, 70 insertions(+), 3 deletions(-) diff --git a/ui/src/app/storyboard/storyboard.html b/ui/src/app/storyboard/storyboard.html index 5548d3d6..affa9dbf 100644 --- a/ui/src/app/storyboard/storyboard.html +++ b/ui/src/app/storyboard/storyboard.html @@ -432,8 +432,6 @@ @if (candidate.isArchived) {
{ expect(image.src).toContain('blob:candidate-b.jpg'); }); + it('ignores archived candidates without changing or persisting the scene', () => { + const activeReference = {path: 'active-reference.png', url: 'active-url'}; + const archivedReference = { + path: 'archived-reference.png', + url: 'archived-url', + }; + const scene: GeneratedScene = { + id: 'archived-selection-scene', + type: 'generated', + name: 'Archived selection scene', + prompt: 'active prompt', + referenceImage: activeReference, + selectedCandidateIndex: 0, + candidates: [ + { + runNumber: 1, + durationSeconds: 4, + model: 'veo-1', + prompt: 'active prompt', + generateAudio: false, + resolution: '1080p', + video: {path: 'active.mp4', url: 'active.mp4'}, + referenceImage: activeReference, + }, + { + runNumber: 2, + durationSeconds: 4, + model: 'veo-1', + prompt: 'archived prompt', + generateAudio: false, + resolution: '1080p', + video: {path: 'archived.mp4', url: 'archived.mp4'}, + referenceImage: archivedReference, + isArchived: true, + }, + ], + }; + projectConfigSignal.update(config => ({...config, storyboard: [scene]})); + const updateProjectConfig = vi.spyOn( + mockConfigService, + 'updateProjectConfig', + ); + + component.selectCandidate(scene, 1); + + expect(scene.selectedCandidateIndex).toBe(0); + expect(scene.prompt).toBe('active prompt'); + expect(scene.referenceImage).toBe(activeReference); + expect(updateProjectConfig).not.toHaveBeenCalled(); + }); + it('uses a persisted reference preview for filmstrip fallback', () => { const referenceImage = { path: 'source.png', @@ -1701,6 +1752,10 @@ describe('Storyboard', () => { expect( fixture.nativeElement.querySelectorAll('.archived-panel .video-item'), ).toHaveLength(1); + const archivedItem = fixture.nativeElement.querySelector( + '.archived-panel .video-item', + ) as HTMLElement; + expect(archivedItem.classList.contains('selected')).toBe(false); expect( fixture.nativeElement.querySelectorAll('.video-info mat-icon'), ).toHaveLength(0); @@ -2306,6 +2361,11 @@ describe('Storyboard', () => { ]); expect(buttons.every(button => !button.disabled)).toBe(true); + const restoreButton = fixture.nativeElement.querySelector( + '.archived-panel .archive-btn', + ) as HTMLButtonElement; + expect(restoreButton.disabled).toBe(false); + mockRemixEngineService.generatingSceneIds.set(new Set(['1'])); fixture.detectChanges(); buttons = Array.from( diff --git a/ui/src/app/storyboard/storyboard.ts b/ui/src/app/storyboard/storyboard.ts index 7c43caf3..e2d7d9bc 100644 --- a/ui/src/app/storyboard/storyboard.ts +++ b/ui/src/app/storyboard/storyboard.ts @@ -972,7 +972,10 @@ export class Storyboard { } selectCandidate(scene: GeneratedScene, index: number) { - const candidate = scene.candidates![index]; + const candidate = scene.candidates?.[index]; + if (!candidate || candidate.isArchived) { + return; + } const promptChanged = scene.prompt !== candidate.prompt; this.referenceUploadEpoch++; scene.selectedCandidateIndex = index; From 55eba39935306842db6226b311aa0d656b947bc8 Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:46:39 +0000 Subject: [PATCH 05/11] Add Apache 2.0 license header to archived-candidate-render.spec.ts --- .../config/archived-candidate-render.spec.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ui/src/app/services/config/archived-candidate-render.spec.ts b/ui/src/app/services/config/archived-candidate-render.spec.ts index c1ebfe98..87e0e512 100644 --- a/ui/src/app/services/config/archived-candidate-render.spec.ts +++ b/ui/src/app/services/config/archived-candidate-render.spec.ts @@ -1,3 +1,19 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + /* eslint-disable @typescript-eslint/no-explicit-any */ /** * Regression tests for the archived-candidate render leak. From 4397a7b6b67af4307c3029df8f29fc31abea87bb Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Wed, 23 Sep 2026 09:06:14 +0000 Subject: [PATCH 06/11] Align Storyboard, Homepage, and candidate generation with archived-candidate UX --- orch.py | 22 ++-- test/test_frontdoor_data.py | 16 +-- ui/src/app/homepage/homepage.spec.ts | 13 +- ui/src/app/homepage/homepage.ts | 35 ++--- .../config/archived-candidate-render.spec.ts | 58 ++++++++- ui/src/app/services/config/config.ts | 34 +++++ .../remix-engine-mediated.spec.ts | 6 +- .../app/services/remix-engine/remix-engine.ts | 53 ++++++-- ui/src/app/storyboard/storyboard.html | 53 +++----- ui/src/app/storyboard/storyboard.ts | 122 +++++++++++++++--- 10 files changed, 301 insertions(+), 111 deletions(-) diff --git a/orch.py b/orch.py index 58ee33d4..6ba89fe9 100644 --- a/orch.py +++ b/orch.py @@ -1275,13 +1275,11 @@ def _read_project_list_docs( thumbnail = {} thumbnail_persist = True if isinstance(scene, dict): - low_quality = scene.get('lowQualityThumbnail') - high_quality = scene.get('highQualityThumbnail') + low_quality = None + high_quality = None if scene.get('type') == 'generated': candidates = scene.get('candidates') selected_index = scene.get('selectedCandidateIndex') - if selected_index is None: - selected_index = 0 candidate = None if ( isinstance(candidates, list) @@ -1289,17 +1287,17 @@ def _read_project_list_docs( and not isinstance(selected_index, bool) and 0 <= selected_index < len(candidates) and isinstance(candidates[selected_index], dict) + and not bool(candidates[selected_index].get('isArchived')) ): candidate = candidates[selected_index] if candidate is not None: - low_quality = candidate.get('lowQualityThumbnail') or low_quality - high_quality = ( - candidate.get('highQualityThumbnail') or high_quality - ) - thumbnail_persist = not bool(candidate.get('isArchived')) - reference_image = scene.get('referenceImage') - if reference_image is not None: - thumbnail['referenceImage'] = reference_image + low_quality = candidate.get('lowQualityThumbnail') + high_quality = candidate.get('highQualityThumbnail') + else: + thumbnail_persist = False + else: + low_quality = scene.get('lowQualityThumbnail') + high_quality = scene.get('highQualityThumbnail') if low_quality is not None: thumbnail['lowQualityThumbnail'] = low_quality if high_quality is not None: diff --git a/test/test_frontdoor_data.py b/test/test_frontdoor_data.py index ce74d55b..a66a88c8 100644 --- a/test/test_frontdoor_data.py +++ b/test/test_frontdoor_data.py @@ -1200,8 +1200,8 @@ def test_project_list_returns_page_summary_and_selects_only_card_root_fields( 'candidates': [ {'prompt': 'hidden zero', 'lowQualityThumbnail': 'zero.jpg'}, { - 'prompt': 'hidden one', - 'isArchived': True, + 'prompt': 'active one', + 'isArchived': False, 'lowQualityThumbnail': 'selected-low.jpg', 'highQualityThumbnail': {'path': 'selected-high.jpg'}, }, @@ -1221,9 +1221,8 @@ def test_project_list_returns_page_summary_and_selects_only_card_root_fields( 'thumbnail': { 'lowQualityThumbnail': 'selected-low.jpg', 'highQualityThumbnail': {'path': 'selected-high.jpg'}, - 'referenceImage': {'path': 'reference.png'}, }, - 'thumbnailPersist': False, + 'thumbnailPersist': True, }] assert fake_db.select_calls == [[ 'id', 'name', 'lastEdited', 'createdBy', 'aspectRatio' @@ -1265,7 +1264,7 @@ def test_project_summary_keeps_provided_image_material_and_empty_projects( assert by_id['empty-summary']['thumbnailPersist'] is True -def test_project_summary_uses_first_candidate_when_selected_index_is_null( +def test_project_summary_omits_thumbnail_when_unselected_or_archived( monkeypatch, orchestrator_module ): del orchestrator_module @@ -1278,7 +1277,9 @@ def test_project_summary_uses_first_candidate_when_selected_index_is_null( 'storyboard': [{ 'type': 'generated', 'selectedCandidateIndex': None, + 'referenceImage': {'path': 'reference.png'}, 'candidates': [{ + 'isArchived': True, 'lowQualityThumbnail': 'first-candidate.jpg', }], }], @@ -1287,9 +1288,8 @@ def test_project_summary_uses_first_candidate_when_selected_index_is_null( summary = client.get('/api/projects').get_json()['projects'][0] - assert summary['thumbnail'] == { - 'lowQualityThumbnail': 'first-candidate.jpg' - } + assert summary['thumbnail'] == {} + assert summary['thumbnailPersist'] is False def test_project_list_keeps_project_with_missing_first_scene( diff --git a/ui/src/app/homepage/homepage.spec.ts b/ui/src/app/homepage/homepage.spec.ts index 4b60593a..25efcd10 100644 --- a/ui/src/app/homepage/homepage.spec.ts +++ b/ui/src/app/homepage/homepage.spec.ts @@ -221,14 +221,14 @@ describe('Homepage', () => { ); it.each([{path: 'reference.jpg'}, {url: 'https://example.com/image.jpg'}])( - 'uses a reference with a usable source: %j', + 'ignores a reference image and shows the placeholder when no active candidate thumbnail exists: %j', referenceImage => { const project = { storyboard: [{type: 'generated', referenceImage}], } as unknown as ProjectConfig; const data = component.getThumbnailData(project); - expect(data.showReference).toBe(true); - expect(data.showPlaceholder).toBe(false); + expect(data.showReference).toBe(false); + expect(data.showPlaceholder).toBe(true); }, ); @@ -290,7 +290,7 @@ describe('Homepage', () => { ); }); - it('uses a reference preview for a summary fallback without changing the original ref', () => { + it('shows the placeholder for a summary with only a reference image and no active candidate thumbnail', () => { const original = { path: 'original.jpg', url: 'https://example.test/original.jpg', @@ -307,8 +307,9 @@ describe('Homepage', () => { const data = component.getThumbnailData(project); - expect(data.referenceImage).toEqual(original.preview); - expect(project.thumbnail!.referenceImage).toBe(original); + expect(data.referenceImage).toBeUndefined(); + expect(data.showReference).toBe(false); + expect(data.showPlaceholder).toBe(true); }); it('renders only the selected candidate thumbnail when a reference fallback exists', async () => { diff --git a/ui/src/app/homepage/homepage.ts b/ui/src/app/homepage/homepage.ts index bbf58636..0e0e154f 100644 --- a/ui/src/app/homepage/homepage.ts +++ b/ui/src/app/homepage/homepage.ts @@ -104,7 +104,10 @@ export class Homepage { project: ProjectSummary | ProjectConfig, ): ThumbnailMaterial { if ('thumbnail' in project && project.thumbnail) { - return project.thumbnail; + return { + lowQualityThumbnail: project.thumbnail.lowQualityThumbnail, + highQualityThumbnail: project.thumbnail.highQualityThumbnail, + }; } if ( !('storyboard' in project) || @@ -122,16 +125,15 @@ export class Homepage { } if (this.config.isGeneratedScene(firstScene)) { const selectedCandidate = - firstScene.candidates?.[firstScene.selectedCandidateIndex ?? 0]; - + firstScene.selectedCandidateIndex !== undefined + ? firstScene.candidates?.[firstScene.selectedCandidateIndex] + : undefined; + if (!selectedCandidate || selectedCandidate.isArchived) { + return {}; + } return { - lowQualityThumbnail: - selectedCandidate?.lowQualityThumbnail || - firstScene.lowQualityThumbnail, - highQualityThumbnail: - selectedCandidate?.highQualityThumbnail || - firstScene.highQualityThumbnail, - referenceImage: firstScene.referenceImage, + lowQualityThumbnail: selectedCandidate.lowQualityThumbnail, + highQualityThumbnail: selectedCandidate.highQualityThumbnail, }; } return {}; @@ -141,15 +143,12 @@ export class Homepage { const thumb = this.getThumbnailMaterial(project); const hasThumb = !!thumb.lowQualityThumbnail || !!thumb.highQualityThumbnail; - const referenceImage = - thumb.referenceImage?.preview ?? thumb.referenceImage; - const hasReference = !!(referenceImage?.path || referenceImage?.url); return { ...thumb, - referenceImage, - showReference: !hasThumb && hasReference, - showPlaceholder: !hasThumb && !hasReference, + referenceImage: undefined, + showReference: false, + showPlaceholder: !hasThumb, }; } @@ -170,7 +169,9 @@ export class Homepage { } const scene = project.storyboard?.[0]; if (!scene || !this.config.isGeneratedScene(scene)) return true; - return !scene.candidates?.[scene.selectedCandidateIndex ?? 0]?.isArchived; + if (scene.selectedCandidateIndex === undefined) return false; + const selected = scene.candidates?.[scene.selectedCandidateIndex]; + return !!selected && !selected.isArchived; } getAspectRatio(project: ProjectSummary | ProjectConfig): string { diff --git a/ui/src/app/services/config/archived-candidate-render.spec.ts b/ui/src/app/services/config/archived-candidate-render.spec.ts index 87e0e512..249f97ec 100644 --- a/ui/src/app/services/config/archived-candidate-render.spec.ts +++ b/ui/src/app/services/config/archived-candidate-render.spec.ts @@ -287,22 +287,33 @@ describe('archived candidates are never rendered', () => { storyboardComponent = fixture.componentInstance; }); - it('clears selectedCandidateIndex when the selected candidate is archived', () => { + it('selects the next active candidate when archiving the selected candidate, and clears selection + prompt + referenceImage when the last candidate is archived', () => { const scene: GeneratedScene = { id: 'scene-1', type: 'generated', name: 'Scene 1', - prompt: 'test prompt', + prompt: 'prompt 0', + referenceImage: {path: 'refs/ref0.jpg', url: 'ref0-url'}, + lowQualityThumbnail: 'scene-low', + highQualityThumbnail: {path: 'thumbs/scene-hq.jpg', url: ''}, selectedCandidateIndex: 0, candidates: [ { video: {path: 'videos/c0.mp4', url: ''}, durationSeconds: 5, + prompt: 'prompt 0', + referenceImage: {path: 'refs/ref0.jpg', url: 'ref0-url'}, + lowQualityThumbnail: 'c0-low', + highQualityThumbnail: {path: 'thumbs/c0.jpg', url: ''}, isArchived: false, }, { video: {path: 'videos/c1.mp4', url: ''}, durationSeconds: 5, + prompt: 'prompt 1', + referenceImage: {path: 'refs/ref1.jpg', url: 'ref1-url'}, + lowQualityThumbnail: 'c1-low', + highQualityThumbnail: {path: 'thumbs/c1.jpg', url: ''}, isArchived: false, }, ] as any, @@ -312,10 +323,53 @@ describe('archived candidates are never rendered', () => { storyboard: [scene], }); + // Archive c0 -> should automatically select c1 and load c1's prompt and referenceImage storyboardComponent.toggleArchive(new Event('click'), scene, 0); expect(scene.candidates![0].isArchived).toBe(true); + expect(scene.selectedCandidateIndex).toBe(1); + expect(scene.prompt).toBe('prompt 1'); + expect(scene.referenceImage).toEqual({ + path: 'refs/ref1.jpg', + url: 'ref1-url', + }); + expect( + storyboardComponent.getSceneFilmstripThumbnailData(scene), + ).toMatchObject({ + lowQuality: 'c1-low', + showReference: false, + showIcon: false, + }); + + // Archive c1 (the last active candidate) -> should clear selection, prompt, and referenceImage, and show fallback icon in filmstrip + storyboardComponent.toggleArchive(new Event('click'), scene, 1); + + expect(scene.candidates![1].isArchived).toBe(true); expect(scene.selectedCandidateIndex).toBeUndefined(); + expect(scene.prompt).toBe(''); + expect(scene.referenceImage).toBeUndefined(); + expect(scene.lowQualityThumbnail).toBeUndefined(); + expect(scene.highQualityThumbnail).toBeUndefined(); + expect( + storyboardComponent.getSceneFilmstripThumbnailData(scene), + ).toMatchObject({ + lowQuality: undefined, + highQuality: undefined, + showReference: false, + showIcon: true, + }); + + // Restore c0 -> should select c0, keep video paused, and reload c0's prompt and referenceImage + storyboardComponent.toggleArchive(new Event('click'), scene, 0); + + expect(scene.candidates![0].isArchived).toBe(false); + expect(scene.selectedCandidateIndex).toBe(0); + expect(scene.prompt).toBe('prompt 0'); + expect(scene.referenceImage).toEqual({ + path: 'refs/ref0.jpg', + url: 'ref0-url', + }); + expect(storyboardComponent.isVideoPlaying()).toBe(false); }); it('leaves selectedCandidateIndex alone when a DIFFERENT candidate is archived (control)', () => { diff --git a/ui/src/app/services/config/config.ts b/ui/src/app/services/config/config.ts index 3f9e98d9..535cb513 100644 --- a/ui/src/app/services/config/config.ts +++ b/ui/src/app/services/config/config.ts @@ -1040,6 +1040,40 @@ export class ConfigService { if (!data.visualOverlays) { data.visualOverlays = []; } + if (Array.isArray(data.storyboard)) { + data.storyboard = data.storyboard.map(scene => { + if (!this.isGeneratedScene(scene)) { + return scene; + } + 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 { + delete updatedScene.selectedCandidateIndex; + updatedScene.prompt = ''; + delete updatedScene.referenceImage; + delete updatedScene.lowQualityThumbnail; + delete updatedScene.highQualityThumbnail; + } + return updatedScene; + }); + } // Snap resolution/duration/aspect ratio a persisted project's own (still // valid) model no longer allows, so a stale combination from before a // catalog change is never posted verbatim. diff --git a/ui/src/app/services/remix-engine/remix-engine-mediated.spec.ts b/ui/src/app/services/remix-engine/remix-engine-mediated.spec.ts index 75ceea02..a6dbe8ab 100644 --- a/ui/src/app/services/remix-engine/remix-engine-mediated.spec.ts +++ b/ui/src/app/services/remix-engine/remix-engine-mediated.spec.ts @@ -334,7 +334,7 @@ describe('RemixEngineService (mediated)', () => { trim: {start: 1, end: 4}, }); expect(attached.candidates).toHaveLength(2); - expect(attached.selectedCandidateIndex).toBe(0); + expect(attached.selectedCandidateIndex).toBe(1); }); it('should clear pendingGeneration on a definitive workflow error', async () => { @@ -918,7 +918,7 @@ describe('RemixEngineService (mediated)', () => { trim: {start: 1, end: 4}, }); expect(finalScene.candidates).toHaveLength(2); - expect(finalScene.selectedCandidateIndex).toBe(0); + expect(finalScene.selectedCandidateIndex).toBe(1); }); it('chooses the edit model from the catalog default, never the project model', async () => { @@ -1542,7 +1542,7 @@ describe('RemixEngineService (mediated)', () => { isArchived: true, trim: {start: 1, end: 4}, }); - expect(finalScene.selectedCandidateIndex).toBe(0); + expect(finalScene.selectedCandidateIndex).toBe(1); expect(finalScene).not.toHaveProperty('pendingGeneration'); }); diff --git a/ui/src/app/services/remix-engine/remix-engine.ts b/ui/src/app/services/remix-engine/remix-engine.ts index b8631fae..ff9fe25a 100644 --- a/ui/src/app/services/remix-engine/remix-engine.ts +++ b/ui/src/app/services/remix-engine/remix-engine.ts @@ -1790,22 +1790,57 @@ export class RemixEngineService { } const existingCandidates = s.candidates ?? []; const candidates = [...existingCandidates, ...newCandidates]; - const selectedCandidateIndex = s.selectedCandidateIndex; + const currentSelected = + s.selectedCandidateIndex !== undefined && + Number.isInteger(s.selectedCandidateIndex) && + s.selectedCandidateIndex >= 0 && + s.selectedCandidateIndex < candidates.length && + !candidates[s.selectedCandidateIndex].isArchived + ? s.selectedCandidateIndex + : undefined; + let nextSelectedIndex = currentSelected; + if (nextSelectedIndex === undefined) { + const firstNewActiveIndex = candidates.findIndex( + (c, idx) => idx >= existingCandidates.length && !c.isArchived, + ); + if (firstNewActiveIndex >= 0) { + nextSelectedIndex = firstNewActiveIndex; + } else { + const firstActiveIndex = candidates.findIndex(c => !c.isArchived); + if (firstActiveIndex >= 0) { + nextSelectedIndex = firstActiveIndex; + } + } + } + const selectedCandidate = + nextSelectedIndex !== undefined + ? candidates[nextSelectedIndex] + : undefined; const updated: GeneratedScene = { ...s, ...(candidates.length ? {candidates} : {}), - ...(candidates.length + ...(nextSelectedIndex !== undefined ? { - selectedCandidateIndex: - selectedCandidateIndex !== undefined && - Number.isInteger(selectedCandidateIndex) && - selectedCandidateIndex >= 0 && - selectedCandidateIndex < candidates.length - ? selectedCandidateIndex - : 0, + selectedCandidateIndex: nextSelectedIndex, + ...(currentSelected === undefined && selectedCandidate + ? { + prompt: selectedCandidate.prompt, + referenceImage: selectedCandidate.referenceImage, + } + : {}), } : {}), }; + if (nextSelectedIndex === undefined) { + delete updated.selectedCandidateIndex; + } + if ( + currentSelected === undefined && + selectedCandidate && + !selectedCandidate.referenceImage + ) { + delete updated.referenceImage; + } if (!candidates.length) { delete updated.candidates; delete updated.selectedCandidateIndex; diff --git a/ui/src/app/storyboard/storyboard.html b/ui/src/app/storyboard/storyboard.html index affa9dbf..cc1e78ae 100644 --- a/ui/src/app/storyboard/storyboard.html +++ b/ui/src/app/storyboard/storyboard.html @@ -80,10 +80,7 @@ delete @let fallbackIcon = this.config.isGeneratedScene(scene) ? 'auto_awesome' : 'videocam'; - @let thumb = - this.config.isGeneratedScene(scene) && (scene.candidates?.length ?? 0) > 0 - ? getThumbnailData(scene.candidates![scene.selectedCandidateIndex ?? 0]) - : getThumbnailData(scene); + @let thumb = getSceneFilmstripThumbnailData(scene); @let thumbnailPersist = thumbnailPersistForScene(scene);
@@ -107,25 +104,14 @@ /> } - - @if (thumb.showReference) { - Reference Image - } - - + @if (thumb.showIcon) {
{{ fallbackIcon }}
} - + @if (areCandidatesGenerating(scene.id)) {
@@ -143,25 +129,22 @@
{{ $index + 1 }}
- @if (this.config.isGeneratedScene(scene)) { - @if ( - scene.selectedCandidateIndex !== undefined && - scene.candidates?.[scene.selectedCandidateIndex]?.referenceImage - ) { - Reference Image - } @else { -
auto_awesome
- } + @if (thumb.highQuality) { + High Quality Thumbnail + } @else if (thumb.lowQuality) { + Low Quality Thumbnail } @else { -
videocam
+
{{ fallbackIcon }}
}
{{ scene.name }}
diff --git a/ui/src/app/storyboard/storyboard.ts b/ui/src/app/storyboard/storyboard.ts index e2d7d9bc..9a7c3b28 100644 --- a/ui/src/app/storyboard/storyboard.ts +++ b/ui/src/app/storyboard/storyboard.ts @@ -255,7 +255,8 @@ export class Storyboard { if (scene.selectedCandidateIndex === undefined) { return undefined; } - return scene.candidates[scene.selectedCandidateIndex]; + const candidate = scene.candidates[scene.selectedCandidateIndex]; + return candidate && !candidate.isArchived ? candidate : undefined; }); candidateCounts = computed(() => { @@ -458,12 +459,61 @@ export class Storyboard { }; } + getSceneFilmstripThumbnailData(scene: GeneratedScene | ProvidedVideoScene) { + if (this.config.isGeneratedScene(scene)) { + const selected = + scene.selectedCandidateIndex !== undefined + ? scene.candidates?.[scene.selectedCandidateIndex] + : undefined; + if (!selected || selected.isArchived) { + return { + lowQuality: undefined, + highQuality: undefined, + reference: undefined, + showReference: false, + showIcon: true, + }; + } + const hasLowQualityThumbnail = !!selected.lowQualityThumbnail; + const hasHighQualityThumbnail = !!( + selected.highQualityThumbnail?.url || + selected.highQualityThumbnail?.path + ); + const hasThumbnail = hasLowQualityThumbnail || hasHighQualityThumbnail; + return { + lowQuality: selected.lowQualityThumbnail, + highQuality: hasHighQualityThumbnail + ? selected.highQualityThumbnail + : undefined, + reference: undefined, + showReference: false, + showIcon: !hasThumbnail, + }; + } + + const hasLowQualityThumbnail = !!scene.lowQualityThumbnail; + const hasHighQualityThumbnail = !!( + scene.highQualityThumbnail?.url || scene.highQualityThumbnail?.path + ); + const hasThumbnail = hasLowQualityThumbnail || hasHighQualityThumbnail; + return { + lowQuality: scene.lowQualityThumbnail, + highQuality: hasHighQualityThumbnail + ? scene.highQualityThumbnail + : undefined, + reference: undefined, + showReference: false, + showIcon: !hasThumbnail, + }; + } + 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; } formatTimeLabel(value: number): string { @@ -1148,33 +1198,67 @@ export class Storyboard { return this.scenePromptRevisions()[sceneId] ?? 0; } + private findNextActiveCandidateIndex( + candidates: readonly Candidate[], + archivedIndex: number, + ): number | undefined { + for (let i = archivedIndex + 1; i < candidates.length; i++) { + if (!candidates[i].isArchived) { + return i; + } + } + for (let i = 0; i < archivedIndex; i++) { + if (!candidates[i].isArchived) { + return i; + } + } + return undefined; + } + toggleArchive(event: Event, scene: GeneratedScene, index: number) { event.stopPropagation(); if (scene.candidates && scene.candidates[index]) { const candidate = scene.candidates[index]; candidate.isArchived = !candidate.isArchived; - // Archiving the candidate that is currently selected must also clear the - // selection. Leaving the index in place keeps the archived clip in the - // composition playlist and renders it into the final video. - if (candidate.isArchived && scene.selectedCandidateIndex === index) { - scene.selectedCandidateIndex = undefined; + if (!candidate.isArchived) { + this.selectCandidate(scene, index); + return; } - this.updateScenes(); - if (candidate.isArchived && candidate.video?.path) { + if (scene.selectedCandidateIndex === index) { + const nextActiveIndex = this.findNextActiveCandidateIndex( + scene.candidates, + index, + ); + if (nextActiveIndex !== undefined) { + this.selectCandidate(scene, nextActiveIndex); + } else { + const promptChanged = scene.prompt !== ''; + this.referenceUploadEpoch++; + scene.selectedCandidateIndex = undefined; + scene.prompt = ''; + delete scene.referenceImage; + delete scene.lowQualityThumbnail; + delete scene.highQualityThumbnail; + this.isVideoPlaying.set(false); + this.updateScenes(scene, promptChanged); + } + } else { + this.updateScenes(scene); + } + if (candidate.video?.path) { void this.candidateVideoCache.invalidateCandidate( this.config.projectConfig.value().id, candidate.video.path, ); } - if (candidate.isArchived) { - 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; + for (const path of [ + candidate.highQualityThumbnail?.path, + candidate.referenceImage?.path, + candidate.referenceImage?.preview?.path, + ]) { + if (path) { + void this.thumbnailCache.invalidateCandidate(projectId, path); } } } From 39c4256cf62297aa36d22632e89786ca428febdc Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:03:23 +0000 Subject: [PATCH 07/11] Align candidate move selection/ingredients and suppress transitions around empty scenes --- ui/src/app/composition/composition.ts | 42 ++++++- ui/src/app/services/config/config.ts | 32 +++++- .../app/services/remix-engine/remix-engine.ts | 11 +- ui/src/app/storyboard/move-candidate.spec.ts | 46 ++++++-- ui/src/app/storyboard/move-candidate.ts | 106 ++++++++++++++++-- ui/src/app/storyboard/storyboard-move.spec.ts | 4 +- ui/src/app/storyboard/storyboard.ts | 39 ++++++- 7 files changed, 242 insertions(+), 38 deletions(-) diff --git a/ui/src/app/composition/composition.ts b/ui/src/app/composition/composition.ts index f604242f..9a0f04ac 100644 --- a/ui/src/app/composition/composition.ts +++ b/ui/src/app/composition/composition.ts @@ -113,23 +113,52 @@ export class Composition { }); 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, + ), ); playlist = computed(() => { + let readyIndex = 0; return this.sceneRenderClips().flatMap(({scene, resolution}) => { if (resolution.state !== 'ready') { return []; } + const isFirst = readyIndex === 0; + readyIndex++; const {video, start, duration} = resolution.clip; return [ { @@ -140,7 +169,8 @@ export class Composition { end: start + duration, duration, includeAudio: resolution.clip.includeAudio, - transitionOverlap: scene.transitionOverlap, + transitionOverlap: + !isFirst && scene.transition ? scene.transitionOverlap : undefined, type: scene.type, }, ]; diff --git a/ui/src/app/services/config/config.ts b/ui/src/app/services/config/config.ts index 535cb513..fc45e390 100644 --- a/ui/src/app/services/config/config.ts +++ b/ui/src/app/services/config/config.ts @@ -533,18 +533,26 @@ export function findTransitionContractViolation( const renderable: Array<{ scene: GeneratedScene | ProvidedVideoScene; duration: number; + prevStoryboardSceneReady: boolean; }> = []; - for (const scene of scenes) { + for (let i = 0; i < scenes.length; i++) { + const scene = scenes[i]; const resolution = resolveSceneRenderClip(scene); if (resolution.state === 'ready') { - renderable.push({scene, duration: resolution.clip.duration}); + const prevStoryboardSceneReady = + i > 0 && resolveSceneRenderClip(scenes[i - 1]).state === 'ready'; + renderable.push({ + scene, + duration: resolution.clip.duration, + prevStoryboardSceneReady, + }); } } // The first clip has nothing to transition from, so ffmpeg ignores its // transition; start at the second. for (let index = 1; index < renderable.length; index++) { - const {scene, duration} = renderable[index]; - if (!scene.transition) { + const {scene, duration, prevStoryboardSceneReady} = renderable[index]; + if (!prevStoryboardSceneReady || !scene.transition) { continue; } const overlap = scene.transitionOverlap ?? DEFAULT_TRANSITION_OVERLAP; @@ -1070,9 +1078,25 @@ export class ConfigService { delete updatedScene.referenceImage; delete updatedScene.lowQualityThumbnail; delete updatedScene.highQualityThumbnail; + delete updatedScene.transition; + delete updatedScene.transitionOverlap; } return updatedScene; }); + for (let i = 0; i < data.storyboard.length; i++) { + if (resolveSceneRenderClip(data.storyboard[i]).state !== 'ready') { + const current = {...data.storyboard[i]}; + delete current.transition; + delete current.transitionOverlap; + data.storyboard[i] = current; + if (i + 1 < data.storyboard.length) { + const next = {...data.storyboard[i + 1]}; + delete next.transition; + delete next.transitionOverlap; + data.storyboard[i + 1] = next; + } + } + } } // Snap resolution/duration/aspect ratio a persisted project's own (still // valid) model no longer allows, so a stale combination from before a diff --git a/ui/src/app/services/remix-engine/remix-engine.ts b/ui/src/app/services/remix-engine/remix-engine.ts index ff9fe25a..48a10ccb 100644 --- a/ui/src/app/services/remix-engine/remix-engine.ts +++ b/ui/src/app/services/remix-engine/remix-engine.ts @@ -2449,10 +2449,13 @@ export class RemixEngineService { if (transitionViolation) { throw new RenderContractError(transitionViolation); } - for (const {scene, resolution} of resolvedScenes) { + for (let i = 0; i < resolvedScenes.length; i++) { + const {scene, resolution} = resolvedScenes[i]; if (resolution.state !== 'ready') { continue; } + const prevStoryboardSceneReady = + i > 0 && resolvedScenes[i - 1].resolution.state === 'ready'; const {video, start, duration, includeAudio} = resolution.clip; const videoArrangement: CombineScenesArrangement = { file_type: 'video', @@ -2462,7 +2465,11 @@ export class RemixEngineService { duration, include_audio: includeAudio, }; - if (scene.transition) { + if ( + arrangement.length > 0 && + prevStoryboardSceneReady && + scene.transition + ) { videoArrangement.transition = scene.transition; videoArrangement.transition_overlap = scene.transitionOverlap ?? DEFAULT_TRANSITION_OVERLAP; diff --git a/ui/src/app/storyboard/move-candidate.spec.ts b/ui/src/app/storyboard/move-candidate.spec.ts index f80026cd..ca65426e 100644 --- a/ui/src/app/storyboard/move-candidate.spec.ts +++ b/ui/src/app/storyboard/move-candidate.spec.ts @@ -141,10 +141,8 @@ describe('moveCandidate', () => { expect( generated(result.storyboard[0]).selectedCandidateIndex, ).toBeUndefined(); - expect(generated(result.storyboard[0]).prompt).toBe('keep prompt'); - expect(generated(result.storyboard[0]).referenceImage?.path).toBe( - 'keep-ref', - ); + expect(generated(result.storyboard[0]).prompt).toBe(''); + expect(generated(result.storyboard[0]).referenceImage).toBeUndefined(); expect(generated(result.storyboard[0]).generationError).toBeUndefined(); expect(resolveSceneRenderClip(result.storyboard[0])).toEqual({ state: 'not-selected', @@ -215,10 +213,17 @@ describe('moveCandidate', () => { expect(remaining.transitionOverlap).toBe(0.2); }); - it('selects an arrival only when the existing destination is unselected', () => { - const source = scene('1', [candidate(1, 'source')]); + it('selects an arrival and loads its prompt and referenceImage when the existing destination has no active selection', () => { + const source = scene('1', [ + { + ...candidate(1, 'source'), + prompt: 'moved prompt', + referenceImage: {path: 'moved-ref', url: 'moved-ref-url'}, + }, + ]); const destination = scene('2', [candidate(3, 'dest')]); delete destination.selectedCandidateIndex; + destination.prompt = 'old prompt'; const result = moveCandidate({ storyboard: [source, destination], sourceSceneId: '1', @@ -231,6 +236,11 @@ describe('moveCandidate', () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(generated(result.storyboard[1]).selectedCandidateIndex).toBe(1); + expect(generated(result.storyboard[1]).prompt).toBe('moved prompt'); + expect(generated(result.storyboard[1]).referenceImage).toEqual({ + path: 'moved-ref', + url: 'moved-ref-url', + }); }); it('preserves first-placement origin across repeated moves and return', () => { @@ -274,7 +284,7 @@ describe('moveCandidate', () => { expect(generated(returned.storyboard[0]).candidates?.[0]).toEqual( generated(first.storyboard[1]).candidates?.[0], ); - expect(generated(returned.storyboard[0]).prompt).toBe(source.prompt); + expect(generated(returned.storyboard[0]).prompt).toBe('prompt'); expect(generated(returned.storyboard[2]).candidates).toBeUndefined(); expect( moveCandidate({ @@ -368,8 +378,15 @@ describe('moveCandidate', () => { expect(moveCandidate(input)).toEqual({ok: false, reason: 'invalid-source'}); }); - it('clears a removed selection and preserves an undefined selection', () => { - const source = scene('1', [candidate(1, 'a'), candidate(1, 'b')]); + it('selects the next active candidate when moving the selected candidate and preserves an undefined selection', () => { + const source = scene('1', [ + {...candidate(1, 'a'), prompt: 'prompt a'}, + { + ...candidate(1, 'b'), + prompt: 'prompt b', + referenceImage: {path: 'ref-b', url: 'ref-b-url'}, + }, + ]); const input = { storyboard: [source], sourceSceneId: '1', @@ -382,9 +399,14 @@ describe('moveCandidate', () => { const selectedResult = moveCandidate(input); expect(selectedResult.ok).toBe(true); if (!selectedResult.ok) return; - expect( - generated(selectedResult.storyboard[0]).selectedCandidateIndex, - ).toBeUndefined(); + expect(generated(selectedResult.storyboard[0]).selectedCandidateIndex).toBe( + 0, + ); + expect(generated(selectedResult.storyboard[0]).prompt).toBe('prompt b'); + expect(generated(selectedResult.storyboard[0]).referenceImage).toEqual({ + path: 'ref-b', + url: 'ref-b-url', + }); expect( generated(selectedResult.storyboard[0]).candidates?.[0].video?.path, ).toBe('b'); diff --git a/ui/src/app/storyboard/move-candidate.ts b/ui/src/app/storyboard/move-candidate.ts index 7106e69e..a1b81c41 100644 --- a/ui/src/app/storyboard/move-candidate.ts +++ b/ui/src/app/storyboard/move-candidate.ts @@ -76,6 +76,33 @@ function cloneMovedCandidate( return moved; } +function findNextActiveIndex( + candidates: readonly Candidate[], + startIndex: number, +): number | undefined { + for (let i = startIndex; i < candidates.length; i++) { + if (!candidates[i].isArchived) { + return i; + } + } + for (let i = 0; i < Math.min(startIndex, candidates.length); i++) { + if (!candidates[i].isArchived) { + return i; + } + } + return undefined; +} + +function clearEmptySceneState(scene: GeneratedScene): void { + delete scene.selectedCandidateIndex; + scene.prompt = ''; + delete scene.referenceImage; + delete scene.lowQualityThumbnail; + delete scene.highQualityThumbnail; + delete scene.transition; + delete scene.transitionOverlap; +} + export function moveCandidate(input: MoveCandidateInput): MoveCandidateResult { const sourceIndex = input.storyboard.findIndex( scene => scene.id === input.sourceSceneId, @@ -136,20 +163,65 @@ export function moveCandidate(input: MoveCandidateInput): MoveCandidateResult { (_candidate, index) => index !== input.candidateIndex, ); const updatedSource: GeneratedScene = {...source}; + let sourceHasActiveRemaining = false; if (!remaining?.length) { delete updatedSource.candidates; - delete updatedSource.selectedCandidateIndex; delete updatedSource.generationError; delete updatedSource.generationErrorAcknowledged; + clearEmptySceneState(updatedSource); } else { updatedSource.candidates = remaining; if (source.selectedCandidateIndex === input.candidateIndex) { - delete updatedSource.selectedCandidateIndex; - } else if ( - source.selectedCandidateIndex !== undefined && - source.selectedCandidateIndex > input.candidateIndex - ) { - updatedSource.selectedCandidateIndex = source.selectedCandidateIndex - 1; + const nextActiveIndex = findNextActiveIndex( + remaining, + input.candidateIndex, + ); + if (nextActiveIndex !== undefined) { + const nextCandidate = remaining[nextActiveIndex]; + updatedSource.selectedCandidateIndex = nextActiveIndex; + updatedSource.prompt = nextCandidate.prompt; + if (nextCandidate.referenceImage) { + updatedSource.referenceImage = nextCandidate.referenceImage; + } else { + delete updatedSource.referenceImage; + } + sourceHasActiveRemaining = true; + } else { + clearEmptySceneState(updatedSource); + } + } else if (source.selectedCandidateIndex !== undefined) { + const adjustedIndex = + source.selectedCandidateIndex > input.candidateIndex + ? source.selectedCandidateIndex - 1 + : source.selectedCandidateIndex; + const adjustedCandidate = remaining[adjustedIndex]; + if (adjustedCandidate && !adjustedCandidate.isArchived) { + updatedSource.selectedCandidateIndex = adjustedIndex; + sourceHasActiveRemaining = true; + } else { + const nextActiveIndex = findNextActiveIndex( + remaining, + input.candidateIndex, + ); + if (nextActiveIndex !== undefined) { + const nextCandidate = remaining[nextActiveIndex]; + updatedSource.selectedCandidateIndex = nextActiveIndex; + updatedSource.prompt = nextCandidate.prompt; + if (nextCandidate.referenceImage) { + updatedSource.referenceImage = nextCandidate.referenceImage; + } else { + delete updatedSource.referenceImage; + } + sourceHasActiveRemaining = true; + } else { + clearEmptySceneState(updatedSource); + } + } + } else { + sourceHasActiveRemaining = remaining.some(c => !c.isArchived); + if (!sourceHasActiveRemaining) { + clearEmptySceneState(updatedSource); + } } } @@ -176,15 +248,27 @@ export function moveCandidate(input: MoveCandidateInput): MoveCandidateResult { const runNumber = candidates.length ? Math.max(...candidates.map(item => item.runNumber)) + 1 : 1; - const destinationWithoutFailure = {...destination}; + const currentDestSelected = + destination.selectedCandidateIndex !== undefined + ? candidates[destination.selectedCandidateIndex] + : undefined; + const shouldSelectArrival = + !currentDestSelected || !!currentDestSelected.isArchived; + const destinationWithoutFailure: GeneratedScene = {...destination}; delete destinationWithoutFailure.generationError; delete destinationWithoutFailure.generationErrorAcknowledged; + if (shouldSelectArrival) { + destinationWithoutFailure.selectedCandidateIndex = candidates.length; + destinationWithoutFailure.prompt = moved.prompt; + if (moved.referenceImage) { + destinationWithoutFailure.referenceImage = moved.referenceImage; + } else { + delete destinationWithoutFailure.referenceImage; + } + } next[destinationIndex] = { ...destinationWithoutFailure, candidates: [...candidates, {...moved, runNumber}], - ...(destination.selectedCandidateIndex === undefined - ? {selectedCandidateIndex: candidates.length} - : {}), }; } return {ok: true, storyboard: next, destinationSceneId}; diff --git a/ui/src/app/storyboard/storyboard-move.spec.ts b/ui/src/app/storyboard/storyboard-move.spec.ts index efe9bd66..fffbb111 100644 --- a/ui/src/app/storyboard/storyboard-move.spec.ts +++ b/ui/src/app/storyboard/storyboard-move.spec.ts @@ -171,7 +171,7 @@ describe('Storyboard move persistence', () => { const moved = config.projectConfig.value().storyboard; expect(moved).toHaveLength(2); expect((moved[0] as GeneratedScene).candidates).toBeUndefined(); - expect((moved[0] as GeneratedScene).prompt).toBe('keep prompt'); + expect((moved[0] as GeneratedScene).prompt).toBe(''); expect((moved[1] as GeneratedScene).candidates).toHaveLength(1); expect(component.selectedSceneId()).toBe('2'); expect(component.selectedScene()?.id).toBe('2'); @@ -211,7 +211,7 @@ describe('Storyboard move persistence', () => { const reloaded = config.projectConfig.value().storyboard; expect(reloaded).toHaveLength(2); expect((reloaded[0] as GeneratedScene).candidates).toBeUndefined(); - expect((reloaded[0] as GeneratedScene).prompt).toBe('keep prompt'); + expect((reloaded[0] as GeneratedScene).prompt).toBe(''); expect((reloaded[1] as GeneratedScene).candidates).toHaveLength(1); expect((reloaded[1] as GeneratedScene).selectedCandidateIndex).toBe(0); expect((reloaded[1] as GeneratedScene).candidates?.[0].video?.path).toBe( diff --git a/ui/src/app/storyboard/storyboard.ts b/ui/src/app/storyboard/storyboard.ts index 9a7c3b28..bc4d46ae 100644 --- a/ui/src/app/storyboard/storyboard.ts +++ b/ui/src/app/storyboard/storyboard.ts @@ -1215,6 +1215,29 @@ export class Storyboard { return undefined; } + private clearSceneAndNextNeighborTransitions(sceneId: string): void { + const storyboard = this.config.projectConfig.value().storyboard; + const sceneIdx = storyboard.findIndex(s => s.id === sceneId); + if (sceneIdx === -1) return; + let changed = false; + const updatedStoryboard = storyboard.map((item, idx) => { + if ( + (idx === sceneIdx || idx === sceneIdx + 1) && + (item.transition !== undefined || item.transitionOverlap !== undefined) + ) { + changed = true; + const copy = {...item}; + delete copy.transition; + delete copy.transitionOverlap; + return copy; + } + return item; + }); + if (changed) { + this.config.updateProjectConfig({storyboard: updatedStoryboard}); + } + } + toggleArchive(event: Event, scene: GeneratedScene, index: number) { event.stopPropagation(); if (scene.candidates && scene.candidates[index]) { @@ -1239,8 +1262,11 @@ export class Storyboard { delete scene.referenceImage; delete scene.lowQualityThumbnail; delete scene.highQualityThumbnail; + delete scene.transition; + delete scene.transitionOverlap; this.isVideoPlaying.set(false); this.updateScenes(scene, promptChanged); + this.clearSceneAndNextNeighborTransitions(scene.id); } } else { this.updateScenes(scene); @@ -1358,7 +1384,18 @@ export class Storyboard { this.userSelectedSceneId.set(null); } const config = this.config.projectConfig.value(); - const scenes = config.storyboard.filter(s => s.id !== id); + const deletedIdx = config.storyboard.findIndex(s => s.id === id); + const scenes = config.storyboard + .map((s, idx) => { + if (deletedIdx !== -1 && idx === deletedIdx + 1) { + const copy = {...s}; + delete copy.transition; + delete copy.transitionOverlap; + return copy; + } + return s; + }) + .filter(s => s.id !== id); this.config.updateProjectConfig({storyboard: scenes}); } }); From af19aed4fd5eee9b3d7b02f413f3005f6104c008 Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:19:25 +0000 Subject: [PATCH 08/11] Always select the moved candidate on the destination scene --- ui/src/app/storyboard/move-candidate.spec.ts | 18 ++++++++++++------ ui/src/app/storyboard/move-candidate.ts | 20 ++++++-------------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/ui/src/app/storyboard/move-candidate.spec.ts b/ui/src/app/storyboard/move-candidate.spec.ts index ca65426e..cad48f40 100644 --- a/ui/src/app/storyboard/move-candidate.spec.ts +++ b/ui/src/app/storyboard/move-candidate.spec.ts @@ -152,15 +152,21 @@ describe('moveCandidate', () => { }); }); - it('appends to an existing destination and preserves its selection', () => { - const source = scene('1', [candidate(1, 'source')]); + it('appends to an existing destination and selects the moved candidate', () => { + const source = scene('1', [ + { + ...candidate(1, 'source'), + prompt: 'moved prompt', + referenceImage: {path: 'moved-ref', url: 'moved-ref-url'}, + }, + ]); const destination = scene('2', [ candidate(4, 'dest'), candidate(9, 'archived'), ]); destination.candidates![1].isArchived = true; destination.selectedCandidateIndex = 0; - destination.prompt = 'keep this prompt'; + destination.prompt = 'previous prompt'; destination.referenceImage = {path: 'ref', url: 'ref-url'}; destination.transition = 'fade'; destination.transitionOverlap = 0.25; @@ -180,9 +186,9 @@ describe('moveCandidate', () => { const movedDestination = generated(result.storyboard[1]); expect(movedDestination.candidates).toHaveLength(3); expect(movedDestination.candidates?.[2].runNumber).toBe(10); - expect(movedDestination.selectedCandidateIndex).toBe(0); - expect(movedDestination.prompt).toBe('keep this prompt'); - expect(movedDestination.referenceImage?.path).toBe('ref'); + expect(movedDestination.selectedCandidateIndex).toBe(2); + expect(movedDestination.prompt).toBe('moved prompt'); + expect(movedDestination.referenceImage?.path).toBe('moved-ref'); expect(movedDestination.transitionOverlap).toBe(0.25); expect(movedDestination.generationError).toBeUndefined(); expect(movedDestination.generationErrorAcknowledged).toBeUndefined(); diff --git a/ui/src/app/storyboard/move-candidate.ts b/ui/src/app/storyboard/move-candidate.ts index a1b81c41..ae24776f 100644 --- a/ui/src/app/storyboard/move-candidate.ts +++ b/ui/src/app/storyboard/move-candidate.ts @@ -248,23 +248,15 @@ export function moveCandidate(input: MoveCandidateInput): MoveCandidateResult { const runNumber = candidates.length ? Math.max(...candidates.map(item => item.runNumber)) + 1 : 1; - const currentDestSelected = - destination.selectedCandidateIndex !== undefined - ? candidates[destination.selectedCandidateIndex] - : undefined; - const shouldSelectArrival = - !currentDestSelected || !!currentDestSelected.isArchived; const destinationWithoutFailure: GeneratedScene = {...destination}; delete destinationWithoutFailure.generationError; delete destinationWithoutFailure.generationErrorAcknowledged; - if (shouldSelectArrival) { - destinationWithoutFailure.selectedCandidateIndex = candidates.length; - destinationWithoutFailure.prompt = moved.prompt; - if (moved.referenceImage) { - destinationWithoutFailure.referenceImage = moved.referenceImage; - } else { - delete destinationWithoutFailure.referenceImage; - } + destinationWithoutFailure.selectedCandidateIndex = candidates.length; + destinationWithoutFailure.prompt = moved.prompt; + if (moved.referenceImage) { + destinationWithoutFailure.referenceImage = moved.referenceImage; + } else { + delete destinationWithoutFailure.referenceImage; } next[destinationIndex] = { ...destinationWithoutFailure, From 47230fc24c26c7399034b6abc7ac8f9505aeb993 Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Wed, 23 Sep 2026 11:33:54 +0000 Subject: [PATCH 09/11] Include candidate thumbnail in test_spa_delivery compression fixture --- test/test_spa_delivery.py | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_spa_delivery.py b/test/test_spa_delivery.py index 19d29513..cc67e312 100644 --- a/test/test_spa_delivery.py +++ b/test/test_spa_delivery.py @@ -330,6 +330,7 @@ def _populate_large_project(fake_db, orch): 'runNumber': index, 'prompt': 'A repeated prompt ' * 30, 'video': {'path': f'videos/{index}.mp4'}, + 'lowQualityThumbnail': 'data:image/png;base64,' + ('a' * 2000), } for index in range(8) ], From 51d3b952e594211bea7efe2de6c8a3e28811ff01 Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:44:46 +0000 Subject: [PATCH 10/11] Address review feedback on transition guards, cache invalidation, and load normalization --- ui/src/app/composition/composition.html | 2 +- ui/src/app/composition/composition.ts | 12 +++++++++--- ui/src/app/services/config/config.ts | 10 ++++++++-- ui/src/app/storyboard/storyboard.ts | 26 +++++++++++++++++++++++-- 4 files changed, 42 insertions(+), 8 deletions(-) diff --git a/ui/src/app/composition/composition.html b/ui/src/app/composition/composition.html index bc759435..fe772bd8 100644 --- a/ui/src/app/composition/composition.html +++ b/ui/src/app/composition/composition.html @@ -266,7 +266,7 @@
@for (scene of filmstripScenes(); track scene.id; let i = $index) { - @if (i > 0) { + @if (i > 0 && scene.canTransitionFromPrev) {
0 && !prevNotReady; const effectiveScene = - prevNotReady && + !canTransitionFromPrev && (scene.transition !== undefined || scene.transitionOverlap !== undefined) ? { @@ -132,6 +133,7 @@ export class Composition { return { scene: effectiveScene, resolution, + canTransitionFromPrev, }; }), ); @@ -139,15 +141,19 @@ export class Composition { filmstripScenes = computed(() => this.sceneRenderClips() .filter(({resolution}) => resolution.state === 'ready') - .map(({scene}, index) => + .map(({scene, canTransitionFromPrev}, index) => index === 0 && (scene.transition || scene.transitionOverlap !== undefined) ? { ...scene, transition: undefined, transitionOverlap: undefined, + canTransitionFromPrev: false, } - : scene, + : { + ...scene, + canTransitionFromPrev: index > 0 && canTransitionFromPrev, + }, ), ); diff --git a/ui/src/app/services/config/config.ts b/ui/src/app/services/config/config.ts index fc45e390..b8ef8800 100644 --- a/ui/src/app/services/config/config.ts +++ b/ui/src/app/services/config/config.ts @@ -1053,10 +1053,14 @@ export class ConfigService { if (!this.isGeneratedScene(scene)) { return scene; } - if (scene.selectedCandidateIndex === undefined) { + const hasCandidates = (scene.candidates?.length ?? 0) > 0; + if (scene.selectedCandidateIndex === undefined && !hasCandidates) { return scene; } - const selected = scene.candidates?.[scene.selectedCandidateIndex]; + const selected = + scene.selectedCandidateIndex !== undefined + ? scene.candidates?.[scene.selectedCandidateIndex] + : undefined; if (selected && !selected.isArchived) { return scene; } @@ -1072,6 +1076,8 @@ export class ConfigService { } else { delete updatedScene.referenceImage; } + delete updatedScene.lowQualityThumbnail; + delete updatedScene.highQualityThumbnail; } else { delete updatedScene.selectedCandidateIndex; updatedScene.prompt = ''; diff --git a/ui/src/app/storyboard/storyboard.ts b/ui/src/app/storyboard/storyboard.ts index bc4d46ae..19ccd88e 100644 --- a/ui/src/app/storyboard/storyboard.ts +++ b/ui/src/app/storyboard/storyboard.ts @@ -511,7 +511,9 @@ export class Storyboard { scene: GeneratedScene | ProvidedVideoScene, ): boolean { if (!this.config.isGeneratedScene(scene)) return true; - if (scene.selectedCandidateIndex === undefined) return false; + if (scene.selectedCandidateIndex === undefined) { + return !!scene.referenceImage; + } const selected = scene.candidates?.[scene.selectedCandidateIndex]; return !!selected && !selected.isArchived; } @@ -1278,12 +1280,32 @@ export class Storyboard { ); } const projectId = this.config.projectConfig.value().id; + const activePaths = new Set(); + 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) { + if (path && !activePaths.has(path)) { void this.thumbnailCache.invalidateCandidate(projectId, path); } } From f1ea5f3d2ad84f7d52a472996e0026022be1eb09 Mon Sep 17 00:00:00 2001 From: christophervoelpel <123032885+christophervoelpel@users.noreply.github.com> Date: Thu, 24 Sep 2026 08:45:52 +0000 Subject: [PATCH 11/11] fix(ui): keep new projects ready after editor route or failed load resetProjectConfig() and setNewProject() now clear the stale projectLoadError and reset projectView to 'full', so a locally created project is not stuck behind setupInputsLoaded() === false after leaving an editor route or a failed load. Also extend the double-404 recovery test to click Retry and verify a 200 hydrates inputConfig, keeps the unsaved local edit, clears setupInputsError and renders the Setup form. Addresses review feedback from victor-paunescu on #202. --- .../services/config/config-mediated.spec.ts | 32 +++++++++++++++++ ui/src/app/services/config/config.ts | 4 +++ ui/src/app/setup/setup.spec.ts | 36 +++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/ui/src/app/services/config/config-mediated.spec.ts b/ui/src/app/services/config/config-mediated.spec.ts index 5ab4f6ef..dfbe9048 100644 --- a/ui/src/app/services/config/config-mediated.spec.ts +++ b/ui/src/app/services/config/config-mediated.spec.ts @@ -1199,6 +1199,38 @@ describe('ConfigService (mediated data plane)', () => { expect(service.setupInputsLoaded()).toBe(true); }); + it('keeps a locally created project ready after leaving an editor route or failed load', async () => { + httpClientMock.get.mockImplementation((url: string) => { + if (url === '/api/config') return of({}); + if (url === '/api/projects/project-a?view=editor') { + return of({...service.projectConfig.value(), id: 'project-a'}); + } + return of({}); + }); + service.loadProjectConfig('project-a', 'editor'); + await vi.waitFor(() => { + expect(service.projectConfig.value().id).toBe('project-a'); + }); + (service as any).projectLoadErrorValue.set(new Error('stale failure')); + httpClientMock.get.mockClear(); + + service.resetProjectConfig(); + service.setNewProject('project-b'); + expect(service.setupInputsLoaded()).toBe(true); + service.saveNow(); + + service.loadProjectConfig('project-b', 'full'); + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(httpClientMock.get).not.toHaveBeenCalledWith( + '/api/projects/project-b', + ); + expect(service.projectLoadError()).toBe(false); + expect(service.setupInputsError()).toBe(false); + expect(service.setupInputsLoaded()).toBe(true); + expect(service.projectConfig.value().id).toBe('project-b'); + }); + it('keeps a locally created full project ready after leaving another project', () => { service.projectConfig.value.set({ ...service.projectConfig.value(), diff --git a/ui/src/app/services/config/config.ts b/ui/src/app/services/config/config.ts index 94c7d248..eb0c0f5b 100644 --- a/ui/src/app/services/config/config.ts +++ b/ui/src/app/services/config/config.ts @@ -1513,8 +1513,10 @@ export class ConfigService { // would otherwise be silently dropped once the config resets (the // post-reset emission has id === '' / shouldSave === false). this.flushPendingSave(); + this.projectLoadErrorValue.set(undefined); this.setupInputsHydrated.set(false); this.projectId.set(null); + this.projectView.set('full'); this.projectConfig.set({...this.DEFAULT_PROJECT_CONFIG()}); this.shouldSave = false; } @@ -1533,7 +1535,9 @@ export class ConfigService { // Not persisted yet: the first autosave POSTs /api/projects, where the // server stamps createdBy from the verified identity. Left undefined here. this.persistedProjectIds.delete(uuid); + this.projectLoadErrorValue.set(undefined); this.setupInputsHydrated.set(true); + this.projectView.set('full'); const project = { ...this.DEFAULT_PROJECT_CONFIG(), id: uuid, diff --git a/ui/src/app/setup/setup.spec.ts b/ui/src/app/setup/setup.spec.ts index e2e72f9e..0206f64a 100644 --- a/ui/src/app/setup/setup.spec.ts +++ b/ui/src/app/setup/setup.spec.ts @@ -201,6 +201,42 @@ describe('Setup full-load failure', () => { expect(fixture.nativeElement.querySelector('.setup-container')).toBeNull(); expect(config.projectConfig.value().inputConfig).toBeUndefined(); http.expectNone('/api/projects/proj-unsettled'); + + const retry = fixture.nativeElement.querySelector( + '.loading-state button', + ) as HTMLButtonElement | null; + expect(retry?.textContent).toContain('Retry'); + retry?.click(); + TestBed.tick(); + const retryGet = http.expectOne('/api/projects/proj-unsettled'); + retryGet.flush({ + id: 'proj-unsettled', + name: 'Server Name', + aspectRatio: '16:9', + resolution: '720p', + candidateDurationSeconds: 4, + generateAudio: false, + numberOfCandidates: 1, + model: 'veo-default', + inputConfig: {products: [], composition: 'Recovered composition'}, + storyboard: [], + audioTracks: [], + visualOverlays: [], + }); + TestBed.tick(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(config.setupInputsError()).toBe(false); + expect(config.setupInputsLoaded()).toBe(true); + expect(config.projectConfig.value().name).toBe('In-Flight'); + expect(config.projectConfig.value().inputConfig).toEqual({ + products: [], + composition: 'Recovered composition', + }); + expect( + fixture.nativeElement.querySelector('.setup-container'), + ).not.toBeNull(); }); });