reelkit-cli 0.3.1 → 0.5.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 +1 -1
- package/package.json +1 -1
- package/skill/SKILL.md +11 -2
- package/skill/reference/beat-sync.md +45 -0
- package/skill/reference/captions.md +18 -2
- package/skill/reference/clips.md +1 -0
- package/skill/reference/continuity.md +99 -0
- package/skill/reference/kit.md +18 -1
- package/skill/reference/motion-design.md +9 -0
- package/skill/reference/references.md +43 -0
- package/skill/reference/remotion-composition.md +2 -1
- package/skill/reference/scene-treatments.md +1 -1
- package/skill/reference/scriptwriting.md +39 -5
- package/skill/reference/sound-design.md +11 -0
- package/skill/reference/styles.md +9 -0
- package/src/cli.ts +14 -2
- package/src/commands/assets.ts +38 -8
- package/src/commands/auth.ts +1 -1
- package/src/commands/build.ts +73 -8
- package/src/commands/plan.ts +26 -4
- package/src/commands/ref.ts +315 -0
- package/src/contract/index.ts +25 -1
- package/src/pipeline/beatsnap.ts +52 -0
- package/src/pipeline/review.ts +28 -1
- package/src/pipeline/schema.ts +14 -0
- package/src/project/beats.ts +130 -0
- package/src/project/chromakey.ts +14 -4
- package/src/project/manifest.ts +22 -1
- package/src/project/music.ts +25 -0
- package/src/project/project.ts +1 -1
- package/src/project/refmeasure.ts +192 -0
- package/src/remotion/Root.tsx +4 -2
- package/src/remotion/kit/Camera.tsx +22 -0
- package/src/remotion/kit/Captions.tsx +25 -8
- package/src/remotion/kit/Carry.tsx +38 -0
- package/src/remotion/kit/Music.tsx +19 -0
- package/src/remotion/kit/beat.ts +23 -0
- package/src/remotion/kit/caption-groups.ts +65 -0
- package/src/remotion/kit/docs.ts +18 -1
- package/src/remotion/kit/index.ts +7 -0
- package/src/remotion/kit/media.ts +15 -0
- package/src/remotion/kit/motion-math.ts +113 -0
- package/src/remotion/kit/music-math.ts +42 -0
- package/src/render/continuity.ts +87 -0
- package/src/render/validate.ts +27 -0
- package/src/testing/conformance.ts +107 -1
- package/src/testing/fake-api.ts +44 -6
|
@@ -23,8 +23,11 @@ export type Harness = {
|
|
|
23
23
|
// Optional, for the cutout tests. A file name that makes a cutout job fail (the fake fails any name containing "cutout-fail"); without
|
|
24
24
|
// it the failing-job case is skipped. The cutout tests wait between status asks for `clipPollMs` too.
|
|
25
25
|
cutoutFailFilename?: string;
|
|
26
|
+
// Optional, for the transcript tests. A file name that makes a transcription fail (the fake fails any name containing "transcribe-fail");
|
|
27
|
+
// without it the failing-job case is skipped.
|
|
28
|
+
transcribeFailFilename?: string;
|
|
26
29
|
};
|
|
27
|
-
export type StartOptions = { voiceoverChars?: number; images?: number; clips?: number; cutoutSeconds?: number };
|
|
30
|
+
export type StartOptions = { voiceoverChars?: number; images?: number; clips?: number; cutoutSeconds?: number; transcribeSeconds?: number };
|
|
28
31
|
|
|
29
32
|
const PNG = new Uint8Array(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", "base64"));
|
|
30
33
|
const DATE = /\d{4}-\d{2}-\d{2}/;
|
|
@@ -87,6 +90,8 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
|
|
|
87
90
|
expect(me.quota.clips.limit).toBeGreaterThan(0);
|
|
88
91
|
expect(me.quota.cutoutSeconds.used).toBe(0);
|
|
89
92
|
expect(me.quota.cutoutSeconds.limit).toBeGreaterThan(0);
|
|
93
|
+
expect(me.quota.transcribeSeconds.used).toBe(0);
|
|
94
|
+
expect(me.quota.transcribeSeconds.limit).toBeGreaterThan(0);
|
|
90
95
|
expect(me.quota.voiceoverChars.limit).toBeGreaterThan(0);
|
|
91
96
|
expect(me.contributions).toBe(0);
|
|
92
97
|
const resets = new Date(me.quota.resetsAt);
|
|
@@ -522,6 +527,107 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
|
|
|
522
527
|
});
|
|
523
528
|
});
|
|
524
529
|
|
|
530
|
+
describe("transcripts", () => {
|
|
531
|
+
const AUDIO = new Uint8Array([73, 68, 51, 4, ...new Array(2000).fill(9)]);
|
|
532
|
+
const meta = (over: Record<string, unknown> = {}) => ({ filename: "talk.mp3", contentType: "audio/mpeg" as const, bytes: AUDIO.length, durationSec: 12.4, ...over });
|
|
533
|
+
const sent = async (api: Api, over: Record<string, unknown> = {}) => {
|
|
534
|
+
const started = await api("transcribeStart", meta(over) as never);
|
|
535
|
+
await put(started.uploadUrl, AUDIO, (over.contentType as string | undefined) ?? "audio/mpeg");
|
|
536
|
+
return started;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
it("start, upload, run: text and segments with increasing times inside the audio; charged in whole seconds to this user only", async () => {
|
|
540
|
+
const api = await as("user-trn0000001"), other = await as("user-trn0000002");
|
|
541
|
+
const started = await api("transcribeStart", meta());
|
|
542
|
+
expect(started.id).toBeTruthy();
|
|
543
|
+
expect(started.uploadUrl).toBeTruthy();
|
|
544
|
+
expect((await api("me", {})).quota.transcribeSeconds.used).toBe(0);
|
|
545
|
+
expect((await put(started.uploadUrl, AUDIO, "audio/mpeg")).ok).toBe(true);
|
|
546
|
+
const t = await api("transcribeRun", { id: started.id });
|
|
547
|
+
expect(t.id).toBe(started.id);
|
|
548
|
+
expect(t.text.length).toBeGreaterThan(0);
|
|
549
|
+
expect(t.durationSec).toBeGreaterThan(0);
|
|
550
|
+
expect(t.segments.length).toBeGreaterThan(0);
|
|
551
|
+
let last = 0;
|
|
552
|
+
for (const s of t.segments) {
|
|
553
|
+
expect(s.text.length).toBeGreaterThan(0);
|
|
554
|
+
expect(s.startSec).toBeGreaterThanOrEqual(last);
|
|
555
|
+
expect(s.endSec).toBeGreaterThanOrEqual(s.startSec);
|
|
556
|
+
expect(s.endSec).toBeLessThanOrEqual(t.durationSec + 0.5);
|
|
557
|
+
last = s.endSec;
|
|
558
|
+
}
|
|
559
|
+
expect((await api("me", {})).quota.transcribeSeconds.used).toBe(13);
|
|
560
|
+
expect((await other("me", {})).quota.transcribeSeconds.used).toBe(0);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("a run before the audio arrived is invalid_request and charges nothing", async () => {
|
|
564
|
+
const api = await as("user-trn0000003");
|
|
565
|
+
const started = await api("transcribeStart", meta());
|
|
566
|
+
expect((await failure(api("transcribeRun", { id: started.id }))).code).toBe("invalid_request");
|
|
567
|
+
expect((await api("me", {})).quota.transcribeSeconds.used).toBe(0);
|
|
568
|
+
await put(started.uploadUrl, AUDIO, "audio/mpeg");
|
|
569
|
+
expect((await api("transcribeRun", { id: started.id })).text.length).toBeGreaterThan(0);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("running twice returns the same transcript and charges once", async () => {
|
|
573
|
+
const api = await as("user-trn0000004");
|
|
574
|
+
const started = await sent(api);
|
|
575
|
+
const first = await api("transcribeRun", { id: started.id });
|
|
576
|
+
const second = await api("transcribeRun", { id: started.id });
|
|
577
|
+
expect(second).toEqual(first);
|
|
578
|
+
expect((await api("me", {})).quota.transcribeSeconds.used).toBe(13);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("a transcript id belongs to its caller: another user, or an unknown id, is not_found", async () => {
|
|
582
|
+
const api = await as("user-trn0000005"), other = await as("user-trn0000006");
|
|
583
|
+
const started = await sent(api);
|
|
584
|
+
await api("transcribeRun", { id: started.id });
|
|
585
|
+
expect((await failure(other("transcribeRun", { id: started.id }))).code).toBe("not_found");
|
|
586
|
+
expect((await failure(api("transcribeRun", { id: "tr-nosuchjob" }))).code).toBe("not_found");
|
|
587
|
+
expect((await other("me", {})).quota.transcribeSeconds.used).toBe(0);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("a failing job is a server error and is not charged", async () => {
|
|
591
|
+
if (!h.transcribeFailFilename) return;
|
|
592
|
+
const api = await as("user-trn0000007");
|
|
593
|
+
const started = await sent(api, { filename: h.transcribeFailFilename });
|
|
594
|
+
expect((await failure(api("transcribeRun", { id: started.id }))).code).toBe("server_error");
|
|
595
|
+
expect((await api("me", {})).quota.transcribeSeconds.used).toBe(0);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("at the limit a run is quota_exceeded with the reset date, charged nothing; other users are unaffected", async () => {
|
|
599
|
+
const t = await start({ transcribeSeconds: 5 });
|
|
600
|
+
try {
|
|
601
|
+
const api = createClient({ baseUrl: t.baseUrl, token: await t.login("user-trnq000001") });
|
|
602
|
+
const other = createClient({ baseUrl: t.baseUrl, token: await t.login("user-trnq000002") });
|
|
603
|
+
const first = await sent(api, { durationSec: 3 });
|
|
604
|
+
await api("transcribeRun", { id: first.id });
|
|
605
|
+
const second = await sent(api, { durationSec: 3 });
|
|
606
|
+
const e = await failure(api("transcribeRun", { id: second.id }));
|
|
607
|
+
expect(e.code).toBe("quota_exceeded");
|
|
608
|
+
expect(e.message).toMatch(DATE);
|
|
609
|
+
const me = await api("me", {});
|
|
610
|
+
expect(me.quota.transcribeSeconds).toEqual({ used: 3, limit: 5 });
|
|
611
|
+
expect(e.message).toContain(me.quota.resetsAt.slice(0, 10));
|
|
612
|
+
const third = await sent(other, { durationSec: 3 });
|
|
613
|
+
expect((await other("transcribeRun", { id: third.id })).text.length).toBeGreaterThan(0);
|
|
614
|
+
} finally { await t.close(); }
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it("refuses audio over 600 seconds, a type that is not audio, a size out of range and a missing login", async () => {
|
|
618
|
+
const api = await as("user-trn0000008");
|
|
619
|
+
for (const bad of [{ durationSec: 600.5 }, { durationSec: 0 }, { durationSec: -1 }, { contentType: "video/mp4" }, { bytes: 0 }, { bytes: 26_214_401 }, { filename: "a\u0000b.mp3" }]) {
|
|
620
|
+
expect((await failure(api("transcribeStart", meta(bad) as never))).code, JSON.stringify(bad)).toBe("invalid_request");
|
|
621
|
+
}
|
|
622
|
+
for (const ok of [{ durationSec: 600 }, { contentType: "audio/mp4" as const }, { contentType: "audio/wav" as const }, { bytes: 26_214_400 }]) {
|
|
623
|
+
expect((await api("transcribeStart", meta(ok))).id).toBeTruthy();
|
|
624
|
+
}
|
|
625
|
+
expect((await failure(api("transcribeRun", { id: "../x" }))).code).toBe("invalid_request");
|
|
626
|
+
expect((await failure(anon("transcribeStart", meta()))).code).toBe("unauthenticated");
|
|
627
|
+
expect((await failure(anon("transcribeRun", { id: "tr-x" }))).code).toBe("unauthenticated");
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
525
631
|
describe("quota", () => {
|
|
526
632
|
it("passes exactly at the limit; one over is quota_exceeded with the reset date; other users are unaffected", async () => {
|
|
527
633
|
const t = await start({ voiceoverChars: 10, images: 1 });
|
package/src/testing/fake-api.ts
CHANGED
|
@@ -81,10 +81,14 @@ function makeCutout(): Blob {
|
|
|
81
81
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
// A transcript job: the audio arrives at the upload URL; `run` reserves the seconds, deletes the audio and keeps the answer, so a second run is free.
|
|
85
|
+
type TranscriptJob = { owner: string; filename: string; durationSec: number; file?: Uint8Array; result?: { id: string; text: string; language: string; segments: { text: string; startSec: number; endSec: number }[]; durationSec: number } };
|
|
86
|
+
const TRANSCRIBE_FAIL = "transcribe-fail";
|
|
87
|
+
|
|
84
88
|
export type FakeApi = Awaited<ReturnType<typeof startFakeApi>>;
|
|
85
89
|
|
|
86
90
|
// An in-memory stand-in for the Reelkit API. It implements every route in the contract.
|
|
87
|
-
export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLimit?: number; clipLimit?: number; noClipProvider?: boolean; cutoutSecondsLimit?: number; noCutoutService?: boolean; autoApprove?: boolean } = {}) {
|
|
91
|
+
export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLimit?: number; clipLimit?: number; noClipProvider?: boolean; cutoutSecondsLimit?: number; noCutoutService?: boolean; transcribeSecondsLimit?: number; autoApprove?: boolean } = {}) {
|
|
88
92
|
// token -> the user it belongs to
|
|
89
93
|
const tokens = new Map<string, string>();
|
|
90
94
|
const devices = new Map<string, { userCode: string; approved: boolean; userId: string }>();
|
|
@@ -96,15 +100,16 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
96
100
|
// `sink` is where the accepted bytes go: a library item, or a cutout job.
|
|
97
101
|
const uploads = new Map<string, { contentType: string; bytes: number; used: boolean; sink: { has(): boolean; put(bytes: Uint8Array): void } | undefined }>();
|
|
98
102
|
// Totals over every user (for tests of the CLI), and the same counts per user (what a quota is measured against).
|
|
99
|
-
const usage = { chars: 0, images: 0, clips: 0, cutoutSeconds: 0, pulls: 0, uploads: 0 };
|
|
100
|
-
const meters = new Map<string, { chars: number; images: number; clips: number; cutoutSeconds: number; uploads: number }>();
|
|
103
|
+
const usage = { chars: 0, images: 0, clips: 0, cutoutSeconds: 0, transcribeSeconds: 0, pulls: 0, uploads: 0 };
|
|
104
|
+
const meters = new Map<string, { chars: number; images: number; clips: number; cutoutSeconds: number; transcribeSeconds: number; uploads: number }>();
|
|
101
105
|
const meter = (userId: string | undefined) => {
|
|
102
106
|
const id = userId ?? "u-test";
|
|
103
|
-
if (!meters.has(id)) meters.set(id, { chars: 0, images: 0, clips: 0, cutoutSeconds: 0, uploads: 0 });
|
|
107
|
+
if (!meters.has(id)) meters.set(id, { chars: 0, images: 0, clips: 0, cutoutSeconds: 0, transcribeSeconds: 0, uploads: 0 });
|
|
104
108
|
return meters.get(id)!;
|
|
105
109
|
};
|
|
106
110
|
const resetDate = () => nextMonthStart().toISOString();
|
|
107
|
-
const limits = { chars: opts.voiceoverCharLimit ?? 10_000, images: opts.imageLimit ?? 30, clips: opts.clipLimit ?? 5, cutoutSeconds: opts.cutoutSecondsLimit ?? 120 };
|
|
111
|
+
const limits = { chars: opts.voiceoverCharLimit ?? 10_000, images: opts.imageLimit ?? 30, clips: opts.clipLimit ?? 5, cutoutSeconds: opts.cutoutSecondsLimit ?? 120, transcribeSeconds: opts.transcribeSecondsLimit ?? 1800 };
|
|
112
|
+
const transcripts = new Map<string, TranscriptJob>();
|
|
108
113
|
const jobs = new Map<string, ClipJob>();
|
|
109
114
|
const cutouts = new Map<string, CutoutJob>();
|
|
110
115
|
let cutoutFile: Blob | undefined;
|
|
@@ -141,7 +146,7 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
141
146
|
const m = meter(userId);
|
|
142
147
|
return {
|
|
143
148
|
userId: userId ?? "u-test", handle: handleOf(userId ?? "u-test"),
|
|
144
|
-
quota: { voiceoverChars: { used: m.chars, limit: limits.chars }, images: { used: m.images, limit: limits.images }, clips: { used: m.clips, limit: limits.clips }, cutoutSeconds: { used: m.cutoutSeconds, limit: limits.cutoutSeconds }, resetsAt: resetDate() },
|
|
149
|
+
quota: { voiceoverChars: { used: m.chars, limit: limits.chars }, images: { used: m.images, limit: limits.images }, clips: { used: m.clips, limit: limits.clips }, cutoutSeconds: { used: m.cutoutSeconds, limit: limits.cutoutSeconds }, transcribeSeconds: { used: m.transcribeSeconds, limit: limits.transcribeSeconds }, resetsAt: resetDate() },
|
|
145
150
|
// What the user gave the shared library that was accepted: their own items that are published. One waiting for review does not count.
|
|
146
151
|
contributions: [...store.values()].filter((x) => x.owner === userId && x.committed && x.item.visibility === "published").length,
|
|
147
152
|
};
|
|
@@ -281,6 +286,37 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
281
286
|
}
|
|
282
287
|
return { id, status: "done", url: fileUrl((cutoutFile ??= makeCutout())), ext: "webm", contentType: "video/webm" };
|
|
283
288
|
},
|
|
289
|
+
transcribeStart: (input, { userId }) => {
|
|
290
|
+
const id = nextId("tr");
|
|
291
|
+
const job: TranscriptJob = { owner: userId ?? "u-test", filename: input.filename, durationSec: input.durationSec };
|
|
292
|
+
transcripts.set(id, job);
|
|
293
|
+
const token = secret();
|
|
294
|
+
uploads.set(token, { contentType: input.contentType, bytes: input.bytes, used: false, sink: { has: () => Boolean(job.file), put: (bytes) => { job.file = bytes; } } });
|
|
295
|
+
return { id, uploadUrl: `${origin}/upload/${token}` };
|
|
296
|
+
},
|
|
297
|
+
transcribeRun: ({ id }, { userId }) => {
|
|
298
|
+
const job = transcripts.get(id);
|
|
299
|
+
if (!job || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", `No transcript with id ${id}.`);
|
|
300
|
+
// Asked again, it answers the same transcript without a second charge.
|
|
301
|
+
if (job.result) return job.result;
|
|
302
|
+
if (!job.file) throw new Fail(400, "invalid_request", `Nothing was uploaded for ${id}. Send the audio to the upload URL first.`);
|
|
303
|
+
const seconds = Math.ceil(job.durationSec);
|
|
304
|
+
const m = meter(userId);
|
|
305
|
+
if (m.transcribeSeconds + seconds > limits.transcribeSeconds) throw new Fail(429, "quota_exceeded", `Transcription quota used up. It resets on ${resetDate().slice(0, 10)}.`);
|
|
306
|
+
// The audio is deleted when the call ends, whatever the result; a failure never keeps the seconds.
|
|
307
|
+
job.file = undefined;
|
|
308
|
+
if (job.filename.includes(TRANSCRIBE_FAIL)) throw new Fail(500, "server_error", "The audio could not be transcribed.");
|
|
309
|
+
m.transcribeSeconds += seconds;
|
|
310
|
+
usage.transcribeSeconds += seconds;
|
|
311
|
+
const base = job.filename.replace(/\.[^.]*$/, "").replace(/[^A-Za-z0-9]+/g, " ").trim() || "audio";
|
|
312
|
+
const half = Math.round((job.durationSec / 2) * 100) / 100;
|
|
313
|
+
job.result = {
|
|
314
|
+
id, language: "en", durationSec: job.durationSec,
|
|
315
|
+
text: `This is ${base}. It ends here.`,
|
|
316
|
+
segments: [{ text: `This is ${base}.`, startSec: 0, endSec: half }, { text: "It ends here.", startSec: half, endSec: job.durationSec }],
|
|
317
|
+
};
|
|
318
|
+
return job.result;
|
|
319
|
+
},
|
|
284
320
|
publicLibrary: ({ q, kind, page }) => {
|
|
285
321
|
// Published items only. Newest first; with a query, best match first and equal matches newest first.
|
|
286
322
|
const want = q ? words(q) : undefined;
|
|
@@ -348,6 +384,8 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
348
384
|
setClipLimit(n: number) { limits.clips = n; },
|
|
349
385
|
// The same for the seconds of video a user may have cut out.
|
|
350
386
|
setCutoutLimit(n: number) { limits.cutoutSeconds = n; },
|
|
387
|
+
// The same for the seconds of audio a user may have transcribed.
|
|
388
|
+
setTranscribeLimit(n: number) { limits.transcribeSeconds = n; },
|
|
351
389
|
// Stops cutout jobs from finishing (true) or lets them finish again (false).
|
|
352
390
|
holdCutouts(hold: boolean) { cutoutsHeld = hold; },
|
|
353
391
|
approve(userCode: string, userId = "u-test") { for (const d of devices.values()) if (d.userCode === userCode) { d.approved = true; d.userId = userId; } },
|