Skip to content

In Place Metadata Write

log5r edited this page Oct 11, 2026 · 4 revisions

タグ保存のその場での上書き

Issue: #32、PR: #61。確認日: 2026-10-10(JST)。

目的と範囲

タグの保存は、従来はすべて MediaFileRewriter.rewrite を通っていた。この経路は、ファイル全体を兄弟ディレクトリの一時ファイルへ複製し、synchronize の後に replaceItemAt で置き換える。そのため、.m4v の歌詞を 1 行保存するだけでも GB 単位の複製が起きていた。#61 では、新しいメタデータが既存の領域に収まる場合に、その領域だけを上書きする MediaFileRewriter.update(at:plan:rewrite:) を追加した。対象は MP4、FLAC、MP3(ファイル先頭の ID3v2)である。Ogg は #58 でページの一括読み込みとテーブル化した CRC を導入済みのため、対象外とした。WAV・AIFF のチャンク内の ID3 も変えていない。

形式 その場で上書きする条件 書く範囲
MP4 新しい moov が元と同じ大きさ、または 8 バイト以上小さい moov(不足分は内部の free で埋める)
MP4 moov がファイル末尾にある moov。ファイル長は変わる
MP4 直後のトップレベルの free/skip で吸収できる 新しい moov と、残りを覆う free のヘッダー(8 または 16 バイト)だけ
FLAC 既存の PADDING を除いた新しいメタデータが、元の領域と同じ大きさか 4 バイト以上小さく、領域が 16 MiB 以下 メタデータ領域全体。末尾に PADDING を付ける
MP3 新しいタグが元のタグ(v2.4 のフッターを含む)以下の大きさ 新しいフレームの終わりと、古いタグの最後のゼロでないバイトのうち後ろの方まで(16 MiB 以下)。ヘッダーの大きさは元のまま

MP4 では、作り直す moov から直下の free/skip を取り除く。こうすると、以前の保存で内部に残した余白も、次の拡大で再利用できる。チャンクやフラグメントのオフセットは、どの経路でも変わらない。収まらない場合は従来の置換を使い、stco/co64 を調整する。

安全性の設計

  • 巻き戻し: 上書き範囲の元のバイト列、ファイル長、更新日時(st_mtimespec)を保持する。書き込み・切り詰め・synchronize のいずれかが失敗したら、これらを元に戻してから元のエラーを投げる。巻き戻しがすべて成功した場合に限り、futimens で更新日時を戻し、解析キャッシュのキー(大きさと更新日時)を保つ。
  • キャンセル: 最初の書き込みの直前まで受け付け、書き込みを始めたら最後まで行う。
  • 直列化: 同じファイルへの保存は、その場での上書きも置換も、ファイルの実体(st_dev と st_ino)ごとのロックで 1 件ずつ実行する。置換は inode を変えるため、ロックを取った後に実体を確かめ直し、変わっていれば取り直す。別プロセスとの調整は行わない。
  • フォールバック: 書き込み用に開けないファイルは置換の経路で保存する。この Mac(Darwin 27)では、読み取り専用(0o444)のファイルや書き込みを拒否する ACL が付いたファイルは、replaceItemAt でも失敗した。
  • 保護されない場合: 上書き中のクラッシュや電源断では、ファイルが途中まで更新された状態で残ることがある。一時ファイル方式ではこの状態は起きない。
  • 置換との違い: 置換しないため、ハードリンクの他の名前にも変更が届く。保存中に同じファイルを読んでいる再生やデコードからも変更が見える。音声データの位置は変わらない。

レビューで見つかった問題

Codex のレビューは 4 回で、各回の指摘はすべて妥当だった。いずれもその場での上書きに特有の問題である。

  1. 同じファイルへの同時保存(2 つのウインドウ)で、書く範囲が食い違う。例えば、一方は moov だけ、他方は moov と直後の free を書く。置換が上書き中のバイトを複製して壊れることもある。→ ファイルごとのロックで直列化した。
  2. 読み取り専用のファイルが、更新用に開く段階で失敗する。→ 置換へフォールバックした。FLAC はメタデータ領域全体をメモリに 2 つ持つ。→ 16 MiB の上限を設けた。
  3. ロックのキーがパスだったため、ハードリンクの別名を区別できない。→ inode をキーにした。巻き戻しに成功しても更新日時が変わり、解析キャッシュに届かない。→ 更新日時を戻した。
  4. 大きなパディング(ID3 は最大約 256 MiB、MP4 の free は GB 単位も有り得る)を吸収するとき、ゼロで埋めたデータと退避用のデータでメモリを使い切る。→ 上限で置換に戻すと、大きなファイルで高価な複製に逆戻りする。そこで書く範囲を狭めた。MP4 では、free/skip の中身は ISO/IEC 14496-12 で意味を持たないため、ヘッダーだけを書く。ID3 では、すでにゼロのバイトはパディングとして残す(ID3v2.3 §3、v2.4 §3.3)。

