diff --git a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md index d6b3ae675..0572907cc 100644 --- a/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md +++ b/docs/【编辑器】画板音乐生成入口设计-2026-06-18.md @@ -43,6 +43,7 @@ - `assetKind="sound-effect"` - `assetKind="background-music"` - 音频结果以小型音频卡加入画布,卡片底部使用融入卡片的自定义播放器组件承载进度、时间和音量,播放 / 暂停只收口到卡片中央图标按钮;生成成功后同时保存为 OSS 私有对象、画布资源和账号级素材库素材,响应携带 `objectKey` / `assetObjectId` 供后续换签和复用。 +- 从素材库拖放音效或背景音乐时,卡片中心必须落在鼠标松手对应的画布位置,不叠加点击添加素材时使用的级联错位量;音频卡除中央播放按钮、底部进度 / 音量控件、标签和信息按钮外,其余卡片区域都作为选择与拖拽热区。 - 普通素材上传入口首版支持图片、MP3 和 MP4;MP3 / MP4 先走 OSS 直传和 asset object confirm,再以素材库素材保存,其他音视频格式暂不开放。 - 音频结果卡片底部显示播放器辅助控件,不在卡片左下角或悬停左上角展示时长;提示词固定显示在卡片左上角。 - 音频结果卡片底部播放器辅助控件仅在鼠标悬停音频卡片时从下方滑入显示,移出后向下滑出收起。 @@ -124,6 +125,7 @@ POST /api/editor/audios/background-music/generations - 点击 `生成游戏背景音乐` 后出现背景音乐面板,字段为 `gpt_description_prompt`,右侧固定显示 `Suno` 模型胶囊,不展示 `make_instrumental`。 - 音效提交到 `/api/editor/audios/sound-effects/generations`,背景音乐提交到 `/api/editor/audios/background-music/generations`。 - 成功后画布新增音频卡,能通过卡片中央播放按钮播放,底部进度、时间和音量控件可操作。 +- 从素材库拖放音效或背景音乐后,音频卡中心位于鼠标松手位置;拖动卡片任意非播放 / 播放器控件区域都能移动图层,点击中央播放按钮只播放或暂停,不启动拖拽。 - 成功后音频素材自动出现在账号级素材库;从素材库再次添加到画布时仍恢复为 `mediaType="audio"` 音频图层。 - 普通素材上传 MP3 / MP4 会写入 OSS 并进入素材库;MP3 添加到画布为音频图层,MP4 添加到画布为视频图层。 - 成功后的音频卡展示提示词;卡片中央按未悬停图标、悬停播放、播放中暂停切换,不在底部重复显示播放 / 暂停按钮,播放器辅助控件直接融入卡片底部。 diff --git a/src/components/image-editor/ImageCanvasEditorModel.test.ts b/src/components/image-editor/ImageCanvasEditorModel.test.ts index e5af5a158..d5dce4279 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.test.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.test.ts @@ -169,7 +169,7 @@ describe('ImageCanvasEditorModel', () => { }); }); - it('creates a layer from an account asset at the requested screen point', () => { + it('creates a cascaded layer from an account asset near the requested screen point', () => { const asset: EditorAsset = { id: 'asset-1', label: '角色草图', @@ -210,6 +210,31 @@ describe('ImageCanvasEditorModel', () => { expect(layer.y).toBe(12); }); + it('centers a dropped asset exactly at the requested screen point', () => { + const asset: EditorAsset = { + id: 'asset-drop', + label: '投放素材', + src: 'data:image/png;base64,drop', + width: 640, + height: 480, + folderId: 'project', + sourceKind: 'uploaded', + sourceType: 'uploaded', + persisted: true, + }; + + const layer = createLayerFromAsset( + asset, + 3, + { x: 20, y: 40, scale: 2 }, + { x: 420, y: 340 }, + { applyCascadeOffset: false }, + ); + + expect(layer.x + layer.width / 2).toBe(200); + expect(layer.y + layer.height / 2).toBe(150); + }); + it('preserves source resource ids from the persisted asset library', () => { const library = normalizeAssetLibrary({ folders: [ diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 66b02fce5..83b30e4c5 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -138,6 +138,7 @@ export function createLayerFromAsset( index: number, viewport: CanvasViewport, screenCenter: { x: number; y: number }, + options: { applyCascadeOffset?: boolean } = {}, ): CanvasLayer { const { width, height } = resolveLayerResolutionSize( asset.width, @@ -151,7 +152,7 @@ export function createLayerFromAsset( }; const worldCenterX = (safeScreenCenter.x - viewport.x) / safeScale; const worldCenterY = (safeScreenCenter.y - viewport.y) / safeScale; - const offset = index * 34; + const offset = options.applyCascadeOffset === false ? 0 : index * 34; const assetKind: CanvasAssetKind | undefined = asset.mediaType === 'video' ? 'video' diff --git a/src/components/image-editor/ImageCanvasWorldView.test.tsx b/src/components/image-editor/ImageCanvasWorldView.test.tsx index 441d96468..f73afc989 100644 --- a/src/components/image-editor/ImageCanvasWorldView.test.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.test.tsx @@ -934,6 +934,50 @@ describe('ImageCanvasWorldView', () => { expect(props.onLayerClick).not.toHaveBeenCalled(); }); + it('starts layer dragging from the audio card outside playback controls', () => { + const layer = createLayer({ + title: '拖拽音效', + src: 'data:audio/mpeg;base64,audio', + mediaType: 'audio', + assetKind: 'sound-effect', + width: 420, + height: 120, + originalWidth: 420, + originalHeight: 120, + durationSeconds: 12, + }); + const { props } = renderWorldView({ + layers: [layer], + hoveredLayerId: layer.id, + }); + + const layerButton = screen.getByRole('button', { name: '选择拖拽音效' }); + const audioCard = layerButton.querySelector( + '.image-canvas-editor__audio-card', + ); + const playbackButton = within(layerButton).getByRole('button', { + name: '播放拖拽音效', + }); + const controls = layerButton.querySelector( + '.image-canvas-editor__audio-card-controls', + ); + + expect(audioCard).toBeTruthy(); + expect(controls).toBeTruthy(); + + fireEvent.pointerDown(audioCard as HTMLElement); + + expect(props.onLayerPointerDown).toHaveBeenCalledWith( + expect.any(Object), + layer, + ); + + fireEvent.pointerDown(playbackButton); + fireEvent.pointerDown(controls as HTMLElement); + + expect(props.onLayerPointerDown).toHaveBeenCalledTimes(1); + }); + it('places layer type labels to the left of the information action', () => { const layer = createLayer({ assetKind: 'character' }); const { props } = renderWorldView({ layers: [layer] }); diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx index d61c6f0f7..020386c18 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.test.tsx @@ -134,7 +134,7 @@ function AssetCanvasBridgeHarness({
@@ -234,7 +234,11 @@ describe('useImageCanvasAssetCanvasBridge', () => { }); expect(appendCanvasLayersWithResources).toHaveBeenCalledWith([ - expect.objectContaining({ sourceAssetId: 'asset-1' }), + expect.objectContaining({ + sourceAssetId: 'asset-1', + x: 75, + y: 190, + }), ]); expect(screen.getByTestId('drag').textContent).toBe('none'); expect(screen.getByTestId('drop-target').textContent).toBe('-'); diff --git a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts index fd93a6006..73148f038 100644 --- a/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts +++ b/src/components/image-editor/useImageCanvasAssetCanvasBridge.ts @@ -139,6 +139,7 @@ export function useImageCanvasAssetCanvasBridge({ x: position?.x ?? canvasSize.width / 2, y: position?.y ?? canvasSize.height / 2, }, + { applyCascadeOffset: position === undefined }, ); captureCanvasHistory(); appendCanvasLayersWithResources([nextLayer]); diff --git a/src/index.css b/src/index.css index bc652a8f5..02d739bdd 100644 --- a/src/index.css +++ b/src/index.css @@ -5714,14 +5714,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock { .image-canvas-editor__audio-card-playback { position: absolute; - inset: 0; + left: 50%; + top: 50%; z-index: 1; display: grid; + width: 2.75rem; + height: 2.75rem; place-items: center; border: 0; + border-radius: 999px; background: transparent; color: rgba(255, 255, 255, 0.9); cursor: pointer; + transform: translate(-50%, -50%); } .image-canvas-editor__audio-card-playback:hover {