SLOPSHOPPER

usage-guard

Show rate-limit usage; when it gets tight (or after the Fable window ends) send Fable subagents to Opus instead

newguardtoaststatus
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-guard
› fix the failing auth test and add an audit log call ⏺ 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 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ usage-guard: 用量 5h 31%
README

<img src="docs/assets/banner.svg" alt="darkroom — Your Lightroom presets, on your own machine" width="100%">

<b>在自己電腦上,用你買的 Lightroom preset 修照片。</b><br> 挑 preset → 調強度 → 微調滑桿 → 即時預覽 → 匯出。不用訂閱,不上傳雲端。


這是什麼

退了 Lightroom 訂閱之後,手上買過的幾百個 XMP preset 就沒地方用了。darkroom 直接讀這些 .xmp,把裡面的 Lightroom 設定(曝光、對比、亮部/陰影、HSL、色彩分級、曲線、漸層遮罩……)用 GPU 在本機重算出來,拖滑桿就能即時看到結果。

讓人省心是這個工具唯一的目標:你只需要專注在擅長的照片編輯,其他的——檔案、路徑、格式、設定——交給 darkroom。 原則很簡單:preset 裡寫的設定一律由程式照算;AI 只做程式做不到的事(看懂畫面、找出位置、重畫內容)。照片原檔和買來的 preset 原檔,darkroom 永遠只讀不寫。

<img src="docs/screenshots/editor-v2.png" alt="darkroom 編輯畫面:左邊 preset 庫、中間預覽與強度、右邊滑桿" width="90%">

<img src="docs/assets/flow.svg" alt="Preset → Strength → Adjust → Preview → Export" width="90%">

功能

功能說明
套 preset解析 Lightroom PV2012 系列 XMP(ProcessVersion 6.7/10.0/11.0/15.4),強度 0~200%;Adobe 沒公開的部分(清晰度、紋理、去朦朧、亮部陰影)用公開演算法近似
即時預覽GPU 渲染,1.5MP 全套運算約 15~20 ms;拖滑桿時只算最新一次
微調滑桿在 preset 之上逐項加減;復原/重做;按住看原圖
讀檔JPEG、PNG、TIFF(含 16-bit)、HEIC/HEIF(iPhone,10-bit、Display P3 轉 sRGB);依 EXIF 自動轉正
A/B 對照編輯前/後拖分隔線對照,隨時「還原成原圖」,也能取回上一份編輯
裁切與旋轉拖框、鎖比例(原始、1:1、4:5、2:3、16:9、自由…)、拉直、轉 90°、鏡像;縮圖、預覽、匯出都套同一個範圍
匯出JPEG(品質或檔案大小上限)、PNG、TIFF(8/16-bit)、WebP;只縮不放的尺寸;中繼資料全留/只留版權/全移除,可單獨拿掉 GPS;輸出銳利化;匯出預設;嵌入 sRGB,永不覆蓋;24MP 約 0.4 秒/張
preset 庫群組樹、搜尋(含 AI 寫的中英文風格標籤)、最愛、改名、搬移、匯入 .xmp、把目前的修改存成自己的 preset、匯出成 Lightroom 讀得到的 .xmp——整理只改索引,原檔不動
照片庫資料夾縮圖格、多選;每張照片的修改自動保存(以照片內容對應,搬移改名都不會掉);複製/貼上修改;匯出所選
設定設定頁(語言 zh-TW/en-US、資料夾、AI、ComfyUI)、改了立即生效;設定可匯出/匯入
給 AI 代理用CLI(--json 固定格式)與 MCP server(38 個 darkroom_* 工具,例如取回上一份編輯 darkroom_edit_restore/CLI edit restore),和 App 共用同一套操作與錯誤訊息

偵測不到的功能(CUDA、HEIC、WebP、語意搜尋、ComfyUI…)會自動關掉並說明原因。還沒做的:RAW、AI 修圖建議、AI 遮罩、去雜物/美顏,見 開發文件的路線圖。

下載安裝

安裝檔(建議):到 GitHub Releases 下載 Windows 的 .msi/-setup.exe,或 Linux 的 .deb/.AppImage。第一次開啟時會說明要下載 Python 與 PyTorch(有 NVIDIA 顯示卡約 3 GB),你按同意才下載,之後就直接進編輯畫面。同一頁還有命令列執行檔 darkroom(給 AI 代理或腳本用)。

