feat: initial Signage 0.1.0
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
import sharp from "sharp";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { SignageError } from "../src/server/errors.js";
|
||||
import { SIGNAGE_IMAGE_MAX_BYTES, SIGNAGE_IMAGE_MAX_PIXELS } from "../src/server/images/limits.js";
|
||||
import { processSignageImage, sha256Hex } from "../src/server/images/processing.js";
|
||||
import {
|
||||
deleteNativeImage,
|
||||
ingestNativeImage,
|
||||
updateNativeImage,
|
||||
} from "../src/server/images/service.js";
|
||||
import { readBoundedBody } from "../src/server/images/streaming.js";
|
||||
import { isSixteenByNine, resolveNativeScene } from "../src/shared/native-images.js";
|
||||
import { createFakeStorage, type FakeStorage } from "./support/fake-storage.js";
|
||||
import {
|
||||
bytesOf,
|
||||
GIF_DOCUMENT_BASE64,
|
||||
makeAnimatedGif,
|
||||
makeAnimatedWebp,
|
||||
makeImage,
|
||||
PDF_DOCUMENT,
|
||||
SVG_DOCUMENT,
|
||||
} from "./support/images.js";
|
||||
import { createMemorySignageStore, type MemorySignageStore } from "./support/memory-store.js";
|
||||
|
||||
async function expectSignageCode(promise: Promise<unknown>, code: string): Promise<SignageError> {
|
||||
try {
|
||||
await promise;
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(SignageError);
|
||||
const signageError = error as SignageError;
|
||||
expect(signageError.code).toBe(code);
|
||||
return signageError;
|
||||
}
|
||||
throw new Error(`Expected ${code}, but the operation succeeded.`);
|
||||
}
|
||||
|
||||
describe("native image processing", () => {
|
||||
it("accepts still PNG, JPEG, and WebP and reports the format the bytes actually are", async () => {
|
||||
for (const format of ["png", "jpeg", "webp"] as const) {
|
||||
const processed = await processSignageImage(
|
||||
await makeImage({ format, height: 720, width: 1280 }),
|
||||
);
|
||||
expect(processed.format).toBe(format);
|
||||
expect(processed.width).toBe(1280);
|
||||
expect(processed.height).toBe(720);
|
||||
expect(processed.renditions.map((rendition) => rendition.kind)).toEqual([
|
||||
"display",
|
||||
"thumbnail",
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses SVG, PDF, GIF, animation, and anything that is not an image at all", async () => {
|
||||
await expectSignageCode(
|
||||
processSignageImage(bytesOf(SVG_DOCUMENT)),
|
||||
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
|
||||
);
|
||||
await expectSignageCode(processSignageImage(bytesOf(PDF_DOCUMENT)), "SIGNAGE_IMAGE_MALFORMED");
|
||||
await expectSignageCode(
|
||||
processSignageImage(new Uint8Array(Buffer.from(GIF_DOCUMENT_BASE64, "base64"))),
|
||||
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
|
||||
);
|
||||
await expectSignageCode(
|
||||
processSignageImage(bytesOf("this is a text file, not a slide")),
|
||||
"SIGNAGE_IMAGE_MALFORMED",
|
||||
);
|
||||
// Animation is detected before format, so an animated GIF reports why it is really refused.
|
||||
await expectSignageCode(
|
||||
processSignageImage(await makeAnimatedWebp()),
|
||||
"SIGNAGE_IMAGE_ANIMATED",
|
||||
);
|
||||
await expectSignageCode(processSignageImage(await makeAnimatedGif()), "SIGNAGE_IMAGE_ANIMATED");
|
||||
});
|
||||
|
||||
it("refuses an image whose decoded pixels exceed the budget, whatever the file size is", async () => {
|
||||
// A decompression bomb is small on disk and enormous decoded, which is exactly why the budget
|
||||
// is measured in pixels rather than in bytes.
|
||||
const bomb = await makeImage({ format: "png", height: 9000, width: 9000 });
|
||||
expect(bomb.byteLength).toBeLessThan(SIGNAGE_IMAGE_MAX_BYTES);
|
||||
expect(9000 * 9000).toBeGreaterThan(SIGNAGE_IMAGE_MAX_PIXELS);
|
||||
|
||||
const error = await expectSignageCode(processSignageImage(bomb), "SIGNAGE_IMAGE_PIXEL_LIMIT");
|
||||
// The caller learns the limit Signage declared, and nothing about the decoder that enforced it.
|
||||
expect(error.detail).toEqual({ maxPixels: SIGNAGE_IMAGE_MAX_PIXELS });
|
||||
expect(error.message).not.toMatch(/sharp|vips|pixel limit exceeded/iu);
|
||||
});
|
||||
|
||||
it("fits renditions inside their bounds, and never crops or upscales", async () => {
|
||||
const tall = await processSignageImage(
|
||||
await makeImage({ format: "png", height: 2400, width: 1000 }),
|
||||
);
|
||||
const display = tall.renditions.find((rendition) => rendition.kind === "display");
|
||||
// Inside-fit: the long edge reaches the bound, the short edge is proportional, nothing is cut.
|
||||
expect(display?.height).toBe(1080);
|
||||
expect(display?.width).toBe(450);
|
||||
|
||||
const small = await processSignageImage(
|
||||
await makeImage({ format: "png", height: 90, width: 160 }),
|
||||
);
|
||||
const smallDisplay = small.renditions.find((rendition) => rendition.kind === "display");
|
||||
// Never upscaled: blowing a small image up to 1920 makes it soft, not better.
|
||||
expect(smallDisplay?.width).toBe(160);
|
||||
expect(smallDisplay?.height).toBe(90);
|
||||
});
|
||||
|
||||
it("strips metadata from renditions and flattens transparency against black", async () => {
|
||||
const transparent = await processSignageImage(
|
||||
await makeImage({ alpha: true, format: "png", height: 200, width: 400 }),
|
||||
);
|
||||
const display = transparent.renditions.find((rendition) => rendition.kind === "display");
|
||||
if (!display) {
|
||||
throw new Error("Expected a display rendition.");
|
||||
}
|
||||
const metadata = await sharp(display.bytes).metadata();
|
||||
|
||||
expect(metadata.format).toBe("webp");
|
||||
expect(metadata.hasAlpha).toBe(false);
|
||||
// EXIF, GPS coordinates, and camera serial numbers never travel with a published rendition.
|
||||
expect(metadata.exif).toBeUndefined();
|
||||
expect(metadata.icc).toBeUndefined();
|
||||
expect(metadata.xmp).toBeUndefined();
|
||||
// The original's own facts are preserved in PostgreSQL instead.
|
||||
expect(transparent.hasAlpha).toBe(true);
|
||||
});
|
||||
|
||||
it("is deterministic, so a retry of the same bytes produces the same renditions", async () => {
|
||||
const bytes = await makeImage({ format: "png", height: 600, width: 800 });
|
||||
const first = await processSignageImage(bytes);
|
||||
const second = await processSignageImage(bytes);
|
||||
|
||||
expect(first.contentSha256).toBe(second.contentSha256);
|
||||
expect(first.contentSha256).toBe(sha256Hex(bytes));
|
||||
expect(first.renditions.map((rendition) => rendition.contentSha256)).toEqual(
|
||||
second.renditions.map((rendition) => rendition.contentSha256),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native image presentation", () => {
|
||||
it("lets a true 16:9 image fill the frame, within a documented tolerance", () => {
|
||||
expect(isSixteenByNine(1920, 1080)).toBe(true);
|
||||
expect(isSixteenByNine(1280, 720)).toBe(true);
|
||||
// A one-or-two-pixel export artefact is still 16:9 to a person looking at the wall.
|
||||
expect(isSixteenByNine(1918, 1080)).toBe(true);
|
||||
expect(isSixteenByNine(1080, 1080)).toBe(false);
|
||||
expect(isSixteenByNine(2560, 1080)).toBe(false);
|
||||
expect(isSixteenByNine(0, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a 16:9 image whole with no background, blur, or shadow", () => {
|
||||
const scene = resolveNativeScene(
|
||||
{ height: 1080, width: 1920 },
|
||||
{
|
||||
backgroundColor: "#112233",
|
||||
backgroundDarkened: true,
|
||||
backgroundMode: "blurred",
|
||||
blurPixels: 20,
|
||||
foregroundShadow: true,
|
||||
},
|
||||
);
|
||||
|
||||
// An image that already fits needs none of the treatments that exist to frame one that does not.
|
||||
expect(scene).toEqual({
|
||||
backgroundColor: "#000000",
|
||||
backgroundImage: false,
|
||||
backgroundMode: "none",
|
||||
blurPixels: 0,
|
||||
darkenOpacity: 0,
|
||||
fit: "fill",
|
||||
shadow: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows every other ratio whole, on the background the slot asked for", () => {
|
||||
const blurred = resolveNativeScene(
|
||||
{ height: 1080, width: 1080 },
|
||||
{
|
||||
backgroundColor: "#112233",
|
||||
backgroundDarkened: true,
|
||||
backgroundMode: "blurred",
|
||||
blurPixels: 24,
|
||||
foregroundShadow: true,
|
||||
},
|
||||
);
|
||||
expect(blurred).toMatchObject({
|
||||
backgroundImage: true,
|
||||
blurPixels: 24,
|
||||
darkenOpacity: 0.25,
|
||||
fit: "contain",
|
||||
shadow: true,
|
||||
});
|
||||
|
||||
const solid = resolveNativeScene(
|
||||
{ height: 1080, width: 1080 },
|
||||
{
|
||||
backgroundColor: "#112233",
|
||||
backgroundDarkened: false,
|
||||
backgroundMode: "solid",
|
||||
blurPixels: 24,
|
||||
foregroundShadow: true,
|
||||
},
|
||||
);
|
||||
// A solid background is not blurred, and an undarkened one is not dimmed.
|
||||
expect(solid).toMatchObject({
|
||||
backgroundColor: "#112233",
|
||||
backgroundImage: false,
|
||||
blurPixels: 0,
|
||||
darkenOpacity: 0,
|
||||
fit: "contain",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded upload reading", () => {
|
||||
function streamedRequest(chunks: readonly Uint8Array[], contentLength?: number): Request {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(chunk);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Request("http://localhost/images?filename=a.png", {
|
||||
body,
|
||||
// Required by Node's fetch for a streaming request body.
|
||||
duplex: "half",
|
||||
headers: contentLength === undefined ? {} : { "content-length": String(contentLength) },
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
it("refuses a declared length over the limit before the body is even touched", async () => {
|
||||
let bodyAccesses = 0;
|
||||
// A stand-in rather than a real `Request`, and `body` is a getter: a `ReadableStream` pulls its
|
||||
// first chunk as soon as it is constructed, so counting reads would measure the stream rather
|
||||
// than the check. Counting *accesses to the body* measures exactly the property under test.
|
||||
const request = {
|
||||
get body(): ReadableStream<Uint8Array> {
|
||||
bodyAccesses += 1;
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(16));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
},
|
||||
headers: new Headers({ "content-length": String(SIGNAGE_IMAGE_MAX_BYTES + 1) }),
|
||||
} as unknown as Request;
|
||||
|
||||
await expectSignageCode(
|
||||
readBoundedBody(request, SIGNAGE_IMAGE_MAX_BYTES),
|
||||
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||
);
|
||||
expect(bodyAccesses).toBe(0);
|
||||
});
|
||||
|
||||
it("counts what actually arrives, so a missing or lying declared length bounds nothing", async () => {
|
||||
const chunk = new Uint8Array(1024);
|
||||
const chunks = Array.from({ length: 8 }, () => chunk);
|
||||
|
||||
// No `content-length` at all, and a total over a small limit.
|
||||
await expectSignageCode(
|
||||
readBoundedBody(streamedRequest(chunks), 4096),
|
||||
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||
);
|
||||
// A declared length that lies about being small.
|
||||
await expectSignageCode(
|
||||
readBoundedBody(streamedRequest(chunks, 10), 4096),
|
||||
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||
);
|
||||
});
|
||||
|
||||
it("reassembles a chunked body exactly", async () => {
|
||||
const image = await makeImage({ format: "png", height: 90, width: 160 });
|
||||
const half = Math.floor(image.byteLength / 2);
|
||||
const bytes = await readBoundedBody(
|
||||
streamedRequest([image.slice(0, half), image.slice(half)]),
|
||||
SIGNAGE_IMAGE_MAX_BYTES,
|
||||
);
|
||||
|
||||
expect(sha256Hex(bytes)).toBe(sha256Hex(image));
|
||||
});
|
||||
|
||||
it("refuses an empty body rather than treating it as an image", async () => {
|
||||
await expectSignageCode(
|
||||
readBoundedBody(streamedRequest([]), SIGNAGE_IMAGE_MAX_BYTES),
|
||||
"SIGNAGE_INVALID_REQUEST",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native image ingest", () => {
|
||||
let store: MemorySignageStore;
|
||||
let storage: FakeStorage;
|
||||
let counter: number;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMemorySignageStore();
|
||||
storage = createFakeStorage();
|
||||
counter = 0;
|
||||
});
|
||||
|
||||
const newId = () => {
|
||||
counter += 1;
|
||||
return `00000000-0000-4000-8000-${String(counter).padStart(12, "0")}`;
|
||||
};
|
||||
|
||||
async function ingest(bytes: Uint8Array, filename = "welcome.png") {
|
||||
return ingestNativeImage({
|
||||
bytes,
|
||||
displayName: filename,
|
||||
expiresAt: null,
|
||||
filename,
|
||||
newId,
|
||||
storage,
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
it("archives the original unchanged and stores both renditions beside it", async () => {
|
||||
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
|
||||
const result = await ingest(bytes);
|
||||
|
||||
expect(result.deduplicated).toBe(false);
|
||||
expect([...storage.objects.keys()].sort()).toEqual([
|
||||
`native-images/assets/${result.image.id}/display.webp`,
|
||||
`native-images/assets/${result.image.id}/original.png`,
|
||||
`native-images/assets/${result.image.id}/thumbnail.webp`,
|
||||
]);
|
||||
// Byte for byte. It is the church's file, and Signage is not entitled to rewrite it.
|
||||
const archived = storage.objects.get(`native-images/assets/${result.image.id}/original.png`);
|
||||
expect(sha256Hex(archived?.bytes ?? new Uint8Array())).toBe(sha256Hex(bytes));
|
||||
expect(result.image.originalContentType).toBe("image/png");
|
||||
expect(result.image.renditions.map((rendition) => rendition.contentType)).toEqual([
|
||||
"image/webp",
|
||||
"image/webp",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the existing asset for identical bytes, and writes nothing new", async () => {
|
||||
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
|
||||
const first = await ingest(bytes, "welcome.png");
|
||||
const objectCount = storage.objects.size;
|
||||
|
||||
const second = await ingest(bytes, "welcome-copy.png");
|
||||
|
||||
expect(second.deduplicated).toBe(true);
|
||||
expect(second.image.id).toBe(first.image.id);
|
||||
expect(storage.objects.size).toBe(objectCount);
|
||||
expect(store.images.size).toBe(1);
|
||||
});
|
||||
|
||||
it("writes nothing at all when the bytes are not an image Signage accepts", async () => {
|
||||
await expectSignageCode(
|
||||
ingest(bytesOf(SVG_DOCUMENT), "logo.svg"),
|
||||
"SIGNAGE_IMAGE_UNSUPPORTED_FORMAT",
|
||||
);
|
||||
|
||||
expect(storage.objects.size).toBe(0);
|
||||
expect(store.images.size).toBe(0);
|
||||
});
|
||||
|
||||
it("removes every object it wrote when a later write fails", async () => {
|
||||
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
|
||||
storage.failPut(`assets/00000000-0000-4000-8000-000000000001/thumbnail.webp`);
|
||||
|
||||
await expectSignageCode(ingest(bytes), "SIGNAGE_STORAGE_UNAVAILABLE");
|
||||
|
||||
// An aborted ingest leaves no orphan bytes and no half-formed asset.
|
||||
expect(storage.objects.size).toBe(0);
|
||||
expect(store.images.size).toBe(0);
|
||||
});
|
||||
|
||||
it("treats a race with an identical upload as a success rather than a failure", async () => {
|
||||
const bytes = await makeImage({ format: "png", height: 1080, width: 1920 });
|
||||
const first = await ingest(bytes);
|
||||
// The second ingest is forced past the dedup check by clearing the hash lookup once, exactly as
|
||||
// a concurrent commit would: the write happens, and the unique index refuses the insert.
|
||||
const originalLookup = store.findReadyImageByContentHash.bind(store);
|
||||
let lookups = 0;
|
||||
store.findReadyImageByContentHash = (hash: string) => {
|
||||
lookups += 1;
|
||||
return lookups === 1 ? Promise.resolve(undefined) : originalLookup(hash);
|
||||
};
|
||||
|
||||
const second = await ingest(bytes);
|
||||
|
||||
expect(second.deduplicated).toBe(true);
|
||||
expect(second.image.id).toBe(first.image.id);
|
||||
// The losing attempt cleaned up after itself.
|
||||
expect(storage.objects.size).toBe(3);
|
||||
});
|
||||
|
||||
it("refuses bytes past Signage's own limit even if the caller read them", async () => {
|
||||
await expectSignageCode(
|
||||
ingestNativeImage({
|
||||
bytes: new Uint8Array(SIGNAGE_IMAGE_MAX_BYTES + 1),
|
||||
displayName: "huge.png",
|
||||
expiresAt: null,
|
||||
filename: "huge.png",
|
||||
storage,
|
||||
store,
|
||||
}),
|
||||
"SIGNAGE_IMAGE_TOO_LARGE",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("native image maintenance", () => {
|
||||
let store: MemorySignageStore;
|
||||
let storage: FakeStorage;
|
||||
|
||||
async function seed() {
|
||||
return ingestNativeImage({
|
||||
bytes: await makeImage({ format: "png", height: 1080, width: 1920 }),
|
||||
displayName: "welcome.png",
|
||||
expiresAt: null,
|
||||
filename: "welcome.png",
|
||||
storage,
|
||||
store,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMemorySignageStore();
|
||||
storage = createFakeStorage();
|
||||
});
|
||||
|
||||
it("writes nothing for an exact no-op", async () => {
|
||||
const { image } = await seed();
|
||||
let writes = 0;
|
||||
const original = store.updateImage.bind(store);
|
||||
store.updateImage = (id, changes) => {
|
||||
writes += 1;
|
||||
return original(id, changes);
|
||||
};
|
||||
|
||||
const unchanged = await updateNativeImage({
|
||||
assetId: image.id,
|
||||
displayName: image.displayName,
|
||||
expiresAt: null,
|
||||
store,
|
||||
});
|
||||
|
||||
expect(unchanged.displayName).toBe(image.displayName);
|
||||
expect(writes).toBe(0);
|
||||
});
|
||||
|
||||
it("renames and expires an image, and treats a null expiration as permanent", async () => {
|
||||
const { image } = await seed();
|
||||
const expires = new Date("2027-01-01T00:00:00.000Z");
|
||||
|
||||
const renamed = await updateNativeImage({
|
||||
assetId: image.id,
|
||||
displayName: "Welcome slide",
|
||||
expiresAt: expires,
|
||||
store,
|
||||
});
|
||||
expect(renamed.displayName).toBe("Welcome slide");
|
||||
expect(renamed.expiresAt?.toISOString()).toBe(expires.toISOString());
|
||||
|
||||
const permanent = await updateNativeImage({ assetId: image.id, expiresAt: null, store });
|
||||
expect(permanent.expiresAt).toBeNull();
|
||||
});
|
||||
|
||||
it("deletes an unreferenced image and every object it owned", async () => {
|
||||
const { image } = await seed();
|
||||
|
||||
await deleteNativeImage({ assetId: image.id, storage, store });
|
||||
|
||||
expect(store.images.size).toBe(0);
|
||||
expect(storage.objects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("refuses to delete an image a slot or a live static hold still refers to", async () => {
|
||||
const { image } = await seed();
|
||||
store.addImageReference(image.id, "slot");
|
||||
store.addImageReference(image.id, "static-hold");
|
||||
|
||||
const error = await expectSignageCode(
|
||||
deleteNativeImage({ assetId: image.id, storage, store }),
|
||||
"SIGNAGE_ASSET_IN_USE",
|
||||
);
|
||||
|
||||
// Counts an operator can act on — never a slot id, a timeline name, or a channel group's name.
|
||||
expect(error.detail).toEqual({ heldByChannelGroups: 1, referencedBySlots: 1 });
|
||||
expect(store.images.size).toBe(1);
|
||||
expect(storage.objects.size).toBe(3);
|
||||
});
|
||||
|
||||
it("refuses an unknown image without saying whether it ever existed", async () => {
|
||||
await expectSignageCode(
|
||||
deleteNativeImage({
|
||||
assetId: "11111111-1111-4111-8111-111111111111",
|
||||
storage,
|
||||
store,
|
||||
}),
|
||||
"SIGNAGE_NOT_FOUND",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user