chore: init clean tree

This commit is contained in:
2025-12-17 23:19:04 +02:00
commit 01d96d3200
45 changed files with 4152 additions and 0 deletions

15
src/renderer/assets.ts Normal file
View File

@@ -0,0 +1,15 @@
import path from "path";
import { loadImage, type Image } from "@napi-rs/canvas";
const ASSET_ROOT = path.resolve(__dirname, "..", "..", "asset");
export function resolveAssetPath(relativePath: string): string {
return path.join(ASSET_ROOT, relativePath);
}
export async function loadImageAsset(relativePath: string): Promise<Image> {
console.debug("[debug] [renderer/assets] loadImageAsset " + relativePath)
const assetPath = resolveAssetPath(relativePath);
return loadImage(assetPath);
}

104
src/renderer/cli.ts Normal file
View File

@@ -0,0 +1,104 @@
export type CliConfig = {
rendererId?: string;
debugGlobalHud: boolean;
debugRendererHud: boolean;
crashRecoverySession?: string | true;
errorScreenRequested?: boolean;
errorScreenMessage?: string;
errorScreenTitle?: string;
errorScreenHint?: string;
debugLogFile?: string;
helpRequested: boolean;
};
export function parseCli(argv: string[]): CliConfig {
// Bun passes: [bunPath, scriptPath, ...]
const args = argv.slice(2);
const config: CliConfig = {
debugGlobalHud: false,
debugRendererHud: false,
helpRequested: false
};
for (let i = 0; i < args.length; i++) {
const arg = args[i] ?? "";
if (arg === "--help" || arg === "-h") {
config.helpRequested = true;
continue;
}
if (arg === "--renderer" && args[i + 1] && !args[i + 1]!.startsWith("--")) {
config.rendererId = args[i + 1]!;
i += 1;
continue;
}
if (arg.startsWith("--renderer=")) {
config.rendererId = arg.split("=")[1];
continue;
}
if (arg === "--debug") {
config.debugGlobalHud = true;
config.debugRendererHud = true;
continue;
}
if (arg === "--debug-global") {
config.debugGlobalHud = true;
continue;
}
if (arg === "--debug-renderer") {
config.debugRendererHud = true;
continue;
}
if (arg === "--error-screen") {
config.errorScreenRequested = true;
if (args[i + 1] && !args[i + 1]!.startsWith("--")) {
config.errorScreenMessage = args[i + 1]!;
i += 1;
}
continue;
}
if (arg.startsWith("--error-screen=")) {
config.errorScreenRequested = true;
config.errorScreenMessage = arg.split("=").slice(1).join("=");
continue;
}
if (arg === "--error-title" && args[i + 1] && !args[i + 1]!.startsWith("--")) {
config.errorScreenTitle = args[i + 1]!;
i += 1;
continue;
}
if (arg.startsWith("--error-title=")) {
config.errorScreenTitle = arg.split("=").slice(1).join("=");
continue;
}
if (arg === "--error-hint" && args[i + 1] && !args[i + 1]!.startsWith("--")) {
config.errorScreenHint = args[i + 1]!;
i += 1;
continue;
}
if (arg.startsWith("--error-hint=")) {
config.errorScreenHint = arg.split("=").slice(1).join("=");
continue;
}
if (arg === "--debug-log-file" && args[i + 1] && !args[i + 1]!.startsWith("--")) {
config.debugLogFile = args[i + 1]!;
i += 1;
continue;
}
if (arg.startsWith("--debug-log-file=")) {
config.debugLogFile = arg.split("=").slice(1).join("=");
continue;
}
if (arg === "--crash-recovery") {
const maybeSession = args[i + 1];
if (maybeSession && !maybeSession.startsWith("--")) {
config.crashRecoverySession = maybeSession;
i += 1;
} else {
config.crashRecoverySession = "Hyprland";
}
continue;
}
}
return config;
}

