@anvpx/motion-cli 0.0.0-stage → 0.1.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 (197) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -2
  3. package/dist/analyze/command.d.ts +2 -0
  4. package/dist/analyze/command.js +190 -0
  5. package/dist/approval/approval.d.ts +32 -0
  6. package/dist/approval/approval.js +141 -0
  7. package/dist/approval/command.d.ts +2 -0
  8. package/dist/approval/command.js +255 -0
  9. package/dist/approval/gate.d.ts +7 -0
  10. package/dist/approval/gate.js +23 -0
  11. package/dist/approval/hash.d.ts +71 -0
  12. package/dist/approval/hash.js +451 -0
  13. package/dist/args.d.ts +30 -0
  14. package/dist/args.js +53 -0
  15. package/dist/assets/command.d.ts +2 -0
  16. package/dist/assets/command.js +165 -0
  17. package/dist/captions/command.d.ts +2 -0
  18. package/dist/captions/command.js +341 -0
  19. package/dist/captions/cues.d.ts +10 -0
  20. package/dist/captions/cues.js +61 -0
  21. package/dist/catalog/command.d.ts +2 -0
  22. package/dist/catalog/command.js +60 -0
  23. package/dist/catalog/format.d.ts +13 -0
  24. package/dist/catalog/format.js +54 -0
  25. package/dist/cli.d.ts +3 -0
  26. package/dist/cli.js +35 -0
  27. package/dist/commands.d.ts +3 -0
  28. package/dist/commands.js +59 -0
  29. package/dist/compare/command.d.ts +4 -0
  30. package/dist/compare/command.js +144 -0
  31. package/dist/contact/argv.d.ts +24 -0
  32. package/dist/contact/argv.js +42 -0
  33. package/dist/contact/command.d.ts +2 -0
  34. package/dist/contact/command.js +79 -0
  35. package/dist/doctor/checks.d.ts +25 -0
  36. package/dist/doctor/checks.js +571 -0
  37. package/dist/doctor/command.d.ts +4 -0
  38. package/dist/doctor/command.js +88 -0
  39. package/dist/doctor/format.d.ts +7 -0
  40. package/dist/doctor/format.js +41 -0
  41. package/dist/doctor/heal.d.ts +14 -0
  42. package/dist/doctor/heal.js +71 -0
  43. package/dist/doctor/pins.d.ts +8 -0
  44. package/dist/doctor/pins.js +28 -0
  45. package/dist/doctor/skills.d.ts +15 -0
  46. package/dist/doctor/skills.js +58 -0
  47. package/dist/doctor/types.d.ts +36 -0
  48. package/dist/doctor/types.js +1 -0
  49. package/dist/doctor/vendored.d.ts +15 -0
  50. package/dist/doctor/vendored.js +90 -0
  51. package/dist/doctor/version.d.ts +8 -0
  52. package/dist/doctor/version.js +11 -0
  53. package/dist/exit-codes.d.ts +16 -0
  54. package/dist/exit-codes.js +15 -0
  55. package/dist/fit/command.d.ts +5 -0
  56. package/dist/fit/command.js +154 -0
  57. package/dist/fit/solve.d.ts +21 -0
  58. package/dist/fit/solve.js +80 -0
  59. package/dist/fit/target.d.ts +18 -0
  60. package/dist/fit/target.js +47 -0
  61. package/dist/generate3d/command.d.ts +3 -0
  62. package/dist/generate3d/command.js +38 -0
  63. package/dist/generate3d/run.d.ts +39 -0
  64. package/dist/generate3d/run.js +77 -0
  65. package/dist/icon/command.d.ts +2 -0
  66. package/dist/icon/command.js +113 -0
  67. package/dist/index.d.ts +18 -0
  68. package/dist/index.js +16 -0
  69. package/dist/install-mk.d.ts +31 -0
  70. package/dist/install-mk.js +168 -0
  71. package/dist/integration/preview.integration.d.ts +1 -0
  72. package/dist/integration/preview.integration.js +53 -0
  73. package/dist/integration/smoke.integration.d.ts +1 -0
  74. package/dist/integration/smoke.integration.js +91 -0
  75. package/dist/main.d.ts +2 -0
  76. package/dist/main.js +4 -0
  77. package/dist/node-io.d.ts +14 -0
  78. package/dist/node-io.js +152 -0
  79. package/dist/optimize/command.d.ts +12 -0
  80. package/dist/optimize/command.js +110 -0
  81. package/dist/packs/command.d.ts +2 -0
  82. package/dist/packs/command.js +109 -0
  83. package/dist/patch/apply.d.ts +36 -0
  84. package/dist/patch/apply.js +186 -0
  85. package/dist/patch/command.d.ts +2 -0
  86. package/dist/patch/command.js +92 -0
  87. package/dist/plugin/install.d.ts +40 -0
  88. package/dist/plugin/install.js +170 -0
  89. package/dist/preview/command.d.ts +10 -0
  90. package/dist/preview/command.js +758 -0
  91. package/dist/preview/manifest.d.ts +94 -0
  92. package/dist/preview/manifest.js +19 -0
  93. package/dist/preview/pack.d.ts +24 -0
  94. package/dist/preview/pack.js +53 -0
  95. package/dist/preview/plan.d.ts +58 -0
  96. package/dist/preview/plan.js +160 -0
  97. package/dist/preview/provenance.d.ts +23 -0
  98. package/dist/preview/provenance.js +38 -0
  99. package/dist/publish/command.d.ts +2 -0
  100. package/dist/publish/command.js +70 -0
  101. package/dist/publish/player.d.ts +25 -0
  102. package/dist/publish/player.js +108 -0
  103. package/dist/publish/static.d.ts +16 -0
  104. package/dist/publish/static.js +78 -0
  105. package/dist/qc.d.ts +2 -0
  106. package/dist/qc.js +192 -0
  107. package/dist/scene/author.d.ts +16 -0
  108. package/dist/scene/author.js +260 -0
  109. package/dist/scene/command.d.ts +2 -0
  110. package/dist/scene/command.js +110 -0
  111. package/dist/scene/ops.d.ts +23 -0
  112. package/dist/scene/ops.js +151 -0
  113. package/dist/state/command.d.ts +2 -0
  114. package/dist/state/command.js +128 -0
  115. package/dist/state/evidence.d.ts +71 -0
  116. package/dist/state/evidence.js +243 -0
  117. package/dist/storyboard/check.d.ts +21 -0
  118. package/dist/storyboard/check.js +39 -0
  119. package/dist/storyboard/containment.d.ts +24 -0
  120. package/dist/storyboard/containment.js +43 -0
  121. package/dist/storyboard/custom.d.ts +15 -0
  122. package/dist/storyboard/custom.js +133 -0
  123. package/dist/storyboard/edit.d.ts +19 -0
  124. package/dist/storyboard/edit.js +39 -0
  125. package/dist/storyboard/load.d.ts +11 -0
  126. package/dist/storyboard/load.js +52 -0
  127. package/dist/storyboard/report.d.ts +5 -0
  128. package/dist/storyboard/report.js +8 -0
  129. package/dist/template/coerce.d.ts +6 -0
  130. package/dist/template/coerce.js +44 -0
  131. package/dist/template/command.d.ts +5 -0
  132. package/dist/template/command.js +163 -0
  133. package/dist/template/env.d.ts +34 -0
  134. package/dist/template/env.js +38 -0
  135. package/dist/template/fork.d.ts +8 -0
  136. package/dist/template/fork.js +156 -0
  137. package/dist/template/format.d.ts +21 -0
  138. package/dist/template/format.js +161 -0
  139. package/dist/template/list.d.ts +15 -0
  140. package/dist/template/list.js +116 -0
  141. package/dist/template/new.d.ts +6 -0
  142. package/dist/template/new.js +302 -0
  143. package/dist/testing/env.d.ts +8 -0
  144. package/dist/testing/env.js +19 -0
  145. package/dist/testing/fakes.d.ts +33 -0
  146. package/dist/testing/fakes.js +95 -0
  147. package/dist/testing/fixtures.d.ts +8 -0
  148. package/dist/testing/fixtures.js +6 -0
  149. package/dist/testing/probe.d.ts +15 -0
  150. package/dist/testing/probe.js +20 -0
  151. package/dist/testing/project.d.ts +22 -0
  152. package/dist/testing/project.js +26 -0
  153. package/dist/tooling/session.d.ts +40 -0
  154. package/dist/tooling/session.js +106 -0
  155. package/dist/types.d.ts +59 -0
  156. package/dist/types.js +1 -0
  157. package/dist/verify/audio-loudness.d.ts +46 -0
  158. package/dist/verify/audio-loudness.js +186 -0
  159. package/dist/verify/command.d.ts +2 -0
  160. package/dist/verify/command.js +161 -0
  161. package/dist/verify/determinism.d.ts +30 -0
  162. package/dist/verify/determinism.js +59 -0
  163. package/dist/verify/expectation.d.ts +15 -0
  164. package/dist/verify/expectation.js +19 -0
  165. package/dist/verify/media-events.d.ts +61 -0
  166. package/dist/verify/media-events.js +174 -0
  167. package/dist/verify/platform.d.ts +33 -0
  168. package/dist/verify/platform.js +59 -0
  169. package/dist/verify/probe.d.ts +14 -0
  170. package/dist/verify/probe.js +23 -0
  171. package/dist/verify/report.d.ts +15 -0
  172. package/dist/verify/report.js +45 -0
  173. package/dist/verify/run.d.ts +27 -0
  174. package/dist/verify/run.js +53 -0
  175. package/dist/voice/command.d.ts +3 -0
  176. package/dist/voice/command.js +49 -0
  177. package/dist/voice/manifest.d.ts +33 -0
  178. package/dist/voice/manifest.js +69 -0
  179. package/dist/voice/run.d.ts +21 -0
  180. package/dist/voice/run.js +181 -0
  181. package/dist/workflow/build.d.ts +2 -0
  182. package/dist/workflow/build.js +25 -0
  183. package/dist/workflow/draft.d.ts +9 -0
  184. package/dist/workflow/draft.js +201 -0
  185. package/dist/workflow/finish.d.ts +23 -0
  186. package/dist/workflow/finish.js +28 -0
  187. package/dist/workflow/project.d.ts +73 -0
  188. package/dist/workflow/project.js +198 -0
  189. package/dist/workflow/provenance.d.ts +18 -0
  190. package/dist/workflow/provenance.js +36 -0
  191. package/dist/workflow/render.d.ts +31 -0
  192. package/dist/workflow/render.js +705 -0
  193. package/dist/workflow/still.d.ts +5 -0
  194. package/dist/workflow/still.js +62 -0
  195. package/dist/workflow/validate.d.ts +2 -0
  196. package/dist/workflow/validate.js +49 -0
  197. package/package.json +38 -3
