@julioventura/opensquad 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +433 -0
- package/_opensquad/config/playwright.config.json +11 -0
- package/_opensquad/core/architect.agent.yaml +112 -0
- package/_opensquad/core/best-practices/_catalog.yaml +126 -0
- package/_opensquad/core/best-practices/blog-post.md +132 -0
- package/_opensquad/core/best-practices/blog-seo.md +127 -0
- package/_opensquad/core/best-practices/brand-resolution-checklist.md +172 -0
- package/_opensquad/core/best-practices/copywriting.md +441 -0
- package/_opensquad/core/best-practices/data-analysis.md +401 -0
- package/_opensquad/core/best-practices/email-newsletter.md +118 -0
- package/_opensquad/core/best-practices/email-sales.md +110 -0
- package/_opensquad/core/best-practices/image-design.md +348 -0
- package/_opensquad/core/best-practices/instagram-feed.md +235 -0
- package/_opensquad/core/best-practices/instagram-reels.md +112 -0
- package/_opensquad/core/best-practices/instagram-stories.md +107 -0
- package/_opensquad/core/best-practices/linkedin-article.md +116 -0
- package/_opensquad/core/best-practices/linkedin-post.md +121 -0
- package/_opensquad/core/best-practices/researching.md +349 -0
- package/_opensquad/core/best-practices/review.md +269 -0
- package/_opensquad/core/best-practices/run-recovery.md +61 -0
- package/_opensquad/core/best-practices/social-networks-publishing.md +327 -0
- package/_opensquad/core/best-practices/squad-creation-checklist.md +32 -0
- package/_opensquad/core/best-practices/strategist.md +344 -0
- package/_opensquad/core/best-practices/technical-writing.md +365 -0
- package/_opensquad/core/best-practices/twitter-post.md +105 -0
- package/_opensquad/core/best-practices/twitter-thread.md +122 -0
- package/_opensquad/core/best-practices/whatsapp-broadcast.md +107 -0
- package/_opensquad/core/best-practices/youtube-script.md +122 -0
- package/_opensquad/core/best-practices/youtube-shorts.md +112 -0
- package/_opensquad/core/defaults/youtube-video-assembly.json +84 -0
- package/_opensquad/core/prompts/build.prompt.md +613 -0
- package/_opensquad/core/prompts/design.prompt.md +606 -0
- package/_opensquad/core/prompts/discovery.prompt.md +377 -0
- package/_opensquad/core/prompts/sherlock-instagram.md +123 -0
- package/_opensquad/core/prompts/sherlock-linkedin.md +73 -0
- package/_opensquad/core/prompts/sherlock-shared.md +684 -0
- package/_opensquad/core/prompts/sherlock-twitter.md +78 -0
- package/_opensquad/core/prompts/sherlock-youtube.md +85 -0
- package/_opensquad/core/runner.pipeline.md +743 -0
- package/_opensquad/core/skills.engine.md +384 -0
- package/bin/opensquad.js +108 -0
- package/dashboard/index.html +15 -0
- package/dashboard/package-lock.json +1964 -0
- package/dashboard/package.json +28 -0
- package/dashboard/public/assets/avatars/Female1_1wave.png +0 -0
- package/dashboard/public/assets/avatars/Female1_2wave.png +0 -0
- package/dashboard/public/assets/avatars/Female1_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female1_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female2_1wave.png +0 -0
- package/dashboard/public/assets/avatars/Female2_2wave.png +0 -0
- package/dashboard/public/assets/avatars/Female2_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female2_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female3_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female3_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female3_wave.png +0 -0
- package/dashboard/public/assets/avatars/Female4_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female4_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female4_wave.png +0 -0
- package/dashboard/public/assets/avatars/Female5_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female5_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female5_wave.png +0 -0
- package/dashboard/public/assets/avatars/Female6_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female6_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female6_wave.png +0 -0
- package/dashboard/public/assets/avatars/Male1_1wave.png +0 -0
- package/dashboard/public/assets/avatars/Male1_2wave.png +0 -0
- package/dashboard/public/assets/avatars/Male1_blink.png +0 -0
- package/dashboard/public/assets/avatars/Male1_talk.png +0 -0
- package/dashboard/public/assets/avatars/Male2_1wave.png +0 -0
- package/dashboard/public/assets/avatars/Male2_2wave.png +0 -0
- package/dashboard/public/assets/avatars/Male2_blink.png +0 -0
- package/dashboard/public/assets/avatars/Male2_talk.png +0 -0
- package/dashboard/public/assets/avatars/Male3_blink.png +0 -0
- package/dashboard/public/assets/avatars/Male3_talk.png +0 -0
- package/dashboard/public/assets/avatars/Male3_wave.png +0 -0
- package/dashboard/public/assets/avatars/Male4_blink.png +0 -0
- package/dashboard/public/assets/avatars/Male4_talk.png +0 -0
- package/dashboard/public/assets/avatars/Male4_wave.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_black_down.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_black_down_coding-1.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_black_down_coding.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_black_up.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_white_down.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_white_down_coding-1.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_white_down_coding.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_white_up.png +0 -0
- package/dashboard/public/assets/furniture/armchair_tan.png +0 -0
- package/dashboard/public/assets/furniture/armchair_tan_down.png +0 -0
- package/dashboard/public/assets/furniture/backpack_blue.png +0 -0
- package/dashboard/public/assets/furniture/backpack_red.png +0 -0
- package/dashboard/public/assets/furniture/blinds.png +0 -0
- package/dashboard/public/assets/furniture/blinds_large_closed_white.png +0 -0
- package/dashboard/public/assets/furniture/bookshelf.png +0 -0
- package/dashboard/public/assets/furniture/bookshelf_purple_tall.png +0 -0
- package/dashboard/public/assets/furniture/bulletin_board.png +0 -0
- package/dashboard/public/assets/furniture/clock.png +0 -0
- package/dashboard/public/assets/furniture/coffee_mug.png +0 -0
- package/dashboard/public/assets/furniture/coffee_mug_blue.png +0 -0
- package/dashboard/public/assets/furniture/coffee_table.png +0 -0
- package/dashboard/public/assets/furniture/coffeepot_right.png +0 -0
- package/dashboard/public/assets/furniture/coffeetable_black_horizontal.png +0 -0
- package/dashboard/public/assets/furniture/couch.png +0 -0
- package/dashboard/public/assets/furniture/couch_tan_down.png +0 -0
- package/dashboard/public/assets/furniture/cushion_blue.png +0 -0
- package/dashboard/public/assets/furniture/cushion_tan.png +0 -0
- package/dashboard/public/assets/furniture/desk_wood.png +0 -0
- package/dashboard/public/assets/furniture/fancy_rug.png +0 -0
- package/dashboard/public/assets/furniture/fancy_rug_wide.png +0 -0
- package/dashboard/public/assets/furniture/flowers1.png +0 -0
- package/dashboard/public/assets/furniture/flowers2.png +0 -0
- package/dashboard/public/assets/furniture/lamp_tan.png +0 -0
- package/dashboard/public/assets/furniture/lantern.png +0 -0
- package/dashboard/public/assets/furniture/monstera.png +0 -0
- package/dashboard/public/assets/furniture/monstera_small.png +0 -0
- package/dashboard/public/assets/furniture/picture_frame.png +0 -0
- package/dashboard/public/assets/furniture/plant1.png +0 -0
- package/dashboard/public/assets/furniture/plant2.png +0 -0
- package/dashboard/public/assets/furniture/plant3.png +0 -0
- package/dashboard/public/assets/furniture/plant_poof.png +0 -0
- package/dashboard/public/assets/furniture/plant_spindly.png +0 -0
- package/dashboard/public/assets/furniture/poster_blue.png +0 -0
- package/dashboard/public/assets/furniture/rug.png +0 -0
- package/dashboard/public/assets/furniture/succulent_blue.png +0 -0
- package/dashboard/public/assets/furniture/succulent_green.png +0 -0
- package/dashboard/public/assets/furniture/treasurechest_closed_gold.png +0 -0
- package/dashboard/public/assets/furniture/water_cooler_better.png +0 -0
- package/dashboard/public/assets/furniture/whiteboard.png +0 -0
- package/dashboard/public/assets/furniture/whiteboard_stand_graph.png +0 -0
- package/dashboard/public/assets/furniture/window_blinds_open.png +0 -0
- package/dashboard/src/App.tsx +46 -0
- package/dashboard/src/components/RunDashboardButton.tsx +92 -0
- package/dashboard/src/components/SquadCard.tsx +49 -0
- package/dashboard/src/components/SquadSelector.tsx +67 -0
- package/dashboard/src/components/StatusBadge.tsx +32 -0
- package/dashboard/src/components/StatusBar.tsx +116 -0
- package/dashboard/src/hooks/useSquadSocket.ts +135 -0
- package/dashboard/src/lib/formatTime.ts +16 -0
- package/dashboard/src/lib/normalizeState.ts +25 -0
- package/dashboard/src/main.tsx +10 -0
- package/dashboard/src/office/AgentSprite.ts +241 -0
- package/dashboard/src/office/OfficeScene.ts +153 -0
- package/dashboard/src/office/PhaserGame.tsx +80 -0
- package/dashboard/src/office/RoomBuilder.ts +190 -0
- package/dashboard/src/office/assetKeys.ts +150 -0
- package/dashboard/src/office/palette.ts +32 -0
- package/dashboard/src/plugin/squadWatcher.ts +397 -0
- package/dashboard/src/store/useSquadStore.ts +56 -0
- package/dashboard/src/styles/globals.css +36 -0
- package/dashboard/src/types/state.ts +63 -0
- package/dashboard/src/vite-env.d.ts +1 -0
- package/dashboard/tsconfig.json +24 -0
- package/dashboard/vite.config.ts +13 -0
- package/package.json +59 -0
- package/public/sfx/slide-transition-sfx.mp3 +0 -0
- package/skills/README.md +84 -0
- package/skills/apify/SKILL.md +55 -0
- package/skills/blotato/SKILL.md +63 -0
- package/skills/canva/SKILL.md +60 -0
- package/skills/higgsfield/SKILL.md +147 -0
- package/skills/image-ai-generator/SKILL.md +124 -0
- package/skills/image-ai-generator/scripts/generate.py +175 -0
- package/skills/image-creator/SKILL.md +166 -0
- package/skills/image-creator/editorial-slide-template.js +645 -0
- package/skills/image-fetcher/SKILL.md +91 -0
- package/skills/imgbb-uploader/SKILL.md +73 -0
- package/skills/imgbb-uploader/scripts/upload.js +125 -0
- package/skills/instagram-publisher/README.md +36 -0
- package/skills/instagram-publisher/SKILL.md +231 -0
- package/skills/instagram-publisher/scripts/publish-playwright.js +418 -0
- package/skills/instagram-publisher/scripts/publish.js +521 -0
- package/skills/opensquad-agent-creator/SKILL.md +192 -0
- package/skills/opensquad-skill-creator/SKILL.md +420 -0
- package/skills/opensquad-skill-creator/agents/analyzer.md +274 -0
- package/skills/opensquad-skill-creator/agents/comparator.md +202 -0
- package/skills/opensquad-skill-creator/agents/grader.md +223 -0
- package/skills/opensquad-skill-creator/assets/eval_review.html +146 -0
- package/skills/opensquad-skill-creator/eval-viewer/generate_review.py +471 -0
- package/skills/opensquad-skill-creator/eval-viewer/viewer.html +1325 -0
- package/skills/opensquad-skill-creator/references/schemas.md +430 -0
- package/skills/opensquad-skill-creator/references/skill-format.md +235 -0
- package/skills/opensquad-skill-creator/scripts/__init__.py +0 -0
- package/skills/opensquad-skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/skills/opensquad-skill-creator/scripts/quick_validate.py +103 -0
- package/skills/opensquad-skill-creator/scripts/run_eval.py +310 -0
- package/skills/opensquad-skill-creator/scripts/utils.py +47 -0
- package/skills/pdf-extractor/SKILL.md +57 -0
- package/skills/pdf-extractor/scripts/extract.py +82 -0
- package/skills/resend/SKILL.md +80 -0
- package/skills/run-dashboard/README.md +93 -0
- package/skills/run-dashboard/SKILL.md +173 -0
- package/skills/run-dashboard/scripts/finalize-state.js +273 -0
- package/skills/run-dashboard/scripts/generate.js +1296 -0
- package/skills/run-dashboard/scripts/serve.js +135 -0
- package/skills/run-dashboard/templates/run-dashboard-simple.template.html +191 -0
- package/skills/run-dashboard/templates/run-dashboard.template.html +1164 -0
- package/skills/smtp-sender/SKILL.md +88 -0
- package/skills/smtp-sender/scripts/send.js +478 -0
- package/skills/template-designer/SKILL.md +201 -0
- package/skills/template-designer/base-templates/model-a.html +27 -0
- package/skills/template-designer/base-templates/model-b.html +31 -0
- package/skills/template-designer/base-templates/model-c.html +42 -0
- package/skills/youtube-publisher/SKILL.md +232 -0
- package/skills/youtube-publisher/scripts/publish.js +2078 -0
- package/src/agents-cli.js +158 -0
- package/src/agents.js +134 -0
- package/src/i18n.js +48 -0
- package/src/init.js +442 -0
- package/src/locales/en.json +79 -0
- package/src/locales/es.json +78 -0
- package/src/locales/pt-BR.json +78 -0
- package/src/logger.js +38 -0
- package/src/prompt.js +46 -0
- package/src/readme/README.md +146 -0
- package/src/runs.js +318 -0
- package/src/skills-cli.js +157 -0
- package/src/skills.js +146 -0
- package/src/supabase-cli.js +584 -0
- package/src/update.js +169 -0
- package/templates/_opensquad/.opensquad-version +1 -0
- package/templates/_opensquad/_investigations/.gitkeep +0 -0
- package/templates/ide-templates/antigravity/.agent/rules/opensquad.md +68 -0
- package/templates/ide-templates/antigravity/.agent/workflows/opensquad.md +102 -0
- package/templates/ide-templates/claude-code/.claude/skills/opensquad/SKILL.md +182 -0
- package/templates/ide-templates/claude-code/.mcp.json +8 -0
- package/templates/ide-templates/claude-code/CLAUDE.md +57 -0
- package/templates/ide-templates/codex/.agents/skills/opensquad/SKILL.md +6 -0
- package/templates/ide-templates/codex/AGENTS.md +120 -0
- package/templates/ide-templates/cursor/.cursor/commands/opensquad.md +9 -0
- package/templates/ide-templates/cursor/.cursor/mcp.json +8 -0
- package/templates/ide-templates/cursor/.cursor/rules/opensquad.mdc +62 -0
- package/templates/ide-templates/cursor/.cursorignore +3 -0
- package/templates/ide-templates/gemini-cli/.gemini/settings.json +8 -0
- package/templates/ide-templates/gemini-cli/.gemini/skills/opensquad/SKILL.md +186 -0
- package/templates/ide-templates/gemini-cli/GEMINI.md +57 -0
- package/templates/ide-templates/opencode/.opencode/commands/opensquad.md +9 -0
- package/templates/ide-templates/opencode/AGENTS.md +120 -0
- package/templates/ide-templates/qwen-code/.qwen/settings.json +8 -0
- package/templates/ide-templates/qwen-code/.qwen/skills/opensquad/SKILL.md +182 -0
- package/templates/ide-templates/qwen-code/QWEN.md +57 -0
- package/templates/ide-templates/trae/.trae/mcp.json +8 -0
- package/templates/ide-templates/trae/.trae/rules/opensquad.md +64 -0
- package/templates/ide-templates/vscode-copilot/.github/copilot-instructions.md +59 -0
- package/templates/ide-templates/vscode-copilot/.github/prompts/opensquad.prompt.md +209 -0
- package/templates/ide-templates/vscode-copilot/.vscode/mcp.json +8 -0
- package/templates/ide-templates/vscode-copilot/.vscode/settings.json +3 -0
- package/templates/package.json +8 -0
- package/templates/squads/.gitkeep +0 -0
|
@@ -0,0 +1,2078 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { copyFile, mkdtemp, mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createReadStream } from 'node:fs';
|
|
5
|
+
import { dirname, extname, join, resolve } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import process from 'node:process';
|
|
9
|
+
import console from 'node:console';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_ENV_KEYS = {
|
|
13
|
+
youtubeClientId: 'YOUTUBE_CLIENT_ID',
|
|
14
|
+
youtubeClientSecret: 'YOUTUBE_CLIENT_SECRET',
|
|
15
|
+
youtubeRefreshToken: 'YOUTUBE_REFRESH_TOKEN',
|
|
16
|
+
youtubeChannelId: 'YOUTUBE_CHANNEL_ID',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const YOUTUBE_OAUTH_SCOPES = [
|
|
20
|
+
'https://www.googleapis.com/auth/youtube.upload',
|
|
21
|
+
'https://www.googleapis.com/auth/youtube.force-ssl',
|
|
22
|
+
'https://www.googleapis.com/auth/youtube.readonly',
|
|
23
|
+
];
|
|
24
|
+
const YOUTUBE_API_BASE = 'https://www.googleapis.com/youtube/v3';
|
|
25
|
+
const YOUTUBE_UPLOAD_BASE = 'https://www.googleapis.com/upload/youtube/v3/videos';
|
|
26
|
+
const YOUTUBE_THUMBNAIL_UPLOAD_BASE = 'https://www.googleapis.com/upload/youtube/v3/thumbnails/set';
|
|
27
|
+
const SLIDE_DURATION_SECONDS = 10;
|
|
28
|
+
const AUDIO_FADE_IN_SECONDS = 2;
|
|
29
|
+
const AUDIO_FADE_OUT_SECONDS = 5;
|
|
30
|
+
const BACKGROUND_AUDIO_VOLUME = 0.3;
|
|
31
|
+
const MAX_THUMBNAIL_BYTES = 2 * 1024 * 1024;
|
|
32
|
+
const TARGET_VIDEO_WIDTH = 1280;
|
|
33
|
+
const TARGET_VIDEO_HEIGHT = 720;
|
|
34
|
+
const TARGET_VIDEO_FPS = 30;
|
|
35
|
+
const VIDEO_PROGRESS_SIZE = 84;
|
|
36
|
+
const VIDEO_PROGRESS_MARGIN = 24;
|
|
37
|
+
const VIDEO_PROGRESS_STROKE = 6;
|
|
38
|
+
const SUPPORTED_IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png'];
|
|
39
|
+
const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const DEFAULT_BACKGROUND_AUDIO_PATH = resolve(SCRIPT_DIRECTORY, '..', '..', '..', 'public', 'Trilha sonora de slides.mp3');
|
|
41
|
+
const DEFAULT_TRANSITION_SOUND_ASSET_PATH = resolve(SCRIPT_DIRECTORY, '..', '..', '..', 'public', 'sfx', 'slide-transition-sfx.mp3');
|
|
42
|
+
const ELEVEN_LABS_VOICE_ID = 'PznTnBc8X6pvixs9UkQm';
|
|
43
|
+
const DEFAULT_TITLE_VOICE_ID = 'PznTnBc8X6pvixs9UkQm';
|
|
44
|
+
const BACKGROUND_AUDIO_VOLUME_WITH_VOICE = 0.12;
|
|
45
|
+
const NARRATION_AUDIO_VOLUME = 1.2;
|
|
46
|
+
const SLIDE_NARRATION_LEAD_IN_SECONDS = 1;
|
|
47
|
+
const SLIDE_NARRATION_TITLE_TO_TEXT_PAUSE_SECONDS = 1;
|
|
48
|
+
const SLIDE_NARRATION_TAIL_SECONDS = 2;
|
|
49
|
+
const SLIDE_TRANSITION_BUFFER_SECONDS = 1;
|
|
50
|
+
const SLIDE_TRANSITION_DURATION_SECONDS = 2;
|
|
51
|
+
const DEFAULT_SLIDE_TRANSITION = 'fadeblack';
|
|
52
|
+
const TRANSITION_SOUND_EFFECT_VOLUME = 0.7;
|
|
53
|
+
const TRANSITION_SOUND_EFFECT_PROMPT = 'A gentle warm whoosh with a pleasing tonal bloom, subtle and polished, for a calm visual transition. No voice, no percussion hit, lightly musical.';
|
|
54
|
+
|
|
55
|
+
function buildCircularProgressOverlayFilter({ slideDurationSeconds, totalVideoDurationSeconds }) {
|
|
56
|
+
const canvasSize = VIDEO_PROGRESS_SIZE;
|
|
57
|
+
const center = Math.round(canvasSize / 2);
|
|
58
|
+
const outerRadius = Math.round(canvasSize / 2) - 4;
|
|
59
|
+
const innerRadius = outerRadius - VIDEO_PROGRESS_STROKE;
|
|
60
|
+
const overlayX = TARGET_VIDEO_WIDTH - canvasSize - VIDEO_PROGRESS_MARGIN;
|
|
61
|
+
const overlayY = VIDEO_PROGRESS_MARGIN;
|
|
62
|
+
const fadeStartSeconds = Math.max(totalVideoDurationSeconds - 3, 0);
|
|
63
|
+
const fadeOpacity = `if(lt(T\\,${fadeStartSeconds}),1,max(0,min(1,(${totalVideoDurationSeconds}-T)/3)))`;
|
|
64
|
+
const ringMask = `between(hypot(X-${center},Y-${center}),${innerRadius},${outerRadius})`;
|
|
65
|
+
const loopProgress = `(mod(T\\,${slideDurationSeconds})/${slideDurationSeconds})`;
|
|
66
|
+
const sweepAngle = `(2*PI*${loopProgress})`;
|
|
67
|
+
const normalizedAngle = `mod(atan2(Y-${center}\\,X-${center})+PI/2+2*PI\\,2*PI)`;
|
|
68
|
+
const track = `color=c=0xB8C2CF@1.0:s=${canvasSize}x${canvasSize}:d=${totalVideoDurationSeconds},format=rgba,geq=r='184':g='194':b='207':a='if(${ringMask},58*${fadeOpacity},0)'[progresstrack]`;
|
|
69
|
+
const fill = `color=c=0x00D1C7@1.0:s=${canvasSize}x${canvasSize}:d=${totalVideoDurationSeconds},format=rgba,geq=r='0':g='209':b='199':a='if(${ringMask}*lt(${normalizedAngle},${sweepAngle}),255*${fadeOpacity},0)'[progressfill]`;
|
|
70
|
+
const composite = `[basevideo][progresstrack]overlay=${overlayX}:${overlayY}:format=auto[progressbase];[progressbase][progressfill]overlay=${overlayX}:${overlayY}:format=auto[vout]`;
|
|
71
|
+
return `${track};${fill};${composite}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildPrimarySlideshowFilter({ slideDurationSeconds, totalVideoDurationSeconds, showProgressOverlay = true }) {
|
|
75
|
+
const baseVideo = `[0:v]split=2[bg][fg];[bg]scale=${TARGET_VIDEO_WIDTH}:${TARGET_VIDEO_HEIGHT}:force_original_aspect_ratio=increase,crop=${TARGET_VIDEO_WIDTH}:${TARGET_VIDEO_HEIGHT},boxblur=20:10[bgblur];[fg]scale=${TARGET_VIDEO_HEIGHT}:${TARGET_VIDEO_HEIGHT}:force_original_aspect_ratio=decrease[fgscaled];[bgblur][fgscaled]overlay=(W-w)/2:(H-h)/2,fps=${TARGET_VIDEO_FPS}[basevideo]`;
|
|
76
|
+
if (!showProgressOverlay) {
|
|
77
|
+
return `${baseVideo};[basevideo]null[vout]`;
|
|
78
|
+
}
|
|
79
|
+
return `${baseVideo};${buildCircularProgressOverlayFilter({ slideDurationSeconds, totalVideoDurationSeconds })}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function buildFallbackSlideshowFilter({ slideDurationSeconds, totalVideoDurationSeconds, showProgressOverlay = true }) {
|
|
83
|
+
const baseVideo = `[0:v]scale=${TARGET_VIDEO_HEIGHT}:${TARGET_VIDEO_HEIGHT}:force_original_aspect_ratio=decrease,pad=w=${TARGET_VIDEO_WIDTH}:h=${TARGET_VIDEO_HEIGHT}:x=(ow-iw)/2:y=(oh-ih)/2:color=0x1A2B45,fps=${TARGET_VIDEO_FPS}[basevideo]`;
|
|
84
|
+
if (!showProgressOverlay) {
|
|
85
|
+
return `${baseVideo};[basevideo]null[vout]`;
|
|
86
|
+
}
|
|
87
|
+
return `${baseVideo};${buildCircularProgressOverlayFilter({ slideDurationSeconds, totalVideoDurationSeconds })}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseBooleanFlag(value, flagName) {
|
|
91
|
+
if (value === 'true') return true;
|
|
92
|
+
if (value === 'false') return false;
|
|
93
|
+
throw new Error(`${flagName} must be 'true' or 'false'`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseArgs(argv) {
|
|
97
|
+
const args = {
|
|
98
|
+
images: [],
|
|
99
|
+
title: '',
|
|
100
|
+
description: '',
|
|
101
|
+
deleteVideoId: '',
|
|
102
|
+
deleteOnly: false,
|
|
103
|
+
tags: [],
|
|
104
|
+
thumbnailPath: '',
|
|
105
|
+
categoryId: '22',
|
|
106
|
+
privacyStatus: 'public',
|
|
107
|
+
madeForKids: false,
|
|
108
|
+
embeddable: true,
|
|
109
|
+
publicStatsViewable: true,
|
|
110
|
+
license: 'youtube',
|
|
111
|
+
publishAt: '',
|
|
112
|
+
defaultLanguage: '',
|
|
113
|
+
defaultAudioLanguage: '',
|
|
114
|
+
recordingDate: '',
|
|
115
|
+
videoOutput: '',
|
|
116
|
+
audioTrackPath: '',
|
|
117
|
+
playlistName: '',
|
|
118
|
+
playlistPrivacy: 'private',
|
|
119
|
+
dryRun: false,
|
|
120
|
+
youtubeClientIdEnv: null,
|
|
121
|
+
youtubeClientSecretEnv: null,
|
|
122
|
+
youtubeRefreshTokenEnv: null,
|
|
123
|
+
youtubeChannelIdEnv: null,
|
|
124
|
+
voiceId: ELEVEN_LABS_VOICE_ID,
|
|
125
|
+
titleVoiceId: DEFAULT_TITLE_VOICE_ID,
|
|
126
|
+
narration: true,
|
|
127
|
+
prebuiltNarrationAudioPath: '',
|
|
128
|
+
prebuiltSlideDurations: null,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
for (let index = 2; index < argv.length; index++) {
|
|
132
|
+
const current = argv[index];
|
|
133
|
+
|
|
134
|
+
if (current === '--images' && index + 1 < argv.length) {
|
|
135
|
+
args.images = argv[++index].split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
136
|
+
} else if (current === '--title' && index + 1 < argv.length) {
|
|
137
|
+
args.title = argv[++index];
|
|
138
|
+
} else if (current === '--description' && index + 1 < argv.length) {
|
|
139
|
+
args.description = argv[++index];
|
|
140
|
+
} else if (current === '--delete-video-id' && index + 1 < argv.length) {
|
|
141
|
+
args.deleteVideoId = argv[++index];
|
|
142
|
+
} else if (current === '--delete-only') {
|
|
143
|
+
args.deleteOnly = true;
|
|
144
|
+
} else if (current === '--tags' && index + 1 < argv.length) {
|
|
145
|
+
args.tags = argv[++index].split(',').map((entry) => entry.trim()).filter(Boolean);
|
|
146
|
+
} else if (current === '--thumbnail' && index + 1 < argv.length) {
|
|
147
|
+
args.thumbnailPath = argv[++index];
|
|
148
|
+
} else if (current === '--category-id' && index + 1 < argv.length) {
|
|
149
|
+
args.categoryId = argv[++index];
|
|
150
|
+
} else if (current === '--privacy-status' && index + 1 < argv.length) {
|
|
151
|
+
args.privacyStatus = argv[++index];
|
|
152
|
+
} else if (current === '--made-for-kids' && index + 1 < argv.length) {
|
|
153
|
+
args.madeForKids = parseBooleanFlag(argv[++index], '--made-for-kids');
|
|
154
|
+
} else if (current === '--embeddable' && index + 1 < argv.length) {
|
|
155
|
+
args.embeddable = parseBooleanFlag(argv[++index], '--embeddable');
|
|
156
|
+
} else if (current === '--public-stats-viewable' && index + 1 < argv.length) {
|
|
157
|
+
args.publicStatsViewable = parseBooleanFlag(argv[++index], '--public-stats-viewable');
|
|
158
|
+
} else if (current === '--license' && index + 1 < argv.length) {
|
|
159
|
+
args.license = argv[++index];
|
|
160
|
+
} else if (current === '--publish-at' && index + 1 < argv.length) {
|
|
161
|
+
args.publishAt = argv[++index];
|
|
162
|
+
} else if (current === '--default-language' && index + 1 < argv.length) {
|
|
163
|
+
args.defaultLanguage = argv[++index];
|
|
164
|
+
} else if (current === '--default-audio-language' && index + 1 < argv.length) {
|
|
165
|
+
args.defaultAudioLanguage = argv[++index];
|
|
166
|
+
} else if (current === '--recording-date' && index + 1 < argv.length) {
|
|
167
|
+
args.recordingDate = argv[++index];
|
|
168
|
+
} else if (current === '--video-output' && index + 1 < argv.length) {
|
|
169
|
+
args.videoOutput = argv[++index];
|
|
170
|
+
} else if (current === '--audio-track' && index + 1 < argv.length) {
|
|
171
|
+
args.audioTrackPath = argv[++index];
|
|
172
|
+
} else if (current === '--playlist-name' && index + 1 < argv.length) {
|
|
173
|
+
args.playlistName = argv[++index];
|
|
174
|
+
} else if (current === '--playlist-privacy' && index + 1 < argv.length) {
|
|
175
|
+
args.playlistPrivacy = argv[++index];
|
|
176
|
+
} else if (current === '--youtube-client-id-env' && index + 1 < argv.length) {
|
|
177
|
+
args.youtubeClientIdEnv = argv[++index];
|
|
178
|
+
} else if (current === '--youtube-client-secret-env' && index + 1 < argv.length) {
|
|
179
|
+
args.youtubeClientSecretEnv = argv[++index];
|
|
180
|
+
} else if (current === '--youtube-refresh-token-env' && index + 1 < argv.length) {
|
|
181
|
+
args.youtubeRefreshTokenEnv = argv[++index];
|
|
182
|
+
} else if (current === '--youtube-channel-id-env' && index + 1 < argv.length) {
|
|
183
|
+
args.youtubeChannelIdEnv = argv[++index];
|
|
184
|
+
} else if (current === '--voice-id' && index + 1 < argv.length) {
|
|
185
|
+
args.voiceId = argv[++index];
|
|
186
|
+
} else if (current === '--title-voice-id' && index + 1 < argv.length) {
|
|
187
|
+
args.titleVoiceId = argv[++index];
|
|
188
|
+
} else if (current === '--no-narration') {
|
|
189
|
+
args.narration = false;
|
|
190
|
+
} else if (current === '--narration-audio' && index + 1 < argv.length) {
|
|
191
|
+
args.prebuiltNarrationAudioPath = argv[++index];
|
|
192
|
+
} else if (current === '--slide-durations' && index + 1 < argv.length) {
|
|
193
|
+
args.prebuiltSlideDurations = argv[++index].split(',').map((v) => Number(v.trim())).filter((v) => Number.isFinite(v) && v > 0);
|
|
194
|
+
} else if (current === '--dry-run') {
|
|
195
|
+
args.dryRun = true;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return args;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function resolveConfiguredEnv(env, options = {}) {
|
|
203
|
+
const youtubeClientIdKey = options.youtubeClientIdEnv || DEFAULT_ENV_KEYS.youtubeClientId;
|
|
204
|
+
const youtubeClientSecretKey = options.youtubeClientSecretEnv || DEFAULT_ENV_KEYS.youtubeClientSecret;
|
|
205
|
+
const youtubeRefreshTokenKey = options.youtubeRefreshTokenEnv || DEFAULT_ENV_KEYS.youtubeRefreshToken;
|
|
206
|
+
const youtubeChannelIdKey = options.youtubeChannelIdEnv || DEFAULT_ENV_KEYS.youtubeChannelId;
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
youtubeClientIdKey,
|
|
210
|
+
youtubeClientSecretKey,
|
|
211
|
+
youtubeRefreshTokenKey,
|
|
212
|
+
youtubeChannelIdKey,
|
|
213
|
+
youtubeClientId: env[youtubeClientIdKey],
|
|
214
|
+
youtubeClientSecret: env[youtubeClientSecretKey],
|
|
215
|
+
youtubeRefreshToken: env[youtubeRefreshTokenKey],
|
|
216
|
+
youtubeChannelId: env[youtubeChannelIdKey],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function cleanContentText(value) {
|
|
221
|
+
return String(value || '')
|
|
222
|
+
.replace(/^>\s*/gm, '')
|
|
223
|
+
.replace(/\*\[([^\]]+)\]\*/g, '$1')
|
|
224
|
+
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, '$1: $2')
|
|
225
|
+
.replace(/\*\*/g, '')
|
|
226
|
+
.replace(/`/g, '')
|
|
227
|
+
.replace(/_/g, '')
|
|
228
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
229
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
230
|
+
.replace(/^[\s'"`<]+/, '')
|
|
231
|
+
.replace(/[\s'"`>]+$/, '')
|
|
232
|
+
.trim();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function extractMarkdownSection(content, headingPattern, stopPatterns = []) {
|
|
236
|
+
return cleanContentText(extractMarkdownSectionRaw(content, headingPattern, stopPatterns));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function extractMarkdownSectionRaw(content, headingPattern, stopPatterns = []) {
|
|
240
|
+
const stopSource = stopPatterns.length
|
|
241
|
+
? `(?=\\n##\\s+(?:${stopPatterns.join('|')})\\b|$)`
|
|
242
|
+
: '$';
|
|
243
|
+
const regex = new RegExp(`##\\s+${headingPattern}\\s+([\\s\\S]*?)${stopSource}`, 'i');
|
|
244
|
+
const match = content.match(regex);
|
|
245
|
+
return String(match?.[1] || '');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function parseVerifiedSources(section) {
|
|
249
|
+
const lines = String(section || '').split(/\r?\n/);
|
|
250
|
+
const sources = [];
|
|
251
|
+
let current = null;
|
|
252
|
+
|
|
253
|
+
for (const rawLine of lines) {
|
|
254
|
+
const line = rawLine.trim();
|
|
255
|
+
if (!line) continue;
|
|
256
|
+
|
|
257
|
+
const sourceMatch = line.match(/^[-*]\s+Fonte:\s+\[([^\]]+)\]\((https?:\/\/[^)]+)\)/i);
|
|
258
|
+
if (sourceMatch) {
|
|
259
|
+
current = {
|
|
260
|
+
name: cleanContentText(sourceMatch[1]),
|
|
261
|
+
url: sourceMatch[2].trim(),
|
|
262
|
+
consultedAt: '',
|
|
263
|
+
};
|
|
264
|
+
sources.push(current);
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const consultedMatch = line.match(/^Consultado em:\s+(.+)$/i);
|
|
269
|
+
if (consultedMatch && current) {
|
|
270
|
+
current.consultedAt = cleanContentText(consultedMatch[1]);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return sources;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseCommunicationChannels(section) {
|
|
278
|
+
return String(section || '')
|
|
279
|
+
.split(/\r?\n/)
|
|
280
|
+
.map((line) => line.trim())
|
|
281
|
+
.filter(Boolean)
|
|
282
|
+
.map((line) => line.match(/^[-*]\s+([^:]+):\s+(.+)$/))
|
|
283
|
+
.filter(Boolean)
|
|
284
|
+
.map((match) => ({
|
|
285
|
+
label: cleanContentText(match[1]),
|
|
286
|
+
value: cleanContentText(match[2]),
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function parseContentPackageMetadata(contentPackagePath) {
|
|
291
|
+
const content = await readFile(contentPackagePath, 'utf-8');
|
|
292
|
+
const legend = extractMarkdownSection(content, 'Legenda', ['Saiba\\s+mais', 'Hashtags', 'YouTube', 'Notas', 'Checklist']);
|
|
293
|
+
const saibaMais = extractMarkdownSection(content, 'Saiba\\s+mais', ['Hashtags', 'YouTube', 'Notas', 'Checklist']);
|
|
294
|
+
const channels = extractMarkdownSection(content, 'Canais\\s+de\\s+comunicaç[aã]o', ['Fontes\\s+verificadas', 'Slide\\s+1', 'Legenda', 'Saiba\\s+mais', 'Hashtags']);
|
|
295
|
+
const verifiedSources = extractMarkdownSectionRaw(content, 'Fontes\\s+verificadas', ['Slide\\s+1', 'Legenda', 'Saiba\\s+mais', 'Hashtags', 'Notas', 'Checklist']);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
legend,
|
|
299
|
+
saibaMais,
|
|
300
|
+
channels: parseCommunicationChannels(channels),
|
|
301
|
+
verifiedSources: parseVerifiedSources(verifiedSources),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function formatYouTubeSourcesBlock(sources) {
|
|
306
|
+
if (!sources.length) return '';
|
|
307
|
+
|
|
308
|
+
return [
|
|
309
|
+
'FONTES VERIFICADAS',
|
|
310
|
+
...sources.map((source) => `${source.name}: ${source.url}${source.consultedAt ? ` | Consultado em: ${source.consultedAt}` : ''}`),
|
|
311
|
+
].join('\n');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function formatBrandChannelsBlock(channels) {
|
|
315
|
+
if (!channels.length) return '';
|
|
316
|
+
|
|
317
|
+
return [
|
|
318
|
+
'ACOMPANHE A MARCA:',
|
|
319
|
+
...channels.map((channel) => `- ${channel.label}: ${channel.value}`),
|
|
320
|
+
].join('\n');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function buildYouTubeDescriptionFromContentPackage(contentPackagePath, baseDescription = '') {
|
|
324
|
+
const metadata = await parseContentPackageMetadata(contentPackagePath);
|
|
325
|
+
const sections = [];
|
|
326
|
+
const initial = cleanContentText(baseDescription || metadata.legend);
|
|
327
|
+
const hasSaibaMais = /SAIBA MAIS:/i.test(initial);
|
|
328
|
+
const hasChannels = /ACOMPANHE A MARCA:/i.test(initial);
|
|
329
|
+
const hasVerifiedSources = /FONTES VERIFICADAS/i.test(initial);
|
|
330
|
+
|
|
331
|
+
if (initial) {
|
|
332
|
+
sections.push(initial);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (!hasSaibaMais && metadata.saibaMais) {
|
|
336
|
+
sections.push(`SAIBA MAIS:\n${metadata.saibaMais}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (!hasChannels) {
|
|
340
|
+
const channelsBlock = formatBrandChannelsBlock(metadata.channels);
|
|
341
|
+
if (channelsBlock) {
|
|
342
|
+
sections.push(channelsBlock);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
if (!hasVerifiedSources) {
|
|
347
|
+
const sourcesBlock = formatYouTubeSourcesBlock(metadata.verifiedSources);
|
|
348
|
+
if (sourcesBlock) {
|
|
349
|
+
sections.push(sourcesBlock);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return sections.join('\n\n').trim();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function deleteVideo(options, env = process.env, deps = {}) {
|
|
357
|
+
const {
|
|
358
|
+
videoId,
|
|
359
|
+
deleteVideoId,
|
|
360
|
+
dryRun,
|
|
361
|
+
youtubeClientIdEnv,
|
|
362
|
+
youtubeClientSecretEnv,
|
|
363
|
+
youtubeRefreshTokenEnv,
|
|
364
|
+
youtubeChannelIdEnv,
|
|
365
|
+
} = options;
|
|
366
|
+
const {
|
|
367
|
+
exchangeRefreshTokenImpl = exchangeRefreshToken,
|
|
368
|
+
fetchOwnChannelImpl = fetchOwnChannel,
|
|
369
|
+
fetchImpl = globalThis.fetch,
|
|
370
|
+
} = deps;
|
|
371
|
+
|
|
372
|
+
const effectiveVideoId = String(videoId || deleteVideoId || '').trim();
|
|
373
|
+
if (!effectiveVideoId) throw new Error('--delete-video-id is required');
|
|
374
|
+
|
|
375
|
+
const configuredEnv = resolveConfiguredEnv(env, {
|
|
376
|
+
youtubeClientIdEnv,
|
|
377
|
+
youtubeClientSecretEnv,
|
|
378
|
+
youtubeRefreshTokenEnv,
|
|
379
|
+
youtubeChannelIdEnv,
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
const {
|
|
383
|
+
youtubeClientIdKey,
|
|
384
|
+
youtubeClientSecretKey,
|
|
385
|
+
youtubeRefreshTokenKey,
|
|
386
|
+
youtubeChannelIdKey,
|
|
387
|
+
youtubeClientId,
|
|
388
|
+
youtubeClientSecret,
|
|
389
|
+
youtubeRefreshToken,
|
|
390
|
+
youtubeChannelId,
|
|
391
|
+
} = configuredEnv;
|
|
392
|
+
|
|
393
|
+
if (!youtubeClientId) throw new Error(`${youtubeClientIdKey} is not set in environment`);
|
|
394
|
+
if (!youtubeClientSecret) throw new Error(`${youtubeClientSecretKey} is not set in environment`);
|
|
395
|
+
if (!youtubeRefreshToken) throw new Error(`${youtubeRefreshTokenKey} is not set in environment`);
|
|
396
|
+
if (!youtubeChannelId) throw new Error(`${youtubeChannelIdKey} is not set in environment`);
|
|
397
|
+
|
|
398
|
+
const accessToken = await exchangeRefreshTokenImpl({
|
|
399
|
+
clientId: youtubeClientId,
|
|
400
|
+
clientSecret: youtubeClientSecret,
|
|
401
|
+
refreshToken: youtubeRefreshToken,
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const channel = await fetchOwnChannelImpl(accessToken, youtubeChannelId);
|
|
405
|
+
const result = {
|
|
406
|
+
status: dryRun ? 'dry-run' : 'deleted',
|
|
407
|
+
youtubeVideoId: effectiveVideoId,
|
|
408
|
+
youtubeUrl: `https://www.youtube.com/watch?v=${effectiveVideoId}`,
|
|
409
|
+
channelId: channel.id,
|
|
410
|
+
channelTitle: channel.snippet?.title ?? null,
|
|
411
|
+
scope: YOUTUBE_OAUTH_SCOPES,
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
if (dryRun) {
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
const response = await fetchImpl(`${YOUTUBE_API_BASE}/videos?id=${encodeURIComponent(effectiveVideoId)}`, {
|
|
419
|
+
method: 'DELETE',
|
|
420
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (!response.ok) {
|
|
424
|
+
throw new Error(`Video deletion failed [${response.status}]: ${await response.text()}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function assertSupportedImageExtension(imagePath) {
|
|
431
|
+
const extension = extname(imagePath).toLowerCase();
|
|
432
|
+
if (!SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
|
|
433
|
+
throw new Error(`Unsupported image format for '${imagePath}'. Use JPG, JPEG, or PNG.`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function assertSupportedThumbnailExtension(imagePath) {
|
|
438
|
+
const extension = extname(imagePath).toLowerCase();
|
|
439
|
+
if (!SUPPORTED_IMAGE_EXTENSIONS.includes(extension)) {
|
|
440
|
+
throw new Error(`Unsupported thumbnail format for '${imagePath}'. Use JPG, JPEG, or PNG.`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function ensureImageFiles(imagePaths) {
|
|
445
|
+
for (const imagePath of imagePaths) {
|
|
446
|
+
assertSupportedImageExtension(imagePath);
|
|
447
|
+
await stat(resolve(imagePath));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export async function ensureThumbnailFile(thumbnailPath) {
|
|
452
|
+
if (!thumbnailPath) return null;
|
|
453
|
+
|
|
454
|
+
assertSupportedThumbnailExtension(thumbnailPath);
|
|
455
|
+
const thumbnailStats = await stat(resolve(thumbnailPath));
|
|
456
|
+
if (thumbnailStats.size > MAX_THUMBNAIL_BYTES) {
|
|
457
|
+
throw new Error(`Thumbnail exceeds YouTube's 2MB limit (${thumbnailStats.size} bytes).`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return resolve(thumbnailPath);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export async function resolveBackgroundAudioPath(audioTrackPath, deps = {}) {
|
|
464
|
+
const { statImpl = stat } = deps;
|
|
465
|
+
const configuredPath = String(audioTrackPath || '').trim();
|
|
466
|
+
const candidatePath = configuredPath ? resolve(configuredPath) : DEFAULT_BACKGROUND_AUDIO_PATH;
|
|
467
|
+
|
|
468
|
+
try {
|
|
469
|
+
await statImpl(candidatePath);
|
|
470
|
+
return candidatePath;
|
|
471
|
+
} catch {
|
|
472
|
+
if (configuredPath) {
|
|
473
|
+
throw new Error(`Audio track not found at '${candidatePath}'.`);
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function isValidIsoDateTime(value) {
|
|
480
|
+
return !Number.isNaN(Date.parse(value));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
export function normalizeVideoMetadata(options) {
|
|
484
|
+
const normalized = {
|
|
485
|
+
title: options.title,
|
|
486
|
+
description: options.description,
|
|
487
|
+
tags: options.tags ?? [],
|
|
488
|
+
categoryId: options.categoryId,
|
|
489
|
+
privacyStatus: options.privacyStatus,
|
|
490
|
+
madeForKids: options.madeForKids ?? false,
|
|
491
|
+
embeddable: options.embeddable ?? true,
|
|
492
|
+
publicStatsViewable: options.publicStatsViewable ?? true,
|
|
493
|
+
license: options.license || 'youtube',
|
|
494
|
+
publishAt: options.publishAt || null,
|
|
495
|
+
defaultLanguage: options.defaultLanguage || null,
|
|
496
|
+
defaultAudioLanguage: options.defaultAudioLanguage || null,
|
|
497
|
+
recordingDate: options.recordingDate || null,
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
if (!['youtube', 'creativeCommon'].includes(normalized.license)) {
|
|
501
|
+
throw new Error(`Unsupported license '${normalized.license}'. Use youtube or creativeCommon.`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (normalized.publishAt && !isValidIsoDateTime(normalized.publishAt)) {
|
|
505
|
+
throw new Error(`Invalid --publish-at value '${normalized.publishAt}'. Use an ISO 8601 datetime.`);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (normalized.recordingDate && !isValidIsoDateTime(normalized.recordingDate)) {
|
|
509
|
+
throw new Error(`Invalid --recording-date value '${normalized.recordingDate}'. Use an ISO 8601 date or datetime.`);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (normalized.publishAt && normalized.privacyStatus !== 'private') {
|
|
513
|
+
throw new Error('--publish-at can only be used with --privacy-status private');
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
return normalized;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function escapeConcatPath(filePath) {
|
|
520
|
+
return filePath.replace(/'/g, "'\\''");
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function fileExists(filePath, deps = {}) {
|
|
524
|
+
const { statImpl = stat } = deps;
|
|
525
|
+
try {
|
|
526
|
+
await statImpl(filePath);
|
|
527
|
+
return true;
|
|
528
|
+
} catch {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
async function findWingetFfmpegCandidates(deps = {}) {
|
|
534
|
+
const {
|
|
535
|
+
env = process.env,
|
|
536
|
+
platform = process.platform,
|
|
537
|
+
readdirImpl = readdir,
|
|
538
|
+
statImpl = stat,
|
|
539
|
+
} = deps;
|
|
540
|
+
|
|
541
|
+
if (platform !== 'win32') return [];
|
|
542
|
+
|
|
543
|
+
const localAppData = String(env.LOCALAPPDATA || '').trim();
|
|
544
|
+
if (!localAppData) return [];
|
|
545
|
+
|
|
546
|
+
const packagesDir = join(localAppData, 'Microsoft', 'WinGet', 'Packages');
|
|
547
|
+
let packageEntries = [];
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
packageEntries = await readdirImpl(packagesDir, { withFileTypes: true });
|
|
551
|
+
} catch {
|
|
552
|
+
return [];
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const candidates = [];
|
|
556
|
+
for (const entry of packageEntries) {
|
|
557
|
+
if (!entry.isDirectory() || !/^Gyan\.FFmpeg/i.test(entry.name)) continue;
|
|
558
|
+
|
|
559
|
+
const packageDir = join(packagesDir, entry.name);
|
|
560
|
+
const directBinary = join(packageDir, 'ffmpeg.exe');
|
|
561
|
+
if (await fileExists(directBinary, { statImpl })) candidates.push(directBinary);
|
|
562
|
+
|
|
563
|
+
let nestedEntries = [];
|
|
564
|
+
try {
|
|
565
|
+
nestedEntries = await readdirImpl(packageDir, { withFileTypes: true });
|
|
566
|
+
} catch {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
for (const nestedEntry of nestedEntries) {
|
|
571
|
+
if (!nestedEntry.isDirectory()) continue;
|
|
572
|
+
const nestedBinary = join(packageDir, nestedEntry.name, 'bin', 'ffmpeg.exe');
|
|
573
|
+
if (await fileExists(nestedBinary, { statImpl })) candidates.push(nestedBinary);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return [...new Set(candidates)].sort((left, right) => right.localeCompare(left, undefined, { numeric: true }));
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
export async function resolveFfmpegCommand(deps = {}) {
|
|
581
|
+
const {
|
|
582
|
+
spawnImpl = spawn,
|
|
583
|
+
env = process.env,
|
|
584
|
+
} = deps;
|
|
585
|
+
|
|
586
|
+
const configuredBinary = String(env.FFMPEG_PATH || env.FFMPEG_BIN || '').trim();
|
|
587
|
+
const candidates = [configuredBinary, 'ffmpeg', ...(await findWingetFfmpegCandidates(deps))].filter(Boolean);
|
|
588
|
+
let lastError = null;
|
|
589
|
+
|
|
590
|
+
for (const candidate of candidates) {
|
|
591
|
+
try {
|
|
592
|
+
await runProcess(candidate, ['-version'], { spawnImpl, captureStdout: false });
|
|
593
|
+
return candidate;
|
|
594
|
+
} catch (error) {
|
|
595
|
+
lastError = error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const message = lastError instanceof Error ? lastError.message : String(lastError || 'Unknown ffmpeg error');
|
|
600
|
+
throw new Error(
|
|
601
|
+
`ffmpeg is required for YouTube publishing. Checked FFMPEG_PATH/FFMPEG_BIN, PATH, and local WinGet installs. `
|
|
602
|
+
+ `On Windows, install the validated full build with 'winget install --id Gyan.FFmpeg --silent --accept-package-agreements --accept-source-agreements'. `
|
|
603
|
+
+ `Original error: ${message}`,
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
export async function ensureFfmpegAvailable(deps = {}) {
|
|
608
|
+
return resolveFfmpegCommand(deps);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function findContentPackage(imagePaths) {
|
|
612
|
+
const candidates = [];
|
|
613
|
+
|
|
614
|
+
// 1. Current working directory
|
|
615
|
+
candidates.push(resolve(process.cwd(), 'content-package.md'));
|
|
616
|
+
candidates.push(resolve(process.cwd(), 'output', 'content-package.md'));
|
|
617
|
+
|
|
618
|
+
if (imagePaths && imagePaths.length > 0) {
|
|
619
|
+
const firstImgDir = dirname(resolve(imagePaths[0]));
|
|
620
|
+
// 2. Directory of the first image
|
|
621
|
+
candidates.push(join(firstImgDir, 'content-package.md'));
|
|
622
|
+
// 3. Parent directory of the first image
|
|
623
|
+
const parentDir = dirname(firstImgDir);
|
|
624
|
+
candidates.push(join(parentDir, 'content-package.md'));
|
|
625
|
+
// 4. v1 directory of the parent directory
|
|
626
|
+
candidates.push(join(parentDir, 'v1', 'content-package.md'));
|
|
627
|
+
// 5. Parent's parent directory
|
|
628
|
+
candidates.push(join(dirname(parentDir), 'content-package.md'));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
for (const candidate of candidates) {
|
|
632
|
+
try {
|
|
633
|
+
const stats = await stat(candidate);
|
|
634
|
+
if (stats.isFile()) {
|
|
635
|
+
return candidate;
|
|
636
|
+
}
|
|
637
|
+
} catch {
|
|
638
|
+
// ignore
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export async function parseSlideTexts(contentPackagePath) {
|
|
645
|
+
try {
|
|
646
|
+
const content = await readFile(contentPackagePath, 'utf-8');
|
|
647
|
+
const sections = content.split(/(?=^##\s+Slide\s+\d+)/mi);
|
|
648
|
+
const slideTexts = [];
|
|
649
|
+
|
|
650
|
+
const startIdx = sections[0].toLowerCase().includes('## slide') ? 0 : 1;
|
|
651
|
+
|
|
652
|
+
for (let i = startIdx; i < sections.length; i++) {
|
|
653
|
+
const section = sections[i];
|
|
654
|
+
const numMatch = section.match(/^##\s+Slide\s+(\d+)/i);
|
|
655
|
+
if (!numMatch) continue;
|
|
656
|
+
const slideNum = parseInt(numMatch[1], 10);
|
|
657
|
+
|
|
658
|
+
const lines = section.split('\n');
|
|
659
|
+
let text = '';
|
|
660
|
+
let title = '';
|
|
661
|
+
// Track which field we are currently accumulating multi-line content for
|
|
662
|
+
let currentField = null;
|
|
663
|
+
const textLines = [];
|
|
664
|
+
const titleLines = [];
|
|
665
|
+
|
|
666
|
+
for (const line of lines) {
|
|
667
|
+
const cleaned = line.trim();
|
|
668
|
+
|
|
669
|
+
if (/^\*\*Texto:\*\*/i.test(cleaned)) {
|
|
670
|
+
currentField = 'text';
|
|
671
|
+
const inline = cleaned.replace(/^\*\*Texto:\*\*\s*/i, '').trim();
|
|
672
|
+
if (inline) textLines.push(inline);
|
|
673
|
+
} else if (/^\*Texto:\*/i.test(cleaned)) {
|
|
674
|
+
currentField = 'text';
|
|
675
|
+
const inline = cleaned.replace(/^\*Texto:\*\s*/i, '').trim();
|
|
676
|
+
if (inline) textLines.push(inline);
|
|
677
|
+
} else if (/^Texto:\s*/i.test(cleaned)) {
|
|
678
|
+
currentField = 'text';
|
|
679
|
+
const inline = cleaned.replace(/^Texto:\s*/i, '').trim();
|
|
680
|
+
if (inline) textLines.push(inline);
|
|
681
|
+
} else if (/^\*\*T[Ći]tulo:\*\*/i.test(cleaned)) {
|
|
682
|
+
currentField = 'title';
|
|
683
|
+
const inline = cleaned.replace(/^\*\*T[Ći]tulo:\*\*\s*/i, '').trim();
|
|
684
|
+
if (inline) titleLines.push(inline);
|
|
685
|
+
} else if (/^\*T[Ći]tulo:\*/i.test(cleaned)) {
|
|
686
|
+
currentField = 'title';
|
|
687
|
+
const inline = cleaned.replace(/^\*T[Ći]tulo:\*\s*/i, '').trim();
|
|
688
|
+
if (inline) titleLines.push(inline);
|
|
689
|
+
} else if (/^T[Ći]tulo:\s*/i.test(cleaned)) {
|
|
690
|
+
currentField = 'title';
|
|
691
|
+
const inline = cleaned.replace(/^T[Ći]tulo:\s*/i, '').trim();
|
|
692
|
+
if (inline) titleLines.push(inline);
|
|
693
|
+
} else if (/^#{1,6}\s+/.test(cleaned)) {
|
|
694
|
+
// Stop when a new markdown section begins (e.g. ## Legenda after the final slide)
|
|
695
|
+
currentField = null;
|
|
696
|
+
} else if (/^\*\*[A-Za-zĆ-Ćæ].*:\*\*/.test(cleaned) || /^---/.test(cleaned)) {
|
|
697
|
+
// New labeled field or section separator ā stop accumulating
|
|
698
|
+
currentField = null;
|
|
699
|
+
} else if (currentField === 'text' && cleaned) {
|
|
700
|
+
// Continuation line for text (skip empty lines that just separate paragraphs)
|
|
701
|
+
textLines.push(cleaned);
|
|
702
|
+
} else if (currentField === 'title' && cleaned) {
|
|
703
|
+
titleLines.push(cleaned);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
text = textLines.join(' ');
|
|
708
|
+
title = titleLines.join(' ');
|
|
709
|
+
|
|
710
|
+
const cleanMarkdown = (str) => {
|
|
711
|
+
return str
|
|
712
|
+
.replace(/\*\*/g, '')
|
|
713
|
+
.replace(/\*/g, '')
|
|
714
|
+
.replace(/`/g, '')
|
|
715
|
+
.replace(/_/g, '')
|
|
716
|
+
.trim();
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
text = cleanMarkdown(text);
|
|
720
|
+
title = cleanMarkdown(title);
|
|
721
|
+
|
|
722
|
+
if (title || text) {
|
|
723
|
+
slideTexts.push({
|
|
724
|
+
slideNum,
|
|
725
|
+
// text holds only the body copy (may be empty); synthesis path uses title-only when empty
|
|
726
|
+
text: text,
|
|
727
|
+
title: title,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
slideTexts.sort((a, b) => a.slideNum - b.slideNum);
|
|
733
|
+
return slideTexts;
|
|
734
|
+
} catch {
|
|
735
|
+
return [];
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
export async function generateSlideAudio(text, apiKey, voiceId, tempDir, slideNum, voiceSettings = { stability: 0.5, similarity_boost: 0.75 }) {
|
|
740
|
+
const response = await globalThis.fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
|
|
741
|
+
method: 'POST',
|
|
742
|
+
headers: {
|
|
743
|
+
'xi-api-key': apiKey,
|
|
744
|
+
'Content-Type': 'application/json',
|
|
745
|
+
'accept': 'audio/mpeg',
|
|
746
|
+
},
|
|
747
|
+
body: JSON.stringify({
|
|
748
|
+
text,
|
|
749
|
+
model_id: 'eleven_multilingual_v2',
|
|
750
|
+
voice_settings: voiceSettings,
|
|
751
|
+
}),
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
if (!response.ok) {
|
|
755
|
+
throw new Error(`Eleven Labs TTS request failed [${response.status}]: ${await response.text()}`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
759
|
+
const buffer = Buffer.from(arrayBuffer);
|
|
760
|
+
const outputPath = join(tempDir, `slide_${slideNum}_raw.mp3`);
|
|
761
|
+
await writeFile(outputPath, buffer);
|
|
762
|
+
return outputPath;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export async function getAudioDuration(filePath, ffprobeCommand = 'ffprobe', spawnImpl = spawn) {
|
|
766
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
767
|
+
const child = spawnImpl(ffprobeCommand, [
|
|
768
|
+
'-v', 'error',
|
|
769
|
+
'-show_entries', 'format=duration',
|
|
770
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
771
|
+
resolve(filePath),
|
|
772
|
+
]);
|
|
773
|
+
let stdout = '';
|
|
774
|
+
let stderr = '';
|
|
775
|
+
child.stdout.on('data', (chunk) => {
|
|
776
|
+
stdout += chunk.toString();
|
|
777
|
+
});
|
|
778
|
+
child.stderr.on('data', (chunk) => {
|
|
779
|
+
stderr += chunk.toString();
|
|
780
|
+
});
|
|
781
|
+
child.on('close', (code) => {
|
|
782
|
+
if (code === 0) {
|
|
783
|
+
const val = parseFloat(stdout.trim());
|
|
784
|
+
if (!Number.isNaN(val)) {
|
|
785
|
+
resolvePromise(val);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
rejectPromise(new Error(stderr.trim() || `Exit code ${code}`));
|
|
790
|
+
});
|
|
791
|
+
child.on('error', rejectPromise);
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
export async function padAudioFile(inputPath, outputPath, duration, ffmpegCommand, spawnImpl) {
|
|
796
|
+
await runProcess(
|
|
797
|
+
ffmpegCommand,
|
|
798
|
+
[
|
|
799
|
+
'-y',
|
|
800
|
+
'-i', resolve(inputPath),
|
|
801
|
+
'-filter_complex', '[0:a]adelay=2000|2000,apad[a]',
|
|
802
|
+
'-map', '[a]',
|
|
803
|
+
'-t', String(duration),
|
|
804
|
+
'-c:a', 'libmp3lame',
|
|
805
|
+
'-ar', '44100',
|
|
806
|
+
'-ac', '2',
|
|
807
|
+
resolve(outputPath),
|
|
808
|
+
],
|
|
809
|
+
{ spawnImpl, captureStdout: false }
|
|
810
|
+
);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export async function generateSilence(outputPath, duration, ffmpegCommand, spawnImpl) {
|
|
814
|
+
await runProcess(
|
|
815
|
+
ffmpegCommand,
|
|
816
|
+
[
|
|
817
|
+
'-y',
|
|
818
|
+
'-f', 'lavfi',
|
|
819
|
+
'-i', 'anullsrc=r=44100:cl=stereo',
|
|
820
|
+
'-t', String(duration),
|
|
821
|
+
'-c:a', 'libmp3lame',
|
|
822
|
+
'-ar', '44100',
|
|
823
|
+
'-ac', '2',
|
|
824
|
+
resolve(outputPath),
|
|
825
|
+
],
|
|
826
|
+
{ spawnImpl, captureStdout: false }
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
export async function buildNarrationTrack(images, contentPackagePath, slideDurationSeconds, apiKey, ffmpegCommand, tempDir, logger, spawnImpl = spawn, voiceId = ELEVEN_LABS_VOICE_ID, titleVoiceId = DEFAULT_TITLE_VOICE_ID, artifactsDir = null) {
|
|
831
|
+
const ffprobeCommand = ffmpegCommand.replace(/\bffmpeg(\.exe)?$/i, 'ffprobe$1');
|
|
832
|
+
const slideTexts = await parseSlideTexts(contentPackagePath);
|
|
833
|
+
logger.log(`šļø Parsed ${slideTexts.length} slide text(s) from content-package.md`);
|
|
834
|
+
|
|
835
|
+
if (artifactsDir) {
|
|
836
|
+
await mkdir(resolve(artifactsDir), { recursive: true });
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Pre-generate silence files
|
|
840
|
+
const silence3sPath = join(tempDir, 'silence_3s.mp3');
|
|
841
|
+
await generateSilence(silence3sPath, 3, ffmpegCommand, spawnImpl);
|
|
842
|
+
const silence2sPath = join(tempDir, 'silence_2s.mp3');
|
|
843
|
+
await generateSilence(silence2sPath, 2, ffmpegCommand, spawnImpl);
|
|
844
|
+
const silence1sPath = join(tempDir, 'silence_1s.mp3');
|
|
845
|
+
await generateSilence(silence1sPath, 1, ffmpegCommand, spawnImpl);
|
|
846
|
+
|
|
847
|
+
const paddedFiles = [];
|
|
848
|
+
const slideDurations = [];
|
|
849
|
+
|
|
850
|
+
for (let i = 0; i < images.length; i++) {
|
|
851
|
+
const imagePath = images[i];
|
|
852
|
+
const base = imagePath.split(/[/\\]/).pop() || '';
|
|
853
|
+
const numMatch = base.match(/slide-(\d+)/i);
|
|
854
|
+
const slideNum = numMatch ? parseInt(numMatch[1], 10) : (i + 1);
|
|
855
|
+
|
|
856
|
+
const paddedPath = join(tempDir, `slide_${slideNum}_padded.mp3`);
|
|
857
|
+
const slideTextEntry = slideTexts.find(entry => entry.slideNum === slideNum);
|
|
858
|
+
|
|
859
|
+
if (slideTextEntry && apiKey) {
|
|
860
|
+
const hasTitle = Boolean(slideTextEntry.title);
|
|
861
|
+
const hasText = Boolean(slideTextEntry.text);
|
|
862
|
+
|
|
863
|
+
if (!hasTitle && !hasText) {
|
|
864
|
+
logger.log(`š Slide ${slideNum} has no title or text. Using silence.`);
|
|
865
|
+
await generateSilence(paddedPath, slideDurationSeconds, ffmpegCommand, spawnImpl);
|
|
866
|
+
paddedFiles.push(paddedPath);
|
|
867
|
+
slideDurations.push(slideDurationSeconds);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
try {
|
|
872
|
+
let titleRawPath = null;
|
|
873
|
+
let titleDuration = 0;
|
|
874
|
+
if (hasTitle) {
|
|
875
|
+
logger.log(`š£ļø Synthesizing slide ${slideNum} title with Eleven Labs (voice: ${titleVoiceId}): "${slideTextEntry.title}"`);
|
|
876
|
+
const titleSettings = {
|
|
877
|
+
stability: 0.35,
|
|
878
|
+
similarity_boost: 0.8,
|
|
879
|
+
style: 0.55,
|
|
880
|
+
use_speaker_boost: true
|
|
881
|
+
};
|
|
882
|
+
titleRawPath = await generateSlideAudio(slideTextEntry.title, apiKey, titleVoiceId, tempDir, `title_${slideNum}`, titleSettings);
|
|
883
|
+
try {
|
|
884
|
+
titleDuration = await getAudioDuration(titleRawPath, ffprobeCommand, spawnImpl);
|
|
885
|
+
} catch (durErr) {
|
|
886
|
+
logger.log(`ā ļø Failed to measure title duration for slide ${slideNum}: ${durErr.message}. Using fallback.`);
|
|
887
|
+
titleDuration = 3.0; // fallback default
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
let textRawPath = null;
|
|
892
|
+
let textDuration = 0;
|
|
893
|
+
if (hasText) {
|
|
894
|
+
logger.log(`š£ļø Synthesizing slide ${slideNum} text with Eleven Labs (voice: ${voiceId}): "${slideTextEntry.text.substring(0, 50)}..."`);
|
|
895
|
+
const textSettings = {
|
|
896
|
+
stability: 0.5,
|
|
897
|
+
similarity_boost: 0.75,
|
|
898
|
+
style: 0.0,
|
|
899
|
+
use_speaker_boost: true
|
|
900
|
+
};
|
|
901
|
+
textRawPath = await generateSlideAudio(slideTextEntry.text, apiKey, voiceId, tempDir, `text_${slideNum}`, textSettings);
|
|
902
|
+
try {
|
|
903
|
+
textDuration = await getAudioDuration(textRawPath, ffprobeCommand, spawnImpl);
|
|
904
|
+
} catch (durErr) {
|
|
905
|
+
logger.log(`ā ļø Failed to measure text duration for slide ${slideNum}: ${durErr.message}. Using fallback.`);
|
|
906
|
+
textDuration = 6.0; // fallback default
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Build slide-specific concat list to combine silence, title, and text
|
|
911
|
+
const slideConcatListPath = join(tempDir, `slide_${slideNum}_concat.txt`);
|
|
912
|
+
const slideLines = [];
|
|
913
|
+
let totalDuration = 0;
|
|
914
|
+
|
|
915
|
+
// 3s silence at the start of the first slide, 1s for the rest
|
|
916
|
+
if (i === 0) {
|
|
917
|
+
slideLines.push(`file '${escapeConcatPath(resolve(silence3sPath))}'`);
|
|
918
|
+
totalDuration += 3;
|
|
919
|
+
} else {
|
|
920
|
+
slideLines.push(`file '${escapeConcatPath(resolve(silence1sPath))}'`);
|
|
921
|
+
totalDuration += SLIDE_NARRATION_LEAD_IN_SECONDS;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
if (hasTitle) {
|
|
925
|
+
slideLines.push(`file '${escapeConcatPath(resolve(titleRawPath))}'`);
|
|
926
|
+
totalDuration += titleDuration;
|
|
927
|
+
if (hasText) {
|
|
928
|
+
// 1s pause between title and text
|
|
929
|
+
slideLines.push(`file '${escapeConcatPath(resolve(silence1sPath))}'`);
|
|
930
|
+
totalDuration += SLIDE_NARRATION_TITLE_TO_TEXT_PAUSE_SECONDS;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
if (hasText) {
|
|
935
|
+
slideLines.push(`file '${escapeConcatPath(resolve(textRawPath))}'`);
|
|
936
|
+
totalDuration += textDuration;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// 2s silence at the end
|
|
940
|
+
slideLines.push(`file '${escapeConcatPath(resolve(silence2sPath))}'`);
|
|
941
|
+
totalDuration += SLIDE_NARRATION_TAIL_SECONDS;
|
|
942
|
+
|
|
943
|
+
await writeFile(slideConcatListPath, `${slideLines.join('\n')}\n`, 'utf-8');
|
|
944
|
+
|
|
945
|
+
const tempMergedPath = join(tempDir, `slide_${slideNum}_merged_raw.mp3`);
|
|
946
|
+
await runProcess(
|
|
947
|
+
ffmpegCommand,
|
|
948
|
+
[
|
|
949
|
+
'-y',
|
|
950
|
+
'-f', 'concat',
|
|
951
|
+
'-safe', '0',
|
|
952
|
+
'-i', slideConcatListPath,
|
|
953
|
+
'-c:a', 'libmp3lame',
|
|
954
|
+
'-ar', '44100',
|
|
955
|
+
'-ac', '2',
|
|
956
|
+
resolve(tempMergedPath),
|
|
957
|
+
],
|
|
958
|
+
{ spawnImpl, captureStdout: false }
|
|
959
|
+
);
|
|
960
|
+
|
|
961
|
+
const finalDuration = totalDuration + SLIDE_TRANSITION_BUFFER_SECONDS;
|
|
962
|
+
|
|
963
|
+
// Pad the merged audio to the final slide duration (ensures audio and video slide transitions stay in perfect sync)
|
|
964
|
+
await runProcess(
|
|
965
|
+
ffmpegCommand,
|
|
966
|
+
[
|
|
967
|
+
'-y',
|
|
968
|
+
'-i', resolve(tempMergedPath),
|
|
969
|
+
'-filter_complex', '[0:a]apad[a]',
|
|
970
|
+
'-map', '[a]',
|
|
971
|
+
'-t', String(finalDuration),
|
|
972
|
+
'-c:a', 'libmp3lame',
|
|
973
|
+
'-ar', '44100',
|
|
974
|
+
'-ac', '2',
|
|
975
|
+
resolve(paddedPath),
|
|
976
|
+
],
|
|
977
|
+
{ spawnImpl, captureStdout: false }
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
paddedFiles.push(paddedPath);
|
|
981
|
+
slideDurations.push(finalDuration);
|
|
982
|
+
if (artifactsDir) {
|
|
983
|
+
const artifactPath = join(resolve(artifactsDir), `slide-${String(slideNum).padStart(2, '0')}.mp3`);
|
|
984
|
+
await copyFile(resolve(paddedPath), artifactPath);
|
|
985
|
+
}
|
|
986
|
+
} catch (error) {
|
|
987
|
+
logger.log(`ā ļø Eleven Labs TTS or merge failed for slide ${slideNum}: ${error.message}. Falling back to silence.`);
|
|
988
|
+
await generateSilence(paddedPath, slideDurationSeconds, ffmpegCommand, spawnImpl);
|
|
989
|
+
paddedFiles.push(paddedPath);
|
|
990
|
+
slideDurations.push(slideDurationSeconds);
|
|
991
|
+
if (artifactsDir) {
|
|
992
|
+
const artifactPath = join(resolve(artifactsDir), `slide-${String(slideNum).padStart(2, '0')}.mp3`);
|
|
993
|
+
await copyFile(resolve(paddedPath), artifactPath);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
} else {
|
|
997
|
+
if (!apiKey && slideTextEntry) {
|
|
998
|
+
logger.log(`ā ļø Eleven Labs API key not available for slide ${slideNum}. Using silence.`);
|
|
999
|
+
} else {
|
|
1000
|
+
logger.log(`š No text for slide ${slideNum}. Using silence.`);
|
|
1001
|
+
}
|
|
1002
|
+
await generateSilence(paddedPath, slideDurationSeconds, ffmpegCommand, spawnImpl);
|
|
1003
|
+
paddedFiles.push(paddedPath);
|
|
1004
|
+
slideDurations.push(slideDurationSeconds);
|
|
1005
|
+
if (artifactsDir) {
|
|
1006
|
+
const artifactPath = join(resolve(artifactsDir), `slide-${String(slideNum).padStart(2, '0')}.mp3`);
|
|
1007
|
+
await copyFile(resolve(paddedPath), artifactPath);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const audioListPath = join(tempDir, 'audio_concat.txt');
|
|
1013
|
+
const lines = [];
|
|
1014
|
+
for (let index = 0; index < paddedFiles.length; index += 1) {
|
|
1015
|
+
lines.push(`file '${escapeConcatPath(resolve(paddedFiles[index]))}'`);
|
|
1016
|
+
if (index < paddedFiles.length - 1) {
|
|
1017
|
+
lines.push(`file '${escapeConcatPath(resolve(silence2sPath))}'`);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
await writeFile(audioListPath, `${lines.join('\n')}\n`, 'utf-8');
|
|
1021
|
+
|
|
1022
|
+
const narrationPath = join(tempDir, 'narration.mp3');
|
|
1023
|
+
logger.log(`š§© Concatenating slide audio files into narration track...`);
|
|
1024
|
+
await runProcess(
|
|
1025
|
+
ffmpegCommand,
|
|
1026
|
+
[
|
|
1027
|
+
'-y',
|
|
1028
|
+
'-f', 'concat',
|
|
1029
|
+
'-safe', '0',
|
|
1030
|
+
'-i', audioListPath,
|
|
1031
|
+
'-c', 'copy',
|
|
1032
|
+
narrationPath,
|
|
1033
|
+
],
|
|
1034
|
+
{ spawnImpl, captureStdout: false }
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
let persistedNarrationPath = null;
|
|
1038
|
+
if (artifactsDir) {
|
|
1039
|
+
persistedNarrationPath = join(resolve(artifactsDir), 'narration.mp3');
|
|
1040
|
+
await copyFile(resolve(narrationPath), persistedNarrationPath);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
return {
|
|
1044
|
+
narrationPath,
|
|
1045
|
+
persistedNarrationPath,
|
|
1046
|
+
slideDurations
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function resolveNarrationArtifactsDir(images) {
|
|
1051
|
+
if (!images?.length) return null;
|
|
1052
|
+
|
|
1053
|
+
const imagesDir = dirname(resolve(images[0]));
|
|
1054
|
+
const runDir = dirname(imagesDir);
|
|
1055
|
+
return join(runDir, 'audio');
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export async function resolveBundledTransitionSoundAssetPath(statImpl = stat) {
|
|
1059
|
+
try {
|
|
1060
|
+
const assetStats = await statImpl(DEFAULT_TRANSITION_SOUND_ASSET_PATH);
|
|
1061
|
+
if (assetStats.isFile()) {
|
|
1062
|
+
return DEFAULT_TRANSITION_SOUND_ASSET_PATH;
|
|
1063
|
+
}
|
|
1064
|
+
} catch {
|
|
1065
|
+
return null;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
return null;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
export async function generateTransitionSoundEffect(options, deps = {}) {
|
|
1072
|
+
const {
|
|
1073
|
+
apiKey,
|
|
1074
|
+
tempDir,
|
|
1075
|
+
logger = console,
|
|
1076
|
+
durationSeconds = SLIDE_TRANSITION_DURATION_SECONDS,
|
|
1077
|
+
artifactsDir = null,
|
|
1078
|
+
prompt = TRANSITION_SOUND_EFFECT_PROMPT,
|
|
1079
|
+
} = options;
|
|
1080
|
+
const {
|
|
1081
|
+
fetchImpl = globalThis.fetch,
|
|
1082
|
+
writeFileImpl = writeFile,
|
|
1083
|
+
mkdirImpl = mkdir,
|
|
1084
|
+
} = deps;
|
|
1085
|
+
|
|
1086
|
+
if (!apiKey) {
|
|
1087
|
+
throw new Error('ELEVEN_LABS_API key is required to generate transition sound effects.');
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const outputPath = join(tempDir, 'slide-transition-sfx.mp3');
|
|
1091
|
+
logger.log('šµ Generating slide transition sound effect...');
|
|
1092
|
+
|
|
1093
|
+
const response = await fetchImpl('https://api.elevenlabs.io/v1/sound-generation', {
|
|
1094
|
+
method: 'POST',
|
|
1095
|
+
headers: {
|
|
1096
|
+
'xi-api-key': apiKey,
|
|
1097
|
+
'Content-Type': 'application/json',
|
|
1098
|
+
},
|
|
1099
|
+
body: JSON.stringify({
|
|
1100
|
+
text: prompt,
|
|
1101
|
+
duration_seconds: durationSeconds,
|
|
1102
|
+
prompt_influence: 0.45,
|
|
1103
|
+
model_id: 'eleven_text_to_sound_v2',
|
|
1104
|
+
}),
|
|
1105
|
+
});
|
|
1106
|
+
|
|
1107
|
+
if (!response.ok) {
|
|
1108
|
+
throw new Error(`Eleven Labs sound-generation failed [${response.status}]: ${await response.text()}`);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
1112
|
+
await writeFileImpl(outputPath, Buffer.from(arrayBuffer));
|
|
1113
|
+
|
|
1114
|
+
let persistedSoundPath = null;
|
|
1115
|
+
if (artifactsDir) {
|
|
1116
|
+
await mkdirImpl(resolve(artifactsDir), { recursive: true });
|
|
1117
|
+
persistedSoundPath = join(resolve(artifactsDir), 'slide-transition-sfx.mp3');
|
|
1118
|
+
await copyFile(resolve(outputPath), persistedSoundPath);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
return {
|
|
1122
|
+
soundPath: outputPath,
|
|
1123
|
+
persistedSoundPath,
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function buildTransitionPrimarySlideshowFilter({ imageCount, slideDurations, transitionDurationSeconds }) {
|
|
1128
|
+
const filterParts = [];
|
|
1129
|
+
|
|
1130
|
+
for (let index = 0; index < imageCount; index += 1) {
|
|
1131
|
+
filterParts.push(
|
|
1132
|
+
`[${index}:v]split=2[bg${index}][fg${index}]`,
|
|
1133
|
+
`[bg${index}]scale=${TARGET_VIDEO_WIDTH}:${TARGET_VIDEO_HEIGHT}:force_original_aspect_ratio=increase,crop=${TARGET_VIDEO_WIDTH}:${TARGET_VIDEO_HEIGHT},boxblur=20:10[bgblur${index}]`,
|
|
1134
|
+
`[fg${index}]scale=${TARGET_VIDEO_HEIGHT}:${TARGET_VIDEO_HEIGHT}:force_original_aspect_ratio=decrease[fgscaled${index}]`,
|
|
1135
|
+
`[bgblur${index}][fgscaled${index}]overlay=(W-w)/2:(H-h)/2,fps=${TARGET_VIDEO_FPS},setsar=1[slide${index}]`,
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
let currentLabel = '[slide0]';
|
|
1140
|
+
let currentOffset = slideDurations[0] || SLIDE_DURATION_SECONDS;
|
|
1141
|
+
for (let index = 1; index < imageCount; index += 1) {
|
|
1142
|
+
const outputLabel = index === imageCount - 1 ? '[vout]' : `[xfade${index}]`;
|
|
1143
|
+
filterParts.push(`${currentLabel}[slide${index}]xfade=transition=${DEFAULT_SLIDE_TRANSITION}:duration=${transitionDurationSeconds}:offset=${currentOffset}${outputLabel}`);
|
|
1144
|
+
currentLabel = outputLabel;
|
|
1145
|
+
currentOffset += (slideDurations[index] || SLIDE_DURATION_SECONDS) + transitionDurationSeconds;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
return filterParts.join(';');
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
function buildTransitionFallbackSlideshowFilter({ imageCount, slideDurations, transitionDurationSeconds }) {
|
|
1152
|
+
const filterParts = [];
|
|
1153
|
+
|
|
1154
|
+
for (let index = 0; index < imageCount; index += 1) {
|
|
1155
|
+
filterParts.push(
|
|
1156
|
+
`[${index}:v]scale=${TARGET_VIDEO_HEIGHT}:${TARGET_VIDEO_HEIGHT}:force_original_aspect_ratio=decrease,pad=w=${TARGET_VIDEO_WIDTH}:h=${TARGET_VIDEO_HEIGHT}:x=(ow-iw)/2:y=(oh-ih)/2:color=0x1A2B45,fps=${TARGET_VIDEO_FPS},setsar=1[slide${index}]`,
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
let currentLabel = '[slide0]';
|
|
1161
|
+
let currentOffset = slideDurations[0] || SLIDE_DURATION_SECONDS;
|
|
1162
|
+
for (let index = 1; index < imageCount; index += 1) {
|
|
1163
|
+
const outputLabel = index === imageCount - 1 ? '[vout]' : `[xfade${index}]`;
|
|
1164
|
+
filterParts.push(`${currentLabel}[slide${index}]xfade=transition=${DEFAULT_SLIDE_TRANSITION}:duration=${transitionDurationSeconds}:offset=${currentOffset}${outputLabel}`);
|
|
1165
|
+
currentLabel = outputLabel;
|
|
1166
|
+
currentOffset += (slideDurations[index] || SLIDE_DURATION_SECONDS) + transitionDurationSeconds;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
return filterParts.join(';');
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
export async function buildSlideshowVideo(options, deps = {}) {
|
|
1173
|
+
const {
|
|
1174
|
+
images,
|
|
1175
|
+
videoOutput,
|
|
1176
|
+
slideDurationSeconds = SLIDE_DURATION_SECONDS,
|
|
1177
|
+
backgroundAudioPath = null,
|
|
1178
|
+
narrationAudioPath = null,
|
|
1179
|
+
slideDurations = null,
|
|
1180
|
+
transitionDurationSeconds = 0,
|
|
1181
|
+
transitionSoundPath = null,
|
|
1182
|
+
} = options;
|
|
1183
|
+
const {
|
|
1184
|
+
spawnImpl = spawn,
|
|
1185
|
+
writeFileImpl = writeFile,
|
|
1186
|
+
mkdirImpl = mkdir,
|
|
1187
|
+
mkdtempImpl = mkdtemp,
|
|
1188
|
+
rmImpl = rm,
|
|
1189
|
+
ffmpegCommand = 'ffmpeg',
|
|
1190
|
+
} = deps;
|
|
1191
|
+
|
|
1192
|
+
const tempDir = await mkdtempImpl(join(tmpdir(), 'opensquad-youtube-'));
|
|
1193
|
+
const outputPath = resolve(videoOutput || join(tempDir, 'youtube-slideshow.mp4'));
|
|
1194
|
+
const concatFilePath = join(tempDir, 'slides.txt');
|
|
1195
|
+
const useTransitions = transitionDurationSeconds > 0 && images.length > 1;
|
|
1196
|
+
const effectiveSlideDurations = slideDurations && slideDurations.length > 0
|
|
1197
|
+
? slideDurations
|
|
1198
|
+
: images.map(() => slideDurationSeconds);
|
|
1199
|
+
const effectiveInputDurations = useTransitions
|
|
1200
|
+
? effectiveSlideDurations.map((duration, index) => {
|
|
1201
|
+
const isFirst = index === 0;
|
|
1202
|
+
const isLast = index === images.length - 1;
|
|
1203
|
+
const transitionPadding = isFirst || isLast
|
|
1204
|
+
? transitionDurationSeconds
|
|
1205
|
+
: transitionDurationSeconds * 2;
|
|
1206
|
+
return duration + transitionPadding;
|
|
1207
|
+
})
|
|
1208
|
+
: effectiveSlideDurations;
|
|
1209
|
+
const totalVideoDurationSeconds = effectiveSlideDurations.reduce((sum, duration, index) => (
|
|
1210
|
+
sum + duration + (useTransitions && index < images.length - 1 ? transitionDurationSeconds : 0)
|
|
1211
|
+
), 0);
|
|
1212
|
+
|
|
1213
|
+
const fadeOutStartSeconds = Math.max(totalVideoDurationSeconds - AUDIO_FADE_OUT_SECONDS, 0);
|
|
1214
|
+
|
|
1215
|
+
await mkdirImpl(dirname(outputPath), { recursive: true });
|
|
1216
|
+
if (!useTransitions) {
|
|
1217
|
+
const lines = [];
|
|
1218
|
+
for (let index = 0; index < images.length; index += 1) {
|
|
1219
|
+
lines.push(`file '${escapeConcatPath(resolve(images[index]))}'`);
|
|
1220
|
+
lines.push(`duration ${effectiveSlideDurations[index]}`);
|
|
1221
|
+
}
|
|
1222
|
+
lines.push(`file '${escapeConcatPath(resolve(images[images.length - 1]))}'`);
|
|
1223
|
+
await writeFileImpl(concatFilePath, `${lines.join('\n')}\n`, 'utf-8');
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
try {
|
|
1227
|
+
const buildFfmpegArgs = (filterComplexBuilder) => {
|
|
1228
|
+
const args = ['-y'];
|
|
1229
|
+
|
|
1230
|
+
if (useTransitions) {
|
|
1231
|
+
for (let index = 0; index < images.length; index += 1) {
|
|
1232
|
+
args.push('-loop', '1', '-t', String(effectiveInputDurations[index]), '-i', resolve(images[index]));
|
|
1233
|
+
}
|
|
1234
|
+
} else {
|
|
1235
|
+
args.push('-f', 'concat', '-safe', '0', '-i', concatFilePath);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
let narrationInputIdx = -1;
|
|
1239
|
+
let bgInputIdx = -1;
|
|
1240
|
+
let transitionSoundInputIdx = -1;
|
|
1241
|
+
let inputCount = useTransitions ? images.length : 1;
|
|
1242
|
+
|
|
1243
|
+
if (narrationAudioPath) {
|
|
1244
|
+
args.push('-i', resolve(narrationAudioPath));
|
|
1245
|
+
narrationInputIdx = inputCount++;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (backgroundAudioPath) {
|
|
1249
|
+
args.push('-stream_loop', '-1', '-i', resolve(backgroundAudioPath));
|
|
1250
|
+
bgInputIdx = inputCount++;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
if (transitionSoundPath) {
|
|
1254
|
+
args.push('-i', resolve(transitionSoundPath));
|
|
1255
|
+
transitionSoundInputIdx = inputCount++;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
const effectiveOverlayDuration = (slideDurations && slideDurations.length > 0)
|
|
1259
|
+
? (totalVideoDurationSeconds / images.length)
|
|
1260
|
+
: slideDurationSeconds;
|
|
1261
|
+
|
|
1262
|
+
const isFixedTiming = (!slideDurations || slideDurations.length === 0) && !useTransitions;
|
|
1263
|
+
const videoFilter = filterComplexBuilder({
|
|
1264
|
+
imageCount: images.length,
|
|
1265
|
+
slideDurations: effectiveSlideDurations,
|
|
1266
|
+
transitionDurationSeconds,
|
|
1267
|
+
slideDurationSeconds: effectiveOverlayDuration,
|
|
1268
|
+
totalVideoDurationSeconds,
|
|
1269
|
+
showProgressOverlay: isFixedTiming,
|
|
1270
|
+
});
|
|
1271
|
+
|
|
1272
|
+
let audioFilter = '';
|
|
1273
|
+
const mixLabels = [];
|
|
1274
|
+
if (narrationInputIdx !== -1) {
|
|
1275
|
+
audioFilter += `;[${narrationInputIdx}:a]volume=${NARRATION_AUDIO_VOLUME},apad,atrim=0:${totalVideoDurationSeconds}[narr]`;
|
|
1276
|
+
mixLabels.push('[narr]');
|
|
1277
|
+
}
|
|
1278
|
+
if (bgInputIdx !== -1) {
|
|
1279
|
+
const bgVolume = narrationInputIdx !== -1 ? BACKGROUND_AUDIO_VOLUME_WITH_VOICE : BACKGROUND_AUDIO_VOLUME;
|
|
1280
|
+
audioFilter += `;[${bgInputIdx}:a]volume=${bgVolume},atrim=0:${totalVideoDurationSeconds},afade=t=in:st=0:d=${AUDIO_FADE_IN_SECONDS},afade=t=out:st=${fadeOutStartSeconds}:d=${AUDIO_FADE_OUT_SECONDS}[bg]`;
|
|
1281
|
+
mixLabels.push('[bg]');
|
|
1282
|
+
}
|
|
1283
|
+
if (transitionSoundInputIdx !== -1 && useTransitions) {
|
|
1284
|
+
const transitionOffsets = [];
|
|
1285
|
+
let currentOffset = effectiveSlideDurations[0];
|
|
1286
|
+
for (let index = 1; index < images.length; index += 1) {
|
|
1287
|
+
transitionOffsets.push(currentOffset);
|
|
1288
|
+
currentOffset += effectiveSlideDurations[index] + transitionDurationSeconds;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
if (transitionOffsets.length === 1) {
|
|
1292
|
+
const delayMs = Math.max(Math.round(transitionOffsets[0] * 1000), 0);
|
|
1293
|
+
audioFilter += `;[${transitionSoundInputIdx}:a]atrim=0:${transitionDurationSeconds},volume=${TRANSITION_SOUND_EFFECT_VOLUME},adelay=${delayMs}|${delayMs}[sfx0]`;
|
|
1294
|
+
mixLabels.push('[sfx0]');
|
|
1295
|
+
} else if (transitionOffsets.length > 1) {
|
|
1296
|
+
const splitLabels = transitionOffsets.map((_, index) => `[sfxsrc${index}]`);
|
|
1297
|
+
audioFilter += `;[${transitionSoundInputIdx}:a]asplit=${splitLabels.length}${splitLabels.join('')}`;
|
|
1298
|
+
transitionOffsets.forEach((offsetSeconds, index) => {
|
|
1299
|
+
const delayMs = Math.max(Math.round(offsetSeconds * 1000), 0);
|
|
1300
|
+
audioFilter += `;[sfxsrc${index}]atrim=0:${transitionDurationSeconds},volume=${TRANSITION_SOUND_EFFECT_VOLUME},adelay=${delayMs}|${delayMs}[sfx${index}]`;
|
|
1301
|
+
mixLabels.push(`[sfx${index}]`);
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
if (mixLabels.length === 1) {
|
|
1307
|
+
audioFilter += `;${mixLabels[0]}anull[aout]`;
|
|
1308
|
+
} else if (mixLabels.length > 1) {
|
|
1309
|
+
audioFilter += `;${mixLabels.join('')}amix=inputs=${mixLabels.length}:duration=first:dropout_transition=0:normalize=0[aout]`;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const fadedVideoFilter = `${videoFilter};[vout]fade=t=in:st=0:d=0.666[vout_faded]`;
|
|
1313
|
+
args.push('-filter_complex', `${fadedVideoFilter}${audioFilter}`);
|
|
1314
|
+
args.push('-map', '[vout_faded]');
|
|
1315
|
+
|
|
1316
|
+
if (mixLabels.length > 0) {
|
|
1317
|
+
args.push('-map', '[aout]', '-c:a', 'aac', '-b:a', '192k');
|
|
1318
|
+
if (bgInputIdx !== -1 && narrationInputIdx === -1) {
|
|
1319
|
+
args.push('-shortest');
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
args.push('-r', String(TARGET_VIDEO_FPS));
|
|
1324
|
+
args.push('-pix_fmt', 'yuv420p');
|
|
1325
|
+
args.push('-c:v', 'libx264');
|
|
1326
|
+
args.push(outputPath);
|
|
1327
|
+
|
|
1328
|
+
return args;
|
|
1329
|
+
};
|
|
1330
|
+
|
|
1331
|
+
try {
|
|
1332
|
+
await runProcess(
|
|
1333
|
+
ffmpegCommand,
|
|
1334
|
+
buildFfmpegArgs(useTransitions ? buildTransitionPrimarySlideshowFilter : buildPrimarySlideshowFilter),
|
|
1335
|
+
{ spawnImpl, captureStdout: false }
|
|
1336
|
+
);
|
|
1337
|
+
} catch (primaryError) {
|
|
1338
|
+
await runProcess(
|
|
1339
|
+
ffmpegCommand,
|
|
1340
|
+
buildFfmpegArgs(useTransitions ? buildTransitionFallbackSlideshowFilter : buildFallbackSlideshowFilter),
|
|
1341
|
+
{ spawnImpl, captureStdout: false }
|
|
1342
|
+
);
|
|
1343
|
+
}
|
|
1344
|
+
return outputPath;
|
|
1345
|
+
} finally {
|
|
1346
|
+
await rmImpl(tempDir, { recursive: true, force: true });
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
export async function buildGeneratedThumbnail(options, deps = {}) {
|
|
1351
|
+
const {
|
|
1352
|
+
sourceImagePath,
|
|
1353
|
+
videoOutput,
|
|
1354
|
+
} = options;
|
|
1355
|
+
const {
|
|
1356
|
+
spawnImpl = spawn,
|
|
1357
|
+
mkdirImpl = mkdir,
|
|
1358
|
+
ffmpegCommand = 'ffmpeg',
|
|
1359
|
+
} = deps;
|
|
1360
|
+
|
|
1361
|
+
const sourcePath = resolve(sourceImagePath);
|
|
1362
|
+
const imageDir = dirname(sourcePath);
|
|
1363
|
+
const thumbnailDir = videoOutput
|
|
1364
|
+
? resolve(dirname(videoOutput), '..', 'thumbs')
|
|
1365
|
+
: resolve(imageDir, '..', 'thumbs');
|
|
1366
|
+
const outputPath = resolve(thumbnailDir, 'youtube-thumb-16x9.jpg');
|
|
1367
|
+
|
|
1368
|
+
await mkdirImpl(thumbnailDir, { recursive: true });
|
|
1369
|
+
try {
|
|
1370
|
+
await runProcess(
|
|
1371
|
+
ffmpegCommand,
|
|
1372
|
+
[
|
|
1373
|
+
'-y',
|
|
1374
|
+
'-i', sourcePath,
|
|
1375
|
+
'-filter_complex',
|
|
1376
|
+
'[0:v]split=2[bg][fg];[bg]scale=1280:720:force_original_aspect_ratio=increase,crop=1280:720,boxblur=20:10[bgblur];[fg]scale=720:720:force_original_aspect_ratio=decrease[fgscaled];[bgblur][fgscaled]overlay=(W-w)/2:(H-h)/2',
|
|
1377
|
+
'-frames:v', '1',
|
|
1378
|
+
'-q:v', '2',
|
|
1379
|
+
outputPath,
|
|
1380
|
+
],
|
|
1381
|
+
{ spawnImpl, captureStdout: false },
|
|
1382
|
+
);
|
|
1383
|
+
} catch {
|
|
1384
|
+
// Fallback for ffmpeg builds that lack boxblur or have stricter filter support.
|
|
1385
|
+
await runProcess(
|
|
1386
|
+
ffmpegCommand,
|
|
1387
|
+
[
|
|
1388
|
+
'-y',
|
|
1389
|
+
'-i', sourcePath,
|
|
1390
|
+
'-vf',
|
|
1391
|
+
'scale=720:720:force_original_aspect_ratio=decrease,pad=w=1280:h=720:x=(ow-iw)/2:y=(oh-ih)/2:color=0x1A2B45',
|
|
1392
|
+
'-frames:v', '1',
|
|
1393
|
+
'-q:v', '2',
|
|
1394
|
+
outputPath,
|
|
1395
|
+
],
|
|
1396
|
+
{ spawnImpl, captureStdout: false },
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
return outputPath;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
async function runProcess(command, args, options = {}) {
|
|
1404
|
+
const { spawnImpl = spawn, captureStdout = true } = options;
|
|
1405
|
+
|
|
1406
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
1407
|
+
const child = spawnImpl(command, args, { stdio: captureStdout ? ['ignore', 'pipe', 'pipe'] : ['ignore', 'ignore', 'pipe'] });
|
|
1408
|
+
let stderr = '';
|
|
1409
|
+
let stdout = '';
|
|
1410
|
+
|
|
1411
|
+
if (child.stdout) {
|
|
1412
|
+
child.stdout.on('data', (chunk) => {
|
|
1413
|
+
stdout += chunk.toString();
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (child.stderr) {
|
|
1418
|
+
child.stderr.on('data', (chunk) => {
|
|
1419
|
+
stderr += chunk.toString();
|
|
1420
|
+
});
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
child.on('error', rejectPromise);
|
|
1424
|
+
child.on('close', (code) => {
|
|
1425
|
+
if (code === 0) {
|
|
1426
|
+
resolvePromise({ stdout, stderr });
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
rejectPromise(new Error(`${command} exited with code ${code}${stderr ? `: ${stderr.trim()}` : ''}`));
|
|
1431
|
+
});
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
export async function exchangeRefreshToken(options, deps = {}) {
|
|
1436
|
+
const {
|
|
1437
|
+
clientId,
|
|
1438
|
+
clientSecret,
|
|
1439
|
+
refreshToken,
|
|
1440
|
+
fetchImpl = globalThis.fetch,
|
|
1441
|
+
} = { ...options, ...deps };
|
|
1442
|
+
|
|
1443
|
+
const body = new globalThis.URLSearchParams({
|
|
1444
|
+
client_id: clientId,
|
|
1445
|
+
client_secret: clientSecret,
|
|
1446
|
+
refresh_token: refreshToken,
|
|
1447
|
+
grant_type: 'refresh_token',
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
const response = await fetchImpl('https://oauth2.googleapis.com/token', {
|
|
1451
|
+
method: 'POST',
|
|
1452
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1453
|
+
body,
|
|
1454
|
+
});
|
|
1455
|
+
|
|
1456
|
+
if (!response.ok) {
|
|
1457
|
+
throw new Error(`OAuth token exchange failed [${response.status}]: ${await response.text()}`);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
const json = await response.json();
|
|
1461
|
+
if (!json.access_token) {
|
|
1462
|
+
throw new Error('OAuth token exchange succeeded without access_token');
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
return json.access_token;
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
export async function fetchOwnChannel(accessToken, channelId, deps = {}) {
|
|
1469
|
+
const { fetchImpl = globalThis.fetch } = deps;
|
|
1470
|
+
const url = `${YOUTUBE_API_BASE}/channels?part=snippet&mine=true`;
|
|
1471
|
+
const response = await fetchImpl(url, {
|
|
1472
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
if (!response.ok) {
|
|
1476
|
+
throw new Error(`Channel lookup failed [${response.status}]: ${await response.text()}`);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const json = await response.json();
|
|
1480
|
+
const channel = json.items?.find((entry) => entry.id === channelId);
|
|
1481
|
+
if (!channel) {
|
|
1482
|
+
throw new Error(`Authenticated Google account does not have access to YouTube channel '${channelId}'.`);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
return channel;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
export async function createResumableUploadSession(options, deps = {}) {
|
|
1489
|
+
const {
|
|
1490
|
+
accessToken,
|
|
1491
|
+
metadata,
|
|
1492
|
+
fetchImpl = globalThis.fetch,
|
|
1493
|
+
} = { ...options, ...deps };
|
|
1494
|
+
|
|
1495
|
+
const payload = {
|
|
1496
|
+
snippet: {
|
|
1497
|
+
title: metadata.title,
|
|
1498
|
+
description: metadata.description,
|
|
1499
|
+
categoryId: metadata.categoryId,
|
|
1500
|
+
tags: metadata.tags,
|
|
1501
|
+
},
|
|
1502
|
+
status: {
|
|
1503
|
+
privacyStatus: metadata.privacyStatus,
|
|
1504
|
+
embeddable: metadata.embeddable,
|
|
1505
|
+
publicStatsViewable: metadata.publicStatsViewable,
|
|
1506
|
+
license: metadata.license,
|
|
1507
|
+
selfDeclaredMadeForKids: metadata.madeForKids,
|
|
1508
|
+
},
|
|
1509
|
+
};
|
|
1510
|
+
|
|
1511
|
+
if (metadata.publishAt) {
|
|
1512
|
+
payload.status.publishAt = metadata.publishAt;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
if (metadata.defaultLanguage) {
|
|
1516
|
+
payload.snippet.defaultLanguage = metadata.defaultLanguage;
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
if (metadata.defaultAudioLanguage) {
|
|
1520
|
+
payload.snippet.defaultAudioLanguage = metadata.defaultAudioLanguage;
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
if (metadata.recordingDate) {
|
|
1524
|
+
payload.recordingDetails = {
|
|
1525
|
+
recordingDate: metadata.recordingDate,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const parts = ['snippet', 'status'];
|
|
1530
|
+
if (payload.recordingDetails) {
|
|
1531
|
+
parts.push('recordingDetails');
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
const url = `${YOUTUBE_UPLOAD_BASE}?uploadType=resumable&part=${parts.join(',')}`;
|
|
1535
|
+
const response = await fetchImpl(url, {
|
|
1536
|
+
method: 'POST',
|
|
1537
|
+
headers: {
|
|
1538
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1539
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
1540
|
+
'X-Upload-Content-Type': 'video/mp4',
|
|
1541
|
+
},
|
|
1542
|
+
body: JSON.stringify(payload),
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
if (!response.ok) {
|
|
1546
|
+
throw new Error(`Upload session creation failed [${response.status}]: ${await response.text()}`);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
const uploadUrl = response.headers.get('location');
|
|
1550
|
+
if (!uploadUrl) {
|
|
1551
|
+
throw new Error('Upload session created without resumable location header');
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
return uploadUrl;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
export async function uploadVideoBinary(options, deps = {}) {
|
|
1558
|
+
const {
|
|
1559
|
+
uploadUrl,
|
|
1560
|
+
videoPath,
|
|
1561
|
+
fetchImpl = globalThis.fetch,
|
|
1562
|
+
createReadStreamImpl = createReadStream,
|
|
1563
|
+
} = { ...options, ...deps };
|
|
1564
|
+
|
|
1565
|
+
const response = await fetchImpl(uploadUrl, {
|
|
1566
|
+
method: 'PUT',
|
|
1567
|
+
headers: {
|
|
1568
|
+
'Content-Type': 'video/mp4',
|
|
1569
|
+
},
|
|
1570
|
+
body: createReadStreamImpl(videoPath),
|
|
1571
|
+
duplex: 'half',
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
if (!response.ok) {
|
|
1575
|
+
throw new Error(`Video upload failed [${response.status}]: ${await response.text()}`);
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
const json = await response.json();
|
|
1579
|
+
if (!json.id) {
|
|
1580
|
+
throw new Error('Video upload succeeded without a returned video ID');
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
return json;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
export async function uploadThumbnail(options, deps = {}) {
|
|
1587
|
+
const {
|
|
1588
|
+
accessToken,
|
|
1589
|
+
videoId,
|
|
1590
|
+
thumbnailPath,
|
|
1591
|
+
fetchImpl = globalThis.fetch,
|
|
1592
|
+
createReadStreamImpl = createReadStream,
|
|
1593
|
+
} = { ...options, ...deps };
|
|
1594
|
+
|
|
1595
|
+
const extension = extname(thumbnailPath).toLowerCase();
|
|
1596
|
+
const contentType = 'image/jpeg';
|
|
1597
|
+
const url = `${YOUTUBE_THUMBNAIL_UPLOAD_BASE}?videoId=${encodeURIComponent(videoId)}`;
|
|
1598
|
+
const response = await fetchImpl(url, {
|
|
1599
|
+
method: 'POST',
|
|
1600
|
+
headers: {
|
|
1601
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1602
|
+
'Content-Type': contentType,
|
|
1603
|
+
},
|
|
1604
|
+
body: createReadStreamImpl(thumbnailPath),
|
|
1605
|
+
duplex: 'half',
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
if (!response.ok) {
|
|
1609
|
+
throw new Error(`Thumbnail upload failed [${response.status}]: ${await response.text()}`);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
return response.json();
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
export async function ensurePlaylist(options, deps = {}) {
|
|
1616
|
+
const {
|
|
1617
|
+
accessToken,
|
|
1618
|
+
playlistName,
|
|
1619
|
+
playlistPrivacy = 'private',
|
|
1620
|
+
fetchImpl = globalThis.fetch,
|
|
1621
|
+
} = { ...options, ...deps };
|
|
1622
|
+
|
|
1623
|
+
if (!playlistName) return null;
|
|
1624
|
+
if (!['private', 'public'].includes(playlistPrivacy)) {
|
|
1625
|
+
throw new Error(`Unsupported playlist privacy '${playlistPrivacy}'. Use private or public.`);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
const listResponse = await fetchImpl(`${YOUTUBE_API_BASE}/playlists?part=snippet,status&mine=true&maxResults=50`, {
|
|
1629
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1632
|
+
if (!listResponse.ok) {
|
|
1633
|
+
throw new Error(`Playlist lookup failed [${listResponse.status}]: ${await listResponse.text()}`);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
const listJson = await listResponse.json();
|
|
1637
|
+
const existing = listJson.items?.find((entry) => entry.snippet?.title === playlistName);
|
|
1638
|
+
if (existing) return existing;
|
|
1639
|
+
|
|
1640
|
+
const createResponse = await fetchImpl(`${YOUTUBE_API_BASE}/playlists?part=snippet,status`, {
|
|
1641
|
+
method: 'POST',
|
|
1642
|
+
headers: {
|
|
1643
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1644
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
1645
|
+
},
|
|
1646
|
+
body: JSON.stringify({
|
|
1647
|
+
snippet: {
|
|
1648
|
+
title: playlistName,
|
|
1649
|
+
},
|
|
1650
|
+
status: {
|
|
1651
|
+
privacyStatus: playlistPrivacy,
|
|
1652
|
+
},
|
|
1653
|
+
}),
|
|
1654
|
+
});
|
|
1655
|
+
|
|
1656
|
+
if (!createResponse.ok) {
|
|
1657
|
+
throw new Error(`Playlist creation failed [${createResponse.status}]: ${await createResponse.text()}`);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
return createResponse.json();
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
export async function addVideoToPlaylist(options, deps = {}) {
|
|
1664
|
+
const {
|
|
1665
|
+
accessToken,
|
|
1666
|
+
playlistId,
|
|
1667
|
+
videoId,
|
|
1668
|
+
fetchImpl = globalThis.fetch,
|
|
1669
|
+
} = { ...options, ...deps };
|
|
1670
|
+
|
|
1671
|
+
if (!playlistId) return false;
|
|
1672
|
+
|
|
1673
|
+
const existingResponse = await fetchImpl(`${YOUTUBE_API_BASE}/playlistItems?part=snippet&playlistId=${encodeURIComponent(playlistId)}&videoId=${encodeURIComponent(videoId)}&maxResults=50`, {
|
|
1674
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
if (!existingResponse.ok) {
|
|
1678
|
+
if (existingResponse.status === 404) {
|
|
1679
|
+
// Newly-created playlists may return 404 on items lookup due to propagation delay.
|
|
1680
|
+
// Treat as "not in playlist" and proceed to add.
|
|
1681
|
+
} else {
|
|
1682
|
+
throw new Error(`Playlist item lookup failed [${existingResponse.status}]: ${await existingResponse.text()}`);
|
|
1683
|
+
}
|
|
1684
|
+
} else {
|
|
1685
|
+
const existingJson = await existingResponse.json();
|
|
1686
|
+
if (existingJson.items?.some((entry) => entry.snippet?.resourceId?.videoId === videoId)) {
|
|
1687
|
+
return false;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
const insertResponse = await fetchImpl(`${YOUTUBE_API_BASE}/playlistItems?part=snippet`, {
|
|
1692
|
+
method: 'POST',
|
|
1693
|
+
headers: {
|
|
1694
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1695
|
+
'Content-Type': 'application/json; charset=UTF-8',
|
|
1696
|
+
},
|
|
1697
|
+
body: JSON.stringify({
|
|
1698
|
+
snippet: {
|
|
1699
|
+
playlistId,
|
|
1700
|
+
resourceId: {
|
|
1701
|
+
kind: 'youtube#video',
|
|
1702
|
+
videoId,
|
|
1703
|
+
},
|
|
1704
|
+
},
|
|
1705
|
+
}),
|
|
1706
|
+
});
|
|
1707
|
+
|
|
1708
|
+
if (!insertResponse.ok) {
|
|
1709
|
+
throw new Error(`Playlist insertion failed [${insertResponse.status}]: ${await insertResponse.text()}`);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
await insertResponse.json();
|
|
1713
|
+
return true;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
export async function publishVideo(options, env = process.env, deps = {}) {
|
|
1717
|
+
const {
|
|
1718
|
+
logger = console,
|
|
1719
|
+
ensureImageFilesImpl = ensureImageFiles,
|
|
1720
|
+
ensureThumbnailFileImpl = ensureThumbnailFile,
|
|
1721
|
+
findContentPackageImpl = findContentPackage,
|
|
1722
|
+
buildYouTubeDescriptionFromContentPackageImpl = buildYouTubeDescriptionFromContentPackage,
|
|
1723
|
+
resolveBackgroundAudioPathImpl = resolveBackgroundAudioPath,
|
|
1724
|
+
ensureFfmpegAvailableImpl = ensureFfmpegAvailable,
|
|
1725
|
+
buildSlideshowVideoImpl = buildSlideshowVideo,
|
|
1726
|
+
buildGeneratedThumbnailImpl = buildGeneratedThumbnail,
|
|
1727
|
+
normalizeVideoMetadataImpl = normalizeVideoMetadata,
|
|
1728
|
+
exchangeRefreshTokenImpl = exchangeRefreshToken,
|
|
1729
|
+
fetchOwnChannelImpl = fetchOwnChannel,
|
|
1730
|
+
createResumableUploadSessionImpl = createResumableUploadSession,
|
|
1731
|
+
uploadVideoBinaryImpl = uploadVideoBinary,
|
|
1732
|
+
uploadThumbnailImpl = uploadThumbnail,
|
|
1733
|
+
ensurePlaylistImpl = ensurePlaylist,
|
|
1734
|
+
addVideoToPlaylistImpl = addVideoToPlaylist,
|
|
1735
|
+
resolveBundledTransitionSoundAssetPathImpl = resolveBundledTransitionSoundAssetPath,
|
|
1736
|
+
generateTransitionSoundEffectImpl = generateTransitionSoundEffect,
|
|
1737
|
+
} = deps;
|
|
1738
|
+
|
|
1739
|
+
const {
|
|
1740
|
+
images,
|
|
1741
|
+
title,
|
|
1742
|
+
description,
|
|
1743
|
+
tags,
|
|
1744
|
+
thumbnailPath,
|
|
1745
|
+
categoryId,
|
|
1746
|
+
privacyStatus,
|
|
1747
|
+
madeForKids,
|
|
1748
|
+
embeddable,
|
|
1749
|
+
publicStatsViewable,
|
|
1750
|
+
license,
|
|
1751
|
+
publishAt,
|
|
1752
|
+
defaultLanguage,
|
|
1753
|
+
defaultAudioLanguage,
|
|
1754
|
+
recordingDate,
|
|
1755
|
+
videoOutput,
|
|
1756
|
+
audioTrackPath,
|
|
1757
|
+
playlistName,
|
|
1758
|
+
playlistPrivacy,
|
|
1759
|
+
dryRun,
|
|
1760
|
+
youtubeClientIdEnv,
|
|
1761
|
+
youtubeClientSecretEnv,
|
|
1762
|
+
youtubeRefreshTokenEnv,
|
|
1763
|
+
youtubeChannelIdEnv,
|
|
1764
|
+
voiceId = ELEVEN_LABS_VOICE_ID,
|
|
1765
|
+
titleVoiceId = DEFAULT_TITLE_VOICE_ID,
|
|
1766
|
+
narration = true,
|
|
1767
|
+
prebuiltNarrationAudioPath = '',
|
|
1768
|
+
prebuiltSlideDurations = null,
|
|
1769
|
+
} = options;
|
|
1770
|
+
|
|
1771
|
+
if (!images.length) throw new Error('--images is required');
|
|
1772
|
+
if (!title) throw new Error('--title is required');
|
|
1773
|
+
if (!['private', 'public', 'unlisted'].includes(privacyStatus)) {
|
|
1774
|
+
throw new Error(`Unsupported privacy status '${privacyStatus}'. Use private, public or unlisted.`);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
let contentPackagePath = null;
|
|
1778
|
+
let resolvedDescription = String(description || '').trim();
|
|
1779
|
+
|
|
1780
|
+
if (!resolvedDescription || narration !== false) {
|
|
1781
|
+
contentPackagePath = await findContentPackageImpl(images);
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
if (contentPackagePath) {
|
|
1785
|
+
resolvedDescription = await buildYouTubeDescriptionFromContentPackageImpl(contentPackagePath, resolvedDescription);
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
if (!resolvedDescription) {
|
|
1789
|
+
throw new Error('--description is required or content-package.md must provide a usable Legenda section');
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
const metadata = normalizeVideoMetadataImpl({
|
|
1793
|
+
title,
|
|
1794
|
+
description: resolvedDescription,
|
|
1795
|
+
tags,
|
|
1796
|
+
categoryId,
|
|
1797
|
+
privacyStatus,
|
|
1798
|
+
madeForKids,
|
|
1799
|
+
embeddable,
|
|
1800
|
+
publicStatsViewable,
|
|
1801
|
+
license,
|
|
1802
|
+
publishAt,
|
|
1803
|
+
defaultLanguage,
|
|
1804
|
+
defaultAudioLanguage,
|
|
1805
|
+
recordingDate,
|
|
1806
|
+
});
|
|
1807
|
+
|
|
1808
|
+
const configuredEnv = resolveConfiguredEnv(env, {
|
|
1809
|
+
youtubeClientIdEnv,
|
|
1810
|
+
youtubeClientSecretEnv,
|
|
1811
|
+
youtubeRefreshTokenEnv,
|
|
1812
|
+
youtubeChannelIdEnv,
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
const {
|
|
1816
|
+
youtubeClientIdKey,
|
|
1817
|
+
youtubeClientSecretKey,
|
|
1818
|
+
youtubeRefreshTokenKey,
|
|
1819
|
+
youtubeChannelIdKey,
|
|
1820
|
+
youtubeClientId,
|
|
1821
|
+
youtubeClientSecret,
|
|
1822
|
+
youtubeRefreshToken,
|
|
1823
|
+
youtubeChannelId,
|
|
1824
|
+
} = configuredEnv;
|
|
1825
|
+
|
|
1826
|
+
if (!youtubeClientId) throw new Error(`${youtubeClientIdKey} is not set in environment`);
|
|
1827
|
+
if (!youtubeClientSecret) throw new Error(`${youtubeClientSecretKey} is not set in environment`);
|
|
1828
|
+
if (!youtubeRefreshToken) throw new Error(`${youtubeRefreshTokenKey} is not set in environment`);
|
|
1829
|
+
if (!youtubeChannelId) throw new Error(`${youtubeChannelIdKey} is not set in environment`);
|
|
1830
|
+
|
|
1831
|
+
logger.log(`š Validating ${images.length} image(s)...`);
|
|
1832
|
+
await ensureImageFilesImpl(images);
|
|
1833
|
+
|
|
1834
|
+
let resolvedThumbnailPath = null;
|
|
1835
|
+
if (thumbnailPath) {
|
|
1836
|
+
logger.log('š¼ļø Validating thumbnail...');
|
|
1837
|
+
resolvedThumbnailPath = await ensureThumbnailFileImpl(thumbnailPath);
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
logger.log('š¬ Validating ffmpeg...');
|
|
1841
|
+
const ffmpegCommand = await ensureFfmpegAvailableImpl();
|
|
1842
|
+
|
|
1843
|
+
const resolvedAudioTrackPath = await resolveBackgroundAudioPathImpl(audioTrackPath);
|
|
1844
|
+
if (resolvedAudioTrackPath) {
|
|
1845
|
+
logger.log(`šµ Using background audio: ${resolvedAudioTrackPath}`);
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
if (!resolvedThumbnailPath) {
|
|
1849
|
+
logger.log('š¼ļø Generating thumbnail from the first slide...');
|
|
1850
|
+
const generatedThumbnailPath = await buildGeneratedThumbnailImpl({
|
|
1851
|
+
sourceImagePath: images[Math.min(1, images.length - 1)],
|
|
1852
|
+
videoOutput,
|
|
1853
|
+
}, { ffmpegCommand });
|
|
1854
|
+
resolvedThumbnailPath = await ensureThumbnailFileImpl(generatedThumbnailPath);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
let tempAudioDir = null;
|
|
1858
|
+
let narrationAudioPath = null;
|
|
1859
|
+
let transitionSoundPath = null;
|
|
1860
|
+
let slideDurations = null;
|
|
1861
|
+
const elevenLabsApiKey = env.ELEVEN_LABS_API;
|
|
1862
|
+
const narrationArtifactsDir = resolveNarrationArtifactsDir(images);
|
|
1863
|
+
|
|
1864
|
+
if (prebuiltNarrationAudioPath) {
|
|
1865
|
+
logger.log(`šļø Reusing prebuilt narration: ${prebuiltNarrationAudioPath}`);
|
|
1866
|
+
narrationAudioPath = prebuiltNarrationAudioPath;
|
|
1867
|
+
slideDurations = prebuiltSlideDurations || null;
|
|
1868
|
+
if (!slideDurations) {
|
|
1869
|
+
logger.log('ā ļø --slide-durations not provided with --narration-audio. Slide timing will be auto-computed from the narration file durations via ffprobe.');
|
|
1870
|
+
}
|
|
1871
|
+
} else if (narration !== false) {
|
|
1872
|
+
if (!elevenLabsApiKey) {
|
|
1873
|
+
throw new Error('ELEVEN_LABS_API key is not set in the environment, but narration is enabled by default. Use --no-narration if you want to skip narration.');
|
|
1874
|
+
}
|
|
1875
|
+
logger.log('šļø Generating narration track...');
|
|
1876
|
+
try {
|
|
1877
|
+
if (contentPackagePath) {
|
|
1878
|
+
logger.log(`š Found content-package: ${contentPackagePath}`);
|
|
1879
|
+
tempAudioDir = await mkdtemp(join(tmpdir(), 'opensquad-tts-'));
|
|
1880
|
+
const narrationResult = await buildNarrationTrack(
|
|
1881
|
+
images,
|
|
1882
|
+
contentPackagePath,
|
|
1883
|
+
SLIDE_DURATION_SECONDS,
|
|
1884
|
+
elevenLabsApiKey,
|
|
1885
|
+
ffmpegCommand,
|
|
1886
|
+
tempAudioDir,
|
|
1887
|
+
logger,
|
|
1888
|
+
spawn,
|
|
1889
|
+
voiceId,
|
|
1890
|
+
titleVoiceId,
|
|
1891
|
+
narrationArtifactsDir
|
|
1892
|
+
);
|
|
1893
|
+
narrationAudioPath = narrationResult.narrationPath;
|
|
1894
|
+
slideDurations = narrationResult.slideDurations;
|
|
1895
|
+
logger.log(`š Narration track generated: ${narrationAudioPath}`);
|
|
1896
|
+
if (narrationResult.persistedNarrationPath) {
|
|
1897
|
+
logger.log(`š¾ Narration assets saved to: ${narrationArtifactsDir}`);
|
|
1898
|
+
}
|
|
1899
|
+
} else {
|
|
1900
|
+
logger.log('ā ļø Could not locate content-package.md. Skipping narration.');
|
|
1901
|
+
}
|
|
1902
|
+
} catch (ttsErr) {
|
|
1903
|
+
logger.log(`ā ļø Narration track generation failed: ${ttsErr.message}. Proceeding without narration.`);
|
|
1904
|
+
narrationAudioPath = null;
|
|
1905
|
+
slideDurations = null;
|
|
1906
|
+
}
|
|
1907
|
+
} else {
|
|
1908
|
+
logger.log('ā¹ļø Narration disabled via flag. Skipping slide narration.');
|
|
1909
|
+
if (prebuiltSlideDurations) {
|
|
1910
|
+
slideDurations = prebuiltSlideDurations;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
if (images.length > 1) {
|
|
1915
|
+
try {
|
|
1916
|
+
const bundledTransitionSoundPath = await resolveBundledTransitionSoundAssetPathImpl();
|
|
1917
|
+
if (bundledTransitionSoundPath) {
|
|
1918
|
+
transitionSoundPath = bundledTransitionSoundPath;
|
|
1919
|
+
logger.log(`šµ Using bundled slide transition sound: ${bundledTransitionSoundPath}`);
|
|
1920
|
+
if (narrationArtifactsDir) {
|
|
1921
|
+
try {
|
|
1922
|
+
await mkdir(resolve(narrationArtifactsDir), { recursive: true });
|
|
1923
|
+
await copyFile(resolve(bundledTransitionSoundPath), join(resolve(narrationArtifactsDir), 'slide-transition-sfx.mp3'));
|
|
1924
|
+
logger.log(`š¾ Slide transition sound saved to: ${narrationArtifactsDir}`);
|
|
1925
|
+
} catch (persistErr) {
|
|
1926
|
+
logger.log(`ā ļø Failed to persist bundled slide transition sound: ${persistErr.message}`);
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
} else {
|
|
1930
|
+
if (!elevenLabsApiKey) {
|
|
1931
|
+
throw new Error('ELEVEN_LABS_API key not available and no bundled transition sound asset was found');
|
|
1932
|
+
}
|
|
1933
|
+
if (!tempAudioDir) {
|
|
1934
|
+
tempAudioDir = await mkdtemp(join(tmpdir(), 'opensquad-tts-'));
|
|
1935
|
+
}
|
|
1936
|
+
const transitionSoundResult = await generateTransitionSoundEffectImpl({
|
|
1937
|
+
apiKey: elevenLabsApiKey,
|
|
1938
|
+
tempDir: tempAudioDir,
|
|
1939
|
+
logger,
|
|
1940
|
+
durationSeconds: SLIDE_TRANSITION_DURATION_SECONDS,
|
|
1941
|
+
artifactsDir: narrationArtifactsDir,
|
|
1942
|
+
});
|
|
1943
|
+
transitionSoundPath = transitionSoundResult.soundPath;
|
|
1944
|
+
if (transitionSoundResult.persistedSoundPath) {
|
|
1945
|
+
logger.log(`š¾ Slide transition sound saved to: ${narrationArtifactsDir}`);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
} catch (transitionErr) {
|
|
1949
|
+
logger.log(`ā ļø Slide transition sound generation failed: ${transitionErr.message}. Proceeding without transition SFX.`);
|
|
1950
|
+
transitionSoundPath = null;
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
let generatedVideoPath;
|
|
1955
|
+
try {
|
|
1956
|
+
logger.log('š§± Building slideshow video...');
|
|
1957
|
+
generatedVideoPath = await buildSlideshowVideoImpl({
|
|
1958
|
+
images,
|
|
1959
|
+
videoOutput,
|
|
1960
|
+
slideDurationSeconds: SLIDE_DURATION_SECONDS,
|
|
1961
|
+
backgroundAudioPath: resolvedAudioTrackPath,
|
|
1962
|
+
narrationAudioPath,
|
|
1963
|
+
slideDurations,
|
|
1964
|
+
transitionDurationSeconds: images.length > 1 ? SLIDE_TRANSITION_DURATION_SECONDS : 0,
|
|
1965
|
+
transitionSoundPath,
|
|
1966
|
+
}, { ffmpegCommand });
|
|
1967
|
+
logger.log(` Video: ${generatedVideoPath}`);
|
|
1968
|
+
} finally {
|
|
1969
|
+
if (tempAudioDir) {
|
|
1970
|
+
try {
|
|
1971
|
+
await rm(tempAudioDir, { recursive: true, force: true });
|
|
1972
|
+
} catch (rmErr) {
|
|
1973
|
+
logger.log(`ā ļø Failed to clean up temp audio directory: ${rmErr.message}`);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
logger.log('š Refreshing YouTube OAuth token...');
|
|
1979
|
+
const accessToken = await exchangeRefreshTokenImpl({
|
|
1980
|
+
clientId: youtubeClientId,
|
|
1981
|
+
clientSecret: youtubeClientSecret,
|
|
1982
|
+
refreshToken: youtubeRefreshToken,
|
|
1983
|
+
});
|
|
1984
|
+
|
|
1985
|
+
logger.log('šŗ Validating channel access...');
|
|
1986
|
+
const channel = await fetchOwnChannelImpl(accessToken, youtubeChannelId);
|
|
1987
|
+
|
|
1988
|
+
const result = {
|
|
1989
|
+
status: dryRun ? 'dry-run' : 'published',
|
|
1990
|
+
youtubeVideoId: null,
|
|
1991
|
+
youtubeUrl: null,
|
|
1992
|
+
generatedVideoPath,
|
|
1993
|
+
thumbnailPath: resolvedThumbnailPath,
|
|
1994
|
+
thumbnailUploaded: false,
|
|
1995
|
+
thumbnailUploadError: null,
|
|
1996
|
+
channelId: channel.id,
|
|
1997
|
+
channelTitle: channel.snippet?.title ?? null,
|
|
1998
|
+
playlistId: null,
|
|
1999
|
+
playlistName: playlistName || null,
|
|
2000
|
+
playlistAssigned: false,
|
|
2001
|
+
metadata,
|
|
2002
|
+
scope: YOUTUBE_OAUTH_SCOPES,
|
|
2003
|
+
};
|
|
2004
|
+
|
|
2005
|
+
if (dryRun) {
|
|
2006
|
+
logger.log('ā
DRY RUN complete ā upload skipped intentionally.');
|
|
2007
|
+
return result;
|
|
2008
|
+
}
|
|
2009
|
+
|
|
2010
|
+
logger.log('āļø Creating resumable upload session...');
|
|
2011
|
+
const uploadUrl = await createResumableUploadSessionImpl({
|
|
2012
|
+
accessToken,
|
|
2013
|
+
metadata,
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
logger.log('š Uploading video to YouTube...');
|
|
2017
|
+
const uploadResult = await uploadVideoBinaryImpl({
|
|
2018
|
+
uploadUrl,
|
|
2019
|
+
videoPath: generatedVideoPath,
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
result.youtubeVideoId = uploadResult.id;
|
|
2023
|
+
result.youtubeUrl = `https://www.youtube.com/watch?v=${uploadResult.id}`;
|
|
2024
|
+
|
|
2025
|
+
if (resolvedThumbnailPath) {
|
|
2026
|
+
logger.log('š¼ļø Uploading thumbnail...');
|
|
2027
|
+
try {
|
|
2028
|
+
await uploadThumbnailImpl({
|
|
2029
|
+
accessToken,
|
|
2030
|
+
videoId: uploadResult.id,
|
|
2031
|
+
thumbnailPath: resolvedThumbnailPath,
|
|
2032
|
+
});
|
|
2033
|
+
result.thumbnailUploaded = true;
|
|
2034
|
+
} catch (error) {
|
|
2035
|
+
result.thumbnailUploadError = error.message;
|
|
2036
|
+
logger.log(`ā ļø Thumbnail upload skipped: ${error.message}`);
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
if (playlistName) {
|
|
2041
|
+
logger.log(`š Ensuring playlist '${playlistName}'...`);
|
|
2042
|
+
const playlist = await ensurePlaylistImpl({
|
|
2043
|
+
accessToken,
|
|
2044
|
+
playlistName,
|
|
2045
|
+
playlistPrivacy,
|
|
2046
|
+
});
|
|
2047
|
+
result.playlistId = playlist.id;
|
|
2048
|
+
result.playlistName = playlist.snippet?.title ?? playlistName;
|
|
2049
|
+
|
|
2050
|
+
logger.log('ā Adding video to playlist...');
|
|
2051
|
+
result.playlistAssigned = await addVideoToPlaylistImpl({
|
|
2052
|
+
accessToken,
|
|
2053
|
+
playlistId: playlist.id,
|
|
2054
|
+
videoId: uploadResult.id,
|
|
2055
|
+
});
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
logger.log(`ā
YouTube: Published successfully (${result.youtubeUrl})`);
|
|
2059
|
+
return result;
|
|
2060
|
+
}
|
|
2061
|
+
|
|
2062
|
+
async function main() {
|
|
2063
|
+
const args = parseArgs(process.argv);
|
|
2064
|
+
if (args.deleteOnly || args.deleteVideoId) {
|
|
2065
|
+
await deleteVideo(args);
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
await publishVideo(args);
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
2073
|
+
if (isMain) {
|
|
2074
|
+
main().catch((error) => {
|
|
2075
|
+
console.error(`\nā ${error.message}`);
|
|
2076
|
+
process.exit(1);
|
|
2077
|
+
});
|
|
2078
|
+
}
|