Files
Genarrative/src/components/image-editor/ImageCanvasWorldView.tsx
T
JenkenB b04b6cee2b 修复画板生成面板与音乐音效链路
统一画板生成类面板的参数选项、热区交互、底部按钮和输入框布局。

新增并完善画板生成音乐入口、音效和背景音乐面板以及音频生成提交流程。

按 VectorEngine Suno 音效文档调整音效请求体、任务 ID 解析、轮询和音频地址解析。

补充 Seedance 视频生成版权限制报错的中文提示。

更新编辑器相关技术文档和回归测试。
2026-06-18 20:24:52 +08:00

460 lines
16 KiB
TypeScript

import {
Film,
ImageIcon,
Info,
Music2,
PersonStanding,
UserRound,
Volume2,
} from 'lucide-react';
import type {
CSSProperties,
MouseEvent as ReactMouseEvent,
PointerEvent as ReactPointerEvent,
} from 'react';
import { PlatformIconButton } from '../common/PlatformIconButton';
import { PlatformPillBadge } from '../common/PlatformPillBadge';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { CANVAS_WORLD_SIZE } from './ImageCanvasEditorModel';
import type {
CanvasGenerationDialogState,
CanvasLayer,
CanvasMarqueeState,
CanvasViewport,
GenerateDialogState,
QuickEditPanelState,
SnapGuide,
} from './ImageCanvasEditorTypes';
import {
getGenerationFrameAriaLabel,
getGenerationFrameLabel,
getLayerKindLabel,
} from './ImageCanvasGenerationModel';
function getGenerationPlaceholderMeta(dialog: CanvasGenerationDialogState) {
switch (dialog.mode) {
case 'character':
return {
frameClassName: 'image-canvas-editor__generation-frame--character',
badgeClassName: 'character',
badgeLabel: '角色',
icon: <UserRound className="h-8 w-8" />,
labelIcon: <UserRound className="h-4 w-4" />,
};
case 'character-animation':
return {
frameClassName:
'image-canvas-editor__generation-frame--character-animation',
badgeClassName: 'character-animation',
badgeLabel: '动作',
icon: <PersonStanding className="h-8 w-8" />,
labelIcon: <PersonStanding className="h-4 w-4" />,
};
case 'spec':
return {
frameClassName: 'image-canvas-editor__generation-frame--spec',
badgeClassName: 'spec',
badgeLabel: '规范',
icon: <ImageIcon className="h-8 w-8" />,
labelIcon: <ImageIcon className="h-4 w-4" />,
};
case 'icon':
return {
frameClassName: 'image-canvas-editor__generation-frame--icon',
badgeClassName: 'icon',
badgeLabel: '图标',
icon: <ImageIcon className="h-8 w-8" />,
labelIcon: <ImageIcon className="h-4 w-4" />,
};
case 'ui-design':
return {
frameClassName: 'image-canvas-editor__generation-frame--ui-design',
badgeClassName: 'ui-design',
badgeLabel: 'UI设计',
icon: <ImageIcon className="h-8 w-8" />,
labelIcon: <ImageIcon className="h-4 w-4" />,
};
case 'video':
return {
frameClassName: 'image-canvas-editor__generation-frame--video',
badgeClassName: 'video',
badgeLabel: '视频',
icon: <Film className="h-8 w-8" />,
labelIcon: <Film className="h-4 w-4" />,
};
case 'audio-sound-effect':
return {
frameClassName:
'image-canvas-editor__generation-frame--audio-sound-effect',
badgeClassName: 'sound-effect',
badgeLabel: '音效',
icon: <Volume2 className="h-8 w-8" />,
labelIcon: <Volume2 className="h-4 w-4" />,
};
case 'audio-background-music':
return {
frameClassName:
'image-canvas-editor__generation-frame--audio-background-music',
badgeClassName: 'background-music',
badgeLabel: '背景音乐',
icon: <Music2 className="h-8 w-8" />,
labelIcon: <Music2 className="h-4 w-4" />,
};
default:
return {
frameClassName: '',
badgeClassName: null,
badgeLabel: null,
icon: <ImageIcon className="h-8 w-8" />,
labelIcon: <ImageIcon className="h-4 w-4" />,
};
}
}
function getInverseViewportScale(viewport: CanvasViewport) {
if (!Number.isFinite(viewport.scale) || viewport.scale <= 0) {
return 1;
}
return 1 / viewport.scale;
}
function buildInverseScaleStyle(
inverseScale: number,
): CSSProperties & Record<string, string> {
return {
'--image-canvas-editor-inverse-scale': String(inverseScale),
'--image-canvas-editor-corner-offset': `calc(0.35rem * ${inverseScale})`,
'--image-canvas-editor-kind-offset': `calc(0.38rem * ${inverseScale})`,
'--image-canvas-editor-beside-kind-offset': `calc(3.95rem * ${inverseScale})`,
'--image-canvas-editor-frame-label-offset': `calc(-1.35rem * ${inverseScale})`,
};
}
export type ImageCanvasWorldViewProps = {
viewport: CanvasViewport;
snapGuide: SnapGuide | null;
layers: CanvasLayer[];
selectedLayerIds: string[];
hoveredLayerId: string | null;
canvasMarquee: CanvasMarqueeState | null;
canvasGenerationDialogs: CanvasGenerationDialogState[];
generateDialog: GenerateDialogState | null;
quickEditPanel: QuickEditPanelState | null;
generationComposerStyle: CSSProperties | null;
onLayerPointerDown: (
event: ReactPointerEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => void;
onLayerClick: (
event: ReactMouseEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => void;
onLayerContextMenu: (
event: ReactMouseEvent<HTMLButtonElement>,
layer: CanvasLayer,
) => void;
onLayerMouseEnter: (layerId: string) => void;
onLayerMouseLeave: (layerId: string) => void;
onOpenLayerMetadata: (layer: CanvasLayer) => void;
onGenerationFramePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
) => void;
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
};
export function ImageCanvasWorldView({
viewport,
snapGuide,
layers,
selectedLayerIds,
hoveredLayerId,
canvasMarquee,
canvasGenerationDialogs,
generateDialog,
quickEditPanel,
generationComposerStyle,
onLayerPointerDown,
onLayerClick,
onLayerContextMenu,
onLayerMouseEnter,
onLayerMouseLeave,
onOpenLayerMetadata,
onGenerationFramePointerDown,
onActivateGenerationDialog,
}: ImageCanvasWorldViewProps) {
const inverseScale = getInverseViewportScale(viewport);
const inverseScaleStyle = buildInverseScaleStyle(inverseScale);
return (
<div
className="image-canvas-editor__world"
style={{
width: CANVAS_WORLD_SIZE,
height: CANVAS_WORLD_SIZE,
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
}}
>
{snapGuide?.vertical !== undefined ? (
<div
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--vertical"
data-testid="image-canvas-editor-snap-guide-vertical"
style={{ left: snapGuide.vertical }}
/>
) : null}
{snapGuide?.horizontal !== undefined ? (
<div
className="image-canvas-editor__snap-guide image-canvas-editor__snap-guide--horizontal"
data-testid="image-canvas-editor-snap-guide-horizontal"
style={{ top: snapGuide.horizontal }}
/>
) : null}
{layers
.slice()
.filter((layer) => !layer.hidden)
.sort((left, right) => left.zIndex - right.zIndex)
.map((layer) => {
const isSelected = selectedLayerIds.includes(layer.id);
const isHovered = hoveredLayerId === layer.id;
const kindLabel = getLayerKindLabel(layer);
const layerGeneratingLabel =
generateDialog?.mode === 'edit' &&
generateDialog.status === 'generating' &&
generateDialog.sourceLayerId === layer.id
? '修改中'
: quickEditPanel?.mode !== 'redraw' &&
quickEditPanel?.status === 'generating' &&
quickEditPanel.sourceLayerId === layer.id
? '生成中'
: null;
return (
<button
key={layer.id}
type="button"
className={`image-canvas-editor__layer ${isSelected ? 'image-canvas-editor__layer--selected' : ''} ${isHovered ? 'image-canvas-editor__layer--hovered' : ''} ${layerGeneratingLabel ? 'image-canvas-editor__layer--generating' : ''} ${layer.locked ? 'image-canvas-editor__layer--locked' : ''}`}
style={{
left: layer.x,
top: layer.y,
width: layer.width,
height: layer.height,
zIndex: layer.zIndex,
display: layer.hidden ? 'none' : undefined,
}}
onPointerDown={(event) => onLayerPointerDown(event, layer)}
onClick={(event) => onLayerClick(event, layer)}
onContextMenu={(event) => onLayerContextMenu(event, layer)}
onMouseEnter={() => onLayerMouseEnter(layer.id)}
onMouseLeave={() => onLayerMouseLeave(layer.id)}
aria-label={`选择${layer.title}`}
>
{layer.mediaType === 'audio' ? (
<div className="image-canvas-editor__audio-card">
<audio
src={layer.src}
controls
aria-label={`画布音频:${layer.title}`}
/>
</div>
) : (
<img
src={layer.src}
alt={
layer.mediaType === 'video'
? `画布视频:${layer.title}`
: `画布图片:${layer.title}`
}
style={{
transform:
layer.flipX || layer.flipY
? `scale(${layer.flipX ? -1 : 1}, ${
layer.flipY ? -1 : 1
})`
: undefined,
}}
/>
)}
{kindLabel ? (
<span
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${layer.assetKind}`}
style={inverseScaleStyle}
>
{kindLabel}
</span>
) : null}
<PlatformIconButton
asChild="spanButton"
variant="darkMini"
className={`image-canvas-editor__metadata-corner ${
kindLabel
? 'image-canvas-editor__metadata-corner--beside-kind'
: ''
}`}
label={`查看${layer.title}${
layer.mediaType === 'audio'
? '音频'
: layer.mediaType === 'video'
? '视频'
: '图片'
}信息`}
icon={<Info className="h-3 w-3" />}
style={inverseScaleStyle}
onClick={(event) => {
event.stopPropagation();
onOpenLayerMetadata(layer);
}}
onPointerDown={(event) => event.stopPropagation()}
/>
{isHovered ? (
<PlatformPillBadge
tone="lightOverlay"
size="xs"
className="image-canvas-editor__size-badge"
style={inverseScaleStyle}
>
{Math.round(layer.originalWidth)} x{' '}
{Math.round(layer.originalHeight)} px
</PlatformPillBadge>
) : null}
{layerGeneratingLabel ? (
<span
className="image-canvas-editor__generation-frame-progress image-canvas-editor__layer-generating-progress"
role="status"
>
{layerGeneratingLabel}
</span>
) : null}
</button>
);
})}
{canvasMarquee ? (
<div
className="image-canvas-editor__canvas-marquee"
aria-hidden="true"
style={{
left:
(Math.min(canvasMarquee.startX, canvasMarquee.currentX) -
viewport.x) /
viewport.scale,
top:
(Math.min(canvasMarquee.startY, canvasMarquee.currentY) -
viewport.y) /
viewport.scale,
width:
Math.abs(canvasMarquee.currentX - canvasMarquee.startX) /
viewport.scale,
height:
Math.abs(canvasMarquee.currentY - canvasMarquee.startY) /
viewport.scale,
}}
/>
) : null}
{canvasGenerationDialogs.map((dialog) => {
const hasGeneratedLayer = Boolean(
dialog.generatedLayerId &&
layers.some((layer) => layer.id === dialog.generatedLayerId),
);
return dialog.placeholder && !hasGeneratedLayer
? (() => {
const isFocused = generateDialog?.id === dialog.id;
const showFocusedChrome =
isFocused || dialog.status === 'generating';
const placeholderMeta = getGenerationPlaceholderMeta(dialog);
return (
<div
key={dialog.id}
className={`image-canvas-editor__generation-frame ${
placeholderMeta.frameClassName
} ${
dialog.status === 'generating'
? 'image-canvas-editor__generation-frame--generating'
: ''
} ${
isFocused
? 'image-canvas-editor__generation-frame--focused'
: ''
}`}
role="button"
tabIndex={0}
style={{
left: dialog.placeholder.x,
top: dialog.placeholder.y,
width: dialog.placeholder.width,
height: dialog.placeholder.height,
}}
aria-label={getGenerationFrameAriaLabel(dialog)}
onPointerDown={(event) =>
onGenerationFramePointerDown(event, dialog)
}
onDoubleClick={() => onActivateGenerationDialog(dialog)}
>
{showFocusedChrome ? (
<span
className="image-canvas-editor__generation-frame-label"
style={inverseScaleStyle}
>
{placeholderMeta.labelIcon}
{getGenerationFrameLabel(dialog)}
</span>
) : null}
{placeholderMeta.badgeLabel ? (
<span
className={`image-canvas-editor__kind-badge image-canvas-editor__kind-badge--${placeholderMeta.badgeClassName}`}
style={inverseScaleStyle}
>
{placeholderMeta.badgeLabel}
</span>
) : null}
{showFocusedChrome ? (
<span
className="image-canvas-editor__generation-frame-size"
style={inverseScaleStyle}
>
{dialog.placeholder.originalWidth} x{' '}
{dialog.placeholder.originalHeight}
</span>
) : null}
<span className="image-canvas-editor__generation-frame-icon">
{placeholderMeta.icon}
</span>
{dialog.status === 'generating' ? (
<span
className="image-canvas-editor__generation-frame-progress"
role="status"
>
生成中
</span>
) : null}
</div>
);
})()
: null;
})}
{(generateDialog?.mode === 'generate' ||
generateDialog?.mode === 'spec' ||
generateDialog?.mode === 'character' ||
generateDialog?.mode === 'icon' ||
generateDialog?.mode === 'ui-design' ||
generateDialog?.mode === 'character-animation' ||
generateDialog?.mode === 'video' ||
generateDialog?.mode === 'audio-sound-effect' ||
generateDialog?.mode === 'audio-background-music') &&
generateDialog.status === 'generating' &&
generationComposerStyle ? (
<PlatformStatusMessage
tone="info"
surface="platform"
size="xs"
className="image-canvas-editor__generate-status image-canvas-editor__generate-status--floating"
role="status"
style={generationComposerStyle}
>
生成中
</PlatformStatusMessage>
) : null}
</div>
);
}