# [メタ情報] # 識別子: 字幕かんたん作成workflow_exe # システム名: 字幕かんたん作成 # 技術種別: Misc # 機能名: クイックアクション # 使用言語: AppleScript ShellScript # 状態: 実行用  env終了 # [/メタ情報] 要約:Finderで選択された対象に応じて動作を切り替えるQuick Action用AppleScript。VTTファイルが選ばれた場合は追記モードで開き、フォルダが選ばれた場合はその中に新規VTTを作成する。HUDアプリ「VTT HUD Box」を連携起動し、QuickTime Playerの再生位置を基準に字幕テキストを入力・保存。重複検出や自動調整機能を備え、挿入・ソート・行番号再付与を自動化。TextEdit操作、HUD終了処理、例外処理も統合された字幕作成支援スクリプト。 字幕かんたん作成.workflow ``` -- ===== Quick Action 用ラッパ ===== -- Finder からの選択を受け取り: -- 1) .vtt が選ばれていればそのまま追加モード -- 2) フォルダが選ばれていれば、そのフォルダ内に新規作成を提案 -- 3) 何も選択が無ければ、従来のメニュー(mainMenu)を表示 -- ★★★ HUD Box 設定(フルパス & 予備:バンドルID)★★★ property __hudAppPath : "/Applications/自作appleスクリプト/VTT HUD Box.app" -- ←デフォルトの実パス property __hudBundleId : "" -- ←実バンドルID(分からなければ "" でも可) property __hudPoll : "0.06" -- 使わない場合は放置 on run {input, parameters} -- ====== env化・動的解決追加(Meister 1 / 2 の環境差を自動吸収) ====== set currentUser to short user name of (system info) set envPath to "/Users/" & currentUser & "/python_scripts/.env" try set envHudPath to do shell script "grep 'VTT_HUD_APP_PATH' " & quoted form of envPath & " | sed -E 's/^VTT_HUD_APP_PATH=//' | sed -E 's/^[\"\\x27]//;s/[\"\\x27]$//'" if envHudPath is not "" then set my __hudAppPath to envHudPath end if on error -- .env が見つからない、または変数が定義されていない場合はデフォルト(property の値)をそのまま使用します。 end try -- ==================================================================== try set vttList to {} set folderList to {} -- Finder 選択 of 仕分け repeat with itm in input set p to POSIX path of itm if (p ends with ".vtt") or (p ends with ".VTT") then set end of vttList to p else -- フォルダ判定 try tell application "System Events" to set isFolder to folder of (info for (POSIX file p)) if isFolder then set end of folderList to p end try end if end repeat if (count of vttList) > 0 then -- .vtt が選ばれている → それぞれを開いて追記セッション repeat with vp in vttList do shell script "open -ga TextEdit -g " & quoted form of vp -- 背面で TextEdit に開く delay 0.2 my goToBottomTextEdit() -- ★ HUD を現在の VTT で起動(重複起動防止のため一旦 quit → open) my launchHUDFor(vp) my interactiveAppendSession(vp) -- ←この中の「終了」で HUD も閉じる end repeat return input else if (count of folderList) > 0 then -- フォルダが選択されている → そのフォルダを既定位置にして新規作成 set targetDir to item 1 of folderList set newPath to my createNewVTTInFolder(targetDir) if newPath is not "" then do shell script "open -ga TextEdit -g " & quoted form of newPath delay 0.2 my goToBottomTextEdit() -- ★ HUD 起動 my launchHUDFor(newPath) my interactiveAppendSession(newPath) end if return input else -- 選択なし、またはその他 → メニュー表示 set btn to my mainMenu() -- 連打時の一番最後の「終了」以外でウィンドウを残さないよう、最終確認 if btn is "終了" then return input -- 念のため最終的にも HUD を閉じておく my quitHUD() return input end if on error errMsg number errNum if errNum is -128 then activate display dialog "ユーザによってキャンセルされました。" buttons {"OK"} default button "OK" else activate display dialog "エラー: " & errMsg & " (" & errNum & ")" buttons {"OK"} default button "OK" with icon caution end if end try end run on resolveHUDBundleId() try -- mdls でバンドルIDを取得(-raw で生値) set bid to do shell script "/usr/bin/mdls -name kMDItemCFBundleIdentifier -raw " & quoted form of __hudAppPath if bid is "(null)" or bid is "" then return "" return bid on error return "" end try end resolveHUDBundleId -- ★★★ HUD を現在の VTT で再起動(-- 書類渡しで open し、on open が呼ばれる)★★★ on launchHUDFor(vPath) try set qV to quoted form of (vPath as text) -- まずは優しく既存に quit(失敗しても無視) try my quitHUD() end try delay 0.2 -- 既存インスタンスに書類を渡す(新規を増やさない) do shell script "/usr/bin/open -a " & quoted form of __hudAppPath & " " & qV on error errMsg number errNum activate display dialog "HUD 起動エラー: " & errMsg & " (" & errNum & ")" buttons {"OK"} default button "OK" with icon caution end try end launchHUDFor -- ====== HUD を終了する(共通ユーティリティ) ====== on quitHUD() set appName to "VTT HUD Box" -- 1) 普通の quit を数回トライ repeat 3 times try tell application appName to quit end try delay 0.2 tell application "System Events" if (exists process appName) is false then return end tell end repeat -- 2) 粘る場合は強制キル tell application "System Events" if (exists process appName) is false then return end tell try do shell script "/usr/bin/killall -KILL " & quoted form of appName & " || true" end try -- 3) 最後に消えたか確認 repeat 10 times delay 0.1 tell application "System Events" if (exists process appName) is false then exit repeat end tell end repeat end quitHUD -- フォルダを既定の保存場所にした新規作成(choose file name の既定ロケーション指定) on createNewVTTInFolder(dirPosix) activate set defaultName to "new_subtitle.vtt" try set dirAlias to POSIX file dirPosix as alias set f to choose file name with prompt "VTTファイル名を指定" default name defaultName default location dirAlias on error number -128 display dialog "ユーザによってキャンセルされました。" buttons {"OK"} default button "OK" return "" end try set posixTarget to POSIX path of f if (posixTarget does not end with ".vtt") and (posixTarget does not end with ".VTT") then set posixTarget to posixTarget & ".vtt" end if do shell script "printf 'WEBVTT ' > " & quoted form of posixTarget return posixTarget end createNewVTTInFolder -- ===== メメニュー ===== on mainMenu() activate set dlg to display dialog "QuickTimePlayer of 動画に合わせて、字幕ファイル(vtt)を作成します。操作を選んでください。" buttons {"終了", "既存ファイルに追加", "新規作成"} default button "既存ファイルに追加" set btn to button returned of dlg if btn is "新規作成" then set newPath to my createNewVTT() if newPath is not "" then do shell script "open -ga TextEdit -g " & quoted form of newPath delay 0.2 my goToBottomTextEdit() -- ★ HUD 起動 my launchHUDFor(newPath) my interactiveAppendSession(newPath) -- ←「終了」で HUD も閉じる end if else if btn is "既存ファイルに追加" then set vttAlias to choose file with prompt "編集するVTTを選択" of type {"public.text"} set vttPath to POSIX path of vttAlias if (vttPath ends with ".vtt") is false and (vttPath ends with ".VTT") is false then activate display dialog "拡張子が .vtt ではありません。" buttons {"OK"} default button 1 with icon caution else do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.2 my goToBottomTextEdit() -- ★ HUD 起動 my launchHUDFor(vttPath) my interactiveAppendSession(vttPath) -- ←「終了」で HUD も閉じる end if else if btn is "終了" then -- ★ ここで HUD も終了(明示的) my quitHUD() end if return btn end mainMenu -- ===== 連続入力セッション ===== on interactiveAppendSession(vttPath) repeat -- 入力Boxの前に最下行へ寄せる(ダイアログ最前面化の影響を受けない) my goToBottomTextEdit() activate set d1 to display dialog "字幕テキストを入力してください。" default answer "" buttons {"終了", "OK"} default button "OK" if button returned of d1 is "終了" then -- ★ 入力ダイアログの「終了」で HUD を閉じる my quitHUD() exit repeat end if set cueText to text returned of d1 if cueText is "" then activate display dialog "空の字幕は追加しません。" buttons {"OK"} default button 1 with icon caution else activate set d2 to display dialog ("最短4秒、1行フル(約20文字)で5秒を目安" & return & return & "継続秒数(何秒間表示しますか?)") ¬ default answer "4.0" buttons {"キャンセル", "OK"} default button "OK" set durOK to true try set durReal to (text returned of d2) as real on error set durOK to false end try if durOK is false then activate display dialog "数値で入力してください。" buttons {"OK"} default button 1 with icon caution else -- 現在の QTP 停止秒を取得 set qSec to my getQTCurrentTimeSeconds() if qSec is missing value then -- QTP停止秒の取得エラー do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() else set startMs to qSec * 1000 set endMs to startMs + (durReal * 1000) -- 衝突検知 set checkRes to my checkOverlap(vttPath, startMs, endMs) set code to item 1 of checkRes set msg to item 2 of checkRes if code is "OK" then -- 正常書き込み(挿入 → ソート → 連番付きで全書き込み) my insertAndRegenerateVTT(vttPath, startMs, endMs, cueText) do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() else if code is "ADJUSTED" then set adjEndMS to item 3 of checkRes activate display dialog (msg & return & return & "表示時間を短縮して保存しますか?") buttons {"キャンセル", "OK"} default button "OK" if button returned of result is "OK" then my insertAndRegenerateVTT(vttPath, startMs, adjEndMS, cueText) end if do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() else activate set d4 to display dialog (msg & return & "(開始位置が既存表示の内部です)" & return & return & "どうしますか?") buttons {"中止", "重ねて追記"} default button "中止" with icon caution if button returned of d4 is "重ねて追記" then my insertAndRegenerateVTT(vttPath, startMs, endMs, cueText) end if do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() end if end if end if end if end repeat end interactiveAppendSession -- ===== 新規VTT作成 ===== on createNewVTT() activate set defaultName to "new_subtitle.vtt" set f to choose file name with prompt "VTTファイル名を指定" default name defaultName set posixTarget to POSIX path of f if (posixTarget does not end with ".vtt") and (posixTarget does not end with ".VTT") then set posixTarget to posixTarget & ".vtt" end if do shell script "printf 'WEBVTT ' > " & quoted form of posixTarget return posixTarget end createNewVTT -- ====== 挿入 → ソート → 行番号付きで全書き込み ====== on insertAndRegenerateVTT(vPath, sMs, eMs, txtStr) try -- 1) 現行全キュー読み込み set cueList to my parseVttFile(vPath) -- 2) 新規追加 set end of cueList to {s:sMs, e:eMs, txt:txtStr} -- 3) 開始秒でソート(シェルソート等の簡易実装) set sortedCues to my sortCues(cueList) -- 4) 再構築テキスト生成 set vttText to "WEBVTT" & linefeed & linefeed set cIdx to 1 repeat with cu in sortedCues set sStr to my formatMSToStamp(s of cu) set eStr to my formatMSToStamp(e of cu) set vttText to vttText & (cIdx as string) & linefeed & sStr & " --> " & eStr & linefeed & (txt of cu) & linefeed & linefeed set cIdx to cIdx + 1 end repeat -- 5) ファイルへUTF-8で上書き保存 set fRef to open for access (vPath as POSIX file) with write permission set eof of fRef to 0 write vttText to fRef as «class utf8» close access fRef on error errMsg try close access (vPath as POSIX file) end try activate display dialog "書き込みに失敗しました: " & errMsg buttons {"OK"} default button 1 with icon caution end try end insertAndRegenerateVTT -- ====== 衝突(オーバーラップ)検査 ====== on checkOverlap(vPath, sMs, eMs) set cueList to my parseVttFile(vPath) repeat with cu in cueList set cs to s of cu set ce to e of cu -- 開始秒が既存表示の内部 if sMs ≥ cs and sMs < ce then return {"OVERLAP", "すでに字幕が表示されています。[" & my formatMSToStamp(cs) & " --> " & my formatMSToStamp(ce) & "] (" & (txt of cu) & ")"} end if -- 開始秒はセーフだが、終了秒が既存開始をはみ出す → 短縮調整可能か判定 if sMs < cs and eMs > cs then -- 最短2.0秒確保できるか if (cs - sMs) ≥ 2000 then set adjEnd to cs - 10 -- 1ミリ秒引いて隙間を作る return {"ADJUSTED", "次の字幕と衝突します。[" & my formatMSToStamp(cs) & " --> " & my formatMSToStamp(ce) & "] (" & (txt of cu) & ")" & return & "表示時間を " & (((adjEnd - sMs) / 1000) as string) & " 秒へ短縮しますか?", adjEnd} else return {"OVERLAP_TOO_SHORT", "次の字幕と衝突し、表示時間を確保できません(2秒未満)。"} end if end if end repeat return {"OK", ""} end checkOverlap -- ====== VTT 解析(AppleScriptネイティブパーサー) ====== on parseVttFile(vPath) set cList to {} try set fText to read (vPath as POSIX file) as «class utf8» set fLines to paragraphs of fText set lineCount to count of fLines set i to 1 repeat while i ≤ lineCount set tLine to item i of fLines if tLine contains "-->" then set stamp to tLine set cueTxt to "" set j to i + 1 -- 字幕テキストの収集(空行が出るまで) repeat while j ≤ lineCount set nextLine to item j of fLines if nextLine is "" then exit repeat if cueTxt is "" then set cueTxt to nextLine else set cueTxt to cueTxt & linefeed & nextLine end if set j to j + 1 end repeat -- タイムスタンプ解析 "00:00:00.000 --> 00:00:00.000" set AppleScript's text item delimiters to " --> " set parts to text items of stamp set sPart to item 1 of parts set ePart to item 2 of parts set AppleScript's text item delimiters to "" set sMs to my parseStampToMS(sPart) set eMs to my parseStampToMS(ePart) set end of cList to {s:sMs, e:eMs, txt:cueTxt} set i to j else set i to i + 1 end if end repeat end try return cList end parseVttFile -- "HH:MM:SS.mmm" または "MM:SS.mmm" をミリ秒(整数)に変換 on parseStampToMS(stampStr) set AppleScript's text item delimiters to ":" set parts to text items of stampStr set AppleScript's text item delimiters to "" set c to count of parts set hh to 0 set mm to 0 set ssAndMs to "" if c is 3 then set hh to item 1 of parts as integer set mm to item 2 of parts as integer set ssAndMs to item 3 of parts else if c is 2 then set mm to item 1 of parts as integer set ssAndMs to item 2 of parts else set ssAndMs to stampStr end if set AppleScript's text item delimiters to "." set ssParts to text items of ssAndMs set AppleScript's text item delimiters to "" set ss to item 1 of ssParts as integer set MS to item 2 of ssParts as integer return (hh * 3600000) + (mm * 60000) + (ss * 1000) + MS end parseStampToMS -- ミリ秒(整数)を "HH:MM:SS.mmm" のスタンプ文字列に変換 on formatMSToStamp(msVal) set hh to msVal div 3600000 set rem to msVal mod 3600000 -- division rem is a bit different in AppleScript, let's stick to safe native: set mm to rem div 60000 set rem2 to rem mod 60000 set ss to rem2 div 1000 set MS to rem2 mod 1000 return zPad(hh, 2) & ":" & zPad(mm, 2) & ":" & zPad(ss, 2) & "." & zPad(MS, 3) end formatMSToStamp on rem2(val1, val2) -- Rem helper end rem2 on zPad(num, digits) set s to num as string repeat while (count of s) < digits set s to "0" & s end repeat return s end zPad -- 簡易ソート(バブルソート) on sortCues(aList) set cCount to count of aList repeat with i from 1 to cCount repeat with j from 1 to (cCount - i) set itemJ to item j of aList set itemNext to item (j + 1) of aList if s of itemJ > s of itemNext then set item j of aList to itemNext set item (j + 1) of aList to itemJ end if end repeat end repeat return aList end sortCues -- ====== QuickTime Player の現在停止時間(秒)を安全取得 ====== on getQTCurrentTimeSeconds() try tell application "QuickTime Player" if (count of documents) is 0 then activate display dialog "QuickTime Player で動画を開いてください。" buttons {"OK"} default button 1 with icon caution return missing value end if set qDoc to document 1 set currentSec to current time of qDoc return currentSec end tell on error errMsg activate display dialog "QuickTime Player から再生時刻を取得できませんでした: " & errMsg buttons {"OK"} default button 1 with icon caution return missing value end try end getQTCurrentTimeSeconds -- ====== TextEdit最前面&最下部へスクロール(保険) ====== on goToBottomTextEdit() try tell application "System Events" -- 最前面アプリ名を記憶(チラつきを抑えてすぐに復帰させるため) set prevFront to name of first process whose frontmost is true tell process "TextEdit" set frontmost to true delay 0.05 -- Cmd-↓ で最下行へ(見切れ対策で↓を一押し) key code 125 using {command down} delay 0.1 key code 125 end tell -- すぐに元の前面アプリへ戻す(チラつきを最小化) if prevFront is not missing value and prevFront is not "TextEdit" then try set frontmost of process prevFront to true end try end if end tell end try end goToBottomTextEdit ```