Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ const makeDraft = (
sourceGenerationId: null,
});

for (const type of ['image', 'video'] as const) {
test(`${type} preview and selected thumbnail agree before and after restoring manual selection`, () => {
const draft = makeDraft(type, '16:9');
const outputs: CanvasOutputCard[] = [0, 1, 2].map((index) => ({
...draft,
id: `take-${index}`,
kind: 'output',
url: index === 2 ? null : `https://example.com/${index}.${type === 'image' ? 'png' : 'mp4'}`,
status: index === 2 ? 'processing' : 'succeeded',
sourceConfigCardId: draft.id,
generationRunId: `run-${index}`,
generationSnapshot: {
...draft,
capturedAt: `2026-10-18T00:0${index}:00.000Z`,
},
}));
for (const pinnedOutputId of [null, outputs[0].id, 'missing']) {
const restored = JSON.parse(JSON.stringify({ ...draft, pinnedOutputId }));
const presentation = buildGenerationCardPresentation({ card: restored, outputs });
const selected = presentation.takes.find((take) => take.isPinned);
assert.equal(selected?.id, pinnedOutputId === outputs[0].id ? outputs[0].id : outputs[1].id);
assert.equal(presentation.latestOutputUrl, selected?.url);
assert.equal(presentation.status, 'processing');
}
});
}

