SLOPSHOPPER

kiri-review

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

newpaneguardcommandtoasttool
v0.1.0MITupdated 2026-10-25tsutsumi389/kiri/plugins/kiri-review
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kiri-review
│ ┃ kiri review ✕ › fix the failing auth test and add an audit log call │ ┃ 画像がまだありません。/kiri-review <パス …> │ ┃ で開くか、Claude ⏺ Read(src/auth.ts) │ ┃ に「ペインで見せて」と頼んでください。 ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /kiri-review │ ⎿ kiri-review: kiri-review ペインを開きました。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · kiri review
画像がまだありません。/kiri-review <パス …> で開くか、Claude に「ペインで見せて」と頼んでください。
README

kiri-review

kiri が書き出した画像を Claude Code のペインに出し、場所ごとに「ここを直して」とコメントして Claude に渡す mod(関数フック製のプラグイン)。

使い方

  • /kiri-review <画像のパス …> か、Claude に「この画像をペインで見せて」と頼む(Claude は mcp__kiri-review__open を呼ぶ)。画像は何枚でも、ディレクトリを渡せばその中の画像を名前順に全部並べる(64 枚まで)。
  • 複数のときは上にサムネイルの一覧(番号つき、表示中は青枠、未解決のコメント数は赤丸)が横一列に出る。サムネイルをクリックすると、その画像を大きく出す。6 枚より多ければ、隠れている側の端に ‹ › が出て、押すと一覧が横に流れる(一覧の上の横ドラッグでも流せる)。一覧で全部を選べないときだけ「画像」の選択も出る。「一覧から外す」で 1 枚外せる(コメントのある画像は、もう一度押して確かめる)。
  • 画像の上の一行が、いま次にすることを案内する。直したい場所を画像の上でドラッグして選び(青い枠)、「コメント」に書いて Enter で追加する。クリックだけならその点のまわりの小さな範囲、選ばずに追加すれば画像全体へのコメントになる。付けたコメントは赤い枠と番号で画像に重なり、下の一覧に同じ番号と本文が並ぶ(× で消す)。画素範囲は Claude に送る文にだけ入る。
  • 「Claude に送る」は送るコメント(未送信と「まだ」)が 1 件でもあるときだけ出る。
  • 拡大: 範囲を選んで「拡大」を押すか、コメントの行の「拡大」を押すと、その範囲(周りに各辺 15% の余白、細長すぎれば短い辺を長い辺の半分まで広げる)を元画像から原寸で切り出し、長辺 640 px に引き伸ばしてペインに大きく出す。拡大の上でもドラッグして範囲を選んでコメントでき、その画素範囲は元画像の座標に戻して記録する。拡大中に選ばずに書けば、拡大している範囲へのコメントになる。「全体に戻る」で全体表示へ。
  • 前後の比較: 画像は読んだときに元画像を .cache/versions/ へ写しておく(APFS なら cp -c の複製で場所を取らない)。同じパスの画像が kiri の再実行などで差し替わる(mtime か寸法が変わる)と版が進み(見出しに「第 N 版」)、一つ前の版の縮小と写しを残す。「前の版と比べる」/「今の版を見る」で切り替えて見比べられる。拡大中なら、前の版の写しから同じ範囲(寸法が変わっていれば同じ割合の位置)を切り出して比べる。拡大中に差し替わったら、同じ範囲で作り直す。前の版を出している間はコメントを付けない。残す前の版は一つだけで、それより古い写しは片付ける。
  • 透過の下地: 透過のある画像は、ペインの縮小も拡大も市松模様の上に重ねて出すので、切り抜きの縁に残った背景が見える(Claude に送る切り抜きは従来どおりマゼンタの上)。
  • 指摘の追跡: 送ったコメントは消さず「送信済み」として残る。その画像が差し替わると「確認待ち」になり(トーストにも件数が出る)、各行の「直った」「まだ」で確かめる。「まだ」を付けたものは次の「Claude に送る」に再指摘として入る(直ったを付けたものは入らない、付け直しもできる)。一覧の赤丸と「画像」の選択の件数は、直ったを付けていないコメントの数。枠と番号の色は、未送信と「まだ」が赤、送信済みが灰、確認待ちが橙、直ったが緑(点線)。未送信・「まだ」・確認待ちのコメントのある画像は、別の画像を開き直しても一覧に残る(送信済みのまま差し替わっていないものや、直ったものだけの画像は残さない)。直ったを付けていないコメントのある画像を外すときは確かめる。一覧が 64 枚を超えて画像を外したときは、黙って消さずにそのことを知らせる。差し替えで画像の寸法が変わったら、コメントの範囲を同じ割合の位置へ移す(画像全体へのコメントは全体のまま)。
  • コメントは画像ごとに付く。「Claude に送る」で、全部の画像の分が画像ごとにまとまり、画像のパスと寸法、各コメントの元画像の画素範囲(と縦横の割合)がプロンプトとして届く。範囲を選んだコメントには、その範囲を切り抜いた PNG のパス(.cache/crops/ の下)も添える。切り抜きは確認用で、範囲の周りに各辺 15% の余白を付け、長辺を 512–1568 px にそろえ、透過部分はマゼンタ(#ff00c8)の上に重ねて縁の残りが見えるようにしてある。Claude は Read で開いて見る。画像全体へのコメントは切り抜かない。切り抜けなかったコメントは座標だけで送る。
  • kiri の指摘: kiri を --json で走らせると、結果の warnings[] のうち HALO_REMAINS / CONTOUR_ROUGH / RIM_CONTAMINATED の data.regions(入力画像の画素、両端を含む)を、outputs[] の source_rect / content_rect で出力画像の画素へ写し(source_rect の外は切り詰める)、橙の点線と K 番号で画像に重ねる。下の「kiri の指摘」の一覧に、コードと score が並ぶ。「拡大」で、その範囲を拡大する。「コメントにする」で、警告のコードを含む未送信のコメントになる。source_rect の無い出力(--rotate)は範囲を写せないので出さない。出し直して画像が差し替わると、新しい結果の指摘に入れ替わる(--json 無しで書き直されたら消える)。前の版には重ねない。
  • 入力画像の範囲: kiri の出力画像(source_rect / content_rect のあるもの)へのコメントは、送るときに入力画像のパスと、逆算した入力画像の画素範囲(両端を含む)、その四隅の多角形(画素と normalized)を添える。多角形は --fg-polygon / --bg-polygon(normalized は --normalized と併せて)にそのまま渡せる。余白(content_rect の外)だけのコメントと、画像全体へのコメントには添えない。
  • edge_repair: 結果に edge_repair(--edge-repair auto)があれば、解き直した数と、縁残り・ギザギザ・縁の混色の前後を一行で出す。
  • 「画像をコピー」で表示中の画像そのもの(縮小前)をクリップボードに入れる。PNG は透過のまま、JPEG と PNG 以外は PNG に変えて入れる。
  • 一度開いたあとは、Claude が kiri を実行するたびに、書き出した画像が一覧に入る(同じパスは差し替え、新しいものは末尾へ)。--json の結果があれば outputs[].path を全部拾うので、--derive / --sizes や batch の何枚もの出力も並ぶ。無ければ -o を拾う。

制約

  • 画像を描くのは Claude Code Desktop のペインだけ(Svg 要素)。ターミナルではコメント欄のみで、コメントは画像全体へのものになる。
  • ドラッグは、画像の上に重ねた透明な Client(hooks/drag.tsx)がポインタを受けて取る。Client の座標は文字のマス単位なので、範囲の細かさはペインの文字の大きさほど(端末が画素を返す面ではそれより細かい)。
  • 縮小画像は macOS の qlmanage と sips で作る(HEIC の回転指定を反映するため Quick Look を使う)。透過のある画像の縮小は、市松模様に重ねるため JXA(AppKit)で作る。拡大は切り抜きと同じ手順(下記)で切り出し、sips で JPEG にして、Svg の上限に収まるまで長辺と品質を下げる。切り抜きは、透過も回転指定も無い画像なら sips -c --cropOffset、それ以外(透過、EXIF の回転、HEIC)は osascript -l JavaScript の AppKit(NSImage は表示の向きで読む)で切り抜いて背景色に重ねる。sips の切り抜きは回転前の画素に効き、背景に重ねることもできないため。macOS 専用。
  • 前の版は画像ごとに一つだけ。写しは、読み込みのたびと起動時に、どの画像も指していないものを片付ける。合計が 2 GB を超えたら、古く写したものから消す(消えた前の版は拡大して比べられない)。写したあとに元画像の mtime を読み直し、写している間に書き換わっていたら写し直す。
  • Svg の source は 131072 文字まで、Select の選択肢は 64 個まで。超えるとペインごと描かれない。一覧のサムネイルは上限に入る枚数だけ描き、残りは「画像」の選択から選ぶ。

インストール

/plugin install kiri-review --marketplace tsutsumi389/kiri

開発

claude plugin validate plugins/kiri-review
claude plugin test plugins/kiri-review
Source 4 files
hooks/register.tsx 1757 lines
1import { 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 lines
1import 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
77
hooks/strip.tsx 32 lines
1import 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
32
types/index.d.ts 157 lines
1/** ペインに出す縮小 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