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} -

+