Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/LOCAL_MODELS.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,30 @@ Creative quality and native-English listening acceptance remain for human review

## Follow-up reuse from the existing story scripts

### Fixed-reference narration in this fork

For the workstation's authenticated remote MCP gateway, `bdesign_asset_upload`
accepts canonical base64 media in 512 KiB decoded chunks. An upload UUID is bound
to the project, filename, total byte length and SHA-256; identical chunks can be
retried, conflicting bytes are rejected. The file limit is 64 MiB. Final import
uses the existing project Asset service and ownership checks. This transports
only explicitly supplied bytes and does not expose the blocked host-path import
tool or change gateway/HTTP authentication. Keep upload receipts for recovery.

`bdesign_audio_generate` accepts `referenceAssetId` plus its verbatim `referenceText`,
and optional `maxNewTokens` (32–1024), `temperature` (0–2), and `topP` (0–1, exclusive zero).
Text remains limited to 200 characters per take. Omit both reference fields to
retain unconditioned generation. UI/API asset-first requests use an `audio_track`
reference and `reference_text`, `max_new_tokens`, `temperature`, `top_p` parameters.
The shared preflight enforces project ownership, local audio media and one
reference. The worker validates a decodable 0–30 second reference and records its
Asset ID, SHA-256, exact transcript, parameters and input fingerprint for each take.
It maps Windows drive paths to the existing WSL Drama service. No client-supplied
filesystem path, remote provider fallback or separate project state is introduced.
Interrupted/ambiguous TTS requests are not automatically repeated. Restart web/MCP
after installing this code. Actual voice quality and listening acceptance remain
distinct from automated request/provenance checks.

The story-production handoff was reviewed separately. The pinned H3 output keeps
its original audio and native duration; adding narration does not replace that
source Asset. Do not import the old story scripts as libraries: several execute
Expand Down
4 changes: 2 additions & 2 deletions scripts/local-worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def main():
from local_h3_duration import execute_h3_with_duration, verify_h3_output
from local_ref2va import execute_ref2va
from local_flux3 import ensure_flux3, execute_flux3, release_flux3
from tools.audio.drama3_local_tts import Drama3LocalTTS
from local_narration import execute_narration
settings = json.loads(Path(args.settings).read_text(encoding='utf8')) if args.settings else {}
flux_config = settings.get('flux3')

Expand Down Expand Up @@ -106,7 +106,7 @@ def notify(message):
if _ready(bootstrap()['comfyui_url'] + '/system_stats'):
release_comfy()
ensure_drama(notify)
result = Drama3LocalTTS().execute({'text': inputs['prompt'], 'output_path': str(output)})
result = execute_narration(inputs, output)
if not result.success:
raise RuntimeError(result.error)
provenance = result.data
Expand Down
93 changes: 93 additions & 0 deletions scripts/local_narration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Local Drama adapter called only by the normal BeatDesign job worker.

