@esquadrao/cli 0.0.0-stage → 1.6.0
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/LICENSE +21 -0
- package/README.md +366 -2
- package/assets/fonts/Anton-Regular.ttf +0 -0
- package/assets/fonts/BebasNeue-Regular.ttf +0 -0
- package/assets/fonts/OFL.txt +93 -0
- package/assets/fonts/Poppins-Bold.ttf +0 -0
- package/assets/fonts/README.md +13 -0
- package/bin/cli.js +126 -0
- package/package.json +58 -4
- package/scripts/ast_slice.js +73 -0
- package/scripts/cutout.py +197 -0
- package/scripts/dag_validator.js +78 -0
- package/scripts/lint.js +43 -0
- package/scripts/merkle_verify.js +83 -0
- package/scripts/wcag_check.js +77 -0
- package/skill-audit/SKILL.md +42 -0
- package/skill-audit/references/merkle-ledger-spec.md +27 -0
- package/skill-audit/references/receipt-ed25519-verification.md +17 -0
- package/skill-audit/references/time-travel-replay.md +10 -0
- package/skill-code-reviewer/SKILL.md +40 -0
- package/skill-context/SKILL.md +44 -0
- package/skill-context/references/ast-virtualization.md +10 -0
- package/skill-context/references/openapi-contract-synthesis.md +10 -0
- package/skill-engineering-manager/SKILL.md +40 -0
- package/skill-media-producer/SKILL.md +328 -0
- package/skill-media-producer/references/caption-templates.md +194 -0
- package/skill-media-producer/references/composicoes-especiais.md +99 -0
- package/skill-media-producer/references/configuracao-inicial.md +239 -0
- package/skill-media-producer/references/corte-podcast-3-niveis.md +179 -0
- package/skill-media-producer/references/llm-agent-guide.md +231 -0
- package/skill-orchestrate/SKILL.md +49 -0
- package/skill-orchestrate/references/mission-lifecycle.md +36 -0
- package/skill-orchestrate/references/swarm-coordination.md +33 -0
- package/skill-pipeline/SKILL.md +42 -0
- package/skill-pipeline/references/auto-healing-patterns.md +17 -0
- package/skill-pipeline/references/dag-decomposition-rules.md +18 -0
- package/skill-pipeline/references/topological-waves.md +16 -0
- package/skill-product-manager/SKILL.md +40 -0
- package/skill-qa-engineer/SKILL.md +40 -0
- package/skill-review/SKILL.md +46 -0
- package/skill-review/references/anti-slop-patterns.md +17 -0
- package/skill-review/references/dual-blind-adjudication.md +15 -0
- package/skill-review/references/voice-calibration.md +10 -0
- package/skill-security-engineer/SKILL.md +40 -0
- package/skill-software-architect/SKILL.md +40 -0
- package/skill-software-engineer/SKILL.md +40 -0
- package/skill-sre-engineer/SKILL.md +40 -0
- package/skill-system-analyst/SKILL.md +40 -0
- package/skill-tech-lead/SKILL.md +40 -0
- package/skill-technical-writer/SKILL.md +40 -0
- package/skill-ui/SKILL.md +45 -0
- package/skill-ui/references/design-tokens-spec.md +19 -0
- package/skill-ui/references/visual-tdd-methodology.md +10 -0
- package/skill-ui/references/wcag-target-sizes.md +9 -0
- package/src/captionTemplates.js +285 -0
- package/src/commands/onboard.js +618 -0
- package/src/commands/role.js +119 -0
- package/src/commands/setup.js +174 -0
- package/src/commands/studio.js +1822 -0
- package/src/roles/authority.js +129 -0
- package/src/roles/codeReviewer.js +65 -0
- package/src/roles/engineeringManager.js +53 -0
- package/src/roles/index.js +80 -0
- package/src/roles/mediaProducer.js +223 -0
- package/src/roles/productManager.js +50 -0
- package/src/roles/qaEngineer.js +54 -0
- package/src/roles/securityEngineer.js +53 -0
- package/src/roles/softwareArchitect.js +58 -0
- package/src/roles/softwareEngineer.js +76 -0
- package/src/roles/sreEngineer.js +60 -0
- package/src/roles/systemAnalyst.js +45 -0
- package/src/roles/techLead.js +46 -0
- package/src/roles/technicalWriter.js +46 -0
|
@@ -0,0 +1,1822 @@
|
|
|
1
|
+
// Video Studio Execution Slice
|
|
2
|
+
// Minimal self-contained local video pipeline with zero npm dependencies.
|
|
3
|
+
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import crypto from 'node:crypto';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { execFileSync, spawnSync, spawn } from 'node:child_process';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
import readline from 'node:readline';
|
|
12
|
+
import { validateEditPlan } from '../roles/mediaProducer.js';
|
|
13
|
+
import { buildTemplateCaptionFilters, getCaptionTemplate, listCaptionTemplates } from '../captionTemplates.js';
|
|
14
|
+
|
|
15
|
+
export function checkBinary(name) {
|
|
16
|
+
try {
|
|
17
|
+
execFileSync(name, ['-version'], { stdio: 'ignore' });
|
|
18
|
+
return true;
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function findWorkspaceDir(customDest) {
|
|
25
|
+
if (customDest) return path.resolve(customDest);
|
|
26
|
+
let cur = process.cwd();
|
|
27
|
+
while (true) {
|
|
28
|
+
if (fs.existsSync(path.join(cur, 'studio.json'))) {
|
|
29
|
+
return cur;
|
|
30
|
+
}
|
|
31
|
+
const parent = path.dirname(cur);
|
|
32
|
+
if (parent === cur) break;
|
|
33
|
+
cur = parent;
|
|
34
|
+
}
|
|
35
|
+
return process.cwd();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function findSystemFont() {
|
|
39
|
+
if (process.platform === 'win32') {
|
|
40
|
+
const winDir = process.env.WINDIR || 'C:\\Windows';
|
|
41
|
+
for (const name of ['arial.ttf', 'segoeui.ttf', 'calibri.ttf']) {
|
|
42
|
+
const p = path.join(winDir, 'Fonts', name);
|
|
43
|
+
if (fs.existsSync(p)) return p;
|
|
44
|
+
}
|
|
45
|
+
} else if (process.platform === 'darwin') {
|
|
46
|
+
for (const p of ['/System/Library/Fonts/Helvetica.ttc', '/Library/Fonts/Arial.ttf']) {
|
|
47
|
+
if (fs.existsSync(p)) return p;
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
for (const p of [
|
|
51
|
+
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
|
|
52
|
+
'/usr/share/fonts/truetype/freefont/FreeSans.ttf',
|
|
53
|
+
'/usr/share/fonts/dejavu/DejaVuSans.ttf',
|
|
54
|
+
]) {
|
|
55
|
+
if (fs.existsSync(p)) return p;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function probeMedia(filePath) {
|
|
62
|
+
if (!checkBinary('ffprobe')) {
|
|
63
|
+
throw new Error('ffprobe is not installed or not found on PATH. Install ffmpeg to use video studio features.');
|
|
64
|
+
}
|
|
65
|
+
const stdout = execFileSync('ffprobe', [
|
|
66
|
+
'-v', 'quiet',
|
|
67
|
+
'-print_format', 'json',
|
|
68
|
+
'-show_format',
|
|
69
|
+
'-show_streams',
|
|
70
|
+
filePath,
|
|
71
|
+
], { encoding: 'utf8' });
|
|
72
|
+
return JSON.parse(stdout);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function loadManifest(manifestPath) {
|
|
76
|
+
if (!fs.existsSync(manifestPath)) {
|
|
77
|
+
return { version: 1, name: 'esquadrao-studio', assets: [], artifacts: [] };
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error(`Error: Corrupted studio manifest at ${manifestPath}: ${err.message}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function saveManifestAtomic(manifestPath, manifest) {
|
|
88
|
+
const dir = path.dirname(manifestPath);
|
|
89
|
+
const tmpPath = path.join(dir, `.studio.json.tmp-${crypto.randomBytes(4).toString('hex')}`);
|
|
90
|
+
fs.writeFileSync(tmpPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
91
|
+
fs.renameSync(tmpPath, manifestPath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveAsset(workspaceDir, clip) {
|
|
95
|
+
const candidate = clip.asset_id || clip.file || clip.path || clip.source;
|
|
96
|
+
if (!candidate) {
|
|
97
|
+
throw new Error('Clip is missing asset reference (expected asset_id, file, or path)');
|
|
98
|
+
}
|
|
99
|
+
if (fs.existsSync(candidate)) return path.resolve(candidate);
|
|
100
|
+
const fromWs = path.join(workspaceDir, candidate);
|
|
101
|
+
if (fs.existsSync(fromWs)) return fromWs;
|
|
102
|
+
const inAssets = path.join(workspaceDir, 'assets', candidate);
|
|
103
|
+
if (fs.existsSync(inAssets)) return inAssets;
|
|
104
|
+
|
|
105
|
+
const manifestPath = path.join(workspaceDir, 'studio.json');
|
|
106
|
+
if (fs.existsSync(manifestPath)) {
|
|
107
|
+
const manifest = loadManifest(manifestPath);
|
|
108
|
+
const found = manifest.assets?.find((a) => a.id === candidate || a.name === candidate);
|
|
109
|
+
if (found?.path) {
|
|
110
|
+
const p = path.resolve(workspaceDir, found.path);
|
|
111
|
+
if (fs.existsSync(p)) return p;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const assetsDir = path.join(workspaceDir, 'assets');
|
|
116
|
+
if (fs.existsSync(assetsDir)) {
|
|
117
|
+
const files = fs.readdirSync(assetsDir);
|
|
118
|
+
const match = files.find((f) => f.startsWith(candidate));
|
|
119
|
+
if (match) return path.join(assetsDir, match);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
throw new Error(`Asset '${candidate}' not found in workspace assets`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function createDrawtextFilter(text, fontOpt, startSec, endSec, yExpr, tempFiles, captionStyle = 'default') {
|
|
126
|
+
const tmpFile = path.join(os.tmpdir(), `usk-cap-${crypto.randomBytes(6).toString('hex')}.txt`);
|
|
127
|
+
fs.writeFileSync(tmpFile, text, 'utf8');
|
|
128
|
+
tempFiles.push(tmpFile);
|
|
129
|
+
const escapedPath = tmpFile.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
|
|
130
|
+
if (captionStyle === 'impact') {
|
|
131
|
+
return `drawtext=textfile='${escapedPath}':expansion=none${fontOpt}:fontcolor=yellow@1:fontsize=56:box=1:boxcolor=black@0.7:boxborderw=12:x=(w-text_w)/2:y=${yExpr}:enable='between(t\\,${startSec}\\,${endSec})'`;
|
|
132
|
+
}
|
|
133
|
+
return `drawtext=textfile='${escapedPath}':expansion=none${fontOpt}:fontcolor=white:fontsize=48:x=(w-text_w)/2:y=${yExpr}:enable='between(t\\,${startSec}\\,${endSec})'`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function buildDarkTextFilters(clips, tempFiles) {
|
|
137
|
+
const font = findSystemFont();
|
|
138
|
+
const fontOpt = font ? `:fontfile='${font.replace(/\\/g, '/').replace(/:/g, '\\:')}'` : '';
|
|
139
|
+
return clips.map((c) => {
|
|
140
|
+
const start = ((c.timeline_start_ms || 0) / 1000).toFixed(3);
|
|
141
|
+
const end = (((c.timeline_start_ms || 0) + c.duration_ms) / 1000).toFixed(3);
|
|
142
|
+
return createDrawtextFilter(c.text, fontOpt, start, end, '(h-text_h)/2', tempFiles);
|
|
143
|
+
}).join(',');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function buildCaptionFilters(captions, baseV, tempFiles, captionStyle = 'default', options = {}) {
|
|
147
|
+
if (!captions || captions.length === 0) {
|
|
148
|
+
return `${baseV}null[outv]`;
|
|
149
|
+
}
|
|
150
|
+
const font = findSystemFont();
|
|
151
|
+
const fontOpt = font ? `:fontfile='${font.replace(/\\/g, '/').replace(/:/g, '\\:')}'` : '';
|
|
152
|
+
const textFilters = captions.map((c) => {
|
|
153
|
+
const templateId = c.template || options.template;
|
|
154
|
+
if (templateId) {
|
|
155
|
+
const template = getCaptionTemplate(templateId);
|
|
156
|
+
return buildTemplateCaptionFilters(c, template, {
|
|
157
|
+
width: options.width,
|
|
158
|
+
height: options.height,
|
|
159
|
+
tempFiles,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const start = ((c.timeline_start_ms || 0) / 1000).toFixed(3);
|
|
163
|
+
const end = (((c.timeline_start_ms || 0) + (c.duration_ms || 1000)) / 1000).toFixed(3);
|
|
164
|
+
const style = c.style || captionStyle;
|
|
165
|
+
const yPos = style === 'impact' ? '(h-text_h)/2' : 'h-200';
|
|
166
|
+
return createDrawtextFilter(c.text, fontOpt, start, end, yPos, tempFiles, style);
|
|
167
|
+
}).join(',');
|
|
168
|
+
return `${baseV}${textFilters}[outv]`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildClipFilters(inputIndex, clip, clipPath, wsDir, width, height, gainDb = 0, { splitBottom = false } = {}) {
|
|
172
|
+
const inSec = ((clip.in_ms || 0) / 1000).toFixed(3);
|
|
173
|
+
const outSec = ((clip.out_ms || (clip.in_ms || 0) + 1000) / 1000).toFixed(3);
|
|
174
|
+
const durSec = Math.max(0.001, ((clip.out_ms || (clip.in_ms || 0) + 1000) - (clip.in_ms || 0)) / 1000).toFixed(3);
|
|
175
|
+
|
|
176
|
+
const halfH = Math.round(height / 2);
|
|
177
|
+
const vFilter = splitBottom
|
|
178
|
+
? `[${inputIndex}:v]trim=start=${inSec}:end=${outSec},setpts=PTS-STARTPTS,scale=${width}:${halfH}:force_original_aspect_ratio=increase,crop=${width}:${halfH},pad=${width}:${height}:0:0:black,setsar=1,fps=30[v${inputIndex}]`
|
|
179
|
+
: `[${inputIndex}:v]trim=start=${inSec}:end=${outSec},setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v${inputIndex}]`;
|
|
180
|
+
const aFilter = hasAudio(wsDir, clipPath, clip)
|
|
181
|
+
? `[${inputIndex}:a]atrim=start=${inSec}:end=${outSec},asetpts=PTS-STARTPTS,volume=${gainDb}dB[a${inputIndex}]`
|
|
182
|
+
: `anullsrc=channel_layout=stereo:sample_rate=44100,atrim=duration=${durSec}[a${inputIndex}]`;
|
|
183
|
+
|
|
184
|
+
return { vFilter, aFilter };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function hasAudio(wsDir, clipPath, clip) {
|
|
188
|
+
const candidate = clip?.asset_id || clip?.source_asset || clip?.name;
|
|
189
|
+
const manifestPath = path.join(wsDir, 'studio.json');
|
|
190
|
+
if (fs.existsSync(manifestPath)) {
|
|
191
|
+
const manifest = loadManifest(manifestPath);
|
|
192
|
+
const found = manifest.assets?.find((a) =>
|
|
193
|
+
(candidate && (a.id === candidate || a.name === candidate)) ||
|
|
194
|
+
(a.path && path.resolve(wsDir, a.path) === path.resolve(clipPath))
|
|
195
|
+
);
|
|
196
|
+
if (found?.metadata && 'audio_codec' in found.metadata) {
|
|
197
|
+
return Boolean(found.metadata.audio_codec);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
const probe = probeMedia(clipPath);
|
|
202
|
+
return Boolean(probe.streams?.some((s) => s.codec_type === 'audio'));
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getAssetDuration(wsDir, clipPath, clip) {
|
|
209
|
+
const candidate = clip?.asset_id || clip?.source_asset || clip?.name;
|
|
210
|
+
const manifestPath = path.join(wsDir, 'studio.json');
|
|
211
|
+
if (fs.existsSync(manifestPath)) {
|
|
212
|
+
const manifest = loadManifest(manifestPath);
|
|
213
|
+
const found = manifest.assets?.find((a) =>
|
|
214
|
+
(candidate && (a.id === candidate || a.name === candidate)) ||
|
|
215
|
+
(a.path && path.resolve(wsDir, a.path) === path.resolve(clipPath))
|
|
216
|
+
);
|
|
217
|
+
if (found?.metadata?.duration_s !== undefined && found?.metadata?.duration_s !== null) {
|
|
218
|
+
return { durationSec: Number(found.metadata.duration_s), assetId: found.id || candidate };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const probe = probeMedia(clipPath);
|
|
223
|
+
return { durationSec: parseFloat(probe.format?.duration || '0'), assetId: candidate || path.basename(clipPath) };
|
|
224
|
+
} catch {
|
|
225
|
+
return { durationSec: 0, assetId: candidate || path.basename(clipPath) };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function computeExpectedDuration(plan) {
|
|
230
|
+
if (plan.mode === 'dark') {
|
|
231
|
+
return Math.max(
|
|
232
|
+
...plan.clips.map((c) => ((c.timeline_start_ms || 0) + c.duration_ms) / 1000),
|
|
233
|
+
1
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (plan.mode === 'edit_only') {
|
|
237
|
+
const c0 = plan.clips?.[0] || {};
|
|
238
|
+
const c1 = plan.clips?.[1] || {};
|
|
239
|
+
return ((c0.out_ms - c0.in_ms) + (c1.out_ms - c1.in_ms)) / 1000;
|
|
240
|
+
}
|
|
241
|
+
if (plan.mode === 'podcast_clip') {
|
|
242
|
+
const videoClips = (plan.clips || []).filter((c) => c.kind === 'video' || !c.kind);
|
|
243
|
+
return videoClips.reduce((sum, c) => sum + ((c.out_ms || 0) - (c.in_ms || 0)) / 1000, 0);
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function verifyArtifact(artifactPath, planPath = null) {
|
|
249
|
+
const probe = probeMedia(artifactPath);
|
|
250
|
+
const vStream = probe.streams?.find((s) => s.codec_type === 'video');
|
|
251
|
+
const aStream = probe.streams?.find((s) => s.codec_type === 'audio');
|
|
252
|
+
const duration = parseFloat(probe.format?.duration || '0');
|
|
253
|
+
|
|
254
|
+
const errors = [];
|
|
255
|
+
if (!vStream) errors.push('Missing video stream');
|
|
256
|
+
if (!aStream) errors.push('Missing audio stream');
|
|
257
|
+
if (duration <= 0) errors.push('Invalid duration (<= 0s)');
|
|
258
|
+
|
|
259
|
+
let planDetails = null;
|
|
260
|
+
if (planPath) {
|
|
261
|
+
const resolvedPlan = path.resolve(planPath);
|
|
262
|
+
if (!fs.existsSync(resolvedPlan)) {
|
|
263
|
+
throw new Error(`Plan file not found: ${planPath}`);
|
|
264
|
+
}
|
|
265
|
+
const plan = JSON.parse(fs.readFileSync(resolvedPlan, 'utf8'));
|
|
266
|
+
|
|
267
|
+
if (plan.output?.width && vStream && vStream.width !== plan.output.width) {
|
|
268
|
+
errors.push(`Width mismatch: expected ${plan.output.width}, got ${vStream.width}`);
|
|
269
|
+
}
|
|
270
|
+
if (plan.output?.height && vStream && vStream.height !== plan.output.height) {
|
|
271
|
+
errors.push(`Height mismatch: expected ${plan.output.height}, got ${vStream.height}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (plan.output?.fps_num && vStream) {
|
|
275
|
+
const expectedFps = plan.output.fps_num / (plan.output.fps_den || 1);
|
|
276
|
+
const [num, den] = (vStream.r_frame_rate || '0/1').split('/').map(Number);
|
|
277
|
+
const actualFps = den ? num / den : num;
|
|
278
|
+
if (Math.abs(actualFps - expectedFps) > 0.5) {
|
|
279
|
+
errors.push(`FPS mismatch: expected ~${expectedFps}, got ${actualFps.toFixed(2)} (${vStream.r_frame_rate})`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const expectedDuration = computeExpectedDuration(plan);
|
|
284
|
+
if (expectedDuration > 0) {
|
|
285
|
+
const tolerance = 0.35;
|
|
286
|
+
if (Math.abs(duration - expectedDuration) > tolerance) {
|
|
287
|
+
errors.push(`Duration mismatch: expected ${expectedDuration.toFixed(2)}s (±${tolerance}s), got ${duration.toFixed(2)}s`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
planDetails = {
|
|
292
|
+
plan_file: path.basename(resolvedPlan),
|
|
293
|
+
expected_duration_s: expectedDuration,
|
|
294
|
+
expected_width: plan.output?.width,
|
|
295
|
+
expected_height: plan.output?.height,
|
|
296
|
+
expected_fps: plan.output?.fps_num ? plan.output.fps_num / (plan.output.fps_den || 1) : null,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
artifact: path.basename(artifactPath),
|
|
302
|
+
path: artifactPath,
|
|
303
|
+
passed: errors.length === 0,
|
|
304
|
+
duration_s: duration,
|
|
305
|
+
streams: {
|
|
306
|
+
video: vStream ? {
|
|
307
|
+
codec: vStream.codec_name,
|
|
308
|
+
width: vStream.width,
|
|
309
|
+
height: vStream.height,
|
|
310
|
+
fps: vStream.r_frame_rate,
|
|
311
|
+
} : null,
|
|
312
|
+
audio: aStream ? {
|
|
313
|
+
codec: aStream.codec_name,
|
|
314
|
+
channels: aStream.channels,
|
|
315
|
+
sample_rate: aStream.sample_rate,
|
|
316
|
+
} : null,
|
|
317
|
+
},
|
|
318
|
+
plan_comparison: planDetails,
|
|
319
|
+
errors,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function runFfmpeg(args, { verbose = false, artifactPath = null } = {}) {
|
|
324
|
+
return new Promise((resolve) => {
|
|
325
|
+
const ffmpegArgs = ['-progress', 'pipe:1', ...args];
|
|
326
|
+
const child = spawn('ffmpeg', ffmpegArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
327
|
+
|
|
328
|
+
let stderr = '';
|
|
329
|
+
let stdout = '';
|
|
330
|
+
let killed = false;
|
|
331
|
+
|
|
332
|
+
const onSigint = () => {
|
|
333
|
+
killed = true;
|
|
334
|
+
if (process.platform === 'win32' && child.pid) {
|
|
335
|
+
try {
|
|
336
|
+
execFileSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
337
|
+
} catch {
|
|
338
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
339
|
+
}
|
|
340
|
+
} else {
|
|
341
|
+
try { child.kill('SIGINT'); } catch {}
|
|
342
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
343
|
+
}
|
|
344
|
+
for (let i = 0; i < 50; i++) {
|
|
345
|
+
if (artifactPath && fs.existsSync(artifactPath)) {
|
|
346
|
+
try {
|
|
347
|
+
fs.unlinkSync(artifactPath);
|
|
348
|
+
break;
|
|
349
|
+
} catch {
|
|
350
|
+
const start = Date.now();
|
|
351
|
+
while (Date.now() - start < 25) {}
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
process.removeListener('SIGINT', onSigint);
|
|
358
|
+
process.exit(130);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
process.on('SIGINT', onSigint);
|
|
362
|
+
|
|
363
|
+
child.stdout.on('data', (chunk) => {
|
|
364
|
+
stdout += chunk;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
child.stderr.on('data', (chunk) => {
|
|
368
|
+
stderr += chunk;
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
child.on('close', (code, signal) => {
|
|
372
|
+
process.removeListener('SIGINT', onSigint);
|
|
373
|
+
|
|
374
|
+
if (killed || signal === 'SIGINT') {
|
|
375
|
+
if (artifactPath && fs.existsSync(artifactPath)) {
|
|
376
|
+
try { fs.unlinkSync(artifactPath); } catch {}
|
|
377
|
+
}
|
|
378
|
+
process.exit(130);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (code !== 0) {
|
|
382
|
+
if (artifactPath && fs.existsSync(artifactPath)) {
|
|
383
|
+
try { fs.unlinkSync(artifactPath); } catch {}
|
|
384
|
+
}
|
|
385
|
+
if (verbose) {
|
|
386
|
+
console.error(`Error: ffmpeg execution failed (exit ${code}):\n${stderr}`);
|
|
387
|
+
} else {
|
|
388
|
+
const lines = stderr.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
389
|
+
const tail = lines.slice(-10).join('\n');
|
|
390
|
+
console.error(`Error: ffmpeg rendering failed:\n${tail || stderr}`);
|
|
391
|
+
}
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
resolve({ code, stdout, stderr });
|
|
396
|
+
});
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ponytail: synchronous child_process calls; upgrade to worker pool when batch rendering >10 parallel plans
|
|
401
|
+
|
|
402
|
+
export function detectSilence(filePath, { noiseThreshold = '-30dB', minDurationSec = 0.8 } = {}) {
|
|
403
|
+
if (!checkBinary('ffmpeg')) {
|
|
404
|
+
throw new Error('ffmpeg is not installed or not found on PATH. Install ffmpeg to use video studio features.');
|
|
405
|
+
}
|
|
406
|
+
const args = [
|
|
407
|
+
'-i', filePath,
|
|
408
|
+
'-af', `silencedetect=noise=${noiseThreshold}:d=${minDurationSec}`,
|
|
409
|
+
'-f', 'null',
|
|
410
|
+
'-'
|
|
411
|
+
];
|
|
412
|
+
const res = spawnSync('ffmpeg', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
413
|
+
const stderr = res.stderr || '';
|
|
414
|
+
|
|
415
|
+
const silences = [];
|
|
416
|
+
const lines = stderr.split(/\r?\n/);
|
|
417
|
+
let currentStart = null;
|
|
418
|
+
for (const line of lines) {
|
|
419
|
+
const sMatch = line.match(/silence_start:\s*([0-9.]+)/);
|
|
420
|
+
if (sMatch) {
|
|
421
|
+
currentStart = parseFloat(sMatch[1]);
|
|
422
|
+
}
|
|
423
|
+
const eMatch = line.match(/silence_end:\s*([0-9.]+)\s*\|\s*silence_duration:\s*([0-9.]+)/);
|
|
424
|
+
if (eMatch && currentStart !== null) {
|
|
425
|
+
const end = parseFloat(eMatch[1]);
|
|
426
|
+
const duration = parseFloat(eMatch[2]);
|
|
427
|
+
silences.push({ start: currentStart, end, duration });
|
|
428
|
+
currentStart = null;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return silences;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export function computeSpeechIntervals(totalDurationSec, silences = [], { minSpeechSec = 0.3 } = {}) {
|
|
435
|
+
const speechIntervals = [];
|
|
436
|
+
let cursor = 0.0;
|
|
437
|
+
|
|
438
|
+
for (const silence of silences) {
|
|
439
|
+
if (silence.start > cursor) {
|
|
440
|
+
const segDuration = silence.start - cursor;
|
|
441
|
+
if (segDuration >= minSpeechSec) {
|
|
442
|
+
speechIntervals.push({
|
|
443
|
+
startSec: parseFloat(cursor.toFixed(3)),
|
|
444
|
+
endSec: parseFloat(silence.start.toFixed(3)),
|
|
445
|
+
durationSec: parseFloat(segDuration.toFixed(3)),
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
cursor = Math.max(cursor, silence.end);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (cursor < totalDurationSec) {
|
|
453
|
+
const remaining = totalDurationSec - cursor;
|
|
454
|
+
if (remaining >= minSpeechSec) {
|
|
455
|
+
speechIntervals.push({
|
|
456
|
+
startSec: parseFloat(cursor.toFixed(3)),
|
|
457
|
+
endSec: parseFloat(totalDurationSec.toFixed(3)),
|
|
458
|
+
durationSec: parseFloat(remaining.toFixed(3)),
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (speechIntervals.length === 0 && totalDurationSec >= minSpeechSec) {
|
|
464
|
+
speechIntervals.push({
|
|
465
|
+
startSec: 0,
|
|
466
|
+
endSec: parseFloat(totalDurationSec.toFixed(3)),
|
|
467
|
+
durationSec: parseFloat(totalDurationSec.toFixed(3)),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return speechIntervals;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function parseSrt(srtContent) {
|
|
475
|
+
const items = [];
|
|
476
|
+
const blocks = srtContent.trim().split(/\r?\n\r?\n/);
|
|
477
|
+
for (const block of blocks) {
|
|
478
|
+
const lines = block.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
479
|
+
const timeLine = lines.find((l) => l.includes('-->'));
|
|
480
|
+
if (!timeLine) continue;
|
|
481
|
+
const [startStr, endStr] = timeLine.split('-->').map((s) => s.trim());
|
|
482
|
+
const parseTime = (t) => {
|
|
483
|
+
const parts = t.replace(',', '.').split(':');
|
|
484
|
+
if (parts.length === 3) {
|
|
485
|
+
return parseFloat(parts[0]) * 3600 + parseFloat(parts[1]) * 60 + parseFloat(parts[2]);
|
|
486
|
+
}
|
|
487
|
+
return 0;
|
|
488
|
+
};
|
|
489
|
+
const startSec = parseTime(startStr);
|
|
490
|
+
const endSec = parseTime(endStr);
|
|
491
|
+
const textIdx = lines.indexOf(timeLine);
|
|
492
|
+
const text = lines.slice(textIdx + 1).join(' ').trim();
|
|
493
|
+
if (text) {
|
|
494
|
+
const words = text.split(/\s+/);
|
|
495
|
+
const durPerWord = (endSec - startSec) / words.length;
|
|
496
|
+
words.forEach((w, idx) => {
|
|
497
|
+
items.push({
|
|
498
|
+
word: w,
|
|
499
|
+
start: parseFloat((startSec + idx * durPerWord).toFixed(3)),
|
|
500
|
+
end: parseFloat((startSec + (idx + 1) * durPerWord).toFixed(3)),
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return items;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function groupWordsIntoCaptions(words = [], { maxWords = 3, captionStyle = 'impact' } = {}) {
|
|
509
|
+
if (!words || words.length === 0) return [];
|
|
510
|
+
|
|
511
|
+
if (captionStyle === 'word') {
|
|
512
|
+
return words.map((w) => ({
|
|
513
|
+
text: (w.word || w.text || '').trim().toUpperCase(),
|
|
514
|
+
startSec: w.start,
|
|
515
|
+
endSec: w.end,
|
|
516
|
+
durationMs: Math.max(150, Math.round((w.end - w.start) * 1000)),
|
|
517
|
+
style: captionStyle,
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
if (captionStyle === 'full') {
|
|
522
|
+
const captions = [];
|
|
523
|
+
let currentWords = [];
|
|
524
|
+
let startSec = null;
|
|
525
|
+
let endSec = null;
|
|
526
|
+
for (const w of words) {
|
|
527
|
+
const text = (w.word || w.text || '').trim();
|
|
528
|
+
if (!text) continue;
|
|
529
|
+
if (startSec === null) startSec = w.start;
|
|
530
|
+
if (endSec !== null && w.start - endSec > 0.5) {
|
|
531
|
+
captions.push({
|
|
532
|
+
text: currentWords.join(' '),
|
|
533
|
+
startSec,
|
|
534
|
+
endSec,
|
|
535
|
+
durationMs: Math.max(200, Math.round((endSec - startSec) * 1000)),
|
|
536
|
+
style: captionStyle,
|
|
537
|
+
});
|
|
538
|
+
currentWords = [];
|
|
539
|
+
startSec = w.start;
|
|
540
|
+
}
|
|
541
|
+
currentWords.push(text);
|
|
542
|
+
endSec = w.end;
|
|
543
|
+
}
|
|
544
|
+
if (currentWords.length > 0 && startSec !== null) {
|
|
545
|
+
captions.push({
|
|
546
|
+
text: currentWords.join(' '),
|
|
547
|
+
startSec,
|
|
548
|
+
endSec,
|
|
549
|
+
durationMs: Math.max(200, Math.round((endSec - startSec) * 1000)),
|
|
550
|
+
style: captionStyle,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
return captions;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Default: Style A ('impact') - 2 to 4 words per chunk in uppercase
|
|
557
|
+
const captions = [];
|
|
558
|
+
let currentWords = [];
|
|
559
|
+
let startSec = null;
|
|
560
|
+
let endSec = null;
|
|
561
|
+
|
|
562
|
+
for (const w of words) {
|
|
563
|
+
const text = (w.word || w.text || '').trim();
|
|
564
|
+
if (!text) continue;
|
|
565
|
+
if (startSec === null) startSec = w.start;
|
|
566
|
+
currentWords.push(text);
|
|
567
|
+
endSec = w.end;
|
|
568
|
+
|
|
569
|
+
if (currentWords.length >= maxWords) {
|
|
570
|
+
captions.push({
|
|
571
|
+
text: currentWords.join(' ').toUpperCase(),
|
|
572
|
+
startSec,
|
|
573
|
+
endSec,
|
|
574
|
+
durationMs: Math.max(200, Math.round((endSec - startSec) * 1000)),
|
|
575
|
+
style: 'impact',
|
|
576
|
+
});
|
|
577
|
+
currentWords = [];
|
|
578
|
+
startSec = null;
|
|
579
|
+
endSec = null;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (currentWords.length > 0 && startSec !== null) {
|
|
584
|
+
captions.push({
|
|
585
|
+
text: currentWords.join(' ').toUpperCase(),
|
|
586
|
+
startSec,
|
|
587
|
+
endSec,
|
|
588
|
+
durationMs: Math.max(200, Math.round((endSec - startSec) * 1000)),
|
|
589
|
+
style: 'impact',
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return captions;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export function remapCaptionsToSpeechTimeline(rawCaptions = [], speechIntervals = []) {
|
|
597
|
+
const remapped = [];
|
|
598
|
+
let timelineOffset = 0.0;
|
|
599
|
+
|
|
600
|
+
for (const seg of speechIntervals) {
|
|
601
|
+
const segStart = seg.startSec;
|
|
602
|
+
const segEnd = seg.endSec;
|
|
603
|
+
const segDur = seg.durationSec;
|
|
604
|
+
|
|
605
|
+
for (const cap of rawCaptions) {
|
|
606
|
+
if (cap.endSec <= segStart || cap.startSec >= segEnd) {
|
|
607
|
+
continue;
|
|
608
|
+
}
|
|
609
|
+
const clampedStart = Math.max(cap.startSec, segStart);
|
|
610
|
+
const clampedEnd = Math.min(cap.endSec, segEnd);
|
|
611
|
+
const startInSeg = clampedStart - segStart;
|
|
612
|
+
const capDur = clampedEnd - clampedStart;
|
|
613
|
+
|
|
614
|
+
if (capDur > 0.05) {
|
|
615
|
+
remapped.push({
|
|
616
|
+
text: cap.text,
|
|
617
|
+
timeline_start_ms: Math.round((timelineOffset + startInSeg) * 1000),
|
|
618
|
+
duration_ms: Math.round(capDur * 1000),
|
|
619
|
+
style: cap.style || 'impact',
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
timelineOffset += segDur;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return remapped;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
export function resolveCaptionSource(flags = {}) {
|
|
630
|
+
if (flags['no-captions']) return { mode: 'none' };
|
|
631
|
+
if (flags.captions) return { mode: 'srt', srtPath: path.resolve(flags.captions) };
|
|
632
|
+
if (flags.local) return { mode: 'local' };
|
|
633
|
+
if (flags.openai) return { mode: 'openai', apiKey: process.env.OPENAI_API_KEY };
|
|
634
|
+
if (process.env.OPENAI_API_KEY) return { mode: 'openai', apiKey: process.env.OPENAI_API_KEY };
|
|
635
|
+
return { mode: 'missing' };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export async function hasLocalTranscriber() {
|
|
639
|
+
try {
|
|
640
|
+
await import('@xenova/transformers');
|
|
641
|
+
return true;
|
|
642
|
+
} catch {
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
export async function assertLocalTranscriber() {
|
|
648
|
+
if (!(await hasLocalTranscriber())) {
|
|
649
|
+
throw new Error("Transcrição local indisponível: instale '@xenova/transformers' (npm install -g @xenova/transformers) ou use --captions/--openai.");
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export async function transcribeAudio(audioPath, { apiKey, useOpenai, srtPath, requireLocal = false } = {}) {
|
|
654
|
+
if (srtPath && fs.existsSync(srtPath)) {
|
|
655
|
+
const content = fs.readFileSync(srtPath, 'utf8');
|
|
656
|
+
return parseSrt(content);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (useOpenai && apiKey) {
|
|
660
|
+
const fileBuf = fs.readFileSync(audioPath);
|
|
661
|
+
const blob = new Blob([fileBuf], { type: 'audio/mp3' });
|
|
662
|
+
const formData = new FormData();
|
|
663
|
+
formData.append('file', blob, path.basename(audioPath));
|
|
664
|
+
formData.append('model', 'whisper-1');
|
|
665
|
+
formData.append('response_format', 'verbose_json');
|
|
666
|
+
formData.append('timestamp_granularities[]', 'word');
|
|
667
|
+
|
|
668
|
+
const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
669
|
+
method: 'POST',
|
|
670
|
+
headers: {
|
|
671
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
672
|
+
},
|
|
673
|
+
body: formData,
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
if (!res.ok) {
|
|
677
|
+
const errText = await res.text();
|
|
678
|
+
throw new Error(`OpenAI Whisper API error (${res.status}): ${errText}`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const data = await res.json();
|
|
682
|
+
if (data.words && Array.isArray(data.words)) {
|
|
683
|
+
return data.words.map((w) => ({
|
|
684
|
+
word: w.word,
|
|
685
|
+
start: w.start,
|
|
686
|
+
end: w.end,
|
|
687
|
+
}));
|
|
688
|
+
}
|
|
689
|
+
if (data.text) {
|
|
690
|
+
return [{ word: data.text, start: 0, end: data.duration || 1 }];
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
try {
|
|
695
|
+
const mod = await import('@xenova/transformers');
|
|
696
|
+
const pipeline = mod.pipeline;
|
|
697
|
+
const transcriber = await pipeline('automatic-speech-recognition', 'Xenova/whisper-tiny');
|
|
698
|
+
const result = await transcriber(audioPath, { return_timestamps: 'word' });
|
|
699
|
+
if (result.chunks && Array.isArray(result.chunks)) {
|
|
700
|
+
return result.chunks.map((c) => ({
|
|
701
|
+
word: c.text,
|
|
702
|
+
start: c.timestamp[0],
|
|
703
|
+
end: c.timestamp[1] || c.timestamp[0] + 0.5,
|
|
704
|
+
}));
|
|
705
|
+
}
|
|
706
|
+
} catch (err) {
|
|
707
|
+
if (requireLocal) {
|
|
708
|
+
throw new Error("Transcrição local indisponível: instale '@xenova/transformers' ou use --captions/--openai.");
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (requireLocal) {
|
|
713
|
+
throw new Error("Transcrição local indisponível: instale '@xenova/transformers' ou use --captions/--openai.");
|
|
714
|
+
}
|
|
715
|
+
return [];
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export const HOOK_PATTERNS = [
|
|
719
|
+
/\b(o (?:maior )?(?:erro|segredo|problema)|a verdade|ninguem te conta|pouca gente sabe)\b/i,
|
|
720
|
+
/\b(por que|porque|como voce|se voce quer|quando voce|nunca faca|pare de|cuidado com)\b/i,
|
|
721
|
+
/\b(voce sabia|presta atencao|olha isso|o que acontece|o resultado)\b/i,
|
|
722
|
+
/\b(primeiro|segundo|passo|dica|estrategia|metodo)\b/i,
|
|
723
|
+
];
|
|
724
|
+
|
|
725
|
+
export function scoreHookSentence(text = '') {
|
|
726
|
+
let score = 0.0;
|
|
727
|
+
const normalized = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
728
|
+
for (const pat of HOOK_PATTERNS) {
|
|
729
|
+
if (pat.test(normalized)) score += 2.5;
|
|
730
|
+
}
|
|
731
|
+
if (text.includes('?')) score += 1.5;
|
|
732
|
+
if (text.includes('!')) score += 1.0;
|
|
733
|
+
return score;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export function findCandidateClips(segments = [], { minDurationSec = 30, maxDurationSec = 60 } = {}) {
|
|
737
|
+
const candidates = [];
|
|
738
|
+
for (let i = 0; i < segments.length; i++) {
|
|
739
|
+
const seg = segments[i];
|
|
740
|
+
const hookScore = scoreHookSentence(seg.text);
|
|
741
|
+
if (hookScore < 1.0 && i > 0 && segments.length > 10) continue;
|
|
742
|
+
|
|
743
|
+
const currText = [seg.text];
|
|
744
|
+
const startSec = seg.start_s !== undefined ? seg.start_s : seg.start;
|
|
745
|
+
for (let j = i + 1; j < segments.length; j++) {
|
|
746
|
+
currText.push(segments[j].text);
|
|
747
|
+
const endSec = segments[j].end_s !== undefined ? segments[j].end_s : segments[j].end;
|
|
748
|
+
const dur = endSec - startSec;
|
|
749
|
+
if (dur >= minDurationSec && dur <= maxDurationSec) {
|
|
750
|
+
candidates.push({
|
|
751
|
+
hook_id: seg.id || i + 1,
|
|
752
|
+
start_s: parseFloat(startSec.toFixed(2)),
|
|
753
|
+
end_s: parseFloat(endSec.toFixed(2)),
|
|
754
|
+
duration_s: parseFloat(dur.toFixed(2)),
|
|
755
|
+
hook_score: hookScore,
|
|
756
|
+
hook_text: seg.text,
|
|
757
|
+
punchline_text: segments[j].text,
|
|
758
|
+
full_text: currText.join(' '),
|
|
759
|
+
});
|
|
760
|
+
} else if (dur > maxDurationSec) {
|
|
761
|
+
break;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
candidates.sort((a, b) => {
|
|
767
|
+
if (b.hook_score !== a.hook_score) return b.hook_score - a.hook_score;
|
|
768
|
+
return Math.abs(a.duration_s - 45.0) - Math.abs(b.duration_s - 45.0);
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
return candidates;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
export function buildAutoClipPlan({ asset, speechIntervals, captions = [], format = '9:16', overlays = [], musicTracks = [] }) {
|
|
775
|
+
const isWidescreen = format === '16:9';
|
|
776
|
+
const width = isWidescreen ? 1920 : 1080;
|
|
777
|
+
const height = isWidescreen ? 1080 : 1920;
|
|
778
|
+
|
|
779
|
+
const clips = [];
|
|
780
|
+
const audioTracks = [];
|
|
781
|
+
let timelineCursorMs = 0;
|
|
782
|
+
|
|
783
|
+
for (let i = 0; i < speechIntervals.length; i++) {
|
|
784
|
+
const seg = speechIntervals[i];
|
|
785
|
+
const clipId = `speech-${String(i + 1).padStart(2, '0')}`;
|
|
786
|
+
const inMs = Math.round(seg.startSec * 1000);
|
|
787
|
+
const outMs = Math.round(seg.endSec * 1000);
|
|
788
|
+
const clipDurMs = outMs - inMs;
|
|
789
|
+
|
|
790
|
+
clips.push({
|
|
791
|
+
kind: 'video',
|
|
792
|
+
clip_id: clipId,
|
|
793
|
+
asset_id: asset.id,
|
|
794
|
+
in_ms: inMs,
|
|
795
|
+
out_ms: outMs,
|
|
796
|
+
timeline_start_ms: timelineCursorMs,
|
|
797
|
+
speed: 1,
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
audioTracks.push({
|
|
801
|
+
kind: 'source',
|
|
802
|
+
clip_id: clipId,
|
|
803
|
+
gain_db: 0,
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
timelineCursorMs += clipDurMs;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const plan = {
|
|
810
|
+
schema_version: 1,
|
|
811
|
+
mode: 'podcast_clip',
|
|
812
|
+
clips,
|
|
813
|
+
audio_tracks: audioTracks,
|
|
814
|
+
captions,
|
|
815
|
+
output: {
|
|
816
|
+
width,
|
|
817
|
+
height,
|
|
818
|
+
fps_num: 30,
|
|
819
|
+
fps_den: 1,
|
|
820
|
+
video_codec: 'h264',
|
|
821
|
+
audio_codec: 'aac',
|
|
822
|
+
},
|
|
823
|
+
};
|
|
824
|
+
if (overlays.length > 0) plan.overlays = overlays;
|
|
825
|
+
if (musicTracks.length > 0) plan.music_tracks = musicTracks;
|
|
826
|
+
return plan;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export async function run({ positionals = [], flags = {} } = {}) {
|
|
830
|
+
const sub = positionals[0];
|
|
831
|
+
|
|
832
|
+
if (!sub || flags.help || sub === 'help') {
|
|
833
|
+
console.log(`Esquadrão Studio
|
|
834
|
+
|
|
835
|
+
Usage:
|
|
836
|
+
esquadrao studio <subcommand> [arguments] [options]
|
|
837
|
+
|
|
838
|
+
Subcommands:
|
|
839
|
+
init [--dest <path>] Create studio workspace dirs (assets, artifacts, packages) and manifest
|
|
840
|
+
import <file> Register source footage into assets/ with AST-xxxxxxxx id and ffprobe metadata
|
|
841
|
+
detect-silence <video> Detect silent intervals and compute active speech segments
|
|
842
|
+
auto-clip <video> Full pipeline: detect silence, cut pauses, transcribe, and render
|
|
843
|
+
transcribe <video> Extract audio and export structured transcript.json with timestamps
|
|
844
|
+
find-hooks <file> Find top viral hook segments (30-60s) from transcript or video
|
|
845
|
+
render <editplan.json> Validate EditPlan contract and execute cuts/captions via ffmpeg
|
|
846
|
+
qa <artifact.mp4> Run automated ffprobe QA verification on rendered video artifact
|
|
847
|
+
cutout <video> Recorta a pessoa e compõe sobre um pack (requer Python/rembg)
|
|
848
|
+
caption-templates [--json] Lista os templates de legenda disponíveis
|
|
849
|
+
|
|
850
|
+
Options:
|
|
851
|
+
--dest <path> Custom studio workspace destination directory
|
|
852
|
+
--silence-thresh <dB> Sensibilidade de silêncio (padrão: -30dB)
|
|
853
|
+
--silence-duration <sec> Pausa mínima para corte de silêncio (padrão: 0.8s)
|
|
854
|
+
--format <9:16|16:9> Aspect ratio do corte final (padrão: 9:16)
|
|
855
|
+
--caption-style <style> Estilo de legendas: impact (padrão), word, full
|
|
856
|
+
--openai Forçar uso da API OpenAI Whisper
|
|
857
|
+
--local Forçar transcrição Whisper local
|
|
858
|
+
--captions <file.srt> Arquivo de legendas externo
|
|
859
|
+
--caption-template <id> Template de legenda (ver caption-templates)
|
|
860
|
+
--no-captions Gerar corte sem legendas (pula transcrição)
|
|
861
|
+
--broll <file> B-roll/cena de apoio (pode ser repetido)
|
|
862
|
+
--music <file.mp3> Música de fundo
|
|
863
|
+
--layout <layout> Layout do B-roll: fullscreen, pip ou split_bottom
|
|
864
|
+
--overlay-duration <mode> inserts (padrão, 2-4s) ou full (todo o clipe)
|
|
865
|
+
--background <file> Pack de fundo do cutout
|
|
866
|
+
--engine <rembg> Motor de recorte (padrão: rembg)
|
|
867
|
+
--fps <n> FPS do cutout (padrão: 15)
|
|
868
|
+
--python <path> Interpretador Python do motor de recorte
|
|
869
|
+
--json Output results in JSON format
|
|
870
|
+
-h, --help Show help message`);
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (sub === 'caption-templates') {
|
|
875
|
+
const templates = listCaptionTemplates();
|
|
876
|
+
const report = { version: 1, reference_canvas: { width: 640, height: 360 }, templates };
|
|
877
|
+
if (flags.json) {
|
|
878
|
+
console.log(JSON.stringify(report, null, 2));
|
|
879
|
+
return report;
|
|
880
|
+
}
|
|
881
|
+
console.log('Templates de legenda disponíveis:');
|
|
882
|
+
for (const template of templates) {
|
|
883
|
+
console.log(`- ${template.id} (${template.name}) — ${template.font}, ${template.font_size}px`);
|
|
884
|
+
}
|
|
885
|
+
return report;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (sub === 'detect-silence') {
|
|
889
|
+
const srcFile = positionals[1];
|
|
890
|
+
if (!srcFile) {
|
|
891
|
+
console.error('Error: Missing file. Usage: esquadrao studio detect-silence <video>');
|
|
892
|
+
process.exit(1);
|
|
893
|
+
}
|
|
894
|
+
const resolvedSrc = path.resolve(srcFile);
|
|
895
|
+
if (!fs.existsSync(resolvedSrc)) {
|
|
896
|
+
console.error(`Error: File not found: ${srcFile}`);
|
|
897
|
+
process.exit(1);
|
|
898
|
+
}
|
|
899
|
+
const thresh = flags['silence-thresh'] || '-30dB';
|
|
900
|
+
const dur = parseFloat(flags['silence-duration'] || '0.8');
|
|
901
|
+
const silences = detectSilence(resolvedSrc, { noiseThreshold: thresh, minDurationSec: dur });
|
|
902
|
+
const probe = probeMedia(resolvedSrc);
|
|
903
|
+
const totalSec = parseFloat(probe.format?.duration || '0');
|
|
904
|
+
const speech = computeSpeechIntervals(totalSec, silences);
|
|
905
|
+
const totalSilence = silences.reduce((acc, s) => acc + s.duration, 0);
|
|
906
|
+
|
|
907
|
+
const report = {
|
|
908
|
+
file: path.basename(resolvedSrc),
|
|
909
|
+
total_duration_s: totalSec,
|
|
910
|
+
silences_count: silences.length,
|
|
911
|
+
total_silence_s: parseFloat(totalSilence.toFixed(3)),
|
|
912
|
+
speech_segments_count: speech.length,
|
|
913
|
+
speech_duration_s: parseFloat((totalSec - totalSilence).toFixed(3)),
|
|
914
|
+
silences,
|
|
915
|
+
speech_segments: speech,
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
if (flags.json) {
|
|
919
|
+
console.log(JSON.stringify(report, null, 2));
|
|
920
|
+
return report;
|
|
921
|
+
}
|
|
922
|
+
console.log(`Silence detection report for ${report.file}:`);
|
|
923
|
+
console.log(`- Total Duration: ${report.total_duration_s.toFixed(2)}s`);
|
|
924
|
+
console.log(`- Silences Detected: ${report.silences_count} (${report.total_silence_s.toFixed(2)}s removed)`);
|
|
925
|
+
console.log(`- Speech Segments: ${report.speech_segments_count} (${report.speech_duration_s.toFixed(2)}s active)`);
|
|
926
|
+
return report;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (sub === 'transcribe') {
|
|
930
|
+
const srcFile = positionals[1];
|
|
931
|
+
if (!srcFile) {
|
|
932
|
+
console.error('Error: Missing video file. Usage: esquadrao studio transcribe <video.mp4> [options]');
|
|
933
|
+
process.exit(1);
|
|
934
|
+
}
|
|
935
|
+
const resolvedSrc = path.resolve(srcFile);
|
|
936
|
+
if (!fs.existsSync(resolvedSrc)) {
|
|
937
|
+
console.error(`Error: Source file not found: ${srcFile}`);
|
|
938
|
+
process.exit(1);
|
|
939
|
+
}
|
|
940
|
+
if (!checkBinary('ffmpeg') || !checkBinary('ffprobe')) {
|
|
941
|
+
console.error('Error: ffmpeg/ffprobe not found. Install ffmpeg to use video studio features.');
|
|
942
|
+
process.exit(1);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const wsDir = findWorkspaceDir(flags.dest);
|
|
946
|
+
fs.mkdirSync(path.join(wsDir, 'packages'), { recursive: true });
|
|
947
|
+
|
|
948
|
+
const probe = probeMedia(resolvedSrc);
|
|
949
|
+
const duration = parseFloat(probe.format?.duration || '0');
|
|
950
|
+
|
|
951
|
+
const captionSource = resolveCaptionSource(flags);
|
|
952
|
+
if (captionSource.mode === 'missing' || captionSource.mode === 'none') {
|
|
953
|
+
throw new Error('Transcrição requer --captions <arquivo.srt>, --openai com OPENAI_API_KEY ou --local (requer @xenova/transformers).');
|
|
954
|
+
}
|
|
955
|
+
if (captionSource.mode === 'local') await assertLocalTranscriber();
|
|
956
|
+
if (captionSource.mode === 'openai' && !captionSource.apiKey) {
|
|
957
|
+
throw new Error('OPENAI_API_KEY não configurada para usar --openai.');
|
|
958
|
+
}
|
|
959
|
+
if (captionSource.mode === 'srt' && !fs.existsSync(captionSource.srtPath)) {
|
|
960
|
+
throw new Error(`Arquivo de legendas não encontrado: ${captionSource.srtPath}`);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
let words = [];
|
|
964
|
+
if (captionSource.mode === 'srt') {
|
|
965
|
+
words = await transcribeAudio(null, { srtPath: captionSource.srtPath });
|
|
966
|
+
} else {
|
|
967
|
+
const tmpAudio = path.join(os.tmpdir(), `usk-transcribe-${crypto.randomBytes(4).toString('hex')}.mp3`);
|
|
968
|
+
try {
|
|
969
|
+
execFileSync('ffmpeg', ['-y', '-i', resolvedSrc, '-vn', '-ar', '16000', '-ac', '1', '-b:a', '64k', tmpAudio], { stdio: 'ignore' });
|
|
970
|
+
words = await transcribeAudio(tmpAudio, {
|
|
971
|
+
apiKey: captionSource.apiKey,
|
|
972
|
+
useOpenai: captionSource.mode === 'openai',
|
|
973
|
+
srtPath: null,
|
|
974
|
+
requireLocal: captionSource.mode === 'local',
|
|
975
|
+
});
|
|
976
|
+
} finally {
|
|
977
|
+
if (fs.existsSync(tmpAudio)) fs.unlinkSync(tmpAudio);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const segments = [];
|
|
982
|
+
let segId = 1;
|
|
983
|
+
let currentWords = [];
|
|
984
|
+
let segStart = null;
|
|
985
|
+
let segEnd = null;
|
|
986
|
+
|
|
987
|
+
for (const w of words) {
|
|
988
|
+
const text = (w.word || w.text || '').trim();
|
|
989
|
+
if (!text) continue;
|
|
990
|
+
if (segStart === null) segStart = w.start;
|
|
991
|
+
if (segEnd !== null && (w.start - segEnd > 1.0 || segEnd - segStart > 7.0)) {
|
|
992
|
+
segments.push({
|
|
993
|
+
id: segId++,
|
|
994
|
+
start_s: parseFloat(segStart.toFixed(2)),
|
|
995
|
+
end_s: parseFloat(segEnd.toFixed(2)),
|
|
996
|
+
duration_s: parseFloat((segEnd - segStart).toFixed(2)),
|
|
997
|
+
text: currentWords.join(' '),
|
|
998
|
+
});
|
|
999
|
+
currentWords = [];
|
|
1000
|
+
segStart = w.start;
|
|
1001
|
+
}
|
|
1002
|
+
currentWords.push(text);
|
|
1003
|
+
segEnd = w.end;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
if (currentWords.length > 0 && segStart !== null) {
|
|
1007
|
+
segments.push({
|
|
1008
|
+
id: segId++,
|
|
1009
|
+
start_s: parseFloat(segStart.toFixed(2)),
|
|
1010
|
+
end_s: parseFloat(segEnd.toFixed(2)),
|
|
1011
|
+
duration_s: parseFloat((segEnd - segStart).toFixed(2)),
|
|
1012
|
+
text: currentWords.join(' '),
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const fullText = segments.map((s) => s.text).join(' ');
|
|
1017
|
+
const transcriptReport = {
|
|
1018
|
+
file: path.basename(resolvedSrc),
|
|
1019
|
+
duration_s: duration,
|
|
1020
|
+
words_count: words.length,
|
|
1021
|
+
segments_count: segments.length,
|
|
1022
|
+
full_text: fullText,
|
|
1023
|
+
segments,
|
|
1024
|
+
words,
|
|
1025
|
+
};
|
|
1026
|
+
|
|
1027
|
+
const outJsonPath = path.join(wsDir, 'packages', 'transcript.json');
|
|
1028
|
+
fs.writeFileSync(outJsonPath, JSON.stringify(transcriptReport, null, 2) + '\n');
|
|
1029
|
+
|
|
1030
|
+
const outSrtPath = path.join(wsDir, 'packages', 'transcript.srt');
|
|
1031
|
+
const srtLines = segments.map((s) => {
|
|
1032
|
+
const fmtTime = (sec) => {
|
|
1033
|
+
const h = String(Math.floor(sec / 3600)).padStart(2, '0');
|
|
1034
|
+
const m = String(Math.floor((sec % 3600) / 60)).padStart(2, '0');
|
|
1035
|
+
const sRem = sec % 60;
|
|
1036
|
+
const sInt = String(Math.floor(sRem)).padStart(2, '0');
|
|
1037
|
+
const ms = String(Math.round((sRem - Math.floor(sRem)) * 1000)).padStart(3, '0');
|
|
1038
|
+
return `${h}:${m}:${sInt},${ms}`;
|
|
1039
|
+
};
|
|
1040
|
+
return `${s.id}\n${fmtTime(s.start_s)} --> ${fmtTime(s.end_s)}\n${s.text}\n`;
|
|
1041
|
+
}).join('\n');
|
|
1042
|
+
fs.writeFileSync(outSrtPath, srtLines);
|
|
1043
|
+
|
|
1044
|
+
if (flags.json) {
|
|
1045
|
+
console.log(JSON.stringify(transcriptReport, null, 2));
|
|
1046
|
+
return transcriptReport;
|
|
1047
|
+
}
|
|
1048
|
+
console.log(`Transcrição concluída para ${transcriptReport.file}:`);
|
|
1049
|
+
console.log(`- Duração Total: ${duration.toFixed(2)}s`);
|
|
1050
|
+
console.log(`- Palavras Reconhecidas: ${words.length}`);
|
|
1051
|
+
console.log(`- Segmentos para Análise de Copy: ${segments.length}`);
|
|
1052
|
+
console.log(`- Salvo em: packages/transcript.json e packages/transcript.srt`);
|
|
1053
|
+
return transcriptReport;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (sub === 'find-hooks') {
|
|
1057
|
+
const inputPath = positionals[1];
|
|
1058
|
+
if (!inputPath) {
|
|
1059
|
+
console.error('Error: Missing file. Usage: esquadrao studio find-hooks <video.mp4 | transcript.json> [options]');
|
|
1060
|
+
process.exit(1);
|
|
1061
|
+
}
|
|
1062
|
+
const resolvedInput = path.resolve(inputPath);
|
|
1063
|
+
if (!fs.existsSync(resolvedInput)) {
|
|
1064
|
+
console.error(`Error: File not found: ${inputPath}`);
|
|
1065
|
+
process.exit(1);
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
const wsDir = findWorkspaceDir(flags.dest);
|
|
1069
|
+
let segments = [];
|
|
1070
|
+
if (resolvedInput.endsWith('.json')) {
|
|
1071
|
+
const parsed = JSON.parse(fs.readFileSync(resolvedInput, 'utf8'));
|
|
1072
|
+
segments = parsed.segments || (Array.isArray(parsed) ? parsed : []);
|
|
1073
|
+
} else {
|
|
1074
|
+
const transRes = await run({ positionals: ['transcribe', resolvedInput], flags: { dest: wsDir, json: true } });
|
|
1075
|
+
segments = transRes.segments || [];
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const minDur = parseFloat(flags['min-dur'] || '30');
|
|
1079
|
+
const maxDur = parseFloat(flags['max-dur'] || '60');
|
|
1080
|
+
const candidates = findCandidateClips(segments, { minDurationSec: minDur, maxDurationSec: maxDur });
|
|
1081
|
+
|
|
1082
|
+
const report = {
|
|
1083
|
+
file: path.basename(resolvedInput),
|
|
1084
|
+
total_segments: segments.length,
|
|
1085
|
+
candidates_count: candidates.length,
|
|
1086
|
+
top_clips: candidates.slice(0, 5),
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
if (flags.json) {
|
|
1090
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1091
|
+
return report;
|
|
1092
|
+
}
|
|
1093
|
+
console.log(`Detecção de Ganchos de Copy para ${report.file}:`);
|
|
1094
|
+
console.log(`- Segmentos analisados: ${report.total_segments}`);
|
|
1095
|
+
console.log(`- Cortes virais identificados (${minDur}s - ${maxDur}s): ${report.candidates_count}`);
|
|
1096
|
+
candidates.slice(0, 3).forEach((c, idx) => {
|
|
1097
|
+
console.log(`\n[Opção ${idx + 1}] Duração: ${c.duration_s}s (De ${c.start_s}s a ${c.end_s}s) | Score: ${c.hook_score}`);
|
|
1098
|
+
console.log(` Gancho: "${c.hook_text}"`);
|
|
1099
|
+
console.log(` Punchline: "${c.punchline_text}"`);
|
|
1100
|
+
});
|
|
1101
|
+
return report;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
if (sub === 'cutout') {
|
|
1105
|
+
const srcFile = positionals[1];
|
|
1106
|
+
if (!srcFile) {
|
|
1107
|
+
console.error('Error: Missing video file. Usage: esquadrao studio cutout <video> --background <pack.mp4> [options]');
|
|
1108
|
+
process.exit(1);
|
|
1109
|
+
}
|
|
1110
|
+
if (!flags.background) {
|
|
1111
|
+
console.error('Error: Missing --background <pack.mp4>. O cutout precisa de um vídeo de fundo.');
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}
|
|
1114
|
+
const resolvedSrc = path.resolve(srcFile);
|
|
1115
|
+
const resolvedBg = path.resolve(flags.background);
|
|
1116
|
+
if (!fs.existsSync(resolvedSrc)) {
|
|
1117
|
+
console.error(`Error: Source video file not found: ${srcFile}`);
|
|
1118
|
+
process.exit(1);
|
|
1119
|
+
}
|
|
1120
|
+
if (!fs.existsSync(resolvedBg)) {
|
|
1121
|
+
console.error(`Error: Background file not found: ${flags.background}`);
|
|
1122
|
+
process.exit(1);
|
|
1123
|
+
}
|
|
1124
|
+
if (!checkBinary('ffmpeg') || !checkBinary('ffprobe')) {
|
|
1125
|
+
console.error('Error: ffmpeg/ffprobe not found. Install ffmpeg to use video studio features.');
|
|
1126
|
+
process.exit(1);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const engine = flags.engine || 'rembg';
|
|
1130
|
+
if (engine !== 'rembg') {
|
|
1131
|
+
console.error(`Error: Engine de cutout não suportado: ${engine}. Use rembg.`);
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
1134
|
+
const format = flags.format || '9:16';
|
|
1135
|
+
if (format !== '9:16') {
|
|
1136
|
+
console.error('Error: studio cutout suporta apenas --format 9:16 nesta versão.');
|
|
1137
|
+
process.exit(1);
|
|
1138
|
+
}
|
|
1139
|
+
const fps = parseFloat(flags.fps || '15');
|
|
1140
|
+
if (!Number.isFinite(fps) || fps <= 0 || fps > 30) {
|
|
1141
|
+
console.error('Error: --fps deve estar entre 1 e 30.');
|
|
1142
|
+
process.exit(1);
|
|
1143
|
+
}
|
|
1144
|
+
const python = flags.python || process.env.ESQUADRAO_PYTHON || 'python3';
|
|
1145
|
+
const deps = spawnSync(python, ['-c', 'import rembg, cv2, numpy, PIL'], { encoding: 'utf8' });
|
|
1146
|
+
if (deps.error || deps.status !== 0) {
|
|
1147
|
+
console.error(`Error: motor de recorte indisponível em '${python}'.`);
|
|
1148
|
+
console.error('Instale: python3 -m pip install "rembg[cpu]" onnxruntime opencv-python-headless numpy Pillow');
|
|
1149
|
+
process.exit(1);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
const wsDir = findWorkspaceDir(flags.dest);
|
|
1153
|
+
fs.mkdirSync(path.join(wsDir, 'artifacts'), { recursive: true });
|
|
1154
|
+
fs.mkdirSync(path.join(wsDir, 'packages'), { recursive: true });
|
|
1155
|
+
const artifactId = `ART-${crypto.randomBytes(4).toString('hex')}`;
|
|
1156
|
+
const artifactPath = path.join(wsDir, 'artifacts', `${artifactId}.mp4`);
|
|
1157
|
+
const scriptPath = fileURLToPath(new URL('../../scripts/cutout.py', import.meta.url));
|
|
1158
|
+
if (!fs.existsSync(scriptPath)) {
|
|
1159
|
+
console.error(`Error: Script de cutout não encontrado: ${scriptPath}`);
|
|
1160
|
+
process.exit(1);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const child = spawn(python, [
|
|
1164
|
+
scriptPath,
|
|
1165
|
+
'--input', resolvedSrc,
|
|
1166
|
+
'--background', resolvedBg,
|
|
1167
|
+
'--output', artifactPath,
|
|
1168
|
+
'--fps', String(fps),
|
|
1169
|
+
'--width', '1080',
|
|
1170
|
+
'--height', '1920',
|
|
1171
|
+
'--model', 'u2net_human_seg',
|
|
1172
|
+
], { stdio: ['ignore', 'pipe', 'inherit'] });
|
|
1173
|
+
|
|
1174
|
+
let stdout = '';
|
|
1175
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
1176
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
1177
|
+
child.on('error', reject);
|
|
1178
|
+
child.on('close', (code) => resolve(code));
|
|
1179
|
+
});
|
|
1180
|
+
if (exitCode !== 0) {
|
|
1181
|
+
if (fs.existsSync(artifactPath)) {
|
|
1182
|
+
try { fs.unlinkSync(artifactPath); } catch {}
|
|
1183
|
+
}
|
|
1184
|
+
console.error(`Error: cutout falhou (exit ${exitCode}).`);
|
|
1185
|
+
process.exit(1);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
let engineReport = null;
|
|
1189
|
+
const lines = stdout.split(/\r?\n/).filter(Boolean);
|
|
1190
|
+
if (lines.length > 0) {
|
|
1191
|
+
try { engineReport = JSON.parse(lines[lines.length - 1]); } catch {}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const artifactRecord = {
|
|
1195
|
+
id: artifactId,
|
|
1196
|
+
path: path.relative(wsDir, artifactPath).replace(/\\/g, '/'),
|
|
1197
|
+
engine: 'rembg',
|
|
1198
|
+
rendered_at: new Date().toISOString(),
|
|
1199
|
+
size_bytes: fs.existsSync(artifactPath) ? fs.statSync(artifactPath).size : 0,
|
|
1200
|
+
};
|
|
1201
|
+
const manifestPath = path.join(wsDir, 'studio.json');
|
|
1202
|
+
const manifest = loadManifest(manifestPath);
|
|
1203
|
+
manifest.artifacts = manifest.artifacts || [];
|
|
1204
|
+
manifest.artifacts.push(artifactRecord);
|
|
1205
|
+
saveManifestAtomic(manifestPath, manifest);
|
|
1206
|
+
|
|
1207
|
+
const qaReport = verifyArtifact(artifactPath);
|
|
1208
|
+
if (!qaReport.passed) {
|
|
1209
|
+
console.error(`Error: Post-render QA validation failed:\n${qaReport.errors.map((e) => ` - ${e}`).join('\n')}`);
|
|
1210
|
+
process.exit(1);
|
|
1211
|
+
}
|
|
1212
|
+
const result = {
|
|
1213
|
+
status: 'ok',
|
|
1214
|
+
engine: 'rembg',
|
|
1215
|
+
fps,
|
|
1216
|
+
artifact: artifactRecord,
|
|
1217
|
+
frames: engineReport?.frames ?? null,
|
|
1218
|
+
elapsed_s: engineReport?.elapsed_s ?? null,
|
|
1219
|
+
qa: qaReport,
|
|
1220
|
+
};
|
|
1221
|
+
if (flags.json) {
|
|
1222
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1223
|
+
return result;
|
|
1224
|
+
}
|
|
1225
|
+
console.log(`Cutout concluído: ${artifactRecord.path} (${artifactRecord.size_bytes} bytes)`);
|
|
1226
|
+
console.log(`- Frames: ${result.frames ?? 'n/d'}; tempo: ${result.elapsed_s ?? 'n/d'}s`);
|
|
1227
|
+
console.log(`- QA: ${qaReport.passed ? 'Aprovado ✓' : 'Falha ✗'}`);
|
|
1228
|
+
return result;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (sub === 'auto-clip') {
|
|
1232
|
+
const srcFile = positionals[1];
|
|
1233
|
+
if (!srcFile) {
|
|
1234
|
+
console.error('Error: Missing video file. Usage: esquadrao studio auto-clip <video.mp4> [options]');
|
|
1235
|
+
process.exit(1);
|
|
1236
|
+
}
|
|
1237
|
+
const resolvedSrc = path.resolve(srcFile);
|
|
1238
|
+
if (!fs.existsSync(resolvedSrc)) {
|
|
1239
|
+
console.error(`Error: Source video file not found: ${srcFile}`);
|
|
1240
|
+
process.exit(1);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
if (!checkBinary('ffmpeg') || !checkBinary('ffprobe')) {
|
|
1244
|
+
console.error('Error: ffmpeg/ffprobe not found. Install ffmpeg to use video studio features.');
|
|
1245
|
+
process.exit(1);
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (flags['caption-template']) getCaptionTemplate(flags['caption-template']);
|
|
1249
|
+
const captionSource = resolveCaptionSource(flags);
|
|
1250
|
+
if (captionSource.mode === 'missing') {
|
|
1251
|
+
throw new Error('Legendas não configuradas. Use --captions <arquivo.srt>, --openai com OPENAI_API_KEY, --local (requer @xenova/transformers) ou --no-captions para gerar apenas o corte.');
|
|
1252
|
+
}
|
|
1253
|
+
if (captionSource.mode === 'local') await assertLocalTranscriber();
|
|
1254
|
+
if (captionSource.mode === 'openai' && !captionSource.apiKey) {
|
|
1255
|
+
throw new Error('OPENAI_API_KEY não configurada para usar --openai.');
|
|
1256
|
+
}
|
|
1257
|
+
if (captionSource.mode === 'srt' && !fs.existsSync(captionSource.srtPath)) {
|
|
1258
|
+
throw new Error(`Arquivo de legendas não encontrado: ${captionSource.srtPath}`);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const wsDir = findWorkspaceDir(flags.dest);
|
|
1262
|
+
const silentIfJson = async (fn) => {
|
|
1263
|
+
if (!flags.json) return await fn();
|
|
1264
|
+
const orig = console.log;
|
|
1265
|
+
console.log = () => {};
|
|
1266
|
+
try { return await fn(); } finally { console.log = orig; }
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
// 1. Ingest asset
|
|
1270
|
+
const assetRecord = await silentIfJson(() => run({ positionals: ['import', resolvedSrc], flags: { dest: wsDir, json: false } }));
|
|
1271
|
+
if (!assetRecord || !assetRecord.id) {
|
|
1272
|
+
console.error('Error: Failed to import source video into workspace.');
|
|
1273
|
+
process.exit(1);
|
|
1274
|
+
}
|
|
1275
|
+
const assetPath = path.join(wsDir, assetRecord.path);
|
|
1276
|
+
const totalDurationSec = assetRecord.metadata?.duration_s || 0;
|
|
1277
|
+
|
|
1278
|
+
// 2. Silence detection
|
|
1279
|
+
const silenceThresh = flags['silence-thresh'] || '-30dB';
|
|
1280
|
+
const silenceDuration = parseFloat(flags['silence-duration'] || '0.8');
|
|
1281
|
+
const silences = detectSilence(assetPath, { noiseThreshold: silenceThresh, minDurationSec: silenceDuration });
|
|
1282
|
+
const speechIntervals = computeSpeechIntervals(totalDurationSec, silences);
|
|
1283
|
+
|
|
1284
|
+
// 3. Transcribe only when captions are enabled
|
|
1285
|
+
let words = [];
|
|
1286
|
+
if (captionSource.mode === 'srt') {
|
|
1287
|
+
words = await transcribeAudio(null, { srtPath: captionSource.srtPath });
|
|
1288
|
+
} else if (captionSource.mode !== 'none') {
|
|
1289
|
+
const tmpAudio = path.join(os.tmpdir(), `usk-audio-${crypto.randomBytes(4).toString('hex')}.mp3`);
|
|
1290
|
+
try {
|
|
1291
|
+
execFileSync('ffmpeg', ['-y', '-i', assetPath, '-vn', '-ar', '16000', '-ac', '1', '-b:a', '64k', tmpAudio], { stdio: 'ignore' });
|
|
1292
|
+
words = await transcribeAudio(tmpAudio, {
|
|
1293
|
+
apiKey: captionSource.apiKey,
|
|
1294
|
+
useOpenai: captionSource.mode === 'openai',
|
|
1295
|
+
srtPath: captionSource.srtPath,
|
|
1296
|
+
requireLocal: captionSource.mode === 'local',
|
|
1297
|
+
});
|
|
1298
|
+
} finally {
|
|
1299
|
+
if (fs.existsSync(tmpAudio)) fs.unlinkSync(tmpAudio);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// 4. Captions grouping and remapping
|
|
1304
|
+
const captionStyle = flags['caption-style'] || 'impact'; // Option A: impact default
|
|
1305
|
+
const rawCaptions = groupWordsIntoCaptions(words, { maxWords: 3, captionStyle });
|
|
1306
|
+
const remappedCaptions = remapCaptionsToSpeechTimeline(rawCaptions, speechIntervals);
|
|
1307
|
+
|
|
1308
|
+
// Handle broll overlays if flags.broll provided
|
|
1309
|
+
const overlays = [];
|
|
1310
|
+
const overlayLayout = flags.layout || 'fullscreen';
|
|
1311
|
+
if (!['fullscreen', 'pip', 'split_bottom'].includes(overlayLayout)) {
|
|
1312
|
+
throw new Error(`Layout de overlay inválido: ${overlayLayout}. Use fullscreen, pip ou split_bottom (split_top está pendente em CLI-06).`);
|
|
1313
|
+
}
|
|
1314
|
+
const overlayDuration = flags['overlay-duration'] || 'inserts';
|
|
1315
|
+
if (!['inserts', 'full'].includes(overlayDuration)) {
|
|
1316
|
+
throw new Error(`Duração de overlay inválida: ${overlayDuration}. Use inserts ou full.`);
|
|
1317
|
+
}
|
|
1318
|
+
if (!flags.broll && (flags.layout || flags['overlay-duration'])) {
|
|
1319
|
+
throw new Error('--layout e --overlay-duration exigem --broll.');
|
|
1320
|
+
}
|
|
1321
|
+
if (flags.broll) {
|
|
1322
|
+
const brollFiles = Array.isArray(flags.broll) ? flags.broll : [flags.broll];
|
|
1323
|
+
const importedBrolls = [];
|
|
1324
|
+
for (const file of brollFiles) {
|
|
1325
|
+
const brollAsset = await silentIfJson(() => run({ positionals: ['import', path.resolve(file)], flags: { dest: wsDir, json: false } }));
|
|
1326
|
+
if (brollAsset && brollAsset.id) importedBrolls.push(brollAsset);
|
|
1327
|
+
}
|
|
1328
|
+
const totalTimelineSec = speechIntervals.reduce((acc, s) => acc + s.durationSec, 0);
|
|
1329
|
+
if (overlayDuration === 'full' && importedBrolls.length > 0 && totalTimelineSec >= 0.5) {
|
|
1330
|
+
if (importedBrolls.length > 1 && !flags.json) {
|
|
1331
|
+
console.error('Aviso: --overlay-duration full usa apenas o primeiro --broll; os demais são ignorados.');
|
|
1332
|
+
}
|
|
1333
|
+
overlays.push({
|
|
1334
|
+
asset_id: importedBrolls[0].id,
|
|
1335
|
+
layout: overlayLayout,
|
|
1336
|
+
in_ms: 0,
|
|
1337
|
+
out_ms: Math.round(totalTimelineSec * 1000),
|
|
1338
|
+
timeline_start_ms: 0,
|
|
1339
|
+
duration_ms: Math.round(totalTimelineSec * 1000),
|
|
1340
|
+
loop: true,
|
|
1341
|
+
});
|
|
1342
|
+
} else if (overlayDuration === 'inserts') {
|
|
1343
|
+
const maxOverlays = Math.max(1, Math.floor(totalTimelineSec / 1.5));
|
|
1344
|
+
const selected = importedBrolls.slice(0, maxOverlays);
|
|
1345
|
+
if (selected.length > 0 && totalTimelineSec >= 2) {
|
|
1346
|
+
const slotSec = totalTimelineSec / selected.length;
|
|
1347
|
+
const targetDurSec = Math.min(3.0, slotSec * 0.9);
|
|
1348
|
+
selected.forEach((brollAsset, index) => {
|
|
1349
|
+
const startSec = index * slotSec + (slotSec - targetDurSec) / 2;
|
|
1350
|
+
const durSec = Math.min(targetDurSec, brollAsset.metadata?.duration_s || targetDurSec, totalTimelineSec - startSec);
|
|
1351
|
+
if (durSec < 0.5) return;
|
|
1352
|
+
overlays.push({
|
|
1353
|
+
asset_id: brollAsset.id,
|
|
1354
|
+
layout: overlayLayout,
|
|
1355
|
+
in_ms: 0,
|
|
1356
|
+
out_ms: Math.round(durSec * 1000),
|
|
1357
|
+
timeline_start_ms: Math.round(startSec * 1000),
|
|
1358
|
+
duration_ms: Math.round(durSec * 1000),
|
|
1359
|
+
});
|
|
1360
|
+
});
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// Handle background music if flags.music provided
|
|
1366
|
+
const musicTracks = [];
|
|
1367
|
+
if (flags.music) {
|
|
1368
|
+
const musicAsset = await silentIfJson(() => run({ positionals: ['import', path.resolve(flags.music)], flags: { dest: wsDir, json: false } }));
|
|
1369
|
+
if (musicAsset && musicAsset.id) {
|
|
1370
|
+
musicTracks.push({
|
|
1371
|
+
asset_id: musicAsset.id,
|
|
1372
|
+
gain_db: -22,
|
|
1373
|
+
ducking: true,
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// 5. Build and validate EditPlan
|
|
1379
|
+
const format = flags.format || '9:16';
|
|
1380
|
+
const plan = buildAutoClipPlan({
|
|
1381
|
+
asset: assetRecord,
|
|
1382
|
+
speechIntervals,
|
|
1383
|
+
captions: remappedCaptions,
|
|
1384
|
+
format,
|
|
1385
|
+
overlays,
|
|
1386
|
+
musicTracks,
|
|
1387
|
+
});
|
|
1388
|
+
if (flags['caption-template']) plan.caption_template = flags['caption-template'];
|
|
1389
|
+
validateEditPlan(plan);
|
|
1390
|
+
|
|
1391
|
+
const packagesDir = path.join(wsDir, 'packages');
|
|
1392
|
+
fs.mkdirSync(packagesDir, { recursive: true });
|
|
1393
|
+
const planPath = path.join(packagesDir, 'autoclip-plan.json');
|
|
1394
|
+
fs.writeFileSync(planPath, JSON.stringify(plan, null, 2) + '\n');
|
|
1395
|
+
|
|
1396
|
+
// 6. Render
|
|
1397
|
+
const artifact = await silentIfJson(() => run({ positionals: ['render', planPath], flags: { dest: wsDir, json: false, verbose: flags.verbose, 'caption-template': flags['caption-template'] } }));
|
|
1398
|
+
if (!artifact || !artifact.path) {
|
|
1399
|
+
throw new Error('Failed to render auto-clip video artifact');
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// 7. QA
|
|
1403
|
+
const fullArtifactPath = path.resolve(wsDir, artifact.path);
|
|
1404
|
+
const qaReport = await silentIfJson(() => run({ positionals: ['qa', fullArtifactPath], flags: { dest: wsDir, plan: planPath, json: false, verbose: flags.verbose } }));
|
|
1405
|
+
|
|
1406
|
+
const totalSilenceSec = silences.reduce((acc, s) => acc + s.duration, 0);
|
|
1407
|
+
const result = {
|
|
1408
|
+
status: 'ok',
|
|
1409
|
+
asset_id: assetRecord.id,
|
|
1410
|
+
original_duration_s: totalDurationSec,
|
|
1411
|
+
silences_detected: silences.length,
|
|
1412
|
+
silence_cut_s: parseFloat(totalSilenceSec.toFixed(3)),
|
|
1413
|
+
speech_clips_count: speechIntervals.length,
|
|
1414
|
+
captions_count: remappedCaptions.length,
|
|
1415
|
+
caption_mode: captionSource.mode,
|
|
1416
|
+
pipeline: 'auto-clip',
|
|
1417
|
+
artifact,
|
|
1418
|
+
qa: qaReport,
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
if (flags.json) {
|
|
1422
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1423
|
+
return result;
|
|
1424
|
+
}
|
|
1425
|
+
console.log(`Auto-clip concluído com sucesso!`);
|
|
1426
|
+
console.log(`- Duração Original: ${totalDurationSec.toFixed(2)}s`);
|
|
1427
|
+
console.log(`- Silêncios Removidos: ${silences.length} (${totalSilenceSec.toFixed(2)}s cortados)`);
|
|
1428
|
+
console.log(`- Cortes Contíguos: ${speechIntervals.length}`);
|
|
1429
|
+
console.log(`- Legendas Sincronizadas: ${remappedCaptions.length} (${captionStyle})`);
|
|
1430
|
+
console.log(`- Artefato: ${artifact.path}`);
|
|
1431
|
+
console.log(`- QA: ${qaReport?.passed ? 'Aprovado ✓' : 'Falha ✗'}`);
|
|
1432
|
+
return result;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
if (sub === 'init') {
|
|
1436
|
+
const targetDir = findWorkspaceDir(flags.dest || positionals[1]);
|
|
1437
|
+
fs.mkdirSync(path.join(targetDir, 'assets'), { recursive: true });
|
|
1438
|
+
fs.mkdirSync(path.join(targetDir, 'artifacts'), { recursive: true });
|
|
1439
|
+
fs.mkdirSync(path.join(targetDir, 'packages'), { recursive: true });
|
|
1440
|
+
|
|
1441
|
+
const manifestPath = path.join(targetDir, 'studio.json');
|
|
1442
|
+
let manifest;
|
|
1443
|
+
if (fs.existsSync(manifestPath)) {
|
|
1444
|
+
manifest = loadManifest(manifestPath);
|
|
1445
|
+
} else {
|
|
1446
|
+
manifest = {
|
|
1447
|
+
version: 1,
|
|
1448
|
+
name: 'esquadrao-studio',
|
|
1449
|
+
created_at: new Date().toISOString(),
|
|
1450
|
+
assets: [],
|
|
1451
|
+
artifacts: [],
|
|
1452
|
+
};
|
|
1453
|
+
saveManifestAtomic(manifestPath, manifest);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
if (flags.json) {
|
|
1457
|
+
console.log(JSON.stringify({ status: 'ok', destination: targetDir, manifest }, null, 2));
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
console.log(`Initialized studio workspace at ${targetDir}`);
|
|
1461
|
+
console.log(`- Created assets/, artifacts/, packages/`);
|
|
1462
|
+
console.log(`- Manifest: studio.json`);
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
if (sub === 'import') {
|
|
1467
|
+
const srcFile = positionals[1];
|
|
1468
|
+
if (!srcFile) {
|
|
1469
|
+
console.error('Error: Missing file to import. Usage: esquadrao studio import <file>');
|
|
1470
|
+
process.exit(1);
|
|
1471
|
+
}
|
|
1472
|
+
const resolvedSrc = path.resolve(srcFile);
|
|
1473
|
+
if (!fs.existsSync(resolvedSrc)) {
|
|
1474
|
+
console.error(`Error: Source file not found: ${srcFile}`);
|
|
1475
|
+
process.exit(1);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
if (!checkBinary('ffprobe')) {
|
|
1479
|
+
console.error('Error: ffprobe is not installed or not found on PATH. Install ffmpeg to use video studio features.');
|
|
1480
|
+
process.exit(1);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
const wsDir = findWorkspaceDir(flags.dest);
|
|
1484
|
+
fs.mkdirSync(path.join(wsDir, 'assets'), { recursive: true });
|
|
1485
|
+
fs.mkdirSync(path.join(wsDir, 'artifacts'), { recursive: true });
|
|
1486
|
+
fs.mkdirSync(path.join(wsDir, 'packages'), { recursive: true });
|
|
1487
|
+
|
|
1488
|
+
const assetId = `AST-${crypto.randomBytes(4).toString('hex')}`;
|
|
1489
|
+
const ext = path.extname(resolvedSrc) || '.mp4';
|
|
1490
|
+
const targetName = `${assetId}${ext}`;
|
|
1491
|
+
const targetPath = path.join(wsDir, 'assets', targetName);
|
|
1492
|
+
fs.copyFileSync(resolvedSrc, targetPath);
|
|
1493
|
+
|
|
1494
|
+
const probe = probeMedia(targetPath);
|
|
1495
|
+
const vStream = probe.streams?.find((s) => s.codec_type === 'video');
|
|
1496
|
+
const aStream = probe.streams?.find((s) => s.codec_type === 'audio');
|
|
1497
|
+
const duration = parseFloat(probe.format?.duration || '0');
|
|
1498
|
+
|
|
1499
|
+
const metadata = {
|
|
1500
|
+
duration_s: duration,
|
|
1501
|
+
width: vStream?.width || null,
|
|
1502
|
+
height: vStream?.height || null,
|
|
1503
|
+
fps: vStream?.r_frame_rate || null,
|
|
1504
|
+
video_codec: vStream?.codec_name || null,
|
|
1505
|
+
audio_codec: aStream?.codec_name || null,
|
|
1506
|
+
channels: aStream?.channels || null,
|
|
1507
|
+
sample_rate: aStream?.sample_rate || null,
|
|
1508
|
+
streams: probe.streams?.length || 0,
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
const assetRecord = {
|
|
1512
|
+
id: assetId,
|
|
1513
|
+
name: path.basename(resolvedSrc),
|
|
1514
|
+
path: path.relative(wsDir, targetPath).replace(/\\/g, '/'),
|
|
1515
|
+
imported_at: new Date().toISOString(),
|
|
1516
|
+
size_bytes: fs.statSync(targetPath).size,
|
|
1517
|
+
metadata,
|
|
1518
|
+
};
|
|
1519
|
+
|
|
1520
|
+
const manifestPath = path.join(wsDir, 'studio.json');
|
|
1521
|
+
const manifest = loadManifest(manifestPath);
|
|
1522
|
+
manifest.assets = manifest.assets || [];
|
|
1523
|
+
manifest.assets.push(assetRecord);
|
|
1524
|
+
saveManifestAtomic(manifestPath, manifest);
|
|
1525
|
+
|
|
1526
|
+
if (flags.json) {
|
|
1527
|
+
console.log(JSON.stringify(assetRecord, null, 2));
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
console.log(`Imported asset ${assetId}: ${assetRecord.name} (${metadata.width || '?'}x${metadata.height || '?'}, ${duration.toFixed(2)}s)`);
|
|
1531
|
+
return assetRecord;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
if (sub === 'render') {
|
|
1535
|
+
if (flags['caption-template']) getCaptionTemplate(flags['caption-template']);
|
|
1536
|
+
const planFile = positionals[1];
|
|
1537
|
+
if (!planFile) {
|
|
1538
|
+
console.error('Error: Missing edit plan file. Usage: esquadrao studio render <editplan.json>');
|
|
1539
|
+
process.exit(1);
|
|
1540
|
+
}
|
|
1541
|
+
const resolvedPlanFile = path.resolve(planFile);
|
|
1542
|
+
if (!fs.existsSync(resolvedPlanFile)) {
|
|
1543
|
+
console.error(`Error: Edit plan file not found: ${planFile}`);
|
|
1544
|
+
process.exit(1);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
let plan;
|
|
1548
|
+
try {
|
|
1549
|
+
plan = JSON.parse(fs.readFileSync(resolvedPlanFile, 'utf8'));
|
|
1550
|
+
} catch (err) {
|
|
1551
|
+
console.error(`Error: Malformed JSON in edit plan: ${err.message}`);
|
|
1552
|
+
process.exit(1);
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
try {
|
|
1556
|
+
validateEditPlan(plan);
|
|
1557
|
+
} catch (err) {
|
|
1558
|
+
console.error(`Error: EditPlan validation failed: ${err.message}`);
|
|
1559
|
+
process.exit(1);
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
if (!checkBinary('ffmpeg')) {
|
|
1563
|
+
console.error('Error: ffmpeg is not installed or not found on PATH. Install ffmpeg to use video studio features.');
|
|
1564
|
+
process.exit(1);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
const wsDir = findWorkspaceDir(flags.dest);
|
|
1568
|
+
|
|
1569
|
+
const videoClipsToCheck = (plan.mode === 'edit_only' || plan.mode === 'podcast_clip')
|
|
1570
|
+
? (plan.clips || []).filter((c) => c.kind === 'video' || (!c.kind && c.asset_id))
|
|
1571
|
+
: [];
|
|
1572
|
+
|
|
1573
|
+
for (let i = 0; i < videoClipsToCheck.length; i++) {
|
|
1574
|
+
const c = videoClipsToCheck[i];
|
|
1575
|
+
const clipPath = resolveAsset(wsDir, c);
|
|
1576
|
+
const { durationSec, assetId } = getAssetDuration(wsDir, clipPath, c);
|
|
1577
|
+
const assetDurMs = Math.round(durationSec * 1000);
|
|
1578
|
+
const clipName = c.clip_id || `clip #${i}`;
|
|
1579
|
+
if (c.out_ms > assetDurMs) {
|
|
1580
|
+
console.error(`Error: Clip '${clipName}' out_ms (${c.out_ms}ms) exceeds asset '${assetId}' duration (${assetDurMs}ms / ${durationSec.toFixed(3)}s)`);
|
|
1581
|
+
process.exit(1);
|
|
1582
|
+
}
|
|
1583
|
+
if (c.in_ms > assetDurMs) {
|
|
1584
|
+
console.error(`Error: Clip '${clipName}' in_ms (${c.in_ms}ms) exceeds asset '${assetId}' duration (${assetDurMs}ms / ${durationSec.toFixed(3)}s)`);
|
|
1585
|
+
process.exit(1);
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
fs.mkdirSync(path.join(wsDir, 'artifacts'), { recursive: true });
|
|
1590
|
+
const artifactId = `ART-${crypto.randomBytes(4).toString('hex')}`;
|
|
1591
|
+
const artifactPath = path.join(wsDir, 'artifacts', `${artifactId}.mp4`);
|
|
1592
|
+
const { width, height } = plan.output;
|
|
1593
|
+
|
|
1594
|
+
const tempFiles = [];
|
|
1595
|
+
try {
|
|
1596
|
+
if (plan.mode === 'dark') {
|
|
1597
|
+
const totalDuration = computeExpectedDuration(plan);
|
|
1598
|
+
const vf = buildDarkTextFilters(plan.clips, tempFiles);
|
|
1599
|
+
const args = [
|
|
1600
|
+
'-y',
|
|
1601
|
+
'-f', 'lavfi',
|
|
1602
|
+
'-i', `color=c=black:s=${width}x${height}:r=30:d=${totalDuration}`,
|
|
1603
|
+
'-f', 'lavfi',
|
|
1604
|
+
'-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
|
|
1605
|
+
'-t', `${totalDuration}`,
|
|
1606
|
+
'-vf', vf,
|
|
1607
|
+
'-c:v', 'libx264',
|
|
1608
|
+
'-c:a', 'aac',
|
|
1609
|
+
'-pix_fmt', 'yuv420p',
|
|
1610
|
+
artifactPath,
|
|
1611
|
+
];
|
|
1612
|
+
await runFfmpeg(args, { verbose: flags.verbose, artifactPath });
|
|
1613
|
+
} else if (plan.mode === 'edit_only') {
|
|
1614
|
+
const clip0Path = resolveAsset(wsDir, plan.clips[0]);
|
|
1615
|
+
const clip1Path = resolveAsset(wsDir, plan.clips[1]);
|
|
1616
|
+
const f0 = buildClipFilters(0, plan.clips[0], clip0Path, wsDir, width, height, plan.audio_tracks?.[0]?.gain_db || 0);
|
|
1617
|
+
const f1 = buildClipFilters(1, plan.clips[1], clip1Path, wsDir, width, height, plan.audio_tracks?.[1]?.gain_db || 0);
|
|
1618
|
+
|
|
1619
|
+
const filterParts = [
|
|
1620
|
+
f0.vFilter,
|
|
1621
|
+
f0.aFilter,
|
|
1622
|
+
f1.vFilter,
|
|
1623
|
+
f1.aFilter,
|
|
1624
|
+
`[v0][a0][v1][a1]concat=n=2:v=1:a=1[basev][outa]`,
|
|
1625
|
+
buildCaptionFilters(plan.captions || [], '[basev]', tempFiles, 'default', { width, height, template: plan.caption_template || flags['caption-template'] }),
|
|
1626
|
+
];
|
|
1627
|
+
|
|
1628
|
+
const args = [
|
|
1629
|
+
'-y',
|
|
1630
|
+
'-i', clip0Path,
|
|
1631
|
+
'-i', clip1Path,
|
|
1632
|
+
'-filter_complex', filterParts.join(';'),
|
|
1633
|
+
'-map', '[outv]',
|
|
1634
|
+
'-map', '[outa]',
|
|
1635
|
+
'-c:v', 'libx264',
|
|
1636
|
+
'-c:a', 'aac',
|
|
1637
|
+
'-pix_fmt', 'yuv420p',
|
|
1638
|
+
artifactPath,
|
|
1639
|
+
];
|
|
1640
|
+
await runFfmpeg(args, { verbose: flags.verbose, artifactPath });
|
|
1641
|
+
} else if (plan.mode === 'podcast_clip') {
|
|
1642
|
+
const videoClips = (plan.clips || []).filter((c) => c.kind === 'video' || !c.kind);
|
|
1643
|
+
const captions = plan.captions || (plan.clips || []).filter((c) => c.kind === 'text');
|
|
1644
|
+
const inputArgs = [];
|
|
1645
|
+
const filterParts = [];
|
|
1646
|
+
const concatInputs = [];
|
|
1647
|
+
const hasSplitBottomFull = (plan.overlays || []).some((ov) => ov.layout === 'split_bottom' && ov.loop);
|
|
1648
|
+
|
|
1649
|
+
for (let i = 0; i < videoClips.length; i++) {
|
|
1650
|
+
const c = videoClips[i];
|
|
1651
|
+
const clipPath = resolveAsset(wsDir, c);
|
|
1652
|
+
inputArgs.push('-i', clipPath);
|
|
1653
|
+
const gain = plan.audio_tracks?.[i]?.gain_db || 0;
|
|
1654
|
+
const { vFilter, aFilter } = buildClipFilters(i, c, clipPath, wsDir, width, height, gain, { splitBottom: hasSplitBottomFull });
|
|
1655
|
+
filterParts.push(vFilter, aFilter);
|
|
1656
|
+
concatInputs.push(`[v${i}][a${i}]`);
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
let baseV = '[concatv]';
|
|
1660
|
+
let speechA = '[outa]';
|
|
1661
|
+
if (videoClips.length === 1) {
|
|
1662
|
+
baseV = '[v0]';
|
|
1663
|
+
filterParts.push(`[a0]anull[outa_speech]`);
|
|
1664
|
+
speechA = '[outa_speech]';
|
|
1665
|
+
} else {
|
|
1666
|
+
filterParts.push(`${concatInputs.join('')}concat=n=${videoClips.length}:v=1:a=1[concatv][outa_speech]`);
|
|
1667
|
+
speechA = '[outa_speech]';
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// B-Roll Overlays
|
|
1671
|
+
let currentV = baseV;
|
|
1672
|
+
let nextInputIdx = videoClips.length;
|
|
1673
|
+
if (plan.overlays && Array.isArray(plan.overlays) && plan.overlays.length > 0) {
|
|
1674
|
+
for (let k = 0; k < plan.overlays.length; k++) {
|
|
1675
|
+
const ov = plan.overlays[k];
|
|
1676
|
+
const ovPath = resolveAsset(wsDir, ov);
|
|
1677
|
+
if (ov.loop) inputArgs.push('-stream_loop', '-1');
|
|
1678
|
+
inputArgs.push('-i', ovPath);
|
|
1679
|
+
const inSec = ((ov.in_ms || 0) / 1000).toFixed(3);
|
|
1680
|
+
const outSec = ((ov.out_ms || (ov.in_ms || 0) + 3000) / 1000).toFixed(3);
|
|
1681
|
+
const startSec = ((ov.timeline_start_ms || 0) / 1000).toFixed(3);
|
|
1682
|
+
const durMs = ov.duration_ms || (ov.out_ms - ov.in_ms);
|
|
1683
|
+
const endSec = (((ov.timeline_start_ms || 0) + durMs) / 1000).toFixed(3);
|
|
1684
|
+
const layout = ov.layout || 'fullscreen';
|
|
1685
|
+
|
|
1686
|
+
if (layout === 'pip') {
|
|
1687
|
+
const pipW = Math.round(width * 0.7);
|
|
1688
|
+
const pipH = Math.round(height * 0.35);
|
|
1689
|
+
filterParts.push(`[${nextInputIdx}:v]trim=start=${inSec}:end=${outSec},setpts=PTS-STARTPTS,scale=${pipW}:${pipH}:force_original_aspect_ratio=decrease,pad=${pipW}:${pipH}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[ov_${k}]`);
|
|
1690
|
+
filterParts.push(`${currentV}[ov_${k}]overlay=(W-w)/2:(H-h)/2:enable='between(t\\,${startSec}\\,${endSec})'[v_ov_${k}]`);
|
|
1691
|
+
} else if (layout === 'split_bottom') {
|
|
1692
|
+
const splitH = Math.round(height / 2);
|
|
1693
|
+
filterParts.push(`[${nextInputIdx}:v]trim=start=${inSec}:end=${outSec},setpts=PTS-STARTPTS,scale=${width}:${splitH}:force_original_aspect_ratio=increase,crop=${width}:${splitH},setsar=1,fps=30[ov_${k}]`);
|
|
1694
|
+
filterParts.push(`${currentV}[ov_${k}]overlay=0:${splitH}:enable='between(t\\,${startSec}\\,${endSec})'[v_ov_${k}]`);
|
|
1695
|
+
} else {
|
|
1696
|
+
// Fullscreen default
|
|
1697
|
+
filterParts.push(`[${nextInputIdx}:v]trim=start=${inSec}:end=${outSec},setpts=PTS-STARTPTS,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[ov_${k}]`);
|
|
1698
|
+
filterParts.push(`${currentV}[ov_${k}]overlay=0:0:enable='between(t\\,${startSec}\\,${endSec})'[v_ov_${k}]`);
|
|
1699
|
+
}
|
|
1700
|
+
currentV = `[v_ov_${k}]`;
|
|
1701
|
+
nextInputIdx++;
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
filterParts.push(buildCaptionFilters(captions, currentV, tempFiles, 'default', { width, height, template: plan.caption_template || flags['caption-template'] }));
|
|
1706
|
+
|
|
1707
|
+
// Background Music Tracks with Ducking
|
|
1708
|
+
let finalA = speechA;
|
|
1709
|
+
if (plan.music_tracks && Array.isArray(plan.music_tracks) && plan.music_tracks.length > 0) {
|
|
1710
|
+
for (let m = 0; m < plan.music_tracks.length; m++) {
|
|
1711
|
+
const track = plan.music_tracks[m];
|
|
1712
|
+
const trackPath = resolveAsset(wsDir, track);
|
|
1713
|
+
inputArgs.push('-i', trackPath);
|
|
1714
|
+
const mGain = track.gain_db !== undefined ? track.gain_db : -22;
|
|
1715
|
+
filterParts.push(`[${nextInputIdx}:a]volume=${mGain}dB,aloop=loop=-1:size=2e+09[mus_${m}]`);
|
|
1716
|
+
filterParts.push(`${finalA}[mus_${m}]amix=inputs=2:duration=first:dropout_transition=2[outa_mix_${m}]`);
|
|
1717
|
+
finalA = `[outa_mix_${m}]`;
|
|
1718
|
+
nextInputIdx++;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
filterParts.push(`${finalA}anull[outa]`);
|
|
1722
|
+
|
|
1723
|
+
const args = [
|
|
1724
|
+
'-y',
|
|
1725
|
+
...inputArgs,
|
|
1726
|
+
'-filter_complex', filterParts.join(';'),
|
|
1727
|
+
'-map', '[outv]',
|
|
1728
|
+
'-map', '[outa]',
|
|
1729
|
+
'-c:v', 'libx264',
|
|
1730
|
+
'-c:a', 'aac',
|
|
1731
|
+
'-pix_fmt', 'yuv420p',
|
|
1732
|
+
artifactPath,
|
|
1733
|
+
];
|
|
1734
|
+
await runFfmpeg(args, { verbose: flags.verbose, artifactPath });
|
|
1735
|
+
}
|
|
1736
|
+
} finally {
|
|
1737
|
+
for (const f of tempFiles) {
|
|
1738
|
+
try { fs.unlinkSync(f); } catch {}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
const artifactRecord = {
|
|
1743
|
+
id: artifactId,
|
|
1744
|
+
path: path.relative(wsDir, artifactPath).replace(/\\/g, '/'),
|
|
1745
|
+
plan_file: path.basename(resolvedPlanFile),
|
|
1746
|
+
mode: plan.mode,
|
|
1747
|
+
rendered_at: new Date().toISOString(),
|
|
1748
|
+
size_bytes: fs.existsSync(artifactPath) ? fs.statSync(artifactPath).size : 0,
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
const manifestPath = path.join(wsDir, 'studio.json');
|
|
1752
|
+
const manifest = loadManifest(manifestPath);
|
|
1753
|
+
manifest.artifacts = manifest.artifacts || [];
|
|
1754
|
+
manifest.artifacts.push(artifactRecord);
|
|
1755
|
+
saveManifestAtomic(manifestPath, manifest);
|
|
1756
|
+
|
|
1757
|
+
const qaReport = verifyArtifact(artifactPath, resolvedPlanFile);
|
|
1758
|
+
if (!qaReport.passed) {
|
|
1759
|
+
console.error(`Error: Post-render QA validation failed:\n${qaReport.errors.map((e) => ` - ${e}`).join('\n')}`);
|
|
1760
|
+
process.exit(1);
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
if (flags.json) {
|
|
1764
|
+
console.log(JSON.stringify(artifactRecord, null, 2));
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
console.log(`Rendered artifact ${artifactId}: ${artifactRecord.path} (${artifactRecord.size_bytes} bytes)`);
|
|
1768
|
+
return artifactRecord;
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
if (sub === 'qa') {
|
|
1772
|
+
const artFile = positionals[1];
|
|
1773
|
+
if (!artFile) {
|
|
1774
|
+
console.error('Error: Missing artifact file. Usage: esquadrao studio qa <artifact.mp4> [--plan <editplan.json>]');
|
|
1775
|
+
process.exit(1);
|
|
1776
|
+
}
|
|
1777
|
+
const resolvedArt = path.resolve(artFile);
|
|
1778
|
+
if (!fs.existsSync(resolvedArt)) {
|
|
1779
|
+
console.error(`Error: Artifact file not found: ${artFile}`);
|
|
1780
|
+
process.exit(1);
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
if (!checkBinary('ffprobe')) {
|
|
1784
|
+
console.error('Error: ffprobe is not installed or not found on PATH. Install ffmpeg to use video studio features.');
|
|
1785
|
+
process.exit(1);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
let report;
|
|
1789
|
+
try {
|
|
1790
|
+
report = verifyArtifact(resolvedArt, flags.plan);
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
console.error(`Error: QA verification failed: ${err.message}`);
|
|
1793
|
+
process.exit(1);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
if (flags.json) {
|
|
1797
|
+
console.log(JSON.stringify(report, null, 2));
|
|
1798
|
+
} else {
|
|
1799
|
+
console.log(`Artifact QA: ${report.passed ? 'PASSED' : 'FAILED'}`);
|
|
1800
|
+
console.log(`- File: ${report.artifact}`);
|
|
1801
|
+
console.log(`- Duration: ${report.duration_s.toFixed(2)}s`);
|
|
1802
|
+
if (report.streams?.video) console.log(`- Video: ${report.streams.video.codec} (${report.streams.video.width}x${report.streams.video.height} @ ${report.streams.video.fps})`);
|
|
1803
|
+
if (report.streams?.audio) console.log(`- Audio: ${report.streams.audio.codec} (${report.streams.audio.channels}ch @ ${report.streams.audio.sample_rate}Hz)`);
|
|
1804
|
+
if (report.plan_comparison) {
|
|
1805
|
+
console.log(`- Plan Comparison: ${report.plan_comparison.plan_file} (expected ${report.plan_comparison.expected_duration_s.toFixed(2)}s, ${report.plan_comparison.expected_width}x${report.plan_comparison.expected_height} @ ${report.plan_comparison.expected_fps}fps)`);
|
|
1806
|
+
}
|
|
1807
|
+
if (!report.passed) {
|
|
1808
|
+
console.error(`QA Failures:\n${report.errors.map((e) => ` - ${e}`).join('\n')}`);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
if (!report.passed) {
|
|
1813
|
+
process.exit(1);
|
|
1814
|
+
}
|
|
1815
|
+
return report;
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
console.error(`Error: Unknown studio subcommand '${sub}'. Run 'esquadrao studio --help' for available subcommands.`);
|
|
1819
|
+
process.exit(1);
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
export default run;
|