Changelog
Unreleased
v1.5.1 - 2026-10-14
過卡的 MHL 清單快了一倍以上:複製完之後不再把整張卡重讀 3 遍。 寫清單時沿用複製當下已經讀回驗證過的 hash(檔案大小與修改時間都沒變才採用), 確認清單時只讀一遍(原本同一份資料算兩次)。每個檔在目的地上的讀取次數從 4 次降為 2 次。 實際一次 117 GB、SMB 走 Wi-Fi 的過卡:MHL 階段原本 6 小時 42 分,照新流程約為原本的三分之一(#535)。
DIT 頁顯示每個檔的複製速度。 「最近的檔」每列多一欄 MiB/s,--progress json 的
file 事件帶 copy_mib_s/verify_mib_s,每個目的地的 summary 帶整體 copy_mib_s。
過卡慢的時候當下就看得出來,不用事後查(#537)。
💡 NAS 用 Bonjour 名稱掛載時,SMB 可能一直走 Wi-Fi(實測 6 MiB/s,有線網卡閒著)。 改用 IP 掛(
smb://<NAS 的 IP>/<共享名稱>)可以避開。arkiv 的複製本身跟cp一樣快。
v1.5.0 - 2026-10-14
Log 素材進庫就是正常顏色了。 S-Log3、D-Log、C-Log、N-Log、F-Log、Apple Log、V-Log 這類 log 模式拍的片,縮圖、vision 讀的關鍵畫面、播放器以前全是灰的;現在會換算成 Rec.709 給你看, 原始檔一個 byte 都沒動。
- 12 個內建色彩設定檔:Sony S-Log3/S-Gamut3、S-Gamut3.Cine,DJI D-Log,Insta360 I-Log, Canon C-Log2/C-Log3,Nikon N-Log,Fujifilm F-Log/F-Log2/F-Log2 C,Apple Log,Panasonic V-Log。 每一個都是 arkiv 依廠商公開發表的曲線與色域公式自己算出來的,不內附任何廠商的 LUT 檔; 曲線用白皮書上的 0%/18%/90% 參考點逐一驗過。這是技術轉換、不是廠商的調色風格, 跟廠商 LUT 不會逐點相同(以 Sony s709 對照:18% 灰 0.426 vs 0.429,90% 白 0.791 vs 0.780)。
- Sony 自動辨識:A7 系列、FX 系列的 XAVC 檔會自己標上 S-Log3。其他廠牌在 Inspector 指定一次即可。
- 自訂 LUT:Inspector 的 COLOR 選單「匯入 .cube…」,或把
.cube放進~/.arkiv-luts/(ARKIV_LUT_DIR可改)。DJI D-Log M 與 Apple Log 2 沒有公開數學,請匯入廠商提供的.cube。 - 播放器即時套用:有設定檔的片子,Inspector 播放器右上「LUT ● / LOG ○」隨時切回原始 log (proxy 本身保持 log,所以切得回去)。套用中時點畫面或按空白鍵播放/暫停。
- 舊素材庫一次補完:
python ingest.py --regenerate-thumbnails會偵測並重生封面; 關鍵畫面要等下次--refresh才套用。 - API:
GET /api/color-profiles、PATCH /api/media/{id}/color-profile、批次POST /api/media/color-profile、POST /api/color-profiles/import。
長片的 proxy 不再白等 10 分鐘然後失敗。 proxy 轉檔的時限原本寫死 600 秒, 4K60 HEVC 在 M2 Max 上大約 0.9 倍即時,超過約 11 分鐘的片全部燒滿 10 分鐘才放棄 (實際一次 ingest:25 支裡 4 支沒有 proxy、多等 40 分鐘)。現在時限依片長給(片長 × 3,最少 600 秒、最多 4 小時)。
FFmpeg 7 以上:長片的關鍵畫面重新依場景切換挑選。 FFmpeg 拿掉了 -vsync,
場景偵測每次都失敗、靜默退回固定位置取格;改用 -fps_mode(FFmpeg 5.1 起支援,arkiv 最低需求 6.0)。
-
批次匯入結束多一行對帳。
Done.之後印出 scanned/ingested/already-indexed/moved/failed, 任何一支沒乾淨進庫的檔都會列出檔名,不再需要自己拿磁碟清單跟資料庫比對(#393)。 -
修復已在庫的音檔列:
python fix_audio_cover_metadata.py。 #521 修好之前進庫的帶封面音檔,codec/fps/寬高存的是封面的值(mjpeg、90000、300×300)。這支指令預設只列出受影響 列數與樣本、不寫入;加--apply才清成 NULL,寫入前自動把 DB 備份到同目錄的<db>.pre-521-<時間>.bak並核對列數,備份失敗就不寫。只動音訊副檔名的列; 看起來也帶封面的影片列只列出來,請用ingest.py --refresh重新探測,不用猜的。 -
ingest 對帳不平時 exit 7,不再 exit 0。 批次結束的
Reconcile:那行若發現有檔案掃到了 卻沒落到任何分類、或計數說進庫了但 DB 沒有那一列,現在以 exit 7 結束(#393)。 原本 0 只代表「沒當掉」,不代表「每支都進庫」。7 是新號碼,0–6 的意義都不變; 同時符合時優先序 2 > 7 > 1。watch.py遇到 7 跟其他失敗一樣:記錄、退避、重試, 監看不會停,且 log 會印出 exit code 與對帳行(原本只印 stderr,而對帳寫在 stdout, 所以只會看到一個沒有原因的「✗ 檔名:」)。 -
帶封面的音檔不再被記成「300×300 的 mjpeg 影片、90000 fps」。 MP3/M4A/FLAC 內嵌的 專輯封面,ffprobe 會報成一條 video stream(只靠
attached_pic標記區分),ingest 把它當成 畫面軌,codec/fps/寬高全部抄自封面。現在 ingest 跳過封面、音訊副檔名一律不讀畫面資訊;/api/stream對舊資料回填 codec 時也不會再把mjpeg寫進音檔列(#521)。 已經在庫裡的錯誤值不會自己變回來,需要另跑修復指令。 -
健檢會發現「模型名稱沒變、算出來的向量變了」。 升級 Ollama、重新 pull 同一個 tag、 或換了量化版本,都可能讓
bge-m3這個名字底下的向量悄悄改變;原本的檢查只比對模型名稱, 抓不到。結果是新的查詢落在跟舊索引不同的空間裡,搜尋變差卻沒有任何錯誤。python health.py現在會拿幾段已存的內容重算一次,跟存著的向量比對, 差太多(cosine < 0.999)就 WARN 並提示python embed.py --rebuild。 索引是空的或連不上時明確標「not measured」,不會算成通過。 -
搜尋結果會告訴你是在第幾秒講到的,點進去直接停在那裡。 命中逐字稿的結果, 摘要前多一個「▶ 01:23」;點開素材後播放器跳到那一秒(不自動播放)。 語意命中取「那段文字開始的地方」,關鍵字命中取「這個詞第一次出現的地方」。 索引本身沒有時間資訊,時間是查詢當下從逐字稿分段(segments)對回去的, 不需要重建索引,舊素材庫直接有。逐字稿經過標點修復、繁簡轉換或校正後, 文字跟分段不會逐字相同,所以用去掉標點的短片段比對;對不上就不顯示時間, 不會用猜的(標籤/畫面描述命中、沒有分段的素材也不顯示)。
v1.4.0 - 2026-10-11
你開著 arkiv 的時候,你瀏覽的任何一個網頁都可能替你按下刪除鍵。這一版把它關上。
arkiv 對從本機(127.0.0.1)來的請求一律信任 —— 那是「自己的電腦上不用輸 token」的由來。 但一個惡意網頁可以把它自己的網域重新解析到 127.0.0.1(DNS rebinding): 瀏覽器認為那是同源,請求的來源又確實是本機,於是那個網頁拿到的是全部 12 種權限, 包括批次刪除、清空回收桶、以及發一把 admin token 給自己。
現在本機信任多看一件事:請求的 Host。只有 IP、localhost/*.localhost、
以及你自己列在 ARKIV_ALLOWED_HOSTS 的名字才算本機;所有寫入路由(原本只有 13/52 支)
都套上同站檢查。順帶修掉三個同一輪審計找到的 MED:轉送標頭不能再拿來繞過 token 的 IP 白名單、
WebSocket 的 ?token= 不再明文寫進 backend.log、/api/logs/tail 輸出前會再遮一次。
升級前要知道
- 🔴 如果你用
localhost或 IP 以外的名字開 arkiv(例如mymac.local、tailnet 的 MagicDNS 名稱、 反向代理的網域),升級後會拿到 401,訊息會直接指向這個設定:ARKIV_ALLOWED_HOSTS=mymac.local,arkiv.example.com(逗號分隔,附加在內建清單之後)。 - 放在反向代理後面的:uvicorn 現在一律以
--no-proxy-headers啟動,帶轉送標頭的請求 不能使用有 IP 白名單的 token。確定代理是你的,才設ARKIV_TRUST_PROXY_HEADERS=1。 - Docker 的對外埠預設只綁本機、Pro 改為簽章授權檔:見下面兩段。
Pro 授權改為簽章驗證 —— 手寫的授權檔不再解鎖。
以前 ~/.arkiv/pro-license.json 只要寫了 licensee 和 key 兩個欄位就算 Pro,任何人自己寫一個就有。
現在授權檔必須帶授權方的 Ed25519 簽章,由 arkiv 以內建公鑰離線驗證(pro_licence.py,純 Python,
不需要額外套件):不連網、不會過期、沒有啟用伺服器。
- 1.1.0 前就在使用的安裝不受影響:無限專案與跨專案聚合照舊,不需要任何授權檔(附加條款第 3 點)。
- 授權檔在、但不算數時,
/api/entitlements的licence_problem會說原因(未簽章/簽章不符/未知金鑰/已撤銷)。 - 名為
arkiv_pro的模組不再「存在就算 Pro」,必須透過has_valid_license()回報。 - 2026-10-08 之前沒有售出任何 Pro;當時發出的授權已改發簽章版。
授權:PolyForm Perimeter 1.0.1 加上「附加條款」—— 修改核心來繞過免費額度或開啟 Pro 功能,不被允許(自己用也一樣)。
原本的條款只禁止「提供他人競爭產品」,而 PolyForm 允許為任何允許的目的修改程式;
免費額度又刻意不做技術鎖(entitlements.py:善意承諾,不是 DRM)。兩者疊起來,
自己改掉上限自用在條款上很可能是允許的 —— 這次補上。PolyForm 原文一字不改,
附加條款放在它前面;1.1.0 前就在使用的安裝永久保留無限專案與跨專案聚合(附加條款第 3 點)。法律本身允許的修改(如著作權法第 59 條)與 Fair Use 不受影響。只適用於含這段文字的版本。
順帶:README 的「尚未開賣」改成實況(2026-10-03 起販售)。
新增:剪輯用 proxy —— 寫到原始檔旁邊的 Proxy/<原檔名>.mov,Resolve 可以直接連結。
Inspector 的 proxy 給不了剪輯軟體用:它叫 {id}_{hash}.mp4(Resolve 靠檔名配對 proxy)、
沒有 timecode、只留一條雙聲道、720p H.264,而且只在瀏覽器播不了的編碼才會產生。
這次另外加一份選用輸出,Inspector 的 proxy 一個字都沒動:
-
ProRes 422 Proxy、保留原始 timecode、保留全部音軌,最高 1080p 且不放大(
ARKIV_EDITOR_PROXY_HEIGHT) -
絕不覆蓋:
Proxy/裡已經有同名.mov/.mp4/.mxf(剪輯師自己做的)就跳過;編碼途中有人放進來也是對方贏 -
先寫隱藏暫存檔,成功才搬到定位;失敗會清掉暫存檔,以及這次才建立的空
Proxy/資料夾 -
用法:
python editor_proxy.py <media_id>…或--all;APIPOST /api/proxy/editor({"ids": […]},ingest_write)、GET /api/proxy/editor/status、GET /api/proxy/editor/{media_id} -
Inspector 的 Reprocess 區多一顆「剪輯用 proxy」:按下去會等到做完,直接顯示寫到哪裡、已經有同名檔所以沒覆蓋、或失敗的原因
⚠️ 它會寫進你的素材資料夾(這正是重點,剪輯軟體才找得到)。唯讀的素材碟會回 failed,不會改寫別處。
Docker:對外埠改成預設只綁本機(127.0.0.1)。
原本 docker-compose.yml 把 8501(API)、8502(MCP)、11434(Ollama)開在所有網卡上,
同一個區網的人拿到 token 就能讀素材庫,Ollama 則是誰都能用。
🔴 如果你是從別台機器連 Docker 裡的 arkiv(例如區網 MCP、另一台電腦開網頁),
升級後要加一個環境變數才連得到:ARKIV_BIND_HOST=0.0.0.0 docker compose up -d。
只在同一台電腦上用的人不受影響。
Fixed
- 現場收音被 VAD 判成「沒人聲」,逐字稿是空的,而且沒有任何訊息。
一場 200 支的現場素材(PA + 人群)有 85 支逐字稿為空,繞過 VAD 重跑後 54 支解出人聲。
現在 VAD 說沒人聲時會改餵整段給 Whisper,再交給既有的幻覺防護判斷,並且印出
[vad] no speech detected — decoding the full file anyway。 ⚠️ 已經是空逐字稿的素材不會自己好:在 Inspector 對它們按「重新轉錄」。 - 轉錄的整批靜音判斷被幾段短配樂拖垮。 三段 1 秒的 BGM 尾巴可以讓 30 秒清楚的人聲整份清空; 現在同時看段數平均與時長加權平均,取較不激進的那個。
- 匯出:相機 timecode 換算錯。 23.976 的相機起始 TC 01:00:00:00 被匯成 00:59:56:10,
EDL/FCPXML 的 source 落在素材第一格之前。DF/NDF 改看相機 TC 本身的分隔符,
單支 FCPXML 錨在相機 TC,
src會做 URL 編碼(空白/#/%讓剪輯軟體找錯檔), camera report 的 DF 標籤也修好了。之前匯出的 EDL/FCPXML 請重新匯出。 - Windows 上讀 Mac 寫的路徑(
/Volumes/…)被當成相對路徑,接在專案根目錄後面然後報錯。 - 繁中 Windows 上,git/ffmpeg/ffprobe 的輸出被用 cp950 解碼,失敗時
returncode仍是 0、輸出卻是None。 - 1 秒以下的素材永遠沒有縮圖(取格時間被推過檔尾)。
- 相機的旋轉旗標錯了會毀掉整條下游(寬高對調、縮圖直式、視覺模型看側躺畫面)。
ingest 現在會印出偵測到的旋轉;確定旗標是錯的那一批,用
ARKIV_IGNORE_ROTATION=1匯入。 - 播放失敗一律說成「編碼問題」。 401、404、後端沒開也被叫去建 proxy;現在會說真正的原因。
/api/stream叫你去建 proxy、proxy builder 卻不建的那幾種編碼(dnxhd/qtrle/mjpeg/cinepak),現在建得出來。- 字幕:gap pass 把原本合格的 cue 削短,merge pass 再把它併進下一句,讓下一句的字最多提早 0.88 秒出現。
- 刪除、評分、精選集複製會假裝成功。 只刪了 metadata 不再顯示「已移至回收桶」; 評分不再順手清掉評分備註;複製失敗以錯誤提示呈現。
- Inspector 標完 IN/OUT 後 350 毫秒內切到下一支,會清掉下一支已存的範圍。
- 回收桶還原在桌面 App 裡幾乎必定 500;「清空全部」改成要確認(列出件數與容量); 未掛載的儲存不再被當成「檔案不見了」清掉。
- 校正字典讀取失敗被當成空字典,存檔時把整份覆蓋掉。現在會警告,存檔前留
.bak。 - Resolve 外掛:不同卡的同名
C0001.MP4勾第一列卻匯入最後一支;評分/標籤改依來源路徑比對; LAN 版下載保留原檔名並串流寫入,不再整支讀進記憶體。 - 過卡(offload):拷完到真正完成之間的兩趟雜湊沒有任何進度(實測 35 分鐘); 一顆碟的 MHL 失敗會讓第二顆碟根本沒被拷,畫面還只顯示「exit 1」。
- 匯入:長素材在健康轉錄時被判超時、Windows 上 watchdog 留下 ffmpeg/whisper 孤兒行程、 跑完的匯入被回報成「已停止」、一個非 UTF-8 位元組讓匯入被誤殺、 打錯一個調校用環境變數會讓整個 API 起不來。
- Projects 頁沒說你現在開的是哪個庫,註冊表看起來像切換器(它不是)。
- CLI 的
--all-projects沒有套用跟 API 一樣的跨專案授權檢查。
Added
- 搜尋框的
tag:/camera:/lang:/rating:/type:/shot:真的會篩了,「所有專案」搜尋也一樣。 以前整串被當成文字丟進語意搜尋,格爾木氧氣 camera:a7s3會回 0 支或回整個庫。 - 匯入的時間上限拆成「靜默多久」與「總共多久」,停下來時會說是哪一道; 原本寫死 30 分鐘(實測 200 支要 94 分鐘),而 UI 實際走的那條路根本沒有上限。 掃描時就顯示預估時間。
- 瀏覽器版的匯入頁有常用來源捷徑(瀏覽器沒有選資料夾的能力,以前每次都得手打路徑)。
- 索引過期看得見:
GET /api/embed/status,匯入頁的「重建索引」底下顯示幾支待更新/尚未索引。 - 剪輯欄位的修改紀錄:評等、IN/OUT、camera、標籤每次真的改動,都會在
.arkiv/audit/edits.jsonl記一行修改前後的值。 - 健康檢查會抓「前端是舊的」:
frontend/dist比最後一次前端原始碼的 commit 舊時回報失敗。
Changed
- 官網改成多頁站(首頁、安裝、文件、FAQ、更新紀錄),首頁功能區改成會動的介面示意;購買 Pro 改成實心按鈕。
- Pro 條款定稿:數位內容交付後不退款、管轄法院為臺灣臺北地方法院。
PUBLIC-INTEREST.md、README、FAQ 裡過期的說明更新為現況(專案上限 1.1.0 起已實施、環境變數預設路徑等)。
v1.3.0 - 2026-09-05
如果你的素材庫裡有 JPEG,它們一直是半盲的。 每一張進庫的 JPEG 都沒有縮圖、沒有格、沒有視覺標籤 —— 而且沒有任何錯誤訊息。
ffprobe 把 .jpg 報成 mjpeg 且 duration 0.040000(單格,假設 25fps),
所以「這是不是靜圖」的舊判準 duration_s <= 0 把每一張 JPEG 當成影片,
送進「跳到中間點再取一格」的路徑。而 -ss 放在 -i 之前對單格 mjpeg
即使 -ss 0 也編不出東西(ffmpeg 回 exit 0,訊息是 “Output file is empty”)。
.png 與 .webp 報 duration=N/A → 0.0,所以它們一直是好的 —— 只有 JPEG 壞。
看得見的損失不是那張缺席的縮圖。零格代表 ingest.py 的
if not skip_vision and frame_data: 從來沒有成立過,視覺模型一張 JPEG 都沒看過:
沒有場景描述、沒有自動標籤,而且用內容搜尋找不到它。
🔴 既有素材庫要把那些 JPEG 重新 ingest 才會補上。 缺的是衍生檔與標籤、不是原始檔, 所以重跑就會好,不需要重新轉錄。
缺陷由外部使用者 pixb 回報(issue #418)。他報的是圖片預覽, 實際去 ingest 一張圖驗證時才發現 PNG 沒事、JPEG 全壞。
Fixed
- 每一張 JPEG 都沒有縮圖/格/視覺標籤(上述)。
- 跨行程寫入之後,搜尋看不到新素材,而
count()一直是對的。 ChromaDB 的count()讀 SQLite、query()讀行程內的 HNSW 索引, 所以「筆數對、但搜尋不到」不是矛盾,是兩個資料來源。 典型情境:CLI ingest 完成,開著的 UI 搜尋不到那批新素材,直到重啟服務。 - 刪掉的素材卡在每一個精選集裡,而那個編號會被下一支素材重用。
media.id是INTEGER PRIMARY KEY沒有AUTOINCREMENT,SQLite 會把刪掉的編號 發給下一次匯入。所以殘留的精選集條目不是永遠顯示「已從庫刪除」—— 它只停在那裡到下一次匯入為止,之後就乾乾淨淨地指向你從沒放進去的素材, 而精選集正是跨專案複製的來源。 刪除現在會同步清掉所有精選集裡的引用;已經殘留的舊條目則靠檔名比對抓出來, 標成「已換成別支」而不是靜靜地指向別人。 - 瀏覽器播不動的編碼一律回 409,而不是只有 hevc 與 prores 兩種。 先前 mjpeg/qtrle/dnxhd/cinepak/rawvideo 都是把原始 bytes 直接交給播放器: 一片黑、沒有錯誤訊息、也沒有辦法要求 proxy。改為 allow-list —— 瀏覽器能播的 編碼是一份短而穩定的清單,播不動的則是無窮無盡的。
docker compose掛了 arkiv 產生的東西,卻從來沒掛使用者的素材。 跨庫精選集與專案 registry 也會持久化了(先前down或重建映像會靜默清空)。- 打包版離開時,backend 與我們啟動的 ollama 沒有被收掉。
Added
- 字幕有了時間維度。 先前只有行寬與斷點規則,沒有任何東西在管 「一句字停多久」「兩句之間有沒有呼吸」。 ⚠️ 規格是量出來的:拿一個 34 支、312 段的真實素材庫實測, CPS(字/秒)中位數 4.7、只有 1.3% 超過 9 —— 原本假設的「字太快」不成立。 真正的缺陷是 28 個過短的 cue 與 256 個零間隙(字接字,沒有喘息)。 依實測加上最短/最長顯示時間、最小間隙、目標 CPS 三道 pass, 違規率 10.2% → 1.7%。既有素材庫不需重新轉錄,排版是算繪時套用的。
- MCP 走 HTTP/SSE,LAN 上的 client 連得到(先前只有 stdio,等於只有本機)。
唯讀工具,沿用 HTTP API 同一套 token store、IP 白名單與到期時間,
另需
videos_readscope。預設仍綁127.0.0.1,要開給 LAN 得自己改; 真正的關卡是 Host 白名單而不是綁定位址。 - media pool 與素材卡片顯示拍攝日,不是入庫日。 (62 支的測試庫裡,56 支有日期的素材中有 55 支兩者的年份不同。)
Changed
- 落地頁的 hero 從「承諾」換成「機制」:講 arkiv 實際做什麼 (有畫面的分析畫面、有聲音的轉錄),而不是講一句每個競品都說得出口的話。
v1.2.0 - 2026-09-02
每一份在 Mac 上產生的逐字稿,時間碼都是錯的 —— 而且錯得沿著片子越走越大。 這一版修好它。如果你用 arkiv 做過訪談或字幕,這是你要的那一版。
缺陷由一位外部使用者回報:點逐字稿的時間碼跳轉,會對不上、或跑回 00:00。
那不是她的環境問題 —— 而且她連修法都一起寄來了,設計與本版採用的相同。
致謝見 ACKNOWLEDGEMENTS.md(具名方式待她本人回覆,本專案先問過再寫)。轉錄前 arkiv 會跑 VAD 把靜音切掉、把語音接成一段沒有空隙的
音檔再送進 whisper —— 而那個函式算完「每一段語音原本在哪裡」之後只回傳了檔案路徑,
對照表就此遺失,裁切檔幾行後被刪除。於是 whisper 報回來的每一個時間都是
「無空隙語音時間」,而 arkiv 裡其他每一個時鐘(畫格、波形、播放器、EDL 來源 TC)
都是媒體時間。誤差等於那句話之前被移除的靜音總和,所以它沿著片子累積遞增。
影響 _transcribe_mlx(所有 Mac)與 _transcribe_faster_whisper(非 Mac 預設);
_transcribe_whisperx 讀原始 wav 不受影響 —— 三個後端對「3.5 秒」的定義並不一致。
🔴 既有素材庫必須重新轉錄才會拿到正確時間碼。 沒有「只補 offset」的捷徑:
stamps 被消費即丟,來源 wav 是當場刪除的暫存檔,只能整支重跑。重跑請帶 backup=True。
Fixed
- VAD 時間碼偏移(上述)。
_vad_filter()現在回傳(path, offset_map), 以bisect將 segment 與逐字時間碼在下游看到之前就映射回媒體時間。 - 使用者從 UI 下載的每一份 SRT/VTT,從來沒經過排版引擎。
subtitle.py(CJK 行寬、 斷點規則、雙語 cue)全樹只有 CLI 與它自己的測試在 import;UI 那三條路各自走手寫的 cue emitter。三條路現在共用同一個 seam。 - mlx 後端向 whisper 要了逐字時間碼,然後把它丟掉 —— 每一支在 Mac 上 ingest 的素材,
words_json都是 NULL。 - 潤稿在長逐字稿上靜默逾時,然後把未標點的原文當成結果回傳,零 log。現在切塊處理、 每塊逾時、並有整體預算;到點就把剩下的原樣回傳並記錄。
- 單支重轉錄完全不拿 ingest 鎖 —— 批次跑到一半點單支重轉,等於兩個 whisper 同時解碼。
- 畫一條波形要去解 4K 原檔;剪接室既有的 proxy 現在會被優先採用。
.mts探測出來是 h264 但瀏覽器播不了;ProRes 原檔配 ProRes proxy 不能算「能播」。- 舊 library 的「在 Finder 顯示」永遠回 403。
- 視覺標籤與結構化欄位仍會把簡體字漏進索引。
- ⌘Q 離開時,backend 與我們啟動的 ollama 都沒有被收掉。
- 側欄那份「Projects」清單其實從來不是專案清單,是你當下開著的那個庫的資料夾名,
而且從未呼叫
/api/projects。現在讀 registry,並顯示這個安裝的免費額度用量。
Added
- 靜態圖片成為一等公民(PNG/JPG/WebP/GIF/SVG):與影片同庫、同樣可搜尋與篩選。
- 回收桶:刪除改為移入
.arkiv/trash而非 unlink,可還原;刪除時同步從跨庫精選集 移除該筆,精選集計數不再指向已消失的素材。 - 第二條 ASR 引擎,走 OpenAI 相容的
/audio/transcriptions介面(QwenASR、Groq、 Cloudflare、whisper.cpp 皆同協定,一個轉接層通到全部)。同一段聲音跑兩次、把兩份稿的 差異變成產出:對齊按字元流而非 segment 邊界。 台語濃的素材上兩個引擎是系統性分歧(一致率 49%),對系統性差異用 diff 是拿錯工具 —— 改以句尾語氣詞密度判斷哪一份稿保留了較多口語質地,一份稿一個數字,不需對齊也不需人耳。 這是引擎選擇的輔助訊號,不是台語轉錄功能。 - 媒體能力探針:批次開始前先問「這台機器解得開這個庫嗎」(存在/讀得到/有音軌/ 解得開四道閘),而不是只問「ffmpeg 裝了嗎」。只有全部不可用才拒絕。
- 手動與正規化 tag、以及視覺 tag 全部進向量索引;改 tag 會被新鮮度檢查視為 stale 而自動重嵌。
- 匯出不含時間碼的純文字稿(server 早就支援,UI 從來沒提供入口)。
- 轉錄階段的進度回報(只有階段,沒有假百分比)。
- per-project 設定真的會被讀到 —— 解析鏈早就完整,但 production 八個消費點沒有一個帶
project=。 docker compose現在會持久化跨庫精選集與專案 registry;先前down或重建映像會靜默清空。
Changed
- 低電平素材的正規化預設關閉。 13 支實測:8 支結果相同、5 支變差、0 支變好。 正規化會把底噪抬起來,VAD 因此多留三倍音訊,whisper 就在噪音上產生幻覺。
- 字幕只保留
,!?,停頓交給 cue 時間;標點政策只在算繪時套用,不動儲存 ——.txt仍完整,既有素材庫不必重新轉錄。 - 字幕行寬收進
export.subtitle_max_cjk,先前硬編在四處。 - 打包的 app 現在會啟動 Ollama 並找得到 ffmpeg。
本版同時包含先前標為 1.1.2 但從未發佈的內容
v1.1.2 有 CHANGELOG 段落與版號,但從未打過 tag、也沒有 release。
其內容(出貨守衛改清單驅動、tauri-cli 快取、Pro add-on 介面合約測試)併入本版。
v1.1.2 - 2026-08-24(已備妥,未發佈)
Nothing in this release changes what arkiv does. It exists so that three changes to the build and packaging path get exercised by a real release instead of waiting for the next one that happens to carry a feature — a release pipeline that has only ever been reasoned about is not a verified one. If you are on 1.1.1 there is no reason to update.
Fixed
- A local build no longer ships a developer’s benchmark logs. Both assemble scripts already
excluded them, and both already refused to ship a stray
.env— but that refusal only looked for the one pattern it named, which is the “only what someone remembered” failure it exists to prevent, one level up. The check is now driven by a list, so covering a new file class is one line and the error says which pattern matched.bench_*.jsonis the second entry: it carries the machine’s GPU model and the filenames of whatever was last ingested, which on a working editor is a list of client media. Artifacts published from this repository were never affected — releases build from a clean CI checkout.
Changed
- Release builds no longer recompile
tauri-clifrom source every time. Measured on the 1.1.1 tag run: 9m38s on macOS and 10m05s on Windows, producing a binary identical to the last one. The CLI version is now pinned and the built binary cached. Pinning also closes a hole that had not yet bitten:--version "^2.0"resolves at run time, so two releases could embed different bundlers with nothing in the repository changing. - The Pro add-on interface (
arkiv_pro) now has tests. The component ships separately and does not exist yet, which is exactly why the contract is worth writing down now: when it arrives, a mistake on this side either refuses a paying user or bypasses the tier, and neither shows up as a failing test unless the contract was pinned first.
v1.1.1 - 2026-08-21
1.1.0 shipped the free allowance. It also shipped three ways for a long-time user to lose the exemption 1.1.0 had just promised them. All three are fixed here. If you have been using arkiv since before 1.1.0, this release is the one that actually keeps that promise.
They are the same bug wearing three faces: the guarantee is written down in one place, and the code that honours it depends on an observation that can disappear.
Fixed
- Your exemption no longer depends on whether a drive is mounted today. The check only ever
looked at libraries reachable at that moment, so an install whose grandfathered corpus lives
on a NAS looked like a brand-new install on any morning the NAS did not mount — refusing a
fourth project and cross-project search, and pointing the user at a Pro add-on they should
never have been asked to buy. The observation is now recorded the first time it is made and
the record is the answer, the same move
first_seen_versionmakes: write the fact down while it is still observable. The record can only ever grant, never revoke. - Upgrading straight from 0.12.x no longer costs you the exemption. The provenance anchor
assumed it would ship a release or two ahead of the cap; in fact the window was one day
(anchor in 1.0.0, cap in 1.1.0). Anyone who upgraded to the newest release rather than to
whichever one happened to carry the anchor had their years-old library stamped
1.1.0on first open and silently reclassified as new. An existing library is now stampedpre-anchor— which is what is actually known about it — instead of a version number it never ran. - The library you have open now counts as evidence. The grandfather probe collected registered projects and an environment variable and nothing else, so the ordinary install — one library, no second project ever registered — produced an empty set and reported itself as new no matter how old that library was. This is why the two fixes above did not help anyone on their own: they marked the library correctly and stood ready to remember it, while nothing put it in front of the check.
- Local builds no longer bundle a developer’s
.env. Both assemble scripts copied the repo into the bundle through a denylist that did not list.env. Artifacts published from this repository are unaffected — releases build from a clean CI checkout, where a gitignored.envdoes not exist — but anyone building an installer on their own machine was shipping their own secrets. The Windows script’s exclusion list also claimed to dropbench_*.jsonwhile its/XFarguments did not.
Changed
- Frontend moved to Svelte 5 (with Vite 8,
vite-plugin-svelte7 andsvelte-spa-router5). No behaviour change is intended; it is here because the dependency set had been pinned by it for weeks. Verified by rendering the real UI on both platforms rather than by the build going green — a build that compiles proves nothing about a framework whose breaking change is at mount time. The initial bundle dropped from ~404 kB to ~219 kB as a side effect. - Release builds cache the Tauri packaging toolchain instead of downloading WiX and NSIS during every build, which had already failed one release mid-flight.
v1.1.0 - 2026-08-19
The free allowance the product page has described for months now exists in the software.
Every build up to and including 1.0.0 shipped with the tier table published and nothing
enforcing it — project_limit, entitlement and is_pro had zero hits across the whole
repository. v1.0.0’s own notes said it plainly (“no shipped build caps anything”), so this is
not a regression being fixed; it is the other half of a design finally arriving. Until now
every user was running the Pro feature set.
If you already use arkiv, nothing changes for you — permanently. The allowance applies to
installations that begin with 1.1.0 or later. An installation already in use before this
release keeps both Pro features, unlimited projects and cross-project aggregation, forever.
That is not a grace period and not a setting: 1.0.0 recorded the version each library was first
seen under (library_meta.first_seen_version), specifically so this could be honoured from a
record instead of guessed at afterwards. A build older than 1.1.0 does not enforce the
allowance at all.
The add-on is still not on sale. The tier is now enforced, but the Pro component and a payment path do not exist yet, so a new installation that reaches three projects cannot currently buy its way past it. The refusal points at the published terms, which state their own availability. If that limit is in your way today, say so — the grandfathering rule is generous by design and the terms have a public-interest route.
Added
- Free tier: 3 projects, no cross-project aggregation — enforced. A single decision point
(
entitlements.py) answers every “is this allowed?” question, rather than three independentifs that would drift apart the way the extension sets did beforemediatypes.py. Three enforcement points, each with a deliberate choice about where to stop:projects.add_project()— only the path that actually grows the registry. Re-registering an existing name (a rename, or a library that moved) leaves the count unchanged and is not refused; gating it would leave someone at the limit unable to repair a project they already own, which the terms never claimed.GET /api/search/all— refused before the fan-out, not by filtering results afterwards. A federated search that quietly returned only the current project would be indistinguishable on screen from “your other projects had no matches”.bins.add_items()— refuses only the operation that widens a collection to a new project. Adding to a project the collection already contains stays available, so existing cross-project collections are never retroactively frozen.
GET /api/entitlements— the tier, in one probe, from the code that enforces it. Reportsarmedexplicitly, because “allowed” and “entitled” are otherwise indistinguishable through the API, and that is exactly how a shipped-but-inert gate goes unnoticed.- Pro entitlement detection, two independent routes, either sufficient: the closed-source
arkiv_procomponent being importable, or~/.arkiv/pro-license.jsonnaming a licensee and a key. Unsigned and unobfuscated on purpose — the licence is enforced by its terms, not by the database, and the anchor row has always been trivially editable.
Fixed
- The cap would have taken effect one release early for a specific user. The grandfathering
machinery answers “has this installation been in use for a while?”, but nothing answered “is
the rule in force yet?”. An installation that had registered several project roots without
ingesting into them owns no
project.db, therefore no version anchor, therefore reads as new — and a 1.0.0 build would have refused its fourth project even though 1.0.0 promised no limit at all. Builds older than the allowance now decline to enforce it regardless of what the registry looks like. - Refusals reached the UI as raw JSON. The new 403s carry a structured
{code, message}detail — the first endpoint to do so — and the client folded any non-string detail throughJSON.stringify, putting braces and quotes in a toast where a sentence belonged. The client now shows the message and keeps the code on the error for callers to branch on.
Changed
- Product page and
docs/pro-addon-license.mdstate 1.1.0 as the boundary instead of “a future release”, and the English terms now spell out that a grandfathered installation keeps both features. The Chinese page had always promised that (“新安裝不含”); the English had only spelled it out for the project count, and the code follows the stronger reading.
v1.0.0 - 2026-08-18
1.0 because the product now has a front door, and the licence finally says one thing.
Not a feature milestone — v0.12.1 already indexed a 1,506-clip production library and shipped on three platforms. What was missing was everything around it: the only way to download arkiv was a GitHub Releases page, the repository’s homepage field pointed at an Instagram account, and the licence required a paragraph of explanation before a working editor could tell whether they were allowed to use it on a paying job. All three are now answered, so the version number stops pretending this is still a preview.
The change that made the rest possible is the licence. Under PolyForm-NC, a freelance editor organising a client’s footage was outside the terms from their first project, while the README told them they were free up to three — a promise the licence did not authorise. PolyForm Perimeter replaces that with one sentence, and a product page can now lead with it.
Nothing is on sale. The paid tier is defined and its terms are published, but no shipped build caps anything, and there is no payment path yet.
Changed
-
License: PolyForm Noncommercial 1.0.0 → PolyForm Perimeter 1.0.1. Using arkiv commercially is now free. The NC license restricted use of the software itself to noncommercial purposes, and PolyForm-NC defines the permitted personal case as use “without any anticipated commercial application”. A freelance editor organising a paying client’s footage has an anticipated commercial application from the first project — so under the old terms, essentially every professional in arkiv’s target audience needed a separate commercial license, and the README told them the opposite. The public wording promised free use with payment only past three projects; the license did not authorise that promise. Perimeter’s Noncompete section replaces the whole problem with one sentence: “Any purpose is a permitted purpose, except for providing to others any product that competes with the software.”
This relaxes the terms for commercial users and, in one narrow way, tightens them: NC permitted a noncommercial competing fork, and Perimeter’s Competition section does not — a substitute product competes “even if it is provided free of charge”, and explicitly whether delivered as a service, library, or plug-in. That was always the actual concern behind leaving MIT in #225 (“so it cannot be forked into a competing commercial product”); NC was an imprecise instrument for it.
The Commercial Output Exception is gone as an exception. Under Perimeter there is nothing to except from — any purpose is permitted, so what you make with arkiv was never in question. What replaces it is a clearly-marked non-binding summary at the top of
LICENSEthat says the same thing plainly and draws the one line users actually need: if your customer is buying a deliverable you produced with arkiv, that is ordinary tool use; if they are buying arkiv’s functionality itself, that competes. The PolyForm Perimeter 1.0.1 body is reproduced verbatim from the canonical source and verified byte-identical against it.PUBLIC-INTEREST.mdexisted only because of the NC restriction — it granted a free commercial license to documentary, nonprofit, and archival work that grants or distribution would otherwise have made “commercial”. That premise dissolved, so the program now grants the paid Pro add-on instead, free and perpetual, on the same case-by-case human review. Same ethos, a promise that now costs something to keep.Contributions made this switchable without tracking anyone down:
CONTRIBUTING.mdcontributor term 3 already grants the maintainer a perpetual right to relicense contributed work, which is exactly the situation it was written for. Versions already released stay available under the terms they shipped with; this applies going forward. reel-scout stays MIT as the open funnel.⚠️ Legal review is still outstanding — the same note #225 left behind, now narrower and more specific. Governing law and the contracting entity are marked
[LEGAL REVIEW]in the add-on terms and must be settled before anything is sold. The third item, refunds, was checked rather than guessed: Consumer Protection Act art. 19 para. 1 gives a distance buyer 7 days, and non-tangible digital content supplied with prior consent is an enumerated exception to even that (Standards for Reasonable Exceptions art. 2 subparas. 4–5), so the add-on could lawfully carry no withdrawal right at all. The terms give the statutory 7 days in full and decline the exception — there is no goodwill window on top because the core is already free for commercial use, so nothing gates access to the product; what is being sold is extra capacity on software the buyer can already run and earn with, and can evaluate across three real projects first. Sitting exactly on the floor rather than above it does make two mechanics load-bearing for the eventual checkout, both recorded in the terms: art. 19 para. 3 (the 7 days do not start until the withdrawal information is supplied at delivery — omit it and the right stays open four months) and para. 5 (any conflicting term is void).
Added
-
Pro add-on terms published ahead of the add-on (
docs/pro-addon-license.md). The single paid line is now defined: NT$3,000, one time, perpetual, unlocking unlimited projects and cross-project aggregation above the free core’s three. One tier rather than two, because a two-tier ladder priced at any honest distance would have collapsed — and because adding a tier later costs nothing while removing one breaks a promise to whoever already bought. No subscription, no seat counter, no activation server: the add-on inherits arkiv’s no-phone-home property, so scope is bounded by the terms rather than by code. Not on sale yet; the terms are published so they can be read before the purchase path exists. -
Browse footage by the day it was shot (#316). v0.12.0 landed the facet endpoint and
?shot_year=; the sidebar half was deliberately left out while we waited on a patch from the documentary user who reported the gap.Shoot date · 拍攝日now sits between Smart Pools and Cameras: pick a year to filter to it and open its shoot days, pick a day to narrow further, with undated clips in a selectable未知日期bucket so they never fall out of reach. Days cap at 20 with 更多/收合, the same answer the tag cloud already gave to the same problem.Both filters run server-side (
?shot_year=/ the new?shot_date=), unlike the camera pool beside them. The counts in that column describe the whole library, so filtering only the loaded page would put “2025 · 54” next to a grid holding whichever of those happened to fall inside the first 500 rows.The year alone was not enough to ship: footage shot across one season collapses into a single bucket, which is the same “one bucket answers nothing” failure that kept
processed_atout of this facet to begin with. Days cost nothing extra — establishing the years already required normalising everycreation_date. They cannot be compared naively the way years can, though: both writers are YYYY-prefixed so the year works by luck, while exiftool’s2025:10:03 13:56:20and the XAVC sidecar’s2025-10-03T13:56:20+08:00differ at characters 5 and 8, so the comparison folds':'to'-'first.
Fixed
-
The shoot-year filter disagreed with its own sidebar count, and no amount of SQL was going to fix it (#316). The facet decides what a bucket contains by calling
normalise_shot_date, a Python parser. The filters ran in SQL. Two parsers for one question is a bug generator, and it produced real ones:0000:00:00 00:00:00— what exiftool writes for an unset date field, so a common value rather than an exotic one — was counted as unreadable by the facet and filed under year"0000"by the filter. Tightening the SQL only moved the boundary; a Codex audit of the tightened version found seven more values that disagreed, in both directions:2025:10-03and2025-10-03Tgarbageadmitted where the parser refused,2025-10-03and2025-10-03+08:00refused where the parser accepted. SQLite cannot reproducedatetime.strptime, and the set of near-misses has no edge to chase to.So there is one parser now.
media.shot_dateholds the normalised ISO day, derived bynormalise_shot_dateat the single write path and backfilled for existing libraries on upgrade; the facet groups on that column and all three filter branches compare against it. A year, a day andunknownare a partition by construction rather than by three predicates agreeing. Nothing was failing loudly before this — the sidebar simply advertised numbers it could not deliver.Two things follow that are worth knowing: the raw
creation_dateis now display-only (it was never sortable anyway —':'sorts above'-'), and the previously documented “calendar-impossible dates” residual is gone rather than reduced. -
The header contradicted the grid whenever a filter was on (#316). Picking “2025 · 54” left the toolbar reading
62 items, and the顯示 N / totalline is hidden once everything has loaded, so nothing on screen corrected it — the sidebar and the grid agreed with each other and the only number in the chrome disagreed with both. It now reads54 / 62 items · 篩選中whenever the view is narrowed, counting the cards actually rendered: stacking a rating filter on a shoot year previously left the header on the server-side total while the grid showed a smaller number, and counting what is drawn cannot disagree with what is drawn. -
?shot_year=was never actually exercised on the semantic-search branch./api/mediafilters on three paths, and both existing?q=tests fell through to the SQL fallback, because tests run without embeddings — so the third implementation of the rule was assumed correct rather than verified. Faking the vector store reaches it, and a new test asserts the three branches return the same rows rather than each passing its own case. The two SQL sites also stopped hand-writing the predicate separately, that duplication being the mechanism behind audits H8 and H14.
v0.12.1 - 2026-08-13
Native dialogs worked for the first time. Not “worked again” — the ACL had never let a single Tauri command through, on either platform, and nothing had noticed because no frontend code had ever called one.
Fixed
-
Every native call was denied by the ACL, on both platforms (#313). A folder browse button was added to Offload, rendered, and then did nothing:
Command plugin:dialog|open not allowed by ACL. The permission was not missing —capabilities/default.jsonalready listeddialog:default— but the capability applied to a context arkiv never runs in. arkiv does not load bundledapp://content:main.rspoints the webview at the Python backend onhttp://127.0.0.1:<negotiated port>, which Tauri’s ACL classifies as a remote origin, and a capability with noremoteblock covers local app URLs only (tauri-utilsacl::capability:remote“defaults to not being set, as our default use case is that the content is served from our local application”).remote.urlsnow brings the running webview inside the capability; the port is chosen at runtime, so the pattern accepts any.What made this read as a dead button rather than a permissions problem:
withGlobalTauriinjects the plugin API objects, and injection is not ACL-gated. Feature detection answered yes, the button appeared, and only the invoke was refused. The webview URL has no platform branch, so macOS was equally affected — this shipped broken in v0.12.0 and in every build before it.Checked rather than assumed, in order: the capability reaches the build (
remote.urlspresent in the generatedcapabilities.json); the pattern actually matches (a scratch binary replicatingRemoteUrlPattern::from_stragainsturlpattern0.3 returns true forhttp://127.0.0.1:59142/and for the hash-routed URL);resolve_commanddoes extend contexts fromremote; and the pattern strings are embedded in the rebuilt binary. One round appeared to fail after the fix — that was a stale installed binary, not a bad fix. -
Dropdowns fell back to the browser’s light list on the dark theme (#313).
app.cssstyledinput.ak-input— element-qualified — so<select class="ak-input">matched none of it, and no route styledselecteither. Every dropdown in the app was painted by the browser default: a grey-white list over near-black chrome. Both aselectand aselect optionrule are needed, because the open popup is a separate paint surface from the closed control and Chromium/WebView2 paints it from the select’s own computedbackground-color— leaving that transparent sends the popup back to the light list however the control looks. Values are tokens, so light mode stays coherent. On macOS the popup is an AppKit menu and follows the system appearance instead; the rule is a no-op there rather than a regression. -
gen/schemas/capabilities.jsonwas left stale. It is generated and tracked, so it regenerated dirty for anyone who built after the capability change. -
Docker ran a different embedding model from every other install (#312).
docker-compose.ymlsetARKIV_EMBED_MODEL=nomic-embed-textandARKIV_VISION_MODEL=qwen3-vl:8b. Those do not document the defaults — they override them, so a Docker user got an English-centric embedder in a project whose headline feature is CJK semantic search, plus the vision modelconfig.pydocuments at length as ~10x slower (~60s/frame against ~8s/frame; ~30h against ~3.5h over 2000 frames, at comparable tag quality). Both now trackconfig.py(bge-m3/qwen2.5vl:7b), with a comment saying they must keep tracking it, because the divergence was invisible: nothing compared the compose file to the defaults it silently shadowed. Migration for existing Docker libraries. The vision change needs nothing — frame descriptions are regenerated per frame and nothing is keyed on the model. The embedding change does:bge-m3is 1024-dimensional againstnomic-embed-text’s 768, so an index built under the old value cannot accept new vectors. Rebuild it once after pulling:docker exec arkiv-arkiv-1 python embed.py --rebuildThis drops and rebuilds the ChromaDB index from the SQLite rows. Media, transcripts, frame descriptions and tags are untouched — only the vectors are recomputed. To stay on the old model instead, set
ARKIV_EMBED_MODEL=nomic-embed-textin your own compose override. -
Six files still named the pre-#202 model pair (#312). #202 corrected the README,
.env.exampleanddocs/install.mdwhen the defaults moved tobge-m3/qwen2.5vl:7b, and recorded why it mattered: a README-follower pulled a 10x slower vision model thathealth.pythen reported MISSING. That sweep missedCONTRIBUTING.md,docs/index.md,docs/pipeline.mdand its.zh-TWtwin,docs/faq.mdandAGENTS.md, so the same trap stayed live for anyone starting from any of them. CONTRIBUTING’s pull block also listed only two of the three modelshealth.pychecks. Historical records (CHANGELOG.md, the handover and acceptance docs) are left as written — they describe what the defaults were at the time.
Added
- Browse buttons on the folder fields that hold a real path (#313). Ingest setup’s Source, plus Offload’s Source and every Destination, open the OS chooser (Explorer / Finder) instead of being type-only. These are the easiest fields in the app to typo without noticing — offload then reads a directory that is not the one you meant. Wired through
window.__TAURI__.dialograther than an@tauri-appsnpm import:withGlobalTauriis already on and the permission already granted, so this costs no dependency and puts nothing desktop-only in the web bundle. The same SPA is served in a plain browser byserver.py, where the global does not exist, so the buttons are gated on that and simply do not render there — the fields still accept a typed path. Cancel deliberately leaves the field untouched rather than clearing it, and failures now toast instead of dying as an unhandled rejection.
v0.12.0 - 2026-08-12
arkiv runs on Windows without a Python setup. v0.11.0 shipped the first installer but only
for macOS Apple Silicon; the README told everyone else to build from source. This release adds
the second platform end to end — a Windows backend assembler, a windows-latest release leg,
and the desktop shell fixes that a Windows bundle turns out to need — so a tag now publishes an
.exe and an .msi alongside the .dmg. Both platforms remain unsigned. Also lands a month of
Smart Collections, shoot-year faceting and storage-migration work.
Added
-
arkiv ships a Windows installer (#308, #309, #310). v0.11.0 was macOS-arm-only:
assemble-backend.shbundled anaarch64-apple-darwinPython andrelease.ymlhad one leg. Windows now has the same path —assemble-backend-win.ps1buildsbackend/{python,site-packages,src}from a python-build-standalonex86_64-pc-windows-msvcinterpreter, and arelease-windowsjob produces an unsignedarkiv_<version>_x64-setup.exe(226 MB) plus an.msi(341 MB) for admin/GPO deployment. Unsigned means SmartScreen shows “Windows protected your PC” once, the same shape as the macOS right-click → Open step;docs/quickstart-windows.mdwalks through it. The 2026-07-28 plan scoped this as “fork the shell script”; three things it did not anticipate mattered more than the fork.The desktop shell was never portable.
main.rshardcodedbackend/python/bin/python3and joined PYTHONPATH with:. A Windows bundle would have built cleanly and then failed to spawn anything on first launch — the exact failure a CI “did it build?” dry-run cannot see, which is why this was built on the target machine and installed before the release leg landed. The interpreter path now branches oncfg!(windows)(python-build-standalone has nobin/there) and PYTHONPATH goes throughstd::env::join_paths. Same class, third fix:windows_subsystem = "windows"silences our own console but not a child’s, so a GUI launch parked a black console window next to the app for the life of the backend; spawn now setsCREATE_NO_WINDOW.torch is 4.2 GB on a machine with an NVIDIA card, not the ~200 MB the plan budgeted.
pip install -r requirements.txtresolves silero-vad’s torch to the+cuXXXbuild, so packaging from the dev venv would have shipped a ~5 GB installer. Both the script and the CI job therefore build a dedicated.venv-packagainst the PyTorch CPU index (524 MB measured). Nothing a user would feel changes: torch only backs silero VAD (transcribe.py:14-15), while transcription runs on ctranslate2. Final staging 1014 MB — python 142 MB, site-packages 850 MB, src 22 MB — comparable to the macOS side, and WiX linked all 25,547 files without falling over, so both bundle targets stay on.Four Windows traps, three of which only fire outside CI.
robocopyreports success as exit 0-7, so treating it like a normal command fails the script on a successful copy and leaves$LASTEXITCODEdirty for whatever reads it next.curl/tarmust be resolved from System32 by absolute path — launched from a Git Bash shell,tarotherwise hits Git’s MSYS twin, which reads theC:\...destination as a remotehost:pathand dies with “Cannot connect to C: resolve failed”. The src copy excludes the gitignored runtime-scratch dirs, because the offload tests leavetemp\arkiv-offload-*behind with ACLs the user can no longer enumerate — 20 of them, which robocopy counts as failed directories. And the.ps1is pinned to pure ASCII: Windows PowerShell 5.1 reads a BOM-less file as the machine’s ANSI codepage rather than UTF-8, so a multi-byte character is re-split under cp950/cp1252, and when that re-split swallows a quote the script dies at parse time with errors pointing at unrelated lines — only on machines with that codepage. A sibling script written the same way did exactly this here. A CI runner checks out clean and runs a Latin codepage, so all but the robocopy one bite only a real workstation: the machine that also does a local release build.Verified by installing the built
.exeon a real Win11 workstation rather than by inspecting the build log: window titledarkiv, backend up on a negotiated port, SPA and every asset 200,/api/collections/api/media/api/stats/api/bins/api/media/4/transcriptsall 200 with the pre-indexed sample library rendering, zero tracebacks inbackend.log, zero python processes holding a window handle, and no orphan uvicorn after close. -
Per-project Smart Collections —
.arkiv/collections.json+ingest --propose-collections/--apply-collections. PR #92 deleted the shipped topical collections (食材特寫 / 店內空景) because a hardcoded food vocabulary mis-filed a cable-making shoot through the shared切割tag; the source comment has said “topical/auto-derived and per-project collections are deferred (B + C)” ever since. The lesson was not that topical collections are bad — it was that a shipped default carries one shoot’s vocabulary into every other project, which a per-project file structurally cannot do. So a project can now define its own, by hand or by derivation, and the file is the UI: plain JSON in the project data dir, the same contract astag_aliases.jsonandcorrections.json, reversible by deleting it. Treated as untrusted input throughout — missing, unparseable or wrong-version degrades to the shipped defaults, one malformed entry costs that entry and not the file, and a file entry’spredicateis alwaysNone, which is a security property rather than a limitation since a config file must never be able to inject a callable into the classifier. Built-ins can be hidden via adisablelist but never redefined by key: they carry predicates JSON cannot express, so an override would leave a collection that can never match anything. Derivation (--propose-collections) prunes the tag cloud, clusters what was shot together by co-occurrence Jaccard, has the LLM name each theme, then measures real membership with the production scorer before proposing anything — member counts are measured, never predicted. Review the proposal, then--apply-collections;--collection-dry-runruns the whole pipeline without Ollama. An empty proposal is a correct answer: on the reference vinyl library every frequent tag co-occurs with every other, and 252 raw tags reduce to 36 derivable, 7 clusters, and exactly 1 proposal — the 18-tag mega-cluster covering 66% of the library is refused, because a collection holding most of the library is a synonym for the library. What stops #92 recurring is pruning: shot types and generic scene furniture (特寫/操作/人物/室內) co-occur with everything, so in a co-occurrence graph they are bridge edges that glue unrelated subjects — the exact role切割played. A test proves the counterfactual: put特寫back and three clearly separate topics collapse into one cluster. Derived keys hash the definition rather than the name or rank, and a re-run diffs against what was already accepted — dropping 3 of 62 clips changed the derived set in five of six trials, so anything keyed on rank would rename or vanish on the next ingest. Unlike--apply-aliases, every failure path here exits non-zero: that command prints and returns, so a missing or malformed proposal still exits 0 and a script reads it as applied. -
Browse footage by the year it was shot —
GET /api/media/facets/shoot-date+?shot_year=(backend half). The sidebar offers tags and smart collections but no time entry point, so a documentary user rebuilt one herself in her own checkout to find clips by year. Which column to group on was settled by measurement rather than preference: on a real 62-clip library,creation_date(EXIF CreateDate / XAVC sidecar) covers 90.3% and spreads across 2022/2025/2026, whileprocessed_atcovers 100% and puts every single row in one bucket — 55 of the 56 dated clips were shot in a different year than they were ingested, so the two are not interchangeable approximations of each other. The facet reports an explicitunknowncount (6 of those 62) and its buckets reconcile with the library total, because a facet that quietly omits rows reads as “there is nothing there”. Grouping had to survive two stored shapes:ingest.exiftool_extractwritesstr(cdate)unnormalised, and exiftool yields2025:10:03 13:56:20while the XAVC NRT sidecar yields ISO2025-10-03T13:56:20+08:00. Both are YYYY-prefixed so year grouping works naively, but ordering does not —:sorts above-— so everything past “what year” goes through a shareddb.normalise_shot_date(timezone dropped, not converted: re-basing to UTC would move footage shot near midnight into the neighbouring shoot day).?shot_year=is wired into all three/api/mediacode paths — SQL list, semantic search, and the degraded SQL text search — since a filter honoured only on the first silently stops applying the moment the user types a query or Ollama goes down, which is exactly the shape of audits H8 and H14;creation_datejoinsLIGHT_COLSso the search branch can see it at all. Verified end-to-end against the real library: every bucket’s filter count matches its facet count exactly, and none of its 56 distinct date values is rejected by the normaliser. 31 tests, mutation-checked — all 6 HTTP guards fail against the unwired router, both?q=variants included. Frontend deliberately not built yet: the user who needs this already has a working version, and it is worth seeing hers before shipping a second one.
Fixed
- Collection membership ignored the reviewed tag alias map.
smart_collectionsimported neithertag_qualitynortag_aliases, and scoring is an exact string comparison — so a clip tagged 黑膠唱盤 scored 0.0 against a collection listing 黑膠唱片, the same object with no overlap. Worse than a missed match: runningingest --apply-aliasesfolded the tag cloud in/api/tagsand did nothing whatsoever to membership, which is the surface where folding actually changes what a user sees, so the reviewed map was only half-connected. Both sides of the comparison now fold throughcanonicalize→to_pref; folding one side only would have traded the old mismatch for a new one. Folding happens on the clip side and never by expanding a collection’s tag list to include every alt spelling: expansion would inflatelen(col.tags)— on the reference library’s vinyl ring, ~4 to ~22 — and0.5*(2/22) + 0.5*(2/3) = 0.379falls belowMIN_CONFIDENCE, so a clip matching two spellings would stop being a member. Clip-side folding also makes “two hits” mean two genuinely different concepts rather than two spellings of one. No shipped collection changes membership (verified: none of the five defaults carries a tagcanonicalize()alters); one existing test did, sincemedia_signalnow returns 吧台 for 吧檯, which is the intended normalisation.classify()also computes the signal once per clip instead of once per collection —media_signalre-parses theframe_tagsJSON blob on every call and/api/collectionsclassifies the whole library uncached on every request. - Opening a Smart Collection made every rated clip look unrated. The grid builds collection cards straight from the
/api/collectionsitems with no/api/mediare-fetch — by design — and hardcodedrating: 'none'because the endpoint never sent one. The column was already in the narrow SELECT for the 待審查 predicate; it just never reached the client. Items now carryratingand the card goes through the sameratingToUi()the normal grid uses. Two adjacent gaps closed with it, both of which stop being cosmetic now that derived collections make this a real browse path:activeCollectionwas set on click but never passed toPoolSidebar, so nothing showed which collection was open and only a camera click ever cleared it — the row now highlights and clicking it again toggles back to the full library, with clearing centralised inload()/runSearch()so the highlight cannot outlive the view it describes; and the collections list was uncapped while tags were capped at 24, so a project deriving a dozen topics would push the Storage footer off the sidebar. Render-verified at 1440 and 900px. --migrate-storagerefused to run, reported success, and told you to delete your API tokens. Its idempotency check wasif (BASE_DIR/.arkiv/project.db).exists(), which answers “does this file exist”, not “did the migration already run”. An emptyproject.dbalso appears the moment anything opens the new layout — a strayserver.pystart, an ingest without--dir, a health check — and from then on the migration refused forever while the real library sat inBASE_DIR/media.db. It stranded 62 media rows on one machine for weeks, and nothing surfaced it: the branch printed[SKIP]and returned, so the process exited 0, and both a human skimming output and any script checking the return code read that as done. The printed remedy made it worse —rm -rf .arkivdeletes aproject.dbthat holds theaccess_tokensrows, and the DB stores only hashes, so following the advice revokes every API token on the machine with no way to recover them, only reissue. The guard now decides onmediarow counts rather than on the inode, and separates the three real states: already migrated (skip, exit 0), an empty shell blocking real legacy data (exit 6, naming what is stranded), and both sides populated (refuse — the migration does not merge). An unreadable DB is reported as unknown (?) instead of being collapsed into “empty”, since acting on an unverified assumption is exactly what the original guard did. The remedy is now tomvthe directory aside, printed alongside how manyaccess_tokensrows would have gone with it; the rollback instruction and bothdocs/pipeline*.mdcopies lost theirrm -rffor the same reason. 8 tests, mutation-checked: 6 fail against the previous code, three of them withDID NOT RAISE SystemExit— the silent-success bug itself.- Federated search silently stopped being search under the pg backend. With
ARKIV_VECTOR_BACKEND=pgthe vectors live in the shared pgvector store keyed byproject_name, and no project has a.arkiv/chroma_dbdirectory. Federation opened that directory anyway. Two things then went wrong in sequence, neither of which produced an error:health.project_healthtreated the missing directory asCHROMA_MISSING, so every project failed preflight and was reported as a project error; and where a project did get past that,_query_chromafound nothing and the code fell through to_sql_like_search— so a semantic query came back as a keywordLIKE, ranked by nothing, with a plausible-looking result set and no marker saying the search had changed kind. The pg path now queries the shared store scoped to the project (PgCollection.queryalready accepted aproject_namefilter, andvectordb._query_collectionalready built it — the wiring was the only thing missing), and the chroma preflight applies only under the chroma backend. As a side effect the pg path opens no external project directory at all, so the untrusted-collection concern documented on_query_chromadoes not arise there.
Added
- Sony
.mxffootage now indexes (DIT wrapper ④). FX6 / FX9 / Venice cards are.mxf, and pointing a--dirscan at one produced zero indexed clips — the extension sat iningest._PRO_UNSUPPORTED_EXTnext to.braw/.r3d/.ari, and the skip notice told the user pro formats “aren’t indexable (ffmpeg has no decoder without vendor SDKs)”. That grouping was over-broad: those three are proprietary RAW codecs with genuinely no ffmpeg decoder, whereas MXF is a container, and ffmpeg decodes what Sony wraps inside it..mxfmoves tomediatypes.VIDEO_EXT, which propagates toingest.SUPPORTED, db.py’s SQL video filter, the watcher, and the query builder through the shared set rather than by hand. Verified before landing across the whole chain ingest depends on —probe(), thumbnail, frame extraction, and the 16 kHz mono audio decode whisper consumes — on two samples:samples.ffmpeg.org/MXF/C0023S01.mxf(Sony 2006, MPEG-4 part 2, 352×288,start_tc 01:43:48:21read from format-level tags) and a synthesised XAVC-Intra file (H.264 High 4:2:2 all-I, 1920×1080,start_tc 01:00:00:00); every stage green on both. An MXF whose inner codec ffmpeg cannot decode degrades exactly as any unreadable file does —probe()returnsNone, the clip is skipped with[ffprobe failed]— so admitting the container adds no new failure mode..m2tsdeliberately stays in the pro-unsupported set: it is also a container, but frame extraction failed on the sample tested, so it needs its own measurement instead of being swept along. Three documents had recorded this feature as shipped since June (ADR 0001’s status line, its Decision ④ entry claiming “Shipped (v0.8.1)”, and the roadmap); the ADR now carries a correction noting it was never true.
Changed
- The Windows CI leg now runs the MCP stdio suite (#273).
test-windowsalready installedmcpandnumpybut only ran the offload deny-policy file, so the prewarm ordering that is the #271 fix was covered on macOS 3.12 only — the one platform where the bug never reproduced. Addingtests/test_mcp_e2e.pygives the stdio surface a Windows leg (subprocess spawn, JSON-RPC framing, teardown) for ~4.5s and zero new dependencies: 50 passed in 7.42s under a venv built from the job’s exact install list. chromadb is deliberately not installed there, both to exercise the real degrade path and because it would cost ~120s (82 packages, ~360 MB) to guard nothing — measured on hosted runners, the stdin-pipe +import numpyprobe completes in 0.6s on windows-latest, ubuntu-latest and macos-latest alike, while deadlocking on the Win11 dev box under both numpy 2.4.3 and 2.5.1. The job comment records that measurement so the question isn’t re-opened from first principles. - The README leads with the downloadable app (#272). v0.11.0 is the first release with an installer, but Quick Start still documented clone+pip only. A “Download the app (macOS, Apple Silicon)” section now comes first — grab the
.dmgfrom Releases, drag to Applications, right-click → Open once (unsigned) — while staying honest that the bundle still needs FFmpeg + Ollama (external services, not pip packages) and is Apple-Silicon-only. The existing pip sections are reframed as run-from-source.
Fixed
mcp2.0.0 turned every PR red (#276).mcp2.0.0 shipped on 2026-07-28 and restructured the SDK, removing themcp.server.fastmcpimport pathmcp_server.pyuses.requirements.txtcarried an unpinnedmcp>=1.2, so from that afternoon every fresh install resolved to 2.0.0 →ModuleNotFoundErrorattest_mcp_servercollection → pytest exit 2 →test(3.12),test-windowsandcoveragered on all PRs (3.9 is unaffected; mcp is gated out there). main last went green at 09:48, before 2.0.0 landed at 13:45. Capped to the 1.x line untilmcp_server.pyis ported to the 2.x API. The cap had to go in twice: CI installs a curated inline dependency set rather than-r requirements.txt, so the requirements cap alone didn’t take — all threepip installblocks (test matrix, test-windows MCP-stdio, coverage) are pinned to match.- Vision-only re-describes went silently unindexed (#275). The G-corpus vector index froze on 07-22 while vision descriptions kept being written through 07-29 — semantic search quietly missed roughly a third of the library’s visual content, with no error anywhere. Two defects combined:
--vision-onlyreturned before ingest’s end-of-run embed, and the overnight scheduler runs exactly--vision-only, so the sole description writer never rebuilt the index; andembed.py’s freshness check only asked “is this media_id present in Chroma, or was--forcepassed?” — never what the row now says — so a row re-described after indexing stayed stale forever. Fixed defence-in-depth: vision-only now returns the ids it wrote and the caller re-embeds them viarun_embed(force_ids=…)(skipped by--no-embed, and run even on halt so committed work is searchable); freshness became content-based via a newmedia.embed_hash(sha256 ofbuild_doc_textat last embed) compared against the current hash, which catches changed descriptions regardless of what the caller passed, with legacy hash-less rows treated as “unverified” → re-embedded rather than assumed fresh; andrun_embednow reports new/stale/unverified/forced/unchanged so a0meaning “checked, nothing stale” is distinguishable from a0meaning “never looked”. Verified on a copy of the real G corpus: old logic reported “To process: 0”, new logic detects 1506 stale. - MCP stdio deadlocked on Windows the moment a search touched numpy (#271).
search_mediaimportsvectordblazily, inside the tool body — so inside the stdio server that import runs while the transport is already up, and on Windows the process then wedged permanently: the server never answered again and only the client’s timeout ended the session. Reproduced with no mcp and no arkiv code, isolating the trigger to a thread parked in a blocking read on a stdin pipe (exactlymcp.server.stdio’s steady state, since anyio’sAsyncFileiteration keeps a worker thread inreadline()): plainimport numpy0.11s, two idle threads +import numpy0.09s, thread blocked on a stdin pipe +import numpynever returns. An import hook in the wedged server shows it reachingnumpy._core._multiarray_umath(the OpenBLAS-linked extension) and stopping. It is numpy-specific rather than “any native import” — lzma, sqlite3 andPIL.Imageall load in under 20 ms in the same running server — and not the event loop, sinceto_threaddeadlocks identically whilesleep(5)and a 222 KB file read inside a sync tool both return. Not version-driven either: mcp 1.2.1 / 1.27.0 / 1.28.1 all fail the same way on the same machine, sorequirements.txtkeeps its range. The fix is to prewarmvectordbbeforemcp.run()starts the transport, which also avoids paying the import mid-session. A stage-label bug had hidden this for two rounds:_run_bounded_mcpsetstage = "cleanup"in afinally, which also runs while thefail_aftercancellation unwinds through that frame, so a hang during the call always reported as “cleanup”. Windows evidence: 1 failed at 93.93s → 6 passed in 10.24s (the end-to-end case 90.03s → 5.82s). - The release pipeline could ship unsigned when signing was intended (#274). A Codex audit found the signed/unsigned branch gated on a single secret (
APPLE_CERTIFICATE != '') while signing actually consumes six. A partial configuration would then either codesign with an empty identity — a hard failure on the first signed release — or, ifAPPLE_CERTIFICATEhappened to be the missing one, fall through silently to an ad-hoc/unsigned release. Replaced with an all-or-nothing preflight step that reads all six and outputsfullornone, exiting non-zero on a partial config with a message naming what’s missing; the import/signed/unsigned steps now read that output. Simulated none/full/partial locally. Also drops a deadbundle/macos/*.app.tar.gzupload glob — that’s an updater artifact and there’s no updater config, so it only ever matched nothing. - A POSIX path literal in a test seed escaped PROJECT_ROOT on Windows (#269).
_seedhardcodedPath("/tmp/arkiv-g2-clip.mp4"), which is absolute only on POSIX: on Windowsstr(Path("/tmp/x"))is\tmp\x— rooted but drive-less — sois_absolute()is False,db.resolve_pathtakes the relative branch instead of the legacy-absolute pass-through the seed intends, and the join drive-anchors toC:\tmp\x, at which point the J2 containment guard correctly raises. Two of the six G2 tests failed there (the ones reaching retranscribe/activate, which actually resolve the media path). Same class as the Wave A offload finding — a POSIX literal on Windows is not the absolute path it looks like. The guard is right; the seed was not portable.gettempdir()is absolute on both platforms. No product code changed, no skip or xfail: 2 failed / 4 passed → 6 passed on Windows.
v0.11.0 - 2026-07-28
First release built and published by the automated release.yml pipeline — arkiv now
ships a downloadable macOS (Apple Silicon) .dmg instead of build-it-yourself. Bundles a
month of Phase 9.x editor / timeline / MCP work (below) plus the repo health-hardening
waves A–E (Windows correctness, CI truthfulness, dependency reproducibility, frontend
quality gates, and version/release hygiene). The .dmg is unsigned — right-click → Open
on first launch — until Apple signing secrets are configured.
Security
- MCP
_safe_pathleakedC:/…absolute paths whole (#182).mcp_servercarried its own copy of the leak guard because the original lived inserver.py, which it must not import (that pulls in the whole FastAPI app) — there was nowhere to share from. Four hours after that copy landed on 2026-06-08, Codex round-2 (fc35b8f) overruled round-1 and madeC:/count as a Windows absolute alongsideC:\; the fix touchedserver.pyonly, so the MCP copy kept passingC:/Users/me/secret.movthrough unchanged for 38 days — on the one surface whose stated red line is “no absolute-path leak”, and the only one facing untrusted downstream agents. R5-25 extracted the guard into thepathresleaf, so the copy is now deleted rather than patched:_safe_pathdelegates topathres._display_path, one guard, nothing left to drift. Paths beginningC:/now basename like every other absolute; every other input is byte-identical. - The offload destination guard silently opened on Windows (Wave A, #223).
webguard._assert_offload_dst_safenormalised withPath(dst).expanduser().resolve()and then string-matched against/-anchored POSIX literals (/etc,/system, …). On Windowsresolve()drive-anchors a rootless path —/etcbecomesC:\etc— which no longer matches/etc, so the 403 gate quietly stopped denying (four hardening tests failed on windows-latest, on a gate nobody had ever run there). The sibling export/ingest guards were unaffected because they usePath.relative_to, which is drive-aware; offload has no allowlist by design, so its denylist itself had to become host-independent. It is now evaluated over the raw pre-resolve literal, the resolved canonical path, and a drive-stripped form, with drive-agnostic Windows system and persistence roots added (Windows, Program Files, Program Files (x86), ProgramData, the per-user Startup folder), and the decision extracted into a pure_offload_deny_reasonhelper that can be fed Windows-shaped strings from a Mac. Two independent audits (Codex and a harness reviewer) then found residual false-negatives through Windows namespace, device and admin-share paths —\\?\C:\Windows,\\.\C:\,\\localhost\C$\,\\?\UNC\…all still reached a system root — which are closed by a normalisation pass; a drive-letter check that only accepts ASCII letters (so1:/etcisn’t mistaken for a drive); and anOSErrorwrapper so a malformed destination no longer 500s. 8.3 short names (C:\PROGRA~1) are recorded as an accepted residual, resolved by the caller’s own Windowsresolve(). - The “explicit embeddings only” invariant on external Chroma is now locked (Wave B, #229).
federation._query_chromaopens external, semi-trusted project Chroma directories. chromadb builds and runs a collection’s persisted embedding function when — and only when — a caller passesquery_texts=or documents without embeddings; that is the ChromaToast / chroma#6717 client-SDK-RCE class. Federation queries with explicitquery_embeddings=only, so the path is unreachable today, and this change is about keeping it that way: a SECURITY comment documenting the untrusted-persisted-EF rule, plus a regression guard that spies the external collection’squery()and asserts federation passesquery_embeddings=and neverquery_texts=oradd(documents=). The pgvector migration does not retire this path — federation hardcodeschromadb.PersistentClientregardless ofARKIV_VECTOR_BACKEND— so the guard belongs here rather than in the backend abstraction.
Added
- arkiv is a self-starting desktop app now, not a terminal dance (B3, #199, #200). The Tauri shell spawns the bundled Python backend on launch:
free_port()negotiates an OS-assigned port (no more hardcoded 8501 clash), the child runspython -m uvicorn server:appwithARKIV_PROJECT_ROOTpointed atapp_local_data_dir/arkiv— writable, and deliberately outside the read-only.appbundle — plusARKIV_TRUST_LOOPBACK=1so the loopback WebView skips the token dance;wait_ready()TCP-polls the port on a 45s budget before the window opens, andRunEvent::ExitRequestedkills the child so no orphan uvicorn survives. The backend resolves from an env override in dev or<resources>/backend/{python,site-packages,src}when packaged.assemble-backend.shrebuilds the ~1.3 GB staging bundle from python-build-standalone plus a trimmed site-packages — not PyInstaller; the spike showed this native-heavy tree boots cleanly under a stock portable interpreter. Validated end-to-end rather than assumed: Rustsetup()spawned the backend, WKWebView loaded the SPA, and every startup call (/api/collections,/api/tags,/api/media,/api/stats,/api/bins) returned 200. One packaging gotcha is recorded with it — the dmg step fails with AppleEvent timeout -1712 in any non-GUI build becausebundle_dmg.shruns a Finder-prettifying AppleScript, soCI=true cargo tauri build(which passes--skip-jenkins) is the recipe; the dmg still contains the.appand the Applications drag-target, just without the custom background layout. - The backend’s output goes to a log file the user can actually retrieve (#201). A Finder-launched
.apphas no terminal, so the sidecar previously threw every uvicorn access line,print()and traceback into the void — a broken tester box was un-debuggable remotely. stdout and stderr are now redirected to<project_root>/logs/backend.log(writable, outside the read-only bundle) with a one-file rotation tobackend.log.prevso the previous run survives; both streams share one duped fd so lines interleave cleanly, and it falls back to inherit if the log can’t be opened. - First-run readiness gate, Load-sample CTA and Report-a-problem (W0, #204, #207, #208).
MainLiveused to boot to an empty grid with silent runtime failures when Ollama, the models or FFmpeg were missing — the binding constraint the launch-readiness audit named. The landing surface now consumes a new unauthenticatedGET /api/health(a JSON readiness report mirroringhealth.py: ffmpeg/ffprobe required, exiftool optional, Ollama reachable and the three configured models pulled — booleans and model names only, no absolute paths, 200 when ready and 503 otherwise) alongsideGET /api/version, and drives a four-state machine: unreachable → Retry; not-ready-and-empty → the failing checks with their fixes plus Recheck/Settings; ready-and-empty → “Load sample library” or “ingest your footage”. Backing it arePOST /api/sample/seed+GET /api/sample/seed/status(SingleFlight guard then the shared ingest slot — the seed spawns whisper, so it must not run alongside a real/api/ingest; idempotency short-circuits before any lock so an already-seeded no-op can’t 409 a concurrent ingest) andGET /api/logs/tail(bounded 256 KB tail, per-line sanitisation, authed since a log can carry paths and tracebacks). Settings → System gains “Report a problem”, assembling a diagnostic from version + health + log tail, each degrading independently. The bug-report template now asks desktop users for that JSON instead of the terminal-onlypython health.py. - A pre-built sample library that makes a fresh install searchable instantly (A1, #205, #209). First-open search-awe without the user owning footage: four tiny (~1 MB total) downscaled CC-BY Blender open-movie clips — Caminandes, Coffee Run, Glass Half, WING IT! — with licences and attribution in
sample/LICENSES.md. v1 (#205) shippedscripts/seed_sample.py, which ingests them into the current project root; the demo queries it advertises were deliberately chosen to be robust, because a 4-clip index is ranking-noisy under bge-m3 anisotropy. v2 (#209) removes the ingest wait entirely: the corpus is packaged pre-indexed into a tar and ATTACH-merged into a fresh project’s default store on startup, so the main grid is searchable with zero re-ingest (verified with a Chinese query —駝羊→ llama clip). Only a non-install, fresh, never-dismissed root is ever seeded; a 範例素材 chip marks the demo as demo, and one click removes it. The merge takes the column intersection and reads ids from the pre-built database, never the live table, so remove can never reach a user’s own footage — an adversarial review round caught exactly that: an earlier_merge_contentreturned every id in the table. - Taiwan Traditional zh transcripts, written at ingest time (Phase 9.8b, #210). Whisper large-v3 emits Simplified for
zhand the audience is Taiwan.zh_convert.convert_resultnow applies OpenCCs2twp(Taiwan idioms — 内存→記憶體) to transcript, segments and words on the write path, so the search index, the UI and every export are Traditional. Converting on display would not have worked: it can’t fix search recall. Timing-safe by construction —start/end/scoreare copied verbatim, so idiom substitutions that change phrase length never shift a timestamp. Degrades to identity when opencc is absent (an optional dependency must never break a transcribe) and is gated to Python ≥3.10, where the arm64 wheel exists; production is 3.12. ingest.py --retraditionalizebackfills libraries indexed before 9.8b (#211). The write-path fix only reaches new transcriptions, so a 5-year archive still answers 記憶體 queries with nothing while holding 内存. This batch-convertsmedia.transcript/segments_json/words_jsonplus the per-language archive with no whisper re-run (follow withembed.py --rebuildfor the semantic half). The naive version of this corrupts data, and building it surfaced why: opencc’s phrase layer re-segments valid Traditional (系統→係統, 音樂類型→型別, 設備→裝置), and even neutral s2t does it. So rows are classified first — genuinely Simplified (a Simplified character, no Traditional-only character) gets fulls2twpidioms matching the write path; mixed gets char-levels2tw, touching only genuinely-Simplified characters with no phrase layer, which is length-preserving so word timings survive; already-Traditional or empty is skipped. Idempotent (converted rows re-classify as Traditional), timing-safe, identity without opencc. Ten tests including the load-bearing already-Traditional-never-corrupted case, and verified on a copy of the real 62-media library: 34 Traditional rows byte-identical, 3 mixed rows char-fixed with zero corruption.GET /api/media/{id}/segments— sentence timecodes without the whole record (three-piece W1a, #217). The clip-detail route shipssegments_jsonas a raw string and dropswords_jsonfor transport size, so a downstream edit agent that only needs sentence-level timing to place an IN/OUT on a quote had to re-parse the entire record. This returns the projection alone —[{start, end, text}], no words, no frames or tags — reusing the same defensive JSON projection asmcp_server, so a corrupt column degrades instead of 500ing.- Speaker labels on transcript segments (A4, #221). arkiv had no diarization. After transcription, the audio is now diarized via the
speaker-alignpackage and each segment tagged with aspeaker_id(contract{start, end, text, speaker_id}). Optional and gated (ARKIV_DIARIZATION_ENABLED, off by default, plusARKIV_PYANNOTE_TOKEN) and soft-failing at every step: no token, package absent, or a diarizer error each return the segments unchanged — an optional label must never break a transcription. The load-bearing detail is which audio gets diarized: it must be the VAD-filtered wav the segments were timed against, not the original media, because VAD concatenates speech chunks and collapses the timeline — labels derived from the original would not line up with the timecodes. Read projections (/api/media/{id}/segments, MCPget_transcript) surfacespeaker_idonly when present, so non-diarized clips keep the exact prior shape and the existing contract tests are untouched.speaker-alignis not yet on PyPI; when it’s absent the feature and its tests self-skip. - Per-clip camera identity and angle for multicam (A-cam, #224). arkiv knew a clip’s camera make, model and reel, but not which angle it was in a multicam shoot — the missing premise for multicam edit decisions. Adds
camera_idandangleas free-form editorial media fields withPATCH /api/media/{id}/camera, mirroring thein_point/out_pointhuman-annotation pattern exactly: PATCH semantics (an omitted field is untouched, null clears), labels trimmed and length-capped, and — the reason that pattern exists — kept out of_ALLOWED_COLSso a re-ingest or refresh can never clobber the marks, with a test pinning it. Carried by the detail route and the REST list viaLIGHT_COLS, so a downstream planner picks them up automatically. - Smart Collections honor hand-added tags (#222). The classifier only saw vision-derived
frame_tags—list_collections’ SELECT never joined thetagstable — so a tag added by hand throughPOST /api/media/{id}/tagscould never form a collection, and a user labelling clips a-roll/b-roll got no clickable sidebar entry, only a Query Builder workaround. One bulk tag load now attaches each clip’s manual tags to the scored signal, and two edit-role collections key on them: A-roll · 主軸 and B-roll · 輔助, hidden at zero members on libraries that don’t use them. A Codex audit caught the follow-on: thetagstable also holdssource='auto'vision copies, so an automatic tag that happened to be nameda-rollwould have joined an editorial collection with no user action — the load is now scoped tosource='manual'. Verified against a real 20-clip library (A-roll → 5, B-roll → 14, structural collections unchanged). - A tag-triggered release pipeline that signs and notarizes (Wave E, #245, #246).
.github/workflows/release.ymlbuilds the self-contained macOS-arm.app/.dmgon av*tag (or aworkflow_dispatchdry run) and attaches it to the GitHub Release, stamping the version from the tag so bundles can’t drift from the tag again. Signing and notarization are conditional — they run only when the Apple secrets are configured, otherwise it builds unsigned. Shipsentitlements.plistwith the hardened-runtime exceptions the bundled Python and torch/mlx native libraries need (allow-jit, unsigned-exec-mem, disable-library-validation). Two bugs were found by actually running it rather than reading it: a step-levelif: ${{ secrets.X != '' }}silently never fires, because the secrets context isn’t available in step conditions — the check had to be hoisted to job-level env; and an unset secret expands to an empty string, soAPPLE_SIGNING_IDENTITY: ""made Tauri attempt “sign with the identity""” and fail bundling outright. Since an arm64 binary must be at least ad-hoc signed to execute at all, the build is now split: a real Developer ID identity when the certificate is present, ad-hoc-otherwise. - Editor keyboard shortcuts for IN/OUT in the inspector. The frame-exact spike (#190) shipped the
◂格/格▸buttons and rVFC frame marking, but every mark was a mouse click — editors reach for keys. Now, whenever a clip with a player is open:,/.step one frame (only when fps is known, so the frame stepper stays inert for audio / unknown-fps),i/omark IN/OUT at the current frame (or playhead for audio). A single guardedsvelte:windowkeydownhandler inInspector.svelteignores events whose target is aninput/textarea/select/contenteditable and any event carrying a modifier (⌘/Ctrl/Alt) — so typing a tag never marks a clip and ⌘K (focus search) is never hijacked — and does nothing at all on the design-mock inspector (novideoSrc). Closes the last P1 item indocs/frame-exact-inout-roadmap.md. - IN/OUT trim marks now persist per clip (D1). The inspector’s IN/OUT points were Svelte-local state — set a range, switch clips, and it was gone; the backend never knew, and the timeline export couldn’t see them. Now
media.in_point/out_point(seconds) are stored via a newPATCH /api/media/{id}/inout(PATCH semantics like rating: an omitted field is untouched, explicit null clears; an invertedin ≥ outwindow is rejected 422; NaN/negative rejected by the model), surfaced by the detail route, and the inspector hydrates its marks from them on clip-open (once, without clobbering an edit already in progress) and persists changes debounced. This is the foundation for the multi-clip timeline export assembling the marked sub-clips instead of laying full clips end-to-end. Marks are written only by this endpoint — kept out of_ALLOWED_COLSso a re-ingest/refresh can never overwrite a user’s range. - Proxy generation uses hardware decode on Apple Silicon (D3). Building a 720p playback proxy for a 125-clip 4K shoot was pure software:
ffmpeg -c:v libx264with a software decode of the (often 100+ Mbps) 4K source. Measured on an M2 Max off a real 140 Mbps 4K clip, the decode is the bottleneck, not the small encode — so proxy gen now prepends-hwaccel videotoolbox(VideoToolbox hardware decode), cutting 26% wall-clock and 59% CPU per clip (a big deal for a sequential batch, where less sustained CPU means less thermal throttling). The encoder stays libx264 on purpose: the videotoolbox encoder was measured slower and produced a ~20× larger file at the same target. A source whose codec/pix_fmt VideoToolbox can’t decode falls back to software automatically (retry without-hwaccel), so nothing that built a proxy before stops building one. Defaults on for arm64 macOS (ARKIV_PROXY_HWDECODE=auto), off elsewhere; proxy height is nowARKIV_PROXY_HEIGHT(default 720) so the resolution can be lifted without a code change. - The timeline export lays the MARKED sub-clip, not the whole file (D2).
GET /api/export/timeline/{fmt}sequenced full clips end-to-end byduration_s, ignoring the IN/OUT marks entirely — so “mark IN/OUT across N clips → one EDL/FCPXML” never produced a cut list. Now each clip contributes only its persisted[in_point, out_point]window (clamped to the real duration; an unmarked clip still contributes its whole duration, so old behaviour is byte-identical until a range is set): the EDL source TC is cut from the in-point and record TC advances by the window; SRT captions are clipped to the window and re-based onto the timeline (a caption fully outside the trim is dropped); the FCPXML asset-clip duration and source start reflect the window. A defensive fallback treats an empty/inverted stored window as unmarked. Together with D1 this closes the “mark across 125 clips → drop one timeline into Resolve” loop. - MCP timecode tools — “which clip” → “which seconds of it” (#184, #185, #186). The MCP server was media-level only: an agent could find a clip but not learn what happens inside it, so arkiv’s understanding of footage stopped at the HTTP API. Now:
get_scenes(media_id)— one entry per scene-detect boundary:start_s/end_s/duration_splus that keyframe’s nine vision fields and akeyframe_path. Unknown id →null; a clip with no vision analysis →total: 0(distinct answers, deliberately).get_transcriptnow carriesduration_s,segments([{start, end, text}], on by default) andhas_words;include_words=trueadds[{word, start, end, score}], capped at 5000. The four original keys are unchanged. Word timing stays opt-in for the same reason the HTTP detail route dropswords_json(round-5 #26) — except over MCP the failure mode is a blown context window, not a slow transfer.- Segments are projected onto
{start, end, text}, not passed through: the three transcribe backends disagree on shape (mlx-whisper — every Mac ingest — stores its native dict includingtokens,seekand logprobs; faster-whisper writes six keys; whisperx a third), so a verbatim payload would depend on which machine ingested the clip. Measured on 8s of real footage: 1128 bytes verbatim → 213 projected. Clips ingested before Phase 9.4 have no segment timing, sosegmentsis[]and callers fall back totranscript. - Backed by a new
scenes.pyleaf that the HTTP route and the MCP tool both import, so the shapes cannot fork — the failure mode #182 had just demonstrated. They differ in exactly one key, pinned by a test: HTTPkeyframe_url(a URL for the authed thumbnail route) vs MCPkeyframe_path(PROJECT_ROOT-relative — a stdio client is on the same machine, so a path is actionable where a URL is not). The HTTP body is frozen byte-for-byte bytests/test_scenes_contract.py, authored against the pre-extraction route.
- A real end-to-end MCP stdio test (
tests/test_mcp_e2e.py). The docs and this changelog had claimed an “end-to-end stdio smoke” since Phase 14, but none was ever checked in — it was an ad-hoc manual run. Now it exists: real subprocess, realmcpclient, seeded temp project. It is the only thing covering FastMCP registration, stdio framing, dispatch, argument coercion and serialisation — every unit test calls the*_implfunctions directly. Includes a path-leak assertion made at the far end of the wire.
Changed
- License: MIT → PolyForm Noncommercial 1.0.0, with a Commercial Output Exception (#225). arkiv moves to source-available so it cannot be forked into a competing commercial product, while remaining free for any noncommercial use. The Commercial Output Exception is the part that matters to a working editor: videos, timelines and exports produced with arkiv stay fully usable commercially — the restriction is on the software, not on your work.
LICENSEcarries the canonical PolyForm-NC 1.0.0 text plus the exception and the Required Notice; both READMEs get the badge and section, and the wording moves from “open-source” to “source-available”, because PolyForm-NC is not an OSI open source licence and saying otherwise would be false. Already-released versions remain MIT-forkable from their commits; this applies going forward. reel-scout stays MIT as the open funnel. Legal review is advisable before a commercial launch (the Output Exception wording, and the holding entity). - Ingest says what it skipped, caps a runaway scene detect, and warns before it garbles your audio (#203). Three silent failures a first-wave user shouldn’t have to diagnose. A
--dirscan silently dropped pro/cinema footage (.mxf/.braw/.r3d/.m2ts/…), so someone emptying a C300, RED or BRAW card saw “Found 0 media files” and concluded arkiv was broken — it now prints “Skipped N unsupported file(s)” with a pro-codec callout explaining that ffmpeg has no decoder for these without vendor SDKs. The scene-detect timeout wasmax(120, duration_s * 2), so a corrupt file lying about a huge duration could wedge one ffmpeg for hours; capped at 30 minutes, mirroring the audio path. And with no--language, Whisper defaults tozhand silently garbles English or Japanese audio — there’s now a one-time upfront notice to pass--language en/ja/ko. - Phase 1 aborts fast when the drive goes away (#206). Phase 2 (vision) halted after N consecutive whole-file failures, but Phase 1 (probe + whisper + frames) had no such guard: an external or network drive unplugged mid-batch would grind through every remaining file at up to ~240s each (
probe()= 2×120s plus a 2s retry), i.e. hours on a large queue. The Phase-2 design is now mirrored into Phase 1 withARKIV_INGEST_HALT_STREAK(default 5,0disables) and a pure, unit-testable predicate. The counter resets on any ok/skip/move — proof the drive is readable, so sporadically corrupt clips never trip it — and increments on both failure branches, including the empty-record path that is the dominant drive-unplug shape. The partial batch still flows on to Phase 2 and embed. - Vision failures name the right cause (#218, #219). Surfaced by the first large real-corpus stress test (1506 files). A fresh project with empty settings resolves the vision model to the hardcoded default; if that model was never pulled, ingest used to 404 on every frame and then halt with a message that pointed at the wrong thing.
vision.is_model_installed()(strict and tag-exact, returningNonewhen Ollama is unreachable so a missing signal never becomes a blocker) now backsingest._preflight_vision_model(), called before both vision entry points, failing loudly and up-front with the installed vision models and how to set one. Separately, the halt message stopped claiming Ollama was broken unconditionally: a halt from the consecutive-failure guard does implicate Ollama, but exceeding--max-failuresjust means isolated bad frames with Ollama perfectly healthy — that case now points at--skip-failedinstead. - One source of truth for the version label (#197). The header showed a stale
v0.9.2while the app shipped v0.10.0, because the string was hardcoded independently in about a dozen files.lib/version.jsis now the single source, imported by the six live routes that display it; future bumps are one line. The/_design/*mock artboards keep their frozen label on purpose — they are design reference snapshots, not the product. - Neutral placeholders in examples and fixtures (#215). Docstrings, a code comment and several test fixtures named a specific real-world client library (and one machine-specific media path). Swapped for neutral stand-ins that keep each example’s teaching value — the registry-name-vs-directory-basename example still contrasts a CJK name with a latin directory, and the vocabulary fixture still exercises the CJK wordlist path. Verified as more than a find-and-replace: disabling
_basename_onlymakes the path-leak test fail, so the edited fixture still bites rather than passing vacuously. - The MCP server boots without a vector backend; the e2e now runs on CI.
mcp_serverimportedvectordb→chromadbat module scope, so a box without chromadb couldn’t start the server at all — even though six of the seven tools never touch a vector index — and the e2e stdio test self-skipped on CI, which installs no heavy backends. The import is now lazy: onlysearch_mediapulls invectordb, inside a try that binds it toNoneand degrades to a SQL filename/transcript match when it (or chromadb) is unavailable. The dim-mismatch branch stays correct becausevdbis bound beforeexcept vdb.EmbeddingDimensionMismatchis evaluated. A new test blocks chromadb in a fresh interpreter and asserts the server still imports, serves the six non-vector tools, and degrades search — so the three e2e tests now execute on CI 3.12 instead of skipping. - Clear error when pointed at an uninitialised project root. A tool call against an
ARKIV_PROJECT_ROOTwith no ingested data returned a rawno such table: mediato the agent. Each tool now checks readiness first and fails with an actionable message (pointARKIV_PROJECT_ROOTat an existing library, or ingest there first) — the check is cached once the table exists, and never callsinit_db()(which prints to stdout and would corrupt the stdio channel). The server still starts and lists its tools regardless; only calls fail, legibly.search_media’s SQL fallback is also wrapped so a live DB fault surfaces as a clear error rather than a raw driver string, kept distinct from the “vector index unavailable” degrade. - chromadb telemetry off.
vectordbnow setsANONYMIZED_TELEMETRY=Falsebefore importing chromadb, so a local, offline-capable media tool stops phoning home to PostHog on every client init. Set via env (not aSettingsobject) so it survives the test suite’s fake chromadb and holds however the process is started; covers the MCP and HTTP surfaces alike.setdefault, so an explicit override still wins.
Fixed
- Federated search returned nothing for libraries indexed before the 8.0c rename (#212). Federation search and registry sync hardcoded
.arkiv/project.db. Libraries indexed before the Phase 8.0cmedia.db→project.dbrename keep their corpus in the legacy.arkiv/media.dband may carry only an emptyproject.dbstub — so cross-project search returned empty for exactly those projects, with no error to suggest anything was wrong. A small resolver now decides in three steps: an explicit override (ARKIV_DB_PATH/--db) wins, else the 8.0c default, else the legacy file. - The tag-merge LLM’s answer was silently thrown away (#214).
json_modeguarantees only that the reply parses as JSON, never that it has the shape the prompt asked for —chat.pyhad already learned this from the M1/H9 audit findings and validates every field, butingest.py’s two tag-canonicalization call sites never got the same treatment. Measured against the real provider (qwen2.5:14b / ollama 0.30.7), a prompt that literally says to return{"groups":[...]}answered{"慢跑":"路跑"}— nogroupskey at all, so.get("groups", [])yielded[], the loop never ran, and the cluster’s judgment was discarded without a word. The model was right (慢跑 and 路跑 are synonyms); arkiv simply dropped it. Silent loss is worse than a crash because nothing surfaces it. Two harder shapes on the same path aborted the entire run and discarded every cluster judged so far, because the failing accessor sat outside the try. Fixed in two layers —llm.chat()takes an optionalschemapassed as ollama’sformatto constrain structure at the source, and the callers validate anyway, because schema adherence is model-dependent and a non-conforming provider must never crash a run.guard_canonical()’s semantic protections were already sound and are untouched; the gap was structural, not semantic. Verified end-to-end: the input that previously yielded nothing now returns{"pref":"慢跑","alts":["路跑"]}. - Ten Taiwan-standard characters made ordinary Traditional text look Simplified (#213).
_char_is_simplified— and thereforeclassify_zh— asked neutrals2twhether a character changes.s2t“corrects” exactly ten Taiwan-standard characters to archaic variants (吃→喫, 唇→脣, 峰→峯, 床→牀, 灶→竈, 痴→癡, 皂→皁, 秘→祕, 粽→糉, 群→羣), and those are common: any transcript containing 吃 or 群 classified as “mixed”, and one that happened to carry no Traditional-only character could reach the “simplified” bucket and be handed to phrase-levels2twp— precisely the input that re-segments and corrupts valid Traditional. Probings2twinstead keeps all ten while still rewriting every genuinely Simplified character (软→軟), so the gate and the char-wise converter agree exactly. No output change on already-correct data; what goes away is a wasted pass and the residual mis-bucketing risk. Verified on a real 122-row library (the 3 rows previously mis-read as “mixed” now classify Traditional) and on a second library byte-identical old-vs-new with every genuine Simplified character still caught. - Every built DMG embedded version 0.2.0 (Wave E, #244).
tauri.conf.jsonandCargo.tomlsat at0.2.0across the entire v0.2 → v0.10 tag history, so the version a user saw in a downloaded bundle bore no relation to the release. Both (and theCargo.lockroot entry) are corrected to 0.10.0, matching the release tag at the time.CONTRIBUTING.mdgains a Releases section documenting the changelog-cut and annotated-tag process plus a release-artifact matrix (macOS arm64 supported; Intel and Windows not built). The structural fix is #245, which stamps the version from the tag so this cannot silently drift again. - The toolbar clipped instead of wrapping on a narrow window (C1, #196). Rendered at the 900px Tauri minimum width, the main toolbar genuinely clipped: the artboard is
overflow:hiddenand.toolrowwas a non-wrapping flex, so below ~1000px the VIDEO/AUDIO/GOOD/REVIEW/N·G filters and the GRID/LIST toggle ran off the right edge with no scroll — simply unreachable. The stats line also shattered one token per line. Fixed withflex-wrapon the tool row and filter row, amin-widthon the live search so it shrinks before it pushes buttons off-screen, andwhite-space:nowrapon the stats line. Verified by re-rendering at both 900px and 1440px. - A fresh install was told to pull a model it had correctly not pulled (B1, #198, #202).
health.pyhardcoded a check fornomic-embedwhileinstall.shand config had switched the default embedding model tobge-m3months earlier — so a correct fresh install reported the embed model MISSING and prompted a pointlessollama pull nomic-embed-text. It now readsconfig.OLLAMA_EMBED_MODELlike the vision, chat and intent checks already did. The same drift ran through the docs: the README pull commands and tables,.env.example, anddocs/install.mdall namedqwen3-vl:8b/nomic-embed-textwhile the actual defaults areqwen2.5vl:7b/bge-m3, so a README-follower pulled a 10× slower model thathealth.pythen flagged as missing. Everything is aligned to the defaults, with one note preserved thatqwen3-vl:8bremains a higher-quality/slower override. Addsdocs/quickstart-mac.md, a hand-holding Apple Silicon guide covering the Gatekeeper right-click → Open step, runninghealth.pyto READY before the first ingest, passing--languagefor non-Chinese footage, and where to findbackend.log.
Internal
- Wave B — CI stopped taking the build’s word for it (#226, #227, #228). The Tauri (Rust) sidecar had no gate at all, so a
Cargo.toml/Cargo.lockdrift or a Rust regression could merge silently; a newtauri-checkjob on macos-latest (the only ship target — WKWebView is a system framework, so no apt webkit deps) builds the frontend first, becausegenerate_context!embedsfrontend/distat compile time and that directory is git-ignored, then runscargo check --locked. Docker was onlydocker compose config, a YAML parse — a broken Dockerfile merged clean; a newdocker-buildjob now builds the real image and boots it, curling/api/statsover loopback inside the container (arkiv trusts loopback and 401s external callers, matching the Dockerfile HEALTHCHECK) with no models or GPU involved. A full Linux build is green-able becauserequirements.txtgates mlx behind a platform marker. Compose also gains an ollamahealthcheckwithdepends_on: condition: service_healthy, so it waits for readiness rather than container-start — it does not wait for model pulls, which stay a runtime step. Buildx was dropped after its Docker Hub builder-image pull flaked the gate; the runner’s built-in builder is sufficient. Separately, the coverage job’s flake was diagnosed rather than retried:tests/test_mcp_e2e.pyspawns a real subprocess over async stdio, and under--covthe parent-side tracer slows the stdio pump into thefail_afterbound while the un-instrumented child adds ~0 coverage — it’s now excluded from the--covleg by marker (and still runs unfiltered on the test legs), with CONTRIBUTING documenting why coverage stays a non-blocking ratchet and what would flip it to blocking. - Wave C — a clean rebuild reproduces (#230, #231). 19 of 21 Python dependencies were bare lower bounds and the Docker bases plus ollama were floating tags, so a rebuild months later could drift arbitrarily. The two build-path bases (
node:20-slim,python:3.11-slim) and the runtimeollama/ollamaimage — the worst offender — are pinned by@sha256, with the human-readable tag kept alongside for legibility and the digest authoritative. Dependabot then keeps those digests from rotting and opens one weekly PR per bump across pip, npm, cargo, github-actions and docker, each linking upstream release notes so a rollback is just a revert. CONTRIBUTING gains a Dependency updates section explaining why there is no Python lockfile (a 3.9 × 3.12 matrix with platform markers). First batch merged: xxhash, pillow, fastapi, silero-vad, faster-whisper (#232, #233, #234, #236, #237) and, on the frontend,@fontsource/jetbrains-monoand three (#239, #241). - Wave D — the frontend has quality gates, not just a build (#242, #243). The frontend CI job was build-only, its own comment conceding that lint and svelte-check were “a follow-up”.
svelte-check --fail-on-warningsnow runs inside the already-required job (so no new required check), and the five pre-existing warnings were fixed to make zero the baseline: unusedexport let→export conston ArkivLogo and Thumb (no caller sets them, API parity kept), ansvelte-ignorewith a stated reason for the deliberate rename-field autofocus in Bins, and two dead CSS rules deleted. A bundle budget was added on the eager initial-load chunks (index js ≤512 KiB, css ≤560 KiB, currently ~404/481) — the lazy Pano360/three chunk at 501 kB, which loads only when inspecting 360 media, and the route chunks are intentionally exempt, because the budget exists to catch new growth in what every user downloads, not to re-litigate an already-split viewer.
v0.10.0 - 2026-07-14
A large release: a month of Phase 9.x transcription/UI work, cross-library
“精選集” bins, live cross-project federation search, an optional pgvector backend,
two security-hardening rounds (round-4 + round-5), a performance batch, and the
internal server.py → routers/ split. ~130 commits since v0.9.2.
Breaking
GET /api/media/{id}/scenesreturns a per-scene shape (#111). Each scene is now an object ({start, end, thumbnail, …}) instead of the previous flat/parallel-array form, so per-scene metadata (deep-links, thumbnails) can attach to the right scene. Clients that indexed the old array layout must read the object fields.
Added
- Batch export to zip —
POST /api/export/batch(Phase 12.4). Bundle the per-clip export (srt/vtt/txt/edl/edl-markers/fcpxml) of many clips into one.zip, one file per clip. Reuses the single-clip builder verbatim so formats + content are identical; missing ids are skipped, duplicate filename stems de-collide (clip.srt,clip_1.srt) so nothing is silently overwritten. (For one stitched timeline use/api/export/timeline.) 6 tests intests/test_export_batch.py. — The rest of Phase 12 (corpus/jsonl/SRT-layout/bilingual viaexport.py) was already shipped; this closes the last item. - ADR 0001 — DIT offload wrapper.
docs/adr/0001-dit-offload-wrapper.mdrecords the Gate.exe teardown decision (build the wrapper UX, not the engine — arkiv’soffload.pyalready beats Gate) so candidate ③ of the DIT-wrapper plan is a findable design record. - Event-driven folder watcher (Phase 11.2).
watch.pyis rewritten from a 30s polling loop into an event-driven watcher:watchdog(FSEvents on macOS / inotify on Linux) when installed, with a polling fallback so a box without it (e.g. the NAS) still works — no hard new dependency. New safety logic over the old version: a stability/debounce gate (a file is ingested only once its size+mtime has held steady for--debounceseconds, so a slow network copy is never ingested mid-write — the old “mtime < 5s” guess missed those), dedup against the DB by resolved path, and unmount-abort (a watched root disappearing — card pulled — stops that root cleanly instead of ingesting a vanishing file). Watches multiple roots at once (python watch.py /Volumes/SDCARD ~/.arkiv/inbox). TheStabilityTracker+Watcher.tickcore is pure + clock-injected; 11 tests intests/test_watch.py. (Still subprocess-per-file for crash isolation; in-process dispatch to avoid model reheat — microtask B8 — and the launchd autostart plist + drag-upload / onboarding UI are tracked follow-ups.) - Per-language transcript tabs (Phase 9.7 G2). A clip used to hold a single transcript — retranscribing in another language destroyed the previous one. Now every transcribed language is kept in a
transcriptsarchive table; the inspector shows language tabs (the active one marked ●), you can view any language and “設為主要” to make it the active/indexed/exported transcript.GET /api/media/{id}/transcripts(lazy-backfills the active language) +POST /api/media/{id}/transcript/activate. Retranscribe, batch-retranscribe and re-ingest now archive the outgoing transcript first, so a fresh ingest no longer silently clobbers a hand-corrected transcript — it’s preserved and restorable (this folds in the real concern behind the retracted “conflict merge” gap, the RP-4 way). - UI scale control in Settings → Appearance (Phase 9.7 G4). A real, persisted scale slider (80–140%) — long deferred because the layout is px-based (a root font-size does nothing). Implemented via CSS
zoomon the document root, which scales the whole tree regardless of px/rem and is supported in WebKit (Tauri/WKWebView) + Chromium. Persists to localStorage and re-applies on load; a 重設 button returns to 100%. Type density stays honestly disabled (px spacing won’t reflow from a density class). - List view in the main browser (Phase 9.7 G1). The Grid/List toggle is now wired: List renders a real table (thumbnail · filename · rating · resolution · duration · size · ingest date) with row-select, selection highlight, and multi-select checkboxes — same selection/pick state as the grid. Columns are drawn from the existing light-list payload; camera/lens columns from the design are deferred (not carried by the list query). Grid remains the default.
- VTT subtitle export in the inspector (Phase 9.7 G3). The export row now offers VTT alongside EDL / FCPXML / SRT (the backend already produced WebVTT — the button was just missing). Honours the IN/OUT trim window like the other subtitle exports.
- Tag-source breakdown in the inspector (Phase 9.7 G8). The Tags section header shows
N AUTO · N MANUAL, surfacing how many tags came from vision vs were hand-added — matching the design SSOT. Reads the existingtags.sourcefield; no schema change. - Correction-dictionary editor in Settings (Phase 9.6c). Settings → Vocabulary: edit the per-project dictionary (rule table
from → to · scope · pre · post), then batch-recorrect from the same panel —預覽命中(dry-run) →套用校正(gated on a preview with hits) with an optional vector-rebuild, plus還原最近一次. The dictionary is the unified vocab UI — itspretoggle is the hotword function, so no separatevocabulary.txteditor is needed. - Project-wide batch retranscribe (Phase 9.6d, the 2a upgrade path).
POST /api/retranscribe-allre-runs Whisper across every audio clip so newly-added hotwords / correction-dictionary pre-terms take effect — for the rare case a term was mis-heard so badly that find→replace can’t recover it. Single-flight +GET /api/retranscribe-all/statusprogress poll (mirrors the embed-rebuild pattern); snapshots all transcripts to the shared correction-backups first, so the existing revert restores them. Preserves the single-clip guard (never blanks a good transcript on an empty/failed decode). Wired into Settings → Vocabulary as a clearly-marked heavy “升級” action with live progress. 6 tests intests/test_retranscribe_all.py. - Per-project correction dictionary + batch recorrect (Phase 9.6b). A
.arkiv/corrections.jsondictionary of{from, to, scope, pre, post}rules drives two paths from one source:preterms feed the Whisper hotword list (initial_prompt, hot-read likevocabulary.txt), andpostrules batch-rewrite already-stored transcripts — fixing the whole backlog’s search recall in seconds without re-running audio. Endpoints:GET/PUT /api/corrections,POST /api/recorrect(defaults to dry-run preview — writes nothing;dry_run=0applies,rebuild=1chains the embedding rebuild),GET /api/recorrect/backups,POST /api/recorrect/revert. CLI:python recorrect.py --dry-run | --apply [--rebuild] | --revert [NAME]. RP-4 safe: preview-first, and every apply writes a timestamped backup restorable via revert. The recorrect syncssegments_jsonalongside the transcript blob (else search/SRT drift), andwords_jsongets whole-token renames. Scopewordguards against bleeding into a longer token (a松→鬆rule never touches馬拉松). Operates on the active project. 16 tests intests/test_corrections.py; full suite 608 passed / 5 skipped. - Cross-library “精選集” bins (multi-project Phase 2, PR A/B/C). Curate clips into named bins that span projects: persistence + CRUD + reference-integrity status (PR A), copy a bin into a project in either reference or verified-copy mode with project bootstrap (PR B), and add-to-bin straight from the main grid plus bin rename and a 精選集 section in the pool sidebar (PR C). Bin-detail status probes are batched per project so a large multi-project bin stays responsive.
- Live cross-project federation search. The search box now queries across projects through the federation layer (
GET /api/search/all), with the API-boundary path sanitisation that keeps avideos_readclient from mapping the operator’s cross-project directory layout (Phase 16.2). - Optional pgvector backend for embeddings (#180). A shared-NAS Postgres/pgvector store is selectable as the vector backend; Chroma stays the default, so existing single-box installs are unaffected.
- Live vision-model picker at ingest (brick 4b). IngestSetup lists the vision models actually installed in Ollama and lets you pick one per run, instead of being pinned to the config default.
- 機型 camera browser in the pool sidebar. Browse the library by camera model, surfacing the camera/lens/timecode/codec metadata already captured at ingest.
- Chat scene deep-links. A chat answer’s scene references filter the main grid to the relevant subset and wire back-navigation into the app; Esc aborts an in-flight request.
- Basic drag-to-look 360 viewer in the inspector for
.insv/.360footage. - Settings tabs — projects CRUD + real transcription engines (Phase 9.7 G5 ①).
- Optional LLM tag canonicalization (E). A semantic synonym-merge pass collapses near-duplicate tags, on top of the prompt-level canonicalization and variant-collapse fixes.
- Draggable IN/OUT waveform markers + click-to-play in the inspector.
- Small quality-of-life bricks: export success/failure toasts (B11), a mixed-frame-rate warning on timeline EDL export (B13), waveform cache dir + thumbnail-coverage reporting in health (B5), and a per-clip original→proxy size delta in the ingest log (B6).
Changed
- Legacy Tailwind UI retired (Svelte cutover Phase 3, #78). The old single-file
index.html+ the standalonedit-offload.htmlisland are deleted;/serves only the built Svelte SPA (a missing build now shows a clear “runnpm run build” message instead of falling back). The/legacyroute and theARKIV_UI=legacyescape hatch are removed (the SPA has baked since v0.9.2), the Tailwind CDN proxy +/tailwind-static.cssgo with them,/ditnow 308-redirects to/#/offload, and the TaurifrontendDistpoints at the built SPA.install.shno longer copies the deletedindex.html. - Default vision model is now
qwen2.5vl:7b(wasqwen3-vl:8b). Qwen3-VL’s vision path (DeepStack / interleaved-MRoPE) runs ~10x slower than Qwen2.5-VL under Ollama and often offloads the vision encoder to CPU — measured ~60s/frame vs ~8s/frame on an M2 Max for identical frames (Ollama issues #12854 / #12882 / #14548). At ~2000 frames that’s ~30h vs ~3.5h, at comparable tag quality (spot-checked). Override withARKIV_OLLAMA_VISION_MODEL=qwen3-vl:8bfor the higher ceiling once the Ollama regression is fixed. - arkiv is now Windows-portable. Cross-platform path handling throughout and a green Windows test suite; the frontend/inspector was rebuilt to a full-window layout with a real video/audio player, waveform, and scrolling side panels.
Fixed
- Vision fallback no longer 404s on every fresh install. The Phase-2 fallback model was hardcoded — and inconsistently (
ingest.pyusedminicpm-v,server.pyusedmoondream2) — whileinstall.shpulled neither, so the issue-#48 resilience path silently 404’d per failed frame and left those frames empty. Now: a single configurableARKIV_OLLAMA_VISION_FALLBACK_MODEL(defaultminicpm-v:latest) used by both paths, pulled byinstall.sh, and skipped gracefully (logged once, frame left for a later--vision-onlyretry) when the model isn’t installed instead of erroring per frame. install.shpulls the right models for the new defaults:qwen2.5vl:7b(vision) +minicpm-v(fallback), alongsidebge-m3+qwen2.5:14b.- CJK-named clips export instead of 500ing. Content-Disposition now uses an RFC 6266
filename*so a clip with a Chinese/Japanese/Korean name downloads correctly instead of erroring. - Sony XAVC camera identity recovered from embedded XML (closes #115); the ingest bench summary now reports the effective vision model rather than the config default.
- Tag-cloud noise cut down across the pipeline: Simplified→Traditional normalisation, artifact/English filtering, per-clip dedup, a canonical prompt + variant collapse, a UI cap, and a library alias map.
- Domain-agnostic Tier-1 smart collections — the hardcoded food vocabulary is gone, so smart pools work for any library.
- Frontend robustness: API errors are surfaced (
ApiErrordetail, no more silently-swallowed mutations), stale-response guards on the bin + search loaders, and a grid truncation notice with offset-based load-more. - Content-hash move-detection dedup at ingest (round-5 #8) and migration of legacy backslash-relative paths (round-5 #9), so a moved/re-ingested file is recognised instead of duplicated.
Security
- Hardening round-4 (fable-audit 2026-07-12). Offload-path denylist, cache-clear CSRF protection, absolute-path leak fixes, and a DDL guard; absolute project paths are now scoped to admin callers only (#22).
- Hardening round-5 (#51, ~30 findings). Highlights:
/api/ingest+ reingest run through a tree-killing process group so an aborted ingest leaves no orphans (#2/#12);/api/cache/clearprotects thumbnails and invalidates the chromadb cache (#15/#16); ingest single-flight now also coverscopy_bin+ retranscribe-all, and the ingest slot is released on a copy_bin index-phase failure (#58); atomic proxy writes with a non-empty gate at every consumer (#1/#60); resumable, abortable, single-flight offload state (#19/#45); transcript-merge re-parents transcripts before the media row is deleted (#7); retranscribe integrity (language backup, revert, archive-outgoing) (#14/#17); a permanently-failing watched file is backed off and quarantined (#5); the deadVISION_MODELglobal is removed and the fallback model threaded through_call_vision(#50); single-source extension sets fix.insv/.360SQL drift (#57).
Performance (round-5)
- Indexed the hot media columns, chunked large
id IN (…)queries, and bounded the federation SQL fallback (#20/#21/#24); trimmed the media-detail payload, made stats a single pass, and reconciled embeddings ids-only (#26/#27/#31); dropped the heavy transcript JSON from the Phase-1→2 retained record (#28); batched bin-detail status probes per project (#23); code-split the/_designmock routes via async import.
Internal
server.pysplit into arouters/package (#51, R5-25). The ~4.5k-line FastAPI monolith was peeled — verbatim, behaviour-preserving — into focusedAPIRoutermodules (routers/*.py) behind leaf service modules (pathres/webguard/export_builders/reqopts/mediarecords/state) that broke the import cycle.server.pyis now a ~490-line app shell (static routes + lifespan + middleware +include_routerwiring). No route paths, methods, or auth changed.
v0.9.2 - 2026-06-17
Added
- New Svelte UI is now the default at
/; the old Tailwind UI moves to/legacy(cutover Phase 1+2, #78).server.pyserves the built SPA fromfrontend/distwith a/assetsmount; it auto-falls back to the legacy page when no build is present, andARKIV_UI=legacyforces the old page.install.shand theDockerfile(newnode:20build stage) now runnpm run build, so a fresh clone / image ships the new UI. The old UI stays reachable at/legacyas an escape hatch during the cutover bake. Hash-routed SPA → no server-side fallback needed. - Tier 1 backend-parity features wired into the new UI (#78). Inline tag editing (add/remove); per-clip re-processing (retranscribe / retry-vision / reingest); editing-proxy build + status (whole-library and per-clip); DaVinci Resolve metadata CSV export (whole-library and current selection); and a chat-history sidebar that lists past conversations and restores their threads.
Notes
- Tier 2 backend capabilities are not yet wired into the new UI (in/out position save, chapters, export-to-path, remotion-props, open-file, project federation, pool view, admin tokens, cache ops, analytics breakdowns). They remain reachable via the API and the
/legacyUI; tracked in #78.
Performance
- DIT
--organize/ preview now probe metadata in one ExifTool spawn, not one per file._probe_camera_metaspawned ExifTool per file, so a 400-clip card preview/offload paid ~400 process spawns (tens of seconds of pure spawn overhead). Extracted_exiftool_batch()(a singleexiftool -json …file1 …file2 …fileNcall keyed bySourceFile) +_probe_camera_meta_batch();preview_layout()and_ensure_file_records()batch-probe up the whole file list once. The Sony XAVC sidecar / mtime fallbacks still run per file (cheap, no subprocess). The single-file_probe_camera_meta(card-watch) is unchanged in behaviour.
Added
- DIT Offload UI (
/dit). A working control panel for the DIT engine: type a source card, destination(s), and an--organizetemplate, hit Preview to see the exact source→destination layout (read-only, no copy), then Run to offload (copy + hash-verify + ascMHL, never deletes the source). New endpointsPOST /api/offload/preview(pure layout viaoffload.preview_layout()) andPOST /api/offload(subprocess, mirrors/api/ingest; state file scoped to the project’s.arkiv, not the install dir); page served atGET /dit. The run streams live per-file progress —offload.py --progress jsonemits single-linedst_start/file/doneevents and/api/offloadrelays them as ndjson via aStreamingResponse, so the UI shows a progress bar + current-file count instead of blocking on one giant request. Verified end-to-end (preview layout + streamed copy + MHL manifest). Tests intests/test_dit_offload_ui.py+tests/test_offload_organize.py. - DIT card-watcher (
offload.py --watch). Waits for a camera card to mount, then auto-offloads it — copy + hash-verify + MHL, never deletes the source — with the--organizenaming policy. A card is a newly-mounted volume with aDCIM/folder or media files; only NEW inserts trigger (already-plugged disks at startup are the baseline and ignored), and a removed→reinserted card re-triggers. One failed offload is logged and the watch loop survives. The “insert → it just copies” half of the Gate replacement, paired with the--organizefolder policy. Requires at least one--dst. Tests intests/test_offload_card_watch.py.
Fixed
--refreshnow actually re-extracts thumbnails + frames (issue #53). It used to re-run vision/embed but reuse cached frame thumbnails (thealready_okcheck skipped extraction whenever a file existed), so a change to the extraction logic itself — e.g. the Phase 8.3b 360 reproject — never re-applied to already-ingested clips; they kept their stale raw-fisheye frames.--refreshnow threadsforce=Truedown to frame/thumbnail extraction. Re-extraction is atomic (ffmpeg writes a temp sibling,os.replaceonto the canonical file only on success), so a forced re-extract that times out / fails can’t destroy the prior good thumbnail while the DB still points at it. Non-refresh ingest still reuses existing thumbnails (cheap re-ingest, unchanged).
Added
- 360 footage is reprojected to equirectangular before vision (Phase 8.3b). Dual-fisheye
.insv/.360(Insta360 / GoPro Max) now get both lenses stitched into a full equirectangular panorama (hstack→v360=dfisheye:equirect) before frame extraction, so the vision model sees an undistorted scene. The raw fisheye buries the wearer and on-screen text (event name, bib numbers) in the distorted edge — vision reads nothing useful; the equirect stitch surfaces them (verified end-to-end: the same clip went from “a group in blue shirts” to reading “POCARI SWEAT RUN” + bib number off the reprojected frame). Detection is ext-gated + confirmed by a two-video-stream ffprobe (a single-lens file mislabeled.360won’t try to stitch a missing stream); 360 frames extract at 1024px (vs the normal 320px) so reprojected text survives for the VLM. Audio/transcript is projection-agnostic and unchanged. Tests intests/test_frames_360.py; design inreferences/plans/arkiv/2026-06-13-arkiv-8.3a-360-indexing-poc.md. - DIT naming / folder policy (
offload.py --organize TEMPLATE). Lay card files into camera-metadata folders instead of mirroring the card — the thing Gate’s folder logic got wrong. Folder template with{date}/{camera}/{reel}/{stem}/{ext}tokens (original filename always appended), e.g.--organize "{date}/{camera}/{reel}". Metadata via ExifTool + a Sony XAVC NRT sidecar fallback (so FX30/FX-series get make/model even when the.mp4carries none); missing values resolve toUNKNOWN, the date falls back to file mtime. Token values are sanitized to one filesystem-safe segment (aSony/FX30can’t spawn a nested directory; no path traversal), and two source files mapping to the same destination are refused (a DIT tool must never silently overwrite). Pair with--dry-runto preview the layout. The default (no--organize) still mirrors the source exactly. Tests intests/test_offload_organize.py. (Follow-up: ExifTool currently spawns per file — batchable for large cards.) - Vision frame-failure tolerance (
--max-failures/--skip-failed, issue #48). The vision phase used to halt the entire run on the first frame that failed both the primary and fallback model — fine interactively, but a single transient Ollama hiccup killed a 481-frame overnight run.--max-failures Ntolerates N cumulative failed frames before halting (N=0, the default, keeps the historical zero-tolerance behaviour).--skip-failednever halts on individual frame failures — it leaves them with an empty description (so a later--vision-onlyretries exactly those frames) and prints a report at the end. A consecutive-failure guard (whole files producing nothing) still halts fast under either flag, so a real Ollama outage can’t spin all night. Successful frames are now committed even on a file that later triggers a halt (no lost work).--vision-onlyand the main Phase-2 loop share one describe→fallback→tolerance path. Tests intests/test_vision_tolerance.py.
v0.8.1 - 2026-06-12
Added
- 360-camera formats (
.insv,.360) are now first-class video. Insta360.insvand GoPro Max.360are HEVC-in-MOV/MP4 — ffmpeg probes them and extracts frames fine, but they were absent from the ingest whitelist, sois_videowas False and they silently skipped thumbnail/frames/vision. Added toVIDEO_EXT/SUPPORTED(ingest),_VIDEO_EXTS/MEDIA_EXTS(server),watch.py, and the UI format lists. Verified on a real.insv(dual 2880×2880 HEVC fisheye + AAC; thumbnail decodes)..insp(Insta360 stills) not included — no image pipeline. Tests intests/test_ingest_microtasks.py.
Tests
- Pinned 3 Codex-flagged edges as resolved (H5 abs/rel row merge, scene_ids dangling reference, M24 codec backfill freshness). All were already fixed in the v0.8.0 sprint; regression tests in
tests/test_v081_edges.pylock the behaviour — frames+tags merge to the survivor with no orphans + FK integrity (incl. the frame_index collision branch); a danglingscene_ids_jsonid drops gracefully (WHERE id IN+find_similarreturn[]); stored codec skips re-probe, NULL probes once + backfills, probe failure falls through without crashing playback.
Docs
- README (EN + zh-TW): documented 360 format support and that camera identity is read from embedded EXIF and the Sony XAVC NRT sidecar XML (so FX30 / FX-series footage keeps make/model/lens/timecode that consumer DAMs drop).
v0.8.0 - 2026-06-12
Security
- Stored/reflected XSS in the web UI closed.
esc()only escaped&<>(the textContent trick), leaving'/"raw — so LLM-generated tag names / filenames interpolated into HTML attributes and inlineonclickstrings were injectable, and the vision-LLM frame description was written intoinnerHTMLunescaped (stored XSS via on-screen text in footage).esc()is now quote-safe, a dedicatedescJsAttr()guards values placed in inline-handler JS strings, and the frame description, tag handlers, autocomplete row, and search-query title are all escaped. /api/mediano longer turns every authenticated read into a DB write. Token resolution updatedlast_used_aton every request, so aGETcompeted for the SQLite write lock during concurrent ingest (“database is locked”). The write is now throttled to once per 60s per token.uninstall.shrefuses torm -rfa non-arkiv directory.ARKIV_DIRcame straight from the environment; a stray value ($HOME, a footage volume) would wipe an unrelated tree. It now requires the install markers (server.py+config.py) before removing anything.
Added
- Custom-vocabulary file for transcription hotwords. Beyond the comma-separated
ARKIV_CUSTOM_VOCABULARYenv, you can now keep a newline-delimited wordlist (one term per line,#comments) —ARKIV_VOCABULARY_FILE, defaulting to.arkiv/vocabulary.txtwhen present. Terms (names, places, product jargon) merge with the env list, dedup, and feed Whisper’sinitial_prompt— the persistent-wordlist workflow FatSub validated with editors. Tests intests/test_transcribe_faster_whisper.py. - Chapter-marker export (
export.py chapters <id>). Turns a clip’s sampled scene frames (scene-change timestamps for long clips) into chapter markers, with titles auto-generated from each frame’s vision description (first sentence, truncated). Two formats:--format youtubeemitsMM:SS Titlelines (first marker forced to0:00per YouTube’s rule) for video descriptions;--format ffmetadataemits an ffmpeg metadata file to embed real chapters (ffmpeg -i in -i chapters.txt -map_metadata 1 -codec copy out). Borrowed from the FatSub ProChapter benchmark; reuses existing scene segmentation, no new deps. Also exposed over HTTP:GET /api/media/{id}/chapters?format=youtube|ffmetadata(scopemedia_read) returns{media_id, format, chapters, count}.
Fixed
probe()no longer swallows ffprobe failures, hangs, or lets one bad clip poison a batch. It used-v quietwith no timeout and returnedNoneon any non-zero exit — so a mid-ingest failure printed only[ffprobe failed]with no cause, and a transient subprocess-spawn failure (e.g. Windows handle exhaustion after a heavy clip) could fail every subsequent clip. Now:-v errorsurfaces the real stderr, a 120s timeout bounds hangs, the actual rc/stderr/exception is printed, and one retry (2s back-off) recovers transient spawn/resource pressure. Tests intests/test_ingest.py.- Headless / SSH ingest on Windows no longer dies at the first
ffprobe([WinError 448]). arkiv invokedffmpeg/ffprobeby bare name, so on a non-interactive Windows session (headless service, SSH) they resolved to the WinGetLinksApp Execution Alias — a reparse-point shim that raises[WinError 448]outside an interactive token, crashingprobe()before any clip is processed. ffmpeg/ffprobe are now resolved once viaconfig.FFMPEG_PATH/config.FFPROBE_PATH:ARKIV_FFMPEG_PATH/ARKIV_FFPROBE_PATHenv >shutil.which()(skipping alias shims) > common install paths (Gyan winget full build, choco, scoop, Homebrew,/usr/bin) > literal name. macOS/Linux behavior is unchanged (whichreturns a real path). New tests intests/test_config.py. UnicodeDecodeErroron Windows zh-TW (cp950) during proxy generation / codec probe.subprocess.run(..., text=True)without an explicit encoding decoded ffmpeg/ffprobe output with the cp950 locale codec and crashed on utf-8 bytes. Proxy gen (ingest.py) and codec probe (codec.py) now pinencoding="utf-8", errors="replace".
Audit batch 2026-06-10 (5-agent review + Codex verification)
- Thumbnails no longer cross-contaminate between same-named clips. Camera cards reuse filenames (
C0001.MP4,GX010001.MP4); with--recursiveover several cards, the second clip found the first’s thumbnail already on disk and reused it — so its vision tags / editability score were computed from the wrong frames and written to its DB row. Thumbnails are now keyed by{stem}_{sha1(abs_path)[:10]}, the same path-hash guard proxies already had. Phase 3no longer re-runs ffprobe over the entire library every ingest (the mid-run ffprobe storm behind the stalled large-batch runs). The detected codec is persisted inmedia.codecat ingest time and reused; only legacy NULL-codec rows probe once, then persist.watch.py’s per-file process spawn no longer triggers a full-library probe per new clip.- A mid-run crash no longer leaves “processed but unsearchable” records. The media row, its auto-tags, and its frame rows are written in one transaction (so a crash can’t leave a frame-less row that
is_processedskips forever); embedding now runs even whenok == 0to reconcile rows a prior crashed run left un-embedded, force re-embeds--refresh’d rows, and prunes ChromaDB entries whose SQLite row is gone;upsert_recorddeletes a media’s old chunks before re-inserting (no orphan chunks from a shrunk transcript). --refreshno longer wipesframe_tags/thumbnail_pathwhen vision or thumbnail extraction fails. Those fields are omitted from the refresh upsert instead of being set toNone, so the prior searchable values survive a failed re-vision.- Vision halt now exits non-zero. A halt on the first file left
vision_fail = 0→ implicit exit 0, hiding a fully-unindexed library from cron/watch; the halt now counts as a failure and--vision-onlypropagates the halt as exit 1. /api/media?q=…honorsrating/langwith semantic search. The filter ran against the raw vector hit (which carries norating), dropping every semantic result underrating=goodand silently falling back to an unfiltered SQLLIKE. It now filters the enriched record, and the SQL fallback applies the same filter.- Chat is hardened against malformed/injected model output. Refinement intersects model-returned scene ids with the prior set (rejecting hallucinated / transcript-injected ids before they’re persisted); analytics validates
time_rangeasYYYY-MManddispatchhas a catch-all so a bad model response returns a clean error turn instead of a 500; the intent limit is clamped>= 1; intent is classified once per request instead of twice. - WebSocket ingest no longer deadlocks or runs unbounded.
stderris merged into the read stream (an unreadstderr=PIPEdeadlocked once its 64 KB buffer filled), the background task is held with a crash-logging callback, a 409 guard serializes concurrent ingests, the progress regex handles filenames with spaces, and the completion message reports the real failed-count and exit code. - “Export GOOD as EDL” exports all good clips, fetched across the whole library and sequenced via the batch timeline endpoint, instead of only the first good clip on the current grid page.
federationsearch timeout actually bounds wall-clock.search_all_projectswaited onshutdown(wait=True)even after a per-future timeout, so a worker hung on a stale mount hung the whole request; it now uses a shared deadline and abandons stragglers.- Installer fixes: the venv is built from the freshly-installed
python@3.12binary (not the rejectedpython3), and Ollama pulls matchconfig.pydefaults (bge-m3/qwen3-vl:8b/qwen2.5:14b) — the oldnomic-embed-textpull left a fresh install’s semantic search silently degraded.
Changed
media.codeccolumn added (additive, backward-compatible migration) to persist the ffprobe codec for proxy decisions.- Dropped the unused
pandasdependency (~70 MB, imported nowhere) and removed the orphaned, brokenvision_refresh.py(superseded byingest.py --vision-only).chromadbandpydanticnow carry upper version bounds.
v0.7.0 - 2026-06-08
Security
- Optional HMAC token hashing (Phase 16.1). Set
ARKIV_TOKEN_HMAC_KEYto store access tokens as HMAC-SHA256 instead of bare SHA-256. Non-breaking: existing tokens keep working (dual-read) and upgrade to HMAC in place on next use. Losing the key invalidates HMAC tokens (re-mint), the same failure mode as losing the DB. - API responses no longer leak absolute filesystem paths (Phase 16.2). Read-scope endpoints (
/api/media,/api/media/{id},/api/media?q=,/api/search/all,/api/cache/info) previously returned absolute paths that exposed the operator’s directory tree. They now return PROJECT_ROOT-relative paths (basename for legacy absolute rows), and/api/cache/infono longer returns cache directory paths. open-file round-trips the relative path. (FCPXMLfile://URIs and user-supplied export destinations intentionally remain absolute — seedocs/phase-16.2-path-leak-triage.md.) /api/search/allpath-leak fully closed. The Phase 16.2 boundary strip handleditems[].pathand each item’sproject_path, but left two absolute paths reachable by avideos_readclient:items[].relative_path(federation sets this to the absolute path for out-of-root rows —_resolve_pathsfalls back tostr(stored)whenrelative_tofails) anderrors[].project_path(the absolute project root, returned on a project timeout / preflight failure). The endpoint now drops the internalabsolute_path/relative_pathfields and basenamesproject_pathacross both items and errors. Regression:tests/test_search_all_path_leak.py.- WebSocket ingest auth + secrets hardening.
/ws/ingestwas open to any LAN/tailnet peer or malicious browser page (the HTTP scope-gate doesn’t reach@app.websocketroutes). The handshake now enforces a same-origin / allowlisted-Origin check (CSWSH guard), the same loopback-trust rule as HTTP plus a?token=carryingingest_write, and a 32-connection cap (close 1013 over the limit). Loopback trust additionally requires no forwarding header (X-Forwarded-For/X-Real-IP/Forwarded/X-Forwarded-Host) — closing the hole where a reverse proxy /tailscale serve(which connects from 127.0.0.1) handed every proxied remote request full admin.ARKIV_ADMIN_BOOTSTRAP_TOKENnow refuses tokens < 24 chars (a weak one was a remotely brute-forceable admin credential).?token=is redacted from uvicorn access logs; the token DB is created0600(data dir0700) on POSIX..env.examplenow documentsARKIV_TRUST_LOOPBACKandARKIV_ADMIN_BOOTSTRAP_TOKEN.
Changed
- Default embedding model is now
bge-m3(1024-dim), up fromnomic-embed-text(768-dim). bge-m3 is multilingual (100+ languages, 8192-token context) and substantially stronger on Chinese retrieval while staying on par with nomic for English — a better default for mixed-language media libraries. Breaking for existing indexes: the dimension change means stored vectors are incompatible; run a full re-index (python embed.py --rebuildor 進階設定 → 重建向量索引) after upgrading. Override withARKIV_EMBED_MODELto keep the old model.
Added
-
MCP server (Phase 14).
mcp_server.pyexposes arkiv’s local media knowledge layer to MCP clients (Claude Desktop, Claude Code, OpenClaw) over stdio, so an agent can search and read your footage library without going through the HTTP API. Six read-only tools:search_media(semantic search over transcripts + vision tags, SQL text fallback),get_media,get_transcript,list_recent,library_stats,list_tags. Reusesdb+vectordb(does not importserver, so no FastAPI startup cost) and returns JSON withensure_ascii=False. No absolute-path leak: every path is PROJECT_ROOT-relative with a basename fallback for out-of-root rows — the same red line as the Phase 16.2 HTTP hardening, enforced in_safe_path. New optional depmcp; 18 unit tests. Seedocs/phase-14-mcp-handover.md. — Since superseded:get_scenesmakes it seven tools andget_transcriptgained timecodes (see Unreleased). The “end-to-end stdio smoke” this entry originally claimed was an ad-hoc run and was never checked in;tests/test_mcp_e2e.pymakes it real. -
Smart Collections — rule-driven curation with a GPS location signal (Tier 1).
smart_collections.pyscores each clip’s existing vision metadata (tags + both per-frame and media-levelcontent_type/atmosphere/energy) against predefined collection definitions; a clip non-exclusively joins every collection it scores ≥ 0.40 for. Surfaced atGET /api/collectionsand in the UI sidebar (rule-driven, not ML clustering). Newgeo.pyturns EXIFgps_lat/gps_loninto a stable location label — a named place from an operator gazetteer (haversine within radius) or a coarse rounded-coordinate cell fallback — exposed in the collection signal and usable as a booster condition. Pure-Python, no new deps, no external geocoding service; missing coords and the(0,0)GPS “null island” cameras emit with no fix never resolve to a phantom location. (Domain-specific definitions beyond the food/interior archetypes await real footage from those shoots so their tag vocabularies can be verified against liveqwen3-vloutput rather than guessed.) -
whisper_guardpackage (Phase 10). The pure text-level hallucination filters (is_repetitive/has_char_loops/remove_char_loops+ aHallucinationGuardclass) are now a standalone, stdlib-only package extracted fromtranscribe.py, staged for a futurewhisper-guardPyPI release.transcribe.pyimports them with unchanged behavior.install.shnow also ships first-party package directories (via a*/__init__.pyglob), so a copy-install includes the new package. -
export.pycorpus/JSONL CLI (Phase 12, core). Turns a library’s transcripts + vision metadata into LoRA/RAG-ready formats.export.py corpus [--lang] [--out]emits a merged plain-text corpus (transcripts only, no JSON residue);export.py jsonl [--lang] [--out]emits one JSON object per media ({id, text, metadata}with tags + frame descriptions);export.py txt <id>dumps a single transcript. Reads the DB directly (no server import);ensure_ascii=Falsekeeps Chinese readable. (Batch-zip API remains deferred.) -
Subtitle layout engine (Phase 12.5).
subtitle.pyre-wraps raw Whisper text into broadcast-style captions, surfaced asexport.py srt <id> [--max-cjk N]. Lines are capped in CJK units (default 14, Netflix zh-Hant; Latin counts 1/3) as a hard invariant, broken at natural points (CJK punctuation / spaces) without splitting a Latin word or separating a number from its measure word (14字). Long segments are time-split into multiple proportional cues; optional bilingual cues (original above translation). Uses segment-aligned timestamps when present, else a single transcript cue. -
Resource-aware ingest pipeline (Phase 11.5). Ingest now probes machine state before a vision batch and backs off when memory is saturated — directly addressing the 427-clip stress test that lost 20 frames to a cold-start timeout and hit a 28 GB unified-memory crunch.
resource_probe.pyreports loaded Ollama models (/api/ps), GPU VRAM (nvidia-smi on PC), unified memory (psutil on Apple Silicon), and active-job count. It is a sensor, not a gate: any source failing degrades that field toNoneand never raises — a broken probe can’t block ingest.ARKIV_PROBE_DISABLE=truemakes it a full no-op.- Backpressure: before each vision batch, ingest waits (bounded exponential backoff,
ARKIV_BACKPRESSURE_MAX_WAIT) while memory pressure exceedsARKIV_GPU_MEM_THRESHOLD(default 0.8), then warms the vision model only if it isn’t already resident. Systematizes the previously ad-hoc warm-up. python ingest.py --queue status|cancel|retry [--job-id N]— a SQLite-backed job queue (no Redis/Celery) with type-derived priority.python ingest.py --status [--json]— one-shot resource + queue snapshot and the decision the next vision phase would take.- New env vars:
ARKIV_GPU_MEM_THRESHOLD,ARKIV_BACKPRESSURE_MAX_WAIT,ARKIV_PROBE_DISABLE,OLLAMA_NUM_PARALLEL.psutiladded as a soft dependency.
⚠️ Mock-tested + verified live on Apple Silicon (probe reads real Ollama/memory). The 11.5d throughput A/B (
OLLAMA_NUM_PARALLEL=1vs2) and the 427-clip cold-start-elimination acceptance still need a real GPU run — seedocs/phase-11.5-acceptance.md. -
POST /api/embed/rebuild(scope:ingest_write) — drops and rebuilds the ChromaDB semantic index from all media in a background subprocess. Backs the existing 進階設定 → 搜尋引擎 「重建向量索引」 button, which previously called a non-existent route (404). -
More ingest container formats + cache visibility (ingest microtasks B3–B6).
.mkv/.avi/.webmnow get thumbnails, frames, and vision — they were ingested into the DB but missing fromVIDEO_EXT, sois_videowas False and they silently skipped all visual processing. Newingest.py --regenerate-thumbnailsrebuilds every video’s poster (force-bypasses the reuse cache).health.py“Cache dirs” now reports file count + size for thumbnails / proxies / chroma_db, and the regen commands print the net size delta on completion.
Fixed
- WebSocket token auth was broken on
main.server.py’s_ws_authorizedcalledauth.resolve_raw_token, but the refactor that introduced it was never staged (agit addmissedauth.py), so onmainevery token-based/ws/ingesthandshake hitAttributeError(caught by a bareexcept→ rejected) and the ingest-progress WebSocket only worked on loopback.resolve_raw_token(hash / expiry / IP-allowlist / scope) is now extracted andverify_tokendelegates to it; the WS Origin check accepts same-origin on any deployment host/port plus the Vite dev origin (:5173), and the frontend WS URL carries the token. - Embedding-dimension mismatch now fails loud instead of silently degrading. After the bge-m3 (1024-dim) default, querying a stale 768-dim index threw a raw ChromaDB error → 500s on chat/federation and a silent SQL-only degradation on
/api/media?q=(a bareexcept: passswallowed it).vectordbnow stamps the embedding model + dim into the collection on create/rebuild, raises a clearEmbeddingDimensionMismatch(“runpython embed.py --rebuild”) when the active model differs, and defensively wraps query/upsert (catching legacy/unstamped indexes by message)./api/media?q=surfaces asearch_degradedwarning instead of hiding it, chat returns a clean error (not a 500), federation reports it per-project inerrors[], and an incrementalembed.pyrun aborts loudly.config.EMBED_DIMis no longer purely informational. install.shno longer hangs on headless installs. Step 8’s desktop-shortcutcpinto~/Desktopblocked indefinitely on a headless / piped (curl | bash) / nohup install — macOS Desktop (TCC) prompts for access with no GUI to answer. It is now guarded on an interactive stdin ([ -t 0 ], not stdout — forcurl | bashfd 0 is the pipe) and skipped silently otherwise; when skipped, the success output prints thecd … uvicornlaunch line instead of advertising the absent shortcut.- Windows zh-TW console no longer reports false errors during embedding.
embed.py’s per-file success marker used✓(U+2713), which raisedUnicodeEncodeErroron cp950 consoles and made every successfully-embedded file print as[ERROR: ...]— masking real failures. Replaced with an ASCII marker.
v0.6.1 - 2026-05-28
Fixed
- Non-Mac (CUDA) ingest no longer breaks at transcription. whisperx 3.8.5 moved the per-call ASR options (
beam_size/condition_on_previous_text/compression_ratio_threshold/log_prob_threshold/initial_prompt) intoload_model(asr_options=…)and now pulls torchcodec, whose DLLs fail to load on the Windows/CUDA box. The non-Mac backend now routes to faster-whisper (already a declared non-Mac dependency, no torchcodec), which still accepts those options per call — a clean drop-in for the whisper-guard layer config. SetARKIV_TRANSCRIBE_BACKEND=whisperxto force the legacy path. The Apple-Silicon MLX path is unchanged.
⚠️ Verified on Mac with mocked
WhisperModelunit tests only. Live CUDA ingest→embed→chat on PC is still pending before this is tagged/released — seeOVERNIGHT_RESUME.md.
v0.6.0 - 2026-05-28
Added
- Chat in the web UI. A new “對話” button in the header opens an interim chat panel that calls
/api/chat: conversation thread, intent badge, latency/token meta, and a scene-thumbnail strip whose clips open in the inspector. The canonical chat screen is being designed separately — this is a functional interim so the feature is usable today.
Fixed
- Web UI works in a plain browser again. Since v0.4.1 every route required a Bearer token, but the browser frontend never sent one — so the web UI returned 401 on all data calls (and a fresh install was unusable in a browser).
auth.pynow trusts loopback (127.0.0.1/::1) as fully-scoped, so the local UI works with no token. Remote / fleet access still requires a token; setARKIV_TRUST_LOOPBACK=falsefor reverse-proxied or network-exposed deployments.
Docs
- README chat section now states the prerequisite: ingest and build the index (
python embed.py) before chatting.compilation/refinement/similarityneed the vector index;analyticsneeds ingested media; onlygeneralworks on an empty library.
v0.5.2 - 2026-05-28
Changed
- Chat needs only one model now.
ARKIV_INTENT_MODELdefaults toARKIV_CHAT_MODEL(qwen2.5:14b), so a singleollama pullcovers both intent classification and answers. Previously it defaulted toqwen2.5:7b-instruct— undocumented and frequently not installed, which silently broke chat. Override only if a smaller intent model is actually present.
Added
health.pynow checks the chat model and warns with anollama pull …hint when the configured chat (or a distinct intent) model is missing — previously health was blind to chat models.- README chat documentation (EN + zh-TW) — model requirement, all five intents,
project_scope, response shape, chat hardware floor, and an “embedding model is locked to your index” warning.
Fixed
- Missing chat model returns a clear message instead of HTTP 500.
/api/chatnow catches the OllamaHTTPErrorand tells you toollama pullthe model.
v0.5.1 - 2026-05-28
Fixed
- Chat
similarityintent crashed with HTTP 500 on real ChromaDB data.vectordb.find_similar()usedref.get("embeddings") or [], which raisesValueError: truth value of an array ... is ambiguousbecause ChromaDB returns embeddings as NumPy arrays. Replaced with explicitNone/len()checks. Mocked tests did not cover this path; caught by live verification against a real index.
v0.5.0 - 2026-05-28
Added — Chat: RAG over your video library
- 5-intent classifier routes natural-language prompts to specialized handlers:
compilation,refinement,similarity,analytics, andgeneral. - Conversation memory persists chat messages and scene IDs, then feeds recent history into follow-up prompts.
- Project-scoped vector search adds
project_scopefiltering tovectordb.search()andvectordb.find_similar(). - Chat API docs in README EN / zh-TW describe intent examples and authenticated curl quickstarts.
Hardening
- Chat dispatch now handles Ollama
Timeout/ConnectionErrorwithout returning HTTP 500. - Oversize prompts are trimmed before classification and handler execution.
- Invalid or empty classifier intents fall back to
general, and classifier limits are capped at 100.
Tests
- Expanded
tests/test_chat.pyto 17 cases covering timeout handling, prompt trimming, classifier fallback, project scope propagation, and compilation-to-refinement flow.
Changed — LLM router abstraction
llm.pyrouter — centralizedchat/embed/visionhelpers with consistent request payloads, token counting, and provider metadata.vision.py,vectordb.py, andtranscribe.pyroute Ollama calls through it while keeping existing module names and fallback hooks intact.- Model config —
config.pyexposesOLLAMA_CHAT_MODEL,OLLAMA_EMBED_MODEL, andOLLAMA_VISION_MODEL(legacyEMBED_MODEL/VISION_MODELaliases preserved). - Router coverage —
tests/test_llm_router.pyadds schema, json-mode, token-clamping, and default-model checks.
v0.4.1 (2026-05-27) — API Scope Token Auth
Security release. All
/api/*endpoints now require a Bearer token with the proper scope. See API Authentication for the bootstrap SOP.
New Features
arkiv_token.pyCLI —create/list/show/revokesubcommands manage access tokens directly in local SQLite, with scope validation, CIDR allowlists, and optional expiry.tests/conftest.pyauth fixture —fastapi_clientnow injects an admin bearer token so existing server tests continue to hit the real authenticated API without per-test boilerplate.- Auth coverage —
tests/test_auth.pyadds CLI create/list/revoke, CIDR allowlist, and multi-scope token cases.
Notes
- Scope-based tokens are designed for multi-machine fleets: read-only review stations can use
videos_readormedia_read, ingest boxes can useingest_write, and admin boxes can manage tokens. - Bootstrap remains first-run only: set
ARKIV_ADMIN_BOOTSTRAP_TOKEN, start the server once, create per-machine tokens, then unset the env var and revoke the bootstrap token.
v0.4.0 (2026-05-27) — W3 DIT three-pack complete
DIT companion ready. Three new CLI tools bring arkiv from “AI metadata layer” to “DIT-grade companion”: hash manifests interoperable with Silverstack / Hedge / MediaVerify, multi-destination resumable offload, and Resolve-friendly camera reports.
New Features
- ASC MHL v2 generation + verify —
mhl.py create/mhl.py verifyCLI emitsurn:ASC:MHL:v2.0manifests withxxh3/md5/sha1/sha256/c4hashing, directory + structure root hashes, and a chainedascmhl_chain.xml. Output lands at<PROJECT_ROOT>/ascmhl/NNNN_<dir>_<timestamp>Z.mhl. Round-trips through ASC reference impl 1.2 (ascmhl inforeads emitted manifests, exit 0). - Multi-destination offload —
offload.py --src <SD> --dst <dst1> --dst <dst2>does chunked-read parallel copy with per-file hash verify, retry up to N times on hash mismatch, atomic.partial → finalrename, sidecar-aware (Sony XAVC / ARRI / RED / iPhone Live Photo / generic). JSON state file atoffload-state.jsonlets you kill mid-copy and resume — pending files pick up exactly where they stopped. Emits a per-dst MHL v2 manifest verifying every copied file (viamhl.create_manifest). - Camera report CSV + day summary —
camera_report.pywrites a 20-column DIT-spec CSV (Reel / TC / Date / Camera / Lens / ISO / Shutter / Aperture / WB / FPS / Codec / Resolution / FileSize / Notes / …) ready for Resolve’sFile → Import Metadata from CSV. Day-summary footer aggregates clip count / total runtime / by-camera / by-card.
Internals
mhl.py723 lines, real ASC MHL v2 schema withcreatorinfo(incl.hostname),processinfo.processenum (ingest/offload/verify), per-file<hash>+ per-directory<directoryhash>with<content>+<structure>separation,<roothash>aggregate.xxhash>=2.0.0required forxxh3/xxh64.offload.py416 lines + 5 pytest scenarios (2-dst copy, hash-mismatch retry, mid-copy resume, src-unmount cleanup, sidecar families).camera_report.py501 lines + 2 pytest scenarios.health.pygains_check_mount(path)helper extracted fromproject_health()— single mount-precondition check reused by offload + ingest preflight.
Driving incident
W3 milestone (per references/plans/arkiv/2026-04-29-positioning-and-moat.md §6): pre-W3, arkiv could only claim “AI metadata layer for DIT workflows” (soft). W3 ship locks in “DIT companion” (hard) — minimum bar to not get fact-checked on r/DaVinciResolve / r/cinematography.
v0.3.1 (2026-05-25) — Per-Project Storage + Startup Preflight
⚠️ Breaking change — default storage layout moves from
BASE_DIR/{media.db, thumbnails/, chroma_db/, proxies/}toBASE_DIR/.arkiv/{project.db, ...}. One-shot migration provided. See Upgrade SOP before running.
New Features
- Phase 8.0c per-project storage —
config.PROJECT_ROOTis now the single source of truth. All 4 storage paths (DB, thumbnails, chroma, proxies) default toPROJECT_ROOT/.arkiv/<xxx>. ExplicitARKIV_<X>_PATHenv vars still override per-path. SettingARKIV_PROJECT_ROOT=/path/to/your/footage/produces a self-contained, portable archive next to the media. - Phase 8.0e startup health check — new
health.preflight_paths()runs before any pipeline work. Catches: dangling symlinks, unwritable storage, NAS mount precondition (/Volumes/paths), stalePROJECT_ROOT(sample DB resolve fails). Returns(ok, errors)for embedding in other entry points.ingest.py main()calls it pre-warm-up; fail →sys.exit(4). - Phase 8.0f NAS-unavailable degradation — preflight short-circuits with explicit “NAS not mounted” message + expected path when
PROJECT_ROOTlives on/Volumes/but mount root is gone. Avoids the “process N files with the same root error” failure mode. --migrate-storage— one-shot migration from legacyBASE_DIR/xxxto newBASE_DIR/.arkiv/xxxlayout. Backup-first (.legacy-backup-{ts}.tar.gz), idempotent, cross-checkssqlite COUNT+ thumbnails file count post-move, cleans dangling symlinks left over from pre-8.0c workarounds.docs/pipeline.md+docs/pipeline.zh-TW.md— complete pipeline reference (4 stages, paths, exit codes, maintenance modes, upgrade SOP) in EN + zh-TW.
Bug Fixes — Silent failure suite
- Exit code reflects fail state —
ingest.py main()now exits non-zero on any failure:2if all files failed,1on partial fail,3on dangling symlink,4on preflight fail. Before this,main()always exited 0 even with 222/222 file failures, hiding regressions from launchd/cron runners. frames.pydangling symlink defense — new_ensure_thumbnails_dir()helper fails fast (exit 3) instead of lettingPath.mkdir(exist_ok=True)raiseFileExistsErroron every file. Last line of defense behind preflight.frames.pystrict ffmpeg success check — new_run_ffmpeg()enforces bothreturncode == 0AND non-zero-size output file. Catches “ffmpeg exits 0 but writes 0-byte file” edge case that would otherwise register empty frames as valid.- Phase 2 honest skip messages — when vision phase starts with empty queue, message now distinguishes “phase 1 had X/Y failures” vs “all already indexed” vs “genuinely no new files”. Replaces single misleading “No new files to run vision on”.
- Phase 2 halt-on-3-consecutive-fail — symmetric with existing
still_failedhalt for both-models-fail. Avoids burning through every remaining file writing the same Ollama-disconnect / model-crash error.
Bug Fixes — Other
--dirargparse fix — maintenance modes (--migrate-storage/--migrate-relative/--regenerate-proxies/--vision-only) no longer require--dir. Long-standing usability bug (runningingest.py --migrate-relativepreviously failed with argparse error).
Internals
health.preflight_paths()returns(bool, list[str])for programmatic embedding (server startup, smoke tests, CI gates).- Migration backup format = single
.tar.gzper timestamp; rollback isrm -rf .arkiv/ && tar xzf <backup>.
Documentation
docs/pipeline.md+docs/pipeline.zh-TW.mdship with full 4-stage flow, storage layout diagram, exit code table, maintenance mode reference, upgrade SOP with rollback, and per-project layout example.- README (EN + zh-TW) Architecture section links to the new pipeline doc + adds “Upgrading from v0.3.0 → v0.3.1” mini section.
Driving incident
Codified after 2026-05-25 overnight ingest failure: 222 video files, 222/222 phase-1 fail on dangling thumbnails symlink (5/15 NAS workaround for server.py:70 hardcode, since fixed but symlink never cleaned), exit code 0 hid the regression. Root-cause-of-root-cause was Phase 8.0c never wiring config defaults despite roadmap BEFORE/AFTER table specifying it.
v0.3.0 (2026-05-22) — DIT Companion
New Features
- DaVinci Resolve metadata CSV export —
/api/export/metadata-csvendpoint + toolbar button + plugin auto-prompt. Drop-in for Resolve’sFile → Import Metadata from CSVworkflow (Phase 7.6b/c/d/e) - ExifTool full integration — Sony XAVC sidecar
.XMLparsing, iPhone Keys group fallback, Blackmagic Cam app per-vendor lens tags (Blackmagic-designCameraLensType). LensModel chain: ExifTool LensModel → BMD vendor tag - ExifTool auto-detect —
config._detect_exiftool()fallback chain (env var →shutil.which→ Windows winget LOCALAPPDATA / Program Files / chocolatey / scoop / macOS homebrew / Linux apt +~/.local/bin+ Strawberry Perl). Solves silent skip on fresh Windows clone - HEVC/ProRes browser proxy —
/api/proxy/build/{id}POST endpoint + 409 surface in frontend + “build proxy” button (Phase 7.7g) - Tauri panic hook — surfaces Rust crashes to stderr (Windows dialog crash diagnosis)
Bug Fixes
- EDL reel name (B10) — was
stem[:8]unconditionally. Now uses ExifTool ReelName when present, falls back to filename stem; sanitizes control chars; pads/truncates to 8-char CMX3600 spec - EDL reel injection hotfix (B10-hotfix) — control char (
\r\netc.) stripped before encode; whitespace-only reel falls back to stem (Codex audit) - Tauri dialog crash (Windows) —
rfdfolder picker crash workaround: droptitlearg - Vision Ollama timeout — bump 120s → 300s for large prompts
- mlx-whisper backend — drop unsupported
beam_sizekwarg
Security
- Codex Round 1+2 audit: 5 SSRF / path-bound hardenings (Batch J), export-to dest allowlist (blocks
~/.ssh/ LaunchAgents RCE), CSV formula injection prevention, XSS hardening, vectordbbuild_doc_textproduction schema alignment
Internals
- Cross-platform
detect_gpu()forbench_ingest.json arkiv_resolve.pyhonorsARKIV_API/ARKIV_HOST/ARKIV_PORTenv- 6+ new tests (ExifTool fallback chain + reel name regressions + CSV scope + BMD lens + ExifTool sidecar)
Known Issues
- Resolve conform pathname pattern
*/%R/%Dmis-parses paths likeiphone 16proas reel — workaround: set conform pattern to filename-based, or use FCPXML import (B10c-resolve, open)
v0.2.1 (2026-04-12)
Performance
- Vision O2+O6 代表幀策略 — 每支影片只對代表幀做完整 12 欄分析,其餘用 LIGHT_PROMPT 11 欄 + 只繼承 edit_reason。未來 ingest 預估省 50% vision 時間
- 廢幀過濾(O6) — PIL+numpy 偵測全黑/全白/嚴重模糊幀,跳過 LLM 推理
- SQLite WAL mode — 啟用 Write-Ahead Logging,允許讀寫併發
Bug Fixes
- DB self-deadlock —
_run_vision_only內巢狀get_conn()導致自鎖。add_tag/delete_frames/upsert_frame新增_conn參數,所有 ingest 呼叫改用同一 connection - sqlite3.Row immutable —
--vision-only模式傳入 Row 物件給需要可寫 dict 的函式,改為dict()轉換 - Vision 冷啟動失敗 — 新增
_warm_up_vision_model()發送 dummy request 確認模型已載入 VRAM
Tests
- 新增 4 測試:
_conn參數 ×3 + vision-only 整合流程 ×1(39 → 43 tests)
Data-Driven Insight
- 用 427 支 / 1,844 幀實測驗證 O2 繼承假設:content_type 一致率僅 23%、atmosphere 11%、edit_reason 3%。原始 5 欄繼承方案推翻,改為只繼承 edit_reason(D+ 方案)
v0.2.0 (2026-04-09)
New Features
- WhisperX 整合 — word-level timestamps +
words_json/remotion-props支援 - Phase 7.6 Tag→Keyword 映射 — 標籤寫入 DaVinci Keywords + Comments,支援 Smart Bin 篩選
- Phase 7.7 Browser Proxy — HEVC/ProRes .mov 透過 FFmpeg 產生 H.264 代理,瀏覽器直接播放
- Vision 錯誤處理強化 — 兩階段 fallback(主模型 → 備援模型)+ 前端 Retry UI
- Silero VAD 前處理 — 語音活動偵測,提升轉錄品質
- FCPXML 匯出 — Final Cut Pro / DaVinci Resolve 時間線匯出
- Clip Color 分級 — 匯入時依評級自動上色(GOOD=Green, NG=Orange, Review=Yellow)
- Ingest 兩階段 Pipeline — Phase 1 轉錄 + Phase 2 視覺分析,分離 VRAM 使用
- pytest 測試框架 — 23 tests 覆蓋 DB CRUD、API endpoints、轉錄 guard、向量搜尋
Localization
- 繁體中文化 — Web UI、DaVinci Plugin、API 錯誤訊息全面中文化
Improvements
- EDL reel name 改用檔名 stem
- Markers 改為 clip marker(非 timeline marker)
- Health check 動態讀取 vision model
- Docker Compose 移除廢棄 version 欄位
- LLaVA retry on empty/garbage response + dynamic ingest timeout
Bug Fixes
- FCPXML spec compliance 修正
- NG clip color 改為 Orange(Red 在 DaVinci 不可見)
- Auto/manual tag legend 改用 inline color(替代 Tailwind class)
- Stream endpoint 使用 ROOT 路徑
- XSS / CORS / path validation 安全修復
Breaking Changes
- 移除 EDL+ markers export(FPS 限制 + 不支援 CJK)
v0.1.0 (2026-03-31)
Initial release — Local Media Asset Manager MVP
- Ingest pipeline (FFprobe + Whisper + LLaVA vision)
- Web UI with grid/list view, rating, tagging
- DaVinci Resolve plugin
- SRT/VTT/EDL export
- ChromaDB semantic search
- Tauri desktop app
- Docker deployment