reelkit-cli 0.6.0 → 0.10.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 (144) hide show
  1. package/README.md +5 -3
  2. package/package.json +52 -9
  3. package/skill/SKILL.md +40 -19
  4. package/skill/THIRD_PARTY.md +104 -2
  5. package/skill/commands/launch-film.md +7 -0
  6. package/skill/reference/art-styles.md +70 -0
  7. package/skill/reference/asset-reuse.md +13 -2
  8. package/skill/reference/backgrounds.md +63 -0
  9. package/skill/reference/beat-sync.md +25 -19
  10. package/skill/reference/brand-motion.md +62 -0
  11. package/skill/reference/captions.md +11 -5
  12. package/skill/reference/clips.md +3 -3
  13. package/skill/reference/component-authoring.md +1 -1
  14. package/skill/reference/continuity.md +26 -7
  15. package/skill/reference/delivery-review.md +40 -0
  16. package/skill/reference/hebrew-rtl.md +3 -4
  17. package/skill/reference/{remotion-composition.md → hyperframes-composition.md} +18 -11
  18. package/skill/reference/kit.md +144 -12
  19. package/skill/reference/launch-film.md +194 -0
  20. package/skill/reference/motion-design.md +18 -16
  21. package/skill/reference/scene-treatments.md +20 -0
  22. package/skill/reference/scriptwriting.md +4 -1
  23. package/skill/reference/sound-design.md +34 -12
  24. package/skill/reference/studio-editing.md +55 -0
  25. package/skill/reference/styles.md +9 -6
  26. package/skill/reference/three-d.md +135 -0
  27. package/skill/reference/voice-sync.md +108 -0
  28. package/src/agents.ts +23 -12
  29. package/src/api/client.ts +4 -1
  30. package/src/cli.ts +18 -7
  31. package/src/commands/assets.ts +314 -30
  32. package/src/commands/build.ts +148 -35
  33. package/src/commands/init.ts +1 -1
  34. package/src/commands/install.ts +1 -1
  35. package/src/commands/plan.ts +8 -5
  36. package/src/commands/ref.ts +5 -2
  37. package/src/contract/index.ts +5 -3
  38. package/src/hyperframes/Root.tsx +1 -0
  39. package/src/hyperframes/fonts.ts +54 -0
  40. package/src/hyperframes/frame.tsx +46 -0
  41. package/src/hyperframes/host.tsx +38 -0
  42. package/src/hyperframes/kit/Assemble3D.tsx +92 -0
  43. package/src/hyperframes/kit/BrandTransform3D.tsx +12 -0
  44. package/src/hyperframes/kit/BrowserFrame.tsx +83 -0
  45. package/src/{remotion → hyperframes}/kit/Camera.tsx +7 -5
  46. package/src/{remotion → hyperframes}/kit/Captions.tsx +34 -18
  47. package/src/hyperframes/kit/Card3D.tsx +211 -0
  48. package/src/{remotion → hyperframes}/kit/Carry.tsx +1 -1
  49. package/src/hyperframes/kit/ChapterFrame.tsx +68 -0
  50. package/src/{remotion → hyperframes}/kit/ClipLayer.tsx +1 -1
  51. package/src/{remotion → hyperframes}/kit/Counter.tsx +1 -1
  52. package/src/hyperframes/kit/CounterRoll.tsx +75 -0
  53. package/src/{remotion → hyperframes}/kit/Entrance.tsx +1 -1
  54. package/src/{remotion → hyperframes}/kit/FootageLayer.tsx +1 -1
  55. package/src/hyperframes/kit/GlassPanel.tsx +43 -0
  56. package/src/hyperframes/kit/Grounds.tsx +177 -0
  57. package/src/hyperframes/kit/Headline.tsx +97 -0
  58. package/src/hyperframes/kit/Hero3D.tsx +197 -0
  59. package/src/hyperframes/kit/HudOverlay.tsx +52 -0
  60. package/src/hyperframes/kit/ImageLayers.tsx +48 -0
  61. package/src/{remotion → hyperframes}/kit/KenBurnsImage.tsx +1 -1
  62. package/src/{remotion → hyperframes}/kit/KeyedClip.tsx +1 -1
  63. package/src/{remotion → hyperframes}/kit/Layers.tsx +1 -1
  64. package/src/{remotion → hyperframes}/kit/LowerThird.tsx +1 -1
  65. package/src/hyperframes/kit/Music.tsx +19 -0
  66. package/src/hyperframes/kit/NamedCursor.tsx +54 -0
  67. package/src/hyperframes/kit/Orbit3D.tsx +49 -0
  68. package/src/hyperframes/kit/Particles3D.tsx +74 -0
  69. package/src/hyperframes/kit/Place.tsx +12 -0
  70. package/src/hyperframes/kit/PromptBox.tsx +84 -0
  71. package/src/hyperframes/kit/Scene3D.tsx +70 -0
  72. package/src/hyperframes/kit/SceneFrame.tsx +96 -0
  73. package/src/{remotion → hyperframes}/kit/ScreenOverlay.tsx +1 -1
  74. package/src/{remotion → hyperframes}/kit/Sfx.tsx +1 -1
  75. package/src/hyperframes/kit/SoundCues.tsx +22 -0
  76. package/src/hyperframes/kit/TerminalLog.tsx +98 -0
  77. package/src/hyperframes/kit/Text3D.tsx +78 -0
  78. package/src/hyperframes/kit/TextOnImage.tsx +41 -0
  79. package/src/{remotion → hyperframes}/kit/TitleCard.tsx +1 -1
  80. package/src/{remotion → hyperframes}/kit/Voiceover.tsx +1 -1
  81. package/src/hyperframes/kit/Warp3D.tsx +59 -0
  82. package/src/hyperframes/kit/bg-math.ts +179 -0
  83. package/src/hyperframes/kit/brand-transform.ts +25 -0
  84. package/src/{remotion → hyperframes}/kit/caption-groups.ts +7 -3
  85. package/src/hyperframes/kit/caption-style.ts +45 -0
  86. package/src/hyperframes/kit/docs.ts +249 -0
  87. package/src/hyperframes/kit/image-layers-math.ts +115 -0
  88. package/src/hyperframes/kit/index.ts +74 -0
  89. package/src/hyperframes/kit/inter-bold-typeface.ts +3 -0
  90. package/src/{remotion → hyperframes}/kit/motion-math.ts +36 -2
  91. package/src/hyperframes/kit/music-math.ts +59 -0
  92. package/src/hyperframes/kit/quiet-three.ts +11 -0
  93. package/src/hyperframes/kit/sample-text.ts +55 -0
  94. package/src/hyperframes/kit/scene3d-context.ts +5 -0
  95. package/src/hyperframes/kit/seeded.ts +13 -0
  96. package/src/hyperframes/kit/sound-cues.ts +89 -0
  97. package/src/hyperframes/kit/sound-kinds.ts +135 -0
  98. package/src/{remotion → hyperframes}/kit/theme.ts +43 -39
  99. package/src/hyperframes/kit/three-fx-math.ts +192 -0
  100. package/src/hyperframes/kit/three-math.ts +145 -0
  101. package/src/hyperframes/kit/transition-math.ts +116 -0
  102. package/src/hyperframes/kit/ui-math.ts +145 -0
  103. package/src/hyperframes/kit/ui-theme.ts +25 -0
  104. package/src/hyperframes/kit/word-anchor.ts +107 -0
  105. package/src/hyperframes/math.ts +62 -0
  106. package/src/hyperframes/three.tsx +10 -0
  107. package/src/pipeline/beatsnap.ts +72 -0
  108. package/src/pipeline/review.ts +44 -10
  109. package/src/pipeline/schema.ts +51 -4
  110. package/src/pipeline/timing.ts +27 -1
  111. package/src/project/background.ts +33 -0
  112. package/src/project/chromakey.ts +1 -1
  113. package/src/project/layers.ts +60 -0
  114. package/src/project/manifest.ts +59 -14
  115. package/src/project/music.ts +19 -5
  116. package/src/project/project.ts +4 -1
  117. package/src/project/serve.ts +2 -2
  118. package/src/project/soundreport.ts +347 -0
  119. package/src/project/svgcheck.ts +21 -0
  120. package/src/render/component-preview.ts +11 -55
  121. package/src/render/contact-sheet.ts +39 -0
  122. package/src/render/continuity.ts +14 -4
  123. package/src/render/deps.ts +15 -3
  124. package/src/render/render.ts +62 -57
  125. package/src/render/serve.ts +31 -0
  126. package/src/render/sound-notes.ts +106 -0
  127. package/src/render/static-check.ts +15 -4
  128. package/src/render/validate.ts +4 -4
  129. package/src/render/word-check.ts +181 -0
  130. package/src/render/worker.ts +71 -0
  131. package/src/testing/conformance.ts +12 -0
  132. package/src/testing/fake-api.ts +4 -4
  133. package/src/testing/fixtures.ts +4 -1
  134. package/src/remotion/Root.tsx +0 -31
  135. package/src/remotion/kit/Music.tsx +0 -19
  136. package/src/remotion/kit/SceneFrame.tsx +0 -19
  137. package/src/remotion/kit/docs.ts +0 -124
  138. package/src/remotion/kit/index.ts +0 -29
  139. package/src/remotion/kit/music-math.ts +0 -42
  140. /package/src/{remotion → hyperframes}/kit/Icon.tsx +0 -0
  141. /package/src/{remotion → hyperframes}/kit/beat.ts +0 -0
  142. /package/src/{remotion → hyperframes}/kit/brand-icons.ts +0 -0
  143. /package/src/{remotion → hyperframes}/kit/media.ts +0 -0
  144. /package/src/{remotion → hyperframes}/types.ts +0 -0
