opencode-nanobanana 0.1.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/.ralph-events.json +151 -0
- package/.ralph-last-branch +1 -0
- package/.ralph-monitor-state.json +7 -0
- package/.ralph-monitor.pid +1 -0
- package/.ralph-timing.json +26 -0
- package/README.md +708 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/platforms/android.d.ts +94 -0
- package/dist/platforms/android.d.ts.map +1 -0
- package/dist/platforms/android.js +123 -0
- package/dist/platforms/android.js.map +1 -0
- package/dist/platforms/ios.d.ts +51 -0
- package/dist/platforms/ios.d.ts.map +1 -0
- package/dist/platforms/ios.js +149 -0
- package/dist/platforms/ios.js.map +1 -0
- package/dist/platforms/macos.d.ts +33 -0
- package/dist/platforms/macos.d.ts.map +1 -0
- package/dist/platforms/macos.js +50 -0
- package/dist/platforms/macos.js.map +1 -0
- package/dist/platforms/watchos.d.ts +36 -0
- package/dist/platforms/watchos.d.ts.map +1 -0
- package/dist/platforms/watchos.js +113 -0
- package/dist/platforms/watchos.js.map +1 -0
- package/dist/platforms/web.d.ts +64 -0
- package/dist/platforms/web.d.ts.map +1 -0
- package/dist/platforms/web.js +96 -0
- package/dist/platforms/web.js.map +1 -0
- package/dist/providers/gemini.d.ts +41 -0
- package/dist/providers/gemini.d.ts.map +1 -0
- package/dist/providers/gemini.js +177 -0
- package/dist/providers/gemini.js.map +1 -0
- package/dist/tools/analyze/compare.d.ts +12 -0
- package/dist/tools/analyze/compare.d.ts.map +1 -0
- package/dist/tools/analyze/compare.js +83 -0
- package/dist/tools/analyze/compare.js.map +1 -0
- package/dist/tools/analyze/mockup.d.ts +12 -0
- package/dist/tools/analyze/mockup.d.ts.map +1 -0
- package/dist/tools/analyze/mockup.js +88 -0
- package/dist/tools/analyze/mockup.js.map +1 -0
- package/dist/tools/analyze/screenshot.d.ts +12 -0
- package/dist/tools/analyze/screenshot.d.ts.map +1 -0
- package/dist/tools/analyze/screenshot.js +61 -0
- package/dist/tools/analyze/screenshot.js.map +1 -0
- package/dist/tools/app-assets/app-icon.d.ts +9 -0
- package/dist/tools/app-assets/app-icon.d.ts.map +1 -0
- package/dist/tools/app-assets/app-icon.js +133 -0
- package/dist/tools/app-assets/app-icon.js.map +1 -0
- package/dist/tools/app-assets/device-mockup.d.ts +9 -0
- package/dist/tools/app-assets/device-mockup.d.ts.map +1 -0
- package/dist/tools/app-assets/device-mockup.js +139 -0
- package/dist/tools/app-assets/device-mockup.js.map +1 -0
- package/dist/tools/app-assets/launch-images.d.ts +3 -0
- package/dist/tools/app-assets/launch-images.d.ts.map +1 -0
- package/dist/tools/app-assets/launch-images.js +171 -0
- package/dist/tools/app-assets/launch-images.js.map +1 -0
- package/dist/tools/app-assets/resize-devices.d.ts +14 -0
- package/dist/tools/app-assets/resize-devices.d.ts.map +1 -0
- package/dist/tools/app-assets/resize-devices.js +296 -0
- package/dist/tools/app-assets/resize-devices.js.map +1 -0
- package/dist/tools/app-assets/screenshots.d.ts +14 -0
- package/dist/tools/app-assets/screenshots.d.ts.map +1 -0
- package/dist/tools/app-assets/screenshots.js +186 -0
- package/dist/tools/app-assets/screenshots.js.map +1 -0
- package/dist/tools/core/edit-image.d.ts +12 -0
- package/dist/tools/core/edit-image.d.ts.map +1 -0
- package/dist/tools/core/edit-image.js +102 -0
- package/dist/tools/core/edit-image.js.map +1 -0
- package/dist/tools/core/generate-image.d.ts +12 -0
- package/dist/tools/core/generate-image.d.ts.map +1 -0
- package/dist/tools/core/generate-image.js +96 -0
- package/dist/tools/core/generate-image.js.map +1 -0
- package/dist/tools/core/restore-image.d.ts +12 -0
- package/dist/tools/core/restore-image.d.ts.map +1 -0
- package/dist/tools/core/restore-image.js +104 -0
- package/dist/tools/core/restore-image.js.map +1 -0
- package/dist/tools/design/mockup-to-code.d.ts +3 -0
- package/dist/tools/design/mockup-to-code.d.ts.map +1 -0
- package/dist/tools/design/mockup-to-code.js +311 -0
- package/dist/tools/design/mockup-to-code.js.map +1 -0
- package/dist/tools/design/sketch-to-code.d.ts +3 -0
- package/dist/tools/design/sketch-to-code.d.ts.map +1 -0
- package/dist/tools/design/sketch-to-code.js +325 -0
- package/dist/tools/design/sketch-to-code.js.map +1 -0
- package/dist/tools/docs/architecture-diagram.d.ts +12 -0
- package/dist/tools/docs/architecture-diagram.d.ts.map +1 -0
- package/dist/tools/docs/architecture-diagram.js +179 -0
- package/dist/tools/docs/architecture-diagram.js.map +1 -0
- package/dist/tools/docs/readme-banner.d.ts +6 -0
- package/dist/tools/docs/readme-banner.d.ts.map +1 -0
- package/dist/tools/docs/readme-banner.js +108 -0
- package/dist/tools/docs/readme-banner.js.map +1 -0
- package/dist/tools/docs/sequence-diagram.d.ts +12 -0
- package/dist/tools/docs/sequence-diagram.d.ts.map +1 -0
- package/dist/tools/docs/sequence-diagram.js +161 -0
- package/dist/tools/docs/sequence-diagram.js.map +1 -0
- package/dist/tools/docs/social-preview.d.ts +11 -0
- package/dist/tools/docs/social-preview.d.ts.map +1 -0
- package/dist/tools/docs/social-preview.js +111 -0
- package/dist/tools/docs/social-preview.js.map +1 -0
- package/dist/tools/video/extend-video.d.ts +14 -0
- package/dist/tools/video/extend-video.d.ts.map +1 -0
- package/dist/tools/video/extend-video.js +39 -0
- package/dist/tools/video/extend-video.js.map +1 -0
- package/dist/tools/video/generate-video.d.ts +14 -0
- package/dist/tools/video/generate-video.d.ts.map +1 -0
- package/dist/tools/video/generate-video.js +39 -0
- package/dist/tools/video/generate-video.js.map +1 -0
- package/dist/tools/video/image-to-video.d.ts +15 -0
- package/dist/tools/video/image-to-video.d.ts.map +1 -0
- package/dist/tools/video/image-to-video.js +42 -0
- package/dist/tools/video/image-to-video.js.map +1 -0
- package/dist/tools/video/storyboard-video.d.ts +91 -0
- package/dist/tools/video/storyboard-video.d.ts.map +1 -0
- package/dist/tools/video/storyboard-video.js +230 -0
- package/dist/tools/video/storyboard-video.js.map +1 -0
- package/dist/utils/ffmpeg.d.ts +30 -0
- package/dist/utils/ffmpeg.d.ts.map +1 -0
- package/dist/utils/ffmpeg.js +205 -0
- package/dist/utils/ffmpeg.js.map +1 -0
- package/dist/utils/file-handler.d.ts +7 -0
- package/dist/utils/file-handler.d.ts.map +1 -0
- package/dist/utils/file-handler.js +10 -0
- package/dist/utils/file-handler.js.map +1 -0
- package/dist/utils/image-processing.d.ts +7 -0
- package/dist/utils/image-processing.d.ts.map +1 -0
- package/dist/utils/image-processing.js +10 -0
- package/dist/utils/image-processing.js.map +1 -0
- package/docs/PLUGIN-VERIFICATION.md +182 -0
- package/logs/notifications.jsonl +46 -0
- package/package.json +61 -0
- package/prd.json +216 -0
- package/progress.txt +145 -0
- package/ralph-report.html +297 -0
- package/src/index.ts +23 -0
- package/src/platforms/android/.gitkeep +0 -0
- package/src/platforms/ios/.gitkeep +0 -0
- package/src/platforms/web/.gitkeep +0 -0
- package/src/providers/.gitkeep +0 -0
- package/src/providers/gemini.ts +288 -0
- package/src/tools/core/.gitkeep +0 -0
- package/src/tools/platform/.gitkeep +0 -0
- package/src/tools/video/extend-video.ts +71 -0
- package/src/tools/video/generate-video.ts +70 -0
- package/src/tools/video/image-to-video.ts +76 -0
- package/src/tools/video/storyboard-video.ts +325 -0
- package/src/utils/.gitkeep +0 -0
- package/src/utils/ffmpeg.ts +266 -0
- package/src/utils/file-handler.ts +10 -0
- package/src/utils/image-processing.ts +10 -0
- package/templates/.gitkeep +0 -0
- package/test-analyze-screenshot.ts +50 -0
- package/test-app-icons.ts +55 -0
- package/test-cat-sunset.ts +30 -0
- package/test-full-plugin.ts +88 -0
- package/test-icon-gen.ts +30 -0
- package/test-output/test-edit.png +0 -0
- package/test-output/test-generate.png +0 -0
- package/test-output/test-video.mp4 +0 -0
- package/test-plugin-load.js +45 -0
- package/test-princess-emma-continue.ts +35 -0
- package/test-princess-emma-full.ts +38 -0
- package/test-princess-emma-short.ts +32 -0
- package/test-princess-emma-with-reference.ts +34 -0
- package/test-princess-emma.ts +38 -0
- package/test-product-ad.ts +66 -0
- package/test-ralph-droid.ts +30 -0
- package/test-social-preview.ts +61 -0
- package/test-veo31-live.ts +187 -0
- package/test-video-gen.ts +40 -0
- package/test-video-veo.ts +73 -0
- package/test-zurich-video.ts +64 -0
- package/tests/.gitkeep +0 -0
- package/tests/providers/gemini.test.ts +388 -0
- package/tests/utils/ffmpeg.test.ts +328 -0
- package/tests/video/storyboard.test.ts +469 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storyboard Video Generation Tool
|
|
3
|
+
*
|
|
4
|
+
* Generates a multi-scene video by creating individual scenes in parallel
|
|
5
|
+
* and stitching them together with transitions using FFmpeg.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { GeminiProvider, type ReferenceImage } from '../../providers/gemini.js';
|
|
9
|
+
import {
|
|
10
|
+
checkFfmpegInstalled,
|
|
11
|
+
concatenateVideos,
|
|
12
|
+
addAudioTrack,
|
|
13
|
+
type ConcatenateOptions,
|
|
14
|
+
} from '../../utils/ffmpeg.js';
|
|
15
|
+
import { writeFile, unlink, readFile } from 'fs/promises';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
import { tmpdir } from 'os';
|
|
18
|
+
|
|
19
|
+
export interface StoryboardVideoOptions {
|
|
20
|
+
scenes: string[];
|
|
21
|
+
style?: string;
|
|
22
|
+
characterDescription?: string;
|
|
23
|
+
referenceImages?: string[];
|
|
24
|
+
aspectRatio?: '16:9' | '9:16';
|
|
25
|
+
transition?: 'cut' | 'crossfade' | 'fade';
|
|
26
|
+
transitionDuration?: number;
|
|
27
|
+
backgroundMusic?: string;
|
|
28
|
+
musicVolume?: number;
|
|
29
|
+
outputPath?: string;
|
|
30
|
+
apiKey: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface StoryboardVideoResult {
|
|
34
|
+
/** Path to the final stitched video */
|
|
35
|
+
videoPath: string;
|
|
36
|
+
/** Total generation time in milliseconds */
|
|
37
|
+
totalTime: number;
|
|
38
|
+
/** Per-scene generation times */
|
|
39
|
+
sceneTimes: Array<{ scene: number; time: number }>;
|
|
40
|
+
/** Number of scenes successfully generated */
|
|
41
|
+
successCount: number;
|
|
42
|
+
/** Number of scenes that failed */
|
|
43
|
+
failureCount: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Generate a multi-scene storyboard video
|
|
48
|
+
*
|
|
49
|
+
* This function generates multiple video scenes in parallel and stitches them
|
|
50
|
+
* together with transitions to create a cohesive video narrative.
|
|
51
|
+
*
|
|
52
|
+
* @param options - Configuration options for the storyboard video
|
|
53
|
+
* @returns Result object with video path and timing information
|
|
54
|
+
*
|
|
55
|
+
* @example
|
|
56
|
+
* Basic usage:
|
|
57
|
+
* ```typescript
|
|
58
|
+
* const result = await generateStoryboardVideo({
|
|
59
|
+
* apiKey: 'your-api-key',
|
|
60
|
+
* scenes: [
|
|
61
|
+
* 'A serene mountain landscape at sunrise',
|
|
62
|
+
* 'A hiker reaching the summit',
|
|
63
|
+
* 'Panoramic view from the peak'
|
|
64
|
+
* ],
|
|
65
|
+
* style: 'cinematic',
|
|
66
|
+
* transition: 'crossfade',
|
|
67
|
+
* outputPath: './mountain-journey.mp4'
|
|
68
|
+
* });
|
|
69
|
+
* ```
|
|
70
|
+
*
|
|
71
|
+
* @example
|
|
72
|
+
* With character consistency:
|
|
73
|
+
* ```typescript
|
|
74
|
+
* const result = await generateStoryboardVideo({
|
|
75
|
+
* apiKey: 'your-api-key',
|
|
76
|
+
* characterDescription: 'A young woman with long brown hair wearing a red jacket',
|
|
77
|
+
* referenceImages: ['./character-ref.jpg'],
|
|
78
|
+
* scenes: [
|
|
79
|
+
* 'Walking through a forest',
|
|
80
|
+
* 'Discovering a hidden waterfall',
|
|
81
|
+
* 'Setting up camp at sunset'
|
|
82
|
+
* ],
|
|
83
|
+
* style: 'cinematic',
|
|
84
|
+
* transition: 'crossfade'
|
|
85
|
+
* });
|
|
86
|
+
* ```
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* With background music:
|
|
90
|
+
* ```typescript
|
|
91
|
+
* const result = await generateStoryboardVideo({
|
|
92
|
+
* apiKey: 'your-api-key',
|
|
93
|
+
* scenes: ['Scene 1', 'Scene 2', 'Scene 3'],
|
|
94
|
+
* generateAudio: true, // Native Veo audio (default)
|
|
95
|
+
* backgroundMusic: './music.mp3', // Add background music
|
|
96
|
+
* musicVolume: 0.3, // 30% volume for background music
|
|
97
|
+
* transition: 'crossfade'
|
|
98
|
+
* });
|
|
99
|
+
* ```
|
|
100
|
+
*/
|
|
101
|
+
export async function generateStoryboardVideo(
|
|
102
|
+
options: StoryboardVideoOptions
|
|
103
|
+
): Promise<StoryboardVideoResult> {
|
|
104
|
+
const startTime = Date.now();
|
|
105
|
+
|
|
106
|
+
// Validate inputs
|
|
107
|
+
if (!options.scenes || options.scenes.length === 0) {
|
|
108
|
+
throw new Error('At least one scene is required');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Check FFmpeg availability
|
|
112
|
+
const ffmpegAvailable = await checkFfmpegInstalled();
|
|
113
|
+
if (!ffmpegAvailable) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'FFmpeg is not installed or not available in PATH. Please install FFmpeg to use this tool.'
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Initialize provider
|
|
120
|
+
const provider = new GeminiProvider(options.apiKey);
|
|
121
|
+
|
|
122
|
+
const {
|
|
123
|
+
scenes,
|
|
124
|
+
style,
|
|
125
|
+
characterDescription,
|
|
126
|
+
referenceImages,
|
|
127
|
+
aspectRatio = '16:9',
|
|
128
|
+
transition = 'crossfade',
|
|
129
|
+
transitionDuration = 0.5,
|
|
130
|
+
backgroundMusic,
|
|
131
|
+
musicVolume = 0.3,
|
|
132
|
+
outputPath = join(tmpdir(), `storyboard-${Date.now()}.mp4`),
|
|
133
|
+
} = options;
|
|
134
|
+
|
|
135
|
+
// Load reference images if provided
|
|
136
|
+
let loadedReferences: ReferenceImage[] | undefined;
|
|
137
|
+
if (referenceImages && referenceImages.length > 0) {
|
|
138
|
+
if (referenceImages.length > 3) {
|
|
139
|
+
throw new Error('Maximum of 3 reference images allowed');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log(`📸 Loading ${referenceImages.length} reference image(s)...`);
|
|
143
|
+
loadedReferences = await Promise.all(
|
|
144
|
+
referenceImages.map(async (imagePath, index) => {
|
|
145
|
+
const buffer = await readFile(imagePath);
|
|
146
|
+
return {
|
|
147
|
+
buffer,
|
|
148
|
+
description: `Reference image ${index + 1} for character/scene consistency`,
|
|
149
|
+
};
|
|
150
|
+
})
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
console.log(`\n🎬 Starting storyboard generation with ${scenes.length} scene(s)`);
|
|
155
|
+
console.log(`⚙️ Configuration:`);
|
|
156
|
+
console.log(` - Aspect ratio: ${aspectRatio}`);
|
|
157
|
+
console.log(` - Transition: ${transition} (${transitionDuration}s)`);
|
|
158
|
+
console.log(` - Audio: native (Veo 3.0)${backgroundMusic ? ' + background music' : ''}`);
|
|
159
|
+
|
|
160
|
+
if (characterDescription) {
|
|
161
|
+
console.log(`👤 Using character description: "${characterDescription}"`);
|
|
162
|
+
}
|
|
163
|
+
if (loadedReferences) {
|
|
164
|
+
console.log(`🖼️ Using ${loadedReferences.length} reference image(s) for consistency`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
console.log(`\n📹 Generating scenes sequentially (to avoid rate limits)...`);
|
|
168
|
+
|
|
169
|
+
type SceneResult = {
|
|
170
|
+
index: number;
|
|
171
|
+
path: string | null;
|
|
172
|
+
time: number;
|
|
173
|
+
success: boolean;
|
|
174
|
+
error?: string;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const sceneResults: SceneResult[] = [];
|
|
178
|
+
|
|
179
|
+
for (let index = 0; index < scenes.length; index++) {
|
|
180
|
+
const sceneDescription = scenes[index]!;
|
|
181
|
+
const sceneStartTime = Date.now();
|
|
182
|
+
console.log(` [${index + 1}/${scenes.length}] Generating: "${sceneDescription.slice(0, 50)}${sceneDescription.length > 50 ? '...' : ''}"`);
|
|
183
|
+
|
|
184
|
+
try {
|
|
185
|
+
let prompt = sceneDescription;
|
|
186
|
+
|
|
187
|
+
if (characterDescription) {
|
|
188
|
+
prompt = `${characterDescription}. ${prompt}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (style) {
|
|
192
|
+
prompt = `${style} style: ${prompt}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let result;
|
|
196
|
+
if (loadedReferences && loadedReferences.length > 0) {
|
|
197
|
+
result = await provider.generateVideoWithReferences(
|
|
198
|
+
prompt,
|
|
199
|
+
loadedReferences,
|
|
200
|
+
{
|
|
201
|
+
aspectRatio,
|
|
202
|
+
resolution: '720p',
|
|
203
|
+
duration: 8,
|
|
204
|
+
numberOfVideos: 1,
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
} else {
|
|
208
|
+
result = await provider.generateVideo(prompt, {
|
|
209
|
+
aspectRatio,
|
|
210
|
+
resolution: '720p',
|
|
211
|
+
duration: 8,
|
|
212
|
+
numberOfVideos: 1,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const tempPath = join(tmpdir(), `scene-${index}-${Date.now()}.mp4`);
|
|
217
|
+
await writeFile(tempPath, result.buffer);
|
|
218
|
+
|
|
219
|
+
const sceneTime = Date.now() - sceneStartTime;
|
|
220
|
+
console.log(` ✅ [${index + 1}/${scenes.length}] Completed in ${(sceneTime / 1000).toFixed(1)}s`);
|
|
221
|
+
|
|
222
|
+
sceneResults.push({
|
|
223
|
+
index,
|
|
224
|
+
path: tempPath,
|
|
225
|
+
time: sceneTime,
|
|
226
|
+
success: true,
|
|
227
|
+
});
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const sceneTime = Date.now() - sceneStartTime;
|
|
230
|
+
console.error(` ❌ [${index + 1}/${scenes.length}] Failed after ${(sceneTime / 1000).toFixed(1)}s:`, error);
|
|
231
|
+
|
|
232
|
+
sceneResults.push({
|
|
233
|
+
index,
|
|
234
|
+
path: null,
|
|
235
|
+
time: sceneTime,
|
|
236
|
+
success: false,
|
|
237
|
+
error: error instanceof Error ? error.message : String(error),
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Filter successful scenes
|
|
243
|
+
const successfulScenes = sceneResults.filter(
|
|
244
|
+
(result): result is { index: number; path: string; time: number; success: true } =>
|
|
245
|
+
result.success && result.path !== null
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
if (successfulScenes.length === 0) {
|
|
249
|
+
throw new Error('All scenes failed to generate. No video to stitch.');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (successfulScenes.length < sceneResults.length) {
|
|
253
|
+
console.warn(
|
|
254
|
+
`⚠️ ${sceneResults.length - successfulScenes.length} scene(s) failed. Continuing with ${successfulScenes.length} successful scene(s).`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Sort by original index to maintain scene order
|
|
259
|
+
successfulScenes.sort((a, b) => a.index - b.index);
|
|
260
|
+
const videoPaths = successfulScenes.map((s) => s.path);
|
|
261
|
+
|
|
262
|
+
// Stitch videos together
|
|
263
|
+
console.log(`\n🎞️ Stitching ${videoPaths.length} scene(s) together...`);
|
|
264
|
+
console.log(` - Transition: ${transition}`);
|
|
265
|
+
console.log(` - Duration: ${transitionDuration}s`);
|
|
266
|
+
|
|
267
|
+
const concatenateOptions: ConcatenateOptions = {
|
|
268
|
+
transition,
|
|
269
|
+
transitionDuration,
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
// Determine final output path based on whether we need to add background music
|
|
273
|
+
const stitchedVideoPath = backgroundMusic
|
|
274
|
+
? join(tmpdir(), `stitched-${Date.now()}.mp4`)
|
|
275
|
+
: outputPath;
|
|
276
|
+
|
|
277
|
+
try {
|
|
278
|
+
await concatenateVideos(videoPaths, stitchedVideoPath, concatenateOptions);
|
|
279
|
+
|
|
280
|
+
// Add background music if provided
|
|
281
|
+
if (backgroundMusic) {
|
|
282
|
+
console.log(`\n🎵 Adding background music...`);
|
|
283
|
+
console.log(` - Volume: ${(musicVolume * 100).toFixed(0)}%`);
|
|
284
|
+
await addAudioTrack(stitchedVideoPath, backgroundMusic, outputPath, musicVolume);
|
|
285
|
+
console.log(` ✅ Audio mixing complete`);
|
|
286
|
+
// Clean up temporary stitched video
|
|
287
|
+
await unlink(stitchedVideoPath).catch(() => {});
|
|
288
|
+
}
|
|
289
|
+
} finally {
|
|
290
|
+
// Clean up temporary scene files
|
|
291
|
+
for (const videoPath of videoPaths) {
|
|
292
|
+
try {
|
|
293
|
+
await unlink(videoPath);
|
|
294
|
+
} catch {
|
|
295
|
+
// Ignore cleanup errors
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const totalTime = Date.now() - startTime;
|
|
301
|
+
|
|
302
|
+
// Calculate average scene generation time
|
|
303
|
+
const avgSceneTime = successfulScenes.length > 0
|
|
304
|
+
? successfulScenes.reduce((sum, s) => sum + s.time, 0) / successfulScenes.length
|
|
305
|
+
: 0;
|
|
306
|
+
|
|
307
|
+
console.log(`\n✨ Storyboard generation complete!`);
|
|
308
|
+
console.log(`📊 Summary:`);
|
|
309
|
+
console.log(` - Total time: ${(totalTime / 1000).toFixed(1)}s`);
|
|
310
|
+
console.log(` - Scenes generated: ${successfulScenes.length}/${sceneResults.length}`);
|
|
311
|
+
console.log(` - Average scene time: ${(avgSceneTime / 1000).toFixed(1)}s`);
|
|
312
|
+
console.log(` - Success rate: ${((successfulScenes.length / sceneResults.length) * 100).toFixed(0)}%`);
|
|
313
|
+
console.log(`📁 Output: ${outputPath}\n`);
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
videoPath: outputPath,
|
|
317
|
+
totalTime,
|
|
318
|
+
sceneTimes: sceneResults.map((r) => ({
|
|
319
|
+
scene: r.index + 1,
|
|
320
|
+
time: r.time,
|
|
321
|
+
})),
|
|
322
|
+
successCount: successfulScenes.length,
|
|
323
|
+
failureCount: sceneResults.length - successfulScenes.length,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FFmpeg Utility Module
|
|
3
|
+
*
|
|
4
|
+
* Provides video processing operations using FFmpeg
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { exec } from 'child_process';
|
|
8
|
+
import { promisify } from 'util';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
|
|
11
|
+
const execAsync = promisify(exec);
|
|
12
|
+
|
|
13
|
+
export interface ConcatenateOptions {
|
|
14
|
+
transition?: 'cut' | 'crossfade' | 'fade';
|
|
15
|
+
transitionDuration?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Check if FFmpeg is installed and available
|
|
20
|
+
*/
|
|
21
|
+
export async function checkFfmpegInstalled(): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
await execAsync('ffmpeg -version');
|
|
24
|
+
return true;
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get the duration of a video in seconds
|
|
32
|
+
*/
|
|
33
|
+
export async function getVideoDuration(videoPath: string): Promise<number> {
|
|
34
|
+
if (!existsSync(videoPath)) {
|
|
35
|
+
throw new Error(`Video file not found: ${videoPath}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const { stdout } = await execAsync(
|
|
40
|
+
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${videoPath}"`
|
|
41
|
+
);
|
|
42
|
+
const duration = parseFloat(stdout.trim());
|
|
43
|
+
if (isNaN(duration)) {
|
|
44
|
+
throw new Error(`Failed to parse video duration: ${stdout}`);
|
|
45
|
+
}
|
|
46
|
+
return duration;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(`Failed to get video duration: ${error instanceof Error ? error.message : String(error)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Trim a video from startTime for the specified duration
|
|
54
|
+
*/
|
|
55
|
+
export async function trimVideo(
|
|
56
|
+
videoPath: string,
|
|
57
|
+
startTime: number,
|
|
58
|
+
duration: number,
|
|
59
|
+
outputPath: string
|
|
60
|
+
): Promise<void> {
|
|
61
|
+
if (!existsSync(videoPath)) {
|
|
62
|
+
throw new Error(`Video file not found: ${videoPath}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (startTime < 0 || duration <= 0) {
|
|
66
|
+
throw new Error(`Invalid trim parameters: startTime=${startTime}, duration=${duration}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await execAsync(
|
|
71
|
+
`ffmpeg -i "${videoPath}" -ss ${startTime} -t ${duration} -c copy "${outputPath}" -y`
|
|
72
|
+
);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
throw new Error(`Failed to trim video: ${error instanceof Error ? error.message : String(error)}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Add an audio track to a video
|
|
80
|
+
*/
|
|
81
|
+
export async function addAudioTrack(
|
|
82
|
+
videoPath: string,
|
|
83
|
+
audioPath: string,
|
|
84
|
+
outputPath: string,
|
|
85
|
+
volume: number = 1.0
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
if (!existsSync(videoPath)) {
|
|
88
|
+
throw new Error(`Video file not found: ${videoPath}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!existsSync(audioPath)) {
|
|
92
|
+
throw new Error(`Audio file not found: ${audioPath}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (volume < 0 || volume > 1) {
|
|
96
|
+
throw new Error(`Volume must be between 0 and 1, got: ${volume}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// Mix video audio with background audio, adjust volume of background
|
|
101
|
+
await execAsync(
|
|
102
|
+
`ffmpeg -i "${videoPath}" -i "${audioPath}" -filter_complex "[1:a]volume=${volume}[a1];[0:a][a1]amix=inputs=2:duration=first[aout]" -map 0:v -map "[aout]" -c:v copy -c:a aac "${outputPath}" -y`
|
|
103
|
+
);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
throw new Error(`Failed to add audio track: ${error instanceof Error ? error.message : String(error)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Concatenate multiple videos with transitions
|
|
111
|
+
*/
|
|
112
|
+
export async function concatenateVideos(
|
|
113
|
+
videoPaths: string[],
|
|
114
|
+
outputPath: string,
|
|
115
|
+
options: ConcatenateOptions = {}
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
if (videoPaths.length === 0) {
|
|
118
|
+
throw new Error('No videos provided for concatenation');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Check all input files exist
|
|
122
|
+
for (const videoPath of videoPaths) {
|
|
123
|
+
if (!existsSync(videoPath)) {
|
|
124
|
+
throw new Error(`Video file not found: ${videoPath}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { transition = 'crossfade', transitionDuration = 0.5 } = options;
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
if (transition === 'cut') {
|
|
132
|
+
// Simple concatenation without transitions
|
|
133
|
+
await concatenateWithCut(videoPaths, outputPath);
|
|
134
|
+
} else if (transition === 'crossfade') {
|
|
135
|
+
// Crossfade transition between videos
|
|
136
|
+
await concatenateWithCrossfade(videoPaths, outputPath, transitionDuration);
|
|
137
|
+
} else if (transition === 'fade') {
|
|
138
|
+
// Fade to black transition between videos
|
|
139
|
+
await concatenateWithFade(videoPaths, outputPath, transitionDuration);
|
|
140
|
+
} else {
|
|
141
|
+
throw new Error(`Unknown transition type: ${transition}`);
|
|
142
|
+
}
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new Error(`Failed to concatenate videos: ${error instanceof Error ? error.message : String(error)}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Concatenate videos with simple cut (no transition)
|
|
150
|
+
*/
|
|
151
|
+
async function concatenateWithCut(videoPaths: string[], outputPath: string): Promise<void> {
|
|
152
|
+
// Create a temporary concat file
|
|
153
|
+
const concatListPath = outputPath + '.concat.txt';
|
|
154
|
+
const fs = await import('fs/promises');
|
|
155
|
+
|
|
156
|
+
const concatContent = videoPaths.map(path => `file '${path}'`).join('\n');
|
|
157
|
+
await fs.writeFile(concatListPath, concatContent);
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
await execAsync(
|
|
161
|
+
`ffmpeg -f concat -safe 0 -i "${concatListPath}" -c copy "${outputPath}" -y`
|
|
162
|
+
);
|
|
163
|
+
} finally {
|
|
164
|
+
// Clean up concat file
|
|
165
|
+
await fs.unlink(concatListPath).catch(() => {});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function concatenateWithCrossfade(
|
|
170
|
+
videoPaths: string[],
|
|
171
|
+
outputPath: string,
|
|
172
|
+
transitionDuration: number
|
|
173
|
+
): Promise<void> {
|
|
174
|
+
if (videoPaths.length === 1) {
|
|
175
|
+
const fs = await import('fs/promises');
|
|
176
|
+
const firstVideo = videoPaths[0];
|
|
177
|
+
if (!firstVideo) {
|
|
178
|
+
throw new Error('No video path provided');
|
|
179
|
+
}
|
|
180
|
+
await fs.copyFile(firstVideo, outputPath);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const durations: number[] = [];
|
|
185
|
+
for (const videoPath of videoPaths) {
|
|
186
|
+
const dur = await getVideoDuration(videoPath);
|
|
187
|
+
durations.push(dur);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let filterComplex = '';
|
|
191
|
+
let audioFilterComplex = '';
|
|
192
|
+
let currentVideoLabel = '[0:v]';
|
|
193
|
+
let currentAudioLabel = '[0:a]';
|
|
194
|
+
let cumulativeOffset = durations[0]! - transitionDuration;
|
|
195
|
+
|
|
196
|
+
for (let i = 1; i < videoPaths.length; i++) {
|
|
197
|
+
const isLast = i === videoPaths.length - 1;
|
|
198
|
+
const nextVideoLabel = isLast ? '[outv]' : `[v${i}]`;
|
|
199
|
+
const nextAudioLabel = isLast ? '[outa]' : `[a${i}]`;
|
|
200
|
+
|
|
201
|
+
filterComplex += `${currentVideoLabel}[${i}:v]xfade=transition=fade:duration=${transitionDuration}:offset=${cumulativeOffset}${nextVideoLabel};`;
|
|
202
|
+
audioFilterComplex += `${currentAudioLabel}[${i}:a]acrossfade=d=${transitionDuration}${nextAudioLabel};`;
|
|
203
|
+
|
|
204
|
+
currentVideoLabel = nextVideoLabel;
|
|
205
|
+
currentAudioLabel = nextAudioLabel;
|
|
206
|
+
|
|
207
|
+
if (!isLast) {
|
|
208
|
+
cumulativeOffset += durations[i]! - transitionDuration;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const fullFilter = filterComplex + audioFilterComplex.slice(0, -1);
|
|
213
|
+
const inputs = videoPaths.map(path => `-i "${path}"`).join(' ');
|
|
214
|
+
|
|
215
|
+
await execAsync(
|
|
216
|
+
`ffmpeg ${inputs} -filter_complex "${fullFilter}" -map "[outv]" -map "[outa]" "${outputPath}" -y`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Concatenate videos with fade to black transition
|
|
222
|
+
*/
|
|
223
|
+
async function concatenateWithFade(
|
|
224
|
+
videoPaths: string[],
|
|
225
|
+
outputPath: string,
|
|
226
|
+
duration: number
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
if (videoPaths.length === 1) {
|
|
229
|
+
// No transition needed for single video
|
|
230
|
+
const fs = await import('fs/promises');
|
|
231
|
+
const firstVideo = videoPaths[0];
|
|
232
|
+
if (!firstVideo) {
|
|
233
|
+
throw new Error('No video path provided');
|
|
234
|
+
}
|
|
235
|
+
await fs.copyFile(firstVideo, outputPath);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// For fade transition, we fade out each clip and fade in the next
|
|
240
|
+
// This is complex with FFmpeg, using a simpler concatenation approach
|
|
241
|
+
const inputs = videoPaths.map(path => `-i "${path}"`).join(' ');
|
|
242
|
+
|
|
243
|
+
// Build filter to fade out and fade in
|
|
244
|
+
let filterComplex = '';
|
|
245
|
+
for (let i = 0; i < videoPaths.length; i++) {
|
|
246
|
+
if (i < videoPaths.length - 1) {
|
|
247
|
+
// Fade out at the end of each video except the last
|
|
248
|
+
filterComplex += `[${i}:v]fade=t=out:st=0:d=${duration}[v${i}out];`;
|
|
249
|
+
// Fade in at the start of next video
|
|
250
|
+
filterComplex += `[${i + 1}:v]fade=t=in:st=0:d=${duration}[v${i + 1}in];`;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Concatenate the faded segments
|
|
255
|
+
const concatInputs = videoPaths.map((_, i) => {
|
|
256
|
+
if (i === 0) return '[v0out]';
|
|
257
|
+
if (i === videoPaths.length - 1) return `[v${i}in]`;
|
|
258
|
+
return `[v${i}out][v${i}in]`;
|
|
259
|
+
}).join('');
|
|
260
|
+
|
|
261
|
+
filterComplex += `${concatInputs}concat=n=${videoPaths.length}:v=1:a=0[outv]`;
|
|
262
|
+
|
|
263
|
+
await execAsync(
|
|
264
|
+
`ffmpeg ${inputs} -filter_complex "${filterComplex}" -map "[outv]" "${outputPath}" -y`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Image Processing Utilities
|
|
3
|
+
*
|
|
4
|
+
* Utilities for processing and manipulating images
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function resizeImage(buffer: Buffer, _width: number, _height: number): Promise<Buffer> {
|
|
8
|
+
// Placeholder - will be implemented when needed
|
|
9
|
+
return Promise.resolve(buffer);
|
|
10
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { GeminiProvider } from './src/providers/gemini.js';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
async function analyzeScreenshot() {
|
|
5
|
+
console.log('🔍 Analyzing Screenshot...\n');
|
|
6
|
+
|
|
7
|
+
const provider = new GeminiProvider();
|
|
8
|
+
const imagePath = '/Volumes/DevHub_ext/factory/toolbox/opencode-nanobanana/generated-assets/screenshots/IMG_3619.jpg';
|
|
9
|
+
|
|
10
|
+
console.log(`📁 Image: ${imagePath}\n`);
|
|
11
|
+
|
|
12
|
+
const imageBuffer = await fs.readFile(imagePath);
|
|
13
|
+
console.log(`📦 Size: ${(imageBuffer.length / 1024).toFixed(1)}KB\n`);
|
|
14
|
+
|
|
15
|
+
const question = `Analyze this UI screenshot in detail. Please provide:
|
|
16
|
+
|
|
17
|
+
1. **OVERVIEW**: What app/screen is this? What is its purpose?
|
|
18
|
+
|
|
19
|
+
2. **WHAT'S GOOD** ✅:
|
|
20
|
+
- Layout and composition
|
|
21
|
+
- Typography and readability
|
|
22
|
+
- Color scheme and contrast
|
|
23
|
+
- Visual hierarchy
|
|
24
|
+
- User experience elements
|
|
25
|
+
|
|
26
|
+
3. **WHAT NEEDS IMPROVEMENT** ❌:
|
|
27
|
+
- Accessibility issues
|
|
28
|
+
- Design inconsistencies
|
|
29
|
+
- Usability problems
|
|
30
|
+
- Visual clutter or confusion
|
|
31
|
+
- Missing elements
|
|
32
|
+
|
|
33
|
+
4. **SPECIFIC RECOMMENDATIONS**:
|
|
34
|
+
- Actionable suggestions to improve the UI
|
|
35
|
+
|
|
36
|
+
Be specific and reference actual elements you see in the screenshot.`;
|
|
37
|
+
|
|
38
|
+
console.log('⏳ Analyzing with AI vision...\n');
|
|
39
|
+
|
|
40
|
+
const startTime = Date.now();
|
|
41
|
+
const analysis = await provider.analyzeImage(imageBuffer, question);
|
|
42
|
+
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
43
|
+
|
|
44
|
+
console.log(`✅ Analysis complete in ${duration}s\n`);
|
|
45
|
+
console.log('━'.repeat(60));
|
|
46
|
+
console.log('\n' + analysis + '\n');
|
|
47
|
+
console.log('━'.repeat(60));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
analyzeScreenshot().catch(console.error);
|