@ossclip/core 0.1.31 → 0.1.34

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,132 @@
1
+ import { z } from "zod/v4";
2
+ import type { LlmProvider } from "./provider";
3
+ import { YOUTUBE_TRANSCRIPT_CHAR_CAP } from "./youtube";
4
+ import { truncateAtWordBoundary } from "../publish/captions";
5
+
6
+ /**
7
+ * Regenerate ONE network's caption from the editor's publish panel
8
+ * (handoff 2026-08-29 item 4). The prompt carries the transcript, the
9
+ * caption AS THE PANEL HOLDS IT (the user's manual edits included — the
10
+ * model must see what the user sees) and the user's correction, and returns
11
+ * replacement text only: nothing here writes to the pack or sends anything.
12
+ *
13
+ * Pure prompt builder separated from the provider call, the
14
+ * `buildYoutubePrompt` split, so the include/cap matrix is testable without
15
+ * an LLM.
16
+ */
17
+
18
+ export const CaptionRegenSchema = z.object({ caption: z.string() });
19
+
20
+ export interface CaptionRegenArgs {
21
+ /** The network the caption posts to ("linkedin", "x", …) — named in the
22
+ * prompt so the rewrite keeps that platform's idiom. */
23
+ network: string;
24
+ /** What the panel's box holds right now, manual edits and all. */
25
+ currentCaption: string;
26
+ /** The user's correction — the whole reason this call exists. */
27
+ instruction: string;
28
+ /** The transcript as plain text — the only source of factual claims. */
29
+ transcriptText: string;
30
+ /** The platform's caption cap (publish/captions.ts's captionCap). */
31
+ charCap: number;
32
+ }
33
+
34
+ /** What a truncated transcript ends with — the model must know it is reading
35
+ * an excerpt (buildYoutubePrompt's TRUNCATION_NOTE rule, restated because
36
+ * that constant is module-private and this note's wording is its own). */
37
+ const TRUNCATION_NOTE = "[transcript truncated — the video continues]";
38
+
39
+ /**
40
+ * Platform idiom the model is told to write toward — the author's own
41
+ * per-platform shapes (his voice guide), not generic social-media advice.
42
+ * Keyed by publish provider name; absence falls through to nothing extra.
43
+ */
44
+ const NETWORK_PRACTICES: Record<string, string> = {
45
+ linkedin:
46
+ "LinkedIn: the fullest version — the reader's gap first, then what was built, one " +
47
+ "technical detail worth defending, the honest limitation inline, warm low-key close. " +
48
+ "Short paragraphs, but never one-line 'broetry' stacked for the algorithm.",
49
+ "linkedin-page":
50
+ "LinkedIn page: same shape as a personal LinkedIn post — gap first, specifics over " +
51
+ "adjectives, honest limitation inline, no broetry.",
52
+ youtube:
53
+ "YouTube description: plain what-it-does in the first two lines (that is all most " +
54
+ "viewers see), keep any existing timestamps/chapters and links intact.",
55
+ facebook:
56
+ "Facebook: conversational, front-load the reader's problem in the first sentence, " +
57
+ "shorter than LinkedIn.",
58
+ instagram:
59
+ "Instagram: short — the visual leads, the caption carries ONE specific. Links do not " +
60
+ "work in captions, so 'link in bio' phrasing, never a raw URL. Keep existing hashtags " +
61
+ "unless instructed.",
62
+ threads:
63
+ "Threads: short and conversational like Instagram; one specific, no hashtag walls.",
64
+ x: "X: the gap and the one surprising detail — nothing padded.",
65
+ tiktok: "TikTok: short hook line plus existing hashtags.",
66
+ };
67
+
68
+ export function buildCaptionRegenPrompt(args: CaptionRegenArgs): { system: string; user: string } {
69
+ const system =
70
+ "You rewrite ONE social media caption for a finished video, applying the user's " +
71
+ "instruction. Hard rules:\n" +
72
+ "- Every factual claim in the caption must be supported by the transcript. If the video " +
73
+ "uses a number or story as an EXAMPLE or hypothetical, never state it as a fact — this " +
74
+ "exact failure has shipped: a video said \"imagine 50 teams applied\" as an example, and " +
75
+ "the published caption stated \"50 teams applied\" as fact.\n" +
76
+ "- NEVER use an em-dash (—) or en-dash (–) anywhere in the caption. Where a pause or " +
77
+ "soft pivot is needed, use an ellipsis (\"...\") — that is the author's voice, not a typo " +
78
+ "to clean up.\n" +
79
+ "- Specifics over adjectives: no \"powerful\", \"seamless\", \"game-changing\". Name the " +
80
+ "actual number or detail, and only numbers the transcript supports.\n" +
81
+ "- No engagement bait (\"thoughts?\", \"drop a comment\", \"who else...\"), no emoji " +
82
+ "bullets or rocket emoji; at most a single \":)\".\n" +
83
+ "- Respect the character cap given for this network — the platform truncates or rejects " +
84
+ "anything longer.\n" +
85
+ "- Keep the author's voice and structure from the current caption unless the instruction " +
86
+ "says otherwise: this is a correction, not a rewrite from scratch.\n" +
87
+ "- Output only the caption text, nothing else.";
88
+ const practice = NETWORK_PRACTICES[args.network];
89
+ // The same cap buildYoutubePrompt applies, imported rather than restated:
90
+ // slice + say so, so the model knows it is reading an excerpt.
91
+ const capped =
92
+ args.transcriptText.length > YOUTUBE_TRANSCRIPT_CHAR_CAP
93
+ ? `${args.transcriptText.slice(0, YOUTUBE_TRANSCRIPT_CHAR_CAP)}\n${TRUNCATION_NOTE}`
94
+ : args.transcriptText;
95
+ const user =
96
+ `Network: ${args.network} (character cap: ${args.charCap})\n` +
97
+ (practice ? `Platform practice: ${practice}\n` : "") +
98
+ "\n" +
99
+ `Current caption:\n${args.currentCaption}\n\n` +
100
+ `Instruction from the author:\n${args.instruction}\n\n` +
101
+ `Transcript:\n${capped}`;
102
+ return { system, user };
103
+ }
104
+
105
+ /**
106
+ * The em/en-dash ban enforced mechanically — the prompt asks, this
107
+ * guarantees. Ellipsis replaces a mid-sentence dash (the author's own pause
108
+ * idiom); a dash already followed by ellipsis-like punctuation just drops.
109
+ */
110
+ export function stripDashes(text: string): string {
111
+ return text.replace(/\s*[—–]\s*/g, "... ").replace(/\.\.\.\s+(?=[.…])/g, "");
112
+ }
113
+
114
+ /** One editorial call → the replacement caption, capped at a word boundary
115
+ * as the belt-and-braces backstop (the schema cannot express a length cap
116
+ * the model is guaranteed to honor). */
117
+ export async function generateCaptionRegen(
118
+ provider: LlmProvider,
119
+ args: CaptionRegenArgs,
120
+ ): Promise<string> {
121
+ const { system, user } = buildCaptionRegenPrompt(args);
122
+ const { caption } = await provider.complete({
123
+ system,
124
+ user,
125
+ schema: CaptionRegenSchema,
126
+ schemaName: "caption_regen",
127
+ // Editorial on purpose: this rewrites the copy real accounts publish,
128
+ // which is exactly the judgement tier the beat sheet buys.
129
+ tier: "editorial",
130
+ });
131
+ return truncateAtWordBoundary(stripDashes(caption), args.charCap);
132
+ }
@@ -27,6 +27,7 @@ export * from "./provider";
27
27
  export * from "./usage";
