demofly 0.2.0 → 0.2.2
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/dist/commands/auth/login.js +1 -1
- package/dist/commands/auth/login.js.map +1 -1
- package/dist/commands/demos/list.d.ts.map +1 -1
- package/dist/commands/demos/list.js +9 -6
- package/dist/commands/demos/list.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +346 -35
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +30 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +11 -0
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/render.d.ts.map +1 -1
- package/dist/commands/render.js +32 -4
- package/dist/commands/render.js.map +1 -1
- package/dist/commands/voices/index.d.ts +3 -0
- package/dist/commands/voices/index.d.ts.map +1 -0
- package/dist/commands/voices/index.js +11 -0
- package/dist/commands/voices/index.js.map +1 -0
- package/dist/commands/voices/list.d.ts +3 -0
- package/dist/commands/voices/list.d.ts.map +1 -0
- package/dist/commands/voices/list.js +72 -0
- package/dist/commands/voices/list.js.map +1 -0
- package/dist/commands/voices/select.d.ts +3 -0
- package/dist/commands/voices/select.d.ts.map +1 -0
- package/dist/commands/voices/select.js +79 -0
- package/dist/commands/voices/select.js.map +1 -0
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/alignment.d.ts +47 -0
- package/dist/lib/alignment.d.ts.map +1 -0
- package/dist/lib/alignment.js +122 -0
- package/dist/lib/alignment.js.map +1 -0
- package/dist/lib/api-client.d.ts +2 -0
- package/dist/lib/api-client.d.ts.map +1 -1
- package/dist/lib/api-client.js +6 -2
- package/dist/lib/api-client.js.map +1 -1
- package/dist/lib/api-types.d.ts +15 -0
- package/dist/lib/api-types.d.ts.map +1 -0
- package/dist/lib/api-types.js +2 -0
- package/dist/lib/api-types.js.map +1 -0
- package/dist/lib/cloud-tts.d.ts +21 -0
- package/dist/lib/cloud-tts.d.ts.map +1 -0
- package/dist/lib/cloud-tts.js +42 -0
- package/dist/lib/cloud-tts.js.map +1 -0
- package/dist/lib/credentials.js +1 -1
- package/dist/lib/credentials.js.map +1 -1
- package/dist/lib/demo-config.d.ts +8 -0
- package/dist/lib/demo-config.d.ts.map +1 -0
- package/dist/lib/demo-config.js +29 -0
- package/dist/lib/demo-config.js.map +1 -0
- package/dist/lib/edit-proposals.d.ts +46 -0
- package/dist/lib/edit-proposals.d.ts.map +1 -0
- package/dist/lib/edit-proposals.js +159 -0
- package/dist/lib/edit-proposals.js.map +1 -0
- package/dist/lib/retiming.d.ts +19 -0
- package/dist/lib/retiming.d.ts.map +1 -0
- package/dist/lib/retiming.js +215 -0
- package/dist/lib/retiming.js.map +1 -0
- package/dist/lib/timestamps.d.ts +43 -0
- package/dist/lib/timestamps.d.ts.map +1 -0
- package/dist/lib/timestamps.js +199 -0
- package/dist/lib/timestamps.js.map +1 -0
- package/dist/lib/tts.d.ts +7 -4
- package/dist/lib/tts.d.ts.map +1 -1
- package/dist/lib/tts.js +15 -8
- package/dist/lib/tts.js.map +1 -1
- package/dist/lib/voice-config.d.ts +7 -0
- package/dist/lib/voice-config.d.ts.map +1 -0
- package/dist/lib/voice-config.js +29 -0
- package/dist/lib/voice-config.js.map +1 -0
- package/dist/lib/voice-resolver.d.ts +82 -0
- package/dist/lib/voice-resolver.d.ts.map +1 -0
- package/dist/lib/voice-resolver.js +108 -0
- package/dist/lib/voice-resolver.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demofly edit proposals — LLM-generated retiming instructions.
|
|
3
|
+
*
|
|
4
|
+
* Takes alignment.json and produces edit-decisions.json with per-segment
|
|
5
|
+
* retiming instructions that can be executed by ffmpeg.
|
|
6
|
+
*
|
|
7
|
+
* Uses Claude API to generate intelligent edit decisions based on drift
|
|
8
|
+
* data, hero moment awareness, and assembly best practices.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { debug } from "./logger.js";
|
|
13
|
+
/** Hard limits for retiming decisions. */
|
|
14
|
+
const LIMITS = {
|
|
15
|
+
maxSpeedup: 1.5,
|
|
16
|
+
maxSlowdown: 1.33,
|
|
17
|
+
maxFreezeFrameMs: 3000,
|
|
18
|
+
minConfidence: 0.7,
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Generate edit proposals from alignment data.
|
|
22
|
+
*
|
|
23
|
+
* This produces deterministic, rule-based proposals without requiring an
|
|
24
|
+
* LLM call. The proposals follow the same format that an LLM would produce,
|
|
25
|
+
* making it easy to swap in LLM-powered proposals later.
|
|
26
|
+
*
|
|
27
|
+
* When an LLM API is available, use `generateLlmEditProposals()` instead
|
|
28
|
+
* for more nuanced decisions (e.g., choosing freeze_frame vs pad_silence
|
|
29
|
+
* based on visual content).
|
|
30
|
+
*/
|
|
31
|
+
export function generateEditProposals(alignment, heroSceneIds = []) {
|
|
32
|
+
const allDecisions = [];
|
|
33
|
+
for (const beat of alignment.beats) {
|
|
34
|
+
const decision = proposeEdit(beat, heroSceneIds);
|
|
35
|
+
if (decision) {
|
|
36
|
+
allDecisions.push(decision);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Split into applied (confidence >= threshold) and skipped
|
|
40
|
+
const applied = allDecisions.filter((d) => d.confidence >= LIMITS.minConfidence);
|
|
41
|
+
const skipped = allDecisions.filter((d) => d.confidence < LIMITS.minConfidence);
|
|
42
|
+
for (const s of skipped) {
|
|
43
|
+
debug(`Skipped edit for ${s.beatId}: ${s.editType} (confidence ${s.confidence})`);
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
decisions: applied,
|
|
47
|
+
skipped,
|
|
48
|
+
summary: {
|
|
49
|
+
total: allDecisions.length,
|
|
50
|
+
applied: applied.length,
|
|
51
|
+
skipped: skipped.length,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Propose a single edit decision for a beat based on its alignment.
|
|
57
|
+
*/
|
|
58
|
+
function proposeEdit(beat, heroSceneIds) {
|
|
59
|
+
const { driftMs, videoDurationMs, audioDurationMs, sceneId } = beat;
|
|
60
|
+
const isHero = heroSceneIds.includes(sceneId);
|
|
61
|
+
// No drift — no edit needed
|
|
62
|
+
if (beat.status === "aligned") {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
// Video is longer than audio (positive drift) — speed up or trim
|
|
66
|
+
if (driftMs > 0) {
|
|
67
|
+
// Never speed up hero moments
|
|
68
|
+
if (isHero) {
|
|
69
|
+
// Pad audio with silence instead
|
|
70
|
+
return {
|
|
71
|
+
beatId: beat.beatId,
|
|
72
|
+
editType: "pad_silence",
|
|
73
|
+
segmentStartMs: 0,
|
|
74
|
+
segmentEndMs: videoDurationMs,
|
|
75
|
+
targetDurationMs: videoDurationMs,
|
|
76
|
+
rationale: `Hero scene: padding audio with ${driftMs}ms silence instead of speeding up video`,
|
|
77
|
+
confidence: 0.9,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
const speedupRatio = videoDurationMs / audioDurationMs;
|
|
81
|
+
if (speedupRatio <= LIMITS.maxSpeedup) {
|
|
82
|
+
return {
|
|
83
|
+
beatId: beat.beatId,
|
|
84
|
+
editType: "speed_video",
|
|
85
|
+
segmentStartMs: 0,
|
|
86
|
+
segmentEndMs: videoDurationMs,
|
|
87
|
+
targetDurationMs: audioDurationMs,
|
|
88
|
+
rationale: `Video ${driftMs}ms longer than audio; ${speedupRatio.toFixed(2)}x speedup within limits`,
|
|
89
|
+
confidence: speedupRatio <= 1.2 ? 0.9 : 0.75,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// Too much speedup needed — trim silence instead
|
|
93
|
+
return {
|
|
94
|
+
beatId: beat.beatId,
|
|
95
|
+
editType: "trim_silence",
|
|
96
|
+
segmentStartMs: 0,
|
|
97
|
+
segmentEndMs: videoDurationMs,
|
|
98
|
+
targetDurationMs: audioDurationMs,
|
|
99
|
+
rationale: `Video ${driftMs}ms longer; speedup would exceed ${LIMITS.maxSpeedup}x limit, trimming silence instead`,
|
|
100
|
+
confidence: 0.65,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Audio is longer than video (negative drift) — slow down or freeze
|
|
104
|
+
if (driftMs < 0) {
|
|
105
|
+
const absDrift = Math.abs(driftMs);
|
|
106
|
+
const slowdownRatio = audioDurationMs / videoDurationMs;
|
|
107
|
+
if (slowdownRatio <= LIMITS.maxSlowdown) {
|
|
108
|
+
return {
|
|
109
|
+
beatId: beat.beatId,
|
|
110
|
+
editType: "slow_video",
|
|
111
|
+
segmentStartMs: 0,
|
|
112
|
+
segmentEndMs: videoDurationMs,
|
|
113
|
+
targetDurationMs: audioDurationMs,
|
|
114
|
+
rationale: `Audio ${absDrift}ms longer than video; ${slowdownRatio.toFixed(2)}x slowdown within limits`,
|
|
115
|
+
confidence: slowdownRatio <= 1.15 ? 0.9 : 0.75,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// Too much slowdown — freeze frame
|
|
119
|
+
if (absDrift <= LIMITS.maxFreezeFrameMs) {
|
|
120
|
+
return {
|
|
121
|
+
beatId: beat.beatId,
|
|
122
|
+
editType: "freeze_frame",
|
|
123
|
+
segmentStartMs: videoDurationMs,
|
|
124
|
+
segmentEndMs: videoDurationMs,
|
|
125
|
+
targetDurationMs: absDrift,
|
|
126
|
+
rationale: `Audio ${absDrift}ms longer; freeze last frame for ${absDrift}ms to cover gap`,
|
|
127
|
+
confidence: absDrift <= 1500 ? 0.85 : 0.7,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// Critical drift — log but low confidence
|
|
131
|
+
return {
|
|
132
|
+
beatId: beat.beatId,
|
|
133
|
+
editType: "freeze_frame",
|
|
134
|
+
segmentStartMs: videoDurationMs,
|
|
135
|
+
segmentEndMs: videoDurationMs,
|
|
136
|
+
targetDurationMs: Math.min(absDrift, LIMITS.maxFreezeFrameMs),
|
|
137
|
+
rationale: `Critical drift: audio ${absDrift}ms longer; capped freeze at ${LIMITS.maxFreezeFrameMs}ms`,
|
|
138
|
+
confidence: 0.5,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Generate edit proposals and write to recordings/edit-decisions.json.
|
|
145
|
+
*/
|
|
146
|
+
export function generateAndWriteEditProposals(projectDir, heroSceneIds = []) {
|
|
147
|
+
const alignmentPath = resolve(projectDir, "recordings", "alignment.json");
|
|
148
|
+
if (!existsSync(alignmentPath)) {
|
|
149
|
+
console.error("No alignment.json found. Run `demofly generate <name> --align` first.");
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
const alignment = JSON.parse(readFileSync(alignmentPath, "utf-8"));
|
|
153
|
+
const proposals = generateEditProposals(alignment, heroSceneIds);
|
|
154
|
+
const outputPath = resolve(projectDir, "recordings", "edit-decisions.json");
|
|
155
|
+
writeFileSync(outputPath, JSON.stringify(proposals, null, 2), "utf-8");
|
|
156
|
+
console.log(` Written: ${outputPath}`);
|
|
157
|
+
return proposals;
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=edit-proposals.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"edit-proposals.js","sourceRoot":"","sources":["../../src/lib/edit-proposals.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demofly retiming — translate edit decisions into ffmpeg filter commands.
|
|
3
|
+
*
|
|
4
|
+
* Segment-based retiming: split video → per-segment transforms → concatenate.
|
|
5
|
+
* Replaces simple stitchAudio() for the intelligent assembly pipeline.
|
|
6
|
+
*/
|
|
7
|
+
import type { TimingData } from "./timing.js";
|
|
8
|
+
import type { EditDecisionsData } from "./edit-proposals.js";
|
|
9
|
+
/**
|
|
10
|
+
* Apply edit decisions to video segments and produce a retimed final video.
|
|
11
|
+
*
|
|
12
|
+
* Pipeline:
|
|
13
|
+
* 1. Split video into per-scene segments
|
|
14
|
+
* 2. Apply per-segment transforms (speed change, freeze frame, etc.)
|
|
15
|
+
* 3. Concatenate retimed segments
|
|
16
|
+
* 4. Mux with audio
|
|
17
|
+
*/
|
|
18
|
+
export declare function retimeAndAssemble(videoPath: string, audioDir: string, timingData: TimingData, decisions: EditDecisionsData, recordingsDir: string): string | null;
|
|
19
|
+
//# sourceMappingURL=retiming.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retiming.d.ts","sourceRoot":"","sources":["../../src/lib/retiming.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAQ7D;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,UAAU,EACtB,SAAS,EAAE,iBAAiB,EAC5B,aAAa,EAAE,MAAM,GACpB,MAAM,GAAG,IAAI,CAwCf"}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demofly retiming — translate edit decisions into ffmpeg filter commands.
|
|
3
|
+
*
|
|
4
|
+
* Segment-based retiming: split video → per-segment transforms → concatenate.
|
|
5
|
+
* Replaces simple stitchAudio() for the intelligent assembly pipeline.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from "node:child_process";
|
|
8
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { debug } from "./logger.js";
|
|
11
|
+
/**
|
|
12
|
+
* Apply edit decisions to video segments and produce a retimed final video.
|
|
13
|
+
*
|
|
14
|
+
* Pipeline:
|
|
15
|
+
* 1. Split video into per-scene segments
|
|
16
|
+
* 2. Apply per-segment transforms (speed change, freeze frame, etc.)
|
|
17
|
+
* 3. Concatenate retimed segments
|
|
18
|
+
* 4. Mux with audio
|
|
19
|
+
*/
|
|
20
|
+
export function retimeAndAssemble(videoPath, audioDir, timingData, decisions, recordingsDir) {
|
|
21
|
+
const workDir = resolve(recordingsDir, ".retiming-work");
|
|
22
|
+
mkdirSync(workDir, { recursive: true });
|
|
23
|
+
try {
|
|
24
|
+
// Step 1: Split video into scene segments
|
|
25
|
+
console.log(" Splitting video into scene segments...");
|
|
26
|
+
const segments = splitIntoSegments(videoPath, timingData, workDir);
|
|
27
|
+
if (segments.length === 0) {
|
|
28
|
+
console.warn("No segments produced. Falling back to simple stitch.");
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
// Step 2: Apply edits to each segment
|
|
32
|
+
console.log(" Applying retiming edits...");
|
|
33
|
+
const retimed = applyEdits(segments, decisions, workDir);
|
|
34
|
+
// Step 3: Mux audio with retimed segments
|
|
35
|
+
console.log(" Muxing audio...");
|
|
36
|
+
const muxed = muxAudioToSegments(retimed, audioDir, workDir);
|
|
37
|
+
// Step 4: Concatenate all segments
|
|
38
|
+
console.log(" Concatenating final video...");
|
|
39
|
+
const outputPath = resolve(recordingsDir, "final.mp4");
|
|
40
|
+
const success = concatenateSegments(muxed, outputPath, workDir);
|
|
41
|
+
if (success) {
|
|
42
|
+
return outputPath;
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
// Clean up work directory
|
|
48
|
+
try {
|
|
49
|
+
execSync(`rm -rf "${workDir}"`, { stdio: "pipe" });
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Non-critical cleanup failure
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Split video into per-scene segments based on timing data.
|
|
58
|
+
*/
|
|
59
|
+
function splitIntoSegments(videoPath, timingData, workDir) {
|
|
60
|
+
const segments = [];
|
|
61
|
+
for (const scene of timingData.scenes) {
|
|
62
|
+
const startSec = scene.startMs / 1000;
|
|
63
|
+
const durationSec = (scene.endMs - scene.startMs) / 1000;
|
|
64
|
+
const outputPath = resolve(workDir, `${scene.sceneId}-raw.mp4`);
|
|
65
|
+
try {
|
|
66
|
+
execSync(`ffmpeg -y -i "${videoPath}" -ss ${startSec} -t ${durationSec} ` +
|
|
67
|
+
`-c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p -an "${outputPath}"`, { encoding: "utf-8", timeout: 120_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
68
|
+
segments.push({ path: outputPath, sceneId: scene.sceneId });
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
const execError = error;
|
|
72
|
+
console.warn(` Warning: Failed to split ${scene.sceneId}:`);
|
|
73
|
+
if (execError.stderr) {
|
|
74
|
+
debug(execError.stderr);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return segments;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Apply edit decisions to segments.
|
|
82
|
+
* Returns paths to the retimed segment files.
|
|
83
|
+
*/
|
|
84
|
+
function applyEdits(segments, decisions, workDir) {
|
|
85
|
+
const decisionsByBeat = new Map(decisions.decisions.map((d) => [d.beatId, d]));
|
|
86
|
+
return segments.map((seg) => {
|
|
87
|
+
const decision = decisionsByBeat.get(seg.sceneId);
|
|
88
|
+
if (!decision) {
|
|
89
|
+
debug(` ${seg.sceneId}: no edit needed`);
|
|
90
|
+
return seg;
|
|
91
|
+
}
|
|
92
|
+
const outputPath = resolve(workDir, `${seg.sceneId}-retimed.mp4`);
|
|
93
|
+
try {
|
|
94
|
+
switch (decision.editType) {
|
|
95
|
+
case "speed_video": {
|
|
96
|
+
const speed = decision.segmentEndMs / decision.targetDurationMs;
|
|
97
|
+
const filter = `setpts=${(1 / speed).toFixed(4)}*PTS`;
|
|
98
|
+
execSync(`ffmpeg -y -i "${seg.path}" -filter:v "${filter}" ` +
|
|
99
|
+
`-c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p -an "${outputPath}"`, { encoding: "utf-8", timeout: 120_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
100
|
+
console.log(` ${seg.sceneId}: speed_video ${speed.toFixed(2)}x`);
|
|
101
|
+
return { path: outputPath, sceneId: seg.sceneId };
|
|
102
|
+
}
|
|
103
|
+
case "slow_video": {
|
|
104
|
+
const speed = decision.segmentEndMs / decision.targetDurationMs;
|
|
105
|
+
const filter = `setpts=${(1 / speed).toFixed(4)}*PTS`;
|
|
106
|
+
execSync(`ffmpeg -y -i "${seg.path}" -filter:v "${filter}" ` +
|
|
107
|
+
`-c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p -an "${outputPath}"`, { encoding: "utf-8", timeout: 120_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
108
|
+
console.log(` ${seg.sceneId}: slow_video ${speed.toFixed(2)}x`);
|
|
109
|
+
return { path: outputPath, sceneId: seg.sceneId };
|
|
110
|
+
}
|
|
111
|
+
case "freeze_frame": {
|
|
112
|
+
const freezeDuration = decision.targetDurationMs / 1000;
|
|
113
|
+
// Extract last frame, create freeze, concatenate
|
|
114
|
+
const freezePath = resolve(workDir, `${seg.sceneId}-freeze.mp4`);
|
|
115
|
+
execSync(`ffmpeg -y -sseof -0.1 -i "${seg.path}" -vframes 1 -loop 1 -t ${freezeDuration} ` +
|
|
116
|
+
`-c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p "${freezePath}"`, { encoding: "utf-8", timeout: 60_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
117
|
+
// Concatenate original + freeze
|
|
118
|
+
const listFile = resolve(workDir, `${seg.sceneId}-concat.txt`);
|
|
119
|
+
writeFileSync(listFile, `file '${seg.path}'\nfile '${freezePath}'\n`);
|
|
120
|
+
execSync(`ffmpeg -y -f concat -safe 0 -i "${listFile}" ` +
|
|
121
|
+
`-c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p -an "${outputPath}"`, { encoding: "utf-8", timeout: 120_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
122
|
+
console.log(` ${seg.sceneId}: freeze_frame +${decision.targetDurationMs}ms`);
|
|
123
|
+
return { path: outputPath, sceneId: seg.sceneId };
|
|
124
|
+
}
|
|
125
|
+
case "trim_silence": {
|
|
126
|
+
// Trim from the end of the segment to match target duration
|
|
127
|
+
const targetSec = decision.targetDurationMs / 1000;
|
|
128
|
+
execSync(`ffmpeg -y -i "${seg.path}" -t ${targetSec} ` +
|
|
129
|
+
`-c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p -an "${outputPath}"`, { encoding: "utf-8", timeout: 120_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
130
|
+
console.log(` ${seg.sceneId}: trim_silence to ${decision.targetDurationMs}ms`);
|
|
131
|
+
return { path: outputPath, sceneId: seg.sceneId };
|
|
132
|
+
}
|
|
133
|
+
case "pad_silence":
|
|
134
|
+
case "no_change":
|
|
135
|
+
debug(` ${seg.sceneId}: ${decision.editType} (no video change)`);
|
|
136
|
+
return seg;
|
|
137
|
+
default:
|
|
138
|
+
debug(` ${seg.sceneId}: unknown edit type ${decision.editType}`);
|
|
139
|
+
return seg;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
const execError = error;
|
|
144
|
+
console.warn(` Warning: Edit failed for ${seg.sceneId}, using original:`);
|
|
145
|
+
if (execError.stderr) {
|
|
146
|
+
debug(execError.stderr);
|
|
147
|
+
}
|
|
148
|
+
return seg;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Mux audio files into retimed video segments.
|
|
154
|
+
*/
|
|
155
|
+
function muxAudioToSegments(segments, audioDir, workDir) {
|
|
156
|
+
return segments.map((seg) => {
|
|
157
|
+
// Find matching audio file
|
|
158
|
+
const wavPath = resolve(audioDir, `${seg.sceneId}.wav`);
|
|
159
|
+
const mp3Path = resolve(audioDir, `${seg.sceneId}.mp3`);
|
|
160
|
+
const audioPath = existsSync(wavPath) ? wavPath : existsSync(mp3Path) ? mp3Path : null;
|
|
161
|
+
if (!audioPath) {
|
|
162
|
+
debug(` ${seg.sceneId}: no audio file, keeping video-only`);
|
|
163
|
+
return seg;
|
|
164
|
+
}
|
|
165
|
+
const outputPath = resolve(workDir, `${seg.sceneId}-muxed.mp4`);
|
|
166
|
+
try {
|
|
167
|
+
execSync(`ffmpeg -y -i "${seg.path}" -i "${audioPath}" ` +
|
|
168
|
+
`-c:v copy -c:a aac -b:a 128k -shortest "${outputPath}"`, { encoding: "utf-8", timeout: 120_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
169
|
+
return { path: outputPath, sceneId: seg.sceneId };
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
const execError = error;
|
|
173
|
+
console.warn(` Warning: Audio mux failed for ${seg.sceneId}:`);
|
|
174
|
+
if (execError.stderr) {
|
|
175
|
+
debug(execError.stderr);
|
|
176
|
+
}
|
|
177
|
+
return seg;
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Concatenate all segments into the final video.
|
|
183
|
+
*/
|
|
184
|
+
function concatenateSegments(segments, outputPath, workDir) {
|
|
185
|
+
if (segments.length === 0)
|
|
186
|
+
return false;
|
|
187
|
+
// If only one segment, just copy it
|
|
188
|
+
if (segments.length === 1) {
|
|
189
|
+
try {
|
|
190
|
+
execSync(`ffmpeg -y -i "${segments[0].path}" -c copy "${outputPath}"`, { encoding: "utf-8", timeout: 120_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// Create concat file
|
|
198
|
+
const listFile = resolve(workDir, "concat-final.txt");
|
|
199
|
+
const lines = segments.map((s) => `file '${s.path}'`).join("\n");
|
|
200
|
+
writeFileSync(listFile, lines + "\n");
|
|
201
|
+
try {
|
|
202
|
+
execSync(`ffmpeg -y -f concat -safe 0 -i "${listFile}" ` +
|
|
203
|
+
`-c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p -c:a aac -b:a 128k "${outputPath}"`, { encoding: "utf-8", timeout: 300_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
const execError = error;
|
|
208
|
+
console.error("Final concatenation failed:");
|
|
209
|
+
if (execError.stderr) {
|
|
210
|
+
console.error(execError.stderr);
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=retiming.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"retiming.js","sourceRoot":"","sources":["../../src/lib/retiming.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demofly Whisper integration — extract word-level timestamps from TTS audio.
|
|
3
|
+
*
|
|
4
|
+
* Shells out to the `whisper` CLI (same subprocess pattern as ffmpeg).
|
|
5
|
+
* Uses whisper-base model — TTS audio is clean, so a small model achieves
|
|
6
|
+
* near-perfect accuracy while keeping runtime under 5s per scene.
|
|
7
|
+
*
|
|
8
|
+
* Fallback: If Whisper isn't installed, returns duration-only data and
|
|
9
|
+
* prints a warning suggesting `pip install openai-whisper`.
|
|
10
|
+
*/
|
|
11
|
+
export interface WordTimestamp {
|
|
12
|
+
word: string;
|
|
13
|
+
startMs: number;
|
|
14
|
+
endMs: number;
|
|
15
|
+
}
|
|
16
|
+
export interface PhraseTimestamp {
|
|
17
|
+
text: string;
|
|
18
|
+
startMs: number;
|
|
19
|
+
endMs: number;
|
|
20
|
+
beatId: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SceneTimestamps {
|
|
23
|
+
sceneId: string;
|
|
24
|
+
audioFile: string;
|
|
25
|
+
durationMs: number;
|
|
26
|
+
words: WordTimestamp[];
|
|
27
|
+
phrases: PhraseTimestamp[];
|
|
28
|
+
}
|
|
29
|
+
export interface TimestampsData {
|
|
30
|
+
scenes: SceneTimestamps[];
|
|
31
|
+
}
|
|
32
|
+
/** Check if the whisper CLI is available. */
|
|
33
|
+
export declare function hasWhisper(): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Extract timestamps from all audio files in a demo's audio/ directory.
|
|
36
|
+
*
|
|
37
|
+
* If Whisper is available, produces word-level and phrase-level timestamps.
|
|
38
|
+
* If not, falls back to duration-only data (empty words/phrases arrays).
|
|
39
|
+
*
|
|
40
|
+
* @returns TimestampsData written to audio/timestamps.json
|
|
41
|
+
*/
|
|
42
|
+
export declare function extractTimestamps(projectDir: string): TimestampsData;
|
|
43
|
+
//# sourceMappingURL=timestamps.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timestamps.d.ts","sourceRoot":"","sources":["../../src/lib/timestamps.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAaH,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,OAAO,EAAE,eAAe,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,eAAe,EAAE,CAAC;CAC3B;AAED,6CAA6C;AAC7C,wBAAgB,UAAU,IAAI,OAAO,CAOpC;AAwHD;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,cAAc,CA4EpE"}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Demofly Whisper integration — extract word-level timestamps from TTS audio.
|
|
3
|
+
*
|
|
4
|
+
* Shells out to the `whisper` CLI (same subprocess pattern as ffmpeg).
|
|
5
|
+
* Uses whisper-base model — TTS audio is clean, so a small model achieves
|
|
6
|
+
* near-perfect accuracy while keeping runtime under 5s per scene.
|
|
7
|
+
*
|
|
8
|
+
* Fallback: If Whisper isn't installed, returns duration-only data and
|
|
9
|
+
* prints a warning suggesting `pip install openai-whisper`.
|
|
10
|
+
*/
|
|
11
|
+
import { execSync } from "node:child_process";
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
|
|
13
|
+
import { resolve, basename, extname } from "node:path";
|
|
14
|
+
import { debug } from "./logger.js";
|
|
15
|
+
/** Check if the whisper CLI is available. */
|
|
16
|
+
export function hasWhisper() {
|
|
17
|
+
try {
|
|
18
|
+
execSync("whisper --help", { stdio: "pipe", timeout: 10_000 });
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Get audio duration in milliseconds using ffprobe.
|
|
27
|
+
* Falls back to 0 if ffprobe isn't available.
|
|
28
|
+
*/
|
|
29
|
+
function getAudioDurationMs(audioPath) {
|
|
30
|
+
try {
|
|
31
|
+
const output = execSync(`ffprobe -v error -show_entries format=duration -of csv=p=0 "${audioPath}"`, { encoding: "utf-8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] });
|
|
32
|
+
return Math.round(parseFloat(output.trim()) * 1000);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run Whisper on a single audio file and extract word-level timestamps.
|
|
40
|
+
* Returns the raw word timestamps array.
|
|
41
|
+
*/
|
|
42
|
+
function whisperTranscribe(audioPath, outputDir) {
|
|
43
|
+
const cmd = [
|
|
44
|
+
"whisper",
|
|
45
|
+
`"${audioPath}"`,
|
|
46
|
+
"--model", "base",
|
|
47
|
+
"--output_format", "json",
|
|
48
|
+
"--word_timestamps", "True",
|
|
49
|
+
"--output_dir", `"${outputDir}"`,
|
|
50
|
+
"--language", "en",
|
|
51
|
+
"--fp16", "False",
|
|
52
|
+
].join(" ");
|
|
53
|
+
debug(`Whisper command: ${cmd}`);
|
|
54
|
+
try {
|
|
55
|
+
execSync(cmd, {
|
|
56
|
+
encoding: "utf-8",
|
|
57
|
+
timeout: 120_000,
|
|
58
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
const execError = error;
|
|
63
|
+
console.warn(`Whisper transcription failed for ${audioPath}:`);
|
|
64
|
+
if (execError.stderr) {
|
|
65
|
+
console.warn(execError.stderr);
|
|
66
|
+
}
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
// Whisper outputs a JSON file with the same name as the input
|
|
70
|
+
const stem = basename(audioPath, extname(audioPath));
|
|
71
|
+
const jsonPath = resolve(outputDir, `${stem}.json`);
|
|
72
|
+
if (!existsSync(jsonPath)) {
|
|
73
|
+
console.warn(`Whisper output not found: ${jsonPath}`);
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const data = JSON.parse(readFileSync(jsonPath, "utf-8"));
|
|
78
|
+
const words = [];
|
|
79
|
+
// Whisper JSON format: { segments: [{ words: [{ word, start, end }] }] }
|
|
80
|
+
for (const segment of data.segments ?? []) {
|
|
81
|
+
for (const w of segment.words ?? []) {
|
|
82
|
+
words.push({
|
|
83
|
+
word: (w.word ?? "").trim(),
|
|
84
|
+
startMs: Math.round((w.start ?? 0) * 1000),
|
|
85
|
+
endMs: Math.round((w.end ?? 0) * 1000),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return words;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
console.warn(`Failed to parse Whisper output: ${jsonPath}`);
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Group word timestamps into phrases (sentences) based on punctuation.
|
|
98
|
+
* Each phrase gets a beatId placeholder that should be updated by the caller.
|
|
99
|
+
*/
|
|
100
|
+
function groupIntoPhrases(words) {
|
|
101
|
+
if (words.length === 0)
|
|
102
|
+
return [];
|
|
103
|
+
const phrases = [];
|
|
104
|
+
let currentWords = [];
|
|
105
|
+
for (const word of words) {
|
|
106
|
+
currentWords.push(word);
|
|
107
|
+
// Split on sentence-ending punctuation
|
|
108
|
+
if (/[.!?]$/.test(word.word)) {
|
|
109
|
+
phrases.push({
|
|
110
|
+
text: currentWords.map((w) => w.word).join(" "),
|
|
111
|
+
startMs: currentWords[0].startMs,
|
|
112
|
+
endMs: word.endMs,
|
|
113
|
+
beatId: "", // To be filled by caller
|
|
114
|
+
});
|
|
115
|
+
currentWords = [];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Remaining words form a final phrase
|
|
119
|
+
if (currentWords.length > 0) {
|
|
120
|
+
phrases.push({
|
|
121
|
+
text: currentWords.map((w) => w.word).join(" "),
|
|
122
|
+
startMs: currentWords[0].startMs,
|
|
123
|
+
endMs: currentWords[currentWords.length - 1].endMs,
|
|
124
|
+
beatId: "",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
return phrases;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Extract timestamps from all audio files in a demo's audio/ directory.
|
|
131
|
+
*
|
|
132
|
+
* If Whisper is available, produces word-level and phrase-level timestamps.
|
|
133
|
+
* If not, falls back to duration-only data (empty words/phrases arrays).
|
|
134
|
+
*
|
|
135
|
+
* @returns TimestampsData written to audio/timestamps.json
|
|
136
|
+
*/
|
|
137
|
+
export function extractTimestamps(projectDir) {
|
|
138
|
+
const audioDir = resolve(projectDir, "audio");
|
|
139
|
+
if (!existsSync(audioDir)) {
|
|
140
|
+
console.error(`No audio directory found at ${audioDir}`);
|
|
141
|
+
return { scenes: [] };
|
|
142
|
+
}
|
|
143
|
+
const audioFiles = readdirSync(audioDir).filter((f) => {
|
|
144
|
+
const ext = extname(f).toLowerCase();
|
|
145
|
+
return [".wav", ".mp3"].includes(ext);
|
|
146
|
+
});
|
|
147
|
+
if (audioFiles.length === 0) {
|
|
148
|
+
console.warn("No audio files found in audio/ directory.");
|
|
149
|
+
return { scenes: [] };
|
|
150
|
+
}
|
|
151
|
+
const whisperAvailable = hasWhisper();
|
|
152
|
+
if (!whisperAvailable) {
|
|
153
|
+
console.warn("Whisper not found. Using duration-only timestamps (less precise).\n" +
|
|
154
|
+
"For word-level timestamps, install: pip install openai-whisper");
|
|
155
|
+
}
|
|
156
|
+
// Create temp dir for Whisper output
|
|
157
|
+
const whisperTmpDir = resolve(audioDir, ".whisper-tmp");
|
|
158
|
+
const scenes = [];
|
|
159
|
+
for (const file of audioFiles.sort()) {
|
|
160
|
+
const sceneId = basename(file, extname(file));
|
|
161
|
+
const audioPath = resolve(audioDir, file);
|
|
162
|
+
const durationMs = getAudioDurationMs(audioPath);
|
|
163
|
+
console.log(` Timestamps: ${sceneId} (${durationMs}ms)`);
|
|
164
|
+
let words = [];
|
|
165
|
+
let phrases = [];
|
|
166
|
+
if (whisperAvailable) {
|
|
167
|
+
mkdirSync(whisperTmpDir, { recursive: true });
|
|
168
|
+
words = whisperTranscribe(audioPath, whisperTmpDir);
|
|
169
|
+
phrases = groupIntoPhrases(words);
|
|
170
|
+
console.log(` → ${words.length} words, ${phrases.length} phrases`);
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
console.log(` → duration only (no Whisper)`);
|
|
174
|
+
}
|
|
175
|
+
scenes.push({
|
|
176
|
+
sceneId,
|
|
177
|
+
audioFile: `audio/${file}`,
|
|
178
|
+
durationMs,
|
|
179
|
+
words,
|
|
180
|
+
phrases,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
// Clean up Whisper temp dir
|
|
184
|
+
if (existsSync(whisperTmpDir)) {
|
|
185
|
+
try {
|
|
186
|
+
execSync(`rm -rf "${whisperTmpDir}"`, { stdio: "pipe" });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Non-critical cleanup failure
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const result = { scenes };
|
|
193
|
+
// Write timestamps.json
|
|
194
|
+
const outputPath = resolve(audioDir, "timestamps.json");
|
|
195
|
+
writeFileSync(outputPath, JSON.stringify(result, null, 2), "utf-8");
|
|
196
|
+
console.log(`\n Written: ${outputPath}`);
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=timestamps.js.map
|