@@ -7,7 +7,10 @@ export type Aspect = z.infer<typeof AspectSchema>;
7
7
 
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
- narration: z.string().min(1).describe("the words spoken in this scene"),
10
+ // The minimum of one character is enforced for a narrated plan in ScenePlanSchema below, so that a video without a voice may leave it empty.
11
+ narration: z.string().describe("the words spoken in this scene; an empty string when the plan has voice \"none\""),
12
+ // How long the scene lasts. Used only when the plan has voice "none"; with a voice the recording sets the length.
13
+ seconds: z.number().min(0.3).max(15).optional().describe("the scene's length in seconds, from 0.3 to 15; required when the plan has voice \"none\""),
11
14
  // clip: a generated or reused video clip is the scene's picture.
12
15
  treatment: z.enum(["motion-graphic", "illustration", "footage-overlay", "clip"]),
13
16
  onScreenText: z.array(z.string()),
@@ -18,9 +21,14 @@ export const SceneSchema = z.object({
18
21
  imageTags: z.array(z.string()).describe("3 to 6 short tags describing the illustration; empty when imagePrompt is null"),
19
22
  userAssetIds: z.array(z.string()),
20
23
  notes: z.string().describe("visual direction for whoever writes the composition"),
24
+ // Says that this scene begins on a deliberate hard cut, so `preview` does not count the missing carry-over against the film.
25
+ cutIn: z.boolean().optional().describe("true when this scene begins on a deliberate hard cut (nothing is meant to carry over into it)"),
21
26
  });
22
27
  export type Scene = z.infer<typeof SceneSchema>;
23
28
 
