@cardanvil/frame-kit 0.5.0 → 0.7.0
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/README.md +119 -0
- package/dist/authoring/collectorInfo.d.ts +17 -0
- package/dist/authoring/collectorInfo.js +62 -0
- package/dist/authoring/index.d.ts +2 -0
- package/dist/authoring/index.js +2 -0
- package/dist/authoring/layoutDefaults.d.ts +20 -2
- package/dist/authoring/layoutDefaults.js +48 -13
- package/dist/authoring/opacityStops.d.ts +8 -0
- package/dist/authoring/opacityStops.js +17 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +1 -0
- package/dist/cli/loadFrame.js +1 -1
- package/dist/cli/main.js +88 -0
- package/dist/cli/sourceWatcher.d.ts +40 -0
- package/dist/cli/sourceWatcher.js +100 -0
- package/dist/cli/studio/boxSets.d.ts +33 -0
- package/dist/cli/studio/boxSets.js +90 -0
- package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
- package/dist/cli/studio/edit/applyEdits.js +177 -0
- package/dist/cli/studio/edit/impact.d.ts +53 -0
- package/dist/cli/studio/edit/impact.js +87 -0
- package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
- package/dist/cli/studio/edit/resolveModule.js +55 -0
- package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
- package/dist/cli/studio/edit/resolvePath.js +606 -0
- package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
- package/dist/cli/studio/edit/sourceIndex.js +73 -0
- package/dist/cli/studio/edit/ts.d.ts +14 -0
- package/dist/cli/studio/edit/ts.js +34 -0
- package/dist/cli/studio/serve.d.ts +23 -0
- package/dist/cli/studio/serve.js +516 -0
- package/dist/cli/studio/session.d.ts +73 -0
- package/dist/cli/studio/session.js +154 -0
- package/dist/cli/trim.d.ts +62 -0
- package/dist/cli/trim.js +251 -0
- package/dist/cli/watch.d.ts +3 -0
- package/dist/cli/watch.js +20 -89
- package/dist/layout/assetPlacement.d.ts +71 -0
- package/dist/layout/assetPlacement.js +104 -0
- package/dist/layout/border.d.ts +27 -0
- package/dist/layout/border.js +31 -0
- package/dist/layout/boxOverrides.d.ts +28 -0
- package/dist/layout/boxOverrides.js +95 -0
- package/dist/layout/color.d.ts +23 -0
- package/dist/layout/color.js +53 -0
- package/dist/layout/cutouts.d.ts +28 -0
- package/dist/layout/cutouts.js +29 -0
- package/dist/layout/decorations.d.ts +73 -0
- package/dist/layout/decorations.js +104 -0
- package/dist/layout/frameLayers.d.ts +163 -0
- package/dist/layout/frameLayers.js +433 -0
- package/dist/layout/index.d.ts +27 -0
- package/dist/layout/index.js +27 -0
- package/dist/layout/paint.d.ts +36 -0
- package/dist/layout/paint.js +148 -0
- package/dist/layout/powerToughnessRow.d.ts +26 -0
- package/dist/layout/powerToughnessRow.js +13 -0
- package/dist/layout/resolveFrameAsset.d.ts +37 -0
- package/dist/layout/resolveFrameAsset.js +66 -0
- package/dist/layout/rulesSlide.d.ts +13 -0
- package/dist/layout/rulesSlide.js +33 -0
- package/dist/layout/settings.d.ts +26 -0
- package/dist/layout/settings.js +62 -0
- package/dist/layout/textStyle.d.ts +29 -0
- package/dist/layout/textStyle.js +28 -0
- package/dist/layout/turn.d.ts +54 -0
- package/dist/layout/turn.js +107 -0
- package/dist/manifest/contract.d.ts +6103 -9063
- package/dist/manifest/contract.js +48 -1
- package/dist/manifest/testFrame.js +9 -0
- package/dist/schema/collectorInfo.d.ts +95 -0
- package/dist/schema/collectorInfo.js +80 -0
- package/dist/schema/condition.d.ts +67 -0
- package/dist/schema/condition.js +47 -0
- package/dist/schema/frame.d.ts +17213 -24450
- package/dist/schema/frame.js +418 -14
- package/dist/schema/frameAssets.d.ts +56 -34
- package/dist/schema/frameAssets.js +96 -2
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.js +3 -0
- package/dist/schema/layout.d.ts +29 -4
- package/dist/schema/layout.js +27 -2
- package/dist/schema/paint.d.ts +202 -0
- package/dist/schema/paint.js +109 -0
- package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
- package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
- package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
- package/dist/studio/assets/index-BnhNwm_r.js +74 -0
- package/dist/studio/index.html +12 -0
- package/package.json +16 -5
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { Frame } from "../../schema/frame.js";
|
|
2
|
+
import { type DiscoveredFrame } from "../discover.js";
|
|
3
|
+
import { type FrameServer } from "../loadFrame.js";
|
|
4
|
+
import { type Problem } from "../report.js";
|
|
5
|
+
import { type LayoutSlot } from "./boxSets.js";
|
|
6
|
+
import { type SourceIndex } from "./edit/sourceIndex.js";
|
|
7
|
+
import { type TypeScriptApi } from "./edit/ts.js";
|
|
8
|
+
/** One box / asset / mask set present in a frame, addressed by logical path. */
|
|
9
|
+
export interface FrameSlot extends LayoutSlot {
|
|
10
|
+
/** `null` for `config.layouts`, else the `alternateLayouts` key. */
|
|
11
|
+
readonly variant: string | null;
|
|
12
|
+
readonly layout: string;
|
|
13
|
+
readonly path: readonly string[];
|
|
14
|
+
/** Which `CardBoxes` members this set actually defines. */
|
|
15
|
+
readonly boxKeys?: readonly string[];
|
|
16
|
+
}
|
|
17
|
+
export interface FramePayload {
|
|
18
|
+
readonly slug: string;
|
|
19
|
+
readonly id: string;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
/** Bumps on every successful reload, so the client can drop stale state. */
|
|
22
|
+
readonly revision: number;
|
|
23
|
+
readonly canvas: {
|
|
24
|
+
readonly width: number;
|
|
25
|
+
readonly height: number;
|
|
26
|
+
};
|
|
27
|
+
/** The validated frame, with asset URLs rewritten to studio endpoints. */
|
|
28
|
+
readonly frame: unknown;
|
|
29
|
+
readonly slots: readonly FrameSlot[];
|
|
30
|
+
/** Root-relative POSIX paths of every source file the frame was built from. */
|
|
31
|
+
readonly sourceFiles: readonly string[];
|
|
32
|
+
readonly problems: readonly Problem[];
|
|
33
|
+
/** True when this is the last good load and the newest one failed. */
|
|
34
|
+
readonly stale: boolean;
|
|
35
|
+
readonly staleProblems?: readonly Problem[];
|
|
36
|
+
}
|
|
37
|
+
export interface FrameSummary {
|
|
38
|
+
readonly slug: string;
|
|
39
|
+
readonly id: string;
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly stale: boolean;
|
|
42
|
+
}
|
|
43
|
+
interface Entry {
|
|
44
|
+
payload: FramePayload;
|
|
45
|
+
/** The frame before URL rewriting — what edits are verified against. */
|
|
46
|
+
raw: Frame;
|
|
47
|
+
discovered: DiscoveredFrame;
|
|
48
|
+
}
|
|
49
|
+
export interface StudioSession {
|
|
50
|
+
readonly root: string;
|
|
51
|
+
readonly index: SourceIndex;
|
|
52
|
+
readonly api: TypeScriptApi;
|
|
53
|
+
readonly frameServer: FrameServer;
|
|
54
|
+
list: () => FrameSummary[];
|
|
55
|
+
get: (slug: string) => Entry | undefined;
|
|
56
|
+
/** Absolute source files worth watching, across every loaded frame. */
|
|
57
|
+
watchDirs: () => string[];
|
|
58
|
+
reload: (only?: readonly string[]) => Promise<string[]>;
|
|
59
|
+
close: () => Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Owns the Vite server, the loaded frames, and the last-good cache.
|
|
63
|
+
*
|
|
64
|
+
* A frame that fails to load keeps its previous payload — an author mid-save
|
|
65
|
+
* produces a syntax error on almost every keystroke, and blanking the canvas
|
|
66
|
+
* each time would make the studio unusable. `loadFrames` already routes every
|
|
67
|
+
* failure into the reporter instead of throwing, so a failed pass simply
|
|
68
|
+
* yields no frame for that slug.
|
|
69
|
+
*/
|
|
70
|
+
export declare function startSession(root: string, only?: readonly string[]): Promise<StudioSession>;
|
|
71
|
+
/** Frames discoverable under `root`, for a startup message. */
|
|
72
|
+
export declare function countFrames(root: string): Promise<number>;
|
|
73
|
+
export {};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { walkAssets } from "../../manifest/walk.js";
|
|
3
|
+
import { setAtPath } from "../../manifest/walk.js";
|
|
4
|
+
import { DEFAULT_CANVAS_HEIGHT, DEFAULT_CANVAS_WIDTH, } from "../../schema/frame.js";
|
|
5
|
+
import { FrameSchema } from "../../schema/frame.js";
|
|
6
|
+
import { loadFrames } from "../api.js";
|
|
7
|
+
import { discoverFrames, relativePosix, } from "../discover.js";
|
|
8
|
+
import { invalidateFrameModules, startFrameServer, } from "../loadFrame.js";
|
|
9
|
+
import { Reporter } from "../report.js";
|
|
10
|
+
import { layoutSlots } from "./boxSets.js";
|
|
11
|
+
import { createSourceIndex } from "./edit/sourceIndex.js";
|
|
12
|
+
import { isUnderRoot, normalizeFile, } from "./edit/sourceIndex.js";
|
|
13
|
+
import { loadTypeScript } from "./edit/ts.js";
|
|
14
|
+
/** Every box/asset/mask set a loaded frame actually defines. */
|
|
15
|
+
function slotsFor(frame) {
|
|
16
|
+
const slots = layoutSlots();
|
|
17
|
+
const found = [];
|
|
18
|
+
const collect = (container, variant, prefix) => {
|
|
19
|
+
for (const [layout, config] of Object.entries(container ?? {})) {
|
|
20
|
+
if (!config || typeof config !== "object") {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const record = config;
|
|
24
|
+
for (const slot of slots) {
|
|
25
|
+
const value = record[slot.key];
|
|
26
|
+
if (!value || typeof value !== "object") {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
found.push({
|
|
30
|
+
...slot,
|
|
31
|
+
variant,
|
|
32
|
+
layout,
|
|
33
|
+
path: [...prefix, layout, slot.key],
|
|
34
|
+
...(slot.kind === "boxes" ? { boxKeys: Object.keys(value) } : {}),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const config = frame.config;
|
|
40
|
+
collect(config.layouts, null, ["config", "layouts"]);
|
|
41
|
+
for (const [variant, layouts] of Object.entries(config.alternateLayouts ?? {})) {
|
|
42
|
+
collect(layouts, variant, ["config", "alternateLayouts", variant]);
|
|
43
|
+
}
|
|
44
|
+
return found;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Rewrites every asset URL to the studio's own endpoint.
|
|
48
|
+
*
|
|
49
|
+
* Driven by `walkAssets` rather than a field list, so an asset field added to
|
|
50
|
+
* the contract is served without touching this.
|
|
51
|
+
*/
|
|
52
|
+
function rewriteAssets(frame) {
|
|
53
|
+
const plain = JSON.parse(JSON.stringify(frame));
|
|
54
|
+
for (const occurrence of walkAssets(FrameSchema, plain)) {
|
|
55
|
+
setAtPath(plain, occurrence.path, `/api/asset?u=${encodeURIComponent(occurrence.url)}`);
|
|
56
|
+
}
|
|
57
|
+
return plain;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Owns the Vite server, the loaded frames, and the last-good cache.
|
|
61
|
+
*
|
|
62
|
+
* A frame that fails to load keeps its previous payload — an author mid-save
|
|
63
|
+
* produces a syntax error on almost every keystroke, and blanking the canvas
|
|
64
|
+
* each time would make the studio unusable. `loadFrames` already routes every
|
|
65
|
+
* failure into the reporter instead of throwing, so a failed pass simply
|
|
66
|
+
* yields no frame for that slug.
|
|
67
|
+
*/
|
|
68
|
+
export async function startSession(root, only = []) {
|
|
69
|
+
const resolvedRoot = path.resolve(root);
|
|
70
|
+
const frameServer = await startFrameServer(resolvedRoot);
|
|
71
|
+
const api = await loadTypeScript();
|
|
72
|
+
const index = createSourceIndex(api);
|
|
73
|
+
const cache = new Map();
|
|
74
|
+
let revision = 0;
|
|
75
|
+
async function reload(slugs) {
|
|
76
|
+
invalidateFrameModules(frameServer);
|
|
77
|
+
const reporter = new Reporter();
|
|
78
|
+
const wanted = slugs ?? only;
|
|
79
|
+
const loaded = await loadFrames({ root: resolvedRoot, ...(wanted.length > 0 ? { only: wanted } : {}) }, reporter, frameServer);
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const sourceFiles = [
|
|
82
|
+
...frameServer.server.environments.ssr.moduleGraph.fileToModulesMap.keys(),
|
|
83
|
+
]
|
|
84
|
+
.filter((file) => isUnderRoot(file, resolvedRoot) && /\.[cm]?tsx?$/.test(file))
|
|
85
|
+
.map((file) => relativePosix(resolvedRoot, file))
|
|
86
|
+
.sort();
|
|
87
|
+
for (const item of loaded) {
|
|
88
|
+
const { discovered, frame } = item;
|
|
89
|
+
seen.add(discovered.slug);
|
|
90
|
+
revision += 1;
|
|
91
|
+
const canvas = frame.config.canvas ?? {
|
|
92
|
+
width: DEFAULT_CANVAS_WIDTH,
|
|
93
|
+
height: DEFAULT_CANVAS_HEIGHT,
|
|
94
|
+
};
|
|
95
|
+
cache.set(discovered.slug, {
|
|
96
|
+
raw: frame,
|
|
97
|
+
discovered,
|
|
98
|
+
payload: {
|
|
99
|
+
slug: discovered.slug,
|
|
100
|
+
id: discovered.meta.id,
|
|
101
|
+
name: frame.name,
|
|
102
|
+
revision,
|
|
103
|
+
canvas,
|
|
104
|
+
frame: rewriteAssets(frame),
|
|
105
|
+
slots: slotsFor(frame),
|
|
106
|
+
sourceFiles,
|
|
107
|
+
problems: reporter.problems.filter((problem) => problem.frame === discovered.slug),
|
|
108
|
+
stale: false,
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Anything asked for that did not come back kept its last good payload.
|
|
113
|
+
for (const [slug, entry] of cache) {
|
|
114
|
+
if (seen.has(slug) || (wanted.length > 0 && !wanted.includes(slug))) {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const problems = reporter.problems.filter((problem) => problem.frame === slug || problem.frame === undefined);
|
|
118
|
+
cache.set(slug, {
|
|
119
|
+
...entry,
|
|
120
|
+
payload: { ...entry.payload, stale: true, staleProblems: problems },
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return [...seen];
|
|
124
|
+
}
|
|
125
|
+
await reload();
|
|
126
|
+
return {
|
|
127
|
+
root: resolvedRoot,
|
|
128
|
+
index,
|
|
129
|
+
api,
|
|
130
|
+
frameServer,
|
|
131
|
+
list: () => [...cache.values()]
|
|
132
|
+
.map((entry) => ({
|
|
133
|
+
slug: entry.payload.slug,
|
|
134
|
+
id: entry.payload.id,
|
|
135
|
+
name: entry.payload.name,
|
|
136
|
+
stale: entry.payload.stale,
|
|
137
|
+
}))
|
|
138
|
+
.sort((a, b) => a.slug.localeCompare(b.slug)),
|
|
139
|
+
get: (slug) => cache.get(slug),
|
|
140
|
+
watchDirs: () => {
|
|
141
|
+
const dirs = new Set([resolvedRoot]);
|
|
142
|
+
for (const entry of cache.values()) {
|
|
143
|
+
dirs.add(normalizeFile(entry.discovered.dir));
|
|
144
|
+
}
|
|
145
|
+
return [...dirs];
|
|
146
|
+
},
|
|
147
|
+
reload,
|
|
148
|
+
close: () => frameServer.close(),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Frames discoverable under `root`, for a startup message. */
|
|
152
|
+
export async function countFrames(root) {
|
|
153
|
+
return (await discoverFrames(path.resolve(root), new Reporter())).length;
|
|
154
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** A rectangle in pixels. */
|
|
2
|
+
export interface Rect {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
width: number;
|
|
6
|
+
height: number;
|
|
7
|
+
}
|
|
8
|
+
/** Decoded pixels, four bytes (RGBA) a pixel, row after row. */
|
|
9
|
+
export interface Pixels {
|
|
10
|
+
width: number;
|
|
11
|
+
height: number;
|
|
12
|
+
data: Uint8Array;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The smallest rectangle holding every pixel that is not fully transparent,
|
|
16
|
+
* or `undefined` when the image has none.
|
|
17
|
+
*/
|
|
18
|
+
export declare function opaqueBounds({ width, height, data, }: Pixels): Rect | undefined;
|
|
19
|
+
/** The smallest rectangle holding all of `rects`, or `undefined` for none. */
|
|
20
|
+
export declare function unionBounds(rects: readonly Rect[]): Rect | undefined;
|
|
21
|
+
/** `pixels` cut down to `rect`, which must lie inside them. */
|
|
22
|
+
export declare function cropPixels(pixels: Pixels, rect: Rect): Pixels;
|
|
23
|
+
export interface TrimOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Cut every image to one rectangle: the smallest holding all of their
|
|
26
|
+
* visible pixels. They must all be the same size, and they then share one
|
|
27
|
+
* position on the sheet — which is what images drawn together, such as a
|
|
28
|
+
* banner and the masks cut from it, need.
|
|
29
|
+
*/
|
|
30
|
+
shared?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Where trimmed images are written, under their own names. Without it each
|
|
33
|
+
* one replaces its original.
|
|
34
|
+
*/
|
|
35
|
+
outDir?: string;
|
|
36
|
+
/** Work everything out but write nothing. */
|
|
37
|
+
dryRun?: boolean;
|
|
38
|
+
}
|
|
39
|
+
export interface TrimmedImage {
|
|
40
|
+
file: string;
|
|
41
|
+
/** Where it was written; `undefined` when nothing was. */
|
|
42
|
+
written: string | undefined;
|
|
43
|
+
/** Its size before trimming. */
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
/**
|
|
47
|
+
* The part kept, with `x` and `y` on the sheet. `undefined` when the image
|
|
48
|
+
* has no visible pixel, and is left as it was.
|
|
49
|
+
*/
|
|
50
|
+
kept: Rect | undefined;
|
|
51
|
+
}
|
|
52
|
+
export interface TrimResult {
|
|
53
|
+
images: TrimmedImage[];
|
|
54
|
+
/** With `shared`: the one rectangle every image was cut to, on the sheet. */
|
|
55
|
+
shared: Rect | undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Trims each PNG in `files` to its visible pixels, or with `shared` all of
|
|
59
|
+
* them to one rectangle, and reports where each now sits on its sheet. An
|
|
60
|
+
* image already as small as it can be is not rewritten.
|
|
61
|
+
*/
|
|
62
|
+
export declare function trimImages(files: readonly string[], options?: TrimOptions): Promise<TrimResult>;
|
package/dist/cli/trim.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trimming: cutting full-sheet art down to the part of it that draws.
|
|
3
|
+
*
|
|
4
|
+
* Frame art is authored on the full 3264 × 4440 sheet so that it lines up by
|
|
5
|
+
* itself, but much of it covers a sliver of that: a Saga banner is under 5% of
|
|
6
|
+
* its sheet. A renderer decodes, caches and composites every pixel of an image,
|
|
7
|
+
* transparent or not, so the empty rest costs memory and time on every card.
|
|
8
|
+
* Trimmed, the image keeps only that sliver, and the frame records where it
|
|
9
|
+
* sits on the sheet.
|
|
10
|
+
*
|
|
11
|
+
* Nothing about how the art looks changes: the pixels kept are byte-for-byte
|
|
12
|
+
* the same, and the chunks that tell a decoder how to show them (colour
|
|
13
|
+
* profile, gamma) are carried over. A trimmed image also records its own
|
|
14
|
+
* position in an `oFFs` chunk, so trimming it again still reports where it
|
|
15
|
+
* sits on the sheet rather than in itself.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { PNG } from "pngjs";
|
|
20
|
+
import { PackagingError } from "./errors.js";
|
|
21
|
+
/**
|
|
22
|
+
* The smallest rectangle holding every pixel that is not fully transparent,
|
|
23
|
+
* or `undefined` when the image has none.
|
|
24
|
+
*/
|
|
25
|
+
export function opaqueBounds({ width, height, data, }) {
|
|
26
|
+
let left = width;
|
|
27
|
+
let right = -1;
|
|
28
|
+
let top = height;
|
|
29
|
+
let bottom = -1;
|
|
30
|
+
for (let y = 0; y < height; y++) {
|
|
31
|
+
const row = y * width * 4;
|
|
32
|
+
for (let x = 0; x < width; x++) {
|
|
33
|
+
if (data[row + x * 4 + 3] !== 0) {
|
|
34
|
+
left = Math.min(left, x);
|
|
35
|
+
right = Math.max(right, x);
|
|
36
|
+
top = Math.min(top, y);
|
|
37
|
+
bottom = y;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return right < 0
|
|
42
|
+
? undefined
|
|
43
|
+
: { x: left, y: top, width: right - left + 1, height: bottom - top + 1 };
|
|
44
|
+
}
|
|
45
|
+
/** The smallest rectangle holding all of `rects`, or `undefined` for none. */
|
|
46
|
+
export function unionBounds(rects) {
|
|
47
|
+
if (rects.length === 0) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const left = Math.min(...rects.map((r) => r.x));
|
|
51
|
+
const top = Math.min(...rects.map((r) => r.y));
|
|
52
|
+
const right = Math.max(...rects.map((r) => r.x + r.width));
|
|
53
|
+
const bottom = Math.max(...rects.map((r) => r.y + r.height));
|
|
54
|
+
return { x: left, y: top, width: right - left, height: bottom - top };
|
|
55
|
+
}
|
|
56
|
+
/** `pixels` cut down to `rect`, which must lie inside them. */
|
|
57
|
+
export function cropPixels(pixels, rect) {
|
|
58
|
+
const data = new Uint8Array(rect.width * rect.height * 4);
|
|
59
|
+
for (let y = 0; y < rect.height; y++) {
|
|
60
|
+
const from = ((rect.y + y) * pixels.width + rect.x) * 4;
|
|
61
|
+
data.set(pixels.data.subarray(from, from + rect.width * 4), y * rect.width * 4);
|
|
62
|
+
}
|
|
63
|
+
return { width: rect.width, height: rect.height, data };
|
|
64
|
+
}
|
|
65
|
+
const PNG_SIGNATURE = Buffer.from([
|
|
66
|
+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
67
|
+
]);
|
|
68
|
+
/** Splits a PNG file into its chunks, without decoding any. */
|
|
69
|
+
function readChunks(file) {
|
|
70
|
+
const chunks = [];
|
|
71
|
+
let offset = PNG_SIGNATURE.length;
|
|
72
|
+
while (offset + 12 <= file.length) {
|
|
73
|
+
const length = file.readUInt32BE(offset);
|
|
74
|
+
const end = offset + 12 + length;
|
|
75
|
+
chunks.push({
|
|
76
|
+
type: file.toString("latin1", offset + 4, offset + 8),
|
|
77
|
+
bytes: file.subarray(offset, end),
|
|
78
|
+
data: file.subarray(offset + 8, offset + 8 + length),
|
|
79
|
+
});
|
|
80
|
+
offset = end;
|
|
81
|
+
}
|
|
82
|
+
return chunks;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Chunks that change how a decoder shows the pixels — colour profile, gamma,
|
|
86
|
+
* chromaticities, significant bits, colour space — plus the pixel density.
|
|
87
|
+
* The encoder writes none of them, and a browser drawing the art honours
|
|
88
|
+
* every one, so each is copied from the original.
|
|
89
|
+
*/
|
|
90
|
+
const KEPT_CHUNKS = new Set([
|
|
91
|
+
"cHRM",
|
|
92
|
+
"cICP",
|
|
93
|
+
"gAMA",
|
|
94
|
+
"iCCP",
|
|
95
|
+
"mDCV",
|
|
96
|
+
"cLLI",
|
|
97
|
+
"sBIT",
|
|
98
|
+
"sRGB",
|
|
99
|
+
"pHYs",
|
|
100
|
+
]);
|
|
101
|
+
/** Where an image sits on its sheet, from an `oFFs` chunk in pixels. */
|
|
102
|
+
function readOffset(chunks) {
|
|
103
|
+
const offs = chunks.find((chunk) => chunk.type === "oFFs");
|
|
104
|
+
// Unit 0 is pixels; a micrometre offset is someone else's and is ignored.
|
|
105
|
+
if (offs?.data.length === 9 && offs.data[8] === 0) {
|
|
106
|
+
return { x: offs.data.readInt32BE(0), y: offs.data.readInt32BE(4) };
|
|
107
|
+
}
|
|
108
|
+
return { x: 0, y: 0 };
|
|
109
|
+
}
|
|
110
|
+
const CRC_TABLE = Array.from({ length: 256 }, (_, n) => {
|
|
111
|
+
let c = n;
|
|
112
|
+
for (let k = 0; k < 8; k++) {
|
|
113
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
114
|
+
}
|
|
115
|
+
return c >>> 0;
|
|
116
|
+
});
|
|
117
|
+
function crc32(bytes) {
|
|
118
|
+
let crc = 0xffffffff;
|
|
119
|
+
for (const byte of bytes) {
|
|
120
|
+
crc = (CRC_TABLE[(crc ^ byte) & 0xff] ?? 0) ^ (crc >>> 8);
|
|
121
|
+
}
|
|
122
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
123
|
+
}
|
|
124
|
+
function chunk(type, data) {
|
|
125
|
+
const typeAndData = Buffer.concat([Buffer.from(type, "latin1"), data]);
|
|
126
|
+
const out = Buffer.alloc(12 + data.length);
|
|
127
|
+
out.writeUInt32BE(data.length, 0);
|
|
128
|
+
typeAndData.copy(out, 4);
|
|
129
|
+
out.writeUInt32BE(crc32(typeAndData), 8 + data.length);
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
function offsetChunk(x, y) {
|
|
133
|
+
const data = Buffer.alloc(9);
|
|
134
|
+
data.writeInt32BE(x, 0);
|
|
135
|
+
data.writeInt32BE(y, 4);
|
|
136
|
+
data.writeUInt8(0, 8);
|
|
137
|
+
return chunk("oFFs", data);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Encodes `pixels` as an RGBA PNG carrying `original`'s display chunks and an
|
|
141
|
+
* `oFFs` chunk at `at`. All of them go straight after the header, which is
|
|
142
|
+
* before any palette or image data, as the format requires.
|
|
143
|
+
*/
|
|
144
|
+
function encode(pixels, original, at) {
|
|
145
|
+
const png = new PNG({ width: pixels.width, height: pixels.height });
|
|
146
|
+
png.data = Buffer.from(pixels.data.buffer, pixels.data.byteOffset, pixels.data.byteLength);
|
|
147
|
+
const encoded = PNG.sync.write(png, { colorType: 6 });
|
|
148
|
+
const [header, ...rest] = readChunks(encoded);
|
|
149
|
+
if (!header) {
|
|
150
|
+
throw new Error("the PNG encoder wrote no header");
|
|
151
|
+
}
|
|
152
|
+
return Buffer.concat([
|
|
153
|
+
PNG_SIGNATURE,
|
|
154
|
+
header.bytes,
|
|
155
|
+
...original.filter((c) => KEPT_CHUNKS.has(c.type)).map((c) => c.bytes),
|
|
156
|
+
offsetChunk(at.x, at.y),
|
|
157
|
+
...rest.map((c) => c.bytes),
|
|
158
|
+
]);
|
|
159
|
+
}
|
|
160
|
+
async function readSource(file) {
|
|
161
|
+
const bytes = await readFile(file);
|
|
162
|
+
if (!bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
|
163
|
+
throw new PackagingError(`${file} is not a PNG. Only PNGs have transparency to trim.`);
|
|
164
|
+
}
|
|
165
|
+
const chunks = readChunks(bytes);
|
|
166
|
+
// Decoding scales 16 bits down to 8, so writing it back would lose detail.
|
|
167
|
+
const header = chunks[0];
|
|
168
|
+
if (header?.type === "IHDR" && header.data[8] === 16) {
|
|
169
|
+
throw new PackagingError(`${file} has 16 bits a channel. Trimming keeps pixels exactly, which ` +
|
|
170
|
+
"it can only do for 8-bit images.");
|
|
171
|
+
}
|
|
172
|
+
const png = PNG.sync.read(bytes);
|
|
173
|
+
const pixels = { width: png.width, height: png.height, data: png.data };
|
|
174
|
+
return {
|
|
175
|
+
file,
|
|
176
|
+
bytes,
|
|
177
|
+
chunks,
|
|
178
|
+
pixels,
|
|
179
|
+
offset: readOffset(chunks),
|
|
180
|
+
bounds: opaqueBounds(pixels),
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Trims each PNG in `files` to its visible pixels, or with `shared` all of
|
|
185
|
+
* them to one rectangle, and reports where each now sits on its sheet. An
|
|
186
|
+
* image already as small as it can be is not rewritten.
|
|
187
|
+
*/
|
|
188
|
+
export async function trimImages(files, options = {}) {
|
|
189
|
+
const sources = await Promise.all(files.map((file) => readSource(file)));
|
|
190
|
+
let shared;
|
|
191
|
+
if (options.shared) {
|
|
192
|
+
const [first] = sources;
|
|
193
|
+
const mismatched = sources.find((s) => first &&
|
|
194
|
+
(s.pixels.width !== first.pixels.width ||
|
|
195
|
+
s.pixels.height !== first.pixels.height ||
|
|
196
|
+
s.offset.x !== first.offset.x ||
|
|
197
|
+
s.offset.y !== first.offset.y));
|
|
198
|
+
if (first && mismatched) {
|
|
199
|
+
throw new PackagingError(`--shared needs images of one size at one place on the sheet, but ` +
|
|
200
|
+
`${mismatched.file} is ${String(mismatched.pixels.width)} × ` +
|
|
201
|
+
`${String(mismatched.pixels.height)} at ` +
|
|
202
|
+
`(${String(mismatched.offset.x)}, ${String(mismatched.offset.y)}) ` +
|
|
203
|
+
`and ${first.file} is ${String(first.pixels.width)} × ` +
|
|
204
|
+
`${String(first.pixels.height)} at ` +
|
|
205
|
+
`(${String(first.offset.x)}, ${String(first.offset.y)}).`);
|
|
206
|
+
}
|
|
207
|
+
shared = unionBounds(sources.flatMap((s) => (s.bounds ? [s.bounds] : [])));
|
|
208
|
+
}
|
|
209
|
+
const images = await Promise.all(sources.map(async (source) => {
|
|
210
|
+
const crop = options.shared ? shared : source.bounds;
|
|
211
|
+
const { width, height } = source.pixels;
|
|
212
|
+
const onSheet = crop && {
|
|
213
|
+
...crop,
|
|
214
|
+
x: source.offset.x + crop.x,
|
|
215
|
+
y: source.offset.y + crop.y,
|
|
216
|
+
};
|
|
217
|
+
const unchanged = !crop ||
|
|
218
|
+
(crop.x === 0 &&
|
|
219
|
+
crop.y === 0 &&
|
|
220
|
+
crop.width === width &&
|
|
221
|
+
crop.height === height);
|
|
222
|
+
if (unchanged || options.dryRun) {
|
|
223
|
+
return {
|
|
224
|
+
file: source.file,
|
|
225
|
+
written: undefined,
|
|
226
|
+
width,
|
|
227
|
+
height,
|
|
228
|
+
kept: onSheet,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
const target = options.outDir
|
|
232
|
+
? path.join(options.outDir, path.basename(source.file))
|
|
233
|
+
: source.file;
|
|
234
|
+
await writeFile(target, encode(cropPixels(source.pixels, crop), source.chunks, onSheet ?? crop));
|
|
235
|
+
return {
|
|
236
|
+
file: source.file,
|
|
237
|
+
written: target,
|
|
238
|
+
width,
|
|
239
|
+
height,
|
|
240
|
+
kept: onSheet,
|
|
241
|
+
};
|
|
242
|
+
}));
|
|
243
|
+
return {
|
|
244
|
+
images,
|
|
245
|
+
shared: shared && {
|
|
246
|
+
...shared,
|
|
247
|
+
x: (sources[0]?.offset.x ?? 0) + shared.x,
|
|
248
|
+
y: (sources[0]?.offset.y ?? 0) + shared.y,
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
package/dist/cli/watch.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export interface WatchOptions extends BuildOptions {
|
|
|
9
9
|
* One Vite server stays alive for the whole session — starting one costs a
|
|
10
10
|
* second or two — and its module graph is dropped before each rebuild so an
|
|
11
11
|
* edit is actually seen. See `invalidateFrameModules`.
|
|
12
|
+
*
|
|
13
|
+
* Debouncing, ignore rules and the non-overlapping drain live in
|
|
14
|
+
* `startSourceWatcher`, shared with `frame-kit studio`.
|
|
12
15
|
*/
|
|
13
16
|
export declare function watchFrames(options: WatchOptions): Promise<void>;
|
|
14
17
|
/**
|