@ossclip/core 0.1.33 → 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.
- package/package.json +1 -1
- package/src/config.ts +40 -10
- package/src/exec.ts +13 -2
- package/src/grounding.ts +35 -13
- package/src/index.ts +1 -0
- package/src/ingest.ts +1 -1
- package/src/producer/caption-regen.ts +132 -0
- package/src/producer/index.ts +1 -0
- package/src/publish/captions.ts +25 -2
- package/src/publish/delivery.ts +303 -0
- package/src/publish/index.ts +3 -0
- package/src/publish/limits.ts +52 -0
- package/src/publish/postiz.ts +113 -25
- package/src/publish/progress.ts +75 -0
- package/src/publish/provider.ts +24 -1
- package/src/resolution.ts +114 -0
- package/src/transcribe.ts +13 -0
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -152,6 +152,14 @@ export interface OssclipConfig {
|
|
|
152
152
|
* (`publishConfigured` in the CLI), never coerced.
|
|
153
153
|
*/
|
|
154
154
|
postizUrl?: string;
|
|
155
|
+
/**
|
|
156
|
+
* `--resolution`'s default for this machine: "auto" (keep what the source
|
|
157
|
+
* has, capped at 2160), "1080" (the built-in default), "1440" or "2160".
|
|
158
|
+
* File-only, the `watermark` posture: validated where it is USED
|
|
159
|
+
* (`resolveResolution` in produce.ts), so a hand-edited "4k" earns one
|
|
160
|
+
* warning and the 1080 default rather than a coerced render size.
|
|
161
|
+
*/
|
|
162
|
+
resolution?: string;
|
|
155
163
|
/**
|
|
156
164
|
* USD per million tokens, keyed by model id or family substring — overrides
|
|
157
165
|
* the built-in assumptions in `producer/usage.ts` so a run's cost line
|
|
@@ -218,22 +226,39 @@ export function loadConfig(): OssclipConfig {
|
|
|
218
226
|
} catch {
|
|
219
227
|
// no config file — fine
|
|
220
228
|
}
|
|
229
|
+
return resolveConfig(fileCfg, process.env);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* The pure half of `loadConfig` — the file-vs-env-vs-default resolution with
|
|
234
|
+
* no homedir read, so the mapping itself is testable (config.test.ts). Split
|
|
235
|
+
* out after the 2026-08-27 publish E2E: `postizUrl` sat on the TYPE and in
|
|
236
|
+
* the docs but this hand-written mapping never copied it, so publish reported
|
|
237
|
+
* "missing postizUrl" against a config.json that plainly had it — a key that
|
|
238
|
+
* exists only in the type is invisible at runtime, and nothing could say so
|
|
239
|
+
* while the mapping lived behind the filesystem.
|
|
240
|
+
*/
|
|
241
|
+
export function resolveConfig(
|
|
242
|
+
fileCfg: Partial<OssclipConfig>,
|
|
243
|
+
env: NodeJS.ProcessEnv,
|
|
244
|
+
): OssclipConfig {
|
|
221
245
|
return {
|
|
222
|
-
ffmpegPath:
|
|
223
|
-
ffprobePath:
|
|
224
|
-
whisperPath:
|
|
225
|
-
modelDir:
|
|
226
|
-
model:
|
|
227
|
-
fastModel:
|
|
246
|
+
ffmpegPath: env.OSSCLIP_FFMPEG ?? fileCfg.ffmpegPath ?? DEFAULTS.ffmpegPath,
|
|
247
|
+
ffprobePath: env.OSSCLIP_FFPROBE ?? fileCfg.ffprobePath ?? DEFAULTS.ffprobePath,
|
|
248
|
+
whisperPath: env.OSSCLIP_WHISPER ?? fileCfg.whisperPath ?? DEFAULTS.whisperPath,
|
|
249
|
+
modelDir: env.OSSCLIP_MODEL_DIR ?? fileCfg.modelDir ?? DEFAULTS.modelDir,
|
|
250
|
+
model: env.OSSCLIP_MODEL ?? fileCfg.model ?? DEFAULTS.model,
|
|
251
|
+
fastModel: env.OSSCLIP_FAST_MODEL ?? fileCfg.fastModel,
|
|
228
252
|
// File-only, the `dictionary` posture — and deliberately NO env spelling
|
|
229
253
|
// (flag + config are the whole interface): validated where it is USED
|
|
230
254
|
// (`resolveLlmEffort` in produce.ts), so a hand-edited `"max"` earns one
|
|
231
255
|
// warning there and agy's default, never a coerced effort.
|
|
232
256
|
llmEffort: fileCfg.llmEffort,
|
|
233
|
-
speaker:
|
|
234
|
-
openEditorAfterProduce: (
|
|
235
|
-
|
|
236
|
-
|
|
257
|
+
speaker: env.OSSCLIP_SPEAKER ?? fileCfg.speaker,
|
|
258
|
+
openEditorAfterProduce: (env.OSSCLIP_OPEN_EDITOR ?? fileCfg.openEditorAfterProduce) as
|
|
259
|
+
| OpenEditorPref
|
|
260
|
+
| undefined,
|
|
261
|
+
browserExecutable: env.OSSCLIP_BROWSER ?? fileCfg.browserExecutable,
|
|
237
262
|
// File-only, like `pricing`: an env spelling would arrive as a string,
|
|
238
263
|
// and "false" is truthy — parse-don't-coerce says no such trap. The
|
|
239
264
|
// strict `=== true` check lives at the consumer (produce's
|
|
@@ -272,5 +297,10 @@ export function loadConfig(): OssclipConfig {
|
|
|
272
297
|
thumbnailBrief: fileCfg.thumbnailBrief,
|
|
273
298
|
thumbnailModel: fileCfg.thumbnailModel,
|
|
274
299
|
pricing: fileCfg.pricing,
|
|
300
|
+
// File-only, non-secret by declaration (the field's own doc): the API key
|
|
301
|
+
// deliberately lives in the environment (publish.ts's
|
|
302
|
+
// `publishConfigured`), so this is only the instance URL.
|
|
303
|
+
postizUrl: fileCfg.postizUrl,
|
|
304
|
+
resolution: fileCfg.resolution,
|
|
275
305
|
};
|
|
276
306
|
}
|
package/src/exec.ts
CHANGED
|
@@ -12,13 +12,24 @@ export interface ExecResult {
|
|
|
12
12
|
export function run(
|
|
13
13
|
bin: string,
|
|
14
14
|
args: string[],
|
|
15
|
-
opts: {
|
|
15
|
+
opts: {
|
|
16
|
+
allowNonZero?: boolean;
|
|
17
|
+
stdin?: string;
|
|
18
|
+
/** Per-chunk stdout tap, IN ADDITION to collection — the delivery
|
|
19
|
+
* encode's `-progress pipe:1` stream needs live chunks, not the
|
|
20
|
+
* post-mortem transcript. */
|
|
21
|
+
onStdout?: (chunk: string) => void;
|
|
22
|
+
} = {},
|
|
16
23
|
): Promise<ExecResult> {
|
|
17
24
|
return new Promise((resolve, reject) => {
|
|
18
25
|
const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
19
26
|
let stdout = "";
|
|
20
27
|
let stderr = "";
|
|
21
|
-
child.stdout.on("data", (c: Buffer) =>
|
|
28
|
+
child.stdout.on("data", (c: Buffer) => {
|
|
29
|
+
const text = c.toString();
|
|
30
|
+
stdout += text;
|
|
31
|
+
opts.onStdout?.(text);
|
|
32
|
+
});
|
|
22
33
|
child.stderr.on("data", (c: Buffer) => (stderr += c.toString()));
|
|
23
34
|
child.on("error", (err) => reject(new Error(`${bin} failed to start: ${err.message}`)));
|
|
24
35
|
child.on("close", (code) => {
|
package/src/grounding.ts
CHANGED
|
@@ -102,6 +102,39 @@ function stringsOf(value: unknown): string[] {
|
|
|
102
102
|
return [];
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The tokens in `text` that the transcript nowhere supports, in text order,
|
|
107
|
+
* duplicates kept. This IS the grounding rule — `checkGrounding` walks scene
|
|
108
|
+
* fields through it, and the publish panel's caption regenerate runs it over
|
|
109
|
+
* a rewritten caption as an ADVISORY (captions legitimately contain brand and
|
|
110
|
+
* platform words never spoken, so its callers show notes, never a block).
|
|
111
|
+
* One spelling on purpose: the module header's §17 history is what a second
|
|
112
|
+
* copy of the supported() relaxation would eventually re-earn.
|
|
113
|
+
*
|
|
114
|
+
* The spoken set is rebuilt per call — cheap at real transcript sizes, and
|
|
115
|
+
* the price of keeping the rule callable on a single string.
|
|
116
|
+
*
|
|
117
|
+
* The transcript parameter demands only spoken text, which is all the rule
|
|
118
|
+
* reads: a full `Transcript` satisfies it, and so does the edit server's
|
|
119
|
+
* leniently-read transcript.json (words filtered to those with string text,
|
|
120
|
+
* timing not re-validated for a check that never looks at it).
|
|
121
|
+
*/
|
|
122
|
+
export function ungroundedTokens(
|
|
123
|
+
text: string,
|
|
124
|
+
transcript: { words: ReadonlyArray<{ text: string }> },
|
|
125
|
+
speaker?: string,
|
|
126
|
+
): string[] {
|
|
127
|
+
const spoken = new Set([
|
|
128
|
+
...transcript.words.flatMap((w) => tokenize(w.text)),
|
|
129
|
+
...(speaker ? tokenize(speaker) : []),
|
|
130
|
+
]);
|
|
131
|
+
const supported = (token: string): boolean =>
|
|
132
|
+
spoken.has(token) ||
|
|
133
|
+
spoken.has(`${token}s`) ||
|
|
134
|
+
(token.endsWith("s") && spoken.has(token.slice(0, -1)));
|
|
135
|
+
return tokenize(text).filter((token) => needsSupport(token) && !supported(token));
|
|
136
|
+
}
|
|
137
|
+
|
|
105
138
|
export function checkGrounding(
|
|
106
139
|
scenes: readonly Scene[],
|
|
107
140
|
transcript: Transcript,
|
|
@@ -113,25 +146,14 @@ export function checkGrounding(
|
|
|
113
146
|
*/
|
|
114
147
|
speaker?: string,
|
|
115
148
|
): GroundingIssue[] {
|
|
116
|
-
const spoken = new Set([
|
|
117
|
-
...transcript.words.flatMap((w) => tokenize(w.text)),
|
|
118
|
-
...(speaker ? tokenize(speaker) : []),
|
|
119
|
-
]);
|
|
120
|
-
const supported = (token: string): boolean =>
|
|
121
|
-
spoken.has(token) ||
|
|
122
|
-
spoken.has(`${token}s`) ||
|
|
123
|
-
(token.endsWith("s") && spoken.has(token.slice(0, -1)));
|
|
124
|
-
|
|
125
149
|
const issues: GroundingIssue[] = [];
|
|
126
150
|
for (const scene of scenes) {
|
|
127
151
|
const fields = CHECKED_FIELDS[scene.component] ?? [];
|
|
128
152
|
const merged = { ...scene.props, ...scene.overrides };
|
|
129
153
|
for (const field of fields) {
|
|
130
154
|
for (const text of stringsOf(merged[field])) {
|
|
131
|
-
for (const token of
|
|
132
|
-
|
|
133
|
-
issues.push({ sceneId: scene.id, component: scene.component, field, token });
|
|
134
|
-
}
|
|
155
|
+
for (const token of ungroundedTokens(text, transcript, speaker)) {
|
|
156
|
+
issues.push({ sceneId: scene.id, component: scene.component, field, token });
|
|
135
157
|
}
|
|
136
158
|
}
|
|
137
159
|
}
|
package/src/index.ts
CHANGED
package/src/ingest.ts
CHANGED
|
@@ -137,7 +137,7 @@ export interface MezzanineScale {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
/** Nearest even dimension — yuv420 chroma subsampling needs both axes even. */
|
|
140
|
-
function evenDim(v: number): number {
|
|
140
|
+
export function evenDim(v: number): number {
|
|
141
141
|
return Math.max(2, 2 * Math.round(v / 2));
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -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
|
+
}
|
package/src/producer/index.ts
CHANGED
|
@@ -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";
|
package/src/publish/captions.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
:
|
|
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
|
|
@@ -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
|
+
}
|
package/src/publish/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/publish/postiz.ts
CHANGED
|
@@ -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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
?
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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 ${
|
|
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
|
+
}
|
package/src/publish/provider.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How big the output actually renders (2026-08-27).
|
|
5
|
+
*
|
|
6
|
+
* ossclip rendered 1080×1920 unconditionally, and three separate stages
|
|
7
|
+
* enforced it: the folder-concat target, the mezzanine's scale filter, and
|
|
8
|
+
* the render. A 4K take therefore lost three quarters of its pixels before
|
|
9
|
+
* anything looked at it — invisible on LinkedIn/Instagram/TikTok, which cap
|
|
10
|
+
* at 1080p anyway, but real on YouTube, which keeps 4K and gives it a better
|
|
11
|
+
* codec tier.
|
|
12
|
+
*
|
|
13
|
+
* This is the ONE place that decides the size, so those three stages cannot
|
|
14
|
+
* disagree. It returns a SCALE FACTOR, not a stage to build from: the
|
|
15
|
+
* composition must stay 1080-wide because `captionFontSizeFor` (scenes/
|
|
16
|
+
* stage.ts) answers in ABSOLUTE px — 64 portrait, 44 landscape — so a
|
|
17
|
+
* composition built at 2160 would draw captions a quarter of their intended
|
|
18
|
+
* size. Remotion's own `scale` renders that same composition larger, fonts
|
|
19
|
+
* and strokes included.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** `--resolution`: an explicit short-edge height, or `auto` from the source. */
|
|
23
|
+
export const RESOLUTION_CHOICES = ["auto", "1080", "1440", "2160"] as const;
|
|
24
|
+
|
|
25
|
+
export type ResolutionChoice = (typeof RESOLUTION_CHOICES)[number];
|
|
26
|
+
|
|
27
|
+
/** The gate every user-supplied resolution passes through — flag AND config,
|
|
28
|
+
* so a hand-edited `"resolution": "4k"` earns the same refusal as a typo'd
|
|
29
|
+
* flag rather than a silent fallback (CLAUDE.md: parse, never coerce). */
|
|
30
|
+
export const ResolutionChoiceSchema = z.enum(RESOLUTION_CHOICES);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The ceiling `auto` will not cross, as a short-edge height. An 8K source
|
|
34
|
+
* answers 2160 rather than 4320: h264 at 8K is not universally playable, and
|
|
35
|
+
* the render cost grows with the pixel count.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_AUTO_HEIGHT = 2160;
|
|
38
|
+
|
|
39
|
+
/** The base short edge both frames share — the unit every choice divides by. */
|
|
40
|
+
const BASE_SHORT_EDGE = 1080;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `auto` snaps DOWN to a half step (1, 1.5, 2). Two reasons, both hard:
|
|
44
|
+
* h264 needs EVEN dimensions, and 1080/1920 times a half step is always even
|
|
45
|
+
* while an arbitrary factor is not (1.125 → 1215, odd); and rounding odd
|
|
46
|
+
* dimensions to even would drift the frame off 9:16, which the platforms
|
|
47
|
+
* letterbox. Snapping down rather than up keeps the promise that auto never
|
|
48
|
+
* invents detail the source does not have.
|
|
49
|
+
*/
|
|
50
|
+
const AUTO_STEP = 0.5;
|
|
51
|
+
|
|
52
|
+
export interface OutputFrame {
|
|
53
|
+
/** What Remotion renders the 1080-wide composition at. */
|
|
54
|
+
scale: number;
|
|
55
|
+
/** The resulting file's dimensions — what `production.json` records. */
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The clip a FOLDER input can honestly be sized by: the smallest.
|
|
62
|
+
*
|
|
63
|
+
* A folder concat letterboxes every take into one frame (`buildConcatFilter`),
|
|
64
|
+
* so the frame carries only what the weakest clip has — sizing by the largest
|
|
65
|
+
* would upscale every other take and charge render time for invented pixels.
|
|
66
|
+
* Clips that failed to probe are ignored rather than counted as zero, and a
|
|
67
|
+
* listing with nothing usable answers `null` so the caller falls back to its
|
|
68
|
+
* default instead of sizing a render off a guess.
|
|
69
|
+
*/
|
|
70
|
+
export function smallestSource(
|
|
71
|
+
sizes: ReadonlyArray<{ width: number; height: number }>,
|
|
72
|
+
): { width: number; height: number } | null {
|
|
73
|
+
const usable = sizes.filter((s) => s.width > 0 && s.height > 0);
|
|
74
|
+
if (usable.length === 0) return null;
|
|
75
|
+
return usable.reduce((min, s) => (s.width * s.height < min.width * min.height ? s : min));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveOutputFrame(args: {
|
|
79
|
+
frame: { width: number; height: number };
|
|
80
|
+
/** The source's DISPLAY dimensions (rotation already applied by the probe). */
|
|
81
|
+
source: { width: number; height: number };
|
|
82
|
+
resolution: ResolutionChoice;
|
|
83
|
+
}): OutputFrame {
|
|
84
|
+
const { frame, source, resolution } = args;
|
|
85
|
+
const at = (scale: number): OutputFrame => ({
|
|
86
|
+
scale,
|
|
87
|
+
width: Math.round(frame.width * scale),
|
|
88
|
+
height: Math.round(frame.height * scale),
|
|
89
|
+
});
|
|
90
|
+
if (resolution !== "auto") {
|
|
91
|
+
return at(Number(resolution) / BASE_SHORT_EDGE);
|
|
92
|
+
}
|
|
93
|
+
// A probe that answered nothing cannot size anything: today's 1080p is the
|
|
94
|
+
// honest fallback, never a throw in the middle of a render.
|
|
95
|
+
if (!(source.width > 0) || !(source.height > 0)) return at(1);
|
|
96
|
+
|
|
97
|
+
// The pixels that SURVIVE the crop, not the ones the file advertises. The
|
|
98
|
+
// source is fitted to the output's aspect and the overflow is cropped
|
|
99
|
+
// (produce's own `force_original_aspect_ratio=increase,crop=`), so the
|
|
100
|
+
// usable width is whichever edge binds.
|
|
101
|
+
const frameAspect = frame.width / frame.height;
|
|
102
|
+
const sourceAspect = source.width / source.height;
|
|
103
|
+
const usableWidth =
|
|
104
|
+
sourceAspect > frameAspect
|
|
105
|
+
? source.height * frameAspect // wider than the frame: sides are cropped
|
|
106
|
+
: source.width; // taller than the frame: top/bottom cropped
|
|
107
|
+
|
|
108
|
+
const raw = usableWidth / frame.width;
|
|
109
|
+
const snapped = Math.floor(raw / AUTO_STEP) * AUTO_STEP;
|
|
110
|
+
const capped = Math.min(snapped, MAX_AUTO_HEIGHT / BASE_SHORT_EDGE);
|
|
111
|
+
// Never below today's output: a 720p source still renders 1080p, which is
|
|
112
|
+
// what every caller already depends on.
|
|
113
|
+
return at(Math.max(capped, 1));
|
|
114
|
+
}
|
package/src/transcribe.ts
CHANGED
|
@@ -235,6 +235,16 @@ export interface WhisperOptions {
|
|
|
235
235
|
* silently dropping the user's terms.
|
|
236
236
|
*/
|
|
237
237
|
prompt?: string;
|
|
238
|
+
/**
|
|
239
|
+
* whisper's TRANSLATE task (`-tr`): decode non-English speech straight to
|
|
240
|
+
* ENGLISH text (2026-08-29). Orthogonal to `language`, which only says what
|
|
241
|
+
* is being SPOKEN — `-l ur` alone emits Urdu script, correct for Urdu
|
|
242
|
+
* captions and wrong for an English-captioned short. Pass both together:
|
|
243
|
+
* whisper still needs to know the source language to decode it well.
|
|
244
|
+
*
|
|
245
|
+
* Left unset, the spawned args stay byte-identical to every prior run.
|
|
246
|
+
*/
|
|
247
|
+
translate?: boolean;
|
|
238
248
|
}
|
|
239
249
|
|
|
240
250
|
/**
|
|
@@ -261,6 +271,9 @@ export function whisperArgs(opts: WhisperOptions, wavPath: string): string[] {
|
|
|
261
271
|
"--no-prints",
|
|
262
272
|
];
|
|
263
273
|
if (opts.language !== undefined) args.push("-l", opts.language);
|
|
274
|
+
// AFTER `-l`: whisper reads the pair as "this language, translated", and
|
|
275
|
+
// keeping the order fixed is what makes the arg list assertable.
|
|
276
|
+
if (opts.translate === true) args.push("-tr");
|
|
264
277
|
if (opts.prompt !== undefined) args.push("--prompt", opts.prompt);
|
|
265
278
|
return args;
|
|
266
279
|
}
|