image.test.ts 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import { describe, expect } from "bun:test"
  2. import { LayerNode } from "@kirincode-ai/core/effect/layer-node"
  3. import { Cause, Effect, Exit } from "effect"
  4. import { Image } from "@/image/image"
  5. import { Config } from "@/config/config"
  6. import { MessageID, PartID, SessionID } from "@/session/schema"
  7. import path from "node:path"
  8. import { TestConfig } from "../fixture/config"
  9. import { testEffect } from "../lib/effect"
  10. const it = testEffect(LayerNode.compile(Image.node, [[Config.node, TestConfig.layer()]]))
  11. const tiny = testEffect(
  12. LayerNode.compile(Image.node, [
  13. [Config.node, TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) })],
  14. ]),
  15. )
  16. function part(mime: string, data: string) {
  17. return {
  18. id: PartID.ascending(),
  19. messageID: MessageID.ascending(),
  20. sessionID: SessionID.make("ses_test"),
  21. type: "file" as const,
  22. mime,
  23. url: `data:${mime};base64,${data}`,
  24. }
  25. }
  26. describe("Image", () => {
  27. it.effect("normalizes generated png and jpeg attachments", () =>
  28. Effect.gen(function* () {
  29. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  30. const source = new photon.PhotonImage(
  31. new Uint8Array(Array.from({ length: 64 * 64 * 4 }, (_, index) => (index % 4 === 3 ? 255 : index % 251))),
  32. 64,
  33. 64,
  34. )
  35. const image = yield* Image.Service
  36. const results = yield* Effect.all([
  37. image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))),
  38. image.normalize(part("image/jpeg", Buffer.from(source.get_bytes_jpeg(90)).toString("base64"))),
  39. ])
  40. source.free()
  41. expect(results.map((result) => result.url.startsWith(`data:${result.mime};base64,`))).toEqual([true, true])
  42. expect(results.every((result) => result.mime === "image/png" || result.mime === "image/jpeg")).toBe(true)
  43. }),
  44. )
  45. it.effect("accepts webp attachments that are already within limits", () =>
  46. Effect.gen(function* () {
  47. const image = yield* Image.Service
  48. const input = part("image/webp", "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA")
  49. expect(yield* image.normalize(input)).toEqual(input)
  50. }),
  51. )
  52. it.effect("resizes images that fit the byte limit but exceed dimension limits", () =>
  53. Effect.gen(function* () {
  54. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  55. const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 9_000 * 4 }, () => 255)), 9_000, 1)
  56. const image = yield* Image.Service
  57. const result = yield* image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64")))
  58. const resized = photon.PhotonImage.new_from_byteslice(
  59. Buffer.from(result.url.slice(result.url.indexOf(";base64,") + ";base64,".length), "base64"),
  60. )
  61. source.free()
  62. expect(resized.get_width()).toBeLessThanOrEqual(2_000)
  63. expect(resized.get_height()).toBeLessThanOrEqual(2_000)
  64. resized.free()
  65. }),
  66. )
  67. it.effect("resizes the 5MB base64 picture fixture", () =>
  68. Effect.gen(function* () {
  69. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  70. const data = Buffer.from(
  71. yield* Effect.promise(() =>
  72. Bun.file(path.join(import.meta.dir, "fixtures", "picture-5mb-base64.png")).arrayBuffer(),
  73. ),
  74. )
  75. const input = part("image/png", data.toString("base64"))
  76. const image = yield* Image.Service
  77. const result = yield* image.normalize(input)
  78. const base64 = result.url.slice(result.url.indexOf(";base64,") + ";base64,".length)
  79. const resized = photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
  80. expect(input.url.slice(input.url.indexOf(";base64,") + ";base64,".length).length).toBe(5 * 1024 * 1024)
  81. expect(result.url).not.toBe(input.url)
  82. expect(base64.length).toBeLessThan(5 * 1024 * 1024)
  83. expect(resized.get_width()).toBeLessThanOrEqual(2_000)
  84. expect(resized.get_height()).toBeLessThanOrEqual(2_000)
  85. resized.free()
  86. }),
  87. )
  88. tiny.effect("fails with a typed size error when no resized candidate fits", () =>
  89. Effect.gen(function* () {
  90. const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node"))
  91. const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1)
  92. const image = yield* Image.Service
  93. const exit = yield* image
  94. .normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64")))
  95. .pipe(Effect.exit)
  96. source.free()
  97. expect(Exit.isFailure(exit)).toBe(true)
  98. if (Exit.isFailure(exit)) {
  99. const error = Cause.squash(exit.cause)
  100. expect(error).toBeInstanceOf(Image.SizeError)
  101. if (error instanceof Image.SizeError) {
  102. expect(error.width).toBe(1)
  103. expect(error.height).toBe(1)
  104. expect(error.max).toBe(1)
  105. }
  106. }
  107. }),
  108. )
  109. })