shorts-maker 0.3.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 +166 -0
- package/bunfig.toml +3 -0
- package/dist/chunk-27wzf6kd.js +240 -0
- package/dist/chunk-wt9saz7q.css +1 -0
- package/dist/index.html +18 -0
- package/hashtags.txt +40 -0
- package/package.json +59 -0
- package/src/build.ts +30 -0
- package/src/cli.ts +70 -0
- package/src/files.ts +24 -0
- package/src/jobs/store.ts +390 -0
- package/src/paths.ts +23 -0
- package/src/pipeline/download.ts +201 -0
- package/src/pipeline/media.ts +312 -0
- package/src/pipeline/overlay.ts +138 -0
- package/src/pipeline/parts.ts +78 -0
- package/src/pipeline/pipeline.ts +217 -0
- package/src/server.ts +645 -0
- package/src/social/config.ts +72 -0
- package/src/social/facebook.ts +232 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Split a video into consecutive parts titled from its name, with random hashtags.
|
|
2
|
+
|
|
3
|
+
import { join, basename } from "node:path";
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import { probe, silenceCutPoints, cutClip } from "./media.ts";
|
|
6
|
+
import { buildParts, duration, type Part } from "./parts.ts";
|
|
7
|
+
import { overlayText, type OverlayPosition } from "./overlay.ts";
|
|
8
|
+
import { packagedHashtags, userHashtags } from "../paths.ts";
|
|
9
|
+
|
|
10
|
+
function slug(text: string, maxlen = 50): string {
|
|
11
|
+
return (
|
|
12
|
+
text
|
|
13
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
14
|
+
.trim()
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[\s_-]+/g, "-")
|
|
17
|
+
.slice(0, maxlen)
|
|
18
|
+
.replace(/^-+|-+$/g, "") || "part"
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fmtTs(seconds: number): string {
|
|
23
|
+
const s = Math.floor(seconds);
|
|
24
|
+
const h = Math.floor(s / 3600);
|
|
25
|
+
const m = Math.floor((s % 3600) / 60);
|
|
26
|
+
const sec = s % 60;
|
|
27
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
28
|
+
return h ? `${pad(h)}:${pad(m)}:${pad(sec)}` : `${pad(m)}:${pad(sec)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanTitle(stem: string): string {
|
|
32
|
+
let t = stem.replace(/_/g, " ");
|
|
33
|
+
t = t.replace(/\b(full episode|full|episode|hd|fhd|1080p|720p|4k|official)\b/gi, "");
|
|
34
|
+
return t.replace(/\s{2,}/g, " ").replace(/^[\s.-]+|[\s.-]+$/g, "") || stem;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Strip leading `#`, drop blanks/dupes (case-insensitive). Shared by file + UI pools. */
|
|
38
|
+
function normalizeTags(tags: string[]): string[] {
|
|
39
|
+
const seen = new Set<string>();
|
|
40
|
+
const out: string[] = [];
|
|
41
|
+
for (const raw of tags) {
|
|
42
|
+
const tag = raw.trim().replace(/^#+/, "").trim();
|
|
43
|
+
if (!tag || seen.has(tag.toLowerCase())) continue;
|
|
44
|
+
seen.add(tag.toLowerCase());
|
|
45
|
+
out.push(tag);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function hashtagPool(): Promise<string[]> {
|
|
51
|
+
// A hashtags.txt in the user's folder overrides the packaged default list.
|
|
52
|
+
const userFile = Bun.file(userHashtags);
|
|
53
|
+
const file = (await userFile.exists()) ? userFile : Bun.file(packagedHashtags);
|
|
54
|
+
if (!(await file.exists())) return [];
|
|
55
|
+
const lines = (await file.text())
|
|
56
|
+
.split("\n")
|
|
57
|
+
.map((l) => l.trim())
|
|
58
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
59
|
+
return normalizeTags(lines);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function sample<T>(arr: T[], k: number): T[] {
|
|
63
|
+
const copy = [...arr];
|
|
64
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
65
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
66
|
+
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
|
67
|
+
}
|
|
68
|
+
return copy.slice(0, k);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function titleOf(p: Part, total: number): string {
|
|
72
|
+
const label = p.index === total ? "final part" : `part-${p.index}`;
|
|
73
|
+
return `${p.title} ${label}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Paste-ready caption: title line, then all hashtags on one space-separated line. */
|
|
77
|
+
function caption(p: Part, total: number): string {
|
|
78
|
+
const tags = p.hashtags.map((t) => `#${t}`).join(" ");
|
|
79
|
+
return tags ? `${titleOf(p, total)}\n\n${tags}` : titleOf(p, total);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface PublishInfo {
|
|
83
|
+
/** Lifecycle of the publish so a page refresh (or restart) reflects reality. */
|
|
84
|
+
status: "pending" | "done" | "error";
|
|
85
|
+
/** Platform post id — present once `done`. */
|
|
86
|
+
id?: string;
|
|
87
|
+
url?: string;
|
|
88
|
+
/** Failure message — present when `error`. */
|
|
89
|
+
error?: string;
|
|
90
|
+
/** Upload progress while `pending`: bytes transferred so far, and the total. */
|
|
91
|
+
sent?: number;
|
|
92
|
+
total?: number;
|
|
93
|
+
/** Id of this post's "All parts" index comment, so it can be edited as parts land. */
|
|
94
|
+
indexCommentId?: string;
|
|
95
|
+
/** When this state was last set (ms epoch). */
|
|
96
|
+
at: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface PartRow {
|
|
100
|
+
file: string;
|
|
101
|
+
caption: string;
|
|
102
|
+
part: number;
|
|
103
|
+
start: number;
|
|
104
|
+
end: number;
|
|
105
|
+
title: string;
|
|
106
|
+
hashtags: string[];
|
|
107
|
+
/** Clip file size in bytes. */
|
|
108
|
+
size?: number;
|
|
109
|
+
/** Where this part has been published, keyed by platform (e.g. "facebook"). */
|
|
110
|
+
published?: Record<string, PublishInfo>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface Manifest {
|
|
114
|
+
source: string;
|
|
115
|
+
parts: PartRow[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface RunOptions {
|
|
119
|
+
video: string;
|
|
120
|
+
outDir: string;
|
|
121
|
+
title?: string;
|
|
122
|
+
partLen?: number;
|
|
123
|
+
numHashtags?: number;
|
|
124
|
+
/** Override the hashtag pool (e.g. edited in the web UI). Falls back to hashtags.txt. */
|
|
125
|
+
hashtags?: string[];
|
|
126
|
+
/**
|
|
127
|
+
* Burn a "PART 2" badge into the first seconds of every clip. Off by default: it forces
|
|
128
|
+
* a re-encode of each part, where a plain cut is a near-instant stream copy.
|
|
129
|
+
*/
|
|
130
|
+
overlay?: OverlayPosition;
|
|
131
|
+
/** Called with every progress line, for live streaming. Always also logged to stdout. */
|
|
132
|
+
onLog?: (line: string) => void;
|
|
133
|
+
/** Aborts the run (kills the active ffmpeg child) — used when the job is deleted. */
|
|
134
|
+
signal?: AbortSignal;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function run(opts: RunOptions): Promise<Manifest> {
|
|
138
|
+
const { video, signal } = opts;
|
|
139
|
+
const stop = () => {
|
|
140
|
+
if (signal?.aborted) throw new Error("Cancelled.");
|
|
141
|
+
};
|
|
142
|
+
const partLen = opts.partLen ?? 900;
|
|
143
|
+
const numHashtags = opts.numHashtags ?? 8;
|
|
144
|
+
const log = (line: string) => {
|
|
145
|
+
console.log(line);
|
|
146
|
+
opts.onLog?.(line);
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (!(await Bun.file(video).exists())) {
|
|
150
|
+
throw new Error(`No such file: ${video}`);
|
|
151
|
+
}
|
|
152
|
+
const partsDir = join(opts.outDir, "parts");
|
|
153
|
+
await mkdir(partsDir, { recursive: true });
|
|
154
|
+
|
|
155
|
+
const stem = basename(video).replace(/\.[^.]+$/, "");
|
|
156
|
+
const baseTitle = opts.title || cleanTitle(stem);
|
|
157
|
+
const info = await probe(video, signal);
|
|
158
|
+
log(`▶ ${basename(video)} · "${baseTitle}"`);
|
|
159
|
+
log(
|
|
160
|
+
` ${info.width}x${info.height} ` +
|
|
161
|
+
`(${info.isVertical ? "vertical 9:16 ✓" : "horizontal"}), ${fmtTs(info.duration)}`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
log("① Finding pauses…");
|
|
165
|
+
const cuts = await silenceCutPoints(video, signal);
|
|
166
|
+
const parts = buildParts(cuts, info.duration, partLen);
|
|
167
|
+
log(` ↳ ${parts.length} parts`);
|
|
168
|
+
|
|
169
|
+
const pool = opts.hashtags?.length ? normalizeTags(opts.hashtags) : await hashtagPool();
|
|
170
|
+
const k = Math.min(numHashtags, pool.length);
|
|
171
|
+
for (const p of parts) {
|
|
172
|
+
p.title = baseTitle;
|
|
173
|
+
p.hashtags = pool.length ? sample(pool, k) : [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Re-encoding for the badge is orders of magnitude slower than a stream copy — say so,
|
|
177
|
+
// so a run that suddenly takes minutes per part doesn't look like a hang.
|
|
178
|
+
log(opts.overlay ? `② Cutting + burning in part badges (${opts.overlay})…` : "② Cutting…");
|
|
179
|
+
const rows: PartRow[] = [];
|
|
180
|
+
for (const p of parts) {
|
|
181
|
+
stop(); // bail promptly if the job was deleted between clips
|
|
182
|
+
const name = `${String(p.index).padStart(2, "0")}_${slug(baseTitle)}`;
|
|
183
|
+
const title = titleOf(p, parts.length);
|
|
184
|
+
const cap = caption(p, parts.length);
|
|
185
|
+
const outPath = join(partsDir, `${name}.mp4`);
|
|
186
|
+
const overlay = opts.overlay
|
|
187
|
+
? {
|
|
188
|
+
text: overlayText(p.index, parts.length),
|
|
189
|
+
position: opts.overlay,
|
|
190
|
+
width: info.width,
|
|
191
|
+
height: info.height,
|
|
192
|
+
}
|
|
193
|
+
: undefined;
|
|
194
|
+
await cutClip(video, outPath, p.start, p.end, signal, overlay);
|
|
195
|
+
const size = Bun.file(outPath).size;
|
|
196
|
+
await Bun.write(
|
|
197
|
+
join(partsDir, `${name}.txt`),
|
|
198
|
+
`${cap}\n\n[source ${fmtTs(p.start)}–${fmtTs(p.end)} · ${duration(p).toFixed(0)}s]\n`,
|
|
199
|
+
);
|
|
200
|
+
log(` [${p.index}/${parts.length}] ${fmtTs(p.start)}–${fmtTs(p.end)} ${title}`);
|
|
201
|
+
rows.push({
|
|
202
|
+
file: `${name}.mp4`,
|
|
203
|
+
caption: cap,
|
|
204
|
+
part: p.index,
|
|
205
|
+
start: Number(p.start.toFixed(2)),
|
|
206
|
+
end: Number(p.end.toFixed(2)),
|
|
207
|
+
title,
|
|
208
|
+
hashtags: p.hashtags,
|
|
209
|
+
size,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const manifest: Manifest = { source: basename(video), parts: rows };
|
|
214
|
+
await Bun.write(join(opts.outDir, "manifest.json"), JSON.stringify(manifest, null, 2));
|
|
215
|
+
log(`\n✓ ${parts.length} parts + captions ready`);
|
|
216
|
+
return manifest;
|
|
217
|
+
}
|