From bf7ecc3544496e9bd32907780d2b36375283934c Mon Sep 17 00:00:00 2001 From: KKKK Date: Tue, 25 Aug 2026 20:43:27 +0800 Subject: [PATCH 1/2] fix: harden canvas generation reference retries Authorize saved project media precisely, refresh only safe expired zero-upload intents, and preserve one-shot submission semantics with regression coverage. --- .../effects/generation-upload-intent.test.ts | 82 +++++++++++++ src/core/effects/generation-upload-intent.ts | 60 ++++++++++ .../project-reference-authorization.test.ts | 84 ++++++++++++++ .../project-reference-authorization.ts | 32 +++++ src/core/effects/submit-generation.ts | 109 +++++++++++++++--- 5 files changed, 350 insertions(+), 17 deletions(-) create mode 100644 src/core/effects/project-reference-authorization.test.ts create mode 100644 src/core/effects/project-reference-authorization.ts diff --git a/src/core/effects/generation-upload-intent.test.ts b/src/core/effects/generation-upload-intent.test.ts index 48d9197..9fd1ee1 100644 --- a/src/core/effects/generation-upload-intent.test.ts +++ b/src/core/effects/generation-upload-intent.test.ts @@ -8,7 +8,9 @@ import { claimGenerationUploadSlot, completeGenerationUploadSlot, consumeGenerationUploadIntent, + failGenerationUploadIntent, GenerationIntentQuotaError, + getGenerationUploadIntentAdmissionState, issueGenerationUploadIntent, } from './generation-upload-intent'; @@ -228,6 +230,86 @@ test('zero-upload intents remain valid once and expired intents are rejected', a } }); +test('classifies intent failures so only safe zero-upload expiry can auto-refresh', async () => { + const { client, db } = await createTestDb(); + try { + const zeroUploadId = await issueGenerationUploadIntent({ + projectId: 'project-1', + effectId: 401, + expectedUploadCount: 0, + now: new Date(1_000), + dbClient: db, + }); + assert.deepEqual( + await getGenerationUploadIntentAdmissionState({ + intentId: zeroUploadId, + projectId: 'project-1', + effectId: 401, + now: new Date(601_000), + dbClient: db, + }), + { status: 'expired', refreshableWithoutUploads: true } + ); + + const uploadIntentId = await issueGenerationUploadIntent({ + projectId: 'project-1', + effectId: 402, + expectedUploadCount: 1, + now: new Date(1_000), + dbClient: db, + }); + assert.deepEqual( + await getGenerationUploadIntentAdmissionState({ + intentId: uploadIntentId, + projectId: 'project-1', + effectId: 402, + now: new Date(2_000), + dbClient: db, + }), + { status: 'incomplete', expectedUploadCount: 1 } + ); + + assert.ok( + await consumeGenerationUploadIntent({ + intentId: zeroUploadId, + projectId: 'project-1', + effectId: 401, + referencedUrls: [], + now: new Date(2_000), + dbClient: db, + }) + ); + assert.deepEqual( + await getGenerationUploadIntentAdmissionState({ + intentId: zeroUploadId, + projectId: 'project-1', + effectId: 401, + now: new Date(3_000), + dbClient: db, + }), + { status: 'used' } + ); + + await failGenerationUploadIntent({ + intentId: uploadIntentId, + now: new Date(3_000), + dbClient: db, + }); + assert.deepEqual( + await getGenerationUploadIntentAdmissionState({ + intentId: uploadIntentId, + projectId: 'project-1', + effectId: 402, + now: new Date(4_000), + dbClient: db, + }), + { status: 'invalid' } + ); + } finally { + client.close(); + } +}); + test('zero-upload retries can reuse previously uploaded project files', async () => { const { client, db } = await createTestDb(); try { diff --git a/src/core/effects/generation-upload-intent.ts b/src/core/effects/generation-upload-intent.ts index 3d8e8e0..087dacc 100644 --- a/src/core/effects/generation-upload-intent.ts +++ b/src/core/effects/generation-upload-intent.ts @@ -55,6 +55,66 @@ export const normalizeExpectedUploadCount = (value: unknown) => { return count >= 0 && count <= MAX_GENERATION_UPLOADS ? count : null; }; +export type GenerationUploadIntentAdmissionState = + | { status: 'missing' | 'invalid' | 'used' } + | { status: 'expired'; refreshableWithoutUploads: boolean } + | { status: 'incomplete' | 'ready'; expectedUploadCount: number }; + +export async function getGenerationUploadIntentAdmissionState({ + intentId, + projectId, + effectId, + now = new Date(), + dbClient, +}: { + intentId: string; + projectId: string; + effectId: number; + now?: Date; + dbClient?: DbClient; +}): Promise { + const db = await resolveDb(dbClient); + const rows = await db + .select() + .from(generationUploadIntent) + .where(eq(generationUploadIntent.id, intentId)) + .limit(1); + const intent = rows[0]; + + if (!intent) return { status: 'missing' }; + if (intent.projectId !== projectId || intent.effectId !== effectId) { + return { status: 'invalid' }; + } + if (intent.status === 'submitting' || intent.status === 'consumed') { + return { status: 'used' }; + } + if (intent.status !== 'pending') { + return { status: 'invalid' }; + } + if (intent.expiresAt <= now) { + return { + status: 'expired', + refreshableWithoutUploads: + intent.expectedUploadCount === 0 && + intent.reservedUploadCount === 0 && + intent.completedUploadCount === 0, + }; + } + if ( + intent.reservedUploadCount !== intent.expectedUploadCount || + intent.completedUploadCount !== intent.expectedUploadCount + ) { + return { + status: 'incomplete', + expectedUploadCount: intent.expectedUploadCount, + }; + } + return { + status: 'ready', + expectedUploadCount: intent.expectedUploadCount, + }; +} + export async function issueGenerationUploadIntent({ projectId, effectId, diff --git a/src/core/effects/project-reference-authorization.test.ts b/src/core/effects/project-reference-authorization.test.ts new file mode 100644 index 0000000..4bb682b --- /dev/null +++ b/src/core/effects/project-reference-authorization.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { CanvasCard } from '@/core/beatcanvas/canvas-types'; +import type { ProjectSnapshotDocument } from '@/core/projects/project-snapshot'; +import { resolveAuthorizedProjectReferenceUrls } from './project-reference-authorization'; + +const card = ({ + id, + kind, + url, +}: { + id: string; + kind: CanvasCard['kind']; + url: string | null; +}): CanvasCard => + ({ + id, + assetId: null, + kind, + type: 'video', + name: id, + url, + resultText: null, + prompt: '', + referenceCardIds: [], + workflowTemplateId: null, + status: 'idle', + error: null, + modelId: '', + aspectRatio: '16:9', + outputQuality: '1k', + duration: '5s', + mode: 'quality', + variant: 'standard', + quality: 'standard', + sourceGenerationId: null, + sourceConfigCardId: null, + generationRunId: null, + generationSnapshot: null, + pinnedOutputId: null, + }) as CanvasCard; + +test('treats saved canvas media as project-authorized generation references', () => { + const canvasVideo = 'https://media.beatapi.io/inputs/canvas-video.mp4'; + const projectImage = 'https://media.beatapi.io/outputs/project-image.png'; + const unrelated = 'https://attacker.example/unrelated.mp4'; + const snapshot: ProjectSnapshotDocument = { + version: 3, + cards: [ + card({ id: 'canvas-video', kind: 'asset', url: canvasVideo }), + card({ id: 'unused-output', kind: 'output', url: unrelated }), + ], + frames: {}, + }; + + assert.deepEqual( + resolveAuthorizedProjectReferenceUrls({ + referencedUrls: [canvasVideo, projectImage], + projectAssetUrls: [projectImage, projectImage], + snapshot, + }), + [projectImage, canvasVideo] + ); +}); + +test('does not authorize a URL merely because another canvas URL is trusted', () => { + const canvasVideo = 'https://media.beatapi.io/inputs/canvas-video.mp4'; + const unknown = 'https://attacker.example/unknown.mp4'; + const snapshot: ProjectSnapshotDocument = { + version: 3, + cards: [card({ id: 'canvas-video', kind: 'generation', url: canvasVideo })], + frames: {}, + }; + + assert.deepEqual( + resolveAuthorizedProjectReferenceUrls({ + referencedUrls: [unknown], + projectAssetUrls: [], + snapshot, + }), + [] + ); +}); diff --git a/src/core/effects/project-reference-authorization.ts b/src/core/effects/project-reference-authorization.ts new file mode 100644 index 0000000..efafce6 --- /dev/null +++ b/src/core/effects/project-reference-authorization.ts @@ -0,0 +1,32 @@ +import type { ProjectSnapshotDocument } from '@/core/projects/project-snapshot'; + +export const resolveAuthorizedProjectReferenceUrls = ({ + referencedUrls, + projectAssetUrls, + snapshot, +}: { + referencedUrls: string[]; + projectAssetUrls: string[]; + snapshot: ProjectSnapshotDocument | null | undefined; +}) => { + const referenced = new Set( + referencedUrls.map((url) => url.trim()).filter(Boolean) + ); + const authorized = new Set(); + + for (const url of projectAssetUrls) { + const normalizedUrl = url.trim(); + if (referenced.has(normalizedUrl)) { + authorized.add(normalizedUrl); + } + } + + for (const card of snapshot?.cards ?? []) { + const normalizedUrl = card.url?.trim(); + if (normalizedUrl && referenced.has(normalizedUrl)) { + authorized.add(normalizedUrl); + } + } + + return [...authorized]; +}; diff --git a/src/core/effects/submit-generation.ts b/src/core/effects/submit-generation.ts index 9c908a1..d3f5d97 100644 --- a/src/core/effects/submit-generation.ts +++ b/src/core/effects/submit-generation.ts @@ -19,16 +19,22 @@ import { } from '@/core/effects/record-generation'; import { startBackendPollingForGeneration } from '@/core/effects/server-poller'; import { withGenerationSubmissionLock } from '@/core/effects/generation-submission-lock'; +import { resolveAuthorizedProjectReferenceUrls } from '@/core/effects/project-reference-authorization'; import { getGenerationPromptConstraints, validateGenerationPrompt, } from '@/core/effects/validation'; -import { getProject } from '@/core/projects/projects'; +import { + getProject, + loadProjectWithLatestSnapshot, +} from '@/core/projects/projects'; import { completeGenerationUploadIntent, consumeGenerationUploadIntent, failGenerationUploadIntent, getCompletedIntentUploads, + getGenerationUploadIntentAdmissionState, + issueGenerationUploadIntent, } from '@/core/effects/generation-upload-intent'; import { linkGenerationAsset, @@ -188,7 +194,7 @@ export async function submitEffectGeneration({ : adapterInput; const admission = await withGenerationSubmissionLock< | { result: SubmitEffectGenerationResult } - | { generationId: string } + | { generationId: string; intentId: string } >(async () => { const [activeProjectId, runningCount] = await Promise.all([ findActiveProject(), @@ -235,25 +241,94 @@ export async function submitEffectGeneration({ } satisfies SubmitEffectGenerationResult, }; } - const authorizedProjectUrls = await getProjectAssetUrls({ - projectId: normalizedProjectId, - urls: referencedUrls, + const [projectAssetUrls, projectState] = await Promise.all([ + getProjectAssetUrls({ + projectId: normalizedProjectId, + urls: referencedUrls, + }), + loadProjectWithLatestSnapshot({ projectId: normalizedProjectId }), + ]); + const authorizedProjectUrls = resolveAuthorizedProjectReferenceUrls({ + referencedUrls, + projectAssetUrls, + snapshot: projectState?.snapshot, }); - const intent = await consumeGenerationUploadIntent({ - intentId: normalizedIntentId, + let admittedIntentId = normalizedIntentId; + let intent = await consumeGenerationUploadIntent({ + intentId: admittedIntentId, projectId: normalizedProjectId, effectId, referencedUrls, authorizedProjectUrls, }); + let intentState = intent + ? null + : await getGenerationUploadIntentAdmissionState({ + intentId: admittedIntentId, + projectId: normalizedProjectId, + effectId, + }); + if ( + !intent && + intentState?.status === 'expired' && + intentState.refreshableWithoutUploads + ) { + await failGenerationUploadIntent({ intentId: admittedIntentId }); + admittedIntentId = await issueGenerationUploadIntent({ + projectId: normalizedProjectId, + effectId, + expectedUploadCount: 0, + }); + intent = await consumeGenerationUploadIntent({ + intentId: admittedIntentId, + projectId: normalizedProjectId, + effectId, + referencedUrls, + authorizedProjectUrls, + }); + intentState = intent + ? null + : await getGenerationUploadIntentAdmissionState({ + intentId: admittedIntentId, + projectId: normalizedProjectId, + effectId, + }); + } if (!intent) { + // Only retire an otherwise valid pending intent that failed reference + // authorization. A submitting intent may belong to an in-flight paid + // request, while an incomplete one may still have uploads finishing. + if (intentState?.status === 'ready') { + await failGenerationUploadIntent({ intentId: admittedIntentId }); + } + const failure = + intentState?.status === 'used' + ? { + code: 'GENERATION_ALREADY_SUBMITTED', + error: + 'This generation was already submitted. Check History before trying again.', + } + : intentState?.status === 'incomplete' + ? { + code: 'GENERATION_REFERENCES_PREPARING', + error: + 'A reference file is still being prepared. Wait a moment and try Generate again.', + } + : intentState?.status === 'ready' + ? { + code: 'GENERATION_REFERENCE_NOT_AUTHORIZED', + error: + 'A reference is no longer available in this canvas. Re-add it and try Generate again.', + } + : { + code: 'GENERATION_REQUEST_CHANGED', + error: + 'The generation request changed before submission. Try Generate again.', + }; return { result: { status: 409, - body: { - error: - 'Generation intent is invalid, expired, incomplete, or already used.', - }, + body: failure, } satisfies SubmitEffectGenerationResult, }; } @@ -265,7 +340,7 @@ export async function submitEffectGeneration({ input: recordedInput, }); if (!generationId) { - await failGenerationUploadIntent({ intentId: normalizedIntentId }); + await failGenerationUploadIntent({ intentId: admittedIntentId }); return { result: { status: 500, @@ -273,10 +348,10 @@ export async function submitEffectGeneration({ } satisfies SubmitEffectGenerationResult, }; } - return { generationId }; + return { generationId, intentId: admittedIntentId }; }); if ('result' in admission) return admission.result; - const { generationId } = admission; + const { generationId, intentId } = admission; try { await linkInputAssets(generationId, adapterInput); @@ -299,10 +374,10 @@ export async function submitEffectGeneration({ }) : transition.output; if (result.status === 'failed') { - await failGenerationUploadIntent({ intentId: normalizedIntentId }); + await failGenerationUploadIntent({ intentId }); } else { await finalizeIntentUploads({ - intentId: normalizedIntentId, + intentId, generationId, }); } @@ -327,7 +402,7 @@ export async function submitEffectGeneration({ }; } catch (cause) { const message = cause instanceof Error ? cause.message : 'Generation failed'; - await failGenerationUploadIntent({ intentId: normalizedIntentId }); + await failGenerationUploadIntent({ intentId }); await updateGenerationById({ id: generationId, status: 'failed', error: message }); return { status: 500, body: { error: message } }; } From e9f83591e411eef6b3ac74a5383c2f636ad25752 Mon Sep 17 00:00:00 2001 From: KKKK Date: Tue, 25 Aug 2026 20:43:41 +0800 Subject: [PATCH 2/2] feat: improve generated media and analysis results Add unified Preview and Download actions for generated media, double-click image previews, and separate selectable video-analysis report nodes with localized copy and regression coverage. --- messages/en.json | 4 + messages/zh.json | 4 + .../beatcanvas-media-preview.test.ts | 20 +- .../beatcanvas/beatcanvas-media-preview.ts | 18 +- .../beatcanvas/beatcanvas-shell.tsx | 13 +- .../beatcanvas/nodes/beatcanvas-node-copy.ts | 15 ++ .../generation-card-node-interaction.test.ts | 26 ++- .../beatcanvas/nodes/generation-card-node.tsx | 109 ++++++---- .../react-flow/beatcanvas-react-flow-types.ts | 1 + .../use-beatcanvas-react-flow-adapter.test.ts | 74 ++++++- .../use-beatcanvas-react-flow-adapter.ts | 188 ++++++++++++++++-- 11 files changed, 405 insertions(+), 67 deletions(-) diff --git a/messages/en.json b/messages/en.json index 17239f9..3bd7619 100644 --- a/messages/en.json +++ b/messages/en.json @@ -415,6 +415,10 @@ "shapes": { "imageGeneration": "Image generation", "videoGeneration": "Video generation", + "videoAnalysis": "Video analysis", + "analysisReport": "Analysis report", + "analysisComplete": "Analysis complete", + "analysisReportReady": "Report added after this node", "image": "Image", "video": "Video", "previewLoading": "Loading preview...", diff --git a/messages/zh.json b/messages/zh.json index 8978c14..56712d7 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -416,6 +416,10 @@ "shapes": { "imageGeneration": "图片生成", "videoGeneration": "视频生成", + "videoAnalysis": "视频分析", + "analysisReport": "分析报告", + "analysisComplete": "分析完成", + "analysisReportReady": "报告已生成在此节点后方", "image": "图片", "video": "视频", "previewLoading": "正在加载预览...", diff --git a/src/components/beatcanvas/beatcanvas-media-preview.test.ts b/src/components/beatcanvas/beatcanvas-media-preview.test.ts index 854ce80..ea98405 100644 --- a/src/components/beatcanvas/beatcanvas-media-preview.test.ts +++ b/src/components/beatcanvas/beatcanvas-media-preview.test.ts @@ -5,6 +5,7 @@ import type { CanvasCard } from '@/core/beatcanvas/canvas-types'; import { getPreviewableCanvasCardFromSelection, + isDownloadableCanvasCard, isPreviewableCanvasCard, resolveBatchCanvasCardSelection, } from './beatcanvas-media-preview'; @@ -31,8 +32,9 @@ const makeCard = (overrides: Partial): CanvasCard => ({ ...overrides, }); -test('recognizes real image and video asset cards as previewable', () => { +test('recognizes real asset and generated media cards as previewable and downloadable', () => { assert.equal(isPreviewableCanvasCard(makeCard({})), true); + assert.equal(isDownloadableCanvasCard(makeCard({})), true); assert.equal( isPreviewableCanvasCard( makeCard({ url: 'data:image/svg+xml;charset=utf-8,%3Csvg%3E' }) @@ -42,6 +44,22 @@ test('recognizes real image and video asset cards as previewable', () => { assert.equal(isPreviewableCanvasCard(makeCard({ type: 'video' })), true); assert.equal( isPreviewableCanvasCard(makeCard({ kind: 'generation' })), + true + ); + assert.equal( + isDownloadableCanvasCard(makeCard({ kind: 'generation' })), + true + ); + assert.equal( + isPreviewableCanvasCard( + makeCard({ kind: 'output', type: 'video' }) + ), + true + ); + assert.equal( + isPreviewableCanvasCard( + makeCard({ kind: 'generation', generationMode: 'analysis' }) + ), false ); }); diff --git a/src/components/beatcanvas/beatcanvas-media-preview.ts b/src/components/beatcanvas/beatcanvas-media-preview.ts index 71d8926..6eef50d 100644 --- a/src/components/beatcanvas/beatcanvas-media-preview.ts +++ b/src/components/beatcanvas/beatcanvas-media-preview.ts @@ -1,14 +1,24 @@ import type { CanvasCard } from '@/core/beatcanvas/canvas-types'; -export const isPreviewableCanvasCard = ( +export const isDownloadableCanvasCard = ( card: CanvasCard | null | undefined ) => Boolean( card?.url && - card.kind === 'asset' && + card.generationMode !== 'analysis' && + !( + card.type === 'image' && card.url.startsWith('data:image/svg+xml') + ) + ); + +export const isPreviewableCanvasCard = ( + card: CanvasCard | null | undefined +) => + Boolean( + isDownloadableCanvasCard(card) && + card && (card.type === 'video' || - (card.type === 'image' && - !card.url.startsWith('data:image/svg+xml'))) + card.type === 'image') ); export const getPreviewableCanvasCardFromSelection = ({ diff --git a/src/components/beatcanvas/beatcanvas-shell.tsx b/src/components/beatcanvas/beatcanvas-shell.tsx index 5e9ee5a..34f099c 100644 --- a/src/components/beatcanvas/beatcanvas-shell.tsx +++ b/src/components/beatcanvas/beatcanvas-shell.tsx @@ -61,6 +61,7 @@ import { registerCardConnectorCallback } from './beatcanvas-card-connector-bridg import type { BeatCanvasPreviewMedia } from './beatcanvas-media-preview-overlay'; import { getPreviewableCanvasCardFromSelection, + isDownloadableCanvasCard, resolveBatchCanvasCardSelection, } from './beatcanvas-media-preview'; import BeatCanvasSidebar from './beatcanvas-sidebar'; @@ -856,22 +857,12 @@ export function BeatCanvasShell({ [canvasCards, selectedCanvasCardIds, selectedGroupCards] ); - const isDownloadableCanvasCard = useCallback( - (card: CanvasCard | null | undefined) => { - if (!card?.url || card.kind !== 'asset') { - return false; - } - - return !card.url.startsWith('data:image/svg+xml'); - }, - [] - ); const downloadableGroupCards = useMemo( () => effectiveSelectedGroupCards.filter((card: CanvasCard) => isDownloadableCanvasCard(card) ), - [effectiveSelectedGroupCards, isDownloadableCanvasCard] + [effectiveSelectedGroupCards] ); const isSingleDownloadable = isDownloadableCanvasCard(selectedSingleCard); const previewableSelectedCard = useMemo( diff --git a/src/components/beatcanvas/nodes/beatcanvas-node-copy.ts b/src/components/beatcanvas/nodes/beatcanvas-node-copy.ts index 0b2c1a0..0130a37 100644 --- a/src/components/beatcanvas/nodes/beatcanvas-node-copy.ts +++ b/src/components/beatcanvas/nodes/beatcanvas-node-copy.ts @@ -6,6 +6,9 @@ type BeatCanvasNodeLocale = 'en' | 'zh'; type BeatCanvasNodeCopy = { imageGeneration: string; videoGeneration: string; + videoAnalysis: string; + analysisComplete: string; + analysisReportReady: string; image: string; video: string; previewLoading: string; @@ -52,6 +55,18 @@ const getNodeCopyForLocale = (locale: BeatCanvasNodeLocale): BeatCanvasNodeCopy {}, { locale } ), + videoAnalysis: m['AppShell.studio.canvas.shapes.videoAnalysis']( + {}, + { locale } + ), + analysisComplete: m['AppShell.studio.canvas.shapes.analysisComplete']( + {}, + { locale } + ), + analysisReportReady: m['AppShell.studio.canvas.shapes.analysisReportReady']( + {}, + { locale } + ), image: m['AppShell.studio.canvas.shapes.image']({}, { locale }), video: m['AppShell.studio.canvas.shapes.video']({}, { locale }), previewLoading: m['AppShell.studio.canvas.shapes.previewLoading']( diff --git a/src/components/beatcanvas/nodes/generation-card-node-interaction.test.ts b/src/components/beatcanvas/nodes/generation-card-node-interaction.test.ts index f1e86fb..0f30c5b 100644 --- a/src/components/beatcanvas/nodes/generation-card-node-interaction.test.ts +++ b/src/components/beatcanvas/nodes/generation-card-node-interaction.test.ts @@ -15,7 +15,7 @@ test('generation media remains a full-card drag surface', () => { ); assert.match( source, - / { + assert.match( + source, + / event\.stopPropagation\(\)\}/ + ); +}); + test('generated videos expose a direct playback entry', () => { assert.match(source, /const handlePreviewLatestOutput/); assert.match( @@ -40,3 +55,12 @@ test('generated videos expose a direct playback entry', () => { / { + assert.match( + source, + / 0) + ); const isEmptySlot = !hasResult && !isFailed; const emptySlotAccent = cardMediaType === 'video' ? 'var(--beat-graph)' : 'var(--beat-accent)'; @@ -60,9 +65,11 @@ export function GenerationCardNode({ const isInsideGroup = Boolean(internalNode?.parentId); const displayLabel = label || - (cardMediaType === 'image' - ? shapeCopy.imageGeneration - : shapeCopy.videoGeneration); + (isAnalysis + ? shapeCopy.videoAnalysis + : cardMediaType === 'image' + ? shapeCopy.imageGeneration + : shapeCopy.videoGeneration); const isCompactActionNode = w <= 128 && h <= 128; const visibleTakes = takes.slice(-MAX_VISIBLE_TAKES); const showTakeStrip = @@ -91,7 +98,7 @@ export function GenerationCardNode({ const handlePreviewLatestOutput = () => { if ( !latestOutputUrl || - cardMediaType !== 'video' || + isAnalysis || typeof window === 'undefined' ) { return; @@ -99,7 +106,7 @@ export function GenerationCardNode({ window.dispatchEvent( new CustomEvent('beatcanvas:preview-media', { detail: { - type: 'video', + type: cardMediaType, url: latestOutputUrl, title: displayLabel, }, @@ -247,14 +254,34 @@ export function GenerationCardNode({ ) : null} {hasResult ? ( isAnalysis && latestOutputText ? ( -
-
+
+
{displayLabel}
-

- {latestOutputText} -

+