メインコンテンツへスキップ
見出し画像
Photo bydodo_mmm

【片手操作】写真をボタン操作だけで少しずつ削って自動で微調整してくれるiPhoneショートカットレシピ

    以前、画像にマス目(グリッド線)を入れて、範囲指定で写真をざっくり切り取るというショートカットを作ったのですが。

    絶対手で操作したほうが速いし(笑)
    個人的には面白い仕組みと思ってますが、切り取りがざっくり過ぎるため細かい余白が残ってしまうという欠点があり、この機能実行後に調整する機能があれば良いなあと思ってました。

    そこで今回思いつきで開発してみたのですが、夢中で作っている内に過去最高にニーズはないのにものすごく大がかりな仕組みとなってしまいました。

    作った直後なのでテンション高め!

    まずは操作画面イメージから

    画像
    実は今回iPhoneショートカット機能
    使っているのはほんの一部(笑)

    ざっくり説明すると上下左右それぞれのボタンを押すと「削り値」の数値のぶんだけ画像を削ってくれるという仕組みです。
    絶対手動の方が速いけど!(笑)

    今回のレシピ本体
    (レシピとは別にScriptableというアプリとコードの設定が必要となります。後述します)

    ※3/25 3:00追記
    異常終了を回避する版に修正しました。
    ただしcloseボタンは相変わらず使用不可です。
    またショートカット名を修正した場合は以下の画像部分のテキストも修正してください。

    画像


    1.描いていた完成像

    今回目指していた完成像がありまして。

    それは加工したい画像をショートカットで呼び出して、その画像を見つつ操作できて、削る様子が画面で更新されていくという仕様でした。

    iPhoneショートカットのアクションですと、「クイックビュー」で画像は確認できて「メニュー」や「入力を要求」などで操作指示は出せると思うのですが「画像を見ながら操作」という要件を満たすことがなかなかできず。

    そこでchatGPTさんに相談したところ紹介されたのがScriptableというiPhoneショートカットと連携してJavaScriptなどのコードを使用できるアプリでした。割と有名ですし、僕も特定の目的のために要所で使ったことはありましたが、がっつり使うのは初めてでした。
    今回はこのアプリが必要となります。


    2.仕組み

    紆余曲折あったのですが、以下の仕組みに落ち着きました。

    【仕組み】
    ①iPhoneショートカットを起動して写真を選択
    ②選択した写真をScriptable渡します。
    ③Scriptableのスクリプトが実行され、HTMLの画面が開きます。
    ④その画面のボタン操作で画像切り取りを実行。
    ⑤保存ボタンを押すともとのショートカットに加工した画像情報が渡され写真アプリに保存されます。

    実は加工した画像もScriptable単体で完結できるのですが、どうしてこの仕様になったかは後述します(今回後述多いな(笑))


    3.設定方法

    【Scriptableの設定】
    ✅今回はショートカットレシピ自体の設定は不要ですが、Scriptableの設定および注意事項があります。

    ①Scriptableをダウンロードして開きます。右上の「+」ボタンを押すとコードを入力する画面が開きます。

    画像
    右側の「+」ボタンを押すと左の画面が開くので
    「1」の右側にコードをコピペします。

    そこに以下のコードをコピペして、「Done」を押してください

    // ========= 画像 =========
    const img = args.images[0]
    const base64 = Data.fromPNG(img).toBase64String()
    
    // ★ 自分のショートカット名
    const shortcutName = "ImageCropFlow"
    
    // ========= HTML =========
    const html = `
    <!DOCTYPE html>
    <html>
    <head>
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
    <style>
    body { margin:0; text-align:center; font-family:-apple-system; }
    
    .notice {
      background:#ffecec;
      color:#c00;
      padding:8px;
      font-size:14px;
    }
    
    img {
      width:100%;
      max-height:38vh;
      object-fit:contain;
    }
    
    button {
      width:90%;
      margin:3px;
      padding:8px;
      font-size:14px;
    }
    
    input {
      width:60px;
      font-size:14px;
    }
    
    .notice {
      padding:6px;
      font-size:13px;
    }
    body {
      touch-action: manipulation;
      -webkit-user-select: none;
      -webkit-touch-callout: none;
    }
    </style>
    </head>
    
    <body>
    
    <div class="notice">
    ⚠ closeは使わないでください(下のボタンで終了)
    </div>
    
    <img id="img" src="data:image/png;base64,${base64}">
    
    <div>
    削り値(px): <input id="stepInput" value="20">
    </div>
    
    <div>
    <button onclick="cutDir('right')">➡ 右</button>
    <button onclick="cutDir('left')">⏪ 左</button>
    <button onclick="cutDir('top')">🔼 上</button>
    <button onclick="cutDir('bottom')">🔽 下</button>
    
    <button onclick="undo()">↩ 戻す</button>
    <button onclick="reset()">🔙 リセット</button>
    
    <button onclick="save()">💾 保存</button>
    <button onclick="finish()">🛑 終了</button>
    </div>
    
    <script>
    let original = new Image()
    original.src = document.getElementById("img").src
    
    let cut = {l:0,t:0,r:0,b:0}
    let history = []
    
    original.onload = () => draw()
    
    function getStep() {
      let val = parseInt(document.getElementById("stepInput").value)
      if (isNaN(val) || val <= 0) return 20
      return val
    }
    
    function draw() {
      const canvas = document.createElement("canvas")
      const ctx = canvas.getContext("2d")
    
      const w = original.width
      const h = original.height
    
      const newW = w - cut.l - cut.r
      const newH = h - cut.t - cut.b
    
      if (newW <= 0 || newH <= 0) return
    
      canvas.width = newW
      canvas.height = newH
    
      ctx.drawImage(original, -cut.l, -cut.t)
    
      document.getElementById("img").src = canvas.toDataURL()
    }
    
    function saveHistory() {
      history.push({...cut})
    }
    
    function undo() {
      if (history.length === 0) return
      cut = history.pop()
      draw()
    }
    
    function reset() {
      cut = {l:0,t:0,r:0,b:0}
      draw()
    }
    
    function cutDir(dir) {
      saveHistory()
      const step = getStep()
    
      if (dir==="left") cut.l += step
      if (dir==="right") cut.r += step
      if (dir==="top") cut.t += step
      if (dir==="bottom") cut.b += step
    
      draw()
    }
    
    function save() {
      window.result = document.getElementById("img").src
    }
    
    function finish() {
      window.result = "NO_SAVE"
    }
    </script>
    
    </body>
    </html>
    `
    
    // ========= WebView =========
    let wv = new WebView()
    await wv.loadHTML(html)
    wv.present(true)
    
    // ========= 結果待ち =========
    let result = null
    
    while (true) {
      await new Promise(resolve => Timer.schedule(0.3, false, resolve))
    
      try {
        result = await wv.evaluateJavaScript("window.result", false)
      } catch(e) {
        break
      }
    
      if (result) break
    }
    
    // ========= 終了分岐 =========
    if (result === "NO_SAVE") {
    
      Pasteboard.copyString("NO_SAVE")
    
      try { wv.dismiss() } catch(e) {}
    
      Safari.open(
        "shortcuts://run-shortcut?name=" + encodeURIComponent(shortcutName)
      )
    
      Script.complete()
      return
    }
    
    if (!result) {
      try { wv.dismiss() } catch(e) {}
      Script.complete()
      return
    }
    
    // ========= 保存 =========
    Pasteboard.copyString(result)
    
    try { wv.dismiss() } catch(e) {}
    
    Safari.open(
      "shortcuts://run-shortcut?name=" + encodeURIComponent(shortcutName)
    )
    
    Script.complete()

    ②初期状態のスクリプト名は「Untitled Script」となっているので、その名称を「ImageMenu」に変更します。スクリプトを長押しするとメニューが出てくるのでその中から「Rename」を選んでスクリプト名を変更してください。

    画像
    ImageMenuに名称を変更

    【注意点】
    ✅ダウンロードしたショートカット名「ImageCropFlow」で動作させることを前提にコードを作っております。もしショートカット名を変更した場合は上記コード内の以下の名称部分を変更してください。

    // ★ 自分のショートカット名
    const shortcutName = "ImageCropFlow"

    ※3/25 3:00追記
    ショートカット名変えた場合はショートカット側の以下の箇所も修正必要になります。

    画像

    これで設定は完了です。


    4.機能説明

    ✅削り値の指定・変更

    画像
    画像を切り取るpx値を指定しているボックスです。
    こちらの数値を変更すれば切り取られる値が変更されます。
    大きめに切り取りたい場合は100くらいにしてみてください。

    ✅ボタン説明

    画像

    🔷上下左右ボタン:
      削り値のぶんだけ指定した方向の箇所を削ることができます。

    🔷「戻す」「リセット」ボタン:
     戻すでひとつ前の状態に戻せます。リセットは元画像の状態まで戻します。

    🔷「保存」「終了」ボタン
     保存ボタンを押すと加工された画像が写真アプリに保存されます。終了ボタンを押すと機能終了となります。

    【最大の問題点】
    このショートカットは以下の動作を行うと異常終了して、その直後に再開すると正常に動作しなくなります。

    ✅HTML画面の「Close」ボタンで終了した場合
    ✅ショートカット終了後にScriptableを開いた場合
    ※3/25 3:00追記
     ショートカット終了後にScriptableを開いた場合の異常終了は解消しました。
     また、「Close」ボタンで終了した場合はショートカットレシピ再起動で復旧するよう改定しました。

    この動作を行った場合はAppスイッチャーを起動して「Scriptable」「ショートカット」のアイコンを上にスワイプして完全終了させると再びお使いになれます。

    ただ・・・この操作を行っても不安定な状態ではあると思います。


    5.何故こんな変な仕様なのか

    元々、このツールが今の仕様に落ち着くまでは以下の段階がありました。

    段階1:
    Scriptableはボタン操作のみ + 画像切り取りはショートカットで実行

    段階2:
    ショートカットは画像選択のみ。Scriptableでボタン操作と画像切り取りを行う(基本HTMLのみ活用)

    現状:
    ScriptableでHTMLとJavaScriptを活用し、ボタン操作と画像切り取りを行い、ショートカットは最初の画像選択と加工後画像保存・終了処理を行う。

    まず、段階1の挙動はボタンを押すたびに「ショートカット→Scriptable→HTML画面」という遷移を繰り返す仕様で切り取りスピードも遅いし画面がちらついて厳しいと感じました。

    次に、段階2は一番安定感はあるもののボタンを押すたびに「Scriptableに一度戻ってしまう」という画面ちらつき問題が気になる点でした。

    そして現状の仕様は画面のちらつきもなく画面内で切り取りが視覚的に体験できてとても満足なのですが「Scriptable単体ではどうしてもこの画面を終了させることができない」という謎の不具合が解消できない状況になってしまいました。

    そこで、強引にショートカットに戻ってそこで画像保存と終了処理を行う仕様にしているのですが。実はScriptable側ではスクリプトは終了できていないんですよね。それを放置して当ショートカットを実行すれば何の問題もないのですが、一度でも戻ったら異常終了になるというバグが残りました。


    でも、僕は今の操作性をどうしても保持したかったのであきらめました(泣)


    6.まとめ

    こんなにこだわって試行錯誤しながら作ったのですが、やはりどう考えても手動でやったほうが速いことには変わりません(笑)

    ただ、写真の余計な部分がボタンを押すたびに少しづつ削れていって、上下左右を処理して最後にできあがった写真を見ると何とも言えない満足感を覚えたのでした。

    今回覚えたScriptableで操作画面を作る小技は他のメニュー系のショートカットにも使えそうな気がするので試してみたいと思いました。

    あと、今回Geminiさんと大喧嘩しながら開発していたのですが、chatGPTさんに切り替えたあとに上手くいった体験についても後日記事にしたいと思いました(笑)


    iPhoneショートカットアプリ関連




    この記事が参加している募集

     
     
    本当のお仕事は底辺の非正規労働者。東京都在住。現実逃避のためchatGPTと一緒に無駄に真面目な開発ごっこをしています。効率化とタスク管理を好む一方で非効率でも自動で動くカッコよさげな仕組みも好き。実はITスキル低め。趣味はAbemaの報道リアリティショー『アベマプライム』視聴。

    あなたへのおすすめ