References are typed project Assets resolved by shared server preflight, never
client-supplied paths. Reuses the runtime's existing service and GPU lease.
"""
import hashlib
import json
from pathlib import Path, PureWindowsPath
import time
from urllib.parse import urlparse


def server_audio_path(path):
"""The configured Drama service runs in WSL on the same workstation."""
win = PureWindowsPath(str(path))
if win.drive:
if len(win.drive) != 2 or win.drive[1] != ':':
raise ValueError('Drama reference must be on a WSL-mounted local drive, not UNC')
return '/mnt/' + win.drive[0].lower() + '/' + '/'.join(win.parts[1:])
return str(Path(path).resolve())


def describe_narration(inputs, probe):
text = inputs.get('prompt')
if not isinstance(text, str) or not text.strip() or len(text) > 200:
raise ValueError('Narration requires 1-200 characters')
payload = {'input': text, 'response_format': 'wav', 'stream': False,
'max_new_tokens': inputs.get('max_new_tokens', 512),
'temperature': inputs.get('temperature', 0.8), 'top_p': inputs.get('top_p', 0.9)}
refs = inputs.get('_localReferences', [])
ref_text = inputs.get('reference_text')
if bool(refs) != bool(ref_text) or len(refs) > 1:
raise ValueError('One reference audio Asset and exact reference text must be supplied together')
references = []
if refs:
ref = refs[0]
if ref.get('type') != 'audio' or not ref.get('assetId'):
raise ValueError('Narration reference must be a resolved audio Asset')
path = Path(ref['path']).resolve(strict=True)
media = probe(path)
if not any(s['codec_type'] == 'audio' for s in media['streams']):
raise ValueError('Reference has no decodable audio')
duration = float(media['format']['duration'])
if not 0 < duration <= 30:
raise ValueError('Reference voice must be greater than zero and at most 30 seconds')
payload['references'] = [{'audio_path': server_audio_path(path), 'text': ref_text}]
references.append({'assetId': ref['assetId'], 'path': str(path), 'text': ref_text,
'sha256': hashlib.sha256(path.read_bytes()).hexdigest(), 'duration_seconds': duration})
fingerprint = hashlib.sha256(json.dumps({'request': payload, 'references': references}, sort_keys=True).encode()).hexdigest()
return payload, references, fingerprint


def execute_narration(inputs, output):
import requests
from lib.local_runtime import bootstrap, probe_media, write_json
from tools.base_tool import ToolResult
started = time.monotonic()
try:
cfg = bootstrap()
base = cfg['drama3_url'].rstrip('/')
if urlparse(base).hostname not in {'localhost', '127.0.0.1', '::1'}:
raise ValueError('The configured Drama worker must be local')
output = Path(output).resolve()
if output.exists() or output.with_suffix('.request.json').exists():
raise FileExistsError('Ambiguous/previous narration attempt exists; inspect before creating a new take')
payload, references, fingerprint = describe_narration(inputs, probe_media)
models = requests.get(base + '/v1/models', timeout=10)
models.raise_for_status()
model_info = models.json()
if not any('drama-3-int4' in m.get('id', '') for m in model_info.get('data', [])):
raise RuntimeError('Configured service does not report Drama 3 INT4')
output.parent.mkdir(parents=True, exist_ok=True)
write_json(output.with_suffix('.request.json'), {'request': payload, 'references': references, 'input_fingerprint': fingerprint})
response = requests.post(base + '/v1/audio/speech', json=payload, timeout=(10, 900))
if not response.ok:
raise RuntimeError(f'Drama HTTP {response.status_code}: {response.text[:1000]}')
if response.content[:4] != b'RIFF':
raise RuntimeError('Drama did not return WAV audio')
output.write_bytes(response.content)
media = probe_media(output)
audio = next(s for s in media['streams'] if s['codec_type'] == 'audio')
provenance = {'provider': 'drama3_local', 'model': cfg['drama3_model'], 'output': str(output),
'text': payload['input'], 'audio_duration_seconds': float(media['format']['duration']),
'sample_rate': int(audio['sample_rate']), 'channels': audio['channels'],
'sha256': hashlib.sha256(response.content).hexdigest(), 'references': references,
'input_fingerprint': fingerprint, 'voice_mode': 'fixed_reference' if references else 'unconditional',
'parameters': {k: v for k, v in payload.items() if k not in ('references', 'input')},
'endpoint_models': model_info, 'human_listening_accepted': False}
write_json(output.with_suffix('.provenance.json'), provenance)
return ToolResult(success=True, data=provenance, artifacts=[str(output)],
model=cfg['drama3_model'], duration_seconds=time.monotonic()-started)
except Exception as exc:
return ToolResult(success=False, error=str(exc), duration_seconds=time.monotonic()-started)
40 changes: 40 additions & 0 deletions scripts/test_local_narration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from pathlib import Path
import tempfile
import unittest
from local_narration import describe_narration, server_audio_path


class NarrationTests(unittest.TestCase):
def probe(self, path):
return {'streams': [{'codec_type': 'audio'}], 'format': {'duration': '12'}}

def test_windows_reference_mount(self):
self.assertEqual(server_audio_path(r'D:\Project with spaces\voice.wav'), '/mnt/d/Project with spaces/voice.wav')
with self.assertRaises(ValueError):
server_audio_path(r'\\remote\share\voice.wav')

def test_reference_hash_transcript_and_redo_identity(self):
with tempfile.TemporaryDirectory() as root:
path = Path(root) / 'voice.wav'; path.write_bytes(b'RIFFfixtureWAVE')
inputs = {'prompt': 'Come back, cat!', 'reference_text': 'A lovely day.',
'_localReferences': [{'assetId': 'voice', 'path': str(path), 'type': 'audio'}]}
payload, refs, fingerprint = describe_narration(inputs, self.probe)
self.assertEqual(payload['references'][0]['text'], inputs['reference_text'])
self.assertEqual(refs[0]['assetId'], 'voice')
self.assertEqual(len(refs[0]['sha256']), 64)
self.assertEqual(describe_narration(inputs, self.probe)[2], fingerprint)
next_payload, next_refs, _ = describe_narration({**inputs, 'prompt': 'Stay here!'}, self.probe)
self.assertEqual(next_refs, refs)
self.assertEqual(next_payload['references'], payload['references'])
path.write_bytes(b'changed');self.assertNotEqual(describe_narration(inputs, self.probe)[2], fingerprint)

def test_reference_limits_and_pairing(self):
with self.assertRaises(ValueError):describe_narration({'prompt':'Hello', 'reference_text':'Ref'}, self.probe)
with self.assertRaises(ValueError):describe_narration({'prompt':'x'*201}, self.probe)
with tempfile.TemporaryDirectory() as root:
path = Path(root) / 'voice.wav';path.write_bytes(b'RIFF')
inputs={'prompt':'Hi', 'reference_text':'Ref','_localReferences':[{'assetId':'voice','type':'audio','path':str(path)}]}
with self.assertRaises(ValueError):describe_narration(inputs, lambda p:{'streams':[{'codec_type':'audio'}],'format':{'duration':'31'}})


if __name__ == '__main__':unittest.main()
13 changes: 12 additions & 1 deletion src/core/effects/registered-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,18 @@ const buildVideoAnalysisEffect = (
};
};

const buildLocalAudioEffect = (binding: GenerationProviderModelBinding, providerId = getActiveGenerationProviderId()): RegisteredEffect => ({ id: binding.effectId, name: LOCAL_AUDIO_MODEL.name, type: 4, model: binding.modelId, version: null, linkName: binding.modelId, description: 'Local short narration', platform: providerId, api: null, provider: providerId, inputSchema: { prompt: { type: 'string', required: true, maxLength: 200 } } });
const buildLocalAudioEffect = (binding: GenerationProviderModelBinding, providerId = getActiveGenerationProviderId()): RegisteredEffect => ({
id: binding.effectId, name: LOCAL_AUDIO_MODEL.name, type: 4, model: binding.modelId,
version: null, linkName: binding.modelId, description: 'Local narration with optional fixed reference voice',
platform: providerId, api: null, provider: providerId,
inputSchema: {
prompt: { type: 'string', required: true, maxLength: 200 },
reference_text: { type: 'string', required: false, maxLength: 4000 },
max_new_tokens: { type: 'number', required: false, minimum: 32, maximum: 1024 },
temperature: { type: 'number', required: false, minimum: 0, maximum: 2 },
top_p: { type: 'number', required: false, exclusiveMinimum: 0, maximum: 1 },
},
});

const optionalAnyField = { type: 'any', required: false } as const;

Expand Down
23 changes: 23 additions & 0 deletions src/core/generation-providers/local-drama3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { z } from 'zod';

export const LOCAL_NARRATION_MAX_TEXT = 200;
export const localNarrationParameters = z.object({
reference_text: z.string().trim().min(1).max(4000).optional(),
max_new_tokens: z.number().int().min(32).max(1024).optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().gt(0).max(1).optional(),
}).strict();

export function validateLocalNarration(input: Record<string, unknown>) {
if (typeof input.prompt !== 'string' || !input.prompt.trim() || input.prompt.length > LOCAL_NARRATION_MAX_TEXT)
throw new Error('Drama 3 narration requires text up to 200 characters.');
if (input.image_urls || input.video_urls) throw new Error('Drama 3 accepts audio references only.');
const { prompt: _prompt, audio_urls, _localReferences: _refs, _localReferencePaths: _paths, ...parameters } = input;
const parsed = localNarrationParameters.safeParse(parameters);
if (!parsed.success) throw new Error(`Unsupported Drama 3 parameter: ${parsed.error.message}`);
if (audio_urls !== undefined && (!Array.isArray(audio_urls) || audio_urls.length !== 1 ||
audio_urls.some(url => typeof url !== 'string' || !url.trim())))
throw new Error('Drama 3 accepts exactly one reference audio when supplied.');
if (Boolean(audio_urls) !== Boolean(parsed.data.reference_text))
throw new Error('Reference audio and its exact reference_text must be supplied together.');
}
4 changes: 2 additions & 2 deletions src/core/generation-providers/local-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createServerOnlyFn } from '@tanstack/react-start';
import { requireLocalH3Timing } from './local-h3-duration';
import referenceLimits from './local-h3-ref2va.json';
import flux3 from './local-flux3.json';
import { validateLocalNarration } from './local-drama3';

const submit = createServerOnlyFn(async (model: string, input: unknown) =>
(await import('./local-runtime.server')).submitLocalJob(model, input));
Expand Down Expand Up @@ -41,6 +42,7 @@ export const localGenerationProvider: GenerationProviderDefinition = {
},
validateInput: (effect, input) => {
if (!LOCAL_MODELS.includes(effect.model)) return beatApiGenerationProvider.validateInput?.(effect, input);
if (effect.model === 'local-drama3') return validateLocalNarration(input);
if (effect.model === 'local-h3' && (!Array.isArray(input.image_urls) || input.image_urls.length !== 1))
throw new Error('Local H3 requires exactly one first-frame image.');
if (effect.model === 'local-h3' && (input.video_urls || input.audio_urls ||
Expand Down Expand Up @@ -70,8 +72,6 @@ export const localGenerationProvider: GenerationProviderDefinition = {
throw new Error(`Local FLUX 3 accepts at most ${flux3.maxReferenceImages} image references.`);
const allowed = new Set(['prompt', '_localReferencePaths', '_localReferences', ...(isH3 ? ['image_urls', 'seed', 'aspect_ratio', 'wmDuration'] : []), ...(effect.model === 'local-h3-ref2va' ? ['video_urls', 'audio_urls'] : []), ...(effect.model === 'local-flux3-klein' ? ['image_urls', 'seed', 'aspect_ratio'] : [])]);
if (Object.keys(input).some(key => !allowed.has(key))) throw new Error('Unsupported local model parameter.');
if (effect.model === 'local-drama3' && (String(input.prompt).length > 200 || input.image_urls || input.audio_urls || input.video_urls))
throw new Error('Drama 3 preview accepts text only, up to 200 characters.');
if (input.seed !== undefined && (!Number.isSafeInteger(input.seed) || Number(input.seed) < 0))
throw new Error('Seed must be a non-negative safe integer.');
},
Expand Down
6 changes: 5 additions & 1 deletion src/core/generation-providers/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,11 @@ export function listGenerationModelDescriptors(): GenerationModelDescriptor[] {
return provider.modelBindings.flatMap<GenerationModelDescriptor>((binding) => {
const effect = getRegisteredEffectById(binding.effectId);
if (!effect) return [];
if (effect.type === 4) return [{ id: binding.modelId, name: effect.name, providerId: provider.id, kind: 'audio', available: true, parameterSchema: effect.inputSchema, defaultParameters: {}, referenceSchema: { image: { min: 0, max: 0 }, video: { min: 0, max: 0 }, audio: { min: 0, max: 0 } } }];
if (effect.type === 4) return [{ id: binding.modelId, name: effect.name, providerId: provider.id, kind: 'audio', available: true, parameterSchema: effect.inputSchema,
defaultParameters: { max_new_tokens: 512, temperature: 0.8, top_p: 0.9 },
referenceSchema: { image: { min: 0, max: 0 }, video: { min: 0, max: 0 }, audio: { min: 0, max: 1 },
maxTotal: 1, audioDurationSeconds: { minExclusive: 0, max: 30 },
requiresReferenceText: true, note: 'One optional project-owned audio Asset and its exact transcript reuse the voice. Omit both for an unconditioned voice.' } }];
if (binding.modelId === VIDEO_ANALYSIS_MODEL_ID) {
return [
{
Expand Down
23 changes: 23 additions & 0 deletions src/core/generation-providers/submit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,29 @@ test('local narration enforces the short-text boundary and is discoverable as au
);
});

test('fixed narration voice uses a project-owned audio Asset and identical UI/MCP compilation', async () => {
const { importLocalProjectAsset } = await import('@/core/projects/import-local-asset');
const project = await createProject({ name: 'Fixed voice' });
const other = await createProject({ name: 'Other voice owner' });
const audioPath = join(directory, 'fixed-voice.wav');
await writeFile(audioPath, Buffer.from('RIFFfixtureWAVE'));
const voice = await importLocalProjectAsset({ projectId: project.id, filePath: audioPath });
const foreign = await importLocalProjectAsset({ projectId: other.id, filePath: audioPath });
const generation = { version: 2, projectId: project.id, modelId: 'local-drama3', mode: 'audio', prompt: 'Come back, cat!',
references: [{assetId: voice.id, role: 'audio_track'}],
parameters: {reference_text: 'A lovely day.', max_new_tokens: 512, _localReferences: [{path:'C:/forged.wav',type:'audio'}]} };
assert.equal((await preflightAssetFirstGeneration(generation)).expectedUploadCount, 0);
await submitAssetFirstGeneration({generation,origin:'ui'});
const ui = inputs.at(-1) as Record<string,unknown>;
await submitAssetFirstGeneration({generation,origin:'mcp'});
assert.deepEqual(inputs.at(-1),ui);
const refs = ui._localReferences as Array<{assetId:string;path:string;type:string}>;
assert.equal(refs[0].assetId,voice.id); assert.equal(refs[0].type,'audio');
assert.ok(!refs[0].path.includes('forged'));
await assert.rejects(()=>preflightAssetFirstGeneration({...generation,references:[{assetId:foreign.id,role:'audio_track'}]}), /does not belong/);
await assert.rejects(()=>preflightAssetFirstGeneration({...generation,parameters:{}}), /together/);
});

test('local Ref2VA delivers ordered typed project references identically from UI and MCP without upload', async () => {
const { importLocalProjectAsset } = await import('@/core/projects/import-local-asset');
const { copyFile } = await import('node:fs/promises');
Expand Down
Loading