103
src/renderer/debug-hud.ts Normal file
View File

@@ -0,0 +1,103 @@
import type { CanvasRenderingContext2D } from "@napi-rs/canvas";
import type { Layout } from "./layout";
export type DebugStats = Record<string, string | number | boolean | undefined>;
export type DebugHudOptions = {
showGlobal: boolean;
showRenderer: boolean;
showCustom?: boolean;
};
export type DebugHudData = {
global: DebugStats | string[];
renderer: {
id: string;
label: string;
stats: DebugStats;
fps: number;
};
custom?: DebugStats;
};
export type DebugHud = {
draw: (ctx: CanvasRenderingContext2D, layout: Layout, data: DebugHudData) => void;
};
export function createDebugHud(options: DebugHudOptions): DebugHud {
const padding = 8;
const lineHeight = 16;
const bg = "rgba(0, 0, 0, 0.65)";
const fg = "yellow"; // global
const rendererFg = "#ff66cc"; // renderer
const customFg = "#00c6ff"; // custom
const drawBlock = (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
title: string,
stats: DebugStats | string[],
color: string
): { width: number; height: number } => {
let lines: string[] = [];
if (Array.isArray(stats)) {
lines = stats
} else {
const keys = Object.keys(stats);
lines = [title, ...keys.map((k) => `${k}: ${String(stats[k])}`)];
}
ctx.font = "14px \"JetBrains Mono\", monospace";
const textWidth = Math.max(...lines.map((l) => ctx.measureText(l).width));
const height = lines.length * lineHeight + padding * 2;
const width = textWidth + padding * 2;
ctx.save();
ctx.fillStyle = bg;
ctx.fillRect(x, y, width, height);
ctx.fillStyle = color;
ctx.textBaseline = "top";
lines.forEach((line, i) => {
ctx.fillText(line, x + padding, y + padding + i * lineHeight);
});
ctx.restore();
return { width, height };
};
const draw = (ctx: CanvasRenderingContext2D, layout: Layout, data: DebugHudData) => {
if (!options.showGlobal && !options.showRenderer && !options.showCustom) return;
ctx.save();
ctx.imageSmoothingEnabled = false;
ctx.globalAlpha = 0.9;
let cursorY = padding;
const originX = padding;
if (options.showGlobal) {
const { height } = drawBlock(ctx, originX, cursorY, "Global", data.global, fg);
cursorY += height + padding;
}
if (options.showRenderer) {
const { height } = drawBlock(
ctx,
originX,
cursorY,
`Renderer: ${data.renderer.label}`,
{ fps: data.renderer.fps.toFixed(2), ...data.renderer.stats },
rendererFg
);
cursorY += height + padding;
}
if (options.showCustom && data.custom) {
drawBlock(ctx, originX, cursorY, "Custom", data.custom, customFg);
}
ctx.restore();
};
return { draw };
}

27
src/renderer/fps.ts Normal file
View File

@@ -0,0 +1,27 @@
export type FpsCounter = {
tick: (nowMs: number) => void;
value: number;
};
export function createFpsCounter(sampleWindowMs = 500): FpsCounter {
let lastSampleStart = Date.now();
let frameCount = 0;
let currentFps = 0;
const tick = (nowMs: number) => {
frameCount += 1;
const elapsed = nowMs - lastSampleStart;
if (elapsed >= sampleWindowMs) {
currentFps = (frameCount * 1000) / elapsed;
frameCount = 0;
lastSampleStart = nowMs;
}
};
return {
tick,
get value() {
return currentFps;
}
};
}

155
src/renderer/index.ts Normal file
View File

