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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/skill/SKILL.md +11 -2
  4. package/skill/reference/beat-sync.md +45 -0
  5. package/skill/reference/captions.md +18 -2
  6. package/skill/reference/clips.md +1 -0
  7. package/skill/reference/continuity.md +99 -0
  8. package/skill/reference/kit.md +18 -1
  9. package/skill/reference/motion-design.md +9 -0
  10. package/skill/reference/references.md +43 -0
  11. package/skill/reference/remotion-composition.md +2 -1
  12. package/skill/reference/scene-treatments.md +1 -1
  13. package/skill/reference/scriptwriting.md +39 -5
  14. package/skill/reference/sound-design.md +11 -0
  15. package/skill/reference/styles.md +9 -0
  16. package/src/cli.ts +14 -2
  17. package/src/commands/assets.ts +38 -8
  18. package/src/commands/auth.ts +1 -1
  19. package/src/commands/build.ts +73 -8
  20. package/src/commands/plan.ts +26 -4
  21. package/src/commands/ref.ts +315 -0
  22. package/src/contract/index.ts +25 -1
  23. package/src/pipeline/beatsnap.ts +52 -0
  24. package/src/pipeline/review.ts +28 -1
  25. package/src/pipeline/schema.ts +14 -0
  26. package/src/project/beats.ts +130 -0
  27. package/src/project/chromakey.ts +14 -4
  28. package/src/project/manifest.ts +22 -1
  29. package/src/project/music.ts +25 -0
  30. package/src/project/project.ts +1 -1
  31. package/src/project/refmeasure.ts +192 -0
  32. package/src/remotion/Root.tsx +4 -2
  33. package/src/remotion/kit/Camera.tsx +22 -0
  34. package/src/remotion/kit/Captions.tsx +25 -8
  35. package/src/remotion/kit/Carry.tsx +38 -0
  36. package/src/remotion/kit/Music.tsx +19 -0
  37. package/src/remotion/kit/beat.ts +23 -0
  38. package/src/remotion/kit/caption-groups.ts +65 -0
  39. package/src/remotion/kit/docs.ts +18 -1
  40. package/src/remotion/kit/index.ts +7 -0
  41. package/src/remotion/kit/media.ts +15 -0
  42. package/src/remotion/kit/motion-math.ts +113 -0
  43. package/src/remotion/kit/music-math.ts +42 -0
  44. package/src/render/continuity.ts +87 -0
  45. package/src/render/validate.ts +27 -0
  46. package/src/testing/conformance.ts +107 -1
  47. 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 });
@@ -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; } },