28
28
  export * from "./beats";
29
29
  export * from "./youtube";
30
+ export * from "./caption-regen";
30
31
  export * from "./scene-props";
31
32
  export * from "./repair";
32
33
  export { AnthropicProvider, DEFAULT_CLAUDE_MODEL } from "./anthropic";
@@ -30,7 +30,7 @@ import { cappedText } from "./beats";
30
30
  * changes the answer, so the Y2 pack cache key carries this (the §78
31
31
  * cache-key posture) — an old cached pack must not survive a new prompt.
32
32
  */
33
- export const YOUTUBE_PROMPT_VERSION = "v2";
33
+ export const YOUTUBE_PROMPT_VERSION = "v3";
34
34
 
35
35
  export const YoutubeChapterSchema = z.object({
36
36
  /** Output-timeline seconds — the produced video's clock, not the source's. */
@@ -72,6 +72,22 @@ export const YoutubePackSchema = z.object({
72
72
  linkedinPost: cappedText(1500).optional(),
73
73
  /** A short YouTube community post for existing subscribers. Optional. */
74
74
  communityPost: cappedText(400).optional(),
75
+ /**
76
+ * Ready-to-post captions for the other short-video platforms (prompt v3,
77
+ * 2026-08-26), written by the same call that already has the transcript
78
+ * and audience in context — a publish step that derived these from titles
79
+ * would ship title-spam as its ceiling. Every field optional: pre-v3
80
+ * approved packs must keep parsing verbatim forever, and `deriveCaption`
81
+ * (publish/captions.ts) fills any gap deterministically at publish time.
82
+ */
83
+ platformCaptions: z
84
+ .object({
85
+ instagram: cappedText(2200).optional(),
86
+ tiktok: cappedText(2200).optional(),
87
+ x: cappedText(280).optional(),
88
+ facebook: cappedText(2200).optional(),
89
+ })
90
+ .optional(),
75
91
  });
76
92
  export type YoutubePack = z.infer<typeof YoutubePackSchema>;
77
93
 
@@ -295,7 +311,13 @@ export function buildYoutubePrompt(args: YoutubePromptArgs): { system: string; u
295
311
  "- linkedinPost: a story-driven LinkedIn post about this video: short lines with line " +
296
312
  "breaks, a curiosity gap, no hashtag spam, ending by pointing to the link in the comments " +
297
313
  "(the LinkedIn convention for off-platform links).\n" +
298
- "- communityPost: a short, casual YouTube community post for existing subscribers.";
314
+ "- communityPost: a short, casual YouTube community post for existing subscribers.\n" +
315
+ "- platformCaptions: ready-to-post captions for the OTHER platforms this short goes to, " +
316
+ "each written for that platform's culture, not copies of each other: \"instagram\" — a " +
317
+ "hook line, short scannable lines, 3-5 hashtags at the end (max 2200 chars); \"tiktok\" — " +
318
+ "casual and direct, 2-4 hashtags (max 2200 chars); \"x\" — ONE punchy post, max 280 " +
319
+ "characters INCLUDING hashtags, no link (links go in a reply); \"facebook\" — " +
320
+ "conversational, a question or hook up front, minimal hashtags (max 2200 chars).";
299
321
  const capped =
300
322
  args.transcriptText.length > YOUTUBE_TRANSCRIPT_CHAR_CAP
301
323
  ? // Slice + say so: the model must know it is reading an excerpt, or it
@@ -430,5 +452,17 @@ export function formatYoutubeMarkdown(
430
452
  if (pack.hook60) lines.push("", "## First-60s hook strategy", "", pack.hook60.trimEnd());
431
453
  if (pack.linkedinPost) lines.push("", "## LinkedIn post", "", pack.linkedinPost.trimEnd());
432
454
  if (pack.communityPost) lines.push("", "## Community post", "", pack.communityPost.trimEnd());
455
+ const captions = pack.platformCaptions;
456
+ if (captions) {
457
+ const order = [
458
+ ["Instagram", captions.instagram],
459
+ ["TikTok", captions.tiktok],
460
+ ["X", captions.x],
461
+ ["Facebook", captions.facebook],
462
+ ] as const;
463
+ for (const [label, text] of order) {
464
+ if (text) lines.push("", `## ${label} caption`, "", text.trimEnd());
465
+ }
466
+ }
433
467
  return `${lines.join("\n")}\n`;
434
468
  }
@@ -0,0 +1,102 @@
1
+ import type { YoutubePack } from "../producer/youtube";
2
+
3
+ /**
4
+ * Per-platform caption resolution for `ossclip publish`.
5
+ *
6
+ * The pack is the author: prompt v3 writes `platformCaptions` (and v2 already
7
+ * wrote `linkedinPost`) with the transcript and audience in context. This
8
+ * module only PICKS from the pack — and, for a pre-v3 pack that never carried
9
+ * a platform's caption, derives one deterministically from the fields every
10
+ * pack has. No LLM call at publish time: publishing must work offline-from-LLM
11
+ * and produce the same caption every run.
12
+ */
13
+
14
+ /** Platform caption caps, by the provider identifier the backend reports. */
15
+ export const CAPTION_CAPS: Record<string, number> = {
16
+ x: 280,
17
+ linkedin: 1500,
18
+ // A company page is the same network with the same limit — Postiz reports
19
+ // it as its own provider (`linkedin-page`), so it needs its own entry or it
20
+ // silently takes DEFAULT_CAPTION_CAP (2026-08-27 live E2E).
21
+ "linkedin-page": 1500,
22
+ // Threads' own hard limit, well under the generic default it used to
23
+ // inherit (2026-08-28).
24
+ threads: 500,
25
+ instagram: 2200,
26
+ tiktok: 2200,
27
+ facebook: 2200,
28
+ youtube: 5000,
29
+ };
30
+
31
+ /** The cap for an unknown provider — the smallest common long-form cap. */
32
+ export const DEFAULT_CAPTION_CAP = 1500;
33
+
34
+ export function captionCap(provider: string): number {
35
+ return CAPTION_CAPS[provider] ?? DEFAULT_CAPTION_CAP;
36
+ }
37
+
38
+ /**
39
+ * Word-boundary truncation to `max`: never slice mid-word, drop the partial
40
+ * word instead. A caption a few words shorter beats one ending "communi".
41
+ */
42
+ export function truncateAtWordBoundary(text: string, max: number): string {
43
+ const trimmed = text.trim();
44
+ if (trimmed.length <= max) return trimmed;
45
+ const slice = trimmed.slice(0, max);
46
+ const lastSpace = slice.lastIndexOf(" ");
47
+ return (lastSpace > 0 ? slice.slice(0, lastSpace) : slice).trimEnd();
48
+ }
49
+
50
+ /**
51
+ * Fallback caption when the pack carries none for this provider: the first
52
+ * title (the strongest line the pack has) plus the hashtags, capped. This is
53
+ * deliberately the floor, not the ceiling — the prompt-v3 `platformCaptions`
54
+ * exist because title-plus-hashtags is what every paste-tool ships.
55
+ */
56
+ export function deriveCaption(pack: YoutubePack, provider: string): string {
57
+ const title = pack.titles[0] ?? "";
58
+ const hashtags = pack.hashtags.map((h) => (h.startsWith("#") ? h : `#${h}`)).join(" ");
59
+ const joined = hashtags.length > 0 ? `${title}\n\n${hashtags}` : title;
60
+ return truncateAtWordBoundary(joined, captionCap(provider));
61
+ }
62
+
63
+ /**
64
+ * The caption `publish` uses for a target: the pack's own field for that
65
+ * platform when present (still capped — an approved pack is user data, and
66
+ * user data gets validated, not trusted), else the derived fallback.
67
+ */
68
+ export function captionForProvider(pack: YoutubePack, provider: string): string {
69
+ const captions = pack.platformCaptions;
70
+ const authored =
71
+ // `linkedin-page` (a company page) reads the SAME authored field: same
72
+ // network, same idiom, same cap. Without this arm a page fell through to
73
+ // the title-plus-hashtags floor while the personal feed published the
74
+ // authored post — the shape the 2026-08-27 live E2E caught in its dry run.
75
+ // YouTube's caption is the DESCRIPTION box, and `description` is the one
76
+ // pack field written for exactly it (the title rides `settings.title`,
77
+ // set by `buildPublishPosts`). Without this arm a pack carrying a full
78
+ // description published the title-plus-hashtags floor — 94 characters of
79
+ // it — which the 2026-08-28 channel connection showed in its dry run.
80
+ provider === "youtube"
81
+ ? pack.description
82
+ : provider === "linkedin" || provider === "linkedin-page"
83
+ ? pack.linkedinPost
84
+ : // Threads has no field of its own, and this module never invents
85
+ // copy — so it borrows the closest idiom the pack DOES write. Same
86
+ // company, same audience, same short-video framing; the 500-char cap
87
+ // above trims it at a word boundary rather than letting Threads
88
+ // reject it (2026-08-28, a real connected account).
89
+ provider === "instagram" || provider === "threads"
90
+ ? captions?.instagram
91
+ : provider === "tiktok"
92
+ ? captions?.tiktok
93
+ : provider === "x"
94
+ ? captions?.x
95
+ : provider === "facebook"
96
+ ? captions?.facebook
97
+ : undefined;
98
+ if (authored !== undefined && authored.trim().length > 0) {
99
+ return truncateAtWordBoundary(authored, captionCap(provider));
100
+ }
101
+ return deriveCaption(pack, provider);
102
+ }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./provider";
2
+ export * from "./captions";
3
+ export * from "./delivery";
4
+ export * from "./limits";
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
+ }