検証

  • macOS のユニットテスト 739 件と CI が成功した。新しいスイートは InPlaceMP4MetadataWriteTests、InPlaceTagWriteTests、InPlaceWriteFallbackTests、MetadataSaveSerializationTests の 4 つである。それぞれ、対応する修正を戻すと失敗することを確認した。

  • 同時保存のテストは、専用のシリアル DispatchQueue 上の TaskExecutor で保存を実行する。待機にはタイムアウト付きのセマフォを使う。LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 の下でもハングしない。

  • MP4 の 64 ビットの free ヘッダーは、truncate(atOffset:) で作った約 4 GiB のスパースファイルで確認した。APFS では作成に約 5 ms かかる。

  • 計測には、コミットしていないテストを使った。1 GiB の mdat を持つ合成 MP4 に約 1.7 KB の歌詞を保存した結果は次のとおり。

    レイアウト 置換 その場で上書き
    moov の直後に free 1.50 秒、1,073,758,208 バイト 0.006 秒、8,192 バイト
    moov がファイル末尾 1.06 秒、1,073,758,208 バイト 0.009 秒、4,096 バイト

実ファイルでの計測(#32 の完了確認)

確認日: 2026-10-11(JST)。#58・#61・#62 の修正前(081fccd)と修正後(main の 88f559c)で、ffmpeg 8 系が出力したファイルのタイトルを保存した。保存の入口は LibraryService.updateEmbeddedMetadata と同じ writer の振り分けで、コミットしない一時テストから呼んだ。各回、元ファイルの APFS クローンから 1 回目と 2 回目(13 バイト長いタイトル)の保存を続けて行い、3 回の中央値を取った。時間は fsync と置換を含む。書き込み量は proc_pid_rusage の ri_diskio_byteswritten の差分である。環境は Mac15,3(メモリ 24 GB)。

ファイル 内容
Opus libopus 96 kbps、60 分、41.2 MB、3,603 ページ
Vorbis libvorbis 約 104 kbps、60 分、46.9 MB、3,526 ページ
m4v H.264 約 43.7 Mbps + AAC、240 秒、1.32 GB。moov 先頭(直後の free は 8 バイト)と末尾の 2 種類
ファイル 構成 修正前 1 回目 / 2 回目 修正後 1 回目 / 2 回目 修正後の書き込み量 1 回目 / 2 回目
Opus Debug 55.7 秒 / 55.7 秒 0.24 秒 / 0.23 秒 41.2 MB / 41.2 MB
Opus Release 2.01 秒 / 2.00 秒 0.11 秒 / 0.11 秒 同上
Vorbis Debug 63.3 秒 / 63.3 秒 0.49 秒 / 0.27 秒 46.9 MB / 46.9 MB
Vorbis Release 2.39 秒 / 2.29 秒 0.24 秒 / 0.12 秒 同上
m4v(moov 先頭) Release 1.09 秒 / 0.85 秒 1.10 秒 / 0.0011 秒 1.32 GB / 213 KB
m4v(moov 末尾) Release 1.44 秒 / 0.90 秒 0.0077 秒 / 0.0007 秒 221 KB / 221 KB
  • 修正前は、どの形式もファイル全体を書いていた。
  • Ogg は修正後も全体を置換するが、CRC のテーブル化とページのまとめ読み(#58)だけで時間が縮んだ。修正前後の出力はバイト単位で一致する。修正前の遅い保存では、計測中にカーネルの書き戻しが走るため、ri_diskio_byteswritten がファイルの大きさより小さく出る。ri_logical_writes はファイル全体に一致した。
  • moov 先頭の m4v の 1 回目は、直後の free が 8 バイトしかないため置換になる。このとき 4 KiB の余白(#62)が付き、2 回目からはその場で上書きできる。
  • 5 分の MP3(ID3v2 なし)と FLAC(PADDING なし)も同じ傾向だった。1 回目は置換して余白を付け、2 回目はその場で上書きして 16 KB だけを書いた。
  • 保存後の 48 ファイルは、すべて ffprobe でタイトルを読めた。再生時間は変わらず、-v warning でも警告はなかった。対象には、余白付きの ID3v2.3 タグ、moov 内の free、FLAC の PADDING を含む。ffmpeg -v error -f null のデコードでもエラーはなかった。-f streamhash のパケット列は元のファイルと一致した。

計測の条件として、元ファイルはページキャッシュに載った状態だった。fsync は F_FULLFSYNC を含まない。アプリの画面からの保存(編集可否の判定、SwiftData の保存)と iOS は含めていない。

前回計測時点の未検証事項

以下は前回計測時点の記録を保持したもの。実co64の4 GiB超M4Vと再生中保存は本頁の2026-10-11後続節で確認した。今回のXcode 27.1 RCでは変更前後ともiOS Simulatorのbuild-for-testingが成功し、修正後は単体テストの実行も成功した(元の27.1 Betaは未確認)。関連回帰結果をBulk Metadata Edit Performanceに記録した。Files実UI/他アプリ表示などは、その後の各結果と未確認範囲を区別する。

  • co64 を持つファイル(4 GiB 超)、64 ビットの大きさや大きさ 0 の moov
  • 余白付きのファイルを、AVFoundation と ffprobe/ffmpeg 以外のツール(Music、Mp3tag など)で読み込んだ場合
  • opusenc などで作ったページ数の多い Ogg
  • iOS の Files 経由のファイル、再生中の保存、上書き中のクラッシュ
  • iOS Simulator のユニットテスト。テストターゲットは main でも型検査のタイムアウトでビルドできない

2026-10-11: 実co64の4 GiB超M4Vでの初回タイトル保存(#71)

実エンコードfixture

12秒の実エンコード seed(319,804,030 bytes)の動画パケットを16回 mux した media/real-co64.m4v は 5,116,869,605 bytes、割当済み容量は 5,123,608,576 bytes だった。H.264 Constrained Baseline/yuv420p/Level 5.1、1280×720、24 fps、192秒と AAC LC/48 kHz/ステレオで、全体の時間は192.020996秒。音声・動画を含む mdat が先頭側にあり、末尾の moov は134,969 bytes。

stream co64 entries 最大 offset UInt32.max 超の entries
動画 4,608 5,115,627,681 740
音声 4,608 5,115,626,723 740

実 chunk offset が4 GiBを超える物理ファイルであり、巨大な free やスパース領域でサイズを増やしていない。

最初に動画と音声をともに concat したところ、AAC priming に由来する境界の non-monotonic DTS 補正 warning が出た。そのファイルは主fixtureに使わず、実動画パケットだけを同じ条件で concat し、全192秒の AAC を連続エンコードして mux し直した。新muxにはDTS warningはない。生成時の音声filterにはffmpeg内部optionのdeprecated warningが出た。

生成コマンドは results/generation-commands.json、条件は repro/generate_co64.py、構造は results/layout-before.json、format/stream/tags は results/ffprobe-before.json。構造とffprobeでの読取を確認済み。保存前の全decodeを伴う decoded streamhash と encoded packet streamhash は exit0、error log はともに空だった。動画・音声の両 stream に SHA-256 の結果を保存した。保存後の結果は以下のとおり。

保存入口と結果

ほかのbuild/再生測定/GUIを停止した専用枠で実行した。#74 後の作業中 source snapshot(基準 HEAD 88f559c81a294505c6a5663beb551ad3a3ab58a1)を一時 Release test に追加し、実 LibraryService.updateEmbeddedMetadata(for:draft:in:) を通した。書込側の製品処理は変更していない。読取側に #74 の件数計数 hook が残る。ソース hash は results/source-sha256.json、入口のハーネスは repro/Issue71Co64SaveTests.swift、関連 source は repro/source-snapshot/。

原本 real-co64.m4v は読み取り専用で残し、APFS clone saved-real-co64.m4v のタイトルを1回だけ保存した。初回の旧値は Original co64 fixture、新値は Changed co64 title through LibraryService 2026-10-11。textFields = [.title] とし、artist/album/comment を変更しない。専用 SQLite store を使い、保存後の別 ModelContext で editedTitle も一致した。検証コードの Optional/throwing macro 展開のビルドエラー2回を修正後、Release build-for-testing と指定 test 1件が成功した。失敗した build は保存・測定を実行していない。

確認項目 保存前 保存後
ファイルサイズ(bytes) 5,116,869,605 5,116,869,636
inode 193077699 193077699
moov offset 5,116,734,636 5,116,734,636
moov size(bytes) 134,969 135,000
動画/音声 co64 entries 各4,608 各4,608、全 table SHA-256 一致
UInt32.max 超の offsets 各740 各740
ffprobe duration 192.020996秒 192.020996秒

moov は同じ末尾位置で31 bytes 拡張し、mdat/ftyp の位置とサイズ、全 chunk offset を維持した。moov を別位置に移す操作や、新しい moov を末尾へ重複して追加する操作ではない。inode だけで「その場の更新」と判定せず、layout・全 offset・書込量も確認した。

保存区間は 0.179674167秒。proc_pid_rusage の process disk bytes written は 147,456 bytes、logical writes は8,536,064 bytes、disk bytes read は6,852,608 bytesだった。これらは同じ test host の SQLite 保存や runtime も含むプロセス差分であり、writer だけの bytes や SSD 内部の書込量ではない。1回の正しさ確認であり、繰り返し中央値として扱わない。指定 test は1 test/1 suite、0.200秒で成功した。JSON が存在し、test 件数が1であることを照合した。

artist=Unchanged artist、album=Unchanged album、comment=Unchanged comment は ffprobe と native reader の保存前後で一致した。保存後の全192秒を ffmpeg でデコードし、動画/音声それぞれの decoded streamhash と encoded packet streamhash は保存前に一致した。decode・packet hash・ffprobe・layout の error log は空。結果は results/save-real-co64.json、verify-summary.json、layout-save-before.json/layout-save-after.json、各 streamhash とログに保存した。

AVFoundation でも保存後の asset は isPlayable=true、2 tracks、duration=192.021秒、title/artist/album の値が一致した。190秒付近の画像を要求し、actualTime=189.979333333秒、1280×720のフレームを取得した。要求時刻との差は約20.7msであり、要求秒との完全一致やtrack startに由来する差とは断定しない。実際に末尾付近の圧縮データを読めたことを確認した。repro/AVCo64Probe.swift と results/avfoundation-after.json に記録した。native GUI での再生や聴き比べの結果には数えない。

再現入口は repro/run_save.py ROOT CHECKOUT DERIVED PACKAGES(新しいrootの原本をAPFS cloneし、一度の保存を実施)、照合は repro/verify_saved_co64.py ROOT FFMPEG FFPROBE。既存保存済み clone の上書き/再利用を拒否する。大容量 fixture、build成果物、SQLite本体をWikiへ登録しない。

再現資材と小さい結果ファイルは benchmarks README と 保存結果JSON。再生中保存/他アプリ互換/iOS画面操作は別の残条件であり、この結果だけでは #71 全体を完了としない。

test host 起動の範囲に関する補足

測定対象の LibraryService と ModelContainer は専用temp領域を使用した。一方、この実行のアプリhostには通常の App.init が残り、起動時の通常Serviceのpending removal再開/temporary cleanup/永続container初期化を分離していなかった。したがって全プロセスがユーザーデータへアクセスしない構成だったとは主張しない。ここに保存したsource snapshot/hashは、実際に測定した版の記録として変更しない。後続のnative互換fixture hostでは、専用temp Service、inMemory host container、専用bundle IDへ分離した。測定したAPIのtemp fixture/storeと起動hostの設定を区別する。

2026-10-11: 再生中の余白内保存とrewrite(#71)

実LibraryServiceを通じた再生中保存は、MP3/M4A/FLAC/M4Vの余白内保存とrewrite各1回、計8条件が成功した。音声6条件は出力sampleTimeとframe数の連続、動画2条件は非zero出力、AVPlayerのplaying状態、新しい映像frameを観測した。確認範囲で保存に伴う中断を観測せず、製品コードに追加修正は行っていない。実機聴取とiPhone実機は未実施である。

macOS27.0.1/arm64、Xcode27.0、Swift6、Release、coverage OFF、ENABLE_TESTABILITY=YES。基準HEADは88f559cで、未commit修正を含む実ソースのSHA-256をsource-SHA256SUMSに残した。test hostはmemory container、専用media/tmp、専用bundle IDへ分離し、通常起動のpending removal/temporary cleanupを迂回した。解析cacheも隔離した。writer、保存処理、デコーダーは製品の処理を使う。実AudioEngineServiceと実VideoPlayerService/AVPlayerで自作合成メディアを再生し、ユーザーの楽曲/Library/defaultsは使っていない。

音声は30秒sine、M4Vは30秒320×180・24fps H264+48kHz stereo AACのfaststart入力。初期の長いtitleを短縮して余白を作り、短いpatchと余白を超えるpatchで2経路を再現した。変更はtitle/comment、保持対象はartist/album/genre/year/track/artwork/lyrics。MP3入力はID3v2.3のUTF-16のみであり、Latin-1混在の他アプリ確認は別fixtureの結果を使う。

採用runは2 parameterized tests/8 cases、32.809秒で成功した。他agentのGUI/build/test停止を確認した専用枠での1回測定であり、中央値やばらつきの推定ではない。GUI停止確認と重なる可能性があった先行runはfunctional-overlapへ分離し、その時間を採用しない。

形式 余白内保存 ms rewrite ms 余白内/rewriteのinode
MP3 4.199 8.475 同一/交換
M4A 4.472 11.348 同一/交換
FLAC 3.486 7.770 同一/交換
M4V 6.558 13.210 同一/交換

保存時間は実LibraryService呼び出し前後のsystemUptime差。8条件とも原本title/commentの保存を独立readerで確認し、titleは保存済modelとLibraryService再読み込みでも一致した。音声6条件はcommentのmodel/再読み込みも確認した。未変更タグ/artwork/lyricsと音声decoded float PCM SHA-256は保存前後で一致した。M4A/M4Vはmoov-before-mdat配置とfree余白を記録し、余白超過による自然なrewriteを確認した。

音声tapは固定容量を事前確保し、callback内の同期I/O、可変append、保存側との共有lockを避けた。保存なし1秒+保存後2秒の30~31buffersは全てnonzero、sampleTimeの次buffer開始と前buffer終端との差は全6条件で0frames、最長zero runは0ms。4800frames/48kHz出力で、hostTime間隔とframe数から算出した間隔との差は最大0.015584msだった。callback間隔はbaseline約106.7ms、保存後最大106.7~107.0ms。保存自体が1bufferより短くduringSaveは0buffersだったため、保存直前と直後の全隣接bufferをsaveBoundaryとして照合した。currentTimeの増加だけを連続性の証拠にしていない。

動画は4096frames/48kHzで、baseline11/post24/whole35buffersが全てnonzeroかつ有効mediaTime。観測開始前のzero/無効mediaTime初期buffer1個を対象区間から除いた。media開始間隔は81.375→81.375→93msの周期で、frame数からの85.333msとの差は-3.958/+7.667ms。保存なし区間にも同じ周期があり、保存前後の最大差はどちらも7.667ms、保存境界は両条件-3.958msだった。保存時だけの逆行/新しいジャンプは観測しなかったが、media時刻とframe数の厳密な一致を証明したとは扱わない。

AVPlayerの各141samplesは全てplayingで、player/item errorとwait reasonによる停止はなし。保存前26/24、保存後各47の新しい映像frameを確認した。最長zero runは0ms。停止後のAVAssetReaderはbefore/after全720映像frameを完了し、さらにFFmpegで各before/afterの映像・音声streamを終端までdecodeしてerrorなし/decoded SHA-256一致を確認した。frame数の完了だけを内容一致の根拠にしていない。

再確認では、専用hostで上記の30秒音声/動画を実AudioEngineService/VideoPlayerServiceから再生し、保存なし1秒と保存後2秒のbufferを固定容量へ記録する。短いpatchと余白超過patchを実LibraryServiceへ渡し、sampleTime+frame数の全隣接境界、zero run、player状態/error、保存後のfresh decodeとdecoded SHA-256を照合する。実装はPR #77を参照する。採用8条件のscenario JSON、callback行、summary、source hash、計測コピーと合成入力はローカルに保全した。聴感や未実施のiPhone実機まで、この観測結果を拡張しない。

外部アプリの表示確認は Mp3tag/Finder/Music互換性 に記録した。実co64はMp3tag/Finderで確認し、Musicでは小M4Vの保存前後を確認した。

2026-10-11追試: iPhone/iPad FilesからのOpus/M4V保存

完了条件は、既存の挿入済み fixture を使わず、Files picker から Opus と m4v を取り込み、各形式を1件ずつ選択して新しい title を画面から保存し、保存所要時間・再起動後のモデル title・アプリ管理下のファイルの title・iPhone/iPad 両方の再生を確認すること。album と artist は未変更値の保持を確認する。Files に置いた原本と、取り込み後の保存先を区別し、保存したファイルのタグ読み戻しと原本の保持を確認する。iPhone/iPad Simulator で検証する。

iPhone17e Simulator27.0 の実 Files picker で、Browse → On My iPhone → 検証用アプリの Documents → fixture folder から Opus と m4v を選択し、取り込み後の音声・動画一覧へ到達した。各形式を1件ずつ選び、画面から Saved Issue71 Opus / Saved Issue71 Video を保存した。成功表示、再起動後のモデル title、両形式の再生時間の進行が成功した。Apply から成功表示確認までは Opus1.855秒 / m4v1.750秒だった(Debug Simulator、別担当のmacOS native GUI検証と並走。writer内部の時間ではない)。

現在の隔離 data container の managed copy を FFprobe で読み戻し、両 title が新しい値、artist が Files Validation、album が Before saving のまま保たれていることを確認した。Documents の Files原本2件は生成時の SHA-256 と一致し、元の title を維持した。XCTest再インストールにより container path が変わるため、読み戻し直前に simctl get_app_container で現在の path を解決した。iPad Pro13 M5 Simulator27.0でも、実Files取り込み、両title保存、各再起動後title、両形式の再生clock進行まで到達した。Apply→summaryはOpus2.142秒 / m4v2.066秒(native GUIおよび後半のmacOS UI testと並走した機能条件)。managed両title・未変更artist/album・Files原本SHAの読み戻しも成功した。ただし全操作testの最後の余分な一覧再訪で、sidebar切替直後に即existsを確認して失敗した(119.054秒)。Runnerの末尾も表示待ち15秒へ揃え、保存済みdataを使った再起動・一覧再訪の確認専用caseが23.136秒で成功した。その後のmanagedタグと原本SHA読み戻しも成功した。全操作testの失敗と、確認caseの成功は別xcresultとして保持し、最初のxcresult全体を成功へ書き換えない。

隔離した Debug コピーの iOS UI-only build と Runner修正版は成功している。生成 Info.plist の UIFileSharingEnabled と LSSupportsOpeningDocumentsInPlace は両方 true。この2設定と表示名・別 bundle ID はコピーの Documents を Filesへ公開する検証用変更で、本体の product設定には加えない。import・保存・再生は本体と同じコードで、fixture自動挿入を使わず実 fileImporter を通した。入力は30秒の Opus音声とMP4 containerのH.264/AAC動画(m4v拡張子)。実機 Files、iCloud、外部 file provider は未検証。大容量・互換性は別担当の結果と分けて記録する。

初回iPhone試験は Opus保存後、「phone row が album も表示する」というテストの仮定で停止した。実際のrow labelはtitle/artistなのでalbumをmanagedタグ読み戻しへ移し、原本・初回保存dataを退避して隔離appのみresetした後、フル操作を再試験して成功した。

再確認は専用DebugコピーでDocumentsをFilesへ公開し、自作Opus/M4Vを置く。上記Browse経由で実fileImporterから取り込み、各title保存→再起動→一覧確認→再生clock進行を操作する。直後に simctl get_app_container で現在containerを解決し、managed copyのFFprobeタグとDocuments原本のSHAを別々に照合する。実import/保存/再生実装はPR #77を参照する。採用ログ・両OS summary・managedタグ・原本SHA・検証コピーのsource hashとxcresultはローカルに保全し、初回採用外部分結果とiPad全操作失敗を成功へ書き換えていない。

Clone this wiki locally