@x12i/youtube-video-uploader-cli 1.2.0 → 1.4.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/README.md +351 -27
- package/bin/cli.js +620 -2
- package/package.json +7 -1
- package/src/actions/index.js +276 -0
- package/src/actions/journey-overlay.js +623 -0
- package/src/actions/replace-background.js +192 -0
- package/src/actions/shorten.js +310 -0
- package/src/batch/clone-metadata.js +51 -0
- package/src/batch/plan.js +195 -0
- package/src/cli/apply-actions.js +152 -0
- package/src/cli/preview-journey.js +111 -0
- package/src/index.d.ts +532 -3
- package/src/index.js +82 -1
- package/src/media/cache.js +134 -0
- package/src/media/compose.js +300 -0
- package/src/media/download.js +151 -0
- package/src/media/resolve.js +476 -0
- package/src/media/select.js +176 -0
- package/src/providers/index.js +235 -0
- package/src/providers/pexels.js +171 -0
- package/src/providers/pixabay.js +184 -0
- package/src/state.js +11 -0
- package/src/update.js +392 -0
- package/src/uploader.js +20 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { loadMetadata } from '../metadata.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Determine the output directory based on source directory and options.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} sourceDir
|
|
9
|
+
* @param {object} [options]
|
|
10
|
+
* @param {string} [options.outputDir]
|
|
11
|
+
* @param {string} [options.outputSuffix]
|
|
12
|
+
* @param {boolean} [options.hasShorten=false]
|
|
13
|
+
* @returns {string}
|
|
14
|
+
*/
|
|
15
|
+
export function resolveOutputDirectory(sourceDir, options = {}) {
|
|
16
|
+
const resolvedSource = path.resolve(sourceDir || process.cwd());
|
|
17
|
+
if (options.outputDir) {
|
|
18
|
+
return path.resolve(options.outputDir);
|
|
19
|
+
}
|
|
20
|
+
const suffix = options.outputSuffix !== undefined
|
|
21
|
+
? options.outputSuffix
|
|
22
|
+
: (options.hasShorten ? ' (short)' : ' (edited)');
|
|
23
|
+
return path.join(path.dirname(resolvedSource), path.basename(resolvedSource) + suffix);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve actions configuration for a specific entry.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} params
|
|
30
|
+
* @param {string} params.songKey
|
|
31
|
+
* @param {string} params.filename
|
|
32
|
+
* @param {object} [params.rawEntry]
|
|
33
|
+
* @param {object} [params.rawMetadata]
|
|
34
|
+
* @param {object} [params.actionsFileConfig]
|
|
35
|
+
* @returns {Array<object>}
|
|
36
|
+
*/
|
|
37
|
+
export function resolveEntryActions({ songKey, filename, rawEntry, rawMetadata, actionsFileConfig }) {
|
|
38
|
+
// If actions file is provided, check it first
|
|
39
|
+
if (actionsFileConfig) {
|
|
40
|
+
if (actionsFileConfig.songs && actionsFileConfig.songs[songKey]?.actions) {
|
|
41
|
+
return actionsFileConfig.songs[songKey].actions;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(actionsFileConfig.videos)) {
|
|
44
|
+
const match = actionsFileConfig.videos.find(v => v.filename === filename || v.songKey === songKey);
|
|
45
|
+
if (match?.actions) return match.actions;
|
|
46
|
+
}
|
|
47
|
+
if (Array.isArray(actionsFileConfig.defaultActions)) {
|
|
48
|
+
return actionsFileConfig.defaultActions;
|
|
49
|
+
}
|
|
50
|
+
if (Array.isArray(actionsFileConfig.actions)) {
|
|
51
|
+
return actionsFileConfig.actions;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Next check entry specific actions
|
|
56
|
+
if (Array.isArray(rawEntry?.actions)) {
|
|
57
|
+
return rawEntry.actions;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Check raw metadata songs[songKey].actions
|
|
61
|
+
if (rawMetadata?.songs && rawMetadata.songs[songKey]?.actions) {
|
|
62
|
+
return rawMetadata.songs[songKey].actions;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check raw metadata defaultActions
|
|
66
|
+
if (Array.isArray(rawMetadata?.defaultActions)) {
|
|
67
|
+
return rawMetadata.defaultActions;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Plan batch actions for a directory.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} sourceDir
|
|
77
|
+
* @param {object} [options]
|
|
78
|
+
* @param {string} [options.metadataFile]
|
|
79
|
+
* @param {string} [options.actionsFile]
|
|
80
|
+
* @param {string} [options.outputDir]
|
|
81
|
+
* @param {string} [options.outputSuffix]
|
|
82
|
+
* @param {string} [options.only]
|
|
83
|
+
* @param {boolean} [options.force=false]
|
|
84
|
+
* @param {boolean} [options.dryRun=false]
|
|
85
|
+
* @returns {object}
|
|
86
|
+
*/
|
|
87
|
+
export function planBatchActions(sourceDir, options = {}) {
|
|
88
|
+
const resolvedSource = path.resolve(sourceDir || process.cwd());
|
|
89
|
+
const metadataPath = options.metadataFile
|
|
90
|
+
? path.resolve(options.metadataFile)
|
|
91
|
+
: path.join(resolvedSource, 'metadata.json');
|
|
92
|
+
|
|
93
|
+
let rawMetadata = {};
|
|
94
|
+
if (fs.existsSync(metadataPath)) {
|
|
95
|
+
try {
|
|
96
|
+
rawMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
97
|
+
} catch (err) {
|
|
98
|
+
throw new Error(`Failed to parse metadata file (${metadataPath}): ${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
let actionsFileConfig = null;
|
|
103
|
+
if (options.actionsFile) {
|
|
104
|
+
const actionsPath = path.resolve(options.actionsFile);
|
|
105
|
+
if (!fs.existsSync(actionsPath)) {
|
|
106
|
+
throw new Error(`Actions file not found: ${actionsPath}`);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
actionsFileConfig = JSON.parse(fs.readFileSync(actionsPath, 'utf-8'));
|
|
110
|
+
} catch (err) {
|
|
111
|
+
throw new Error(`Failed to parse actions file (${actionsPath}): ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const normalized = loadMetadata(resolvedSource, options.metadataFile);
|
|
116
|
+
const journeyConfig = {
|
|
117
|
+
enabled: true,
|
|
118
|
+
mode: 'before-current',
|
|
119
|
+
position: 'right',
|
|
120
|
+
displayDuration: 4,
|
|
121
|
+
sidebarDisplayDuration: undefined,
|
|
122
|
+
sidebarLabel: 'stage',
|
|
123
|
+
theme: 'dark',
|
|
124
|
+
style: 'badge',
|
|
125
|
+
maxCenterChars: 60,
|
|
126
|
+
maxVisibleRows: 8,
|
|
127
|
+
...(rawMetadata.journey || {}),
|
|
128
|
+
...(actionsFileConfig?.journey || {}),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const rawSongs = rawMetadata.songs || {};
|
|
132
|
+
let entries = normalized.videos.map((v, index) => {
|
|
133
|
+
const songKey = v.filename;
|
|
134
|
+
const rawEntry = rawSongs[songKey] || v;
|
|
135
|
+
const actions = resolveEntryActions({
|
|
136
|
+
songKey,
|
|
137
|
+
filename: v.filename,
|
|
138
|
+
rawEntry,
|
|
139
|
+
rawMetadata,
|
|
140
|
+
actionsFileConfig,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
index,
|
|
145
|
+
songKey,
|
|
146
|
+
filename: v.filename,
|
|
147
|
+
title: v.title,
|
|
148
|
+
sourcePath: v.filePath,
|
|
149
|
+
actions,
|
|
150
|
+
rawEntry,
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (options.only) {
|
|
155
|
+
const onlyTarget = String(options.only).trim();
|
|
156
|
+
entries = entries.filter(
|
|
157
|
+
e => e.songKey === onlyTarget || e.filename === onlyTarget || path.basename(e.filename, path.extname(e.filename)) === onlyTarget
|
|
158
|
+
);
|
|
159
|
+
if (entries.length === 0) {
|
|
160
|
+
throw new Error(`No video matching --only "${options.only}" was found.`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const hasShorten = entries.some(e => e.actions.some(a => a.type === 'shorten'));
|
|
165
|
+
const outputDir = resolveOutputDirectory(resolvedSource, {
|
|
166
|
+
outputDir: options.outputDir,
|
|
167
|
+
outputSuffix: options.outputSuffix,
|
|
168
|
+
hasShorten,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
if (fs.existsSync(outputDir) && !options.force && !options.outputDir) {
|
|
172
|
+
const files = fs.readdirSync(outputDir).filter(f => !f.startsWith('.'));
|
|
173
|
+
if (files.length > 0) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`Output directory already exists and is not empty: ${outputDir}. Use --force to overwrite or specify --output-dir.`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const plannedEntries = entries.map(e => ({
|
|
181
|
+
...e,
|
|
182
|
+
targetPath: path.join(outputDir, e.filename),
|
|
183
|
+
}));
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
sourceDir: resolvedSource,
|
|
187
|
+
outputDir,
|
|
188
|
+
hasShorten,
|
|
189
|
+
rawMetadata,
|
|
190
|
+
journeyConfig,
|
|
191
|
+
entries: plannedEntries,
|
|
192
|
+
dryRun: Boolean(options.dryRun),
|
|
193
|
+
force: Boolean(options.force),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { planBatchActions } from '../batch/plan.js';
|
|
3
|
+
import { cloneMetadataForOutput } from '../batch/clone-metadata.js';
|
|
4
|
+
import { runActionsPipeline } from '../actions/index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Apply actions pipeline across videos in a directory.
|
|
8
|
+
*
|
|
9
|
+
* @param {string} targetDir
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {string} [options.metadataFile]
|
|
12
|
+
* @param {string} [options.actionsFile]
|
|
13
|
+
* @param {string} [options.outputDir]
|
|
14
|
+
* @param {string} [options.outputSuffix]
|
|
15
|
+
* @param {string} [options.only]
|
|
16
|
+
* @param {boolean} [options.force=false]
|
|
17
|
+
* @param {boolean} [options.dryRun=false]
|
|
18
|
+
* @param {string} [options.pexelsKey]
|
|
19
|
+
* @param {string} [options.pixabayKey]
|
|
20
|
+
* @param {string} [options.cacheDir]
|
|
21
|
+
* @param {typeof fetch} [options.fetchFn]
|
|
22
|
+
* @param {(event: { type: string, [key: string]: any }) => void} [options.onEvent]
|
|
23
|
+
* @returns {Promise<{
|
|
24
|
+
* sourceDir: string,
|
|
25
|
+
* outputDir: string,
|
|
26
|
+
* total: number,
|
|
27
|
+
* processed: Array<object>,
|
|
28
|
+
* skipped: Array<object>,
|
|
29
|
+
* failed: Array<object>,
|
|
30
|
+
* dryRun: boolean
|
|
31
|
+
* }>}
|
|
32
|
+
*/
|
|
33
|
+
export async function batchApplyActions(targetDir, options = {}) {
|
|
34
|
+
const plan = planBatchActions(targetDir, options);
|
|
35
|
+
const onEvent = options.onEvent;
|
|
36
|
+
|
|
37
|
+
if (onEvent) {
|
|
38
|
+
onEvent({
|
|
39
|
+
type: 'actions_plan',
|
|
40
|
+
sourceDir: plan.sourceDir,
|
|
41
|
+
outputDir: plan.outputDir,
|
|
42
|
+
hasShorten: plan.hasShorten,
|
|
43
|
+
total: plan.entries.length,
|
|
44
|
+
entries: plan.entries,
|
|
45
|
+
dryRun: plan.dryRun,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const results = [];
|
|
50
|
+
const skipped = [];
|
|
51
|
+
const failed = [];
|
|
52
|
+
|
|
53
|
+
const context = {
|
|
54
|
+
sourceDir: plan.sourceDir,
|
|
55
|
+
outputDir: plan.outputDir,
|
|
56
|
+
rawMetadata: plan.rawMetadata,
|
|
57
|
+
journeyConfig: plan.journeyConfig,
|
|
58
|
+
dryRun: plan.dryRun,
|
|
59
|
+
force: plan.force,
|
|
60
|
+
pexelsKey: options.pexelsKey,
|
|
61
|
+
pixabayKey: options.pixabayKey,
|
|
62
|
+
cacheDir: options.cacheDir,
|
|
63
|
+
fetchFn: options.fetchFn,
|
|
64
|
+
onEvent,
|
|
65
|
+
onWarn: (msg) => {
|
|
66
|
+
if (onEvent) {
|
|
67
|
+
onEvent({ type: 'action_warn', message: msg });
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (let i = 0; i < plan.entries.length; i++) {
|
|
73
|
+
const entry = plan.entries[i];
|
|
74
|
+
|
|
75
|
+
if (onEvent) {
|
|
76
|
+
onEvent({
|
|
77
|
+
type: 'entry_start',
|
|
78
|
+
index: i,
|
|
79
|
+
total: plan.entries.length,
|
|
80
|
+
songKey: entry.songKey,
|
|
81
|
+
filename: entry.filename,
|
|
82
|
+
actionsCount: entry.actions.length,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
const res = await runActionsPipeline(entry, entry.actions, context);
|
|
88
|
+
if (res.skipped) {
|
|
89
|
+
skipped.push(res);
|
|
90
|
+
if (onEvent) {
|
|
91
|
+
onEvent({
|
|
92
|
+
type: 'entry_skipped',
|
|
93
|
+
songKey: entry.songKey,
|
|
94
|
+
filename: entry.filename,
|
|
95
|
+
reason: res.reason,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
results.push(res);
|
|
100
|
+
if (onEvent) {
|
|
101
|
+
onEvent({
|
|
102
|
+
type: 'entry_success',
|
|
103
|
+
index: i,
|
|
104
|
+
total: plan.entries.length,
|
|
105
|
+
songKey: entry.songKey,
|
|
106
|
+
filename: entry.filename,
|
|
107
|
+
outputPath: res.outputPath,
|
|
108
|
+
actionsApplied: res.actionsApplied,
|
|
109
|
+
steps: res.steps,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch (err) {
|
|
114
|
+
failed.push({
|
|
115
|
+
songKey: entry.songKey,
|
|
116
|
+
filename: entry.filename,
|
|
117
|
+
error: err,
|
|
118
|
+
});
|
|
119
|
+
if (onEvent) {
|
|
120
|
+
onEvent({
|
|
121
|
+
type: 'entry_error',
|
|
122
|
+
index: i,
|
|
123
|
+
total: plan.entries.length,
|
|
124
|
+
songKey: entry.songKey,
|
|
125
|
+
filename: entry.filename,
|
|
126
|
+
error: err,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (!plan.dryRun && results.length > 0) {
|
|
133
|
+
cloneMetadataForOutput(plan.rawMetadata, plan.outputDir, results);
|
|
134
|
+
if (onEvent) {
|
|
135
|
+
onEvent({
|
|
136
|
+
type: 'metadata_cloned',
|
|
137
|
+
outputDir: plan.outputDir,
|
|
138
|
+
outputPath: path.join(plan.outputDir, 'metadata.json'),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
sourceDir: plan.sourceDir,
|
|
145
|
+
outputDir: plan.outputDir,
|
|
146
|
+
total: plan.entries.length,
|
|
147
|
+
processed: results,
|
|
148
|
+
skipped,
|
|
149
|
+
failed,
|
|
150
|
+
dryRun: plan.dryRun,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
generateJourneySvg,
|
|
5
|
+
renderSvgToPng,
|
|
6
|
+
resolveJourneyTracks,
|
|
7
|
+
truncate,
|
|
8
|
+
} from '../actions/journey-overlay.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Preview journey overlay as a standalone PNG image.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} songKey - Song key or filename to preview
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @param {string} [options.dir] - Target source folder containing metadata.json
|
|
16
|
+
* @param {string} [options.metadata] - Path to custom metadata.json
|
|
17
|
+
* @param {string} [options.centerText] - Override center text for preview
|
|
18
|
+
* @param {string} [options.output] - Output path for the PNG file
|
|
19
|
+
* @param {number} [options.width=1920]
|
|
20
|
+
* @param {number} [options.height=1080]
|
|
21
|
+
* @returns {Promise<{ outputPath: string, width: number, height: number, tracksCount: number }>}
|
|
22
|
+
*/
|
|
23
|
+
export async function previewJourney(songKey, options = {}) {
|
|
24
|
+
const targetDir = path.resolve(options.dir || process.cwd());
|
|
25
|
+
const metadataPath = options.metadata
|
|
26
|
+
? path.resolve(options.metadata)
|
|
27
|
+
: path.join(targetDir, 'metadata.json');
|
|
28
|
+
|
|
29
|
+
let rawMetadata = {};
|
|
30
|
+
if (fs.existsSync(metadataPath)) {
|
|
31
|
+
try {
|
|
32
|
+
rawMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new Error(`Failed to parse metadata file (${metadataPath}): ${err.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const journeyConfig = {
|
|
39
|
+
enabled: true,
|
|
40
|
+
mode: 'before-current',
|
|
41
|
+
position: 'right',
|
|
42
|
+
displayDuration: 4,
|
|
43
|
+
sidebarDisplayDuration: undefined,
|
|
44
|
+
sidebarLabel: 'stage',
|
|
45
|
+
theme: 'dark',
|
|
46
|
+
style: 'badge',
|
|
47
|
+
maxCenterChars: 60,
|
|
48
|
+
maxVisibleRows: 8,
|
|
49
|
+
...(rawMetadata.journey || {}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const songData = rawMetadata.songs?.[songKey] || {};
|
|
53
|
+
const journeyAction = Array.isArray(songData.actions)
|
|
54
|
+
? songData.actions.find(a => a.type === 'journey-overlay')
|
|
55
|
+
: null;
|
|
56
|
+
|
|
57
|
+
let centerText = options.centerText;
|
|
58
|
+
if (centerText === undefined) {
|
|
59
|
+
if (Array.isArray(journeyAction?.centerConcepts) && journeyAction.centerConcepts.length > 0) {
|
|
60
|
+
const idx = options.conceptIndex !== undefined ? parseInt(options.conceptIndex, 10) : 0;
|
|
61
|
+
centerText = journeyAction.centerConcepts[idx] || journeyAction.centerConcepts[0];
|
|
62
|
+
} else {
|
|
63
|
+
centerText = journeyAction?.centerText || '';
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (centerText && centerText.length > journeyConfig.maxCenterChars) {
|
|
68
|
+
centerText = truncate(centerText, journeyConfig.maxCenterChars);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tracks = resolveJourneyTracks({
|
|
72
|
+
currentSongKey: songKey,
|
|
73
|
+
rawMetadata,
|
|
74
|
+
mode: journeyConfig.mode,
|
|
75
|
+
maxVisibleRows: journeyConfig.style === 'panel' ? journeyConfig.maxVisibleRows : null,
|
|
76
|
+
sidebarLabel: journeyConfig.sidebarLabel,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const width = options.width || 1920;
|
|
80
|
+
const height = options.height || 1080;
|
|
81
|
+
|
|
82
|
+
const svgContent = generateJourneySvg({
|
|
83
|
+
width,
|
|
84
|
+
height,
|
|
85
|
+
tracks,
|
|
86
|
+
centerText,
|
|
87
|
+
showSidebar: journeyConfig.enabled !== false,
|
|
88
|
+
theme: journeyConfig.theme,
|
|
89
|
+
position: journeyConfig.position,
|
|
90
|
+
style: journeyConfig.style,
|
|
91
|
+
stage: songData.journeyStage || songData.albumRole,
|
|
92
|
+
step: typeof songData.trackNumber === 'number' ? songData.trackNumber : (songData.playlistPosition !== undefined ? songData.playlistPosition + 1 : undefined),
|
|
93
|
+
totalSteps: rawMetadata.album?.trackCount || rawMetadata.album?.arc?.length || (tracks[0]?.totalTracks) || 11,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const pngBuffer = renderSvgToPng(svgContent, width, height);
|
|
97
|
+
|
|
98
|
+
const outputPath = options.output
|
|
99
|
+
? path.resolve(options.output)
|
|
100
|
+
: path.join(targetDir, `journey-preview-${path.basename(songKey, path.extname(songKey))}.png`);
|
|
101
|
+
|
|
102
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
103
|
+
fs.writeFileSync(outputPath, pngBuffer);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
outputPath,
|
|
107
|
+
width,
|
|
108
|
+
height,
|
|
109
|
+
tracksCount: tracks.length,
|
|
110
|
+
};
|
|
111
|
+
}
|