うならぼ

申し訳程度のアフィリエイトとか広告とか解析とかは/aboutを参照

F#からFiddlerCoreを触る

リセマラ用にキャッシュプロキシでも作ってみようかと思ったんだ。

結局そこまで高速化はできなくて、そもそもリセマラ面倒になったよね。

SAZの読み書きを実装してみる

FiddlerではセッションをSAZという形式で保存することができます。FiddlerCoreでもこれを読み書きすることはできるのですが、Zipアーカイブの処理は自分で実装する必要があります。

例によってろくなドキュメントがないので、SampleAppのSAZ-DotNetZip.csを参考に実装していきます。その名の通りサンプルはDotNetZipを使っていますが、今回はSystem.IO.Compression.ZipFileを使います。

open System
open Fiddler
open System.IO
open System.IO.Compression

type SAZWriter(zipname) =
    let zip = ZipFile.Open(zipname, ZipArchiveMode.Create)
    interface ISAZWriter with
        member self.Filename = zipname
        member self.Comment with set(v) = () // これは必ず呼ばれるので例外を投げられない
        member self.EncryptionMethod = raise <| NotSupportedException()
        member self.EncryptionStrength = raise <| NotSupportedException()
        member self.SetPassword(password) = raise <| NotSupportedException()
        member self.AddFile(filename, writer) =
            use stream = zip.CreateEntry(filename).Open()
            writer.Invoke(stream)
        member self.CompleteArchive() =
            zip.Dispose()
            true

type SAZReader(zipname) = 
    let zip = ZipFile.OpenRead(zipname)
    interface ISAZReader with
        member self.Filename = zipname
        member self.Comment = raise <| NotSupportedException()
        member self.EncryptionMethod = raise <| NotSupportedException()
        member self.EncryptionStrength = raise <| NotSupportedException()
        member self.Close() =
            zip.Dispose()
        member self.GetRequestFileList() =
            seq { for x in zip.Entries -> x.FullName }
            |> Seq.filter (fun x -> x.StartsWith("raw/") && x.EndsWith("_c.txt"))
            |> Seq.toArray
        member self.GetFileStream(filename) =
            zip.GetEntry(filename).Open()
        member self.GetFileBytes(filename) =
            use src = zip.GetEntry(filename).Open()
            use buf = new MemoryStream()
            src.CopyTo(buf)
            buf.ToArray()

type SAZProvider() =
    interface ISAZProvider with
        member self.BufferLocally = false
        member self.SupportsEncryption = false
        member self.CreateSAZ(zipname) =
            new SAZWriter(zipname) :> ISAZWriter
        member self.LoadSAZ(zipname) =
            new SAZReader(zipname) :> ISAZReader

[<EntryPoint>]
let main argv =
    FiddlerApplication.OnNotification.Add (fun e -> printfn "%s" e.NotifyString |> ignore)
    FiddlerApplication.oSAZProvider <- new SAZProvider()
    let sessions = Utilities.ReadSessionArchive(@"r:\archive.saz", true)
    printfn "%d sessions loaded" sessions.Length
    Utilities.WriteSessionArchive(@"r:\archive2.saz", sessions, null, true)
    0

内包表記とパイプライン演算子GetRequestFileListで内包表記やパイプライン演算子を使っていますが、内包表記ひとつで済ますこともできます。

member self.GetRequestFileList() =
    [| for x in zip.Entries do
        let name = x.FullName
        if name.StartsWith("raw/") && name.EndsWith("_c.txt") then
            yield name |]

内包表記なしでも。

member self.GetRequestFileList() =
    zip.Entries
    |> Seq.map (fun x -> x.FullName)
    |> Seq.filter (fun x -> x.StartsWith("raw/") && x.EndsWith("_c.txt"))
    |> Seq.take 1
    |> Seq.toArray

パイプライン使うのはLINQで見慣れてるし、内包表記もラムダの嵐を避けられて悪くないし・・・と思った結果がさっきのコードです。中途半端かもしれない。

もうひとつF#らしいところというと、GetFileBytesで使っているuse演算子でしょうか。C#でいうusingですが、インデントが深くならなくて素敵。

