「字幕かんたん作成」 ◆◆VTT HUD Box.scptは、スクリプトエディタを立ち上げ、AppleScriptを選択、箱の中に、スクリプトをコピペ、保存、 次に、書き出すを選択、ファイルフォーマット:アプリケーション、オプション:ハンドラの実行後に終了しないを選択、コード署名:コード署名をしないを選択、保存。 ◆◆Automatorスクリプトは、ワークフローが受け取る現在の項目:ファイルまたはフォルダ、検索対象:Finder.app、 「AppleScriptを実行」を選択して右側に記入箱を用意、その中に、スクリプトをコピペして保存してください。 ◆VTT HUD Box.scpt -- use AppleScript version "2.8" use framework "Foundation" use framework "AppKit" use scripting additions -- ========= 表示設定 ========= property fontBody : "Hiragino Sans" property fontBold : "Hiragino Sans W6" property fontMono : "Menlo" property szLine : 18 property szTime : 18 property szBody : 18 -- ========= 監視設定 ========= property pollMin : 0.06 -- 最小ポーリング秒(起動引数で上書き可) -- ========= 内部状態 ========= property thePanel : missing value property lblLine : missing value property lblTime : missing value property lblBody : missing value property theTimer : missing value property vttPath : missing value property cues : {} -- 各要素: {s:ms, e:ms, txt:text} property starts : {} -- 各キューの開始ms(検索用) property lastIdx : -1 property lastMtime : -1 ------------------------------------------------------------ -- 起動(引数: [vttPath] [pollMin]) ------------------------------------------------------------ on run argv -- NSApp を必ず初期化 if (current application's NSApp) is missing value then ¬ (current application's NSApplication's sharedApplication()) try if (count of argv) ≥ 1 then set vttPath to my coerceToPath(item 1 of argv) end try try if (count of argv) ≥ 2 then set pollMin to (item 2 of argv) as real end try try set pollMin to pollMin as real on error set pollMin to 0.06 end try if vttPath is missing value then try set f to choose file with prompt "表示するVTTファイルを選択" of type {"public.text"} set p to POSIX path of f if (p ends with ".vtt") is false and (p ends with ".VTT") is false then error "VTT拡張子のファイルを選んでください。" set vttPath to p on error errMsg number errNum if errNum is -128 then return -- ユーザーがキャンセル display dialog errMsg buttons {"OK"} default button 1 with icon caution return end try end if my startWithPath(vttPath) end run ------------------------------------------------------------ -- Finder ドロップ起動 ------------------------------------------------------------ on open droppedItems -- NSApp を必ず初期化 if (current application's NSApp) is missing value then ¬ (current application's NSApplication's sharedApplication()) try if (count of droppedItems) ≥ 1 then set p to my coerceToPath(item 1 of droppedItems) my startWithPath(p) end if on error errMsg display dialog errMsg buttons {"OK"} default button 1 with icon caution end try end open ------------------------------------------------------------ -- メイン開始 ------------------------------------------------------------ on startWithPath(p) if p is missing value then return if (p ends with ".vtt") is false and (p ends with ".VTT") is false then display dialog "VTT拡張子のファイルを指定してください。" buttons {"OK"} default button 1 with icon caution return end if set vttPath to p -- パネル作成(NSApp 初期化&メインスレッド実行を保証) my buildPanel() (current application's NSApp's activateIgnoringOtherApps:true) -- 初回ロード set cues to my readCues(vttPath) if (count of cues) > 0 then set starts to my cueStartsArray(cues) else set starts to {} end if set lastMtime to my fileMtime(vttPath) set lastIdx to -1 -- 即時描画 my drawAtCurrentTime() -- タイマー開始 set theTimer to (current application's NSTimer's scheduledTimerWithTimeInterval:pollMin target:me selector:"tick:" userInfo:(missing value) repeats:true) (current application's NSRunLoop's mainRunLoop()'s addTimer:theTimer forMode:(current application's NSRunLoopCommonModes)) end startWithPath ------------------------------------------------------------ -- Cocoa パネルを組み立て(ラッパー:必ず NSApp 初期化&メインスレッドで実行) ------------------------------------------------------------ on buildPanel() -- NSApp を必ず用意 if (current application's NSApp) is missing value then ¬ (current application's NSApplication's sharedApplication()) -- メインスレッド保証 if ((current application's NSThread's isMainThread()) as boolean) then my buildPanelOnMain() else me's performSelectorOnMainThread:"buildPanelOnMain" withObject:(missing value) waitUntilDone:true end if end buildPanel ------------------------------------------------------------ -- Cocoa パネル実体(本文=太字/横幅=全角21字/本文3行) ------------------------------------------------------------ on buildPanelOnMain() -- ====== 可変レイアウト用の計算 ====== set marginX to 16 set marginY to 16 set gapY to 6 set colsJP to 21 -- 全角21文字 set maxBodyLines to 4 -- 本文は4行まで -- フォント(本文=太字/行番号・タイム=等幅) set bodyFont to (current application's NSFont's fontWithName:fontBold |size|:szBody) set lineFont to (current application's NSFont's fontWithName:fontMono |size|:szLine) set timeFont to (current application's NSFont's fontWithName:fontMono |size|:szTime) -- 行高:ascender - descender + leading(descenderは負値) set lhBody to ((bodyFont's ascender()) - (bodyFont's descender()) + (bodyFont's leading())) as real set lhLine to ((lineFont's ascender()) - (lineFont's descender()) + (lineFont's leading())) as real set lhTime to ((timeFont's ascender()) - (timeFont's descender()) + (timeFont's leading())) as real -- 横幅:全角21文字ぶん(1em≈フォントサイズ)+余裕 set contentW to (colsJP * szBody * 1.05) as integer set w to contentW + (marginX * 2) -- 変更後(環境差を吸収するため 2pxだけ削る。まだ覗くなら -3 に) set bodyH to (((lhBody * maxBodyLines) as integer) - 2) -- パネル全高:本文 + タイム + 行番号 + 余白 + 隙間 set h to (marginY + bodyH + gapY + lhTime + gapY + lhLine + marginY) as integer -- ====== ウィンドウ生成 ====== set x to 120 set y to 100 set styleTitled to ((current application's NSWindowStyleMaskTitled) as integer) set styleClosable to ((current application's NSWindowStyleMaskClosable) as integer) set styleNonAct to ((current application's NSWindowStyleMaskNonactivatingPanel) as integer) set styleMask to styleTitled + styleClosable + styleNonAct set rect to (current application's NSMakeRect(x, y, w, h)) set thePanel to ((current application's NSPanel's alloc())'s ¬ initWithContentRect:rect styleMask:styleMask backing:(current application's NSBackingStoreBuffered) defer:false) thePanel's setTitle:"VTT HUD" thePanel's setLevel:(current application's NSStatusWindowLevel) thePanel's setHidesOnDeactivate:false thePanel's setOpaque:false thePanel's setBackgroundColor:((current application's NSColor's colorWithCalibratedRed:1.0 green:1.0 blue:0.85 alpha:0.92)) thePanel's setMovableByWindowBackground:true thePanel's setCollectionBehavior:(((current application's NSWindowCollectionBehaviorCanJoinAllSpaces) as integer) + ¬ ((current application's NSWindowCollectionBehaviorFullScreenAuxiliary) as integer)) set contentView to thePanel's contentView() -- ====== ラベル3つ(上:行番号/中:タイムコード/下:本文) ====== -- 行番号 set lblLine to ((current application's NSTextField's labelWithString:"")) (lblLine's setFont:lineFont) (lblLine's setTextColor:(current application's NSColor's blackColor())) (lblLine's setFrame:(current application's NSMakeRect(marginX, (h - marginY - lhLine), (w - marginX * 2), lhLine))) ((lblLine's cell())'s setScrollable:false) -- タイムコード set lblTime to ((current application's NSTextField's labelWithString:"")) (lblTime's setFont:timeFont) (lblTime's setTextColor:(current application's NSColor's blackColor())) (lblTime's setFrame:(current application's NSMakeRect(marginX, (h - marginY - lhLine - gapY - lhTime), (w - marginX * 2), lhTime))) ((lblTime's cell())'s setScrollable:false) -- 本文(太字・折返し・最大3行ぶんの高さでクリップ) set lblBody to ((current application's NSTextField's labelWithString:"")) (lblBody's setFont:bodyFont) (lblBody's setTextColor:(current application's NSColor's blackColor())) (lblBody's setFrame:(current application's NSMakeRect(marginX, marginY, contentW, bodyH))) -- 折返し設定 ((lblBody's cell())'s setUsesSingleLineMode:false) ((lblBody's cell())'s setLineBreakMode:(current application's NSLineBreakByWordWrapping)) ((lblBody's cell())'s setWraps:true) ((lblBody's cell())'s setScrollable:false) -- 省略記号を対応OSで有効化(任意) try if ((lblBody's cell())'s respondsToSelector:"setTruncatesLastVisibleLine:") as boolean then ((lblBody's cell())'s setTruncatesLastVisibleLine:true) end if end try (contentView's addSubview:lblLine) (contentView's addSubview:lblTime) (contentView's addSubview:lblBody) thePanel's makeKeyAndOrderFront:me thePanel's orderFrontRegardless() end buildPanelOnMain ------------------------------------------------------------ -- タイマー:時間取得/再描画/ファイル監視 ------------------------------------------------------------ on tick:sender try -- VTT 更新チェック set mt to my fileMtime(vttPath) if mt > 0 and mt is not lastMtime then set lastMtime to mt set cues to my readCues(vttPath) if (count of cues) > 0 then set starts to my cueStartsArray(cues) else set starts to {} end if set lastIdx to -1 end if end try my drawAtCurrentTime() end tick: ------------------------------------------------------------ -- いまの QuickTime の時刻に合わせて描画 ------------------------------------------------------------ on drawAtCurrentTime() set ok to false set tms to 0 tell application "QuickTime Player" if (it is running) and (count of documents) > 0 then set theDoc to front document set tms to (current time of theDoc) * 1000 as integer set ok to true end if end tell if not ok then (lblLine's setStringValue:"") (lblTime's setStringValue:"") (lblBody's setStringValue:"") return end if set idx to my findCueIndexAt(tms) if idx is not lastIdx then set lastIdx to idx if idx = 0 then (lblLine's setStringValue:"") (lblTime's setStringValue:"") (lblBody's setStringValue:"") else set c to item idx of cues set sMs to (s of c) set eMs to (e of c) (lblLine's setStringValue:("順番 " & (idx as string))) (lblTime's setStringValue:(my msToTimestamp(sMs) & " --> " & my msToTimestamp(eMs))) (lblBody's setStringValue:(txt of c)) end if end if end drawAtCurrentTime ------------------------------------------------------------ -- 現在時刻にヒットするキューのインデックス(無ければ0) ------------------------------------------------------------ on findCueIndexAt(MS) set i to 1 repeat with c in cues if (s of c) ≤ MS and MS < (e of c) then return i set i to i + 1 end repeat return 0 end findCueIndexAt ------------------------------------------------------------ -- VTT を読み込んで {s:ms, e:ms, txt:text} の配列へ ------------------------------------------------------------ on readCues(p) set L to {} set qP to quoted form of p -- 改行を LF に正規化 try set raw to do shell script "perl -0777 -pe 's/\\r\\n?|\\n/\\n/g' " & qP on error set raw to "" end try if raw = "" then return L set ps to paragraphs of raw set i to 1 if (count of ps) ≥ 1 and item 1 of ps is "WEBVTT" then set i to 2 -- 先頭空行スキップ repeat while i ≤ (count of ps) and (item i of ps as string) is "" set i to i + 1 end repeat repeat while i ≤ (count of ps) set lineText to item i of ps as string -- 番号行は無視(整数のみ) if my isNumericLine(lineText) then set i to i + 1 else if lineText contains " --> " then set parts to my splitText(lineText, " --> ") if (count of parts) = 2 then set sMs to my timestampToMs(item 1 of parts) set eMs to my timestampToMs(item 2 of parts) set i to i + 1 set textLines to {} repeat while i ≤ (count of ps) set t to item i of ps as string if t is "" then exit repeat set end of textLines to t set i to i + 1 end repeat set AppleScript's text item delimiters to linefeed set body to textLines as text set AppleScript's text item delimiters to "" if sMs is not missing value and eMs is not missing value and eMs > sMs then set end of L to {s:sMs, e:eMs, txt:body} end if else set i to i + 1 end if else set i to i + 1 end if -- 次が空行なら1行飛ばす if i ≤ (count of ps) then if item i of ps is "" then set i to i + 1 end if end repeat return L end readCues on cueStartsArray(L) set A to {} repeat with c in L set end of A to (s of c) end repeat return A end cueStartsArray ------------------------------------------------------------ -- 小物ユーティリティ ------------------------------------------------------------ on fileMtime(p) try return (do shell script "stat -f %m " & quoted form of p) as integer on error return -1 end try end fileMtime on msToTimestamp(msVal) set totalMs to msVal as integer set totalSec to totalMs div 1000 set ms3 to totalMs mod 1000 set hh to totalSec div 3600 set rem to totalSec mod 3600 set mm to rem div 60 set ss to rem mod 60 return (my pad2(hh)) & ":" & (my pad2(mm)) & ":" & (my pad2(ss)) & "." & (my pad3(ms3)) end msToTimestamp on timestampToMs(ts) try set h to (text 1 thru 2 of ts) as integer set M to (text 4 thru 5 of ts) as integer set s to (text 7 thru 8 of ts) as integer set MS to (text 10 thru 12 of ts) as integer return ((h * 3600 + M * 60 + s) * 1000 + MS) on error return missing value end try end timestampToMs on splitText(theText, delim) set AppleScript's text item delimiters to delim set xs to text items of theText set AppleScript's text item delimiters to "" return xs end splitText on isNumericLine(t) try set _ to (t as integer) return true on error return false end try end isNumericLine on pad2(n) set s to n as string if (count of s) = 1 then return "0" & s return s end pad2 on pad3(n) set s to n as string if (count of s) = 1 then return "00" & s if (count of s) = 2 then return "0" & s return s end pad3 ------------------------------------------------------------ -- 任意型を POSIX パス文字列に変換 ------------------------------------------------------------ on coerceToPath(x) if x is missing value then error number -1700 considering case if (class of x) is text then return x if (class of x) is alias then return POSIX path of x end considering try set u to (current application's |NSURL|'s URLWithString:(x as text)) if u is not missing value then return (u's |path|()) as text end try try return POSIX path of x on error return (x as text) end try end coerceToPath on invalidateTimer() try if theTimer is not missing value then theTimer's invalidate() set theTimer to missing value end if end try end invalidateTimer on quit -- タイマー停止&ウィンドウを引っ込める my invalidateTimer() try if thePanel is not missing value then thePanel's orderOut:me end try -- Cocoa 側にも終了を伝える(保険) try (current application's NSApp)'s terminate:me end try continue quit end quit on applicationShouldTerminate:sender -- Quit の前に必ずタイマーを止める my invalidateTimer() return (current application's NSTerminateNow) end applicationShouldTerminate: ◆Automator スクリプト -- ===== Quick Action 用ラッパ ===== -- Finder からの選択を受け取り: -- 1) .vtt が選ばれていればそのまま追加モード -- 2) フォルダが選ばれていれば、そのフォルダ内に新規作成を提案 -- 3) 何も選択が無ければ、従来のメニュー(mainMenu)を表示 -- ★★★ HUD Box 設定(フルパス & 予備:バンドルID)★★★ property __hudAppPath : "/Applications/自作appleスクリプト/VTT HUD Box.app" -- ←あなたの実パスに変更 property __hudBundleId : "com.example.vtt-hud-box" -- ←実バンドルID(分からなければ "" でも可) property __hudPoll : "0.06" -- 使わない場合は放置 on run {input, parameters} try set vttList to {} set folderList to {} -- Finder 選択の仕分け 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) -- ←終了時に HUD も閉じる end if return input else -- 選択が無い/対象外 → 従来のメニューで「新規作成 or 既存に追加」 repeat set btn to my mainMenu() if btn is "終了" then exit repeat end repeat -- 念のため最終的にも 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) それでも残っていれば、同一ユーザセッションで TERM / KILL try do shell script "/usr/bin/pkill -x " & quoted form of appName & " || true" end try delay 0.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の動画に合わせて、字幕ファイル(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 -- QuickTimeから現在位置 set canProceed to true set curSec to 0 tell application "QuickTime Player" if not (it is running) then set canProceed to false else if (count of documents) = 0 then set canProceed to false else set theDoc to front document if (playing of theDoc) is true then pause theDoc set curSec to current time of theDoc end if end tell if canProceed is false then my alertFront("QuickTime Playerが起動していないか、動画が開かれていません。") else -- 表示を閉じる(可能なら) my tryCloseIfOpen(vttPath) -- 新規キューの時刻 set startMs to round (curSec * 1000) set endMs to startMs + (durReal * 1000) -- 重なり検出(既存から) set ranges to my parseVttRanges(vttPath) set ovCheck to my checkOverlap(startMs, endMs, ranges) set isOverlap to item 1 of ovCheck set nextBoundaryMs to item 2 of ovCheck if isOverlap then set msg to "秒が長過ぎます(既存と重なります)。" & return & return & "開始: " & my prettyMMSSmmm(startMs) & return & "終了(要求): " & my prettyMMSSmmm(endMs) if nextBoundaryMs > startMs then set msg to msg & return & "重ならない上限: " & my prettyMMSSmmm(nextBoundaryMs) & " まで" & return & return & "どうしますか?" activate set d3 to display dialog msg buttons {"中止", "重ねて追記", "自動調整"} default button "自動調整" with icon caution set sel to button returned of d3 if sel is "中止" then do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() else if sel is "自動調整" then set endMs to nextBoundaryMs if endMs ≤ startMs then my alertFront("重ならない長さが確保できませんでした。追加を中止します。") do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() else my insertSortAndRenumber(vttPath, startMs, endMs, cueText) do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() end if else -- 重ねて追記(そのまま) my insertSortAndRenumber(vttPath, startMs, endMs, cueText) do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() end if end if else activate set d4 to display dialog (msg & return & "(開始位置が既存表示の内部です)" & return & return & "どうしますか?") buttons {"中止", "重ねて追記"} default button "中止" with icon caution if button returned of d4 is "中止" then do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() else my insertSortAndRenumber(vttPath, startMs, endMs, cueText) do shell script "open -ga TextEdit -g " & quoted form of vttPath delay 0.15 my goToBottomTextEdit() end if end if else -- 重ならないので普通に差し込み→ソート→番号付け my insertSortAndRenumber(vttPath, startMs, endMs, cueText) 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 insertSortAndRenumber(vttPath, sMs, eMs, cueText) -- 1) 既存を読み込み(改行正規化) set cues to my readCues(vttPath) -- list of {s:ms, e:ms, txt:text} -- 2) 新規を追加 set end of cues to {s:sMs, e:eMs, txt:cueText} -- 3) 開始時刻でソート set cues to my sortCueRecordsByStart(cues) -- 4) 行番号を振って全体を書き戻し my writeCuesWithNumbers(vttPath, cues) end insertSortAndRenumber -- ====== 既存VTTを cue 配列に読む ====== on readCues(vttPath) set cues to {} set qV to quoted form of vttPath -- 改行をLFへ正規化して読込 try set raw to do shell script "perl -0777 -pe 's/\\r\\n?|\\n/\\n/g' " & qV on error set raw to "" end try set ps to paragraphs of raw set i to 1 -- ヘッダ "WEBVTT" をスキップ if (count of ps) > 0 then if item 1 of ps is "WEBVTT" then set i to 2 end if -- 先頭の空行をスキップ repeat while i ≤ (count of ps) and (item i of ps as string) is "" set i to i + 1 end repeat repeat while i ≤ (count of ps) set lineText to item i of ps as string -- 番号行は無視(整数のみの行) if my isNumericLine(lineText) then set i to i + 1 else if lineText contains " --> " then -- タイムコード行 set parts to my splitText(lineText, " --> ") if (count of parts) = 2 then set a to item 1 of parts set b to item 2 of parts set sMs to my timestampToMs(a) set eMs to my timestampToMs(b) -- 次の空行までを本文として集める(複数行対応) set i to i + 1 set textLines to {} repeat while i ≤ (count of ps) set ln to item i of ps as string if ln is "" then exit repeat set end of textLines to ln set i to i + 1 end repeat set AppleScript's text item delimiters to linefeed set txt to textLines as text set AppleScript's text item delimiters to "" if sMs is not missing value and eMs is not missing value and eMs > sMs then set end of cues to {s:sMs, e:eMs, txt:txt} end if else set i to i + 1 end if else set i to i + 1 end if -- 次が空行なら1行飛ばす if i ≤ (count of ps) then if item i of ps is "" then set i to i + 1 end if end repeat return cues end readCues on isNumericLine(t) try set _ to (t as integer) return true on error return false end try end isNumericLine on sortCueRecordsByStart(cues) set L to cues set swapped to true repeat while swapped set swapped to false repeat with i from 1 to (count of L) - 1 set c1 to item i of L set c2 to item (i + 1) of L if (s of c1) > (s of c2) then set item i of L to c2 set item (i + 1) of L to c1 set swapped to true end if end repeat end repeat return L end sortCueRecordsByStart on writeCuesWithNumbers(vttPath, cues) set buf to "WEBVTT" & linefeed & linefeed set idx to 1 repeat with c in cues set numLine to (idx as string) & linefeed set timeLine to my msToTimestamp(s of c) & " --> " & my msToTimestamp(e of c) & linefeed set txtLine to (txt of c) & linefeed & linefeed set buf to buf & numLine & timeLine & txtLine set idx to idx + 1 end repeat set f to (POSIX file vttPath) set fh to open for access f with write permission try set eof of fh to 0 write buf to fh as «class utf8» on error errMsg number errNum close access fh error errMsg number errNum end try close access fh end writeCuesWithNumbers -- ===== 既存VTTから範囲だけ取る(重なり検出用) ===== on parseVttRanges(vttPath) set ranges to {} try set raw to do shell script "cat " & quoted form of vttPath on error return ranges end try repeat with seg in paragraphs of raw set t to seg as string if (t contains " --> ") then try set parts to my splitText(t, " --> ") if (count of parts) = 2 then set a to item 1 of parts set b to item 2 of parts set sMs to my timestampToMs(a) set eMs to my timestampToMs(b) if (sMs is not missing value) and (eMs is not missing value) and (eMs > sMs) then set end of ranges to {s:sMs, e:eMs} end if end try end if end repeat return my sortRangesByStart(ranges) end parseVttRanges on checkOverlap(sNew, eNew, ranges) set overlap to false set nextCap to sNew repeat with r in ranges set sOld to (s of r) set eOld to (e of r) if not (eNew ≤ sOld or sNew ≥ eOld) then set overlap to true if sOld > sNew then if (nextCap = sNew) or (sOld < nextCap) then set nextCap to sOld end if end if end repeat return {overlap, nextCap} end checkOverlap on sortRangesByStart(aList) set L to aList set swapped to true repeat while swapped set swapped to false repeat with i from 1 to (count of L) - 1 set r1 to item i of L set r2 to item (i + 1) of L if (s of r1) > (s of r2) then set item i of L to r2 set item (i + 1) of L to r1 set swapped to true end if end repeat end repeat return L end sortRangesByStart -- ===== ユーティリティ ===== on msToTimestamp(msVal) set totalMs to msVal as integer set totalSec to totalMs div 1000 set ms3 to totalMs mod 1000 set hh to totalSec div 3600 set rem to totalSec mod 3600 set mm to rem div 60 set ss to rem mod 60 return my pad2(hh) & ":" & my pad2(mm) & ":" & my pad2(ss) & "." & my pad3(ms3) end msToTimestamp on timestampToMs(ts) try set H to (text 1 thru 2 of ts) as integer set M to (text 4 thru 5 of ts) as integer set s to (text 7 thru 8 of ts) as integer set MS to (text 10 thru 12 of ts) as integer return ((H * 3600 + M * 60 + s) * 1000 + MS) on error return missing value end try end timestampToMs on splitText(theText, delim) set AppleScript's text item delimiters to delim set xs to text items of theText set AppleScript's text item delimiters to "" return xs end splitText on pad2(n) set s to n as string if (count of s) = 1 then return "0" & s return s end pad2 on pad3(n) set s to n as string if (count of s) = 1 then return "00" & s if (count of s) = 2 then return "0" & s return s end pad3 on prettyMMSS(msVal) set totalSec to (msVal div 1000) set mm to totalSec div 60 set ss to totalSec mod 60 return (mm as string) & "分" & (ss as string) & "秒" end prettyMMSS on prettyMMSSmmm(msVal) set totalSec to (msVal div 1000) set mm to totalSec div 60 set ss to totalSec mod 60 set ms3 to msVal mod 1000 return (mm as string) & "分" & (ss as string) & "秒" & my pad3(ms3) end prettyMMSSmmm on alertFront(msg) activate display dialog msg buttons {"OK"} default button 1 with icon caution end alertFront -- 表示中なら閉じる(TextEdit / CotEditor / BBEdit) on tryCloseIfOpen(vttPath) try set fname to do shell script "basename " & quoted form of vttPath tell application "System Events" set procNames to name of processes end tell -- TextEdit if procNames contains "TextEdit" then tell application "TextEdit" repeat with d in documents try if (name of d) = fname then close d saving no end try end repeat end tell end if -- CotEditor(★ 修正:Automator ではなく CotEditor を正しくターゲット) if procNames contains "CotEditor" then tell application "Automator" repeat with d in documents try if (name of d) = fname then close d saving no end try end repeat end tell end if -- BBEdit(未インストールでもダイアログを出さない) if procNames contains "BBEdit" then set scpt to "tell application \"BBEdit\" repeat with d in documents try if (name of d) = " & quoted form of fname & " then close d saving no end try end repeat end tell" run script scpt end if end try end tryCloseIfOpen -- ===== 最下行表示(ソフト前面化+復元)===== on goToBottomTextEdit() try tell application "System Events" -- いま前面のアプリを記録(後で戻す) set prevFront to missing value try set prevFront to name of first application process whose frontmost is true end try -- TextEdit を“静かに”前面へ(activateせず) if (name of processes) contains "TextEdit" then set frontmost of process "TextEdit" to true delay 0.05 -- Cmd-↓ で最下行へ(見切れ対策で↓を一押し) key code 125 using {command down} delay 0.02 key code 125 end if -- すぐに元の前面アプリへ戻す(チラつきを最小化) 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