@vibeframe/cli 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-lint.log +21 -0
- package/.turbo/turbo-test.log +689 -0
- package/dist/agent/adapters/claude.d.ts +15 -0
- package/dist/agent/adapters/claude.d.ts.map +1 -0
- package/dist/agent/adapters/claude.js +119 -0
- package/dist/agent/adapters/claude.js.map +1 -0
- package/dist/agent/adapters/gemini.d.ts +15 -0
- package/dist/agent/adapters/gemini.d.ts.map +1 -0
- package/dist/agent/adapters/gemini.js +132 -0
- package/dist/agent/adapters/gemini.js.map +1 -0
- package/dist/agent/adapters/index.d.ts +27 -0
- package/dist/agent/adapters/index.d.ts.map +1 -0
- package/dist/agent/adapters/index.js +38 -0
- package/dist/agent/adapters/index.js.map +1 -0
- package/dist/agent/adapters/ollama.d.ts +20 -0
- package/dist/agent/adapters/ollama.d.ts.map +1 -0
- package/dist/agent/adapters/ollama.js +186 -0
- package/dist/agent/adapters/ollama.js.map +1 -0
- package/dist/agent/adapters/openai.d.ts +15 -0
- package/dist/agent/adapters/openai.d.ts.map +1 -0
- package/dist/agent/adapters/openai.js +92 -0
- package/dist/agent/adapters/openai.js.map +1 -0
- package/dist/agent/adapters/xai.d.ts +15 -0
- package/dist/agent/adapters/xai.d.ts.map +1 -0
- package/dist/agent/adapters/xai.js +95 -0
- package/dist/agent/adapters/xai.js.map +1 -0
- package/dist/agent/index.d.ts +69 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +180 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/memory/index.d.ts +70 -0
- package/dist/agent/memory/index.d.ts.map +1 -0
- package/dist/agent/memory/index.js +132 -0
- package/dist/agent/memory/index.js.map +1 -0
- package/dist/agent/prompts/system.d.ts +6 -0
- package/dist/agent/prompts/system.d.ts.map +1 -0
- package/dist/agent/prompts/system.js +103 -0
- package/dist/agent/prompts/system.js.map +1 -0
- package/dist/agent/tools/ai-editing.d.ts +15 -0
- package/dist/agent/tools/ai-editing.d.ts.map +1 -0
- package/dist/agent/tools/ai-editing.js +763 -0
- package/dist/agent/tools/ai-editing.js.map +1 -0
- package/dist/agent/tools/ai-generation.d.ts +13 -0
- package/dist/agent/tools/ai-generation.d.ts.map +1 -0
- package/dist/agent/tools/ai-generation.js +973 -0
- package/dist/agent/tools/ai-generation.js.map +1 -0
- package/dist/agent/tools/ai-pipeline.d.ts +14 -0
- package/dist/agent/tools/ai-pipeline.d.ts.map +1 -0
- package/dist/agent/tools/ai-pipeline.js +961 -0
- package/dist/agent/tools/ai-pipeline.js.map +1 -0
- package/dist/agent/tools/ai.d.ts +13 -0
- package/dist/agent/tools/ai.d.ts.map +1 -0
- package/dist/agent/tools/ai.js +19 -0
- package/dist/agent/tools/ai.js.map +1 -0
- package/dist/agent/tools/batch.d.ts +6 -0
- package/dist/agent/tools/batch.d.ts.map +1 -0
- package/dist/agent/tools/batch.js +383 -0
- package/dist/agent/tools/batch.js.map +1 -0
- package/dist/agent/tools/e2e.test.d.ts +26 -0
- package/dist/agent/tools/e2e.test.d.ts.map +1 -0
- package/dist/agent/tools/e2e.test.js +397 -0
- package/dist/agent/tools/e2e.test.js.map +1 -0
- package/dist/agent/tools/export.d.ts +6 -0
- package/dist/agent/tools/export.d.ts.map +1 -0
- package/dist/agent/tools/export.js +171 -0
- package/dist/agent/tools/export.js.map +1 -0
- package/dist/agent/tools/filesystem.d.ts +6 -0
- package/dist/agent/tools/filesystem.d.ts.map +1 -0
- package/dist/agent/tools/filesystem.js +212 -0
- package/dist/agent/tools/filesystem.js.map +1 -0
- package/dist/agent/tools/index.d.ts +65 -0
- package/dist/agent/tools/index.d.ts.map +1 -0
- package/dist/agent/tools/index.js +120 -0
- package/dist/agent/tools/index.js.map +1 -0
- package/dist/agent/tools/integration.test.d.ts +11 -0
- package/dist/agent/tools/integration.test.d.ts.map +1 -0
- package/dist/agent/tools/integration.test.js +659 -0
- package/dist/agent/tools/integration.test.js.map +1 -0
- package/dist/agent/tools/media.d.ts +6 -0
- package/dist/agent/tools/media.d.ts.map +1 -0
- package/dist/agent/tools/media.js +616 -0
- package/dist/agent/tools/media.js.map +1 -0
- package/dist/agent/tools/project.d.ts +6 -0
- package/dist/agent/tools/project.d.ts.map +1 -0
- package/dist/agent/tools/project.js +284 -0
- package/dist/agent/tools/project.js.map +1 -0
- package/dist/agent/tools/timeline.d.ts +6 -0
- package/dist/agent/tools/timeline.d.ts.map +1 -0
- package/dist/agent/tools/timeline.js +873 -0
- package/dist/agent/tools/timeline.js.map +1 -0
- package/dist/agent/types.d.ts +59 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +5 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/commands/agent.d.ts +21 -0
- package/dist/commands/agent.d.ts.map +1 -0
- package/dist/commands/agent.js +290 -0
- package/dist/commands/agent.js.map +1 -0
- package/dist/commands/ai-analyze.d.ts +106 -0
- package/dist/commands/ai-analyze.d.ts.map +1 -0
- package/dist/commands/ai-analyze.js +327 -0
- package/dist/commands/ai-analyze.js.map +1 -0
- package/dist/commands/ai-animated-caption.d.ts +64 -0
- package/dist/commands/ai-animated-caption.d.ts.map +1 -0
- package/dist/commands/ai-animated-caption.js +272 -0
- package/dist/commands/ai-animated-caption.js.map +1 -0
- package/dist/commands/ai-audio.d.ts +20 -0
- package/dist/commands/ai-audio.d.ts.map +1 -0
- package/dist/commands/ai-audio.js +808 -0
- package/dist/commands/ai-audio.js.map +1 -0
- package/dist/commands/ai-broll.d.ts +15 -0
- package/dist/commands/ai-broll.d.ts.map +1 -0
- package/dist/commands/ai-broll.js +406 -0
- package/dist/commands/ai-broll.js.map +1 -0
- package/dist/commands/ai-edit-cli.d.ts +14 -0
- package/dist/commands/ai-edit-cli.d.ts.map +1 -0
- package/dist/commands/ai-edit-cli.js +579 -0
- package/dist/commands/ai-edit-cli.js.map +1 -0
- package/dist/commands/ai-edit.d.ts +398 -0
- package/dist/commands/ai-edit.d.ts.map +1 -0
- package/dist/commands/ai-edit.js +1019 -0
- package/dist/commands/ai-edit.js.map +1 -0
- package/dist/commands/ai-fill-gaps.d.ts +14 -0
- package/dist/commands/ai-fill-gaps.d.ts.map +1 -0
- package/dist/commands/ai-fill-gaps.js +451 -0
- package/dist/commands/ai-fill-gaps.js.map +1 -0
- package/dist/commands/ai-helpers.d.ts +20 -0
- package/dist/commands/ai-helpers.d.ts.map +1 -0
- package/dist/commands/ai-helpers.js +59 -0
- package/dist/commands/ai-helpers.js.map +1 -0
- package/dist/commands/ai-highlights.d.ts +127 -0
- package/dist/commands/ai-highlights.d.ts.map +1 -0
- package/dist/commands/ai-highlights.js +1026 -0
- package/dist/commands/ai-highlights.js.map +1 -0
- package/dist/commands/ai-image.d.ts +34 -0
- package/dist/commands/ai-image.d.ts.map +1 -0
- package/dist/commands/ai-image.js +653 -0
- package/dist/commands/ai-image.js.map +1 -0
- package/dist/commands/ai-motion.d.ts +50 -0
- package/dist/commands/ai-motion.d.ts.map +1 -0
- package/dist/commands/ai-motion.js +271 -0
- package/dist/commands/ai-motion.js.map +1 -0
- package/dist/commands/ai-narrate.d.ts +66 -0
- package/dist/commands/ai-narrate.d.ts.map +1 -0
- package/dist/commands/ai-narrate.js +329 -0
- package/dist/commands/ai-narrate.js.map +1 -0
- package/dist/commands/ai-review.d.ts +57 -0
- package/dist/commands/ai-review.d.ts.map +1 -0
- package/dist/commands/ai-review.js +251 -0
- package/dist/commands/ai-review.js.map +1 -0
- package/dist/commands/ai-script-pipeline-cli.d.ts +9 -0
- package/dist/commands/ai-script-pipeline-cli.d.ts.map +1 -0
- package/dist/commands/ai-script-pipeline-cli.js +1494 -0
- package/dist/commands/ai-script-pipeline-cli.js.map +1 -0
- package/dist/commands/ai-script-pipeline.d.ts +259 -0
- package/dist/commands/ai-script-pipeline.d.ts.map +1 -0
- package/dist/commands/ai-script-pipeline.js +1027 -0
- package/dist/commands/ai-script-pipeline.js.map +1 -0
- package/dist/commands/ai-suggest-edit.d.ts +14 -0
- package/dist/commands/ai-suggest-edit.d.ts.map +1 -0
- package/dist/commands/ai-suggest-edit.js +220 -0
- package/dist/commands/ai-suggest-edit.js.map +1 -0
- package/dist/commands/ai-video-fx.d.ts +14 -0
- package/dist/commands/ai-video-fx.d.ts.map +1 -0
- package/dist/commands/ai-video-fx.js +395 -0
- package/dist/commands/ai-video-fx.js.map +1 -0
- package/dist/commands/ai-video.d.ts +15 -0
- package/dist/commands/ai-video.d.ts.map +1 -0
- package/dist/commands/ai-video.js +785 -0
- package/dist/commands/ai-video.js.map +1 -0
- package/dist/commands/ai-viral.d.ts +15 -0
- package/dist/commands/ai-viral.d.ts.map +1 -0
- package/dist/commands/ai-viral.js +519 -0
- package/dist/commands/ai-viral.js.map +1 -0
- package/dist/commands/ai-visual-fx.d.ts +14 -0
- package/dist/commands/ai-visual-fx.d.ts.map +1 -0
- package/dist/commands/ai-visual-fx.js +505 -0
- package/dist/commands/ai-visual-fx.js.map +1 -0
- package/dist/commands/ai.d.ts +38 -0
- package/dist/commands/ai.d.ts.map +1 -0
- package/dist/commands/ai.js +225 -0
- package/dist/commands/ai.js.map +1 -0
- package/dist/commands/ai.test.d.ts +2 -0
- package/dist/commands/ai.test.d.ts.map +1 -0
- package/dist/commands/ai.test.js +554 -0
- package/dist/commands/ai.test.js.map +1 -0
- package/dist/commands/analyze.d.ts +16 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +247 -0
- package/dist/commands/analyze.js.map +1 -0
- package/dist/commands/audio.d.ts +18 -0
- package/dist/commands/audio.d.ts.map +1 -0
- package/dist/commands/audio.js +539 -0
- package/dist/commands/audio.js.map +1 -0
- package/dist/commands/batch.d.ts +3 -0
- package/dist/commands/batch.d.ts.map +1 -0
- package/dist/commands/batch.js +366 -0
- package/dist/commands/batch.js.map +1 -0
- package/dist/commands/batch.test.d.ts +2 -0
- package/dist/commands/batch.test.d.ts.map +1 -0
- package/dist/commands/batch.test.js +203 -0
- package/dist/commands/batch.test.js.map +1 -0
- package/dist/commands/detect.d.ts +3 -0
- package/dist/commands/detect.d.ts.map +1 -0
- package/dist/commands/detect.js +273 -0
- package/dist/commands/detect.js.map +1 -0
- package/dist/commands/doctor.d.ts +6 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +191 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/edit-cmd.d.ts +26 -0
- package/dist/commands/edit-cmd.d.ts.map +1 -0
- package/dist/commands/edit-cmd.js +870 -0
- package/dist/commands/edit-cmd.js.map +1 -0
- package/dist/commands/export.d.ts +39 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +730 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/generate.d.ts +25 -0
- package/dist/commands/generate.d.ts.map +1 -0
- package/dist/commands/generate.js +1885 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/media.d.ts +3 -0
- package/dist/commands/media.d.ts.map +1 -0
- package/dist/commands/media.js +165 -0
- package/dist/commands/media.js.map +1 -0
- package/dist/commands/output.d.ts +45 -0
- package/dist/commands/output.d.ts.map +1 -0
- package/dist/commands/output.js +122 -0
- package/dist/commands/output.js.map +1 -0
- package/dist/commands/pipeline.d.ts +19 -0
- package/dist/commands/pipeline.d.ts.map +1 -0
- package/dist/commands/pipeline.js +345 -0
- package/dist/commands/pipeline.js.map +1 -0
- package/dist/commands/project.d.ts +3 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +139 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/project.test.d.ts +2 -0
- package/dist/commands/project.test.d.ts.map +1 -0
- package/dist/commands/project.test.js +105 -0
- package/dist/commands/project.test.js.map +1 -0
- package/dist/commands/sanitize.d.ts +21 -0
- package/dist/commands/sanitize.d.ts.map +1 -0
- package/dist/commands/sanitize.js +56 -0
- package/dist/commands/sanitize.js.map +1 -0
- package/dist/commands/schema.d.ts +11 -0
- package/dist/commands/schema.d.ts.map +1 -0
- package/dist/commands/schema.js +101 -0
- package/dist/commands/schema.js.map +1 -0
- package/dist/commands/setup.d.ts +6 -0
- package/dist/commands/setup.d.ts.map +1 -0
- package/dist/commands/setup.js +440 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/timeline.d.ts +3 -0
- package/dist/commands/timeline.d.ts.map +1 -0
- package/dist/commands/timeline.js +469 -0
- package/dist/commands/timeline.js.map +1 -0
- package/dist/commands/timeline.test.d.ts +2 -0
- package/dist/commands/timeline.test.d.ts.map +1 -0
- package/dist/commands/timeline.test.js +320 -0
- package/dist/commands/timeline.test.js.map +1 -0
- package/dist/commands/validate.d.ts +32 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +63 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/config/config.test.d.ts +2 -0
- package/dist/config/config.test.d.ts.map +1 -0
- package/dist/config/config.test.js +164 -0
- package/dist/config/config.test.js.map +1 -0
- package/dist/config/index.d.ts +35 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +101 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/schema.d.ts +43 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +42 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/engine/index.d.ts +3 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +2 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/project.d.ts +84 -0
- package/dist/engine/project.d.ts.map +1 -0
- package/dist/engine/project.js +355 -0
- package/dist/engine/project.js.map +1 -0
- package/dist/engine/project.test.d.ts +2 -0
- package/dist/engine/project.test.d.ts.map +1 -0
- package/dist/engine/project.test.js +599 -0
- package/dist/engine/project.test.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +131 -0
- package/dist/index.js.map +1 -0
- package/dist/utils/api-key.d.ts +36 -0
- package/dist/utils/api-key.d.ts.map +1 -0
- package/dist/utils/api-key.js +211 -0
- package/dist/utils/api-key.js.map +1 -0
- package/dist/utils/api-key.test.d.ts +2 -0
- package/dist/utils/api-key.test.d.ts.map +1 -0
- package/dist/utils/api-key.test.js +35 -0
- package/dist/utils/api-key.test.js.map +1 -0
- package/dist/utils/audio.d.ts +23 -0
- package/dist/utils/audio.d.ts.map +1 -0
- package/dist/utils/audio.js +79 -0
- package/dist/utils/audio.js.map +1 -0
- package/dist/utils/exec-safe.d.ts +22 -0
- package/dist/utils/exec-safe.d.ts.map +1 -0
- package/dist/utils/exec-safe.js +62 -0
- package/dist/utils/exec-safe.js.map +1 -0
- package/dist/utils/first-run.d.ts +13 -0
- package/dist/utils/first-run.d.ts.map +1 -0
- package/dist/utils/first-run.js +48 -0
- package/dist/utils/first-run.js.map +1 -0
- package/dist/utils/provider-resolver.d.ts +15 -0
- package/dist/utils/provider-resolver.d.ts.map +1 -0
- package/dist/utils/provider-resolver.js +42 -0
- package/dist/utils/provider-resolver.js.map +1 -0
- package/dist/utils/remotion.d.ts +210 -0
- package/dist/utils/remotion.d.ts.map +1 -0
- package/dist/utils/remotion.js +731 -0
- package/dist/utils/remotion.js.map +1 -0
- package/dist/utils/subtitle.d.ts +65 -0
- package/dist/utils/subtitle.d.ts.map +1 -0
- package/dist/utils/subtitle.js +135 -0
- package/dist/utils/subtitle.js.map +1 -0
- package/dist/utils/subtitle.test.d.ts +2 -0
- package/dist/utils/subtitle.test.d.ts.map +1 -0
- package/dist/utils/subtitle.test.js +175 -0
- package/dist/utils/subtitle.test.js.map +1 -0
- package/dist/utils/tty.d.ts +45 -0
- package/dist/utils/tty.d.ts.map +1 -0
- package/dist/utils/tty.js +172 -0
- package/dist/utils/tty.js.map +1 -0
- package/package.json +102 -0
- package/src/agent/adapters/claude.ts +143 -0
- package/src/agent/adapters/gemini.ts +159 -0
- package/src/agent/adapters/index.ts +61 -0
- package/src/agent/adapters/ollama.ts +231 -0
- package/src/agent/adapters/openai.ts +116 -0
- package/src/agent/adapters/xai.ts +119 -0
- package/src/agent/index.ts +251 -0
- package/src/agent/memory/index.ts +151 -0
- package/src/agent/prompts/system.ts +106 -0
- package/src/agent/tools/ai-editing.ts +845 -0
- package/src/agent/tools/ai-generation.ts +1073 -0
- package/src/agent/tools/ai-pipeline.ts +1055 -0
- package/src/agent/tools/ai.ts +21 -0
- package/src/agent/tools/batch.ts +429 -0
- package/src/agent/tools/e2e.test.ts +545 -0
- package/src/agent/tools/export.ts +184 -0
- package/src/agent/tools/filesystem.ts +237 -0
- package/src/agent/tools/index.ts +150 -0
- package/src/agent/tools/integration.test.ts +775 -0
- package/src/agent/tools/media.ts +697 -0
- package/src/agent/tools/project.ts +313 -0
- package/src/agent/tools/timeline.ts +951 -0
- package/src/agent/types.ts +68 -0
- package/src/commands/agent.ts +340 -0
- package/src/commands/ai-analyze.ts +429 -0
- package/src/commands/ai-animated-caption.ts +390 -0
- package/src/commands/ai-audio.ts +941 -0
- package/src/commands/ai-broll.ts +490 -0
- package/src/commands/ai-edit-cli.ts +658 -0
- package/src/commands/ai-edit.ts +1542 -0
- package/src/commands/ai-fill-gaps.ts +566 -0
- package/src/commands/ai-helpers.ts +65 -0
- package/src/commands/ai-highlights.ts +1303 -0
- package/src/commands/ai-image.ts +761 -0
- package/src/commands/ai-motion.ts +347 -0
- package/src/commands/ai-narrate.ts +451 -0
- package/src/commands/ai-review.ts +309 -0
- package/src/commands/ai-script-pipeline-cli.ts +1710 -0
- package/src/commands/ai-script-pipeline.ts +1365 -0
- package/src/commands/ai-suggest-edit.ts +264 -0
- package/src/commands/ai-video-fx.ts +445 -0
- package/src/commands/ai-video.ts +915 -0
- package/src/commands/ai-viral.ts +595 -0
- package/src/commands/ai-visual-fx.ts +601 -0
- package/src/commands/ai.test.ts +627 -0
- package/src/commands/ai.ts +307 -0
- package/src/commands/analyze.ts +282 -0
- package/src/commands/audio.ts +644 -0
- package/src/commands/batch.test.ts +279 -0
- package/src/commands/batch.ts +440 -0
- package/src/commands/detect.ts +329 -0
- package/src/commands/doctor.ts +237 -0
- package/src/commands/edit-cmd.ts +1014 -0
- package/src/commands/export.ts +918 -0
- package/src/commands/generate.ts +2146 -0
- package/src/commands/media.ts +177 -0
- package/src/commands/output.ts +142 -0
- package/src/commands/pipeline.ts +398 -0
- package/src/commands/project.test.ts +127 -0
- package/src/commands/project.ts +149 -0
- package/src/commands/sanitize.ts +60 -0
- package/src/commands/schema.ts +130 -0
- package/src/commands/setup.ts +509 -0
- package/src/commands/timeline.test.ts +499 -0
- package/src/commands/timeline.ts +529 -0
- package/src/commands/validate.ts +77 -0
- package/src/config/config.test.ts +197 -0
- package/src/config/index.ts +125 -0
- package/src/config/schema.ts +82 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/project.test.ts +702 -0
- package/src/engine/project.ts +439 -0
- package/src/index.ts +146 -0
- package/src/utils/api-key.test.ts +41 -0
- package/src/utils/api-key.ts +247 -0
- package/src/utils/audio.ts +83 -0
- package/src/utils/exec-safe.ts +75 -0
- package/src/utils/first-run.ts +52 -0
- package/src/utils/provider-resolver.ts +56 -0
- package/src/utils/remotion.ts +951 -0
- package/src/utils/subtitle.test.ts +227 -0
- package/src/utils/subtitle.ts +169 -0
- package/src/utils/tty.ts +196 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module ai-highlights
|
|
3
|
+
*
|
|
4
|
+
* Highlight extraction and auto-shorts generation for long-form content.
|
|
5
|
+
*
|
|
6
|
+
* CLI commands: highlights, auto-shorts
|
|
7
|
+
*
|
|
8
|
+
* Execute functions:
|
|
9
|
+
* executeHighlights - Extract best moments from video/audio (Whisper+Claude or Gemini)
|
|
10
|
+
* executeAutoShorts - Generate short-form vertical clips from long-form video
|
|
11
|
+
*
|
|
12
|
+
* @dependencies Whisper (OpenAI), Claude (Anthropic), Gemini (Google), FFmpeg
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
15
|
+
import { resolve, dirname, basename, extname } from "node:path";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
import chalk from "chalk";
|
|
18
|
+
import ora from "ora";
|
|
19
|
+
import { GeminiProvider, WhisperProvider, ClaudeProvider, } from "@vibeframe/ai-providers";
|
|
20
|
+
import { Project } from "../engine/index.js";
|
|
21
|
+
import { getApiKey } from "../utils/api-key.js";
|
|
22
|
+
import { formatTime } from "./ai-helpers.js";
|
|
23
|
+
import { execSafe, commandExists, ffprobeDuration } from "../utils/exec-safe.js";
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// Shared helpers
|
|
26
|
+
// ============================================================================
|
|
27
|
+
function filterHighlights(highlights, options) {
|
|
28
|
+
let filtered = highlights.filter((h) => h.confidence >= options.threshold);
|
|
29
|
+
filtered.sort((a, b) => b.confidence - a.confidence);
|
|
30
|
+
if (options.maxCount && filtered.length > options.maxCount) {
|
|
31
|
+
filtered = filtered.slice(0, options.maxCount);
|
|
32
|
+
}
|
|
33
|
+
if (options.targetDuration) {
|
|
34
|
+
const targetWithTolerance = options.targetDuration * 1.1;
|
|
35
|
+
let total = 0;
|
|
36
|
+
filtered = filtered.filter((h) => {
|
|
37
|
+
if (total + h.duration <= targetWithTolerance) {
|
|
38
|
+
total += h.duration;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
filtered.sort((a, b) => a.startTime - b.startTime);
|
|
45
|
+
return filtered.map((h, i) => ({ ...h, index: i + 1 }));
|
|
46
|
+
}
|
|
47
|
+
function getCategoryColor(category) {
|
|
48
|
+
switch (category) {
|
|
49
|
+
case "emotional":
|
|
50
|
+
return chalk.magenta;
|
|
51
|
+
case "informative":
|
|
52
|
+
return chalk.cyan;
|
|
53
|
+
case "funny":
|
|
54
|
+
return chalk.yellow;
|
|
55
|
+
default:
|
|
56
|
+
return chalk.white;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function truncate(text, maxLength) {
|
|
60
|
+
if (text.length <= maxLength)
|
|
61
|
+
return text;
|
|
62
|
+
return text.slice(0, maxLength - 3) + "...";
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Extract the best highlights from a video or audio file.
|
|
66
|
+
*
|
|
67
|
+
* Supports two analysis backends:
|
|
68
|
+
* - Whisper + Claude (default): transcribe audio, then analyze text for highlights
|
|
69
|
+
* - Gemini (--useGemini): multimodal visual+audio analysis for richer detection
|
|
70
|
+
*
|
|
71
|
+
* @param options - Highlight extraction configuration
|
|
72
|
+
* @returns Result with filtered highlights sorted by time
|
|
73
|
+
*/
|
|
74
|
+
export async function executeHighlights(options) {
|
|
75
|
+
try {
|
|
76
|
+
const absPath = resolve(process.cwd(), options.media);
|
|
77
|
+
if (!existsSync(absPath)) {
|
|
78
|
+
return { success: false, highlights: [], totalDuration: 0, totalHighlightDuration: 0, error: `File not found: ${absPath}` };
|
|
79
|
+
}
|
|
80
|
+
const ext = extname(absPath).toLowerCase();
|
|
81
|
+
const videoExtensions = [".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"];
|
|
82
|
+
const isVideo = videoExtensions.includes(ext);
|
|
83
|
+
const targetDuration = options.duration;
|
|
84
|
+
const maxCount = options.count;
|
|
85
|
+
const threshold = options.threshold ?? 0.7;
|
|
86
|
+
let allHighlights = [];
|
|
87
|
+
let sourceDuration = 0;
|
|
88
|
+
if (options.useGemini && isVideo) {
|
|
89
|
+
const geminiApiKey = await getApiKey("GOOGLE_API_KEY", "Google");
|
|
90
|
+
if (!geminiApiKey) {
|
|
91
|
+
return { success: false, highlights: [], totalDuration: 0, totalHighlightDuration: 0, error: "Google API key required for Gemini Video Understanding. Run 'vibe setup' or set GOOGLE_API_KEY in .env" };
|
|
92
|
+
}
|
|
93
|
+
sourceDuration = await ffprobeDuration(absPath);
|
|
94
|
+
const gemini = new GeminiProvider();
|
|
95
|
+
await gemini.initialize({ apiKey: geminiApiKey });
|
|
96
|
+
const videoBuffer = await readFile(absPath);
|
|
97
|
+
const criteriaText = options.criteria === "all" || !options.criteria
|
|
98
|
+
? "emotional, informative, and funny moments"
|
|
99
|
+
: `${options.criteria} moments`;
|
|
100
|
+
const durationText = targetDuration ? `Target a total highlight duration of ${targetDuration} seconds.` : "";
|
|
101
|
+
const countText = maxCount ? `Find up to ${maxCount} highlights.` : "";
|
|
102
|
+
const geminiPrompt = `Analyze this video and identify the most engaging highlights based on BOTH visual and audio content.
|
|
103
|
+
|
|
104
|
+
Focus on finding ${criteriaText}. ${durationText} ${countText}
|
|
105
|
+
|
|
106
|
+
For each highlight, provide:
|
|
107
|
+
1. Start timestamp (in seconds, as a number)
|
|
108
|
+
2. End timestamp (in seconds, as a number)
|
|
109
|
+
3. Category: "emotional", "informative", or "funny"
|
|
110
|
+
4. Confidence score (0-1)
|
|
111
|
+
5. Brief reason why this is a highlight
|
|
112
|
+
6. What is said/shown during this moment
|
|
113
|
+
|
|
114
|
+
IMPORTANT: Respond ONLY with valid JSON in this exact format:
|
|
115
|
+
{
|
|
116
|
+
"highlights": [
|
|
117
|
+
{
|
|
118
|
+
"startTime": 12.5,
|
|
119
|
+
"endTime": 28.3,
|
|
120
|
+
"category": "emotional",
|
|
121
|
+
"confidence": 0.95,
|
|
122
|
+
"reason": "Powerful personal story about overcoming challenges",
|
|
123
|
+
"transcript": "When I first started, everyone said it was impossible..."
|
|
124
|
+
}
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
Analyze both what is SHOWN (visual cues, actions, expressions) and what is SAID (speech, reactions) to find the most compelling moments.`;
|
|
129
|
+
const result = await gemini.analyzeVideo(videoBuffer, geminiPrompt, {
|
|
130
|
+
fps: 1,
|
|
131
|
+
lowResolution: options.lowRes,
|
|
132
|
+
});
|
|
133
|
+
if (!result.success || !result.response) {
|
|
134
|
+
return { success: false, highlights: [], totalDuration: 0, totalHighlightDuration: 0, error: `Gemini analysis failed: ${result.error}` };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
let jsonStr = result.response;
|
|
138
|
+
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
139
|
+
if (jsonMatch)
|
|
140
|
+
jsonStr = jsonMatch[1];
|
|
141
|
+
const objectMatch = jsonStr.match(/\{[\s\S]*"highlights"[\s\S]*\}/);
|
|
142
|
+
if (objectMatch)
|
|
143
|
+
jsonStr = objectMatch[0];
|
|
144
|
+
const parsed = JSON.parse(jsonStr);
|
|
145
|
+
if (parsed.highlights && Array.isArray(parsed.highlights)) {
|
|
146
|
+
allHighlights = parsed.highlights.map((h, i) => ({
|
|
147
|
+
index: i + 1,
|
|
148
|
+
startTime: h.startTime,
|
|
149
|
+
endTime: h.endTime,
|
|
150
|
+
duration: h.endTime - h.startTime,
|
|
151
|
+
category: h.category || "all",
|
|
152
|
+
confidence: h.confidence || 0.8,
|
|
153
|
+
reason: h.reason || "Engaging moment",
|
|
154
|
+
transcript: h.transcript || "",
|
|
155
|
+
}));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return { success: false, highlights: [], totalDuration: 0, totalHighlightDuration: 0, error: "Failed to parse Gemini response" };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
const openaiApiKey = await getApiKey("OPENAI_API_KEY", "OpenAI");
|
|
164
|
+
if (!openaiApiKey) {
|
|
165
|
+
return { success: false, highlights: [], totalDuration: 0, totalHighlightDuration: 0, error: "OpenAI API key required for Whisper transcription. Run 'vibe setup' or set OPENAI_API_KEY in .env" };
|
|
166
|
+
}
|
|
167
|
+
const claudeApiKey = await getApiKey("ANTHROPIC_API_KEY", "Anthropic");
|
|
168
|
+
if (!claudeApiKey) {
|
|
169
|
+
return { success: false, highlights: [], totalDuration: 0, totalHighlightDuration: 0, error: "Anthropic API key required for highlight analysis. Run 'vibe setup' or set ANTHROPIC_API_KEY in .env" };
|
|
170
|
+
}
|
|
171
|
+
let audioPath = absPath;
|
|
172
|
+
let tempAudioPath = null;
|
|
173
|
+
if (isVideo) {
|
|
174
|
+
if (!commandExists("ffmpeg")) {
|
|
175
|
+
return { success: false, highlights: [], totalDuration: 0, totalHighlightDuration: 0, error: "FFmpeg not found" };
|
|
176
|
+
}
|
|
177
|
+
tempAudioPath = `/tmp/vibe_highlight_audio_${Date.now()}.wav`;
|
|
178
|
+
await execSafe("ffmpeg", [
|
|
179
|
+
"-i", absPath, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", tempAudioPath, "-y",
|
|
180
|
+
], { maxBuffer: 50 * 1024 * 1024 });
|
|
181
|
+
audioPath = tempAudioPath;
|
|
182
|
+
sourceDuration = await ffprobeDuration(absPath);
|
|
183
|
+
}
|
|
184
|
+
const whisper = new WhisperProvider();
|
|
185
|
+
await whisper.initialize({ apiKey: openaiApiKey });
|
|
186
|
+
const audioBuffer = await readFile(audioPath);
|
|
187
|
+
const audioBlob = new Blob([audioBuffer]);
|
|
188
|
+
const transcriptResult = await whisper.transcribe(audioBlob, options.language);
|
|
189
|
+
if (tempAudioPath && existsSync(tempAudioPath)) {
|
|
190
|
+
const { unlink: unlinkFile } = await import("node:fs/promises");
|
|
191
|
+
await unlinkFile(tempAudioPath).catch(() => { });
|
|
192
|
+
}
|
|
193
|
+
if (transcriptResult.status === "failed" || !transcriptResult.segments) {
|
|
194
|
+
return { success: false, highlights: [], totalDuration: 0, totalHighlightDuration: 0, error: `Transcription failed: ${transcriptResult.error}` };
|
|
195
|
+
}
|
|
196
|
+
if (transcriptResult.segments.length > 0) {
|
|
197
|
+
sourceDuration = transcriptResult.segments[transcriptResult.segments.length - 1].endTime;
|
|
198
|
+
}
|
|
199
|
+
const claude = new ClaudeProvider();
|
|
200
|
+
await claude.initialize({ apiKey: claudeApiKey });
|
|
201
|
+
allHighlights = await claude.analyzeForHighlights(transcriptResult.segments, {
|
|
202
|
+
criteria: (options.criteria || "all"),
|
|
203
|
+
targetDuration,
|
|
204
|
+
maxCount,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (allHighlights.length === 0) {
|
|
208
|
+
return { success: true, highlights: [], totalDuration: sourceDuration, totalHighlightDuration: 0 };
|
|
209
|
+
}
|
|
210
|
+
const filteredHighlights = filterHighlights(allHighlights, { threshold, targetDuration, maxCount });
|
|
211
|
+
const totalHighlightDuration = filteredHighlights.reduce((sum, h) => sum + h.duration, 0);
|
|
212
|
+
const extractResult = {
|
|
213
|
+
success: true,
|
|
214
|
+
highlights: filteredHighlights,
|
|
215
|
+
totalDuration: sourceDuration,
|
|
216
|
+
totalHighlightDuration,
|
|
217
|
+
};
|
|
218
|
+
if (options.output) {
|
|
219
|
+
const outputPath = resolve(process.cwd(), options.output);
|
|
220
|
+
await writeFile(outputPath, JSON.stringify({
|
|
221
|
+
sourceFile: absPath,
|
|
222
|
+
totalDuration: sourceDuration,
|
|
223
|
+
criteria: options.criteria || "all",
|
|
224
|
+
threshold,
|
|
225
|
+
highlightsCount: filteredHighlights.length,
|
|
226
|
+
totalHighlightDuration,
|
|
227
|
+
highlights: filteredHighlights,
|
|
228
|
+
}, null, 2), "utf-8");
|
|
229
|
+
extractResult.outputPath = outputPath;
|
|
230
|
+
}
|
|
231
|
+
if (options.project) {
|
|
232
|
+
const project = new Project("Highlight Reel");
|
|
233
|
+
const source = project.addSource({
|
|
234
|
+
name: basename(absPath),
|
|
235
|
+
url: absPath,
|
|
236
|
+
type: isVideo ? "video" : "audio",
|
|
237
|
+
duration: sourceDuration,
|
|
238
|
+
});
|
|
239
|
+
const videoTrack = project.getTracks().find((t) => t.type === "video");
|
|
240
|
+
if (videoTrack) {
|
|
241
|
+
let currentTime = 0;
|
|
242
|
+
for (const highlight of filteredHighlights) {
|
|
243
|
+
project.addClip({
|
|
244
|
+
sourceId: source.id,
|
|
245
|
+
trackId: videoTrack.id,
|
|
246
|
+
startTime: currentTime,
|
|
247
|
+
duration: highlight.duration,
|
|
248
|
+
sourceStartOffset: highlight.startTime,
|
|
249
|
+
sourceEndOffset: highlight.endTime,
|
|
250
|
+
});
|
|
251
|
+
currentTime += highlight.duration;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const projectPath = resolve(process.cwd(), options.project);
|
|
255
|
+
await writeFile(projectPath, JSON.stringify(project.toJSON(), null, 2), "utf-8");
|
|
256
|
+
extractResult.projectPath = projectPath;
|
|
257
|
+
}
|
|
258
|
+
return extractResult;
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
return {
|
|
262
|
+
success: false,
|
|
263
|
+
highlights: [],
|
|
264
|
+
totalDuration: 0,
|
|
265
|
+
totalHighlightDuration: 0,
|
|
266
|
+
error: error instanceof Error ? error.message : String(error),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Auto-generate short-form vertical clips from a long-form video.
|
|
272
|
+
*
|
|
273
|
+
* Finds the best viral-worthy moments using Whisper+Claude or Gemini analysis,
|
|
274
|
+
* then extracts and crops them to the target aspect ratio using FFmpeg.
|
|
275
|
+
*
|
|
276
|
+
* @param options - Auto-shorts generation configuration
|
|
277
|
+
* @returns Result with extracted short clips and metadata
|
|
278
|
+
*/
|
|
279
|
+
export async function executeAutoShorts(options) {
|
|
280
|
+
try {
|
|
281
|
+
if (!commandExists("ffmpeg")) {
|
|
282
|
+
return { success: false, shorts: [], error: "FFmpeg not found" };
|
|
283
|
+
}
|
|
284
|
+
const absPath = resolve(process.cwd(), options.video);
|
|
285
|
+
if (!existsSync(absPath)) {
|
|
286
|
+
return { success: false, shorts: [], error: `File not found: ${absPath}` };
|
|
287
|
+
}
|
|
288
|
+
const targetDuration = options.duration ?? 60;
|
|
289
|
+
const shortCount = options.count ?? 1;
|
|
290
|
+
let highlights = [];
|
|
291
|
+
if (options.useGemini) {
|
|
292
|
+
const geminiApiKey = await getApiKey("GOOGLE_API_KEY", "Google");
|
|
293
|
+
if (!geminiApiKey) {
|
|
294
|
+
return { success: false, shorts: [], error: "Google API key required for Gemini Video Understanding. Run 'vibe setup' or set GOOGLE_API_KEY in .env" };
|
|
295
|
+
}
|
|
296
|
+
const gemini = new GeminiProvider();
|
|
297
|
+
await gemini.initialize({ apiKey: geminiApiKey });
|
|
298
|
+
const videoBuffer = await readFile(absPath);
|
|
299
|
+
const geminiPrompt = `Analyze this video to find the BEST moments for short-form vertical video content (TikTok, YouTube Shorts, Instagram Reels).
|
|
300
|
+
|
|
301
|
+
Find ${shortCount * 3} potential clips that are ${targetDuration} seconds or shorter each.
|
|
302
|
+
|
|
303
|
+
Look for:
|
|
304
|
+
- Visually striking or surprising moments
|
|
305
|
+
- Emotional peaks (laughter, reactions, reveals)
|
|
306
|
+
- Key quotes or memorable statements
|
|
307
|
+
- Action sequences or dramatic moments
|
|
308
|
+
- Meme-worthy or shareable moments
|
|
309
|
+
- Strong hooks (great opening lines)
|
|
310
|
+
- Satisfying conclusions
|
|
311
|
+
|
|
312
|
+
For each highlight, provide:
|
|
313
|
+
1. Start timestamp (seconds, as number)
|
|
314
|
+
2. End timestamp (seconds, as number) - ensure duration is close to ${targetDuration}s
|
|
315
|
+
3. Virality score (0-1) - how likely this would perform on social media
|
|
316
|
+
4. Hook quality (0-1) - how strong is the opening
|
|
317
|
+
5. Brief reason why this would work as a short
|
|
318
|
+
|
|
319
|
+
IMPORTANT: Respond ONLY with valid JSON:
|
|
320
|
+
{
|
|
321
|
+
"highlights": [
|
|
322
|
+
{
|
|
323
|
+
"startTime": 45.2,
|
|
324
|
+
"endTime": 75.8,
|
|
325
|
+
"confidence": 0.92,
|
|
326
|
+
"hookQuality": 0.85,
|
|
327
|
+
"reason": "Unexpected plot twist with strong visual reaction"
|
|
328
|
+
}
|
|
329
|
+
]
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
Analyze both VISUALS (expressions, actions, scene changes) and AUDIO (speech, reactions, music) to find viral-worthy moments.`;
|
|
333
|
+
const result = await gemini.analyzeVideo(videoBuffer, geminiPrompt, {
|
|
334
|
+
fps: 1,
|
|
335
|
+
lowResolution: options.lowRes,
|
|
336
|
+
});
|
|
337
|
+
if (!result.success || !result.response) {
|
|
338
|
+
return { success: false, shorts: [], error: `Gemini analysis failed: ${result.error}` };
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
let jsonStr = result.response;
|
|
342
|
+
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
343
|
+
if (jsonMatch)
|
|
344
|
+
jsonStr = jsonMatch[1];
|
|
345
|
+
const objectMatch = jsonStr.match(/\{[\s\S]*"highlights"[\s\S]*\}/);
|
|
346
|
+
if (objectMatch)
|
|
347
|
+
jsonStr = objectMatch[0];
|
|
348
|
+
const parsed = JSON.parse(jsonStr);
|
|
349
|
+
if (parsed.highlights && Array.isArray(parsed.highlights)) {
|
|
350
|
+
highlights = parsed.highlights.map((h, i) => ({
|
|
351
|
+
index: i + 1,
|
|
352
|
+
startTime: h.startTime,
|
|
353
|
+
endTime: h.endTime,
|
|
354
|
+
duration: h.endTime - h.startTime,
|
|
355
|
+
category: "viral",
|
|
356
|
+
confidence: h.confidence || 0.8,
|
|
357
|
+
reason: h.reason || "Engaging moment",
|
|
358
|
+
transcript: "",
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
return { success: false, shorts: [], error: "Failed to parse Gemini response" };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
else {
|
|
367
|
+
const openaiApiKey = await getApiKey("OPENAI_API_KEY", "OpenAI");
|
|
368
|
+
if (!openaiApiKey) {
|
|
369
|
+
return { success: false, shorts: [], error: "OpenAI API key required for transcription. Run 'vibe setup' or set OPENAI_API_KEY in .env" };
|
|
370
|
+
}
|
|
371
|
+
const claudeApiKey = await getApiKey("ANTHROPIC_API_KEY", "Anthropic");
|
|
372
|
+
if (!claudeApiKey) {
|
|
373
|
+
return { success: false, shorts: [], error: "Anthropic API key required for highlight detection. Run 'vibe setup' or set ANTHROPIC_API_KEY in .env" };
|
|
374
|
+
}
|
|
375
|
+
const tempAudio = absPath.replace(/(\.[^.]+)$/, "-temp-audio.mp3");
|
|
376
|
+
await execSafe("ffmpeg", ["-i", absPath, "-vn", "-acodec", "libmp3lame", "-q:a", "2", tempAudio, "-y"]);
|
|
377
|
+
const whisper = new WhisperProvider();
|
|
378
|
+
await whisper.initialize({ apiKey: openaiApiKey });
|
|
379
|
+
const audioBuffer = await readFile(tempAudio);
|
|
380
|
+
const audioBlob = new Blob([audioBuffer]);
|
|
381
|
+
const transcript = await whisper.transcribe(audioBlob, options.language);
|
|
382
|
+
try {
|
|
383
|
+
const { unlink: unlinkFile } = await import("node:fs/promises");
|
|
384
|
+
await unlinkFile(tempAudio);
|
|
385
|
+
}
|
|
386
|
+
catch { /* ignore */ }
|
|
387
|
+
if (!transcript.segments || transcript.segments.length === 0) {
|
|
388
|
+
return { success: false, shorts: [], error: "No transcript found" };
|
|
389
|
+
}
|
|
390
|
+
const claude = new ClaudeProvider();
|
|
391
|
+
await claude.initialize({ apiKey: claudeApiKey });
|
|
392
|
+
highlights = await claude.analyzeForHighlights(transcript.segments, {
|
|
393
|
+
criteria: "all",
|
|
394
|
+
targetDuration: targetDuration * shortCount,
|
|
395
|
+
maxCount: shortCount * 3,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
if (highlights.length === 0) {
|
|
399
|
+
return { success: false, shorts: [], error: "No highlights found" };
|
|
400
|
+
}
|
|
401
|
+
highlights.sort((a, b) => b.confidence - a.confidence);
|
|
402
|
+
const selectedHighlights = highlights.slice(0, shortCount);
|
|
403
|
+
if (options.analyzeOnly) {
|
|
404
|
+
return {
|
|
405
|
+
success: true,
|
|
406
|
+
shorts: selectedHighlights.map((h, i) => ({
|
|
407
|
+
index: i + 1,
|
|
408
|
+
startTime: h.startTime,
|
|
409
|
+
endTime: h.endTime,
|
|
410
|
+
duration: h.duration,
|
|
411
|
+
confidence: h.confidence,
|
|
412
|
+
reason: h.reason,
|
|
413
|
+
})),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
const outputDir = options.outputDir
|
|
417
|
+
? resolve(process.cwd(), options.outputDir)
|
|
418
|
+
: dirname(absPath);
|
|
419
|
+
if (options.outputDir && !existsSync(outputDir)) {
|
|
420
|
+
await mkdir(outputDir, { recursive: true });
|
|
421
|
+
}
|
|
422
|
+
const result = {
|
|
423
|
+
success: true,
|
|
424
|
+
shorts: [],
|
|
425
|
+
};
|
|
426
|
+
for (let i = 0; i < selectedHighlights.length; i++) {
|
|
427
|
+
const h = selectedHighlights[i];
|
|
428
|
+
const baseName = basename(absPath, extname(absPath));
|
|
429
|
+
const outputPath = resolve(outputDir, `${baseName}-short-${i + 1}.mp4`);
|
|
430
|
+
const { stdout: probeOut } = await execSafe("ffprobe", [
|
|
431
|
+
"-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", absPath,
|
|
432
|
+
]);
|
|
433
|
+
const [width, height] = probeOut.trim().split(",").map(Number);
|
|
434
|
+
const aspect = options.aspect || "9:16";
|
|
435
|
+
const [targetW, targetH] = aspect.split(":").map(Number);
|
|
436
|
+
const targetRatio = targetW / targetH;
|
|
437
|
+
const sourceRatio = width / height;
|
|
438
|
+
let cropW, cropH, cropX, cropY;
|
|
439
|
+
if (sourceRatio > targetRatio) {
|
|
440
|
+
cropH = height;
|
|
441
|
+
cropW = Math.round(height * targetRatio);
|
|
442
|
+
cropX = Math.round((width - cropW) / 2);
|
|
443
|
+
cropY = 0;
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
cropW = width;
|
|
447
|
+
cropH = Math.round(width / targetRatio);
|
|
448
|
+
cropX = 0;
|
|
449
|
+
cropY = Math.round((height - cropH) / 2);
|
|
450
|
+
}
|
|
451
|
+
const vf = `crop=${cropW}:${cropH}:${cropX}:${cropY}`;
|
|
452
|
+
await execSafe("ffmpeg", [
|
|
453
|
+
"-ss", String(h.startTime), "-i", absPath, "-t", String(h.duration),
|
|
454
|
+
"-vf", vf, "-c:a", "aac", "-b:a", "128k", outputPath, "-y",
|
|
455
|
+
], { timeout: 300000 });
|
|
456
|
+
result.shorts.push({
|
|
457
|
+
index: i + 1,
|
|
458
|
+
startTime: h.startTime,
|
|
459
|
+
endTime: h.endTime,
|
|
460
|
+
duration: h.duration,
|
|
461
|
+
confidence: h.confidence,
|
|
462
|
+
reason: h.reason,
|
|
463
|
+
outputPath,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
return result;
|
|
467
|
+
}
|
|
468
|
+
catch (error) {
|
|
469
|
+
return {
|
|
470
|
+
success: false,
|
|
471
|
+
shorts: [],
|
|
472
|
+
error: error instanceof Error ? error.message : String(error),
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// ============================================================================
|
|
477
|
+
// CLI command registration
|
|
478
|
+
// ============================================================================
|
|
479
|
+
export function registerHighlightsCommands(aiCommand) {
|
|
480
|
+
aiCommand
|
|
481
|
+
.command("highlights")
|
|
482
|
+
.description("Extract highlights from long-form video/audio content")
|
|
483
|
+
.argument("<media>", "Video or audio file path")
|
|
484
|
+
.option("-o, --output <path>", "Output JSON file with highlights")
|
|
485
|
+
.option("--project <path>", "Create project with highlight clips")
|
|
486
|
+
.option("-d, --duration <seconds>", "Target highlight reel duration", "60")
|
|
487
|
+
.option("-n, --count <number>", "Maximum number of highlights")
|
|
488
|
+
.option("-t, --threshold <value>", "Confidence threshold (0-1)", "0.7")
|
|
489
|
+
.option("--criteria <type>", "Selection criteria: emotional | informative | funny | all", "all")
|
|
490
|
+
.option("-l, --language <lang>", "Language code for transcription (e.g., en, ko)")
|
|
491
|
+
.option("--use-gemini", "Use Gemini Video Understanding for enhanced visual+audio analysis")
|
|
492
|
+
.option("--low-res", "Use low resolution mode for longer videos (Gemini only)")
|
|
493
|
+
.action(async (mediaPath, options) => {
|
|
494
|
+
try {
|
|
495
|
+
const absPath = resolve(process.cwd(), mediaPath);
|
|
496
|
+
if (!existsSync(absPath)) {
|
|
497
|
+
console.error(chalk.red(`File not found: ${absPath}`));
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
const ext = extname(absPath).toLowerCase();
|
|
501
|
+
const videoExtensions = [".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v"];
|
|
502
|
+
const isVideo = videoExtensions.includes(ext);
|
|
503
|
+
console.log();
|
|
504
|
+
console.log(chalk.bold.cyan("🎬 Highlight Extraction Pipeline"));
|
|
505
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
506
|
+
if (options.useGemini) {
|
|
507
|
+
console.log(chalk.dim("Using Gemini Video Understanding (visual + audio analysis)"));
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
console.log(chalk.dim("Using Whisper + Claude (audio-based analysis)"));
|
|
511
|
+
}
|
|
512
|
+
console.log();
|
|
513
|
+
const targetDuration = options.duration ? parseFloat(options.duration) : undefined;
|
|
514
|
+
const maxCount = options.count ? parseInt(options.count) : undefined;
|
|
515
|
+
let allHighlights = [];
|
|
516
|
+
let sourceDuration = 0;
|
|
517
|
+
if (options.useGemini && isVideo) {
|
|
518
|
+
const geminiApiKey = await getApiKey("GOOGLE_API_KEY", "Google");
|
|
519
|
+
if (!geminiApiKey) {
|
|
520
|
+
console.error(chalk.red("Google API key required for Gemini Video Understanding. Set GOOGLE_API_KEY in .env or run: vibe setup"));
|
|
521
|
+
console.error(chalk.dim("Set GOOGLE_API_KEY environment variable"));
|
|
522
|
+
process.exit(1);
|
|
523
|
+
}
|
|
524
|
+
const durationSpinner = ora("📊 Analyzing video metadata...").start();
|
|
525
|
+
try {
|
|
526
|
+
sourceDuration = await ffprobeDuration(absPath);
|
|
527
|
+
durationSpinner.succeed(chalk.green(`Video duration: ${formatTime(sourceDuration)}`));
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
durationSpinner.fail(chalk.red("Failed to get video duration"));
|
|
531
|
+
process.exit(1);
|
|
532
|
+
}
|
|
533
|
+
const geminiSpinner = ora("🎬 Analyzing video with Gemini (visual + audio)...").start();
|
|
534
|
+
const gemini = new GeminiProvider();
|
|
535
|
+
await gemini.initialize({ apiKey: geminiApiKey });
|
|
536
|
+
const videoBuffer = await readFile(absPath);
|
|
537
|
+
const criteriaText = options.criteria === "all"
|
|
538
|
+
? "emotional, informative, and funny moments"
|
|
539
|
+
: `${options.criteria} moments`;
|
|
540
|
+
const durationText = targetDuration ? `Target a total highlight duration of ${targetDuration} seconds.` : "";
|
|
541
|
+
const countText = maxCount ? `Find up to ${maxCount} highlights.` : "";
|
|
542
|
+
const geminiPrompt = `Analyze this video and identify the most engaging highlights based on BOTH visual and audio content.
|
|
543
|
+
|
|
544
|
+
Focus on finding ${criteriaText}. ${durationText} ${countText}
|
|
545
|
+
|
|
546
|
+
For each highlight, provide:
|
|
547
|
+
1. Start timestamp (in seconds, as a number)
|
|
548
|
+
2. End timestamp (in seconds, as a number)
|
|
549
|
+
3. Category: "emotional", "informative", or "funny"
|
|
550
|
+
4. Confidence score (0-1)
|
|
551
|
+
5. Brief reason why this is a highlight
|
|
552
|
+
6. What is said/shown during this moment
|
|
553
|
+
|
|
554
|
+
IMPORTANT: Respond ONLY with valid JSON in this exact format:
|
|
555
|
+
{
|
|
556
|
+
"highlights": [
|
|
557
|
+
{
|
|
558
|
+
"startTime": 12.5,
|
|
559
|
+
"endTime": 28.3,
|
|
560
|
+
"category": "emotional",
|
|
561
|
+
"confidence": 0.95,
|
|
562
|
+
"reason": "Powerful personal story about overcoming challenges",
|
|
563
|
+
"transcript": "When I first started, everyone said it was impossible..."
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
Analyze both what is SHOWN (visual cues, actions, expressions) and what is SAID (speech, reactions) to find the most compelling moments.`;
|
|
569
|
+
const result = await gemini.analyzeVideo(videoBuffer, geminiPrompt, {
|
|
570
|
+
fps: 1,
|
|
571
|
+
lowResolution: options.lowRes,
|
|
572
|
+
});
|
|
573
|
+
if (!result.success || !result.response) {
|
|
574
|
+
geminiSpinner.fail(chalk.red(`Gemini analysis failed: ${result.error}`));
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
let jsonStr = result.response;
|
|
579
|
+
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
580
|
+
if (jsonMatch)
|
|
581
|
+
jsonStr = jsonMatch[1];
|
|
582
|
+
const objectMatch = jsonStr.match(/\{[\s\S]*"highlights"[\s\S]*\}/);
|
|
583
|
+
if (objectMatch)
|
|
584
|
+
jsonStr = objectMatch[0];
|
|
585
|
+
const parsed = JSON.parse(jsonStr);
|
|
586
|
+
if (parsed.highlights && Array.isArray(parsed.highlights)) {
|
|
587
|
+
allHighlights = parsed.highlights.map((h, i) => ({
|
|
588
|
+
index: i + 1,
|
|
589
|
+
startTime: h.startTime,
|
|
590
|
+
endTime: h.endTime,
|
|
591
|
+
duration: h.endTime - h.startTime,
|
|
592
|
+
category: h.category || "all",
|
|
593
|
+
confidence: h.confidence || 0.8,
|
|
594
|
+
reason: h.reason || "Engaging moment",
|
|
595
|
+
transcript: h.transcript || "",
|
|
596
|
+
}));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
geminiSpinner.fail(chalk.red("Failed to parse Gemini response"));
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
603
|
+
geminiSpinner.succeed(chalk.green(`Found ${allHighlights.length} highlights via visual+audio analysis`));
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
const openaiApiKey = await getApiKey("OPENAI_API_KEY", "OpenAI");
|
|
607
|
+
if (!openaiApiKey) {
|
|
608
|
+
console.error(chalk.red("OpenAI API key required for Whisper transcription. Set OPENAI_API_KEY in .env or run: vibe setup"));
|
|
609
|
+
console.error(chalk.dim("Set OPENAI_API_KEY environment variable"));
|
|
610
|
+
process.exit(1);
|
|
611
|
+
}
|
|
612
|
+
const claudeApiKey = await getApiKey("ANTHROPIC_API_KEY", "Anthropic");
|
|
613
|
+
if (!claudeApiKey) {
|
|
614
|
+
console.error(chalk.red("Anthropic API key required for highlight analysis. Set ANTHROPIC_API_KEY in .env or run: vibe setup"));
|
|
615
|
+
console.error(chalk.dim("Set ANTHROPIC_API_KEY environment variable"));
|
|
616
|
+
process.exit(1);
|
|
617
|
+
}
|
|
618
|
+
let audioPath = absPath;
|
|
619
|
+
let tempAudioPath = null;
|
|
620
|
+
if (isVideo) {
|
|
621
|
+
const audioSpinner = ora("🎵 Extracting audio from video...").start();
|
|
622
|
+
try {
|
|
623
|
+
if (!commandExists("ffmpeg")) {
|
|
624
|
+
audioSpinner.fail(chalk.red("FFmpeg not found. Please install FFmpeg."));
|
|
625
|
+
process.exit(1);
|
|
626
|
+
}
|
|
627
|
+
const { stdout: probeOut } = await execSafe("ffprobe", [
|
|
628
|
+
"-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_type", "-of", "csv=p=0", absPath,
|
|
629
|
+
]);
|
|
630
|
+
const hasAudio = probeOut.trim().length > 0;
|
|
631
|
+
if (!hasAudio) {
|
|
632
|
+
audioSpinner.fail(chalk.yellow("Video has no audio track — cannot use Whisper transcription"));
|
|
633
|
+
console.log(chalk.yellow("\n⚠ This video has no audio stream."));
|
|
634
|
+
console.log(chalk.dim(" Use --use-gemini flag for visual-only analysis of videos without audio."));
|
|
635
|
+
process.exit(1);
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
tempAudioPath = `/tmp/vibe_highlight_audio_${Date.now()}.wav`;
|
|
639
|
+
await execSafe("ffmpeg", [
|
|
640
|
+
"-i", absPath, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", tempAudioPath, "-y",
|
|
641
|
+
], { maxBuffer: 50 * 1024 * 1024 });
|
|
642
|
+
audioPath = tempAudioPath;
|
|
643
|
+
}
|
|
644
|
+
sourceDuration = await ffprobeDuration(absPath);
|
|
645
|
+
if (hasAudio) {
|
|
646
|
+
audioSpinner.succeed(chalk.green(`Extracted audio (${formatTime(sourceDuration)} total duration)`));
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
audioSpinner.fail(chalk.red("Failed to extract audio"));
|
|
651
|
+
console.error(error);
|
|
652
|
+
process.exit(1);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const transcribeSpinner = ora("📝 Transcribing with Whisper...").start();
|
|
656
|
+
const whisper = new WhisperProvider();
|
|
657
|
+
await whisper.initialize({ apiKey: openaiApiKey });
|
|
658
|
+
const audioBuffer = await readFile(audioPath);
|
|
659
|
+
const audioBlob = new Blob([audioBuffer]);
|
|
660
|
+
const transcriptResult = await whisper.transcribe(audioBlob, options.language);
|
|
661
|
+
if (transcriptResult.status === "failed" || !transcriptResult.segments) {
|
|
662
|
+
transcribeSpinner.fail(chalk.red(`Transcription failed: ${transcriptResult.error}`));
|
|
663
|
+
if (tempAudioPath && existsSync(tempAudioPath)) {
|
|
664
|
+
const { unlink: unlinkFile } = await import("node:fs/promises");
|
|
665
|
+
await unlinkFile(tempAudioPath).catch(() => { });
|
|
666
|
+
}
|
|
667
|
+
process.exit(1);
|
|
668
|
+
}
|
|
669
|
+
transcribeSpinner.succeed(chalk.green(`Transcribed ${transcriptResult.segments.length} segments`));
|
|
670
|
+
if (tempAudioPath && existsSync(tempAudioPath)) {
|
|
671
|
+
const { unlink: unlinkFile } = await import("node:fs/promises");
|
|
672
|
+
await unlinkFile(tempAudioPath).catch(() => { });
|
|
673
|
+
}
|
|
674
|
+
if (transcriptResult.segments.length > 0) {
|
|
675
|
+
sourceDuration = transcriptResult.segments[transcriptResult.segments.length - 1].endTime;
|
|
676
|
+
}
|
|
677
|
+
const analyzeSpinner = ora("🔍 Analyzing highlights with Claude...").start();
|
|
678
|
+
const claude = new ClaudeProvider();
|
|
679
|
+
await claude.initialize({ apiKey: claudeApiKey });
|
|
680
|
+
allHighlights = await claude.analyzeForHighlights(transcriptResult.segments, {
|
|
681
|
+
criteria: options.criteria,
|
|
682
|
+
targetDuration,
|
|
683
|
+
maxCount,
|
|
684
|
+
});
|
|
685
|
+
if (allHighlights.length === 0) {
|
|
686
|
+
analyzeSpinner.warn(chalk.yellow("No highlights detected in the content"));
|
|
687
|
+
process.exit(0);
|
|
688
|
+
}
|
|
689
|
+
analyzeSpinner.succeed(chalk.green(`Found ${allHighlights.length} potential highlights`));
|
|
690
|
+
}
|
|
691
|
+
if (allHighlights.length === 0) {
|
|
692
|
+
console.log(chalk.yellow("No highlights detected in the content"));
|
|
693
|
+
process.exit(0);
|
|
694
|
+
}
|
|
695
|
+
const filterSpinner = ora("📊 Filtering and ranking...").start();
|
|
696
|
+
const threshold = parseFloat(options.threshold);
|
|
697
|
+
const filteredHighlights = filterHighlights(allHighlights, { threshold, targetDuration, maxCount });
|
|
698
|
+
const totalHighlightDuration = filteredHighlights.reduce((sum, h) => sum + h.duration, 0);
|
|
699
|
+
filterSpinner.succeed(chalk.green(`Selected ${filteredHighlights.length} highlights (${totalHighlightDuration.toFixed(1)}s total)`));
|
|
700
|
+
const result = {
|
|
701
|
+
sourceFile: absPath,
|
|
702
|
+
totalDuration: sourceDuration,
|
|
703
|
+
criteria: options.criteria,
|
|
704
|
+
threshold,
|
|
705
|
+
highlightsCount: filteredHighlights.length,
|
|
706
|
+
totalHighlightDuration,
|
|
707
|
+
highlights: filteredHighlights,
|
|
708
|
+
};
|
|
709
|
+
console.log();
|
|
710
|
+
console.log(chalk.bold.cyan("Highlights Summary"));
|
|
711
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
712
|
+
for (const highlight of filteredHighlights) {
|
|
713
|
+
const startFormatted = formatTime(highlight.startTime);
|
|
714
|
+
const endFormatted = formatTime(highlight.endTime);
|
|
715
|
+
const confidencePercent = (highlight.confidence * 100).toFixed(0);
|
|
716
|
+
const categoryColor = getCategoryColor(highlight.category);
|
|
717
|
+
console.log();
|
|
718
|
+
console.log(` ${chalk.yellow(`${highlight.index}.`)} [${startFormatted} - ${endFormatted}] ${categoryColor(highlight.category)}, ${chalk.dim(`${confidencePercent}%`)}`);
|
|
719
|
+
console.log(` ${chalk.white(highlight.reason)}`);
|
|
720
|
+
console.log(` ${chalk.dim(truncate(highlight.transcript, 80))}`);
|
|
721
|
+
}
|
|
722
|
+
console.log();
|
|
723
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
724
|
+
console.log(`Total: ${chalk.bold(filteredHighlights.length)} highlights, ${chalk.bold(totalHighlightDuration.toFixed(1))} seconds`);
|
|
725
|
+
console.log();
|
|
726
|
+
if (options.output) {
|
|
727
|
+
const outputPath = resolve(process.cwd(), options.output);
|
|
728
|
+
await writeFile(outputPath, JSON.stringify(result, null, 2), "utf-8");
|
|
729
|
+
console.log(chalk.green(`💾 Saved highlights to: ${outputPath}`));
|
|
730
|
+
}
|
|
731
|
+
if (options.project) {
|
|
732
|
+
const projectSpinner = ora("📦 Creating project...").start();
|
|
733
|
+
const project = new Project("Highlight Reel");
|
|
734
|
+
const source = project.addSource({
|
|
735
|
+
name: basename(absPath),
|
|
736
|
+
url: absPath,
|
|
737
|
+
type: isVideo ? "video" : "audio",
|
|
738
|
+
duration: sourceDuration,
|
|
739
|
+
});
|
|
740
|
+
const videoTrack = project.getTracks().find((t) => t.type === "video");
|
|
741
|
+
if (!videoTrack) {
|
|
742
|
+
projectSpinner.fail(chalk.red("Failed to create project"));
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
let currentTime = 0;
|
|
746
|
+
for (const highlight of filteredHighlights) {
|
|
747
|
+
project.addClip({
|
|
748
|
+
sourceId: source.id,
|
|
749
|
+
trackId: videoTrack.id,
|
|
750
|
+
startTime: currentTime,
|
|
751
|
+
duration: highlight.duration,
|
|
752
|
+
sourceStartOffset: highlight.startTime,
|
|
753
|
+
sourceEndOffset: highlight.endTime,
|
|
754
|
+
});
|
|
755
|
+
currentTime += highlight.duration;
|
|
756
|
+
}
|
|
757
|
+
const projectPath = resolve(process.cwd(), options.project);
|
|
758
|
+
await writeFile(projectPath, JSON.stringify(project.toJSON(), null, 2), "utf-8");
|
|
759
|
+
projectSpinner.succeed(chalk.green(`Created project: ${projectPath}`));
|
|
760
|
+
}
|
|
761
|
+
console.log();
|
|
762
|
+
console.log(chalk.bold.green("✅ Highlight extraction complete!"));
|
|
763
|
+
console.log();
|
|
764
|
+
}
|
|
765
|
+
catch (error) {
|
|
766
|
+
console.error(chalk.red("Highlight extraction failed"));
|
|
767
|
+
console.error(error);
|
|
768
|
+
process.exit(1);
|
|
769
|
+
}
|
|
770
|
+
});
|
|
771
|
+
aiCommand
|
|
772
|
+
.command("auto-shorts")
|
|
773
|
+
.alias("shorts")
|
|
774
|
+
.description("Auto-generate shorts from long-form video")
|
|
775
|
+
.argument("<video>", "Video file path")
|
|
776
|
+
.option("-o, --output <path>", "Output file (single) or directory (multiple)")
|
|
777
|
+
.option("-d, --duration <seconds>", "Target duration in seconds (15-60)", "60")
|
|
778
|
+
.option("-n, --count <number>", "Number of shorts to generate", "1")
|
|
779
|
+
.option("-a, --aspect <ratio>", "Aspect ratio: 9:16, 1:1", "9:16")
|
|
780
|
+
.option("--output-dir <dir>", "Output directory for multiple shorts")
|
|
781
|
+
.option("--add-captions", "Add auto-generated captions")
|
|
782
|
+
.option("--caption-style <style>", "Caption style: minimal, bold, animated", "bold")
|
|
783
|
+
.option("--analyze-only", "Show segments without generating")
|
|
784
|
+
.option("-l, --language <lang>", "Language code for transcription")
|
|
785
|
+
.option("--use-gemini", "Use Gemini Video Understanding for enhanced visual+audio analysis")
|
|
786
|
+
.option("--low-res", "Use low resolution mode for longer videos (Gemini only)")
|
|
787
|
+
.action(async (videoPath, options) => {
|
|
788
|
+
try {
|
|
789
|
+
if (!commandExists("ffmpeg")) {
|
|
790
|
+
console.error(chalk.red("FFmpeg not found. Please install FFmpeg."));
|
|
791
|
+
process.exit(1);
|
|
792
|
+
}
|
|
793
|
+
const absPath = resolve(process.cwd(), videoPath);
|
|
794
|
+
if (!existsSync(absPath)) {
|
|
795
|
+
console.error(chalk.red(`File not found: ${absPath}`));
|
|
796
|
+
process.exit(1);
|
|
797
|
+
}
|
|
798
|
+
const targetDuration = parseInt(options.duration);
|
|
799
|
+
const shortCount = parseInt(options.count);
|
|
800
|
+
console.log();
|
|
801
|
+
console.log(chalk.bold.cyan("🎬 Auto Shorts Generator"));
|
|
802
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
803
|
+
if (options.useGemini) {
|
|
804
|
+
console.log(chalk.dim("Using Gemini Video Understanding (visual + audio analysis)"));
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
console.log(chalk.dim("Using Whisper + Claude (audio-based analysis)"));
|
|
808
|
+
}
|
|
809
|
+
console.log();
|
|
810
|
+
let highlights = [];
|
|
811
|
+
if (options.useGemini) {
|
|
812
|
+
const geminiApiKey = await getApiKey("GOOGLE_API_KEY", "Google");
|
|
813
|
+
if (!geminiApiKey) {
|
|
814
|
+
console.error(chalk.red("Google API key required for Gemini Video Understanding. Set GOOGLE_API_KEY in .env or run: vibe setup"));
|
|
815
|
+
console.error(chalk.dim("Set GOOGLE_API_KEY environment variable"));
|
|
816
|
+
process.exit(1);
|
|
817
|
+
}
|
|
818
|
+
const spinner = ora("🎬 Analyzing video with Gemini (visual + audio)...").start();
|
|
819
|
+
const gemini = new GeminiProvider();
|
|
820
|
+
await gemini.initialize({ apiKey: geminiApiKey });
|
|
821
|
+
const videoBuffer = await readFile(absPath);
|
|
822
|
+
const geminiPrompt = `Analyze this video to find the BEST moments for short-form vertical video content (TikTok, YouTube Shorts, Instagram Reels).
|
|
823
|
+
|
|
824
|
+
Find ${shortCount * 3} potential clips that are ${targetDuration} seconds or shorter each.
|
|
825
|
+
|
|
826
|
+
Look for:
|
|
827
|
+
- Visually striking or surprising moments
|
|
828
|
+
- Emotional peaks (laughter, reactions, reveals)
|
|
829
|
+
- Key quotes or memorable statements
|
|
830
|
+
- Action sequences or dramatic moments
|
|
831
|
+
- Meme-worthy or shareable moments
|
|
832
|
+
- Strong hooks (great opening lines)
|
|
833
|
+
- Satisfying conclusions
|
|
834
|
+
|
|
835
|
+
For each highlight, provide:
|
|
836
|
+
1. Start timestamp (seconds, as number)
|
|
837
|
+
2. End timestamp (seconds, as number) - ensure duration is close to ${targetDuration}s
|
|
838
|
+
3. Virality score (0-1) - how likely this would perform on social media
|
|
839
|
+
4. Hook quality (0-1) - how strong is the opening
|
|
840
|
+
5. Brief reason why this would work as a short
|
|
841
|
+
|
|
842
|
+
IMPORTANT: Respond ONLY with valid JSON:
|
|
843
|
+
{
|
|
844
|
+
"highlights": [
|
|
845
|
+
{
|
|
846
|
+
"startTime": 45.2,
|
|
847
|
+
"endTime": 75.8,
|
|
848
|
+
"confidence": 0.92,
|
|
849
|
+
"hookQuality": 0.85,
|
|
850
|
+
"reason": "Unexpected plot twist with strong visual reaction"
|
|
851
|
+
}
|
|
852
|
+
]
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
Analyze both VISUALS (expressions, actions, scene changes) and AUDIO (speech, reactions, music) to find viral-worthy moments.`;
|
|
856
|
+
const result = await gemini.analyzeVideo(videoBuffer, geminiPrompt, {
|
|
857
|
+
fps: 1,
|
|
858
|
+
lowResolution: options.lowRes,
|
|
859
|
+
});
|
|
860
|
+
if (!result.success || !result.response) {
|
|
861
|
+
spinner.fail(chalk.red(`Gemini analysis failed: ${result.error}`));
|
|
862
|
+
process.exit(1);
|
|
863
|
+
}
|
|
864
|
+
try {
|
|
865
|
+
let jsonStr = result.response;
|
|
866
|
+
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
867
|
+
if (jsonMatch)
|
|
868
|
+
jsonStr = jsonMatch[1];
|
|
869
|
+
const objectMatch = jsonStr.match(/\{[\s\S]*"highlights"[\s\S]*\}/);
|
|
870
|
+
if (objectMatch)
|
|
871
|
+
jsonStr = objectMatch[0];
|
|
872
|
+
const parsed = JSON.parse(jsonStr);
|
|
873
|
+
if (parsed.highlights && Array.isArray(parsed.highlights)) {
|
|
874
|
+
highlights = parsed.highlights.map((h, i) => ({
|
|
875
|
+
index: i + 1,
|
|
876
|
+
startTime: h.startTime,
|
|
877
|
+
endTime: h.endTime,
|
|
878
|
+
duration: h.endTime - h.startTime,
|
|
879
|
+
category: "viral",
|
|
880
|
+
confidence: h.confidence || 0.8,
|
|
881
|
+
reason: h.reason || "Engaging moment",
|
|
882
|
+
transcript: "",
|
|
883
|
+
}));
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
catch {
|
|
887
|
+
spinner.fail(chalk.red("Failed to parse Gemini response"));
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
spinner.succeed(chalk.green(`Found ${highlights.length} potential shorts via visual+audio analysis`));
|
|
891
|
+
}
|
|
892
|
+
else {
|
|
893
|
+
const openaiApiKey = await getApiKey("OPENAI_API_KEY", "OpenAI");
|
|
894
|
+
if (!openaiApiKey) {
|
|
895
|
+
console.error(chalk.red("OpenAI API key required for transcription. Set OPENAI_API_KEY in .env or run: vibe setup"));
|
|
896
|
+
process.exit(1);
|
|
897
|
+
}
|
|
898
|
+
const claudeApiKey = await getApiKey("ANTHROPIC_API_KEY", "Anthropic");
|
|
899
|
+
if (!claudeApiKey) {
|
|
900
|
+
console.error(chalk.red("Anthropic API key required for highlight detection. Set ANTHROPIC_API_KEY in .env or run: vibe setup"));
|
|
901
|
+
process.exit(1);
|
|
902
|
+
}
|
|
903
|
+
const spinner = ora("Extracting audio...").start();
|
|
904
|
+
const { stdout: autoShortsProbe } = await execSafe("ffprobe", [
|
|
905
|
+
"-v", "error", "-select_streams", "a", "-show_entries", "stream=codec_type", "-of", "csv=p=0", absPath,
|
|
906
|
+
]);
|
|
907
|
+
if (!autoShortsProbe.trim()) {
|
|
908
|
+
spinner.fail(chalk.yellow("Video has no audio track — cannot use Whisper transcription"));
|
|
909
|
+
console.log(chalk.yellow("\n⚠ This video has no audio stream."));
|
|
910
|
+
console.log(chalk.dim(" Use --use-gemini flag for visual-only analysis of videos without audio."));
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
const tempAudio = absPath.replace(/(\.[^.]+)$/, "-temp-audio.mp3");
|
|
914
|
+
await execSafe("ffmpeg", ["-i", absPath, "-vn", "-acodec", "libmp3lame", "-q:a", "2", tempAudio, "-y"]);
|
|
915
|
+
spinner.text = "Transcribing audio...";
|
|
916
|
+
const whisper = new WhisperProvider();
|
|
917
|
+
await whisper.initialize({ apiKey: openaiApiKey });
|
|
918
|
+
const audioBuffer = await readFile(tempAudio);
|
|
919
|
+
const audioBlob = new Blob([audioBuffer]);
|
|
920
|
+
const transcript = await whisper.transcribe(audioBlob, options.language);
|
|
921
|
+
try {
|
|
922
|
+
const { unlink: unlinkFile } = await import("node:fs/promises");
|
|
923
|
+
await unlinkFile(tempAudio);
|
|
924
|
+
}
|
|
925
|
+
catch { /* ignore cleanup errors */ }
|
|
926
|
+
if (!transcript.segments || transcript.segments.length === 0) {
|
|
927
|
+
spinner.fail(chalk.red("No transcript found"));
|
|
928
|
+
process.exit(1);
|
|
929
|
+
}
|
|
930
|
+
spinner.text = "Finding highlights...";
|
|
931
|
+
const claude = new ClaudeProvider();
|
|
932
|
+
await claude.initialize({ apiKey: claudeApiKey });
|
|
933
|
+
highlights = await claude.analyzeForHighlights(transcript.segments, {
|
|
934
|
+
criteria: "all",
|
|
935
|
+
targetDuration: targetDuration * shortCount,
|
|
936
|
+
maxCount: shortCount * 3,
|
|
937
|
+
});
|
|
938
|
+
spinner.succeed(chalk.green(`Found ${highlights.length} potential highlights`));
|
|
939
|
+
}
|
|
940
|
+
if (highlights.length === 0) {
|
|
941
|
+
console.error(chalk.red("No highlights found"));
|
|
942
|
+
process.exit(1);
|
|
943
|
+
}
|
|
944
|
+
highlights.sort((a, b) => b.confidence - a.confidence);
|
|
945
|
+
const selectedHighlights = highlights.slice(0, shortCount);
|
|
946
|
+
console.log(chalk.green(`Selected top ${selectedHighlights.length} for short generation`));
|
|
947
|
+
console.log();
|
|
948
|
+
console.log(chalk.bold.cyan("Auto Shorts"));
|
|
949
|
+
console.log(chalk.dim("─".repeat(60)));
|
|
950
|
+
console.log(`Target duration: ${targetDuration}s`);
|
|
951
|
+
console.log(`Aspect ratio: ${options.aspect}`);
|
|
952
|
+
console.log();
|
|
953
|
+
for (let i = 0; i < selectedHighlights.length; i++) {
|
|
954
|
+
const h = selectedHighlights[i];
|
|
955
|
+
console.log(chalk.yellow(`[Short ${i + 1}] ${formatTime(h.startTime)} - ${formatTime(h.endTime)} (${h.duration.toFixed(1)}s)`));
|
|
956
|
+
console.log(` ${h.reason}`);
|
|
957
|
+
console.log(chalk.dim(` Confidence: ${(h.confidence * 100).toFixed(0)}%`));
|
|
958
|
+
}
|
|
959
|
+
console.log();
|
|
960
|
+
if (options.analyzeOnly) {
|
|
961
|
+
console.log(chalk.dim("Use without --analyze-only to generate shorts."));
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
const outputDir = options.outputDir
|
|
965
|
+
? resolve(process.cwd(), options.outputDir)
|
|
966
|
+
: dirname(absPath);
|
|
967
|
+
if (options.outputDir && !existsSync(outputDir)) {
|
|
968
|
+
await mkdir(outputDir, { recursive: true });
|
|
969
|
+
}
|
|
970
|
+
for (let i = 0; i < selectedHighlights.length; i++) {
|
|
971
|
+
const h = selectedHighlights[i];
|
|
972
|
+
const shortSpinner = ora(`Generating short ${i + 1}/${selectedHighlights.length}...`).start();
|
|
973
|
+
let outputPath;
|
|
974
|
+
if (shortCount === 1 && options.output) {
|
|
975
|
+
outputPath = resolve(process.cwd(), options.output);
|
|
976
|
+
if (!extname(outputPath)) {
|
|
977
|
+
outputPath += ".mp4";
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
const baseName = basename(absPath, extname(absPath));
|
|
982
|
+
outputPath = resolve(outputDir, `${baseName}-short-${i + 1}.mp4`);
|
|
983
|
+
}
|
|
984
|
+
const parentDir = dirname(outputPath);
|
|
985
|
+
if (!existsSync(parentDir)) {
|
|
986
|
+
await mkdir(parentDir, { recursive: true });
|
|
987
|
+
}
|
|
988
|
+
const { stdout: probeOut } = await execSafe("ffprobe", [
|
|
989
|
+
"-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", absPath,
|
|
990
|
+
]);
|
|
991
|
+
const [width, height] = probeOut.trim().split(",").map(Number);
|
|
992
|
+
const [targetW, targetH] = options.aspect.split(":").map(Number);
|
|
993
|
+
const targetRatio = targetW / targetH;
|
|
994
|
+
const sourceRatio = width / height;
|
|
995
|
+
let cropW, cropH, cropX, cropY;
|
|
996
|
+
if (sourceRatio > targetRatio) {
|
|
997
|
+
cropH = height;
|
|
998
|
+
cropW = Math.round(height * targetRatio);
|
|
999
|
+
cropX = Math.round((width - cropW) / 2);
|
|
1000
|
+
cropY = 0;
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
cropW = width;
|
|
1004
|
+
cropH = Math.round(width / targetRatio);
|
|
1005
|
+
cropX = 0;
|
|
1006
|
+
cropY = Math.round((height - cropH) / 2);
|
|
1007
|
+
}
|
|
1008
|
+
const vf = `crop=${cropW}:${cropH}:${cropX}:${cropY}`;
|
|
1009
|
+
await execSafe("ffmpeg", [
|
|
1010
|
+
"-ss", String(h.startTime), "-i", absPath, "-t", String(h.duration),
|
|
1011
|
+
"-vf", vf, "-c:a", "aac", "-b:a", "128k", outputPath, "-y",
|
|
1012
|
+
], { timeout: 300000 });
|
|
1013
|
+
shortSpinner.succeed(chalk.green(`Short ${i + 1}: ${outputPath}`));
|
|
1014
|
+
}
|
|
1015
|
+
console.log();
|
|
1016
|
+
console.log(chalk.bold.green(`Generated ${selectedHighlights.length} short(s)`));
|
|
1017
|
+
console.log();
|
|
1018
|
+
}
|
|
1019
|
+
catch (error) {
|
|
1020
|
+
console.error(chalk.red("Auto shorts failed"));
|
|
1021
|
+
console.error(error);
|
|
1022
|
+
process.exit(1);
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
//# sourceMappingURL=ai-highlights.js.map
|