yt-thumbnails 1.0.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 +77 -0
- package/dist/index.js +190 -0
- package/package.json +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# yt-thumbnails
|
|
2
|
+
|
|
3
|
+
YouTube video thumbnail grid generator. Extract frames from YouTube videos and create a grid image.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- [Bun](https://bun.sh)
|
|
8
|
+
- [ffmpeg](https://ffmpeg.org)
|
|
9
|
+
- [yt-dlp](https://github.com/yt-dlp/yt-dlp)
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
# macOS
|
|
13
|
+
brew install ffmpeg yt-dlp
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
git clone <repo-url>
|
|
20
|
+
cd yt-thumbnails
|
|
21
|
+
bun install
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
### Development
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
bun start "<youtube-url>"
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### Build
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun run build
|
|
36
|
+
./dist/yt-thumbnails "<youtube-url>"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Options
|
|
40
|
+
|
|
41
|
+
| Option | Short | Description | Default |
|
|
42
|
+
| ----------------- | ----- | ------------------------------- | ------------------- |
|
|
43
|
+
| `--grid <n>` | `-g` | Grid size (NxN) | 4 |
|
|
44
|
+
| `--scene` | `-s` | Use scene detection mode | uniform |
|
|
45
|
+
| `--threshold <n>` | `-t` | Scene detection threshold (0-1) | 0.4 |
|
|
46
|
+
| `--output <path>` | `-o` | Output file path | `grid_NxN_mode.jpg` |
|
|
47
|
+
|
|
48
|
+
## Examples
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# Basic 4x4 grid with uniform intervals
|
|
52
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx"
|
|
53
|
+
|
|
54
|
+
# 6x6 grid
|
|
55
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx" --grid 6
|
|
56
|
+
|
|
57
|
+
# Scene detection mode (better for music videos)
|
|
58
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx" --scene
|
|
59
|
+
|
|
60
|
+
# Scene detection with lower threshold
|
|
61
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx" --scene --threshold 0.3
|
|
62
|
+
|
|
63
|
+
# Custom output path
|
|
64
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx" -o ~/thumbnails/video.jpg
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Modes
|
|
68
|
+
|
|
69
|
+
### Uniform Mode (default)
|
|
70
|
+
|
|
71
|
+
Extracts frames at equal time intervals throughout the video.
|
|
72
|
+
|
|
73
|
+
### Scene Mode (`--scene`)
|
|
74
|
+
|
|
75
|
+
Detects scene changes and extracts frames at transition points. Better for videos with distinct scenes like music videos or trailers.
|
|
76
|
+
|
|
77
|
+
If no scene changes are detected, falls back to uniform mode automatically.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import {execSync as execSync2} from "child_process";
|
|
5
|
+
import {existsSync, mkdirSync, rmSync} from "fs";
|
|
6
|
+
import {join as join3} from "path";
|
|
7
|
+
|
|
8
|
+
// src/args.ts
|
|
9
|
+
function parseArgs() {
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const options = {
|
|
12
|
+
url: null,
|
|
13
|
+
grid: 4,
|
|
14
|
+
mode: "uniform",
|
|
15
|
+
threshold: 0.4,
|
|
16
|
+
output: null
|
|
17
|
+
};
|
|
18
|
+
for (let i = 0;i < args.length; i++) {
|
|
19
|
+
const arg = args[i];
|
|
20
|
+
if (arg === "--scene" || arg === "-s") {
|
|
21
|
+
options.mode = "scene";
|
|
22
|
+
} else if (arg === "--threshold" || arg === "-t") {
|
|
23
|
+
options.threshold = parseFloat(args[++i]);
|
|
24
|
+
} else if (arg === "--grid" || arg === "-g") {
|
|
25
|
+
options.grid = parseInt(args[++i]);
|
|
26
|
+
} else if (arg === "--output" || arg === "-o") {
|
|
27
|
+
options.output = args[++i];
|
|
28
|
+
} else if (!arg.startsWith("-")) {
|
|
29
|
+
options.url = arg;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return options;
|
|
33
|
+
}
|
|
34
|
+
function printUsage() {
|
|
35
|
+
console.log(`
|
|
36
|
+
Usage: yt-thumbnails <youtube-url> [options]
|
|
37
|
+
|
|
38
|
+
Options:
|
|
39
|
+
-g, --grid <n> Grid size (default: 4 = 4x4)
|
|
40
|
+
-s, --scene Use scene detection instead of uniform intervals
|
|
41
|
+
-t, --threshold <n> Scene detection threshold 0-1 (default: 0.4)
|
|
42
|
+
-o, --output <path> Output file path (default: grid_NxN_mode.jpg)
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx"
|
|
46
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx" --grid 6
|
|
47
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx" --scene
|
|
48
|
+
yt-thumbnails "https://youtube.com/watch?v=xxx" -o ~/thumbnails/video.jpg
|
|
49
|
+
`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// src/constants.ts
|
|
53
|
+
var TEMP_DIR = "./temp";
|
|
54
|
+
var THUMB_SIZE = 120;
|
|
55
|
+
|
|
56
|
+
// src/extract.ts
|
|
57
|
+
import {exec, execSync} from "child_process";
|
|
58
|
+
import {promisify} from "util";
|
|
59
|
+
import {readFileSync} from "fs";
|
|
60
|
+
import {join} from "path";
|
|
61
|
+
function extractUniform(videoPath, totalFrames, duration) {
|
|
62
|
+
const interval = Math.max(1, Math.floor(duration / totalFrames));
|
|
63
|
+
console.log(` \uD504\uB808\uC784 \uAC04\uACA9: ${interval}\uCD08`);
|
|
64
|
+
execSync(`ffmpeg -i "${videoPath}" -vf "fps=1/${interval}" -frames:v ${totalFrames} "${TEMP_DIR}/frame_%03d.jpg" -y -loglevel warning`, { stdio: "inherit" });
|
|
65
|
+
}
|
|
66
|
+
async function extractScenes(videoPath, totalFrames, threshold) {
|
|
67
|
+
console.log(` \uC7A5\uBA74 \uAC10\uC9C0 \uC911 (threshold: ${threshold})...`);
|
|
68
|
+
const sceneFile = join(TEMP_DIR, "scenes.txt");
|
|
69
|
+
execSync(`ffmpeg -i "${videoPath}" -vf "select='gte(scene,0)',metadata=print:file=${sceneFile}" -vsync vfr -f null - 2>/dev/null`, { encoding: "utf-8" });
|
|
70
|
+
const sceneData = readFileSync(sceneFile, "utf-8");
|
|
71
|
+
const scenes = [];
|
|
72
|
+
let currentFrame = {};
|
|
73
|
+
for (const line of sceneData.split("\n")) {
|
|
74
|
+
if (line.startsWith("frame:")) {
|
|
75
|
+
const pts = line.match(/pts_time:([\d.]+)/);
|
|
76
|
+
if (pts) {
|
|
77
|
+
currentFrame.time = parseFloat(pts[1]);
|
|
78
|
+
}
|
|
79
|
+
} else if (line.includes("scene_score=")) {
|
|
80
|
+
const score = line.match(/scene_score=([\d.]+)/);
|
|
81
|
+
if (score) {
|
|
82
|
+
currentFrame.score = parseFloat(score[1]);
|
|
83
|
+
if (currentFrame.score >= threshold && currentFrame.time !== undefined) {
|
|
84
|
+
scenes.push(currentFrame);
|
|
85
|
+
}
|
|
86
|
+
currentFrame = {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
console.log(` ${scenes.length}\uAC1C \uC7A5\uBA74 \uC804\uD658 \uAC10\uC9C0\uB428`);
|
|
91
|
+
if (scenes.length === 0) {
|
|
92
|
+
console.log(" \u26A0\uFE0F \uC7A5\uBA74 \uC804\uD658 \uC5C6\uC74C, threshold \uB0AE\uCDB0\uBCF4\uC138\uC694");
|
|
93
|
+
console.log(" \u2192 \uADE0\uB4F1 \uAC04\uACA9\uC73C\uB85C \uB300\uCCB4\uD569\uB2C8\uB2E4");
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const selectedTimes = [];
|
|
97
|
+
if (scenes.length <= totalFrames) {
|
|
98
|
+
selectedTimes.push(...scenes.map((s) => s.time));
|
|
99
|
+
} else {
|
|
100
|
+
const step = scenes.length / totalFrames;
|
|
101
|
+
for (let i = 0;i < totalFrames; i++) {
|
|
102
|
+
const idx = Math.floor(i * step);
|
|
103
|
+
selectedTimes.push(scenes[idx].time);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
console.log(` ${selectedTimes.length}\uAC1C \uD504\uB808\uC784 \uBCD1\uB82C \uCD94\uCD9C \uC911...`);
|
|
107
|
+
await Promise.all(selectedTimes.map((time, i) => {
|
|
108
|
+
const outFile = join(TEMP_DIR, `frame_${String(i + 1).padStart(3, "0")}.jpg`);
|
|
109
|
+
return execAsync(`ffmpeg -ss ${time} -i "${videoPath}" -frames:v 1 "${outFile}" -y -loglevel warning`);
|
|
110
|
+
}));
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
var execAsync = promisify(exec);
|
|
114
|
+
|
|
115
|
+
// src/grid.ts
|
|
116
|
+
import sharp from "sharp";
|
|
117
|
+
import {readdirSync} from "fs";
|
|
118
|
+
import {join as join2} from "path";
|
|
119
|
+
async function createGrid(grid, outputName) {
|
|
120
|
+
const frames = readdirSync(TEMP_DIR).filter((f) => f.startsWith("frame_") && f.endsWith(".jpg")).sort().slice(0, grid * grid);
|
|
121
|
+
console.log(`\uD83D\uDCF7 ${frames.length}\uAC1C \uD504\uB808\uC784\uC73C\uB85C \uADF8\uB9AC\uB4DC \uC0DD\uC131`);
|
|
122
|
+
if (frames.length === 0) {
|
|
123
|
+
throw new Error("\uD504\uB808\uC784 \uCD94\uCD9C \uC2E4\uD328");
|
|
124
|
+
}
|
|
125
|
+
const composites = await Promise.all(frames.map(async (f, i) => ({
|
|
126
|
+
input: await sharp(join2(TEMP_DIR, f)).resize(THUMB_SIZE, THUMB_SIZE, { fit: "cover" }).toBuffer(),
|
|
127
|
+
left: i % grid * THUMB_SIZE,
|
|
128
|
+
top: Math.floor(i / grid) * THUMB_SIZE
|
|
129
|
+
})));
|
|
130
|
+
await sharp({
|
|
131
|
+
create: {
|
|
132
|
+
width: THUMB_SIZE * grid,
|
|
133
|
+
height: THUMB_SIZE * grid,
|
|
134
|
+
channels: 3,
|
|
135
|
+
background: { r: 0, g: 0, b: 0 }
|
|
136
|
+
}
|
|
137
|
+
}).composite(composites).jpeg({ quality: 90 }).toFile(outputName);
|
|
138
|
+
return outputName;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/index.ts
|
|
142
|
+
async function main() {
|
|
143
|
+
const opts = parseArgs();
|
|
144
|
+
if (!opts.url) {
|
|
145
|
+
printUsage();
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
const totalFrames = opts.grid * opts.grid;
|
|
149
|
+
const modeLabel = opts.mode === "scene" ? "scene" : "uniform";
|
|
150
|
+
const defaultName = `grid_${opts.grid}x${opts.grid}_${modeLabel}.jpg`;
|
|
151
|
+
const outputName = opts.output ?? defaultName;
|
|
152
|
+
if (existsSync(TEMP_DIR)) {
|
|
153
|
+
rmSync(TEMP_DIR, {
|
|
154
|
+
recursive: true
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
mkdirSync(TEMP_DIR);
|
|
158
|
+
try {
|
|
159
|
+
console.log("\uD83D\uDCF9 \uC601\uC0C1 \uC815\uBCF4 \uAC00\uC838\uC624\uB294 \uC911...");
|
|
160
|
+
const duration = parseFloat(execSync2(`yt-dlp --print duration "${opts.url}"`, {
|
|
161
|
+
encoding: "utf-8"
|
|
162
|
+
}).trim());
|
|
163
|
+
console.log(` \uAE38\uC774: ${Math.floor(duration / 60)}\uBD84 ${Math.floor(duration % 60)}\uCD08`);
|
|
164
|
+
console.log("\u2B07\uFE0F \uC601\uC0C1 \uB2E4\uC6B4\uB85C\uB4DC \uC911...");
|
|
165
|
+
execSync2(`yt-dlp -f "best[height<=720]" -o "${TEMP_DIR}/video.mp4" "${opts.url}"`, {
|
|
166
|
+
stdio: "inherit"
|
|
167
|
+
});
|
|
168
|
+
const videoPath = join3(TEMP_DIR, "video.mp4");
|
|
169
|
+
console.log(`\uD83C\uDF9E\uFE0F \uD504\uB808\uC784 \uCD94\uCD9C \uC911 (${opts.mode} \uBAA8\uB4DC)...`);
|
|
170
|
+
if (opts.mode === "scene") {
|
|
171
|
+
const success = await extractScenes(videoPath, totalFrames, opts.threshold);
|
|
172
|
+
if (!success) {
|
|
173
|
+
extractUniform(videoPath, totalFrames, duration);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
extractUniform(videoPath, totalFrames, duration);
|
|
177
|
+
}
|
|
178
|
+
console.log("\uD83D\uDD32 \uADF8\uB9AC\uB4DC \uC0DD\uC131 \uC911...");
|
|
179
|
+
await createGrid(opts.grid, outputName);
|
|
180
|
+
console.log(`\u2705 \uC644\uB8CC: ${outputName}`);
|
|
181
|
+
} finally {
|
|
182
|
+
if (existsSync(TEMP_DIR)) {
|
|
183
|
+
rmSync(TEMP_DIR, { recursive: true });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
main().catch((err) => {
|
|
188
|
+
console.error("\u274C \uC5D0\uB7EC:", err.message);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "yt-thumbnails",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "YouTube video thumbnail grid generator - Extract frames and create beautiful grid images from YouTube videos",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"yt-thumbnails": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"start": "bun run src/index.ts",
|
|
15
|
+
"build": "bun build src/index.ts --outfile dist/index.js --target node --external sharp",
|
|
16
|
+
"prepublishOnly": "npm run build",
|
|
17
|
+
"typecheck": "tsc --noEmit"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"youtube",
|
|
21
|
+
"thumbnail",
|
|
22
|
+
"video",
|
|
23
|
+
"grid",
|
|
24
|
+
"ffmpeg",
|
|
25
|
+
"cli"
|
|
26
|
+
],
|
|
27
|
+
"author": "",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/ieunsu/yt-thumbnails"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"sharp": "^0.34.5"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "^25.0.3",
|
|
41
|
+
"bun-types": "^1.3.5",
|
|
42
|
+
"typescript": "^5.9.3"
|
|
43
|
+
}
|
|
44
|
+
}
|