@treasuryspatial/render-kit 0.1.1
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.
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/postcard.d.ts +20 -0
- package/dist/postcard.d.ts.map +1 -0
- package/dist/postcard.js +242 -0
- package/dist/render-api.d.ts +17 -0
- package/dist/render-api.d.ts.map +1 -0
- package/dist/render-api.js +28 -0
- package/dist/render-prompt.d.ts +416 -0
- package/dist/render-prompt.d.ts.map +1 -0
- package/dist/render-prompt.js +711 -0
- package/package.json +24 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAC;AAC7B,cAAc,iBAAiB,CAAC;AAChC,cAAc,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type PostcardFontKey = 'neusa' | 'nunito' | 'mono' | 'serif';
|
|
2
|
+
export type PostcardFontOption = {
|
|
3
|
+
key: PostcardFontKey;
|
|
4
|
+
label: string;
|
|
5
|
+
fontFamily: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const POSTCARD_FONT_OPTIONS: PostcardFontOption[];
|
|
8
|
+
export declare const DEFAULT_POSTCARD_FONT: PostcardFontKey;
|
|
9
|
+
export type PostcardText = {
|
|
10
|
+
to: string;
|
|
11
|
+
from: string;
|
|
12
|
+
greeting: string;
|
|
13
|
+
font: PostcardFontKey;
|
|
14
|
+
};
|
|
15
|
+
type DecorationOptions = {
|
|
16
|
+
postcard?: PostcardText | null;
|
|
17
|
+
};
|
|
18
|
+
export declare function decoratePostcardImage(imageDataUrl: string, options?: DecorationOptions): Promise<string>;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=postcard.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postcard.d.ts","sourceRoot":"","sources":["../src/postcard.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AAEpE,MAAM,MAAM,kBAAkB,GAAG;IAC/B,GAAG,EAAE,eAAe,CAAC;IACrB,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,eAAO,MAAM,qBAAqB,EAAE,kBAAkB,EAKrD,CAAC;AAEF,eAAO,MAAM,qBAAqB,EAAE,eAAyB,CAAC;AAE9D,MAAM,MAAM,YAAY,GAAG;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,eAAe,CAAC;CACvB,CAAC;AAEF,KAAK,iBAAiB,GAAG;IACvB,QAAQ,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;CAChC,CAAC;AA4HF,wBAAsB,qBAAqB,CACzC,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE,iBAAsB,GAC9B,OAAO,CAAC,MAAM,CAAC,CAsJjB"}
|
package/dist/postcard.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
export const POSTCARD_FONT_OPTIONS = [
|
|
2
|
+
{ key: 'neusa', label: 'neusa serif', fontFamily: "'Neusa', 'Times New Roman', serif" },
|
|
3
|
+
{ key: 'nunito', label: 'nunito sans', fontFamily: "'Nunito Sans', Arial, sans-serif" },
|
|
4
|
+
{ key: 'mono', label: 'typewriter mono', fontFamily: "ui-monospace, 'SFMono-Regular', Menlo, monospace" },
|
|
5
|
+
{ key: 'serif', label: 'classic serif', fontFamily: "'Times New Roman', Georgia, serif" },
|
|
6
|
+
];
|
|
7
|
+
export const DEFAULT_POSTCARD_FONT = 'neusa';
|
|
8
|
+
const BADGE_LEFT_SRC = '/treasury-spatial-composer-pill.png';
|
|
9
|
+
const BADGE_RIGHT_SRC = '/postcard/courtyard-urbanist.png';
|
|
10
|
+
const imageCache = new Map();
|
|
11
|
+
const boundsCache = new Map();
|
|
12
|
+
const loadImage = (src) => {
|
|
13
|
+
if (!imageCache.has(src)) {
|
|
14
|
+
const promise = new Promise((resolve, reject) => {
|
|
15
|
+
const img = new Image();
|
|
16
|
+
img.crossOrigin = 'anonymous';
|
|
17
|
+
img.onload = () => resolve(img);
|
|
18
|
+
img.onerror = () => {
|
|
19
|
+
imageCache.delete(src);
|
|
20
|
+
reject(new Error(`Failed to load image: ${src}`));
|
|
21
|
+
};
|
|
22
|
+
img.src = src;
|
|
23
|
+
});
|
|
24
|
+
imageCache.set(src, promise);
|
|
25
|
+
}
|
|
26
|
+
return imageCache.get(src);
|
|
27
|
+
};
|
|
28
|
+
const loadBadge = async (svgSrc, pngSrc) => {
|
|
29
|
+
try {
|
|
30
|
+
return await loadImage(svgSrc);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return await loadImage(pngSrc);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const getOpaqueBounds = async (image, cacheKey) => {
|
|
37
|
+
if (!boundsCache.has(cacheKey)) {
|
|
38
|
+
const promise = new Promise((resolve) => {
|
|
39
|
+
const width = image.naturalWidth || image.width;
|
|
40
|
+
const height = image.naturalHeight || image.height;
|
|
41
|
+
const canvas = document.createElement('canvas');
|
|
42
|
+
canvas.width = width;
|
|
43
|
+
canvas.height = height;
|
|
44
|
+
const ctx = canvas.getContext('2d');
|
|
45
|
+
if (!ctx || width === 0 || height === 0) {
|
|
46
|
+
resolve({ minX: 0, minY: 0, maxX: width, maxY: height });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
ctx.drawImage(image, 0, 0, width, height);
|
|
50
|
+
const data = ctx.getImageData(0, 0, width, height).data;
|
|
51
|
+
let minX = width;
|
|
52
|
+
let minY = height;
|
|
53
|
+
let maxX = 0;
|
|
54
|
+
let maxY = 0;
|
|
55
|
+
let found = false;
|
|
56
|
+
for (let y = 0; y < height; y += 1) {
|
|
57
|
+
for (let x = 0; x < width; x += 1) {
|
|
58
|
+
const alpha = data[(y * width + x) * 4 + 3];
|
|
59
|
+
if (alpha > 0) {
|
|
60
|
+
found = true;
|
|
61
|
+
if (x < minX)
|
|
62
|
+
minX = x;
|
|
63
|
+
if (y < minY)
|
|
64
|
+
minY = y;
|
|
65
|
+
if (x > maxX)
|
|
66
|
+
maxX = x;
|
|
67
|
+
if (y > maxY)
|
|
68
|
+
maxY = y;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!found) {
|
|
73
|
+
resolve({ minX: 0, minY: 0, maxX: width, maxY: height });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
resolve({ minX, minY, maxX: maxX + 1, maxY: maxY + 1 });
|
|
77
|
+
});
|
|
78
|
+
boundsCache.set(cacheKey, promise);
|
|
79
|
+
}
|
|
80
|
+
return boundsCache.get(cacheKey);
|
|
81
|
+
};
|
|
82
|
+
const loadImageNoCache = (src) => {
|
|
83
|
+
return new Promise((resolve, reject) => {
|
|
84
|
+
const img = new Image();
|
|
85
|
+
img.crossOrigin = 'anonymous';
|
|
86
|
+
img.onload = () => resolve(img);
|
|
87
|
+
img.onerror = () => reject(new Error(`Failed to load image: ${src}`));
|
|
88
|
+
img.src = src;
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
const scaleToFit = (width, height, maxWidth, maxHeight) => {
|
|
92
|
+
const scale = Math.min(maxWidth / width, maxHeight / height, 1);
|
|
93
|
+
return { width: Math.round(width * scale), height: Math.round(height * scale) };
|
|
94
|
+
};
|
|
95
|
+
// Badge scale is relative to the image, not the original asset size.
|
|
96
|
+
const BADGE_SCALE = 0.147;
|
|
97
|
+
const wrapText = (ctx, text, maxWidth) => {
|
|
98
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
99
|
+
const lines = [];
|
|
100
|
+
let line = '';
|
|
101
|
+
words.forEach((word) => {
|
|
102
|
+
const testLine = line ? `${line} ${word}` : word;
|
|
103
|
+
const metrics = ctx.measureText(testLine);
|
|
104
|
+
if (metrics.width > maxWidth && line) {
|
|
105
|
+
lines.push(line);
|
|
106
|
+
line = word;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
line = testLine;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
if (line)
|
|
113
|
+
lines.push(line);
|
|
114
|
+
return lines;
|
|
115
|
+
};
|
|
116
|
+
const resolveFontFamily = (key) => {
|
|
117
|
+
return POSTCARD_FONT_OPTIONS.find((option) => option.key === key)?.fontFamily || POSTCARD_FONT_OPTIONS[0].fontFamily;
|
|
118
|
+
};
|
|
119
|
+
export async function decoratePostcardImage(imageDataUrl, options = {}) {
|
|
120
|
+
if (typeof window === 'undefined' || !imageDataUrl)
|
|
121
|
+
return imageDataUrl;
|
|
122
|
+
try {
|
|
123
|
+
const [baseImage, leftBadge, rightBadge] = await Promise.all([
|
|
124
|
+
loadImageNoCache(imageDataUrl),
|
|
125
|
+
loadBadge(BADGE_LEFT_SRC, BADGE_LEFT_SRC.replace(/\.svg$/i, '.png')),
|
|
126
|
+
loadBadge(BADGE_RIGHT_SRC, BADGE_RIGHT_SRC.replace(/\.svg$/i, '.png')),
|
|
127
|
+
]);
|
|
128
|
+
const canvas = document.createElement('canvas');
|
|
129
|
+
canvas.width = baseImage.width;
|
|
130
|
+
canvas.height = baseImage.height;
|
|
131
|
+
const ctx = canvas.getContext('2d');
|
|
132
|
+
if (!ctx)
|
|
133
|
+
return imageDataUrl;
|
|
134
|
+
ctx.drawImage(baseImage, 0, 0, canvas.width, canvas.height);
|
|
135
|
+
const pad = Math.round(Math.min(canvas.width, canvas.height) * 0.02);
|
|
136
|
+
const badgeMaxWidth = canvas.width * BADGE_SCALE;
|
|
137
|
+
const badgeMaxHeight = canvas.height * BADGE_SCALE;
|
|
138
|
+
const leftSize = scaleToFit(leftBadge.width, leftBadge.height, badgeMaxWidth, badgeMaxHeight);
|
|
139
|
+
const rightSize = scaleToFit(rightBadge.width, rightBadge.height, badgeMaxWidth, badgeMaxHeight);
|
|
140
|
+
const [leftBounds, rightBounds] = await Promise.all([
|
|
141
|
+
getOpaqueBounds(leftBadge, BADGE_LEFT_SRC),
|
|
142
|
+
getOpaqueBounds(rightBadge, BADGE_RIGHT_SRC),
|
|
143
|
+
]);
|
|
144
|
+
const baselineY = canvas.height - pad;
|
|
145
|
+
const leftScaleX = leftSize.width / leftBadge.width;
|
|
146
|
+
const leftScaleY = leftSize.height / leftBadge.height;
|
|
147
|
+
const rightScaleX = rightSize.width / rightBadge.width;
|
|
148
|
+
const rightScaleY = rightSize.height / rightBadge.height;
|
|
149
|
+
const leftX = pad - leftBounds.minX * leftScaleX;
|
|
150
|
+
const leftY = baselineY - leftBounds.maxY * leftScaleY;
|
|
151
|
+
const rightX = canvas.width - pad - rightBounds.maxX * rightScaleX;
|
|
152
|
+
const rightY = baselineY - rightBounds.maxY * rightScaleY;
|
|
153
|
+
ctx.drawImage(leftBadge, leftX, leftY, leftSize.width, leftSize.height);
|
|
154
|
+
ctx.drawImage(rightBadge, rightX, rightY, rightSize.width, rightSize.height);
|
|
155
|
+
if (options.postcard) {
|
|
156
|
+
const { to, from, greeting, font } = options.postcard;
|
|
157
|
+
const fontFamily = resolveFontFamily(font);
|
|
158
|
+
const tabWidth = Math.round(canvas.width * 0.33);
|
|
159
|
+
const tabHeight = Math.round(canvas.height * 0.26);
|
|
160
|
+
const tabX = 0;
|
|
161
|
+
const tabY = 0;
|
|
162
|
+
const tabRadius = Math.round(tabHeight * 0.18);
|
|
163
|
+
const tabPadding = Math.round(tabHeight * 0.12);
|
|
164
|
+
const leftColWidth = Math.round(tabWidth * 0.33);
|
|
165
|
+
const rightColWidth = tabWidth - leftColWidth;
|
|
166
|
+
const availableHeight = Math.max(0, tabHeight - tabPadding * 2);
|
|
167
|
+
const labelFontSize = Math.max(10, Math.round(Math.min(tabHeight * 0.16, availableHeight / 2.4)));
|
|
168
|
+
const lineGap = Math.round(labelFontSize * 0.4);
|
|
169
|
+
const baseGreetingFont = Math.max(12, Math.round(tabHeight * 0.24));
|
|
170
|
+
if (document.fonts?.load) {
|
|
171
|
+
try {
|
|
172
|
+
await document.fonts.load(`${labelFontSize}px ${fontFamily}`);
|
|
173
|
+
await document.fonts.load(`${baseGreetingFont}px ${fontFamily}`);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
// ignore font loading failures
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
ctx.save();
|
|
180
|
+
ctx.textBaseline = 'top';
|
|
181
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.65)';
|
|
182
|
+
ctx.beginPath();
|
|
183
|
+
ctx.moveTo(tabX, tabY);
|
|
184
|
+
ctx.lineTo(tabX + tabWidth, tabY);
|
|
185
|
+
ctx.lineTo(tabX + tabWidth, tabY + tabHeight - tabRadius);
|
|
186
|
+
ctx.quadraticCurveTo(tabX + tabWidth, tabY + tabHeight, tabX + tabWidth - tabRadius, tabY + tabHeight);
|
|
187
|
+
ctx.lineTo(tabX, tabY + tabHeight);
|
|
188
|
+
ctx.closePath();
|
|
189
|
+
ctx.fill();
|
|
190
|
+
const toText = (to || '').trim();
|
|
191
|
+
const fromText = (from || '').trim();
|
|
192
|
+
const hasRecipients = Boolean(toText || fromText);
|
|
193
|
+
if (hasRecipients) {
|
|
194
|
+
ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
|
|
195
|
+
ctx.font = `${labelFontSize}px ${fontFamily}`;
|
|
196
|
+
const leftX = tabX + tabPadding;
|
|
197
|
+
const leftBlockHeight = labelFontSize * 2 + lineGap;
|
|
198
|
+
const leftTop = tabY + Math.round((tabHeight - leftBlockHeight) / 2);
|
|
199
|
+
let leftY = leftTop;
|
|
200
|
+
ctx.fillText(`to: ${toText}`, leftX, leftY);
|
|
201
|
+
leftY += labelFontSize + lineGap;
|
|
202
|
+
ctx.fillText(`from: ${fromText}`, leftX, leftY);
|
|
203
|
+
}
|
|
204
|
+
const greetingX = tabX + (hasRecipients ? leftColWidth : 0) + tabPadding;
|
|
205
|
+
const greetingWidth = (hasRecipients ? rightColWidth : tabWidth) - tabPadding * 2;
|
|
206
|
+
const computeGreeting = (fontSize) => {
|
|
207
|
+
ctx.font = `${fontSize}px ${fontFamily}`;
|
|
208
|
+
const lines = greeting ? wrapText(ctx, greeting, greetingWidth) : [];
|
|
209
|
+
const capped = lines.slice(0, 4);
|
|
210
|
+
const gap = Math.round(fontSize * 0.28);
|
|
211
|
+
const height = capped.length > 0 ? capped.length * fontSize + (capped.length - 1) * gap : 0;
|
|
212
|
+
return { lines: capped, gap, height, fontSize };
|
|
213
|
+
};
|
|
214
|
+
let greetingLayout = computeGreeting(baseGreetingFont);
|
|
215
|
+
if (greetingLayout.height > availableHeight && greetingLayout.height > 0) {
|
|
216
|
+
const scale = availableHeight / greetingLayout.height;
|
|
217
|
+
const nextFont = Math.max(10, Math.round(baseGreetingFont * scale));
|
|
218
|
+
greetingLayout = computeGreeting(nextFont);
|
|
219
|
+
}
|
|
220
|
+
while (greetingLayout.height > availableHeight && greetingLayout.lines.length > 1) {
|
|
221
|
+
greetingLayout.lines.pop();
|
|
222
|
+
greetingLayout.height =
|
|
223
|
+
greetingLayout.lines.length * greetingLayout.fontSize +
|
|
224
|
+
Math.max(0, greetingLayout.lines.length - 1) * greetingLayout.gap;
|
|
225
|
+
}
|
|
226
|
+
const greetingTop = tabY + Math.round((tabHeight - greetingLayout.height) / 2);
|
|
227
|
+
let greetingY = greetingTop;
|
|
228
|
+
ctx.font = `${greetingLayout.fontSize}px ${fontFamily}`;
|
|
229
|
+
ctx.fillStyle = 'rgba(15, 23, 42, 0.95)';
|
|
230
|
+
greetingLayout.lines.forEach((line) => {
|
|
231
|
+
ctx.fillText(line, greetingX, greetingY);
|
|
232
|
+
greetingY += greetingLayout.fontSize + greetingLayout.gap;
|
|
233
|
+
});
|
|
234
|
+
ctx.restore();
|
|
235
|
+
}
|
|
236
|
+
return canvas.toDataURL('image/png');
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
console.warn('[postcard] Failed to decorate image', error);
|
|
240
|
+
return imageDataUrl;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type RenderRequestKind = 'render' | 'adapt';
|
|
2
|
+
export type RenderRequestPayload = {
|
|
3
|
+
prompt: string;
|
|
4
|
+
image?: string;
|
|
5
|
+
referenceImages?: string[];
|
|
6
|
+
requestKind: RenderRequestKind;
|
|
7
|
+
previousInteractionId?: string;
|
|
8
|
+
meta?: Record<string, any>;
|
|
9
|
+
aspectRatio?: string;
|
|
10
|
+
imageSize?: '1K' | '2K' | '4K';
|
|
11
|
+
};
|
|
12
|
+
export type RenderRequestResult = {
|
|
13
|
+
imageUrl: string;
|
|
14
|
+
interactionId?: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function renderRequest(payload: RenderRequestPayload): Promise<RenderRequestResult>;
|
|
17
|
+
//# sourceMappingURL=render-api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"render-api.d.ts","sourceRoot":"","sources":["../src/render-api.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,OAAO,CAAC;AAEnD,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,WAAW,EAAE,iBAAiB,CAAC;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAC3B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;CAChC,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAKF,wBAAsB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CA2B/F"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const DEFAULT_ASPECT_RATIO = '1:1';
|
|
2
|
+
const DEFAULT_IMAGE_SIZE = '2K';
|
|
3
|
+
export async function renderRequest(payload) {
|
|
4
|
+
const res = await fetch('/api/gemini/generate', {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
headers: { 'Content-Type': 'application/json' },
|
|
7
|
+
body: JSON.stringify({
|
|
8
|
+
...payload,
|
|
9
|
+
aspectRatio: payload.aspectRatio ?? DEFAULT_ASPECT_RATIO,
|
|
10
|
+
imageSize: payload.imageSize ?? DEFAULT_IMAGE_SIZE,
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
13
|
+
const text = await res.text();
|
|
14
|
+
let json = null;
|
|
15
|
+
try {
|
|
16
|
+
json = text ? JSON.parse(text) : null;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// ignore
|
|
20
|
+
}
|
|
21
|
+
if (!res.ok || !json?.success) {
|
|
22
|
+
throw new Error(json?.error || `Gemini failed (HTTP ${res.status})`);
|
|
23
|
+
}
|
|
24
|
+
const imageUrl = json.enhanced_image || json.imageUrl || '';
|
|
25
|
+
if (!imageUrl)
|
|
26
|
+
throw new Error('Gemini response missing image');
|
|
27
|
+
return { imageUrl, interactionId: json.interactionId };
|
|
28
|
+
}
|