@mekuru/cli 0.0.0-stage → 0.1.2
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 +29 -0
- package/README.md +28 -2
- package/package.json +29 -4
- package/src/compiler.ts +39 -0
- package/src/mekuru-compiler.ts +14 -0
- package/src/mekuru-server.ts +61 -0
- package/src/preview.ts +46 -0
- package/src/protocol.ts +87 -0
- package/src/server.ts +281 -0
- package/viewer/assets/index-90h-0LiO.css +2 -0
- package/viewer/assets/index-CPTd1lpN.js +9 -0
- package/viewer/index.html +15 -0
package/README.jp.md
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# @mekuru/cli
|
|
2
|
+
|
|
3
|
+
[English](README.md)
|
|
4
|
+
|
|
5
|
+
プレビューと動画書き出しのコマンドです。Bun 1.4 以上・ffmpeg・ffprobe・台本の文字に対応したフォントが必要です。
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add -d @mekuru/cli
|
|
9
|
+
bunx mekuru-server .mekuru/main.xml --run 'bun --hot src/main.tsx'
|
|
10
|
+
bunx mekuru-compiler .mekuru/main.xml -o video.mp4
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- `mekuru-server <xml>`: XML を監視してブラウザのビューアを起動。
|
|
14
|
+
- `--run <cmd>`: XML を生成するコマンドを起動。
|
|
15
|
+
- `--port`, `--title`: ポート(既定: 3333)とブラウザのタイトル。
|
|
16
|
+
- `--no-open`, `--open`: ブラウザの自動起動。既定は開く。`MEKURU_OPEN=0` で抑止。
|
|
17
|
+
- `mekuru-compiler <xml>`: ネイティブコンパイラで動画を書き出す。
|
|
18
|
+
- `-o <file>`: 出力ファイル。
|
|
19
|
+
- `--preview`: 半分の解像度・15 fps。
|
|
20
|
+
- `--scale`, `--fps`: 解像度の倍率とフレームレート。
|
|
21
|
+
- `--from`, `--to`: 書き出す範囲(秒)。
|
|
22
|
+
- `--preset`, `--crf`: x264 のエンコード設定。
|
|
23
|
+
- `--no-audio`: 音声なしで書き出す。
|
|
24
|
+
|
|
25
|
+
両コマンドの `--root <dir>` で素材の基準フォルダを指定できます。既定は XML のあるフォルダ(`.mekuru/` ならその親)です。
|
|
26
|
+
|
|
27
|
+
コンパイラは `MEKURU_COMPILER` → OS 別 npm パッケージ → 更新日時が新しい `target/debug`・`target/release` の順で探します(同時刻なら debug)。`@mekuru/cli/protocol` はサーバーとビューアの共有型です。
|
|
28
|
+
|
|
29
|
+
[TSX の作業手順](../../docs/reference/Component.jp.md)・[開発](../../docs/dev/Developer.jp.md)
|
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mekuru/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[日本語](README.jp.md)
|
|
4
|
+
|
|
5
|
+
Preview and video export commands. Requires Bun 1.4+, ffmpeg, ffprobe, and fonts for the script.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add -d @mekuru/cli
|
|
9
|
+
bunx mekuru-server .mekuru/main.xml --run 'bun --hot src/main.tsx'
|
|
10
|
+
bunx mekuru-compiler .mekuru/main.xml -o video.mp4
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- `mekuru-server <xml>`: Watch XML and open the browser viewer.
|
|
14
|
+
- `--run <cmd>`: Run the command that generates XML.
|
|
15
|
+
- `--port`, `--title`: Port (default: 3333) and browser title.
|
|
16
|
+
- `--no-open`, `--open`: Control browser launch. Default: open, unless `MEKURU_OPEN=0`.
|
|
17
|
+
- `mekuru-compiler <xml>`: Export video with the native compiler.
|
|
18
|
+
- `-o <file>`: Output file.
|
|
19
|
+
- `--preview`: Half resolution, 15 fps.
|
|
20
|
+
- `--scale`, `--fps`: Resolution multiplier and frame rate.
|
|
21
|
+
- `--from`, `--to`: Export range in seconds.
|
|
22
|
+
- `--preset`, `--crf`: x264 encoding settings.
|
|
23
|
+
- `--no-audio`: Export without audio.
|
|
24
|
+
|
|
25
|
+
Both commands accept `--root <dir>` for the media base directory. Default: the XML directory, or its parent when named `.mekuru/`.
|
|
26
|
+
|
|
27
|
+
Compiler lookup: `MEKURU_COMPILER` → platform npm package → newest local `target/debug` or `target/release` binary (debug wins ties). `@mekuru/cli/protocol` exports the server/viewer types.
|
|
28
|
+
|
|
29
|
+
[TSX workflow](../../docs/reference/Component.md) · [Development](../../docs/dev/Developer.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mekuru/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "The commands of Mekuru Movie Maker: mekuru-server (the viewer in the browser, which can run the script with --run) and mekuru-compiler (XML → video, a prebuilt binary for each platform)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"mekuru-server": "./src/mekuru-server.ts",
|
|
8
|
+
"mekuru-compiler": "./src/mekuru-compiler.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"viewer"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"bun": ">=1.4"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"commander": "^14.0.3"
|
|
19
|
+
},
|
|
20
|
+
"exports": {
|
|
21
|
+
"./protocol": "./src/protocol.ts",
|
|
22
|
+
"./*": "./*"
|
|
23
|
+
},
|
|
24
|
+
"optionalDependencies": {
|
|
25
|
+
"@mekuru/compiler-darwin-arm64": "0.1.2",
|
|
26
|
+
"@mekuru/compiler-darwin-x64": "0.1.2",
|
|
27
|
+
"@mekuru/compiler-linux-arm64": "0.1.2",
|
|
28
|
+
"@mekuru/compiler-linux-x64": "0.1.2",
|
|
29
|
+
"@mekuru/compiler-win32-x64": "0.1.2"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/compiler.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const EXE = process.platform === "win32" ? "mekuru-compiler.exe" : "mekuru-compiler";
|
|
5
|
+
const PKG = `@mekuru/compiler-${process.platform}-${process.arch}`;
|
|
6
|
+
const ENV = "MEKURU_COMPILER";
|
|
7
|
+
|
|
8
|
+
const fromPlatformPackage = (): string | undefined => {
|
|
9
|
+
try {
|
|
10
|
+
const path = join(dirname(Bun.resolveSync(`${PKG}/package.json`, import.meta.dir)), "bin", EXE);
|
|
11
|
+
return existsSync(path) ? path : undefined;
|
|
12
|
+
} catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const fromRepository = (): string | undefined => {
|
|
18
|
+
const target = join(import.meta.dir, "..", "..", "..", "target");
|
|
19
|
+
return ["debug", "release"]
|
|
20
|
+
.map((p) => join(target, p, EXE))
|
|
21
|
+
.filter((p) => existsSync(p))
|
|
22
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const findCompiler = (): string => {
|
|
26
|
+
const env = process.env[ENV];
|
|
27
|
+
if (env) {
|
|
28
|
+
if (!existsSync(env)) throw new Error(`${ENV} points to ${env}, which does not exist`);
|
|
29
|
+
return env;
|
|
30
|
+
}
|
|
31
|
+
const found = fromPlatformPackage() ?? fromRepository();
|
|
32
|
+
if (found) return found;
|
|
33
|
+
throw new Error(
|
|
34
|
+
`mekuru-compiler for ${process.platform}-${process.arch} is not installed.\n` +
|
|
35
|
+
` - reinstall the packages (\`bun install\`) so that ${PKG} is installed, or\n` +
|
|
36
|
+
` - build it from the Mekuru Movie Maker repository (\`cargo build --locked\`)\n` +
|
|
37
|
+
` and set ${ENV} to the binary`,
|
|
38
|
+
);
|
|
39
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { findCompiler } from "./compiler.ts";
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
const child = Bun.spawn([findCompiler(), ...Bun.argv.slice(2)], { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
7
|
+
const stop = () => child.kill("SIGINT");
|
|
8
|
+
process.on("SIGINT", stop);
|
|
9
|
+
process.on("SIGTERM", stop);
|
|
10
|
+
process.exit(await child.exited);
|
|
11
|
+
} catch (e) {
|
|
12
|
+
console.error(`error: ${(e as Error).message}`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { Command, InvalidArgumentError } from "commander";
|
|
4
|
+
import { openBrowser, startServer } from "./server.ts";
|
|
5
|
+
|
|
6
|
+
const VERSION: string = (await Bun.file(new URL("../package.json", import.meta.url)).json()).version;
|
|
7
|
+
|
|
8
|
+
interface Options {
|
|
9
|
+
run?: string;
|
|
10
|
+
port: number;
|
|
11
|
+
root?: string;
|
|
12
|
+
title?: string;
|
|
13
|
+
open?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const parsePort = (value: string): number => {
|
|
17
|
+
const port = Number(value);
|
|
18
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new InvalidArgumentError("a port is an integer from 0 to 65535");
|
|
19
|
+
return port;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const openByDefault = (): boolean => !/^(0|false|no|off)$/i.test(process.env.MEKURU_OPEN ?? "");
|
|
23
|
+
|
|
24
|
+
const serve = async (xml: string, { open = openByDefault(), ...opts }: Options): Promise<void> => {
|
|
25
|
+
const server = await startServer({ xml, ...opts }).catch((e: unknown) => {
|
|
26
|
+
console.error(`error: ${(e as Error).message}`);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
|
29
|
+
process.stderr.write(`mekuru viewer: ${server.url} (${opts.title ?? xml})\n`);
|
|
30
|
+
if (open) openBrowser(server.url);
|
|
31
|
+
const stop = () => {
|
|
32
|
+
server.close();
|
|
33
|
+
setTimeout(() => process.exit(0), 300).unref();
|
|
34
|
+
};
|
|
35
|
+
process.on("SIGINT", stop);
|
|
36
|
+
process.on("SIGTERM", stop);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
new Command("mekuru-server")
|
|
40
|
+
.description(
|
|
41
|
+
"The viewer of Mekuru Movie Maker: shows the video of the XML that a script writes in the browser, as a preview and a timeline, " +
|
|
42
|
+
"and shows it again whenever the XML changes.",
|
|
43
|
+
)
|
|
44
|
+
.version(VERSION)
|
|
45
|
+
.argument("<xml>", "the XML that the script writes (such as .mekuru/main.xml)")
|
|
46
|
+
.option(
|
|
47
|
+
"--run <cmd>",
|
|
48
|
+
"a command that writes the XML (`bun --hot src/main.tsx`), run in the project folder while the viewer is open: " +
|
|
49
|
+
"what it prints on stderr is shown as the error of the script until it writes the XML again",
|
|
50
|
+
)
|
|
51
|
+
.option("-p, --port <port>", "the port to listen on", parsePort, 3333)
|
|
52
|
+
.option(
|
|
53
|
+
"--root <dir>",
|
|
54
|
+
"the project folder: the paths of the files are relative to it (default: the folder of the XML, or the one above it when that is .mekuru/)",
|
|
55
|
+
)
|
|
56
|
+
.option("--title <name>", "the name shown in the browser tab (default: the file name of the XML)")
|
|
57
|
+
.option("--open", "open a browser (the default, unless the environment variable MEKURU_OPEN is 0)")
|
|
58
|
+
.option("--no-open", "do not open a browser (in the terminal of VS Code, select the URL that it prints to open it in the integrated browser)")
|
|
59
|
+
.showHelpAfterError("(add --help for more information)")
|
|
60
|
+
.action(serve)
|
|
61
|
+
.parseAsync();
|
package/src/preview.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { findCompiler } from "./compiler.ts";
|
|
2
|
+
import type { Compilation } from "./protocol.ts";
|
|
3
|
+
|
|
4
|
+
export const previewCompiler = (root: string, publish: (version: number, result: Compilation | Error) => void) => {
|
|
5
|
+
let child: Bun.Subprocess | undefined;
|
|
6
|
+
let closed = false;
|
|
7
|
+
let pending: { xml: string; version: number } | undefined;
|
|
8
|
+
const next = async () => {
|
|
9
|
+
if (closed || child || !pending) return;
|
|
10
|
+
const { xml, version } = pending;
|
|
11
|
+
pending = undefined;
|
|
12
|
+
let result: Compilation | Error;
|
|
13
|
+
try {
|
|
14
|
+
const proc = Bun.spawn([findCompiler(), "-", "--emit-preview", "--root", root], {
|
|
15
|
+
stdin: new Blob([xml]),
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
});
|
|
19
|
+
child = proc;
|
|
20
|
+
const [stdout, stderr, code] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]);
|
|
21
|
+
if (code !== 0) throw new Error(stderr.slice(-16000).trim() || `mekuru-compiler exited (${code})`);
|
|
22
|
+
result = JSON.parse(stdout) as Compilation;
|
|
23
|
+
if (!Array.isArray(result.diagnostics) || !("preview" in result)) throw new Error("unexpected compiler response; rebuild or update mekuru-compiler");
|
|
24
|
+
} catch (e) {
|
|
25
|
+
result = e instanceof Error ? e : new Error(String(e));
|
|
26
|
+
} finally {
|
|
27
|
+
child = undefined;
|
|
28
|
+
}
|
|
29
|
+
if (closed) return;
|
|
30
|
+
if (!pending) publish(version, result);
|
|
31
|
+
void next();
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
update(xml: string, version: number) {
|
|
35
|
+
if (!closed) {
|
|
36
|
+
pending = { xml, version };
|
|
37
|
+
void next();
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
close() {
|
|
41
|
+
closed = true;
|
|
42
|
+
pending = undefined;
|
|
43
|
+
child?.kill();
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
export interface Diagnostic {
|
|
2
|
+
code: string;
|
|
3
|
+
severity: "error" | "warning";
|
|
4
|
+
line: number;
|
|
5
|
+
message: string;
|
|
6
|
+
}
|
|
7
|
+
export interface Compilation {
|
|
8
|
+
preview: PreviewDto | null;
|
|
9
|
+
diagnostics: Diagnostic[];
|
|
10
|
+
}
|
|
11
|
+
export interface StateDto {
|
|
12
|
+
path: string;
|
|
13
|
+
version: number;
|
|
14
|
+
previewVersion: number;
|
|
15
|
+
status: "building" | "ready" | "error";
|
|
16
|
+
error: string | null;
|
|
17
|
+
diagnostics: Diagnostic[];
|
|
18
|
+
preview: PreviewDto | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PreviewDto {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
fps: number;
|
|
25
|
+
duration: number;
|
|
26
|
+
elements: SceneElement[];
|
|
27
|
+
timeline: TimelineDto;
|
|
28
|
+
}
|
|
29
|
+
export type Bounds = [number, number, number, number];
|
|
30
|
+
export type Fade = [start: number, end: number, fadeIn: number, fadeOut: number, level: number];
|
|
31
|
+
export type DrawOp =
|
|
32
|
+
| {
|
|
33
|
+
kind: "path";
|
|
34
|
+
d: string;
|
|
35
|
+
color: string;
|
|
36
|
+
stroke: { width: number; lineCap: "butt" | "round" | "square"; lineJoin: "miter" | "round" | "bevel"; dash: number[] } | null;
|
|
37
|
+
}
|
|
38
|
+
| { kind: "image"; src: string; bounds: Bounds; transform: [number, number, number, number, number, number] | null };
|
|
39
|
+
export interface MediaMap {
|
|
40
|
+
from: number;
|
|
41
|
+
end: number;
|
|
42
|
+
segments: [outStart: number, outEnd: number, srcStart: number, rate: number][];
|
|
43
|
+
}
|
|
44
|
+
export interface SceneElement {
|
|
45
|
+
id: number;
|
|
46
|
+
kind: "view" | "video" | "audio";
|
|
47
|
+
start: number;
|
|
48
|
+
end: number;
|
|
49
|
+
opacity: number;
|
|
50
|
+
fades: Fade[];
|
|
51
|
+
ops: DrawOp[];
|
|
52
|
+
bounds: Bounds | null;
|
|
53
|
+
media: { src: string; gain: number; map: MediaMap } | null;
|
|
54
|
+
repeat: [from: number, period: number, until: number][];
|
|
55
|
+
hidden: [start: number, end: number][];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface TimelineNode {
|
|
59
|
+
id: number;
|
|
60
|
+
parent: number | null;
|
|
61
|
+
depth: number;
|
|
62
|
+
kind: "seq" | "par" | "repeat";
|
|
63
|
+
tag: string;
|
|
64
|
+
label: string;
|
|
65
|
+
line: number;
|
|
66
|
+
start: number;
|
|
67
|
+
end: number;
|
|
68
|
+
pin: number | null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ClipLane = "speech" | "visual" | "video" | "audio";
|
|
72
|
+
|
|
73
|
+
export interface TimelineClip {
|
|
74
|
+
tag: string;
|
|
75
|
+
lane: ClipLane;
|
|
76
|
+
line: number;
|
|
77
|
+
start: number;
|
|
78
|
+
end: number;
|
|
79
|
+
label: string;
|
|
80
|
+
estimated: boolean;
|
|
81
|
+
freezes: [start: number, end: number][];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface TimelineDto {
|
|
85
|
+
nodes: TimelineNode[];
|
|
86
|
+
clips: TimelineClip[];
|
|
87
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, unwatchFile, watch, watchFile } from "node:fs";
|
|
2
|
+
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
3
|
+
import { previewCompiler } from "./preview.ts";
|
|
4
|
+
import type { Diagnostic, StateDto } from "./protocol.ts";
|
|
5
|
+
|
|
6
|
+
export interface ServerOptions {
|
|
7
|
+
xml: string;
|
|
8
|
+
root?: string;
|
|
9
|
+
run?: string;
|
|
10
|
+
port: number;
|
|
11
|
+
title?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const projectRoot = (xml: string): string => {
|
|
15
|
+
const dir = dirname(resolve(xml));
|
|
16
|
+
return basename(dir) === ".mekuru" ? dirname(dir) : dir;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const VIEWER = join(import.meta.dir, "..", "viewer");
|
|
20
|
+
|
|
21
|
+
const NOT_BUILT = `<!doctype html><meta charset=utf-8><title>Mekuru Movie Maker</title><body style="font-family:sans-serif;padding:2em"><h1>Mekuru Movie Maker</h1><p>The viewer is not built.</p><pre>bun install\nbun run --cwd pkgs/viewer build</pre>`;
|
|
22
|
+
|
|
23
|
+
const log = (line: string) => process.stderr.write(`${new Date().toTimeString().slice(0, 8)} ${line}\n`);
|
|
24
|
+
const seconds = (since: number) => `${((Date.now() - since) / 1000).toFixed(1)}s`;
|
|
25
|
+
const formatDiagnostic = (d: Diagnostic) => `L${d.line} ${d.code}: ${d.message}`;
|
|
26
|
+
|
|
27
|
+
const sendFile = async (req: Request, path: string): Promise<Response> => {
|
|
28
|
+
const file = Bun.file(path);
|
|
29
|
+
if (!(await file.exists())) return new Response(null, { status: 404 });
|
|
30
|
+
const size = file.size;
|
|
31
|
+
const headers = { "content-type": file.type, "accept-ranges": "bytes", "cache-control": "no-cache" };
|
|
32
|
+
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get("range") ?? "");
|
|
33
|
+
if (range && (range[1] || range[2])) {
|
|
34
|
+
const start = Math.max(0, range[1] ? Number(range[1]) : size - Number(range[2]));
|
|
35
|
+
const end = Math.min(size - 1, range[1] && range[2] ? Number(range[2]) : size - 1);
|
|
36
|
+
if (start > end) return new Response(null, { status: 416, headers: { "content-range": `bytes */${size}` } });
|
|
37
|
+
return new Response(req.method === "HEAD" ? null : file.slice(start, end + 1), {
|
|
38
|
+
status: 206,
|
|
39
|
+
headers: { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": String(end - start + 1) },
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return new Response(req.method === "HEAD" ? null : file, { headers: { ...headers, "content-length": String(size) } });
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const inside = (dir: string, rel: string): string | undefined => {
|
|
46
|
+
let decoded: string;
|
|
47
|
+
try {
|
|
48
|
+
decoded = decodeURIComponent(rel);
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
const path = resolve(dir, `.${sep}${decoded}`);
|
|
53
|
+
return path === dir || path.startsWith(dir + sep) ? path : undefined;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const openBrowser = (url: string): void => {
|
|
57
|
+
const wsl = process.platform === "linux" && (!!process.env.WSL_INTEROP || existsSync("/proc/sys/fs/binfmt_misc/WSLInterop"));
|
|
58
|
+
const tries: string[][] = wsl
|
|
59
|
+
? [["wslview"], ["cmd.exe", "/c", "start", ""]]
|
|
60
|
+
: process.platform === "darwin"
|
|
61
|
+
? [["open"]]
|
|
62
|
+
: process.platform === "win32"
|
|
63
|
+
? [["cmd", "/c", "start", ""]]
|
|
64
|
+
: [["xdg-open"]];
|
|
65
|
+
for (const [cmd, ...args] of tries) {
|
|
66
|
+
try {
|
|
67
|
+
Bun.spawn([cmd, ...args, url], { stdin: "ignore", stdout: "ignore", stderr: "ignore", cwd: wsl ? "/mnt/c" : undefined }).unref();
|
|
68
|
+
break;
|
|
69
|
+
} catch {}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export interface Server {
|
|
74
|
+
url: string;
|
|
75
|
+
close(): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export const startServer = async (opts: ServerOptions): Promise<Server> => {
|
|
79
|
+
const xmlPath = resolve(opts.xml);
|
|
80
|
+
const root = resolve(opts.root ?? projectRoot(xmlPath));
|
|
81
|
+
const title = opts.title ?? opts.xml;
|
|
82
|
+
|
|
83
|
+
let xml: string;
|
|
84
|
+
if (await Bun.file(xmlPath).exists()) xml = await Bun.file(xmlPath).text();
|
|
85
|
+
else if (opts.run) {
|
|
86
|
+
mkdirSync(dirname(xmlPath), { recursive: true });
|
|
87
|
+
xml = "<mekuru><seq/></mekuru>";
|
|
88
|
+
} else throw new Error(`cannot read ${opts.xml}`);
|
|
89
|
+
let closed = false;
|
|
90
|
+
const cleanup: (() => void)[] = [];
|
|
91
|
+
const close = () => {
|
|
92
|
+
if (closed) return;
|
|
93
|
+
closed = true;
|
|
94
|
+
for (const dispose of cleanup.reverse()) dispose();
|
|
95
|
+
};
|
|
96
|
+
let writes = 0;
|
|
97
|
+
let scriptError: string | null = null;
|
|
98
|
+
let compileError: string | null = null;
|
|
99
|
+
let current: StateDto = { path: title, version: 1, previewVersion: 0, status: "building", error: null, diagnostics: [], preview: null };
|
|
100
|
+
const clients = new Set<ReadableStreamDefaultController<Uint8Array>>();
|
|
101
|
+
const encoder = new TextEncoder();
|
|
102
|
+
const send = (client: ReadableStreamDefaultController<Uint8Array>) => client.enqueue(encoder.encode(`event: state\ndata: ${JSON.stringify(current)}\n\n`));
|
|
103
|
+
const emit = () => {
|
|
104
|
+
if (closed) return;
|
|
105
|
+
current = { ...current, error: scriptError ?? compileError };
|
|
106
|
+
clients.forEach(send);
|
|
107
|
+
};
|
|
108
|
+
let started = Date.now();
|
|
109
|
+
const compiler = previewCompiler(root, (version, result) => {
|
|
110
|
+
if (closed || version !== current.version) return;
|
|
111
|
+
if (result instanceof Error) {
|
|
112
|
+
compileError = result.message;
|
|
113
|
+
current = { ...current, status: "error", diagnostics: [] };
|
|
114
|
+
log(`error: the preview failed\n${result.message}`);
|
|
115
|
+
} else {
|
|
116
|
+
compileError = result.preview ? null : result.diagnostics.map(formatDiagnostic).join("\n");
|
|
117
|
+
current = {
|
|
118
|
+
...current,
|
|
119
|
+
status: result.preview ? "ready" : "error",
|
|
120
|
+
diagnostics: result.diagnostics,
|
|
121
|
+
preview: result.preview ?? current.preview,
|
|
122
|
+
previewVersion: result.preview ? version : current.previewVersion,
|
|
123
|
+
};
|
|
124
|
+
const warnings = result.diagnostics.filter((d) => d.severity === "warning").length;
|
|
125
|
+
const head = result.preview
|
|
126
|
+
? `preview updated in ${seconds(started)}${warnings ? ` (${warnings} warning${warnings > 1 ? "s" : ""})` : ""}`
|
|
127
|
+
: "error: the preview failed";
|
|
128
|
+
log([head, ...result.diagnostics.map((d) => ` ${formatDiagnostic(d)}`)].join("\n"));
|
|
129
|
+
}
|
|
130
|
+
emit();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
cleanup.push(() => compiler.close());
|
|
134
|
+
|
|
135
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
136
|
+
try {
|
|
137
|
+
server = Bun.serve({
|
|
138
|
+
hostname: "127.0.0.1",
|
|
139
|
+
port: opts.port,
|
|
140
|
+
idleTimeout: 0,
|
|
141
|
+
async fetch(req) {
|
|
142
|
+
const path = new URL(req.url).pathname;
|
|
143
|
+
if (path === "/api/state") return Response.json(current, { headers: { "cache-control": "no-store" } });
|
|
144
|
+
if (path === "/api/events") {
|
|
145
|
+
let client: ReadableStreamDefaultController<Uint8Array>;
|
|
146
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
147
|
+
start(controller) {
|
|
148
|
+
client = controller;
|
|
149
|
+
clients.add(client);
|
|
150
|
+
send(client);
|
|
151
|
+
},
|
|
152
|
+
cancel() {
|
|
153
|
+
clients.delete(client);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" } });
|
|
157
|
+
}
|
|
158
|
+
if (path.startsWith("/files/")) {
|
|
159
|
+
const file = inside(root, path.slice("/files/".length));
|
|
160
|
+
return file ? sendFile(req, file) : new Response(null, { status: 404 });
|
|
161
|
+
}
|
|
162
|
+
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
|
|
163
|
+
const index = join(VIEWER, "index.html");
|
|
164
|
+
if (!(await Bun.file(index).exists())) return new Response(NOT_BUILT, { headers: { "content-type": "text/html; charset=utf-8" } });
|
|
165
|
+
const file = inside(VIEWER, path === "/" ? "index.html" : path.slice(1));
|
|
166
|
+
return sendFile(req, file && (await Bun.file(file).exists()) ? file : index);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
} catch (e) {
|
|
170
|
+
close();
|
|
171
|
+
if ((e as { code?: string }).code === "EADDRINUSE") throw new Error(`cannot listen on 127.0.0.1:${opts.port} (is the port in use? try --port)`);
|
|
172
|
+
throw e;
|
|
173
|
+
}
|
|
174
|
+
cleanup.push(() => {
|
|
175
|
+
for (const client of clients) client.close();
|
|
176
|
+
clients.clear();
|
|
177
|
+
void server.stop(true);
|
|
178
|
+
});
|
|
179
|
+
const url = `http://localhost:${server.port}`;
|
|
180
|
+
const keepAlive = setInterval(() => {
|
|
181
|
+
for (const c of clients) c.enqueue(encoder.encode(": keep-alive\n\n"));
|
|
182
|
+
}, 15_000);
|
|
183
|
+
cleanup.push(() => clearInterval(keepAlive));
|
|
184
|
+
|
|
185
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
186
|
+
cleanup.push(() => clearTimeout(timer));
|
|
187
|
+
let readVersion = 0;
|
|
188
|
+
const changed = () => {
|
|
189
|
+
const version = ++readVersion;
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
timer = setTimeout(async () => {
|
|
192
|
+
let text: string;
|
|
193
|
+
try {
|
|
194
|
+
text = await Bun.file(xmlPath).text();
|
|
195
|
+
if (closed || version !== readVersion) return;
|
|
196
|
+
} catch {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
writes++;
|
|
200
|
+
scriptError = null;
|
|
201
|
+
if (text === xml) {
|
|
202
|
+
log("the XML has not changed");
|
|
203
|
+
emit();
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
xml = text;
|
|
207
|
+
compileError = null;
|
|
208
|
+
started = Date.now();
|
|
209
|
+
current = { ...current, version: current.version + 1, status: "building", diagnostics: [] };
|
|
210
|
+
emit();
|
|
211
|
+
compiler.update(xml, current.version);
|
|
212
|
+
}, 100);
|
|
213
|
+
};
|
|
214
|
+
try {
|
|
215
|
+
if (xmlPath.startsWith("/mnt/")) {
|
|
216
|
+
watchFile(xmlPath, { interval: 500 }, changed);
|
|
217
|
+
cleanup.push(() => unwatchFile(xmlPath, changed));
|
|
218
|
+
} else {
|
|
219
|
+
const watcher = watch(dirname(xmlPath), (_event, name) => {
|
|
220
|
+
if (name === null || name.toString() === basename(xmlPath)) changed();
|
|
221
|
+
});
|
|
222
|
+
cleanup.push(() => watcher.close());
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {
|
|
225
|
+
close();
|
|
226
|
+
throw e;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let child: Bun.Subprocess | undefined;
|
|
230
|
+
let pause: ReturnType<typeof setTimeout> | undefined;
|
|
231
|
+
cleanup.push(() => {
|
|
232
|
+
clearTimeout(pause);
|
|
233
|
+
child?.kill("SIGTERM");
|
|
234
|
+
});
|
|
235
|
+
if (opts.run) {
|
|
236
|
+
const [shell, args] = process.platform === "win32" ? ["cmd", ["/C", opts.run]] : ["sh", ["-c", opts.run]];
|
|
237
|
+
const env = { ...process.env, BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD: process.env.BUN_CONFIG_NO_CLEAR_TERMINAL_ON_RELOAD ?? "1" };
|
|
238
|
+
try {
|
|
239
|
+
const proc = Bun.spawn([shell, ...args], {
|
|
240
|
+
cwd: root,
|
|
241
|
+
env,
|
|
242
|
+
stdin: "ignore",
|
|
243
|
+
stdout: "inherit",
|
|
244
|
+
stderr: "pipe",
|
|
245
|
+
onExit(_proc, code, signal) {
|
|
246
|
+
if (!closed) console.error(`\`${opts.run}\` exited (${signal ?? code})`);
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
child = proc;
|
|
250
|
+
void (async () => {
|
|
251
|
+
let text = "";
|
|
252
|
+
let since = writes;
|
|
253
|
+
const decoder = new TextDecoder();
|
|
254
|
+
for await (const chunk of proc.stderr) {
|
|
255
|
+
process.stderr.write(chunk);
|
|
256
|
+
if (writes !== since) {
|
|
257
|
+
text = "";
|
|
258
|
+
since = writes;
|
|
259
|
+
}
|
|
260
|
+
text += Bun.stripANSI(decoder.decode(chunk, { stream: true }));
|
|
261
|
+
clearTimeout(pause);
|
|
262
|
+
pause = setTimeout(() => {
|
|
263
|
+
if (!closed && writes === since && text.trim()) {
|
|
264
|
+
scriptError = text;
|
|
265
|
+
emit();
|
|
266
|
+
}
|
|
267
|
+
}, 150);
|
|
268
|
+
}
|
|
269
|
+
})().catch((e: unknown) => {
|
|
270
|
+
scriptError = String(e);
|
|
271
|
+
emit();
|
|
272
|
+
});
|
|
273
|
+
} catch (e) {
|
|
274
|
+
scriptError = (e as Error).message;
|
|
275
|
+
emit();
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
compiler.update(xml, current.version);
|
|
280
|
+
return { url, close };
|
|
281
|
+
};
|