-
Notifications
You must be signed in to change notification settings - Fork 169
Expand file tree
/
Copy pathpipeline.ts
More file actions
245 lines (214 loc) · 9.85 KB
/
Copy pathpipeline.ts
File metadata and controls
245 lines (214 loc) · 9.85 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
import { readFile, writeFile, mkdir, copyFile } from "node:fs/promises";
import { join, dirname, basename } from "node:path";
import { fileURLToPath } from "node:url";
import pLimit from "p-limit";
import { ScriptSchema, type Script } from "./render/script-schema.js";
import { loadConfig } from "./config.js";
import { createTtsClient } from "./tts/tts-client.js";
import { fetchImage } from "./assets/image-fetcher.js";
import { getDurationSec, concatWithSilence, mixSfxOntoVoice, type SfxMixSpec } from "./assets/audio-tools.js";
import { indexSfxLibrary, pickSfxForScene, defaultPlayback } from "./assets/sfx-selector.js";
import { existsSync } from "node:fs";
import { composeHtml } from "./render/html-composer.js";
import { renderWithHyperframes } from "./render/hyperframes-runner.js";
import { log } from "./utils/logger.js";
const TOTAL_STEPS = 8;
const DURATION_MIN_SEC = 48;
const DURATION_MAX_SEC = 72;
const SCENE_GAP_SEC = 0.3;
/**
* Extra seconds added to the outro scene visual duration AFTER the voice ends.
* Gives the TikTok follow card time to be read by the viewer (otherwise the
* video ends a few hundred ms after the card slides up + click animation).
* Audio stays silent during this hold; visual stays on screen.
*/
const OUTRO_HOLD_SEC = 3;
const __dirname = dirname(fileURLToPath(import.meta.url));
const TPL_DIR = join(__dirname, "render", "templates");
/** Path to the SFX library (relative to project root) */
const SFX_DIR = join(__dirname, "..", "assets", "sfx");
const HYPERFRAMES_CONFIG = {
$schema: "https://hyperframes.heygen.com/schema/hyperframes.json",
registry: "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry",
paths: {
blocks: "compositions",
components: "compositions/components",
assets: "assets",
},
};
export async function runPipeline(scriptPath: string): Promise<void> {
const cfg = loadConfig();
const outputDir = dirname(scriptPath);
log.info(`Output directory: ${outputDir}`);
// STEP 1
log.step(1, TOTAL_STEPS, `Load env + validate script.json (TTS provider: ${cfg.ttsProvider})`);
const raw = JSON.parse(await readFile(scriptPath, "utf8"));
// Substitute env placeholder before validation (works for both providers)
if (raw.voice?.voiceId === "${VIETNAMESE_VOICEID}" || raw.voice?.voiceId === "${VOICE_ID}") {
raw.voice.voiceId = cfg.ttsProvider === "lucylab" ? cfg.lucylabVoiceId! : cfg.elevenlabsVoiceId!;
}
const script: Script = ScriptSchema.parse(raw);
// STEP 2
log.step(2, TOTAL_STEPS, "Write script.txt for CapCut");
const fullText = script.scenes.map((s) => s.voiceText).join("\n\n");
await writeFile(join(outputDir, "script.txt"), fullText);
// STEP 3 + 4 in parallel
log.step(3, TOTAL_STEPS, "Fetch og:image (parallel) + Step 4 TTS");
const imgPath = join(outputDir, "images", "bg.jpg");
const imgPromise = fetchImage(script.metadata.source.image, imgPath);
// STEP 4
const ttsClient = createTtsClient(cfg);
// Concurrency: LucyLab requires 1 (only 1 concurrent export per key);
// ElevenLabs supports parallel calls but we keep 1 by default to be polite.
const limit = pLimit(cfg.ttsConcurrency);
const voiceDir = join(outputDir, "voice");
await mkdir(voiceDir, { recursive: true });
const sceneAudioPromises = script.scenes.map((scene) =>
limit(async () => {
const out = join(voiceDir, `scene-${scene.id}.mp3`);
const srtOut = join(voiceDir, `scene-${scene.id}.srt`);
// IDEMPOTENT: skip TTS if voice file already exists.
// To force re-TTS for a scene, delete its mp3 file before running.
// This saves API quota when only some scenes' voiceText changed.
if (existsSync(out)) {
const dur = await getDurationSec(out);
log.info(` scene ${scene.id}: REUSE existing mp3 (${dur.toFixed(2)}s) — delete to force re-TTS`);
return { id: scene.id, path: out, durationSec: dur };
}
log.info(` TTS scene ${scene.id} (${scene.voiceText.length} chars)...`);
await ttsClient.generate(scene.voiceText, out, srtOut);
const dur = await getDurationSec(out);
log.info(` scene ${scene.id}: ${dur.toFixed(2)}s`);
return { id: scene.id, path: out, durationSec: dur };
}),
);
const [imgResult, sceneAudio] = await Promise.all([
imgPromise,
Promise.all(sceneAudioPromises),
]);
let bgImageRelPath: string | null = null;
if (imgResult.success) {
bgImageRelPath = "images/bg.jpg";
} else {
log.warn(`Background image fetch failed: ${imgResult.reason} → using gradient fallback`);
}
// STEP 5
log.step(5, TOTAL_STEPS, "Concat voice scenes + mix SFX layer");
const voiceRawMp3 = join(outputDir, "voice-raw.mp3");
const voiceMp3 = join(outputDir, "voice.mp3");
await concatWithSilence(sceneAudio.map((a) => a.path), SCENE_GAP_SEC, voiceRawMp3);
// Compute scene start times (cumulative voice durations + gaps)
let cursor = 0;
const sceneStarts: Record<string, number> = {};
for (const a of sceneAudio) {
sceneStarts[a.id] = cursor;
cursor += a.durationSec + SCENE_GAP_SEC;
}
// Build SFX mix list using smart 3-tier selector
const sfxIndex = indexSfxLibrary(SFX_DIR);
const indexCats = Object.keys(sfxIndex).length;
const indexFiles = Object.values(sfxIndex).reduce((s, a) => s + a.length, 0);
log.info(` SFX library: ${indexFiles} files in ${indexCats} categories`);
const sfxList: SfxMixSpec[] = [];
for (const scene of script.scenes) {
const startSec = sceneStarts[scene.id];
// Tier 1: explicit override in script.json
if (scene.sfx) {
if (scene.sfx.name === "none") {
log.info(` scene ${scene.id}: SFX disabled (explicit "none")`);
continue;
}
const sfxPath = join(SFX_DIR, `${scene.sfx.name}.mp3`);
if (existsSync(sfxPath)) {
sfxList.push({ path: sfxPath, startSec: startSec + scene.sfx.startOffsetSec, volume: scene.sfx.volume });
log.info(` scene ${scene.id}: SFX override -> ${scene.sfx.name}.mp3`);
} else {
log.warn(` scene ${scene.id}: explicit SFX not found, skipping: ${scene.sfx.name}.mp3`);
}
continue;
}
// Tier 2/3: smart selection by content + template
const picked = pickSfxForScene({
voiceText: scene.voiceText,
templateName: scene.templateData.template,
sceneId: scene.id,
index: sfxIndex,
});
if (!picked) {
log.warn(` scene ${scene.id}: no SFX available (empty library?)`);
continue;
}
const sfxPath = join(SFX_DIR, picked.relPath);
const playback = defaultPlayback(picked);
sfxList.push({ path: sfxPath, startSec: startSec + playback.offsetSec, volume: playback.volume });
const why = picked.source === "semantic"
? `semantic match "${picked.matchedKeyword}"`
: picked.source;
log.info(` scene ${scene.id}: SFX -> ${picked.relPath} (${why})`);
}
log.info(` mixing ${sfxList.length} SFX into voice.mp3`);
await mixSfxOntoVoice(voiceRawMp3, sfxList, voiceMp3);
const totalAudioSec = await getDurationSec(voiceMp3);
log.info(` voice.mp3 total: ${totalAudioSec.toFixed(2)}s`);
if (totalAudioSec < DURATION_MIN_SEC || totalAudioSec > DURATION_MAX_SEC) {
log.warn(`Total duration ${totalAudioSec.toFixed(1)}s outside [${DURATION_MIN_SEC}, ${DURATION_MAX_SEC}]s tolerance — proceeding anyway`);
}
// STEP 6 — Compose HTML + write hyperframes project files
log.step(6, TOTAL_STEPS, "Compose HTML + project files");
// Resolve TikTok avatar — download URL if provided, else copy bundled default
// Bundled avatar can be jpg/jpeg/png/webp — pick whichever exists
const findBundledAvatar = (): string => {
const baseDir = join(__dirname, "..", "assets");
for (const ext of ["jpg", "jpeg", "png", "webp"]) {
const p = join(baseDir, `avatar.${ext}`);
if (existsSync(p)) return p;
}
throw new Error(`No bundled avatar found. Place an image at assets/avatar.{jpg,png,webp}`);
};
const bundledAvatar = findBundledAvatar();
const ttAvatarExt = bundledAvatar.split(".").pop()!.toLowerCase();
const ttAvatarFile = `tiktok-avatar.${ttAvatarExt}`;
const ttAvatarOut = join(outputDir, ttAvatarFile);
if (cfg.tiktok.avatarUrl) {
const r = await fetchImage(cfg.tiktok.avatarUrl, ttAvatarOut);
if (!r.success) {
log.warn(`TikTok avatar download failed: ${r.reason} → falling back to bundled default`);
await copyFile(bundledAvatar, ttAvatarOut);
}
} else {
await copyFile(bundledAvatar, ttAvatarOut);
}
const html = composeHtml({
script,
sceneAudio: sceneAudio.map((a) => ({ id: a.id, durationSec: a.durationSec })),
gapSec: SCENE_GAP_SEC,
bgImageRelPath,
audioRelPath: "voice.mp3",
tiktok: cfg.tiktok,
tiktokAvatarRelPath: ttAvatarFile,
outroHoldSec: OUTRO_HOLD_SEC,
});
// hyperframes expects: index.html (NOT composition.html), hyperframes.json, meta.json in DIR
await writeFile(join(outputDir, "index.html"), html);
await writeFile(join(outputDir, "hyperframes.json"), JSON.stringify(HYPERFRAMES_CONFIG, null, 2));
const slug = basename(outputDir);
await writeFile(join(outputDir, "meta.json"), JSON.stringify({
id: slug,
name: script.metadata.title,
createdAt: new Date().toISOString(),
}, null, 2));
// Copy templates next to the index.html so relative paths resolve
await copyFile(join(TPL_DIR, "styles.css"), join(outputDir, "styles.css"));
await copyFile(join(TPL_DIR, "animations.js"), join(outputDir, "animations.js"));
// STEP 7
log.step(7, TOTAL_STEPS, "Render with hyperframes");
const videoPath = join(outputDir, "video.mp4");
await renderWithHyperframes({ compositionDir: outputDir, outputPath: videoPath });
// STEP 8
log.step(8, TOTAL_STEPS, "Done");
console.log("\n=== Result ===");
console.log(`Video: ${videoPath}`);
console.log(`Audio: ${voiceMp3} (cho CapCut)`);
console.log(`Script: ${join(outputDir, "script.txt")} (cho CapCut auto-caption)`);
console.log(`Tong thoi luong: ${totalAudioSec.toFixed(2)}s`);
}