@tomako/tools-runtime 0.1.6 → 0.1.7
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/package.json +1 -1
- package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx +44 -10
- package/src/features/tools/app-store-screenshot-generator/widget/asset-slots.test.ts +52 -0
- package/src/features/tools/app-store-screenshot-generator/widget/asset-slots.ts +3 -2
- package/src/features/tools/app-store-screenshot-generator/widget/input-media.test.ts +81 -0
- package/src/features/tools/app-store-screenshot-generator/widget/input-media.ts +20 -8
package/package.json
CHANGED
package/src/features/tools/app-store-screenshot-generator/widget/app-store-screenshot-generator.tsx
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
buildGeneratedAssetSlots,
|
|
45
45
|
clampAssetCount,
|
|
46
46
|
dimensionsForAssetFormat,
|
|
47
|
-
|
|
47
|
+
gridClassForAssetFormat,
|
|
48
48
|
} from "./asset-slots";
|
|
49
49
|
import {
|
|
50
50
|
LOGO_REFERENCE_MAX_DATA_URL_LENGTH,
|
|
@@ -160,9 +160,12 @@ export function AppStoreScreenshotGenerator() {
|
|
|
160
160
|
() => downloadableAssets.filter((asset) => downloadSelectionIds.has(asset.id)),
|
|
161
161
|
[downloadSelectionIds, downloadableAssets],
|
|
162
162
|
);
|
|
163
|
+
const soleDownloadableAsset = downloadableAssets.length === 1 ? downloadableAssets[0] : null;
|
|
163
164
|
const downloadButtonLabel = downloadSelectionMode
|
|
164
165
|
? `${t("download")} ${selectedDownloadAssets.length}/${downloadableAssets.length}`
|
|
165
|
-
:
|
|
166
|
+
: soleDownloadableAsset
|
|
167
|
+
? t("downloadPng")
|
|
168
|
+
: t("download");
|
|
166
169
|
|
|
167
170
|
async function submit(event: FormEvent<HTMLFormElement>) {
|
|
168
171
|
event.preventDefault();
|
|
@@ -489,6 +492,14 @@ export function AppStoreScreenshotGenerator() {
|
|
|
489
492
|
if (!downloadableAssets.length || downloading) return;
|
|
490
493
|
setDownloadError(null);
|
|
491
494
|
|
|
495
|
+
if (soleDownloadableAsset) {
|
|
496
|
+
await downloadAssets([soleDownloadableAsset], {
|
|
497
|
+
forceZip: false,
|
|
498
|
+
zipName: buildScreenshotsZipName(draft, "selected"),
|
|
499
|
+
});
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
492
503
|
if (!downloadSelectionMode) {
|
|
493
504
|
setDownloadSelectionMode(true);
|
|
494
505
|
setDownloadSelectionIds(
|
|
@@ -521,6 +532,15 @@ export function AppStoreScreenshotGenerator() {
|
|
|
521
532
|
});
|
|
522
533
|
}
|
|
523
534
|
|
|
535
|
+
function handleDownloadSvgClick(asset: AppStoreScreenshotAsset) {
|
|
536
|
+
if (!asset.svg || downloading) return;
|
|
537
|
+
setDownloadError(null);
|
|
538
|
+
downloadBlob(
|
|
539
|
+
new Blob([asset.svg], { type: "image/svg+xml;charset=utf-8" }),
|
|
540
|
+
`${assetFileBase(draft, asset)}.svg`,
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
|
|
524
544
|
function cancelDownloadSelection() {
|
|
525
545
|
setDownloadSelectionMode(false);
|
|
526
546
|
setDownloadSelectionIds(new Set());
|
|
@@ -998,6 +1018,18 @@ export function AppStoreScreenshotGenerator() {
|
|
|
998
1018
|
{downloading ? t("downloading") : downloadButtonLabel}
|
|
999
1019
|
</span>
|
|
1000
1020
|
</Button>
|
|
1021
|
+
{soleDownloadableAsset?.svg ? (
|
|
1022
|
+
<Button
|
|
1023
|
+
type="button"
|
|
1024
|
+
variant="outline"
|
|
1025
|
+
onClick={() => handleDownloadSvgClick(soleDownloadableAsset)}
|
|
1026
|
+
disabled={downloading}
|
|
1027
|
+
className="h-10 rounded-lg border-[#D8D4CA] bg-white px-4 text-xs font-semibold text-[#111111] shadow-none hover:bg-[#F4F4F4] disabled:pointer-events-none disabled:opacity-55"
|
|
1028
|
+
>
|
|
1029
|
+
<Download aria-hidden className="size-4" />
|
|
1030
|
+
{t("downloadSvg")}
|
|
1031
|
+
</Button>
|
|
1032
|
+
) : null}
|
|
1001
1033
|
{downloadSelectionMode ? (
|
|
1002
1034
|
<Button
|
|
1003
1035
|
type="button"
|
|
@@ -1095,7 +1127,13 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1095
1127
|
</div>
|
|
1096
1128
|
</div>
|
|
1097
1129
|
) : null}
|
|
1098
|
-
<div
|
|
1130
|
+
<div
|
|
1131
|
+
className={`grid items-start gap-4 ${
|
|
1132
|
+
resultSlots.length === 1
|
|
1133
|
+
? "grid-cols-1"
|
|
1134
|
+
: "grid-cols-1 sm:grid-cols-2 xl:grid-cols-3"
|
|
1135
|
+
}`}
|
|
1136
|
+
>
|
|
1099
1137
|
{resultSlots.map(({ asset, format, slot }) => {
|
|
1100
1138
|
if (!asset) {
|
|
1101
1139
|
const dimensions = dimensionsForAssetFormat(format);
|
|
@@ -1104,11 +1142,8 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1104
1142
|
key={`pending-${slot}`}
|
|
1105
1143
|
aria-label={t("pendingImageLoading", { index: slot })}
|
|
1106
1144
|
role="status"
|
|
1107
|
-
style={{
|
|
1108
|
-
|
|
1109
|
-
width: widthForAssetFormat(format),
|
|
1110
|
-
}}
|
|
1111
|
-
className="flex max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)]"
|
|
1145
|
+
style={{ aspectRatio: `${dimensions.width} / ${dimensions.height}` }}
|
|
1146
|
+
className={`flex w-full max-w-full flex-col items-center justify-center rounded-lg border border-dashed border-[#D8D4CA] bg-white p-4 text-center shadow-[0_8px_22px_rgba(17,17,17,0.04)] ${gridClassForAssetFormat(format, resultSlots.length === 1)}`}
|
|
1112
1147
|
>
|
|
1113
1148
|
<span className="flex size-10 items-center justify-center rounded-lg bg-[#F4F4F4] text-[#111111]">
|
|
1114
1149
|
<Loader2 aria-hidden className="size-4 animate-spin" />
|
|
@@ -1130,8 +1165,7 @@ export function AppStoreScreenshotGenerator() {
|
|
|
1130
1165
|
return (
|
|
1131
1166
|
<div
|
|
1132
1167
|
key={asset.id}
|
|
1133
|
-
|
|
1134
|
-
className="relative max-w-full"
|
|
1168
|
+
className={`relative w-full max-w-full ${gridClassForAssetFormat(asset.format, resultSlots.length === 1)}`}
|
|
1135
1169
|
>
|
|
1136
1170
|
<button
|
|
1137
1171
|
type="button"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import type { AppStoreScreenshotAsset } from "../../../../lib/tools/app-store-screenshot-generator-schema";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildGeneratedAssetSlots,
|
|
8
|
+
gridClassForAssetFormat,
|
|
9
|
+
} from "./asset-slots";
|
|
10
|
+
|
|
11
|
+
function asset(overrides: Partial<AppStoreScreenshotAsset>): AppStoreScreenshotAsset {
|
|
12
|
+
return {
|
|
13
|
+
id: "asset-1",
|
|
14
|
+
platform: "google",
|
|
15
|
+
format: "googlePhone",
|
|
16
|
+
width: 1080,
|
|
17
|
+
height: 1920,
|
|
18
|
+
imageUrl: "https://example.com/generated.png",
|
|
19
|
+
...overrides,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("app store screenshot result slots", () => {
|
|
24
|
+
it("keeps completed and pending Google assets in the same ordered slot model", () => {
|
|
25
|
+
const slots = buildGeneratedAssetSlots({
|
|
26
|
+
assets: [asset({ id: "phone-2", slot: 2 })],
|
|
27
|
+
expectedCount: 3,
|
|
28
|
+
includePending: true,
|
|
29
|
+
platform: "google",
|
|
30
|
+
totalCount: 3,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
assert.deepEqual(
|
|
34
|
+
slots.map(({ asset: result, format, slot }) => ({ id: result?.id, format, slot })),
|
|
35
|
+
[
|
|
36
|
+
{ id: undefined, format: "featureGraphic", slot: 1 },
|
|
37
|
+
{ id: "phone-2", format: "googlePhone", slot: 2 },
|
|
38
|
+
{ id: undefined, format: "googlePhone", slot: 3 },
|
|
39
|
+
],
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("lets feature graphics span the responsive result grid", () => {
|
|
44
|
+
assert.equal(gridClassForAssetFormat("featureGraphic"), "sm:col-span-2 xl:col-span-3");
|
|
45
|
+
assert.equal(gridClassForAssetFormat("iphone"), "");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("centers a sole result at an inspectable width without creating implicit grid columns", () => {
|
|
49
|
+
assert.equal(gridClassForAssetFormat("featureGraphic", true), "mx-auto max-w-[340px]");
|
|
50
|
+
assert.equal(gridClassForAssetFormat("iphone", true), "mx-auto max-w-[340px]");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -54,8 +54,9 @@ export function sortGeneratedAssets(assets: AppStoreScreenshotAsset[]) {
|
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function
|
|
58
|
-
|
|
57
|
+
export function gridClassForAssetFormat(format: AssetFormat, isSoleResult = false) {
|
|
58
|
+
if (isSoleResult) return "mx-auto max-w-[340px]";
|
|
59
|
+
return format === "featureGraphic" ? "sm:col-span-2 xl:col-span-3" : "";
|
|
59
60
|
}
|
|
60
61
|
|
|
61
62
|
export function dimensionsForAssetFormat(format: AssetFormat) {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
AppStoreScreenshotAsset,
|
|
6
|
+
AppStoreScreenshotGeneratorInput,
|
|
7
|
+
} from "../../../../lib/tools/app-store-screenshot-generator-schema";
|
|
8
|
+
|
|
9
|
+
import { createGeneratedAssetZipEntries } from "./input-media";
|
|
10
|
+
|
|
11
|
+
const input = {
|
|
12
|
+
source: "upload",
|
|
13
|
+
productUrl: "",
|
|
14
|
+
hasScreenshot: true,
|
|
15
|
+
screenshotName: "source.png",
|
|
16
|
+
screenshotDataUrl: "",
|
|
17
|
+
screenshotReferences: [],
|
|
18
|
+
logoName: "",
|
|
19
|
+
logoDataUrl: "",
|
|
20
|
+
productName: "Tomako",
|
|
21
|
+
productBenefits: "",
|
|
22
|
+
assetCount: 1,
|
|
23
|
+
outputLanguage: "en",
|
|
24
|
+
platform: "apple",
|
|
25
|
+
style: "clean",
|
|
26
|
+
palette: "auto",
|
|
27
|
+
layout: "mixed",
|
|
28
|
+
headline: "",
|
|
29
|
+
subheadline: "",
|
|
30
|
+
} satisfies AppStoreScreenshotGeneratorInput;
|
|
31
|
+
|
|
32
|
+
describe("app store screenshot ZIP entries", () => {
|
|
33
|
+
it("includes the PNG and existing SVG wrapper for each generated asset", async () => {
|
|
34
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg"><metadata>${"prompt ".repeat(20)}</metadata></svg>`;
|
|
35
|
+
const asset = {
|
|
36
|
+
id: "hero",
|
|
37
|
+
platform: "apple",
|
|
38
|
+
format: "iphone",
|
|
39
|
+
title: "Core benefit",
|
|
40
|
+
width: 1290,
|
|
41
|
+
height: 2796,
|
|
42
|
+
imageUrl: "https://example.com/hero.png",
|
|
43
|
+
svg,
|
|
44
|
+
} satisfies AppStoreScreenshotAsset;
|
|
45
|
+
|
|
46
|
+
const files = await createGeneratedAssetZipEntries(
|
|
47
|
+
[asset],
|
|
48
|
+
input,
|
|
49
|
+
async () => new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
assert.deepEqual(
|
|
53
|
+
files.map((file) => file.path),
|
|
54
|
+
[
|
|
55
|
+
"01-tomako-apple-iphone-core-benefit.png",
|
|
56
|
+
"01-tomako-apple-iphone-core-benefit.svg",
|
|
57
|
+
],
|
|
58
|
+
);
|
|
59
|
+
assert.deepEqual([...files[0]!.data], [137, 80, 78, 71]);
|
|
60
|
+
assert.equal(new TextDecoder().decode(files[1]!.data), svg);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not advertise a missing SVG by adding an empty file", async () => {
|
|
64
|
+
const asset = {
|
|
65
|
+
id: "hero",
|
|
66
|
+
platform: "apple",
|
|
67
|
+
format: "iphone",
|
|
68
|
+
width: 1290,
|
|
69
|
+
height: 2796,
|
|
70
|
+
imageUrl: "https://example.com/hero.png",
|
|
71
|
+
} satisfies AppStoreScreenshotAsset;
|
|
72
|
+
|
|
73
|
+
const files = await createGeneratedAssetZipEntries(
|
|
74
|
+
[asset],
|
|
75
|
+
input,
|
|
76
|
+
async () => new Blob([new Uint8Array([1])], { type: "image/png" }),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
assert.deepEqual(files.map((file) => file.path), ["01-tomako-apple-iphone-hero.png"]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
|
|
6
6
|
import { renderAssetToPngBlob } from "./generation";
|
|
7
7
|
import type { SourceKey } from "./types";
|
|
8
|
-
import { createZipBlob, slugForFileName, type ZipFileInput } from "./zip";
|
|
8
|
+
import { createZipBlob, encodeText, slugForFileName, type ZipFileInput } from "./zip";
|
|
9
9
|
|
|
10
10
|
export function inferSource(input: AppStoreScreenshotGeneratorInput): SourceKey {
|
|
11
11
|
const hasUrl = input.productUrl.trim().length > 0;
|
|
@@ -137,22 +137,34 @@ export async function createGeneratedAssetsZip(
|
|
|
137
137
|
assets: readonly AppStoreScreenshotAsset[],
|
|
138
138
|
input: AppStoreScreenshotGeneratorInput,
|
|
139
139
|
): Promise<Blob> {
|
|
140
|
+
return createZipBlob(await createGeneratedAssetZipEntries(assets, input));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function createGeneratedAssetZipEntries(
|
|
144
|
+
assets: readonly AppStoreScreenshotAsset[],
|
|
145
|
+
input: AppStoreScreenshotGeneratorInput,
|
|
146
|
+
renderPng: (asset: AppStoreScreenshotAsset) => Promise<Blob> = renderAssetToPngBlob,
|
|
147
|
+
): Promise<ZipFileInput[]> {
|
|
140
148
|
const files: ZipFileInput[] = [];
|
|
141
149
|
const usedPaths = new Set<string>();
|
|
142
150
|
|
|
143
151
|
for (const [index, asset] of assets.entries()) {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
usedPaths,
|
|
147
|
-
`${String(index + 1).padStart(2, "0")}-${assetFileBase(input, asset)}-${slugForFileName(asset.title ?? asset.id)}.png`,
|
|
148
|
-
);
|
|
152
|
+
const fileStem = `${String(index + 1).padStart(2, "0")}-${assetFileBase(input, asset)}-${slugForFileName(asset.title ?? asset.id)}`;
|
|
153
|
+
const blob = await renderPng(asset);
|
|
149
154
|
files.push({
|
|
150
|
-
path,
|
|
155
|
+
path: uniqueZipPath(usedPaths, `${fileStem}.png`),
|
|
151
156
|
data: new Uint8Array(await blob.arrayBuffer()),
|
|
152
157
|
});
|
|
158
|
+
|
|
159
|
+
if (asset.svg) {
|
|
160
|
+
files.push({
|
|
161
|
+
path: uniqueZipPath(usedPaths, `${fileStem}.svg`),
|
|
162
|
+
data: encodeText(asset.svg),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
153
165
|
}
|
|
154
166
|
|
|
155
|
-
return
|
|
167
|
+
return files;
|
|
156
168
|
}
|
|
157
169
|
|
|
158
170
|
export function uniqueZipPath(usedPaths: Set<string>, path: string) {
|