test('draft frame size follows portrait and landscape ratios', () => {
assert.deepEqual(resolveDraftShapeSize(null, makeDraft('video', '9:16')), {
w: 236,
Expand Down
40 changes: 16 additions & 24 deletions src/components/beatcanvas/use-beatcanvas-react-flow-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ import type {
CanvasDraftCard,
CanvasOutputCard,
} from '@/core/beatcanvas/canvas-types';
import { buildGenerationTakes } from '@/core/beatcanvas/generation-history';
import {
buildGenerationTakes,
resolvePinnedGenerationOutputId,
} from '@/core/beatcanvas/generation-history';
import {
isCanvasDraftCard,
isCanvasOutputCard,
Expand Down Expand Up @@ -186,21 +189,13 @@ export const buildGenerationCardPresentation = ({
) || left.id.localeCompare(right.id)
);
const latestOutput = orderedOutputs[orderedOutputs.length - 1] ?? null;
const pinnedOutput = card.pinnedOutputId
? orderedOutputs.find(
(output) =>
output.id === card.pinnedOutputId &&
output.status === 'succeeded' &&
Boolean(output.url || output.resultText)
) ?? null
: null;
const latestSucceededOutput = [...orderedOutputs]
.reverse()
.find(
(output) =>
output.status === 'succeeded' &&
Boolean(output.url || output.resultText)
);
const selectedOutputId = resolvePinnedGenerationOutputId({
outputs: orderedOutputs,
pinnedOutputId: card.pinnedOutputId,
});
const selectedOutput = orderedOutputs.find(
(output) => output.id === selectedOutputId && output.status === 'succeeded'
);
const isAnalysis = card.generationMode === 'analysis';
const analysisReportCount = isAnalysis
? orderedOutputs.filter(
Expand All @@ -213,14 +208,10 @@ export const buildGenerationCardPresentation = ({
isAnalysis,
latestOutputUrl: isAnalysis
? null
: pinnedOutput?.url ?? latestSucceededOutput?.url ?? card.url ?? null,
latestOutputText:
isAnalysis
? null
: pinnedOutput?.resultText ??
latestSucceededOutput?.resultText ??
card.resultText ??
null,
: (selectedOutput?.url ?? card.url ?? null),
latestOutputText: isAnalysis
? null
: (selectedOutput?.resultText ?? card.resultText ?? null),
analysisReportCount,
takes: buildGenerationTakes({
outputs: orderedOutputs,
Expand Down Expand Up @@ -1224,6 +1215,7 @@ export function useBeatCanvasReactFlowAdapter({
setCanvasCard(completedOutputCard);
setCanvasCard({
...draftCard,
pinnedOutputId: outputCardId,
url: isAnalysisOutput ? null : (url ?? null),
resultText: isAnalysisOutput ? null : resultText,
});
Expand Down
36 changes: 36 additions & 0 deletions src/core/beatcanvas/generation-history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,42 @@ const makeOutput = (
},
});

for (const type of ['image', 'video'] as const) {
test(`${type} defaults to the newest usable take regardless of input order`, () => {
const older = { ...makeOutput('old', '2026-10-18T00:00:00.000Z'), type };
const newer = { ...makeOutput('new', '2026-10-18T00:01:00.000Z'), type };
const pending = { ...makeOutput('pending', '2026-10-18T00:02:00.000Z', 'processing'), type };
const empty = { ...makeOutput('empty', '2026-10-18T00:03:00.000Z'), type, url: null };
const failed = { ...makeOutput('failed', '2026-10-18T00:04:00.000Z', 'failed'), type };
for (const outputs of [[older, pending, newer, empty, failed], [failed, empty, newer, pending, older]]) {
const originalOrder = outputs.map((output) => output.id);
for (const pinnedOutputId of [null, 'missing', failed.id, empty.id, pending.id]) {
assert.equal(resolvePinnedGenerationOutputId({ outputs, pinnedOutputId }), newer.id);
const takes = buildGenerationTakes({ outputs, pinnedOutputId });
assert.equal(takes.find((take) => take.isPinned)?.id, newer.id);
assert.equal(takes.find((take) => take.id === newer.id)?.takeNumber, 2);
}
assert.equal(resolvePinnedGenerationOutputId({ outputs, pinnedOutputId: older.id }), older.id);
assert.deepEqual(outputs.map((output) => output.id), originalOrder);
}
const draft = { ...older, id: 'draft-1', kind: 'generation' as const, pinnedOutputId: null };
const cards = Object.fromEntries([draft, older, newer, pending, failed].map((card) => [card.id, card]));
assert.equal(resolveConcreteCanvasMediaCard({ cards, card: draft })?.id, newer.id);
});
}

test('fallback handles empty histories, only failed outputs and equal timestamps deterministically', () => {
const time = '2026-10-18T00:00:00.000Z';
assert.equal(resolvePinnedGenerationOutputId({ outputs: [] }), null);
assert.equal(resolvePinnedGenerationOutputId({ outputs: [makeOutput('failed', time, 'failed')] }), null);
for (const status of ['succeeded', 'pending'] as const) {
const a = makeOutput('a', time, status);
const b = makeOutput('b', time, status);
assert.equal(resolvePinnedGenerationOutputId({ outputs: [a, b] }), 'b');
assert.equal(resolvePinnedGenerationOutputId({ outputs: [b, a] }), 'b');
}
});

test('resolves a generation node to its pinned concrete output asset', () => {
const older = makeOutput('output-old', '2026-08-16T00:00:00.000Z');
const newer = makeOutput('output-new', '2026-08-16T00:02:00.000Z');
Expand Down
21 changes: 13 additions & 8 deletions src/core/beatcanvas/generation-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,21 +40,26 @@ export const resolvePinnedGenerationOutputId = ({
outputs: CanvasOutputCard[];
pinnedOutputId?: string | null;
}) => {
const newestFirst = [...outputs]
.filter((output) => output.status !== 'failed')
.sort(
(left, right) =>
right.generationSnapshot.capturedAt.localeCompare(
left.generationSnapshot.capturedAt
) || right.id.localeCompare(left.id)
);
const hasResult = (output: CanvasOutputCard) =>
output.status === 'succeeded' && Boolean(output.url || output.resultText);
if (
pinnedOutputId &&
outputs.some((output) => output.id === pinnedOutputId)
newestFirst.some((output) => output.id === pinnedOutputId && hasResult(output))
) {
return pinnedOutputId;
}

return (
outputs.find(
(output) =>
output.status === 'succeeded' &&
Boolean(output.url || output.resultText)
)
?.id ??
outputs[0]?.id ??
newestFirst.find(hasResult)?.id ??
newestFirst[0]?.id ??
null
);
};
Expand Down
65 changes: 65 additions & 0 deletions tests/e2e/workspace.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,71 @@ async function project(request: APIRequestContext, name: string) {
return (await response.json()).id as string;
}

for (const type of ['image', 'video'] as const) {
test(`Canvas ${type} defaults to latest take and persists manual history selection`, async ({ page, request }) => {
const projectId = await project(request, `Latest ${type} take QA`);
const fixture = await readFile(type === 'image' ? 'public/logo.png' : 'tests/fixtures/tiny-black.mp4');
const urls: string[] = [];
const assetIds: string[] = [];
for (const index of [1, 2]) {
const uploaded = await request.post(`/api/app/projects/${projectId}/assets`, {
headers,
multipart: { file: {
name: `take-${index}.${type === 'image' ? 'png' : 'mp4'}`,
mimeType: type === 'image' ? 'image/png' : 'video/mp4',
buffer: Buffer.concat([fixture, Buffer.from(`take-${index}`)]),
} },
});
expect(uploaded.ok()).toBeTruthy();
const { asset } = await uploaded.json();
urls.push(asset.publicUrl);
assetIds.push(asset.id);
}
const draft = {
id: 'qa-latest-draft', kind: 'generation', type, name: 'Latest take QA', url: null,
prompt: 'Local version selection test', referenceCardIds: [], workflowTemplateId: null,
status: 'idle', error: null, modelId: type === 'image' ? 'gpt-image-2.5-flare' : 'seedance-2.0',
aspectRatio: '16:9', outputQuality: '720p', duration: '8s', mode: 'quality',
variant: 'standard', quality: 'standard', sourceGenerationId: null,
};
const outputs = [0, 1, 2].map((index) => ({
...draft, id: `qa-take-${index}`, kind: 'output',
assetId: assetIds[index] ?? null,
status: index === 2 ? 'processing' : 'succeeded', url: urls[index] ?? null,
sourceConfigCardId: draft.id, generationRunId: `qa-run-${index}`,
generationSnapshot: { ...draft, capturedAt: `2026-10-18T00:0${index}:00.000Z` },
}));
const applied = await mcp(request, 'bdesign_canvas_apply', { projectId, operations: [draft, ...outputs].map((card) => ({
type: 'upsert_card', card, frame: { x: 200, y: 160, w: 420, h: 236 },
})) });
expect(applied.structuredContent.result.ok, JSON.stringify(applied)).toBe(true);
const snapshot = await (await request.get(`/api/app/projects/${projectId}/snapshot`)).json();
expect(snapshot.document.cards).toHaveLength(4);
await page.goto(`/canvas/${projectId}`);
const node = page.locator(`[data-card-id="${draft.id}"]`);
await expect(node).toBeVisible();
await expect(node.getByRole('button', { name: 'Take 2', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'false');
if (type === 'image') {
await expect(node.locator('img').first()).toHaveAttribute('src', urls[1]);
} else {
await node.getByRole('button', { name: /^View result:/ }).click();
await expect(page.locator('video').first()).toHaveAttribute('src', urls[1]);
await page.getByRole('dialog').getByRole('button').click();
}
await node.getByRole('button', { name: 'Take 1', exact: true }).click();
await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('dialog').getByRole('button').click();
await expect.poll(async () => {
const state = await (await request.get(`/api/app/projects/${projectId}/snapshot`)).json();
return state.document.cards.find((card: { id: string }) => card.id === draft.id)?.pinnedOutputId;
}).toBe(outputs[0].id);
await page.reload();
await expect(node.getByRole('button', { name: 'Take 1', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.screenshot({ path: `test-results/latest-${type}-take.png` });
});
}

test('Canvas UI commands and MCP edits share durable state; unchanged polling is lightweight', async ({ page, request }) => {
const id = await project(request, 'Canvas concurrency QA');
const initial = { id: 'qa-draft', kind: 'generation', type: 'image', name: 'QA draft', prompt: 'Original prompt', modelId: 'gpt-image-2.5-flare' };
Expand Down
Binary file added tests/fixtures/tiny-black.mp4
Binary file not shown.