Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions orch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1275,31 +1275,29 @@ 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)
and isinstance(selected_index, int)
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:
Expand Down
16 changes: 8 additions & 8 deletions test/test_frontdoor_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
},
Expand All @@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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',
}],
}],
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions test/test_spa_delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
],
Expand Down
42 changes: 36 additions & 6 deletions ui/src/app/composition/composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
);
Comment on lines 115 to 158

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

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

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

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

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


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 [
{
Expand All @@ -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,
},
];
Expand Down
13 changes: 7 additions & 6 deletions ui/src/app/homepage/homepage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
);

Expand Down Expand Up @@ -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',
Expand All @@ -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 () => {
Expand Down
35 changes: 18 additions & 17 deletions ui/src/app/homepage/homepage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand All @@ -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 {};
Expand All @@ -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,
};
}

Expand All @@ -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 {
Expand Down
Loading
Loading