reelkit-cli 0.1.3 → 0.3.0
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/README.md +2 -1
- package/package.json +1 -1
- package/skill/SKILL.md +8 -4
- package/skill/reference/clips.md +98 -0
- package/skill/reference/kit.md +8 -0
- package/skill/reference/remotion-composition.md +4 -3
- package/skill/reference/scene-treatments.md +2 -1
- package/src/cli.ts +16 -7
- package/src/commands/assets.ts +334 -15
- package/src/commands/auth.ts +1 -1
- package/src/contract/index.ts +62 -3
- package/src/pipeline/schema.ts +13 -1
- package/src/project/chromakey.ts +51 -0
- package/src/project/manifest.ts +12 -0
- package/src/project/project.ts +1 -1
- package/src/remotion/kit/ClipLayer.tsx +12 -0
- package/src/remotion/kit/KeyedClip.tsx +15 -0
- package/src/remotion/kit/docs.ts +8 -0
- package/src/remotion/kit/index.ts +2 -0
- package/src/testing/conformance.ts +227 -1
- package/src/testing/fake-api.ts +144 -13
package/src/commands/assets.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { copyFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { copyFileSync, mkdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, resolve } from "node:path";
|
|
4
|
-
import { download, uploadTo } from "../api/client";
|
|
4
|
+
import { ApiFailure, download, uploadTo } from "../api/client";
|
|
5
5
|
import { client, type Ctx, type Result } from "../context";
|
|
6
|
-
import { LibraryKindSchema, UploadKindSchema } from "../contract";
|
|
6
|
+
import { LibraryKindSchema, MAX_CUTOUT_SECONDS, MAX_UPLOAD_BYTES, UploadKindSchema, type CutoutType } from "../contract";
|
|
7
7
|
import { PACE_SPEED, type AssetManifest, type AssetRecord, type ScenePlan } from "../pipeline/schema";
|
|
8
|
-
import {
|
|
8
|
+
import { keyGreen } from "../project/chromakey";
|
|
9
|
+
import { buildManifest, voiceoverStale, type ClipRecord, type Voiceover } from "../project/manifest";
|
|
9
10
|
import { loadPlan } from "./plan";
|
|
10
11
|
import { FILE_NAME } from "../render/validate";
|
|
11
12
|
import { probeFile } from "../project/probe";
|
|
@@ -30,9 +31,80 @@ export function setSceneImage(project: Project, sceneId: string, path: string) {
|
|
|
30
31
|
project.writeJson(FILES.images, { ...project.readJsonOr<Record<string, string>>(FILES.images, {}), [sceneId]: path });
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
const CUTOUT_POLL_MS = 5_000;
|
|
35
|
+
const CUTOUT_PROGRESS_MS = 30_000;
|
|
36
|
+
const CUTOUT_TIMEOUT_MS = 10 * 60_000;
|
|
37
|
+
const SENT_NOTE = "The video was sent to the Reelkit server to be processed there; the server deletes its copy of it when the job ends.";
|
|
38
|
+
|
|
39
|
+
// What a test may replace for a cutout: the clock the wait is timed by.
|
|
40
|
+
export type CutoutDeps = { now?: () => number };
|
|
41
|
+
|
|
42
|
+
type CutoutInput = { source: string; filename: string; contentType: CutoutType; bytes: number; durationSec: number };
|
|
43
|
+
|
|
44
|
+
// A path as it is typed in a command: quoted only when it has to be.
|
|
45
|
+
const shellArg = (s: string) => (/^[\w./~@%+=:,-]+$/.test(s) ? s : JSON.stringify(s));
|
|
46
|
+
|
|
47
|
+
// Sends a video to the server and starts its cutout. The seconds are reserved when the job is run, so this is the point where it becomes
|
|
48
|
+
// paid for: nothing is charged by a failure before it, and the id it returns is the only handle on the job after it.
|
|
49
|
+
async function startCutout(ctx: Ctx, input: CutoutInput): Promise<string> {
|
|
50
|
+
const api = client(ctx);
|
|
51
|
+
ctx.log(`Sending ${input.filename} to the Reelkit server to cut the subject out.`);
|
|
52
|
+
const up = await api("cutoutStart", { filename: input.filename.slice(-200), contentType: input.contentType, bytes: input.bytes, durationSec: input.durationSec });
|
|
53
|
+
await uploadTo(up.uploadUrl, new Uint8Array(readFileSync(input.source)), input.contentType);
|
|
54
|
+
await api("cutoutRun", { id: up.id });
|
|
55
|
+
ctx.log(`Started cutout ${up.id}. It usually takes about a minute.`);
|
|
56
|
+
return up.id;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type CutoutOutcome = { kind: "done" } | { kind: "failed"; message: string } | { kind: "timeout" } | { kind: "lost"; message: string };
|
|
60
|
+
|
|
61
|
+
// Asks every few seconds until the job ends (at most ten minutes), then saves the result at `dest`. A job that is not found or a login that
|
|
62
|
+
// is wrong has nothing to resume and is thrown; any other trouble may be a hiccup on a job that is paid for, so it is an outcome.
|
|
63
|
+
async function pollCutout(ctx: Ctx, id: string, dest: string, deps: CutoutDeps): Promise<CutoutOutcome> {
|
|
64
|
+
const api = client(ctx);
|
|
65
|
+
const now = deps.now ?? Date.now;
|
|
66
|
+
const started = now();
|
|
67
|
+
let lastLog = started;
|
|
68
|
+
try {
|
|
69
|
+
for (;;) {
|
|
70
|
+
const st = await api("cutoutStatus", { id });
|
|
71
|
+
if (st.status === "failed") return { kind: "failed", message: st.message ?? "The server gave no reason." };
|
|
72
|
+
if (st.status === "done") {
|
|
73
|
+
await download(st.url!, dest);
|
|
74
|
+
return { kind: "done" };
|
|
75
|
+
}
|
|
76
|
+
const elapsed = now() - started;
|
|
77
|
+
if (elapsed >= CUTOUT_TIMEOUT_MS) return { kind: "timeout" };
|
|
78
|
+
if (now() - lastLog >= CUTOUT_PROGRESS_MS) {
|
|
79
|
+
lastLog = now();
|
|
80
|
+
ctx.log(`Still cutting out the subject (${Math.round(elapsed / 1000)}s so far)...`);
|
|
81
|
+
}
|
|
82
|
+
await ctx.sleep(CUTOUT_POLL_MS);
|
|
83
|
+
}
|
|
84
|
+
} catch (e) {
|
|
85
|
+
if (e instanceof ApiFailure && (e.code === "not_found" || e.code === "unauthenticated")) throw e;
|
|
86
|
+
return { kind: "lost", message: e instanceof Error ? e.message : String(e) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The one line a person reads when a video cannot be cut out, or undefined when it can.
|
|
91
|
+
function cutoutRefusal(file: string, probe: Awaited<ReturnType<typeof probeFile>>): string | undefined {
|
|
92
|
+
if (probe.kind !== "video") return `${file} is ${probe.kind}, not a video, so there is nothing to cut out.`;
|
|
93
|
+
if (!probe.durationSec || probe.durationSec <= 0) return `${file} has no length that could be read, so it cannot be cut out. Export it again.`;
|
|
94
|
+
if (probe.durationSec > MAX_CUTOUT_SECONDS) return `${file} is ${Math.ceil(probe.durationSec)} seconds long and a cutout takes at most ${MAX_CUTOUT_SECONDS}. Trim it to ${MAX_CUTOUT_SECONDS} seconds or less and run the command again.`;
|
|
95
|
+
if (probe.bytes > MAX_UPLOAD_BYTES) return `${file} is larger than 200 MB, which is the most a cutout takes. Trim or compress it and run the command again.`;
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
33
99
|
// Registers one of the user's own files. It stays on this machine unless --share is given.
|
|
34
|
-
export async function assetsUpload(
|
|
100
|
+
export async function assetsUpload(
|
|
101
|
+
ctx: Ctx, file: string, opts: { describe?: string; footage?: boolean; green?: boolean; cutout?: boolean; resume?: string; share?: boolean; kind?: string; tags?: string },
|
|
102
|
+
deps: { key?: typeof keyGreen } & CutoutDeps = {},
|
|
103
|
+
): Promise<Result> {
|
|
35
104
|
const project = openProject(ctx.cwd);
|
|
105
|
+
if (opts.green && opts.cutout) return { ok: false, summary: "Use --green or --cutout, not both: --green keys a green background on this machine for free, --cutout removes any background on the server." };
|
|
106
|
+
if (opts.resume && !opts.cutout) return { ok: false, summary: "--resume goes with --cutout: it keeps waiting for a cutout that was already started." };
|
|
107
|
+
if (opts.resume) return resumeUploadCutout(ctx, project, file, opts.resume, deps);
|
|
36
108
|
if (opts.share && opts.kind === "component") return { ok: false, summary: "Components cannot be shared from the CLI yet. Share images, overlays, sound effects, music or clips." };
|
|
37
109
|
const source = resolve(ctx.cwd, file);
|
|
38
110
|
let probe: Awaited<ReturnType<typeof probeFile>>;
|
|
@@ -42,15 +114,31 @@ export async function assetsUpload(ctx: Ctx, file: string, opts: { describe?: st
|
|
|
42
114
|
return { ok: false, summary: e instanceof Error ? e.message : String(e) };
|
|
43
115
|
}
|
|
44
116
|
if (opts.footage && probe.kind !== "video") return { ok: false, summary: `${file} is ${probe.kind}, not a video, so it cannot be the footage.` };
|
|
117
|
+
if (opts.green && probe.kind !== "video") return { ok: false, summary: `${file} is ${probe.kind}, not a video, so there is no green screen to key out.` };
|
|
118
|
+
const refusal = opts.cutout ? cutoutRefusal(file, probe) : undefined;
|
|
119
|
+
if (refusal) return { ok: false, summary: refusal };
|
|
45
120
|
const kind = opts.share ? UploadKindSchema.safeParse(opts.kind) : undefined;
|
|
46
121
|
if (opts.share && (!kind?.success || !opts.describe)) return { ok: false, summary: "Sharing needs --kind (image, overlay, sfx, music or clip) and --describe \"what it is\"." };
|
|
47
122
|
|
|
48
123
|
const id = `a-${randomUUID().slice(0, 8)}`;
|
|
49
124
|
const filename = basename(source).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
50
125
|
const key = `assets/user/${id}-${filename}`;
|
|
126
|
+
// A cutout is made on the server and paid for when it is run, so it is started before anything is registered: if the server refuses
|
|
127
|
+
// (quota used up, no GPU service) or the upload fails, nothing is left behind.
|
|
128
|
+
const cutoutId = opts.cutout ? await startCutout(ctx, { source, filename, contentType: probe.contentType as CutoutType, bytes: probe.bytes, durationSec: probe.durationSec! }) : undefined;
|
|
51
129
|
mkdirSync(dirname(project.path(key)), { recursive: true });
|
|
52
130
|
copyFileSync(source, project.path(key));
|
|
53
|
-
|
|
131
|
+
// A subject filmed on green gets a second copy with the green made transparent, sound kept. Nothing is registered if it cannot be keyed.
|
|
132
|
+
let keyedKey: string | undefined;
|
|
133
|
+
if (opts.green) {
|
|
134
|
+
keyedKey = key.replace(/\.[^./]+$/, "") + ".webm";
|
|
135
|
+
try { await (deps.key ?? keyGreen)(project.path(key), project.path(keyedKey), { keepAudio: true, requireGreen: true }); }
|
|
136
|
+
catch (e) {
|
|
137
|
+
rmSync(project.path(key), { force: true });
|
|
138
|
+
return { ok: false, summary: e instanceof Error ? e.message : String(e) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const record: AssetRecord = { id, filename, key, ...probe, ...(opts.describe ? { description: opts.describe } : {}), ...(keyedKey ? { keyedKey } : {}), ...(cutoutId ? { cutoutId } : {}), createdAt: new Date().toISOString() };
|
|
54
142
|
project.writeJson(FILES.assetIndex, [...project.assets(), record]);
|
|
55
143
|
if (opts.footage) project.writeJson(FILES.config, { ...project.config(), footage: id });
|
|
56
144
|
|
|
@@ -66,12 +154,52 @@ export async function assetsUpload(ctx: Ctx, file: string, opts: { describe?: st
|
|
|
66
154
|
await uploadTo(up.uploadUrl, new Uint8Array(readFileSync(source)), probe.contentType);
|
|
67
155
|
libraryId = (await api("libraryCommit", { id: up.id })).item.id;
|
|
68
156
|
}
|
|
157
|
+
if (cutoutId) {
|
|
158
|
+
const finished = await finishUploadCutout(ctx, project, record, file, deps);
|
|
159
|
+
return { ...finished, data: { ...(finished.data as object), ...(libraryId ? { libraryId } : {}) }, summary: `Added ${filename} as ${id}${opts.footage ? ", set as the footage" : ""}${libraryId ? `, shared to the library as ${libraryId} (awaiting review)` : ""}. ${finished.summary}` };
|
|
160
|
+
}
|
|
69
161
|
return {
|
|
70
162
|
ok: true, data: { ...record, ...(libraryId ? { libraryId } : {}) },
|
|
71
|
-
summary: `Added ${filename} as ${id} (${probe.kind})${opts.footage ? ", set as the footage" : ""}${libraryId ? `, shared to the library as ${libraryId} (awaiting review)` : ", private to this project"}
|
|
163
|
+
summary: `Added ${filename} as ${id} (${probe.kind})${opts.footage ? ", set as the footage" : ""}${libraryId ? `, shared to the library as ${libraryId} (awaiting review)` : ", private to this project"}.${keyedKey ? ` The green is keyed out into ${keyedKey}: place it with KeyedClip as urls["${keyedKey}"].` : ""}`,
|
|
72
164
|
};
|
|
73
165
|
}
|
|
74
166
|
|
|
167
|
+
// Waits for the cutout of a registered video and saves it beside the original. The asset stays registered whatever happens.
|
|
168
|
+
async function finishUploadCutout(ctx: Ctx, project: Project, record: AssetRecord, file: string, deps: CutoutDeps): Promise<Result> {
|
|
169
|
+
const cutoutId = record.cutoutId!;
|
|
170
|
+
const keyedKey = record.key.replace(/\.[^./]+$/, "") + ".webm";
|
|
171
|
+
const save = (r: AssetRecord) => project.writeJson(FILES.assetIndex, project.assets().map((a) => (a.id === r.id ? r : a)));
|
|
172
|
+
const outcome = await pollCutout(ctx, cutoutId, project.path(keyedKey), deps);
|
|
173
|
+
const resume = `reelkit assets upload ${shellArg(file)} --cutout --resume ${cutoutId}`;
|
|
174
|
+
const { cutoutId: _pending, ...settled } = record;
|
|
175
|
+
if (outcome.kind === "done") {
|
|
176
|
+
const done: AssetRecord = { ...settled, keyedKey };
|
|
177
|
+
save(done);
|
|
178
|
+
const seconds = Math.ceil(record.durationSec ?? 0);
|
|
179
|
+
return { ok: true, data: done, summary: `The subject is cut out into ${keyedKey}: place it with KeyedClip as urls["${keyedKey}"]. It used ${seconds} second${seconds === 1 ? "" : "s"} of your monthly cutout quota. ${SENT_NOTE}` };
|
|
180
|
+
}
|
|
181
|
+
if (outcome.kind === "failed") {
|
|
182
|
+
save(settled);
|
|
183
|
+
return { ok: false, data: { ...settled, status: "failed" }, summary: `The cutout failed: ${outcome.message} You were not charged. The video stays added without a cutout; try a different video. ${SENT_NOTE}` };
|
|
184
|
+
}
|
|
185
|
+
if (outcome.kind === "timeout") {
|
|
186
|
+
return { ok: false, data: { ...record, status: "pending" }, summary: `The video stays added without a cutout: the cutout (id ${cutoutId}) is not ready after ${CUTOUT_TIMEOUT_MS / 60_000} minutes. It is still being made and is not lost: run \`${resume}\` to keep waiting for it; that does not upload or charge again.` };
|
|
187
|
+
}
|
|
188
|
+
return { ok: false, data: { ...record, status: "pending" }, summary: `The video stays added without a cutout: contact was lost while it was being made (${outcome.message}) The cutout (id ${cutoutId}) is not lost: run \`${resume}\`; that does not upload or charge again.` };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Picks up a cutout that was started earlier: finds the asset waiting for it and only polls.
|
|
192
|
+
async function resumeUploadCutout(ctx: Ctx, project: Project, file: string, id: string, deps: CutoutDeps): Promise<Result> {
|
|
193
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(id)) return { ok: false, summary: `"${id}" is not a cutout id. Copy it from the earlier message.` };
|
|
194
|
+
const record = project.assets().find((a) => a.cutoutId === id);
|
|
195
|
+
if (!record) return { ok: false, summary: `No asset in this project is waiting for cutout ${id}. Copy the id from the earlier message.` };
|
|
196
|
+
const name = basename(file).replace(/[^A-Za-z0-9._-]/g, "_");
|
|
197
|
+
if (name !== record.filename) return { ok: false, summary: `Cutout ${id} is for ${record.filename}, not ${file}. Give the file it was started with.` };
|
|
198
|
+
ctx.log(`Waiting for cutout ${id} of ${record.filename}.`);
|
|
199
|
+
const finished = await finishUploadCutout(ctx, project, record, file, deps);
|
|
200
|
+
return { ...finished, summary: `Resumed ${record.filename} (${record.id}). ${finished.summary}` };
|
|
201
|
+
}
|
|
202
|
+
|
|
75
203
|
export async function assetsSearch(ctx: Ctx, query: string, opts: { kind?: string; limit?: string }): Promise<Result> {
|
|
76
204
|
const kind = opts.kind === undefined ? undefined : LibraryKindSchema.safeParse(opts.kind);
|
|
77
205
|
if (kind && !kind.success) return { ok: false, summary: `Unknown kind "${opts.kind}". Use one of: ${LibraryKindSchema.options.join(", ")}.` };
|
|
@@ -90,19 +218,35 @@ export function unsafeName(name: string, kind: string): boolean {
|
|
|
90
218
|
return kind === "component" && (!FILE_NAME.test(name) || name === "Video.tsx");
|
|
91
219
|
}
|
|
92
220
|
|
|
93
|
-
|
|
221
|
+
// What a test may replace: the ffmpeg call that keys green out, and the clock the clip wait is timed by.
|
|
222
|
+
export type ClipDeps = { key?: (input: string, output: string) => Promise<void>; now?: () => number };
|
|
223
|
+
|
|
224
|
+
// The keyed copy sits beside its clip: clip.mp4 becomes clip.webm.
|
|
225
|
+
const keyedPathOf = (path: string) => path.replace(/\.[^./]+$/, "") + ".webm";
|
|
226
|
+
|
|
227
|
+
function setSceneClip(project: Project, sceneId: string, record: ClipRecord) {
|
|
228
|
+
project.writeJson(FILES.clips, { ...project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {}), [sceneId]: record });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; force?: boolean }, deps: ClipDeps = {}): Promise<Result> {
|
|
94
232
|
const project = openProject(ctx.cwd);
|
|
95
233
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) return { ok: false, summary: `"${id}" is not a library id. Copy the id from \`reelkit assets search\`.` };
|
|
96
234
|
if (opts.scene) {
|
|
97
235
|
// Checked before anything is downloaded.
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
236
|
+
const scenes = loadPlan(project).scenes;
|
|
237
|
+
const illustrations = scenes.filter((s) => s.treatment === "illustration").map((s) => s.id);
|
|
238
|
+
const clipScenes = scenes.filter((s) => s.treatment === "clip").map((s) => s.id);
|
|
239
|
+
if (!illustrations.includes(opts.scene) && !clipScenes.includes(opts.scene)) {
|
|
240
|
+
return { ok: false, summary: `Scene ${opts.scene} is not an illustration scene in plan.json. ${illustrations.length ? `The illustration scenes are: ${illustrations.join(", ")}.` : "The plan has no illustration scenes."}${clipScenes.length ? ` The clip scenes are: ${clipScenes.join(", ")}.` : ""}` };
|
|
101
241
|
}
|
|
102
242
|
}
|
|
103
243
|
const pulled = await client(ctx)("libraryPull", { id });
|
|
104
244
|
if (unsafeName(pulled.filename, pulled.item.kind)) return { ok: false, summary: `The library returned an unsafe file name for ${id}, so nothing was written. Report this item.` };
|
|
105
|
-
if (opts.scene
|
|
245
|
+
if (opts.scene) {
|
|
246
|
+
const treatment = loadPlan(project).scenes.find((x) => x.id === opts.scene)?.treatment;
|
|
247
|
+
if (treatment === "clip" && pulled.item.kind !== "clip") return { ok: false, summary: `${id} is ${pulled.item.kind}, not a clip, so it cannot be scene ${opts.scene}'s clip.` };
|
|
248
|
+
if (treatment !== "clip" && pulled.item.kind !== "image") return { ok: false, summary: `${id} is ${pulled.item.kind}, not an image, so it cannot be a scene's image.` };
|
|
249
|
+
}
|
|
106
250
|
// A component is code, not media: it goes beside Video.tsx, which may only import files in src/.
|
|
107
251
|
if (pulled.item.kind === "component") {
|
|
108
252
|
const path = `src/${pulled.filename}`;
|
|
@@ -116,15 +260,24 @@ export async function assetsPull(ctx: Ctx, id: string, opts: { scene?: string; f
|
|
|
116
260
|
const path = `assets/lib/${id}/${pulled.filename}`;
|
|
117
261
|
await download(pulled.url, project.path(path));
|
|
118
262
|
recordPull(project, id, { path, kind: pulled.item.kind, title: pulled.item.title, meta: pulled.item.meta });
|
|
263
|
+
// A clip filmed on green also comes keyed, so it can be laid over a background.
|
|
264
|
+
const green = pulled.item.kind === "clip" && pulled.item.meta.greenScreen === true;
|
|
265
|
+
const keyedPath = green ? keyedPathOf(path) : undefined;
|
|
266
|
+
if (keyedPath) await (deps.key ?? keyGreen)(project.path(path), project.path(keyedPath));
|
|
267
|
+
const sceneIsClip = pulled.item.kind === "clip";
|
|
119
268
|
let note: string | undefined;
|
|
120
269
|
if (opts.scene) {
|
|
121
|
-
|
|
270
|
+
if (sceneIsClip) {
|
|
271
|
+
const durationSec = typeof pulled.item.meta.durationSec === "number" ? pulled.item.meta.durationSec : 5;
|
|
272
|
+
setSceneClip(project, opts.scene, { key: path, greenScreen: green, durationSec, ...(keyedPath ? { keyedKey: keyedPath } : {}) });
|
|
273
|
+
} else setSceneImage(project, opts.scene, path);
|
|
122
274
|
if (project.exists(FILES.plan)) note = tryManifest(project, loadPlan(project)).note;
|
|
123
275
|
}
|
|
276
|
+
const refs = keyedPath ? ` The original is urls["${path}"]; the keyed copy with a transparent background is urls["${keyedPath}"].` : ` Reference it in the composition as urls["${path}"].`;
|
|
124
277
|
return {
|
|
125
|
-
ok: true, data: { path, kind: pulled.item.kind },
|
|
278
|
+
ok: true, data: { path, kind: pulled.item.kind, ...(keyedPath ? { keyedPath } : {}) },
|
|
126
279
|
summary: [
|
|
127
|
-
opts.scene ? `Pulled ${id} as the image for scene ${opts.scene}
|
|
280
|
+
opts.scene ? `Pulled ${id} as the ${sceneIsClip ? "clip" : "image"} for scene ${opts.scene}.${keyedPath ? refs : ""}` : `Pulled ${id} to ${path}.${refs}`,
|
|
128
281
|
...(note ? [note] : []),
|
|
129
282
|
].join("\n"),
|
|
130
283
|
};
|
|
@@ -194,3 +347,169 @@ export async function assetsGenImage(ctx: Ctx, prompt: string | undefined, opts:
|
|
|
194
347
|
summary: [`Generated the image for scene ${scene.id} at ${path}${img.libraryId ? `, and shared it to the library as ${img.libraryId} (awaiting review)` : ""}.`, ...(note ? [note] : [])].join("\n"),
|
|
195
348
|
};
|
|
196
349
|
}
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
const CLIP_POLL_MS = 5_000;
|
|
353
|
+
const CLIP_PROGRESS_MS = 30_000;
|
|
354
|
+
const CLIP_TIMEOUT_MS = 10 * 60_000;
|
|
355
|
+
|
|
356
|
+
// A clip takes minutes, so it is a job: start it (which reserves the clip from the quota), then ask every few seconds until it ends.
|
|
357
|
+
// The id is the only handle on a paid job, so every way out after the start says it and how to resume.
|
|
358
|
+
export async function assetsGenClip(
|
|
359
|
+
ctx: Ctx, prompt: string | undefined,
|
|
360
|
+
opts: { scene: string; green?: boolean; cutout?: boolean; seconds?: string; share?: boolean; redo?: boolean; resume?: string },
|
|
361
|
+
deps: ClipDeps = {},
|
|
362
|
+
): Promise<Result> {
|
|
363
|
+
const project = openProject(ctx.cwd);
|
|
364
|
+
const plan = loadPlan(project);
|
|
365
|
+
const scene = plan.scenes.find((s) => s.id === opts.scene);
|
|
366
|
+
if (!scene || scene.treatment !== "clip" || !scene.clipPrompt) return { ok: false, summary: `Scene ${opts.scene} is not a clip scene, so it does not take a clip. Set its treatment to "clip" and give it a clipPrompt in plan.json.` };
|
|
367
|
+
if (opts.green && opts.cutout) return { ok: false, summary: "Use --green or --cutout, not both: --green asks for a green background and keys it on this machine for free, --cutout removes any background on the server." };
|
|
368
|
+
const seconds = opts.seconds === undefined ? 5 : Number(opts.seconds);
|
|
369
|
+
if (seconds !== 5 && seconds !== 10) return { ok: false, summary: `--seconds must be 5 or 10, not "${opts.seconds}".` };
|
|
370
|
+
if (opts.resume && !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(opts.resume)) return { ok: false, summary: `"${opts.resume}" is not a clip id. Copy it from the earlier message.` };
|
|
371
|
+
if (opts.share && (!scene.shareable || scene.userAssetIds.length > 0 || project.footage())) {
|
|
372
|
+
return { ok: false, summary: `Scene ${scene.id} is private (it is not marked shareable in plan.json, or it shows your own files), so --share is refused. Run again without --share.` };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const key = deps.key ?? keyGreen;
|
|
376
|
+
const now = deps.now ?? Date.now;
|
|
377
|
+
const keyedKey = `assets/clip-${scene.id}.webm`;
|
|
378
|
+
const records = project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {});
|
|
379
|
+
const have = records[scene.id];
|
|
380
|
+
// A scene that already has its clip is not charged for again, whatever prompt is given; only --redo asks for a new one.
|
|
381
|
+
if (have && project.exists(have.key) && !opts.redo && !opts.resume) {
|
|
382
|
+
if (opts.cutout) {
|
|
383
|
+
if (have.keyedKey && project.exists(have.keyedKey)) return { ok: true, data: { path: have.key, keyedPath: have.keyedKey, skipped: true }, summary: `Scene ${scene.id} already has a clip at ${have.key} and its cutout at ${have.keyedKey}. Use --redo to generate a new clip.` };
|
|
384
|
+
// The clip was paid for but its cutout was not made (or is still being made): only the cutout is done, and the clip is not charged again.
|
|
385
|
+
const cut = await cutoutScene(ctx, project, scene.id, have, now);
|
|
386
|
+
return { ...cut, data: { path: have.key, ...(cut.ok ? { keyedPath: keyedKey } : {}) }, summary: cut.ok ? `Cut out scene ${scene.id}'s clip. ${cut.summary}` : cut.summary };
|
|
387
|
+
}
|
|
388
|
+
if (opts.green && have.greenScreen && !have.keyedKey) {
|
|
389
|
+
// The clip was paid for but not keyed (ffmpeg failed or --green came later): keying is free, so do it now.
|
|
390
|
+
const keyed = await keyScene(project, scene.id, have, key);
|
|
391
|
+
if (!keyed.ok) return keyed;
|
|
392
|
+
return { ok: true, data: { path: have.key, keyedPath: keyedKey }, summary: `Keyed the green out of scene ${scene.id}'s clip into ${keyedKey}.` };
|
|
393
|
+
}
|
|
394
|
+
return { ok: true, data: { path: have.key, skipped: true }, summary: `Scene ${scene.id} already has a clip at ${have.key}. Use --redo to generate a new one.` };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const api = client(ctx);
|
|
398
|
+
const started = now();
|
|
399
|
+
let id = opts.resume;
|
|
400
|
+
if (!id) {
|
|
401
|
+
const job = await api("clipStart", {
|
|
402
|
+
prompt: prompt ?? scene.clipPrompt, aspect: plan.aspect, greenScreen: Boolean(opts.green), durationSec: seconds,
|
|
403
|
+
shareable: Boolean(opts.share), tags: scene.imageTags,
|
|
404
|
+
});
|
|
405
|
+
id = job.id;
|
|
406
|
+
ctx.log(`Started clip ${id} for scene ${scene.id}. It usually takes a few minutes.`);
|
|
407
|
+
} else ctx.log(`Waiting for clip ${id} for scene ${scene.id}.`);
|
|
408
|
+
const resume = `reelkit assets gen clip --resume ${id} --scene ${scene.id}${opts.cutout ? " --cutout" : ""}`;
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
let lastLog = started;
|
|
412
|
+
for (;;) {
|
|
413
|
+
const st = await api("clipStatus", { id });
|
|
414
|
+
if (st.status === "failed") {
|
|
415
|
+
return { ok: false, data: { id, status: "failed" }, summary: `The clip for scene ${scene.id} failed: ${st.message ?? "the video provider gave no reason."} You were not charged. Change the prompt and run the command again.` };
|
|
416
|
+
}
|
|
417
|
+
if (st.status === "done") {
|
|
418
|
+
const path = `assets/clip-${scene.id}.${st.ext}`;
|
|
419
|
+
await download(st.url!, project.path(path));
|
|
420
|
+
const green = Boolean(st.greenScreen ?? opts.green);
|
|
421
|
+
const record: ClipRecord = { key: path, greenScreen: green, durationSec: st.durationSec ?? seconds };
|
|
422
|
+
setSceneClip(project, scene.id, record);
|
|
423
|
+
let keyedPath: string | undefined;
|
|
424
|
+
if (green || opts.green) {
|
|
425
|
+
// The paid clip is already saved above; if keying fails, running the command again keys it without a new charge.
|
|
426
|
+
const keyed = await keyScene(project, scene.id, { ...record, greenScreen: true }, key);
|
|
427
|
+
if (!keyed.ok) return keyed;
|
|
428
|
+
keyedPath = keyedKey;
|
|
429
|
+
}
|
|
430
|
+
let cutoutNote: string | undefined;
|
|
431
|
+
if (opts.cutout) {
|
|
432
|
+
// The paid clip is already saved above; if the cutout fails, running the command again does only the cutout.
|
|
433
|
+
const cut = await cutoutScene(ctx, project, scene.id, record, now);
|
|
434
|
+
const shared = st.libraryId ? `, and shared it to the library as ${st.libraryId} (awaiting review)` : "";
|
|
435
|
+
if (!cut.ok) return { ...cut, data: { id, path, durationSec: record.durationSec, libraryId: st.libraryId ?? null }, summary: `Generated the clip for scene ${scene.id}${shared}. ${cut.summary}` };
|
|
436
|
+
keyedPath = keyedKey;
|
|
437
|
+
cutoutNote = cut.summary;
|
|
438
|
+
}
|
|
439
|
+
const { note } = tryManifest(project, plan);
|
|
440
|
+
return {
|
|
441
|
+
ok: true, data: { id, path, durationSec: record.durationSec, ...(keyedPath ? { keyedPath } : {}), libraryId: st.libraryId ?? null },
|
|
442
|
+
summary: [
|
|
443
|
+
`Generated the clip for scene ${scene.id} at ${path}${keyedPath && !cutoutNote ? `, keyed to ${keyedPath}` : ""}${st.libraryId ? `, and shared it to the library as ${st.libraryId} (awaiting review)` : ""}.${cutoutNote ? ` ${cutoutNote}` : ""}`,
|
|
444
|
+
...(note ? [note] : []),
|
|
445
|
+
].join("\n"),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
const elapsed = now() - started;
|
|
449
|
+
if (elapsed >= CLIP_TIMEOUT_MS) {
|
|
450
|
+
return { ok: false, data: { id, status: "pending" }, summary: `The clip for scene ${scene.id} (id ${id}) is not ready after ${CLIP_TIMEOUT_MS / 60_000} minutes. It is still being made and is not lost: run \`${resume}\` to keep waiting for it; that does not charge again.` };
|
|
451
|
+
}
|
|
452
|
+
if (now() - lastLog >= CLIP_PROGRESS_MS) {
|
|
453
|
+
lastLog = now();
|
|
454
|
+
ctx.log(`Still making the clip for scene ${scene.id} (${Math.round(elapsed / 1000)}s so far)...`);
|
|
455
|
+
}
|
|
456
|
+
await ctx.sleep(CLIP_POLL_MS);
|
|
457
|
+
}
|
|
458
|
+
} catch (e) {
|
|
459
|
+
// A clip that is not found or a login that is wrong has nothing to resume; anything else may be a hiccup on a clip that is paid for.
|
|
460
|
+
if (e instanceof ApiFailure && (e.code === "not_found" || e.code === "unauthenticated")) throw e;
|
|
461
|
+
return { ok: false, data: { id }, summary: `Lost contact while the clip for scene ${scene.id} (id ${id}) was being made: ${e instanceof Error ? e.message : String(e)} The clip is not lost: run \`${resume}\`; that does not charge again.` };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Cuts the subject out of a scene's saved clip on the server and records the transparent copy as the clip's keyed copy. The id of a job
|
|
466
|
+
// that is still being made is kept in the record, so running the command again waits for it instead of paying again. The clip stays
|
|
467
|
+
// whatever happens here, and every way out says so and how to try the cutout again.
|
|
468
|
+
async function cutoutScene(ctx: Ctx, project: Project, sceneId: string, record: ClipRecord, now: () => number): Promise<Result> {
|
|
469
|
+
const keyedKey = `assets/clip-${sceneId}.webm`;
|
|
470
|
+
const retry = `reelkit assets gen clip --scene ${sceneId} --cutout`;
|
|
471
|
+
const saved = `The clip is saved at ${record.key}`;
|
|
472
|
+
let id = record.cutoutId;
|
|
473
|
+
if (!id) {
|
|
474
|
+
try {
|
|
475
|
+
id = await startCutout(ctx, { source: project.path(record.key), filename: basename(record.key), contentType: "video/mp4", bytes: statSync(project.path(record.key)).size, durationSec: record.durationSec });
|
|
476
|
+
} catch (e) {
|
|
477
|
+
return { ok: false, summary: `${saved}, but the cutout could not be started: ${e instanceof Error ? e.message : String(e)} Run \`${retry}\` to try the cutout again; the clip is not charged again.` };
|
|
478
|
+
}
|
|
479
|
+
setSceneClip(project, sceneId, { ...record, cutoutId: id });
|
|
480
|
+
} else ctx.log(`Waiting for cutout ${id} of scene ${sceneId}'s clip.`);
|
|
481
|
+
const { cutoutId: _pending, ...settled } = record;
|
|
482
|
+
let outcome: CutoutOutcome;
|
|
483
|
+
try {
|
|
484
|
+
outcome = await pollCutout(ctx, id, project.path(keyedKey), { now });
|
|
485
|
+
} catch (e) {
|
|
486
|
+
if (!(e instanceof ApiFailure && e.code === "not_found")) throw e;
|
|
487
|
+
setSceneClip(project, sceneId, settled);
|
|
488
|
+
return { ok: false, summary: `${saved}, but the server no longer has cutout ${id}, so it was forgotten. Run \`${retry}\` to start a new one; the clip is not charged again.` };
|
|
489
|
+
}
|
|
490
|
+
if (outcome.kind === "done") {
|
|
491
|
+
setSceneClip(project, sceneId, { ...settled, keyedKey });
|
|
492
|
+
const seconds = Math.ceil(record.durationSec);
|
|
493
|
+
return { ok: true, summary: `The subject is cut out into ${keyedKey} (${seconds} second${seconds === 1 ? "" : "s"} of your monthly cutout quota). ${SENT_NOTE}` };
|
|
494
|
+
}
|
|
495
|
+
if (outcome.kind === "failed") {
|
|
496
|
+
setSceneClip(project, sceneId, settled);
|
|
497
|
+
return { ok: false, summary: `${saved}, but the cutout failed: ${outcome.message} You were not charged for the cutout. Run \`${retry}\` to try the cutout again; the clip is not charged again. ${SENT_NOTE}` };
|
|
498
|
+
}
|
|
499
|
+
if (outcome.kind === "timeout") {
|
|
500
|
+
return { ok: false, summary: `${saved}, but the cutout (id ${id}) is not ready after ${CUTOUT_TIMEOUT_MS / 60_000} minutes. It is still being made and is not lost: run \`${retry}\` to keep waiting for it; that does not upload or charge again. ${SENT_NOTE}` };
|
|
501
|
+
}
|
|
502
|
+
return { ok: false, summary: `${saved}, but contact was lost while the cutout (id ${id}) was being made: ${outcome.message} It is not lost: run \`${retry}\`; that does not upload or charge again.` };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Keys a scene's saved clip and records the keyed copy. A failure leaves the record as it was.
|
|
506
|
+
async function keyScene(project: Project, sceneId: string, record: ClipRecord, key: NonNullable<ClipDeps["key"]>): Promise<Result> {
|
|
507
|
+
const keyedKey = `assets/clip-${sceneId}.webm`;
|
|
508
|
+
try {
|
|
509
|
+
await key(project.path(record.key), project.path(keyedKey));
|
|
510
|
+
} catch (e) {
|
|
511
|
+
return { ok: false, data: { path: record.key }, summary: `The clip is saved at ${record.key}, but the green could not be keyed out: ${e instanceof Error ? e.message : String(e)}` };
|
|
512
|
+
}
|
|
513
|
+
setSceneClip(project, sceneId, { ...record, greenScreen: true, keyedKey });
|
|
514
|
+
return { ok: true, summary: "" };
|
|
515
|
+
}
|
package/src/commands/auth.ts
CHANGED
|
@@ -113,6 +113,6 @@ export async function whoami(ctx: Ctx): Promise<Result> {
|
|
|
113
113
|
const q = me.quota;
|
|
114
114
|
return {
|
|
115
115
|
ok: true, data: me,
|
|
116
|
-
summary: `${me.handle}\nVoiceover: ${q.voiceoverChars.used}/${q.voiceoverChars.limit} characters\nImages: ${q.images.used}/${q.images.limit}\nResets: ${q.resetsAt.slice(0, 10)}\nContributions: ${me.contributions}`,
|
|
116
|
+
summary: `${me.handle}\nVoiceover: ${q.voiceoverChars.used}/${q.voiceoverChars.limit} characters\nImages: ${q.images.used}/${q.images.limit}\nClips: ${q.clips.used}/${q.clips.limit}\nCutouts: ${q.cutoutSeconds.used}/${q.cutoutSeconds.limit} seconds\nResets: ${q.resetsAt.slice(0, 10)}\nContributions: ${me.contributions}`,
|
|
117
117
|
};
|
|
118
118
|
}
|
package/src/contract/index.ts
CHANGED
|
@@ -39,9 +39,34 @@
|
|
|
39
39
|
// a file URL serves one file, an upload URL accepts uploads for one object only.
|
|
40
40
|
// - The public listing's `previewUrl` is a preview that needs no login. An item with a preview of its own gets that one; otherwise an `image`
|
|
41
41
|
// or an `sfx` gets a URL of its own file (a picture and a short sound need no login to look at or hear), and every other kind (music,
|
|
42
|
-
// overlay, clip, component), whose full file is long media or code, gets no `previewUrl`.
|
|
42
|
+
// overlay, clip, component), whose full file is long media or code, gets no `previewUrl`. The one exception is a clip of ten seconds
|
|
43
|
+
// or less (`meta.durationSec`): short generated clips are made to be shared, so such a clip is previewed by its own file.
|
|
43
44
|
// - Every returned URL needs no headers and stays valid for at least five minutes.
|
|
44
45
|
// - The generation routes (`voiceover`, `images`) are synchronous: they return the URL of the finished file.
|
|
46
|
+
// - A video clip takes minutes, so it is a job with two calls. `clipStart` checks the request, reserves one clip from the caller's monthly clip
|
|
47
|
+
// quota and answers at once with an id and `pending`; the client then asks `clipStatus` for that id until it is `done` or `failed`.
|
|
48
|
+
// `url`, `ext` and `contentType` are present exactly when the status is `done`, and `message` (one plain line saying what went wrong)
|
|
49
|
+
// exactly when it is `failed`. A failed clip is not charged: the reservation is released. A clip id belongs to its caller: another user's id,
|
|
50
|
+
// or an unknown one, is `not_found`.
|
|
51
|
+
// - When the quota is used up, `clipStart` is 429 `quota_exceeded` with the reset date, as for images. When the server has no video provider
|
|
52
|
+
// configured, `clipStart` is 400 `invalid_request` with the message "Clip generation is not available on this server yet."
|
|
53
|
+
// - A green-screen clip (`greenScreen: true`) is a subject filmed on a flat pure green background, meant to be keyed out by the client. The server
|
|
54
|
+
// stores and returns the original, green and all; it does not remove the background. A shareable clip also becomes a library item of kind
|
|
55
|
+
// `clip` in review, whose `meta` may carry `greenScreen: true` and `durationSec`, and its id is returned as `libraryId`.
|
|
56
|
+
// - A cutout removes the background from a video, whatever it is, and answers with the subject on a transparent background. The video is sent to
|
|
57
|
+
// the server, a GPU model separates the subject, and a transparent `.webm` (VP9 with alpha, sound kept) comes back. It takes about a minute.
|
|
58
|
+
// It is a job with three calls: `cutoutStart` checks the request and answers with an id and an `uploadUrl` (charging nothing; the client sends
|
|
59
|
+
// the file there exactly as for a library upload); `cutoutRun` checks that the file arrived (else 400 `invalid_request`), reserves
|
|
60
|
+
// `ceil(durationSec)` seconds from the caller's monthly cutout quota and starts the job; the client then asks `cutoutStatus` until it is `done`
|
|
61
|
+
// or `failed`, with the same exactly-when rules as `clipStatus` (`url`, `ext` and `contentType` when done, `message` when failed).
|
|
62
|
+
// `cutoutStatus` for a job whose `cutoutRun` has not been called is 400 `invalid_request`.
|
|
63
|
+
// `cutoutRun` for a job already started answers its current pending state and does not charge again. A failed cutout is not charged.
|
|
64
|
+
// A cutout id belongs to its caller: another user's id, or an unknown one, is `not_found` for run and status.
|
|
65
|
+
// - The video uploaded for a cutout is used only to make the cutout, and is deleted from the server when the job ends. The result stays in
|
|
66
|
+
// the user's private files and is never shared. A cutout is charged in whole seconds of video (`ceil(durationSec)`), and a video longer than
|
|
67
|
+
// 20 seconds is refused by the request schema.
|
|
68
|
+
// - When the cutout quota is used up, `cutoutRun` is 429 `quota_exceeded` with the reset date. When the server has no GPU service configured,
|
|
69
|
+
// `cutoutStart` and `cutoutRun` are 400 `invalid_request` with the message "Background removal is not available on this server yet."
|
|
45
70
|
// - `durationSec` is the decoded audio length; `words` are in seconds from the start of that audio.
|
|
46
71
|
// - Search returns published items only, and leaves out items that are not matches at all; the default `limit` is 8.
|
|
47
72
|
import { z } from "zod";
|
|
@@ -71,6 +96,11 @@ const Meta = z.record(z.string(), z.unknown()).refine((m) => new TextEncoder().e
|
|
|
71
96
|
|
|
72
97
|
// The most a single upload may be: 200 MB.
|
|
73
98
|
export const MAX_UPLOAD_BYTES = 209_715_200;
|
|
99
|
+
// The video types a cutout takes.
|
|
100
|
+
export const CutoutTypeSchema = z.enum(["video/mp4", "video/quicktime", "video/webm"]);
|
|
101
|
+
export type CutoutType = z.infer<typeof CutoutTypeSchema>;
|
|
102
|
+
// The longest video a cutout takes, in seconds.
|
|
103
|
+
export const MAX_CUTOUT_SECONDS = 20;
|
|
74
104
|
|
|
75
105
|
export const ErrorCodeSchema = z.enum(["unauthenticated", "quota_exceeded", "not_found", "invalid_request", "server_error"]);
|
|
76
106
|
export type ErrorCode = z.infer<typeof ErrorCodeSchema>;
|
|
@@ -89,7 +119,8 @@ export const LibraryItemSchema = z.object({
|
|
|
89
119
|
title: z.string(),
|
|
90
120
|
description: z.string(),
|
|
91
121
|
tags: z.array(z.string()),
|
|
92
|
-
// width, height, durationSec, aspect, props, source, licence: whatever applies to the kind.
|
|
122
|
+
// width, height, durationSec, aspect, props, source, licence: whatever applies to the kind. A clip may carry `greenScreen: true` (filmed on a
|
|
123
|
+
// flat green background, to be keyed out) and `durationSec`.
|
|
93
124
|
meta: z.record(z.string(), z.unknown()),
|
|
94
125
|
visibility: z.enum(["private", "review", "published"]),
|
|
95
126
|
});
|
|
@@ -105,7 +136,7 @@ export type Voice = z.infer<typeof VoiceSchema>;
|
|
|
105
136
|
const Meter = z.object({ used: z.number(), limit: z.number() });
|
|
106
137
|
export const MeSchema = z.object({
|
|
107
138
|
userId: z.string(), handle: z.string(),
|
|
108
|
-
quota: z.object({ voiceoverChars: Meter, images: Meter, resetsAt: z.string() }),
|
|
139
|
+
quota: z.object({ voiceoverChars: Meter, images: Meter, clips: Meter, cutoutSeconds: Meter, resetsAt: z.string() }),
|
|
109
140
|
// How many of the user's own items are published in the shared library. An item still in review, or sent back, is not counted.
|
|
110
141
|
contributions: z.number(),
|
|
111
142
|
});
|
|
@@ -148,6 +179,34 @@ export const routes = {
|
|
|
148
179
|
z.object({ prompt: text(2000).min(1), aspect: AspectSchema, shareable: z.boolean(), tags: z.array(text(40)).max(12) }),
|
|
149
180
|
// libraryId is set when the image was also added to the shared library.
|
|
150
181
|
z.object({ url: z.string(), ext: z.enum(["png", "jpg", "jpeg", "webp"]), contentType: z.string(), libraryId: z.string().optional() })),
|
|
182
|
+
clipStart: route("POST", "/clips", true,
|
|
183
|
+
z.object({
|
|
184
|
+
prompt: text(2000).min(1), aspect: AspectSchema, greenScreen: z.boolean().default(false), durationSec: z.union([z.literal(5), z.literal(10)]).default(5),
|
|
185
|
+
shareable: z.boolean(), tags: z.array(text(40)).max(12),
|
|
186
|
+
}),
|
|
187
|
+
z.object({ id: z.string(), status: z.literal("pending") })),
|
|
188
|
+
clipStatus: route("POST", "/clips/status", true,
|
|
189
|
+
z.object({ id: ItemId }),
|
|
190
|
+
z.object({
|
|
191
|
+
id: z.string(), status: z.enum(["pending", "done", "failed"]), message: z.string().optional(),
|
|
192
|
+
url: z.string().optional(), ext: z.enum(["mp4"]).optional(), contentType: z.string().optional(), durationSec: z.number().positive().optional(),
|
|
193
|
+
greenScreen: z.boolean().optional(), libraryId: z.string().optional(),
|
|
194
|
+
}).refine((r) => (r.status === "done") === (r.url !== undefined && r.ext !== undefined && r.contentType !== undefined) && (r.status === "failed") === (r.message !== undefined),
|
|
195
|
+
"url, ext and contentType belong to done and message to failed")),
|
|
196
|
+
cutoutStart: route("POST", "/cutouts", true,
|
|
197
|
+
z.object({
|
|
198
|
+
filename: text(200), contentType: CutoutTypeSchema,
|
|
199
|
+
bytes: z.number().int().min(1).max(MAX_UPLOAD_BYTES), durationSec: z.number().positive().max(MAX_CUTOUT_SECONDS),
|
|
200
|
+
}),
|
|
201
|
+
z.object({ id: z.string(), uploadUrl: z.string() })),
|
|
202
|
+
cutoutRun: route("POST", "/cutouts/run", true, z.object({ id: ItemId }), z.object({ id: z.string(), status: z.literal("pending") })),
|
|
203
|
+
cutoutStatus: route("POST", "/cutouts/status", true,
|
|
204
|
+
z.object({ id: ItemId }),
|
|
205
|
+
z.object({
|
|
206
|
+
id: z.string(), status: z.enum(["pending", "done", "failed"]), message: z.string().optional(),
|
|
207
|
+
url: z.string().optional(), ext: z.enum(["webm"]).optional(), contentType: z.literal("video/webm").optional(),
|
|
208
|
+
}).refine((r) => (r.status === "done") === (r.url !== undefined && r.ext !== undefined && r.contentType !== undefined) && (r.status === "failed") === (r.message !== undefined),
|
|
209
|
+
"url, ext and contentType belong to done and message to failed")),
|
|
151
210
|
publicLibrary: route("GET", "/public/library", false,
|
|
152
211
|
z.object({ q: text(200).optional(), kind: LibraryKindSchema.optional(), page: z.coerce.number().int().min(1).max(10000).optional() }),
|
|
153
212
|
// With `q` the items are the best matches by meaning, best first, each with `match` (0..1), in one page (`hasMore` false). Under heavy
|
package/src/pipeline/schema.ts
CHANGED
|
@@ -8,9 +8,12 @@ export type Aspect = z.infer<typeof AspectSchema>;
|
|
|
8
8
|
export const SceneSchema = z.object({
|
|
9
9
|
id: z.string().regex(/^[a-z0-9-]+$/).describe("short unique id, lowercase letters, digits, dashes"),
|
|
10
10
|
narration: z.string().min(1).describe("the words spoken in this scene"),
|
|
11
|
-
|
|
11
|
+
// clip: a generated or reused video clip is the scene's picture.
|
|
12
|
+
treatment: z.enum(["motion-graphic", "illustration", "footage-overlay", "clip"]),
|
|
12
13
|
onScreenText: z.array(z.string()),
|
|
13
14
|
imagePrompt: z.string().nullable().describe("required when treatment is illustration, otherwise null"),
|
|
15
|
+
// Optional only so plans stored before clips existed still load.
|
|
16
|
+
clipPrompt: z.string().nullable().optional().describe("required when treatment is clip, otherwise null: one subject, one action, a camera move, lighting, no text"),
|
|
14
17
|
shareable: z.boolean().describe("true only when imagePrompt is fully generic: no brand, product, person or detail specific to this user. Such images are saved to a library shared by all users."),
|
|
15
18
|
imageTags: z.array(z.string()).describe("3 to 6 short tags describing the illustration; empty when imagePrompt is null"),
|
|
16
19
|
userAssetIds: z.array(z.string()),
|
|
@@ -43,6 +46,10 @@ export const AssetRecordSchema = z.object({
|
|
|
43
46
|
durationSec: z.number().optional(),
|
|
44
47
|
// What the file shows, as given at upload, so the plan can place it where it is relevant.
|
|
45
48
|
description: z.string().optional(),
|
|
49
|
+
// A green-screen video also has a copy with the green made transparent, at this path.
|
|
50
|
+
keyedKey: z.string().optional(),
|
|
51
|
+
// The server cutout still being made for this video. Kept so that coming back to wait for it neither uploads nor charges again.
|
|
52
|
+
cutoutId: z.string().optional(),
|
|
46
53
|
createdAt: z.string(),
|
|
47
54
|
});
|
|
48
55
|
export type AssetRecord = z.infer<typeof AssetRecordSchema>;
|
|
@@ -57,6 +64,9 @@ export const ManifestSceneSchema = z.object({
|
|
|
57
64
|
voiceoverKey: z.string().optional(),
|
|
58
65
|
words: z.array(WordTimingSchema),
|
|
59
66
|
imageKey: z.string().optional(),
|
|
67
|
+
// The scene's video clip, and its transparent copy when it was made on a green background.
|
|
68
|
+
clipKey: z.string().optional(),
|
|
69
|
+
clipKeyedKey: z.string().optional(),
|
|
60
70
|
userAssetKeys: z.array(z.string()),
|
|
61
71
|
});
|
|
62
72
|
export type ManifestScene = z.infer<typeof ManifestSceneSchema>;
|
|
@@ -103,6 +113,8 @@ export function validatePlan(
|
|
|
103
113
|
for (const s of plan.scenes) {
|
|
104
114
|
if (s.treatment === "illustration" && !s.imagePrompt)
|
|
105
115
|
errors.push(`Scene ${s.id}: imagePrompt is required for an illustration.`);
|
|
116
|
+
if (s.treatment === "clip" && !s.clipPrompt)
|
|
117
|
+
errors.push(`Scene ${s.id}: clipPrompt is required for a clip.`);
|
|
106
118
|
for (const id of s.userAssetIds)
|
|
107
119
|
if (!allowed.has(id)) errors.push(`Scene ${s.id}: unknown user asset ${id}.`);
|
|
108
120
|
}
|