@mekuru/core 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.jp.md ADDED
@@ -0,0 +1,18 @@
1
+ # @mekuru/core
2
+
3
+ [English](README.md)
4
+
5
+ Mekuru Movie Maker の台本を TSX で書き、XML に書き出すパッケージです。組み込み要素の型と、Hono の JSX ランタイムの再エクスポートを提供します。
6
+
7
+ Bun 1.4 以上が必要です。
8
+
9
+ ```sh
10
+ bun add -d @mekuru/core
11
+ ```
12
+
13
+ - `tsconfig.json` の `jsx` を `react-jsx`、`jsxImportSource` を `@mekuru/core` に設定します。
14
+ - `writeXml(path, video)` で部品を展開して XML を書き出し、`toXml(video)` で XML 文字列を取得できます。
15
+ - 非同期の読み上げ部品は、先に下書きを書き出し、音声合成が終わると更新できます。
16
+ - XML のプレビューと動画への書き出しには [@mekuru/cli](../cli/README.jp.md) を使います。
17
+
18
+ [TSX の書き方](../../docs/reference/Component.jp.md) と [組み込みの要素](../../docs/reference/Syntax.jp.md)
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @mekuru/core
2
+
3
+ [日本語](README.jp.md)
4
+
5
+ TSX authoring and XML output for Mekuru Movie Maker. Provides types for built-in elements and re-exports Hono's JSX runtime.
6
+
7
+ Requires Bun 1.4 or later.
8
+
9
+ ```sh
10
+ bun add -d @mekuru/core
11
+ ```
12
+
13
+ - Set `jsx` to `react-jsx` and `jsxImportSource` to `@mekuru/core` in `tsconfig.json`.
14
+ - Use `writeXml(path, video)` to expand components and write XML, or `toXml(video)` to get an XML string.
15
+ - Async speech components can write an initial draft and update it after synthesis finishes.
16
+ - Use [@mekuru/cli](../cli/README.md) to preview the XML or render a video.
17
+
18
+ See [TSX authoring](../../docs/reference/Component.md) and [built-in elements](../../docs/reference/Syntax.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mekuru/core",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "The types of the XML of Mekuru Movie Maker, for writing videos in TSX with jsxImportSource @mekuru/core (the JSX runtime is Hono's); the viewer and the compiler are in @mekuru/cli",
5
5
  "type": "module",
6
6
  "exports": {
package/src/context.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export interface BuildContext {
4
+ root: string;
5
+ draft: boolean;
6
+ waits: Set<Promise<unknown>>;
7
+ values: Map<symbol, unknown>;
8
+ }
9
+
10
+ export const builds = new AsyncLocalStorage<BuildContext>();
11
+
12
+ export const context = (draft = false): BuildContext => ({ root: process.cwd(), draft, waits: new Set(), values: new Map() });
13
+
14
+ export const projectRoot = (): string => builds.getStore()?.root ?? process.cwd();
15
+ export const isDraft = (): boolean => builds.getStore()?.draft ?? false;
16
+
17
+ export const waitFor = (work: Promise<unknown>): void => {
18
+ work.catch(() => {});
19
+ builds.getStore()?.waits.add(work);
20
+ };
21
+
22
+ /** Share a value within one build, including its draft and final passes. */
23
+ export const buildValue = <T>(key: symbol, create: () => T): T => {
24
+ const values = builds.getStore()?.values;
25
+ if (!values) throw new Error("buildValue() is available while toXml or writeXml expands a video");
26
+ if (!values.has(key)) values.set(key, create());
27
+ return values.get(key) as T;
28
+ };
package/src/expand.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ type Props = Record<string, unknown>;
3
+
4
+ export interface Element {
5
+ tag: string;
6
+ props: Props;
7
+ children: Item[];
8
+ }
9
+ export type Item = Element | string;
10
+
11
+ interface JsxNode {
12
+ tag: unknown;
13
+ props: Props;
14
+ children: unknown[];
15
+ }
16
+
17
+ const isJsxNode = (v: unknown): v is JsxNode => typeof v === "object" && v !== null && "tag" in v && "props" in v && Array.isArray((v as JsxNode).children);
18
+
19
+ const describe = (v: unknown): string => (typeof v === "object" && v !== null ? (v.constructor?.name ?? "an object") : typeof v);
20
+
21
+ const rate = new AsyncLocalStorage<number>();
22
+
23
+ export const fps = (): number => {
24
+ const f = rate.getStore();
25
+ if (f === undefined) throw new Error("fps() is known only inside `<mekuru>` while writeXml writes it: call it in a component");
26
+ return f;
27
+ };
28
+
29
+ const frameRate = (v: unknown): number => {
30
+ const f = v === undefined ? 30 : Number(v);
31
+ if (!(f > 0 && f <= 240)) throw new Error(`\`<mekuru fps>\` is frames per second, a number above 0 (not ${String(v)})`);
32
+ return f;
33
+ };
34
+
35
+ export const expand = async (node: unknown, out: Item[]): Promise<void> => {
36
+ node = await node;
37
+ if (node === null || node === undefined || typeof node === "boolean") return;
38
+ if (typeof node === "string" || typeof node === "number" || typeof node === "bigint" || node instanceof String) {
39
+ out.push(String(node));
40
+ return;
41
+ }
42
+ if (Array.isArray(node)) {
43
+ for (const n of node) await expand(n, out);
44
+ return;
45
+ }
46
+ if (isJsxNode(node)) {
47
+ const { tag, props, children } = node;
48
+ if (typeof tag === "function") {
49
+ const p = { ...props };
50
+ if (children.length) p.children = children.length === 1 ? children[0] : children;
51
+ return expand(await tag(p), out);
52
+ }
53
+ if (tag === "") {
54
+ for (const c of children) await expand(c, out);
55
+ return;
56
+ }
57
+ if (typeof tag === "string") {
58
+ const el: Element = { tag, props, children: [] };
59
+ const inside = async () => {
60
+ for (const c of children) await expand(c, el.children);
61
+ };
62
+ await (tag === "mekuru" ? rate.run(frameRate(props.fps), inside) : inside());
63
+ out.push(el);
64
+ return;
65
+ }
66
+ }
67
+ throw new Error(`a video is made of elements and text, not ${describe(node)}`);
68
+ };
69
+
70
+ export type TextRun = string | null;
71
+
72
+ /** Expand text components using the same rules as the rest of the video. Null is a line break. */
73
+ export const resolveText = async (children: unknown): Promise<TextRun[]> => {
74
+ const items: Item[] = [];
75
+ await expand(children, items);
76
+ const runs: TextRun[] = [];
77
+ for (const item of items) {
78
+ if (typeof item === "string") {
79
+ const last = runs[runs.length - 1];
80
+ if (typeof last === "string") runs[runs.length - 1] = last + item;
81
+ else runs.push(item);
82
+ } else if (item.tag === "br" && item.children.length === 0) runs.push(null);
83
+ else throw new Error(`a line of speech is text (and \`<br />\`), not <${item.tag}>`);
84
+ }
85
+ return runs;
86
+ };
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { Fragment, jsx, jsx as jsxs, jsx as jsxDEV } from "hono/jsx/jsx-runtime";
2
2
 
3
3
  export type { Anchor, At, Color, IntrinsicElements, JSX, Length, Node, Time } from "./jsx.ts";
4
- export { isDraft, waitFor, writeXml } from "./write.ts";
5
- export { fps, toXml } from "./xml.ts";
4
+ export { buildValue, isDraft, projectRoot, waitFor } from "./context.ts";
5
+ export { writeXml } from "./write.ts";
6
+ export { fps, resolveText, toXml, type TextRun } from "./xml.ts";
@@ -0,0 +1,89 @@
1
+ import type { Element } from "./expand.ts";
2
+ export interface X {
3
+ tag: string;
4
+ attrs: [string, string][];
5
+ children: (X | string)[];
6
+ }
7
+
8
+ const TIMES = new Set(["len", "fadein", "fadeout", "from", "to", "delay"]);
9
+ const LENGTHS = new Set(["x", "y", "w", "h", "min-w", "max-w", "min-h", "max-h", "gap"]);
10
+ const RATIOS = new Set(["opacity", "volume"]);
11
+ const COLORS = new Set(["fill", "stroke", "color", "background"]);
12
+
13
+ const NAMED: Record<string, string> = {
14
+ black: "#000000",
15
+ white: "#FFFFFF",
16
+ red: "#FF0000",
17
+ lime: "#00FF00",
18
+ green: "#008000",
19
+ blue: "#0000FF",
20
+ yellow: "#FFFF00",
21
+ cyan: "#00FFFF",
22
+ aqua: "#00FFFF",
23
+ magenta: "#FF00FF",
24
+ fuchsia: "#FF00FF",
25
+ gray: "#808080",
26
+ grey: "#808080",
27
+ silver: "#C0C0C0",
28
+ maroon: "#800000",
29
+ olive: "#808000",
30
+ purple: "#800080",
31
+ teal: "#008080",
32
+ navy: "#000080",
33
+ orange: "#FFA500",
34
+ transparent: "#00000000",
35
+ none: "#00000000",
36
+ };
37
+
38
+ const ANCHORS: Record<string, string> = { center: "c", top: "t", bottom: "b", left: "l", right: "r" };
39
+
40
+ export const num = (n: number): string => {
41
+ const s = String(n);
42
+ return /e/i.test(s) && Math.abs(n) < 1 ? n.toFixed(20).replace(/\.?0+$/, "") : s;
43
+ };
44
+
45
+ const frames = (v: unknown): string => (typeof v === "number" ? num(v) : String(v).trim());
46
+
47
+ const length = (v: unknown): string => (typeof v === "number" ? num(v) : String(v));
48
+
49
+ const ratio = (v: unknown): string => {
50
+ if (typeof v === "number") return num(v);
51
+ const s = String(v).trim();
52
+ const pct = /^(-?[\d.]+)%$/.exec(s);
53
+ return pct ? num(Number(pct[1]) / 100) : s;
54
+ };
55
+
56
+ const color = (v: unknown): string => NAMED[String(v).trim().toLowerCase()] ?? String(v).trim();
57
+
58
+ const at = (v: unknown): [string, string] => {
59
+ const s = typeof v === "string" ? v.trim() : v;
60
+ if (typeof s !== "string" || !/^[\p{L}_]/u.test(s)) return ["delay", frames(s)];
61
+ const [id, q] = s.includes(":") ? [s.slice(0, s.indexOf(":")), s.slice(s.indexOf(":") + 1)] : [s, "0"];
62
+ return ["at", `${id.trim()}:${frames(q)}`];
63
+ };
64
+
65
+ const attr = (tag: string, key: string, v: unknown): [string, string] | undefined => {
66
+ if (key === "at") return tag === "hold" ? ["at", frames(v)] : at(v);
67
+ if (typeof v === "boolean") return key === "grow" ? [key, v ? "1" : "0"] : v ? [key, "true"] : undefined;
68
+ if (TIMES.has(key)) return [key, frames(v)];
69
+ if (LENGTHS.has(key)) return [key, length(v)];
70
+ if (RATIOS.has(key)) return [key, ratio(v)];
71
+ if (COLORS.has(key)) return [key, color(v)];
72
+ if (key === "padding") return [key, (Array.isArray(v) ? v : [v]).map(length).join(" ")];
73
+ if (key === "anchor") return [key, ANCHORS[String(v)] ?? String(v)];
74
+ return [key, typeof v === "number" ? num(v) : String(v)];
75
+ };
76
+
77
+ export const normalize = (el: Element): X => {
78
+ const x: X = { tag: el.tag, attrs: [], children: [] };
79
+ for (const c of el.children) {
80
+ if (typeof c !== "string") x.children.push(normalize(c));
81
+ else if (el.tag === "text" || c.trim()) x.children.push(c);
82
+ }
83
+ for (const [k, v] of Object.entries(el.props)) {
84
+ if (k === "children" || k === "key" || v === undefined || v === null) continue;
85
+ const a = attr(el.tag, k, v);
86
+ if (a) x.attrs.push(a);
87
+ }
88
+ return x;
89
+ };
package/src/write.ts CHANGED
@@ -1,24 +1,17 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
1
  import { rename, rm } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { builds, context as newContext } from "./context.ts";
3
4
  import { toXml } from "./xml.ts";
4
5
 
5
- const output = new AsyncLocalStorage<{ draft: boolean; waits: Set<Promise<unknown>> }>();
6
-
7
- export const isDraft = (): boolean => output.getStore()?.draft ?? false;
8
-
9
- export const waitFor = (work: Promise<unknown>): void => {
10
- work.catch(() => {});
11
- output.getStore()?.waits.add(work);
12
- };
13
-
14
6
  export const writeXml = async (path: string, video: unknown): Promise<void> => {
15
- const context = { draft: true, waits: new Set<Promise<unknown>>() };
16
- await output.run(context, async () => {
17
- const tmp = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
7
+ const context = newContext(true);
8
+ const file = resolve(context.root, path);
9
+ await builds.run(context, async () => {
10
+ const tmp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
18
11
  const save = async () => {
19
12
  const xml = await toXml(video);
20
13
  await Bun.write(tmp, xml);
21
- await rename(tmp, path);
14
+ await rename(tmp, file);
22
15
  };
23
16
  try {
24
17
  await save();
package/src/xml.ts CHANGED
@@ -1,161 +1,7 @@
1
- import { AsyncLocalStorage } from "node:async_hooks";
2
-
3
- type Props = Record<string, unknown>;
4
-
5
- interface Element {
6
- tag: string;
7
- props: Props;
8
- children: Item[];
9
- }
10
- type Item = Element | string;
11
-
12
- interface JsxNode {
13
- tag: unknown;
14
- props: Props;
15
- children: unknown[];
16
- }
17
-
18
- const isJsxNode = (v: unknown): v is JsxNode => typeof v === "object" && v !== null && "tag" in v && "props" in v && Array.isArray((v as JsxNode).children);
19
-
20
- const describe = (v: unknown): string => (typeof v === "object" && v !== null ? (v.constructor?.name ?? "an object") : typeof v);
21
-
22
- const rate = new AsyncLocalStorage<number>();
23
-
24
- export const fps = (): number => {
25
- const f = rate.getStore();
26
- if (f === undefined) throw new Error("fps() is known only inside `<mekuru>` while writeXml writes it: call it in a component");
27
- return f;
28
- };
29
-
30
- const frameRate = (v: unknown): number => {
31
- const f = v === undefined ? 30 : Number(v);
32
- if (!(f > 0 && f <= 240)) throw new Error(`\`<mekuru fps>\` is frames per second, a number above 0 (not ${String(v)})`);
33
- return f;
34
- };
35
-
36
- const expand = async (node: unknown, out: Item[]): Promise<void> => {
37
- node = await node;
38
- if (node === null || node === undefined || typeof node === "boolean") return;
39
- if (typeof node === "string" || typeof node === "number" || typeof node === "bigint" || node instanceof String) {
40
- out.push(String(node));
41
- return;
42
- }
43
- if (Array.isArray(node)) {
44
- for (const n of node) await expand(n, out);
45
- return;
46
- }
47
- if (isJsxNode(node)) {
48
- const { tag, props, children } = node;
49
- if (typeof tag === "function") {
50
- const p = { ...props };
51
- if (children.length) p.children = children.length === 1 ? children[0] : children;
52
- return expand(await tag(p), out);
53
- }
54
- if (tag === "") {
55
- for (const c of children) await expand(c, out);
56
- return;
57
- }
58
- if (typeof tag === "string") {
59
- const el: Element = { tag, props, children: [] };
60
- const inside = async () => {
61
- for (const c of children) await expand(c, el.children);
62
- };
63
- await (tag === "mekuru" ? rate.run(frameRate(props.fps), inside) : inside());
64
- out.push(el);
65
- return;
66
- }
67
- }
68
- throw new Error(`a video is made of elements and text, not ${describe(node)}`);
69
- };
70
-
71
- interface X {
72
- tag: string;
73
- attrs: [string, string][];
74
- children: (X | string)[];
75
- }
76
-
77
- const TIMES = new Set(["len", "fadein", "fadeout", "from", "to", "delay"]);
78
- const LENGTHS = new Set(["x", "y", "w", "h", "min-w", "max-w", "min-h", "max-h", "gap"]);
79
- const RATIOS = new Set(["opacity", "volume"]);
80
- const COLORS = new Set(["fill", "stroke", "color", "background"]);
81
-
82
- const NAMED: Record<string, string> = {
83
- black: "#000000",
84
- white: "#FFFFFF",
85
- red: "#FF0000",
86
- lime: "#00FF00",
87
- green: "#008000",
88
- blue: "#0000FF",
89
- yellow: "#FFFF00",
90
- cyan: "#00FFFF",
91
- aqua: "#00FFFF",
92
- magenta: "#FF00FF",
93
- fuchsia: "#FF00FF",
94
- gray: "#808080",
95
- grey: "#808080",
96
- silver: "#C0C0C0",
97
- maroon: "#800000",
98
- olive: "#808000",
99
- purple: "#800080",
100
- teal: "#008080",
101
- navy: "#000080",
102
- orange: "#FFA500",
103
- transparent: "#00000000",
104
- none: "#00000000",
105
- };
106
-
107
- const ANCHORS: Record<string, string> = { center: "c", top: "t", bottom: "b", left: "l", right: "r" };
108
-
109
- const num = (n: number): string => {
110
- const s = String(n);
111
- return /e/i.test(s) && Math.abs(n) < 1 ? n.toFixed(20).replace(/\.?0+$/, "") : s;
112
- };
113
-
114
- const frames = (v: unknown): string => (typeof v === "number" ? num(v) : String(v).trim());
115
-
116
- const length = (v: unknown): string => (typeof v === "number" ? num(v) : String(v));
117
-
118
- const ratio = (v: unknown): string => {
119
- if (typeof v === "number") return num(v);
120
- const s = String(v).trim();
121
- const pct = /^(-?[\d.]+)%$/.exec(s);
122
- return pct ? num(Number(pct[1]) / 100) : s;
123
- };
124
-
125
- const color = (v: unknown): string => NAMED[String(v).trim().toLowerCase()] ?? String(v).trim();
126
-
127
- const at = (v: unknown): [string, string] => {
128
- const s = typeof v === "string" ? v.trim() : v;
129
- if (typeof s !== "string" || !/^[\p{L}_]/u.test(s)) return ["delay", frames(s)];
130
- const [id, q] = s.includes(":") ? [s.slice(0, s.indexOf(":")), s.slice(s.indexOf(":") + 1)] : [s, "0"];
131
- return ["at", `${id.trim()}:${frames(q)}`];
132
- };
133
-
134
- const attr = (tag: string, key: string, v: unknown): [string, string] | undefined => {
135
- if (key === "at") return tag === "hold" ? ["at", frames(v)] : at(v);
136
- if (typeof v === "boolean") return key === "grow" ? [key, v ? "1" : "0"] : v ? [key, "true"] : undefined;
137
- if (TIMES.has(key)) return [key, frames(v)];
138
- if (LENGTHS.has(key)) return [key, length(v)];
139
- if (RATIOS.has(key)) return [key, ratio(v)];
140
- if (COLORS.has(key)) return [key, color(v)];
141
- if (key === "padding") return [key, (Array.isArray(v) ? v : [v]).map(length).join(" ")];
142
- if (key === "anchor") return [key, ANCHORS[String(v)] ?? String(v)];
143
- return [key, typeof v === "number" ? num(v) : String(v)];
144
- };
145
-
146
- const normalize = (el: Element): X => {
147
- const x: X = { tag: el.tag, attrs: [], children: [] };
148
- for (const c of el.children) {
149
- if (typeof c !== "string") x.children.push(normalize(c));
150
- else if (el.tag === "text" || c.trim()) x.children.push(c);
151
- }
152
- for (const [k, v] of Object.entries(el.props)) {
153
- if (k === "children" || k === "key" || v === undefined || v === null) continue;
154
- const a = attr(el.tag, k, v);
155
- if (a) x.attrs.push(a);
156
- }
157
- return x;
158
- };
1
+ import { normalize, num, type X } from "./normalize.ts";
2
+ import { expand, type Element, type Item } from "./expand.ts";
3
+ export { fps, resolveText, type TextRun } from "./expand.ts";
4
+ import { builds, context } from "./context.ts";
159
5
 
160
6
  const escapeXml = (s: string, quote: boolean) =>
161
7
  s.replace(
@@ -178,7 +24,7 @@ const write = (x: X, indent: string, out: string[]) => {
178
24
  }
179
25
  };
180
26
 
181
- export const toXml = async (video: unknown): Promise<string> => {
27
+ const renderXml = async (video: unknown): Promise<string> => {
182
28
  const top: Item[] = [];
183
29
  await expand(video, top);
184
30
  const root = top.find((i): i is Element => typeof i !== "string");
@@ -192,3 +38,5 @@ export const toXml = async (video: unknown): Promise<string> => {
192
38
  write(x, "", out);
193
39
  return `${out.join("\n")}\n`;
194
40
  };
41
+
42
+ export const toXml = (video: unknown): Promise<string> => (builds.getStore() ? renderXml(video) : builds.run(context(), () => renderXml(video)));