@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.
- package/Makefile +23 -10
- package/dist/cli/commands/add-assets.d.ts +3 -0
- package/dist/cli/commands/add-assets.d.ts.map +1 -0
- package/dist/cli/commands/add-assets.js +113 -0
- package/dist/cli/commands/add-assets.js.map +1 -0
- package/dist/cli/commands/bootstrap.d.ts +3 -0
- package/dist/cli/commands/bootstrap.d.ts.map +1 -0
- package/dist/cli/commands/bootstrap.js +49 -0
- package/dist/cli/commands/bootstrap.js.map +1 -0
- package/dist/cli/commands/generate.d.ts +3 -0
- package/dist/cli/commands/generate.d.ts.map +1 -0
- package/dist/cli/commands/generate.js +132 -0
- package/dist/cli/commands/generate.js.map +1 -0
- package/dist/cli/commands/upload.d.ts +6 -0
- package/dist/cli/commands/upload.d.ts.map +1 -0
- package/dist/cli/commands/upload.js +67 -0
- package/dist/cli/commands/upload.js.map +1 -0
- package/dist/cli/s3/s3-upload-strategy.d.ts +18 -0
- package/dist/cli/s3/s3-upload-strategy.d.ts.map +1 -0
- package/dist/cli/s3/s3-upload-strategy.js +149 -0
- package/dist/cli/s3/s3-upload-strategy.js.map +1 -0
- package/dist/cli/upload-strategy-factory.d.ts +23 -0
- package/dist/cli/upload-strategy-factory.d.ts.map +1 -0
- package/dist/cli/upload-strategy-factory.js +49 -0
- package/dist/cli/upload-strategy-factory.js.map +1 -0
- package/dist/cli/upload-strategy.d.ts +25 -0
- package/dist/cli/upload-strategy.d.ts.map +1 -0
- package/dist/cli/upload-strategy.js +3 -0
- package/dist/cli/upload-strategy.js.map +1 -0
- package/dist/cli/youtube/auth-commands.d.ts +3 -0
- package/dist/cli/youtube/auth-commands.d.ts.map +1 -0
- package/dist/cli/youtube/auth-commands.js +273 -0
- package/dist/cli/youtube/auth-commands.js.map +1 -0
- package/dist/cli/youtube/cli.d.ts +7 -0
- package/dist/cli/youtube/cli.d.ts.map +1 -0
- package/dist/cli/youtube/cli.js +13 -0
- package/dist/cli/youtube/cli.js.map +1 -0
- package/dist/cli/youtube/upload-handler.d.ts +12 -0
- package/dist/cli/youtube/upload-handler.d.ts.map +1 -0
- package/dist/cli/youtube/upload-handler.js +66 -0
- package/dist/cli/youtube/upload-handler.js.map +1 -0
- package/dist/cli/youtube/youtube-upload-strategy.d.ts +15 -0
- package/dist/cli/youtube/youtube-upload-strategy.d.ts.map +1 -0
- package/dist/cli/youtube/youtube-upload-strategy.js +37 -0
- package/dist/cli/youtube/youtube-upload-strategy.js.map +1 -0
- package/dist/cli.js +12 -251
- package/dist/cli.js.map +1 -1
- package/dist/container-renderer.d.ts +5 -1
- package/dist/container-renderer.d.ts.map +1 -1
- package/dist/container-renderer.js +8 -7
- package/dist/container-renderer.js.map +1 -1
- package/dist/ffmpeg.d.ts +1 -1
- package/dist/ffmpeg.d.ts.map +1 -1
- package/dist/ffmpeg.js +5 -10
- package/dist/ffmpeg.js.map +1 -1
- package/dist/html-parser.d.ts +3 -4
- package/dist/html-parser.d.ts.map +1 -1
- package/dist/html-parser.js +20 -17
- package/dist/html-parser.js.map +1 -1
- package/dist/html-project-parser.d.ts +32 -0
- package/dist/html-project-parser.d.ts.map +1 -1
- package/dist/html-project-parser.js +413 -44
- package/dist/html-project-parser.js.map +1 -1
- package/dist/project.d.ts +19 -3
- package/dist/project.d.ts.map +1 -1
- package/dist/project.js +67 -3
- package/dist/project.js.map +1 -1
- package/dist/type.d.ts +30 -4
- package/dist/type.d.ts.map +1 -1
- package/dist/youtube-uploader.d.ts +40 -0
- package/dist/youtube-uploader.d.ts.map +1 -0
- package/dist/youtube-uploader.js +227 -0
- package/dist/youtube-uploader.js.map +1 -0
- package/package.json +6 -2
- package/src/cli/commands/add-assets.ts +159 -0
- package/src/cli/commands/bootstrap.ts +57 -0
- package/src/cli/commands/generate.ts +189 -0
- package/src/cli/commands/upload.ts +83 -0
- package/src/cli/s3/s3-upload-strategy.ts +194 -0
- package/src/cli/upload-strategy-factory.ts +58 -0
- package/src/cli/upload-strategy.ts +31 -0
- package/src/cli/youtube/auth-commands.ts +312 -0
- package/src/cli/youtube/cli.ts +11 -0
- package/src/cli/youtube/upload-handler.ts +101 -0
- package/src/cli/youtube/youtube-upload-strategy.ts +43 -0
- package/src/cli.ts +14 -350
- package/src/container-renderer.ts +10 -8
- package/src/ffmpeg.ts +5 -12
- package/src/html-parser.ts +23 -21
- package/src/html-project-parser.ts +474 -48
- package/src/project.ts +85 -2
- package/src/type.ts +35 -4
- package/src/youtube-uploader.ts +288 -0
package/src/project.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
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 {
|
|
1
|
+
import type { AnyNode, Document, Element } from 'domhandler';
|
|
2
2
|
import { CompiledExpression } from './expression-parser';
|
|
3
3
|
|
|
4
|
-
export type ASTNode =
|
|
5
|
-
export type 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
|
+
}
|