@gannochenko/staticstripes 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.d.ts +3 -4
  2. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.d.ts.map +1 -1
  3. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.js +22 -32
  4. package/dist/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.js.map +1 -1
  5. package/dist/cli/commands/generate.d.ts.map +1 -1
  6. package/dist/cli/commands/generate.js +8 -2
  7. package/dist/cli/commands/generate.js.map +1 -1
  8. package/dist/cli/credentials.d.ts +81 -0
  9. package/dist/cli/credentials.d.ts.map +1 -0
  10. package/dist/cli/credentials.js +109 -0
  11. package/dist/cli/credentials.js.map +1 -0
  12. package/dist/cli/instagram/instagram-upload-strategy.d.ts +3 -1
  13. package/dist/cli/instagram/instagram-upload-strategy.d.ts.map +1 -1
  14. package/dist/cli/instagram/instagram-upload-strategy.js +37 -26
  15. package/dist/cli/instagram/instagram-upload-strategy.js.map +1 -1
  16. package/dist/cli/s3/s3-upload-strategy.d.ts +7 -1
  17. package/dist/cli/s3/s3-upload-strategy.d.ts.map +1 -1
  18. package/dist/cli/s3/s3-upload-strategy.js +142 -43
  19. package/dist/cli/s3/s3-upload-strategy.js.map +1 -1
  20. package/dist/cli/youtube/youtube-upload-strategy.d.ts +3 -0
  21. package/dist/cli/youtube/youtube-upload-strategy.d.ts.map +1 -1
  22. package/dist/cli/youtube/youtube-upload-strategy.js +22 -20
  23. package/dist/cli/youtube/youtube-upload-strategy.js.map +1 -1
  24. package/dist/html-project-parser.d.ts +8 -0
  25. package/dist/html-project-parser.d.ts.map +1 -1
  26. package/dist/html-project-parser.js +86 -5
  27. package/dist/html-project-parser.js.map +1 -1
  28. package/dist/project.d.ts +3 -1
  29. package/dist/project.d.ts.map +1 -1
  30. package/dist/project.js +6 -1
  31. package/dist/project.js.map +1 -1
  32. package/dist/time-utils.d.ts +7 -0
  33. package/dist/time-utils.d.ts.map +1 -0
  34. package/dist/time-utils.js +17 -0
  35. package/dist/time-utils.js.map +1 -0
  36. package/dist/type.d.ts +1 -1
  37. package/dist/type.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/ai-music-api-ai/ai-music-api-ai-generation-strategy.ts +24 -55
  40. package/src/cli/commands/generate.ts +10 -2
  41. package/src/cli/credentials.ts +171 -0
  42. package/src/cli/instagram/instagram-upload-strategy.ts +38 -39
  43. package/src/cli/s3/s3-upload-strategy.ts +178 -57
  44. package/src/cli/youtube/youtube-upload-strategy.ts +22 -23
  45. package/src/html-project-parser.ts +99 -4
  46. package/src/project.ts +5 -0
  47. package/src/time-utils.ts +15 -0
  48. package/src/type.ts +1 -1
