kiri が書き出した画像をペインに出し、ドラッグで選んだ場所ごとの修正コメントを Claude に送る

kiri が書き出した画像を Claude Code のペインに出し、場所ごとに「ここを直して」とコメントして Claude に渡す mod(関数フック製のプラグイン)。
/kiri-review <画像のパス …> か、Claude に「この画像をペインで見せて」と頼む(Claude は mcp__kiri-review__open を呼ぶ)。画像は何枚でも、ディレクトリを渡せばその中の画像を名前順に全部並べる(64 枚まで)。.cache/versions/ へ写しておく(APFS なら cp -c の複製で場所を取らない)。同じパスの画像が kiri の再実行などで差し替わる(mtime か寸法が変わる)と版が進み(見出しに「第 N 版」)、一つ前の版の縮小と写しを残す。「前の版と比べる」/「今の版を見る」で切り替えて見比べられる。拡大中なら、前の版の写しから同じ範囲(寸法が変わっていれば同じ割合の位置)を切り出して比べる。拡大中に差し替わったら、同じ範囲で作り直す。前の版を出している間はコメントを付けない。残す前の版は一つだけで、それより古い写しは片付ける。.cache/crops/ の下)も添える。切り抜きは確認用で、範囲の周りに各辺 15% の余白を付け、長辺を 512–1568 px にそろえ、透過部分はマゼンタ(#ff00c8)の上に重ねて縁の残りが見えるようにしてある。Claude は Read で開いて見る。画像全体へのコメントは切り抜かない。切り抜けなかったコメントは座標だけで送る。--json で走らせると、結果の warnings[] のうち HALO_REMAINS / CONTOUR_ROUGH / RIM_CONTAMINATED の data.regions(入力画像の画素、両端を含む)を、outputs[] の source_rect / content_rect で出力画像の画素へ写し(source_rect の外は切り詰める)、橙の点線と K 番号で画像に重ねる。下の「kiri の指摘」の一覧に、コードと score が並ぶ。「拡大」で、その範囲を拡大する。「コメントにする」で、警告のコードを含む未送信のコメントになる。source_rect の無い出力(--rotate)は範囲を写せないので出さない。出し直して画像が差し替わると、新しい結果の指摘に入れ替わる(--json 無しで書き直されたら消える)。前の版には重ねない。source_rect / content_rect のあるもの)へのコメントは、送るときに入力画像のパスと、逆算した入力画像の画素範囲(両端を含む)、その四隅の多角形(画素と normalized)を添える。多角形は --fg-polygon / --bg-polygon(normalized は --normalized と併せて)にそのまま渡せる。余白(content_rect の外)だけのコメントと、画像全体へのコメントには添えない。edge_repair(--edge-repair auto)があれば、解き直した数と、縁残り・ギザギザ・縁の混色の前後を一行で出す。--json の結果があれば outputs[].path を全部拾うので、--derive / --sizes や batch の何枚もの出力も並ぶ。無ければ -o を拾う。Svg 要素)。ターミナルではコメント欄のみで、コメントは画像全体へのものになる。Client(hooks/drag.tsx)がポインタを受けて取る。Client の座標は文字のマス単位なので、範囲の細かさはペインの文字の大きさほど(端末が画素を返す面ではそれより細かい)。qlmanage と sips で作る(HEIC の回転指定を反映するため Quick Look を使う)。透過のある画像の縮小は、市松模様に重ねるため JXA(AppKit)で作る。拡大は切り抜きと同じ手順(下記)で切り出し、sips で JPEG にして、Svg の上限に収まるまで長辺と品質を下げる。切り抜きは、透過も回転指定も無い画像なら sips -c --cropOffset、それ以外(透過、EXIF の回転、HEIC)は osascript -l JavaScript の AppKit(NSImage は表示の向きで読む)で切り抜いて背景色に重ねる。sips の切り抜きは回転前の画素に効き、背景に重ねることもできないため。macOS 専用。Svg の source は 131072 文字まで、Select の選択肢は 64 個まで。超えるとペインごと描かれない。一覧のサムネイルは上限に入る枚数だけ描き、残りは「画像」の選択から選ぶ。/plugin install kiri-review --marketplace tsutsumi389/kiri
claude plugin validate plugins/kiri-review
claude plugin test plugins/kiri-reviewhooks/register.tsx 1757 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { KiriInfo, KiriMapping, KiriRegion, KiriRepair, ReviewComment, ReviewImage, ReviewSelection, ReviewShot, ReviewStatus, ReviewVersion, ReviewZoom } from '../types'
5
6const PANE = 'kiri-review'
7// 画像に重ねるドラッグ用の Client の key
8const DRAG = 'drag'
9// 一覧に重ねるクリックとドラッグ用の Client の key
10const STRIP = 'strip'
11const TOOL = 'mcp__kiri-review__open'
12// Svg の source は 131072 文字まで。枠とコメントの印の分を残す
13export const MAX_B64 = 95000
14// 一覧の Svg に入れるサムネイルの base64 の合計。枠と番号の分を残す
15export const MAX_STRIP_B64 = 120000
16// 画像の切り替えは Select で選ぶ。Select の選択肢は 64 個まで
17export const MAX_IMAGES = 64
18const THUMB = 160
19// 一覧に一度に並べるサムネイルの数と、1 枚分の幅(px)。それより多ければ横に流す
20export const STRIP_SLOTS = 6
21const SLOT = 108
22// 一覧の端の ‹ › の幅(px)。隠れた画像があれば、ここのクリックで一覧を流す
23const EDGE = 26
24
25const images = atom({ plugin: 'kiri-review', key: 'images' } as const, [])
26const current = atom({ plugin: 'kiri-review', key: 'current' } as const, '')
27const comments = atom({ plugin: 'kiri-review', key: 'comments' } as const, [])
28const selection = atom({ plugin: 'kiri-review', key: 'selection' } as const, null as ReviewSelection | null)
29const error = atom({ plugin: 'kiri-review', key: 'error' } as const, '')
30// コメントの番号は使い回さない。送っている間に消して足したコメントを、送った分と取り違えないため
31const lastId = atom({ plugin: 'kiri-review', key: 'lastId' } as const, 0)
32// 一覧の左端に出しているサムネイルの番号(0 から)
33const stripOffset = atom({ plugin: 'kiri-review', key: 'stripOffset' } as const, 0)
34// 「一覧から外す」を一度押した画像。コメントごと消えるので、もう一度押して確かめる
35const confirmRemove = atom({ plugin: 'kiri-review', key: 'confirmRemove' } as const, '')
36// 拡大して出している範囲と、前の版を出しているか
37const zoom = atom({ plugin: 'kiri-review', key: 'zoom' } as const, null as ReviewZoom | null)
38const compare = atom({ plugin: 'kiri-review', key: 'compare' } as const, false)
39const busy = atom({ plugin: 'kiri-review', key: 'busy' } as const, '')
40
41const IMAGE_EXT = /\.(png|jpe?g|webp|avif|heic|tiff?|gif|bmp)$/i
42
43function isKiri(command: string): boolean {
44 return /(^|[\s;&|/])kiri\s/.test(command)
45}
46
47/** cd したあとの相対パスは、どこを指すか分からないので拾わない */
48function isReachable(command: string, path: string): boolean {
49 return path.startsWith('/') || !/(^|[;&|(]\s*)cd\s/.test(command)
50}
51
52/** `kiri ... -o out.png` の出力先を拾う */
53export function outputPath(command: string): string | undefined {
54 if (!isKiri(command)) return undefined
55 const m = command.match(/(?:^|\s)(?:-o|--output|--out)(?:=|\s+)("([^"]+)"|'([^']+)'|(\S+))/)
56 const path = m?.[2] ?? m?.[3] ?? m?.[4]
57 if (path === undefined || !IMAGE_EXT.test(path)) return undefined
58 if (!isReachable(command, path)) return undefined
59 return path
60}
61
62/** 結果 JSON の中の `outputs[].path` を全部拾う(batch は results[].result.outputs[]) */
63export function reportPaths(json: unknown): string[] {
64 const found: string[] = []
65 const walk = (v: unknown): void => {
66 if (Array.isArray(v)) {
67 v.forEach(walk)
68 return
69 }
70 if (v === null || typeof v !== 'object') return
71 const o = v as Record<string, unknown>
72 // --dry-run の outputs[].path にファイルは無い
73 if (o.dry_run === true) return
74 if (Array.isArray(o.outputs)) {
75 for (const x of o.outputs) {
76 const p = (x as { path?: unknown } | null)?.path
77 if (typeof p === 'string' && IMAGE_EXT.test(p)) found.push(p)
78 }
79 }
80 Object.entries(o).forEach(([k, x]) => {
81 if (k !== 'outputs') walk(x)
82 })
83 }
84 walk(json)
85 return [...new Set(found)]
86}
87
88function parseJson(text: string): unknown {
89 try {
90 return JSON.parse(text)
91 } catch {
92 return undefined
93 }
94}
95
96/**
97 * stdout から JSON を全部拾う。kiri は整形して出すので、行頭の `{` から行頭の `}` までを
98 * 1 つとして読む(`kiri a --json && kiri b --json` や前後のログが混ざっていても拾える)
99 */
100export function jsonObjects(stdout: string): unknown[] {
101 const whole = parseJson(stdout.trim())
102 if (whole !== undefined) return [whole]
103 const found: unknown[] = []
104 let start = -1
105 const lines = stdout.split('\n')
106 lines.forEach((line, i) => {
107 if (line === '{') start = i
108 else if (line === '}' && start >= 0) {
109 const v = parseJson(lines.slice(start, i + 1).join('\n'))
110 if (v !== undefined) {
111 found.push(v)
112 start = -1
113 }
114 } else if (start < 0) {
115 // 1 行の JSON
116 const v = /^\{.*\}$/.test(line.trim()) ? parseJson(line.trim()) : undefined
117 if (v !== undefined) found.push(v)
118 }
119 })
120 return found
121}
122
123/**
124 * kiri が書き出した画像を全部拾う。--json の結果があればその outputs[] から
125 * (--derive や batch では何枚も書く)、無ければ -o から
126 */
127export function outputPaths(command: string, stdout: string): string[] {
128 if (!isKiri(command)) return []
129 const reports = jsonObjects(stdout)
130 if (reports.length > 0) {
131 // --dry-run の結果だけなら、何も書いていない
132 if (reports.every(r => (r as { dry_run?: unknown } | null)?.dry_run === true)) return []
133 const paths = [...new Set(reports.flatMap(reportPaths))].filter(p => isReachable(command, p))
134 if (paths.length > 0) return paths
135 }
136 const one = outputPath(command)
137 return one === undefined ? [] : [one]
138}
139
140/** 範囲を出力画像に重ねて見せる kiri の警告 */
141export const REGION_CODES: Readonly<Record<string, string>> = {
142 HALO_REMAINS: '縁に背景が残っている',
143 CONTOUR_ROUGH: '輪郭がギザギザ',
144 RIM_CONTAMINATED: '縁に背景の色が混じっている',
145}
146
147type Rect4 = [number, number, number, number]
148
149function rect4(v: unknown): Rect4 | undefined {
150 if (!Array.isArray(v) || v.length !== 4) return undefined
151 if (!v.every((n): n is number => typeof n === 'number' && Number.isFinite(n))) return undefined
152 const [a, b, c, d] = v as Rect4
153 return a <= c && b <= d ? [a, b, c, d] : undefined
154}
155
156/**
157 * 入力画像の画素範囲(両端を含む)を、source_rect の中に切り詰めてから出力画像の画素へ写す。
158 * 写し方は kiri の契約どおり c.x1 + (x - s.x1) × (c.x2-c.x1+1)/(s.x2-s.x1+1)。返す範囲は右と下の端を含まない
159 */
160export function inputToOutput(
161 b: Rect4,
162 m: Pick<KiriMapping, 'source' | 'content'>,
163 out: { width: number; height: number },
164): { x0: number; y0: number; x1: number; y1: number } | undefined {
165 const [s0, s1, s2, s3] = m.source
166 const [c0, c1, c2, c3] = m.content
167 const ix0 = Math.max(b[0], s0)
168 const iy0 = Math.max(b[1], s1)
169 const ix1 = Math.min(b[2], s2)
170 const iy1 = Math.min(b[3], s3)
171 if (ix0 > ix1 || iy0 > iy1) return undefined
172 const kx = (c2 - c0 + 1) / (s2 - s0 + 1)
173 const ky = (c3 - c1 + 1) / (s3 - s1 + 1)
174 const tidy = (v: number) => Math.round(v * 1e6) / 1e6
175 const x0 = Math.max(0, Math.floor(tidy(c0 + (ix0 - s0) * kx)))
176 const y0 = Math.max(0, Math.floor(tidy(c1 + (iy0 - s1) * ky)))
177 const x1 = Math.min(out.width, Math.max(x0 + 1, Math.ceil(tidy(c0 + (ix1 + 1 - s0) * kx))))
178 const y1 = Math.min(out.height, Math.max(y0 + 1, Math.ceil(tidy(c1 + (iy1 + 1 - s1) * ky))))
179 return { x0, y0, x1, y1 }
180}
181
182/**
183 * 出力画像の画素範囲(右と下の端を含まない)を、content_rect の中に切り詰めてから入力画像の画素へ戻す。
184 * 返す範囲は両端を含む(kiri の bbox と同じ)。content_rect に掛からなければ undefined
185 */
186export function outputToInput(
187 r: { x0: number; y0: number; x1: number; y1: number },
188 m: Pick<KiriMapping, 'source' | 'content'>,
189): Rect4 | undefined {
190 const [s0, s1, s2, s3] = m.source
191 const [c0, c1, c2, c3] = m.content
192 const ox0 = Math.max(r.x0, c0)
193 const oy0 = Math.max(r.y0, c1)
194 const ox1 = Math.min(r.x1, c2 + 1)
195 const oy1 = Math.min(r.y1, c3 + 1)
196 if (ox0 >= ox1 || oy0 >= oy1) return undefined
197 const kx = (s2 - s0 + 1) / (c2 - c0 + 1)
198 const ky = (s3 - s1 + 1) / (c3 - c1 + 1)
199 const tidy = (v: number) => Math.round(v * 1e6) / 1e6
200 const x0 = Math.max(s0, Math.floor(tidy(s0 + (ox0 - c0) * kx)))
201 const y0 = Math.max(s1, Math.floor(tidy(s1 + (oy0 - c1) * ky)))
202 return [x0, y0, Math.min(s2, Math.max(x0, Math.ceil(tidy(s0 + (ox1 - c0) * kx)) - 1)), Math.min(s3, Math.max(y0, Math.ceil(tidy(s1 + (oy1 - c1) * ky)) - 1))]
203}
204
205function numbers(v: unknown): Record<string, number | null> {
206 if (v === null || typeof v !== 'object' || Array.isArray(v)) return {}
207 return Object.fromEntries(
208 Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, typeof x === 'number' && Number.isFinite(x) ? x : null]),
209 )
210}
211
212/** edge_repair の塊を読む。無いか形が違えば undefined */
213function repairOf(v: unknown): KiriRepair | undefined {
214 if (v === null || typeof v !== 'object') return undefined
215 const o = v as Record<string, unknown>
216 return {
217 repaired: typeof o.repaired === 'number' ? o.repaired : 0,
218 gatedBy: typeof o.gated_by === 'string' ? o.gated_by : null,
219 before: numbers(o.before),
220 after: numbers(o.after),
221 }
222}
223
224/**
225 * kiri の --json の結果から、出力画像ごとの情報(警告の範囲、入力画像との対応、edge_repair)を拾う。
226 * 鍵は outputs[].path のまま(outputPaths が返すパスと同じ)。input の相対パスは cwd からの絶対パスにする。
227 * source_rect / content_rect の無い出力(--rotate)は範囲を写せないので、範囲も対応も持たない
228 */
229export function kiriInfos(command: string, stdout: string, cwd = ''): Map<string, KiriInfo> {
230 const found = new Map<string, KiriInfo>()
231 if (!isKiri(command)) return found
232 const visit = (report: Record<string, unknown>) => {
233 if (report.dry_run === true || !Array.isArray(report.outputs)) return
234 const source = report.source as { width?: unknown; height?: unknown } | undefined
235 const input = typeof report.input === 'string' ? report.input : ''
236 const abs = input === '' || input.startsWith('/') || !isReachable(command, input) || cwd === '' ? input : `${cwd.replace(/\/$/, '')}/${input}`
237 const warnings = Array.isArray(report.warnings) ? report.warnings : []
238 const repair = repairOf(report.edge_repair)
239 for (const o of report.outputs as unknown[]) {
240 const out = o as { path?: unknown; width?: unknown; height?: unknown; source_rect?: unknown; content_rect?: unknown } | null
241 if (typeof out?.path !== 'string' || !IMAGE_EXT.test(out.path)) continue
242 const s = rect4(out.source_rect)
243 const c = rect4(out.content_rect)
244 const mapping: KiriMapping | undefined =
245 s !== undefined && c !== undefined && typeof source?.width === 'number' && typeof source.height === 'number' && abs !== ''
246 // パスはそのまま Claude への文に入る。改行や制御文字を含むパスは、文の外へ
247 // 指示を書き足せてしまうので、対応ごと使わない
248 && !/[\u0000-\u001f\u007f]/.test(abs)
249 ? { input: abs, inputWidth: source.width, inputHeight: source.height, source: s, content: c }
250 : undefined
251 const size = { width: typeof out.width === 'number' ? out.width : Infinity, height: typeof out.height === 'number' ? out.height : Infinity }
252 const regions: KiriRegion[] = []
253 for (const w of warnings) {
254 const code = (w as { code?: unknown } | null)?.code
255 if (typeof code !== 'string' || !(code in REGION_CODES) || mapping === undefined) continue
256 const list = (w as { data?: { regions?: unknown } }).data?.regions
257 for (const r of Array.isArray(list) ? list.slice(0, 5) : []) {
258 const b = rect4((r as { bbox?: unknown } | null)?.bbox)
259 const score = (r as { score?: unknown }).score
260 const at = b === undefined ? undefined : inputToOutput(b, mapping, size)
261 if (at !== undefined) regions.push({ code, score: typeof score === 'number' ? score : 0, ...at })
262 }
263 }
264 found.set(out.path, { regions, ...(mapping === undefined ? {} : { mapping }), ...(repair === undefined ? {} : { repair }) })
265 }
266 }
267 const walk = (v: unknown): void => {
268 if (Array.isArray(v)) {
269 v.forEach(walk)
270 return
271 }
272 if (v === null || typeof v !== 'object') return
273 const o = v as Record<string, unknown>
274 if (Array.isArray(o.outputs)) visit(o)
275 Object.entries(o).forEach(([k, x]) => {
276 if (k !== 'outputs') walk(x)
277 })
278 }
279 jsonObjects(stdout).forEach(walk)
280 return found
281}
282
283const METRIC_LABEL: Readonly<Record<string, string>> = {
284 halo_ratio: '縁残り',
285 contour_roughness: 'ギザギザ',
286 rim_contamination: '縁の混色',
287}
288
289/** edge_repair を一行に(「境界の解き直し 9 か所: 縁残り 0.0239→0.0068 · …」) */
290export function repairLine(r: KiriRepair): string {
291 const keys = [...new Set([...Object.keys(r.before), ...Object.keys(r.after)])]
292 const show = (v: number | null | undefined) => (typeof v === 'number' ? String(v) : '—')
293 const metrics = keys.map(k => `${METRIC_LABEL[k] ?? k} ${show(r.before[k])}→${show(r.after[k])}`).join(' · ')
294 return `kiri の境界の解き直し ${r.repaired} か所${r.gatedBy === null ? '' : `(見送り: ${r.gatedBy})`}${metrics === '' ? '' : `: ${metrics}`}`
295}
296
297/** `a.png "b c.png" 'd.jpg'` を引用符を外して分ける */
298export function splitArgs(args: string): string[] {
299 return [...args.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => m[1] ?? m[2] ?? m[3] ?? '').filter(s => s !== '')
300}
301
302export function baseName(path: string): string {
303 return path.split('/').at(-1) ?? path
304}
305
306/** 細すぎる範囲(クリックだけ、1 列や 1 行のドラッグ)は、その辺を画像の短辺の 1/10 まで広げる */
307const MIN_SIDE = 0.1
308
309/**
310 * ドラッグの範囲(画像に対する 0–1)を元画像の画素へ。数でないものは undefined。
311 * Client から届いた値なので、範囲の外は画像の中へ収める
312 */
313export function fractionToRect(
314 f: { x0: unknown; y0: unknown; x1: unknown; y1: unknown },
315 img: Pick<ReviewImage, 'width' | 'height'>,
316): { x0: number; y0: number; x1: number; y1: number } | undefined {
317 const v = [f.x0, f.y0, f.x1, f.y1]
318 if (!v.every((n): n is number => typeof n === 'number' && Number.isFinite(n))) return undefined
319 const [a, b, c, d] = v.map(n => Math.min(1, Math.max(0, n))) as [number, number, number, number]
320 const side = MIN_SIDE * Math.min(img.width, img.height)
321 // 1 辺ずつ、足りなければ中心を保って広げ、画像の中に収める
322 const widen = (lo: number, hi: number, size: number): [number, number] => {
323 if (hi - lo >= side / 4) return [lo, hi]
324 const s = Math.min(side, size)
325 const start = Math.max(0, Math.min(size - s, (lo + hi) / 2 - s / 2))
326 return [start, start + s]
327 }
328 const [x0, x1] = widen(Math.min(a, c) * img.width, Math.max(a, c) * img.width, img.width)
329 const [y0, y1] = widen(Math.min(b, d) * img.height, Math.max(b, d) * img.height, img.height)
330 // 15.5 / 30 * 600 が 310.00000000000006 になる類いを、丸めの前に落とす
331 const tidy = (v: number) => Math.round(v * 1e6) / 1e6
332 return {
333 x0: Math.floor(tidy(x0)),
334 y0: Math.floor(tidy(y0)),
335 x1: Math.min(img.width, Math.ceil(tidy(x1))),
336 y1: Math.min(img.height, Math.ceil(tidy(y1))),
337 }
338}
339
340export function isWhole(c: { x0: number; y0: number; x1: number; y1: number }, img: Pick<ReviewImage, 'width' | 'height'>): boolean {
341 return c.x0 <= 0 && c.y0 <= 0 && c.x1 >= img.width && c.y1 >= img.height
342}
343
344const pct = (v: number, of: number) => Math.round((100 * v) / of)
345
346/** 'x 100–200, y 50–80 px(横 13–25%、縦 8–13%)'。画像全体なら '画像全体' */
347export function describeRect(c: { x0: number; y0: number; x1: number; y1: number }, img: Pick<ReviewImage, 'width' | 'height'>): string {
348 if (isWhole(c, img)) return '画像全体'
349 return `x ${c.x0}–${c.x1}, y ${c.y0}–${c.y1} px(横 ${pct(c.x0, img.width)}–${pct(c.x1, img.width)}%、縦 ${pct(c.y0, img.height)}–${pct(c.y1, img.height)}%)`
350}
351
352export function statusOf(c: Pick<ReviewComment, 'status'>): ReviewStatus {
353 return c.status ?? 'draft'
354}
355
356/** 次の「Claude に送る」に入るコメント(まだ送っていないものと、「まだ」を付けたもの) */
357export function isSendable(c: Pick<ReviewComment, 'status'>): boolean {
358 const s = statusOf(c)
359 return s === 'draft' || s === 'again'
360}
361
362/** 直ったと確かめていないコメント。一覧の件数はこれを数える */
363export function isOpen(c: Pick<ReviewComment, 'status'>): boolean {
364 return statusOf(c) !== 'fixed'
365}
366
367/** 画像ごとの未解決(直ったを付けていない)コメントの数 */
368export function openCounts(all: readonly ReviewComment[]): Map<string, number> {
369 const counts = new Map<string, number>()
370 for (const c of all) if (isOpen(c)) counts.set(c.path, (counts.get(c.path) ?? 0) + 1)
371 return counts
372}
373
374/** 送ったコメントを「送信済み」にする。送っている間に消されたものは戻さない */
375export function markSent(all: readonly ReviewComment[], ids: ReadonlySet<number>): ReviewComment[] {
376 return all.map(c => (ids.has(c.id) && isSendable(c) ? { ...c, status: 'sent' } : c))
377}
378
379/**
380 * 一覧に残しておく理由になるコメント(未送信、「まだ」、確認待ち)。送信済みのまま差し替わっていない
381 * コメントや直ったものだけの画像は、開き直したら外してよい
382 */
383export function needsAttention(c: Pick<ReviewComment, 'status'>): boolean {
384 const s = statusOf(c)
385 return s === 'draft' || s === 'again' || s === 'check'
386}
387
388/**
389 * 画像の寸法が変わったら、コメントの範囲を同じ割合の位置へ移す。画像全体へのコメントは全体のまま。
390 * 寸法を持たない(寸法を覚える前の)コメントは、一覧にあった前の画像の寸法で付けたものとみなす
391 */
392export function fitComments(
393 all: readonly ReviewComment[],
394 prev: readonly Pick<ReviewImage, 'path' | 'width' | 'height'>[],
395 next: readonly Pick<ReviewImage, 'path' | 'width' | 'height'>[],
396): ReviewComment[] {
397 return all.map(c => {
398 const img = next.find(x => x.path === c.path)
399 if (img === undefined) return c
400 const old = prev.find(x => x.path === c.path) ?? img
401 const from = { width: c.width ?? old.width, height: c.height ?? old.height }
402 const size = { width: img.width, height: img.height }
403 if (from.width === img.width && from.height === img.height) return c.width === undefined ? { ...c, ...size } : c
404 if (isWhole(c, from)) return { ...c, ...size, x0: 0, y0: 0, x1: img.width, y1: img.height }
405 const sx = img.width / Math.max(1, from.width)
406 const sy = img.height / Math.max(1, from.height)
407 const x0 = Math.max(0, Math.min(img.width - 1, Math.floor(c.x0 * sx)))
408 const y0 = Math.max(0, Math.min(img.height - 1, Math.floor(c.y0 * sy)))
409 return {
410 ...c,
411 ...size,
412 x0,
413 y0,
414 x1: Math.max(x0 + 1, Math.min(img.width, Math.ceil(c.x1 * sx))),
415 y1: Math.max(y0 + 1, Math.min(img.height, Math.ceil(c.y1 * sy))),
416 }
417 })
418}
419
420/** 差し替わった画像への送信済みのコメントを「確認待ち」にする */
421export function markReplaced(all: readonly ReviewComment[], replaced: ReadonlySet<string>): ReviewComment[] {
422 return all.map(c => (replaced.has(c.path) && statusOf(c) === 'sent' ? { ...c, status: 'check' } : c))
423}
424
425/** 切り抜きの余白(範囲の辺の長さに対する割合)と、その最小(px) */
426export const CROP_MARGIN = 0.15
427const CROP_MARGIN_MIN = 16
428/** 透過画像の切り抜きを重ねる背景。縁の残りが見えるよう、商品写真に無さそうな色にする */
429export const CROP_BG = 'ff00c8'
430/** 切り抜きの長辺。小さい範囲は拡大し、大きい範囲は縮める */
431const CROP_MIN_SIDE = 512
432const CROP_MAX_SIDE = 1568
433
434/** コメントの範囲に余白を付けた、切り抜く範囲(元画像の画素、画像の中に収める) */
435export function cropRect(
436 c: { x0: number; y0: number; x1: number; y1: number },
437 img: Pick<ReviewImage, 'width' | 'height'>,
438): { x: number; y: number; w: number; h: number } {
439 const mx = Math.max(CROP_MARGIN_MIN, Math.round(CROP_MARGIN * (c.x1 - c.x0)))
440 const my = Math.max(CROP_MARGIN_MIN, Math.round(CROP_MARGIN * (c.y1 - c.y0)))
441 const x = Math.max(0, c.x0 - mx)
442 const y = Math.max(0, c.y0 - my)
443 return { x, y, w: Math.min(img.width, c.x1 + mx) - x, h: Math.min(img.height, c.y1 + my) - y }
444}
445
446/** 切り抜きを書き出す寸法。長辺を CROP_MIN_SIDE–CROP_MAX_SIDE に収める */
447export function cropSize(w: number, h: number): { w: number; h: number } {
448 const long = Math.max(w, h)
449 const k = Math.min(CROP_MAX_SIDE, Math.max(CROP_MIN_SIDE, long)) / long
450 return { w: Math.max(1, Math.round(w * k)), h: Math.max(1, Math.round(h * k)) }
451}
452
453/**
454 * 切り抜いて背景に重ね、PNG に書く JXA(osascript -l JavaScript -e で渡す)。
455 * NSImage は表示の向きで読む(HEIC の回転も反映済み)ので、表示の向きの座標のまま切り抜ける。
456 * 引数: 元画像 書き出し先 表示の幅 高さ 切り抜く x y w h 書き出す幅 高さ 背景色(16 進か CHECKER、空なら塗らない)
457 */
458export const CROP_JXA = `function run(argv) {
459 ObjC.import('AppKit')
460 const [src, out, W, H, x, y, w, h, outW, outH, bg] = argv
461 const n = Number
462 const im = $.NSImage.alloc.initWithContentsOfFile(src)
463 if (im.isNil()) throw new Error('読めません: ' + src)
464 // NSImage の座標は点で、左下が原点
465 const sx = im.size.width / n(W)
466 const sy = im.size.height / n(H)
467 // 市松模様は、ビットマップの文脈を立てる前に模様の画像を作っておく
468 let fill = null
469 if (bg === 'checker') {
470 const t = 8
471 const pat = $.NSImage.alloc.initWithSize($.NSMakeSize(2 * t, 2 * t))
472 pat.lockFocus
473 $.NSColor.colorWithSRGBRedGreenBlueAlpha(1, 1, 1, 1).setFill
474 $.NSRectFill($.NSMakeRect(0, 0, 2 * t, 2 * t))
475 $.NSColor.colorWithSRGBRedGreenBlueAlpha(0.78, 0.78, 0.78, 1).setFill
476 $.NSRectFill($.NSMakeRect(0, 0, t, t))
477 $.NSRectFill($.NSMakeRect(t, t, t, t))
478 pat.unlockFocus
479 fill = $.NSColor.colorWithPatternImage(pat)
480 } else if (bg) {
481 const c = parseInt(bg, 16)
482 fill = $.NSColor.colorWithSRGBRedGreenBlueAlpha(((c >> 16) & 255) / 255, ((c >> 8) & 255) / 255, (c & 255) / 255, 1)
483 }
484 const rep = $.NSBitmapImageRep.alloc.initWithBitmapDataPlanesPixelsWidePixelsHighBitsPerSampleSamplesPerPixelHasAlphaIsPlanarColorSpaceNameBytesPerRowBitsPerPixel(null, n(outW), n(outH), 8, 4, true, false, $.NSDeviceRGBColorSpace, 0, 0)
485 const ctx = $.NSGraphicsContext.graphicsContextWithBitmapImageRep(rep)
486 $.NSGraphicsContext.saveGraphicsState
487 $.NSGraphicsContext.setCurrentContext(ctx)
488 ctx.setImageInterpolation($.NSImageInterpolationHigh)
489 const dst = $.NSMakeRect(0, 0, n(outW), n(outH))
490 if (fill !== null) {
491 fill.setFill
492 $.NSRectFill(dst)
493 }
494 im.drawInRectFromRectOperationFraction(dst, $.NSMakeRect(n(x) * sx, (n(H) - n(y) - n(h)) * sy, n(w) * sx, n(h) * sy), $.NSCompositingOperationSourceOver, 1)
495 ctx.flushGraphics
496 $.NSGraphicsContext.restoreGraphicsState
497 const png = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $())
498 if (!png.writeToFileAtomically(out, true)) throw new Error('書けません: ' + out)
499 return out
500}`
501
502/** 透過部分に敷く市松模様(拡大とペインの縮小用)。CROP_JXA の背景色の代わりに渡す */
503export const CHECKER = 'checker'
504
505/**
506 * 切り抜きを書く手順(コマンドの列)。info は元画像の `sips -g pixelWidth -g pixelHeight -g hasAlpha -g orientation`。
507 * sips の切り抜きは回転前の画素に効くので、そのまま使えるのは透過も回転指定も無い画像だけ。
508 * ほかは(背景に重ねる処理も含めて)JXA で切り抜く
509 */
510export function cropCommands(
511 img: Pick<ReviewImage, 'path' | 'width' | 'height'>,
512 c: { x0: number; y0: number; x1: number; y1: number },
513 info: string,
514 out: string,
515): string[][] {
516 const r = cropRect(c, img)
517 return cropCommandsFor(img, r, cropSize(r.w, r.h), info, out, CROP_BG)
518}
519
520/** r(表示の向きの画素)を切り抜き、size の PNG にして out へ書く手順。透過は bg の上に重ねる */
521export function cropCommandsFor(
522 img: Pick<ReviewImage, 'path' | 'width' | 'height'>,
523 r: { x: number; y: number; w: number; h: number },
524 size: { w: number; h: number },
525 info: string,
526 out: string,
527 bg: string,
528): string[][] {
529 const rawW = Number(info.match(/pixelWidth:\s*(\d+)/)?.[1])
530 const rawH = Number(info.match(/pixelHeight:\s*(\d+)/)?.[1])
531 const orientation = info.match(/orientation:\s*(\d+)/)?.[1]
532 const isPlain =
533 rawW === img.width && rawH === img.height &&
534 !/hasAlpha:\s*yes/.test(info) &&
535 (orientation === undefined || orientation === '1') &&
536 // HEIC の回転は sips の orientation に出ず、180 度は寸法でも分からない
537 !/\.(heic|heif|avif)$/i.test(img.path)
538 if (!isPlain) {
539 return [['osascript', '-l', 'JavaScript', '-e', CROP_JXA, img.path, out,
540 ...[img.width, img.height, r.x, r.y, r.w, r.h, size.w, size.h].map(String), bg]]
541 }
542 return [
543 ['sips', '-c', String(r.h), String(r.w), '--cropOffset', String(r.y), String(r.x), '-s', 'format', 'png', img.path, '--out', out],
544 ...(size.w === r.w && size.h === r.h ? [] : [['sips', '-Z', String(Math.max(size.w, size.h)), out]]),
545 ]
546}
547
548/**
549 * コメントごとに範囲を切り抜き、.cache/crops に PNG で書く。画像全体へのコメントは切り抜かない。
550 * 切り抜けなかったコメントは結果に入れない(送るのは止めない)
551 */
552async function writeCrops($: EngineInterface, list: readonly ReviewImage[], all: readonly ReviewComment[]): Promise<Map<number, string>> {
553 const crops = new Map<number, string>()
554 const targets = all.flatMap(c => {
555 const img = list.find(x => x.path === c.path)
556 return img === undefined || isWhole(c, img) ? [] : [{ c, img }]
557 })
558 if (targets.length === 0) return crops
559 const dir = `${$.plugin.root}/.cache/crops`
560 try {
561 await run($, ['mkdir', '-p', dir])
562 // Claude が読み終えたころの古い切り抜きを片付ける
563 await run($, ['find', dir, '-name', '*.png', '-mmin', '+1440', '-delete']).catch(() => undefined)
564 } catch {
565 return crops
566 }
567 const stamp = Date.now()
568 const infos = new Map<string, Promise<string>>()
569 for (const { c, img } of targets) {
570 try {
571 let info = infos.get(img.path)
572 if (info === undefined) {
573 info = run($, ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', '-g', 'hasAlpha', '-g', 'orientation', img.path])
574 infos.set(img.path, info)
575 }
576 const out = `${dir}/${stamp}-${c.id}-${baseName(img.path).replace(/\.[^.]+$/, '')}.png`
577 for (const argv of cropCommands(img, c, await info, out)) await run($, argv)
578 crops.set(c.id, out)
579 } catch {
580 // この行は座標だけで送る
581 }
582 }
583 return crops
584}
585
586/** 拡大の長辺(px)。小さい範囲は引き伸ばし、ペインの画像と同じくらいの大きさで出す */
587export const ZOOM_SIDE = 640
588/** 拡大の JPEG を Svg の上限に収めるまで、長辺と品質を下げていく */
589const ZOOM_STEPS = [[640, 75], [560, 60], [480, 50], [400, 45], [320, 40]] as const
590
591type PxRect = { x: number; y: number; w: number; h: number }
592
593/**
594 * 拡大して出す範囲(元画像の画素)。切り抜きと同じ余白を付け、細長すぎる範囲は
595 * 短い辺を長い辺の半分まで広げる(ペインで細い帯にならないように)
596 */
597export function zoomRect(c: { x0: number; y0: number; x1: number; y1: number }, img: Pick<ReviewImage, 'width' | 'height'>): PxRect {
598 const r = cropRect(c, img)
599 const widen = (lo: number, len: number, want: number, size: number): [number, number] => {
600 if (len >= want) return [lo, len]
601 const s = Math.min(size, Math.ceil(want))
602 const start = Math.floor(Math.max(0, Math.min(size - s, lo + len / 2 - s / 2)))
603 return [start, Math.min(size - start, s)]
604 }
605 const [x, w] = widen(r.x, r.w, r.h / 2, img.width)
606 const [y, h] = widen(r.y, r.h, r.w / 2, img.height)
607 return { x, y, w, h }
608}
609
610/** 拡大を書き出す寸法。長辺を ZOOM_SIDE にそろえる */
611export function zoomSize(w: number, h: number): { w: number; h: number } {
612 const k = ZOOM_SIDE / Math.max(1, w, h)
613 return { w: Math.max(1, Math.round(w * k)), h: Math.max(1, Math.round(h * k)) }
614}
615
616/** 寸法の違う版へ、範囲を同じ割合の位置に移す */
617export function mapRect(r: PxRect, from: Pick<ReviewImage, 'width' | 'height'>, to: Pick<ReviewImage, 'width' | 'height'>): PxRect {
618 if (from.width === to.width && from.height === to.height) return r
619 const sx = to.width / Math.max(1, from.width)
620 const sy = to.height / Math.max(1, from.height)
621 const x = Math.max(0, Math.min(to.width - 1, Math.floor(r.x * sx)))
622 const y = Math.max(0, Math.min(to.height - 1, Math.floor(r.y * sy)))
623 return { x, y, w: Math.max(1, Math.min(to.width - x, Math.round(r.w * sx))), h: Math.max(1, Math.min(to.height - y, Math.round(r.h * sy))) }
624}
625
626/**
627 * 拡大の上でドラッグした範囲(拡大に対する 0–1)を元画像の画素へ。
628 * 細すぎる範囲の広げ方は全体のときと同じで、基準は拡大の範囲
629 */
630export function zoomFractionToRect(
631 f: { x0: unknown; y0: unknown; x1: unknown; y1: unknown },
632 z: PxRect,
633): { x0: number; y0: number; x1: number; y1: number } | undefined {
634 const r = fractionToRect(f, { width: z.w, height: z.h })
635 if (r === undefined) return undefined
636 return { x0: z.x + r.x0, y0: z.y + r.y0, x1: z.x + r.x1, y1: z.y + r.y1 }
637}
638
639/** ドラッグの Client に渡す、いま出しているものの名前。拡大を出し入れしたあとに届いた選択を捨てるため */
640export function viewKey(z: ReviewZoom | null): string {
641 return z === null ? 'full' : `zoom:${z.x},${z.y},${z.w},${z.h}:${z.version}`
642}
643
644/** src の r(表示の向きの画素)を原寸で切り出し、ペインに出せる大きさの JPEG にする。透過は市松模様の上に重ねる */
645async function writeZoomShot($: EngineInterface, src: Pick<ReviewImage, 'path' | 'width' | 'height'>, r: PxRect, tag: string): Promise<ReviewShot> {
646 const dir = `${$.plugin.root}/.cache`
647 await run($, ['mkdir', '-p', dir])
648 const info = await run($, ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', '-g', 'hasAlpha', '-g', 'orientation', src.path])
649 const png = `${dir}/zoom-${tag}.png`
650 const jpg = `${dir}/zoom-${tag}.jpg`
651 for (const argv of cropCommandsFor(src, r, zoomSize(r.w, r.h), info, png, CHECKER)) await run($, argv)
652 let preview = ''
653 for (const [side, q] of ZOOM_STEPS) {
654 await run($, ['sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', String(q), '-Z', String(side), png, '--out', jpg])
655 preview = (await $.fs.read(jpg, { as: 'bytes' })).base64
656 if (preview.length <= MAX_B64) break
657 }
658 const d = dims(await run($, ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', jpg]))
659 return { preview, previewWidth: d.w, previewHeight: d.h }
660}
661
662/** 拡大を作る。今の版は読んだときの写しから(無ければ元画像から)、前の版があれば同じ範囲をその写しから切り出す */
663async function buildZoom($: EngineInterface, img: ReviewImage, r: PxRect): Promise<ReviewZoom> {
664 const cur = { path: img.snapshot || img.path, width: img.width, height: img.height }
665 const after = await writeZoomShot($, cur, r, 'after').catch(err => {
666 if (cur.path === img.path) throw err
667 // 写しが片付けられていたら、元画像から
668 return writeZoomShot($, { ...cur, path: img.path }, r, 'after')
669 })
670 const zoomed: ReviewZoom = { path: img.path, version: img.version ?? 1, x: r.x, y: r.y, w: r.w, h: r.h, after }
671 const b = img.before
672 if (b === undefined) return zoomed
673 if (b.snapshot === '') return { ...zoomed, beforeError: '前の版の元画像を残せていないので、拡大して比べられません' }
674 try {
675 return { ...zoomed, before: await writeZoomShot($, { path: b.snapshot, width: b.width, height: b.height }, mapRect(r, img, b), 'before') }
676 } catch (err) {
677 return { ...zoomed, beforeError: `前の版を切り出せませんでした: ${err instanceof Error ? err.message : String(err)}` }
678 }
679}
680
681/** コメントを画像ごとにまとめ、一覧の順で並べる。crops があれば、コメントの id ごとの切り抜きのパスも添える */
682export function promptFor(
683 list: readonly ReviewImage[],
684 all: readonly ReviewComment[],
685 crops: ReadonlyMap<number, string> = new Map(),
686): string {
687 const groups = list
688 .map(img => ({ img, items: all.filter(c => c.path === img.path) }))
689 .filter(g => g.items.length > 0)
690 const body = groups.flatMap(({ img, items }, gi) => [
691 `${groups.length > 1 ? `## ${gi + 1}. ` : ''}画像 ${img.path}(表示の向きで ${img.width}×${img.height} px)`,
692 ...items.flatMap((c, i) => {
693 const crop = crops.get(c.id)
694 return [
695 `${i + 1}. ${describeRect(c, img)}: ${c.text}${statusOf(c) === 'again' ? '(再指摘: 前回の修正のあとも直っていません)' : ''}`,
696 ...(crop === undefined ? [] : [` 切り抜き: ${crop}(Read で開いて確認できます)`]),
697 ...inputLines(c, img),
698 ]
699 }),
700 '',
701 ])
702 const hasCrop = all.some(c => crops.has(c.id))
703 const hasInput = groups.some(({ img, items }) => items.some(c => inputLines(c, img).length > 0))
704 return [
705 groups.length > 1
706 ? `${groups.length} 枚の画像に修正コメントが付きました。位置は元画像の画素範囲(左上が原点)です。`
707 : '画像に修正コメントが付きました。位置は元画像の画素範囲(左上が原点)です。',
708 ...(hasCrop
709 ? [`切り抜きは確認用で、範囲の周りに各辺 ${Math.round(CROP_MARGIN * 100)}% の余白を付け、透過部分はマゼンタ(#${CROP_BG})の上に重ねています。位置の基準は画素範囲です。`]
710 : []),
711 ...(hasInput
712 ? ['入力画像の範囲は、kiri の結果の source_rect / content_rect から逆算したものです(両端を含む)。多角形は範囲の四隅で、そのまま --fg-polygon / --bg-polygon に渡せます(normalized は --normalized と併せて)。']
713 : []),
714 '',
715 ...body,
716 '画像の該当範囲を見て、各コメントに対応してください。kiri の出力なら、原因を kiri の設定か実装から探して出し直してください。',
717 ].join('\n')
718}
719
720/**
721 * kiri の出力画像へのコメントなら、入力画像での範囲を添える(kiri の --fg-polygon / --bg-polygon にそのまま渡せる形)。
722 * 対応の分からない画像、画像全体へのコメント、余白(content_rect の外)だけのコメントには添えない
723 */
724export function inputLines(c: { x0: number; y0: number; x1: number; y1: number }, img: Pick<ReviewImage, 'width' | 'height' | 'kiri'>): string[] {
725 const m = img.kiri?.mapping
726 if (m === undefined || isWhole(c, img)) return []
727 const r = outputToInput(c, m)
728 if (r === undefined) return []
729 const [x0, y0, x1, y1] = r
730 // 多角形は画素の外周(右と下は端の画素の外側)
731 const corners = [x0, y0, x1 + 1, y0, x1 + 1, y1 + 1, x0, y1 + 1]
732 const norm = corners.map((v, i) => (Math.round((v / (i % 2 === 0 ? m.inputWidth : m.inputHeight)) * 1e4) / 1e4).toString())
733 return [
734 ` 入力画像 ${m.input}(${m.inputWidth}×${m.inputHeight} px)では x ${x0}–${x1}, y ${y0}–${y1}`,
735 ` 多角形: ${corners.join(',')}(normalized: ${norm.join(',')})`,
736 ]
737}
738
739async function run($: EngineInterface, argv: string[]): Promise<string> {
740 const r = await $.process.run(argv, { timeoutMs: 60_000 })
741 if (r.exitCode !== 0) throw new Error(`${argv[0]}: ${r.stderr.trim() || r.exitCode}`)
742 return r.stdout
743}
744
745function dims(out: string): { w: number; h: number } {
746 const w = Number(out.match(/pixelWidth:\s*(\d+)/)?.[1])
747 const h = Number(out.match(/pixelHeight:\s*(\d+)/)?.[1])
748 if (!(w > 0 && h > 0)) throw new Error('画像の寸法が読めません')
749 return { w, h }
750}
751
752/** パスごとに決まる短い名前(FNV-1a)。版の写しのファイル名に使う */
753export function pathKey(path: string): string {
754 let h = 0x811c9dc5
755 for (let i = 0; i < path.length; i++) h = Math.imul(h ^ path.charCodeAt(i), 0x01000193) >>> 0
756 return h.toString(16).padStart(8, '0')
757}
758
759/** 版の写しのパス。同じパスの同じ版(mtime)なら同じ名前になる */
760export function snapshotPath(dir: string, path: string, mtimeMs: number): string {
761 const ext = path.match(/\.[^./]+$/)?.[0] ?? ''
762 return `${dir}/${pathKey(path)}-${Math.round(mtimeMs)}${ext}`
763}
764
765/** 版の写しの合計の上限(バイト)。超えたら古い写しから消す */
766export const VERSIONS_CAP = 2 * 1024 ** 3
767
768function versionsDir($: EngineInterface): string {
769 return `${$.plugin.root}/.cache/versions`
770}
771
772/**
773 * 元画像を .cache/versions へ写す。kiri が同じパスに書き直したあとも、前の版を原寸で切り出せるように。
774 * APFS なら複製(cp -c)で場所を取らない。写したあとに元画像の mtime を読み直し、写している間に
775 * 書き換わっていたら写し直す(写しの名前と版の mtime を合わせる)。写せなくても読み込みは止めない
776 */
777async function snapshot($: EngineInterface, abs: string, mtimeMs: number): Promise<{ path: string; mtimeMs: number }> {
778 const dir = versionsDir($)
779 let m = mtimeMs
780 try {
781 await run($, ['mkdir', '-p', dir])
782 for (let i = 0; i < 2; i++) {
783 const out = snapshotPath(dir, abs, m)
784 await run($, ['cp', '-c', abs, out]).catch(() => run($, ['cp', abs, out]))
785 // cp -c は元の mtime を引き継ぐ。写しの古さ(片付けの順)は写した時刻で数えるので付け直す
786 await run($, ['touch', '-m', out])
787 const after = (await $.fs.stat(abs, { resolve: true })).mtimeMs ?? m
788 if (after === m) return { path: out, mtimeMs: m }
789 await removeFiles($, [out])
790 m = after
791 }
792 } catch {
793 // 写しなしで読む
794 }
795 return { path: '', mtimeMs: m }
796}
797
798/**
799 * 片付ける写し: state の画像が指していない写しは全部(state が戻ったあとの残りも含む)。
800 * 指している写しも、合計が cap を超えたら古いほうから。
801 * `orphanBefore` を渡すと、指されていない写しはそれより前に写したものだけを消す
802 */
803export function prunePlan(
804 files: readonly { path: string; size: number; mtimeMs: number }[],
805 keep: ReadonlySet<string>,
806 cap: number = VERSIONS_CAP,
807 orphanBefore: number = Infinity,
808): string[] {
809 const orphans = files.filter(f => !keep.has(f.path) && f.mtimeMs < orphanBefore).map(f => f.path)
810 const used = files.filter(f => keep.has(f.path)).sort((a, b) => a.mtimeMs - b.mtimeMs)
811 let total = used.reduce((n, f) => n + f.size, 0)
812 const over: string[] = []
813 for (const f of used) {
814 if (total <= cap) break
815 over.push(f.path)
816 total -= f.size
817 }
818 return [...orphans, ...over]
819}
820
821/** 写しを片付ける。消した写しを指していた画像は、写し無し(前の版の拡大は比べられない)にする */
822/** 起動時の片付けで、孤立した写しを残しておく期間 */
823const ORPHAN_GRACE_MS = 7 * 24 * 60 * 60 * 1000
824
825async function pruneVersions($: EngineInterface, orphanBefore: number = Infinity): Promise<void> {
826 const dir = versionsDir($)
827 try {
828 const entries = await $.fs.list(dir)
829 const list = await read($, images)
830 const keep = new Set(list.flatMap(x => [x.snapshot ?? '', x.before?.snapshot ?? '']).filter(p => p !== ''))
831 const files = entries
832 .filter(e => e.kind === 'file')
833 .map(e => ({ path: `${dir}/${e.name}`, size: e.size ?? 0, mtimeMs: (e as { mtimeMs?: number }).mtimeMs ?? 0 }))
834 const remove = prunePlan(files, keep, VERSIONS_CAP, orphanBefore)
835 if (remove.length === 0) return
836 await removeFiles($, remove)
837 const gone = new Set(remove)
838 await update($, images, l => l.map(x => ({
839 ...x,
840 snapshot: gone.has(x.snapshot ?? '') ? '' : x.snapshot,
841 before: x.before !== undefined && gone.has(x.before.snapshot) ? { ...x.before, snapshot: '' } : x.before,
842 })))
843 } catch {
844 // 片付けられなくても困らない
845 }
846}
847
848/** 画像を読み、ペイン用の縮小 JPEG と一覧用のサムネイルを作る(state には触らない) */
849async function load($: EngineInterface, path: string): Promise<ReviewImage> {
850 const st = await $.fs.stat(path, { resolve: true })
851 // 相対パスのままだと、`-` で始まる名前が sips や cp のオプションに読まれる
852 const abs = st.realPath ?? (path.startsWith('/') ? path : `${await $.session.cwd().catch(() => '.')}/${path}`)
853 // sips の寸法は回転前の画素。iPhone の HEIC は「回して表示」の指定を持つので、
854 // 向きは Quick Look の縮小画像(正立)から取り、縦横が入れ替わっていれば合わせる
855 const info = await run($, ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', '-g', 'hasAlpha', abs])
856 const raw = dims(info)
857 const hasAlpha = /hasAlpha:\s*yes/.test(info)
858 // 版の mtime は写したあとに読んだもの
859 const { path: snap, mtimeMs } = await snapshot($, abs, st.mtimeMs ?? 0)
860 const dir = `${$.plugin.root}/.cache`
861 await run($, ['mkdir', '-p', dir])
862 const thumbPng = `${dir}/${baseName(abs)}.png`
863 const out = `${dir}/preview.jpg`
864 let preview = ''
865 for (const [size, q] of [[640, 70], [480, 55], [360, 45], [260, 40]] as const) {
866 await run($, ['rm', '-f', thumbPng])
867 if (hasAlpha) {
868 // JPEG にすると透過が消えるので、市松模様の上に重ねてから縮める(背景の残りが見える)
869 const k = Math.min(1, size / Math.max(raw.w, raw.h))
870 await run($, ['osascript', '-l', 'JavaScript', '-e', CROP_JXA, abs, thumbPng,
871 ...[raw.w, raw.h, 0, 0, raw.w, raw.h, Math.max(1, Math.round(raw.w * k)), Math.max(1, Math.round(raw.h * k))].map(String), CHECKER])
872 } else {
873 await run($, ['qlmanage', '-t', '-s', String(size), '-o', dir, abs])
874 }
875 await run($, ['sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', String(q), thumbPng, '--out', out])
876 const { base64 } = await $.fs.read(out, { as: 'bytes' })
877 preview = base64
878 if (base64.length <= MAX_B64) break
879 }
880 const p = dims(await run($, ['sips', '-g', 'pixelWidth', '-g', 'pixelHeight', out]))
881 // 縮小画像は正立しているので、そこからさらに縮める
882 const small = `${dir}/thumb.jpg`
883 await run($, ['sips', '-s', 'format', 'jpeg', '-s', 'formatOptions', '50', '-Z', String(THUMB), out, '--out', small])
884 const { base64: thumb } = await $.fs.read(small, { as: 'bytes' })
885 const ts = Math.min(1, THUMB / Math.max(p.w, p.h))
886 // 正方形に近い画像は縮小で縦横が丸まるので、向きが逆のときだけ回っているとみなす
887 const isTurned = (raw.w > raw.h && p.w < p.h) || (raw.w < raw.h && p.w > p.h)
888 const w = isTurned ? raw.h : raw.w
889 const h = isTurned ? raw.w : raw.h
890 return {
891 path: abs,
892 width: w,
893 height: h,
894 preview,
895 previewWidth: p.w,
896 previewHeight: p.h,
897 thumb,
898 thumbWidth: Math.max(1, Math.round(p.w * ts)),
899 thumbHeight: Math.max(1, Math.round(p.h * ts)),
900 mtimeMs,
901 hasAlpha,
902 snapshot: snap,
903 version: 1,
904 }
905}
906
907/**
908 * 読み直した画像に、一覧にあった同じパスの画像の版を引き継ぐ。中身が変わっていれば(mtime か寸法)
909 * 版を一つ進め、前の版として前の縮小と写しを残す。変わっていなければ版も前の版もそのまま
910 */
911export function carryHistory(old: ReviewImage | undefined, x: ReviewImage): { img: ReviewImage; replaced: boolean } {
912 if (old === undefined) return { img: x, replaced: false }
913 const v = old.version ?? 1
914 const changed = old.mtimeMs !== x.mtimeMs || old.width !== x.width || old.height !== x.height
915 if (!changed) return { img: { ...x, version: v, before: old.before }, replaced: false }
916 const before: ReviewVersion = {
917 snapshot: old.snapshot ?? '',
918 width: old.width,
919 height: old.height,
920 preview: old.preview,
921 previewWidth: old.previewWidth,
922 previewHeight: old.previewHeight,
923 mtimeMs: old.mtimeMs,
924 version: v,
925 }
926 return { img: { ...x, version: v + 1, before }, replaced: true }
927}
928
929/** prev にあって next に無い写し(片付けてよいもの) */
930export function staleSnapshots(prev: readonly ReviewImage[], next: readonly ReviewImage[]): string[] {
931 const used = new Set(next.flatMap(x => [x.snapshot ?? '', x.before?.snapshot ?? '']))
932 const old = prev.flatMap(x => [x.snapshot ?? '', x.before?.snapshot ?? ''])
933 return [...new Set(old)].filter(p => p !== '' && !used.has(p))
934}
935
936async function removeFiles($: EngineInterface, paths: readonly string[]): Promise<void> {
937 if (paths.length === 0) return
938 await run($, ['rm', '-f', ...paths]).catch(() => undefined)
939}
940
941/** ディレクトリならその中の画像(名前順)、そうでなければそのまま */
942async function expand($: EngineInterface, path: string): Promise<string[]> {
943 const st = await $.fs.stat(path, { resolve: true }).catch(() => undefined)
944 if (st?.kind !== 'dir') return [path]
945 const dir = (st.realPath ?? path).replace(/\/$/, '')
946 const entries = await $.fs.list(dir)
947 return entries
948 .filter(e => e.kind === 'file' && IMAGE_EXT.test(e.name) && !e.name.startsWith('.'))
949 .map(e => e.name)
950 .sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
951 .map(name => `${dir}/${name}`)
952}
953
954/** checking: 差し替わって「確認待ち」になったコメントの数 */
955/** dropped: 一覧の上限を超えて外した画像、lost: それといっしょに消えたコメントの数 */
956type Loaded = { loaded: ReviewImage[]; failed: string[]; kept: number; checking: number; dropped: string[]; lost: number }
957
958/** 上限を超えて外した画像を知らせる文。外していなければ undefined */
959export function droppedNotice(dropped: readonly string[], lost: number): string | undefined {
960 if (dropped.length === 0) return undefined
961 return `一覧は ${MAX_IMAGES} 枚までなので ${dropped.length} 枚を外しました(${dropped.slice(0, 3).map(baseName).join('、')}${dropped.length > 3 ? ' ほか' : ''}${lost > 0 ? `。コメント ${lost} 件も消えました` : ''})。`
962}
963
964/**
965 * 一覧の次の姿。replace なら読んだ画像だけにするが、未解決のコメントのある画像は
966 * 残す(黙って消さない)。そうでなければ同じパスは差し替え、新しいものは末尾へ。
967 * MAX_IMAGES を超えたら、今回読んだ画像とコメントのある画像を除いて古いほうから外す
968 */
969export function mergeImages<T extends { path: string }>(
970 prev: readonly T[],
971 loaded: readonly T[],
972 replace: boolean,
973 commented: ReadonlySet<string>,
974): { next: T[]; kept: number; dropped: string[] } {
975 const isNew = (x: T) => loaded.some(y => y.path === x.path)
976 const kept = replace ? prev.filter(x => commented.has(x.path) && !isNew(x)) : []
977 const merged = replace
978 ? [...loaded, ...kept]
979 : [...prev.map(x => loaded.find(y => y.path === x.path) ?? x), ...loaded.filter(y => !prev.some(x => x.path === y.path))]
980 let over = merged.length - MAX_IMAGES
981 const next = merged.filter(x => {
982 if (over <= 0 || isNew(x) || commented.has(x.path)) return true
983 over -= 1
984 return false
985 })
986 const capped = next.slice(0, MAX_IMAGES)
987 // 上限で外したもの(replace で入れ替えた画像は外したことにしない)。黙って消さず、呼び手が知らせる
988 const dropped = merged.filter(x => !capped.includes(x)).map(x => x.path)
989 return { next: capped, kept: kept.length, dropped }
990}
991
992/**
993 * 画像を読み、一覧へ入れる。replace なら一覧をこの画像たちで置き換え、そうでなければ
994 * 同じパスは差し替え、新しいものは末尾に足す。大きく出すのは最初に読めた画像
995 */
996async function addImages(
997 $: EngineInterface,
998 paths: readonly string[],
999 replace: boolean,
1000 infos: ReadonlyMap<string, KiriInfo> = new Map(),
1001): Promise<Loaded> {
1002 // 一覧に入りきらない分は読まない
1003 const expanded = (await Promise.all(paths.map(p => expand($, p)))).flat().slice(0, MAX_IMAGES)
1004 const loaded: ReviewImage[] = []
1005 const failed: string[] = []
1006 // 読んだ画像(絶対パス)ごとの kiri の結果
1007 const infoOf = new Map<string, KiriInfo>()
1008 for (const p of expanded) {
1009 try {
1010 const img = await load($, p)
1011 const info = infos.get(p)
1012 if (info !== undefined) infoOf.set(img.path, info)
1013 if (!loaded.some(x => x.path === img.path)) loaded.push(img)
1014 } catch (err) {
1015 failed.push(`${p}: ${err instanceof Error ? err.message : String(err)}`)
1016 }
1017 }
1018 if (loaded.length === 0) {
1019 if (failed.length > 0) await update($, error, () => failed.join('\n'))
1020 return { loaded, failed, kept: 0, checking: 0, dropped: [], lost: 0 }
1021 }
1022 const prev = await read($, images)
1023 // 同じパスがあれば版を引き継ぎ、中身が変わっていれば前の版を残す
1024 const carried = loaded.map(x => carryHistory(prev.find(y => y.path === x.path), x))
1025 // kiri の指摘は書いた版のもの。新しい結果があればそれに替え、結果なしに差し替わったら消す
1026 const fresh = carried.map(({ img, replaced: isReplaced }) => {
1027 const kiri = infoOf.get(img.path) ?? (isReplaced ? undefined : prev.find(y => y.path === img.path)?.kiri)
1028 return kiri === undefined ? img : { ...img, kiri }
1029 })
1030 const replaced = new Set(carried.filter(c => c.replaced).map(c => c.img.path))
1031 // 未送信・「まだ」・確認待ちのある画像は、開き直しても一覧に残す
1032 const commented = new Set((await read($, comments)).filter(needsAttention).map(c => c.path))
1033 const { next, kept, dropped } = mergeImages(prev, fresh, replace, commented)
1034 const shown = await read($, current)
1035 // 開くと頼まれたときは読んだ画像へ移る。kiri の書き出しで差し替えるときは、見ている画像のまま
1036 const stay = replace ? undefined : next.find(x => x.path === shown)
1037 const first = stay ?? fresh.find(x => next.some(y => y.path === x.path)) ?? next[0]
1038 await update($, images, () => next)
1039 // 一覧から外れた画像のコメントは送れないので持ち越さない。差し替わった画像への送信済みは確認待ちへ
1040 // 寸法の変わった画像のコメントは、同じ割合の位置へ移す
1041 const was = await read($, comments)
1042 const checking = was.filter(c => replaced.has(c.path) && statusOf(c) === 'sent').length
1043 const lost = was.filter(c => dropped.includes(c.path)).length
1044 await update($, comments, l => markReplaced(fitComments(l.filter(c => next.some(x => x.path === c.path)), prev, next), replaced))
1045 await update($, current, () => first?.path ?? '')
1046 // 別の画像へ移ったら、「一覧から外す」の確かめと、拡大と前の版の表示はやり直す
1047 if (first?.path !== shown) await leaveImage($)
1048 // 見ている画像が差し替わったら、新しい版を出す
1049 else if (first !== undefined && replaced.has(first.path)) await update($, compare, () => false)
1050 await revealCurrent($)
1051 const notice = droppedNotice(dropped, lost)
1052 await update($, error, () => [...failed, ...(notice === undefined ? [] : [notice])].join('\n'))
1053 await rezoom($, prev, next, first?.path ?? '')
1054 await removeFiles($, staleSnapshots(prev, next))
1055 // 片付けは読み込みごとに一度(画像ごとではなく)。state が指していない写しと、上限を超えた分
1056 await pruneVersions($)
1057 // 一覧から外れた画像や、寸法の変わった画像の選択は、別の場所を指すので外す
1058 await update($, selection, sel => {
1059 const now = sel === null ? undefined : next.find(x => x.path === sel.path)
1060 return now === undefined || sel === null || now.width !== sel.width || now.height !== sel.height ? null : sel
1061 })
1062 return { loaded: fresh.filter(x => next.some(y => y.path === x.path)), failed, kept, checking, dropped, lost }
1063}
1064
1065/** 別の画像へ移るときに、その画像だけの表示(外す確かめ、拡大、前の版)を戻す */
1066async function leaveImage($: EngineInterface): Promise<void> {
1067 await update($, confirmRemove, () => '')
1068 await update($, zoom, () => null)
1069 await update($, compare, () => false)
1070}
1071
1072/**
1073 * 拡大している画像が差し替わったら、同じ範囲(寸法が変わっていれば同じ割合の位置)で作り直す。
1074 * 画像が一覧から外れたか、別の画像を出しているなら拡大をやめる
1075 */
1076async function rezoom($: EngineInterface, prev: readonly ReviewImage[], next: readonly ReviewImage[], shown: string): Promise<void> {
1077 const z = await read($, zoom)
1078 if (z === null) return
1079 const now = next.find(x => x.path === z.path)
1080 if (now === undefined || now.path !== shown) {
1081 await update($, zoom, () => null)
1082 return
1083 }
1084 if ((now.version ?? 1) === z.version) return
1085 const old = prev.find(x => x.path === z.path) ?? now
1086 try {
1087 const rebuilt = await buildZoom($, now, mapRect({ x: z.x, y: z.y, w: z.w, h: z.h }, old, now))
1088 await update($, zoom, () => rebuilt)
1089 } catch (err) {
1090 await update($, zoom, () => null)
1091 await update($, error, () => `拡大を作り直せませんでした: ${err instanceof Error ? err.message : String(err)}`)
1092 }
1093}
1094
1095// load() は .cache の同じファイルに書くので、重なると縮小画像と寸法が別の画像のものと混ざる。
1096// 1 本ずつ流し、あとから頼んだ画像が最後に state に残るようにする
1097let queue: Promise<unknown> = Promise.resolve()
1098
1099function addSerial(
1100 $: EngineInterface,
1101 paths: readonly string[],
1102 replace: boolean,
1103 infos?: ReadonlyMap<string, KiriInfo>,
1104): Promise<Loaded> {
1105 const done = queue.then(() => addImages($, paths, replace, infos))
1106 queue = done.catch(() => undefined)
1107 return done
1108}
1109
1110/**
1111 * 画像の範囲 c を拡大して出す。.cache の同じファイルに書くので、読み込みと同じ列に並べる。
1112 * 作っている間に別の画像へ移ったか差し替わったら、作ったものは捨てる
1113 */
1114function zoomSerial($: EngineInterface, path: string, c: { x0: number; y0: number; x1: number; y1: number }): Promise<void> {
1115 const done = queue.then(async () => {
1116 const img = (await read($, images)).find(x => x.path === path)
1117 if (img === undefined) return
1118 await update($, busy, () => '拡大を作っています…')
1119 try {
1120 const z = await buildZoom($, img, zoomRect(c, img))
1121 const now = (await read($, images)).find(x => x.path === path)
1122 if ((await read($, current)) === path && (now?.version ?? 1) === z.version) await update($, zoom, () => z)
1123 } catch (err) {
1124 await update($, error, () => `拡大できませんでした: ${err instanceof Error ? err.message : String(err)}`)
1125 } finally {
1126 await update($, busy, () => '')
1127 }
1128 })
1129 queue = done.catch(() => undefined)
1130 return done
1131}
1132
1133async function openAndReport($: EngineInterface, paths: readonly string[], replace: boolean): Promise<string> {
1134 if (paths.length === 0) return '画像のパスがありません。'
1135 try {
1136 const { loaded, failed, kept, dropped, lost } = await addSerial($, paths, replace)
1137 const notice = droppedNotice(dropped, lost)
1138 const lines = [
1139 ...(loaded.length === 0
1140 ? []
1141 : [`${loaded.length} 枚をペインに出しました。`, ...loaded.map(i => `- ${i.path}(${i.width}×${i.height})`)]),
1142 ...(kept > 0 ? [`未解決のコメントのある画像 ${kept} 枚は一覧に残しました。`] : []),
1143 ...(notice === undefined ? [] : [notice]),
1144 ...failed.map(f => `読めませんでした: ${f}`),
1145 ]
1146 return lines.length === 0 ? '画像が見つかりませんでした。' : lines.join('\n')
1147 } catch (err) {
1148 const msg = err instanceof Error ? err.message : String(err)
1149 await update($, error, () => msg)
1150 return `読めませんでした: ${msg}`
1151 }
1152}
1153
1154/** クリップボードに画像そのものを入れる osascript の引数。PNG は透過を残すため PNGf で、それ以外は JPEG で渡す */
1155export function copyImageArgv(path: string, png?: string): string[] {
1156 const as = png === undefined && /\.jpe?g$/i.test(path) ? 'JPEG picture' : '«class PNGf»'
1157 return [
1158 'osascript',
1159 '-e', 'on run argv',
1160 '-e', `set the clipboard to (read (POSIX file (item 1 of argv)) as ${as})`,
1161 '-e', 'end run',
1162 png ?? path,
1163 ]
1164}
1165
1166/** 元画像を(縮小せず)クリップボードへ。JPEG と PNG 以外は PNG に変えてから入れる */
1167async function copyImage($: EngineInterface, path: string): Promise<void> {
1168 if (/\.(jpe?g|png)$/i.test(path)) {
1169 await run($, copyImageArgv(path))
1170 return
1171 }
1172 const png = `${$.plugin.root}/.cache/clipboard.png`
1173 await run($, ['sips', '-s', 'format', 'png', path, '--out', png])
1174 await run($, copyImageArgv(path, png))
1175}
1176
1177/** 状態ごとの枠と番号の色。未送信と「まだ」は赤、送信済みは灰、確認待ちは橙、直ったは緑 */
1178export const STATUS_COLOR: Record<ReviewStatus, string> = {
1179 draft: '#ef4444',
1180 again: '#ef4444',
1181 sent: '#64748b',
1182 check: '#f59e0b',
1183 fixed: '#22c55e',
1184}
1185
1186export const STATUS_LABEL: Record<ReviewStatus, string> = {
1187 draft: '',
1188 again: '(まだ・次に送ります)',
1189 sent: '(送信済み)',
1190 check: '(確認待ち)',
1191 fixed: '(直った)',
1192}
1193
1194/** ペインに出すもの: 縮小画像と、それが元画像のどの範囲(画素)か */
1195export type SvgView = ReviewShot & { x: number; y: number; w: number; h: number }
1196
1197/** 画像全体を出すときの SvgView */
1198export function fullView(img: ReviewImage, shot: ReviewShot = img): SvgView {
1199 return { preview: shot.preview, previewWidth: shot.previewWidth, previewHeight: shot.previewHeight, x: 0, y: 0, w: img.width, h: img.height }
1200}hooks/drag.tsx 77 lines1import type { ClientModule, ClientPointerEvent } from 'claude-code'
2
3/**
4 * path: どの画像の上に重ねているか。送るときに付けて返し、切り替えのあとに届いた選択を取り違えない。
5 * aspect: 画像の縦 / 横。領域を画像の高さまで伸ばす行数を決める
6 * view: 何を出している上か('full' か拡大の範囲)。拡大を出し入れしたあとに届いた選択を取り違えない
7 */
8export type DragProps = { path: string; aspect: number; view?: string }
9
10/** 領域の中の位置(マス単位)。coarse はマスの中の位置が分からない面 */
11type Point = { x: number; y: number; coarse: boolean }
12type DragState = { from?: Point; to?: Point }
13type Box01 = { x0: number; y0: number; x1: number; y1: number }
14
15const clamp = (v: number) => Math.min(1, Math.max(0, v))
16
17function point(e: ClientPointerEvent): Point {
18 return e.fine === undefined ? { x: e.x, y: e.y, coarse: true } : { x: e.fine.x, y: e.fine.y, coarse: false }
19}
20
21/**
22 * 2 点で囲む範囲を領域に対する 0–1 へ。マス単位の面では、両端のマスを丸ごと含める
23 * (ドラッグ中に描く枠と同じ範囲になり、端のマスまで選べる)
24 */
25export function span(a: Point, b: Point, columns: number, rows: number): Box01 {
26 const pad = a.coarse || b.coarse ? 1 : 0
27 return {
28 x0: clamp(Math.min(a.x, b.x) / Math.max(1, columns)),
29 y0: clamp(Math.min(a.y, b.y) / Math.max(1, rows)),
30 x1: clamp((Math.max(a.x, b.x) + pad) / Math.max(1, columns)),
31 y1: clamp((Math.max(a.y, b.y) + pad) / Math.max(1, rows)),
32 }
33}
34
35/**
36 * 画像の上に重ねる透明な領域。ドラッグの間は枠を描き、離したら範囲(0–1)を
37 * フックのモジュールへ送る。画像そのものはフック側の Svg が描く
38 */
39const Drag: ClientModule<DragProps, DragState> = (props, surface) => {
40 const { Box, Text } = surface.elements
41 const state = surface.state ?? {}
42 // 毎回付け直して、いまの props と state を使う
43 surface.onPointer(e => {
44 const now = surface.state ?? {}
45 const p = point(e)
46 if (e.type === 'down' && e.button === 'left') surface.setState({ from: p, to: p })
47 else if (e.type === 'move' && now.from !== undefined) {
48 // ボタンを離したのが窓の外で up が来なかったときは、ボタン無しの move で終える
49 surface.setState(e.button === 'left' ? { ...now, to: p } : {})
50 } else if (e.type === 'up' && now.from !== undefined) {
51 surface.post({ path: props.path, view: props.view ?? 'full', ...span(now.from, p, surface.columns, surface.rows) })
52 surface.setState({})
53 }
54 })
55 // デスクトップでは Client の高さは描いた行数で決まり、height の指定は効かない。
56 // 画像より多めに空行を描いて伸ばすと、重ねた Box(画像の大きさ)で切られて画像とちょうど重なる。
57 // 1 マスは横より縦が長いので、列数 × 縦横比の行数あれば足りる
58 const lines = Math.max(1, Math.ceil(Math.max(surface.columns, 40) * Math.min(4, Math.max(0.1, props.aspect || 1))))
59 const filler = Array.from({ length: lines }, () => <Text> </Text>)
60 const { from, to } = state
61 if (from === undefined || to === undefined) return <Box flexDirection="column" width="100%">{filler}</Box>
62 const r = span(from, to, surface.columns, surface.rows)
63 const left = Math.floor(r.x0 * surface.columns)
64 const top = Math.floor(r.y0 * surface.rows)
65 const right = Math.ceil(r.x1 * surface.columns)
66 const bottom = Math.ceil(r.y1 * surface.rows)
67 return (
68 <Box flexDirection="column" width="100%">
69 {filler}
70 <Box position="absolute" left={left} top={top} width={Math.max(1, right - left)} height={Math.max(1, bottom - top)}
71 borderStyle="single" borderColor="blue" />
72 </Box>
73 )
74}
75
76export default Drag
77hooks/strip.tsx 32 lines1import type { ClientModule } from 'claude-code'
2
3/** aspect: 一覧の Svg の縦 / 横。領域を一覧の高さまで伸ばす行数を決める */
4export type StripProps = { aspect: number }
5
6type StripState = { from?: number }
7
8/**
9 * サムネイルの一覧に重ねる透明な領域。クリックした位置(横の 0–1)か、
10 * 横にドラッグした量(領域の幅に対する割合)をフックのモジュールへ送る。
11 * デスクトップのペインはホイールを Client に渡さないので、流すのは端の ‹ › のクリックかドラッグ
12 */
13const Strip: ClientModule<StripProps, StripState> = (props, surface) => {
14 const { Box, Text } = surface.elements
15 surface.onPointer(e => {
16 const now = surface.state ?? {}
17 const columns = Math.max(1, surface.columns)
18 if (e.type === 'down' && e.button === 'left') surface.setState({ from: e.x })
19 else if (e.type === 'move' && now.from !== undefined && e.button !== 'left') surface.setState({})
20 else if (e.type === 'up' && now.from !== undefined) {
21 // 1 マスまでの動きはクリック。それより動いたら、動いた分だけ一覧を流す
22 surface.post(Math.abs(e.x - now.from) <= 1 ? { pick: (now.from + 0.5) / columns } : { drag: (e.x - now.from) / columns })
23 surface.setState({})
24 }
25 })
26 // drag.tsx と同じく、高さは描いた行数で決まるので多めに空行を描き、重ねた Box で切る
27 const lines = Math.max(1, Math.ceil(Math.max(surface.columns, 40) * Math.min(4, Math.max(0.05, props.aspect || 1))))
28 return <Box flexDirection="column" width="100%">{Array.from({ length: lines }, () => <Text> </Text>)}</Box>
29}
30
31export default Strip
32types/index.d.ts 157 lines1/** ペインに出す縮小 JPEG(base64)とその寸法 */
2export type ReviewShot = {
3 preview: string
4 previewWidth: number
5 previewHeight: number
6}
7
8/** 差し替えられる前の版。縮小画像と、元画像の写し(拡大して比べるため) */
9export type ReviewVersion = ReviewShot & {
10 /** 元画像の写しのパス(.cache/versions の下)。写せなかったら '' */
11 snapshot: string
12 width: number
13 height: number
14 mtimeMs: number
15 /** 何版目か(1 から) */
16 version: number
17}
18
19/** kiri の警告が指した範囲を、出力画像の画素へ写したもの(左上が原点、右と下の端は含まない) */
20export type KiriRegion = {
21 /** 警告のコード(HALO_REMAINS / CONTOUR_ROUGH / RIM_CONTAMINATED) */
22 code: string
23 score: number
24 x0: number
25 y0: number
26 x1: number
27 y1: number
28}
29
30/** 出力画像と入力画像の対応(kiri の outputs[].source_rect / content_rect。両端を含む) */
31export type KiriMapping = {
32 /** 入力画像のパス(絶対にできなければ kiri に渡したまま) */
33 input: string
34 /** 入力画像の寸法(EXIF を当てたあと) */
35 inputWidth: number
36 inputHeight: number
37 source: [number, number, number, number]
38 content: [number, number, number, number]
39}
40
41/** --edge-repair auto の結果 */
42export type KiriRepair = {
43 repaired: number
44 gatedBy: string | null
45 before: Record<string, number | null>
46 after: Record<string, number | null>
47}
48
49/** kiri の --json の結果から拾った、出力画像 1 枚ぶんの情報 */
50export type KiriInfo = {
51 regions: KiriRegion[]
52 mapping?: KiriMapping
53 repair?: KiriRepair
54}
55
56export type ReviewImage = ReviewShot & {
57 /** 元画像の絶対パス */
58 path: string
59 /** 元画像の画素寸法 */
60 width: number
61 height: number
62 /** 一覧に並べる小さな JPEG(base64)とその寸法 */
63 thumb: string
64 thumbWidth: number
65 thumbHeight: number
66 mtimeMs: number
67 /** 透過のある画像。縮小と拡大は市松模様の上に重ねる */
68 hasAlpha?: boolean
69 /** 読んだときの元画像の写し(.cache/versions の下)。差し替えられたら前の版になる。写せなかったら '' */
70 snapshot?: string
71 /** 何版目か(1 から)。同じパスの画像が差し替わるたびに増える */
72 version?: number
73 /** 一つ前の版。差し替わったことが無ければ無い */
74 before?: ReviewVersion
75 /** この版を書いた kiri の結果から拾ったもの。--json の結果が無ければ無い */
76 kiri?: KiriInfo
77}
78
79/** 画像の上をドラッグして選んだ、まだコメントを付けていない範囲(元画像の画素) */
80export type ReviewSelection = {
81 path: string
82 /** 選んだときの画像の寸法。読み直して変わっていたら選択を外す */
83 width: number
84 height: number
85 x0: number
86 y0: number
87 x1: number
88 y1: number
89}
90
91/**
92 * コメントの状態。draft: まだ送っていない、sent: 送った、check: 送ったあと画像が差し替わった(確認待ち)、
93 * fixed: 人が「直った」を付けた、again: 人が「まだ」を付けた(次の送信に入れる)
94 */
95export type ReviewStatus = 'draft' | 'sent' | 'check' | 'fixed' | 'again'
96
97export type ReviewComment = {
98 id: number
99 /** どの画像へのコメントか(ReviewImage.path) */
100 path: string
101 /** 元画像の画素での範囲。画像全体なら 0, 0, width, height */
102 x0: number
103 y0: number
104 x1: number
105 y1: number
106 text: string
107 /** 無ければ draft(追跡を足す前のコメント) */
108 status?: ReviewStatus
109 /** 付けたときの画像の寸法。差し替えで寸法が変わったら、範囲を同じ割合の位置へ移す */
110 width?: number
111 height?: number
112}
113
114/** 元画像から原寸で切り出して大きく出している範囲 */
115export type ReviewZoom = {
116 path: string
117 /** 切り出したときの画像の版。差し替わったら作り直す */
118 version: number
119 /** 切り出した範囲(元画像の画素、左上が原点) */
120 x: number
121 y: number
122 w: number
123 h: number
124 after: ReviewShot
125 /** 前の版の同じ範囲。前の版が無いか、切り出せなかったら無い */
126 before?: ReviewShot
127 /** 前の版を切り出せなかった理由 */
128 beforeError?: string
129}
130
131declare module 'claude-code' {
132 interface PluginState {
133 'kiri-review': {
134 /** ペインに並べている画像。順序は一覧の順 */
135 images: ReviewImage[]
136 /** 大きく出している画像のパス */
137 current: string
138 comments: ReviewComment[]
139 /** ドラッグで選んだ範囲。無ければ次のコメントは画像全体へ */
140 selection: ReviewSelection | null
141 error: string
142 /** これまでに付けたコメントの番号の最大。番号を使い回さないため */
143 lastId: number
144 /** 一覧の左端に出しているサムネイルの番号(0 から)。多い画像は横に流して見る */
145 stripOffset: number
146 /** 「一覧から外す」を一度押した画像のパス。コメントのある画像は二度押しで外す */
147 confirmRemove: string
148 /** 拡大して出している範囲。無ければ画像全体 */
149 zoom: ReviewZoom | null
150 /** 前の版を出しているか */
151 compare: boolean
152 /** 拡大を作っている間の案内 */
153 busy: string
154 }
155 }
156}
157