@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,465 @@
1
+ import { z } from "zod/v4";
2
+ import type { Transcript } from "../schema";
3
+ import { SFX_MEME_TAG, type LoadedSfxSound } from "../sfx-pack";
4
+ import { cappedText, type BeatSheet } from "./beats";
5
+ // The scene-id formula, imported rather than restated: the ids this prompt
6
+ // offers must be the ones `generateScenes` actually mints (see
7
+ // `momentSceneId`).
8
+ import { momentSceneId } from "./scene-props";
9
+ import type { LlmProvider } from "./provider";
10
+
11
+ /**
12
+ * Call 3 — sound-effect placement (SFX plan, Approach A): a SEPARATE call
13
+ * after the beat sheet, with the graphics plan in context, so a whoosh can
14
+ * land on a graphic entrance. Mirrors beats.ts on purpose — schema, prompt
15
+ * builder, deterministic normalize pass, generate wrapper — because the
16
+ * deterministic pass, not the model, is what makes the output shippable.
17
+ *
18
+ * Nothing in this module touches the filesystem: the library arrives already
19
+ * loaded (`loadSfxLibrary`, the only fs toucher), so the prompt/normalize
20
+ * matrix is testable with a hand-written array of sounds.
21
+ */
22
+
23
+ export const SfxLevelSchema = z.enum(["subtle", "normal", "meme"]);
24
+ export type SfxLevel = z.infer<typeof SfxLevelSchema>;
25
+
26
+ /**
27
+ * One sound at one instant. A single `word` anchor and no `endWord`: an SFX
28
+ * is an instant, not a span — it plays for its own duration wherever it is
29
+ * triggered, so a range would be a number the renderer has to ignore.
30
+ */
31
+ export const SfxPlacementSchema = z.object({
32
+ soundId: z.string(),
33
+ word: z.number().int().nonnegative(),
34
+ /**
35
+ * The scene whose ENTRANCE this sound marks (field report, 2026-08-29).
36
+ *
37
+ * The failure it fixes: the model placed whooshes rationalised "as the
38
+ * TitleCard enters" but could only anchor them to WORDS, so the moment the
39
+ * user moved or trimmed that scene in the editor the graphic started
40
+ * somewhere else and the whoosh played over nothing. With the link stored,
41
+ * `resolveSfxCues` takes the scene's FINAL start — user timing overrides
42
+ * included — as the instant.
43
+ *
44
+ * `word` stays REQUIRED beside it, and that is the whole compatibility
45
+ * story: it is the speech-sync anchor for the sounds that have no scene,
46
+ * and the FALLBACK for the ones whose scene is deleted or re-planned away.
47
+ * A placement can therefore never be orphaned by a scene disappearing —
48
+ * `resolveSfxCues` reports "scene gone" and still fires it on its word.
49
+ * Optional, so every plan written before this field parses unchanged.
50
+ */
51
+ sceneId: z.string().optional(),
52
+ /** Per-placement trim, multiplied by the sound's own gain at render time. */
53
+ gain: z.number().min(0).max(2).optional(),
54
+ rationale: cappedText(120).optional(),
55
+ });
56
+ export type SfxPlacement = z.infer<typeof SfxPlacementSchema>;
57
+
58
+ /**
59
+ * 64 is a ceiling on the RESPONSE, not the policy: the density budget below
60
+ * is what actually decides how many survive. It exists so a runaway
61
+ * generation cannot hand the normalize pass thousands of entries.
62
+ */
63
+ export const SfxPlanSchema = z.object({
64
+ placements: z.array(SfxPlacementSchema).max(64),
65
+ });
66
+ export type SfxPlan = z.infer<typeof SfxPlanSchema>;
67
+
68
+ /**
69
+ * Bump whenever `SFX_SYSTEM`/`buildSfxUserPrompt` change what they ask for —
70
+ * the placement cache key carries it, the same §78 posture as
71
+ * PRODUCER_PROMPT_VERSION and YOUTUBE_PROMPT_VERSION. An old cached plan must
72
+ * not survive a new prompt.
73
+ */
74
+ export const SFX_PROMPT_VERSION = 2;
75
+
76
+ /** Placements per minute of runtime, by level. */
77
+ export const SFX_PER_MIN: Record<SfxLevel, number> = { subtle: 2, normal: 4, meme: 8 };
78
+
79
+ /**
80
+ * Floors, so a 20-second take is not silently sound-designed to zero. Same
81
+ * shape as the §29 short-take graphics floor: under a threshold, a COUNT
82
+ * beats a rate — a per-minute budget on a short take rounds to nothing, and
83
+ * short is exactly where the user asked for sound design.
84
+ */
85
+ export const SFX_MIN_PLACEMENTS: Record<SfxLevel, number> = { subtle: 1, normal: 2, meme: 3 };
86
+
87
+ /**
88
+ * Two effects inside 1.5s read as one glitch rather than two beats, and the
89
+ * tails overlap for most of the starter pack (durations run 0.2–1.6s).
90
+ * Enforced here rather than asked for, because spacing is arithmetic.
91
+ */
92
+ export const SFX_MIN_SPACING_SEC = 1.5;
93
+
94
+ /**
95
+ * Why a placement is not in the final plan.
96
+ *
97
+ * A zod enum rather than a bare TS union because these reasons come BACK from
98
+ * disk: produce caches the accounting beside the plan (`sfx-<key>.json`) so a
99
+ * cached re-run can print the same line, and a value read from a file is
100
+ * parsed, never coerced (CLAUDE.md). The last two are the RESOLVER's
101
+ * (`resolveSfxCues`), emitted long after planning — named here so
102
+ * `formatSfxAccounting`, shared by the console and report.txt, counts them
103
+ * alongside the planning drops:
104
+ * - "cut word": the anchor word was removed by the cut.
105
+ * - "missing file": the library still knows the sound, but its file is gone
106
+ * (a user pack deleted between planning and a re-render) — distinct from
107
+ * "unknown sound", which is an id the library no longer has at all.
108
+ *
109
+ * "scene gone" is the ODD ONE OUT and the name is kept honest below: it is an
110
+ * ISSUE, never a drop. A placement whose `sceneId` names no scene keeps its
111
+ * required `word` anchor and still fires — the sync intent is lost, the sound
112
+ * is not. It lives in this enum because it travels the same cached
113
+ * `SfxValidationIssue[]` to disk, and is deliberately absent from
114
+ * `DROP_REASONS` so `formatSfxAccounting` never counts it as a casualty.
115
+ */
116
+ export const SfxDropReasonSchema = z.enum([
117
+ "unknown sound",
118
+ "meme level",
119
+ "outside transcript",
120
+ "too close",
121
+ "over budget",
122
+ "invalid",
123
+ "cut word",
124
+ "missing file",
125
+ "scene gone",
126
+ ]);
127
+ export type SfxDropReason = z.infer<typeof SfxDropReasonSchema>;
128
+
129
+ /**
130
+ * Fixed print order, so the accounting line is stable run to run — and the
131
+ * BREAKDOWN vocabulary: a reason absent from this list costs no placement and
132
+ * so has no business in "N dropped: …". "scene gone" is the one such reason
133
+ * (see `SfxDropReasonSchema`); it still prints as its own warning line.
134
+ */
135
+ const DROP_REASONS: SfxDropReason[] = [
136
+ "cut word",
137
+ "missing file",
138
+ "unknown sound",
139
+ "meme level",
140
+ "outside transcript",
141
+ "too close",
142
+ "over budget",
143
+ "invalid",
144
+ ];
145
+
146
+ export const SfxValidationIssueSchema = z.object({
147
+ /** Index of the offending placement in the model's plan, or -1 plan-wide. */
148
+ placement: z.number().int(),
149
+ reason: SfxDropReasonSchema,
150
+ issue: z.string(),
151
+ });
152
+ export type SfxValidationIssue = z.infer<typeof SfxValidationIssueSchema>;
153
+
154
+ /**
155
+ * The sounds a level may use. Meme-tagged sounds are omitted from the menu
156
+ * entirely below `meme`, rather than asked-not-to-use: a sound the model
157
+ * cannot see is a sound it cannot pick, and `normalizeSfxPlan` re-checks
158
+ * anyway (a cached plan or a hallucinated id never passed through this menu).
159
+ */
160
+ export function eligibleSfxSounds(
161
+ sounds: readonly LoadedSfxSound[],
162
+ level: SfxLevel,
163
+ ): LoadedSfxSound[] {
164
+ if (level === "meme") return [...sounds];
165
+ return sounds.filter((s) => !s.tags.includes(SFX_MEME_TAG));
166
+ }
167
+
168
+ /**
169
+ * The scene ids a placement may anchor to: one per GRAPHIC moment, in the
170
+ * exact form `generateScenes` will mint (`momentSceneId`). Talking-head
171
+ * moments (`sceneKind === "none"`) become no scene at all, so an id for one
172
+ * would name nothing in production.json.
173
+ *
174
+ * ONE implementation, called by the prompt (which OFFERS the ids) and
175
+ * `normalizeSfxPlan` (which ENFORCES them) — `sfxBudget`'s rule: two copies
176
+ * is how the model gets shown a menu the deterministic pass then rejects.
177
+ */
178
+ export function sfxSceneIds(sheet: BeatSheet): string[] {
179
+ return sheet.moments.flatMap((m, i) => (m.sceneKind === "none" ? [] : [momentSceneId(i)]));
180
+ }
181
+
182
+ /** Runtime in seconds, measured like beats.ts: first word start → last word end. */
183
+ function transcriptRuntime(transcript: Transcript): number {
184
+ const words = transcript.words;
185
+ if (words.length === 0) return 0;
186
+ return Math.max(0, words[words.length - 1]!.end - words[0]!.start);
187
+ }
188
+
189
+ /**
190
+ * How many placements this take may carry. ONE implementation, called by both
191
+ * the prompt (which states the number) and the normalize pass (which enforces
192
+ * it) — two copies of this arithmetic is how the model gets asked for a
193
+ * budget the deterministic pass then contradicts (§154's two-copies lesson).
194
+ */
195
+ export function sfxBudget(
196
+ transcript: Transcript,
197
+ level: SfxLevel,
198
+ ): { max: number; runtimeSec: number } {
199
+ const runtimeSec = transcriptRuntime(transcript);
200
+ // The epsilon is beats.ts's float posture: a runtime that is 60s to the
201
+ // eye can be 59.999999s in the stamps, and the boundary case is exactly
202
+ // the one a user counts.
203
+ const byRate = Math.floor((SFX_PER_MIN[level] * runtimeSec) / 60 + 1e-6);
204
+ return { max: Math.max(SFX_MIN_PLACEMENTS[level], byRate), runtimeSec };
205
+ }
206
+
207
+ /** How much sound design each level wants, in the model's own terms. */
208
+ const LEVEL_GUIDANCE: Record<SfxLevel, string> = {
209
+ subtle:
210
+ "SUBTLE: sound design you notice only if you look for it. Transitions and the single " +
211
+ "biggest payoff, nothing more. When in doubt, place nothing.",
212
+ normal:
213
+ "NORMAL: tasteful punctuation. Graphic entrances, list items landing, the key takeaway. " +
214
+ "Silence is still the default — an effect earns its place or it is noise.",
215
+ meme:
216
+ "MEME: comedic timing is allowed and meme sounds are on the menu. Still one effect per " +
217
+ "beat, never a stack — a meme sound lands because the moment before it was quiet.",
218
+ };
219
+
220
+ export const SFX_SYSTEM = `You are the sound designer for a short video that has already been cut and storyboarded. You receive the sound library you may use, the graphics plan, and a word-indexed transcript.
221
+
222
+ Your job is PLACEMENT, not authorship: choose sounds FROM THE LIBRARY and anchor each one to the transcript word it should fire on.
223
+
224
+ Rules:
225
+ - Only ids from the library below. Never invent a sound.
226
+ - One anchor word per placement. The effect fires at that word and plays for its own length.
227
+ - Effects punctuate; they do not score. Long stretches with no effect are correct.
228
+ - Never two effects on top of each other — leave at least ${SFX_MIN_SPACING_SEC} seconds of speech between placements.
229
+ - Sync with the graphics plan where it helps: a whoosh on the word a graphic enters on reads as one gesture.
230
+ - When a sound marks a GRAPHIC APPEARING, also set "sceneId" to that moment's scene id (shown in brackets beside it). The effect then FOLLOWS that graphic if it is later moved or trimmed. Omit "sceneId" for speech-synced sounds — a sound that lands on what is being said is not a scene's sound. The anchor word is required either way.
231
+ - Match the sound to what is being SAID, using its "when to use" line. A confirmation sound on a failure is worse than silence.
232
+ - Respect the placement budget stated in the prompt. Fewer, better-placed effects beat hitting the number.`;
233
+
234
+ /**
235
+ * The user half of the placement call.
236
+ *
237
+ * The transcript numbering is beats.ts's `[i]word` shape (beats.ts:198-200),
238
+ * restated rather than imported because the two prompts must agree on what a
239
+ * "word index" means — every downstream index in this pipeline is a position
240
+ * in THIS list.
241
+ */
242
+ export function buildSfxUserPrompt(
243
+ transcript: Transcript,
244
+ sheet: BeatSheet,
245
+ sounds: readonly LoadedSfxSound[],
246
+ level: SfxLevel,
247
+ ): string {
248
+ const eligible = eligibleSfxSounds(sounds, level);
249
+ // The scene-registry menu shape (beats.ts:201-203): the library is a list of
250
+ // items with a whenToUse line, which is the format the producer prompt has
251
+ // already been tuned against.
252
+ const menu = eligible.map((s) => `- ${s.id}: ${s.whenToUse}`).join("\n");
253
+ const words = transcript.words.map((w, i) => `[${i}]${w.text}`).join(" ");
254
+ const { max, runtimeSec } = sfxBudget(transcript, level);
255
+ const moments = sheet.moments
256
+ .map(
257
+ (m, i) =>
258
+ `- words [${m.startWord}..${m.endWord}] ${m.sceneKind === "none" ? "talking head" : m.sceneKind}` +
259
+ // The scene id, shown only for GRAPHIC moments because only they mint
260
+ // a scene (`sfxSceneIds`): offering an id beside "talking head" is
261
+ // offering an id `normalizeSfxPlan` would strip straight back off.
262
+ (m.sceneKind === "none" ? "" : ` [${momentSceneId(i)}]: "${m.onScreenCopy}"`) +
263
+ ` — ${m.purpose}`,
264
+ )
265
+ .join("\n");
266
+ return (
267
+ `Level: ${level}\n${LEVEL_GUIDANCE[level]}\n\n` +
268
+ `Runtime: ${runtimeSec.toFixed(1)}s. Place AT MOST ${max} sound effects.\n\n` +
269
+ `Sound library (use these ids and nothing else):\n${menu}\n\n` +
270
+ // The graphics plan sits above the transcript for the same reason the
271
+ // framing brief does in beats.ts: the constraint is read before the
272
+ // content it constrains.
273
+ `Graphics plan (hook: "${sheet.hook}") — a graphic ENTERS at its first word, ` +
274
+ `and [scene-N] is the id to put in "sceneId" when a sound marks that entrance:\n${moments}\n\n` +
275
+ `Word-indexed transcript (word indices refer to THIS list):\n${words}`
276
+ );
277
+ }
278
+
279
+ /**
280
+ * Everything between the model and the render. Passes run in a fixed order,
281
+ * each dropping rather than repairing, because a placement is one sound at
282
+ * one word: there is nothing to salvage in a wrong one, unlike a beat-sheet
283
+ * moment whose end can be clamped back into range.
284
+ *
285
+ * Order matters: identity first (unknown id, meme gate), then position, then
286
+ * the relational passes (spacing, budget) which only make sense once the
287
+ * survivors are known and sorted.
288
+ *
289
+ * `sheet` is here for ONE pass — the scene link (`sceneId`) is checked against
290
+ * the ids the prompt actually offered (`sfxSceneIds`). Required rather than
291
+ * optional because every caller has the sheet in hand: an optional "no scene
292
+ * context" mode would silently pass hallucinated ids straight through to the
293
+ * resolver, which is the drift this gate exists to stop.
294
+ */
295
+ export function normalizeSfxPlan(
296
+ plan: SfxPlan,
297
+ transcript: Transcript,
298
+ sounds: readonly LoadedSfxSound[],
299
+ level: SfxLevel,
300
+ sheet: BeatSheet,
301
+ ): { plan: SfxPlan; issues: SfxValidationIssue[] } {
302
+ const issues: SfxValidationIssue[] = [];
303
+ const byId = new Map(sounds.map((s) => [s.id, s]));
304
+ // The ids the prompt offered, so an id the model invented (or one left over
305
+ // from a sheet that has since changed) is caught here rather than reaching
306
+ // the resolver as a scene that was never planned.
307
+ const knownScenes = new Set(sfxSceneIds(sheet));
308
+ const maxIndex = transcript.words.length - 1;
309
+ const drop = (placement: number, reason: SfxDropReason, issue: string): void => {
310
+ issues.push({ placement, reason, issue });
311
+ };
312
+
313
+ // Carries the model's own index so every issue names the placement the
314
+ // model wrote, not a position in some intermediate array.
315
+ let kept: Array<{ index: number; placement: SfxPlacement }> = [];
316
+ for (let i = 0; i < plan.placements.length; i++) {
317
+ const p = plan.placements[i]!;
318
+ const sound = byId.get(p.soundId);
319
+ if (!sound) {
320
+ drop(i, "unknown sound", `unknown soundId "${p.soundId}"`);
321
+ continue;
322
+ }
323
+ // Belt and braces over the menu gate in `buildSfxUserPrompt`: a plan
324
+ // restored from cache was built against a different level, and a model
325
+ // can name a sound it was never shown.
326
+ if (level !== "meme" && sound.tags.includes(SFX_MEME_TAG)) {
327
+ drop(i, "meme level", `"${p.soundId}" is meme-tagged; level is ${level}`);
328
+ continue;
329
+ }
330
+ // beats.ts:394's posture for the START anchor: out of range is a DROP,
331
+ // never a clamp. beats clamps `endWord` because the moment survives with
332
+ // a shorter span; an SFX anchor IS the placement, so clamping it would
333
+ // fire a sound on a word nobody chose.
334
+ if (!Number.isInteger(p.word) || p.word < 0 || p.word > maxIndex) {
335
+ drop(i, "outside transcript", `word ${p.word} beyond transcript (${Math.max(0, maxIndex)})`);
336
+ continue;
337
+ }
338
+ // The scene link, checked LAST because it is the only thing here that is
339
+ // not a drop: a `sceneId` naming no graphic moment is STRIPPED and the
340
+ // placement kept on its word. The word anchor is required precisely so a
341
+ // broken link costs the sync intent rather than the sound — the same
342
+ // fallback `resolveSfxCues` takes when a scene is gone by render time,
343
+ // and the reason this reason is not in `DROP_REASONS`.
344
+ let placement = p;
345
+ if (p.sceneId !== undefined && !knownScenes.has(p.sceneId)) {
346
+ const { sceneId: _unknown, ...withoutScene } = p;
347
+ placement = withoutScene;
348
+ issues.push({
349
+ placement: i,
350
+ reason: "scene gone",
351
+ issue:
352
+ `"${p.soundId}" names scene ${p.sceneId}, which this beat sheet has no graphic for — ` +
353
+ `keeping it on word ${p.word}`,
354
+ });
355
+ }
356
+ kept.push({ index: i, placement });
357
+ }
358
+
359
+ // Stable sort by anchor: two placements on the same word keep the model's
360
+ // order, so the spacing pass drops the LATER-written one deterministically.
361
+ kept.sort((a, b) => a.placement.word - b.placement.word || a.index - b.index);
362
+
363
+ const at = (p: SfxPlacement): number => transcript.words[p.word]!.start;
364
+ const spaced: typeof kept = [];
365
+ for (const entry of kept) {
366
+ const prev = spaced[spaced.length - 1];
367
+ if (prev && at(entry.placement) - at(prev.placement) < SFX_MIN_SPACING_SEC - 1e-6) {
368
+ drop(
369
+ entry.index,
370
+ "too close",
371
+ `"${entry.placement.soundId}" at word ${entry.placement.word} is ` +
372
+ `${(at(entry.placement) - at(prev.placement)).toFixed(2)}s after "${prev.placement.soundId}" ` +
373
+ `(min ${SFX_MIN_SPACING_SEC}s)`,
374
+ );
375
+ continue;
376
+ }
377
+ spaced.push(entry);
378
+ }
379
+
380
+ // Keep the EARLIEST N. Not "the best N": nothing here can rank placements,
381
+ // and dropping from the front would strip the hook — the one moment the
382
+ // whole grammar says must land.
383
+ const { max } = sfxBudget(transcript, level);
384
+ for (const over of spaced.slice(max)) {
385
+ drop(
386
+ over.index,
387
+ "over budget",
388
+ `"${over.placement.soundId}" at word ${over.placement.word} over the ${max} placement budget (level ${level})`,
389
+ );
390
+ }
391
+ kept = spaced.slice(0, max);
392
+
393
+ // Last gate, fail-soft per entry (the scene-props batch posture): callers
394
+ // hand us plans reloaded from a cache file as well as fresh model output,
395
+ // and one malformed entry must cost that entry only. It also applies the
396
+ // schema's defaults to whatever survives.
397
+ const placements: SfxPlacement[] = [];
398
+ for (const entry of kept) {
399
+ const parsed = SfxPlacementSchema.safeParse(entry.placement);
400
+ if (!parsed.success) {
401
+ drop(entry.index, "invalid", `placement failed validation: ${parsed.error.message}`);
402
+ continue;
403
+ }
404
+ placements.push(parsed.data);
405
+ }
406
+
407
+ return { plan: { placements }, issues };
408
+ }
409
+
410
+ /**
411
+ * The one-line SFX accounting, shared by the console and report.txt so the
412
+ * two can never say different things about the same run (the
413
+ * `formatGraphicsAccounting` contract, §118b).
414
+ *
415
+ * `issues` may include the resolver's "cut word" drops, which happen after
416
+ * planning — this formatter is the only place the two sets are counted
417
+ * together.
418
+ */
419
+ export function formatSfxAccounting(
420
+ placed: number,
421
+ planned: number,
422
+ level: SfxLevel,
423
+ issues: readonly SfxValidationIssue[],
424
+ ): string {
425
+ const counts = new Map<SfxDropReason, number>();
426
+ for (const i of issues) counts.set(i.reason, (counts.get(i.reason) ?? 0) + 1);
427
+ const breakdown = DROP_REASONS.filter((r) => counts.has(r))
428
+ .map((r) => `${counts.get(r)} ${r}`)
429
+ .join(", ");
430
+ const dropped = Math.max(0, planned - placed);
431
+ return (
432
+ `sfx: ${placed} of ${planned} planned placed (level ${level}` +
433
+ (dropped > 0 ? `, ${dropped} dropped${breakdown ? `: ${breakdown}` : ""}` : "") +
434
+ ")"
435
+ );
436
+ }
437
+
438
+ /**
439
+ * The placement call. `planned` is the count the MODEL returned, before the
440
+ * deterministic passes — what "of N planned" means in the accounting line.
441
+ */
442
+ export async function generateSfxPlan(
443
+ provider: LlmProvider,
444
+ transcript: Transcript,
445
+ sheet: BeatSheet,
446
+ sounds: readonly LoadedSfxSound[],
447
+ level: SfxLevel,
448
+ ): Promise<{ plan: SfxPlan; issues: SfxValidationIssue[]; planned: number }> {
449
+ const raw = await provider.complete({
450
+ system: SFX_SYSTEM,
451
+ user: buildSfxUserPrompt(transcript, sheet, sounds, level),
452
+ schema: SfxPlanSchema,
453
+ schemaName: "sfx_plan",
454
+ // Mechanical, not editorial (the §37 tiering rule): the editorial
455
+ // judgement — what this video is about, where its beats are — was already
456
+ // bought by the beat sheet, and this call picks from a fixed menu against
457
+ // it. `normalizeSfxPlan` is the real gate on the answer, so a small model
458
+ // getting it wrong costs a dropped placement, not a bad video.
459
+ tier: "mechanical",
460
+ });
461
+ return {
462
+ ...normalizeSfxPlan(raw, transcript, sounds, level, sheet),
463
+ planned: raw.placements.length,
464
+ };
465
+ }
@@ -15,6 +15,13 @@ import type { YoutubePack } from "../producer/youtube";
15
15
  export const CAPTION_CAPS: Record<string, number> = {
16
16
  x: 280,
17
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,
18
25
  instagram: 2200,
19
26
  tiktok: 2200,
20
27
  facebook: 2200,
@@ -61,9 +68,25 @@ export function deriveCaption(pack: YoutubePack, provider: string): string {
61
68
  export function captionForProvider(pack: YoutubePack, provider: string): string {
62
69
  const captions = pack.platformCaptions;
63
70
  const authored =
64
- provider === "linkedin"
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"
65
83
  ? pack.linkedinPost
66
- : provider === "instagram"
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"
67
90
  ? captions?.instagram
68
91
  : provider === "tiktok"
69
92
  ? captions?.tiktok