@gannochenko/staticstripes 0.0.10 → 0.0.12

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 (93) hide show
  1. package/Makefile +23 -10
  2. package/dist/cli/commands/add-assets.d.ts +3 -0
  3. package/dist/cli/commands/add-assets.d.ts.map +1 -0
  4. package/dist/cli/commands/add-assets.js +113 -0
  5. package/dist/cli/commands/add-assets.js.map +1 -0
  6. package/dist/cli/commands/bootstrap.d.ts +3 -0
  7. package/dist/cli/commands/bootstrap.d.ts.map +1 -0
  8. package/dist/cli/commands/bootstrap.js +49 -0
  9. package/dist/cli/commands/bootstrap.js.map +1 -0
  10. package/dist/cli/commands/generate.d.ts +3 -0
  11. package/dist/cli/commands/generate.d.ts.map +1 -0
  12. package/dist/cli/commands/generate.js +132 -0
  13. package/dist/cli/commands/generate.js.map +1 -0
  14. package/dist/cli/commands/upload.d.ts +6 -0
  15. package/dist/cli/commands/upload.d.ts.map +1 -0
  16. package/dist/cli/commands/upload.js +67 -0
  17. package/dist/cli/commands/upload.js.map +1 -0
  18. package/dist/cli/s3/s3-upload-strategy.d.ts +18 -0
  19. package/dist/cli/s3/s3-upload-strategy.d.ts.map +1 -0
  20. package/dist/cli/s3/s3-upload-strategy.js +149 -0
  21. package/dist/cli/s3/s3-upload-strategy.js.map +1 -0
  22. package/dist/cli/upload-strategy-factory.d.ts +23 -0
  23. package/dist/cli/upload-strategy-factory.d.ts.map +1 -0
  24. package/dist/cli/upload-strategy-factory.js +49 -0
  25. package/dist/cli/upload-strategy-factory.js.map +1 -0
  26. package/dist/cli/upload-strategy.d.ts +25 -0
  27. package/dist/cli/upload-strategy.d.ts.map +1 -0
  28. package/dist/cli/upload-strategy.js +3 -0
  29. package/dist/cli/upload-strategy.js.map +1 -0
  30. package/dist/cli/youtube/auth-commands.d.ts +3 -0
  31. package/dist/cli/youtube/auth-commands.d.ts.map +1 -0
  32. package/dist/cli/youtube/auth-commands.js +273 -0
  33. package/dist/cli/youtube/auth-commands.js.map +1 -0
  34. package/dist/cli/youtube/cli.d.ts +7 -0
  35. package/dist/cli/youtube/cli.d.ts.map +1 -0
  36. package/dist/cli/youtube/cli.js +13 -0
  37. package/dist/cli/youtube/cli.js.map +1 -0
  38. package/dist/cli/youtube/upload-handler.d.ts +12 -0
  39. package/dist/cli/youtube/upload-handler.d.ts.map +1 -0
  40. package/dist/cli/youtube/upload-handler.js +66 -0
  41. package/dist/cli/youtube/upload-handler.js.map +1 -0
  42. package/dist/cli/youtube/youtube-upload-strategy.d.ts +15 -0
  43. package/dist/cli/youtube/youtube-upload-strategy.d.ts.map +1 -0
  44. package/dist/cli/youtube/youtube-upload-strategy.js +37 -0
  45. package/dist/cli/youtube/youtube-upload-strategy.js.map +1 -0
  46. package/dist/cli.js +12 -251
  47. package/dist/cli.js.map +1 -1
  48. package/dist/container-renderer.d.ts +5 -1
  49. package/dist/container-renderer.d.ts.map +1 -1
  50. package/dist/container-renderer.js +8 -7
  51. package/dist/container-renderer.js.map +1 -1
  52. package/dist/ffmpeg.d.ts +1 -1
  53. package/dist/ffmpeg.d.ts.map +1 -1
  54. package/dist/ffmpeg.js +5 -10
  55. package/dist/ffmpeg.js.map +1 -1
  56. package/dist/html-parser.d.ts +3 -4
  57. package/dist/html-parser.d.ts.map +1 -1
  58. package/dist/html-parser.js +20 -17
  59. package/dist/html-parser.js.map +1 -1
  60. package/dist/html-project-parser.d.ts +32 -0
  61. package/dist/html-project-parser.d.ts.map +1 -1
  62. package/dist/html-project-parser.js +413 -44
  63. package/dist/html-project-parser.js.map +1 -1
  64. package/dist/project.d.ts +19 -3
  65. package/dist/project.d.ts.map +1 -1
  66. package/dist/project.js +67 -3
  67. package/dist/project.js.map +1 -1
  68. package/dist/type.d.ts +30 -4
  69. package/dist/type.d.ts.map +1 -1
  70. package/dist/youtube-uploader.d.ts +40 -0
  71. package/dist/youtube-uploader.d.ts.map +1 -0
  72. package/dist/youtube-uploader.js +227 -0
  73. package/dist/youtube-uploader.js.map +1 -0
  74. package/package.json +6 -2
  75. package/src/cli/commands/add-assets.ts +159 -0
  76. package/src/cli/commands/bootstrap.ts +57 -0
  77. package/src/cli/commands/generate.ts +189 -0
  78. package/src/cli/commands/upload.ts +83 -0
  79. package/src/cli/s3/s3-upload-strategy.ts +194 -0
  80. package/src/cli/upload-strategy-factory.ts +58 -0
  81. package/src/cli/upload-strategy.ts +31 -0
  82. package/src/cli/youtube/auth-commands.ts +312 -0
  83. package/src/cli/youtube/cli.ts +11 -0
  84. package/src/cli/youtube/upload-handler.ts +101 -0
  85. package/src/cli/youtube/youtube-upload-strategy.ts +43 -0
  86. package/src/cli.ts +14 -350
  87. package/src/container-renderer.ts +10 -8
  88. package/src/ffmpeg.ts +5 -12
  89. package/src/html-parser.ts +23 -21
  90. package/src/html-project-parser.ts +474 -48
  91. package/src/project.ts +85 -2
  92. package/src/type.ts +35 -4
  93. package/src/youtube-uploader.ts +288 -0