プロキシを立てる

SAZを読み込んでURLが一致するものにキャッシュから返そうかと思ったんですが、数が多いとSAZの読み書きが遅いとか、Fiddler使うの面倒とか・・・そんなわけでhttpで取得されるあらゆるファイルをURLに対応したパスにキャッシュするという雑な実装に。

open System
open Fiddler
open System.IO

let port = 1601
let cachePathBase = @"R:\response\"

let cachePathFor (session: Session) =
    let uri = new Uri(session.fullUrl)
    if uri.LocalPath.EndsWith("/") then
        None
    else
        Some(Path.Combine(cachePathBase, uri.Host, uri.LocalPath.Substring(1)))

let onBeforeRequest (sess: Session) =
    match cachePathFor sess with
    | Some cachePath ->
        if (File.Exists(cachePath)) then
            sess.Ignore()
            sess.LoadResponseFromFile(cachePath) |> ignore
            printfn "hit: %s" sess.url |> ignore
        else
            sess.Tag <- cachePath
            printfn "save: %s" sess.url |> ignore
    | None ->
        sess.Ignore()

let onBeforeResponse (sess: Session) =
    sess.SaveResponse(sess.Tag :?> string, false)
        
[<EntryPoint>]
let main argv = 
    CONFIG.IgnoreServerCertErrors <- true
    FiddlerApplication.OnNotification.Add <| fun e -> Console.WriteLine e.NotifyString
    FiddlerApplication.Prefs.SetBoolPref("fiddler.network.streaming.abortifclientaborts", true)
    
    FiddlerApplication.add_BeforeRequest <| new SessionStateHandler(onBeforeRequest)
    FiddlerApplication.add_BeforeResponse <| new SessionStateHandler(onBeforeResponse)
    
    FiddlerApplication.Startup (port, FiddlerCoreStartupFlags.Default &&& ~~~FiddlerCoreStartupFlags.RegisterAsSystemProxy &&& ~~~FiddlerCoreStartupFlags.DecryptSSL)
    printfn "Port: %d\nPress Enter to exit." port
    stdin.ReadLine() |> ignore

    0

FiddlerCoreではおなじみのBeforeRequestイベントですが、 BeforeRequest.Add (fun e -> ...) とはできません。こんなエラーが出ます。

イベント BeforeRequest が標準以外の型です。このイベントが別の CLI 言語で宣言された場合、イベントにアクセスするには、このイベントに明示的な add_BeforeRequest メソッドや remove_BeforeRequest メソッドを使用する必要があります。このイベントが F# で宣言された場合、イベントの型を IDelegateEvent<_> または IEvent<_,_>インスタンス化にします。

どうやら EventHandler<T> でないことが問題のようです。自分でSessionStateHandler型のインスタンスを作って、add_BeforeRequest に渡さないといけません。

あとはoption使ってる以外はF#らしくないというか、すごく手続き型っぽいコードですねえ。。

Exifのサムネイルの縦横比を修正して、デジカメできちんと表示されるように

SDカードに過去の写真が結構入っている*1のですが、RAWで撮ってるので結構場所をとります。原本をPCに転送したらJPEGに置き換えたい。

と思って適当にJPEGに変換して入れてみると、個別に表示することはできても一覧でサムネイルが表示されない。

f:id:unarist:20160811224057p:plain

いろいろ探った結果、サムネイルの大きさがDCF規格の160x120*2と違う縦横比だったのが原因だったようです。ちなみに比率が正しければもっと大きくても表示できました*3

プログラム書かない方へ:F6 Exifのインポート機能なども使えます(手間はかかりますが)。他にもサムネ再生成してくれるソフトはありそうですが、適当なものだと縦横比修正してくれないかも。

で、サムネイル再生成する何かをいつものようにC#で書こうとしたものの、System.DrawingやWPFメタデータ付きのJPEGを書き出すとビッグエンディアンになってしまうようなのです。これではサムネイルだけでなく全く表示できなくなってしまいました。

