@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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +366 -2
  3. package/assets/fonts/Anton-Regular.ttf +0 -0
  4. package/assets/fonts/BebasNeue-Regular.ttf +0 -0
  5. package/assets/fonts/OFL.txt +93 -0
  6. package/assets/fonts/Poppins-Bold.ttf +0 -0
  7. package/assets/fonts/README.md +13 -0
  8. package/bin/cli.js +126 -0
  9. package/package.json +58 -4
  10. package/scripts/ast_slice.js +73 -0
  11. package/scripts/cutout.py +197 -0
  12. package/scripts/dag_validator.js +78 -0
  13. package/scripts/lint.js +43 -0
  14. package/scripts/merkle_verify.js +83 -0
  15. package/scripts/wcag_check.js +77 -0
  16. package/skill-audit/SKILL.md +42 -0
  17. package/skill-audit/references/merkle-ledger-spec.md +27 -0
  18. package/skill-audit/references/receipt-ed25519-verification.md +17 -0
  19. package/skill-audit/references/time-travel-replay.md +10 -0
  20. package/skill-code-reviewer/SKILL.md +40 -0
  21. package/skill-context/SKILL.md +44 -0
  22. package/skill-context/references/ast-virtualization.md +10 -0
  23. package/skill-context/references/openapi-contract-synthesis.md +10 -0
  24. package/skill-engineering-manager/SKILL.md +40 -0
  25. package/skill-media-producer/SKILL.md +328 -0
  26. package/skill-media-producer/references/caption-templates.md +194 -0
  27. package/skill-media-producer/references/composicoes-especiais.md +99 -0
  28. package/skill-media-producer/references/configuracao-inicial.md +239 -0
  29. package/skill-media-producer/references/corte-podcast-3-niveis.md +179 -0
  30. package/skill-media-producer/references/llm-agent-guide.md +231 -0
  31. package/skill-orchestrate/SKILL.md +49 -0
  32. package/skill-orchestrate/references/mission-lifecycle.md +36 -0
  33. package/skill-orchestrate/references/swarm-coordination.md +33 -0
  34. package/skill-pipeline/SKILL.md +42 -0
  35. package/skill-pipeline/references/auto-healing-patterns.md +17 -0
  36. package/skill-pipeline/references/dag-decomposition-rules.md +18 -0
  37. package/skill-pipeline/references/topological-waves.md +16 -0
  38. package/skill-product-manager/SKILL.md +40 -0
  39. package/skill-qa-engineer/SKILL.md +40 -0
  40. package/skill-review/SKILL.md +46 -0
  41. package/skill-review/references/anti-slop-patterns.md +17 -0
  42. package/skill-review/references/dual-blind-adjudication.md +15 -0
  43. package/skill-review/references/voice-calibration.md +10 -0
  44. package/skill-security-engineer/SKILL.md +40 -0
  45. package/skill-software-architect/SKILL.md +40 -0
  46. package/skill-software-engineer/SKILL.md +40 -0
  47. package/skill-sre-engineer/SKILL.md +40 -0
  48. package/skill-system-analyst/SKILL.md +40 -0
  49. package/skill-tech-lead/SKILL.md +40 -0
  50. package/skill-technical-writer/SKILL.md +40 -0
  51. package/skill-ui/SKILL.md +45 -0
  52. package/skill-ui/references/design-tokens-spec.md +19 -0
  53. package/skill-ui/references/visual-tdd-methodology.md +10 -0
  54. package/skill-ui/references/wcag-target-sizes.md +9 -0
  55. package/src/captionTemplates.js +285 -0
  56. package/src/commands/onboard.js +618 -0
  57. package/src/commands/role.js +119 -0
  58. package/src/commands/setup.js +174 -0
  59. package/src/commands/studio.js +1822 -0
  60. package/src/roles/authority.js +129 -0
  61. package/src/roles/codeReviewer.js +65 -0
  62. package/src/roles/engineeringManager.js +53 -0
  63. package/src/roles/index.js +80 -0
  64. package/src/roles/mediaProducer.js +223 -0
  65. package/src/roles/productManager.js +50 -0
  66. package/src/roles/qaEngineer.js +54 -0
  67. package/src/roles/securityEngineer.js +53 -0
  68. package/src/roles/softwareArchitect.js +58 -0
  69. package/src/roles/softwareEngineer.js +76 -0
  70. package/src/roles/sreEngineer.js +60 -0
  71. package/src/roles/systemAnalyst.js +45 -0
  72. package/src/roles/techLead.js +46 -0
  73. 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;