feat(web): add audio speaker and particle authoring
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-29 06:30:42 +08:00
parent 81113159dc
commit 43d0c437bb
44 changed files with 2228 additions and 54 deletions

Binary file not shown.

View File

@@ -703,6 +703,9 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
const activeCurve = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "CURVE") : undefined;
const activeFont = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "FONT") : undefined;
const activeText = activeNode?.dataId ? snapshot?.texts?.find((data) => data.id === activeNode.dataId) : undefined;
const activeSound = activeNode?.dataId ? snapshot?.sounds?.find((data) => data.id === activeNode.dataId) : undefined;
const activeSpeaker = activeNode?.dataId ? snapshot?.speakers?.find((data) => data.id === activeNode.dataId) : undefined;
const activeParticleSettings = activeNode?.dataId ? snapshot?.particleSettings?.find((data) => data.id === activeNode.dataId) : undefined;
const activeVolume = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "VOLUME") : undefined;
const activeMetaball = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "METABALL") : undefined;
const activeGreasePencil = activeNode?.dataId ? snapshot?.greasePencils?.find((data) => data.id === activeNode.dataId) : undefined;
@@ -907,6 +910,9 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
{packedImages.length > 0 ? <div className="property-section" data-testid="resource-workbench"><h3>Resources</h3><output>{packedImages.length} packed image assets</output>{packedImages.map((image) => <button key={image.assetId} type="button" onClick={() => onRequestAsset(image.assetId)}>加载 {image.name}</button>)}</div> : null}
{(snapshot?.vfonts?.length ?? 0) > 0 || (snapshot?.sounds?.length ?? 0) > 0 || (snapshot?.speakers?.length ?? 0) > 0 || (snapshot?.texts?.length ?? 0) > 0 || (snapshot?.textures?.length ?? 0) > 0 || (snapshot?.particleSettings?.length ?? 0) > 0 ? <div className="property-section" data-testid="resource-scene-workbench"><h3>Scene Resources</h3><output data-testid="resource-scene-counts">VFonts {snapshot?.vfonts?.length ?? 0} · Sounds {snapshot?.sounds?.length ?? 0} · Speakers {snapshot?.speakers?.length ?? 0} · Texts {snapshot?.texts?.length ?? 0} · Textures {snapshot?.textures?.length ?? 0} · Particles {snapshot?.particleSettings?.length ?? 0}</output>{snapshot?.vfonts?.map((font) => <output key={font.id} data-resource-id={font.id}>VFont: {font.name} {font.packed ? "(packed)" : "(external)"}</output>)}{snapshot?.sounds?.map((sound) => <output key={sound.id} data-resource-id={sound.id}>Sound: {sound.name} · {sound.audioChannels}ch @ {sound.sampleRate}Hz</output>)}{snapshot?.speakers?.map((speaker) => <output key={speaker.id} data-resource-id={speaker.id}>Speaker: {speaker.name} · volume {speaker.volume.toFixed(2)}</output>)}{snapshot?.texts?.map((text) => <output key={text.id} data-resource-id={text.id}>Text: {text.name} · {text.lineCount} lines{ text.internal ? " (internal)" : ""}</output>)}{snapshot?.textures?.map((texture) => <output key={texture.id} data-resource-id={texture.id}>Texture: {texture.name}</output>)}{snapshot?.particleSettings?.map((settings) => <output key={settings.id} data-resource-id={settings.id}>Particles: {settings.name} · {settings.totalParticles} total</output>)}</div> : null}
{activeText ? <div className="property-section" data-testid="text-datablock-workbench"><h3>Text Data-Block</h3><output>{activeText.name} · {activeText.lineCount} lines · {activeText.byteLength} bytes</output><textarea aria-label="Text source" value={textSource} maxLength={1_048_576} onChange={(event) => setTextSource(event.target.value)} /><div className="property-actions"><button type="button" disabled={textSource === activeText.source || new TextEncoder().encode(textSource).byteLength > 1_048_576} onClick={() => onCommand({ type: "setTextSource", dataId: activeText.id, source: textSource })}>Apply Text Source</button></div></div> : null}
{activeSound ? <div className="property-section" data-testid="audio-workbench"><h3>Sound</h3><output>{activeSound.name} · {activeSound.audioChannels}ch @ {activeSound.sampleRate}Hz</output><output>Volume {activeSound.volume.toFixed(2)} · Pitch {activeSound.pitch.toFixed(2)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setSoundProperties(activeSound.id, { volume: Math.min(1000000, activeSound.volume + 0.1) }))}>Increase Volume</button><button type="button" onClick={() => onCommand(ioMediaCommands.setSoundProperties(activeSound.id, { pitch: Math.min(1000000, activeSound.pitch + 0.05) }))}>Increase Pitch</button></div></div> : null}
{activeSpeaker ? <div className="property-section" data-testid="speaker-workbench"><h3>Speaker</h3><output>{activeSpeaker.name} · {activeSpeaker.soundId ?? "No sound"}</output><output>Volume {activeSpeaker.volume.toFixed(2)} · Pitch {activeSpeaker.pitch.toFixed(2)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setSpeakerProperties(activeSpeaker.id, { volume: Math.min(1000000, activeSpeaker.volume + 0.1) }))}>Increase Volume</button><button type="button" onClick={() => onCommand(ioMediaCommands.setSpeakerProperties(activeSpeaker.id, { coneVolumeOuter: Math.max(0, Math.min(1, activeSpeaker.coneVolumeOuter - 0.05)) }))}>Reduce Cone</button></div></div> : null}
{activeParticleSettings ? <div className="property-section" data-testid="particle-settings-workbench"><h3>Particle Settings</h3><output>{activeParticleSettings.name} · {activeParticleSettings.totalParticles} particles</output><output>Lifetime {activeParticleSettings.lifetime.toFixed(2)} · Size {activeParticleSettings.size.toFixed(3)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setParticleSettings(activeParticleSettings.id, { totalParticles: Math.min(1000000, activeParticleSettings.totalParticles + 100) }))}>Increase Count</button><button type="button" onClick={() => onCommand(ioMediaCommands.setParticleSettings(activeParticleSettings.id, { size: Math.min(1000000, activeParticleSettings.size + 0.01) }))}>Increase Size</button></div></div> : null}
{activeCamera ? <div className="property-section" data-testid="camera-workbench"><h3>Camera</h3><output>{activeCamera.projection} · {activeCamera.lensMm.toFixed(1)}mm</output><button type="button" onClick={() => onCommand(animationRenderCommands.setCameraProperties(activeCamera.id, { projection: activeCamera.projection === "ORTHOGRAPHIC" ? "PERSPECTIVE" : "ORTHOGRAPHIC" }))}>切换投影</button><button type="button" onClick={() => onCommand(animationRenderCommands.setCameraProperties(activeCamera.id, { lensMm: Math.min(10000, activeCamera.lensMm + 5) }))}>增加焦距</button></div> : null}
{activeLight ? <div className="property-section" data-testid="light-workbench"><h3>Light</h3><output>{activeLight.energy.toFixed(1)} energy</output><button type="button" onClick={() => onCommand(animationRenderCommands.setLightProperties(activeLight.id, { energy: Math.min(1e9, activeLight.energy + 10) }))}>增加能量</button><button type="button" onClick={() => onCommand(animationRenderCommands.setLightProperties(activeLight.id, { castsShadow: !(activeLight.castsShadow ?? true) }))}>切换阴影</button></div> : null}
{activeWorld ? <div className="property-section" data-testid="world-workbench"><h3>World</h3><output>Exposure {activeWorld.exposure.toFixed(2)}</output><button type="button" onClick={() => onCommand(animationRenderCommands.setWorldProperties(activeWorld.id, { exposure: Math.max(-20, Math.min(20, activeWorld.exposure + 0.5)) }))}>增加曝光</button></div> : null}