@@ -0,0 +1,155 @@
import type { Events } from "@kmamal/sdl";
import {
createCanvas,
type Canvas,
type CanvasRenderingContext2D
} from "@napi-rs/canvas";
import { SDLWindow, type WindowProps } from "./window";
type RenderFrame = (
ctx: CanvasRenderingContext2D,
size: { width: number; height: number }
) => void | Promise<void>;
type RendererOptions = WindowProps & { window?: SDLWindow };
export class Renderer {
readonly window: SDLWindow;
readonly canvas: Canvas;
readonly ctx: CanvasRenderingContext2D;
#animation: ReturnType<typeof setInterval> | undefined;
#pixelBuffer: Buffer | undefined;
#stride = 0;
#size: { width: number; height: number };
#stop: (() => void) | undefined;
constructor(options: RendererOptions = {}) {
console.debug("[debug] [renderer] new Renderer")
const { window: providedWindow, ...windowProps } = options;
this.window = providedWindow ?? new SDLWindow(windowProps);
const { width, height } = this.window.size;
this.#size = { width, height };
this.canvas = createCanvas(width, height);
this.ctx = this.canvas.getContext("2d");
this.ctx.imageSmoothingEnabled = false;
this.#syncPixelBuffer();
}
get size(): { width: number; height: number } {
return this.#size;
}
resize(width: number, height: number): void {
this.#size = { width, height };
if (this.canvas.width === width && this.canvas.height === height) {
return;
}
this.canvas.width = width;
this.canvas.height = height;
this.ctx.imageSmoothingEnabled = false;
this.#syncPixelBuffer();
}
present(): void {
if (!this.#pixelBuffer) {
this.#syncPixelBuffer();
}
this.window.renderFromBuffer(
this.canvas.width,
this.canvas.height,
this.#stride,
this.#pixelBuffer!
);
}
#syncPixelBuffer(): void {
this.#pixelBuffer = this.canvas.data();
this.#stride = Math.floor(
this.#pixelBuffer.byteLength / Math.max(1, this.canvas.height)
);
}
requestStop(): void {
this.#stop?.();
}
async run(renderFrame: RenderFrame): Promise<void> {
console.debug("[debug] [renderer] starting render")
const listeners: Array<() => void> = [];
let rendering = false;
const addListener = <E extends Events.Window.Any["type"]>(
event: E,
handler: (event: Extract<Events.Window.Any, { type: E }>) => void
) => {
listeners.push(this.window.on(event, handler));
};
const renderOnce = async () => {
await renderFrame(this.ctx, this.size);
this.present();
};
await renderOnce();
await new Promise<void>((resolve) => {
let stopped = false;
const cleanup = () => {
if (this.#animation) {
clearInterval(this.#animation);
this.#animation = undefined;
}
this.#stop = undefined;
listeners.splice(0).forEach((off) => off());
};
const stop = () => {
if (stopped) return;
stopped = true;
cleanup();
this.window.destroy();
resolve();
};
this.#stop = stop;
const tick = () => {
if (rendering) return;
rendering = true;
void renderOnce().finally(() => {
rendering = false;
});
};
this.#animation = setInterval(tick, 1000 / 60);
this.#animation.unref?.();
addListener("resize", async (event) => {
this.resize(event.pixelWidth, event.pixelHeight);
tick();
});
addListener("expose", () => {
tick();
});
addListener("keyDown", (event) => {
if (event.key === "Escape" || event.key === "Q") {
stop();
}
});
addListener("beforeClose", (event) => {
event.prevent();
stop();
});
addListener("close", () => stop());
});
}
}
export function createRenderer(options: RendererOptions = {}): Renderer {
return new Renderer(options);
}

65
src/renderer/layout.ts Normal file
View File

