b04b6cee2b
统一画板生成类面板的参数选项、热区交互、底部按钮和输入框布局。 新增并完善画板生成音乐入口、音效和背景音乐面板以及音频生成提交流程。 按 VectorEngine Suno 音效文档调整音效请求体、任务 ID 解析、轮询和音频地址解析。 补充 Seedance 视频生成版权限制报错的中文提示。 更新编辑器相关技术文档和回归测试。
460 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|