29
+ export const MAX_NARRATED_SCENES = 8;
30
+ export const MAX_VOICELESS_SCENES = 16;
31
+
24
32
  export const ScenePlanSchema = z.object({
25
33
  title: z.string(),
26
34
  aspect: AspectSchema,
@@ -28,18 +36,45 @@ export const ScenePlanSchema = z.object({
28
36
  // Optional only so plans stored before voices existed still load; a new plan must choose one.
29
37
  voiceId: z.string().optional().describe("id of the narration voice, one of the ids from `reelkit assets voices`, chosen to suit the idea, audience and language"),
30
38
  pace: z.enum(["slow", "normal", "fast"]).optional().describe("speaking pace: slow for calm or emotional, normal by default, fast for high-energy"),
39
+ // The silence between one scene's last word and the next scene's first word: tight 0.2 s, normal 0.3 s (the default), relaxed 0.5 s.
40
+ // (`pace` is the speed of the voice itself, so the gap has its own setting.)
41
+ gap: z.enum(["tight", "normal", "relaxed"]).optional().describe("silence between one sentence and the next: tight 0.2 s, normal 0.3 s (the default), relaxed 0.5 s; a tight promo is tight"),
42
+ // Absent means narrated. "none" is a video carried by music and sound effects alone: scenes get their length from `seconds`.
43
+ voice: z.enum(["narrated", "none"]).optional().describe("narrated (the default), or none for a video with no voice"),
31
44
  // Whether the video has captions and of which kind: none, one word at a time, or a phrase at a time. Absent means "phrase".
32
- captions: z.enum(["none", "word", "phrase"]).optional().describe("captions: none, one word at a time (word), or a phrase at a time (phrase, the default), as the user chose"),
45
+ captions: z.enum(["none", "word", "phrase"]).optional().describe("captions: none, one word at a time (word), or a few words at a time (phrase, the default; a few words at a time is phrase, not word), as the user chose"),
33
46
  // A video the new one is made "like": what is taken from it is how it feels (structure, pacing, motion), never its footage, music or words.
34
47
  // Optional, so plans stored before references existed still load.
35
48
  reference: z.object({
36
49
  id: z.string().describe("the id of a reference in this project, from `reelkit ref list`"),
37
50
  take: z.array(z.string()).min(1).max(6).describe("short notes on what is taken from it, e.g. \"fast cuts every ~1.2s\", \"big type on colour fields\""),
38
51
  }).optional(),
39
- scenes: z.array(SceneSchema).min(3).max(8),
52
+ scenes: z.array(SceneSchema).min(3).max(MAX_VOICELESS_SCENES),
53
+ }).superRefine((plan, ctx) => {
54
+ if (plan.voice === "none") {
55
+ plan.scenes.forEach((s, i) => {
56
+ if (s.seconds === undefined) ctx.addIssue({ code: "custom", path: ["scenes", i, "seconds"], message: `Scene ${s.id} needs seconds (a number from 0.3 to 15) because the video has no voice. Add it, or remove voice from the plan.` });
57
+ });
58
+ return;
59
+ }
60
+ // The narrated rules, as they were before voice existed.
61
+ if (plan.scenes.length > MAX_NARRATED_SCENES) ctx.addIssue({ code: "too_big", origin: "array", maximum: MAX_NARRATED_SCENES, inclusive: true, input: plan.scenes, path: ["scenes"] });
62
+ plan.scenes.forEach((s, i) => {
63
+ if (s.narration.length < 1) ctx.addIssue({ code: "too_small", origin: "string", minimum: 1, inclusive: true, input: s.narration, path: ["scenes", i, "narration"] });
64
+ });
40
65
  });
41
66
 
67
+ export const isVoiceless = (plan: { voice?: "narrated" | "none" }): boolean => plan.voice === "none";
68
+
42
69
  export const PACE_SPEED = { slow: 0.92, normal: 1, fast: 1.1 } as const;
70
+ // The silence, in seconds, between one scene's last word and the next scene's first word, by the plan's `gap`.
71
+ export const GAP_SEC = { tight: 0.2, normal: 0.3, relaxed: 0.5 } as const;
72
+ export const gapSec = (plan: { gap?: keyof typeof GAP_SEC }): number => GAP_SEC[plan.gap ?? "normal"];
73
+ // The last scene keeps this long after its last word, so that the film does not end on the last syllable.
74
+ export const LAST_TAIL_SEC = 0.7;
75
+ // The voice speaks about this many words a second at `pace: "normal"` (measured on a recorded film: 2.0 to 2.4, 2.25 over the whole of it, the
76
+ // pauses inside a sentence included). `plan check` estimates the length from it.
77
+ export const VOICE_WORDS_PER_SEC = 2.25;
43
78
  export type ScenePlan = z.infer<typeof ScenePlanSchema>;
44
79
 
45
80
  export const AssetRecordSchema = z.object({
@@ -76,6 +111,16 @@ export const ManifestSceneSchema = z.object({
76
111
  clipKey: z.string().optional(),
77
112
  clipKeyedKey: z.string().optional(),
78
113
  userAssetKeys: z.array(z.string()),
114
+ // The scene's picture in layers, from `reelkit assets layers`: the picture itself, the subject cut out of it when it has a clear one (a PNG with alpha beside it) and its box,
115
+ // and where words can sit (`region`, how light and how busy). Pass it to <ImageLayers layers={...}> and <TextOnImage layers={...}>.
116
+ imageLayers: z.object({
117
+ back: z.string(),
118
+ subject: z.string().optional(),
119
+ subjectBox: z.object({ x: z.number(), y: z.number(), w: z.number(), h: z.number() }).optional(),
120
+ textZone: z.object({ region: z.enum(["top", "middle", "bottom", "left", "right"]), luminance: z.number(), busy: z.number() }),
121
+ }).optional(),
122
+ // The picture chosen as this scene's ground, with `reelkit assets pull <id> --background --scene <id>`.
123
+ background: z.object({ key: z.string() }).optional(),
79
124
  });
80
125
  export type ManifestScene = z.infer<typeof ManifestSceneSchema>;
81
126
 
@@ -91,6 +136,8 @@ export const AssetManifestSchema = z.object({
91
136
  captions: z.enum(["none", "word", "phrase"]).optional(),
92
137
  // Linear gain per sound file path that brings every pulled sound to a common level; the kit's Sfx and Music apply it.
93
138
  soundGain: z.record(z.string(), z.number()).optional(),
139
+ // The film's ground (a video or a picture), chosen with `--background`; durationSec is set for a video.
140
+ background: z.object({ key: z.string(), durationSec: z.number().optional() }).optional(),
94
141
  scenes: z.array(ManifestSceneSchema),
95
142
  });
96
143
  export type AssetManifest = z.infer<typeof AssetManifestSchema>;
@@ -102,7 +149,7 @@ export function validatePlan(
102
149
  ctx: { aspect: Aspect; footage?: AssetRecord; assets: AssetRecord[]; voiceIds?: string[] },
103
150
  ): string[] {
104
151
  const errors: string[] = [];
105
- if (ctx.voiceIds) {
152
+ if (ctx.voiceIds && !isVoiceless(plan)) {
106
153
  if (!plan.voiceId) errors.push("Choose a narration voice: set voiceId to one of the ids from `reelkit assets voices`.");
107
154
  else if (!ctx.voiceIds.includes(plan.voiceId)) errors.push(`voiceId ${plan.voiceId} is not one of the available voices.`);
108
155
  }
@@ -1,4 +1,4 @@
1
- import type { Aspect, AssetRecord } from "./schema";
1
+ import { LAST_TAIL_SEC, type Aspect, type AssetRecord, type WordTiming } from "./schema";
2
2
 
3
3
  export const FPS = 30;
4
4
  export const SCENE_PADDING_SEC = 0.4;
@@ -28,3 +28,29 @@ export function dimensionsFor(aspect: Aspect, footage?: AssetRecord) {
28
28
  if (aspect === "1:1") return { width: 1080, height: 1080 };
29
29
  return { width: 1080, height: 1920 };
30
30
  }
31
+
32
+ // A narrated scene's audio: when its last word ends (seconds from its start) and when its first word starts. A recording with no word times
33
+ // counts as one stretch of speech as long as the file.
34
+ export type SpokenScene = { durationSec: number; words: WordTiming[] };
35
+ export const lastWordEndSec = (v: SpokenScene): number => (v.words.length ? Math.max(...v.words.map((w) => w.endSec)) : v.durationSec);
36
+ export const firstWordStartSec = (v: SpokenScene): number => (v.words.length ? Math.min(...v.words.map((w) => w.startSec)) : 0);
37
+
38
+ // The boundary never comes closer than this after a scene's last word, however tight the gap is.
39
+ export const MIN_AFTER_WORD_SEC = 0.1;
40
+
41
+ // Lays narrated scenes end to end so that the silence between one scene's last word and the next scene's first word is `gapSec`. Each scene's
42
+ // audio starts on its first frame, so a scene lasts until its last word has ended plus the gap, less the silence its successor opens with. The
43
+ // last scene keeps `tailSec` after its last word. Returns the natural lengths, before any snapping to the beat.
44
+ export function layoutNarrated(scenes: SpokenScene[], gapSec: number, fps = FPS, tailSec = LAST_TAIL_SEC) {
45
+ let cursor = 0;
46
+ const out = scenes.map((v, i) => {
47
+ const end = lastWordEndSec(v);
48
+ const next = scenes[i + 1];
49
+ const seconds = next ? Math.max(end + gapSec - firstWordStartSec(next), end + MIN_AFTER_WORD_SEC) : end + tailSec;
50
+ const durationFrames = secondsToFrames(seconds, fps);
51
+ const scene = { startFrame: cursor, durationFrames };
52
+ cursor += durationFrames;
53
+ return scene;
54
+ });
55
+ return { scenes: out, totalFrames: cursor };
56
+ }
@@ -0,0 +1,33 @@
1
+ import { FILES, type Project } from "./project";
2
+
3
+ // The film's ground, when one was chosen: a video or a picture for the whole film, and pictures for single scenes (a video is only ever for the film).
4
+ // Stored in assets/background.json and shown to the composition as manifest.background and manifest.scenes[i].background.
5
+ export type BackgroundEntry = { key: string; id?: string; title?: string; durationSec?: number };
6
+ export type BackgroundFile = { film?: BackgroundEntry; scenes: Record<string, BackgroundEntry> };
7
+
8
+ export const readBackground = (project: Project): BackgroundFile => {
9
+ const raw = project.readJsonOr<Partial<BackgroundFile>>(FILES.background, {});
10
+ return { ...(raw.film ? { film: raw.film } : {}), scenes: raw.scenes ?? {} };
11
+ };
12
+
13
+ // One ground for the film: setting another replaces it. A scene's picture is set the same way, by scene id.
14
+ export function setBackground(project: Project, entry: BackgroundEntry, sceneId?: string): BackgroundFile {
15
+ const current = readBackground(project);
16
+ const next: BackgroundFile = sceneId ? { ...current, scenes: { ...current.scenes, [sceneId]: entry } } : { ...current, film: entry };
17
+ project.writeJson(FILES.background, next);
18
+ return next;
19
+ }
20
+
21
+ export const VIDEO_EXT = /\.(mp4|mov|webm|m4v)$/i;
22
+ export const isVideoPath = (path: string): boolean => VIDEO_EXT.test(path);
23
+
24
+ // What is added to a prompt for a video or picture meant to sit behind everything, so that it stays calm and leaves room for type.
25
+ export const BACKGROUND_CLIP_SENTENCE = "An abstract, slow, seamless ambient background with no people, no objects in focus, no text and no logos, soft and low in contrast so text stays readable on top.";
26
+ export const BACKGROUND_IMAGE_SENTENCE = "An abstract, soft, low-contrast background with no people, no text and no logos, with empty space for type on top.";
27
+ export const DEFAULT_BACKGROUND_LOOK = "soft, slowly shifting colour fields in a muted dark palette";
28
+
29
+ // A look for a background that was asked for with no prompt: the colours the first scene's notes name, when they name any, else a neutral default.
30
+ export function lookFromNotes(notes: string | undefined): string {
31
+ const named = [...new Set((notes ?? "").toLowerCase().match(/\b(?:black|white|navy|blue|teal|cyan|green|lime|yellow|amber|orange|red|crimson|pink|magenta|purple|violet|indigo|brown|beige|cream|grey|gray|gold|silver)\b|#[0-9a-f]{6}\b/g) ?? [])].slice(0, 4);
32
+ return named.length ? `soft, slowly shifting colour fields in ${named.join(", ")}` : DEFAULT_BACKGROUND_LOOK;
33
+ }
@@ -41,7 +41,7 @@ async function cornerGreen(input: string): Promise<{ colour: string; saturated:
41
41
  return { colour: `0x${[r, g, b].map((n) => n.toString(16).padStart(2, "0")).join("")}`, saturated: g - Math.max(r, b) >= 60 };
42
42
  }
43
43
 
44
- // Turns the green of a green-screen clip into transparency: VP9 with an alpha channel, which Remotion plays with `transparent`.
44
+ // Turns the green of a green-screen clip into transparency: VP9 with an alpha channel, which HyperFrames plays with `transparent`.
45
45
  // The key is on the green found in the frame's corners, with some give for noise and uneven light; the despill pulls the green glow
46
46
  // off the subject's edges. The original is left alone.
47
47
  export async function keyGreen(input: string, output: string, opts: KeyOptions = {}): Promise<void> {
@@ -0,0 +1,60 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { promisify } from "node:util";
6
+ import { coverageOf, subjectBoxOf, textZoneOf, type ImageLayersRecord, type Rect, type TextZone } from "../hyperframes/kit/image-layers-math";
7
+
8
+ const run = promisify(execFile);
9
+ const GRID = 48;
10
+
11
+ // The picture's size is capped, so that the 1-second video a cutout is made from stays small.
12
+ const MAX_SIDE = 1920;
13
+
14
+ async function raw(args: string[]): Promise<Buffer> {
15
+ try {
16
+ const { stdout } = await run("ffmpeg", ["-v", "error", ...args], { encoding: "buffer", maxBuffer: 1 << 24 });
17
+ return stdout;
18
+ } catch (e) {
19
+ if ((e as NodeJS.ErrnoException)?.code === "ENOENT") throw new Error("ffmpeg was not found. Install ffmpeg (macOS: `brew install ffmpeg`) and run the command again.");
20
+ throw new Error(`ffmpeg could not read the picture: ${e instanceof Error ? e.message.split("\n")[0] : String(e)}`);
21
+ }
22
+ }
23
+
24
+ // A picture as a GRID by GRID grid of grey values.
25
+ export async function greyGrid(image: string): Promise<Uint8Array> {
26
+ return new Uint8Array(await raw(["-i", image, "-frames:v", "1", "-vf", `scale=${GRID}:${GRID}:flags=area,format=gray`, "-f", "rawvideo", "-"]));
27
+ }
28
+ // The alpha channel of a picture as a GRID by GRID grid (255 opaque), 255 everywhere when it has none.
29
+ export async function alphaGrid(image: string): Promise<Uint8Array> {
30
+ return new Uint8Array(await raw(["-i", image, "-frames:v", "1", "-vf", `format=rgba,alphaextract,scale=${GRID}:${GRID}:flags=area,format=gray`, "-f", "rawvideo", "-"]));
31
+ }
32
+
33
+ // Where words can sit in this picture: the calmest region, how light it is and how busy. `subject` is the subject's box when there is one.
34
+ export async function measureTextZone(image: string, subject?: Rect): Promise<TextZone> {
35
+ return textZoneOf(await greyGrid(image), GRID, GRID, subject);
36
+ }
37
+
38
+ // The one-second video a still is sent to the cutout service as: 2 frames a second, even sides, never over 1920 on the long side.
39
+ export async function stillToVideo(image: string, out: string): Promise<void> {
40
+ const vf = `scale='min(iw,${MAX_SIDE})':'min(ih,${MAX_SIDE})':force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p`;
41
+ await raw(["-y", "-loop", "1", "-framerate", "2", "-i", image, "-t", "1", "-vf", vf, "-c:v", "libx264", "-preset", "veryfast", "-crf", "20", "-an", out]);
42
+ }
43
+
44
+ // One frame of the transparent video the service returns, as a PNG with alpha: VP9 with alpha needs the libvpx decoder, so it is asked for by name.
45
+ // Throws when the PNG has no transparent pixel at all (the alpha did not survive), and returns what the subject covers and the box that holds it.
46
+ export async function frameWithAlpha(webm: string, png: string): Promise<{ coverage: number; box?: Rect }> {
47
+ await raw(["-y", "-c:v", "libvpx-vp9", "-i", webm, "-frames:v", "1", "-pix_fmt", "rgba", png]);
48
+ const a = await alphaGrid(png);
49
+ if (!a.some((v) => v < 250)) throw new Error("The cut-out came back without any transparent pixel, so there is no subject layer to make. The picture stays flat.");
50
+ return { coverage: coverageOf(a), box: subjectBoxOf(a, GRID, GRID) };
51
+ }
52
+
53
+ export const tempDir = (): { dir: string; done: () => void } => {
54
+ const dir = mkdtempSync(join(tmpdir(), "rk-layers-"));
55
+ return { dir, done: () => rmSync(dir, { recursive: true, force: true }) };
56
+ };
57
+
58
+ export type LayerEntry = { subject?: string; subjectBox?: Rect; textZone: TextZone; noSubject?: boolean; coverage?: number; cutoutId?: string };
59
+ // The record the manifest carries for a picture: its key as `back`, the subject's file and box when there is one, and where text can sit.
60
+ export const toImageLayers = (key: string, e: LayerEntry): ImageLayersRecord => ({ back: key, ...(e.subject && !e.noSubject ? { subject: e.subject, ...(e.subjectBox ? { subjectBox: e.subjectBox } : {}) } : {}), textZone: e.textZone });
@@ -1,6 +1,8 @@
1
- import { PACE_SPEED, type AssetManifest, type ScenePlan, type WordTiming } from "../pipeline/schema";
2
- import { extendBeats, snapToBeats, toBeatFrames } from "../pipeline/beatsnap";
3
- import { dimensionsFor, FPS, layoutScenes } from "../pipeline/timing";
1
+ import { gapSec, isVoiceless, PACE_SPEED, type AssetManifest, type ScenePlan, type WordTiming } from "../pipeline/schema";
2
+ import { extendBeats, MIN_TAIL_SEC, snapNarrated, snapToBeats, toBeatFrames } from "../pipeline/beatsnap";
3
+ import { dimensionsFor, FPS, lastWordEndSec, layoutNarrated, layoutScenes, secondsToFrames } from "../pipeline/timing";
4
+ import { readBackground } from "./background";
5
+ import { toImageLayers, type LayerEntry } from "./layers";
4
6
  import { dbToLinear } from "./loudness";
5
7
  import type { MusicRecord } from "./music";
6
8
  import { FILES, type Project } from "./project";
@@ -17,26 +19,48 @@ export function voiceoverStale(vo: Voiceover, scene: ScenePlan["scenes"][number]
17
19
  return vo.text !== scene.narration || vo.voiceId !== plan.voiceId || vo.speed !== PACE_SPEED[plan.pace ?? "normal"];
18
20
  }
19
21
 
22
+ // What the length of a film with no voice comes to once its cuts are on the beat of the pulled track, in seconds. Undefined without a track
23
+ // whose tempo can be trusted. Used by `plan check`, which has no manifest yet.
24
+ export function snappedSeconds(project: Project, plan: ScenePlan): number | undefined {
25
+ if (!isVoiceless(plan)) return undefined;
26
+ const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
27
+ if (!music?.bpm || !music.beats.length) return undefined;
28
+ const layout = layoutScenes(plan.scenes.map((s) => s.seconds ?? 0), FPS, 0);
29
+ const beatFrames = toBeatFrames(extendBeats(music.beats, music.bpm, layout.totalFrames / FPS + 10), FPS);
30
+ const lengths = snapToBeats({ naturalFrames: layout.scenes.map((s) => s.durationFrames), lastWordEndFrame: 0, beatFrames, fps: FPS, grid: "nearest" });
31
+ return lengths.reduce((a, b) => a + b, 0) / FPS;
32
+ }
33
+
20
34
  // Lays the scenes out on the timeline from the real voiceover lengths. Paths are relative to the project folder.
21
35
  // Returns undefined, and writes nothing, until every scene has its voiceover.
22
36
  export function buildManifest(project: Project, plan: ScenePlan): AssetManifest | undefined {
23
- const voiceovers = project.readJsonOr<Record<string, Voiceover>>(FILES.voiceovers, {});
24
- if (plan.scenes.some((s) => !voiceovers[s.id])) return undefined;
37
+ const voiceless = isVoiceless(plan);
38
+ const voiceovers = voiceless ? {} : project.readJsonOr<Record<string, Voiceover>>(FILES.voiceovers, {});
39
+ if (!voiceless && plan.scenes.some((s) => !voiceovers[s.id])) return undefined;
25
40
  const images = project.readJsonOr<Record<string, string>>(FILES.images, {});
26
41
  const clips = project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {});
27
42
  const footage = project.footage();
28
43
  const byId = new Map(project.assets().map((a) => [a.id, a]));
29
44
 
30
- let layout = layoutScenes(plan.scenes.map((s) => voiceovers[s.id].durationSec));
45
+ // With no voice the plan's own seconds are the lengths, with no padding after them: the plan already says how long each scene holds.
46
+ // With a voice each scene lasts until its last word has ended plus the plan's gap (the silence before the next sentence); the last keeps a longer tail.
47
+ let layout = voiceless ? layoutScenes(plan.scenes.map((s) => s.seconds ?? 0), FPS, 0) : layoutNarrated(plan.scenes.map((s) => voiceovers[s.id]!), gapSec(plan), FPS);
31
48
  const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
32
49
  let beatFrames: number[] = [];
33
50
  if (music) {
34
51
  beatFrames = toBeatFrames(extendBeats(music.beats, music.bpm ?? 120, layout.totalFrames / FPS + 10), FPS);
35
52
  // Scene changes land on beats only when the track has a trusted tempo. Footage has a fixed length that a longer scene must not overrun.
36
53
  if (music.bpm && music.beats.length) {
37
- const last = voiceovers[plan.scenes[plan.scenes.length - 1].id];
38
- const lastWords = last.words.length ? Math.max(...last.words.map((w) => w.endSec)) : last.durationSec;
39
- const lengths = snapToBeats({ naturalFrames: layout.scenes.map((s) => s.durationFrames), lastWordEndFrame: Math.ceil(lastWords * FPS), beatFrames, fps: FPS });
54
+ const naturalFrames = layout.scenes.map((s) => s.durationFrames);
55
+ let lengths: number[];
56
+ if (voiceless) {
57
+ // No word to leave a tail after: the last scene is as long as the plan says, so the tail rule must not stretch it.
58
+ const lastWordEndFrame = Math.max(0, naturalFrames[naturalFrames.length - 1]! - Math.ceil(MIN_TAIL_SEC * FPS));
59
+ lengths = snapToBeats({ naturalFrames, lastWordEndFrame, beatFrames, fps: FPS, grid: "nearest" });
60
+ } else {
61
+ // The voice is the clock: a change moves to a beat only when that is a few frames away and cuts into no word.
62
+ lengths = snapNarrated({ naturalFrames, lastWordEndFrames: plan.scenes.map((s) => Math.ceil(lastWordEndSec(voiceovers[s.id]!) * FPS - 1e-9)), beatFrames }).frames;
63
+ }
40
64
  let cursor = 0;
41
65
  const snapped = { scenes: lengths.map((durationFrames) => { const r = { startFrame: cursor, durationFrames }; cursor += durationFrames; return r; }), totalFrames: 0 };
42
66
  snapped.totalFrames = cursor;
@@ -47,7 +71,9 @@ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest
47
71
  if (footage) {
48
72
  const footageFrames = Math.floor((footage.durationSec ?? 0) * FPS);
49
73
  if (layout.totalFrames > footageFrames) {
50
- throw new Error(`The voiceover (${(layout.totalFrames / FPS).toFixed(1)}s) is longer than the footage (${(footageFrames / FPS).toFixed(1)}s). Use a longer clip or shorten the script.`);
74
+ throw new Error(voiceless
75
+ ? `The scenes (${(layout.totalFrames / FPS).toFixed(1)}s) are longer than the footage (${(footageFrames / FPS).toFixed(1)}s). Use a longer clip or shorten the scenes' seconds.`
76
+ : `The voiceover (${(layout.totalFrames / FPS).toFixed(1)}s) is longer than the footage (${(footageFrames / FPS).toFixed(1)}s). Use a longer clip or shorten the script.`);
51
77
  }
52
78
  totalFrames = footageFrames;
53
79
  }
@@ -59,22 +85,27 @@ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest
59
85
  }
60
86
  if (music) soundGain[music.key] = dbToLinear(music.gainDb);
61
87
 
88
+ const background = readBackground(project);
89
+ const layerEntries = project.readJsonOr<Record<string, LayerEntry>>(FILES.layers, {});
62
90
  const manifest: AssetManifest = {
63
91
  fps: FPS,
64
92
  ...dimensionsFor(plan.aspect, footage),
65
93
  totalFrames,
66
- captions: plan.captions ?? "phrase",
94
+ captions: plan.captions ?? (voiceless ? "none" : "phrase"),
67
95
  ...(Object.keys(soundGain).length ? { soundGain } : {}),
68
96
  ...(footage ? { footageKey: footage.key } : {}),
97
+ ...(background.film ? { background: { key: background.film.key, ...(background.film.durationSec ? { durationSec: background.film.durationSec } : {}) } } : {}),
69
98
  ...(music ? { music: { key: music.key, ...(music.bpm ? { bpm: music.bpm } : {}), beatFrames: music.bpm ? beatFrames.filter((f) => f < totalFrames) : [] } } : {}),
70
99
  scenes: plan.scenes.map((scene, i) => ({
71
100
  id: scene.id,
72
101
  startFrame: layout.scenes[i].startFrame,
73
102
  durationFrames: layout.scenes[i].durationFrames,
74
- voiceoverKey: voiceovers[scene.id].key,
75
- words: voiceovers[scene.id].words,
103
+ ...(voiceless ? {} : { voiceoverKey: voiceovers[scene.id]!.key }),
104
+ words: voiceless ? [] : voiceovers[scene.id]!.words,
76
105
  ...(scene.treatment === "illustration" && images[scene.id] ? { imageKey: images[scene.id] } : {}),
106
+ ...(scene.treatment === "illustration" && images[scene.id] && layerEntries[images[scene.id]!] ? { imageLayers: toImageLayers(images[scene.id]!, layerEntries[images[scene.id]!]!) } : {}),
77
107
  ...(scene.treatment === "clip" && clips[scene.id] ? { clipKey: clips[scene.id].key, ...(clips[scene.id].keyedKey ? { clipKeyedKey: clips[scene.id].keyedKey } : {}) } : {}),
108
+ ...(background.scenes[scene.id] ? { background: { key: background.scenes[scene.id]!.key } } : {}),
78
109
  userAssetKeys: scene.userAssetIds.map((id) => {
79
110
  const a = byId.get(id);
80
111
  if (!a) throw new Error(`Scene ${scene.id} references unknown asset ${id}. Add it with \`reelkit assets upload\`.`);
@@ -92,10 +123,12 @@ export function missingAssets(project: Project, plan: ScenePlan): string[] {
92
123
  const images = project.readJsonOr<Record<string, string>>(FILES.images, {});
93
124
  const clips = project.readJsonOr<Record<string, ClipRecord>>(FILES.clips, {});
94
125
  const problems: string[] = [];
126
+ const voiceless = isVoiceless(plan);
95
127
  const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
96
128
  if (music && !project.exists(music.key)) problems.push(`Missing file: ${music.key}. Pull the track again with \`reelkit assets pull ${music.id} --music\`.`);
97
129
  for (const s of plan.scenes) {
98
- if (!voiceovers[s.id]) problems.push(`Scene ${s.id} has no voiceover.`);
130
+ if (voiceless) { /* nothing is recorded for a video with no voice */ }
131
+ else if (!voiceovers[s.id]) problems.push(`Scene ${s.id} has no voiceover.`);
99
132
  else if (voiceoverStale(voiceovers[s.id], s, plan)) problems.push(`Scene ${s.id}'s voiceover is out of date. Run \`reelkit assets voiceover --all\`.`);
100
133
  else if (!project.exists(voiceovers[s.id].key)) problems.push(`Missing file: ${voiceovers[s.id].key}`);
101
134
  // Only an illustration scene uses an image; one left over from an earlier plan is not needed.
@@ -111,3 +144,15 @@ export function missingAssets(project: Project, plan: ScenePlan): string[] {
111
144
  }
112
145
  return problems;
113
146
  }
147
+
148
+ // The silence between each scene's last word and the next scene's first word, in seconds, as the manifest lays them out. This is what a listener hears
149
+ // between two sentences, and it is what the plan's `gap` sets (the beat can move a change by a few frames).
150
+ export function sentenceGaps(manifest: AssetManifest): number[] {
151
+ return manifest.scenes.slice(0, -1).map((s, i) => {
152
+ const next = manifest.scenes[i + 1]!;
153
+ if (!s.words.length || !next.words.length) return Math.max(0, (next.startFrame - s.startFrame) / manifest.fps);
154
+ const end = s.startFrame / manifest.fps + Math.max(...s.words.map((w) => w.endSec));
155
+ const start = next.startFrame / manifest.fps + Math.min(...next.words.map((w) => w.startSec));
156
+ return Math.round((start - end) * 100) / 100;
157
+ });
158
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AssetManifest } from "../pipeline/schema";
2
+ import { gridPoints } from "../pipeline/beatsnap";
2
3
  import { analyzeBeats } from "./beats";
3
4
  import { audioDuration, decodeMono } from "./refmeasure";
4
5
 
@@ -14,12 +15,25 @@ export async function measureMusic(path: string): Promise<Pick<MusicRecord, "dur
14
15
  return { durationSec, beatConfidence: r.confidence, beats: r.tempoBpm !== undefined && r.beats ? r.beats : [], ...(r.tempoBpm !== undefined ? { bpm: r.tempoBpm } : {}) };
15
16
  }
16
17
 
17
- // How many scene changes fall on a beat, for the preview.
18
- export function beatReport(manifest: AssetManifest): { bpm: number; boundariesOnBeat: number; boundaries: number; line: string } | undefined {
18
+ // How many scene changes fall on a beat, for the preview. A film with no voice puts its cuts on a finer grid, so there a change on a half or
19
+ // a quarter beat counts too, and the line says which grid was needed.
20
+ export function beatReport(manifest: AssetManifest, opts: { finer?: boolean } = {}): { bpm: number; boundariesOnBeat: number; boundaries: number; line: string } | undefined {
19
21
  const m = manifest.music;
20
22
  if (!m?.bpm || manifest.scenes.length < 2) return undefined;
21
- const beats = new Set(m.beatFrames);
22
23
  const starts = manifest.scenes.slice(1).map((s) => s.startFrame);
23
- const on = starts.filter((f) => beats.has(f)).length;
24
- return { bpm: m.bpm, boundariesOnBeat: on, boundaries: starts.length, line: `${on} of ${starts.length} scene changes land on the beat at ${Math.round(m.bpm)} BPM` };
24
+ const beats = new Set(m.beatFrames);
25
+ const bpm = Math.round(m.bpm);
26
+ if (!opts.finer) {
27
+ const on = starts.filter((f) => beats.has(f)).length;
28
+ // With a narrator the voice sets where a scene ends: a change is moved to a beat only when that is a few frames away, so say honestly how many are.
29
+ const rest = starts.length - on;
30
+ return { bpm: m.bpm, boundariesOnBeat: on, boundaries: starts.length, line: `${on} of ${starts.length} scene changes land on the beat at ${bpm} BPM${rest ? `; the other${rest === 1 ? "" : "s"} follow${rest === 1 ? "s" : ""} the voice` : ""}` };
31
+ }
32
+ const halves = new Set(gridPoints(m.beatFrames, 2)), quarters = new Set(gridPoints(m.beatFrames, 4));
33
+ const onBeat = starts.filter((f) => beats.has(f)).length;
34
+ const onHalf = starts.filter((f) => !beats.has(f) && halves.has(f)).length;
35
+ const onQuarter = starts.filter((f) => !beats.has(f) && !halves.has(f) && quarters.has(f)).length;
36
+ const on = onBeat + onHalf + onQuarter;
37
+ const grid = onQuarter ? "the beat, a half beat or a quarter beat" : onHalf ? "the beat or a half beat" : "the beat";
38
+ return { bpm: m.bpm, boundariesOnBeat: on, boundaries: starts.length, line: `${on} of ${starts.length} scene changes land on ${grid} at ${bpm} BPM` };
25
39
  }
@@ -5,7 +5,7 @@ import { AspectSchema, type AssetRecord } from "../pipeline/schema";
5
5
 
6
6
  export const FILES = {
7
7
  config: "reelkit.json", plan: "plan.json", manifest: "manifest.json",
8
- assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", clips: "assets/clips.json", library: "assets/library.json", music: "assets/music.json", searches: "assets/searches.json", shared: "assets/shared.json",
8
+ assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", clips: "assets/clips.json", library: "assets/library.json", music: "assets/music.json", background: "assets/background.json", layers: "assets/layers.json", searches: "assets/searches.json", shared: "assets/shared.json",
9
9
  } as const;
10
10
 
11
11
  // "path: message", or just the message for a problem at the root of the file.
@@ -17,6 +17,9 @@ export const ProjectConfigSchema = z.object({ aspect: AspectSchema, name: z.stri
17
17
  shareComponents: z.boolean().optional() });
18
18
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
19
19
 
20
+ // True for a project made with `reelkit init --private` (shareComponents: false): nothing in it is sent to the library, whatever the plan or a flag says.
21
+ export const isPrivateProject = (project: { config(): ProjectConfig }): boolean => project.config().shareComponents === false;
22
+
20
23
  // One video's working folder. Every command reads and writes its state here.
21
24
  export class Project {
22
25
  constructor(readonly dir: string) {}
@@ -12,9 +12,9 @@ const TYPES: Record<string, string> = {
12
12
 
13
13
  export type MediaServer = { url(rel: string): string; close(): Promise<void> };
14
14
 
15
- // Remotion loads media over HTTP, so the project's media is served on a local port while a build runs.
15
+ // HyperFrames loads media over HTTP, so the project's media is served on a local port while a build runs.
16
16
  // Only files under <project>/assets/ are served, never .json, and only under a random per-run path prefix: access-control-allow-origin
17
- // has to be * for Remotion's renderer, so the unguessable prefix is what keeps other web pages from reading the files.
17
+ // has to be * for HyperFrames's renderer, so the unguessable prefix is what keeps other web pages from reading the files.
18
18
  export async function serveDir(root: string): Promise<MediaServer> {
19
19
  const assets = join(resolve(root), "assets");
20
20
  const prefix = randomBytes(16).toString("hex");