Merge branch 'master' into codex/tiaoyitiao
This commit is contained in:
@@ -177,6 +177,23 @@ test('account panel uses compact binding cards and keeps logout actions at the b
|
||||
).toBe('true');
|
||||
});
|
||||
|
||||
test('account panel avoids bare bound label when wechat display name is missing', () => {
|
||||
renderAccountModal({
|
||||
entryMode: 'account',
|
||||
user: {
|
||||
...baseUser,
|
||||
wechatDisplayName: null,
|
||||
wechatAccount: 'openid_abcdef123456',
|
||||
},
|
||||
});
|
||||
|
||||
const accountDialog = screen.getByRole('dialog', { name: '账号信息' });
|
||||
expect(within(accountDialog).getByText('绑定微信')).toBeTruthy();
|
||||
expect(within(accountDialog).getByText('微信账号尾号 123456')).toBeTruthy();
|
||||
expect(within(accountDialog).queryByText('openid_abcdef123456')).toBeNull();
|
||||
expect(within(accountDialog).queryByText('已绑定')).toBeNull();
|
||||
});
|
||||
|
||||
test('account actions open in independent panels instead of inline expansion', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -109,6 +109,15 @@ function formatSessionTime(value: string) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatBoundWechatAccount(value: string | null | undefined) {
|
||||
const normalized = value?.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `微信账号尾号 ${normalized.slice(-6)}`;
|
||||
}
|
||||
|
||||
function SettingsEntryCard({
|
||||
label,
|
||||
detail,
|
||||
@@ -444,7 +453,9 @@ export function AccountModal({
|
||||
const boundPhoneNumber =
|
||||
user.phoneNumber?.trim() || user.phoneNumberMasked || '未绑定';
|
||||
const boundWechatDisplayName =
|
||||
user.wechatDisplayName?.trim() || (user.wechatBound ? '已绑定' : '未绑定');
|
||||
user.wechatDisplayName?.trim() ||
|
||||
formatBoundWechatAccount(user.wechatAccount) ||
|
||||
(user.wechatBound ? '微信账号已绑定' : '未绑定');
|
||||
|
||||
const sectionSummaries: Record<PrimarySettingsSection, string> = {
|
||||
appearance:
|
||||
|
||||
300
src/components/common/CreativeAudioInputPanel.test.tsx
Normal file
300
src/components/common/CreativeAudioInputPanel.test.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
CreativeAudioInputPanel,
|
||||
} from './CreativeAudioInputPanel';
|
||||
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
|
||||
|
||||
type TestAudioAsset = CreativeAudioAsset;
|
||||
|
||||
const originalMediaRecorder = globalThis.MediaRecorder;
|
||||
const originalMediaDevices = navigator.mediaDevices;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.MediaRecorder = originalMediaRecorder;
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: originalMediaDevices,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
|
||||
return {
|
||||
assetId: 'asset-test',
|
||||
audioSrc: 'blob:audio-preview',
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source: 'uploaded',
|
||||
prompt: 'hit.wav',
|
||||
durationMs: 800,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
overrides: Partial<ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>> = {},
|
||||
) {
|
||||
const onAssetChange = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const readFileAsAsset = vi.fn(async (file: File, source: 'uploaded' | 'recorded') =>
|
||||
buildAsset({
|
||||
audioSrc: `blob:${source}`,
|
||||
source,
|
||||
prompt: file.name,
|
||||
}),
|
||||
);
|
||||
|
||||
const rendered = render(
|
||||
<CreativeAudioInputPanel<TestAudioAsset>
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
asset={null}
|
||||
buildRecordedFileName={() => 'recorded-hit.webm'}
|
||||
onAssetChange={onAssetChange}
|
||||
onError={onError}
|
||||
readFileAsAsset={readFileAsAsset}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
|
||||
return { ...rendered, onAssetChange, onError, readFileAsAsset };
|
||||
}
|
||||
|
||||
function getUploadInput() {
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
return input!;
|
||||
}
|
||||
|
||||
test('音频面板按需显示最长限制标签', () => {
|
||||
renderPanel({ limitLabel: '最长 1 秒' });
|
||||
|
||||
expect(screen.getByText('最长 1 秒')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('音频面板未传限制标签时不渲染限制提示', () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.queryByText('最长 1 秒')).toBeNull();
|
||||
});
|
||||
|
||||
test('音频面板无资产时显示默认音效文案', () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByText('默认木鱼音')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('音频面板有预览地址时渲染 audio 控件', () => {
|
||||
const { container } = render(
|
||||
<CreativeAudioInputPanel<TestAudioAsset>
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
asset={buildAsset({ audioSrc: 'blob:preview' })}
|
||||
buildRecordedFileName={() => 'recorded-hit.webm'}
|
||||
onAssetChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('audio')?.getAttribute('src')).toBe(
|
||||
'blob:preview',
|
||||
);
|
||||
});
|
||||
|
||||
test('音频面板有资产但无预览地址时显示已选择状态', () => {
|
||||
renderPanel({ asset: buildAsset({ audioSrc: '' }) });
|
||||
|
||||
expect(screen.getByText('音效已选择')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('点击重置清空当前音频资产', () => {
|
||||
const onAssetChange = vi.fn();
|
||||
renderPanel({
|
||||
asset: buildAsset(),
|
||||
onAssetChange,
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '重置' }));
|
||||
|
||||
expect(onAssetChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('取消上传选择时不读取音频', () => {
|
||||
const { readFileAsAsset, onAssetChange } = renderPanel();
|
||||
|
||||
fireEvent.change(getUploadInput(), { target: { files: [] } });
|
||||
|
||||
expect(readFileAsAsset).not.toHaveBeenCalled();
|
||||
expect(onAssetChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('上传音频成功后清空错误并写入资产', async () => {
|
||||
const audioFile = new File(['audio'], 'hit.webm', { type: 'audio/webm' });
|
||||
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
|
||||
|
||||
fireEvent.change(getUploadInput(), { target: { files: [audioFile] } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(readFileAsAsset).toHaveBeenCalledWith(audioFile, 'uploaded'),
|
||||
);
|
||||
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
|
||||
expect(onError).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('上传音频失败时提示错误且不写入资产', async () => {
|
||||
const readFileAsAsset = vi.fn(async () => {
|
||||
throw new Error('音频最长 1 秒。');
|
||||
});
|
||||
const onAssetChange = vi.fn();
|
||||
const onError = vi.fn();
|
||||
renderPanel({ readFileAsAsset, onAssetChange, onError });
|
||||
|
||||
fireEvent.change(getUploadInput(), {
|
||||
target: {
|
||||
files: [new File(['audio'], 'hit.webm', { type: 'audio/webm' })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith('音频最长 1 秒。'));
|
||||
expect(onAssetChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('浏览器不支持录音时提示错误', async () => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
globalThis.MediaRecorder = undefined as unknown as typeof MediaRecorder;
|
||||
const { onError } = renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onError).toHaveBeenCalledWith('当前浏览器不支持录音。'),
|
||||
);
|
||||
});
|
||||
|
||||
test('录音启动失败时透传启动错误', async () => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: vi.fn(async () => {
|
||||
throw new Error('麦克风拒绝授权。');
|
||||
}),
|
||||
},
|
||||
});
|
||||
globalThis.MediaRecorder = class {
|
||||
start = vi.fn();
|
||||
stop = vi.fn();
|
||||
} as unknown as typeof MediaRecorder;
|
||||
const { onError } = renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith('麦克风拒绝授权。'));
|
||||
});
|
||||
|
||||
test('录音停止后按 recorded 来源读取音频', async () => {
|
||||
const stopTrack = vi.fn();
|
||||
const recorderInstances: Array<{
|
||||
ondataavailable: ((event: BlobEvent) => void) | null;
|
||||
onstop: (() => void) | null;
|
||||
}> = [];
|
||||
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: vi.fn(async () => ({
|
||||
getTracks: () => [{ stop: stopTrack }],
|
||||
})),
|
||||
},
|
||||
});
|
||||
globalThis.MediaRecorder = class {
|
||||
mimeType = 'audio/webm';
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
|
||||
constructor() {
|
||||
recorderInstances.push(this);
|
||||
}
|
||||
|
||||
start = vi.fn();
|
||||
|
||||
stop = vi.fn(() => {
|
||||
this.ondataavailable?.({
|
||||
data: new Blob(['recorded-audio'], { type: 'audio/webm' }),
|
||||
} as BlobEvent);
|
||||
this.onstop?.();
|
||||
});
|
||||
} as unknown as typeof MediaRecorder;
|
||||
|
||||
const { readFileAsAsset, onAssetChange } = renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止' }));
|
||||
|
||||
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
|
||||
const [recordedFile, source] = readFileAsAsset.mock.calls[0]!;
|
||||
expect(recordedFile).toBeInstanceOf(File);
|
||||
expect((recordedFile as File).name).toBe('recorded-hit.webm');
|
||||
expect(source).toBe('recorded');
|
||||
expect(stopTrack).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
|
||||
expect(recorderInstances).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('录音保存失败时提示错误', async () => {
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: vi.fn(async () => ({
|
||||
getTracks: () => [],
|
||||
})),
|
||||
},
|
||||
});
|
||||
globalThis.MediaRecorder = class {
|
||||
mimeType = 'audio/webm';
|
||||
ondataavailable: ((event: BlobEvent) => void) | null = null;
|
||||
onstop: (() => void) | null = null;
|
||||
start = vi.fn();
|
||||
stop = vi.fn(() => this.onstop?.());
|
||||
} as unknown as typeof MediaRecorder;
|
||||
const readFileAsAsset = vi.fn(async () => {
|
||||
throw new Error('音频声音过小,请重新录制或上传。');
|
||||
});
|
||||
const onError = vi.fn();
|
||||
renderPanel({ readFileAsAsset, onError });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '停止' })).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole('button', { name: '停止' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onError).toHaveBeenCalledWith('音频声音过小,请重新录制或上传。'),
|
||||
);
|
||||
});
|
||||
|
||||
test('禁用状态不启动录音也不允许上传', () => {
|
||||
const getUserMedia = vi.fn();
|
||||
Object.defineProperty(navigator, 'mediaDevices', {
|
||||
configurable: true,
|
||||
value: { getUserMedia },
|
||||
});
|
||||
const { container } = renderPanel({ disabled: true });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '录音' }));
|
||||
|
||||
expect(getUserMedia).not.toHaveBeenCalled();
|
||||
const input = container.querySelector('input[type="file"]');
|
||||
expect(input).not.toBeNull();
|
||||
expect((input as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
type CreativeAudioAsset,
|
||||
readCreativeAudioFileAsAsset,
|
||||
} from './creativeAudioFileAsset';
|
||||
import { trimLeadingSilenceFromRecordedAudioFile } from './creativeAudioSilenceTrim';
|
||||
|
||||
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
defaultLabel: string;
|
||||
limitLabel?: string;
|
||||
asset: TAsset | null;
|
||||
buildRecordedFileName: () => string;
|
||||
onAssetChange: (asset: TAsset | null) => void;
|
||||
@@ -25,6 +25,7 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
disabled = false,
|
||||
title,
|
||||
defaultLabel,
|
||||
limitLabel,
|
||||
asset,
|
||||
buildRecordedFileName,
|
||||
onAssetChange,
|
||||
@@ -64,8 +65,7 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
const file = new File([blob], buildRecordedFileName(), {
|
||||
type: blob.type,
|
||||
});
|
||||
void trimLeadingSilenceFromRecordedAudioFile(file)
|
||||
.then((trimmedFile) => readFileAsAsset(trimmedFile, 'recorded'))
|
||||
void readFileAsAsset(file, 'recorded')
|
||||
.then(onAssetChange)
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
@@ -95,8 +95,15 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
return (
|
||||
<section className="platform-subpanel rounded-[1.25rem] p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="text-sm font-black text-[var(--platform-text-strong)]">
|
||||
{title}
|
||||
</div>
|
||||
{limitLabel ? (
|
||||
<div className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-black text-[var(--platform-text-soft)]">
|
||||
{limitLabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{asset ? (
|
||||
<button
|
||||
|
||||
@@ -287,6 +287,118 @@ test('creative image input panel supports a preview-only main image mode', () =>
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('creative image input panel can preview the main image and keep upload on a corner button', () => {
|
||||
const onMainImageFileSelect = vi.fn();
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
mainImageClickMode="preview"
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
}}
|
||||
onMainImageFileSelect={onMainImageFileSelect}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看关卡图片' }));
|
||||
expect(screen.getByRole('dialog', { name: '查看关卡图片' })).toBeTruthy();
|
||||
expect(screen.getAllByAltText('拼图关卡图').length).toBeGreaterThanOrEqual(2);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '关闭关卡图片预览' }),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更换参考图' }));
|
||||
expect(inputClickSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
|
||||
target: {
|
||||
files: [new File(['a'], 'level-reference.png', { type: 'image/png' })],
|
||||
},
|
||||
});
|
||||
expect(onMainImageFileSelect).toHaveBeenCalledWith(expect.any(File));
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('creative image input panel can hide upload and history controls independently', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
uploadedImageSrc="/generated-puzzle-assets/session/level/image.png"
|
||||
uploadedImageAlt="拼图关卡图"
|
||||
canUploadMainImage={false}
|
||||
canUseImageHistory={false}
|
||||
mainImageInputId="level-image-upload-input"
|
||||
promptTextareaId="level-prompt-input"
|
||||
prompt="旧街灯牌下的猫。"
|
||||
promptLabel="画面描述"
|
||||
aiRedraw
|
||||
promptReferenceImages={[]}
|
||||
imageModelPicker={null}
|
||||
submitLabel="重新生成画面"
|
||||
submitDisabled={false}
|
||||
labels={{
|
||||
imageField: '画面图',
|
||||
uploadImage: '上传参考图',
|
||||
replaceImage: '更换参考图',
|
||||
emptyImageHint: '上传图片/填写画面描述',
|
||||
removeImage: '移除参考图',
|
||||
removeImageConfirmTitle: '移除参考图?',
|
||||
removeImageConfirmBody: '移除后可重新上传或选择历史图片。',
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
history: '选择历史图片',
|
||||
}}
|
||||
onMainImageFileSelect={() => {}}
|
||||
onMainImageRemove={() => {}}
|
||||
onAiRedrawChange={() => {}}
|
||||
onPromptChange={() => {}}
|
||||
onHistoryClick={() => {}}
|
||||
onSubmit={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '查看关卡图片' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '更换参考图' })).toBeNull();
|
||||
expect(
|
||||
screen.queryByLabelText('上传参考图', { selector: 'input' }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
|
||||
test('creative image input panel does not show empty upload hint over a non-removable image', () => {
|
||||
render(
|
||||
<CreativeImageInputPanel
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
@@ -28,6 +28,8 @@ export type CreativeImageInputPanelLabels = {
|
||||
promptReferenceUpload: string;
|
||||
promptReferencePreviewAlt: string;
|
||||
closePromptReferencePreview: string;
|
||||
previewMainImage?: string;
|
||||
closeMainImagePreview?: string;
|
||||
history?: string;
|
||||
};
|
||||
|
||||
@@ -37,6 +39,9 @@ export type CreativeImageInputPanelProps = {
|
||||
disabled?: boolean;
|
||||
isSubmitting?: boolean;
|
||||
mainImageMode?: 'edit' | 'preview';
|
||||
mainImageClickMode?: 'upload' | 'preview';
|
||||
canUploadMainImage?: boolean;
|
||||
canUseImageHistory?: boolean;
|
||||
canRemoveMainImage?: boolean;
|
||||
canToggleAiRedraw?: boolean;
|
||||
canUploadPromptReferences?: boolean;
|
||||
@@ -82,6 +87,9 @@ export function CreativeImageInputPanel({
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
mainImageMode = 'edit',
|
||||
mainImageClickMode = 'preview',
|
||||
canUploadMainImage = true,
|
||||
canUseImageHistory = true,
|
||||
canRemoveMainImage = true,
|
||||
canToggleAiRedraw = true,
|
||||
canUploadPromptReferences,
|
||||
@@ -117,8 +125,10 @@ export function CreativeImageInputPanel({
|
||||
onHistoryClick,
|
||||
onSubmit,
|
||||
}: CreativeImageInputPanelProps) {
|
||||
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewReferenceImage, setPreviewReferenceImage] =
|
||||
useState<CreativeImageInputReferenceImage | null>(null);
|
||||
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
|
||||
const [isRemoveImageConfirmOpen, setIsRemoveImageConfirmOpen] =
|
||||
useState(false);
|
||||
const showPrompt = mainImageMode === 'preview' || !uploadedImageSrc || aiRedraw;
|
||||
@@ -127,10 +137,19 @@ export function CreativeImageInputPanel({
|
||||
const promptReferenceUploadDisabled =
|
||||
disabled || promptReferenceImages.length >= promptReferenceLimit;
|
||||
const canEditMainImage = mainImageMode === 'edit';
|
||||
const isMainImageUploadEnabled = canEditMainImage && canUploadMainImage;
|
||||
const shouldShowHistoryButton =
|
||||
canEditMainImage && canUseImageHistory && Boolean(onHistoryClick);
|
||||
const shouldPreviewMainImage =
|
||||
mainImageClickMode === 'preview' && Boolean(uploadedImageSrc);
|
||||
const shouldShowMainImageUploadButton =
|
||||
isMainImageUploadEnabled && shouldPreviewMainImage;
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedImageSrc) {
|
||||
setPreviewReferenceImage(null);
|
||||
} else {
|
||||
setIsMainImagePreviewOpen(false);
|
||||
}
|
||||
}, [uploadedImageSrc]);
|
||||
|
||||
@@ -187,35 +206,48 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
<div className={imageFrameClassName}>
|
||||
<div className={imageCardClassName}>
|
||||
{canEditMainImage ? (
|
||||
<>
|
||||
<input
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
uploadedImageSrc ? labels.replaceImage : labels.uploadImage
|
||||
{isMainImageUploadEnabled ? (
|
||||
<input
|
||||
ref={mainImageInputRef}
|
||||
id={mainImageInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
disabled={disabled}
|
||||
aria-label={labels.uploadImage}
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc ? labels.replaceImage : labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
</>
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
) : null}
|
||||
{shouldPreviewMainImage ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 z-[2] cursor-zoom-in"
|
||||
aria-label={labels.previewMainImage ?? uploadedImageAlt}
|
||||
title={labels.previewMainImage ?? uploadedImageAlt}
|
||||
onClick={() => setIsMainImagePreviewOpen(true)}
|
||||
/>
|
||||
) : isMainImageUploadEnabled ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
title={
|
||||
uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage
|
||||
}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -232,7 +264,19 @@ export function CreativeImageInputPanel({
|
||||
</span>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(180deg,rgba(255,255,255,0.12)_0%,rgba(255,255,255,0.04)_42%,rgba(255,255,255,0.18)_100%)]" />
|
||||
{canEditMainImage && onHistoryClick ? (
|
||||
{shouldShowMainImageUploadButton ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => mainImageInputRef.current?.click()}
|
||||
className="absolute bottom-3 right-3 z-10 inline-flex h-10 w-10 items-center justify-center rounded-full border border-white/80 bg-white/94 text-[var(--platform-text-strong)] shadow-sm backdrop-blur transition hover:text-[var(--platform-accent)] disabled:cursor-not-allowed disabled:opacity-55"
|
||||
aria-label={labels.replaceImage}
|
||||
title={labels.replaceImage}
|
||||
>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
</button>
|
||||
) : null}
|
||||
{shouldShowHistoryButton ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
@@ -284,7 +328,7 @@ export function CreativeImageInputPanel({
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
) : canEditMainImage && !uploadedImageSrc ? (
|
||||
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
|
||||
@@ -477,6 +521,48 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isMainImagePreviewOpen && uploadedImageSrc ? (
|
||||
<div
|
||||
className="platform-modal-backdrop fixed inset-0 z-[82] flex items-center justify-center px-4 py-6"
|
||||
onClick={() => setIsMainImagePreviewOpen(false)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="creative-image-main-preview-title"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-4xl rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
||||
<div
|
||||
id="creative-image-main-preview-title"
|
||||
className="min-w-0 truncate text-sm font-black text-[var(--platform-text-strong)]"
|
||||
>
|
||||
{labels.previewMainImage ?? uploadedImageAlt}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={
|
||||
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
||||
}
|
||||
onClick={() => setIsMainImagePreviewOpen(false)}
|
||||
className="platform-profile-icon-button flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="h-full max-h-[82vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isRemoveImageConfirmOpen ? (
|
||||
<div className="platform-modal-backdrop fixed inset-0 z-[80] flex items-center justify-center px-4 py-6">
|
||||
<div
|
||||
|
||||
241
src/components/common/creativeAudioProcessing.test.ts
Normal file
241
src/components/common/creativeAudioProcessing.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
encodePcmChannelsToWavBlob,
|
||||
findAudibleFrameRange,
|
||||
normalizeAudioBufferSection,
|
||||
prepareCreativeAudioFileForLocalUse,
|
||||
} from './creativeAudioProcessing';
|
||||
|
||||
const originalAudioContext = globalThis.AudioContext;
|
||||
const originalCreateObjectUrl = URL.createObjectURL;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.AudioContext = originalAudioContext;
|
||||
URL.createObjectURL = originalCreateObjectUrl;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createAudioBufferStub(
|
||||
channels: number[][],
|
||||
sampleRate = 1000,
|
||||
): AudioBuffer {
|
||||
return {
|
||||
length: channels[0]?.length ?? 0,
|
||||
numberOfChannels: channels.length,
|
||||
sampleRate,
|
||||
duration: (channels[0]?.length ?? 0) / sampleRate,
|
||||
getChannelData: (channel: number) =>
|
||||
new Float32Array(channels[channel] ?? []),
|
||||
} as AudioBuffer;
|
||||
}
|
||||
|
||||
function installAudioContextMock(
|
||||
decodeAudioData: (bytes: ArrayBuffer) => Promise<AudioBuffer>,
|
||||
) {
|
||||
globalThis.AudioContext = class {
|
||||
decodeAudioData = decodeAudioData;
|
||||
close = vi.fn();
|
||||
} as unknown as typeof AudioContext;
|
||||
}
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects empty audio files', async () => {
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File([], 'empty.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('音频文件为空,请重新选择。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects non-audio files', async () => {
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['not-audio'], 'note.txt', { type: 'text/plain' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('请选择音频文件。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse reports decode failures', async () => {
|
||||
installAudioContextMock(async () => {
|
||||
throw new Error('decode failed');
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'broken.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('音频解码失败,请重新选择。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects when AudioContext is unavailable', async () => {
|
||||
globalThis.AudioContext = undefined as unknown as typeof AudioContext;
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('当前浏览器不支持音频处理。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects all-silent audio', async () => {
|
||||
installAudioContextMock(async () => createAudioBufferStub([[0, 0.001, 0]], 1000));
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'silent.webm', { type: 'audio/webm' }),
|
||||
'recorded',
|
||||
),
|
||||
).rejects.toThrow('音频声音过小,请重新录制或上传。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse allows audio exactly at the visible limit', async () => {
|
||||
URL.createObjectURL = vi.fn(() => 'blob:one-second-audio');
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([Array.from({ length: 1000 }, () => 0.2)], 1000),
|
||||
);
|
||||
|
||||
const asset = await prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'one-second.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
);
|
||||
|
||||
expect(asset.durationMs).toBe(1000);
|
||||
expect(asset.audioSrc).toBe('blob:one-second-audio');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse rejects audio longer than the visible limit after trimming', async () => {
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([[0, ...Array.from({ length: 1001 }, () => 0.2), 0]], 1000),
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'long.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).rejects.toThrow('音频最长 1 秒。');
|
||||
});
|
||||
|
||||
test('findAudibleFrameRange trims quiet leading and trailing frames', () => {
|
||||
const buffer = createAudioBufferStub([
|
||||
[0, 0.003, 0.02, 0.2, -0.03, 0.004],
|
||||
[0, 0, 0, 0.05, 0, 0],
|
||||
]);
|
||||
|
||||
expect(findAudibleFrameRange(buffer, 0.01)).toEqual({
|
||||
startFrame: 2,
|
||||
frameCount: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test('normalizeAudioBufferSection pulls samples toward -15 LKFS approximation', () => {
|
||||
const buffer = createAudioBufferStub([[0.02, -0.02, 0.02, -0.02]], 1000);
|
||||
const normalized = normalizeAudioBufferSection(
|
||||
buffer,
|
||||
{ startFrame: 0, frameCount: 4 },
|
||||
{ targetLkfs: -15, peakCeiling: 0.98 },
|
||||
);
|
||||
const channel = normalized[0];
|
||||
expect(channel).toBeDefined();
|
||||
const rms = Math.sqrt(
|
||||
channel!.reduce((sum, sample) => sum + sample * sample, 0) /
|
||||
channel!.length,
|
||||
);
|
||||
|
||||
expect(rms).toBeCloseTo(Math.pow(10, -15 / 20), 3);
|
||||
});
|
||||
|
||||
test('normalizeAudioBufferSection avoids clipping when target gain is too high', () => {
|
||||
const buffer = createAudioBufferStub([[0.8, -0.8, 0.4, -0.4]], 1000);
|
||||
const normalized = normalizeAudioBufferSection(
|
||||
buffer,
|
||||
{ startFrame: 0, frameCount: 4 },
|
||||
{ targetLkfs: 0, peakCeiling: 0.5 },
|
||||
);
|
||||
const channel = normalized[0];
|
||||
expect(channel).toBeDefined();
|
||||
const peak = Math.max(...channel!.map((sample) => Math.abs(sample)));
|
||||
|
||||
expect(peak).toBeLessThanOrEqual(0.5);
|
||||
});
|
||||
|
||||
test('normalizeAudioBufferSection rejects zero-energy sections', () => {
|
||||
const buffer = createAudioBufferStub([[0, 0, 0]], 1000);
|
||||
|
||||
expect(() =>
|
||||
normalizeAudioBufferSection(buffer, { startFrame: 0, frameCount: 3 }),
|
||||
).toThrow('音频声音过小,请重新录制或上传。');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse writes trimmed normalized wav blob', async () => {
|
||||
URL.createObjectURL = vi.fn(() => 'blob:processed-audio');
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([[0, 0, 0.12, -0.12, 0]], 1000),
|
||||
);
|
||||
|
||||
const asset = await prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
);
|
||||
const bytes = await asset.blob.arrayBuffer();
|
||||
|
||||
expect(asset.fileName).toBe('hit.wav');
|
||||
expect(asset.mimeType).toBe('audio/wav');
|
||||
expect(asset.audioSrc).toBe('blob:processed-audio');
|
||||
expect(asset.durationMs).toBe(2);
|
||||
expect(String.fromCharCode(...new Uint8Array(bytes, 0, 4))).toBe('RIFF');
|
||||
expect(String.fromCharCode(...new Uint8Array(bytes, 8, 4))).toBe('WAVE');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse still succeeds without object URL support', async () => {
|
||||
URL.createObjectURL = undefined as unknown as typeof URL.createObjectURL;
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([[0.12, -0.12]], 1000),
|
||||
);
|
||||
|
||||
const asset = await prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'hit.webm', { type: 'audio/webm' }),
|
||||
'recorded',
|
||||
);
|
||||
|
||||
expect(asset.audioSrc).toBe('');
|
||||
expect(asset.previewUrl).toBe('');
|
||||
});
|
||||
|
||||
test('prepareCreativeAudioFileForLocalUse normalizes processed wav file names', async () => {
|
||||
URL.createObjectURL = vi.fn(() => 'blob:processed-audio');
|
||||
installAudioContextMock(async () =>
|
||||
createAudioBufferStub([[0.12, -0.12]], 1000),
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], 'hit-sound', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).resolves.toMatchObject({ fileName: 'hit-sound.wav' });
|
||||
await expect(
|
||||
prepareCreativeAudioFileForLocalUse(
|
||||
new File(['audio-bytes'], ' ', { type: 'audio/webm' }),
|
||||
'uploaded',
|
||||
),
|
||||
).resolves.toMatchObject({ fileName: 'creative-audio.wav' });
|
||||
});
|
||||
|
||||
test('encodePcmChannelsToWavBlob writes pcm16 wav bytes', async () => {
|
||||
const blob = encodePcmChannelsToWavBlob(
|
||||
[new Float32Array([0.25, -0.5])],
|
||||
1000,
|
||||
);
|
||||
const bytes = await blob.arrayBuffer();
|
||||
const view = new DataView(bytes);
|
||||
|
||||
expect(blob.type).toBe('audio/wav');
|
||||
expect(view.getUint32(40, true)).toBe(4);
|
||||
expect(view.getInt16(44, true)).toBeCloseTo(8191, -1);
|
||||
expect(view.getInt16(46, true)).toBeCloseTo(-16384, -1);
|
||||
});
|
||||
308
src/components/common/creativeAudioProcessing.ts
Normal file
308
src/components/common/creativeAudioProcessing.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
type CreativeAudioAsset,
|
||||
} from './creativeAudioFileAsset';
|
||||
|
||||
type BrowserAudioGlobal = typeof globalThis & {
|
||||
webkitAudioContext?: typeof AudioContext;
|
||||
};
|
||||
|
||||
export type CreativeAudioSource = 'uploaded' | 'recorded';
|
||||
|
||||
export type PendingCreativeAudioAsset = CreativeAudioAsset & {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
blob: Blob;
|
||||
source: CreativeAudioSource;
|
||||
previewUrl: string;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type CreativeAudioProcessingOptions = {
|
||||
maxDurationMs?: number;
|
||||
silenceThreshold?: number;
|
||||
targetLkfs?: number;
|
||||
peakCeiling?: number;
|
||||
};
|
||||
|
||||
export type AudibleFrameRange = {
|
||||
startFrame: number;
|
||||
frameCount: number;
|
||||
};
|
||||
|
||||
const DEFAULT_MAX_DURATION_MS = 1000;
|
||||
const DEFAULT_SILENCE_THRESHOLD = 0.01;
|
||||
const DEFAULT_TARGET_LKFS = -15;
|
||||
const DEFAULT_PEAK_CEILING = 0.98;
|
||||
const WAV_HEADER_BYTE_LENGTH = 44;
|
||||
const WAV_BITS_PER_SAMPLE = 16;
|
||||
const WAV_BYTES_PER_SAMPLE = WAV_BITS_PER_SAMPLE / 8;
|
||||
|
||||
export async function prepareCreativeAudioFileForLocalUse(
|
||||
file: File,
|
||||
source: CreativeAudioSource,
|
||||
options: CreativeAudioProcessingOptions = {},
|
||||
): Promise<PendingCreativeAudioAsset> {
|
||||
validateCreativeAudioFile(file);
|
||||
|
||||
const decodedBuffer = await decodeCreativeAudioFile(file);
|
||||
const range = findAudibleFrameRange(
|
||||
decodedBuffer,
|
||||
options.silenceThreshold ?? DEFAULT_SILENCE_THRESHOLD,
|
||||
);
|
||||
if (!range) {
|
||||
throw new Error('音频声音过小,请重新录制或上传。');
|
||||
}
|
||||
|
||||
const durationMs = Math.round(
|
||||
(range.frameCount / decodedBuffer.sampleRate) * 1000,
|
||||
);
|
||||
const maxDurationMs = options.maxDurationMs ?? DEFAULT_MAX_DURATION_MS;
|
||||
if (durationMs > maxDurationMs) {
|
||||
throw new Error(`音频最长 ${formatDurationSeconds(maxDurationMs)} 秒。`);
|
||||
}
|
||||
|
||||
const normalized = normalizeAudioBufferSection(decodedBuffer, range, {
|
||||
targetLkfs: options.targetLkfs ?? DEFAULT_TARGET_LKFS,
|
||||
peakCeiling: options.peakCeiling ?? DEFAULT_PEAK_CEILING,
|
||||
});
|
||||
const blob = encodePcmChannelsToWavBlob(normalized, decodedBuffer.sampleRate);
|
||||
const fileName = buildProcessedAudioFileName(file.name);
|
||||
const previewUrl =
|
||||
typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
|
||||
? URL.createObjectURL(blob)
|
||||
: '';
|
||||
|
||||
return {
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: previewUrl,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs,
|
||||
fileName,
|
||||
mimeType: blob.type,
|
||||
blob,
|
||||
previewUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export function findAudibleFrameRange(
|
||||
buffer: AudioBuffer,
|
||||
silenceThreshold = DEFAULT_SILENCE_THRESHOLD,
|
||||
): AudibleFrameRange | null {
|
||||
const threshold = Math.max(0, silenceThreshold);
|
||||
let startFrame: number | null = null;
|
||||
let endFrame: number | null = null;
|
||||
|
||||
for (let frameIndex = 0; frameIndex < buffer.length; frameIndex += 1) {
|
||||
if (isFrameAudible(buffer, frameIndex, threshold)) {
|
||||
startFrame = frameIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (startFrame === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let frameIndex = buffer.length - 1; frameIndex >= startFrame; frameIndex -= 1) {
|
||||
if (isFrameAudible(buffer, frameIndex, threshold)) {
|
||||
endFrame = frameIndex;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endFrame === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
startFrame,
|
||||
frameCount: endFrame - startFrame + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAudioBufferSection(
|
||||
buffer: AudioBuffer,
|
||||
range: AudibleFrameRange,
|
||||
options: Pick<CreativeAudioProcessingOptions, 'targetLkfs' | 'peakCeiling'> = {},
|
||||
) {
|
||||
const channelCount = Math.max(1, buffer.numberOfChannels);
|
||||
const targetLkfs = options.targetLkfs ?? DEFAULT_TARGET_LKFS;
|
||||
const peakCeiling = Math.max(0.01, options.peakCeiling ?? DEFAULT_PEAK_CEILING);
|
||||
const channels = Array.from({ length: channelCount }, (_value, channelIndex) =>
|
||||
copyChannelSection(buffer, channelIndex, range),
|
||||
);
|
||||
const stats = measurePcmStats(channels);
|
||||
if (stats.rms <= 0 || stats.peak <= 0) {
|
||||
throw new Error('音频声音过小,请重新录制或上传。');
|
||||
}
|
||||
|
||||
// 浏览器端近似:用全通道 RMS 估算 LKFS,再按 GY/T 377-2023 目标值拉到 -15 LKFS。
|
||||
const targetLinear = Math.pow(10, targetLkfs / 20);
|
||||
const loudnessGain = targetLinear / stats.rms;
|
||||
const protectedGain = Math.min(loudnessGain, peakCeiling / stats.peak);
|
||||
|
||||
return channels.map((channel) =>
|
||||
Float32Array.from(channel, (sample) => clampSample(sample * protectedGain)),
|
||||
);
|
||||
}
|
||||
|
||||
export function encodePcmChannelsToWavBlob(
|
||||
channels: Float32Array[],
|
||||
sampleRate: number,
|
||||
) {
|
||||
const channelCount = Math.max(1, channels.length);
|
||||
const frameCount = channels[0]?.length ?? 0;
|
||||
const dataByteLength = frameCount * channelCount * WAV_BYTES_PER_SAMPLE;
|
||||
const output = new ArrayBuffer(WAV_HEADER_BYTE_LENGTH + dataByteLength);
|
||||
const view = new DataView(output);
|
||||
|
||||
writeAscii(view, 0, 'RIFF');
|
||||
view.setUint32(4, 36 + dataByteLength, true);
|
||||
writeAscii(view, 8, 'WAVE');
|
||||
writeAscii(view, 12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, channelCount, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * channelCount * WAV_BYTES_PER_SAMPLE, true);
|
||||
view.setUint16(32, channelCount * WAV_BYTES_PER_SAMPLE, true);
|
||||
view.setUint16(34, WAV_BITS_PER_SAMPLE, true);
|
||||
writeAscii(view, 36, 'data');
|
||||
view.setUint32(40, dataByteLength, true);
|
||||
|
||||
let outputOffset = WAV_HEADER_BYTE_LENGTH;
|
||||
for (let frameIndex = 0; frameIndex < frameCount; frameIndex += 1) {
|
||||
for (let channelIndex = 0; channelIndex < channelCount; channelIndex += 1) {
|
||||
const sample = channels[channelIndex]?.[frameIndex] ?? 0;
|
||||
view.setInt16(outputOffset, toSignedPcm16(sample), true);
|
||||
outputOffset += WAV_BYTES_PER_SAMPLE;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([output], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
function validateCreativeAudioFile(file: File) {
|
||||
if (file.size <= 0) {
|
||||
throw new Error('音频文件为空,请重新选择。');
|
||||
}
|
||||
if (!resolveFileMimeType(file).startsWith('audio/')) {
|
||||
throw new Error('请选择音频文件。');
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeCreativeAudioFile(file: File) {
|
||||
const AudioContextConstructor = getAudioContextConstructor();
|
||||
if (!AudioContextConstructor) {
|
||||
throw new Error('当前浏览器不支持音频处理。');
|
||||
}
|
||||
|
||||
const context = new AudioContextConstructor();
|
||||
try {
|
||||
const bytes = await file.arrayBuffer();
|
||||
return await context.decodeAudioData(bytes.slice(0));
|
||||
} catch {
|
||||
throw new Error('音频解码失败,请重新选择。');
|
||||
} finally {
|
||||
void context.close();
|
||||
}
|
||||
}
|
||||
|
||||
function getAudioContextConstructor() {
|
||||
const audioGlobal = globalThis as BrowserAudioGlobal;
|
||||
return audioGlobal.AudioContext ?? audioGlobal.webkitAudioContext ?? null;
|
||||
}
|
||||
|
||||
function resolveFileMimeType(file: File) {
|
||||
if (file.type.trim()) {
|
||||
return file.type.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function isFrameAudible(
|
||||
buffer: AudioBuffer,
|
||||
frameIndex: number,
|
||||
threshold: number,
|
||||
) {
|
||||
for (
|
||||
let channelIndex = 0;
|
||||
channelIndex < buffer.numberOfChannels;
|
||||
channelIndex += 1
|
||||
) {
|
||||
const channelData = buffer.getChannelData(channelIndex);
|
||||
if (Math.abs(channelData[frameIndex] ?? 0) > threshold) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function copyChannelSection(
|
||||
buffer: AudioBuffer,
|
||||
channelIndex: number,
|
||||
range: AudibleFrameRange,
|
||||
) {
|
||||
const source =
|
||||
channelIndex < buffer.numberOfChannels
|
||||
? buffer.getChannelData(channelIndex)
|
||||
: new Float32Array(buffer.length);
|
||||
const output = new Float32Array(range.frameCount);
|
||||
for (let frameOffset = 0; frameOffset < range.frameCount; frameOffset += 1) {
|
||||
output[frameOffset] = source[range.startFrame + frameOffset] ?? 0;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function measurePcmStats(channels: Float32Array[]) {
|
||||
let sumSquares = 0;
|
||||
let peak = 0;
|
||||
let sampleCount = 0;
|
||||
for (const channel of channels) {
|
||||
for (const sample of channel) {
|
||||
sumSquares += sample * sample;
|
||||
peak = Math.max(peak, Math.abs(sample));
|
||||
sampleCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
rms: sampleCount > 0 ? Math.sqrt(sumSquares / sampleCount) : 0,
|
||||
peak,
|
||||
};
|
||||
}
|
||||
|
||||
function clampSample(sample: number) {
|
||||
return Math.max(-1, Math.min(1, sample));
|
||||
}
|
||||
|
||||
function toSignedPcm16(sample: number) {
|
||||
const clamped = clampSample(sample);
|
||||
return clamped < 0
|
||||
? Math.round(clamped * 0x8000)
|
||||
: Math.round(clamped * 0x7fff);
|
||||
}
|
||||
|
||||
function writeAscii(view: DataView, offset: number, value: string) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
view.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
}
|
||||
|
||||
function buildProcessedAudioFileName(fileName: string) {
|
||||
const normalizedName = fileName.trim();
|
||||
if (!normalizedName) {
|
||||
return 'creative-audio.wav';
|
||||
}
|
||||
return /\.[^.]+$/u.test(normalizedName)
|
||||
? normalizedName.replace(/\.[^.]+$/u, '.wav')
|
||||
: `${normalizedName}.wav`;
|
||||
}
|
||||
|
||||
function formatDurationSeconds(durationMs: number) {
|
||||
return Number.isInteger(durationMs / 1000)
|
||||
? String(durationMs / 1000)
|
||||
: (durationMs / 1000).toFixed(1);
|
||||
}
|
||||
@@ -743,6 +743,20 @@ function getPlatformRecommendRuntimeKind(
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
function resolveRecommendEntryShareStage(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): PublishShareModalPayload['stage'] {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish-runtime';
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle-gallery-detail';
|
||||
}
|
||||
|
||||
return 'work-detail';
|
||||
}
|
||||
|
||||
function isRecommendRuntimeReadyForEntry(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
state: RecommendRuntimeState,
|
||||
@@ -1920,6 +1934,13 @@ function buildPuzzleCreationUrlState(
|
||||
};
|
||||
}
|
||||
|
||||
function pushPuzzleResultHistoryEntry(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
pushAppHistoryPath('/creation/puzzle/result');
|
||||
writeCreationUrlState(buildPuzzleCreationUrlState(session));
|
||||
}
|
||||
|
||||
function buildPuzzleDraftRuntimeUrlState(
|
||||
item: PuzzleWorkSummary,
|
||||
levelId?: string | null,
|
||||
@@ -5205,6 +5226,22 @@ export function PlatformEntryFlowShellImpl({
|
||||
[],
|
||||
);
|
||||
|
||||
const openRecommendShareModal = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
openPublishShareModal({
|
||||
title: entry.worldName,
|
||||
publicWorkCode,
|
||||
stage: resolveRecommendEntryShareStage(entry),
|
||||
});
|
||||
},
|
||||
[openPublishShareModal],
|
||||
);
|
||||
|
||||
const openRpgPublishShareModal = useCallback(
|
||||
async (profile: CustomWorldProfile | null | undefined) => {
|
||||
const profileId = profile?.id?.trim();
|
||||
@@ -6635,6 +6672,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
pushPuzzleResultHistoryEntry(response.session);
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, null),
|
||||
@@ -7000,6 +7038,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
pushPuzzleResultHistoryEntry(latestSession);
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, null),
|
||||
@@ -7992,6 +8031,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
pushPuzzleResultHistoryEntry(response.session);
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, null),
|
||||
@@ -11926,6 +11966,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleRun(run);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleRuntimeReturnStage('puzzle-result');
|
||||
pushPuzzleResultHistoryEntry(puzzleSession);
|
||||
openPuzzleRuntimeStage(
|
||||
setSelectionStage,
|
||||
buildPuzzleDraftRuntimeUrlState(item, options.levelId ?? null),
|
||||
@@ -11940,8 +11981,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
},
|
||||
[
|
||||
isPuzzleBusy,
|
||||
puzzleSession?.publishedProfileId,
|
||||
puzzleSession?.sessionId,
|
||||
puzzleSession,
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
@@ -17799,6 +17839,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
onLikeRecommendEntry={(entry) => {
|
||||
likePublicWork(entry);
|
||||
}}
|
||||
onShareRecommendEntry={(entry) => {
|
||||
openRecommendShareModal(entry);
|
||||
}}
|
||||
onRemixRecommendEntry={(entry) => {
|
||||
remixPublicWork(entry);
|
||||
}}
|
||||
@@ -19712,7 +19755,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
error={puzzleError}
|
||||
hideBackButton={Boolean(puzzleOnboardingDraft)}
|
||||
onBack={() => {
|
||||
setSelectionStage(puzzleRuntimeReturnStage);
|
||||
const returnStage = puzzleRuntimeReturnStage;
|
||||
setSelectionStage(returnStage);
|
||||
if (returnStage === 'puzzle-result') {
|
||||
writeCreationUrlState(
|
||||
buildPuzzleCreationUrlState(puzzleSession),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onRemodelWork={
|
||||
selectedPuzzleDetail?.publicationStatus === 'published'
|
||||
|
||||
@@ -427,6 +427,19 @@ describe('PuzzleResultView', () => {
|
||||
const formalImageCard = formalImageTitle
|
||||
.closest('.creative-image-input-panel__image-field')
|
||||
?.querySelector('.puzzle-image-upload-card');
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole('button', { name: '查看关卡图片' }),
|
||||
);
|
||||
const imagePreviewDialog = screen.getByRole('dialog', {
|
||||
name: '查看关卡图片',
|
||||
});
|
||||
expect(within(imagePreviewDialog).getByAltText('暖灯猫街')).toBeTruthy();
|
||||
fireEvent.click(
|
||||
within(imagePreviewDialog).getByRole('button', {
|
||||
name: '关闭关卡图片预览',
|
||||
}),
|
||||
);
|
||||
expect(within(dialog).getByRole('button', { name: '更换参考图' })).toBeTruthy();
|
||||
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
|
||||
expect(levelNameInput.closest('.platform-subpanel')).toBeNull();
|
||||
expect(formalImageTitle.closest('.platform-subpanel')).toBeNull();
|
||||
|
||||
@@ -867,6 +867,8 @@ function PuzzleLevelDetailDialog({
|
||||
promptReferenceUpload: '上传参考图',
|
||||
promptReferencePreviewAlt: '参考图预览',
|
||||
closePromptReferencePreview: '关闭参考图预览',
|
||||
previewMainImage: '查看关卡图片',
|
||||
closeMainImagePreview: '关闭关卡图片预览',
|
||||
history: '选择历史图片',
|
||||
}}
|
||||
onMainImageFileSelect={(file) => {
|
||||
|
||||
@@ -6113,10 +6113,19 @@ test('puzzle draft generation auto starts trial and runtime back opens draft res
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.history.back();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/creation/puzzle/result');
|
||||
});
|
||||
await user.click(await screen.findByRole('button', { name: '返回上一页' }));
|
||||
|
||||
expect(await screen.findByText('拼图结果页')).toBeTruthy();
|
||||
expect(screen.getByDisplayValue('雨夜猫街')).toBeTruthy();
|
||||
const creationParams = new URLSearchParams(window.location.search);
|
||||
expect(creationParams.get('sessionId')).toBe('puzzle-session-auto-1');
|
||||
expect(creationParams.get('profileId')).toBe('puzzle-profile-auto-1');
|
||||
});
|
||||
|
||||
test('embedded puzzle form recovers when compile request times out after backend completion', async () => {
|
||||
@@ -7319,6 +7328,47 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
|
||||
});
|
||||
});
|
||||
|
||||
test('home recommendation share opens publish share modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-share-1',
|
||||
profileId: 'SHARE001',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: 'puzzle-session-share-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '星桥分享关',
|
||||
summary: '旋转碎片并接通星桥机关。',
|
||||
themeTags: ['机关', '星桥'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 3,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [publishedPuzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
const meta = await screen.findByLabelText('星桥分享关 作品信息');
|
||||
await user.click(within(meta).getByRole('button', { name: '分享' }));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '分享给朋友' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/作品号:PZ-SHARE001/u)).toBeTruthy();
|
||||
expect(screen.getByText(/\/gallery\/puzzle\/detail\?work=PZ-SHARE001/u))
|
||||
.toBeTruthy();
|
||||
});
|
||||
|
||||
test('home recommendation keeps logged-in puzzle start on default auth instead of guest token', async () => {
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-2',
|
||||
@@ -11881,6 +11931,7 @@ test('creation hub gives jump hop wooden fish and bark battle cards the shared d
|
||||
themeText: '跳台删除草稿',
|
||||
workTitle: '跳台删除草稿',
|
||||
workDescription: '跳一跳草稿也应接入统一删除。',
|
||||
themeText: '跳台',
|
||||
themeTags: ['跳台'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
|
||||
@@ -4037,6 +4037,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
const onSelectNextRecommendEntry = vi.fn();
|
||||
const onSelectPreviousRecommendEntry = vi.fn();
|
||||
const onLikeRecommendEntry = vi.fn();
|
||||
const onShareRecommendEntry = vi.fn();
|
||||
const onRemixRecommendEntry = vi.fn();
|
||||
const firstEntry = {
|
||||
...puzzlePublicEntry,
|
||||
@@ -4122,6 +4123,7 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
|
||||
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
|
||||
onLikeRecommendEntry={onLikeRecommendEntry}
|
||||
onShareRecommendEntry={onShareRecommendEntry}
|
||||
onRemixRecommendEntry={onRemixRecommendEntry}
|
||||
onOpenLibraryDetail={vi.fn()}
|
||||
onSearchPublicCode={vi.fn()}
|
||||
@@ -4140,11 +4142,6 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText('评论')).toBeNull();
|
||||
expect(screen.queryByLabelText(/游玩/u)).toBeNull();
|
||||
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText: clipboardWriteText },
|
||||
});
|
||||
|
||||
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
|
||||
expect(meta.closest('[data-recommend-swipe-zone="true"]')).toBeTruthy();
|
||||
@@ -4166,10 +4163,9 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
||||
fireEvent.click(remixButton);
|
||||
|
||||
expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(onShareRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining('作品号:PZ-FEED1'),
|
||||
);
|
||||
expect(activeRecommendCard.getByRole('button', { name: '分享' })).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
MessageCircle,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
ScanLine,
|
||||
Search,
|
||||
Settings,
|
||||
@@ -203,6 +202,7 @@ export interface RpgEntryHomeViewProps {
|
||||
onSelectNextRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onSelectPreviousRecommendEntry?: (activeEntryKey?: string | null) => void;
|
||||
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onShareRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
|
||||
onOpenLibraryDetail: (
|
||||
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
|
||||
@@ -1070,7 +1070,6 @@ function RecommendSwipeCard({
|
||||
authorSummary,
|
||||
isActive,
|
||||
visual,
|
||||
shareState,
|
||||
onDragPointerDown,
|
||||
onDragPointerMove,
|
||||
onDragPointerUp,
|
||||
@@ -1084,7 +1083,6 @@ function RecommendSwipeCard({
|
||||
authorSummary?: PublicUserSummary | null;
|
||||
isActive: boolean;
|
||||
visual: ReactNode;
|
||||
shareState?: 'idle' | 'copied' | 'failed';
|
||||
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||
@@ -1108,7 +1106,6 @@ function RecommendSwipeCard({
|
||||
authorAvatarUrl={authorAvatarUrl}
|
||||
authorSummary={authorSummary}
|
||||
isActive={isActive}
|
||||
shareState={shareState}
|
||||
onDragPointerDown={onDragPointerDown}
|
||||
onDragPointerMove={onDragPointerMove}
|
||||
onDragPointerUp={onDragPointerUp}
|
||||
@@ -1130,7 +1127,6 @@ function RecommendRuntimeMeta({
|
||||
onDragPointerMove,
|
||||
onDragPointerUp,
|
||||
onDragPointerCancel,
|
||||
shareState = 'idle',
|
||||
onLike,
|
||||
onShare,
|
||||
onRemix,
|
||||
@@ -1143,7 +1139,6 @@ function RecommendRuntimeMeta({
|
||||
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
|
||||
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
|
||||
shareState?: 'idle' | 'copied' | 'failed';
|
||||
onLike?: () => void;
|
||||
onShare?: () => void;
|
||||
onRemix?: () => void;
|
||||
@@ -1234,13 +1229,7 @@ function RecommendRuntimeMeta({
|
||||
onShare?.();
|
||||
}}
|
||||
disabled={!isActive || !onShare}
|
||||
aria-label={
|
||||
shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
aria-label="分享"
|
||||
title="分享"
|
||||
>
|
||||
<Share2 className="h-5 w-5" aria-hidden="true" />
|
||||
@@ -4134,6 +4123,7 @@ export function RpgEntryHomeView({
|
||||
onSelectNextRecommendEntry,
|
||||
onSelectPreviousRecommendEntry,
|
||||
onLikeRecommendEntry,
|
||||
onShareRecommendEntry,
|
||||
onRemixRecommendEntry,
|
||||
onOpenLibraryDetail,
|
||||
onDeleteLibraryEntry,
|
||||
@@ -4415,7 +4405,7 @@ export function RpgEntryHomeView({
|
||||
? {
|
||||
home: Sparkles,
|
||||
category: Compass,
|
||||
create: Plus,
|
||||
create: Sparkles,
|
||||
saves: Pencil,
|
||||
profile: UserRound,
|
||||
}
|
||||
@@ -5560,13 +5550,9 @@ export function RpgEntryHomeView({
|
||||
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
|
||||
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
|
||||
useState<1 | -1 | null>(null);
|
||||
const [recommendShareState, setRecommendShareState] = useState<
|
||||
'idle' | 'copied' | 'failed'
|
||||
>('idle');
|
||||
const activeRecommendEntryKeyForSelection = activeRecommendEntry
|
||||
? buildPublicGalleryCardKey(activeRecommendEntry)
|
||||
: null;
|
||||
const recommendShareResetTimerRef = useRef<number | null>(null);
|
||||
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
|
||||
const recommendDragStartRef = useRef<{
|
||||
pointerId: number;
|
||||
@@ -5704,39 +5690,6 @@ export function RpgEntryHomeView({
|
||||
onSelectNextRecommendEntry,
|
||||
recommendedFeedEntries.length,
|
||||
]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
setRecommendShareState('idle');
|
||||
}, [activeRecommendEntryKey]);
|
||||
const shareRecommendEntry = useCallback(
|
||||
(entry: PlatformPublicGalleryCard) => {
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
|
||||
if (!publicWorkCode) {
|
||||
setRecommendShareState('failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const shareText = `邀请你来玩《${entry.worldName}》\n作品号:${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
|
||||
void copyTextToClipboard(shareText).then((copied) => {
|
||||
setRecommendShareState(copied ? 'copied' : 'failed');
|
||||
if (recommendShareResetTimerRef.current !== null) {
|
||||
window.clearTimeout(recommendShareResetTimerRef.current);
|
||||
}
|
||||
recommendShareResetTimerRef.current = window.setTimeout(() => {
|
||||
recommendShareResetTimerRef.current = null;
|
||||
setRecommendShareState('idle');
|
||||
}, 1400);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
const leadPublicEntry = featuredShelf[0] ?? generalLatestEntries[0] ?? null;
|
||||
const openLeadPublicEntry = () => {
|
||||
if (leadPublicEntry) {
|
||||
@@ -5880,9 +5833,8 @@ export function RpgEntryHomeView({
|
||||
onDragPointerMove={moveRecommendDrag}
|
||||
onDragPointerUp={endRecommendDrag}
|
||||
onDragPointerCancel={cancelRecommendDrag}
|
||||
shareState={recommendShareState}
|
||||
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
|
||||
onShare={() => shareRecommendEntry(activeRecommendEntry)}
|
||||
onShare={() => onShareRecommendEntry?.(activeRecommendEntry)}
|
||||
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import { WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT } from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { prepareCreativeAudioFileForLocalUse } from '../../common/creativeAudioProcessing';
|
||||
import { WoodenFishCreationWorkspace } from './WoodenFishCreationWorkspace';
|
||||
|
||||
vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
|
||||
@@ -13,8 +18,18 @@ vi.mock('../../../services/wooden-fish/woodenFishClient', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/wooden-fish/woodenFishAssetClient', () => ({
|
||||
uploadWoodenFishHitSoundAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../common/creativeAudioProcessing', () => ({
|
||||
prepareCreativeAudioFileForLocalUse: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(woodenFishClient.createSession).mockReset();
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockReset();
|
||||
vi.mocked(prepareCreativeAudioFileForLocalUse).mockReset();
|
||||
vi.mocked(woodenFishClient.createSession).mockResolvedValue({
|
||||
session: {
|
||||
sessionId: 'wooden-fish-session-test',
|
||||
@@ -25,6 +40,30 @@ beforeEach(() => {
|
||||
updatedAt: '2026-05-24T00:00:00Z',
|
||||
},
|
||||
});
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockResolvedValue({
|
||||
assetId: 'uploaded-hit-sound-asset',
|
||||
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
audioObjectKey: 'generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
assetObjectId: 'asset-object-hit-sound',
|
||||
source: 'uploaded',
|
||||
prompt: 'hit-sound.webm',
|
||||
durationMs: null,
|
||||
});
|
||||
vi.mocked(prepareCreativeAudioFileForLocalUse).mockImplementation(
|
||||
async (file, source) => ({
|
||||
assetId: 'local-hit-sound-asset',
|
||||
audioSrc: 'blob:local-hit-sound',
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: 800,
|
||||
fileName: 'hit-sound.wav',
|
||||
mimeType: 'audio/wav',
|
||||
blob: new Blob(['processed-audio-bytes'], { type: 'audio/wav' }),
|
||||
previewUrl: 'blob:local-hit-sound',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('敲什么输入栏初始置空但提交时仍使用默认生成提示词', async () => {
|
||||
@@ -103,10 +142,303 @@ test('敲击音效临时关闭提示词生成入口,仅保留上传和录音',
|
||||
|
||||
expect(section).not.toBeNull();
|
||||
expect(within(section as HTMLElement).queryByText('音效描述')).toBeNull();
|
||||
expect(within(section as HTMLElement).getByText('最长 1 秒')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByText('上传')).toBeTruthy();
|
||||
expect(within(section as HTMLElement).getByText('录音')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('敲击音效上传后只生成本地待提交音频,点击生成时才上传 OSS', async () => {
|
||||
const onSubmitted = vi.fn();
|
||||
const audioFile = new File(['audio-bytes'], 'hit-sound.webm', {
|
||||
type: 'audio/webm',
|
||||
});
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: { files: [audioFile] },
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledWith(
|
||||
audioFile,
|
||||
'uploaded',
|
||||
),
|
||||
);
|
||||
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||
expect(uploadWoodenFishHitSoundAsset).toHaveBeenCalledTimes(1);
|
||||
const uploadCall = vi.mocked(uploadWoodenFishHitSoundAsset).mock.calls[0];
|
||||
expect(uploadCall).toBeDefined();
|
||||
const [uploadedFile, source] = uploadCall!;
|
||||
expect(uploadedFile).toBeInstanceOf(File);
|
||||
const uploadedAudioFile = uploadedFile as File;
|
||||
expect(uploadedAudioFile.name).toBe('hit-sound.wav');
|
||||
expect(uploadedFile.type).toBe('audio/wav');
|
||||
expect(source).toBe('uploaded');
|
||||
|
||||
const payload = onSubmitted.mock.calls[0]?.[1];
|
||||
expect(payload.hitSoundAsset).toMatchObject({
|
||||
assetObjectId: 'asset-object-hit-sound',
|
||||
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
});
|
||||
expect(payload.hitSoundAsset.audioSrc).not.toContain('data:audio');
|
||||
});
|
||||
|
||||
test('未选择敲击音效时生成不上传 OSS 且使用默认木鱼音', async () => {
|
||||
const onSubmitted = vi.fn();
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
|
||||
expect(onSubmitted.mock.calls[0]?.[1].hitSoundAsset).toEqual(
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
);
|
||||
});
|
||||
|
||||
test('敲击音效超过 1 秒时展示错误且不提交音频', async () => {
|
||||
const onSubmitted = vi.fn();
|
||||
vi.mocked(prepareCreativeAudioFileForLocalUse).mockRejectedValueOnce(
|
||||
new Error('音频最长 1 秒。'),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={onSubmitted}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['too-long-audio'], 'too-long.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(await screen.findByText('音频最长 1 秒。')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
await waitFor(() => expect(onSubmitted).toHaveBeenCalledTimes(1));
|
||||
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
|
||||
expect(onSubmitted.mock.calls[0]?.[1].hitSoundAsset.audioSrc).not.toContain(
|
||||
'data:audio',
|
||||
);
|
||||
});
|
||||
|
||||
test('敲击音效上传失败时停留在工作台且不创建 session', async () => {
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockRejectedValueOnce(
|
||||
new Error('上传敲击音效失败。'),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['audio-bytes'], 'hit-sound.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(await screen.findByText('上传敲击音效失败。')).toBeTruthy();
|
||||
expect(woodenFishClient.createSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('创建木鱼草稿失败时停留工作台并展示错误', async () => {
|
||||
vi.mocked(woodenFishClient.createSession).mockRejectedValueOnce(
|
||||
new Error('创建敲木鱼共创会话失败'),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||
|
||||
expect(await screen.findByText('创建敲木鱼共创会话失败')).toBeTruthy();
|
||||
expect(uploadWoodenFishHitSoundAsset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('提交中重复点击生成不会重复上传或创建 session', async () => {
|
||||
let resolveUpload: (
|
||||
value: Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>,
|
||||
) => void = () => {};
|
||||
vi.mocked(uploadWoodenFishHitSoundAsset).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Awaited<ReturnType<typeof uploadWoodenFishHitSoundAsset>>>((resolve) => {
|
||||
resolveUpload = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['audio-bytes'], 'hit-sound.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(prepareCreativeAudioFileForLocalUse).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
|
||||
const submitButton = screen.getByRole('button', { name: '生成' });
|
||||
fireEvent.click(submitButton);
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(uploadWoodenFishHitSoundAsset).toHaveBeenCalledTimes(1);
|
||||
expect(woodenFishClient.createSession).not.toHaveBeenCalled();
|
||||
|
||||
resolveUpload({
|
||||
assetId: 'uploaded-hit-sound-asset',
|
||||
audioSrc: '/generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
audioObjectKey: 'generated-wooden-fish-assets/draft/hit-sound.webm',
|
||||
assetObjectId: 'asset-object-hit-sound',
|
||||
source: 'uploaded',
|
||||
prompt: 'hit-sound.webm',
|
||||
durationMs: null,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(woodenFishClient.createSession).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('替换本地敲击音效时回收旧预览地址', async () => {
|
||||
const revokeObjectURL = vi.fn();
|
||||
URL.revokeObjectURL = revokeObjectURL;
|
||||
vi.mocked(prepareCreativeAudioFileForLocalUse)
|
||||
.mockResolvedValueOnce({
|
||||
assetId: 'local-hit-sound-old',
|
||||
audioSrc: 'blob:old-hit-sound',
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source: 'uploaded',
|
||||
prompt: 'old.webm',
|
||||
durationMs: 800,
|
||||
fileName: 'old.wav',
|
||||
mimeType: 'audio/wav',
|
||||
blob: new Blob(['old-audio'], { type: 'audio/wav' }),
|
||||
previewUrl: 'blob:old-hit-sound',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetId: 'local-hit-sound-new',
|
||||
audioSrc: 'blob:new-hit-sound',
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source: 'uploaded',
|
||||
prompt: 'new.webm',
|
||||
durationMs: 700,
|
||||
fileName: 'new.wav',
|
||||
mimeType: 'audio/wav',
|
||||
blob: new Blob(['new-audio'], { type: 'audio/wav' }),
|
||||
previewUrl: 'blob:new-hit-sound',
|
||||
});
|
||||
|
||||
render(
|
||||
<WoodenFishCreationWorkspace
|
||||
onBack={() => {}}
|
||||
onSubmitted={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen
|
||||
.getByText('上传')
|
||||
.closest('label')
|
||||
?.querySelector('input[type="file"]') as HTMLInputElement | null;
|
||||
expect(input).not.toBeNull();
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['old'], 'old.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(screen.getByText('重置')).toBeTruthy());
|
||||
|
||||
fireEvent.change(input as HTMLInputElement, {
|
||||
target: {
|
||||
files: [
|
||||
new File(['new'], 'new.webm', {
|
||||
type: 'audio/webm',
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith('blob:old-hit-sound'),
|
||||
);
|
||||
});
|
||||
|
||||
test('敲击音效和功德词条不放进独立滚动窗', () => {
|
||||
const { container } = render(
|
||||
<WoodenFishCreationWorkspace
|
||||
|
||||
@@ -13,12 +13,17 @@ import type {
|
||||
WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/woodenFish';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../../services/puzzleReferenceImage';
|
||||
import { uploadWoodenFishHitSoundAsset } from '../../../services/wooden-fish/woodenFishAssetClient';
|
||||
import { woodenFishClient } from '../../../services/wooden-fish/woodenFishClient';
|
||||
import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../../services/wooden-fish/woodenFishDefaults';
|
||||
import { CreativeAudioInputPanel } from '../../common/CreativeAudioInputPanel';
|
||||
import {
|
||||
type PendingCreativeAudioAsset,
|
||||
prepareCreativeAudioFileForLocalUse,
|
||||
} from '../../common/creativeAudioProcessing';
|
||||
import { CreativeImageInputPanel } from '../../common/CreativeImageInputPanel';
|
||||
|
||||
type WoodenFishCreationWorkspaceProps = {
|
||||
@@ -36,7 +41,7 @@ type WoodenFishCreationWorkspaceProps = {
|
||||
type WoodenFishWorkspaceFormState = {
|
||||
hitObjectPrompt: string;
|
||||
hitObjectReferenceImageSrc: string;
|
||||
hitSoundAsset: WoodenFishAudioAsset | null;
|
||||
hitSoundAsset: PendingCreativeAudioAsset | null;
|
||||
floatingWords: string[];
|
||||
};
|
||||
|
||||
@@ -132,6 +137,14 @@ export function WoodenFishCreationWorkspace({
|
||||
setLocalError(null);
|
||||
|
||||
try {
|
||||
const hitSoundAsset: WoodenFishAudioAsset = formState.hitSoundAsset
|
||||
? await uploadWoodenFishHitSoundAsset(
|
||||
new File([formState.hitSoundAsset.blob], formState.hitSoundAsset.fileName, {
|
||||
type: formState.hitSoundAsset.mimeType,
|
||||
}),
|
||||
formState.hitSoundAsset.source,
|
||||
)
|
||||
: WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET;
|
||||
const payload: WoodenFishWorkspaceCreateRequest = {
|
||||
templateId: 'wooden-fish',
|
||||
workTitle: '',
|
||||
@@ -143,8 +156,7 @@ export function WoodenFishCreationWorkspace({
|
||||
hitObjectReferenceImageSrc:
|
||||
formState.hitObjectReferenceImageSrc.trim() || null,
|
||||
hitSoundPrompt: null,
|
||||
hitSoundAsset:
|
||||
formState.hitSoundAsset ?? WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
hitSoundAsset,
|
||||
floatingWords: normalizedFloatingWords,
|
||||
};
|
||||
const response = await woodenFishClient.createSession(payload);
|
||||
@@ -246,18 +258,28 @@ export function WoodenFishCreationWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pr-0 lg:pr-1">
|
||||
<CreativeAudioInputPanel<WoodenFishAudioAsset>
|
||||
<CreativeAudioInputPanel<PendingCreativeAudioAsset>
|
||||
disabled={isBusy || isSubmitting}
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
limitLabel="最长 1 秒"
|
||||
asset={formState.hitSoundAsset}
|
||||
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
|
||||
onAssetChange={(asset) =>
|
||||
readFileAsAsset={prepareCreativeAudioFileForLocalUse}
|
||||
onAssetChange={(asset) => {
|
||||
if (
|
||||
formState.hitSoundAsset?.previewUrl &&
|
||||
formState.hitSoundAsset.previewUrl !== asset?.previewUrl &&
|
||||
typeof URL !== 'undefined' &&
|
||||
typeof URL.revokeObjectURL === 'function'
|
||||
) {
|
||||
URL.revokeObjectURL(formState.hitSoundAsset.previewUrl);
|
||||
}
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
hitSoundAsset: asset,
|
||||
}))
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onError={setLocalError}
|
||||
/>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user