feat(gateway): support bun-compiled embedded gateway
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai";
|
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-ai";
|
||||||
import { bashTool, codingTools, readTool } from "@mariozechner/pi-coding-agent";
|
import { bashTool, codingTools, readTool } from "@mariozechner/pi-coding-agent";
|
||||||
import type { TSchema } from "@sinclair/typebox";
|
import type { TSchema } from "@sinclair/typebox";
|
||||||
import sharp from "sharp";
|
|
||||||
|
|
||||||
|
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
|
|
||||||
// TODO(steipete): Remove this wrapper once pi-mono ships file-magic MIME detection
|
// TODO(steipete): Remove this wrapper once pi-mono ships file-magic MIME detection
|
||||||
@@ -125,11 +125,9 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
maxDimensionPx: number;
|
maxDimensionPx: number;
|
||||||
}): Promise<{ base64: string; mimeType: string; resized: boolean }> {
|
}): Promise<{ base64: string; mimeType: string; resized: boolean }> {
|
||||||
const buf = Buffer.from(params.base64, "base64");
|
const buf = Buffer.from(params.base64, "base64");
|
||||||
const img = sharp(buf, { failOnError: false });
|
const meta = await getImageMetadata(buf);
|
||||||
const meta = await img.metadata();
|
const width = meta?.width;
|
||||||
|
const height = meta?.height;
|
||||||
const width = meta.width;
|
|
||||||
const height = meta.height;
|
|
||||||
if (
|
if (
|
||||||
typeof width !== "number" ||
|
typeof width !== "number" ||
|
||||||
typeof height !== "number" ||
|
typeof height !== "number" ||
|
||||||
@@ -138,23 +136,36 @@ async function resizeImageBase64IfNeeded(params: {
|
|||||||
return { base64: params.base64, mimeType: params.mimeType, resized: false };
|
return { base64: params.base64, mimeType: params.mimeType, resized: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resized = img.resize({
|
|
||||||
width: params.maxDimensionPx,
|
|
||||||
height: params.maxDimensionPx,
|
|
||||||
fit: "inside",
|
|
||||||
withoutEnlargement: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mime = params.mimeType.toLowerCase();
|
const mime = params.mimeType.toLowerCase();
|
||||||
let out: Buffer;
|
let out: Buffer;
|
||||||
if (mime === "image/jpeg" || mime === "image/jpg") {
|
try {
|
||||||
out = await resized.jpeg({ quality: 85 }).toBuffer();
|
const mod = (await import("sharp")) as unknown as {
|
||||||
} else if (mime === "image/webp") {
|
default?: typeof import("sharp");
|
||||||
out = await resized.webp({ quality: 85 }).toBuffer();
|
};
|
||||||
} else if (mime === "image/png") {
|
const sharp = mod.default ?? (mod as unknown as typeof import("sharp"));
|
||||||
out = await resized.png().toBuffer();
|
const img = sharp(buf, { failOnError: false }).resize({
|
||||||
} else {
|
width: params.maxDimensionPx,
|
||||||
out = await resized.png().toBuffer();
|
height: params.maxDimensionPx,
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: true,
|
||||||
|
});
|
||||||
|
if (mime === "image/jpeg" || mime === "image/jpg") {
|
||||||
|
out = await img.jpeg({ quality: 85 }).toBuffer();
|
||||||
|
} else if (mime === "image/webp") {
|
||||||
|
out = await img.webp({ quality: 85 }).toBuffer();
|
||||||
|
} else if (mime === "image/png") {
|
||||||
|
out = await img.png().toBuffer();
|
||||||
|
} else {
|
||||||
|
out = await img.png().toBuffer();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Bun can't load sharp native addons. Fall back to a JPEG conversion.
|
||||||
|
out = await resizeToJpeg({
|
||||||
|
buffer: buf,
|
||||||
|
maxSide: params.maxDimensionPx,
|
||||||
|
quality: 85,
|
||||||
|
withoutEnlargement: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sniffed = detectMime({ buffer: out.slice(0, 256) });
|
const sniffed = detectMime({ buffer: out.slice(0, 256) });
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import sharp from "sharp";
|
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
|
||||||
|
|
||||||
export const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2000;
|
export const DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE = 2000;
|
||||||
export const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
|
export const DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
@@ -19,9 +19,9 @@ export async function normalizeBrowserScreenshot(
|
|||||||
Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES),
|
Math.round(opts?.maxBytes ?? DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES),
|
||||||
);
|
);
|
||||||
|
|
||||||
const meta = await sharp(buffer, { failOnError: false }).metadata();
|
const meta = await getImageMetadata(buffer);
|
||||||
const width = Number(meta.width ?? 0);
|
const width = Number(meta?.width ?? 0);
|
||||||
const height = Number(meta.height ?? 0);
|
const height = Number(meta?.height ?? 0);
|
||||||
const maxDim = Math.max(width, height);
|
const maxDim = Math.max(width, height);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -42,15 +42,12 @@ export async function normalizeBrowserScreenshot(
|
|||||||
|
|
||||||
for (const side of sideGrid) {
|
for (const side of sideGrid) {
|
||||||
for (const quality of qualities) {
|
for (const quality of qualities) {
|
||||||
const out = await sharp(buffer, { failOnError: false })
|
const out = await resizeToJpeg({
|
||||||
.resize({
|
buffer,
|
||||||
width: side,
|
maxSide: side,
|
||||||
height: side,
|
quality,
|
||||||
fit: "inside",
|
withoutEnlargement: true,
|
||||||
withoutEnlargement: true,
|
});
|
||||||
})
|
|
||||||
.jpeg({ quality, mozjpeg: true })
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
if (!smallest || out.byteLength < smallest.size) {
|
if (!smallest || out.byteLength < smallest.size) {
|
||||||
smallest = { buffer: out, size: out.byteLength };
|
smallest = { buffer: out, size: out.byteLength };
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const UI_PREFIX = "/ui/";
|
const _UI_PREFIX = "/ui/";
|
||||||
const ROOT_PREFIX = "/";
|
const ROOT_PREFIX = "/";
|
||||||
|
|
||||||
function resolveControlUiRoot(): string | null {
|
function resolveControlUiRoot(): string | null {
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ import {
|
|||||||
normalizeThinkLevel,
|
normalizeThinkLevel,
|
||||||
normalizeVerboseLevel,
|
normalizeVerboseLevel,
|
||||||
} from "../auto-reply/thinking.js";
|
} from "../auto-reply/thinking.js";
|
||||||
import {
|
|
||||||
startBrowserControlServerFromConfig,
|
|
||||||
stopBrowserControlServer,
|
|
||||||
} from "../browser/server.js";
|
|
||||||
import {
|
import {
|
||||||
type CanvasHostServer,
|
type CanvasHostServer,
|
||||||
startCanvasHost,
|
startCanvasHost,
|
||||||
@@ -99,6 +95,23 @@ import { sendMessageWhatsApp } from "../web/outbound.js";
|
|||||||
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
||||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||||
import { handleControlUiHttpRequest } from "./control-ui.js";
|
import { handleControlUiHttpRequest } from "./control-ui.js";
|
||||||
|
|
||||||
|
let stopBrowserControlServerIfStarted: (() => Promise<void>) | null = null;
|
||||||
|
|
||||||
|
async function startBrowserControlServerIfEnabled(): Promise<void> {
|
||||||
|
if (process.env.CLAWDIS_SKIP_BROWSER_CONTROL_SERVER === "1") return;
|
||||||
|
// Lazy import to keep optional heavyweight deps (playwright/electron) out of
|
||||||
|
// embedded/daemon builds.
|
||||||
|
const spec =
|
||||||
|
process.env.CLAWDIS_BROWSER_CONTROL_MODULE ??
|
||||||
|
// Intentionally not a static string literal so bun bundling can omit it.
|
||||||
|
// (The embedded gateway sets CLAWDIS_SKIP_BROWSER_CONTROL_SERVER=1.)
|
||||||
|
["..", "browser", "server.js"].join("/");
|
||||||
|
const mod = await import(spec);
|
||||||
|
stopBrowserControlServerIfStarted = mod.stopBrowserControlServer;
|
||||||
|
await mod.startBrowserControlServerFromConfig(defaultRuntime);
|
||||||
|
}
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type ConnectParams,
|
type ConnectParams,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@@ -4040,7 +4053,7 @@ export async function startGatewayServer(
|
|||||||
defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`);
|
defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`);
|
||||||
|
|
||||||
// Start clawd browser control server (unless disabled via config).
|
// Start clawd browser control server (unless disabled via config).
|
||||||
void startBrowserControlServerFromConfig(defaultRuntime).catch((err) => {
|
void startBrowserControlServerIfEnabled().catch((err) => {
|
||||||
logError(`gateway: clawd browser server failed to start: ${String(err)}`);
|
logError(`gateway: clawd browser server failed to start: ${String(err)}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4110,7 +4123,9 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clients.clear();
|
clients.clear();
|
||||||
await stopBrowserControlServer().catch(() => {});
|
if (stopBrowserControlServerIfStarted) {
|
||||||
|
await stopBrowserControlServerIfStarted().catch(() => {});
|
||||||
|
}
|
||||||
await Promise.allSettled(providerTasks);
|
await Promise.allSettled(providerTasks);
|
||||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||||
await new Promise<void>((resolve, reject) =>
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
|||||||
135
src/macos/gateway-daemon.ts
Normal file
135
src/macos/gateway-daemon.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import process from "node:process";
|
||||||
|
|
||||||
|
declare const __CLAWDIS_VERSION__: string;
|
||||||
|
|
||||||
|
const BUNDLED_VERSION =
|
||||||
|
typeof __CLAWDIS_VERSION__ === "string" ? __CLAWDIS_VERSION__ : "0.0.0";
|
||||||
|
|
||||||
|
function argValue(args: string[], flag: string): string | undefined {
|
||||||
|
const idx = args.indexOf(flag);
|
||||||
|
if (idx < 0) return undefined;
|
||||||
|
const value = args[idx + 1];
|
||||||
|
return value && !value.startsWith("-") ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasFlag(args: string[], flag: string): boolean {
|
||||||
|
return args.includes(flag);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
if (hasFlag(args, "--version") || hasFlag(args, "-v")) {
|
||||||
|
// Match `clawdis --version` behavior for Swift env/version checks.
|
||||||
|
// Keep output a single line.
|
||||||
|
console.log(BUNDLED_VERSION);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GatewayWsLogStyle = "auto" | "full" | "compact";
|
||||||
|
|
||||||
|
const [
|
||||||
|
{ loadConfig },
|
||||||
|
{ startGatewayServer },
|
||||||
|
{ setGatewayWsLogStyle },
|
||||||
|
{ setVerbose },
|
||||||
|
{ defaultRuntime },
|
||||||
|
] = await Promise.all([
|
||||||
|
import("../config/config.js"),
|
||||||
|
import("../gateway/server.js"),
|
||||||
|
import("../gateway/ws-logging.js"),
|
||||||
|
import("../globals.js"),
|
||||||
|
import("../runtime.js"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setVerbose(hasFlag(args, "--verbose"));
|
||||||
|
|
||||||
|
const wsLogRaw = (
|
||||||
|
hasFlag(args, "--compact") ? "compact" : argValue(args, "--ws-log")
|
||||||
|
) as string | undefined;
|
||||||
|
const wsLogStyle: GatewayWsLogStyle =
|
||||||
|
wsLogRaw === "compact" ? "compact" : wsLogRaw === "full" ? "full" : "auto";
|
||||||
|
setGatewayWsLogStyle(wsLogStyle);
|
||||||
|
|
||||||
|
const portRaw =
|
||||||
|
argValue(args, "--port") ?? process.env.CLAWDIS_GATEWAY_PORT ?? "18789";
|
||||||
|
const port = Number.parseInt(portRaw, 10);
|
||||||
|
if (Number.isNaN(port) || port <= 0) {
|
||||||
|
defaultRuntime.error(`Invalid --port (${portRaw})`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const bindRaw =
|
||||||
|
argValue(args, "--bind") ??
|
||||||
|
process.env.CLAWDIS_GATEWAY_BIND ??
|
||||||
|
cfg.gateway?.bind ??
|
||||||
|
"loopback";
|
||||||
|
const bind =
|
||||||
|
bindRaw === "loopback" ||
|
||||||
|
bindRaw === "tailnet" ||
|
||||||
|
bindRaw === "lan" ||
|
||||||
|
bindRaw === "auto"
|
||||||
|
? bindRaw
|
||||||
|
: null;
|
||||||
|
if (!bind) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
'Invalid --bind (use "loopback", "tailnet", "lan", or "auto")',
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = argValue(args, "--token");
|
||||||
|
if (token) process.env.CLAWDIS_GATEWAY_TOKEN = token;
|
||||||
|
|
||||||
|
let server: Awaited<ReturnType<typeof startGatewayServer>> | null = null;
|
||||||
|
let shuttingDown = false;
|
||||||
|
let forceExitTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const shutdown = (signal: string) => {
|
||||||
|
process.removeListener("SIGTERM", onSigterm);
|
||||||
|
process.removeListener("SIGINT", onSigint);
|
||||||
|
|
||||||
|
if (shuttingDown) {
|
||||||
|
defaultRuntime.log(
|
||||||
|
`gateway: received ${signal} during shutdown; exiting now`,
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
shuttingDown = true;
|
||||||
|
defaultRuntime.log(`gateway: received ${signal}; shutting down`);
|
||||||
|
|
||||||
|
forceExitTimer = setTimeout(() => {
|
||||||
|
defaultRuntime.error(
|
||||||
|
"gateway: shutdown timed out; exiting without full cleanup",
|
||||||
|
);
|
||||||
|
process.exit(0);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await server?.close();
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`gateway: shutdown error: ${String(err)}`);
|
||||||
|
} finally {
|
||||||
|
if (forceExitTimer) clearTimeout(forceExitTimer);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSigterm = () => shutdown("SIGTERM");
|
||||||
|
const onSigint = () => shutdown("SIGINT");
|
||||||
|
|
||||||
|
process.once("SIGTERM", onSigterm);
|
||||||
|
process.once("SIGINT", onSigint);
|
||||||
|
|
||||||
|
try {
|
||||||
|
server = await startGatewayServer(port, { bind });
|
||||||
|
} catch (err) {
|
||||||
|
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep process alive
|
||||||
|
await new Promise<never>(() => {});
|
||||||
155
src/media/image-ops.ts
Normal file
155
src/media/image-ops.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { runExec } from "../process/exec.js";
|
||||||
|
|
||||||
|
type Sharp = typeof import("sharp");
|
||||||
|
|
||||||
|
export type ImageMetadata = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isBun(): boolean {
|
||||||
|
return typeof (process.versions as { bun?: unknown }).bun === "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function prefersSips(): boolean {
|
||||||
|
return (
|
||||||
|
process.env.CLAWDIS_IMAGE_BACKEND === "sips" ||
|
||||||
|
(process.env.CLAWDIS_IMAGE_BACKEND !== "sharp" &&
|
||||||
|
isBun() &&
|
||||||
|
process.platform === "darwin")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSharp(): Promise<(buffer: Buffer) => ReturnType<Sharp>> {
|
||||||
|
const mod = (await import("sharp")) as unknown as { default?: Sharp };
|
||||||
|
const sharp = mod.default ?? (mod as unknown as Sharp);
|
||||||
|
return (buffer) => sharp(buffer, { failOnError: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-img-"));
|
||||||
|
try {
|
||||||
|
return await fn(dir);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sipsMetadataFromBuffer(
|
||||||
|
buffer: Buffer,
|
||||||
|
): Promise<ImageMetadata | null> {
|
||||||
|
return await withTempDir(async (dir) => {
|
||||||
|
const input = path.join(dir, "in.img");
|
||||||
|
await fs.writeFile(input, buffer);
|
||||||
|
const { stdout } = await runExec(
|
||||||
|
"/usr/bin/sips",
|
||||||
|
["-g", "pixelWidth", "-g", "pixelHeight", input],
|
||||||
|
{
|
||||||
|
timeoutMs: 10_000,
|
||||||
|
maxBuffer: 512 * 1024,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const w = stdout.match(/pixelWidth:\s*([0-9]+)/);
|
||||||
|
const h = stdout.match(/pixelHeight:\s*([0-9]+)/);
|
||||||
|
if (!w?.[1] || !h?.[1]) return null;
|
||||||
|
const width = Number.parseInt(w[1], 10);
|
||||||
|
const height = Number.parseInt(h[1], 10);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
||||||
|
if (width <= 0 || height <= 0) return null;
|
||||||
|
return { width, height };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sipsResizeToJpeg(params: {
|
||||||
|
buffer: Buffer;
|
||||||
|
maxSide: number;
|
||||||
|
quality: number;
|
||||||
|
}): Promise<Buffer> {
|
||||||
|
return await withTempDir(async (dir) => {
|
||||||
|
const input = path.join(dir, "in.img");
|
||||||
|
const output = path.join(dir, "out.jpg");
|
||||||
|
await fs.writeFile(input, params.buffer);
|
||||||
|
await runExec(
|
||||||
|
"/usr/bin/sips",
|
||||||
|
[
|
||||||
|
"-Z",
|
||||||
|
String(Math.max(1, Math.round(params.maxSide))),
|
||||||
|
"-s",
|
||||||
|
"format",
|
||||||
|
"jpeg",
|
||||||
|
"-s",
|
||||||
|
"formatOptions",
|
||||||
|
String(Math.max(1, Math.min(100, Math.round(params.quality)))),
|
||||||
|
input,
|
||||||
|
"--out",
|
||||||
|
output,
|
||||||
|
],
|
||||||
|
{ timeoutMs: 20_000, maxBuffer: 1024 * 1024 },
|
||||||
|
);
|
||||||
|
return await fs.readFile(output);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getImageMetadata(
|
||||||
|
buffer: Buffer,
|
||||||
|
): Promise<ImageMetadata | null> {
|
||||||
|
if (prefersSips()) {
|
||||||
|
return await sipsMetadataFromBuffer(buffer).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sharp = await loadSharp();
|
||||||
|
const meta = await sharp(buffer).metadata();
|
||||||
|
const width = Number(meta.width ?? 0);
|
||||||
|
const height = Number(meta.height ?? 0);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
||||||
|
if (width <= 0 || height <= 0) return null;
|
||||||
|
return { width, height };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resizeToJpeg(params: {
|
||||||
|
buffer: Buffer;
|
||||||
|
maxSide: number;
|
||||||
|
quality: number;
|
||||||
|
withoutEnlargement?: boolean;
|
||||||
|
}): Promise<Buffer> {
|
||||||
|
if (prefersSips()) {
|
||||||
|
// Avoid enlarging by checking dimensions first (sips has no withoutEnlargement flag).
|
||||||
|
if (params.withoutEnlargement !== false) {
|
||||||
|
const meta = await getImageMetadata(params.buffer);
|
||||||
|
if (meta) {
|
||||||
|
const maxDim = Math.max(meta.width, meta.height);
|
||||||
|
if (maxDim > 0 && maxDim <= params.maxSide) {
|
||||||
|
return await sipsResizeToJpeg({
|
||||||
|
buffer: params.buffer,
|
||||||
|
maxSide: maxDim,
|
||||||
|
quality: params.quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await sipsResizeToJpeg({
|
||||||
|
buffer: params.buffer,
|
||||||
|
maxSide: params.maxSide,
|
||||||
|
quality: params.quality,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sharp = await loadSharp();
|
||||||
|
return await sharp(params.buffer)
|
||||||
|
.resize({
|
||||||
|
width: params.maxSide,
|
||||||
|
height: params.maxSide,
|
||||||
|
fit: "inside",
|
||||||
|
withoutEnlargement: params.withoutEnlargement !== false,
|
||||||
|
})
|
||||||
|
.jpeg({ quality: params.quality, mozjpeg: true })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
declare const __CLAWDIS_VERSION__: string | undefined;
|
||||||
const pkg = require("../package.json") as { version?: string };
|
|
||||||
|
|
||||||
// Single source of truth for the current clawdis version (reads from package.json).
|
function readVersionFromPackageJson(): string | null {
|
||||||
export const VERSION = pkg.version ?? "0.0.0";
|
try {
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const pkg = require("../package.json") as { version?: string };
|
||||||
|
return pkg.version ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single source of truth for the current clawdis version.
|
||||||
|
// - Embedded/bundled builds: injected define or env var.
|
||||||
|
// - Dev/npm builds: package.json.
|
||||||
|
export const VERSION =
|
||||||
|
(typeof __CLAWDIS_VERSION__ === "string" && __CLAWDIS_VERSION__) ||
|
||||||
|
process.env.CLAWDIS_BUNDLED_VERSION ||
|
||||||
|
readVersionFromPackageJson() ||
|
||||||
|
"0.0.0";
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import sharp from "sharp";
|
|
||||||
|
|
||||||
import { isVerbose, logVerbose } from "../globals.js";
|
import { isVerbose, logVerbose } from "../globals.js";
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +7,7 @@ import {
|
|||||||
maxBytesForKind,
|
maxBytesForKind,
|
||||||
mediaKindFromMime,
|
mediaKindFromMime,
|
||||||
} from "../media/constants.js";
|
} from "../media/constants.js";
|
||||||
|
import { resizeToJpeg } from "../media/image-ops.js";
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
|
|
||||||
export async function loadWebMedia(
|
export async function loadWebMedia(
|
||||||
@@ -130,15 +130,12 @@ export async function optimizeImageToJpeg(
|
|||||||
|
|
||||||
for (const side of sides) {
|
for (const side of sides) {
|
||||||
for (const quality of qualities) {
|
for (const quality of qualities) {
|
||||||
const out = await sharp(buffer)
|
const out = await resizeToJpeg({
|
||||||
.resize({
|
buffer,
|
||||||
width: side,
|
maxSide: side,
|
||||||
height: side,
|
quality,
|
||||||
fit: "inside",
|
withoutEnlargement: true,
|
||||||
withoutEnlargement: true,
|
});
|
||||||
})
|
|
||||||
.jpeg({ quality, mozjpeg: true })
|
|
||||||
.toBuffer();
|
|
||||||
const size = out.length;
|
const size = out.length;
|
||||||
if (!smallest || size < smallest.size) {
|
if (!smallest || size < smallest.size) {
|
||||||
smallest = { buffer: out, size, resizeSide: side, quality };
|
smallest = { buffer: out, size, resizeSide: side, quality };
|
||||||
|
|||||||
Reference in New Issue
Block a user