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,72 @@
|
|
|
1
|
+
// Local, gitignored store for social-media credentials (config/social.json).
|
|
2
|
+
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { mkdir } from "node:fs/promises";
|
|
5
|
+
import { CONFIG_DIR } from "../paths.ts";
|
|
6
|
+
|
|
7
|
+
const CONFIG_FILE = join(CONFIG_DIR, "social.json");
|
|
8
|
+
|
|
9
|
+
export interface FacebookConfig {
|
|
10
|
+
pageId: string;
|
|
11
|
+
token: string;
|
|
12
|
+
pageName?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface SocialConfig {
|
|
16
|
+
facebook?: FacebookConfig;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function read(): Promise<SocialConfig> {
|
|
20
|
+
const f = Bun.file(CONFIG_FILE);
|
|
21
|
+
if (!(await f.exists())) return {};
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(await f.text()) as SocialConfig;
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function write(cfg: SocialConfig): Promise<void> {
|
|
30
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
31
|
+
await Bun.write(CONFIG_FILE, JSON.stringify(cfg, null, 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const getConfig = read;
|
|
35
|
+
|
|
36
|
+
export async function getFacebook(): Promise<FacebookConfig | undefined> {
|
|
37
|
+
return (await read()).facebook;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Tokens never contain whitespace/quotes — strip anything a copy-paste dragged in. */
|
|
41
|
+
export function cleanToken(raw: string): string {
|
|
42
|
+
return raw.replace(/\s+/g, "").replace(/^["']+|["']+$/g, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Merge-save the Facebook config. A blank/absent token keeps the existing one, so the
|
|
47
|
+
* user can change the Page ID without re-entering the token. Changing anything clears
|
|
48
|
+
* the cached page name until re-verified.
|
|
49
|
+
*/
|
|
50
|
+
export async function saveFacebook(patch: { pageId: string; token?: string }): Promise<void> {
|
|
51
|
+
const cfg = await read();
|
|
52
|
+
const prev = cfg.facebook;
|
|
53
|
+
const cleaned = patch.token ? cleanToken(patch.token) : "";
|
|
54
|
+
const token = cleaned || prev?.token || "";
|
|
55
|
+
cfg.facebook = { pageId: patch.pageId.trim(), token, pageName: undefined };
|
|
56
|
+
await write(cfg);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Remember the verified page name so the settings page can show it without a round-trip. */
|
|
60
|
+
export async function setFacebookPageName(pageName: string): Promise<void> {
|
|
61
|
+
const cfg = await read();
|
|
62
|
+
if (cfg.facebook) {
|
|
63
|
+
cfg.facebook.pageName = pageName;
|
|
64
|
+
await write(cfg);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function clearFacebook(): Promise<void> {
|
|
69
|
+
const cfg = await read();
|
|
70
|
+
delete cfg.facebook;
|
|
71
|
+
await write(cfg);
|
|
72
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Facebook Page publishing via the Graph API.
|
|
2
|
+
|
|
3
|
+
// Single place to bump the Graph API version. v25.0 is the latest live version and
|
|
4
|
+
// matches Facebook's video-publishing guide.
|
|
5
|
+
const API_VERSION = "v25.0";
|
|
6
|
+
const GRAPH = `https://graph.facebook.com/${API_VERSION}`;
|
|
7
|
+
const GRAPH_VIDEO = `https://graph-video.facebook.com/${API_VERSION}`;
|
|
8
|
+
|
|
9
|
+
export class FacebookError extends Error {}
|
|
10
|
+
|
|
11
|
+
interface GraphError {
|
|
12
|
+
error?: { message?: string; type?: string; code?: number; error_subcode?: number };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parse a Graph response body defensively — FB can return `null`, empty, or non-JSON. */
|
|
16
|
+
async function readJson<T>(res: Response): Promise<T> {
|
|
17
|
+
const data = await res.json().catch(() => null);
|
|
18
|
+
return (data && typeof data === "object" ? data : {}) as T;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Build a helpful message from Facebook's error object. */
|
|
22
|
+
function fbMessage(err: GraphError["error"], status: number): string {
|
|
23
|
+
if (!err) return `Graph API returned ${status}.`;
|
|
24
|
+
let msg = err.message || `Graph API returned ${status}.`;
|
|
25
|
+
if (err.code) msg += ` (code ${err.code}${err.error_subcode ? `/${err.error_subcode}` : ""})`;
|
|
26
|
+
if (/session has expired|expired/i.test(err.message ?? "")) {
|
|
27
|
+
msg += " — this token has expired. Generate a fresh one (and extend it to long-lived).";
|
|
28
|
+
} else if (/malformed|cannot parse|invalid oauth access token data/i.test(err.message ?? "")) {
|
|
29
|
+
msg += " — the token looks malformed. Re-copy it as a single line with no spaces.";
|
|
30
|
+
}
|
|
31
|
+
return msg;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Permissions the token needs to publish a video to a Page. */
|
|
35
|
+
const REQUIRED_PERMISSIONS = ["pages_manage_posts"];
|
|
36
|
+
|
|
37
|
+
/** Read the token's granted permissions via debug_token (null if it can't be determined). */
|
|
38
|
+
async function tokenScopes(token: string): Promise<string[] | null> {
|
|
39
|
+
try {
|
|
40
|
+
const url = `${GRAPH}/debug_token?input_token=${encodeURIComponent(token)}&access_token=${encodeURIComponent(token)}`;
|
|
41
|
+
const res = await fetch(url);
|
|
42
|
+
const data = await readJson<{ data?: { scopes?: string[]; granular_scopes?: { scope: string }[] } }>(res);
|
|
43
|
+
if (!data?.data) return null;
|
|
44
|
+
const scopes = new Set(data.data.scopes ?? []);
|
|
45
|
+
for (const g of data.data.granular_scopes ?? []) scopes.add(g.scope);
|
|
46
|
+
return scopes.size ? [...scopes] : null;
|
|
47
|
+
} catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface VerifyResult {
|
|
53
|
+
pageName: string;
|
|
54
|
+
/** Required publishing permissions the token is missing (empty if OK or undeterminable). */
|
|
55
|
+
missing: string[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Verify the token + page (name + publishing permissions). Fails loudly with FB's message. */
|
|
59
|
+
export async function verify(pageId: string, token: string): Promise<VerifyResult> {
|
|
60
|
+
if (!pageId || !token) throw new FacebookError("Page ID and access token are both required.");
|
|
61
|
+
const url = `${GRAPH}/${encodeURIComponent(pageId)}?fields=name&access_token=${encodeURIComponent(token)}`;
|
|
62
|
+
const res = await fetch(url);
|
|
63
|
+
const data = await readJson<GraphError & { name?: string }>(res);
|
|
64
|
+
if (!res.ok || data.error) throw new FacebookError(fbMessage(data.error, res.status));
|
|
65
|
+
if (!data.name) throw new FacebookError("Could not read the page name — check the Page ID.");
|
|
66
|
+
|
|
67
|
+
const scopes = await tokenScopes(token);
|
|
68
|
+
const missing = scopes ? REQUIRED_PERMISSIONS.filter((p) => !scopes.includes(p)) : [];
|
|
69
|
+
return { pageName: data.name, missing };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Look up a nice permalink for a freshly-uploaded video (best-effort). */
|
|
73
|
+
async function permalink(videoId: string, token: string): Promise<string | undefined> {
|
|
74
|
+
try {
|
|
75
|
+
const url = `${GRAPH}/${videoId}?fields=permalink_url&access_token=${encodeURIComponent(token)}`;
|
|
76
|
+
const res = await fetch(url);
|
|
77
|
+
const data = await readJson<{ permalink_url?: string }>(res);
|
|
78
|
+
if (data.permalink_url) {
|
|
79
|
+
return data.permalink_url.startsWith("http")
|
|
80
|
+
? data.permalink_url
|
|
81
|
+
: `https://www.facebook.com${data.permalink_url}`;
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
/* ignore — the id alone is enough */
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Delete a Page video (removes the post). */
|
|
90
|
+
export async function deleteVideo(videoId: string, token: string): Promise<void> {
|
|
91
|
+
const url = `${GRAPH}/${encodeURIComponent(videoId)}?access_token=${encodeURIComponent(token)}`;
|
|
92
|
+
const res = await fetch(url, { method: "DELETE" });
|
|
93
|
+
const data = await readJson<GraphError & { success?: boolean }>(res);
|
|
94
|
+
if (data.error) throw new FacebookError(fbMessage(data.error, res.status));
|
|
95
|
+
if (!data.success) throw new FacebookError(`Delete failed (HTTP ${res.status}).`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Comment on a post/video as the Page (needs `pages_manage_engagement`). */
|
|
99
|
+
export async function postComment(objectId: string, message: string, token: string): Promise<{ id: string }> {
|
|
100
|
+
const form = new FormData();
|
|
101
|
+
form.append("access_token", token);
|
|
102
|
+
form.append("message", message);
|
|
103
|
+
const res = await fetch(`${GRAPH}/${encodeURIComponent(objectId)}/comments`, { method: "POST", body: form });
|
|
104
|
+
const data = await readJson<GraphError & { id?: string }>(res);
|
|
105
|
+
if (data.error) throw new FacebookError(fbMessage(data.error, res.status));
|
|
106
|
+
if (!data.id) throw new FacebookError(`Comment failed (HTTP ${res.status}).`);
|
|
107
|
+
return { id: data.id };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Edit an existing Page comment in place. */
|
|
111
|
+
export async function updateComment(commentId: string, message: string, token: string): Promise<void> {
|
|
112
|
+
const form = new FormData();
|
|
113
|
+
form.append("access_token", token);
|
|
114
|
+
form.append("message", message);
|
|
115
|
+
const res = await fetch(`${GRAPH}/${encodeURIComponent(commentId)}`, { method: "POST", body: form });
|
|
116
|
+
const data = await readJson<GraphError & { success?: boolean }>(res);
|
|
117
|
+
if (data.error) throw new FacebookError(fbMessage(data.error, res.status));
|
|
118
|
+
if (!data.success) throw new FacebookError(`Comment update failed (HTTP ${res.status}).`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Delete a Page comment. */
|
|
122
|
+
export async function deleteComment(commentId: string, token: string): Promise<void> {
|
|
123
|
+
const url = `${GRAPH}/${encodeURIComponent(commentId)}?access_token=${encodeURIComponent(token)}`;
|
|
124
|
+
const res = await fetch(url, { method: "DELETE" });
|
|
125
|
+
const data = await readJson<GraphError & { success?: boolean }>(res);
|
|
126
|
+
if (data.error) throw new FacebookError(fbMessage(data.error, res.status));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface UploadResult {
|
|
130
|
+
id: string;
|
|
131
|
+
url?: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface UploadOpts {
|
|
135
|
+
pageId: string;
|
|
136
|
+
token: string;
|
|
137
|
+
filePath: string;
|
|
138
|
+
title?: string;
|
|
139
|
+
description: string;
|
|
140
|
+
/** Called as bytes land on Facebook, for a live progress bar. */
|
|
141
|
+
onProgress?: (sent: number, total: number) => void;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Publish a local video to the Page via Facebook's chunked video upload
|
|
146
|
+
* (`upload_phase = start → transfer… → finish` on the `/<page-id>/videos` edge).
|
|
147
|
+
* Facebook dictates each chunk's byte range, so there are no oversized requests and no
|
|
148
|
+
* guessed chunk sizes. This is the long-standing Page path; the newer Resumable Upload
|
|
149
|
+
* API (`/uploads` + `fbuploader_video_file_chunk`) consistently failed ingest here with
|
|
150
|
+
* code 6000/1363019 even for valid files, so we deliberately avoid it.
|
|
151
|
+
* Docs: developers.facebook.com/docs/graph-api/reference/page/videos/
|
|
152
|
+
*/
|
|
153
|
+
export async function uploadVideo(opts: UploadOpts): Promise<UploadResult> {
|
|
154
|
+
const file = Bun.file(opts.filePath);
|
|
155
|
+
if (!(await file.exists())) throw new FacebookError("The clip file is missing on disk.");
|
|
156
|
+
const size = file.size;
|
|
157
|
+
const url = `${GRAPH_VIDEO}/${encodeURIComponent(opts.pageId)}/videos`;
|
|
158
|
+
|
|
159
|
+
const post = async <T>(fields: Record<string, string | Blob>): Promise<T & GraphError> => {
|
|
160
|
+
const form = new FormData();
|
|
161
|
+
form.append("access_token", opts.token);
|
|
162
|
+
for (const [k, v] of Object.entries(fields)) form.append(k, v);
|
|
163
|
+
const res = await fetch(url, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
body: form,
|
|
166
|
+
signal: AbortSignal.timeout(10 * 60_000), // don't hang forever on a stalled socket
|
|
167
|
+
});
|
|
168
|
+
return readJson<T & GraphError>(res);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// 1) start → Facebook opens a session and tells us which bytes it wants first.
|
|
172
|
+
const start = await post<{ upload_session_id?: string; video_id?: string; start_offset?: string; end_offset?: string }>(
|
|
173
|
+
{ upload_phase: "start", file_size: String(size) },
|
|
174
|
+
);
|
|
175
|
+
if (start.error) throw new FacebookError(fbMessage(start.error, 200));
|
|
176
|
+
const sessionId = start.upload_session_id;
|
|
177
|
+
const videoId = start.video_id;
|
|
178
|
+
if (!sessionId || !videoId) throw new FacebookError("Couldn’t start the upload session.");
|
|
179
|
+
|
|
180
|
+
// 2) transfer — until Facebook reports both offsets equal (everything received).
|
|
181
|
+
// Its hinted ranges are tiny (~1–5 MB), and a 300 MB clip in 1 MB chunks pays
|
|
182
|
+
// hundreds of sequential round-trips; verified empirically that it happily accepts
|
|
183
|
+
// (and fully consumes) chunks far larger than the hint, so we send 32 MB at a time
|
|
184
|
+
// and only drop back to the exact hinted range while retrying after a failure.
|
|
185
|
+
// Error 1363037 even tells us where to resume.
|
|
186
|
+
const CHUNK = 32 * 1024 * 1024;
|
|
187
|
+
let from = Number(start.start_offset ?? 0);
|
|
188
|
+
let to = Number(start.end_offset ?? 0);
|
|
189
|
+
let retries = 0;
|
|
190
|
+
opts.onProgress?.(0, size);
|
|
191
|
+
while (from < to) {
|
|
192
|
+
const sendTo = retries > 0 ? to : Math.min(Math.max(to, from + CHUNK), size);
|
|
193
|
+
const chunk = await post<{ start_offset?: string; end_offset?: string }>({
|
|
194
|
+
upload_phase: "transfer",
|
|
195
|
+
upload_session_id: sessionId,
|
|
196
|
+
start_offset: String(from),
|
|
197
|
+
video_file_chunk: file.slice(from, sendTo),
|
|
198
|
+
}).catch((e) => ({ error: { message: (e as Error).message } }) as GraphError & { start_offset?: string; end_offset?: string });
|
|
199
|
+
|
|
200
|
+
if (chunk.error) {
|
|
201
|
+
// error_data on 1363037 carries the offset to resume from.
|
|
202
|
+
const resume = (chunk.error as { error_data?: { start_offset?: string } }).error_data?.start_offset;
|
|
203
|
+
if (resume !== undefined) from = Number(resume);
|
|
204
|
+
if (++retries > 5) throw new FacebookError(fbMessage(chunk.error, 200));
|
|
205
|
+
await Bun.sleep(Math.min(1500 * 2 ** retries, 15_000));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
retries = 0;
|
|
209
|
+
from = Number(chunk.start_offset ?? sendTo);
|
|
210
|
+
to = Number(chunk.end_offset ?? sendTo);
|
|
211
|
+
opts.onProgress?.(Math.min(from, size), size);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// 3) finish — attach the caption and publish. `success` may be false without an error
|
|
215
|
+
// object on rare hiccups, so retry that too.
|
|
216
|
+
for (let attempt = 0; ; attempt++) {
|
|
217
|
+
const fin = await post<{ success?: boolean }>({
|
|
218
|
+
upload_phase: "finish",
|
|
219
|
+
upload_session_id: sessionId,
|
|
220
|
+
description: opts.description,
|
|
221
|
+
...(opts.title ? { title: opts.title } : {}),
|
|
222
|
+
});
|
|
223
|
+
if (fin.success) break;
|
|
224
|
+
if (attempt >= 3) {
|
|
225
|
+
throw new FacebookError(fin.error ? fbMessage(fin.error, 200) : "Facebook didn’t confirm the publish.");
|
|
226
|
+
}
|
|
227
|
+
await Bun.sleep(2500 * (attempt + 1));
|
|
228
|
+
}
|
|
229
|
+
opts.onProgress?.(size, size);
|
|
230
|
+
|
|
231
|
+
return { id: videoId, url: await permalink(videoId, opts.token) };
|
|
232
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext", "DOM", "DOM.Iterable"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "bundler",
|
|
7
|
+
"allowImportingTsExtensions": true,
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"types": ["bun-types"],
|
|
10
|
+
"strict": true,
|
|
11
|
+
"skipLibCheck": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"verbatimModuleSyntax": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src", "frontend"]
|
|
16
|
+
}
|