@@ -0,0 +1,65 @@
export type Layout = {
width: number;
height: number;
viewScale: number;
boxWidth: number;
boxHeight: number;
boxX: number;
boxY: number;
contentScale: number;
drawWidth: number;
drawHeight: number;
x: number;
y: number;
centerX: number;
centerY: number;
};
export function createLayoutCalculator(options: {
baseWidth: number;
baseHeight: number;
viewWidth: number;
viewHeight: number;
}): (size: { width: number; height: number }) => Layout {
let cachedLayout: Layout | undefined;
return (size: { width: number; height: number }): Layout => {
const { width, height } = size;
if (cachedLayout && cachedLayout.width === width && cachedLayout.height === height) {
return cachedLayout;
}
const viewScale = Math.min(width / options.viewWidth, height / options.viewHeight);
const boxWidth = options.viewWidth * viewScale;
const boxHeight = options.viewHeight * viewScale;
const boxX = (width - boxWidth) / 2;
const boxY = (height - boxHeight) / 2;
const contentScale = Math.min(boxWidth / options.baseWidth, boxHeight / options.baseHeight);
const drawWidth = options.baseWidth * contentScale;
const drawHeight = options.baseHeight * contentScale;
const x = boxX + (boxWidth - drawWidth) / 2;
const y = boxY + (boxHeight - drawHeight) / 2;
const centerX = boxX + boxWidth / 2;
const centerY = boxY + boxHeight / 2;
cachedLayout = {
width,
height,
viewScale,
boxWidth,
boxHeight,
boxX,
boxY,
contentScale,
drawWidth,
drawHeight,
x,
y,
centerX,
centerY
};
return cachedLayout;
};
}

View File

@@ -0,0 +1,45 @@
export type LazyResource<T> = {
load: () => Promise<T>;
unload: () => void;
isLoaded: () => boolean;
};
export function createLazyResource<T>(
loader: () => Promise<T>,
dispose?: (value: T) => void
): LazyResource<T> {
let cached: T | null = null;
let inflight: Promise<T> | null = null;
const load = async (): Promise<T> => {
if (cached) return cached;
if (inflight) return inflight;
inflight = (async () => {
const value = await loader();
cached = value;
inflight = null;
return value;
})();
return inflight;
};
const unload = () => {
if (cached && dispose) {
try {
dispose(cached);
} catch (error) {
console.error("[lazy-resource] failed to dispose resource", error);
}
}
cached = null;
inflight = null;
};
return {
load,
unload,
isLoaded: () => cached !== null
};
}

94
src/renderer/video.ts Normal file
View File

@@ -0,0 +1,94 @@
import { ImageData } from "@napi-rs/canvas";
import { resolveAssetPath } from "./assets";
type VideoLoaderOptions = {
width: number;
height: number;
fps?: number;
maxFramesInMemory?: number;
frameSampleStep?: number;
};
export type VideoFrameSequence = {
width: number;
height: number;
fps: number;
durationMs: number;
frames: ImageData[];
};
export async function loadVideoFrames(
relativePath: string,
options: VideoLoaderOptions
): Promise<VideoFrameSequence> {
const targetFps = options.fps ?? 30;
const assetPath = resolveAssetPath(relativePath);
const maxFrames = options.maxFramesInMemory ?? 0;
const sampleStep = Math.max(1, options.frameSampleStep ?? 1);
const ffmpeg = Bun.spawn(
[
"ffmpeg",
"-v", "error",
"-i", assetPath,
"-an",
"-vf", `scale=${options.width}:${options.height}`,
"-r", `${targetFps}`,
"-f", "rawvideo",
"-pix_fmt", "rgba",
"-"
],
{ stdout: "pipe", stderr: "pipe" }
);
const frameSize = options.width * options.height * 4;
const frames: ImageData[] = [];
let residual = new Uint8Array(0);
let decodedFrameCount = 0;
const stderrPromise = ffmpeg.stderr ? Bun.readableStreamToText(ffmpeg.stderr) : Promise.resolve("");
for await (const chunk of ffmpeg.stdout) {
const merged = new Uint8Array(residual.length + chunk.length);
merged.set(residual, 0);
merged.set(chunk, residual.length);
residual = merged;
while (residual.length >= frameSize) {
const frameBytes = residual.slice(0, frameSize);
residual = residual.slice(frameSize);
decodedFrameCount += 1;
if (decodedFrameCount % sampleStep !== 0) {
continue;
}
const clamped = new Uint8ClampedArray(frameBytes.buffer, frameBytes.byteOffset, frameBytes.byteLength);
const image = new ImageData(clamped, options.width, options.height);
if (maxFrames > 0 && frames.length >= maxFrames) {
frames.shift();
}
frames.push(image);
}
}
const exitCode = await ffmpeg.exited;
const stderr = await stderrPromise;
if (exitCode !== 0) {
throw new Error(`ffmpeg exited with code ${exitCode}${stderr ? `: ${stderr}` : ""}`);
}
if (frames.length === 0) {
throw new Error("No frames decoded from video");
}
const effectiveFrameCount = decodedFrameCount;
return {
width: options.width,
height: options.height,
fps: targetFps,
durationMs: (effectiveFrameCount / targetFps) * 1000,
frames
};
}

