@cardanvil/frame-kit 0.4.0 → 0.6.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 +45 -0
- package/dist/cli/main.js +16 -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 +87 -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 +513 -0
- package/dist/cli/studio/session.d.ts +73 -0
- package/dist/cli/studio/session.js +154 -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/decorations.d.ts +78 -0
- package/dist/layout/decorations.js +108 -0
- package/dist/layout/frameLayers.d.ts +136 -0
- package/dist/layout/frameLayers.js +291 -0
- package/dist/layout/index.d.ts +16 -0
- package/dist/layout/index.js +16 -0
- package/dist/layout/resolveFrameAsset.d.ts +31 -0
- package/dist/layout/resolveFrameAsset.js +58 -0
- package/dist/manifest/contract.d.ts +671 -1
- package/dist/manifest/contract.js +7 -1
- package/dist/schema/frame.d.ts +1692 -0
- package/dist/schema/frame.js +27 -0
- package/dist/schema/frameAssets.d.ts +3 -1
- package/dist/schema/frameAssets.js +23 -1
- 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-CNzyV22G.js +76 -0
- package/dist/studio/index.html +12 -0
- package/package.json +12 -3
|
@@ -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
|
+
}
|
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
|
/**
|
package/dist/cli/watch.js
CHANGED
|
@@ -1,30 +1,9 @@
|
|
|
1
|
-
import { watch } from "node:fs";
|
|
2
1
|
import path from "node:path";
|
|
3
2
|
import { buildFrames } from "./build.js";
|
|
4
|
-
import { discoverFrames
|
|
3
|
+
import { discoverFrames } from "./discover.js";
|
|
5
4
|
import { invalidateFrameModules, startFrameServer } from "./loadFrame.js";
|
|
6
5
|
import { Reporter, formatProblem } from "./report.js";
|
|
7
|
-
|
|
8
|
-
const SKIP_DIRS = new Set([
|
|
9
|
-
"node_modules",
|
|
10
|
-
"dist",
|
|
11
|
-
".git",
|
|
12
|
-
".pnpm-store",
|
|
13
|
-
"__fixtures__",
|
|
14
|
-
]);
|
|
15
|
-
/** Files a rebuild produces, or an editor leaves lying around. */
|
|
16
|
-
const IGNORED_FILE = /(\.cardframe|frame-index\.json|\.preview\.[a-z]+|~|\.swp|\.tmp)$/i;
|
|
17
|
-
/**
|
|
18
|
-
* How long to wait for changes to stop before rebuilding.
|
|
19
|
-
*
|
|
20
|
-
* One save can produce several events — a write, a rename, an attribute
|
|
21
|
-
* change — and a tool upstream may rewrite a whole directory.
|
|
22
|
-
*/
|
|
23
|
-
const DEBOUNCE_MS = 200;
|
|
24
|
-
function isIgnored(file) {
|
|
25
|
-
const segments = file.split(path.sep);
|
|
26
|
-
return (segments.some((segment) => SKIP_DIRS.has(segment) || segment.startsWith(".")) || IGNORED_FILE.test(file));
|
|
27
|
-
}
|
|
6
|
+
import { startSourceWatcher } from "./sourceWatcher.js";
|
|
28
7
|
const plural = (n) => `${String(n)} frame${n === 1 ? "" : "s"}`;
|
|
29
8
|
/**
|
|
30
9
|
* Rebuilds frames as their sources change, until the process is interrupted.
|
|
@@ -32,6 +11,9 @@ const plural = (n) => `${String(n)} frame${n === 1 ? "" : "s"}`;
|
|
|
32
11
|
* One Vite server stays alive for the whole session — starting one costs a
|
|
33
12
|
* second or two — and its module graph is dropped before each rebuild so an
|
|
34
13
|
* edit is actually seen. See `invalidateFrameModules`.
|
|
14
|
+
*
|
|
15
|
+
* Debouncing, ignore rules and the non-overlapping drain live in
|
|
16
|
+
* `startSourceWatcher`, shared with `frame-kit studio`.
|
|
35
17
|
*/
|
|
36
18
|
export async function watchFrames(options) {
|
|
37
19
|
const root = path.resolve(options.root);
|
|
@@ -41,10 +23,7 @@ export async function watchFrames(options) {
|
|
|
41
23
|
console.log(summary);
|
|
42
24
|
});
|
|
43
25
|
const frameServer = await startFrameServer(root);
|
|
44
|
-
|
|
45
|
-
const pending = new Set();
|
|
46
|
-
let timer;
|
|
47
|
-
let building = false;
|
|
26
|
+
let watcher;
|
|
48
27
|
function runBuild(only) {
|
|
49
28
|
invalidateFrameModules(frameServer);
|
|
50
29
|
const reporter = new Reporter();
|
|
@@ -56,66 +35,23 @@ export async function watchFrames(options) {
|
|
|
56
35
|
report(`${plural(frames.length)} rebuilt in ${String(Date.now() - started)}ms`);
|
|
57
36
|
});
|
|
58
37
|
}
|
|
59
|
-
async function drain() {
|
|
60
|
-
if (building) {
|
|
61
|
-
// Two builds would race on the same output file. Whatever arrived is
|
|
62
|
-
// already in `pending`, and the loop below will pick it up.
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
building = true;
|
|
66
|
-
try {
|
|
67
|
-
// Keyed off `pending` rather than a flag: anything that arrived during a
|
|
68
|
-
// build is still queued when it finishes.
|
|
69
|
-
while (pending.size > 0) {
|
|
70
|
-
const changed = [...pending];
|
|
71
|
-
pending.clear();
|
|
72
|
-
const slugs = await slugsFor(root, changed, options.only);
|
|
73
|
-
if (slugs?.length === 0) {
|
|
74
|
-
continue; // nothing we build was touched
|
|
75
|
-
}
|
|
76
|
-
await runBuild(slugs);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
catch (cause) {
|
|
80
|
-
// A failed rebuild must not end the session: the author is mid-edit and
|
|
81
|
-
// the next save is probably the fix.
|
|
82
|
-
console.error(cause instanceof Error ? cause.message : String(cause));
|
|
83
|
-
}
|
|
84
|
-
finally {
|
|
85
|
-
building = false;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
function onChange(file) {
|
|
89
|
-
if (isIgnored(file) || file.startsWith(outDir)) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
pending.add(file);
|
|
93
|
-
if (timer) {
|
|
94
|
-
clearTimeout(timer);
|
|
95
|
-
}
|
|
96
|
-
timer = setTimeout(() => void drain(), DEBOUNCE_MS);
|
|
97
|
-
}
|
|
98
|
-
function watchDir(dir) {
|
|
99
|
-
try {
|
|
100
|
-
watchers.push(watch(dir, { recursive: true }, (_event, name) => {
|
|
101
|
-
if (name !== null) {
|
|
102
|
-
onChange(path.join(dir, name));
|
|
103
|
-
}
|
|
104
|
-
}));
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
console.error(`Could not watch ${relativePosix(root, dir)}. Recursive watching is ` +
|
|
108
|
-
`not supported on this filesystem; rebuild with \`frame-kit build\`.`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
38
|
try {
|
|
112
39
|
await runBuild(undefined);
|
|
113
40
|
// Each frame's own directory, plus the root, so a frame added or removed
|
|
114
41
|
// while watching is noticed.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
42
|
+
const frames = await discoverFrames(root, new Reporter());
|
|
43
|
+
watcher = startSourceWatcher({
|
|
44
|
+
root,
|
|
45
|
+
dirs: [...frames.map((frame) => frame.dir), root],
|
|
46
|
+
ignore: (file) => file.startsWith(outDir),
|
|
47
|
+
onChange: async (changed) => {
|
|
48
|
+
const slugs = await slugsFor(root, changed, options.only);
|
|
49
|
+
if (slugs?.length === 0) {
|
|
50
|
+
return; // nothing we build was touched
|
|
51
|
+
}
|
|
52
|
+
await runBuild(slugs);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
119
55
|
report("Watching for changes. Press Ctrl+C to stop.");
|
|
120
56
|
await new Promise((resolve) => {
|
|
121
57
|
const stop = () => {
|
|
@@ -126,12 +62,7 @@ export async function watchFrames(options) {
|
|
|
126
62
|
});
|
|
127
63
|
}
|
|
128
64
|
finally {
|
|
129
|
-
|
|
130
|
-
clearTimeout(timer);
|
|
131
|
-
}
|
|
132
|
-
for (const watcher of watchers) {
|
|
133
|
-
watcher.close();
|
|
134
|
-
}
|
|
65
|
+
watcher?.close();
|
|
135
66
|
await frameServer.close();
|
|
136
67
|
}
|
|
137
68
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CrownRenderConfig, NicknameRenderConfig } from "../schema/frame.js";
|
|
2
|
+
/** Where the PT plate sits when a layout does not say. */
|
|
3
|
+
export declare const DEFAULT_PT_IMAGE: {
|
|
4
|
+
readonly x: 2441;
|
|
5
|
+
readonly y: 3847;
|
|
6
|
+
};
|
|
7
|
+
/** The crown's black bar is fixed art at a fixed place. */
|
|
8
|
+
export declare const CROWN_BLACK_BAR: {
|
|
9
|
+
readonly x: 220;
|
|
10
|
+
readonly y: 200;
|
|
11
|
+
readonly width: 2815;
|
|
12
|
+
readonly height: 140;
|
|
13
|
+
};
|
|
14
|
+
/** Horizontal fallback for the nyx insert when the crown's width is unknown. */
|
|
15
|
+
export declare const NYX_INSERT_FALLBACK_X = 611;
|
|
16
|
+
export interface PlacementConfig {
|
|
17
|
+
readonly crownConfig?: CrownRenderConfig | undefined;
|
|
18
|
+
readonly nicknameConfig?: NicknameRenderConfig | undefined;
|
|
19
|
+
readonly ptImage?: {
|
|
20
|
+
readonly x: number;
|
|
21
|
+
readonly y: number;
|
|
22
|
+
} | undefined;
|
|
23
|
+
/** Whether the card being previewed has a nickname; moves the crown. */
|
|
24
|
+
readonly hasNickname?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface Placed {
|
|
27
|
+
readonly x: number;
|
|
28
|
+
readonly y: number;
|
|
29
|
+
}
|
|
30
|
+
export type Placement =
|
|
31
|
+
/** Centred on the canvas — full-sheet art. */
|
|
32
|
+
{
|
|
33
|
+
readonly kind: "center";
|
|
34
|
+
}
|
|
35
|
+
/** Drawn at a fixed point on the sheet. */
|
|
36
|
+
| {
|
|
37
|
+
readonly kind: "at";
|
|
38
|
+
readonly at: Placed;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Positioned by the renderer at draw time from measurements nothing static
|
|
42
|
+
* has — ability rows, saga chapters, the flipside plate.
|
|
43
|
+
*/
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: "dynamic";
|
|
46
|
+
readonly reason: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Where an asset is drawn on the sheet.
|
|
50
|
+
*
|
|
51
|
+
* Most frame art is full-sheet and simply centres, but several families do
|
|
52
|
+
* not: crowns, nicknames and the PT plate are placed from the layout's own
|
|
53
|
+
* render config, and drawing them centred — as a naive compositor does — puts
|
|
54
|
+
* a legendary crown in the middle of the card. The positions here mirror
|
|
55
|
+
* Card Anvil's `konva/nodes/imageNodes.ts`, which is the authority.
|
|
56
|
+
*
|
|
57
|
+
* `assetPath` is the dotted path within a `FrameAssets`, as `walkAssets`
|
|
58
|
+
* reports it: `base.w`, `crown.nickname`, `pt.u`, and so on.
|
|
59
|
+
*/
|
|
60
|
+
export declare function placeAsset(assetPath: string, config?: PlacementConfig, image?: {
|
|
61
|
+
readonly width: number;
|
|
62
|
+
readonly height: number;
|
|
63
|
+
}): Placement;
|
|
64
|
+
/** Resolves a placement to concrete coordinates for an image of known size. */
|
|
65
|
+
export declare function placementOrigin(placement: Placement, image: {
|
|
66
|
+
readonly width: number;
|
|
67
|
+
readonly height: number;
|
|
68
|
+
}, canvas: {
|
|
69
|
+
readonly width: number;
|
|
70
|
+
readonly height: number;
|
|
71
|
+
}): Placed | undefined;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { defaultCrownConfig, defaultNicknameConfig } from "../schema/frame.js";
|
|
2
|
+
/** Where the PT plate sits when a layout does not say. */
|
|
3
|
+
export const DEFAULT_PT_IMAGE = { x: 2441, y: 3847 };
|
|
4
|
+
/** The crown's black bar is fixed art at a fixed place. */
|
|
5
|
+
export const CROWN_BLACK_BAR = {
|
|
6
|
+
x: 220,
|
|
7
|
+
y: 200,
|
|
8
|
+
width: 2815,
|
|
9
|
+
height: 140,
|
|
10
|
+
};
|
|
11
|
+
/** Horizontal fallback for the nyx insert when the crown's width is unknown. */
|
|
12
|
+
export const NYX_INSERT_FALLBACK_X = 611;
|
|
13
|
+
/** First segment of a dotted asset path, e.g. `crown` in `crown.nickname`. */
|
|
14
|
+
const familyOf = (assetPath) => assetPath.split(".")[0] ?? assetPath;
|
|
15
|
+
/**
|
|
16
|
+
* Where an asset is drawn on the sheet.
|
|
17
|
+
*
|
|
18
|
+
* Most frame art is full-sheet and simply centres, but several families do
|
|
19
|
+
* not: crowns, nicknames and the PT plate are placed from the layout's own
|
|
20
|
+
* render config, and drawing them centred — as a naive compositor does — puts
|
|
21
|
+
* a legendary crown in the middle of the card. The positions here mirror
|
|
22
|
+
* Card Anvil's `konva/nodes/imageNodes.ts`, which is the authority.
|
|
23
|
+
*
|
|
24
|
+
* `assetPath` is the dotted path within a `FrameAssets`, as `walkAssets`
|
|
25
|
+
* reports it: `base.w`, `crown.nickname`, `pt.u`, and so on.
|
|
26
|
+
*/
|
|
27
|
+
export function placeAsset(assetPath, config = {}, image) {
|
|
28
|
+
const family = familyOf(assetPath);
|
|
29
|
+
const leaf = assetPath.split(".").slice(1).join(".");
|
|
30
|
+
switch (family) {
|
|
31
|
+
// Full-sheet art.
|
|
32
|
+
case "base":
|
|
33
|
+
case "land":
|
|
34
|
+
case "nyx":
|
|
35
|
+
case "tall":
|
|
36
|
+
case "creature":
|
|
37
|
+
case "tokenTitle":
|
|
38
|
+
case "extendedOverlay":
|
|
39
|
+
return { kind: "center" };
|
|
40
|
+
case "black":
|
|
41
|
+
return { kind: "at", at: { x: CROWN_BLACK_BAR.x, y: CROWN_BLACK_BAR.y } };
|
|
42
|
+
case "dfcIcon":
|
|
43
|
+
case "dfcIconSet":
|
|
44
|
+
case "dfcIconColorSet":
|
|
45
|
+
return { kind: "at", at: { x: 0, y: 0 } };
|
|
46
|
+
case "crown": {
|
|
47
|
+
const crown = config.crownConfig ?? defaultCrownConfig;
|
|
48
|
+
const nicknamed = config.hasNickname === true;
|
|
49
|
+
const x = (nicknamed ? crown.nicknameCrownX : undefined) ?? crown.x;
|
|
50
|
+
const y = (nicknamed ? crown.nicknameCrownY : undefined) ?? crown.y;
|
|
51
|
+
// The nyx insert sits just inside the crown it decorates.
|
|
52
|
+
if (leaf.startsWith("nyxInsert")) {
|
|
53
|
+
const insetX = crown.nyxInsertX ??
|
|
54
|
+
(image
|
|
55
|
+
? x + (CROWN_BLACK_BAR.width - image.width) / 2
|
|
56
|
+
: NYX_INSERT_FALLBACK_X);
|
|
57
|
+
const insetY = (nicknamed ? crown.nyxInsertNicknameY : undefined) ??
|
|
58
|
+
crown.nyxInsertY ??
|
|
59
|
+
y + 2;
|
|
60
|
+
return { kind: "at", at: { x: insetX, y: insetY } };
|
|
61
|
+
}
|
|
62
|
+
return { kind: "at", at: { x, y } };
|
|
63
|
+
}
|
|
64
|
+
case "nickname": {
|
|
65
|
+
const nickname = config.nicknameConfig ?? defaultNicknameConfig;
|
|
66
|
+
return { kind: "at", at: { x: nickname.x, y: nickname.y } };
|
|
67
|
+
}
|
|
68
|
+
case "pt": {
|
|
69
|
+
const pt = config.ptImage ?? DEFAULT_PT_IMAGE;
|
|
70
|
+
return { kind: "at", at: { x: pt.x, y: pt.y } };
|
|
71
|
+
}
|
|
72
|
+
case "planeswalker":
|
|
73
|
+
return {
|
|
74
|
+
kind: "dynamic",
|
|
75
|
+
reason: "ability rows are laid out from the card's ability count",
|
|
76
|
+
};
|
|
77
|
+
case "saga":
|
|
78
|
+
return {
|
|
79
|
+
kind: "dynamic",
|
|
80
|
+
reason: "chapter badges are laid out from the card's chapter count",
|
|
81
|
+
};
|
|
82
|
+
case "flipside":
|
|
83
|
+
return {
|
|
84
|
+
kind: "dynamic",
|
|
85
|
+
reason: "positioned from the other face's power/toughness",
|
|
86
|
+
};
|
|
87
|
+
default:
|
|
88
|
+
return { kind: "center" };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Resolves a placement to concrete coordinates for an image of known size. */
|
|
92
|
+
export function placementOrigin(placement, image, canvas) {
|
|
93
|
+
switch (placement.kind) {
|
|
94
|
+
case "center":
|
|
95
|
+
return {
|
|
96
|
+
x: (canvas.width - image.width) / 2,
|
|
97
|
+
y: (canvas.height - image.height) / 2,
|
|
98
|
+
};
|
|
99
|
+
case "at":
|
|
100
|
+
return placement.at;
|
|
101
|
+
default:
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { FrameAssets, FrameColor } from "../schema/frameAssets.js";
|
|
2
|
+
import { type FrameDetails } from "./frameLayers.js";
|
|
3
|
+
/**
|
|
4
|
+
* The type-line facts the decoration colours turn on.
|
|
5
|
+
*
|
|
6
|
+
* A `CardShape` satisfies this structurally, and so does a card the app has
|
|
7
|
+
* already read a type line from.
|
|
8
|
+
*/
|
|
9
|
+
export interface DecorationFacts {
|
|
10
|
+
readonly isLand?: boolean;
|
|
11
|
+
readonly isArtifact?: boolean;
|
|
12
|
+
readonly isVehicle?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** One colour, or the two halves of a two-colour banner. */
|
|
15
|
+
export type BannerColors = FrameColor | [FrameColor, FrameColor];
|
|
16
|
+
/**
|
|
17
|
+
* The colour a card's banners take.
|
|
18
|
+
*
|
|
19
|
+
* Driven by the resolved identity rather than the background, so a crown, a
|
|
20
|
+
* nickname plate and a PT box on the same card always agree with each other
|
|
21
|
+
* and with the pinlines.
|
|
22
|
+
*/
|
|
23
|
+
export declare function identityColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
|
|
24
|
+
/** Legendary crown colour(s). */
|
|
25
|
+
export declare function crownColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
|
|
26
|
+
/**
|
|
27
|
+
* Nickname banner colour(s).
|
|
28
|
+
*
|
|
29
|
+
* A land with no resolved identity uses the dedicated land nickname art rather
|
|
30
|
+
* than the colourless one.
|
|
31
|
+
*/
|
|
32
|
+
export declare function nicknameColors(details: FrameDetails, facts?: DecorationFacts): BannerColors;
|
|
33
|
+
/**
|
|
34
|
+
* PT box colour.
|
|
35
|
+
*
|
|
36
|
+
* Only a true two-colour hybrid takes the colourless box. A card built from
|
|
37
|
+
* monocoloured-hybrid pips in three or more colours is an ordinary gold card
|
|
38
|
+
* and must resolve by identity length instead.
|
|
39
|
+
*/
|
|
40
|
+
export declare function ptBoxColor(details: FrameDetails, facts?: DecorationFacts): FrameColor;
|
|
41
|
+
/** Combined crown + nickname banner; the UB variant falls back to the plain one. */
|
|
42
|
+
export declare function resolveCrownNicknameAsset(assets: FrameAssets, color: FrameColor, useUBCrowns: boolean): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* Legendary crown art.
|
|
45
|
+
*
|
|
46
|
+
* A nicknamed card prefers the combined crown+nickname asset, and falls back
|
|
47
|
+
* to the plain crown when a frame does not ship one.
|
|
48
|
+
*/
|
|
49
|
+
export declare function resolveCrownAsset(assets: FrameAssets, color: FrameColor, opts: {
|
|
50
|
+
nickname: boolean;
|
|
51
|
+
useUBCrowns: boolean;
|
|
52
|
+
}): string | undefined;
|
|
53
|
+
export declare function resolveNicknameAsset(assets: FrameAssets, color: FrameColor): string | undefined;
|
|
54
|
+
export declare function resolvePtBoxAsset(assets: FrameAssets, color: FrameColor): string | undefined;
|
|
55
|
+
export declare function resolveNyxInsertAsset(assets: FrameAssets, color: FrameColor, useUBCrowns?: boolean): string | undefined;
|
|
56
|
+
/** What a card is, for deciding which banners it gets. */
|
|
57
|
+
export interface DecorationCard {
|
|
58
|
+
readonly isLegendary?: boolean;
|
|
59
|
+
/** The nickname text; a card has a nickname when this is non-empty. */
|
|
60
|
+
readonly nickname?: string;
|
|
61
|
+
readonly power?: string;
|
|
62
|
+
readonly toughness?: string;
|
|
63
|
+
}
|
|
64
|
+
export interface DecorationState {
|
|
65
|
+
readonly crown: boolean;
|
|
66
|
+
/** The bar behind the crown, drawn on the same condition. */
|
|
67
|
+
readonly crownBlackBar: boolean;
|
|
68
|
+
readonly nickname: boolean;
|
|
69
|
+
readonly ptBox: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Which banners a card draws.
|
|
73
|
+
*
|
|
74
|
+
* Mirrors the renderer's conditions: crowns are legendary-only, the nickname
|
|
75
|
+
* plate needs nickname text, and the PT box needs *both* a power and a
|
|
76
|
+
* toughness — a card with only one is not a creature and gets no plate.
|
|
77
|
+
*/
|
|
78
|
+
export declare function decorationsFor(card: DecorationCard): DecorationState;
|