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
4 changes: 4 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
4 changes: 4 additions & 0 deletions messages/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,10 @@
"shapes": {
"imageGeneration": "图片生成",
"videoGeneration": "视频生成",
"videoAnalysis": "视频分析",
"analysisReport": "分析报告",
"analysisComplete": "分析完成",
"analysisReportReady": "报告已生成在此节点后方",
"image": "图片",
"video": "视频",
"previewLoading": "正在加载预览...",
Expand Down
20 changes: 19 additions & 1 deletion src/components/beatcanvas/beatcanvas-media-preview.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { CanvasCard } from '@/core/beatcanvas/canvas-types';

import {
getPreviewableCanvasCardFromSelection,
isDownloadableCanvasCard,
isPreviewableCanvasCard,
resolveBatchCanvasCardSelection,
} from './beatcanvas-media-preview';
Expand All @@ -31,8 +32,9 @@ const makeCard = (overrides: Partial<CanvasCard>): 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' })
Expand All @@ -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
);
});
Expand Down
18 changes: 14 additions & 4 deletions src/components/beatcanvas/beatcanvas-media-preview.ts
Original file line number Diff line number Diff line change
@@ -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 = ({
Expand Down
13 changes: 2 additions & 11 deletions src/components/beatcanvas/beatcanvas-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions src/components/beatcanvas/nodes/beatcanvas-node-copy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'](
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ test('generation media remains a full-card drag surface', () => {
);
assert.match(
source,
/<img[\s\S]*?draggable=\{false\}\s+className="nowheel"/
/<img[\s\S]*?draggable=\{false\}[\s\S]*?className="nowheel"/
);
});

Expand All @@ -29,6 +29,21 @@ test('generation node leaves prompt and parameters to the attached Composer', ()
assert.match(source, /Take \$\{take\.takeNumber\}/);
});

test('analysis reports expose selectable read-only text without dragging the node', () => {
assert.match(
source,
/<textarea[\s\S]*?readOnly[\s\S]*?value=\{latestOutputText\}/
);
assert.match(
source,
/className="nodrag nopan nowheel[^"]*cursor-text[^"]*selection:bg-/
);
assert.match(
source,
/onPointerDown=\{\(event\) => event\.stopPropagation\(\)\}/
);
});

