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.
Files changed (3) hide show
  1. package/README.md +77 -0
  2. package/dist/index.js +190 -0
  3. 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
+ }