そこで今回はExiv2コマンドラインツールで埋め込むことにしました。exiv2 -it hoge.jpg とすると、hoge-thumb.jpg を hoge.jpg に埋め込んでくれます。

またRAWからのJPEG化・サムネイル生成はPhotoshopをCOM経由で叩いてみました。

参考 Automating Photoshop With C# // Josh Wright

ただうちの環境ではタイプライブラリの場所が正しく登録されていなかったようで、手動で C:\Program Files (x86)\Adobe\Adobe Photoshop CS5\Plug-ins\Extensions\ScriptingSupport.8li からtlbimpしました。

// using Photoshop;

var src_dir = @"G:\Src\";
var target_dir = @"G:\Thumb\";
var exiv2_exe = @"R:\exiv2.exe";
var thumb_width = 320;

var app = new Application();
//app.Visible = false;
app.BackgroundColor = new SolidColor { RGB = new RGBColor { HexValue = "000000" } };

// 後々の座標・長さ指定はこの単位が適用される
var ruler_old = app.Preferences.RulerUnits;
app.Preferences.RulerUnits = PsUnits.psPixels;

var export_opts = new ExportOptionsSaveForWeb
{
    Format = PsSaveDocumentType.psJPEGSave,
    Blur = 0.2,
    Quality = 50,
    Optimized = true
};
var save_opts = new JPEGSaveOptions
{
    Quality = 8,
    FormatOptions = PsFormatOptionsType.psOptimizedBaseline
};

foreach (var src in Directory.EnumerateFiles(src_dir, "*.cr2"))
{
    var basename = Path.GetFileNameWithoutExtension(src);
    var img_path = $"{target_dir}{basename}.jpg";
    var thumb_path = $"{target_dir}{basename}-thumb.jpg";
    
    if (File.Exists(img_path)) File.Delete(img_path);
    if (File.Exists(thumb_path)) File.Delete(thumb_path);

    // サムネイル生成で使うExportはファイル名を指定できないので、後で移動する。
    // ので、先にサムネ生成してから縮小前までUndoしてフルサイズ画像を書き出す。
    
    var doc = app.Open(src);
    var state = doc.ActiveHistoryState;
    
    if (doc.Width > doc.Height)
    {
        doc.ResizeImage(Width: thumb_width, ResampleMethod: PsResampleMethod.psBicubicSharper);
    }
    else
    {
        // doc.ResizeImage(Height: thumb_width * 3 / 4, ResampleMethod: PsResampleMethod.psBicubicSharper);
        // サムネはこれでいいが、フルサイズ画像も(横長のまま)Exifで回転指定しないといけない・・・
        throw new NotSupportedException("縦長画像は今後の課題。");
    }
    doc.ResizeCanvas(thumb_width, thumb_width * 3 / 4, PsAnchorPosition.psMiddleCenter);
    doc.Export(target_dir, PsExportType.psSaveForWeb, export_opts);
    File.Move(img_path, thumb_path);
    
    doc.ActiveHistoryState = state;
    doc.SaveAs(img_path, save_opts);
    doc.Close();

    // exiv2 -it foo.jpg
    // => foo.jpg に foo-thumb.jpg をサムネイル画像として埋め込む
    Process.Start(new ProcessStartInfo {
        FileName = exiv2_exe,
        Arguments = $"-it \"{img_path}\"",
        UseShellExecute = false,
        CreateNoWindow = true
    }).WaitForExit();
    File.Delete(thumb_path);
}

app.Preferences.RulerUnits = ruler_old;
//app.Quit();

ここまで書いてから、WICなんだからRAW形式もBitmapImageとかで読み込めて、Photoshopいらないのではと気付いた。面倒くささはいい勝負のような気がするけど…。

*1:撮った写真を人に見せられるようにってことなんですが、本当は画面大きくて操作しやすいタブレットに移行したい。

*2:CIPAのサイトからダウンロードできるDC-009-2010の§4.4.6.3参照

*3:うちのKiss X4でも液晶ディスプレイが横1400pxぐらいはあるようなので、3枚横に並べるとしても一枚160pxは余裕すぎる。ファイルサイズや表示速度に影響しそうではあるけど。