Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ Base: **https://assets.inkads.poc.singletonsd.com**
| Icon SVG (nav) | `…/svg/icon/icon-dark.svg` |
| Lockup SVG (nav) | `…/logo/wordmark/lockup-horizontal/dark/dark.svg` |
| Icon PNG 512 | `…/logo/static/dark/512.png` |
| House-ad preview | `…/marketing/house-ad/preview.png` |
| House-ad framebuffer | `…/marketing/house-ad/framebuffer.bin` |
| House-ad metadata | `…/marketing/house-ad/metadata.json` |

DNS + Pages setup: [`docs/deployment.md`](docs/deployment.md).

House-ad Waveshare package (POC-260): [`src/marketing/house-ad/README.md`](src/marketing/house-ad/README.md).

Firmware / e-paper / on-device UI: [`docs/firmware-consume.md`](docs/firmware-consume.md)
(mono SVG/PNG, favicons, pin strategy).

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,18 @@
"scripts": {
"sync:legacy-svg": "node scripts/sync-legacy-svg.mjs",
"validate": "node scripts/validate-assets.mjs",
"test": "node --test scripts/validate-assets.test.mjs",
"test": "node --test scripts/*.test.mjs",
"build": "node scripts/build.mjs",
"generate": "pnpm build",
"generate:house-ad-qr": "node scripts/generate-house-ad-qr.mjs",
"render:house-ad": "node scripts/render-house-ad.mjs",
"prepublishOnly": "pnpm sync:legacy-svg && pnpm validate && pnpm build",
"release": "release-it -VV --dry-run --ci",
"release:ci": "release-it --ci"
},
"devDependencies": {
"@release-it/conventional-changelog": "^10.0.6",
"@singleton-sd/inkads-epaper-renderer": "1.4.1",
"conventional-changelog-conventionalcommits": "^8.0.0",
"qrcode": "1.5.4",
"release-it": "^19.2.4",
Expand Down
23 changes: 23 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Single-package repo; pnpm 11 settings live here (not package.json).
allowBuilds:
sharp: true
minimumReleaseAgeExclude:
- '@singleton-sd/inkads-epaper-renderer@1.4.1'
6 changes: 6 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
import toIco from "to-ico";
import { renderHouseAd } from "./render-house-ad.mjs";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const DIST = join(ROOT, "dist");
Expand Down Expand Up @@ -190,11 +191,15 @@ async function writeCatalogHtml() {
<tr><td>OG (dark)</td><td><code>${base}/og-image/dark/og-default.png</code></td></tr>
<tr><td>Nav icon SVG</td><td><code>${base}/svg/icon/icon-dark.svg</code></td></tr>
<tr><td>Nav lockup SVG</td><td><code>${base}/logo/wordmark/lockup-horizontal/dark/dark.svg</code></td></tr>
<tr><td>House-ad preview</td><td><code>${base}/marketing/house-ad/preview.png</code></td></tr>
<tr><td>House-ad framebuffer</td><td><code>${base}/marketing/house-ad/framebuffer.bin</code></td></tr>
<tr><td>House-ad metadata</td><td><code>${base}/marketing/house-ad/metadata.json</code></td></tr>
</table>

<h2>Previews</h2>
<p><img src="./svg/icon/icon-dark.svg" alt="icon dark" width="48" height="48" />
<img src="./logo/wordmark/lockup-horizontal/dark/dark.svg" alt="lockup dark" height="40" /></p>
<p><img src="./marketing/house-ad/preview.png" alt="house-ad 800x480 preview" width="400" height="240" /></p>

<h2>Legacy jsDelivr</h2>
<pre>https://cdn.jsdelivr.net/gh/singleton-sd/poc-inkads-assets@&lt;sha&gt;/svg/icon/icon-dark.svg</pre>
Expand All @@ -217,6 +222,7 @@ async function main() {
await buildWordmarks();
await copyOg();
await copyVectorsToDist();
await renderHouseAd({ outDir: join(DIST, "marketing/house-ad") });
await writeCatalogHtml();
const product = JSON.parse(
await readFile(join(ROOT, "config/product.json"), "utf8"),
Expand Down
121 changes: 121 additions & 0 deletions scripts/render-house-ad.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
#!/usr/bin/env node
/**
* Rasterize house-ad SVG → Waveshare 7.5″ B/W package under dist/marketing/house-ad/.
*
* Uses @singleton-sd/inkads-epaper-renderer (threshold mode for QR/text/logos).
* Out of scope: firmware / device serving.
*/
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
RENDERER_VERSION,
packMonoBitmap,
renderMono,
toPreviewImage,
waveshare75BwProfile as profile,
} from "@singleton-sd/inkads-epaper-renderer";
import {
encodePreviewPng,
ingestImageToProfile,
} from "@singleton-sd/inkads-epaper-renderer/node";
import sharp from "sharp";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const SRC_DIR = join(ROOT, "src/marketing/house-ad");
const DEFAULT_OUT = join(ROOT, "dist/marketing/house-ad");

const MODE = "threshold";
const WIDTH = 800;
const HEIGHT = 480;
const QR_SIZE = 260;
const QR_LEFT = 476;
const QR_TOP = 110;

/**
* Sharp/librsvg does not reliably resolve relative &lt;image href&gt; from a
* buffer, so we rasterize the master without the QR image and composite the
* QR PNG at the design coordinates.
*/
async function rasterizeHouseAdPng() {
const masterSvg = await readFile(join(SRC_DIR, "house-ad-800x480.svg"), "utf8");
const qrSvg = await readFile(join(SRC_DIR, "qr-go.svg"));

const withoutQr = masterSvg.replace(
/<image\b[^>]*\bhref=["']qr-go\.svg["'][^>]*\/>/i,
"",
);

const basePng = await sharp(Buffer.from(withoutQr, "utf8"))
.resize(WIDTH, HEIGHT, { fit: "fill" })
.png()
.toBuffer();

const qrPng = await sharp(qrSvg)
.resize(QR_SIZE, QR_SIZE, { fit: "fill" })
.png()
.toBuffer();

return sharp(basePng)
.composite([{ input: qrPng, left: QR_LEFT, top: QR_TOP }])
.png()
.toBuffer();
}

export async function renderHouseAd({ outDir = DEFAULT_OUT } = {}) {
const pngBytes = await rasterizeHouseAdPng();
const rgb = ingestImageToProfile(pngBytes, { profile });
const bitmap = renderMono(rgb, { mode: MODE });
const packed = packMonoBitmap(bitmap, { profile });
const preview = toPreviewImage(packed, profile);
const previewPng = encodePreviewPng(preview);

await mkdir(outDir, { recursive: true });

const framebufferPath = join(outDir, "framebuffer.bin");
const previewPath = join(outDir, "preview.png");
const metadataPath = join(outDir, "metadata.json");

await writeFile(framebufferPath, packed.bytes);
await writeFile(previewPath, previewPng);

const metadata = {
...packed.metadata,
mode: MODE,
rendererVersion: packed.metadata.rendererVersion ?? RENDERER_VERSION,
source: "src/marketing/house-ad/house-ad-800x480.svg",
generatedAt: new Date().toISOString(),
};
await writeFile(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);

// Publish editable sources next to the package for CDN consumers.
await writeFile(
join(outDir, "house-ad-800x480.svg"),
await readFile(join(SRC_DIR, "house-ad-800x480.svg")),
);
await writeFile(join(outDir, "qr-go.svg"), await readFile(join(SRC_DIR, "qr-go.svg")));

return {
outDir,
byteLength: packed.bytes.length,
checksum: packed.metadata.checksum,
profileId: packed.metadata.profileId,
mode: MODE,
rendererVersion: metadata.rendererVersion,
paths: {
framebuffer: framebufferPath,
preview: previewPath,
metadata: metadataPath,
},
};
}

const isMain =
process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];

if (isMain) {
const result = await renderHouseAd();
console.log(
`house-ad render ok -> ${result.outDir} (${result.byteLength} bytes, ${result.profileId}, checksum ${result.checksum})`,
);
}
38 changes: 38 additions & 0 deletions scripts/render-house-ad.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { renderHouseAd } from "./render-house-ad.mjs";

test("renderHouseAd produces Waveshare 7.5 B/W package", async () => {
const outDir = await mkdtemp(join(tmpdir(), "inkads-house-ad-"));
try {
const result = await renderHouseAd({ outDir });

assert.equal(result.byteLength, 48_000);
assert.equal(result.profileId, "waveshare-7.5-bw");
assert.equal(result.mode, "threshold");
assert.ok(result.checksum && /^[0-9a-f]+$/i.test(result.checksum));

const packed = await readFile(join(outDir, "framebuffer.bin"));
assert.equal(packed.length, 48_000);

const metadata = JSON.parse(
await readFile(join(outDir, "metadata.json"), "utf8"),
);
assert.equal(metadata.profileId, "waveshare-7.5-bw");
assert.equal(metadata.byteLength, 48_000);
assert.ok(metadata.checksum);
assert.equal(metadata.checksum, result.checksum);
assert.equal(metadata.mode, "threshold");
assert.ok(metadata.rendererVersion);

const preview = await readFile(join(outDir, "preview.png"));
assert.ok(preview.length > 100);
assert.equal(preview[0], 0x89);
assert.equal(preview[1], 0x50); // P
} finally {
await rm(outDir, { recursive: true, force: true });
}
});
45 changes: 38 additions & 7 deletions src/marketing/house-ad/README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
# House ad (800×480 B/W)
# House ad (800x480 B/W)

Editable source for the InkAds **default / fallback** creative shown on
Waveshare 7.5″ B/W panels when no campaign creative is scheduled
Waveshare 7.5" B/W panels when no campaign creative is scheduled
(POC-260 / ClickUp track — assets only; no firmware in this repo).

## Composition

| File | Role |
| --- | --- |
| `house-ad-800x480.svg` | Master composition (viewBox `0 0 800 480`) |
| `qr-go.svg` | Generated QR modules (260×260, ≥4-module quiet zone) |
| `qr-go.svg` | Generated QR modules (260x260, >=4-module quiet zone) |

Layout (left → right):
Layout (left -> right):

1. Mono brand mark (from `src/logo/sources/mono.svg`), **InkAds** wordmark, **BY SINGLETON SD**
2. Concise CTA: “Advertise here” / “Put your brand on display” / “Scan to learn more”
2. Concise CTA: "Advertise here" / "Put your brand on display" / "Scan to learn more"
3. Large QR in a framed quiet panel (~260px)

Colors are **pure black / white** only for reliable 1-bit e-paper thresholding.
Expand Down Expand Up @@ -42,9 +42,40 @@ pnpm generate:house-ad-qr
Script: `scripts/generate-house-ad-qr.mjs` (devDependency `qrcode`). Writes
`qr-go.svg` only; the master SVG references it via relative `<image href="qr-go.svg">`.

## Render / publish (Waveshare package)

```sh
pnpm render:house-ad
# or as part of the full catalog:
pnpm build
```

Uses `@singleton-sd/inkads-epaper-renderer@1.4.1` in **threshold** mode (preferred for
QR / text / logos). Outputs under `dist/marketing/house-ad/` (do not hand-edit):

| File | Description |
| --- | --- |
| `preview.png` | 800x480 1-bit preview of the packed buffer |
| `framebuffer.bin` | 48_000-byte packed Waveshare buffer |
| `metadata.json` | profileId, checksum, mode, rendererVersion, … |
| `house-ad-800x480.svg` / `qr-go.svg` | Published source copies |

### Consumer CDN URLs

Base: `https://assets.inkads.poc.singletonsd.com`

| Asset | URL |
| --- | --- |
| Preview | `…/marketing/house-ad/preview.png` |
| Framebuffer | `…/marketing/house-ad/framebuffer.bin` |
| Metadata | `…/marketing/house-ad/metadata.json` |
| Master SVG | `…/marketing/house-ad/house-ad-800x480.svg` |

Firmware / device serving is out of scope for POC-260 (see other tracks).

## Venue distance / readability

- Designed for ~1–2 m hospitality viewing (lobby / corridor / F&B, not bathroom-only).
- CTA type ≥20 px at 800×480; primary headline ~36 px.
- QR ≥220 px with margin 4 modules — prefer threshold mono render (issue #10), not dither, so modules stay crisp.
- CTA type >=20 px at 800x480; primary headline ~36 px.
- QR >=220 px with margin 4 modules — threshold mono render keeps modules crisp.
- Keep the framed white quiet zone; do not crop into the QR when compositing.
Loading