test('generated videos expose a direct playback entry', () => {
assert.match(source, /const handlePreviewLatestOutput/);
assert.match(
Expand All @@ -40,3 +55,12 @@ test('generated videos expose a direct playback entry', () => {
/<video[\s\S]*?onDoubleClick=\{[\s\S]*?handlePreviewLatestOutput/
);
});

test('generated images open the same unified media preview on double click', () => {
assert.match(
source,
/<img[\s\S]*?onDoubleClick=\{[\s\S]*?handlePreviewLatestOutput/
);
assert.match(source, /type: cardMediaType/);
assert.match(source, /cursor: 'zoom-in'/);
});
109 changes: 75 additions & 34 deletions src/components/beatcanvas/nodes/generation-card-node.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,16 @@ export function GenerationCardNode({
isAnalysis = false,
latestOutputUrl = null,
latestOutputText = null,
analysisReportCount = 0,
takes = [],
} = props;
const isBusy = status === 'pending' || status === 'processing';
const isFailed = status === 'failed';
const hasResult = Boolean(latestOutputUrl || latestOutputText);
const hasResult = Boolean(
latestOutputUrl ||
latestOutputText ||
(isAnalysis && analysisReportCount > 0)
);
const isEmptySlot = !hasResult && !isFailed;
const emptySlotAccent =
cardMediaType === 'video' ? 'var(--beat-graph)' : 'var(--beat-accent)';
Expand All @@ -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 =
Expand Down Expand Up @@ -91,15 +98,15 @@ export function GenerationCardNode({
const handlePreviewLatestOutput = () => {
if (
!latestOutputUrl ||
cardMediaType !== 'video' ||
isAnalysis ||
typeof window === 'undefined'
) {
return;
}
window.dispatchEvent(
new CustomEvent('beatcanvas:preview-media', {
detail: {
type: 'video',
type: cardMediaType,
url: latestOutputUrl,
title: displayLabel,
},
Expand Down Expand Up @@ -247,14 +254,34 @@ export function GenerationCardNode({
) : null}
{hasResult ? (
isAnalysis && latestOutputText ? (
<div className="nowheel h-full overflow-y-auto p-4 text-left text-[12px] leading-5 text-[var(--beat-text-2)]">
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--beat-graph)]">
<div className="nodrag nopan nowheel flex h-full cursor-default flex-col p-4 text-left">
<div className="mb-2 flex shrink-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.12em] text-[var(--beat-graph)]">
<ScanSearch className="size-3.5" />
{displayLabel}
</div>
<p className="whitespace-pre-wrap break-words">
{latestOutputText}
</p>
<textarea
aria-label={displayLabel}
readOnly
spellCheck={false}
value={latestOutputText}
className="nodrag nopan nowheel min-h-0 flex-1 cursor-text resize-none overflow-y-auto border-0 bg-transparent p-0 text-[12px] leading-5 text-[var(--beat-text-2)] outline-none selection:bg-[rgba(127,176,242,0.28)] selection:text-[var(--beat-text-1)] focus-visible:ring-0"
onPointerDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
/>
</div>
) : isAnalysis && analysisReportCount > 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
<div className="grid size-12 place-items-center rounded-2xl border border-[rgba(127,176,242,0.28)] bg-[var(--beat-graph-soft)] text-[var(--beat-graph)] shadow-[0_10px_24px_rgba(0,0,0,0.24)]">
<ScanSearch className="size-6" aria-hidden="true" />
</div>
<div>
<div className="text-[13px] font-semibold text-[var(--beat-text-1)]">
{shapeCopy.analysisComplete}
</div>
<div className="mt-1 text-[11px] leading-4 text-[var(--beat-text-3)]">
{shapeCopy.analysisReportReady}
</div>
</div>
</div>
) : cardMediaType === 'video' ? (
<video
Expand Down Expand Up @@ -282,12 +309,18 @@ export function GenerationCardNode({
src={latestOutputUrl ?? undefined}
alt={displayLabel}
draggable={false}
onDoubleClick={(event) => {
event.preventDefault();
event.stopPropagation();
handlePreviewLatestOutput();
}}
className="nowheel"
style={{
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
cursor: 'zoom-in',
}}
/>
)
Expand All @@ -303,30 +336,38 @@ export function GenerationCardNode({
gap: 12,
}}
>
<svg
width="86"
height="64"
viewBox="0 0 104 76"
fill="none"
aria-hidden="true"
style={{
display: 'block',
maxWidth: isInsideGroup ? '26%' : '24%',
height: 'auto',
color: isFailed
? 'rgba(255,107,115,0.22)'
: isEmptySlot
? emptySlotAccent
: PLACEHOLDER_COLOR,
opacity: isEmptySlot ? 0.42 : isInsideGroup ? 0.72 : 1,
}}
>
<circle cx="72.5" cy="17.5" r="8.5" fill="currentColor" />
<path
d="M8.55 64.5C5.95 64.5 4.34 61.71 5.64 59.47L39.26 10.48C40.54 8.28 43.72 8.28 45 10.48L67.12 48.44L75.04 35.66C76.37 33.52 79.52 33.58 80.77 35.77L99.3 59.61C100.55 61.86 98.93 64.5 96.37 64.5H8.55Z"
fill="currentColor"
{isAnalysis ? (
<ScanSearch
aria-hidden="true"
className="size-12 text-[var(--beat-graph)] opacity-40"
strokeWidth={1.35}
/>
</svg>
) : (
<svg
width="86"
height="64"
viewBox="0 0 104 76"
fill="none"
aria-hidden="true"
style={{
display: 'block',
maxWidth: isInsideGroup ? '26%' : '24%',
height: 'auto',
color: isFailed
? 'rgba(255,107,115,0.22)'
: isEmptySlot
? emptySlotAccent
: PLACEHOLDER_COLOR,
opacity: isEmptySlot ? 0.42 : isInsideGroup ? 0.72 : 1,
}}
>
<circle cx="72.5" cy="17.5" r="8.5" fill="currentColor" />
<path
d="M8.55 64.5C5.95 64.5 4.34 61.71 5.64 59.47L39.26 10.48C40.54 8.28 43.72 8.28 45 10.48L67.12 48.44L75.04 35.66C76.37 33.52 79.52 33.58 80.77 35.77L99.3 59.61C100.55 61.86 98.93 64.5 96.37 64.5H8.55Z"
fill="currentColor"
/>
</svg>
)}
{isFailed && (
<div
style={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export type GenerationCardNodeProps = {
isAnalysis?: boolean;
latestOutputUrl?: string | null;
latestOutputText?: string | null;
analysisReportCount?: number;
takes?: GenerationTake[];
};

Expand Down
Loading
Loading