feat: unify phase one creation flow
This commit is contained in:
206
src/components/common/CreativeAudioInputPanel.tsx
Normal file
206
src/components/common/CreativeAudioInputPanel.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Mic, Pause, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export type CreativeAudioAsset = {
|
||||
assetId: string;
|
||||
audioSrc: string;
|
||||
audioObjectKey: string;
|
||||
assetObjectId: string;
|
||||
source: string;
|
||||
prompt?: string | null;
|
||||
durationMs?: number | null;
|
||||
};
|
||||
|
||||
type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
|
||||
disabled?: boolean;
|
||||
title: string;
|
||||
defaultLabel: string;
|
||||
asset: TAsset | null;
|
||||
buildRecordedFileName: () => string;
|
||||
onAssetChange: (asset: TAsset | null) => void;
|
||||
onError: (message: string | null) => void;
|
||||
readFileAsAsset?: (
|
||||
file: File,
|
||||
source: 'uploaded' | 'recorded',
|
||||
) => Promise<TAsset>;
|
||||
};
|
||||
|
||||
export function readCreativeAudioFileAsAsset<TAsset extends CreativeAudioAsset>(
|
||||
file: File,
|
||||
source: 'uploaded' | 'recorded',
|
||||
) {
|
||||
return new Promise<TAsset>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('音频读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: reader.result,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: null,
|
||||
} as TAsset);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
disabled = false,
|
||||
title,
|
||||
defaultLabel,
|
||||
asset,
|
||||
buildRecordedFileName,
|
||||
onAssetChange,
|
||||
onError,
|
||||
readFileAsAsset = readCreativeAudioFileAsAsset,
|
||||
}: CreativeAudioInputPanelProps<TAsset>) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (disabled || isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia ||
|
||||
typeof MediaRecorder === 'undefined'
|
||||
) {
|
||||
throw new Error('当前浏览器不支持录音。');
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, {
|
||||
type: recorder.mimeType || 'audio/webm',
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const file = new File([blob], buildRecordedFileName(), {
|
||||
type: blob.type,
|
||||
});
|
||||
void readFileAsAsset(file, 'recorded')
|
||||
.then(onAssetChange)
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '录音保存失败。',
|
||||
);
|
||||
});
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
onError(null);
|
||||
} catch (caughtError) {
|
||||
onError(
|
||||
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
recorderRef.current?.stop();
|
||||
recorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
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>
|
||||
{asset ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssetChange(null)}
|
||||
disabled={disabled}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
|
||||
disabled ? 'pointer-events-none opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
上传
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
void readFileAsAsset(file, 'uploaded')
|
||||
.then((nextAsset) => {
|
||||
onError(null);
|
||||
onAssetChange(nextAsset);
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '音频读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
void startRecording();
|
||||
}}
|
||||
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Mic className="h-4 w-4" />
|
||||
)}
|
||||
{isRecording ? '停止' : '录音'}
|
||||
</button>
|
||||
{asset?.audioSrc ? (
|
||||
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
|
||||
) : (
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{asset ? '音效已选择' : defaultLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreativeAudioInputPanel;
|
||||
File diff suppressed because it is too large
Load Diff
42
src/components/unified-creation/UnifiedCreationPage.test.tsx
Normal file
42
src/components/unified-creation/UnifiedCreationPage.test.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { UnifiedCreationPage } from './UnifiedCreationPage';
|
||||
import { getUnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
|
||||
describe('UnifiedCreationPage', () => {
|
||||
test('按后端字段 spec 暴露统一创作页字段契约', () => {
|
||||
render(
|
||||
<UnifiedCreationPage spec={getUnifiedCreationSpec('wooden-fish')}>
|
||||
<div>敲木鱼工作台</div>
|
||||
</UnifiedCreationPage>,
|
||||
);
|
||||
|
||||
const root = screen
|
||||
.getByText('敲木鱼工作台')
|
||||
.closest('.unified-creation-page');
|
||||
expect(root?.getAttribute('data-play-id')).toBe('wooden-fish');
|
||||
expect(root?.getAttribute('data-field-kinds')).toBe(
|
||||
'text,image,audio,text',
|
||||
);
|
||||
expect(root?.getAttribute('data-workspace-stage')).toBe(
|
||||
'wooden-fish-workspace',
|
||||
);
|
||||
expect(root?.getAttribute('data-generation-stage')).toBe(
|
||||
'wooden-fish-generating',
|
||||
);
|
||||
expect(root?.getAttribute('data-result-stage')).toBe('wooden-fish-result');
|
||||
|
||||
const fields = screen.getAllByTestId('unified-creation-field');
|
||||
expect(fields.map((field) => field.getAttribute('data-field-id'))).toEqual([
|
||||
'hitObjectPrompt',
|
||||
'hitObjectReferenceImage',
|
||||
'hitSoundAsset',
|
||||
'floatingWords',
|
||||
]);
|
||||
expect(fields[2]?.getAttribute('data-field-kind')).toBe('audio');
|
||||
expect(fields[3]?.getAttribute('data-required')).toBe('true');
|
||||
});
|
||||
});
|
||||
44
src/components/unified-creation/UnifiedCreationPage.tsx
Normal file
44
src/components/unified-creation/UnifiedCreationPage.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import type { UnifiedCreationSpec } from './unifiedCreationSpecs';
|
||||
|
||||
type UnifiedCreationPageProps = {
|
||||
spec: UnifiedCreationSpec;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function UnifiedCreationPage({
|
||||
spec,
|
||||
children,
|
||||
}: UnifiedCreationPageProps) {
|
||||
return (
|
||||
<div
|
||||
className="unified-creation-page flex h-full min-h-0 flex-col"
|
||||
data-play-id={spec.playId}
|
||||
data-field-kinds={spec.fields.map((field) => field.kind).join(',')}
|
||||
data-workspace-stage={spec.workspaceStage}
|
||||
data-generation-stage={spec.generationStage}
|
||||
data-result-stage={spec.resultStage}
|
||||
>
|
||||
<div className="sr-only" data-testid="unified-creation-spec">
|
||||
<h1>{spec.title}</h1>
|
||||
<ul>
|
||||
{spec.fields.map((field) => (
|
||||
<li
|
||||
key={field.id}
|
||||
data-testid="unified-creation-field"
|
||||
data-field-id={field.id}
|
||||
data-field-kind={field.kind}
|
||||
data-required={field.required ? 'true' : 'false'}
|
||||
>
|
||||
{field.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnifiedCreationPage;
|
||||
@@ -0,0 +1,53 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { UnifiedGenerationPage } from './UnifiedGenerationPage';
|
||||
|
||||
function createProgress(): CustomWorldGenerationProgress {
|
||||
return {
|
||||
phaseId: 'puzzle-cover-image',
|
||||
phaseLabel: '生成拼图首图',
|
||||
phaseDetail: '正在生成图片。',
|
||||
batchLabel: '生成拼图首图',
|
||||
overallProgress: 36,
|
||||
completedWeight: 36,
|
||||
totalWeight: 100,
|
||||
elapsedMs: 12_000,
|
||||
estimatedRemainingMs: 30_000,
|
||||
activeStepIndex: 0,
|
||||
steps: [
|
||||
{
|
||||
id: 'puzzle-cover-image',
|
||||
label: '生成拼图首图',
|
||||
detail: '正在生成图片。',
|
||||
completed: 0.36,
|
||||
total: 1,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('UnifiedGenerationPage', () => {
|
||||
test('按玩法下发统一生成页文案并透传进度', () => {
|
||||
render(
|
||||
<UnifiedGenerationPage
|
||||
playId="puzzle"
|
||||
settingText="一只发光的纸船"
|
||||
progress={createProgress()}
|
||||
isGenerating
|
||||
onBack={() => {}}
|
||||
onEditSetting={() => {}}
|
||||
onRetry={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(document.body.textContent).toContain('拼图图片生成进度');
|
||||
expect(screen.getByText('图片生成中')).toBeTruthy();
|
||||
expect(screen.getAllByText('生成拼图首图').length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('当前拼图信息')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
91
src/components/unified-creation/UnifiedGenerationPage.tsx
Normal file
91
src/components/unified-creation/UnifiedGenerationPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
|
||||
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
|
||||
import type { UnifiedCreationPlayId } from './unifiedCreationSpecs';
|
||||
|
||||
type UnifiedGenerationPageProps = {
|
||||
playId: UnifiedCreationPlayId;
|
||||
settingText: string;
|
||||
anchorEntries?: CustomWorldStructuredAnchorEntry[];
|
||||
progress: CustomWorldGenerationProgress | null;
|
||||
isGenerating: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onEditSetting: () => void;
|
||||
onRetry: () => void;
|
||||
hideBatchModule?: boolean;
|
||||
};
|
||||
|
||||
const UNIFIED_GENERATION_COPY = {
|
||||
puzzle: {
|
||||
retryLabel: '重新生成图片',
|
||||
settingTitle: '当前拼图信息',
|
||||
progressTitle: '拼图图片生成进度',
|
||||
activeBadgeLabel: '图片生成中',
|
||||
},
|
||||
match3d: {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前抓大鹅信息',
|
||||
progressTitle: '抓大鹅草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
'wooden-fish': {
|
||||
retryLabel: '重新生成草稿',
|
||||
settingTitle: '当前敲木鱼信息',
|
||||
progressTitle: '敲木鱼草稿生成进度',
|
||||
activeBadgeLabel: '素材生成中',
|
||||
},
|
||||
} as const satisfies Record<
|
||||
UnifiedCreationPlayId,
|
||||
{
|
||||
retryLabel: string;
|
||||
settingTitle: string;
|
||||
progressTitle: string;
|
||||
activeBadgeLabel: string;
|
||||
}
|
||||
>;
|
||||
|
||||
export function getUnifiedGenerationCopy(playId: UnifiedCreationPlayId) {
|
||||
return UNIFIED_GENERATION_COPY[playId];
|
||||
}
|
||||
|
||||
export function UnifiedGenerationPage({
|
||||
playId,
|
||||
settingText,
|
||||
anchorEntries = [],
|
||||
progress,
|
||||
isGenerating,
|
||||
error = null,
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
hideBatchModule = false,
|
||||
}: UnifiedGenerationPageProps) {
|
||||
const copy = getUnifiedGenerationCopy(playId);
|
||||
|
||||
return (
|
||||
<CustomWorldGenerationView
|
||||
settingText={settingText}
|
||||
anchorEntries={anchorEntries}
|
||||
progress={progress}
|
||||
isGenerating={isGenerating}
|
||||
error={error}
|
||||
onBack={onBack}
|
||||
onEditSetting={onEditSetting}
|
||||
onRetry={onRetry}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
retryLabel={copy.retryLabel}
|
||||
settingTitle={copy.settingTitle}
|
||||
settingDescription={null}
|
||||
progressTitle={copy.progressTitle}
|
||||
activeBadgeLabel={copy.activeBadgeLabel}
|
||||
pausedBadgeLabel="素材生成已暂停"
|
||||
idleBadgeLabel="等待返回工作区"
|
||||
hideBatchModule={hideBatchModule}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnifiedGenerationPage;
|
||||
42
src/components/unified-creation/unifiedCreationSpecs.test.ts
Normal file
42
src/components/unified-creation/unifiedCreationSpecs.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
getUnifiedCreationSpec,
|
||||
listUnifiedCreationSpecs,
|
||||
} from './unifiedCreationSpecs';
|
||||
|
||||
describe('unified creation specs', () => {
|
||||
test('一期只接拼图、抓大鹅和敲木鱼', () => {
|
||||
expect(listUnifiedCreationSpecs().map((spec) => spec.playId).sort()).toEqual(
|
||||
['match3d', 'puzzle', 'wooden-fish'],
|
||||
);
|
||||
});
|
||||
|
||||
test('字段模型只包含首期公共能力', () => {
|
||||
const fieldKinds = new Set(
|
||||
listUnifiedCreationSpecs().flatMap((spec) =>
|
||||
spec.fields.map((field) => field.kind),
|
||||
),
|
||||
);
|
||||
|
||||
expect([...fieldKinds].sort()).toEqual(['audio', 'image', 'select', 'text']);
|
||||
});
|
||||
|
||||
test('三条链路都映射到统一创作、生成、结果阶段', () => {
|
||||
expect(getUnifiedCreationSpec('puzzle')).toMatchObject({
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('match3d')).toMatchObject({
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
generationStage: 'match3d-generating',
|
||||
resultStage: 'match3d-result',
|
||||
});
|
||||
expect(getUnifiedCreationSpec('wooden-fish')).toMatchObject({
|
||||
workspaceStage: 'wooden-fish-workspace',
|
||||
generationStage: 'wooden-fish-generating',
|
||||
resultStage: 'wooden-fish-result',
|
||||
});
|
||||
});
|
||||
});
|
||||
107
src/components/unified-creation/unifiedCreationSpecs.ts
Normal file
107
src/components/unified-creation/unifiedCreationSpecs.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type {
|
||||
CreationEntryTypeConfig,
|
||||
UnifiedCreationSpec,
|
||||
} from '../../services/creationEntryConfigService';
|
||||
|
||||
export type UnifiedCreationPlayId = UnifiedCreationSpec['playId'];
|
||||
export type { UnifiedCreationSpec };
|
||||
|
||||
const FALLBACK_UNIFIED_CREATION_SPECS: Record<
|
||||
UnifiedCreationPlayId,
|
||||
UnifiedCreationSpec
|
||||
> = {
|
||||
puzzle: {
|
||||
playId: 'puzzle',
|
||||
title: '想做个什么玩法?',
|
||||
workspaceStage: 'puzzle-agent-workspace',
|
||||
generationStage: 'puzzle-generating',
|
||||
resultStage: 'puzzle-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'pictureDescription',
|
||||
kind: 'text',
|
||||
label: '画面描述',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'referenceImage',
|
||||
kind: 'image',
|
||||
label: '拼图画面',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'promptReferenceImages',
|
||||
kind: 'image',
|
||||
label: '参考图',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
match3d: {
|
||||
playId: 'match3d',
|
||||
title: '想做个什么玩法?',
|
||||
workspaceStage: 'match3d-agent-workspace',
|
||||
generationStage: 'match3d-generating',
|
||||
resultStage: 'match3d-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'themeText',
|
||||
kind: 'text',
|
||||
label: '题材',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: 'difficulty',
|
||||
kind: 'select',
|
||||
label: '难度',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
'wooden-fish': {
|
||||
playId: 'wooden-fish',
|
||||
title: '想做个什么玩法?',
|
||||
workspaceStage: 'wooden-fish-workspace',
|
||||
generationStage: 'wooden-fish-generating',
|
||||
resultStage: 'wooden-fish-result',
|
||||
fields: [
|
||||
{
|
||||
id: 'hitObjectPrompt',
|
||||
kind: 'text',
|
||||
label: '敲什么',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'hitObjectReferenceImage',
|
||||
kind: 'image',
|
||||
label: '参考图',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'hitSoundAsset',
|
||||
kind: 'audio',
|
||||
label: '敲击音效',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: 'floatingWords',
|
||||
kind: 'text',
|
||||
label: '功德有什么',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function getUnifiedCreationSpec(
|
||||
playId: UnifiedCreationPlayId,
|
||||
configType?: CreationEntryTypeConfig | null,
|
||||
) {
|
||||
return (
|
||||
configType?.unifiedCreationSpec ?? FALLBACK_UNIFIED_CREATION_SPECS[playId]
|
||||
);
|
||||
}
|
||||
|
||||
export function listUnifiedCreationSpecs() {
|
||||
return Object.values(FALLBACK_UNIFIED_CREATION_SPECS);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Mic,
|
||||
Pause,
|
||||
Plus,
|
||||
Send,
|
||||
X,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type {
|
||||
WoodenFishAudioAsset,
|
||||
@@ -21,6 +18,7 @@ import {
|
||||
WOODEN_FISH_DEFAULT_HIT_OBJECT_PROMPT,
|
||||
WOODEN_FISH_DEFAULT_HIT_SOUND_ASSET,
|
||||
} from '../../services/wooden-fish/woodenFishDefaults';
|
||||
import { CreativeAudioInputPanel } from '../common/CreativeAudioInputPanel';
|
||||
import { CreativeImageInputPanel } from '../common/CreativeImageInputPanel';
|
||||
|
||||
type WoodenFishWorkspaceProps = {
|
||||
@@ -68,182 +66,6 @@ function normalizeFloatingWords(words: string[]) {
|
||||
return normalized.length > 0 ? normalized : [...DEFAULT_FLOATING_WORDS];
|
||||
}
|
||||
|
||||
function readAudioFileAsAsset(file: File, source: 'uploaded' | 'recorded') {
|
||||
return new Promise<WoodenFishAudioAsset>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('音频读取失败,请重试。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('音频读取失败,请重试。'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
assetId: `local-${source}-${Date.now()}`,
|
||||
audioSrc: reader.result,
|
||||
audioObjectKey: '',
|
||||
assetObjectId: '',
|
||||
source,
|
||||
prompt: file.name,
|
||||
durationMs: null,
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
function WoodenFishAudioInputPanel({
|
||||
disabled,
|
||||
asset,
|
||||
onAssetChange,
|
||||
onError,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
asset: WoodenFishAudioAsset | null;
|
||||
onAssetChange: (asset: WoodenFishAudioAsset | null) => void;
|
||||
onError: (message: string | null) => void;
|
||||
}) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
|
||||
const startRecording = async () => {
|
||||
if (disabled || isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
typeof navigator === 'undefined' ||
|
||||
!navigator.mediaDevices?.getUserMedia ||
|
||||
typeof MediaRecorder === 'undefined'
|
||||
) {
|
||||
throw new Error('当前浏览器不支持录音。');
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, {
|
||||
type: recorder.mimeType || 'audio/webm',
|
||||
});
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
const file = new File([blob], `wooden-fish-hit-${Date.now()}.webm`, {
|
||||
type: blob.type,
|
||||
});
|
||||
void readAudioFileAsAsset(file, 'recorded')
|
||||
.then(onAssetChange)
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '录音保存失败。',
|
||||
);
|
||||
});
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
onError(null);
|
||||
} catch (caughtError) {
|
||||
onError(
|
||||
caughtError instanceof Error ? caughtError.message : '录音启动失败。',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
recorderRef.current?.stop();
|
||||
recorderRef.current = null;
|
||||
setIsRecording(false);
|
||||
};
|
||||
|
||||
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)]">
|
||||
敲击音效
|
||||
</div>
|
||||
{asset ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAssetChange(null)}
|
||||
disabled={disabled}
|
||||
className="platform-button platform-button--ghost min-h-0 px-3 py-2 text-xs"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||
<label
|
||||
className={`platform-button platform-button--secondary min-h-10 cursor-pointer gap-2 px-3 py-2 text-sm ${
|
||||
disabled ? 'pointer-events-none opacity-55' : ''
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
上传
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
disabled={disabled}
|
||||
className="sr-only"
|
||||
onChange={(event) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
void readAudioFileAsAsset(file, 'uploaded')
|
||||
.then((nextAsset) => {
|
||||
onError(null);
|
||||
onAssetChange(nextAsset);
|
||||
})
|
||||
.catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '音频读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
if (isRecording) {
|
||||
stopRecording();
|
||||
return;
|
||||
}
|
||||
void startRecording();
|
||||
}}
|
||||
className="platform-button platform-button--ghost min-h-10 gap-2 px-3 py-2 text-sm"
|
||||
>
|
||||
{isRecording ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Mic className="h-4 w-4" />
|
||||
)}
|
||||
{isRecording ? '停止' : '录音'}
|
||||
</button>
|
||||
{asset?.audioSrc ? (
|
||||
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
|
||||
) : (
|
||||
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
|
||||
{asset ? '音效已选择' : '默认木鱼音'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function WoodenFishWorkspace({
|
||||
isBusy = false,
|
||||
error = null,
|
||||
@@ -410,9 +232,12 @@ export function WoodenFishWorkspace({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-3 overflow-y-auto pr-0 lg:pr-1">
|
||||
<WoodenFishAudioInputPanel
|
||||
<CreativeAudioInputPanel<WoodenFishAudioAsset>
|
||||
disabled={isBusy || isSubmitting}
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
asset={formState.hitSoundAsset}
|
||||
buildRecordedFileName={() => `wooden-fish-hit-${Date.now()}.webm`}
|
||||
onAssetChange={(asset) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
|
||||
@@ -13,6 +13,29 @@ export type CreationEntryTypeConfig = {
|
||||
categoryLabel: string;
|
||||
categorySortOrder: number;
|
||||
updatedAtMicros: number;
|
||||
unifiedCreationSpec?: UnifiedCreationSpec | null;
|
||||
};
|
||||
|
||||
export type UnifiedCreationField = {
|
||||
id: string;
|
||||
kind: 'text' | 'select' | 'image' | 'audio';
|
||||
label: string;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
export type UnifiedCreationSpec = {
|
||||
playId: 'puzzle' | 'match3d' | 'wooden-fish';
|
||||
title: string;
|
||||
workspaceStage:
|
||||
| 'puzzle-agent-workspace'
|
||||
| 'match3d-agent-workspace'
|
||||
| 'wooden-fish-workspace';
|
||||
generationStage:
|
||||
| 'puzzle-generating'
|
||||
| 'match3d-generating'
|
||||
| 'wooden-fish-generating';
|
||||
resultStage: 'puzzle-result' | 'match3d-result' | 'wooden-fish-result';
|
||||
fields: UnifiedCreationField[];
|
||||
};
|
||||
|
||||
export type CreationEntryConfig = {
|
||||
|
||||
@@ -467,6 +467,24 @@ function clampProgress(value: number) {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
export function resolveMiniGameDraftGenerationStartedAtMs(
|
||||
startedAt: string | number | null | undefined,
|
||||
fallbackMs = Date.now(),
|
||||
) {
|
||||
if (typeof startedAt === 'number' && Number.isFinite(startedAt)) {
|
||||
return startedAt;
|
||||
}
|
||||
|
||||
if (typeof startedAt === 'string') {
|
||||
const parsed = Date.parse(startedAt);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackMs;
|
||||
}
|
||||
|
||||
function getStepDefinitions(kind: MiniGameDraftGenerationKind) {
|
||||
if (kind === 'puzzle') {
|
||||
return buildPuzzleSteps(createMiniGameDraftGenerationState('puzzle'));
|
||||
@@ -542,6 +560,7 @@ function buildMiniGameProgressSteps(
|
||||
|
||||
export function createMiniGameDraftGenerationState(
|
||||
kind: MiniGameDraftGenerationKind,
|
||||
startedAtMs = Date.now(),
|
||||
): MiniGameDraftGenerationState {
|
||||
return {
|
||||
kind,
|
||||
@@ -559,7 +578,7 @@ export function createMiniGameDraftGenerationState(
|
||||
: kind === 'wooden-fish'
|
||||
? 'wooden-fish-draft'
|
||||
: 'compile',
|
||||
startedAtMs: Date.now(),
|
||||
startedAtMs,
|
||||
completedAssetCount: 0,
|
||||
totalAssetCount: 0,
|
||||
error: null,
|
||||
|
||||
Reference in New Issue
Block a user