@ossclip/core 0.1.31 → 0.1.33

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.
@@ -30,7 +30,7 @@ import { cappedText } from "./beats";
30
30
  * changes the answer, so the Y2 pack cache key carries this (the §78
31
31
  * cache-key posture) — an old cached pack must not survive a new prompt.
32
32
  */
33
- export const YOUTUBE_PROMPT_VERSION = "v2";
33
+ export const YOUTUBE_PROMPT_VERSION = "v3";
34
34
 
35
35
  export const YoutubeChapterSchema = z.object({
36
36
  /** Output-timeline seconds — the produced video's clock, not the source's. */
@@ -72,6 +72,22 @@ export const YoutubePackSchema = z.object({
72
72
  linkedinPost: cappedText(1500).optional(),
73
73
  /** A short YouTube community post for existing subscribers. Optional. */
74
74
  communityPost: cappedText(400).optional(),
75
+ /**
76
+ * Ready-to-post captions for the other short-video platforms (prompt v3,
77
+ * 2026-08-26), written by the same call that already has the transcript
78
+ * and audience in context — a publish step that derived these from titles
79
+ * would ship title-spam as its ceiling. Every field optional: pre-v3
80
+ * approved packs must keep parsing verbatim forever, and `deriveCaption`
81
+ * (publish/captions.ts) fills any gap deterministically at publish time.
82
+ */
83
+ platformCaptions: z
84
+ .object({
85
+ instagram: cappedText(2200).optional(),
86
+ tiktok: cappedText(2200).optional(),
87
+ x: cappedText(280).optional(),
88
+ facebook: cappedText(2200).optional(),
89
+ })
90
+ .optional(),
75
91
  });
76
92
  export type YoutubePack = z.infer<typeof YoutubePackSchema>;
77
93
 
@@ -295,7 +311,13 @@ export function buildYoutubePrompt(args: YoutubePromptArgs): { system: string; u
295
311
  "- linkedinPost: a story-driven LinkedIn post about this video: short lines with line " +
296
312
  "breaks, a curiosity gap, no hashtag spam, ending by pointing to the link in the comments " +
297
313
  "(the LinkedIn convention for off-platform links).\n" +
298
- "- communityPost: a short, casual YouTube community post for existing subscribers.";
314
+ "- communityPost: a short, casual YouTube community post for existing subscribers.\n" +
315
+ "- platformCaptions: ready-to-post captions for the OTHER platforms this short goes to, " +
316
+ "each written for that platform's culture, not copies of each other: \"instagram\" — a " +
317
+ "hook line, short scannable lines, 3-5 hashtags at the end (max 2200 chars); \"tiktok\" — " +
318
+ "casual and direct, 2-4 hashtags (max 2200 chars); \"x\" — ONE punchy post, max 280 " +
319
+ "characters INCLUDING hashtags, no link (links go in a reply); \"facebook\" — " +
320
+ "conversational, a question or hook up front, minimal hashtags (max 2200 chars).";
299
321
  const capped =
300
322
  args.transcriptText.length > YOUTUBE_TRANSCRIPT_CHAR_CAP
301
323
  ? // Slice + say so: the model must know it is reading an excerpt, or it
@@ -430,5 +452,17 @@ export function formatYoutubeMarkdown(
430
452
  if (pack.hook60) lines.push("", "## First-60s hook strategy", "", pack.hook60.trimEnd());
431
453
  if (pack.linkedinPost) lines.push("", "## LinkedIn post", "", pack.linkedinPost.trimEnd());
432
454
  if (pack.communityPost) lines.push("", "## Community post", "", pack.communityPost.trimEnd());
455
+ const captions = pack.platformCaptions;
456
+ if (captions) {
457
+ const order = [
458
+ ["Instagram", captions.instagram],
459
+ ["TikTok", captions.tiktok],
460
+ ["X", captions.x],
461
+ ["Facebook", captions.facebook],
462
+ ] as const;
463
+ for (const [label, text] of order) {
464
+ if (text) lines.push("", `## ${label} caption`, "", text.trimEnd());
465
+ }
466
+ }
433
467
  return `${lines.join("\n")}\n`;
434
468
  }
@@ -0,0 +1,79 @@
1
+ import type { YoutubePack } from "../producer/youtube";
2
+
3
+ /**
4
+ * Per-platform caption resolution for `ossclip publish`.
5
+ *
6
+ * The pack is the author: prompt v3 writes `platformCaptions` (and v2 already
7
+ * wrote `linkedinPost`) with the transcript and audience in context. This
8
+ * module only PICKS from the pack — and, for a pre-v3 pack that never carried
9
+ * a platform's caption, derives one deterministically from the fields every
10
+ * pack has. No LLM call at publish time: publishing must work offline-from-LLM
11
+ * and produce the same caption every run.
12
+ */
13
+
14
+ /** Platform caption caps, by the provider identifier the backend reports. */
15
+ export const CAPTION_CAPS: Record<string, number> = {
16
+ x: 280,
17
+ linkedin: 1500,
18
+ instagram: 2200,
19
+ tiktok: 2200,
20
+ facebook: 2200,
21
+ youtube: 5000,
22
+ };
23
+
24
+ /** The cap for an unknown provider — the smallest common long-form cap. */
25
+ export const DEFAULT_CAPTION_CAP = 1500;
26
+
27
+ export function captionCap(provider: string): number {
28
+ return CAPTION_CAPS[provider] ?? DEFAULT_CAPTION_CAP;
29
+ }
30
+
31
+ /**
32
+ * Word-boundary truncation to `max`: never slice mid-word, drop the partial
33
+ * word instead. A caption a few words shorter beats one ending "communi".
34
+ */
35
+ export function truncateAtWordBoundary(text: string, max: number): string {
36
+ const trimmed = text.trim();
37
+ if (trimmed.length <= max) return trimmed;
38
+ const slice = trimmed.slice(0, max);
39
+ const lastSpace = slice.lastIndexOf(" ");
40
+ return (lastSpace > 0 ? slice.slice(0, lastSpace) : slice).trimEnd();
41
+ }
42
+
43
+ /**
44
+ * Fallback caption when the pack carries none for this provider: the first
45
+ * title (the strongest line the pack has) plus the hashtags, capped. This is
46
+ * deliberately the floor, not the ceiling — the prompt-v3 `platformCaptions`
47
+ * exist because title-plus-hashtags is what every paste-tool ships.
48
+ */
49
+ export function deriveCaption(pack: YoutubePack, provider: string): string {
50
+ const title = pack.titles[0] ?? "";
51
+ const hashtags = pack.hashtags.map((h) => (h.startsWith("#") ? h : `#${h}`)).join(" ");
52
+ const joined = hashtags.length > 0 ? `${title}\n\n${hashtags}` : title;
53
+ return truncateAtWordBoundary(joined, captionCap(provider));
54
+ }
55
+
56
+ /**
57
+ * The caption `publish` uses for a target: the pack's own field for that
58
+ * platform when present (still capped — an approved pack is user data, and
59
+ * user data gets validated, not trusted), else the derived fallback.
60
+ */
61
+ export function captionForProvider(pack: YoutubePack, provider: string): string {
62
+ const captions = pack.platformCaptions;
63
+ const authored =
64
+ provider === "linkedin"
65
+ ? pack.linkedinPost
66
+ : provider === "instagram"
67
+ ? captions?.instagram
68
+ : provider === "tiktok"
69
+ ? captions?.tiktok
70
+ : provider === "x"
71
+ ? captions?.x
72
+ : provider === "facebook"
73
+ ? captions?.facebook
74
+ : undefined;
75
+ if (authored !== undefined && authored.trim().length > 0) {
76
+ return truncateAtWordBoundary(authored, captionCap(provider));
77
+ }
78
+ return deriveCaption(pack, provider);
79
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./provider";
2
+ export * from "./captions";
3
+ export * from "./postiz";
@@ -0,0 +1,232 @@
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
+ export function buildPostsPayload(args: {
73
+ posts: PublishPost[];
74
+ when: PublishWhen;
75
+ dateIso: string;
76
+ media: PostizUpload;
77
+ }): Record<string, unknown> {
78
+ const image = [{ id: args.media.id, path: args.media.path }];
79
+ return {
80
+ type: args.when.kind === "now" ? "now" : "schedule",
81
+ date: args.when.kind === "at" ? args.when.iso : args.dateIso,
82
+ shortLink: false,
83
+ posts: args.posts.map((p) => ({
84
+ integration: { id: p.target.id },
85
+ value: [{ content: p.caption, image }],
86
+ settings: {
87
+ __type: p.target.provider,
88
+ ...(p.title !== undefined ? { title: p.title } : {}),
89
+ },
90
+ })),
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Post ids out of whatever shape `/posts` answers with. Lenient BY DESIGN,
96
+ * unlike every other parse here: the 2xx status is the success signal, the
97
+ * ids are a convenience for the receipt, and a Postiz version that renames
98
+ * this envelope must not turn an accepted publish into a thrown "failure"
99
+ * after the posts already went out.
100
+ */
101
+ export function extractPostIds(json: unknown): string[] {
102
+ const items = Array.isArray(json)
103
+ ? json
104
+ : typeof json === "object" && json !== null && Array.isArray((json as { posts?: unknown }).posts)
105
+ ? ((json as { posts: unknown[] }).posts)
106
+ : [json];
107
+ const ids: string[] = [];
108
+ for (const item of items) {
109
+ if (typeof item === "object" && item !== null) {
110
+ const id = (item as { id?: unknown; postId?: unknown }).id ?? (item as { postId?: unknown }).postId;
111
+ if (typeof id === "string") ids.push(id);
112
+ }
113
+ }
114
+ return ids;
115
+ }
116
+
117
+ /** `postizUrl` as the API base: trailing slashes dropped, `/api/public/v1`
118
+ * appended unless the user already wrote it. */
119
+ export function postizApiBase(url: string): string {
120
+ const trimmed = url.replace(/\/+$/, "");
121
+ return trimmed.endsWith("/api/public/v1") ? trimmed : `${trimmed}/api/public/v1`;
122
+ }
123
+
124
+ export class PostizHttpError extends Error {
125
+ constructor(
126
+ readonly method: string,
127
+ readonly path: string,
128
+ readonly status: number,
129
+ bodySnippet: string,
130
+ ) {
131
+ const hint =
132
+ status === 401 || status === 403
133
+ ? " — Postiz rejected the API key (Settings → Public API in your Postiz instance)"
134
+ : status === 413
135
+ ? " — the upload exceeds the Postiz instance's size limit"
136
+ : status === 429
137
+ ? " — Postiz rate limit (90 posts/hour per self-hosted instance)"
138
+ : "";
139
+ super(`Postiz ${method} ${path} failed: ${status}${hint}${bodySnippet ? `\n${bodySnippet}` : ""}`);
140
+ this.name = "PostizHttpError";
141
+ }
142
+ }
143
+
144
+ export interface PostizProviderOptions {
145
+ baseUrl: string;
146
+ apiKey: string;
147
+ fetchImpl?: typeof fetch;
148
+ /** Per-request cap. Uploads carry whole videos — default is generous. */
149
+ timeoutMs?: number;
150
+ }
151
+
152
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
153
+ const BODY_SNIPPET_CHARS = 300;
154
+
155
+ export function createPostizProvider(opts: PostizProviderOptions): PublishProvider {
156
+ const base = postizApiBase(opts.baseUrl);
157
+ const fetchImpl = opts.fetchImpl ?? fetch;
158
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
159
+
160
+ const request = async (method: string, path: string, body?: BodyInit, headers?: Record<string, string>): Promise<unknown> => {
161
+ const ac = new AbortController();
162
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
163
+ let res: Response;
164
+ try {
165
+ res = await fetchImpl(`${base}${path}`, {
166
+ method,
167
+ headers: { Authorization: opts.apiKey, ...headers },
168
+ body,
169
+ signal: ac.signal,
170
+ });
171
+ } catch (err) {
172
+ throw new Error(
173
+ `Postiz ${method} ${path} unreachable at ${base}: ${err instanceof Error ? err.message : String(err)}`,
174
+ );
175
+ } finally {
176
+ clearTimeout(timer);
177
+ }
178
+ const text = await res.text();
179
+ if (!res.ok) {
180
+ throw new PostizHttpError(method, path, res.status, text.slice(0, BODY_SNIPPET_CHARS));
181
+ }
182
+ try {
183
+ return text.length > 0 ? JSON.parse(text) : null;
184
+ } catch {
185
+ throw new Error(`Postiz ${method} ${path} answered non-JSON: ${text.slice(0, BODY_SNIPPET_CHARS)}`);
186
+ }
187
+ };
188
+
189
+ return {
190
+ name: "postiz",
191
+ async listTargets(): Promise<PublishTarget[]> {
192
+ return parseIntegrations(await request("GET", "/integrations"));
193
+ },
194
+ 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
+ const { openAsBlob } = await import("node:fs");
199
+ const blob = await openAsBlob(req.videoPath, { type: "video/mp4" });
200
+ const form = new FormData();
201
+ form.append("file", blob, basename(req.videoPath));
202
+ const media = PostizUploadSchema.parse(await request("POST", "/upload", form));
203
+
204
+ const payload = buildPostsPayload({
205
+ posts: req.posts,
206
+ when: req.when,
207
+ dateIso: new Date().toISOString(),
208
+ media,
209
+ });
210
+ let answer: unknown;
211
+ try {
212
+ answer = await request("POST", "/posts", JSON.stringify(payload), {
213
+ "content-type": "application/json",
214
+ });
215
+ } catch (err) {
216
+ // The media is already up — say so, so a retry is one request, not
217
+ // a re-upload of the whole video.
218
+ throw new Error(
219
+ `${err instanceof Error ? err.message : String(err)}\n` +
220
+ `(the video uploaded fine — media id ${media.id}; retrying will re-upload it)`,
221
+ );
222
+ }
223
+ return {
224
+ backend: "postiz",
225
+ postIds: extractPostIds(answer),
226
+ publishedAt: new Date().toISOString(),
227
+ when: req.when,
228
+ targets: req.posts.map((p) => p.target),
229
+ };
230
+ },
231
+ };
232
+ }
@@ -0,0 +1,58 @@
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
+
32
+ export interface PublishRequest {
33
+ /** Absolute path of the rendered video. */
34
+ videoPath: string;
35
+ posts: PublishPost[];
36
+ when: PublishWhen;
37
+ }
38
+
39
+ /**
40
+ * What `publish()` returns AND what `<workdir>/publish-receipt.json` holds —
41
+ * the double-post guard reads this file, so it records enough to tell the
42
+ * user what already went out, and when.
43
+ */
44
+ export interface PublishReceipt {
45
+ backend: string;
46
+ /** Backend post ids, when the backend reports them; may be empty. */
47
+ postIds: string[];
48
+ /** ISO time the publish request was accepted (not the scheduled time). */
49
+ publishedAt: string;
50
+ when: PublishWhen;
51
+ targets: PublishTarget[];
52
+ }
53
+
54
+ export interface PublishProvider {
55
+ readonly name: string;
56
+ listTargets(): Promise<PublishTarget[]>;
57
+ publish(req: PublishRequest): Promise<PublishReceipt>;
58
+ }
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 at = remapPoint(`split "${s.id}"`, s.at, oldMap, newMap, reports);
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 && s.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
- s.at <= oldMap.outputDuration - SPLIT_MIN_PIECE_SEC
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 } }];