Binary file not shown.

View File

@@ -420,6 +420,33 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
if (typeof payload.source !== "string" || new TextEncoder().encode(payload.source).byteLength > 1_048_576) throw report("NON_MESH_PROPERTY_INVALID", "Text source must be valid UTF-8 within the 1 MiB budget");
return;
}
case "setSoundProperties": {
const sound = currentSnapshot?.sounds?.find((candidate) => candidate.id === payload.dataId);
if (!sound) throw report("NON_MESH_DATA_UNSUPPORTED", `Sound data block is unavailable: ${payload.dataId}`);
if (!validPropertyKeys(payload.properties, ["volume", "attenuation", "pitch", "minGain", "maxGain", "distance", "offsetTime"])) throw report("NON_MESH_PROPERTY_INVALID", "Sound properties are empty or contain unsupported fields");
for (const value of Object.values(payload.properties)) if (!boundedNumber(value, 0, 1_000_000)) throw report("NON_MESH_PROPERTY_INVALID", "Sound properties are outside the bounded range");
return;
}
case "setSpeakerProperties": {
const speaker = currentSnapshot?.speakers?.find((candidate) => candidate.id === payload.dataId);
if (!speaker) throw report("NON_MESH_DATA_UNSUPPORTED", `Speaker data block is unavailable: ${payload.dataId}`);
if (!validPropertyKeys(payload.properties, ["volumeMax", "volumeMin", "distanceMax", "distanceReference", "attenuation", "coneAngleOuter", "coneAngleInner", "coneVolumeOuter", "volume", "pitch"])) throw report("NON_MESH_PROPERTY_INVALID", "Speaker properties are empty or contain unsupported fields");
for (const [key, value] of Object.entries(payload.properties)) {
const maximum = key.startsWith("coneAngle") ? 360 : key === "coneVolumeOuter" ? 1 : 1_000_000;
if (!boundedNumber(value, 0, maximum)) throw report("NON_MESH_PROPERTY_INVALID", "Speaker properties are outside the bounded range");
}
return;
}
case "setParticleSettings": {
const settings = currentSnapshot?.particleSettings?.find((candidate) => candidate.id === payload.dataId);
if (!settings) throw report("NON_MESH_DATA_UNSUPPORTED", `ParticleSettings data block is unavailable: ${payload.dataId}`);
if (!validPropertyKeys(payload.properties, ["type", "from", "distribution", "physicsType", "totalParticles", "start", "end", "lifetime", "size", "drawSize"])) throw report("NON_MESH_PROPERTY_INVALID", "Particle settings are empty or contain unsupported fields");
for (const [key, value] of Object.entries(payload.properties)) {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1_000_000) throw report("NON_MESH_PROPERTY_INVALID", `Particle setting ${key} is outside the bounded range`);
if (["type", "from", "distribution", "physicsType", "totalParticles"].includes(key) && !Number.isSafeInteger(value)) throw report("NON_MESH_PROPERTY_INVALID", `Particle setting ${key} must be an integer`);
}
return;
}
case "setVolumeProperties": {
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
if (!data || data.type !== "VOLUME") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Volume data block is unavailable: ${payload.dataId}`);

View File

@@ -10,4 +10,13 @@ export const ioMediaCommands = Object.freeze({
setMaterialImageNode(materialId: string, imageId: string, usage: IOCommand<"setMaterialImageNode">["usage"], uvMap?: string): IOCommand<"setMaterialImageNode"> {
return { type: "setMaterialImageNode", materialId, imageId, usage, ...(uvMap ? { uvMap } : {}) };
},
setSoundProperties(dataId: string, properties: IOCommand<"setSoundProperties">["properties"]): IOCommand<"setSoundProperties"> {
return { type: "setSoundProperties", dataId, properties: { ...properties } };
},
setSpeakerProperties(dataId: string, properties: IOCommand<"setSpeakerProperties">["properties"]): IOCommand<"setSpeakerProperties"> {
return { type: "setSpeakerProperties", dataId, properties: { ...properties } };
},
setParticleSettings(dataId: string, properties: IOCommand<"setParticleSettings">["properties"]): IOCommand<"setParticleSettings"> {
return { type: "setParticleSettings", dataId, properties: { ...properties } };
},
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand>);

View File

@@ -62,6 +62,9 @@ export const WEB_ENGINE_COMMAND_CAPABILITIES = {
importVFont: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setFontLinks: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setTextSource: { family: "IO", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setSoundProperties: { family: "AUDIO", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setSpeakerProperties: { family: "AUDIO", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setParticleSettings: { family: "PARTICLE", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setVolumeProperties: { family: "VOLUME", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
createGreasePencilLayer: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
removeGreasePencilLayer: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },

View File

@@ -126,6 +126,9 @@ export type WebEngineEditCommand =
| { type: "importVFont"; schemaVersion: 1; projectId: string; assetId: string; assetPath: string; sourcePath: string; name: string; format: "TTF" | "OTF" | "PFB"; mimeType: string; byteLength: number; sha256: string; base64: string }
| { type: "setFontLinks"; dataId: string; links: NonMeshFontLinksIR }
| { type: "setTextSource"; dataId: string; source: string }
| { type: "setSoundProperties"; dataId: string; properties: { volume?: number; attenuation?: number; pitch?: number; minGain?: number; maxGain?: number; distance?: number; offsetTime?: number } }
| { type: "setSpeakerProperties"; dataId: string; properties: { volumeMax?: number; volumeMin?: number; distanceMax?: number; distanceReference?: number; attenuation?: number; coneAngleOuter?: number; coneAngleInner?: number; coneVolumeOuter?: number; volume?: number; pitch?: number } }
| { type: "setParticleSettings"; dataId: string; properties: { type?: number; from?: number; distribution?: number; physicsType?: number; totalParticles?: number; start?: number; end?: number; lifetime?: number; size?: number; drawSize?: number } }
| { type: "setVolumeProperties"; dataId: string; sourcePath: string; displayDensity: number; interpolation: "NEAREST" | "LINEAR"; stepSize: number; velocityGrid?: string; velocityScale?: number }
| { type: "createGreasePencilLayer"; dataId: string; name: string }
| { type: "removeGreasePencilLayer"; dataId: string; layerId: string }