@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.
- package/package.json +1 -1
- package/src/browser.ts +19 -0
- package/src/captions.ts +101 -2
- package/src/config.ts +64 -10
- package/src/cover-in-video.ts +56 -0
- package/src/cutlist.ts +38 -1
- package/src/exec.ts +13 -2
- package/src/grounding.ts +35 -13
- package/src/index.ts +5 -0
- package/src/ingest.ts +35 -1
- package/src/kept-takes.ts +170 -0
- package/src/overrides.ts +468 -35
- package/src/producer/caption-regen.ts +132 -0
- package/src/producer/index.ts +1 -0
- package/src/producer/youtube.ts +36 -2
- package/src/publish/captions.ts +102 -0
- package/src/publish/delivery.ts +303 -0
- package/src/publish/index.ts +6 -0
- package/src/publish/limits.ts +52 -0
- package/src/publish/postiz.ts +320 -0
- package/src/publish/progress.ts +75 -0
- package/src/publish/provider.ts +81 -0
- package/src/recut.ts +20 -4
- package/src/resolution.ts +114 -0
- package/src/restamp.ts +383 -0
- package/src/retime-preview.ts +166 -60
- package/src/scene-schema.ts +13 -0
- package/src/transcribe.ts +13 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import type {
|
|
4
|
+
PublishPost,
|
|
5
|
+
PublishProvider,
|
|
6
|
+
PublishReceipt,
|
|
7
|
+
PublishRequest,
|
|
8
|
+
PublishTarget,
|
|
9
|
+
PublishWhen,
|
|
10
|
+
} from "./provider";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The Postiz implementation of `PublishProvider` — a self-hosted Postiz
|
|
14
|
+
* instance (https://github.com/gitroomhq/postiz-app) the USER runs and has
|
|
15
|
+
* connected their social accounts to; ossclip only speaks its public HTTP
|
|
16
|
+
* API (https://docs.postiz.com/public-api) and vendors nothing (Postiz is
|
|
17
|
+
* AGPL; calling an API is not derivation).
|
|
18
|
+
*
|
|
19
|
+
* Contract, per those docs: base `{url}/api/public/v1`, `Authorization:
|
|
20
|
+
* <apiKey>` verbatim (NOT `Bearer <apiKey>`), `POST /upload` (multipart →
|
|
21
|
+
* `{id, path}`), `GET /integrations`, `POST /posts` with
|
|
22
|
+
* `{type, date, posts: [{integration:{id}, value:[{content, image:[...]}],
|
|
23
|
+
* settings:{__type: <provider>}}]}`. Rate limit 90 posts/hr self-hosted.
|
|
24
|
+
*
|
|
25
|
+
* Error posture is the OPPOSITE of telemetry.ts: a publish is the user's
|
|
26
|
+
* explicit action on their own content, so every non-2xx throws with the
|
|
27
|
+
* method, path, status and a body snippet — no retries, no swallowing, no
|
|
28
|
+
* partial state pretending to be success.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** `GET /integrations` — only the fields ossclip reads; unknown keys drop. */
|
|
32
|
+
export const PostizIntegrationSchema = z.object({
|
|
33
|
+
id: z.string(),
|
|
34
|
+
name: z.string(),
|
|
35
|
+
/** The platform identifier ("linkedin", "x", "instagram", ...). */
|
|
36
|
+
identifier: z.string(),
|
|
37
|
+
disabled: z.boolean().optional(),
|
|
38
|
+
});
|
|
39
|
+
export const PostizIntegrationsSchema = z.array(PostizIntegrationSchema);
|
|
40
|
+
|
|
41
|
+
/** `POST /upload` — the media reference `/posts` embeds. */
|
|
42
|
+
export const PostizUploadSchema = z.object({
|
|
43
|
+
id: z.string(),
|
|
44
|
+
path: z.string(),
|
|
45
|
+
});
|
|
46
|
+
export type PostizUpload = z.infer<typeof PostizUploadSchema>;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Connected accounts → publish targets. Disabled integrations are dropped
|
|
50
|
+
* here, not at selection time — an account Postiz itself won't post to must
|
|
51
|
+
* never appear in a picker.
|
|
52
|
+
*/
|
|
53
|
+
export function parseIntegrations(json: unknown): PublishTarget[] {
|
|
54
|
+
const parsed = PostizIntegrationsSchema.parse(json);
|
|
55
|
+
return parsed
|
|
56
|
+
.filter((i) => i.disabled !== true)
|
|
57
|
+
.map((i) => ({ id: i.id, provider: i.identifier, name: i.name }));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `/posts` accepts one request for MANY integrations — one unit against the
|
|
62
|
+
* 90/hr limit, and atomic from ossclip's side. Pure: the caller passes the
|
|
63
|
+
* already-uploaded media ref and the date, so the exact payload is testable
|
|
64
|
+
* (and `--dry-run` printable) without a network or a clock.
|
|
65
|
+
*
|
|
66
|
+
* `settings.__type` must name the integration's platform; beyond that the
|
|
67
|
+
* per-provider settings surface is Postiz's own validation domain — ossclip
|
|
68
|
+
* sends the minimum and surfaces Postiz's errors verbatim rather than
|
|
69
|
+
* duplicating (and drifting from) that matrix. YouTube is the one platform
|
|
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.
|
|
80
|
+
*/
|
|
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
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
type: args.when.kind === "now" ? "now" : "schedule",
|
|
119
|
+
date: args.when.kind === "at" ? args.when.iso : args.dateIso,
|
|
120
|
+
shortLink: false,
|
|
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
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Post ids out of whatever shape `/posts` answers with. Lenient BY DESIGN,
|
|
158
|
+
* unlike every other parse here: the 2xx status is the success signal, the
|
|
159
|
+
* ids are a convenience for the receipt, and a Postiz version that renames
|
|
160
|
+
* this envelope must not turn an accepted publish into a thrown "failure"
|
|
161
|
+
* after the posts already went out.
|
|
162
|
+
*/
|
|
163
|
+
export function extractPostIds(json: unknown): string[] {
|
|
164
|
+
const items = Array.isArray(json)
|
|
165
|
+
? json
|
|
166
|
+
: typeof json === "object" && json !== null && Array.isArray((json as { posts?: unknown }).posts)
|
|
167
|
+
? ((json as { posts: unknown[] }).posts)
|
|
168
|
+
: [json];
|
|
169
|
+
const ids: string[] = [];
|
|
170
|
+
for (const item of items) {
|
|
171
|
+
if (typeof item === "object" && item !== null) {
|
|
172
|
+
const id = (item as { id?: unknown; postId?: unknown }).id ?? (item as { postId?: unknown }).postId;
|
|
173
|
+
if (typeof id === "string") ids.push(id);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return ids;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `postizUrl` as the API base: trailing slashes dropped, `/api/public/v1`
|
|
180
|
+
* appended unless the user already wrote it. */
|
|
181
|
+
export function postizApiBase(url: string): string {
|
|
182
|
+
const trimmed = url.replace(/\/+$/, "");
|
|
183
|
+
return trimmed.endsWith("/api/public/v1") ? trimmed : `${trimmed}/api/public/v1`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export class PostizHttpError extends Error {
|
|
187
|
+
constructor(
|
|
188
|
+
readonly method: string,
|
|
189
|
+
readonly path: string,
|
|
190
|
+
readonly status: number,
|
|
191
|
+
bodySnippet: string,
|
|
192
|
+
) {
|
|
193
|
+
const hint =
|
|
194
|
+
status === 401 || status === 403
|
|
195
|
+
? " — Postiz rejected the API key (Settings → Public API in your Postiz instance)"
|
|
196
|
+
: status === 413
|
|
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"
|
|
205
|
+
: status === 429
|
|
206
|
+
? " — Postiz rate limit (90 posts/hour per self-hosted instance)"
|
|
207
|
+
: "";
|
|
208
|
+
super(`Postiz ${method} ${path} failed: ${status}${hint}${bodySnippet ? `\n${bodySnippet}` : ""}`);
|
|
209
|
+
this.name = "PostizHttpError";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface PostizProviderOptions {
|
|
214
|
+
baseUrl: string;
|
|
215
|
+
apiKey: string;
|
|
216
|
+
fetchImpl?: typeof fetch;
|
|
217
|
+
/** Per-request cap. Uploads carry whole videos — default is generous. */
|
|
218
|
+
timeoutMs?: number;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
222
|
+
const BODY_SNIPPET_CHARS = 300;
|
|
223
|
+
|
|
224
|
+
export function createPostizProvider(opts: PostizProviderOptions): PublishProvider {
|
|
225
|
+
const base = postizApiBase(opts.baseUrl);
|
|
226
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
227
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
228
|
+
|
|
229
|
+
const request = async (method: string, path: string, body?: BodyInit, headers?: Record<string, string>): Promise<unknown> => {
|
|
230
|
+
const ac = new AbortController();
|
|
231
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
232
|
+
let res: Response;
|
|
233
|
+
try {
|
|
234
|
+
res = await fetchImpl(`${base}${path}`, {
|
|
235
|
+
method,
|
|
236
|
+
headers: { Authorization: opts.apiKey, ...headers },
|
|
237
|
+
body,
|
|
238
|
+
signal: ac.signal,
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Postiz ${method} ${path} unreachable at ${base}: ${err instanceof Error ? err.message : String(err)}`,
|
|
243
|
+
);
|
|
244
|
+
} finally {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
}
|
|
247
|
+
const text = await res.text();
|
|
248
|
+
if (!res.ok) {
|
|
249
|
+
throw new PostizHttpError(method, path, res.status, text.slice(0, BODY_SNIPPET_CHARS));
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
return text.length > 0 ? JSON.parse(text) : null;
|
|
253
|
+
} catch {
|
|
254
|
+
throw new Error(`Postiz ${method} ${path} answered non-JSON: ${text.slice(0, BODY_SNIPPET_CHARS)}`);
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
name: "postiz",
|
|
260
|
+
async listTargets(): Promise<PublishTarget[]> {
|
|
261
|
+
return parseIntegrations(await request("GET", "/integrations"));
|
|
262
|
+
},
|
|
263
|
+
async publish(req: PublishRequest): Promise<PublishReceipt> {
|
|
264
|
+
const { openAsBlob } = await import("node:fs");
|
|
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
|
+
}
|
|
289
|
+
|
|
290
|
+
const payload = buildPostsPayload({
|
|
291
|
+
posts: req.posts,
|
|
292
|
+
when: req.when,
|
|
293
|
+
dateIso: new Date().toISOString(),
|
|
294
|
+
media: uploads,
|
|
295
|
+
defaultVideoPath: req.videoPath,
|
|
296
|
+
});
|
|
297
|
+
let answer: unknown;
|
|
298
|
+
try {
|
|
299
|
+
answer = await request("POST", "/posts", JSON.stringify(payload), {
|
|
300
|
+
"content-type": "application/json",
|
|
301
|
+
});
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// The media is already up — say so, so a retry is one request, not
|
|
304
|
+
// a re-upload of the whole video.
|
|
305
|
+
const ids = [...uploads.values()].map((u) => u.id).join(", ");
|
|
306
|
+
throw new Error(
|
|
307
|
+
`${err instanceof Error ? err.message : String(err)}\n` +
|
|
308
|
+
`(the video uploaded fine — media id ${ids}; retrying will re-upload it)`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
backend: "postiz",
|
|
313
|
+
postIds: extractPostIds(answer),
|
|
314
|
+
publishedAt: new Date().toISOString(),
|
|
315
|
+
when: req.when,
|
|
316
|
+
targets: req.posts.map((p) => p.target),
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one seam between ossclip and any social-publishing backend, mirroring
|
|
3
|
+
* `producer/provider.ts`: no backend types leak past this interface (the
|
|
4
|
+
* PHASE1 §4 posture, applied to publishing). One implementation today —
|
|
5
|
+
* Postiz (`postiz.ts`) — but the CLI, the edit server and the tests all
|
|
6
|
+
* speak this shape, so a direct per-platform adapter later is a new file,
|
|
7
|
+
* not a rewrite.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** One connected account at the backend — an "integration" in Postiz terms. */
|
|
11
|
+
export interface PublishTarget {
|
|
12
|
+
/** The backend's own id for the connected account. */
|
|
13
|
+
id: string;
|
|
14
|
+
/** The platform identifier the backend reports, e.g. "linkedin", "x". */
|
|
15
|
+
provider: string;
|
|
16
|
+
/** Human-readable account name, for pickers and receipts. */
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type PublishWhen = { kind: "now" } | { kind: "at"; iso: string };
|
|
21
|
+
|
|
22
|
+
export interface PublishPost {
|
|
23
|
+
target: PublishTarget;
|
|
24
|
+
caption: string;
|
|
25
|
+
/**
|
|
26
|
+
* Some platforms carry a title separate from the caption (YouTube).
|
|
27
|
+
* Optional — most don't.
|
|
28
|
+
*/
|
|
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;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PublishRequest {
|
|
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
|
+
*/
|
|
57
|
+
videoPath: string;
|
|
58
|
+
posts: PublishPost[];
|
|
59
|
+
when: PublishWhen;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* What `publish()` returns AND what `<workdir>/publish-receipt.json` holds —
|
|
64
|
+
* the double-post guard reads this file, so it records enough to tell the
|
|
65
|
+
* user what already went out, and when.
|
|
66
|
+
*/
|
|
67
|
+
export interface PublishReceipt {
|
|
68
|
+
backend: string;
|
|
69
|
+
/** Backend post ids, when the backend reports them; may be empty. */
|
|
70
|
+
postIds: string[];
|
|
71
|
+
/** ISO time the publish request was accepted (not the scheduled time). */
|
|
72
|
+
publishedAt: string;
|
|
73
|
+
when: PublishWhen;
|
|
74
|
+
targets: PublishTarget[];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PublishProvider {
|
|
78
|
+
readonly name: string;
|
|
79
|
+
listTargets(): Promise<PublishTarget[]>;
|
|
80
|
+
publish(req: PublishRequest): Promise<PublishReceipt>;
|
|
81
|
+
}
|
package/src/recut.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SPLIT_MIN_PIECE_SEC, type OverrideDoc } from "./overrides";
|
|
1
|
+
import { isSrcTiming, SPLIT_MIN_PIECE_SEC, type OverrideDoc } from "./overrides";
|
|
2
2
|
import type { Segment } from "./schema";
|
|
3
3
|
import { mapsClose, TimeMap } from "./timemap";
|
|
4
4
|
|
|
@@ -65,8 +65,16 @@ export function remapOverridesThroughRecut(
|
|
|
65
65
|
// (§137, `SplitSchema`) — re-deriving it here is what renamed the half and
|
|
66
66
|
// orphaned the overrides on it.
|
|
67
67
|
const splits = doc.splits.map((s) => {
|
|
68
|
+
// A src-anchored split passes through UNTOUCHED — the `doc.cuts`
|
|
69
|
+
// non-remap rule below, for the same reason `cleanup.kept` needs no
|
|
70
|
+
// entry here at all: source time is stable across every re-cut, and
|
|
71
|
+
// `resolveSplitPoints` re-derives the output instant fresh at each
|
|
72
|
+
// application. Only the src-less legacy shape still carries an
|
|
73
|
+
// old-clock `at` worth moving.
|
|
74
|
+
if (s.src !== undefined) return s;
|
|
68
75
|
const before = reports.length;
|
|
69
|
-
const
|
|
76
|
+
const atBefore = s.at!;
|
|
77
|
+
const at = remapPoint(`split "${s.id}"`, atBefore, oldMap, newMap, reports);
|
|
70
78
|
// `splitCues` needs a cue with `at >= startSec + SPLIT_MIN_PIECE_SEC` AND
|
|
71
79
|
// `at <= endSec - SPLIT_MIN_PIECE_SEC`. Output time runs [0,
|
|
72
80
|
// outputDuration] and every cue lives inside it, so a split closer than
|
|
@@ -101,7 +109,7 @@ export function remapOverridesThroughRecut(
|
|
|
101
109
|
// a cut edge; restating it here would read as a second, separate move
|
|
102
110
|
// rather than the consequence of the one already reported.
|
|
103
111
|
const where = reports.length > before ? "is" : `is now ${at.toFixed(3)}s —`;
|
|
104
|
-
if (at < SPLIT_MIN_PIECE_SEC &&
|
|
112
|
+
if (at < SPLIT_MIN_PIECE_SEC && atBefore >= SPLIT_MIN_PIECE_SEC) {
|
|
105
113
|
reports.push(
|
|
106
114
|
`split "${s.id}" ${where} too close to the start to divide a scene, ` +
|
|
107
115
|
`so any edit on its second half will not apply`,
|
|
@@ -111,7 +119,7 @@ export function remapOverridesThroughRecut(
|
|
|
111
119
|
// no longer divide anything — two would read as two problems.
|
|
112
120
|
} else if (
|
|
113
121
|
at > newMap.outputDuration - SPLIT_MIN_PIECE_SEC &&
|
|
114
|
-
|
|
122
|
+
atBefore <= oldMap.outputDuration - SPLIT_MIN_PIECE_SEC
|
|
115
123
|
) {
|
|
116
124
|
reports.push(
|
|
117
125
|
`split "${s.id}" ${where} too close to the end to divide a scene, ` +
|
|
@@ -128,6 +136,14 @@ export function remapOverridesThroughRecut(
|
|
|
128
136
|
const scenes = Object.fromEntries(
|
|
129
137
|
Object.entries(doc.scenes).map(([id, scene]) => {
|
|
130
138
|
if (!scene.timing) return [id, scene];
|
|
139
|
+
// A SRC-anchored pin is not remapped, for the same reason `cuts` below
|
|
140
|
+
// and `cleanup.kept` are not: source seconds are the one clock a re-cut
|
|
141
|
+
// cannot move, so re-anchoring one could only ever corrupt it — and a
|
|
142
|
+
// pin inside material THIS re-cut removed has no image on the new
|
|
143
|
+
// clock at all, which `remapPoint` would answer by clamping it onto
|
|
144
|
+
// the seam instead of leaving it inert (`resolveTimingPin` is where
|
|
145
|
+
// that verdict belongs). Legacy old-clock pins keep the remap verbatim.
|
|
146
|
+
if (isSrcTiming(scene.timing)) return [id, scene];
|
|
131
147
|
const startSec = remapPoint(`"${id}" pinned start`, scene.timing.startSec, oldMap, newMap, reports);
|
|
132
148
|
const endSec = remapPoint(`"${id}" pinned end`, scene.timing.endSec, oldMap, newMap, reports);
|
|
133
149
|
return [id, { ...scene, timing: { startSec, endSec } }];
|
|
@@ -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
|
+
}
|