@ljwei-stak/dsh-compositor 0.0.0-stage → 0.1.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/.dsh-plugin/client.js +3 -0
- package/.dsh-plugin/index.mjs +76 -0
- package/.dsh-plugin/shared/comp-blend.mjs +142 -0
- package/.dsh-plugin/shared/comp-docs.mjs +186 -0
- package/.dsh-plugin/shared/comp-paths.mjs +102 -0
- package/.dsh-plugin/shared/comp-png.mjs +209 -0
- package/.dsh-plugin/shared/comp-preview.mjs +276 -0
- package/.dsh-plugin/shared/comp-project.mjs +152 -0
- package/.dsh-plugin/shared/comp-tools.mjs +298 -0
- package/.dsh-plugin/shared/comp-validate.mjs +446 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/README.zh.md +82 -0
- package/cordis.patch.yml +5 -0
- package/package.json +65 -4
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host plugin of @ljwei-stak/dsh-compositor for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Gives Harness agents everything needed to build and edit Compositor
|
|
5
|
+
* (.comp) image projects: live format documentation read from the synced
|
|
6
|
+
* github.com/robbietilton/Compositor checkout, a forward-compatible
|
|
7
|
+
* validator, atomic manifest writes, layer PNG/mask generation and a
|
|
8
|
+
* best-effort flattened preview. The checkout is the single source of truth
|
|
9
|
+
* — nothing about the format is frozen in the plugin, so `git pull` in the
|
|
10
|
+
* checkout is all it takes to follow upstream.
|
|
11
|
+
*/
|
|
12
|
+
import z from '@deepseek-ai/schemastery'
|
|
13
|
+
import { loadRepoDocs } from './shared/comp-docs.mjs'
|
|
14
|
+
import { buildCompositorTools } from './shared/comp-tools.mjs'
|
|
15
|
+
|
|
16
|
+
/** Cordis plugin name; equals the profile entry id in cordis.patch.yml. */
|
|
17
|
+
export const name = 'dsh-compositor'
|
|
18
|
+
/** Optional services only; registration happens through ctx.inject(['tools']). */
|
|
19
|
+
export const inject = []
|
|
20
|
+
|
|
21
|
+
export const Config = z.object({
|
|
22
|
+
repoDir: z.string().default('F:\\everyAI\\dsh-Compositor\\Compositor').description('Compositor 源码库(git clone)的本地路径。插件的格式知识(blend modes、adjustment kinds、版本上限、各种限制)都从这个库的 docs/ 实时解析,git pull 之后自动跟上上游更新。'),
|
|
23
|
+
agentTools: z.boolean().default(true).description('向 Harness 的 Agent 提供 compositor_* 工具(文档、校验、原子写入、图层 PNG、预览、清理)。'),
|
|
24
|
+
previewEnabled: z.boolean().default(true).description('启用 compositor_preview(需要可选依赖 sharp 做图片解码;未安装时工具会明确报告)。'),
|
|
25
|
+
docsMaxChars: z.number().step(1000).min(10000).max(2000000).default(200000).description('compositor_docs 单次返回的文档字符上限。'),
|
|
26
|
+
}).description('Compositor 图像项目工具')
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register the agent tools once the host's `tools` service is up (optional:
|
|
30
|
+
* without it the plugin simply contributes nothing, never fails the boot).
|
|
31
|
+
*/
|
|
32
|
+
export function registerAgentTools(ctx, state = {}, definitions = null) {
|
|
33
|
+
if (typeof ctx?.inject !== 'function') { state.error = 'no inject'; return null }
|
|
34
|
+
try {
|
|
35
|
+
return ctx.inject(['tools'], toolCtx => {
|
|
36
|
+
toolCtx.effect(() => {
|
|
37
|
+
const tools = definitions ?? buildCompositorTools({ config: toolCtx.config ?? {} })
|
|
38
|
+
const disposers = tools.map(tool => toolCtx.tools.register(tool))
|
|
39
|
+
state.registered = tools.length
|
|
40
|
+
return () => {
|
|
41
|
+
state.registered = 0
|
|
42
|
+
for (const disposer of disposers) { try { disposer?.() } catch { /* gone */ } }
|
|
43
|
+
}
|
|
44
|
+
}, 'dsh-compositor: agent tools')
|
|
45
|
+
})
|
|
46
|
+
} catch (error) {
|
|
47
|
+
state.error = String(error?.message ?? error)
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function apply(ctx, config = {}) {
|
|
53
|
+
const state = { registered: 0 }
|
|
54
|
+
|
|
55
|
+
if (config.agentTools === false) {
|
|
56
|
+
ctx.logger.info('agent tools disabled by settings')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Report the checkout status once at startup: it is the compatibility
|
|
61
|
+
// contract, so make it visible when it is missing or unreadable.
|
|
62
|
+
loadRepoDocs(config.repoDir || 'F:\\everyAI\\dsh-Compositor\\Compositor')
|
|
63
|
+
.then(docs => {
|
|
64
|
+
if (!docs.repoExists) ctx.logger.warn(`Compositor checkout not found at ${docs.repoDir} — tools will run on built-in fallback docs; set the repoDir setting to the git clone of github.com/robbietilton/Compositor`)
|
|
65
|
+
else ctx.logger.info(`using Compositor checkout ${docs.repoDir}${docs.git?.commit ? ` @ ${docs.git.commit}` : ''} (v1–${docs.vocab.maxVersion}, ${docs.vocab.blendModes.length} blend modes from ${docs.vocab.blendModesSource})`)
|
|
66
|
+
})
|
|
67
|
+
.catch(() => { /* reported per call */ })
|
|
68
|
+
|
|
69
|
+
const definitions = buildCompositorTools({ config })
|
|
70
|
+
if (config.previewEnabled === false) {
|
|
71
|
+
const filtered = definitions.filter(tool => tool.name !== 'compositor_preview')
|
|
72
|
+
definitions.length = 0
|
|
73
|
+
definitions.push(...filtered)
|
|
74
|
+
}
|
|
75
|
+
registerAgentTools(ctx, state, definitions)
|
|
76
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Photoshop blend math for the preview renderer.
|
|
3
|
+
*
|
|
4
|
+
* All 24 Compositor blend modes, implemented on straight (unpremultiplied)
|
|
5
|
+
* 8-bit RGBA buffers using the W3C compositing and blending model:
|
|
6
|
+
* Co = αs × (1−αb) × Cs + αs × αb × B(Cb, Cs) (premultiplied result)
|
|
7
|
+
* αo = αs + αb × (1−αs)
|
|
8
|
+
* Non-separable modes (Hue, Saturation, Color, Luminosity) use the standard
|
|
9
|
+
* lum/sat clip functions. Inputs and outputs are 0–255 per channel; the
|
|
10
|
+
* caller applies this per destination pixel over a source scan.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const clamp01 = t => (t < 0 ? 0 : t > 1 ? 1 : t)
|
|
14
|
+
const divide = (a, b) => (b === 0 ? 1 : a / b)
|
|
15
|
+
|
|
16
|
+
// separable blend functions on 0–1 channels
|
|
17
|
+
const separable = {
|
|
18
|
+
normal: (b, s) => s,
|
|
19
|
+
multiply: (b, s) => b * s,
|
|
20
|
+
screen: (b, s) => b + s - b * s,
|
|
21
|
+
overlay: (b, s) => hardLight(s, b),
|
|
22
|
+
darken: (b, s) => Math.min(b, s),
|
|
23
|
+
lighten: (b, s) => Math.max(b, s),
|
|
24
|
+
'color-dodge': (b, s) => (s >= 1 ? 1 : Math.min(1, b / (1 - s))),
|
|
25
|
+
'color-burn': (b, s) => (s <= 0 ? 0 : 1 - Math.min(1, (1 - b) / s)),
|
|
26
|
+
'linear-burn': (b, s) => clamp01(b + s - 1),
|
|
27
|
+
'linear-dodge': (b, s) => clamp01(b + s),
|
|
28
|
+
difference: (b, s) => Math.abs(b - s),
|
|
29
|
+
exclusion: (b, s) => b + s - 2 * b * s,
|
|
30
|
+
subtract: (b, s) => clamp01(b - s),
|
|
31
|
+
divide: (b, s) => (s <= 0 ? 1 : clamp01(b / s)),
|
|
32
|
+
'soft-light': (b, s) => {
|
|
33
|
+
const d = b <= 0.25 ? ((16 * b - 12) * b + 4) * b : Math.sqrt(b)
|
|
34
|
+
return s <= 0.5 ? b - (1 - 2 * s) * b * (1 - b) : b + (2 * s - 1) * (d - b)
|
|
35
|
+
},
|
|
36
|
+
'vivid-light': (b, s) => (s < 0.5 ? (s <= 0 ? 0 : 1 - Math.min(1, (1 - b) / (2 * s))) : s >= 1 ? 1 : Math.min(1, b / (2 * (1 - s)))),
|
|
37
|
+
'linear-light': (b, s) => clamp01(b + 2 * s - 1),
|
|
38
|
+
'pin-light': (b, s) => (s < 0.5 ? Math.min(b, 2 * s) : Math.max(b, 2 * s - 1)),
|
|
39
|
+
'hard-mix': (b, s) => (separable['vivid-light'](b, s) < 0.5 ? 0 : 1),
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hardLight(b, s) {
|
|
43
|
+
return s <= 0.5 ? 2 * b * s : 1 - 2 * (1 - b) * (1 - s)
|
|
44
|
+
}
|
|
45
|
+
separable['hard-light'] = hardLight
|
|
46
|
+
|
|
47
|
+
// non-separable: HSL helpers
|
|
48
|
+
function lum(r, g, b) {
|
|
49
|
+
return 0.3 * r + 0.59 * g + 0.11 * b
|
|
50
|
+
}
|
|
51
|
+
function clipColor(r, g, b) {
|
|
52
|
+
const l = lum(r, g, b)
|
|
53
|
+
const min = Math.min(r, g, b)
|
|
54
|
+
const max = Math.max(r, g, b)
|
|
55
|
+
if (min < 0) { r = l + ((r - l) * l) / (l - min); g = l + ((g - l) * l) / (l - min); b = l + ((b - l) * l) / (l - min) }
|
|
56
|
+
if (max > 1) { r = l + ((r - l) * (1 - l)) / (max - l); g = l + ((g - l) * (1 - l)) / (max - l); b = l + ((b - l) * (1 - l)) / (max - l) }
|
|
57
|
+
return [r, g, b]
|
|
58
|
+
}
|
|
59
|
+
function sat(r, g, b) {
|
|
60
|
+
return Math.max(r, g, b) - Math.min(r, g, b)
|
|
61
|
+
}
|
|
62
|
+
function setLum([r, g, b], l) {
|
|
63
|
+
const d = l - lum(r, g, b)
|
|
64
|
+
return clipColor(r + d, g + d, b + d)
|
|
65
|
+
}
|
|
66
|
+
function setSat([r, g, b], s) {
|
|
67
|
+
// order the channels, set the middle-to-max span to s, min stays 0
|
|
68
|
+
const ordered = [r, g, b].map((c, i) => ({ c, i })).sort((a, b2) => a.c - b2.c)
|
|
69
|
+
const out = [0, 0, 0]
|
|
70
|
+
if (ordered[2].c > ordered[0].c) {
|
|
71
|
+
out[ordered[1].i] = ((ordered[1].c - ordered[0].c) * s) / (ordered[2].c - ordered[0].c)
|
|
72
|
+
out[ordered[2].i] = s
|
|
73
|
+
}
|
|
74
|
+
return out
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const nonSeparable = {
|
|
78
|
+
hue: (b, s) => setLum(setSat(s, sat(b[0], b[1], b[2])), lum(b[0], b[1], b[2])),
|
|
79
|
+
saturation: (b, s) => setLum(setSat(b, sat(s[0], s[1], s[2])), lum(b[0], b[1], b[2])),
|
|
80
|
+
color: (b, s) => setLum(s, lum(b[0], b[1], b[2])),
|
|
81
|
+
luminosity: (b, s) => setLum(b, lum(s[0], s[1], s[2])),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Compositor blend name → blending function; b and s are [r,g,b] in 0–1.
|
|
85
|
+
// Separable modes work per channel, so they are lifted to RGB-array form.
|
|
86
|
+
const separableMode = channelFn => (b, s) => [channelFn(b[0], s[0]), channelFn(b[1], s[1]), channelFn(b[2], s[2])]
|
|
87
|
+
|
|
88
|
+
const MODES = {
|
|
89
|
+
Normal: separableMode(separable.normal),
|
|
90
|
+
Multiply: separableMode(separable.multiply),
|
|
91
|
+
Screen: separableMode(separable.screen),
|
|
92
|
+
Overlay: separableMode(separable.overlay),
|
|
93
|
+
Darken: separableMode(separable.darken),
|
|
94
|
+
Lighten: separableMode(separable.lighten),
|
|
95
|
+
'Color Dodge': separableMode(separable['color-dodge']),
|
|
96
|
+
'Color Burn': separableMode(separable['color-burn']),
|
|
97
|
+
'Linear Burn': separableMode(separable['linear-burn']),
|
|
98
|
+
'Linear Dodge (Add)': separableMode(separable['linear-dodge']),
|
|
99
|
+
Difference: separableMode(separable.difference),
|
|
100
|
+
Exclusion: separableMode(separable.exclusion),
|
|
101
|
+
Subtract: separableMode(separable.subtract),
|
|
102
|
+
Divide: separableMode(separable.divide),
|
|
103
|
+
'Soft Light': separableMode(separable['soft-light']),
|
|
104
|
+
'Hard Light': separableMode(separable['hard-light']),
|
|
105
|
+
'Vivid Light': separableMode(separable['vivid-light']),
|
|
106
|
+
'Linear Light': separableMode(separable['linear-light']),
|
|
107
|
+
'Pin Light': separableMode(separable['pin-light']),
|
|
108
|
+
'Hard Mix': separableMode(separable['hard-mix']),
|
|
109
|
+
Hue: nonSeparable.hue,
|
|
110
|
+
Saturation: nonSeparable.saturation,
|
|
111
|
+
Color: nonSeparable.color,
|
|
112
|
+
Luminosity: nonSeparable.luminosity,
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function blendFunction(blendMode) {
|
|
116
|
+
return MODES[blendMode] ?? null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const SUPPORTED_BLEND_MODES = Object.keys(MODES)
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Composite one source pixel over one destination pixel (straight alpha,
|
|
123
|
+
* 0–255 channels). `mode` is a Compositor blend name; anything unknown falls
|
|
124
|
+
* back to Normal. `opacity` is the layer/coverage product in 0–1; the source
|
|
125
|
+
* pixel's own alpha is applied here.
|
|
126
|
+
*/
|
|
127
|
+
export function compositePixel(dst, di, src, si, mode, opacity) {
|
|
128
|
+
const sa = opacity * src[si + 3] / 255
|
|
129
|
+
if (sa <= 0) return
|
|
130
|
+
const br = dst[di] / 255, bg = dst[di + 1] / 255, bb = dst[di + 2] / 255, ba = dst[di + 3] / 255
|
|
131
|
+
const sr = src[si] / 255, sg = src[si + 1] / 255, sb = src[si + 2] / 255
|
|
132
|
+
const blend = MODES[mode] ?? MODES.Normal
|
|
133
|
+
const mixed = blend([br, bg, bb], [sr, sg, sb])
|
|
134
|
+
const alphaOut = sa + ba * (1 - sa)
|
|
135
|
+
if (alphaOut <= 0) { dst[di] = dst[di + 1] = dst[di + 2] = dst[di + 3] = 0; return }
|
|
136
|
+
// W3C simple alpha compositing: source-only ∪ blended intersection ∪ backdrop-only
|
|
137
|
+
const channel = (s, m, b) => (sa * (1 - ba) * s + sa * ba * m + (1 - sa) * ba * b) / alphaOut
|
|
138
|
+
dst[di] = Math.round(clamp01(channel(sr, mixed[0], br)) * 255)
|
|
139
|
+
dst[di + 1] = Math.round(clamp01(channel(sg, mixed[1], bg)) * 255)
|
|
140
|
+
dst[di + 2] = Math.round(clamp01(channel(sb, mixed[2], bb)) * 255)
|
|
141
|
+
dst[di + 3] = Math.round(alphaOut * 255)
|
|
142
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live documentation loader — the compatibility core of this plugin.
|
|
3
|
+
*
|
|
4
|
+
* Nothing about the .comp format is frozen here: blend modes, adjustment
|
|
5
|
+
* kinds, the highest known manifest version and the numeric limits are all
|
|
6
|
+
* parsed at runtime from the docs inside the synced Compositor git checkout
|
|
7
|
+
* (github.com/robbietilton/Compositor). When upstream changes the format and
|
|
8
|
+
* the checkout is pulled, the plugin follows automatically. The constants
|
|
9
|
+
* below are only fallbacks for when a doc cannot be read or its wording has
|
|
10
|
+
* drifted; every value also reports where it came from so an agent can tell.
|
|
11
|
+
*/
|
|
12
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
13
|
+
import { join } from 'node:path'
|
|
14
|
+
import { readGitState } from './comp-paths.mjs'
|
|
15
|
+
|
|
16
|
+
export const DOCS = {
|
|
17
|
+
writing: 'docs/writing-comp-files.md',
|
|
18
|
+
format: 'docs/project-format.md',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const FALLBACK = {
|
|
22
|
+
// writing-comp-files.md "Blend modes are spelled exactly" (24 modes)
|
|
23
|
+
blendModes: [
|
|
24
|
+
'Normal', 'Darken', 'Multiply', 'Color Burn', 'Linear Burn', 'Lighten', 'Screen',
|
|
25
|
+
'Color Dodge', 'Linear Dodge (Add)', 'Overlay', 'Soft Light', 'Hard Light',
|
|
26
|
+
'Vivid Light', 'Linear Light', 'Pin Light', 'Hard Mix', 'Difference', 'Exclusion',
|
|
27
|
+
'Subtract', 'Divide', 'Hue', 'Saturation', 'Color', 'Luminosity',
|
|
28
|
+
],
|
|
29
|
+
// writing-comp-files.md "`kind` is one of ..." (13 kinds, versions 7 and 9)
|
|
30
|
+
adjustmentKinds: [
|
|
31
|
+
'Hue/Saturation', 'Levels', 'Curves', 'Exposure', 'Gradient Map', 'Grain',
|
|
32
|
+
'Invert', 'Black & White', 'Color Balance', 'Gaussian Blur', 'Motion Blur', 'Add Noise',
|
|
33
|
+
],
|
|
34
|
+
maxVersion: 13,
|
|
35
|
+
limits: { canvasSide: 30000, totalLayerPixels: 100_000_000, totalMaskPixels: 100_000_000, layers: 10000, manifestBytes: 4 * 1024 * 1024, assetBytes: 512 * 1024 * 1024, guideCount: 1000, guidePosition: 1_000_000, nestDepth: 64, maskSourceChain: 256 },
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const backticked = line => {
|
|
39
|
+
const values = []
|
|
40
|
+
const pattern = /`([^`]+)`/g
|
|
41
|
+
let match
|
|
42
|
+
while ((match = pattern.exec(line))) values.push(match[1])
|
|
43
|
+
return values
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseBlendModes(writingText) {
|
|
47
|
+
if (!writingText) return null
|
|
48
|
+
const line = writingText.split('\n').find(text => text.includes('Blend modes are spelled exactly'))
|
|
49
|
+
if (!line) return null
|
|
50
|
+
const modes = backticked(line)
|
|
51
|
+
return modes.length >= 8 ? modes : null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseAdjustmentKinds(writingText, formatText) {
|
|
55
|
+
const sources = [writingText, formatText].filter(Boolean)
|
|
56
|
+
const kinds = new Set()
|
|
57
|
+
for (const text of sources) {
|
|
58
|
+
for (const line of text.split('\n')) {
|
|
59
|
+
// "`kind` is one of `Hue/Saturation`, `Levels`, ..."
|
|
60
|
+
if (/`kind`\s+is one of/.test(line)) for (const kind of backticked(line)) if (kind !== 'kind') kinds.add(kind)
|
|
61
|
+
// "... version 9 adds three adjustment kinds: `Gaussian Blur` (...), ..."
|
|
62
|
+
if (/adds?\s+(?:three|new|\d+)\s+adjustment kinds/.test(line)) for (const kind of backticked(line)) kinds.add(kind)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return kinds.size >= 8 ? [...kinds] : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function parseMaxVersion(formatText) {
|
|
69
|
+
if (!formatText) return null
|
|
70
|
+
const title = formatText.split('\n', 1)[0] || ''
|
|
71
|
+
// "# Compositor project format, versions 1–13" (also handles 1-13, 1—13)
|
|
72
|
+
const match = /versions?\s*1\s*[–—\-]\s*(\d+)/i.exec(title)
|
|
73
|
+
return match ? Number(match[1]) : null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseLimits(formatText, writingText) {
|
|
77
|
+
const text = `${formatText ?? ''}\n${writingText ?? ''}`
|
|
78
|
+
const out = {}
|
|
79
|
+
// "30,000 pixels per canvas/image side" / "canvases up to 30,000 pixels on a side"
|
|
80
|
+
for (const pattern of [/(\d[\d,]*)\s*pixels per canvas\/image side/, /up to (\d[\d,]*)\s*pixels on a side/]) {
|
|
81
|
+
const match = pattern.exec(text)
|
|
82
|
+
if (match) { out.canvasSide = Number(match[1].replace(/,/g, '')); break }
|
|
83
|
+
}
|
|
84
|
+
// "100 million total source pixels" / "Up to 100 million mask pixels"
|
|
85
|
+
let match = /(\d+)\s*million total source pixels/.exec(text)
|
|
86
|
+
if (match) out.totalLayerPixels = Number(match[1]) * 1e6
|
|
87
|
+
match = /(\d+)\s*million mask pixels/.exec(text)
|
|
88
|
+
if (match) out.totalMaskPixels = Number(match[1]) * 1e6
|
|
89
|
+
match = /(\d[\d,]*)\s*layers/.exec(text)
|
|
90
|
+
if (match) out.layers = Number(match[1].replace(/,/g, ''))
|
|
91
|
+
match = /(\d[\d.]*)\s*MiB manifest/.exec(text)
|
|
92
|
+
if (match) out.manifestBytes = Math.round(Number(match[1]) * 1024 * 1024)
|
|
93
|
+
match = /(\d[\d.]*)\s*MiB per encoded asset/.exec(text)
|
|
94
|
+
if (match) out.assetBytes = Math.round(Number(match[1]) * 1024 * 1024)
|
|
95
|
+
return out
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const cache = new Map()
|
|
99
|
+
|
|
100
|
+
async function readDocIfChanged(repoDir, relative, entry) {
|
|
101
|
+
const path = join(repoDir, relative)
|
|
102
|
+
const fileStat = await stat(path).catch(() => null)
|
|
103
|
+
if (!fileStat || !fileStat.isFile()) {
|
|
104
|
+
if (entry?.text !== undefined) return entry
|
|
105
|
+
return { text: null, mtimeMs: 0, bytes: 0 }
|
|
106
|
+
}
|
|
107
|
+
if (entry && entry.mtimeMs === fileStat.mtimeMs && entry.size === fileStat.size) return entry
|
|
108
|
+
const text = await readFile(path, 'utf8').catch(() => null)
|
|
109
|
+
return { text, mtimeMs: fileStat.mtimeMs, size: fileStat.size, bytes: Buffer.byteLength(text ?? '') }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Load docs and vocab from the checkout. Cached per repoDir; the cache is
|
|
114
|
+
* keyed on doc mtimes so a `git pull` invalidates it immediately.
|
|
115
|
+
*/
|
|
116
|
+
export async function loadRepoDocs(repoDir) {
|
|
117
|
+
const cached = cache.get(repoDir)
|
|
118
|
+
const [writing, format] = await Promise.all([
|
|
119
|
+
readDocIfChanged(repoDir, DOCS.writing, cached?.writing),
|
|
120
|
+
readDocIfChanged(repoDir, DOCS.format, cached?.format),
|
|
121
|
+
])
|
|
122
|
+
|
|
123
|
+
const warnings = []
|
|
124
|
+
if (writing.text === null) warnings.push(`cannot read ${DOCS.writing} in ${repoDir}`)
|
|
125
|
+
if (format.text === null) warnings.push(`cannot read ${DOCS.format} in ${repoDir}`)
|
|
126
|
+
|
|
127
|
+
const parsedBlend = parseBlendModes(writing.text)
|
|
128
|
+
const parsedKinds = parseAdjustmentKinds(writing.text, format.text)
|
|
129
|
+
const parsedMaxVersion = parseMaxVersion(format.text)
|
|
130
|
+
const parsedLimits = parseLimits(format.text, writing.text)
|
|
131
|
+
|
|
132
|
+
const blendModes = parsedBlend ?? FALLBACK.blendModes
|
|
133
|
+
const adjustmentKinds = parsedKinds ?? FALLBACK.adjustmentKinds
|
|
134
|
+
const maxVersion = parsedMaxVersion ?? FALLBACK.maxVersion
|
|
135
|
+
const limits = { ...FALLBACK.limits, ...parsedLimits }
|
|
136
|
+
if (!parsedBlend) warnings.push('blend mode list fell back to the built-in copy; the doc wording may have changed')
|
|
137
|
+
if (!parsedKinds) warnings.push('adjustment kind list fell back to the built-in copy')
|
|
138
|
+
if (!parsedMaxVersion) warnings.push('max manifest version fell back to the built-in value')
|
|
139
|
+
|
|
140
|
+
const git = await readGitState(repoDir)
|
|
141
|
+
const entry = {
|
|
142
|
+
repoDir,
|
|
143
|
+
repoExists: writing.text !== null || format.text !== null,
|
|
144
|
+
git,
|
|
145
|
+
docs: { writing: { path: join(repoDir, DOCS.writing), found: writing.text !== null, bytes: writing.bytes }, format: { path: join(repoDir, DOCS.format), found: format.text !== null, bytes: format.bytes } },
|
|
146
|
+
texts: { writing: writing.text, format: format.text },
|
|
147
|
+
vocab: {
|
|
148
|
+
blendModes,
|
|
149
|
+
blendModesSource: parsedBlend ? 'docs' : 'fallback',
|
|
150
|
+
adjustmentKinds,
|
|
151
|
+
adjustmentKindsSource: parsedKinds ? 'docs' : 'fallback',
|
|
152
|
+
maxVersion,
|
|
153
|
+
maxVersionSource: parsedMaxVersion ? 'docs' : 'fallback',
|
|
154
|
+
limits,
|
|
155
|
+
limitsSource: Object.keys(parsedLimits).length ? 'docs+fallback' : 'fallback',
|
|
156
|
+
},
|
|
157
|
+
warnings,
|
|
158
|
+
writingEntry: writing,
|
|
159
|
+
formatEntry: format,
|
|
160
|
+
}
|
|
161
|
+
cache.set(repoDir, entry)
|
|
162
|
+
return entry
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Public shape without internal cache entries or huge text blobs. */
|
|
166
|
+
export function publicDocs(entry, { includeText = false } = {}) {
|
|
167
|
+
const out = {
|
|
168
|
+
repoDir: entry.repoDir,
|
|
169
|
+
repoExists: entry.repoExists,
|
|
170
|
+
git: entry.git,
|
|
171
|
+
docs: entry.docs,
|
|
172
|
+
vocab: {
|
|
173
|
+
blendModeCount: entry.vocab.blendModes.length,
|
|
174
|
+
blendModes: entry.vocab.blendModes,
|
|
175
|
+
blendModesSource: entry.vocab.blendModesSource,
|
|
176
|
+
adjustmentKinds: entry.vocab.adjustmentKinds,
|
|
177
|
+
adjustmentKindsSource: entry.vocab.adjustmentKindsSource,
|
|
178
|
+
maxManifestVersion: entry.vocab.maxVersion,
|
|
179
|
+
maxVersionSource: entry.vocab.maxVersionSource,
|
|
180
|
+
limits: entry.vocab.limits,
|
|
181
|
+
},
|
|
182
|
+
warnings: entry.warnings,
|
|
183
|
+
}
|
|
184
|
+
if (includeText) out.texts = entry.texts
|
|
185
|
+
return out
|
|
186
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path helpers for .comp packages and best-effort git state for the
|
|
3
|
+
* Compositor checkout. Git state is read straight from the .git directory
|
|
4
|
+
* (HEAD, refs, packed-refs, loose commit objects) so it works without git
|
|
5
|
+
* on PATH and without spawning processes.
|
|
6
|
+
*/
|
|
7
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
8
|
+
import { inflateSync } from 'node:zlib'
|
|
9
|
+
import { basename, isAbsolute, join, resolve } from 'node:path'
|
|
10
|
+
|
|
11
|
+
const UUID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/
|
|
12
|
+
|
|
13
|
+
export function isUuid(value) {
|
|
14
|
+
return typeof value === 'string' && UUID_RE.test(value)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function looksLikeManifestPath(path) {
|
|
18
|
+
return basename(path).toLowerCase() === 'manifest.json'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Accept a .comp folder, its manifest.json path, or a folder that contains
|
|
23
|
+
* manifest.json; return { compDir, manifestPath, imagesDir }.
|
|
24
|
+
*/
|
|
25
|
+
export async function resolveCompPath(input) {
|
|
26
|
+
if (typeof input !== 'string' || !input.trim()) throw new Error('path is required')
|
|
27
|
+
const path = resolve(input.trim().replace(/[\\/]+$/, ''))
|
|
28
|
+
if (!isAbsolute(path)) throw new Error(`expected an absolute path, got ${input}`)
|
|
29
|
+
const compDir = looksLikeManifestPath(path) ? resolve(path, '..') : path
|
|
30
|
+
const manifestPath = join(compDir, 'manifest.json')
|
|
31
|
+
const imagesDir = join(compDir, 'images')
|
|
32
|
+
const manifestStat = await stat(manifestPath).catch(() => null)
|
|
33
|
+
if (!manifestStat || !manifestStat.isFile()) {
|
|
34
|
+
throw new Error(`no manifest.json found at ${manifestPath} — a .comp project is a folder containing manifest.json and images/`)
|
|
35
|
+
}
|
|
36
|
+
return { compDir, manifestPath, imagesDir }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readGitDir(repoDir) {
|
|
40
|
+
const dotGitPath = join(repoDir, '.git')
|
|
41
|
+
const gitStat = await stat(dotGitPath).catch(() => null)
|
|
42
|
+
if (!gitStat) return null
|
|
43
|
+
if (gitStat.isDirectory()) return dotGitPath
|
|
44
|
+
// worktree/submodule: .git is a file "gitdir: <path>"
|
|
45
|
+
try {
|
|
46
|
+
const text = (await readFile(dotGitPath, 'utf8')).trim()
|
|
47
|
+
const match = /^gitdir:\s*(.+)$/.exec(text)
|
|
48
|
+
if (match) return resolve(match[1])
|
|
49
|
+
} catch { /* unreadable */ }
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function resolveRef(gitDir, ref) {
|
|
54
|
+
const direct = await readFile(join(gitDir, ref), 'utf8').catch(() => null)
|
|
55
|
+
if (direct && /^[0-9a-f]{40}$/i.test(direct.trim())) return direct.trim()
|
|
56
|
+
try {
|
|
57
|
+
const packed = await readFile(join(gitDir, 'packed-refs'), 'utf8')
|
|
58
|
+
for (const line of packed.split('\n')) {
|
|
59
|
+
const match = /^([0-9a-f]{40})\s+(\S+)$/.exec(line.trim())
|
|
60
|
+
if (match && match[2] === ref) return match[1]
|
|
61
|
+
}
|
|
62
|
+
} catch { /* no packed-refs */ }
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function commitSubject(gitDir, commit) {
|
|
67
|
+
const loose = join(gitDir, 'objects', commit.slice(0, 2), commit.slice(2))
|
|
68
|
+
try {
|
|
69
|
+
const raw = inflateSync(await readFile(loose))
|
|
70
|
+
const text = raw.toString('utf8')
|
|
71
|
+
if (!text.startsWith('commit ')) return null
|
|
72
|
+
const body = text.slice(text.indexOf('\n\n') + 2)
|
|
73
|
+
return (body.split('\n')[0] || '').slice(0, 160) || null
|
|
74
|
+
} catch { /* packed object or missing: subject unavailable */ }
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read { branch, commit, subject } from a git checkout without running git.
|
|
80
|
+
* Returns null when repoDir is not a git checkout.
|
|
81
|
+
*/
|
|
82
|
+
export async function readGitState(repoDir) {
|
|
83
|
+
const gitDir = await readGitDir(repoDir)
|
|
84
|
+
if (!gitDir) return null
|
|
85
|
+
let branch = null
|
|
86
|
+
let commit = null
|
|
87
|
+
try {
|
|
88
|
+
const head = (await readFile(join(gitDir, 'HEAD'), 'utf8')).trim()
|
|
89
|
+
const refMatch = /^ref:\s*(?:refs\/heads\/)?(\S+)$/.exec(head)
|
|
90
|
+
if (refMatch) {
|
|
91
|
+
branch = refMatch[1]
|
|
92
|
+
commit = await resolveRef(gitDir, `refs/heads/${branch}`)
|
|
93
|
+
} else if (/^[0-9a-f]{40}$/i.test(head)) commit = head
|
|
94
|
+
} catch { /* HEAD unreadable */ }
|
|
95
|
+
const subject = commit ? await commitSubject(gitDir, commit) : null
|
|
96
|
+
return {
|
|
97
|
+
branch,
|
|
98
|
+
commit: commit ? commit.slice(0, 12) : null,
|
|
99
|
+
subject,
|
|
100
|
+
note: 'read from the .git directory without running git; run `git -C <repo> pull` yourself when the user wants the checkout updated',
|
|
101
|
+
}
|
|
102
|
+
}
|