shorts-maker 0.3.0 → 0.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/dist/chunk-1w58sy4p.css +1 -0
- package/dist/chunk-23ted1q8.js +248 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/pipeline/pipeline.ts +5 -1
- package/src/server.ts +164 -87
- package/src/social/facebook.ts +40 -0
- package/dist/chunk-27wzf6kd.js +0 -240
- package/dist/chunk-wt9saz7q.css +0 -1
package/dist/index.html
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
type="image/svg+xml"
|
|
11
11
|
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%237c5cff'/%3E%3Cstop offset='1' stop-color='%232dd4bf'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='32' height='32' rx='8' fill='url(%23g)'/%3E%3Cg transform='translate(6.5 6.5) scale(0.79)' fill='none' stroke='white' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='6' cy='6' r='3'/%3E%3Ccircle cx='6' cy='18' r='3'/%3E%3Cline x1='20' y1='4' x2='8.12' y2='15.88'/%3E%3Cline x1='14.47' y1='14.48' x2='20' y2='20'/%3E%3Cline x1='8.12' y1='8.12' x2='12' y2='12'/%3E%3C/g%3E%3C/svg%3E"
|
|
12
12
|
/>
|
|
13
|
-
<link rel="stylesheet" crossorigin href="/chunk-
|
|
13
|
+
<link rel="stylesheet" crossorigin href="/chunk-1w58sy4p.css"><script type="module" crossorigin src="/chunk-23ted1q8.js"></script></head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
16
16
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shorts-maker",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Split a video into consecutive shorts titled from its name, add hashtags, and publish them to Facebook — runs locally in your browser.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"module": "src/server.ts",
|
package/src/pipeline/pipeline.ts
CHANGED
|
@@ -90,7 +90,11 @@ export interface PublishInfo {
|
|
|
90
90
|
/** Upload progress while `pending`: bytes transferred so far, and the total. */
|
|
91
91
|
sent?: number;
|
|
92
92
|
total?: number;
|
|
93
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Id of the Page comment written on this post from the job page's comment dialog, so a
|
|
95
|
+
* later run edits it in place instead of adding a second one. (Named for the "All parts"
|
|
96
|
+
* index it used to serve, which the dialog replaced.)
|
|
97
|
+
*/
|
|
94
98
|
indexCommentId?: string;
|
|
95
99
|
/** When this state was last set (ms epoch). */
|
|
96
100
|
at: number;
|
package/src/server.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { join, basename, extname, dirname } from "node:path";
|
|
5
5
|
import { readdir, mkdir, rm } from "node:fs/promises";
|
|
6
6
|
import { existsSync } from "node:fs";
|
|
7
|
-
import { hashtagPool } from "./pipeline/pipeline.ts";
|
|
7
|
+
import { hashtagPool, type PublishInfo } from "./pipeline/pipeline.ts";
|
|
8
8
|
import { isOverlayPosition, OVERLAY_POSITIONS } from "./pipeline/overlay.ts";
|
|
9
9
|
import * as store from "./jobs/store.ts";
|
|
10
10
|
import * as social from "./social/config.ts";
|
|
@@ -138,72 +138,11 @@ async function handleFbTest(req: Request): Promise<Response> {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
/**
|
|
142
|
-
* Keep one "All parts" index comment on every posted part, edited in place as parts are
|
|
143
|
-
* published or removed. With fewer than two posts an index is noise, so any leftover
|
|
144
|
-
* index comment is deleted instead.
|
|
145
|
-
*/
|
|
146
|
-
async function syncPartsIndex(
|
|
147
|
-
job: store.Job,
|
|
148
|
-
platform: string,
|
|
149
|
-
token: string,
|
|
150
|
-
): Promise<{ updated: number; warnings: string[] }> {
|
|
151
|
-
const published = job.parts
|
|
152
|
-
.filter((p) => p.published?.[platform]?.status === "done" && p.published[platform]!.id)
|
|
153
|
-
.sort((a, b) => a.part - b.part);
|
|
154
|
-
const warnings: string[] = [];
|
|
155
|
-
let updated = 0;
|
|
156
|
-
|
|
157
|
-
if (published.length < 2) {
|
|
158
|
-
for (const p of published) {
|
|
159
|
-
const pub = p.published![platform]!;
|
|
160
|
-
if (!pub.indexCommentId) continue;
|
|
161
|
-
await facebook.deleteComment(pub.indexCommentId, token).catch(() => {});
|
|
162
|
-
store.setPublishState(job.id, p.file, platform, { ...pub, indexCommentId: undefined });
|
|
163
|
-
}
|
|
164
|
-
return { updated, warnings };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Footer flips automatically once the final part lands, since comments are re-edited
|
|
168
|
-
// on every sync.
|
|
169
|
-
const complete = published.length === job.parts.length;
|
|
170
|
-
const footer = complete
|
|
171
|
-
? "❤️ Enjoyed the series? Follow us for more content!"
|
|
172
|
-
: "🔔 Follow us — more parts coming soon!";
|
|
173
|
-
const message =
|
|
174
|
-
"📺 All parts:\n" +
|
|
175
|
-
published
|
|
176
|
-
.map((p) => {
|
|
177
|
-
const pub = p.published![platform]!;
|
|
178
|
-
return `▶ Part ${p.part}: ${pub.url ?? `https://www.facebook.com/${pub.id}`}`;
|
|
179
|
-
})
|
|
180
|
-
.join("\n") +
|
|
181
|
-
`\n\n${footer}`;
|
|
182
|
-
|
|
183
|
-
for (const p of published) {
|
|
184
|
-
const pub = p.published![platform]!;
|
|
185
|
-
try {
|
|
186
|
-
if (pub.indexCommentId) {
|
|
187
|
-
await facebook.updateComment(pub.indexCommentId, message, token);
|
|
188
|
-
} else {
|
|
189
|
-
const c = await facebook.postComment(pub.id!, message, token);
|
|
190
|
-
store.setPublishState(job.id, p.file, platform, { ...pub, indexCommentId: c.id });
|
|
191
|
-
}
|
|
192
|
-
updated++;
|
|
193
|
-
} catch (err) {
|
|
194
|
-
warnings.push(`Part ${p.part}: ${(err as Error).message}`);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return { updated, warnings };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
141
|
async function handlePublish(req: Request): Promise<Response> {
|
|
201
142
|
const body = (await req.json().catch(() => null)) as {
|
|
202
143
|
jobId?: string;
|
|
203
144
|
file?: string;
|
|
204
145
|
platform?: string;
|
|
205
|
-
/** Maintain an "All parts" index comment on every posted part of this series. */
|
|
206
|
-
linkAll?: boolean;
|
|
207
146
|
} | null;
|
|
208
147
|
const jobId = body?.jobId ?? "";
|
|
209
148
|
const file = body?.file ?? "";
|
|
@@ -242,24 +181,7 @@ async function handlePublish(req: Request): Promise<Response> {
|
|
|
242
181
|
});
|
|
243
182
|
const info = { status: "done" as const, id: res.id, url: res.url, at: Date.now() };
|
|
244
183
|
store.setPublishState(jobId, file, platform, info);
|
|
245
|
-
|
|
246
|
-
// Series index: refresh the "All parts" comment across every posted part.
|
|
247
|
-
// Best-effort — the publish itself already succeeded, so index trouble is reported
|
|
248
|
-
// as a warning, never as a failed publish.
|
|
249
|
-
let indexed: number | undefined;
|
|
250
|
-
let linkWarning: string | undefined;
|
|
251
|
-
if (body?.linkAll) {
|
|
252
|
-
const sync = await syncPartsIndex(job, platform, fb.token);
|
|
253
|
-
indexed = sync.updated;
|
|
254
|
-
if (sync.warnings.length) linkWarning = `Index comment issues — ${sync.warnings.join("; ")}`;
|
|
255
|
-
}
|
|
256
|
-
return json({
|
|
257
|
-
ok: true,
|
|
258
|
-
platform,
|
|
259
|
-
...info,
|
|
260
|
-
...(indexed !== undefined ? { indexed } : {}),
|
|
261
|
-
...(linkWarning ? { linkWarning } : {}),
|
|
262
|
-
});
|
|
184
|
+
return json({ ok: true, platform, ...info });
|
|
263
185
|
} catch (err) {
|
|
264
186
|
const msg = (err as Error).message;
|
|
265
187
|
store.setPublishState(jobId, file, platform, { status: "error", error: msg, at: Date.now() });
|
|
@@ -267,6 +189,163 @@ async function handlePublish(req: Request): Promise<Response> {
|
|
|
267
189
|
}
|
|
268
190
|
}
|
|
269
191
|
|
|
192
|
+
/** A comment Facebook no longer has — editing it must fall back to writing a fresh one. */
|
|
193
|
+
const commentGone = (msg: string) => /does not exist|cannot be loaded|unsupported get request|missing/i.test(msg);
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Comment on several of a job's published parts at once.
|
|
197
|
+
*
|
|
198
|
+
* A part whose post already carries this app's comment (`indexCommentId`) has it edited in
|
|
199
|
+
* place; otherwise a fresh comment is written and its id stored, so the next run edits that
|
|
200
|
+
* one instead of piling up copies.
|
|
201
|
+
*
|
|
202
|
+
* Each part is attempted independently: one Page/rate-limit failure must not hide the
|
|
203
|
+
* comments that did land, so every outcome comes back per-part rather than as a single throw.
|
|
204
|
+
*/
|
|
205
|
+
async function handleComment(req: Request): Promise<Response> {
|
|
206
|
+
const body = (await req.json().catch(() => null)) as {
|
|
207
|
+
jobId?: string;
|
|
208
|
+
files?: string[];
|
|
209
|
+
platform?: string;
|
|
210
|
+
message?: string;
|
|
211
|
+
} | null;
|
|
212
|
+
const platform = body?.platform ?? "facebook";
|
|
213
|
+
if (platform !== "facebook") return json({ error: `Unsupported platform: ${platform}` }, 400);
|
|
214
|
+
|
|
215
|
+
const message = (body?.message ?? "").trim();
|
|
216
|
+
if (!message) return json({ error: "A comment message is required." }, 400);
|
|
217
|
+
|
|
218
|
+
const job = store.get(body?.jobId ?? "");
|
|
219
|
+
if (!job) return json({ error: "No such job." }, 404);
|
|
220
|
+
|
|
221
|
+
const files = Array.isArray(body?.files) ? body.files.map(String) : [];
|
|
222
|
+
if (!files.length) return json({ error: "Pick at least one published part." }, 400);
|
|
223
|
+
|
|
224
|
+
const fb = await social.getFacebook();
|
|
225
|
+
if (!fb?.pageId || !fb?.token) {
|
|
226
|
+
return json({ error: "Facebook isn’t configured. Add your Page in Settings." }, 400);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
type Result = { file: string; part?: number; ok: boolean; id?: string; action?: "created" | "updated"; error?: string };
|
|
230
|
+
const results: Result[] = [];
|
|
231
|
+
|
|
232
|
+
/** Write a new comment on the post and remember its id, so the next run edits it. */
|
|
233
|
+
const create = async (file: string, postId: string, pub: PublishInfo): Promise<Result> => {
|
|
234
|
+
const c = await facebook.postComment(postId, message, fb.token);
|
|
235
|
+
store.setPublishState(job.id, file, platform, { ...pub, indexCommentId: c.id });
|
|
236
|
+
return { file, ok: true, id: c.id, action: "created" };
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
for (const file of files) {
|
|
240
|
+
const part = job.parts.find((p) => p.file === file);
|
|
241
|
+
const pub = part?.published?.[platform];
|
|
242
|
+
if (!part || pub?.status !== "done" || !pub.id) {
|
|
243
|
+
results.push({ file, part: part?.part, ok: false, error: "This part isn’t posted." });
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
if (pub.indexCommentId) {
|
|
248
|
+
try {
|
|
249
|
+
await facebook.updateComment(pub.indexCommentId, message, fb.token);
|
|
250
|
+
results.push({ file, part: part.part, ok: true, id: pub.indexCommentId, action: "updated" });
|
|
251
|
+
} catch (err) {
|
|
252
|
+
// Someone deleted it on Facebook's side — write a fresh one and re-track it.
|
|
253
|
+
if (!commentGone((err as Error).message)) throw err;
|
|
254
|
+
results.push({ ...(await create(file, pub.id, pub)), part: part.part });
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
results.push({ ...(await create(file, pub.id, pub)), part: part.part });
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
results.push({ file, part: part.part, ok: false, error: (err as Error).message });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
const posted = results.filter((r) => r.ok).length;
|
|
264
|
+
return json({ ok: posted === results.length, posted, results });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Reconcile the tracked comment ids against what's actually on Facebook, without writing
|
|
269
|
+
* anything to the Page. For each published part we list the comments its post carries that
|
|
270
|
+
* were authored by our Page, then:
|
|
271
|
+
*
|
|
272
|
+
* - the tracked comment is still there → "tracked" (nothing to do)
|
|
273
|
+
* - it's gone, or we never had one, and exactly
|
|
274
|
+
* one Page comment exists → "linked" (adopt it)
|
|
275
|
+
* - several Page comments exist and none is
|
|
276
|
+
* tracked → "ambiguous" (adopt nothing, list them)
|
|
277
|
+
* - the Page has commented nothing → "none"
|
|
278
|
+
*
|
|
279
|
+
* The ambiguous case is deliberately left alone: guessing wrong would mean the next comment
|
|
280
|
+
* silently overwrites the wrong one. The candidates come back so the user can decide.
|
|
281
|
+
*/
|
|
282
|
+
async function handleSyncComments(req: Request): Promise<Response> {
|
|
283
|
+
const body = (await req.json().catch(() => null)) as { jobId?: string; platform?: string } | null;
|
|
284
|
+
const platform = body?.platform ?? "facebook";
|
|
285
|
+
if (platform !== "facebook") return json({ error: `Unsupported platform: ${platform}` }, 400);
|
|
286
|
+
|
|
287
|
+
const job = store.get(body?.jobId ?? "");
|
|
288
|
+
if (!job) return json({ error: "No such job." }, 404);
|
|
289
|
+
|
|
290
|
+
const fb = await social.getFacebook();
|
|
291
|
+
if (!fb?.pageId || !fb?.token) {
|
|
292
|
+
return json({ error: "Facebook isn’t configured. Add your Page in Settings." }, 400);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type Status = "tracked" | "linked" | "ambiguous" | "none";
|
|
296
|
+
const results: {
|
|
297
|
+
file: string;
|
|
298
|
+
part: number;
|
|
299
|
+
status?: Status;
|
|
300
|
+
ok: boolean;
|
|
301
|
+
commentId?: string;
|
|
302
|
+
candidates?: facebook.PageComment[];
|
|
303
|
+
error?: string;
|
|
304
|
+
}[] = [];
|
|
305
|
+
|
|
306
|
+
for (const part of job.parts) {
|
|
307
|
+
const pub = part.published?.[platform];
|
|
308
|
+
if (pub?.status !== "done" || !pub.id) continue; // not posted — nothing to reconcile
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
const mine = await facebook.listPageComments(pub.id, fb.pageId, fb.token);
|
|
312
|
+
const tracked = pub.indexCommentId && mine.some((c) => c.id === pub.indexCommentId);
|
|
313
|
+
|
|
314
|
+
let status: Status;
|
|
315
|
+
let commentId: string | undefined;
|
|
316
|
+
if (tracked) {
|
|
317
|
+
status = "tracked";
|
|
318
|
+
commentId = pub.indexCommentId;
|
|
319
|
+
} else if (mine.length === 1) {
|
|
320
|
+
status = "linked";
|
|
321
|
+
commentId = mine[0]!.id;
|
|
322
|
+
} else if (mine.length > 1) {
|
|
323
|
+
status = "ambiguous";
|
|
324
|
+
} else {
|
|
325
|
+
status = "none";
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Adopt the found comment; drop a tracked id that no longer exists on the Page.
|
|
329
|
+
if (commentId !== pub.indexCommentId) {
|
|
330
|
+
store.setPublishState(job.id, part.file, platform, { ...pub, indexCommentId: commentId });
|
|
331
|
+
}
|
|
332
|
+
results.push({
|
|
333
|
+
file: part.file,
|
|
334
|
+
part: part.part,
|
|
335
|
+
ok: true,
|
|
336
|
+
status,
|
|
337
|
+
commentId,
|
|
338
|
+
...(status === "ambiguous" ? { candidates: mine } : {}),
|
|
339
|
+
});
|
|
340
|
+
} catch (err) {
|
|
341
|
+
results.push({ file: part.file, part: part.part, ok: false, error: (err as Error).message });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (!results.length) return json({ error: "No published parts to sync." }, 400);
|
|
346
|
+
return json({ ok: results.every((r) => r.ok), results });
|
|
347
|
+
}
|
|
348
|
+
|
|
270
349
|
/** Delete the Facebook post for a part, then forget the local publish record. */
|
|
271
350
|
async function handleUnpublish(req: Request): Promise<Response> {
|
|
272
351
|
const body = (await req.json().catch(() => null)) as { jobId?: string; file?: string; platform?: string } | null;
|
|
@@ -290,14 +369,10 @@ async function handleUnpublish(req: Request): Promise<Response> {
|
|
|
290
369
|
return json({ error: msg }, 502);
|
|
291
370
|
}
|
|
292
371
|
}
|
|
372
|
+
// Deleting the post takes its comment with it, so the local record (comment id included)
|
|
373
|
+
// is simply forgotten. Comments on the *other* posts may now list a dead link — reposting
|
|
374
|
+
// from the job page's comment dialog rewrites them with the current parts.
|
|
293
375
|
store.clearPublishState(jobId, file, platform);
|
|
294
|
-
|
|
295
|
-
// If this series maintains "All parts" index comments, scrub the dead link from the
|
|
296
|
-
// remaining posts (best-effort — the removal itself already succeeded).
|
|
297
|
-
const job = store.get(jobId);
|
|
298
|
-
if (job && job.parts.some((p) => p.published?.[platform]?.indexCommentId)) {
|
|
299
|
-
await syncPartsIndex(job, platform, fb.token).catch(() => {});
|
|
300
|
-
}
|
|
301
376
|
return json({ ok: true });
|
|
302
377
|
}
|
|
303
378
|
|
|
@@ -583,6 +658,8 @@ const server = Bun.serve({
|
|
|
583
658
|
}
|
|
584
659
|
if (pathname === "/api/publish" && req.method === "POST") return handlePublish(req);
|
|
585
660
|
if (pathname === "/api/publish" && req.method === "DELETE") return handleUnpublish(req);
|
|
661
|
+
if (pathname === "/api/comment" && req.method === "POST") return handleComment(req);
|
|
662
|
+
if (pathname === "/api/comment/sync" && req.method === "POST") return handleSyncComments(req);
|
|
586
663
|
|
|
587
664
|
if (pathname === "/api/uploads" && req.method === "GET") return handleListUploads();
|
|
588
665
|
if (pathname === "/api/uploads" && req.method === "DELETE") return handleClearUploads();
|
package/src/social/facebook.ts
CHANGED
|
@@ -107,6 +107,46 @@ export async function postComment(objectId: string, message: string, token: stri
|
|
|
107
107
|
return { id: data.id };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
export interface PageComment {
|
|
111
|
+
id: string;
|
|
112
|
+
message: string;
|
|
113
|
+
createdTime?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface CommentNode {
|
|
117
|
+
id: string;
|
|
118
|
+
message?: string;
|
|
119
|
+
created_time?: string;
|
|
120
|
+
from?: { id?: string };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Every comment on `objectId` authored by the Page itself, oldest first. Read-only.
|
|
125
|
+
*
|
|
126
|
+
* `from` is only populated for comments the token's Page owns, so a comment with no `from`
|
|
127
|
+
* is somebody else's and is skipped — we must never adopt a viewer's comment as our own.
|
|
128
|
+
* Paging is capped: a post with thousands of comments isn't worth walking, and our comment
|
|
129
|
+
* (posted by us, right after publishing) sits near the front.
|
|
130
|
+
*/
|
|
131
|
+
export async function listPageComments(objectId: string, pageId: string, token: string): Promise<PageComment[]> {
|
|
132
|
+
let url =
|
|
133
|
+
`${GRAPH}/${encodeURIComponent(objectId)}/comments` +
|
|
134
|
+
`?fields=id,message,created_time,from&order=chronological&limit=100&access_token=${encodeURIComponent(token)}`;
|
|
135
|
+
const found: PageComment[] = [];
|
|
136
|
+
|
|
137
|
+
for (let page = 0; page < 5 && url; page++) {
|
|
138
|
+
const res = await fetch(url);
|
|
139
|
+
const data = await readJson<GraphError & { data?: CommentNode[]; paging?: { next?: string } }>(res);
|
|
140
|
+
if (data.error) throw new FacebookError(fbMessage(data.error, res.status));
|
|
141
|
+
for (const c of data.data ?? []) {
|
|
142
|
+
if (c.from?.id !== pageId) continue;
|
|
143
|
+
found.push({ id: c.id, message: c.message ?? "", createdTime: c.created_time });
|
|
144
|
+
}
|
|
145
|
+
url = data.paging?.next ?? "";
|
|
146
|
+
}
|
|
147
|
+
return found;
|
|
148
|
+
}
|
|
149
|
+
|
|
110
150
|
/** Edit an existing Page comment in place. */
|
|
111
151
|
export async function updateComment(commentId: string, message: string, token: string): Promise<void> {
|
|
112
152
|
const form = new FormData();
|