@t3lnet/sceneforge 1.0.7 → 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -0
- package/dist/index.cjs +419 -54
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +137 -0
- package/dist/index.d.ts +137 -0
- package/dist/index.js +419 -54
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -180,8 +180,8 @@ function formatValidationError(error) {
|
|
|
180
180
|
}
|
|
181
181
|
const issues = error.issues;
|
|
182
182
|
return issues.map((issue) => {
|
|
183
|
-
const
|
|
184
|
-
return `${
|
|
183
|
+
const path5 = issue.path.length ? issue.path.join(".") : "root";
|
|
184
|
+
return `${path5}: ${issue.message}`;
|
|
185
185
|
}).join("; ");
|
|
186
186
|
}
|
|
187
187
|
function parseDemoDefinition(input) {
|
|
@@ -311,7 +311,7 @@ function createEmptyStep(id) {
|
|
|
311
311
|
};
|
|
312
312
|
}
|
|
313
313
|
var SECRET_PATTERN = /\$\{(ENV|SECRET):([A-Za-z0-9_]+)\}/g;
|
|
314
|
-
function resolveSecrets(value, resolver,
|
|
314
|
+
function resolveSecrets(value, resolver, path5 = "root") {
|
|
315
315
|
if (typeof value === "string") {
|
|
316
316
|
if (!SECRET_PATTERN.test(value)) {
|
|
317
317
|
return value;
|
|
@@ -320,20 +320,20 @@ function resolveSecrets(value, resolver, path4 = "root") {
|
|
|
320
320
|
return value.replace(SECRET_PATTERN, (_match, _type, key) => {
|
|
321
321
|
const resolved = resolver(key);
|
|
322
322
|
if (resolved === void 0) {
|
|
323
|
-
throw new Error(`Missing secret for ${key} at ${
|
|
323
|
+
throw new Error(`Missing secret for ${key} at ${path5}`);
|
|
324
324
|
}
|
|
325
325
|
return resolved;
|
|
326
326
|
});
|
|
327
327
|
}
|
|
328
328
|
if (Array.isArray(value)) {
|
|
329
329
|
return value.map(
|
|
330
|
-
(entry, index) => resolveSecrets(entry, resolver, `${
|
|
330
|
+
(entry, index) => resolveSecrets(entry, resolver, `${path5}[${index}]`)
|
|
331
331
|
);
|
|
332
332
|
}
|
|
333
333
|
if (value && typeof value === "object") {
|
|
334
334
|
const result = {};
|
|
335
335
|
for (const [key, entry] of Object.entries(value)) {
|
|
336
|
-
result[key] = resolveSecrets(entry, resolver, `${
|
|
336
|
+
result[key] = resolveSecrets(entry, resolver, `${path5}.${key}`);
|
|
337
337
|
}
|
|
338
338
|
return result;
|
|
339
339
|
}
|
|
@@ -412,10 +412,10 @@ function createTypeAction(selector, text) {
|
|
|
412
412
|
text
|
|
413
413
|
};
|
|
414
414
|
}
|
|
415
|
-
function createNavigateAction(
|
|
415
|
+
function createNavigateAction(path5) {
|
|
416
416
|
return {
|
|
417
417
|
action: "navigate",
|
|
418
|
-
path:
|
|
418
|
+
path: path5
|
|
419
419
|
};
|
|
420
420
|
}
|
|
421
421
|
function createWaitAction(duration) {
|
|
@@ -488,14 +488,323 @@ function createDragAction(selector, deltaX, deltaY, steps = 20) {
|
|
|
488
488
|
|
|
489
489
|
// ../playwright/src/demo-runner.ts
|
|
490
490
|
import { expect } from "@playwright/test";
|
|
491
|
-
import * as
|
|
492
|
-
import * as
|
|
491
|
+
import * as fs4 from "fs/promises";
|
|
492
|
+
import * as path4 from "path";
|
|
493
493
|
|
|
494
494
|
// ../generation/src/voice-synthesis.ts
|
|
495
495
|
import { spawn } from "child_process";
|
|
496
496
|
import { ElevenLabsClient } from "elevenlabs";
|
|
497
|
+
import * as fs2 from "fs/promises";
|
|
498
|
+
import * as path2 from "path";
|
|
499
|
+
|
|
500
|
+
// ../generation/src/voice-cache.ts
|
|
501
|
+
import * as crypto from "crypto";
|
|
497
502
|
import * as fs from "fs/promises";
|
|
498
503
|
import * as path from "path";
|
|
504
|
+
var CACHE_VERSION = 1;
|
|
505
|
+
var INDEX_FILE = "index.json";
|
|
506
|
+
var AUDIO_DIR = "audio";
|
|
507
|
+
var DEFAULT_VOICE_SETTINGS = {
|
|
508
|
+
stability: 0.5,
|
|
509
|
+
similarityBoost: 0.75,
|
|
510
|
+
style: 0,
|
|
511
|
+
useSpeakerBoost: true
|
|
512
|
+
};
|
|
513
|
+
function normalizeVoiceSettings(settings) {
|
|
514
|
+
return {
|
|
515
|
+
stability: settings?.stability ?? DEFAULT_VOICE_SETTINGS.stability,
|
|
516
|
+
similarityBoost: settings?.similarityBoost ?? DEFAULT_VOICE_SETTINGS.similarityBoost,
|
|
517
|
+
style: settings?.style ?? DEFAULT_VOICE_SETTINGS.style,
|
|
518
|
+
useSpeakerBoost: settings?.useSpeakerBoost ?? DEFAULT_VOICE_SETTINGS.useSpeakerBoost
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function generateCacheKey(voiceId, modelId, text, voiceSettings) {
|
|
522
|
+
const normalizedText = text.trim();
|
|
523
|
+
const keyData = JSON.stringify({
|
|
524
|
+
voiceId,
|
|
525
|
+
modelId,
|
|
526
|
+
text: normalizedText,
|
|
527
|
+
voiceSettings: {
|
|
528
|
+
stability: voiceSettings.stability,
|
|
529
|
+
similarityBoost: voiceSettings.similarityBoost,
|
|
530
|
+
style: voiceSettings.style,
|
|
531
|
+
useSpeakerBoost: voiceSettings.useSpeakerBoost
|
|
532
|
+
}
|
|
533
|
+
});
|
|
534
|
+
return crypto.createHash("sha256").update(keyData).digest("hex");
|
|
535
|
+
}
|
|
536
|
+
var VoiceCache = class {
|
|
537
|
+
constructor(config) {
|
|
538
|
+
__publicField(this, "config");
|
|
539
|
+
__publicField(this, "index", null);
|
|
540
|
+
__publicField(this, "stats", { hits: 0, misses: 0 });
|
|
541
|
+
__publicField(this, "indexDirty", false);
|
|
542
|
+
this.config = {
|
|
543
|
+
...config,
|
|
544
|
+
enabled: config.enabled ?? true
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Gets the path to the cache index file
|
|
549
|
+
*/
|
|
550
|
+
get indexPath() {
|
|
551
|
+
return path.join(this.config.cacheDir, INDEX_FILE);
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Gets the path to the audio cache directory
|
|
555
|
+
*/
|
|
556
|
+
get audioDir() {
|
|
557
|
+
return path.join(this.config.cacheDir, AUDIO_DIR);
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Whether caching is enabled
|
|
561
|
+
*/
|
|
562
|
+
get enabled() {
|
|
563
|
+
return this.config.enabled ?? true;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Initializes the cache directory and loads the index
|
|
567
|
+
*/
|
|
568
|
+
async initialize() {
|
|
569
|
+
if (!this.enabled) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
await fs.mkdir(this.config.cacheDir, { recursive: true });
|
|
573
|
+
await fs.mkdir(this.audioDir, { recursive: true });
|
|
574
|
+
await this.loadIndex();
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Loads the cache index from disk
|
|
578
|
+
*/
|
|
579
|
+
async loadIndex() {
|
|
580
|
+
try {
|
|
581
|
+
const content = await fs.readFile(this.indexPath, "utf-8");
|
|
582
|
+
const parsed = JSON.parse(content);
|
|
583
|
+
if (parsed.version !== CACHE_VERSION) {
|
|
584
|
+
console.warn(
|
|
585
|
+
`[voice-cache] Index version mismatch (${parsed.version} vs ${CACHE_VERSION}), rebuilding cache`
|
|
586
|
+
);
|
|
587
|
+
this.index = { version: CACHE_VERSION, entries: {} };
|
|
588
|
+
this.indexDirty = true;
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
this.index = parsed;
|
|
592
|
+
} catch (error) {
|
|
593
|
+
if (error.code === "ENOENT") {
|
|
594
|
+
this.index = { version: CACHE_VERSION, entries: {} };
|
|
595
|
+
this.indexDirty = true;
|
|
596
|
+
} else {
|
|
597
|
+
console.warn(`[voice-cache] Failed to load index, creating new one:`, error);
|
|
598
|
+
this.index = { version: CACHE_VERSION, entries: {} };
|
|
599
|
+
this.indexDirty = true;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Saves the cache index to disk
|
|
605
|
+
*/
|
|
606
|
+
async saveIndex() {
|
|
607
|
+
if (!this.enabled || !this.index || !this.indexDirty) {
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
await fs.writeFile(this.indexPath, JSON.stringify(this.index, null, 2));
|
|
611
|
+
this.indexDirty = false;
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Looks up a cached audio file by its synthesis parameters.
|
|
615
|
+
* Returns the cache entry if found, null otherwise.
|
|
616
|
+
*/
|
|
617
|
+
async get(voiceId, modelId, text, voiceSettings) {
|
|
618
|
+
if (!this.enabled || !this.index) {
|
|
619
|
+
this.stats.misses++;
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
const key = generateCacheKey(voiceId, modelId, text, voiceSettings);
|
|
623
|
+
const entry = this.index.entries[key];
|
|
624
|
+
if (!entry) {
|
|
625
|
+
this.stats.misses++;
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
const audioPath = path.join(this.audioDir, entry.audioFileName);
|
|
629
|
+
try {
|
|
630
|
+
await fs.access(audioPath);
|
|
631
|
+
} catch {
|
|
632
|
+
delete this.index.entries[key];
|
|
633
|
+
this.indexDirty = true;
|
|
634
|
+
this.stats.misses++;
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
entry.lastUsedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
638
|
+
this.indexDirty = true;
|
|
639
|
+
this.stats.hits++;
|
|
640
|
+
return entry;
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Gets the full path to a cached audio file
|
|
644
|
+
*/
|
|
645
|
+
getAudioPath(entry) {
|
|
646
|
+
return path.join(this.audioDir, entry.audioFileName);
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* Stores a synthesized audio file in the cache.
|
|
650
|
+
* The audio data should be provided as a Buffer.
|
|
651
|
+
*/
|
|
652
|
+
async put(voiceId, modelId, text, voiceSettings, audioData, durationMs) {
|
|
653
|
+
if (!this.index) {
|
|
654
|
+
await this.initialize();
|
|
655
|
+
}
|
|
656
|
+
const key = generateCacheKey(voiceId, modelId, text, voiceSettings);
|
|
657
|
+
const audioFileName = `${key}.mp3`;
|
|
658
|
+
const audioPath = path.join(this.audioDir, audioFileName);
|
|
659
|
+
await fs.writeFile(audioPath, audioData);
|
|
660
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
661
|
+
const entry = {
|
|
662
|
+
key,
|
|
663
|
+
voiceId,
|
|
664
|
+
modelId,
|
|
665
|
+
text: text.trim(),
|
|
666
|
+
voiceSettings,
|
|
667
|
+
audioFileName,
|
|
668
|
+
durationMs,
|
|
669
|
+
fileSizeBytes: audioData.length,
|
|
670
|
+
createdAt: now,
|
|
671
|
+
lastUsedAt: now
|
|
672
|
+
};
|
|
673
|
+
this.index.entries[key] = entry;
|
|
674
|
+
this.indexDirty = true;
|
|
675
|
+
return entry;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Removes a specific entry from the cache
|
|
679
|
+
*/
|
|
680
|
+
async remove(key) {
|
|
681
|
+
if (!this.enabled || !this.index) {
|
|
682
|
+
return false;
|
|
683
|
+
}
|
|
684
|
+
const entry = this.index.entries[key];
|
|
685
|
+
if (!entry) {
|
|
686
|
+
return false;
|
|
687
|
+
}
|
|
688
|
+
const audioPath = path.join(this.audioDir, entry.audioFileName);
|
|
689
|
+
try {
|
|
690
|
+
await fs.unlink(audioPath);
|
|
691
|
+
} catch {
|
|
692
|
+
}
|
|
693
|
+
delete this.index.entries[key];
|
|
694
|
+
this.indexDirty = true;
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Clears all entries from the cache
|
|
699
|
+
*/
|
|
700
|
+
async clear() {
|
|
701
|
+
if (!this.index) {
|
|
702
|
+
await this.initialize();
|
|
703
|
+
}
|
|
704
|
+
const count = Object.keys(this.index.entries).length;
|
|
705
|
+
try {
|
|
706
|
+
const files = await fs.readdir(this.audioDir);
|
|
707
|
+
await Promise.all(
|
|
708
|
+
files.map((file) => fs.unlink(path.join(this.audioDir, file)).catch(() => {
|
|
709
|
+
}))
|
|
710
|
+
);
|
|
711
|
+
} catch {
|
|
712
|
+
}
|
|
713
|
+
this.index.entries = {};
|
|
714
|
+
this.indexDirty = true;
|
|
715
|
+
await this.saveIndex();
|
|
716
|
+
return count;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Lists all entries in the cache
|
|
720
|
+
*/
|
|
721
|
+
async list() {
|
|
722
|
+
if (!this.index) {
|
|
723
|
+
await this.initialize();
|
|
724
|
+
}
|
|
725
|
+
return Object.values(this.index.entries).sort(
|
|
726
|
+
(a, b) => new Date(b.lastUsedAt).getTime() - new Date(a.lastUsedAt).getTime()
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* Gets statistics about the cache
|
|
731
|
+
*/
|
|
732
|
+
async getStats() {
|
|
733
|
+
if (!this.index) {
|
|
734
|
+
await this.initialize();
|
|
735
|
+
}
|
|
736
|
+
const entries = Object.values(this.index.entries);
|
|
737
|
+
const totalSizeBytes = entries.reduce((sum, e) => sum + e.fileSizeBytes, 0);
|
|
738
|
+
let oldestEntry = null;
|
|
739
|
+
let newestEntry = null;
|
|
740
|
+
if (entries.length > 0) {
|
|
741
|
+
const sorted = [...entries].sort(
|
|
742
|
+
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
743
|
+
);
|
|
744
|
+
oldestEntry = sorted[0].createdAt;
|
|
745
|
+
newestEntry = sorted[sorted.length - 1].createdAt;
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
totalEntries: entries.length,
|
|
749
|
+
totalSizeBytes,
|
|
750
|
+
oldestEntry,
|
|
751
|
+
newestEntry,
|
|
752
|
+
hitCount: this.stats.hits,
|
|
753
|
+
missCount: this.stats.misses
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Prunes entries older than the specified age (in days)
|
|
758
|
+
*/
|
|
759
|
+
async pruneOlderThan(days) {
|
|
760
|
+
if (!this.index) {
|
|
761
|
+
await this.initialize();
|
|
762
|
+
}
|
|
763
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
|
|
764
|
+
let removed = 0;
|
|
765
|
+
for (const [key, entry] of Object.entries(this.index.entries)) {
|
|
766
|
+
const lastUsed = new Date(entry.lastUsedAt).getTime();
|
|
767
|
+
if (lastUsed < cutoff) {
|
|
768
|
+
await this.remove(key);
|
|
769
|
+
removed++;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
await this.saveIndex();
|
|
773
|
+
return removed;
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* Validates the cache by checking that all indexed files exist
|
|
777
|
+
* and removing orphaned entries
|
|
778
|
+
*/
|
|
779
|
+
async validate() {
|
|
780
|
+
if (!this.index) {
|
|
781
|
+
await this.initialize();
|
|
782
|
+
}
|
|
783
|
+
let valid = 0;
|
|
784
|
+
let removed = 0;
|
|
785
|
+
for (const [key, entry] of Object.entries(this.index.entries)) {
|
|
786
|
+
const audioPath = path.join(this.audioDir, entry.audioFileName);
|
|
787
|
+
try {
|
|
788
|
+
await fs.access(audioPath);
|
|
789
|
+
valid++;
|
|
790
|
+
} catch {
|
|
791
|
+
delete this.index.entries[key];
|
|
792
|
+
this.indexDirty = true;
|
|
793
|
+
removed++;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (removed > 0) {
|
|
797
|
+
await this.saveIndex();
|
|
798
|
+
}
|
|
799
|
+
return { valid, removed };
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
function getDefaultCacheDir(projectRoot) {
|
|
803
|
+
const root = projectRoot || process.cwd();
|
|
804
|
+
return path.join(root, ".voice-cache");
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// ../generation/src/voice-synthesis.ts
|
|
499
808
|
var DEFAULT_MAX_OUTPUT_BYTES = 512 * 1024;
|
|
500
809
|
var DEFAULT_MAX_CONCURRENCY = 2;
|
|
501
810
|
var DEFAULT_RETRY_OPTIONS = {
|
|
@@ -660,10 +969,34 @@ var VoiceSynthesizer = class {
|
|
|
660
969
|
constructor(config) {
|
|
661
970
|
__publicField(this, "client");
|
|
662
971
|
__publicField(this, "config");
|
|
972
|
+
__publicField(this, "cache", null);
|
|
973
|
+
__publicField(this, "cacheInitialized", false);
|
|
663
974
|
this.config = config;
|
|
664
975
|
this.client = new ElevenLabsClient({
|
|
665
976
|
apiKey: config.apiKey
|
|
666
977
|
});
|
|
978
|
+
if (config.cache !== false) {
|
|
979
|
+
const cacheConfig = config.cache ?? {
|
|
980
|
+
cacheDir: getDefaultCacheDir(),
|
|
981
|
+
enabled: true
|
|
982
|
+
};
|
|
983
|
+
this.cache = new VoiceCache(cacheConfig);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Ensures the cache is initialized before use
|
|
988
|
+
*/
|
|
989
|
+
async ensureCacheInitialized() {
|
|
990
|
+
if (this.cache && !this.cacheInitialized) {
|
|
991
|
+
await this.cache.initialize();
|
|
992
|
+
this.cacheInitialized = true;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Gets the voice cache instance (if enabled)
|
|
997
|
+
*/
|
|
998
|
+
getCache() {
|
|
999
|
+
return this.cache;
|
|
667
1000
|
}
|
|
668
1001
|
/**
|
|
669
1002
|
* List available voices (useful for finding voice IDs)
|
|
@@ -680,14 +1013,31 @@ var VoiceSynthesizer = class {
|
|
|
680
1013
|
* Synthesize a single text segment to audio
|
|
681
1014
|
*/
|
|
682
1015
|
async synthesizeSegment(text, outputPath, options) {
|
|
1016
|
+
const modelId = this.config.modelId || "eleven_multilingual_v2";
|
|
1017
|
+
const voiceSettings = normalizeVoiceSettings(options);
|
|
1018
|
+
if (this.cache && !options?.skipCache) {
|
|
1019
|
+
await this.ensureCacheInitialized();
|
|
1020
|
+
const cachedEntry = await this.cache.get(
|
|
1021
|
+
this.config.voiceId,
|
|
1022
|
+
modelId,
|
|
1023
|
+
text,
|
|
1024
|
+
voiceSettings
|
|
1025
|
+
);
|
|
1026
|
+
if (cachedEntry) {
|
|
1027
|
+
const cachedPath = this.cache.getAudioPath(cachedEntry);
|
|
1028
|
+
await fs2.copyFile(cachedPath, outputPath);
|
|
1029
|
+
console.log(`[voice] Cache hit for "${text.slice(0, 40)}..." (saved API call)`);
|
|
1030
|
+
return { durationMs: cachedEntry.durationMs, fromCache: true };
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
683
1033
|
const audio = await this.client.textToSpeech.convert(this.config.voiceId, {
|
|
684
1034
|
text,
|
|
685
|
-
model_id:
|
|
1035
|
+
model_id: modelId,
|
|
686
1036
|
voice_settings: {
|
|
687
|
-
stability:
|
|
688
|
-
similarity_boost:
|
|
689
|
-
style:
|
|
690
|
-
use_speaker_boost:
|
|
1037
|
+
stability: voiceSettings.stability,
|
|
1038
|
+
similarity_boost: voiceSettings.similarityBoost,
|
|
1039
|
+
style: voiceSettings.style,
|
|
1040
|
+
use_speaker_boost: voiceSettings.useSpeakerBoost
|
|
691
1041
|
}
|
|
692
1042
|
});
|
|
693
1043
|
const chunks = [];
|
|
@@ -695,15 +1045,29 @@ var VoiceSynthesizer = class {
|
|
|
695
1045
|
chunks.push(Buffer.from(chunk));
|
|
696
1046
|
}
|
|
697
1047
|
const audioBuffer = Buffer.concat(chunks);
|
|
698
|
-
await
|
|
1048
|
+
await fs2.writeFile(outputPath, audioBuffer);
|
|
699
1049
|
const probedDurationMs = await probeMediaDurationMs(outputPath);
|
|
1050
|
+
let durationMs;
|
|
700
1051
|
if (probedDurationMs !== null) {
|
|
701
|
-
|
|
1052
|
+
durationMs = probedDurationMs;
|
|
1053
|
+
} else {
|
|
1054
|
+
const fileSizeBytes = audioBuffer.length;
|
|
1055
|
+
durationMs = Math.round(fileSizeBytes * 8 / 128);
|
|
1056
|
+
console.warn(`[voice] ffprobe unavailable, using estimated duration for ${outputPath}`);
|
|
1057
|
+
}
|
|
1058
|
+
if (this.cache) {
|
|
1059
|
+
await this.ensureCacheInitialized();
|
|
1060
|
+
await this.cache.put(
|
|
1061
|
+
this.config.voiceId,
|
|
1062
|
+
modelId,
|
|
1063
|
+
text,
|
|
1064
|
+
voiceSettings,
|
|
1065
|
+
audioBuffer,
|
|
1066
|
+
durationMs
|
|
1067
|
+
);
|
|
1068
|
+
await this.cache.saveIndex();
|
|
702
1069
|
}
|
|
703
|
-
|
|
704
|
-
const estimatedDurationMs = Math.round(fileSizeBytes * 8 / 128);
|
|
705
|
-
console.warn(`[voice] ffprobe unavailable, using estimated duration for ${outputPath}`);
|
|
706
|
-
return { durationMs: estimatedDurationMs };
|
|
1070
|
+
return { durationMs, fromCache: false };
|
|
707
1071
|
}
|
|
708
1072
|
/**
|
|
709
1073
|
* Generate a sound effect from text description
|
|
@@ -721,16 +1085,16 @@ var VoiceSynthesizer = class {
|
|
|
721
1085
|
for await (const chunk of audio) {
|
|
722
1086
|
chunks.push(Buffer.from(chunk));
|
|
723
1087
|
}
|
|
724
|
-
await
|
|
1088
|
+
await fs2.writeFile(outputPath, Buffer.concat(chunks));
|
|
725
1089
|
}
|
|
726
1090
|
/**
|
|
727
1091
|
* Synthesize all segments from a script JSON file
|
|
728
1092
|
*/
|
|
729
1093
|
async synthesizeScript(scriptPath, outputDir, options) {
|
|
730
|
-
const scriptContent = await
|
|
1094
|
+
const scriptContent = await fs2.readFile(scriptPath, "utf-8");
|
|
731
1095
|
const script = JSON.parse(scriptContent);
|
|
732
|
-
const audioDir =
|
|
733
|
-
await
|
|
1096
|
+
const audioDir = path2.join(outputDir, "audio", script.demoName);
|
|
1097
|
+
await fs2.mkdir(audioDir, { recursive: true });
|
|
734
1098
|
const synthesizedSegments = new Array(script.segments.length);
|
|
735
1099
|
const maxConcurrency = Math.max(
|
|
736
1100
|
1,
|
|
@@ -748,7 +1112,7 @@ var VoiceSynthesizer = class {
|
|
|
748
1112
|
const segment = script.segments[index];
|
|
749
1113
|
const safeStepId = sanitizeFileSegment(segment.stepId, `step-${index + 1}`);
|
|
750
1114
|
const audioFileName = `${String(index + 1).padStart(2, "0")}_${safeStepId}.mp3`;
|
|
751
|
-
const audioPath =
|
|
1115
|
+
const audioPath = path2.join(audioDir, audioFileName);
|
|
752
1116
|
progressCount += 1;
|
|
753
1117
|
options?.onProgress?.(progressCount, script.segments.length, segment.stepId);
|
|
754
1118
|
try {
|
|
@@ -762,7 +1126,8 @@ var VoiceSynthesizer = class {
|
|
|
762
1126
|
durationMs: result.durationMs,
|
|
763
1127
|
text: segment.text
|
|
764
1128
|
};
|
|
765
|
-
|
|
1129
|
+
const cacheStatus = result.fromCache ? "[cached]" : "[new]";
|
|
1130
|
+
console.log(`[voice] ${cacheStatus} ${segment.stepId} (${result.durationMs.toFixed(0)}ms)`);
|
|
766
1131
|
} catch (error) {
|
|
767
1132
|
console.error(`[voice] Failed to synthesize ${segment.stepId}:`, error);
|
|
768
1133
|
throw error;
|
|
@@ -772,7 +1137,7 @@ var VoiceSynthesizer = class {
|
|
|
772
1137
|
await Promise.all(workers);
|
|
773
1138
|
let soundEffectsPath;
|
|
774
1139
|
if (options?.generateClickSounds) {
|
|
775
|
-
soundEffectsPath =
|
|
1140
|
+
soundEffectsPath = path2.join(audioDir, "click.mp3");
|
|
776
1141
|
await this.generateClickSound(soundEffectsPath);
|
|
777
1142
|
}
|
|
778
1143
|
return {
|
|
@@ -812,7 +1177,7 @@ var VoiceSynthesizer = class {
|
|
|
812
1177
|
}
|
|
813
1178
|
};
|
|
814
1179
|
async function generateTimingManifest(scriptPath, synthesisResult, outputPath) {
|
|
815
|
-
const scriptContent = await
|
|
1180
|
+
const scriptContent = await fs2.readFile(scriptPath, "utf-8");
|
|
816
1181
|
const script = JSON.parse(scriptContent);
|
|
817
1182
|
const manifest = {
|
|
818
1183
|
demoName: script.demoName,
|
|
@@ -829,7 +1194,7 @@ async function generateTimingManifest(scriptPath, synthesisResult, outputPath) {
|
|
|
829
1194
|
soundEffects: synthesisResult.soundEffectsPath ? { clickSound: synthesisResult.soundEffectsPath } : void 0,
|
|
830
1195
|
backgroundMusic: synthesisResult.backgroundMusicPath
|
|
831
1196
|
};
|
|
832
|
-
await
|
|
1197
|
+
await fs2.writeFile(outputPath, JSON.stringify(manifest, null, 2));
|
|
833
1198
|
return manifest;
|
|
834
1199
|
}
|
|
835
1200
|
function createVoiceSynthesizer(config) {
|
|
@@ -837,8 +1202,8 @@ function createVoiceSynthesizer(config) {
|
|
|
837
1202
|
}
|
|
838
1203
|
|
|
839
1204
|
// ../generation/src/script-generator.ts
|
|
840
|
-
import * as
|
|
841
|
-
import * as
|
|
1205
|
+
import * as fs3 from "fs/promises";
|
|
1206
|
+
import * as path3 from "path";
|
|
842
1207
|
var ScriptGenerator = class {
|
|
843
1208
|
constructor(demoName, title, startTimeMs) {
|
|
844
1209
|
__publicField(this, "segments", []);
|
|
@@ -937,8 +1302,8 @@ var ScriptGenerator = class {
|
|
|
937
1302
|
*/
|
|
938
1303
|
async exportJSON(outputPath) {
|
|
939
1304
|
const output = this.getOutput();
|
|
940
|
-
await
|
|
941
|
-
await
|
|
1305
|
+
await fs3.mkdir(path3.dirname(outputPath), { recursive: true });
|
|
1306
|
+
await fs3.writeFile(outputPath, JSON.stringify(output, null, 2));
|
|
942
1307
|
}
|
|
943
1308
|
/**
|
|
944
1309
|
* Exports script as SRT subtitle format.
|
|
@@ -957,8 +1322,8 @@ var ScriptGenerator = class {
|
|
|
957
1322
|
|
|
958
1323
|
`;
|
|
959
1324
|
});
|
|
960
|
-
await
|
|
961
|
-
await
|
|
1325
|
+
await fs3.mkdir(path3.dirname(outputPath), { recursive: true });
|
|
1326
|
+
await fs3.writeFile(outputPath, srt.trim());
|
|
962
1327
|
}
|
|
963
1328
|
/**
|
|
964
1329
|
* Exports script in a format suitable for AI voice generation services.
|
|
@@ -980,8 +1345,8 @@ var ScriptGenerator = class {
|
|
|
980
1345
|
syncPointMs: segment.startTimeMs
|
|
981
1346
|
}))
|
|
982
1347
|
};
|
|
983
|
-
await
|
|
984
|
-
await
|
|
1348
|
+
await fs3.mkdir(path3.dirname(outputPath), { recursive: true });
|
|
1349
|
+
await fs3.writeFile(outputPath, JSON.stringify(voiceScript, null, 2));
|
|
985
1350
|
}
|
|
986
1351
|
/**
|
|
987
1352
|
* Exports script as human-readable markdown for voice actors.
|
|
@@ -1028,8 +1393,8 @@ var ScriptGenerator = class {
|
|
|
1028
1393
|
|
|
1029
1394
|
`;
|
|
1030
1395
|
});
|
|
1031
|
-
await
|
|
1032
|
-
await
|
|
1396
|
+
await fs3.mkdir(path3.dirname(outputPath), { recursive: true });
|
|
1397
|
+
await fs3.writeFile(outputPath, markdown);
|
|
1033
1398
|
}
|
|
1034
1399
|
};
|
|
1035
1400
|
function formatSRTTime(ms) {
|
|
@@ -1468,13 +1833,13 @@ async function withActionTimeout(timeoutMs, label, fn) {
|
|
|
1468
1833
|
}
|
|
1469
1834
|
async function captureDiagnostics(params) {
|
|
1470
1835
|
const { context, stepId, stepIndex, actionIndex, action, error, runId } = params;
|
|
1471
|
-
const diagnosticsDir =
|
|
1836
|
+
const diagnosticsDir = path4.join(context.outputDir, "diagnostics");
|
|
1472
1837
|
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1473
1838
|
const stepLabel = sanitizeDiagnosticsSegment(stepId ?? "unknown-step", "unknown-step");
|
|
1474
1839
|
const actionLabel = actionIndex !== void 0 ? `action-${actionIndex + 1}` : "action";
|
|
1475
1840
|
const baseName = `${timestamp}-${runId}-${stepLabel}-${actionLabel}`;
|
|
1476
1841
|
try {
|
|
1477
|
-
await
|
|
1842
|
+
await fs4.mkdir(diagnosticsDir, { recursive: true });
|
|
1478
1843
|
} catch {
|
|
1479
1844
|
}
|
|
1480
1845
|
const metadata = {
|
|
@@ -1488,7 +1853,7 @@ async function captureDiagnostics(params) {
|
|
|
1488
1853
|
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1489
1854
|
};
|
|
1490
1855
|
try {
|
|
1491
|
-
const screenshotPath =
|
|
1856
|
+
const screenshotPath = path4.join(diagnosticsDir, `${baseName}.png`);
|
|
1492
1857
|
await context.page.screenshot({ path: screenshotPath, fullPage: true });
|
|
1493
1858
|
} catch {
|
|
1494
1859
|
}
|
|
@@ -1497,28 +1862,28 @@ async function captureDiagnostics(params) {
|
|
|
1497
1862
|
const maxChars = 2e4;
|
|
1498
1863
|
const snippet = domContent.length > maxChars ? `${domContent.slice(0, maxChars)}
|
|
1499
1864
|
<!-- truncated -->` : domContent;
|
|
1500
|
-
const domPath =
|
|
1501
|
-
await
|
|
1865
|
+
const domPath = path4.join(diagnosticsDir, `${baseName}.dom.html`);
|
|
1866
|
+
await fs4.writeFile(domPath, snippet, "utf-8");
|
|
1502
1867
|
} catch {
|
|
1503
1868
|
}
|
|
1504
1869
|
try {
|
|
1505
|
-
const metaPath =
|
|
1506
|
-
await
|
|
1870
|
+
const metaPath = path4.join(diagnosticsDir, `${baseName}.json`);
|
|
1871
|
+
await fs4.writeFile(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
1507
1872
|
} catch {
|
|
1508
1873
|
}
|
|
1509
1874
|
}
|
|
1510
1875
|
async function loadDemoDefinition(filePath, options) {
|
|
1511
|
-
const content = await
|
|
1876
|
+
const content = await fs4.readFile(filePath, "utf-8");
|
|
1512
1877
|
const definition = parseFromYAML(content, options);
|
|
1513
1878
|
validateDemoDefinition(definition);
|
|
1514
1879
|
return definition;
|
|
1515
1880
|
}
|
|
1516
1881
|
function resolveFilePath(filePath, baseDir) {
|
|
1517
|
-
if (
|
|
1882
|
+
if (path4.isAbsolute(filePath)) {
|
|
1518
1883
|
return filePath;
|
|
1519
1884
|
}
|
|
1520
1885
|
const root = baseDir ?? process.cwd();
|
|
1521
|
-
return
|
|
1886
|
+
return path4.resolve(root, filePath);
|
|
1522
1887
|
}
|
|
1523
1888
|
function resolveNavigatePath(actionPath, context) {
|
|
1524
1889
|
const resolvedPath = resolvePath(actionPath, { baseURL: context.baseURL });
|
|
@@ -1960,8 +2325,8 @@ async function runDemo(definition, context, options = {}) {
|
|
|
1960
2325
|
}
|
|
1961
2326
|
if (scriptGenerator) {
|
|
1962
2327
|
scriptGenerator.finishAllSteps();
|
|
1963
|
-
const scriptOutputDir = options.scriptOutputDir ??
|
|
1964
|
-
const scriptBasePath =
|
|
2328
|
+
const scriptOutputDir = options.scriptOutputDir ?? path4.join(context.outputDir, "scripts");
|
|
2329
|
+
const scriptBasePath = path4.join(scriptOutputDir, definition.name);
|
|
1965
2330
|
await scriptGenerator.exportJSON(`${scriptBasePath}.json`);
|
|
1966
2331
|
await scriptGenerator.exportSRT(`${scriptBasePath}.srt`);
|
|
1967
2332
|
await scriptGenerator.exportMarkdown(`${scriptBasePath}.md`);
|
|
@@ -2017,8 +2382,8 @@ async function runDemoFromFile(definitionPath, context, options) {
|
|
|
2017
2382
|
return runDemo(definition, context, options);
|
|
2018
2383
|
}
|
|
2019
2384
|
async function discoverDemos(demoDir) {
|
|
2020
|
-
const files = await
|
|
2021
|
-
return files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).map((f) =>
|
|
2385
|
+
const files = await fs4.readdir(demoDir);
|
|
2386
|
+
return files.filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).map((f) => path4.join(demoDir, f));
|
|
2022
2387
|
}
|
|
2023
2388
|
export {
|
|
2024
2389
|
DEMO_SCHEMA_VERSION,
|