@@ -0,0 +1,94 @@
1
+ import { type ApprovalInputManifest } from "../approval/hash.ts";
2
+ /** Pack directory suffix: the pack of `<stem>.json` is `<out>/<stem>.preview`. */
3
+ export declare const PREVIEW_SUFFIX = ".preview";
4
+ export declare const PREVIEW_JSON = "preview.json";
5
+ /** Default pack location base: `<storyboard dir>/out` (the pack is `<out>/preview`). */
6
+ export declare const defaultOut: (storyboard: string) => string;
7
+ /** `<out>/<stem>.preview`: one pack per storyboard. */
8
+ export declare const packDir: (out: string, storyboard: string) => string;
9
+ export declare const previewManifestPath: (out: string, storyboard: string) => string;
10
+ export type ClipResult = {
11
+ status: "ok";
12
+ file: string;
13
+ sec: number;
14
+ } | {
15
+ status: "skipped";
16
+ reason: string;
17
+ };
18
+ export interface StillRow {
19
+ /** Relative to the pack directory. */
20
+ file: string;
21
+ scene: string;
22
+ t: number;
23
+ theme: string;
24
+ }
25
+ export interface ChecklistItem {
26
+ id: string;
27
+ text: string;
28
+ done: boolean;
29
+ }
30
+ /** Exact CLI inputs a pack was previewed with, so `mk approve` can infer them (A4). */
31
+ export interface PreviewInputs {
32
+ readonly assetsDir: string | null;
33
+ readonly ledger: string | null;
34
+ readonly audioManifest: string | null;
35
+ readonly music: string | null;
36
+ readonly musicVolume: number | null;
37
+ readonly musicLicense: string | null;
38
+ }
39
+ /** `preview.json`: what the user is about to approve. Written last; `hash` binds it to the storyboard. */
40
+ export interface PreviewManifest {
41
+ /** Approval hash (storyboard + vendored themes) the pack was made from. */
42
+ hash: string;
43
+ /** Exact visual inputs the user approves (single resolver); gated render rechecks it. */
44
+ inputManifest: ApprovalInputManifest;
45
+ /** Canonical sha of `inputManifest`; no timestamp or machine path enters it. */
46
+ inputManifestSha256: string;
47
+ storyboard: string;
48
+ /** Absolute inputs (never approval identity) `mk approve` reuses when flags are omitted. */
49
+ inputs: PreviewInputs;
50
+ /** `id@version` when the storyboard came from `mk new`, else null. */
51
+ template: string | null;
52
+ blueprint: string | null;
53
+ style: string | null;
54
+ theme: string;
55
+ /** Paired theme used for the dark/light extras, or null. */
56
+ pair: string | null;
57
+ frame: {
58
+ width: number;
59
+ height: number;
60
+ fps: number;
61
+ };
62
+ totalSec: number;
63
+ scenes: {
64
+ id: string;
65
+ type: string;
66
+ start: number;
67
+ duration: number;
68
+ }[];
69
+ stills: StillRow[];
70
+ contact: string | null;
71
+ clip: ClipResult;
72
+ /** Audible binding: voice manifest + wav hashes the clip was mixed from. Silent packs record audible:false. */
73
+ audioMix: {
74
+ manifestSha: string | null;
75
+ wavSha: string | null;
76
+ audible: boolean;
77
+ /** AU-01 bed binding the clip was mixed from; null when narration-only/silent. */
78
+ musicSha: string | null;
79
+ musicVolume: number | null;
80
+ musicLicenseSha: string | null;
81
+ };
82
+ /** Labelled estimate; null when the clip was skipped (nothing measured). */
83
+ estFullRenderSec: number | null;
84
+ estimateNote: string;
85
+ lint: {
86
+ errors: number;
87
+ warnings: number;
88
+ items: string[];
89
+ };
90
+ budgetSec: number;
91
+ elapsedSec: number;
92
+ checklist: ChecklistItem[];
93
+ }
94
+ export declare const CHECKLIST: readonly Omit<ChecklistItem, "done">[];
@@ -0,0 +1,19 @@
1
+ import { dirname, join, resolve } from "node:path";
2
+ import { storyboardStem } from "../approval/hash.js";
3
+ /** Pack directory suffix: the pack of `<stem>.json` is `<out>/<stem>.preview`. */
4
+ export const PREVIEW_SUFFIX = ".preview";
5
+ export const PREVIEW_JSON = "preview.json";
6
+ /** Default pack location base: `<storyboard dir>/out` (the pack is `<out>/preview`). */
7
+ export const defaultOut = (storyboard) => join(dirname(resolve(storyboard)), "out");
8
+ /** `<out>/<stem>.preview`: one pack per storyboard. */
9
+ export const packDir = (out, storyboard) => join(out, `${storyboardStem(storyboard)}${PREVIEW_SUFFIX}`);
10
+ export const previewManifestPath = (out, storyboard) => join(packDir(out, storyboard), PREVIEW_JSON);
11
+ export const CHECKLIST = [
12
+ { id: "shown-contact", text: "contact.png was shown to the user (or its path given)" },
13
+ { id: "shown-clip", text: "clip.mp4 path was given to the user (or the skip was explained)" },
14
+ {
15
+ id: "not-shown",
16
+ text: "the user knows what is NOT previewed: voice, motion between stills, and any scene without a still",
17
+ },
18
+ { id: "explicit-yes", text: "the user said an explicit yes to this exact preview" },
19
+ ];
@@ -0,0 +1,24 @@
1
+ import type { Host, Io } from "../types.ts";
2
+ import type { StillPick } from "./plan.ts";
3
+ /** A still that was captured (light = the storyboard's own theme; extras use the paired theme). */
4
+ export interface Captured {
5
+ pick: StillPick;
6
+ theme: string;
7
+ /** PNG written by `mk still`. */
8
+ source: string;
9
+ }
10
+ export interface NamedStill extends Captured {
11
+ /** `NN-<scene>-<theme>.png`. */
12
+ name: string;
13
+ }
14
+ export declare const CONTACT_COLS = 3;
15
+ /** Timeline order; within one scene the storyboard's own theme comes first. */
16
+ export declare function nameStills(captured: readonly Captured[], primary: string): NamedStill[];
17
+ /** Deletes every PNG in a directory (stale output of an earlier run); a missing directory is fine. */
18
+ export declare function clearPngs(host: Host, dir: string): Promise<void>;
19
+ /** PNGs in a directory, sorted by name (the order `hyperframes snapshot` numbered them). */
20
+ export declare function listPngs(host: Host, dir: string): Promise<string[]>;
21
+ /** ffconcat list for the stills (forward slashes; a quote in a path is refused, not escaped). */
22
+ export declare function concatList(files: readonly string[]): string;
23
+ /** Tiles the stills into one `contact.png` with ffmpeg at `width` px per tile; resolves to an exit code. */
24
+ export declare function contactSheet(io: Io, files: readonly string[], listFile: string, out: string, width?: number): Promise<number>;
@@ -0,0 +1,53 @@
1
+ import { join } from "node:path";
2
+ import { contactImagesArgs, TILE_WIDTH } from "../contact/argv.js";
3
+ import { EXIT } from "../exit-codes.js";
4
+ const TWO_DIGITS = 2;
5
+ export const CONTACT_COLS = 3;
6
+ /** Timeline order; within one scene the storyboard's own theme comes first. */
7
+ export function nameStills(captured, primary) {
8
+ const ordered = [...captured].sort((a, b) => a.pick.t - b.pick.t || Number(b.theme === primary) - Number(a.theme === primary));
9
+ return ordered.map((still, index) => ({
10
+ ...still,
11
+ name: `${String(index + 1).padStart(TWO_DIGITS, "0")}-${still.pick.id}-${still.theme}.png`,
12
+ }));
13
+ }
14
+ /** Deletes every PNG in a directory (stale output of an earlier run); a missing directory is fine. */
15
+ export async function clearPngs(host, dir) {
16
+ const names = await host.listDir(dir).catch(() => []);
17
+ for (const name of names)
18
+ if (name.endsWith(".png"))
19
+ await host.remove(join(dir, name));
20
+ }
21
+ /** PNGs in a directory, sorted by name (the order `hyperframes snapshot` numbered them). */
22
+ export async function listPngs(host, dir) {
23
+ const names = await host.listDir(dir).catch(() => []);
24
+ return names
25
+ .filter((name) => name.endsWith(".png"))
26
+ .sort()
27
+ .map((name) => join(dir, name));
28
+ }
29
+ /** ffconcat list for the stills (forward slashes; a quote in a path is refused, not escaped). */
30
+ export function concatList(files) {
31
+ const rows = files.map((file) => {
32
+ if (file.includes("'") || /[\r\n]/.test(file)) {
33
+ throw new Error(`cannot tile ${JSON.stringify(file)}: quote or line break in path`);
34
+ }
35
+ return `file '${file.replaceAll("\\", "/")}'\nduration 1\n`;
36
+ });
37
+ return rows.join("");
38
+ }
39
+ /** Tiles the stills into one `contact.png` with ffmpeg at `width` px per tile; resolves to an exit code. */
40
+ export async function contactSheet(io, files, listFile, out, width = TILE_WIDTH) {
41
+ await io.host.writeTextAtomic(listFile, concatList(files));
42
+ const args = contactImagesArgs(listFile, out, files.length, Math.min(CONTACT_COLS, files.length), width);
43
+ const result = await io.run("ffmpeg", args, { timeoutMs: 120_000 });
44
+ if (result.code === EXIT.MISSING_TOOL) {
45
+ io.stderr("mk: ffmpeg not found; install ffmpeg and put it on PATH\n");
46
+ return EXIT.MISSING_TOOL;
47
+ }
48
+ if (result.code !== 0) {
49
+ io.stderr(`mk preview: ffmpeg failed: ${result.stderr.trim().split(/\r?\n/).pop()}\n`);
50
+ return EXIT.ERROR;
51
+ }
52
+ return EXIT.OK;
53
+ }
@@ -0,0 +1,58 @@
1
+ import type { Storyboard } from "@anvpx/motion-schema";
2
+ /** A base scene as the pickers see it: timing from the resolved timeline, weight = prop size. */
3
+ export interface SceneInfo {
4
+ id: string;
5
+ type: string;
6
+ weight: number;
7
+ start: number;
8
+ duration: number;
9
+ }
10
+ export interface StillPick {
11
+ id: string;
12
+ type: string;
13
+ /** Seconds on the storyboard timeline. */
14
+ t: number;
15
+ }
16
+ export declare const CLIP_SCENE_SEC = 2;
17
+ export declare const CLIP_MAX_SEC = 30;
18
+ /** A still is taken here inside its scene, after the entrance animation has settled. */
19
+ export declare const SETTLED = 0.7;
20
+ /** Scenes shown in both themes of a pair. */
21
+ export declare const DARK_COUNT = 3;
22
+ /**
23
+ * Which scenes to show, as ascending indices: first, last, each distinct `type` once (the instance
24
+ * with the most props), then an even spread. Pure and deterministic.
25
+ */
26
+ export declare function pickScenes(scenes: readonly SceneInfo[], count: number): number[];
27
+ /** Time of each pick: 70% into its scene. */
28
+ export declare function stillPlan(scenes: readonly SceneInfo[], count: number): StillPick[];
29
+ /** The `limit` most distinct picks (different types first), in timeline order. */
30
+ export declare function distinctPicks(picks: readonly StillPick[], limit: number): StillPick[];
31
+ /** Overlay offset/duration pulled inside a base scene that is now `baseSec` long. */
32
+ export declare function withinScene(offset: number, duration: number, baseSec: number): {
33
+ offset: number;
34
+ duration: number;
35
+ };
36
+ export interface ClipPlan {
37
+ storyboard: Storyboard;
38
+ totalSec: number;
39
+ /** Base scenes kept, in order. */
40
+ sceneIds: string[];
41
+ }
42
+ /** Base scenes with resolved timing, as the pickers see them; undefined when timing fails. */
43
+ export declare function sceneInfos(board: Storyboard): SceneInfo[] | undefined;
44
+ /**
45
+ * What the default still picker chooses from: the base scenes plus every overlay that is off
46
+ * screen at the settled time of its base scene (so its id would otherwise never be shown), in
47
+ * timeline order. `mk preview --every` uses `sceneInfos` instead.
48
+ */
49
+ export declare function stillCandidates(board: Storyboard): SceneInfo[];
50
+ /**
51
+ * Preview clip as an in-memory storyboard: every base scene at most `CLIP_SCENE_SEC`, overlays
52
+ * clamped inside, narration stripped. Over `CLIP_MAX_SEC` in total, scenes are sampled with the
53
+ * same picker as the stills. Scenes are pure functions of time, so the first seconds show the
54
+ * entrance animation faithfully.
55
+ */
56
+ export declare function capClip(board: Storyboard, cap?: number, max?: number): ClipPlan;
57
+ /** Budget fallback: run the clip only if the time left covers its expected cost. */
58
+ export declare function shouldRunClip(elapsedMs: number, stillsMs: number, budgetMs: number): boolean;
@@ -0,0 +1,160 @@
1
+ import { resolveTimeline } from "@anvpx/motion-build";
2
+ export const CLIP_SCENE_SEC = 2;
3
+ export const CLIP_MAX_SEC = 30;
4
+ /** A still is taken here inside its scene, after the entrance animation has settled. */
5
+ export const SETTLED = 0.7;
6
+ /** Scenes shown in both themes of a pair. */
7
+ export const DARK_COUNT = 3;
8
+ /** The clip is expected to cost about this much of what the stills batch cost. */
9
+ const CLIP_COST_FACTOR = 1.5;
10
+ const MIN_OVERLAY_SEC = 1;
11
+ const round3 = (value) => Math.round(value * 1000) / 1000;
12
+ /** Midpoint of the widest gap between picked indices (ties: the earliest gap). */
13
+ function bisect(picked) {
14
+ const sorted = [...picked].sort((a, b) => a - b);
15
+ let best = 0;
16
+ for (let i = 1; i < sorted.length; i += 1) {
17
+ if ((sorted[i] ?? 0) - (sorted[i - 1] ?? 0) > (sorted[best + 1] ?? 0) - (sorted[best] ?? 0)) {
18
+ best = i - 1;
19
+ }
20
+ }
21
+ return Math.floor(((sorted[best] ?? 0) + (sorted[best + 1] ?? 0)) / 2);
22
+ }
23
+ const weightOf = (scenes, index) => scenes[index]?.weight ?? 0;
24
+ /**
25
+ * Which scenes to show, as ascending indices: first, last, each distinct `type` once (the instance
26
+ * with the most props), then an even spread. Pure and deterministic.
27
+ */
28
+ export function pickScenes(scenes, count) {
29
+ const n = scenes.length;
30
+ if (n === 0 || count <= 0)
31
+ return [];
32
+ if (count >= n)
33
+ return scenes.map((_, i) => i);
34
+ const picked = new Set([0]);
35
+ const add = (index) => {
36
+ if (picked.size < count)
37
+ picked.add(index);
38
+ };
39
+ add(n - 1);
40
+ for (const type of new Set(scenes.map((s) => s.type))) {
41
+ const same = scenes.flatMap((s, i) => (s.type === type ? [i] : []));
42
+ if (same.some((i) => picked.has(i)))
43
+ continue;
44
+ add(same.reduce((a, b) => (weightOf(scenes, b) > weightOf(scenes, a) ? b : a)));
45
+ }
46
+ while (picked.size < count)
47
+ picked.add(bisect(picked));
48
+ return [...picked].sort((a, b) => a - b);
49
+ }
50
+ /** Time of each pick: 70% into its scene. */
51
+ export function stillPlan(scenes, count) {
52
+ return pickScenes(scenes, count).flatMap((index) => {
53
+ const scene = scenes[index];
54
+ if (scene === undefined)
55
+ return [];
56
+ const t = Math.round((scene.start + SETTLED * scene.duration) * 100) / 100;
57
+ return [{ id: scene.id, type: scene.type, t }];
58
+ });
59
+ }
60
+ /** The `limit` most distinct picks (different types first), in timeline order. */
61
+ export function distinctPicks(picks, limit) {
62
+ if (picks.length <= limit)
63
+ return [...picks];
64
+ const infos = picks.map((p, i) => ({ id: p.id, type: p.type, weight: 1, start: i, duration: 1 }));
65
+ return pickScenes(infos, limit).flatMap((i) => (picks[i] ? [picks[i]] : []));
66
+ }
67
+ /** Overlay offset/duration pulled inside a base scene that is now `baseSec` long. */
68
+ export function withinScene(offset, duration, baseSec) {
69
+ const room = Math.min(MIN_OVERLAY_SEC, baseSec);
70
+ const start = round3(Math.min(offset, baseSec - room));
71
+ return { offset: start, duration: round3(Math.min(duration, baseSec - start)) };
72
+ }
73
+ const isOverlay = (scene) => scene.layer === "overlay";
74
+ /** Base scenes with resolved timing, as the pickers see them; undefined when timing fails. */
75
+ export function sceneInfos(board) {
76
+ const timeline = resolveTimeline(board);
77
+ if (!timeline.ok)
78
+ return undefined;
79
+ const entries = new Map(board.scenes.map((s) => [s.id, s]));
80
+ return timeline.value.scenes.map((t) => ({
81
+ id: t.id,
82
+ type: entries.get(t.id)?.type ?? "",
83
+ weight: JSON.stringify(entries.get(t.id)?.props ?? {}).length,
84
+ start: t.start,
85
+ duration: t.duration,
86
+ }));
87
+ }
88
+ /**
89
+ * What the default still picker chooses from: the base scenes plus every overlay that is off
90
+ * screen at the settled time of its base scene (so its id would otherwise never be shown), in
91
+ * timeline order. `mk preview --every` uses `sceneInfos` instead.
92
+ */
93
+ export function stillCandidates(board) {
94
+ const bases = sceneInfos(board);
95
+ const timeline = resolveTimeline(board);
96
+ if (bases === undefined || !timeline.ok)
97
+ return [];
98
+ const entries = new Map(board.scenes.map((s) => [s.id, s]));
99
+ const baseById = new Map(bases.map((b) => [b.id, b]));
100
+ const missed = timeline.value.overlays.flatMap((o) => {
101
+ const host = baseById.get(o.over);
102
+ const settled = host === undefined ? undefined : host.start + SETTLED * host.duration;
103
+ if (settled !== undefined && settled >= o.start && settled < o.end)
104
+ return [];
105
+ const entry = entries.get(o.id);
106
+ return [
107
+ {
108
+ id: o.id,
109
+ type: entry?.type ?? "",
110
+ weight: JSON.stringify(entry?.props ?? {}).length,
111
+ start: o.start,
112
+ duration: o.duration,
113
+ },
114
+ ];
115
+ });
116
+ return [...bases, ...missed].sort((a, b) => a.start - b.start);
117
+ }
118
+ const capped = (scene, durations, cap) => Math.min(durations.get(scene.id) ?? cap, cap);
119
+ const trimOverlay = (scene, keep, durations, cap) => {
120
+ const over = scene.over ?? "";
121
+ if (!keep.has(over))
122
+ return [];
123
+ const baseSec = Math.min(durations.get(over) ?? cap, cap);
124
+ const fit = withinScene(scene.offset ?? 0, scene.duration ?? baseSec, baseSec);
125
+ return [{ ...scene, ...fit }];
126
+ };
127
+ const trimmed = (board, keep, durations, cap) => board.scenes.flatMap((entry) => {
128
+ const { narration: _narration, ...scene } = entry;
129
+ if (isOverlay(scene))
130
+ return trimOverlay(scene, keep, durations, cap);
131
+ return keep.has(scene.id)
132
+ ? [{ ...scene, duration: round3(capped(scene, durations, cap)) }]
133
+ : [];
134
+ });
135
+ /**
136
+ * Preview clip as an in-memory storyboard: every base scene at most `CLIP_SCENE_SEC`, overlays
137
+ * clamped inside, narration stripped. Over `CLIP_MAX_SEC` in total, scenes are sampled with the
138
+ * same picker as the stills. Scenes are pure functions of time, so the first seconds show the
139
+ * entrance animation faithfully.
140
+ */
141
+ export function capClip(board, cap = CLIP_SCENE_SEC, max = CLIP_MAX_SEC) {
142
+ const infos = sceneInfos(board);
143
+ if (infos === undefined)
144
+ return { storyboard: board, totalSec: 0, sceneIds: [] };
145
+ const durations = new Map(infos.map((info) => [info.id, info.duration]));
146
+ for (let count = infos.length; count >= 1; count -= 1) {
147
+ const keep = new Set(pickScenes(infos, count).map((i) => infos[i]?.id ?? ""));
148
+ const candidate = { ...board, scenes: trimmed(board, keep, durations, cap) };
149
+ const timeline = resolveTimeline(candidate);
150
+ if (timeline.ok && (timeline.value.total <= max || count === 1)) {
151
+ const sceneIds = timeline.value.scenes.map((s) => s.id);
152
+ return { storyboard: candidate, totalSec: timeline.value.total, sceneIds };
153
+ }
154
+ }
155
+ return { storyboard: board, totalSec: 0, sceneIds: [] };
156
+ }
157
+ /** Budget fallback: run the clip only if the time left covers its expected cost. */
158
+ export function shouldRunClip(elapsedMs, stillsMs, budgetMs) {
159
+ return elapsedMs + stillsMs * CLIP_COST_FACTOR <= budgetMs;
160
+ }
@@ -0,0 +1,23 @@
1
+ import { type TemplateEnv } from "../template/env.ts";
2
+ import type { Host, Io } from "../types.ts";
3
+ /** What `mk new` recorded in `.mk/<stem>/template.json`. */
4
+ export interface TemplateProvenance {
5
+ /** `id@version`. */
6
+ template: string;
7
+ blueprint: string;
8
+ style: string;
9
+ length: string;
10
+ }
11
+ export declare const provenancePath: (storyboard: string) => string;
12
+ /** The recorded provenance, or undefined for a storyboard that did not come from `mk new`. */
13
+ export declare function readProvenance(host: Host, storyboard: string): Promise<TemplateProvenance | undefined>;
14
+ export interface Pair {
15
+ id: string;
16
+ /** Theme file text, ready to vendor into a scratch project. */
17
+ text: string;
18
+ }
19
+ /**
20
+ * The paired theme (light <-> dark) of `theme`, from the template that made the storyboard.
21
+ * Undefined when the template is not installed, has no pair, or the storyboard is hand-written.
22
+ */
23
+ export declare function resolvePair(env: TemplateEnv, io: Io, provenance: TemplateProvenance | undefined, theme: string): Pair | undefined;
@@ -0,0 +1,38 @@
1
+ import { join } from "node:path";
2
+ import { mkDir, readSidecar } from "../approval/hash.js";
3
+ import { findTemplate } from "../template/env.js";
4
+ import { TEMPLATE_PROVENANCE } from "../template/new.js";
5
+ export const provenancePath = (storyboard) => join(mkDir(storyboard), TEMPLATE_PROVENANCE);
6
+ const isProvenance = (value) => {
7
+ const row = value;
8
+ return (typeof row === "object" &&
9
+ row !== null &&
10
+ typeof row.template === "string" &&
11
+ typeof row.blueprint === "string" &&
12
+ typeof row.style === "string" &&
13
+ typeof row.length === "string");
14
+ };
15
+ /** The recorded provenance, or undefined for a storyboard that did not come from `mk new`. */
16
+ export async function readProvenance(host, storyboard) {
17
+ try {
18
+ const parsed = JSON.parse(await readSidecar(host, storyboard, TEMPLATE_PROVENANCE));
19
+ return isProvenance(parsed) ? parsed : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ /**
26
+ * The paired theme (light <-> dark) of `theme`, from the template that made the storyboard.
27
+ * Undefined when the template is not installed, has no pair, or the storyboard is hand-written.
28
+ */
29
+ export function resolvePair(env, io, provenance, theme) {
30
+ if (provenance === undefined)
31
+ return undefined;
32
+ const found = findTemplate(env, io, provenance.template.split("@")[0] ?? "");
33
+ if (!found.ok)
34
+ return undefined;
35
+ const pair = found.template.themes.get(theme)?.pair;
36
+ const file = pair === undefined ? undefined : found.template.themes.get(pair);
37
+ return file === undefined ? undefined : { id: file.id, text: file.text };
38
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from "../types.ts";
2
+ export declare const publishCommand: Command;
@@ -0,0 +1,70 @@
1
+ import { join } from "node:path";
2
+ import { EXIT } from "../exit-codes.js";
3
+ import { parseBuildArgs, prepareProject } from "../workflow/project.js";
4
+ import { COMPOSITION_FILE, PLAYER_FILE, PLAYER_JS, renderCompositionHtml, renderWrapperHtml, WRAPPER_FILE, } from "./player.js";
5
+ import { serveStatic } from "./static.js";
6
+ const USAGE = `mk publish <storyboard.json> [--out <dir>] [--port <n>] [--serve]
7
+
8
+ Build the storyboard and write a static site that plays it in a browser at 16:9.
9
+ --out <dir> project directory (default: <storyboard dir>/out/publish)
10
+ --port <n> port for --serve (default: 4321; 0 picks a free one)
11
+ --serve serve the directory until interrupted, and print the URL
12
+
13
+ The site is three files over the built project: composition.html (the build, plus a player),
14
+ player.js (a requestAnimationFrame loop that dispatches the renderer's seek event), and an
15
+ index.html wrapper that scales the fixed 1920x1080 composition to the window. Nothing is
16
+ re-laid-out and nothing is fetched: it plays the same timeline that renders to video.
17
+ `;
18
+ const DEFAULT_PORT = 4321;
19
+ /** The composition's own `<title>`, so the published tab matches the build. */
20
+ const titleOf = (html) => /<title>([^<]*)<\/title>/.exec(html)?.[1] ?? "composition";
21
+ export const publishCommand = {
22
+ name: "publish",
23
+ summary: "build a static 16:9 site that plays the storyboard in a browser",
24
+ async run(args, io) {
25
+ const parsed = parseBuildArgs("publish", USAGE, args, { port: { type: "string" }, serve: { type: "boolean" } }, io);
26
+ if (parsed.kind === "exit")
27
+ return parsed.code;
28
+ const prepared = await prepareProject(parsed.request, io);
29
+ if (!prepared.ok)
30
+ return prepared.code;
31
+ const index = join(prepared.outDir, WRAPPER_FILE);
32
+ const original = await io.host.readText(index).catch(() => undefined);
33
+ if (original === undefined) {
34
+ io.stderr(`mk: publish: ${index} is missing; the build did not produce an index.html\n`);
35
+ return EXIT.ERROR;
36
+ }
37
+ try {
38
+ await io.host.writeTextAtomic(join(prepared.outDir, COMPOSITION_FILE), renderCompositionHtml(original));
39
+ await io.host.writeTextAtomic(join(prepared.outDir, PLAYER_FILE), PLAYER_JS);
40
+ await io.host.writeTextAtomic(index, renderWrapperHtml({
41
+ title: titleOf(original),
42
+ width: prepared.meta.width,
43
+ height: prepared.meta.height,
44
+ }));
45
+ }
46
+ catch (error) {
47
+ io.stderr(`mk: publish failed: ${error.message}\n`);
48
+ return EXIT.ERROR;
49
+ }
50
+ const portText = parsed.flags.port;
51
+ const port = typeof portText === "string" ? Number(portText) : DEFAULT_PORT;
52
+ if (typeof portText === "string" && (!Number.isInteger(port) || port < 0 || port > 65535)) {
53
+ io.stderr(`mk publish: --port "${portText}" is not a port number\n`);
54
+ return EXIT.USAGE;
55
+ }
56
+ io.stdout(`published ${prepared.outDir}\n`);
57
+ if (parsed.flags.serve !== true) {
58
+ io.stdout(`serve it with any static file server; ES modules do not load from file://\n`);
59
+ return EXIT.OK;
60
+ }
61
+ const served = await serveStatic(prepared.outDir, port);
62
+ io.stdout(`serving ${served.url} (Ctrl-C to stop)\n`);
63
+ await new Promise((done) => {
64
+ process.once("SIGINT", () => done());
65
+ process.once("SIGTERM", () => done());
66
+ });
67
+ await served.close();
68
+ return EXIT.OK;
69
+ },
70
+ };
@@ -0,0 +1,25 @@
1
+ /** Files `mk publish` adds beside a built project. */
2
+ export declare const PLAYER_FILE = "player.js";
3
+ export declare const COMPOSITION_FILE = "composition.html";
4
+ export declare const WRAPPER_FILE = "index.html";
5
+ /**
6
+ * The playback loop. The built project only animates when something dispatches the renderer's
7
+ * `hf-seek` event, so the player stands in for HyperFrames: it dispatches the same event on a
8
+ * requestAnimationFrame loop, looping the timeline forever. Nothing else in the page changes.
9
+ */
10
+ export declare const PLAYER_JS = "// motion-kit player: loops the composition by dispatching the renderer's own seek event.\nconst root = document.getElementById(\"root\");\nconst total = Number(root?.dataset.duration ?? 0) || 1;\nlet startedAt = null;\nlet paused = false;\nlet pausedAt = 0;\n\nconst seek = (time) => {\n window.dispatchEvent(\n new CustomEvent(\"hf-seek\", { detail: { time, waitUntil: () => undefined } }),\n );\n};\n\nconst tick = (now) => {\n if (startedAt === null) startedAt = now;\n if (!paused) seek((((now - startedAt) / 1000) % total));\n requestAnimationFrame(tick);\n};\n\n// Click or Space toggles playback; the timeline keeps looping either way.\nwindow.addEventListener(\"click\", () => {\n paused = !paused;\n if (!paused) startedAt = performance.now() - pausedAt * 1000;\n else pausedAt = ((performance.now() - startedAt) / 1000) % total;\n});\nwindow.addEventListener(\"keydown\", (event) => {\n if (event.code === \"Space\") {\n event.preventDefault();\n window.dispatchEvent(new Event(\"click\"));\n }\n});\n\nrequestAnimationFrame(tick);\n";
11
+ /**
12
+ * The composition with the player appended. Every other byte is the build's, so `composition.html`
13
+ * and the project `mk build` produced stay comparable.
14
+ */
15
+ export declare const renderCompositionHtml: (indexHtml: string) => string;
16
+ export interface WrapperInput {
17
+ readonly title: string;
18
+ readonly width: number;
19
+ readonly height: number;
20
+ }
21
+ /**
22
+ * A 16:9 stage that scales the fixed-size composition to the window. This is the whole of the
23
+ * "web page" claim: same timeline, same pixels, fitted to the viewport, no responsive re-layout.
24
+ */
25
+ export declare const renderWrapperHtml: (input: WrapperInput) => string;