@saadnvd1/agent-os 0.3.790 → 0.3.792
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.
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { needsShrink, renamed, SHRINK_STEPS, targetSize } from "./shrinkPlan";
|
|
2
|
+
|
|
3
|
+
type Canvas = OffscreenCanvas | HTMLCanvasElement;
|
|
4
|
+
|
|
5
|
+
function makeCanvas(width: number, height: number): Canvas {
|
|
6
|
+
if (typeof OffscreenCanvas !== "undefined")
|
|
7
|
+
return new OffscreenCanvas(width, height);
|
|
8
|
+
const c = document.createElement("canvas");
|
|
9
|
+
c.width = width;
|
|
10
|
+
c.height = height;
|
|
11
|
+
return c;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function encode(
|
|
15
|
+
canvas: Canvas,
|
|
16
|
+
type: string,
|
|
17
|
+
quality?: number
|
|
18
|
+
): Promise<Blob | null> {
|
|
19
|
+
if ("convertToBlob" in canvas) return canvas.convertToBlob({ type, quality });
|
|
20
|
+
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasAlpha(
|
|
24
|
+
ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
|
|
25
|
+
w: number,
|
|
26
|
+
h: number
|
|
27
|
+
) {
|
|
28
|
+
const data = ctx.getImageData(0, 0, w, h).data;
|
|
29
|
+
for (let i = 3; i < data.length; i += 4) if (data[i] < 255) return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ShrinkResult = { ok: true; file: File } | { ok: false };
|
|
34
|
+
|
|
35
|
+
// Downscale an image that is over the byte cap or 2000px on its long edge.
|
|
36
|
+
// Returns the file untouched when it is already fine, and { ok: false } when
|
|
37
|
+
// it can't be decoded or still doesn't fit after every step.
|
|
38
|
+
export async function shrinkImage(
|
|
39
|
+
file: File,
|
|
40
|
+
cap: number
|
|
41
|
+
): Promise<ShrinkResult> {
|
|
42
|
+
let bitmap: ImageBitmap;
|
|
43
|
+
try {
|
|
44
|
+
bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
|
|
45
|
+
} catch {
|
|
46
|
+
// A file we can't decode is only a problem if it was too big anyway.
|
|
47
|
+
return file.size > cap ? { ok: false } : { ok: true, file };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
if (!needsShrink(bitmap.width, bitmap.height, file.size, cap))
|
|
51
|
+
return { ok: true, file };
|
|
52
|
+
// Animated GIFs would lose their frames; only touch one that can't go as is.
|
|
53
|
+
if (file.type === "image/gif" && file.size <= cap)
|
|
54
|
+
return { ok: true, file };
|
|
55
|
+
|
|
56
|
+
let keepPng: boolean | null = null;
|
|
57
|
+
for (const { maxEdge, quality } of SHRINK_STEPS) {
|
|
58
|
+
const { width, height } = targetSize(
|
|
59
|
+
bitmap.width,
|
|
60
|
+
bitmap.height,
|
|
61
|
+
maxEdge
|
|
62
|
+
);
|
|
63
|
+
const canvas = makeCanvas(width, height);
|
|
64
|
+
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D | null;
|
|
65
|
+
if (!ctx) return { ok: false };
|
|
66
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
67
|
+
if (keepPng === null)
|
|
68
|
+
keepPng = file.type === "image/png" && hasAlpha(ctx, width, height);
|
|
69
|
+
if (keepPng) {
|
|
70
|
+
const png = await encode(canvas, "image/png");
|
|
71
|
+
if (png && png.size <= cap)
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
file: new File([png], file.name, { type: "image/png" }),
|
|
75
|
+
};
|
|
76
|
+
keepPng = false;
|
|
77
|
+
}
|
|
78
|
+
// JPEG has no alpha: put the picture on white first.
|
|
79
|
+
ctx.globalCompositeOperation = "destination-over";
|
|
80
|
+
ctx.fillStyle = "#fff";
|
|
81
|
+
ctx.fillRect(0, 0, width, height);
|
|
82
|
+
const jpeg = await encode(canvas, "image/jpeg", quality);
|
|
83
|
+
if (jpeg && jpeg.size <= cap)
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
file: new File([jpeg], renamed(file.name, "jpg"), {
|
|
87
|
+
type: "image/jpeg",
|
|
88
|
+
}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return { ok: false };
|
|
92
|
+
} finally {
|
|
93
|
+
bitmap.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { needsShrink, renamed, SHRINK_STEPS, targetSize } from "./shrinkPlan";
|
|
3
|
+
|
|
4
|
+
describe("shrink plan", () => {
|
|
5
|
+
it("shrinks a big photo and leaves a small one alone", () => {
|
|
6
|
+
expect(needsShrink(5712, 4284, 8_000_000, 3_750_000)).toBe(true);
|
|
7
|
+
expect(needsShrink(1200, 800, 4_000_000, 3_750_000)).toBe(true);
|
|
8
|
+
expect(needsShrink(3000, 1000, 100_000, 3_750_000)).toBe(true);
|
|
9
|
+
expect(needsShrink(2000, 1500, 500_000, 3_750_000)).toBe(false);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("fits the long edge and keeps the aspect ratio", () => {
|
|
13
|
+
expect(targetSize(5712, 4284, 2000)).toEqual({ width: 2000, height: 1500 });
|
|
14
|
+
expect(targetSize(4284, 5712, 1600)).toEqual({ width: 1200, height: 1600 });
|
|
15
|
+
expect(targetSize(800, 600, 2000)).toEqual({ width: 800, height: 600 });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("steps quality down before size", () => {
|
|
19
|
+
expect(SHRINK_STEPS.map((s) => `${s.maxEdge}@${s.quality}`)).toEqual([
|
|
20
|
+
"2000@0.85",
|
|
21
|
+
"2000@0.75",
|
|
22
|
+
"2000@0.65",
|
|
23
|
+
"1600@0.75",
|
|
24
|
+
"1600@0.65",
|
|
25
|
+
]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("swaps the extension", () => {
|
|
29
|
+
expect(renamed("IMG_1.HEIC", "jpg")).toBe("IMG_1.jpg");
|
|
30
|
+
expect(renamed("a.b.png", "jpg")).toBe("a.b.jpg");
|
|
31
|
+
expect(renamed("noext", "jpg")).toBe("noext.jpg");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The decisions behind shrinking a big photo before it is attached, kept pure
|
|
2
|
+
// so they can be tested without a canvas.
|
|
3
|
+
|
|
4
|
+
export const MAX_EDGE = 2000;
|
|
5
|
+
const FALLBACK_EDGE = 1600;
|
|
6
|
+
|
|
7
|
+
export interface ShrinkStep {
|
|
8
|
+
maxEdge: number;
|
|
9
|
+
quality: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Tried in order until the result fits the cap.
|
|
13
|
+
export const SHRINK_STEPS: ShrinkStep[] = [
|
|
14
|
+
{ maxEdge: MAX_EDGE, quality: 0.85 },
|
|
15
|
+
{ maxEdge: MAX_EDGE, quality: 0.75 },
|
|
16
|
+
{ maxEdge: MAX_EDGE, quality: 0.65 },
|
|
17
|
+
{ maxEdge: FALLBACK_EDGE, quality: 0.75 },
|
|
18
|
+
{ maxEdge: FALLBACK_EDGE, quality: 0.65 },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const needsShrink = (
|
|
22
|
+
width: number,
|
|
23
|
+
height: number,
|
|
24
|
+
bytes: number,
|
|
25
|
+
cap: number
|
|
26
|
+
) => bytes > cap || Math.max(width, height) > MAX_EDGE;
|
|
27
|
+
|
|
28
|
+
// Fit inside maxEdge on the long side; never enlarge.
|
|
29
|
+
export function targetSize(width: number, height: number, maxEdge: number) {
|
|
30
|
+
const scale = Math.min(1, maxEdge / Math.max(width, height));
|
|
31
|
+
return {
|
|
32
|
+
width: Math.max(1, Math.round(width * scale)),
|
|
33
|
+
height: Math.max(1, Math.round(height * scale)),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// photo.HEIC -> photo.jpg; a name without an extension just gains one.
|
|
38
|
+
export const renamed = (name: string, ext: "jpg" | "png") =>
|
|
39
|
+
`${name.replace(/\.[^./\\]+$/, "")}.${ext}`;
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
import type { ChatFile, ChatImage } from "@/lib/chat/events";
|
|
17
17
|
import { uploadFile } from "@/lib/file-upload";
|
|
18
18
|
import { isImage } from "./paste";
|
|
19
|
+
import { shrinkImage } from "./shrinkImage";
|
|
19
20
|
|
|
20
21
|
export interface Upload {
|
|
21
22
|
key: string;
|
|
@@ -79,9 +80,9 @@ export function useAttachFiles({
|
|
|
79
80
|
if (held.current + images.length + others.length >= limits.max)
|
|
80
81
|
error = `You can attach up to ${limits.max} files to one message.`;
|
|
81
82
|
else if (isImage(f)) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
else
|
|
83
|
+
const shrunk = await shrinkImage(f, limits.maxImageBytes);
|
|
84
|
+
if (shrunk.ok) images.push(shrunk.file);
|
|
85
|
+
else error = tooBigMessage(f.name, f.size, limits.maxImageBytes);
|
|
85
86
|
} else if (!typeFor(f.name, limits.types))
|
|
86
87
|
error = unsupportedMessage(f.name);
|
|
87
88
|
else if (f.size > limits.maxBytes)
|
package/content/whats-new.json
CHANGED
|
@@ -1777,6 +1777,14 @@
|
|
|
1777
1777
|
"area": "AgentOS",
|
|
1778
1778
|
"text": "What's New now sits as a small icon in the sidebar footer.",
|
|
1779
1779
|
"url": "https://github.com/saadnvd1/agent-os/pull/528"
|
|
1780
|
+
},
|
|
1781
|
+
{
|
|
1782
|
+
"id": 529,
|
|
1783
|
+
"date": "2026-10-31",
|
|
1784
|
+
"kind": "fixed",
|
|
1785
|
+
"area": "Chat",
|
|
1786
|
+
"text": "Large photos from your phone now attach to a chat instead of being refused.",
|
|
1787
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/529"
|
|
1780
1788
|
}
|
|
1781
1789
|
]
|
|
1782
1790
|
}
|