@@ -0,0 +1,171 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { resolve } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ /**
6
+ * Generic credentials interface for upload services
7
+ */
8
+ export interface Credentials {
9
+ [key: string]: any;
10
+ }
11
+
12
+ /**
13
+ * S3-specific credentials format
14
+ */
15
+ export interface S3Credentials extends Credentials {
16
+ accessKeyId: string;
17
+ secretAccessKey: string;
18
+ }
19
+
20
+ /**
21
+ * YouTube-specific credentials format
22
+ */
23
+ export interface YouTubeCredentials extends Credentials {
24
+ clientId: string;
25
+ clientSecret: string;
26
+ }
27
+
28
+ /**
29
+ * Instagram-specific credentials format
30
+ */
31
+ export interface InstagramCredentials extends Credentials {
32
+ accessToken: string;
33
+ igUserId: string;
34
+ }
35
+
36
+ /**
37
+ * AI Music API credentials format
38
+ */
39
+ export interface MusicAPICredentials extends Credentials {
40
+ apiKey: string;
41
+ }
42
+
43
+ /**
44
+ * Credentials manager that handles loading credentials from local or global locations
45
+ *
46
+ * Search priority:
47
+ * 1. Local: <projectPath>/.auth/<credentialName>.json
48
+ * 2. Global: $HOME/.staticstripes/auth/<credentialName>.json
49
+ */
50
+ export class CredentialsManager {
51
+ private projectPath: string;
52
+ private credentialName: string;
53
+
54
+ constructor(projectPath: string, credentialName: string) {
55
+ this.projectPath = projectPath;
56
+ this.credentialName = credentialName;
57
+ }
58
+
59
+ /**
60
+ * Get the local credentials path
61
+ */
62
+ private getLocalPath(): string {
63
+ const authDir = resolve(this.projectPath, '.auth');
64
+ return resolve(authDir, `${this.credentialName}.json`);
65
+ }
66
+
67
+ /**
68
+ * Get the global credentials path
69
+ * Works cross-platform (Windows, macOS, Linux)
70
+ */
71
+ private getGlobalPath(): string {
72
+ const homeDir = homedir();
73
+ const globalAuthDir = resolve(homeDir, '.staticstripes', 'auth');
74
+ return resolve(globalAuthDir, `${this.credentialName}.json`);
75
+ }
76
+
77
+ /**
78
+ * Find and return the credentials file path
79
+ * Returns the path and whether it was found locally or globally
80
+ */
81
+ private findCredentialsPath(): { path: string; location: 'local' | 'global' } | null {
82
+ const localPath = this.getLocalPath();
83
+ if (existsSync(localPath)) {
84
+ return { path: localPath, location: 'local' };
85
+ }
86
+
87
+ const globalPath = this.getGlobalPath();
88
+ if (existsSync(globalPath)) {
89
+ return { path: globalPath, location: 'global' };
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Load credentials from local or global location
97
+ *
98
+ * @param requiredFields - Array of required field names to validate
99
+ * @returns The credentials object
100
+ * @throws Error if credentials not found or invalid
101
+ */
102
+ load<T extends Credentials>(requiredFields: string[] = []): T {
103
+ const found = this.findCredentialsPath();
104
+
105
+ if (!found) {
106
+ const localPath = this.getLocalPath();
107
+ const globalPath = this.getGlobalPath();
108
+
109
+ throw new Error(
110
+ `āŒ Error: Credentials not found for "${this.credentialName}"\n\n` +
111
+ `Searched in:\n` +
112
+ ` 1. Local: ${localPath}\n` +
113
+ ` 2. Global: ${globalPath}\n\n` +
114
+ `šŸ’” Create a JSON file in one of these locations with your credentials.\n` +
115
+ ` The global location is useful for sharing credentials across projects.\n`,
116
+ );
117
+ }
118
+
119
+ const { path: credentialsPath, location } = found;
120
+ console.log(`šŸ” Loading credentials from ${location} storage: ${credentialsPath}`);
121
+
122
+ try {
123
+ const credentialsJson = readFileSync(credentialsPath, 'utf-8');
124
+ const credentials = JSON.parse(credentialsJson) as T;
125
+
126
+ // Validate required fields
127
+ const missingFields = requiredFields.filter(
128
+ (field) => !credentials[field],
129
+ );
130
+
131
+ if (missingFields.length > 0) {
132
+ throw new Error(
133
+ `Missing required fields: ${missingFields.join(', ')}`,
134
+ );
135
+ }
136
+
137
+ return credentials;
138
+ } catch (error) {
139
+ throw new Error(
140
+ `āŒ Error: Failed to parse credentials from ${credentialsPath}\n` +
141
+ `Ensure the file contains valid JSON.\n` +
142
+ `Error: ${error instanceof Error ? error.message : String(error)}`,
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Check if credentials exist (locally or globally)
149
+ */
150
+ exists(): boolean {
151
+ return this.findCredentialsPath() !== null;
152
+ }
153
+
154
+ /**
155
+ * Get information about where credentials are stored
156
+ */
157
+ getInfo(): {
158
+ localPath: string;
159
+ globalPath: string;
160
+ exists: boolean;
161
+ location?: 'local' | 'global';
162
+ } {
163
+ const found = this.findCredentialsPath();
164
+ return {
165
+ localPath: this.getLocalPath(),
166
+ globalPath: this.getGlobalPath(),
167
+ exists: found !== null,
168
+ location: found?.location,
169
+ };
170
+ }
171
+ }
@@ -1,18 +1,9 @@
1
1
  import { UploadStrategy } from '../upload-strategy';
2
2
  import { Project } from '../../project';
3
3
  import { Upload } from '../../type';
4
- import { readFileSync, existsSync } from 'fs';
5
- import { resolve } from 'path';
6
4
  import ejs from 'ejs';
7
5
  import { makeRequest } from '../../lib/net';
8
-
9
- /**
10
- * Instagram credentials format stored in .auth/<upload-name>.json
11
- */
12
- interface InstagramCredentials {
13
- accessToken: string; // Long-lived Instagram Graph API access token
14
- igUserId: string; // Instagram User ID (not the username)
15
- }
6
+ import { CredentialsManager, InstagramCredentials } from '../credentials';
16
7
 
17
8
  /**
18
9
  * Instagram upload strategy implementation
@@ -22,7 +13,7 @@ export class InstagramUploadStrategy implements UploadStrategy {
22
13
  private readonly API_VERSION = 'v21.0';
23
14
  private readonly GRAPH_API_BASE = 'https://graph.instagram.com';
24
15
 
25
- constructor() {}
16
+ constructor(private credentialsManager?: CredentialsManager) {}
26
17
 
27
18
  getTag(): string {
28
19
  return 'instagram';
@@ -47,36 +38,32 @@ export class InstagramUploadStrategy implements UploadStrategy {
47
38
  const { caption, shareToFeed, thumbOffset, coverUrl, videoUrl } =
48
39
  upload.instagram;
49
40
 
50
- // Load credentials from .auth/<upload-name>.json
51
- const authDir = resolve(projectPath, '.auth');
52
- const credentialsPath = resolve(authDir, `${upload.name}.json`);
53
-
54
- if (!existsSync(credentialsPath)) {
55
- throw new Error(
56
- `āŒ Error: Instagram credentials not found\n\n` +
57
- `Expected location: ${credentialsPath}\n\n` +
58
- `šŸ’” Run authentication wizard:\n` +
59
- ` staticstripes auth --upload-name ${upload.name}\n\n` +
60
- `šŸ“– Or view detailed setup instructions:\n` +
61
- ` staticstripes auth-help instagram\n`,
62
- );
63
- }
64
-
65
- console.log(`šŸ” Loading credentials from: ${credentialsPath}`);
41
+ // Load credentials from local .auth/<upload-name>.json or global ~/.staticstripes/auth/<upload-name>.json
42
+ const manager =
43
+ this.credentialsManager ||
44
+ new CredentialsManager(projectPath, upload.name);
66
45
 
67
46
  let credentials: InstagramCredentials;
68
47
  try {
69
- const credentialsJson = readFileSync(credentialsPath, 'utf-8');
70
- credentials = JSON.parse(credentialsJson);
71
-
72
- if (!credentials.accessToken || !credentials.igUserId) {
73
- throw new Error('Missing accessToken or igUserId');
74
- }
48
+ credentials = manager.load<InstagramCredentials>([
49
+ 'accessToken',
50
+ 'igUserId',
51
+ ]);
75
52
  } catch (error) {
53
+ // Add helpful context about Instagram credentials
54
+ const errorMessage =
55
+ error instanceof Error ? error.message : String(error);
76
56
  throw new Error(
77
- `āŒ Error: Failed to parse Instagram credentials from ${credentialsPath}\n` +
78
- `Ensure the file contains valid JSON with accessToken and igUserId.\n` +
79
- `Error: ${error instanceof Error ? error.message : String(error)}`,
57
+ `${errorMessage}\n\n` +
58
+ `šŸ’” Instagram credentials file should contain:\n` +
59
+ `{\n` +
60
+ ` "accessToken": "YOUR_LONG_LIVED_ACCESS_TOKEN",\n` +
61
+ ` "igUserId": "YOUR_INSTAGRAM_USER_ID"\n` +
62
+ `}\n\n` +
63
+ `šŸ“– Run authentication wizard:\n` +
64
+ ` staticstripes auth --upload-name ${upload.name}\n\n` +
65
+ `Or view detailed setup instructions:\n` +
66
+ ` staticstripes auth-help instagram\n`,
80
67
  );
81
68
  }
82
69
 
@@ -108,6 +95,9 @@ export class InstagramUploadStrategy implements UploadStrategy {
108
95
  // Determine title (use upload-specific title or fall back to project title)
109
96
  const title = upload.title || project.getTitle();
110
97
 
98
+ // Get date from project
99
+ const date = project.getDate();
100
+
111
101
  // Format tags with # and space-separated (Instagram style)
112
102
  const formattedTags = upload.tags.map((tag) => `#${tag}`).join(' ');
113
103
 
@@ -116,6 +106,7 @@ export class InstagramUploadStrategy implements UploadStrategy {
116
106
 
117
107
  const processedCaption = ejs.render(ejsCaption, {
118
108
  title,
109
+ date,
119
110
  tags: formattedTags,
120
111
  });
121
112
 
@@ -329,18 +320,26 @@ export class InstagramUploadStrategy implements UploadStrategy {
329
320
  throw new Error('S3 configuration missing');
330
321
  }
331
322
 
332
- const { endpoint, region, bucket, path } = s3Upload.s3;
323
+ const { endpoint, region, bucket, paths } = s3Upload.s3;
333
324
  const output = project.getOutput(s3Upload.outputName);
334
325
  if (!output) {
335
326
  throw new Error(`Output "${s3Upload.outputName}" not found`);
336
327
  }
337
328
 
329
+ // Get the file path
330
+ const filePath = paths.get('file');
331
+ if (!filePath) {
332
+ throw new Error('S3 upload missing "file" path');
333
+ }
334
+
338
335
  // Interpolate path variables
339
336
  const slug = this.slugify(project.getTitle());
340
337
  const outputName = output.name;
341
- const interpolatedPath = path
338
+ const date = project.getDate() || '';
339
+ const interpolatedPath = filePath
342
340
  .replace(/\$\{slug\}/g, slug)
343
- .replace(/\$\{output\}/g, outputName);
341
+ .replace(/\$\{output\}/g, outputName)
342
+ .replace(/\$\{date\}/g, date);
344
343
 
345
344
  // Construct URL
346
345
  if (endpoint) {
@@ -2,23 +2,20 @@ import { UploadStrategy } from '../upload-strategy';
2
2
  import { Project } from '../../project';
3
3
  import { Upload } from '../../type';
4
4
  import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
5
- import { readFileSync, existsSync } from 'fs';
6
- import { resolve } from 'path';
5
+ import { readFileSync, existsSync, mkdirSync } from 'fs';
6
+ import { CredentialsManager, S3Credentials } from '../credentials';
7
+ import { exec } from 'child_process';
8
+ import { promisify } from 'util';
9
+ import { dirname, resolve } from 'path';
7
10
 
8
- /**
9
- * S3 credentials format stored in .auth/<upload-name>.json
10
- */
11
- interface S3Credentials {
12
- accessKeyId: string;
13
- secretAccessKey: string;
14
- }
11
+ const execAsync = promisify(exec);
15
12
 
16
13
  /**
17
14
  * S3 upload strategy implementation
18
15
  * Supports generic S3-compatible storage (AWS S3, DigitalOcean Spaces, etc.)
19
16
  */
20
17
  export class S3UploadStrategy implements UploadStrategy {
21
- constructor() {}
18
+ constructor(private credentialsManager?: CredentialsManager) {}
22
19
 
23
20
  getTag(): string {
24
21
  return 's3';
@@ -40,7 +37,14 @@ export class S3UploadStrategy implements UploadStrategy {
40
37
  );
41
38
  }
42
39
 
43
- const { endpoint, region, bucket, path, acl } = upload.s3;
40
+ const { endpoint, region, bucket, paths, acl } = upload.s3;
41
+
42
+ // Validate that we have a "file" path
43
+ if (!paths.has('file')) {
44
+ throw new Error(
45
+ `āŒ Error: S3 upload "${upload.name}" missing required <path name="file"> element`,
46
+ );
47
+ }
44
48
 
45
49
  // Validate ACL value if specified
46
50
  const allowedAcls = ['private', 'public-read', 'authenticated-read'];
@@ -52,15 +56,24 @@ export class S3UploadStrategy implements UploadStrategy {
52
56
  );
53
57
  }
54
58
 
55
- // Load credentials from .auth/<upload-name>.json
56
- const authDir = resolve(projectPath, '.auth');
57
- const credentialsPath = resolve(authDir, `${upload.name}.json`);
59
+ // Load credentials from local .auth/<upload-name>.json or global ~/.staticstripes/auth/<upload-name>.json
60
+ const manager =
61
+ this.credentialsManager ||
62
+ new CredentialsManager(projectPath, upload.name);
58
63
 
59
- if (!existsSync(credentialsPath)) {
64
+ let credentials: S3Credentials;
65
+ try {
66
+ credentials = manager.load<S3Credentials>([
67
+ 'accessKeyId',
68
+ 'secretAccessKey',
69
+ ]);
70
+ } catch (error) {
71
+ // Add helpful context about S3 credentials format
72
+ const errorMessage =
73
+ error instanceof Error ? error.message : String(error);
60
74
  throw new Error(
61
- `āŒ Error: S3 credentials not found\n\n` +
62
- `Expected location: ${credentialsPath}\n\n` +
63
- `šŸ’” Create a JSON file with your S3 credentials:\n` +
75
+ `${errorMessage}\n\n` +
76
+ `šŸ’” S3 credentials file should contain:\n` +
64
77
  `{\n` +
65
78
  ` "accessKeyId": "YOUR_ACCESS_KEY",\n` +
66
79
  ` "secretAccessKey": "YOUR_SECRET_KEY"\n` +
@@ -71,24 +84,6 @@ export class S3UploadStrategy implements UploadStrategy {
71
84
  );
72
85
  }
73
86
 
74
- console.log(`šŸ” Loading credentials from: ${credentialsPath}`);
75
-
76
- let credentials: S3Credentials;
77
- try {
78
- const credentialsJson = readFileSync(credentialsPath, 'utf-8');
79
- credentials = JSON.parse(credentialsJson);
80
-
81
- if (!credentials.accessKeyId || !credentials.secretAccessKey) {
82
- throw new Error('Missing accessKeyId or secretAccessKey');
83
- }
84
- } catch (error) {
85
- throw new Error(
86
- `āŒ Error: Failed to parse S3 credentials from ${credentialsPath}\n` +
87
- `Ensure the file contains valid JSON with accessKeyId and secretAccessKey.\n` +
88
- `Error: ${error instanceof Error ? error.message : String(error)}`,
89
- );
90
- }
91
-
92
87
  // Get the output file
93
88
  const output = project.getOutput(upload.outputName);
94
89
  if (!output) {
@@ -102,12 +97,23 @@ export class S3UploadStrategy implements UploadStrategy {
102
97
  );
103
98
  }
104
99
 
105
- // Interpolate path variables
100
+ // Prepare interpolation variables
106
101
  const slug = this.slugify(project.getTitle());
107
102
  const outputName = output.name;
108
- const interpolatedPath = path
109
- .replace(/\$\{slug\}/g, slug)
110
- .replace(/\$\{output\}/g, outputName);
103
+ const date = project.getDate() || '';
104
+ const title = project.getTitle();
105
+ const tags = upload.tags;
106
+
107
+ // Helper function to interpolate path variables
108
+ const interpolatePath = (pathTemplate: string): string => {
109
+ return pathTemplate
110
+ .replace(/\$\{slug\}/g, slug)
111
+ .replace(/\$\{output\}/g, outputName)
112
+ .replace(/\$\{date\}/g, date);
113
+ };
114
+
115
+ // Get and interpolate the file path
116
+ const filePath = interpolatePath(paths.get('file')!);
111
117
 
112
118
  console.log(`\nšŸ“¦ Preparing S3 upload...`);
113
119
  console.log(` Bucket: ${bucket}`);
@@ -115,8 +121,18 @@ export class S3UploadStrategy implements UploadStrategy {
115
121
  if (endpoint) {
116
122
  console.log(` Endpoint: ${endpoint}`);
117
123
  }
118
- console.log(` Path: ${interpolatedPath}`);
119
- console.log(` File: ${output.path}\n`);
124
+ console.log(` File path: ${filePath}`);
125
+ console.log(` Video file: ${output.path}`);
126
+ if (paths.has('metadata')) {
127
+ const metadataPath = interpolatePath(paths.get('metadata')!);
128
+ console.log(` Metadata path: ${metadataPath}`);
129
+ }
130
+ if (upload.thumbnailTimecode !== undefined && paths.has('thumbnail')) {
131
+ const thumbnailPath = interpolatePath(paths.get('thumbnail')!);
132
+ console.log(` Thumbnail path: ${thumbnailPath}`);
133
+ console.log(` Thumbnail timecode: ${upload.thumbnailTimecode}ms`);
134
+ }
135
+ console.log('');
120
136
 
121
137
  // Configure S3 client
122
138
  const s3Config: any = {
@@ -138,41 +154,126 @@ export class S3UploadStrategy implements UploadStrategy {
138
154
 
139
155
  const s3Client = new S3Client(s3Config);
140
156
 
141
- // Read file
142
- console.log(`šŸ“¤ Uploading to S3...`);
157
+ // Upload video file
158
+ console.log(`šŸ“¤ Uploading video file...`);
143
159
  const fileBuffer = readFileSync(output.path);
144
160
 
145
- // Upload file
146
- const uploadParams: any = {
161
+ const fileUploadParams: any = {
147
162
  Bucket: bucket,
148
- Key: interpolatedPath,
163
+ Key: filePath,
149
164
  Body: fileBuffer,
150
165
  ContentType: 'video/mp4',
151
166
  };
152
167
 
153
168
  // Add ACL if specified in configuration
154
169
  if (acl) {
155
- uploadParams.ACL = acl;
170
+ fileUploadParams.ACL = acl;
156
171
  }
157
172
 
158
- const command = new PutObjectCommand(uploadParams);
159
-
160
173
  try {
161
- await s3Client.send(command);
174
+ await s3Client.send(new PutObjectCommand(fileUploadParams));
162
175
 
163
- // Construct public URL
164
- let publicUrl: string;
176
+ // Construct public URL for video
177
+ let fileUrl: string;
165
178
  if (endpoint) {
166
179
  // For DigitalOcean Spaces and similar services
167
180
  // Format: https://{bucket}.{region}.{endpoint}/{path}
168
- publicUrl = `https://${bucket}.${region}.${endpoint}/${interpolatedPath}`;
181
+ fileUrl = `https://${bucket}.${region}.${endpoint}/${filePath}`;
169
182
  } else {
170
183
  // For AWS S3
171
- publicUrl = `https://${bucket}.s3.${region}.amazonaws.com/${interpolatedPath}`;
184
+ fileUrl = `https://${bucket}.s3.${region}.amazonaws.com/${filePath}`;
172
185
  }
173
186
 
174
- console.log(`\nāœ… Upload successful!`);
175
- console.log(`šŸ”— Public URL: ${publicUrl}\n`);
187
+ console.log(`āœ… Video uploaded successfully!`);
188
+ console.log(`šŸ”— Video URL: ${fileUrl}`);
189
+
190
+ // Upload thumbnail if specified
191
+ if (upload.thumbnailTimecode !== undefined && paths.has('thumbnail')) {
192
+ console.log(
193
+ `\nšŸ–¼ļø Extracting thumbnail at ${upload.thumbnailTimecode}ms...`,
194
+ );
195
+ const thumbnailPath = resolve(
196
+ dirname(projectPath),
197
+ '.cache',
198
+ 'thumbnail.jpeg',
199
+ );
200
+ await this.extractThumbnail(
201
+ output.path,
202
+ upload.thumbnailTimecode,
203
+ thumbnailPath,
204
+ );
205
+
206
+ console.log(`šŸ“¤ Uploading thumbnail...`);
207
+ const s3ThumbnailPath = interpolatePath(paths.get('thumbnail')!);
208
+ const thumbnailBuffer = readFileSync(thumbnailPath);
209
+
210
+ const thumbnailUploadParams: any = {
211
+ Bucket: bucket,
212
+ Key: s3ThumbnailPath,
213
+ Body: thumbnailBuffer,
214
+ ContentType: 'image/jpeg',
215
+ };
216
+
217
+ // Add ACL if specified
218
+ if (acl) {
219
+ thumbnailUploadParams.ACL = acl;
220
+ }
221
+
222
+ await s3Client.send(new PutObjectCommand(thumbnailUploadParams));
223
+
224
+ // Construct public URL for thumbnail
225
+ let thumbnailUrl: string;
226
+ if (endpoint) {
227
+ thumbnailUrl = `https://${bucket}.${region}.${endpoint}/${s3ThumbnailPath}`;
228
+ } else {
229
+ thumbnailUrl = `https://${bucket}.s3.${region}.amazonaws.com/${s3ThumbnailPath}`;
230
+ }
231
+
232
+ console.log(`āœ… Thumbnail uploaded successfully!`);
233
+ console.log(`šŸ”— Thumbnail URL: ${thumbnailUrl}`);
234
+ }
235
+
236
+ // Upload metadata file if specified
237
+ if (paths.has('metadata')) {
238
+ console.log(`\nšŸ“¤ Uploading metadata file...`);
239
+ const metadataPath = interpolatePath(paths.get('metadata')!);
240
+
241
+ // Create metadata JSON
242
+ const metadata = {
243
+ title,
244
+ date: date || null,
245
+ tags,
246
+ };
247
+
248
+ const metadataBuffer = Buffer.from(JSON.stringify(metadata, null, 2), 'utf-8');
249
+
250
+ const metadataUploadParams: any = {
251
+ Bucket: bucket,
252
+ Key: metadataPath,
253
+ Body: metadataBuffer,
254
+ ContentType: 'application/json',
255
+ };
256
+
257
+ // Add ACL if specified
258
+ if (acl) {
259
+ metadataUploadParams.ACL = acl;
260
+ }
261
+
262
+ await s3Client.send(new PutObjectCommand(metadataUploadParams));
263
+
264
+ // Construct public URL for metadata
265
+ let metadataUrl: string;
266
+ if (endpoint) {
267
+ metadataUrl = `https://${bucket}.${region}.${endpoint}/${metadataPath}`;
268
+ } else {
269
+ metadataUrl = `https://${bucket}.s3.${region}.amazonaws.com/${metadataPath}`;
270
+ }
271
+
272
+ console.log(`āœ… Metadata uploaded successfully!`);
273
+ console.log(`šŸ”— Metadata URL: ${metadataUrl}`);
274
+ }
275
+
276
+ console.log('');
176
277
  } catch (error) {
177
278
  throw new Error(
178
279
  `āŒ Error: Failed to upload to S3\n` +
@@ -195,4 +296,24 @@ export class S3UploadStrategy implements UploadStrategy {
195
296
  .replace(/^-+/, '') // Trim - from start
196
297
  .replace(/-+$/, ''); // Trim - from end
197
298
  }
299
+
300
+ /**
301
+ * Extracts a frame from video at specific timecode using ffmpeg
302
+ */
303
+ private async extractThumbnail(
304
+ videoPath: string,
305
+ timecode: number,
306
+ outputPath: string,
307
+ ): Promise<void> {
308
+ // Ensure the output directory exists
309
+ const outputDir = dirname(outputPath);
310
+ if (!existsSync(outputDir)) {
311
+ mkdirSync(outputDir, { recursive: true });
312
+ }
313
+
314
+ const timeInSeconds = timecode / 1000;
315
+ const command = `ffmpeg -y -ss ${timeInSeconds} -i "${videoPath}" -frames:v 1 -q:v 2 "${outputPath}"`;
316
+
317
+ await execAsync(command);
318
+ }
198
319
  }