@ossclip/core 0.1.33 → 0.1.35

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.
@@ -0,0 +1,303 @@
1
+ import { existsSync } from "node:fs";
2
+ import { rename, rm, stat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { run } from "../exec";
5
+ import { evenDim, probe, type IngestTools } from "../ingest";
6
+ import { parseFfmpegProgress, type FfmpegProgress } from "./progress";
7
+ import type { Probe } from "../schema";
8
+
9
+ /**
10
+ * Delivery encode for `ossclip publish` (2026-08-29 handoff, item 1).
11
+ *
12
+ * The first real multi-platform publish uploaded the MASTER render — 589MB at
13
+ * ~56 Mbps after `--resolution auto` kept the 4K source's pixels — and failed
14
+ * 5/6 channels. Every platform re-encodes to 6–12 Mbps on ingest, so master
15
+ * quality buys nothing but upload failures (Instagram 2207077, opaque
16
+ * Facebook/Threads errors, and enough bytes to make LinkedIn's ranged-GET
17
+ * issue fatal). The fix is a delivery encode: ≤1080p h264/aac at ~10 Mbps,
18
+ * built lazily at publish time and cached in the workdir. The master stays
19
+ * untouched for the archive.
20
+ */
21
+
22
+ /** Delivery caps: 1920×1080 landscape, 1080×1920 portrait. */
23
+ export const DELIVERY_MAX_SHORT_EDGE = 1080;
24
+ export const DELIVERY_MAX_LONG_EDGE = 1920;
25
+
26
+ /**
27
+ * 10 Mbps target, 12 Mbps ceiling — the top of the range platforms transcode
28
+ * to, so nothing visible is lost that the platform would have kept anyway.
29
+ * The same 12k ceiling doubles as the skip threshold: a master already at or
30
+ * under it gains nothing from a re-encode.
31
+ */
32
+ export const DELIVERY_VIDEO_BITRATE_KBPS = 10000;
33
+ export const DELIVERY_MAX_BITRATE_KBPS = 12000;
34
+
35
+ /** What encodeDelivery's `-b:a` always is — fitBitrateKbps must budget for it. */
36
+ export const DELIVERY_AUDIO_BITRATE_KBPS = 192;
37
+
38
+ /**
39
+ * Below ~1 Mbps, 1080p h264 is visibly broken — a size cap that forces the
40
+ * video bitrate under this floor is unattainable, and refusing the channel
41
+ * beats publishing mush the platform would host forever.
42
+ */
43
+ export const DELIVERY_MIN_VIDEO_BITRATE_KBPS = 1000;
44
+
45
+ /**
46
+ * mp4 container overhead margin (~3%) between raw stream bitrates and the
47
+ * bytes on disk. Checked against the field data (2026-08-29): a 2000k video +
48
+ * 192k audio encode of a 321s take landed at 88MB, i.e. within this margin of
49
+ * the naive stream sum — so budgeting streams at cap/1.03 keeps the file
50
+ * under the cap without giving away real bitrate.
51
+ */
52
+ const DELIVERY_MUX_OVERHEAD = 1.03;
53
+
54
+ /**
55
+ * The video bitrate (kbps, floored) that fits a delivery file under
56
+ * `capBytes`: total byte budget shrunk by the mux-overhead margin, minus the
57
+ * audio's share. May come out below the quality floor (or negative) for long
58
+ * videos — `deliveryEncodePlan` turns that into an explicit `unattainable`
59
+ * verdict rather than clamping.
60
+ */
61
+ export function fitBitrateKbps(
62
+ capBytes: number,
63
+ durationSec: number,
64
+ audioKbps: number = DELIVERY_AUDIO_BITRATE_KBPS,
65
+ ): number {
66
+ const totalKbps = (capBytes * 8) / DELIVERY_MUX_OVERHEAD / durationSec / 1000;
67
+ return Math.floor(totalKbps - audioKbps);
68
+ }
69
+
70
+ export interface DeliverySource {
71
+ width: number;
72
+ height: number;
73
+ fps: number;
74
+ /** Seconds, from probe. */
75
+ duration: number;
76
+ /** From stat — with duration this measures the real bitrate, no probe schema change needed. */
77
+ sizeBytes: number;
78
+ }
79
+
80
+ export interface DeliveryPlan {
81
+ width: number;
82
+ height: number;
83
+ videoBitrateKbps: number;
84
+ fileName: string;
85
+ }
86
+
87
+ /**
88
+ * The delivery file's name, which IS its cache key (mezzanine precedent,
89
+ * `mezzanineFileName`): the encode parameters live in the name so a rule
90
+ * change misses the old cache instead of silently serving it.
91
+ */
92
+ export function deliveryFileName(width: number, height: number, videoBitrateKbps: number): string {
93
+ return `delivery-${width}x${height}@${videoBitrateKbps}k.mp4`;
94
+ }
95
+
96
+ /**
97
+ * The verdict when a size cap cannot be met above the quality floor —
98
+ * distinct from null (no encode NEEDED) so a caller can refuse the channel
99
+ * with the number that doomed it. The verdict lives in the plan's return
100
+ * rather than a separate `sizeCapAttainable()` checker because the fit
101
+ * arithmetic would then exist twice and drift — a caller cannot plan and
102
+ * forget to check when the plan IS the check.
103
+ */
104
+ export interface DeliveryUnattainable {
105
+ unattainable: true;
106
+ /** The video kbps the cap would have needed — for the refusal message. */
107
+ fittedKbps: number;
108
+ }
109
+
110
+ /**
111
+ * What the delivery encode should be, or null when the master is already
112
+ * uploadable as-is (dims within caps AND measured bitrate ≤ the ceiling —
113
+ * masters are always h264/aac out of Remotion, so codec never enters the
114
+ * rule).
115
+ *
116
+ * Scale factor caps BOTH orientations without caring which one this is:
117
+ * min(1, 1080/short-edge, 1920/long-edge) lands landscape on 1920×1080 and
118
+ * portrait on 1080×1920, and never upscales — a small master re-encoded
119
+ * larger would soften every frame for zero bytes saved.
120
+ *
121
+ * `sizeCapBytes` is the per-platform upload ceiling (2026-08-29, live:
122
+ * Instagram's URL-fetch ingest rejected the 409MB 10 Mbps delivery file with
123
+ * 2207077 twice, then published the same 1080p take at 88MB/2 Mbps — see
124
+ * `PLATFORM_SIZE_CAP_BYTES`). When set, the bitrate is fitted under the cap;
125
+ * the null-skip additionally requires the master itself to fit, since an
126
+ * in-spec master can still be over a platform's byte ceiling.
127
+ */
128
+ export function deliveryEncodePlan(src: DeliverySource): DeliveryPlan | null;
129
+ export function deliveryEncodePlan(
130
+ src: DeliverySource,
131
+ opts: { sizeCapBytes?: number },
132
+ ): DeliveryPlan | DeliveryUnattainable | null;
133
+ export function deliveryEncodePlan(
134
+ src: DeliverySource,
135
+ opts: { sizeCapBytes?: number } = {},
136
+ ): DeliveryPlan | DeliveryUnattainable | null {
137
+ if (src.width <= 0 || src.height <= 0 || src.duration <= 0) return null;
138
+ const k = Math.min(
139
+ 1,
140
+ DELIVERY_MAX_SHORT_EDGE / Math.min(src.width, src.height),
141
+ DELIVERY_MAX_LONG_EDGE / Math.max(src.width, src.height),
142
+ );
143
+ const measuredKbps = (src.sizeBytes * 8) / src.duration / 1000;
144
+ const fitsCap = opts.sizeCapBytes === undefined || src.sizeBytes <= opts.sizeCapBytes;
145
+ if (k === 1 && measuredKbps <= DELIVERY_MAX_BITRATE_KBPS && fitsCap) return null;
146
+ // At k === 1 keep the exact source dims — even-rounding a size that is not
147
+ // being rescaled would manufacture a 1px no-op rescale (mezzanineScale
148
+ // learned the same lesson).
149
+ const width = k < 1 ? evenDim(src.width * k) : src.width;
150
+ const height = k < 1 ? evenDim(src.height * k) : src.height;
151
+ let videoBitrateKbps = DELIVERY_VIDEO_BITRATE_KBPS;
152
+ if (opts.sizeCapBytes !== undefined) {
153
+ const fitted = fitBitrateKbps(opts.sizeCapBytes, src.duration);
154
+ if (fitted < DELIVERY_MIN_VIDEO_BITRATE_KBPS) {
155
+ return { unattainable: true, fittedKbps: fitted };
156
+ }
157
+ videoBitrateKbps = Math.min(DELIVERY_VIDEO_BITRATE_KBPS, fitted);
158
+ }
159
+ return {
160
+ width,
161
+ height,
162
+ videoBitrateKbps,
163
+ fileName: deliveryFileName(width, height, videoBitrateKbps),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * Run the delivery encode. `+faststart` is load-bearing: it moves the moov
169
+ * atom up front, which is what makes platforms' progressive/ranged fetches
170
+ * work (LinkedIn's 206 consumer was the victim of a tail-moov master).
171
+ */
172
+ export async function encodeDelivery(
173
+ tools: IngestTools,
174
+ src: { path: string; width: number; height: number },
175
+ dest: string,
176
+ plan: DeliveryPlan,
177
+ opts: { onProgress?: (p: FfmpegProgress) => void } = {},
178
+ ): Promise<void> {
179
+ const scaling = plan.width !== src.width || plan.height !== src.height;
180
+ // ffmpeg's -progress stream vs. chunk boundaries: a data event can split a
181
+ // line mid-value ("out_time_us=12" + "345\n" parses as the wrong number),
182
+ // so only complete lines reach the parser and the tail carries over. The
183
+ // merged latest goes out per chunk — undefined never overwrites a value
184
+ // already seen.
185
+ let carry = "";
186
+ const latest: { outTimeSec?: number; speed?: number } = {};
187
+ const onStdout = (chunk: string): void => {
188
+ const text = carry + chunk;
189
+ const lastNewline = text.lastIndexOf("\n");
190
+ if (lastNewline < 0) {
191
+ carry = text;
192
+ return;
193
+ }
194
+ carry = text.slice(lastNewline + 1);
195
+ const parsed = parseFfmpegProgress(text.slice(0, lastNewline + 1));
196
+ if (parsed.outTimeSec === undefined && parsed.speed === undefined) return;
197
+ if (parsed.outTimeSec !== undefined) latest.outTimeSec = parsed.outTimeSec;
198
+ if (parsed.speed !== undefined) latest.speed = parsed.speed;
199
+ opts.onProgress?.({ ...latest });
200
+ };
201
+ // Encode to a sibling temp path, rename only on success (R27 §125): ffmpeg
202
+ // writes the container header as it goes, so an encode that dies mid-run
203
+ // leaves a valid-looking file, and the existence-keyed cache below would
204
+ // reuse that corpse forever.
205
+ const partial = `${dest}.partial.mp4`;
206
+ try {
207
+ await run(tools.ffmpegPath, [
208
+ "-y", "-i", src.path,
209
+ // Machine-readable progress on stdout, and -nostats so the human
210
+ // frame-counter doesn't spam stderr alongside it.
211
+ "-progress", "pipe:1", "-nostats",
212
+ ...(scaling ? ["-vf", `scale=${plan.width}:${plan.height}`] : []),
213
+ "-c:v", "libx264", "-preset", "medium", "-pix_fmt", "yuv420p",
214
+ "-b:v", `${plan.videoBitrateKbps}k`,
215
+ "-maxrate", `${DELIVERY_MAX_BITRATE_KBPS}k`, "-bufsize", "20000k",
216
+ // The audio rate fitBitrateKbps budgets for — one constant, no drift.
217
+ "-c:a", "aac", "-b:a", `${DELIVERY_AUDIO_BITRATE_KBPS}k`,
218
+ "-movflags", "+faststart",
219
+ partial,
220
+ ], { onStdout });
221
+ await rename(partial, dest);
222
+ } catch (err) {
223
+ await rm(partial, { force: true });
224
+ throw err;
225
+ }
226
+ }
227
+
228
+ export interface DeliveryResult {
229
+ /** The file to upload: the delivery encode, or the master when no encode is needed. */
230
+ path: string;
231
+ /** True when this call ran ffmpeg (vs. skip or cache hit). */
232
+ encoded: boolean;
233
+ /** The MASTER's probe — callers need its duration for the duration caps. */
234
+ probe: Probe;
235
+ }
236
+
237
+ /**
238
+ * The delivery file for a master, encoding it on first need and caching it in
239
+ * the workdir. A cache hit requires the delivery file to exist AND be no
240
+ * older than the master: a re-render writes the same master filename, so
241
+ * existence alone would silently publish the PREVIOUS render's delivery
242
+ * encode.
243
+ */
244
+ export async function ensureDeliveryFile(
245
+ tools: IngestTools,
246
+ workdir: string,
247
+ masterPath: string,
248
+ opts: {
249
+ onStart?: (fileName: string) => void;
250
+ /** Live encode progress (percent/ETA are the caller's arithmetic —
251
+ * both already hold the master's duration). Never fires on a skip or a
252
+ * cache hit, which is why the consumers keep a static fallback line. */
253
+ onProgress?: (p: FfmpegProgress) => void;
254
+ /**
255
+ * Per-platform upload ceiling (`PLATFORM_SIZE_CAP_BYTES`) — the bitrate
256
+ * fits under it, and the bitrate-bearing filename caches the capped
257
+ * variant BESIDE the default one (delivery-1920x1080@10000k.mp4 and
258
+ * @2106k.mp4 coexist), so a multi-platform publish encodes each at most
259
+ * once.
260
+ */
261
+ sizeCapBytes?: number;
262
+ } = {},
263
+ ): Promise<DeliveryResult> {
264
+ const [masterProbe, masterStat] = await Promise.all([probe(tools, masterPath), stat(masterPath)]);
265
+ const plan = deliveryEncodePlan(
266
+ {
267
+ width: masterProbe.width,
268
+ height: masterProbe.height,
269
+ fps: masterProbe.fps,
270
+ duration: masterProbe.duration,
271
+ sizeBytes: masterStat.size,
272
+ },
273
+ { sizeCapBytes: opts.sizeCapBytes },
274
+ );
275
+ if (!plan) return { path: masterPath, encoded: false, probe: masterProbe };
276
+ if ("unattainable" in plan) {
277
+ // A throw, not a silent fallback: falling back to the 10 Mbps file would
278
+ // re-run the exact 2207077 failure the cap exists to prevent. Callers
279
+ // that want to refuse the channel gracefully pre-check with the pure
280
+ // deliveryEncodePlan before spending an encode.
281
+ throw new Error(
282
+ `a ${opts.sizeCapBytes} byte cap needs ~${plan.fittedKbps} kbps for ` +
283
+ `${Math.round(masterProbe.duration)}s of video — under the ` +
284
+ `${DELIVERY_MIN_VIDEO_BITRATE_KBPS} kbps quality floor; the video is too long for this platform's size cap`,
285
+ );
286
+ }
287
+ const deliveryPath = join(workdir, plan.fileName);
288
+ if (existsSync(deliveryPath)) {
289
+ const deliveryStat = await stat(deliveryPath);
290
+ if (deliveryStat.mtimeMs >= masterStat.mtimeMs) {
291
+ return { path: deliveryPath, encoded: false, probe: masterProbe };
292
+ }
293
+ }
294
+ opts.onStart?.(plan.fileName);
295
+ await encodeDelivery(
296
+ tools,
297
+ { path: masterPath, width: masterProbe.width, height: masterProbe.height },
298
+ deliveryPath,
299
+ plan,
300
+ { onProgress: opts.onProgress },
301
+ );
302
+ return { path: deliveryPath, encoded: true, probe: masterProbe };
303
+ }
@@ -1,3 +1,6 @@
1
1
  export * from "./provider";
2
2
  export * from "./captions";
3
+ export * from "./delivery";
4
+ export * from "./limits";
3
5
  export * from "./postiz";
6
+ export * from "./progress";
@@ -0,0 +1,52 @@
1
+ import type { PublishTarget } from "./provider";
2
+
3
+ /**
4
+ * Per-platform video duration caps, by the provider identifier the backend
5
+ * reports (the `CAPTION_CAPS` shape, applied to duration). Only platforms
6
+ * with a cap under long-form appear; absence means unlimited — a wrong
7
+ * refusal is worse than a platform error, so an unknown provider is never
8
+ * capped (2026-08-29 handoff: the 5:20 take was doomed on Threads' 5:00 cap
9
+ * before a single byte uploaded).
10
+ */
11
+ export const PLATFORM_DURATION_CAPS_SEC: Record<string, number> = {
12
+ threads: 300,
13
+ tiktok: 600,
14
+ instagram: 900,
15
+ };
16
+
17
+ /**
18
+ * Per-platform upload size caps, in bytes, same shape and posture as the
19
+ * duration caps: absence means uncapped, because a wrong refusal is worse
20
+ * than a platform error. The Instagram number is empirical (2026-08-29,
21
+ * live): its URL-fetch ingest rejected the 409MB 10 Mbps delivery file with
22
+ * error 2207077 TWICE, then published the very same 1080p landscape take at
23
+ * 88MB (2 Mbps, same 192k audio) — the ceiling sits around 100MB, and 95MB
24
+ * leaves margin under it. LinkedIn took the 409MB file fine the same day, so
25
+ * capped platforms get their own smaller encode and everyone else keeps the
26
+ * 10 Mbps file.
27
+ */
28
+ export const PLATFORM_SIZE_CAP_BYTES: Record<string, number> = {
29
+ instagram: 95_000_000,
30
+ };
31
+
32
+ export interface DurationViolation {
33
+ target: PublishTarget;
34
+ capSec: number;
35
+ }
36
+
37
+ /**
38
+ * The targets this video is too long for. Semantics downstream: refuse the
39
+ * violating channels, publish the rest — the platform hard-fails an over-cap
40
+ * upload anyway, so there is no `--force` for duration.
41
+ */
42
+ export function checkDurationCaps(targets: PublishTarget[], durationSec: number): DurationViolation[] {
43
+ const violations: DurationViolation[] = [];
44
+ for (const target of targets) {
45
+ const capSec = PLATFORM_DURATION_CAPS_SEC[target.provider];
46
+ // Strictly over: a video exactly at the cap is what the cap permits.
47
+ if (capSec !== undefined && durationSec > capSec) {
48
+ violations.push({ target, capSec });
49
+ }
50
+ }
51
+ return violations;
52
+ }
@@ -68,26 +68,88 @@ export function parseIntegrations(json: unknown): PublishTarget[] {
68
68
  * sends the minimum and surfaces Postiz's errors verbatim rather than
69
69
  * duplicating (and drifting from) that matrix. YouTube is the one platform
70
70
  * whose settings carry a required title, so a post's `title` passes through.
71
+ *
72
+ * Media comes in two shapes because posts can carry per-post files now
73
+ * (2026-08-29: Instagram's size cap gets its own smaller encode, everyone
74
+ * else shares the default — `PublishPost.videoPath`): a single upload keeps
75
+ * the original one-file contract for callers with no per-post media (the
76
+ * CLI's dry-run placeholder included), while the map form pairs every
77
+ * distinct `videoPath` with its upload and REQUIRES the default path the
78
+ * lookup falls back to — the union makes forgetting it a type error, not a
79
+ * runtime surprise.
71
80
  */
72
- export function buildPostsPayload(args: {
73
- posts: PublishPost[];
74
- when: PublishWhen;
75
- dateIso: string;
76
- media: PostizUpload;
77
- }): Record<string, unknown> {
78
- const image = [{ id: args.media.id, path: args.media.path }];
81
+ export type PostsPayloadMedia =
82
+ | { media: PostizUpload; defaultVideoPath?: undefined }
83
+ | { media: ReadonlyMap<string, PostizUpload>; defaultVideoPath: string };
84
+
85
+ export function buildPostsPayload(
86
+ args: {
87
+ posts: PublishPost[];
88
+ when: PublishWhen;
89
+ dateIso: string;
90
+ } & PostsPayloadMedia,
91
+ ): Record<string, unknown> {
92
+ // instanceof on the property does not narrow the sibling `defaultVideoPath`
93
+ // through the union, so split the two shapes once up front.
94
+ const byPath = args.media instanceof Map ? (args.media as ReadonlyMap<string, PostizUpload>) : null;
95
+ const single = byPath === null ? (args.media as PostizUpload) : null;
96
+ const uploadFor = (p: PublishPost): PostizUpload => {
97
+ if (single !== null) {
98
+ // Single-upload shape: there is no path→upload pairing to consult, so a
99
+ // post asking for its own file would silently get the WRONG video —
100
+ // worse than any throw (2207077 at least fails; a LinkedIn post carrying
101
+ // the Instagram encode publishes).
102
+ if (p.videoPath !== undefined) {
103
+ throw new Error(
104
+ `post for ${p.target.provider} carries its own videoPath but buildPostsPayload got a single upload — pass the media map`,
105
+ );
106
+ }
107
+ return single;
108
+ }
109
+ const path = p.videoPath ?? args.defaultVideoPath;
110
+ const upload = path === undefined ? undefined : byPath!.get(path);
111
+ // publish() derives its upload set from the same `p.videoPath ??
112
+ // default` expression, so a miss is a caller bug — but see above: wrong
113
+ // video beats no video for worst outcome.
114
+ if (upload === undefined) throw new Error(`no upload for media path ${path}`);
115
+ return upload;
116
+ };
79
117
  return {
80
118
  type: args.when.kind === "now" ? "now" : "schedule",
81
119
  date: args.when.kind === "at" ? args.when.iso : args.dateIso,
82
120
  shortLink: false,
83
- posts: args.posts.map((p) => ({
84
- integration: { id: p.target.id },
85
- value: [{ content: p.caption, image }],
86
- settings: {
87
- __type: p.target.provider,
88
- ...(p.title !== undefined ? { title: p.title } : {}),
89
- },
90
- })),
121
+ // Required by /posts' DTO as a top-level array ("tags should not be null
122
+ // or undefined" — the 2026-08-27 live E2E's first real request bounced on
123
+ // it). Always empty: calendar tags are a Postiz-UI concept ossclip has no
124
+ // gesture for.
125
+ tags: [],
126
+ posts: args.posts.map((p) => {
127
+ const upload = uploadFor(p);
128
+ return {
129
+ integration: { id: p.target.id },
130
+ value: [{ content: p.caption, image: [{ id: upload.id, path: upload.path }] }],
131
+ settings: {
132
+ __type: p.target.provider,
133
+ ...(p.title !== undefined ? { title: p.title } : {}),
134
+ // YouTube's privacy status is REQUIRED by Postiz's DTO (`type`,
135
+ // @IsDefined) — without it the whole /posts call is rejected at
136
+ // validation and nothing publishes, not just the YouTube post
137
+ // (2026-08-28). `private` is the default on purpose: the other
138
+ // platforms post publicly, but an accidental `--all` must not push to
139
+ // a subscriber list, and making a private video public in YouTube
140
+ // Studio is one click where un-publishing is not.
141
+ ...(p.target.provider === "youtube"
142
+ ? { type: p.youtubePrivacy ?? "private" }
143
+ : {}),
144
+ // Instagram's own required setting (`post_type`, @IsDefined — the
145
+ // second one a real publish found, at the same cost: a 400 AFTER the
146
+ // whole video had uploaded). Always `post`: ossclip renders a
147
+ // finished short, and a story expires in 24 hours, which nobody
148
+ // publishing a produced video is asking for.
149
+ ...(p.target.provider === "instagram" ? { post_type: "post" } : {}),
150
+ },
151
+ };
152
+ }),
91
153
  };
92
154
  }
93
155
 
@@ -132,7 +194,14 @@ export class PostizHttpError extends Error {
132
194
  status === 401 || status === 403
133
195
  ? " — Postiz rejected the API key (Settings → Public API in your Postiz instance)"
134
196
  : status === 413
135
- ? " — the upload exceeds the Postiz instance's size limit"
197
+ ? // Usually NOT Postiz: a reverse proxy in front of it refuses the
198
+ // body first (Cloudflare's free plan caps a proxied upload at
199
+ // 100MB — a 171MB render bounced on it during the 2026-08-27 live
200
+ // E2E, with Cloudflare's own HTML as the "Postiz" answer). Name the
201
+ // proxy and the way through, since tuning Postiz would do nothing.
202
+ " — the upload was refused as too large. A reverse proxy in front of Postiz is the" +
203
+ " usual cause (Cloudflare's free plan caps proxied uploads at 100MB); point" +
204
+ " postizUrl at the instance directly (its LAN/VPN address) or raise the proxy's limit"
136
205
  : status === 429
137
206
  ? " — Postiz rate limit (90 posts/hour per self-hosted instance)"
138
207
  : "";
@@ -192,20 +261,38 @@ export function createPostizProvider(opts: PostizProviderOptions): PublishProvid
192
261
  return parseIntegrations(await request("GET", "/integrations"));
193
262
  },
194
263
  async publish(req: PublishRequest): Promise<PublishReceipt> {
195
- // openAsBlob streams the file into multipart form-data without ever
196
- // holding the whole video in memory — a rendered short is routinely
197
- // hundreds of MB, and a string/Buffer round-trip would double it.
198
264
  const { openAsBlob } = await import("node:fs");
199
- const blob = await openAsBlob(req.videoPath, { type: "video/mp4" });
200
- const form = new FormData();
201
- form.append("file", blob, basename(req.videoPath));
202
- const media = PostizUploadSchema.parse(await request("POST", "/upload", form));
265
+ // Each DISTINCT file uploads exactly once: a size-capped platform
266
+ // carries its own smaller encode (`PublishPost.videoPath`, 2026-08-29)
267
+ // while the rest share the request default, and posts then map to
268
+ // their own media in the payload. Sequential on purpose — uploads are
269
+ // hundreds of MB, and parallelism buys contention, not time.
270
+ const paths = [...new Set(req.posts.map((p) => p.videoPath ?? req.videoPath))];
271
+ const uploads = new Map<string, PostizUpload>();
272
+ for (const path of paths) {
273
+ // openAsBlob streams the file into multipart form-data without ever
274
+ // holding the whole video in memory — a rendered short is routinely
275
+ // hundreds of MB, and a string/Buffer round-trip would double it.
276
+ const blob = await openAsBlob(path, { type: "video/mp4" });
277
+ const form = new FormData();
278
+ form.append("file", blob, basename(path));
279
+ try {
280
+ uploads.set(path, PostizUploadSchema.parse(await request("POST", "/upload", form)));
281
+ } catch (err) {
282
+ // "POST /upload failed" alone no longer says WHICH file when
283
+ // several are in flight — name it.
284
+ throw new Error(
285
+ `${err instanceof Error ? err.message : String(err)}\n(while uploading ${path})`,
286
+ );
287
+ }
288
+ }
203
289
 
204
290
  const payload = buildPostsPayload({
205
291
  posts: req.posts,
206
292
  when: req.when,
207
293
  dateIso: new Date().toISOString(),
208
- media,
294
+ media: uploads,
295
+ defaultVideoPath: req.videoPath,
209
296
  });
210
297
  let answer: unknown;
211
298
  try {
@@ -215,9 +302,10 @@ export function createPostizProvider(opts: PostizProviderOptions): PublishProvid
215
302
  } catch (err) {
216
303
  // The media is already up — say so, so a retry is one request, not
217
304
  // a re-upload of the whole video.
305
+ const ids = [...uploads.values()].map((u) => u.id).join(", ");
218
306
  throw new Error(
219
307
  `${err instanceof Error ? err.message : String(err)}\n` +
220
- `(the video uploaded fine — media id ${media.id}; retrying will re-upload it)`,
308
+ `(the video uploaded fine — media id ${ids}; retrying will re-upload it)`,
221
309
  );
222
310
  }
223
311
  return {
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Encode progress for the delivery encode (2026-08-29): a real 5-minute video
3
+ * spends minutes in x264 with zero feedback — the CLI line and the editor
4
+ * panel both need percent + ETA, so the parsing lives here, pure, where both
5
+ * can reach it.
6
+ *
7
+ * ffmpeg's `-progress pipe:1` emits key=value lines on stdout roughly twice a
8
+ * second; this module turns those into { outTimeSec, speed } and the ETA
9
+ * arithmetic. No spawning here — encodeDelivery owns the ffmpeg call.
10
+ */
11
+
12
+ export interface FfmpegProgress {
13
+ /** How far into the OUTPUT the encode is, in seconds. */
14
+ outTimeSec?: number;
15
+ /** Encode speed as a multiple of realtime (`speed=1.53x` → 1.53). */
16
+ speed?: number;
17
+ }
18
+
19
+ /**
20
+ * Parse a chunk of ffmpeg's `-progress` key=value stream. Returns the LAST
21
+ * value seen per key in this chunk — the stream repeats the block every
22
+ * ~500ms, and only the newest matters. `N/A` values (the first block, before
23
+ * ffmpeg has numbers) are ignored, and unparseable text yields nothing rather
24
+ * than a guess. The caller keeps a running latest across chunks; feeding only
25
+ * complete lines is also the caller's job (a chunk boundary can split a line
26
+ * mid-value, and half a number parses as the wrong number).
27
+ */
28
+ export function parseFfmpegProgress(chunk: string): FfmpegProgress {
29
+ const out: FfmpegProgress = {};
30
+ for (const line of chunk.split(/\r?\n/)) {
31
+ const eq = line.indexOf("=");
32
+ if (eq < 0) continue;
33
+ const key = line.slice(0, eq).trim();
34
+ const value = line.slice(eq + 1).trim();
35
+ if (value.length === 0 || value === "N/A") continue;
36
+ // out_time_us preferred; out_time_ms is ALSO microseconds despite the
37
+ // name (long-standing ffmpeg quirk — trusting the name would report a
38
+ // 1000x-too-long encode), out_time is the HH:MM:SS.xx spelling.
39
+ if (key === "out_time_us" || key === "out_time_ms") {
40
+ const us = Number(value);
41
+ if (Number.isFinite(us) && us >= 0) out.outTimeSec = us / 1_000_000;
42
+ } else if (key === "out_time") {
43
+ const m = /^(\d+):(\d{1,2}):(\d{1,2}(?:\.\d+)?)$/.exec(value);
44
+ if (m !== null) {
45
+ out.outTimeSec = Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
46
+ }
47
+ } else if (key === "speed") {
48
+ const n = Number(value.replace(/x$/i, ""));
49
+ if (Number.isFinite(n) && n >= 0) out.speed = n;
50
+ }
51
+ }
52
+ return out;
53
+ }
54
+
55
+ /**
56
+ * Seconds of encode left: (duration − done) / speed. Null when speed ≤ 0 —
57
+ * a division by ffmpeg's warm-up `speed=0x` would print "Infinity left".
58
+ * Clamped at 0: out_time can overshoot the probed duration at the tail
59
+ * (muxer flush), and a negative ETA reads as nonsense.
60
+ */
61
+ export function encodeEta(durationSec: number, outTimeSec: number, speed: number): number | null {
62
+ if (speed <= 0) return null;
63
+ return Math.max(0, (durationSec - outTimeSec) / speed);
64
+ }
65
+
66
+ /**
67
+ * Seconds → "5:20". Lived in the CLI's publish.ts first (duration-cap
68
+ * messages); moved here so the progress lines on both sides of the wire spell
69
+ * time the same way — the CLI re-exports it, the panel keeps its own copy for
70
+ * the documented Vite-bundle reason.
71
+ */
72
+ export function formatMinSec(sec: number): string {
73
+ const whole = Math.round(sec);
74
+ return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
75
+ }
@@ -27,10 +27,33 @@ export interface PublishPost {
27
27
  * Optional — most don't.
28
28
  */
29
29
  title?: string;
30
+ /**
31
+ * YouTube's privacy status — REQUIRED by Postiz's own DTO (`type`,
32
+ * @IsDefined), so a YouTube post without it fails validation and takes the
33
+ * whole /posts call with it (2026-08-28). Optional here because only
34
+ * YouTube has the concept; `buildPostsPayload` supplies the default.
35
+ */
36
+ youtubePrivacy?: "public" | "unlisted" | "private";
37
+ /**
38
+ * This post's media, when it must differ from the request's default
39
+ * `videoPath`. Size-capped platforms are the reason it exists (2026-08-29,
40
+ * live: Instagram bounced the 409MB delivery file with 2207077 but
41
+ * published the 88MB re-encode — `PLATFORM_SIZE_CAP_BYTES`): they carry
42
+ * their own smaller encode while everyone else keeps the 10 Mbps file.
43
+ * Each distinct file uploads once; posts map to their media.
44
+ */
45
+ videoPath?: string;
30
46
  }
31
47
 
32
48
  export interface PublishRequest {
33
- /** Absolute path of the rendered video. */
49
+ /**
50
+ * Absolute path of the default media — the delivery encode, or the master
51
+ * when none is needed (`ensureDeliveryFile`). A post whose platform needs
52
+ * a different file sets its own `PublishPost.videoPath`; the provider
53
+ * uploads each distinct file once and maps posts to their media. Sending
54
+ * the MASTER to YouTube via that mechanism remains the noted follow-up
55
+ * (2026-08-29 plan).
56
+ */
34
57
  videoPath: string;
35
58
  posts: PublishPost[];
36
59
  when: PublishWhen;