ossclip 0.1.26 → 0.1.28
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/editor-dist/assets/index-BBJKC2Z2.js +164 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/cover.ts +846 -0
- package/src/edit.ts +204 -45
- package/src/interactive/resolve-workdir.ts +11 -2
- package/src/llm-detect.ts +11 -0
- package/src/produce.ts +706 -187
- package/src/program.ts +153 -26
- package/src/replay-argv.ts +22 -1
- package/src/setup/download.ts +24 -0
- package/src/setup/manifest.ts +30 -12
- package/src/setup/setup.ts +47 -4
- package/editor-dist/assets/index-DVI51_2u.js +0 -163
package/src/edit.ts
CHANGED
|
@@ -29,7 +29,11 @@ import {
|
|
|
29
29
|
outPathInsideInput,
|
|
30
30
|
PORTRAIT_MIME_TYPES,
|
|
31
31
|
portraitMimeType,
|
|
32
|
+
readCoverProvenance,
|
|
33
|
+
SegmentSchema,
|
|
34
|
+
type Segment,
|
|
32
35
|
thumbnailImageCacheName,
|
|
36
|
+
type CoverProvenance,
|
|
33
37
|
type GenerateThumbnailImageOptions,
|
|
34
38
|
type ThumbnailConcept,
|
|
35
39
|
type ThumbnailConceptApproved,
|
|
@@ -39,7 +43,23 @@ import {
|
|
|
39
43
|
// server startup — open.ts is node:child_process + node:path and pure command
|
|
40
44
|
// building, with nothing to defer.
|
|
41
45
|
import { revealInFileManager } from "./open";
|
|
42
|
-
|
|
46
|
+
// The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
|
|
47
|
+
// cover` needs the same out-resolution rule this server's thumbnail dest,
|
|
48
|
+
// youtube markdown and reveal endpoint derive from, and two spellings of it
|
|
49
|
+
// could disagree about which file a replay writes. cover.ts stays free of a
|
|
50
|
+
// static @ossclip/renderer import for exactly this reason.
|
|
51
|
+
import {
|
|
52
|
+
CoverAtSecondsSchema,
|
|
53
|
+
CoverFromSchema,
|
|
54
|
+
RecordedCommandSchema,
|
|
55
|
+
readRecordedCommand,
|
|
56
|
+
recordedArtifactPath as recordedArtifactPathIn,
|
|
57
|
+
recordedOutPath,
|
|
58
|
+
regenerateCover,
|
|
59
|
+
type CoverSeams,
|
|
60
|
+
type RecordedCommand,
|
|
61
|
+
} from "./cover";
|
|
62
|
+
import { expandHome } from "./paths";
|
|
43
63
|
import {
|
|
44
64
|
PORTRAIT_OVERRIDE_BASENAME,
|
|
45
65
|
portraitExtensionForMime,
|
|
@@ -81,21 +101,6 @@ export function resolveEditorPageDir(): string | null {
|
|
|
81
101
|
* replays the invocation `produce` recorded, never anything a client sent.
|
|
82
102
|
*/
|
|
83
103
|
|
|
84
|
-
/**
|
|
85
|
-
* The invocation `produce` recorded into the workdir (R11 Task 4.1).
|
|
86
|
-
* Validated on read — it's a file on disk like any other user data — and the
|
|
87
|
-
* ONLY thing `/api/render` will ever spawn: this server binds locally, but
|
|
88
|
-
* accepting a client-supplied command would make it a remote shell.
|
|
89
|
-
*/
|
|
90
|
-
const CommandSchema = z.object({
|
|
91
|
-
execPath: z.string(),
|
|
92
|
-
execArgv: z.array(z.string()).default([]),
|
|
93
|
-
script: z.string(),
|
|
94
|
-
args: z.array(z.string()),
|
|
95
|
-
cwd: z.string(),
|
|
96
|
-
out: z.string().optional(),
|
|
97
|
-
});
|
|
98
|
-
|
|
99
104
|
/** Ring-buffer cap for captured render output. */
|
|
100
105
|
const RENDER_LOG_LINES = 200;
|
|
101
106
|
export interface EditServer {
|
|
@@ -254,6 +259,13 @@ export async function startEditServer(
|
|
|
254
259
|
* observe the revealed path instead of popping a real Finder/Explorer
|
|
255
260
|
* window on the runner. */
|
|
256
261
|
reveal?: (path: string) => void;
|
|
262
|
+
/**
|
|
263
|
+
* The cover render seam, exactly like `generateThumbnail` above. Without
|
|
264
|
+
* it `regenerateCover` lazily imports @ossclip/renderer and boots a
|
|
265
|
+
* headless browser — which `edit-server.test.ts` must never do, and which
|
|
266
|
+
* is also why cover.ts keeps that import lazy in the first place.
|
|
267
|
+
*/
|
|
268
|
+
renderCover?: CoverSeams["renderCover"];
|
|
257
269
|
} = {},
|
|
258
270
|
): Promise<EditServer> {
|
|
259
271
|
// MUTABLE since R17 §83: the server can start with no project (the page
|
|
@@ -293,36 +305,13 @@ export async function startEditServer(
|
|
|
293
305
|
// buy two.
|
|
294
306
|
let thumbnailBusy = false;
|
|
295
307
|
/** command.json's recorded invocation, or null when absent/corrupt — the
|
|
296
|
-
* thumbnail panel degrades to the config fallback rather than 500ing.
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
try {
|
|
300
|
-
const parsed = CommandSchema.safeParse(JSON.parse(await readFile(commandPath(), "utf8")));
|
|
301
|
-
return parsed.success ? parsed.data : null;
|
|
302
|
-
} catch {
|
|
303
|
-
return null;
|
|
304
|
-
}
|
|
305
|
-
};
|
|
306
|
-
/** The recorded out as an absolute path (the top-level `out` when
|
|
307
|
-
* recorded, else the argv's -o/--out resolved against the recorded cwd —
|
|
308
|
-
* the replay's own resolution), or null when no out was ever recorded.
|
|
309
|
-
* ONE spelling of the out-resolution rule: the artifact paths below and
|
|
310
|
-
* the reveal endpoint both derive from it, so they can never disagree
|
|
311
|
-
* about which file a replay writes. */
|
|
312
|
-
const recordedOutPath = (cmd: z.infer<typeof CommandSchema>): string | null => {
|
|
313
|
-
const out = cmd.out ?? lastFlagValue(cmd.args, ["-o", "--out"]);
|
|
314
|
-
if (out === undefined) return null;
|
|
315
|
-
return resolve(cmd.cwd, expandHome(out));
|
|
316
|
-
};
|
|
308
|
+
* thumbnail panel degrades to the config fallback rather than 500ing.
|
|
309
|
+
* Bound to the CURRENT workdir; the rule itself lives in cover.ts. */
|
|
310
|
+
const readCommandRecord = (): Promise<RecordedCommand | null> => readRecordedCommand(workdir!);
|
|
317
311
|
/** `<out><ext>` from the recorded out, or null when no out was ever
|
|
318
312
|
* recorded. Shared by the thumbnail dest and the youtube markdown. */
|
|
319
|
-
const recordedArtifactPath =
|
|
320
|
-
|
|
321
|
-
if (!cmd) return null;
|
|
322
|
-
const out = recordedOutPath(cmd);
|
|
323
|
-
if (out === null) return null;
|
|
324
|
-
return artifactPath(out, ext);
|
|
325
|
-
};
|
|
313
|
+
const recordedArtifactPath = (ext: string): Promise<string | null> =>
|
|
314
|
+
recordedArtifactPathIn(workdir!, ext);
|
|
326
315
|
const thumbnailDestPath = (): Promise<string | null> => recordedArtifactPath(".thumbnail.png");
|
|
327
316
|
/** Newest workdir file passing `test`, by mtime — the cache fallbacks. */
|
|
328
317
|
const newestWorkdirFile = async (test: (name: string) => boolean): Promise<string | null> => {
|
|
@@ -354,6 +343,33 @@ export async function startEditServer(
|
|
|
354
343
|
return null;
|
|
355
344
|
}
|
|
356
345
|
};
|
|
346
|
+
// ---- Cover regeneration (editor panel, 2026-08-19) ----------------------
|
|
347
|
+
// The cover is written on EVERY produce, `--youtube` or not, so this is not
|
|
348
|
+
// a YouTube-menu concern — it has its own top-bar button in the page. The
|
|
349
|
+
// panel round-trips through the workdir's `cover.json`, which is the same
|
|
350
|
+
// provenance `ossclip cover` reads and produce honours (`textSource:
|
|
351
|
+
// "user"`), so an edit made here survives into future renders with no new
|
|
352
|
+
// plumbing — the thumbnail block's approval-file contract, applied.
|
|
353
|
+
//
|
|
354
|
+
// One regeneration at a time: it can shell out to ffmpeg and it boots a
|
|
355
|
+
// headless browser, and a double-click must not run two renders at the same
|
|
356
|
+
// destination. `thumbnailBusy`'s rule, for the same reason.
|
|
357
|
+
let coverBusy = false;
|
|
358
|
+
/** Where the JPEG lives right now: the destination the last cover used,
|
|
359
|
+
* else `<recorded out>.cover.jpg`. Existence is the caller's check — a
|
|
360
|
+
* recorded destination that was never rendered is a real state (the panel
|
|
361
|
+
* shows a placeholder), not an error. */
|
|
362
|
+
const currentCoverImage = async (provenance: CoverProvenance | null): Promise<string | null> => {
|
|
363
|
+
if (provenance !== null && existsSync(provenance.out)) return provenance.out;
|
|
364
|
+
const dest = await recordedArtifactPath(".cover.jpg");
|
|
365
|
+
return dest !== null && existsSync(dest) ? dest : null;
|
|
366
|
+
};
|
|
367
|
+
/** mtime as the ts so the URL changes exactly when the file does — the
|
|
368
|
+
* thumbnail imageUrl's own cache-busting rule (a regenerate REPLACES the
|
|
369
|
+
* file behind this URL). */
|
|
370
|
+
const coverImageUrl = (image: string | null): string | null =>
|
|
371
|
+
image === null ? null : `/api/cover/image?ts=${Math.round(statSync(image).mtimeMs)}`;
|
|
372
|
+
|
|
357
373
|
// ---- Portrait override (editor face swap, 2026-08-17) -------------------
|
|
358
374
|
// A per-project `portrait-override.<ext>` in the workdir that outranks the
|
|
359
375
|
// pin and the config (portrait-override.ts has the precedence argument).
|
|
@@ -534,6 +550,46 @@ export async function startEditServer(
|
|
|
534
550
|
});
|
|
535
551
|
}
|
|
536
552
|
|
|
553
|
+
if (url.pathname === "/api/cleanup" && req.method === "GET") {
|
|
554
|
+
// The labeled removals: since cut review step 3 this serves the
|
|
555
|
+
// PROPOSAL (`cutlistProposed` — the automatic cutlist before the
|
|
556
|
+
// user's cleanup vetoes and user cuts), because that is what the
|
|
557
|
+
// editor's checkboxes and seams reason about: a DECLINED pause has
|
|
558
|
+
// already merged into a plain keep in the resolved `cutlist`, so
|
|
559
|
+
// serving that would make the veto invisible the moment it worked.
|
|
560
|
+
// The fallback to `cutlist` keeps pre-step-3 workdirs drawing their
|
|
561
|
+
// seams — back then the recorded cutlist WAS the proposal (plus
|
|
562
|
+
// applied user cuts, whose seams the applied-cut restore marker
|
|
563
|
+
// draws independently anyway). Same lenient-read posture as
|
|
564
|
+
// /api/usage above: a missing or corrupt production.json degrades
|
|
565
|
+
// to an empty cutlist, never a 500 — the timeline simply draws no
|
|
566
|
+
// removal seams.
|
|
567
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
568
|
+
let cutlist: Segment[] = [];
|
|
569
|
+
try {
|
|
570
|
+
const production = JSON.parse(
|
|
571
|
+
await readFile(join(workdir, "production.json"), "utf8"),
|
|
572
|
+
) as { cutlist?: unknown; cutlistProposed?: unknown };
|
|
573
|
+
const source = Array.isArray(production.cutlistProposed)
|
|
574
|
+
? production.cutlistProposed
|
|
575
|
+
: production.cutlist;
|
|
576
|
+
if (Array.isArray(source)) {
|
|
577
|
+
// Each span parses ALONE: a hand-edited production.json with
|
|
578
|
+
// one bad span (a string srcIn, a negative time) drops that
|
|
579
|
+
// span and keeps the rest, instead of either 500ing or letting
|
|
580
|
+
// a NaN through to position a seam off-screen. zod parse, not
|
|
581
|
+
// a cast — the house rule for anything a user can have edited.
|
|
582
|
+
cutlist = source.flatMap((s) => {
|
|
583
|
+
const parsed = SegmentSchema.safeParse(s);
|
|
584
|
+
return parsed.success ? [parsed.data] : [];
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
} catch {
|
|
588
|
+
// degrade — same as /api/usage's readJson
|
|
589
|
+
}
|
|
590
|
+
return send(200, { cutlist });
|
|
591
|
+
}
|
|
592
|
+
|
|
537
593
|
if (url.pathname === "/api/workdir" && req.method === "POST") {
|
|
538
594
|
// Open/switch the project (R17 §83). Refused mid-render: the
|
|
539
595
|
// running child belongs to the CURRENT workdir, and its status
|
|
@@ -647,7 +703,7 @@ export async function startEditServer(
|
|
|
647
703
|
} catch {
|
|
648
704
|
// ignore
|
|
649
705
|
}
|
|
650
|
-
const parsed =
|
|
706
|
+
const parsed = RecordedCommandSchema.safeParse(
|
|
651
707
|
JSON.parse(await readFile(commandPath(), "utf8")),
|
|
652
708
|
);
|
|
653
709
|
if (!parsed.success) return send(500, { error: `command.json is not valid: ${parsed.error.message}` });
|
|
@@ -1124,6 +1180,109 @@ export async function startEditServer(
|
|
|
1124
1180
|
return send(200, { ok: true, mdPath });
|
|
1125
1181
|
}
|
|
1126
1182
|
|
|
1183
|
+
if (url.pathname === "/api/cover" && req.method === "GET") {
|
|
1184
|
+
// The cover panel's one status call (2026-08-19): the provenance to
|
|
1185
|
+
// prefill and where the current image is. All reads — the panel
|
|
1186
|
+
// owns no state on the server.
|
|
1187
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1188
|
+
const provenance = await readCoverProvenance(workdir);
|
|
1189
|
+
const image = await currentCoverImage(provenance);
|
|
1190
|
+
// Where a regeneration would WRITE. `coverDestination`'s canonical
|
|
1191
|
+
// ladder: the destination the last cover used, else
|
|
1192
|
+
// `<recorded out>.cover.jpg`. Neither means regenerateCover would
|
|
1193
|
+
// throw for want of a destination, and the panel says so up front
|
|
1194
|
+
// rather than after a click.
|
|
1195
|
+
const outPath = provenance?.out ?? (await recordedArtifactPath(".cover.jpg"));
|
|
1196
|
+
return send(200, {
|
|
1197
|
+
status: outPath === null ? "unavailable" : "ready",
|
|
1198
|
+
...(outPath === null
|
|
1199
|
+
? { reason: "no-destination" as const }
|
|
1200
|
+
: image === null
|
|
1201
|
+
? { reason: "never-rendered" as const }
|
|
1202
|
+
: {}),
|
|
1203
|
+
provenance,
|
|
1204
|
+
outPath,
|
|
1205
|
+
imageUrl: coverImageUrl(image),
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (url.pathname === "/api/cover/image" && req.method === "GET") {
|
|
1210
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1211
|
+
const image = await currentCoverImage(await readCoverProvenance(workdir));
|
|
1212
|
+
if (image === null) return send(404, { error: "no cover image" });
|
|
1213
|
+
// Whole-file read + no-store, the thumbnail image endpoint's exact
|
|
1214
|
+
// posture and for the same reason: a regenerate REPLACES the file
|
|
1215
|
+
// behind a URL the panel busts with ?ts, and a cached 200 would show
|
|
1216
|
+
// the old cover against the new ts on some proxies.
|
|
1217
|
+
const bytes = await readFile(image);
|
|
1218
|
+
res.writeHead(200, {
|
|
1219
|
+
"content-type": "image/jpeg",
|
|
1220
|
+
"cache-control": "no-store",
|
|
1221
|
+
"content-length": String(bytes.length),
|
|
1222
|
+
});
|
|
1223
|
+
res.end(bytes);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
if (url.pathname === "/api/cover/regenerate" && req.method === "POST") {
|
|
1228
|
+
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1229
|
+
// A regeneration can shell out to ffmpeg and it boots a headless
|
|
1230
|
+
// browser — one at a time, a second is a 409 like a second render.
|
|
1231
|
+
if (coverBusy) return send(409, { error: "a cover regeneration is already running" });
|
|
1232
|
+
const chunks: Buffer[] = [];
|
|
1233
|
+
for await (const c of req) chunks.push(c as Buffer);
|
|
1234
|
+
// Three steerable values and NOTHING else. `atSec` rides the CLI's
|
|
1235
|
+
// own schema so a negative seek is refused at both surfaces, and
|
|
1236
|
+
// `from` rides the enum so a typo'd "finall" is a 400 rather than a
|
|
1237
|
+
// cover quietly rebuilt from the wrong video (CLAUDE.md's
|
|
1238
|
+
// --source-fit rule). Unknown keys are stripped by the parse, which
|
|
1239
|
+
// is the load-bearing half of the paragraph below.
|
|
1240
|
+
const parsed = z
|
|
1241
|
+
.object({
|
|
1242
|
+
text: z.string().optional(),
|
|
1243
|
+
atSec: CoverAtSecondsSchema.optional(),
|
|
1244
|
+
from: CoverFromSchema.optional(),
|
|
1245
|
+
})
|
|
1246
|
+
.safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
|
|
1247
|
+
if (!parsed.success) return send(400, { error: parsed.error.message });
|
|
1248
|
+
coverBusy = true;
|
|
1249
|
+
try {
|
|
1250
|
+
// Every PATH is derived server-side — from command.json,
|
|
1251
|
+
// cover.json and render-props.json — and never from the body: the
|
|
1252
|
+
// stance the render and reveal endpoints already hold. This server
|
|
1253
|
+
// binds locally, but an endpoint that WRITES a file wherever a
|
|
1254
|
+
// client names is the same door as spawning a client-supplied
|
|
1255
|
+
// command. Note the absent `outPath`: it exists on
|
|
1256
|
+
// CoverRegenerateOptions for `ossclip cover --out`, and passing
|
|
1257
|
+
// one through from here is exactly the bug this omission prevents.
|
|
1258
|
+
const notes: string[] = [];
|
|
1259
|
+
const provenance = await regenerateCover(
|
|
1260
|
+
workdir,
|
|
1261
|
+
{ text: parsed.data.text, atSec: parsed.data.atSec, from: parsed.data.from },
|
|
1262
|
+
{ renderCover: opts.renderCover, log: (line) => notes.push(line) },
|
|
1263
|
+
);
|
|
1264
|
+
const image = await currentCoverImage(provenance);
|
|
1265
|
+
// The notes ride back so the panel can show what the CLI PRINTS —
|
|
1266
|
+
// a headline trimmed to nine words, or a re-picked frame. Silence
|
|
1267
|
+
// on either is how a user ships a cover they did not write.
|
|
1268
|
+
return send(200, {
|
|
1269
|
+
ok: true,
|
|
1270
|
+
provenance,
|
|
1271
|
+
notes,
|
|
1272
|
+
outPath: provenance.out,
|
|
1273
|
+
imageUrl: coverImageUrl(image),
|
|
1274
|
+
});
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
// 200 with ok:false, the thumbnail regenerate's posture: these
|
|
1277
|
+
// failures are user-actionable sentences ("is the timestamp past
|
|
1278
|
+
// the end?", "--from source needs cover.json") and the panel shows
|
|
1279
|
+
// them inline VERBATIM rather than as a dead 500.
|
|
1280
|
+
return send(200, { ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
1281
|
+
} finally {
|
|
1282
|
+
coverBusy = false;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1127
1286
|
if (url.pathname.startsWith("/media/")) {
|
|
1128
1287
|
if (!workdir) return send(409, { error: "no workdir open" });
|
|
1129
1288
|
const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
|
|
@@ -106,10 +106,19 @@ export function resolveWorkdir(
|
|
|
106
106
|
* interactive picker cannot run. Each line is rendered through
|
|
107
107
|
* renderCommand so a path containing a space pastes into a shell as ONE
|
|
108
108
|
* argument — an unquoted list defeats the only thing this branch is for.
|
|
109
|
+
*
|
|
110
|
+
* `command` is the subcommand the user actually ran: this ladder is shared
|
|
111
|
+
* with `ossclip cover`, and printing `ossclip edit <path>` to someone who
|
|
112
|
+
* typed `cover` sends them to a different command than the one they wanted.
|
|
113
|
+
* Defaults to "edit", the only caller when this was written.
|
|
109
114
|
*/
|
|
110
|
-
export function candidateListMessage(
|
|
115
|
+
export function candidateListMessage(
|
|
116
|
+
dir: string,
|
|
117
|
+
candidates: Candidate[],
|
|
118
|
+
command: string = "edit",
|
|
119
|
+
): string {
|
|
111
120
|
return (
|
|
112
121
|
`several produce runs under ${dir} — name one:\n` +
|
|
113
|
-
candidates.map((c) => ` ${renderCommand([
|
|
122
|
+
candidates.map((c) => ` ${renderCommand([command, c.path])}`).join("\n")
|
|
114
123
|
);
|
|
115
124
|
}
|
package/src/llm-detect.ts
CHANGED
|
@@ -60,3 +60,14 @@ export function detectionLine(name: ProviderName): string {
|
|
|
60
60
|
return "▸ using the mock provider (no LLM)";
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The out-loud line for a §143 timeout fallback (2026-08-22): agy expired its
|
|
66
|
+
* own print timeout on the editorial call and another provider is answering
|
|
67
|
+
* it. Names all three facts — who failed, on which call, who took over —
|
|
68
|
+
* because a silent substitution would be worse than the hang: the user must
|
|
69
|
+
* know which model planned their video.
|
|
70
|
+
*/
|
|
71
|
+
export function fallbackLine(from: string, to: string, schemaName: string): string {
|
|
72
|
+
return `⚠ ${from} timed out on ${schemaName} — falling back to ${to}`;
|
|
73
|
+
}
|