@mekuru/core 0.1.2 → 0.1.4
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 +58 -7
- package/README.md +58 -7
- package/bin/launch.js +19 -0
- package/bin/mekuru-compiler.js +3 -0
- package/bin/mekuru-server.js +3 -0
- package/npm/compiler.d.ts +1 -0
- package/npm/compiler.js +43 -0
- package/npm/timeline.ts +38 -0
- package/package.json +27 -11
- package/src/index.ts +0 -5
- package/src/jsx.ts +0 -138
- package/src/write.ts +0 -35
- package/src/xml.ts +0 -194
package/README.jp.md
CHANGED
|
@@ -2,17 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md)
|
|
4
4
|
|
|
5
|
-
Mekuru Movie Maker
|
|
5
|
+
Mekuru Movie Maker の Rust 製動画コンパイラ兼プレビューサーバーです。XML の監視、コンパイル、HTTP/WebSocket・素材の配信、ブラウザ用ビューアを1つの実行ファイルにまとめています。ffmpeg による動画書き出しも行います。
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
旧 `@mekuru/core` の TSX の型・JSX ランタイム・XML 出力は [@mekuru/types](../types/README.jp.md) へ移動しました。台本の import と `jsxImportSource` は `@mekuru/types` へ変更してください。
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
bun add -d @mekuru/core
|
|
11
|
+
bunx mekuru-compiler .mekuru/main.xml --serve --run 'bun --hot src/main.tsx'
|
|
12
|
+
bunx mekuru-compiler .mekuru/main.xml -o video.mp4
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
- `writeXml(path, video)` で部品を展開して XML を書き出し、`toXml(video)` で XML 文字列を取得できます。
|
|
15
|
-
- 非同期の読み上げ部品は、先に下書きを書き出し、音声合成が終わると更新できます。
|
|
16
|
-
- XML のプレビューと動画への書き出しには [@mekuru/cli](../cli/README.jp.md) を使います。
|
|
15
|
+
`@mekuru/cli` を `@mekuru/core` に統合しました。依存名と `@mekuru/cli/timeline` などの import を `@mekuru/core/timeline` へ変更してください。OS 別バイナリは自動導入されます。npm コマンドにはバイナリ選択用の小さな Node/Bun ランチャーだけを残し、サーバーの処理はすべて Rust です。`mekuru-server <xml>` は `mekuru-compiler <xml> --serve` の別名として使えます。JavaScript の旧 `startServer` API は廃止し、ネイティブコマンドの起動または Rust の `mekuru_compiler::server::start` を利用します。
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
実行ファイルをコピーすれば、Node・Bun・別のビューアフォルダなしで直接起動できます。
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
mekuru-compiler project/main.xml --serve --no-open
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`--run` で TSX から XML を作る場合は、そのコマンドに Bun が必要です。動画書き出し・メディア解析には ffmpeg/ffprobe、文字表示には適切なフォントを用意してください。
|
|
24
|
+
|
|
25
|
+
## プレビューのオプション
|
|
26
|
+
|
|
27
|
+
- `--serve`: XML を監視し、ループバックでプレビューとタイムラインを配信します。
|
|
28
|
+
- `-p, --port <port>`: ポート。既定は3333、0なら空きポートを選びます。
|
|
29
|
+
- `--root <dir>`: 素材とスクリプトの基準フォルダ。既定は XML のフォルダ、`.mekuru` の場合はその親です。
|
|
30
|
+
- `--run <command>`: プロジェクトフォルダでシェルコマンドを実行します。標準エラーを次の XML 保存までビューアへ表示し、サーバー終了時は子プロセスも終了させます。
|
|
31
|
+
- `--title <name>`: ブラウザのタイトル。既定は XML の引数です。
|
|
32
|
+
- `--no-open` / `--open`: ブラウザの自動起動。`MEKURU_OPEN=0`・`false`・`no`・`off` でも抑止できます。
|
|
33
|
+
|
|
34
|
+
`--run` があれば XML がまだ存在しなくても起動できます。コンパイル失敗時は最後の成功シーンを維持します。保存後は10 ms間隔で2回読み、内容の一致を確認します。`/mnt/` では500 ms間隔のポーリングを使います。受信が遅れた WebSocket 接続には snapshot を送り直します。素材は HEAD・バイト範囲取得に対応し、プロジェクト外へ解決されるパスは配信しません。
|
|
35
|
+
|
|
36
|
+
## 書き出し・機械向け出力
|
|
37
|
+
|
|
38
|
+
- `--scale`・`--fps`・`--from`・`--to`・`--preset`・`--crf`・`--no-audio`・`--preview` で動画書き出しを設定します。
|
|
39
|
+
- `--emit-preview`: 展開済みのプレビュー JSON を出力します。
|
|
40
|
+
- `--emit-timeline`: 描画パスを含めず、台本 ID・整数フレーム区間・繰り返し・診断を出力します。
|
|
41
|
+
- `--preview-worker`: ツールと計測用に JSON Lines 接続を維持します。ネイティブサーバーはコンパイラを同一プロセス内で呼び出し、IPC を使いません。
|
|
42
|
+
|
|
43
|
+
## 公開APIと軽量タイムライン
|
|
44
|
+
|
|
45
|
+
Bun の台本用アダプターと型は `@mekuru/core/timeline` から利用できます。
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { compileTimeline, timelineIntervals } from "@mekuru/core/timeline";
|
|
49
|
+
const result = await compileTimeline(".mekuru/main.xml");
|
|
50
|
+
for (const entry of result.timeline?.entries ?? []) {
|
|
51
|
+
console.log(entry.sourceId, timelineIntervals(entry));
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`compileTimeline(xmlPath, { root? })` は `{ timeline, diagnostics }` を返し、解析失敗時は timeline が null です。`timelineIntervals` は入れ子の繰り返しを展開し、最終周回を切り詰めた論理区間を返します。スロットによる表示区間とは別です。`@mekuru/core/compiler` の `findCompiler` は、`MEKURU_COMPILER`、OS 別 npm パッケージ、ローカルの新しい debug/release バイナリの順に探します。
|
|
56
|
+
|
|
57
|
+
## ビルド
|
|
58
|
+
|
|
59
|
+
Rust 1.89 以上、C リンカー、UI ビルド用の Bun を使います。
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
bun install
|
|
63
|
+
bun run build:release
|
|
64
|
+
./target/release/mekuru-compiler path/to/main.xml --serve
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
ビューアを `pkgs/core/viewer/` にビルドし、`build.rs` で実行ファイルへ埋め込みます。UI 変更後は実行ファイルも再ビルドしてください。release ビルドは UI 未生成なら失敗し、Rust だけの debug ビルドでは作成手順を表示します。CI はすべての OS 別バイナリへ UI を埋め込んでから梱包します。
|
|
68
|
+
|
|
69
|
+
[XML構文](../../docs/reference/Syntax.jp.md)・[開発手順](../../docs/dev/Developer.jp.md)も参照してください。
|
package/README.md
CHANGED
|
@@ -2,17 +2,68 @@
|
|
|
2
2
|
|
|
3
3
|
[日本語](README.jp.md)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
The Rust video compiler and preview server for Mekuru Movie Maker. One native executable watches XML, compiles scenes, serves HTTP/WebSocket and media, and contains the browser viewer. It also exports video through ffmpeg.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
TSX types, the JSX runtime and XML output are provided by [@mekuru/types](../types/README.md), formerly `@mekuru/core`. Update authoring imports and `jsxImportSource` to `@mekuru/types`.
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
bun add -d @mekuru/core
|
|
11
|
+
bunx mekuru-compiler .mekuru/main.xml --serve --run 'bun --hot src/main.tsx'
|
|
12
|
+
bunx mekuru-compiler .mekuru/main.xml -o video.mp4
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
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.
|
|
15
|
+
`@mekuru/core` replaces `@mekuru/cli`. Change that dependency and imports such as `@mekuru/cli/timeline` to `@mekuru/core/timeline`. Platform binaries are installed automatically. The npm commands are small Node/Bun launchers that select the binary; all server behavior is Rust. `mekuru-server <xml>` remains an alias for `mekuru-compiler <xml> --serve`. The old JavaScript `startServer` API is removed; spawn the native command or use Rust's `mekuru_compiler::server::start`.
|
|
17
16
|
|
|
18
|
-
|
|
17
|
+
The native executable can be copied and run directly without Node, Bun or a separate viewer folder:
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
mekuru-compiler project/main.xml --serve --no-open
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Bun is still needed when your `--run` command authors XML using TSX. Video export and media analysis require ffmpeg/ffprobe; install fonts suitable for your script.
|
|
24
|
+
|
|
25
|
+
## Preview options
|
|
26
|
+
|
|
27
|
+
- `--serve`: watch XML and serve the embedded preview and timeline on loopback.
|
|
28
|
+
- `-p, --port <port>`: port, default 3333; use 0 to choose an available port.
|
|
29
|
+
- `--root <dir>`: asset and script working directory. Defaults to the XML folder, or its parent when that folder is `.mekuru`.
|
|
30
|
+
- `--run <command>`: run a shell command in the project directory. Stderr appears in the viewer until the next XML save. The server terminates the command and its child processes on shutdown.
|
|
31
|
+
- `--title <name>`: browser title, default the XML argument.
|
|
32
|
+
- `--no-open` / `--open`: control automatic browser opening. `MEKURU_OPEN=0`, `false`, `no` or `off` also disables it.
|
|
33
|
+
|
|
34
|
+
A missing XML file is allowed with `--run`. Compilation errors preserve the last successful scene. Saving uses two matching reads 10 ms apart; `/mnt/` uses a 500 ms polling fallback. Slow WebSocket readers receive a fresh snapshot when they fall behind. Asset URLs support HEAD and byte ranges; paths must resolve inside the project root.
|
|
35
|
+
|
|
36
|
+
## Export and machine output
|
|
37
|
+
|
|
38
|
+
- `--scale`, `--fps`, `--from`, `--to`, `--preset`, `--crf`, `--no-audio`, and `--preview` configure video export.
|
|
39
|
+
- `--emit-preview` emits standalone expanded preview JSON.
|
|
40
|
+
- `--emit-timeline` emits source IDs, integer frame intervals, repeat chains and diagnostics without drawing paths.
|
|
41
|
+
- `--preview-worker` retains the JSON Lines interface for tools and benchmarks. The native server calls the compiler in process and does not use IPC.
|
|
42
|
+
|
|
43
|
+
## Programmatic APIs
|
|
44
|
+
|
|
45
|
+
The Bun authoring adapter and its types are available from `@mekuru/core/timeline`:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { compileTimeline, timelineIntervals } from "@mekuru/core/timeline";
|
|
49
|
+
const result = await compileTimeline(".mekuru/main.xml");
|
|
50
|
+
for (const entry of result.timeline?.entries ?? []) {
|
|
51
|
+
console.log(entry.sourceId, timelineIntervals(entry));
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`compileTimeline(xmlPath, { root? })` returns `{ timeline, diagnostics }`. Failed analysis has a null timeline. `timelineIntervals` expands nested repeats and clips the final round; it describes logical intervals, not slot visibility. `findCompiler` from `@mekuru/core/compiler` resolves `MEKURU_COMPILER`, then the platform npm package, then the newest local debug/release binary.
|
|
56
|
+
|
|
57
|
+
## Building
|
|
58
|
+
|
|
59
|
+
Use Rust 1.89 or later, a C linker and Bun for the UI build:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
bun install
|
|
63
|
+
bun run build:release
|
|
64
|
+
./target/release/mekuru-compiler path/to/main.xml --serve
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The viewer builds to `pkgs/core/viewer/` and `build.rs` embeds it. Rebuild the executable after UI changes. A release build fails if the UI is missing; a Rust-only debug build displays build instructions instead. CI embeds the UI in every platform binary before packaging.
|
|
68
|
+
|
|
69
|
+
See [XML syntax](../../docs/reference/Syntax.md) and [development](../../docs/dev/Developer.md).
|
package/bin/launch.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { findCompiler } from "../npm/compiler.js";
|
|
3
|
+
|
|
4
|
+
export function launch(prefix = []) {
|
|
5
|
+
try {
|
|
6
|
+
const child = spawn(findCompiler(), [...prefix, ...process.argv.slice(2)], { stdio: "inherit" });
|
|
7
|
+
const stop = (signal) => child.kill(signal);
|
|
8
|
+
process.on("SIGINT", stop);
|
|
9
|
+
process.on("SIGTERM", stop);
|
|
10
|
+
child.on("error", (error) => {
|
|
11
|
+
console.error(`error: ${error.message}`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
});
|
|
14
|
+
child.on("exit", (code, signal) => process.exit(code ?? (signal === "SIGINT" ? 130 : 1)));
|
|
15
|
+
} catch (error) {
|
|
16
|
+
console.error(`error: ${error.message}`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const findCompiler: () => string;
|
package/npm/compiler.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
const EXE = process.platform === "win32" ? "mekuru-compiler.exe" : "mekuru-compiler";
|
|
9
|
+
const PKG = `@mekuru/core-${process.platform}-${process.arch}`;
|
|
10
|
+
const ENV = "MEKURU_COMPILER";
|
|
11
|
+
|
|
12
|
+
const fromPlatformPackage = () => {
|
|
13
|
+
try {
|
|
14
|
+
const path = join(dirname(require.resolve(`${PKG}/package.json`)), "bin", EXE);
|
|
15
|
+
return existsSync(path) ? path : undefined;
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const fromRepository = () => {
|
|
22
|
+
const target = join(here, "..", "..", "..", "target");
|
|
23
|
+
return ["debug", "release"]
|
|
24
|
+
.map((p) => join(target, p, EXE))
|
|
25
|
+
.filter((p) => existsSync(p))
|
|
26
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const findCompiler = () => {
|
|
30
|
+
const env = process.env[ENV];
|
|
31
|
+
if (env) {
|
|
32
|
+
if (!existsSync(env)) throw new Error(`${ENV} points to ${env}, which does not exist`);
|
|
33
|
+
return env;
|
|
34
|
+
}
|
|
35
|
+
const found = fromPlatformPackage() ?? fromRepository();
|
|
36
|
+
if (found) return found;
|
|
37
|
+
throw new Error(
|
|
38
|
+
`mekuru-compiler for ${process.platform}-${process.arch} is not installed.\n` +
|
|
39
|
+
` - reinstall the packages (\`bun install\`) so that ${PKG} is installed, or\n` +
|
|
40
|
+
` - build it from the Mekuru Movie Maker repository (\`cargo build --locked\`)\n` +
|
|
41
|
+
` and set ${ENV} to the binary`,
|
|
42
|
+
);
|
|
43
|
+
};
|
package/npm/timeline.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { FrameInterval, TimelineCompilation, TimelineEntry } from "@mekuru/protocol";
|
|
2
|
+
import { findCompiler } from "./compiler.js";
|
|
3
|
+
export type { FrameRate, FrameInterval, TimelineEntry, ResolvedTimeline, TimelineCompilation } from "@mekuru/protocol";
|
|
4
|
+
|
|
5
|
+
export async function compileTimeline(xmlPath: string, options: { root?: string } = {}): Promise<TimelineCompilation> {
|
|
6
|
+
const args = [findCompiler(), xmlPath, "--emit-timeline"];
|
|
7
|
+
if (options.root) args.push("--root", options.root);
|
|
8
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
9
|
+
const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
10
|
+
if (code !== 0) throw new Error(stderr.slice(-16000).trim() || `mekuru-compiler exited (${code})`);
|
|
11
|
+
const result = JSON.parse(stdout) as TimelineCompilation;
|
|
12
|
+
if (
|
|
13
|
+
!result ||
|
|
14
|
+
!Array.isArray(result.diagnostics) ||
|
|
15
|
+
!("timeline" in result) ||
|
|
16
|
+
(result.timeline !== null && (!Array.isArray(result.timeline?.entries) || !result.timeline?.fps))
|
|
17
|
+
)
|
|
18
|
+
throw new Error("unexpected compiler response; rebuild or update mekuru-compiler");
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Expand only this entry's logical intervals, not slot visibility. */
|
|
23
|
+
export function timelineIntervals(entry: TimelineEntry): FrameInterval[] {
|
|
24
|
+
let intervals: FrameInterval[] = [{ startFrame: entry.startFrame, endFrame: entry.endFrame }];
|
|
25
|
+
for (const r of [...entry.repeat].reverse()) {
|
|
26
|
+
if (!Number.isSafeInteger(r.periodFrames) || r.periodFrames <= 0) throw new Error("invalid repeat period");
|
|
27
|
+
const next: FrameInterval[] = [];
|
|
28
|
+
for (const span of intervals) {
|
|
29
|
+
for (let shift = 0; r.fromFrame + shift < r.untilFrame; shift += r.periodFrames) {
|
|
30
|
+
const startFrame = span.startFrame + shift;
|
|
31
|
+
const endFrame = Math.min(span.endFrame + shift, r.untilFrame);
|
|
32
|
+
if (endFrame > startFrame) next.push({ startFrame, endFrame });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
intervals = next;
|
|
36
|
+
}
|
|
37
|
+
return intervals.filter((s) => s.endFrame > s.startFrame).sort((a, b) => a.startFrame - b.startFrame);
|
|
38
|
+
}
|
package/package.json
CHANGED
|
@@ -1,21 +1,37 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mekuru/core",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/kanade-k-1228/mekuru.git",
|
|
7
|
+
"directory": "pkgs/core"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/kanade-k-1228/mekuru/blob/main/pkgs/core/README.md",
|
|
10
|
+
"description": "Native video compiler and preview server with an embedded browser viewer",
|
|
5
11
|
"type": "module",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"./jsx-dev-runtime": "./src/index.ts"
|
|
12
|
+
"bin": {
|
|
13
|
+
"mekuru-compiler": "./bin/mekuru-compiler.js",
|
|
14
|
+
"mekuru-server": "./bin/mekuru-server.js"
|
|
10
15
|
},
|
|
11
16
|
"files": [
|
|
12
|
-
"
|
|
17
|
+
"bin",
|
|
18
|
+
"npm"
|
|
13
19
|
],
|
|
14
|
-
"
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=20"
|
|
22
|
+
},
|
|
15
23
|
"dependencies": {
|
|
16
|
-
"
|
|
24
|
+
"@mekuru/protocol": "^0.1.4"
|
|
17
25
|
},
|
|
18
|
-
"
|
|
19
|
-
"
|
|
26
|
+
"exports": {
|
|
27
|
+
"./compiler": "./npm/compiler.js",
|
|
28
|
+
"./timeline": "./npm/timeline.ts"
|
|
29
|
+
},
|
|
30
|
+
"optionalDependencies": {
|
|
31
|
+
"@mekuru/core-darwin-arm64": "0.1.4",
|
|
32
|
+
"@mekuru/core-darwin-x64": "0.1.4",
|
|
33
|
+
"@mekuru/core-linux-arm64": "0.1.4",
|
|
34
|
+
"@mekuru/core-linux-x64": "0.1.4",
|
|
35
|
+
"@mekuru/core-win32-x64": "0.1.4"
|
|
20
36
|
}
|
|
21
37
|
}
|
package/src/index.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export { Fragment, jsx, jsx as jsxs, jsx as jsxDEV } from "hono/jsx/jsx-runtime";
|
|
2
|
-
|
|
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";
|
package/src/jsx.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import type { Child } from "hono/jsx";
|
|
2
|
-
import type { JSX as HonoJSX } from "hono/jsx/jsx-runtime";
|
|
3
|
-
|
|
4
|
-
export type Node = Child;
|
|
5
|
-
|
|
6
|
-
export type Length = number | `${number}%` | "auto";
|
|
7
|
-
type Ratio = number | `${number}%`;
|
|
8
|
-
export type Time = number;
|
|
9
|
-
export type At = number | string;
|
|
10
|
-
export type Color = string;
|
|
11
|
-
export type Anchor = "tl" | "t" | "tr" | "l" | "c" | "r" | "bl" | "b" | "br" | "center" | "top" | "bottom" | "left" | "right";
|
|
12
|
-
|
|
13
|
-
interface Base {
|
|
14
|
-
key?: string | number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface WithChildren {
|
|
18
|
-
children?: Node;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface Timed {
|
|
22
|
-
id?: string;
|
|
23
|
-
at?: At;
|
|
24
|
-
fadein?: Time;
|
|
25
|
-
fadeout?: Time;
|
|
26
|
-
slot?: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
interface Look {
|
|
30
|
-
z?: number;
|
|
31
|
-
opacity?: Ratio;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface Box {
|
|
35
|
-
x?: Length;
|
|
36
|
-
y?: Length;
|
|
37
|
-
anchor?: Anchor;
|
|
38
|
-
w?: Length;
|
|
39
|
-
h?: Length;
|
|
40
|
-
"min-w"?: Length;
|
|
41
|
-
"max-w"?: Length;
|
|
42
|
-
"min-h"?: Length;
|
|
43
|
-
"max-h"?: Length;
|
|
44
|
-
grow?: boolean | number;
|
|
45
|
-
shrink?: number;
|
|
46
|
-
rotate?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
interface Deco {
|
|
50
|
-
fill?: Color;
|
|
51
|
-
stroke?: Color;
|
|
52
|
-
"stroke-width"?: number;
|
|
53
|
-
radius?: number;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
type Align = "start" | "center" | "end" | "stretch";
|
|
57
|
-
|
|
58
|
-
interface Overlay extends Base, Timed, Look, Box, Deco, WithChildren {
|
|
59
|
-
padding?: Length | readonly Length[];
|
|
60
|
-
align?: Align;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
interface Flow extends Overlay {
|
|
64
|
-
gap?: Length;
|
|
65
|
-
justify?: "start" | "center" | "end" | "between" | "around" | "evenly";
|
|
66
|
-
wrap?: boolean;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
interface TextStyle {
|
|
70
|
-
size?: number;
|
|
71
|
-
color?: Color;
|
|
72
|
-
font?: string;
|
|
73
|
-
stroke?: Color;
|
|
74
|
-
"stroke-width"?: number;
|
|
75
|
-
"line-height"?: number;
|
|
76
|
-
background?: Color;
|
|
77
|
-
radius?: number;
|
|
78
|
-
padding?: Length | readonly Length[];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
interface Media {
|
|
82
|
-
src: string;
|
|
83
|
-
from?: Time;
|
|
84
|
-
to?: Time;
|
|
85
|
-
volume?: Ratio;
|
|
86
|
-
children?: Node;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export interface IntrinsicElements {
|
|
90
|
-
mekuru: Base &
|
|
91
|
-
WithChildren & {
|
|
92
|
-
size?: `${number}x${number}`;
|
|
93
|
-
fps?: number;
|
|
94
|
-
font?: string;
|
|
95
|
-
preset?: string;
|
|
96
|
-
crf?: number;
|
|
97
|
-
"audio-bitrate"?: string;
|
|
98
|
-
};
|
|
99
|
-
seq: Overlay;
|
|
100
|
-
stack: Overlay;
|
|
101
|
-
row: Flow;
|
|
102
|
-
column: Flow;
|
|
103
|
-
repeat: Base & Timed & Look & WithChildren;
|
|
104
|
-
dur: Base & Timed & Look & WithChildren & { len: Time };
|
|
105
|
-
|
|
106
|
-
text: Base & Timed & Look & Box & TextStyle & WithChildren & { weight?: "normal" | "bold"; align?: "left" | "center" | "right"; pre?: boolean };
|
|
107
|
-
image: Base & Timed & Look & Box & { src: string };
|
|
108
|
-
video: Base & Timed & Look & Omit<Box, "rotate"> & Media;
|
|
109
|
-
audio: Base & Omit<Timed, "slot"> & Omit<Media, "src"> & { src?: string; label?: string; draft?: boolean };
|
|
110
|
-
hold: Base & WithChildren & Look & { at: Time; id?: string; fadein?: Time; fadeout?: Time; slot?: string };
|
|
111
|
-
speed: Base & { from?: Time; to?: Time; rate: number };
|
|
112
|
-
cut: Base & { from?: Time; to?: Time };
|
|
113
|
-
rect: Base & Timed & Look & Box & Deco;
|
|
114
|
-
circle: Base & Timed & Look & Box & Deco;
|
|
115
|
-
line: Base &
|
|
116
|
-
Timed &
|
|
117
|
-
Look & {
|
|
118
|
-
p: string;
|
|
119
|
-
closed?: boolean;
|
|
120
|
-
fill?: Color;
|
|
121
|
-
stroke?: Color;
|
|
122
|
-
"stroke-width"?: number;
|
|
123
|
-
head?: "none" | "start" | "end" | "both";
|
|
124
|
-
dashed?: boolean;
|
|
125
|
-
dotted?: boolean;
|
|
126
|
-
};
|
|
127
|
-
br: Base;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export namespace JSX {
|
|
131
|
-
export type Element = HonoJSX.Element;
|
|
132
|
-
export type ElementType = HonoJSX.ElementType;
|
|
133
|
-
export interface ElementChildrenAttribute extends HonoJSX.ElementChildrenAttribute {}
|
|
134
|
-
export interface IntrinsicAttributes extends HonoJSX.IntrinsicAttributes {}
|
|
135
|
-
export interface IntrinsicElements extends IntrinsicElementsBase {}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
type IntrinsicElementsBase = IntrinsicElements;
|
package/src/write.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import { rename, rm } from "node:fs/promises";
|
|
3
|
-
import { toXml } from "./xml.ts";
|
|
4
|
-
|
|
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
|
-
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`;
|
|
18
|
-
const save = async () => {
|
|
19
|
-
const xml = await toXml(video);
|
|
20
|
-
await Bun.write(tmp, xml);
|
|
21
|
-
await rename(tmp, path);
|
|
22
|
-
};
|
|
23
|
-
try {
|
|
24
|
-
await save();
|
|
25
|
-
context.draft = false;
|
|
26
|
-
if (context.waits.size === 0) return;
|
|
27
|
-
await Promise.all(context.waits);
|
|
28
|
-
await save();
|
|
29
|
-
} finally {
|
|
30
|
-
context.draft = false;
|
|
31
|
-
context.waits.clear();
|
|
32
|
-
await rm(tmp, { force: true });
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
};
|
package/src/xml.ts
DELETED
|
@@ -1,194 +0,0 @@
|
|
|
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
|
-
};
|
|
159
|
-
|
|
160
|
-
const escapeXml = (s: string, quote: boolean) =>
|
|
161
|
-
s.replace(
|
|
162
|
-
quote ? /[&<>"\n\r\t]/g : /[&<>]/g,
|
|
163
|
-
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "\n": " ", "\r": " ", "\t": "	" })[c] ?? c,
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
const head = (x: X) => `<${x.tag}${x.attrs.map(([k, v]) => ` ${k}="${escapeXml(v, true)}"`).join("")}`;
|
|
167
|
-
|
|
168
|
-
const inline = (c: X | string): string =>
|
|
169
|
-
typeof c === "string" ? escapeXml(c, false) : c.children.length ? `${head(c)}>${c.children.map(inline).join("")}</${c.tag}>` : `${head(c)}/>`;
|
|
170
|
-
|
|
171
|
-
const write = (x: X, indent: string, out: string[]) => {
|
|
172
|
-
if (x.children.length === 0 || x.children.some((c) => typeof c === "string")) {
|
|
173
|
-
out.push(`${indent}${inline(x)}`);
|
|
174
|
-
} else {
|
|
175
|
-
out.push(`${indent}${head(x)}>`);
|
|
176
|
-
for (const c of x.children) write(c as X, `${indent} `, out);
|
|
177
|
-
out.push(`${indent}</${x.tag}>`);
|
|
178
|
-
}
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
export const toXml = async (video: unknown): Promise<string> => {
|
|
182
|
-
const top: Item[] = [];
|
|
183
|
-
await expand(video, top);
|
|
184
|
-
const root = top.find((i): i is Element => typeof i !== "string");
|
|
185
|
-
if (top.length !== 1 || !root || root.tag !== "mekuru") throw new Error("writeXml takes a video: one `<mekuru>` element");
|
|
186
|
-
const x: X = { tag: "mekuru", attrs: [], children: [] };
|
|
187
|
-
for (const [k, v] of Object.entries(root.props)) {
|
|
188
|
-
if (k !== "children" && k !== "key" && v !== undefined && v !== null) x.attrs.push([k, typeof v === "number" ? num(v) : String(v)]);
|
|
189
|
-
}
|
|
190
|
-
x.children.push(normalize({ tag: "seq", props: {}, children: root.children }));
|
|
191
|
-
const out: string[] = [];
|
|
192
|
-
write(x, "", out);
|
|
193
|
-
return `${out.join("\n")}\n`;
|
|
194
|
-
};
|