從原始碼:需要 Python 3.13、Node.js 22+、NVIDIA GPU(CUDA)。或者把 docs/agent-install.md 交給你的 Claude Code/Codex/Cursor,它會一步一步裝好、遇到要你決定的地方先問你。

兩種方式的完整步驟、GPU/CUDA 說明與疑難排解都在 安裝說明。

快速開始

5 分鐘上手:第一次啟動 → 指定 preset 資料夾 → 修一張 → 匯出,以及 CLI 與 MCP 各一個例子。

其他文件:設定、架構、開發與發版、給 AI 代理的使用手冊、用詞定義。

安全保證

darkroom 會被你自己和 AI 代理操作,所以這幾條是寫成測試守住的,不是口頭承諾:

  • 照片原檔、買來的 preset 原檔永遠只讀。 程式只有一個寫檔出口(safe_write):只建新檔、限定在指定資料夾、拒絕寫進 preset 資料夾、拒絕替換或刪除照片與 .xmp。
  • 匯出永不覆蓋,同名一律加序號;匯出到照片所在資料夾時,原檔前後的 SHA-256 不變。
  • 只綁 127.0.0.1,而且擋掉「你瀏覽器裡的其他網頁」:檢查 Host、Origin、Sec-Fetch-Site、Content-Type,讀照片路徑的請求要帶專用標頭,HTTP 也不收任意寫入路徑。
  • 金鑰不落地:AI 功能的金鑰只記 1Password 參照(op://…)或讀環境變數,設定檔裡放金鑰本身會被拒絕。
  • 測試全程掛寫檔守門(Python audit hook):任何測試只要寫到宣告範圍以外就直接失敗,受保護的資料夾前後比對雜湊。

授權

MIT。darkroom 不附任何 preset;你買來的 preset 依它們原本的授權使用。

Source 1 files
hooks/register.ts 44 lines
1import type { Register, SessionRateLimit } from 'claude-code'
2
3// Temporary mod for the darkroom campaign (2026-10-09): the user asked for Fable subagents until
4// 2026-10-10 02:00 +08:00, then left model choice to the main session; this mod only steps Fable
5// subagents down to Opus 5.5 whenever usage gets tight.
6const TIGHT_PERCENT = 85                                   // any 5-hour or 7-day window at or past this is "tight"
7const WINDOWS = ['five_hour', 'seven_day']
8
9let limits: SessionRateLimit[] = []
10let warned = ''
11
12const label = (k: string) => (k === 'five_hour' ? '5h' : k === 'seven_day' ? '7d' : k)
13const tightOnes = () => limits.filter(l => WINDOWS.includes(l.kind) && l.percentUsed >= TIGHT_PERCENT)
14const summary = () => limits.filter(l => WINDOWS.includes(l.kind)).map(l => `${label(l.kind)} ${l.percentUsed}%`).join(' · ')
15
16const statusText = () => {
17  const s = summary()
18  return s ? `用量 ${s}${tightOnes().length ? '(緊繃:子代理改 Opus)' : ''}` : undefined
19}
20
21export const register: Register = on => {
22  on('session.start', async ($, e, next) => {
23    const r = await next(e)
24    try { limits = (await $.session.usage()).rateLimits } catch { /* no reading yet */ }
25    $.ui.status(statusText())
26    return r
27  })
28
29  on('session.measure', ($, e, next) => {
30    limits = e.rateLimits
31    $.ui.status(statusText())
32    return next(e)
33  })
34
35  on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
36    if (e.model !== 'fable') return next(e)
37    const tight = tightOnes()
38    if (!tight.length) return next(e)
39    const why = `用量緊繃(${tight.map(l => `${label(l.kind)} ${l.percentUsed}%`).join('、')})`
40    if (warned !== why) { warned = why; $.ui.toast(`usage-guard:${why},子代理改用 Opus 5.5`) }
41    return next({ ...e, model: 'opus' })
42  }).catch(($, e, next) => next(e))   // never block a subagent because this mod failed
43}
44