feat: add official game audio generation client
This commit is contained in:
119
electron/coding-plugins/adapters/game-audio.ts
Normal file
119
electron/coding-plugins/adapters/game-audio.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { CodingPluginToolDefinition } from '../../../shared/coding-plugins';
|
||||
import type { MarketplaceHostedAdmissionResolver } from '../hosted-admission';
|
||||
import { MarketplaceHostedAdmissionError } from '../hosted-admission';
|
||||
import type { AdapterInvocationResult, CodingPluginAdapter, TrustedCodingCapabilityContext } from '../registry';
|
||||
import { audioRecord, GameAudioClientError, type AudioCommand, type GameAudioClient } from '../../services/game-audio-client';
|
||||
import type { AudioDeliveryResult, GameAudioDeliveryCoordinator } from '../../services/game-audio-delivery';
|
||||
import { getWorksSquareAccountBinding } from '../../services/works-square-session';
|
||||
|
||||
interface Options {
|
||||
client: Pick<GameAudioClient, 'cancel'>;
|
||||
delivery: Pick<GameAudioDeliveryCoordinator, 'generateAndMaterialize'>;
|
||||
admission: Pick<MarketplaceHostedAdmissionResolver, 'resolve'>;
|
||||
getPricingVersion: () => string | null;
|
||||
getAccountKey?: () => string | null;
|
||||
}
|
||||
function invalid(): never { throw new GameAudioClientError('plugin_input_invalid', 422, 'Audio generation input is invalid'); }
|
||||
function result(value: AudioDeliveryResult, projectId: string): AdapterInvocationResult {
|
||||
const { billing, executionId, progressPercent, ...remaining } = value;
|
||||
const data = { ...remaining, projectId,
|
||||
...(executionId === null ? {} : { executionId }),
|
||||
...(progressPercent === null ? {} : { progressPercent }) };
|
||||
if (value.providerStatus === 'failed') return {
|
||||
success: false, status: 503, code: value.errorCode ?? 'game_audio_generation_failed',
|
||||
error: 'Audio generation failed; inspect the billing receipt before a new attempt',
|
||||
retryable: false, payload_schema: 'game-audio.v1', data: null, billing,
|
||||
};
|
||||
return { success: true, status: value.deliveryStatus === 'saved' ? 200 : 202,
|
||||
code: null, error: null,
|
||||
retryable: false, payload_schema: 'game-audio.v1', data, billing };
|
||||
}
|
||||
async function references(project: string, value: unknown) {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length > 4) invalid();
|
||||
const items: { name: string; mime_type: string; data_base64: string }[] = [];
|
||||
let total = 0;
|
||||
for (const candidate of value) {
|
||||
if (typeof candidate !== 'string' || !candidate || candidate.length > 1024) invalid();
|
||||
const absolute = path.resolve(project, candidate);
|
||||
const relative = path.relative(path.resolve(project), absolute);
|
||||
if (!relative || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) invalid();
|
||||
const mime = ({ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' } as Record<string, string>)[path.extname(absolute).toLowerCase()];
|
||||
const metadata = await stat(absolute);
|
||||
if (!mime || !metadata.isFile() || metadata.size <= 0 || metadata.size > 8 * 1024 * 1024) invalid();
|
||||
const content = await readFile(absolute);
|
||||
total += content.byteLength;
|
||||
if (!content.length || content.length > 8 * 1024 * 1024 || total > 16 * 1024 * 1024) invalid();
|
||||
items.push({ name: path.basename(absolute).slice(0, 160), mime_type: mime, data_base64: content.toString('base64') });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
export class GameAudioPluginAdapter implements CodingPluginAdapter {
|
||||
readonly pluginId = 'makelore.game-audio';
|
||||
constructor(private readonly options: Options) {}
|
||||
async inspect() { return { status: 'ready' as const }; }
|
||||
async invoke(context: TrustedCodingCapabilityContext, tool: CodingPluginToolDefinition,
|
||||
input: unknown, onProgress?: (value: AdapterInvocationResult) => void): Promise<AdapterInvocationResult> {
|
||||
let submitted = false;
|
||||
try {
|
||||
if (!audioRecord(input)) invalid();
|
||||
const account = this.options.getAccountKey ? this.options.getAccountKey() : getWorksSquareAccountBinding()?.accountKey;
|
||||
if (!account) throw new GameAudioClientError('authentication_required', 401, 'Sign-in is required');
|
||||
if (tool.name === 'game_audio_cancel') {
|
||||
if (typeof input.executionId !== 'string' || Object.keys(input).some((key) => key !== 'executionId')) invalid();
|
||||
const generation = await this.options.client.cancel(input.executionId, account);
|
||||
return { success: true, status: 200, code: null, error: null, retryable: false,
|
||||
payload_schema: 'game-audio.v1', data: { executionId: generation.execution_id,
|
||||
providerStatus: generation.provider_status, outputCount: generation.outputs.length,
|
||||
generationBilling: Object.fromEntries(Object.entries(generation.billing).filter(([, value]) => value !== null)) } };
|
||||
}
|
||||
if (input.confirmed !== true) throw new GameAudioClientError('confirmation_required', 400, 'Confirm Token Point generation and automatic project save first');
|
||||
const sound = tool.name === 'game_sound_generate';
|
||||
if (!sound && tool.name !== 'game_music_generate' && tool.name !== 'game_music_preview') invalid();
|
||||
const allowed = sound ? ['prompt', 'confirmed', 'mode', 'duration', 'count', 'loop'] : ['prompt', 'confirmed', 'referencePaths'];
|
||||
if (Object.keys(input).some((key) => !allowed.includes(key)) || typeof input.prompt !== 'string'
|
||||
|| !input.prompt.trim() || input.prompt.trim().length > 4000) invalid();
|
||||
const pricing = this.options.getPricingVersion();
|
||||
if (!pricing) throw new GameAudioClientError('plugin_billing_unavailable', 503, 'Current audio pricing is unavailable');
|
||||
let specification: Pick<Extract<AudioCommand, { kind: 'sound' }>, 'kind' | 'mode' | 'duration' | 'count' | 'loop'>
|
||||
| Pick<Extract<AudioCommand, { kind: 'music' }>, 'kind' | 'mode' | 'reference_files'>;
|
||||
if (sound) {
|
||||
const mode = input.mode ?? 'single';
|
||||
const duration = input.duration ?? 2;
|
||||
const count = input.count ?? (mode === 'single' ? 1 : 4);
|
||||
const loop = input.loop ?? false;
|
||||
if (!['single', 'pack', 'variants'].includes(String(mode)) || typeof mode !== 'string'
|
||||
|| typeof duration !== 'number' || !(duration === 0.5 || Number.isInteger(duration) && duration >= 1 && duration <= 10)
|
||||
|| typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > 10
|
||||
|| (mode === 'single' && count !== 1) || typeof loop !== 'boolean') invalid();
|
||||
specification = { kind: 'sound', mode: mode as 'single' | 'pack' | 'variants', duration, count, loop };
|
||||
} else {
|
||||
specification = { kind: 'music', mode: tool.name === 'game_music_preview' ? 'demo' : 'full',
|
||||
reference_files: await references(context.projectPath, input.referencePaths) };
|
||||
}
|
||||
const admission = await this.options.admission.resolve({ pluginId: this.pluginId,
|
||||
workerSnapshot: { requestId: context.requestId, pluginReleaseId: context.pluginReleaseId } });
|
||||
submitted = true;
|
||||
const delivered = await this.options.delivery.generateAndMaterialize({
|
||||
accountKey: account, conversationId: context.conversationId, runId: context.runId,
|
||||
localProjectId: context.localProjectId, durableProjectId: context.durableProjectId,
|
||||
projectPath: context.projectPath, logicalOperationId: context.requestId,
|
||||
}, { ...specification, release_id: admission.releaseId, release_admission_id: admission.releaseAdmissionId,
|
||||
project_id: context.durableProjectId, logical_operation_id: context.requestId,
|
||||
pricing_version_id: pricing, prompt: input.prompt.trim(), confirmed: true },
|
||||
(progress) => onProgress?.(result(progress, context.localProjectId)));
|
||||
return result(delivered, context.localProjectId);
|
||||
} catch (error) {
|
||||
const known = error instanceof GameAudioClientError || error instanceof MarketplaceHostedAdmissionError;
|
||||
return { success: false, status: known ? error.status : 503,
|
||||
code: known ? error.code : 'plugin_backend_unavailable',
|
||||
error: known ? error.message : submitted
|
||||
? 'Audio receipt is unavailable; recover the original operation instead of generating again'
|
||||
: 'Audio service is unavailable; no new generation was started',
|
||||
retryable: false, payload_schema: 'game-audio.v1', data: null,
|
||||
billing: { mode: 'platform_metered', status: submitted ? 'receipt_unavailable' : 'not_started' } };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
CODE_OWNED_PLUGIN_PERMISSION_IDS,
|
||||
GAME_RESOURCE_BUNDLED_RELEASE_ID,
|
||||
GAME_RESOURCE_PLUGIN_ID,
|
||||
GAME_AUDIO_PLUGIN_ID,
|
||||
GAME_AUDIO_BUNDLED_RELEASE_ID,
|
||||
PROJECT_SCAFFOLD_BUNDLED_RELEASE_ID,
|
||||
PROJECT_SCAFFOLD_PLUGIN_ID,
|
||||
DATA_SERVICE_CAPABILITY_IDS,
|
||||
@@ -38,9 +40,17 @@ export const BUNDLED_CODING_PLUGIN_ROOTS = Object.freeze([
|
||||
'data-service',
|
||||
'game-resource',
|
||||
'project-scaffold',
|
||||
'game-audio',
|
||||
] as const);
|
||||
|
||||
const BUNDLED_CODING_PLUGIN_METADATA = Object.freeze({
|
||||
'game-audio': Object.freeze({
|
||||
pluginId: GAME_AUDIO_PLUGIN_ID,
|
||||
runtimeKind: 'platform_hosted' as const,
|
||||
acquisitionMode: 'user_acquired' as const,
|
||||
releaseId: GAME_AUDIO_BUNDLED_RELEASE_ID,
|
||||
bundledV2: true,
|
||||
}),
|
||||
'data-service': Object.freeze({
|
||||
pluginId: DATA_SERVICE_PLUGIN_ID,
|
||||
runtimeKind: 'bundled_typed' as const,
|
||||
@@ -1182,9 +1192,10 @@ function validateBundledDefinitions(definitions: readonly CodingPluginDefinition
|
||||
}
|
||||
for (const tool of definition.tools) {
|
||||
if (toolIds.has(tool.name)) throw new Error(`Duplicate bundled tool identifier: ${tool.name}`);
|
||||
if (operationIds.has(tool.operation)) throw new Error(`Duplicate bundled operation identifier: ${tool.operation}`);
|
||||
const operationKey = `${definition.id}:${tool.capabilityId}:${tool.operation}`;
|
||||
if (operationIds.has(operationKey)) throw new Error(`Duplicate bundled operation identifier: ${operationKey}`);
|
||||
toolIds.add(tool.name);
|
||||
operationIds.add(tool.operation);
|
||||
operationIds.add(operationKey);
|
||||
capabilityIds.add(tool.capabilityId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user