98
src/renderer/window.ts Normal file
View File

@@ -0,0 +1,98 @@
import assert from "assert";
import sdl, { type Events, type Sdl } from "@kmamal/sdl";
import { createCanvas, Image, type CanvasRenderingContext2D } from "@napi-rs/canvas";
export type WindowProps = {
title?: string;
width?: number;
height?: number;
visible?: boolean;
fullscreen?: boolean;
resizable?: boolean;
borderless?: boolean;
alwaysOnTop?: boolean;
};
export class SDLWindow {
#window: Sdl.Video.Window | undefined;
constructor(props: WindowProps = {}) {
console.debug("[debug] [renderer/window] new SDLWindow", props)
this.#window = sdl.video.createWindow({
...props,
title: props.title ?? "SDL Application"
});
if (process.env.NODE_ENV === "development") {
this.#window.on("resize", (e) => {
this.#window?.setTitle(`${props.title ?? "SDL Application"} [${e.pixelWidth}x${e.pixelHeight}]`)
})
}
}
get size(): { width: number; height: number } {
const { pixelWidth, pixelHeight } = this.Window;
return { width: pixelWidth, height: pixelHeight };
}
get Window(): Sdl.Video.Window {
if (!this.#window) throw "Window not present";
return this.#window;
}
on<EventName extends Events.Window.Any["type"]>(
event: EventName,
handler: (
event: Extract<Events.Window.Any, { type: EventName }>
) => void
): () => void {
const target = this.Window as unknown as {
on: (event: Events.Window.Any["type"], listener: (event: Events.Window.Any) => void) => void;
off?: (
event: Events.Window.Any["type"],
listener: (event: Events.Window.Any) => void
) => void;
removeListener?: (
event: Events.Window.Any["type"],
listener: (event: Events.Window.Any) => void
) => void;
};
target.on(event, handler as (event: Events.Window.Any) => void);
return () => {
if (typeof target.off === "function") {
target.off(event, handler as (event: Events.Window.Any) => void);
return;
}
if (typeof target.removeListener === "function") {
target.removeListener(
event,
handler as (event: Events.Window.Any) => void
);
}
};
}
renderFromBuffer(width: number, height: number, stride: number, buffer: Buffer): void {
this.Window.render(width, height, stride, "rgba32", buffer);
}
renderFromContext(ctx: CanvasRenderingContext2D): void {
const { width, height } = this.size;
const buffer = Buffer.from(ctx.getImageData(0, 0, width, height).data);
this.renderFromBuffer(width, height, width * 4, buffer);
}
setIconFromImage(image: Image): void {
const canvas = createCanvas(image.width, image.height);
const ctx = canvas.getContext("2d");
ctx.drawImage(image as any, 0, 0);
const data = ctx.getImageData(0, 0, image.width, image.height).data;
this.Window.setIcon(image.width, image.height, image.width * 4, "rgba32", Buffer.from(data));
}
destroy(): void {
this.Window.destroy();
this.#window = undefined;
}
}