package/src/project.ts CHANGED
@@ -1,4 +1,10 @@
1
- import { Asset, Output, SequenceDefinition } from './type';
1
+ import {
2
+ Asset,
3
+ Output,
4
+ SequenceDefinition,
5
+ FFmpegOption,
6
+ Upload,
7
+ } from './type';
2
8
  import { Label } from './ffmpeg';
3
9
  import { AssetManager } from './asset-manager';
4
10
  import { Sequence } from './sequence';
@@ -15,6 +21,9 @@ export class Project {
15
21
  private sequencesDefinitions: SequenceDefinition[],
16
22
  assets: Asset[],
17
23
  private outputs: Map<string, Output>,
24
+ private ffmpegOptions: Map<string, FFmpegOption>,
25
+ private uploads: Map<string, Upload>,
26
+ private title: string,
18
27
  private cssText: string,
19
28
  private projectPath: string,
20
29
  ) {
@@ -93,10 +102,80 @@ export class Project {
93
102
  return this.outputs;
94
103
  }
95
104
 
105
+ public getFfmpegOptions(): Map<string, FFmpegOption> {
106
+ return this.ffmpegOptions;
107
+ }
108
+
109
+ public getFfmpegOption(name: string): FFmpegOption | undefined {
110
+ return this.ffmpegOptions.get(name);
111
+ }
112
+
113
+ public getUploads(): Map<string, Upload> {
114
+ return this.uploads;
115
+ }
116
+
117
+ public getUpload(name: string): Upload | undefined {
118
+ return this.uploads.get(name);
119
+ }
120
+
121
+ // Legacy aliases for backward compatibility
122
+ public getYouTubeUploads(): Map<string, Upload> {
123
+ return this.getUploads();
124
+ }
125
+
126
+ public getYouTubeUpload(name: string): Upload | undefined {
127
+ return this.getUpload(name);
128
+ }
129
+
130
+ public getTitle(): string {
131
+ return this.title;
132
+ }
133
+
96
134
  public getCssText(): string {
97
135
  return this.cssText;
98
136
  }
99
137
 
138
+ /**
139
+ * Collects timecodes from fragments with timecodeLabel
140
+ * Returns formatted timecodes in YouTube format (MM:SS or HH:MM:SS Label)
141
+ * Note: This must be called after build() to have accurate times in expressionContext
142
+ */
143
+ public getTimecodes(): string[] {
144
+ const timecodes: Array<{ time: number; label: string }> = [];
145
+
146
+ // Collect all fragments with timecode labels
147
+ for (const seqDef of this.sequencesDefinitions) {
148
+ for (const fragment of seqDef.fragments) {
149
+ if (fragment.timecodeLabel && this.expressionContext.fragments.has(fragment.id)) {
150
+ const fragmentData = this.expressionContext.fragments.get(fragment.id)!;
151
+ timecodes.push({
152
+ time: fragmentData.time.start / 1000, // Convert ms to seconds
153
+ label: fragment.timecodeLabel,
154
+ });
155
+ }
156
+ }
157
+ }
158
+
159
+ // Sort by time
160
+ timecodes.sort((a, b) => a.time - b.time);
161
+
162
+ // Format as YouTube timecodes (MM:SS or HH:MM:SS)
163
+ return timecodes.map(({ time, label }) => {
164
+ const hours = Math.floor(time / 3600);
165
+ const minutes = Math.floor((time % 3600) / 60);
166
+ const seconds = Math.floor(time % 60);
167
+
168
+ let timeStr: string;
169
+ if (hours > 0) {
170
+ timeStr = `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
171
+ } else {
172
+ timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`;
173
+ }
174
+
175
+ return `${timeStr} ${label}`;
176
+ });
177
+ }
178
+
100
179
  public getSequenceDefinitions(): SequenceDefinition[] {
101
180
  return this.sequencesDefinitions;
102
181
  }
@@ -121,7 +200,10 @@ export class Project {
121
200
  /**
122
201
  * Renders all containers and creates virtual assets for them
123
202
  */
124
- public async renderContainers(outputName: string): Promise<void> {
203
+ public async renderContainers(
204
+ outputName: string,
205
+ activeCacheKeys?: Set<string>,
206
+ ): Promise<void> {
125
207
  const output = this.getOutput(outputName);
126
208
  if (!output) {
127
209
  throw new Error(`Output "${outputName}" not found`);
@@ -148,6 +230,7 @@ export class Project {
148
230
  output.resolution.height,
149
231
  projectDir,
150
232
  outputName,
233
+ activeCacheKeys,
151
234
  );
152
235
 
153
236
  // Create virtual assets and update fragment assetNames
package/src/type.ts CHANGED
@@ -1,9 +1,8 @@
1
- import { type DefaultTreeAdapterMap } from 'parse5';
1
+ import type { AnyNode, Document, Element } from 'domhandler';
2
2
  import { CompiledExpression } from './expression-parser';
3
3
 
4
- export type ASTNode = DefaultTreeAdapterMap['node'];
5
- export type Document = DefaultTreeAdapterMap['document'];
6
- export type Element = DefaultTreeAdapterMap['element'];
4
+ export type ASTNode = AnyNode;
5
+ export type { Document, Element };
7
6
 
8
7
  export type CSSProperties = {
9
8
  [key: string]: string;
@@ -57,6 +56,7 @@ export type Fragment = {
57
56
  chromakeyColor: string;
58
57
  visualFilter?: string; // Optional visual filter (e.g., 'instagram-nashville')
59
58
  container?: Container; // Optional container attached to this fragment
59
+ timecodeLabel?: string; // Optional label for timecode (from data-timecode attribute)
60
60
  };
61
61
 
62
62
  export type SequenceDefinition = {
@@ -73,6 +73,37 @@ export type Output = {
73
73
  fps: number; // e.g. 30
74
74
  };
75
75
 
76
+ export type FFmpegOption = {
77
+ name: string; // e.g. "preview", "production"
78
+ args: string; // e.g. "-c:v h264_nvenc -preset fast"
79
+ };
80
+
81
+ export type Upload = {
82
+ name: string; // e.g. "yt_primary", "s3_backup"
83
+ tag: string; // e.g. "youtube", "s3" - used to identify upload provider
84
+ outputName: string; // e.g. "youtube" - references Output name
85
+ title?: string; // Upload-specific title (optional, falls back to global title)
86
+ videoId?: string; // Video ID after upload (YouTube, etc.)
87
+ privacy: 'public' | 'unlisted' | 'private';
88
+ madeForKids: boolean;
89
+ tags: string[];
90
+ category: string; // e.g. "entertainment"
91
+ language: string; // e.g. "en"
92
+ description: string; // Pre-processed description (can contain EJS)
93
+ thumbnailTimecode?: number; // Milliseconds, if thumbnail should be extracted from video
94
+ // S3-specific configuration
95
+ s3?: {
96
+ endpoint?: string; // e.g. "digitaloceanspaces.com"
97
+ region: string; // e.g. "ams3", "us-east-1"
98
+ bucket: string; // e.g. "my-bucket"
99
+ path: string; // e.g. "videos/${slug}/${output}.mp4"
100
+ acl?: string; // e.g. "public-read", "private"
101
+ };
102
+ };
103
+
104
+ // Legacy alias for backward compatibility
105
+ export type YouTubeUpload = Upload;
106
+
76
107
  export type ProjectStructure = {
77
108
  sequences: SequenceDefinition[];
78
109
  assets: Map<string, Asset>;
@@ -0,0 +1,288 @@
1
+ import { google, youtube_v3 } from 'googleapis';
2
+ import { OAuth2Client } from 'google-auth-library';
3
+ import {
4
+ createReadStream,
5
+ existsSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from 'fs';
10
+ import { resolve, dirname } from 'path';
11
+ import { exec } from 'child_process';
12
+ import { promisify } from 'util';
13
+ import { YouTubeUpload } from './type';
14
+
15
+ const execAsync = promisify(exec);
16
+
17
+ // OAuth2 configuration
18
+ const SCOPES = ['https://www.googleapis.com/auth/youtube.upload'];
19
+ const TOKEN_DIR = '.auth'; // Directory to store authentication tokens (excluded from git)
20
+
21
+ export class YouTubeUploader {
22
+ private oauth2Client: OAuth2Client;
23
+ private youtube: youtube_v3.Youtube;
24
+
25
+ constructor(
26
+ clientId: string,
27
+ clientSecret: string,
28
+ redirectUri: string = 'http://localhost:3000/oauth2callback',
29
+ ) {
30
+ this.oauth2Client = new google.auth.OAuth2(
31
+ clientId,
32
+ clientSecret,
33
+ redirectUri,
34
+ );
35
+ this.youtube = google.youtube({ version: 'v3', auth: this.oauth2Client });
36
+ }
37
+
38
+ /**
39
+ * Gets the authorization URL for OAuth flow
40
+ */
41
+ public getAuthUrl(): string {
42
+ return this.oauth2Client.generateAuthUrl({
43
+ access_type: 'offline',
44
+ prompt: 'consent', // Force consent screen to ensure refresh token is issued
45
+ scope: SCOPES,
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Exchanges authorization code for tokens and saves them
51
+ */
52
+ public async authenticate(
53
+ code: string,
54
+ uploadName: string,
55
+ projectDir: string,
56
+ ): Promise<void> {
57
+ const { tokens } = await this.oauth2Client.getToken(code);
58
+ this.oauth2Client.setCredentials(tokens);
59
+
60
+ // Save tokens to file
61
+ const tokenPath = this.getTokenPath(projectDir, uploadName);
62
+
63
+ // Ensure the token directory exists
64
+ const tokenDir = dirname(tokenPath);
65
+ if (!existsSync(tokenDir)) {
66
+ mkdirSync(tokenDir, { recursive: true });
67
+ }
68
+
69
+ writeFileSync(tokenPath, JSON.stringify(tokens, null, 2));
70
+ console.log(`✅ Tokens saved to ${tokenPath}`);
71
+ }
72
+
73
+ /**
74
+ * Loads saved tokens for an upload
75
+ */
76
+ public loadTokens(uploadName: string, projectDir: string): boolean {
77
+ const tokenPath = this.getTokenPath(projectDir, uploadName);
78
+
79
+ if (!existsSync(tokenPath)) {
80
+ return false;
81
+ }
82
+
83
+ const tokens = JSON.parse(readFileSync(tokenPath, 'utf-8'));
84
+ this.oauth2Client.setCredentials(tokens);
85
+
86
+ // Set up auto-refresh: save updated tokens when they're refreshed
87
+ this.oauth2Client.on('tokens', (refreshedTokens) => {
88
+ // Merge with existing tokens (preserve refresh_token if not returned)
89
+ const currentTokens = this.oauth2Client.credentials;
90
+ const updatedTokens = { ...currentTokens, ...refreshedTokens };
91
+
92
+ // Ensure token directory exists
93
+ const tokenDir = dirname(tokenPath);
94
+ if (!existsSync(tokenDir)) {
95
+ mkdirSync(tokenDir, { recursive: true });
96
+ }
97
+
98
+ writeFileSync(tokenPath, JSON.stringify(updatedTokens, null, 2));
99
+ console.log('🔄 Access token refreshed automatically');
100
+ });
101
+
102
+ return true;
103
+ }
104
+
105
+ /**
106
+ * Uploads a video to YouTube
107
+ */
108
+ public async uploadVideo(
109
+ videoPath: string,
110
+ upload: YouTubeUpload,
111
+ title: string,
112
+ ): Promise<string> {
113
+ console.log(`📤 Uploading video to YouTube...`);
114
+
115
+ // Map category name to YouTube category ID
116
+ const categoryId = this.getCategoryId(upload.category);
117
+
118
+ const requestBody: youtube_v3.Schema$Video = {
119
+ snippet: {
120
+ title,
121
+ description: upload.description,
122
+ tags: upload.tags,
123
+ categoryId,
124
+ defaultLanguage: upload.language,
125
+ },
126
+ status: {
127
+ privacyStatus: upload.privacy,
128
+ selfDeclaredMadeForKids: upload.madeForKids,
129
+ },
130
+ };
131
+
132
+ const media = {
133
+ body: createReadStream(videoPath),
134
+ };
135
+
136
+ const response = await this.youtube.videos.insert({
137
+ part: ['snippet', 'status'],
138
+ requestBody,
139
+ media,
140
+ });
141
+
142
+ const videoId = response.data.id;
143
+ if (!videoId) {
144
+ throw new Error('Failed to get video ID from YouTube response');
145
+ }
146
+
147
+ console.log(`✅ Video uploaded successfully!`);
148
+ console.log(`📹 Video ID: ${videoId}`);
149
+ console.log(`🔗 URL: https://www.youtube.com/watch?v=${videoId}`);
150
+
151
+ return videoId;
152
+ }
153
+
154
+ /**
155
+ * Uploads a custom thumbnail to YouTube video
156
+ */
157
+ public async uploadThumbnail(
158
+ videoId: string,
159
+ thumbnailPath: string,
160
+ ): Promise<void> {
161
+ console.log(`🖼️ Uploading custom thumbnail...`);
162
+
163
+ const media = {
164
+ mimeType: 'image/png',
165
+ body: createReadStream(thumbnailPath),
166
+ };
167
+
168
+ await this.youtube.thumbnails.set({
169
+ videoId,
170
+ media,
171
+ });
172
+
173
+ console.log(`✅ Thumbnail uploaded successfully!`);
174
+ }
175
+
176
+ /**
177
+ * Extracts a frame from video at specific timecode using ffmpeg
178
+ */
179
+ public async extractThumbnail(
180
+ videoPath: string,
181
+ timecode: number,
182
+ outputPath: string,
183
+ ): Promise<void> {
184
+ // Ensure the output directory exists
185
+ const outputDir = dirname(outputPath);
186
+ if (!existsSync(outputDir)) {
187
+ mkdirSync(outputDir, { recursive: true });
188
+ }
189
+
190
+ const timeInSeconds = timecode / 1000;
191
+ const command = `ffmpeg -y -ss ${timeInSeconds} -i "${videoPath}" -frames:v 1 -q:v 2 "${outputPath}"`;
192
+
193
+ await execAsync(command);
194
+ }
195
+
196
+ /**
197
+ * Gets the token file path for an upload
198
+ */
199
+ private getTokenPath(projectDir: string, uploadName: string): string {
200
+ return resolve(projectDir, TOKEN_DIR, `${uploadName}.json`);
201
+ }
202
+
203
+ /**
204
+ * Maps category name to YouTube category ID
205
+ * Full list: https://developers.google.com/youtube/v3/docs/videoCategories/list
206
+ */
207
+ private getCategoryId(category: string): string {
208
+ const categoryMap: Record<string, string> = {
209
+ // Main categories
210
+ 'film & animation': '1',
211
+ 'film-animation': '1',
212
+ 'film': '1',
213
+ 'animation': '1',
214
+ 'autos & vehicles': '2',
215
+ 'autos-vehicles': '2',
216
+ 'autos': '2',
217
+ 'vehicles': '2',
218
+ 'music': '10',
219
+ 'pets & animals': '15',
220
+ 'pets-animals': '15',
221
+ 'pets': '15',
222
+ 'animals': '15',
223
+ 'sports': '17',
224
+ 'short movies': '18',
225
+ 'short-movies': '18',
226
+ 'travel & events': '19',
227
+ 'travel-events': '19',
228
+ 'travel': '19',
229
+ 'events': '19',
230
+ 'gaming': '20',
231
+ 'videoblogging': '21',
232
+ 'vlogging': '21',
233
+ 'vlog': '21',
234
+ 'people & blogs': '22',
235
+ 'people-blogs': '22',
236
+ 'people': '22',
237
+ 'blogs': '22',
238
+ 'comedy': '23',
239
+ 'entertainment': '24',
240
+ 'news & politics': '25',
241
+ 'news-politics': '25',
242
+ 'news': '25',
243
+ 'politics': '25',
244
+ 'howto & style': '26',
245
+ 'howto-style': '26',
246
+ 'howto': '26',
247
+ 'how to': '26',
248
+ 'how-to': '26',
249
+ 'style': '26',
250
+ 'education': '27',
251
+ 'science & technology': '28',
252
+ 'science-technology': '28',
253
+ 'science': '28',
254
+ 'technology': '28',
255
+ 'tech': '28',
256
+ 'nonprofits & activism': '29',
257
+ 'nonprofits-activism': '29',
258
+ 'nonprofits': '29',
259
+ 'activism': '29',
260
+ 'nonprofit': '29',
261
+
262
+ // Movie-related categories
263
+ 'movies': '30',
264
+ 'anime/animation': '31',
265
+ 'anime': '31',
266
+ 'action/adventure': '32',
267
+ 'action-adventure': '32',
268
+ 'action': '32',
269
+ 'adventure': '32',
270
+ 'classics': '33',
271
+ 'documentary': '35',
272
+ 'drama': '36',
273
+ 'family': '37',
274
+ 'foreign': '38',
275
+ 'horror': '39',
276
+ 'sci-fi/fantasy': '40',
277
+ 'sci-fi': '40',
278
+ 'scifi': '40',
279
+ 'fantasy': '40',
280
+ 'thriller': '41',
281
+ 'shorts': '42',
282
+ 'shows': '43',
283
+ 'trailers': '44',
284
+ };
285
+
286
+ return categoryMap[category.toLowerCase()] || '24'; // Default to Entertainment
287
+ }
288
+ }