O U T S T A N D I N G / AI 動画活用

コピペするだけ。
ショート動画メーカー Lv.1

下のテキストをコピーして、Claude Code に貼り付けてください。
環境が自動で作られ、今日1本出せます。

STEP 1下のボタンでコピー
STEP 2Claude Code に貼る
STEP 3動画を入れて実行
Claude Code に貼り付けるテキスト
🔒 偽物の見分け方:下の本文には curl iex irm | bash という文字は一切含まれていません。もし混ざっていたら改変された偽物です。実行せず、LINEで「ショート」と送って届く正規URLから取り直してください。
あなたはこれから、私のパソコンに「縦型ショート動画メーカー Lv.1」をセットアップします。
以下を上から順に、あなた自身が実行してください。私に作業を指示しないでください。

## 実行してよい範囲(最重要・厳守)

- 導入に使ってよいのは **winget / brew / pip と、この本文に書かれたコマンドだけ**
- **インターネットからスクリプトを取得して実行しない**(いかなる形でも)
- **環境変数の恒久変更をしない**。`setx` は絶対に使わない(ユーザーのPATHを破壊することがある)。
  PATHを通す必要があるときは、いま開いているセッション内だけの一時追加にすること
- 本文の候補パスに実体が無くても、**別の場所を探したり別のサイトから取得したりしない**。
  そこで止まって、状況を私に伝えること
- 管理者パスワードが必要な場面では、私が自分で入力する。あなたは聞き出さないこと

## 0. 前提をそろえる

次を順に確認し、足りないものだけ導入してください。
**確認は必ずコマンドを実行して行い、「入っているはず」で先に進まないこと。**

**Python 3.10以上**

- Windows: `py --version`。無ければ `winget install -e --id Python.Python.3.12`
- Mac: `python3 --version`。無ければ Homebrew の `brew install python`
- Mac で Homebrew 自体が無い場合:**あなたは導入しないこと。**
  「ブラウザで brew.sh を開いて、そこに書かれた1行を自分で実行してください」と私に案内し、
  完了の合図を待つこと
- 以降このプロンプト内の `py` は、Mac では `python3` に読み替えて実行してください

**ffmpeg**

- `ffmpeg -version` を実行。通ればOK
- 通らなければ Windows は `winget install -e --id Gyan.FFmpeg`、Mac は `brew install ffmpeg`
- ⚠️ **導入直後は、いま開いているシェルの PATH に反映されません。** もう一度 `ffmpeg -version` が
  通らなくても、そこで止まらないこと。
  Windows なら `%LOCALAPPDATA%\Microsoft\WinGet\Links\ffmpeg.exe`、Mac なら
  `/opt/homebrew/bin/ffmpeg` を直接実行して存在を確認してください。存在すればそれでOKです
  (下のスクリプトは同じ場所を自分で探すので、PATHを通さなくても動きます)。
  どうしてもこのセッションでPATHが必要なら、**一時追加のみ**(`setx` 禁止)

**faster-whisper**

- `py -c "import faster_whisper"` を実行。通らなければ `py -m pip install faster-whisper`
- ⚠️ 初回の実行時に、文字起こしモデル(約500MB)をインターネットから取得します。
  会社のネットワークで遮断される場合があります。ここで止まったら、その旨を私に伝えてください

## 1. 作業フォルダを作る

デスクトップに `shortvideo` フォルダを作り、その中に `input` と `output` を作ってください。

## 2. 次のスクリプトを `shortvideo/make_short.py` として保存する

**一字一句そのまま保存してください。要約・省略・自分なりの書き換えをしないこと。**
保存後、ファイルのバイト数が 12070 バイトと一致するか確認し、
ズレていたら書き直すこと。

```python
"""縦動画 -> 字幕焼き込み+前後カット済みショート.

usage: py make_short.py input/xxx.mp4 output/xxx.mp4   (Mac は python3)
"""
from __future__ import annotations
import json, re, shutil, subprocess, sys, tempfile
from pathlib import Path
from faster_whisper import WhisperModel

W, H = 1080, 1920
SAFE_W = 990            # 字幕がはみ出さない横幅(左右に余白を残す)
BASE_FS = 125           # 字幕の基準サイズ。長い行はここから自動で縮む
MAX_CHARS = 8           # 1行の目安文字数
HARD_MAX = 12           # 1行の絶対上限(SAFE_W と合わせて最小 82px を保証)
ROWS_Y = [1300, 1520, 1740]   # 字幕の縦位置(3行まで積む)
MIN_SCREEN = 1.1        # 1画面の最低表示秒(これ未満は読めない)
HEAD_PAD, TAIL_PAD = 0.15, 0.35
PARTICLES = ("は","が","を","に","で","と","も","へ","の","から","まで")
SAFE = ("もう","もし","もの","という","ところ","とき","では","でも","には","はい")
FONT = "Hiragino Sans W6" if sys.platform == "darwin" else "Yu Gothic UI Semibold"

STYLE = f"""[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
Style: Sub, {FONT}, {BASE_FS}, &H00FFFFFF, &H000000FF, &H00102040, &H1A000000, 1, 0, 0, 0, 100, 100, 3, 0, 1, 7, 3, 5, 30, 30, 0, 1
"""
LINE = ("Dialogue: 0,{s},{e},Sub,,0,0,0,,{{\\an5\\pos(540,{y})\\fs{fs}\\fad(90,60)"
        "\\fscx105\\fscy105\\t(0,200,\\fscx100\\fscy100)}}{t}")


def ts(x: float) -> str:
    x = max(x, 0.0)
    return f"{int(x//3600)}:{int((x%3600)//60):02d}:{x%60:05.2f}"


def ffbin(name: str) -> str:
    """ffmpeg/ffprobe の実体を探す。winget 直後で PATH が未反映でも動くように。"""
    p = shutil.which(name)
    if p:
        return p
    cands = [Path.home()/"AppData/Local/Microsoft/WinGet/Links"/f"{name}.exe",
             Path("C:/Program Files/ffmpeg/bin")/f"{name}.exe",
             Path("/opt/homebrew/bin")/name, Path("/usr/local/bin")/name]
    for c in cands:
        if c.exists():
            return str(c)
    sys.exit(f"{name} が見つかりません。導入後に一度ターミナルを開き直してください。")


FFMPEG, FFPROBE = ffbin("ffmpeg"), ffbin("ffprobe")


def cw(t: str) -> float:
    """表示幅(全角=1.0 / 半角=0.55)。"""
    return sum(0.55 if c.isascii() else 1.0 for c in t)


def fit_fs(t: str) -> int:
    """その行が SAFE_W に収まる文字サイズ。切れを物理的に防ぐ。
    下限で丸めると保証が壊れるので、ここでは絶対に切り上げない。"""
    return min(BASE_FS, int(SAFE_W / max(cw(t), 0.5)))


def probe_wh(src: Path):
    """回転メタデータ込みの表示サイズ。iPhone縦動画は 1920x1080+rotation で入る。"""
    r = subprocess.run(
        [FFPROBE,"-v","error","-select_streams","v:0","-show_streams","-of","json",str(src)],
        capture_output=True, text=True)
    if r.returncode != 0 or not r.stdout.strip():
        sys.exit("動画を読めませんでした。ファイル形式を確認してください。")
    st = json.loads(r.stdout)["streams"][0]
    w, h = int(st["width"]), int(st["height"])
    rot = 0
    for sd in st.get("side_data_list", []):
        rot = int(sd.get("rotation", rot))
    return (h, w) if abs(rot) % 180 == 90 else (w, h)


def transcribe(src: Path):
    # 一時WAVは毎回ユニークなフォルダに作る。固定名だと2本同時に処理したとき
    # 別の動画の音声を拾い、字幕が入れ替わる静かな事故になる(fugu指摘 P1-1)。
    tmpdir = Path(tempfile.mkdtemp(prefix="ms_"))
    wav = tmpdir / "a.wav"
    try:
        subprocess.run([FFMPEG,"-y","-v","error","-i",str(src),"-vn","-ac","1",
                        "-ar","16000","-c:a","pcm_s16le",str(wav)], check=True)
        model = WhisperModel("small", device="cpu", compute_type="int8")
        segs, _ = model.transcribe(str(wav), language="ja", word_timestamps=True)
        return [{"s": round(w.start,2), "e": round(w.end,2), "w": w.word.strip()}
                for sg in segs for w in (sg.words or []) if w.word.strip()]
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)


def ass_safe(t: str) -> str:
    """字幕テキストの { } \\ はASSの制御記法として解釈されるため無害化する。"""
    return t.replace("\\", "\").replace("{", "{").replace("}", "}")


def kanji(c): return "\u4e00" <= c <= "\u9fff"
def kata(c):  return "\u30a0" <= c <= "\u30ff"
def hira(c):  return "\u3040" <= c <= "\u309f"
PUNCT = "\u3002\u3001\uff01\uff1f!?,."


def score(t: str, k: int, words) -> int:
    """この位置で行を切ったときの良さ。語の途中で切らないための採点。"""
    s = -abs(len(t) - MAX_CHARS) * 3
    if t[-1] in PUNCT:
        s += 120                                 # 句読点の直後が最良の切り位置
    if len(t) < 3:
        s -= 60
    if k + 1 < len(words):
        gap = words[k+1]["s"] - words[k]["e"]
        s += 60 if gap >= 0.35 else (25 if gap >= 0.15 else 0)
        nx = words[k+1]["w"]
        if nx.startswith(PARTICLES) and not nx.startswith(SAFE):
            s -= 90                              # 次の行が助詞で始まる=約束違反
        elif hira(nx[0]) and t[-1] not in PUNCT:
            s -= 65                              # 送り仮名・活用の途中(作|りました 等)
        if kanji(t[-1]) and kanji(nx[0]):
            s -= 70                              # 漢字と漢字の間=熟語の途中の可能性
        if kata(t[-1]) and kata(nx[0]):
            s -= 70                              # カタカナ語の途中
        if kanji(nx[0]) or kata(nx[0]) or nx[0].isascii():
            s += 25                              # 次が内容語の頭=切ってよい位置
    return s


def group(words):
    """語を「読める単位」にまとめる。切る位置は候補を採点して選ぶ。"""
    rows, i, n = [], 0, len(words)
    while i < n:
        cands, text, j = [], "", i
        while j < n:
            nt = text + words[j]["w"]
            if text and len(nt) > HARD_MAX:
                break
            text, j = nt, j + 1
            cands.append((j-1, text))
            if j < n and words[j]["s"] - words[j-1]["e"] >= 0.60:
                break                            # 大きな間は必ずそこで切る
        if not cands:
            break
        k, t = max(cands, key=lambda c: score(c[1], c[0], words))
        rows.append({"t": t, "s": words[i]["s"], "e": words[k]["e"]})
        i = k + 1
    # 保険:それでも行頭が助詞なら前の行へ寄せる(長さは問わない。文字は自動で縮む)
    out = []
    for r in rows:
        if out and r["t"].startswith(PARTICLES) and not r["t"].startswith(SAFE) \
           and len(out[-1]["t"]) + len(r["t"]) <= HARD_MAX + 5:
            out[-1]["t"] += r["t"]
            out[-1]["e"] = r["e"]
        else:
            out.append(r)
    return out


def silences(src: Path, a: float, b: float):
    log = subprocess.run([FFMPEG,"-hide_banner","-ss",str(a),"-to",str(b),"-i",str(src),
                          "-af","silencedetect=n=-35dB:d=0.35","-f","null","-"],
                         capture_output=True, text=True, encoding="utf-8",
                         errors="replace").stderr
    ss = [float(x)+a for x in re.findall(r"silence_start:\s*([\d.]+)", log)]
    ee = [float(x)+a for x in re.findall(r"silence_end:\s*([\d.]+)", log)]
    return list(zip(ss, ee))


def main():
    if len(sys.argv) < 3:
        sys.exit("usage: make_short.py input/xxx.mp4 output/xxx.mp4")
    src, out = Path(sys.argv[1]), Path(sys.argv[2])
    if not src.exists():
        sys.exit(f"入力が見つかりません: {src}")
    out.parent.mkdir(parents=True, exist_ok=True)
    # 出力は -y で無警告上書きするため、入力と同じ場所を指すと元動画が消える(fugu指摘 P1-2)
    if out.resolve() == src.resolve():
        sys.exit("入力と出力に同じファイルを指定しないでください(元の動画が消えます)")
    if out.exists():
        sys.exit(f"出力先に既にファイルがあります: {out}\n別の名前にするか、先に移動してください。")
    print("1/4 文字起こし中(初回はモデルの取得で数分かかります)...")
    words = transcribe(src)
    if not words:
        sys.exit("音声が認識できませんでした。喋っている動画を入れてください。")
    rows = group(words)

    print("2/4 前後と間をカット中...")
    head, tail = max(words[0]["s"]-HEAD_PAD, 0), words[-1]["e"]+TAIL_PAD
    keeps, cur = [], head
    for a, b in silences(src, head, tail):
        if a <= head or b >= tail or b-a < 0.5:
            continue
        keeps.append((cur, a+0.08)); cur = b-0.08
    keeps.append((cur, tail))
    keeps = [(a,b) for a,b in keeps if b-a > 0.05]

    offs, acc = [], 0.0
    for a,b in keeps:
        offs.append(acc); acc += b-a
    def conv(t):
        for (a,b),o in zip(keeps,offs):
            if t < a: return o
            if t <= b: return o+(t-a)
        return acc

    print("3/4 字幕を作成中...")
    for r in rows:
        r["s"], r["e"] = conv(r["s"]), conv(r["e"])
    # 字幕ファイルは固定の安全名にする。出力名から作ると日本語+記号の名前で
    # ffmpegのフィルタ文字列(ass=...)が壊れる(fugu指摘 P1-3)。
    ass = out.parent / "_ms_subs.ass"
    body = [f"[Script Info]\nScriptType: v4.00+\nPlayResX: {W}\nPlayResY: {H}\n"
            f"ScaledBorderAndShadow: yes\nWrapStyle: 2\n\n{STYLE}\n[Events]\n"
            "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"]
    for i in range(0, len(rows), len(ROWS_Y)):
        scr = rows[i:i+len(ROWS_Y)]
        nxt = rows[i+len(ROWS_Y)]["s"] if i+len(ROWS_Y) < len(rows) else scr[-1]["e"]+0.8
        end = max(nxt, scr[0]["s"]+MIN_SCREEN)
        for k, r in enumerate(scr):
            body.append(LINE.format(s=ts(r["s"]), e=ts(end), y=ROWS_Y[k],
                                    fs=fit_fs(r["t"]), t=ass_safe(r["t"])))
    ass.write_text("\n".join(body)+"\n", encoding="utf-8")

    print("4/4 書き出し中...")
    sw, sh = probe_wh(src)
    if not sw or not sh:
        sys.exit("動画の縦横サイズを取得できませんでした。別の動画で試してください。")
    if len(keeps) > 400:
        # 無音だらけの長尺だと -vf が肥大してWindowsのコマンド長上限で失敗する(fugu指摘 P2-3)
        sys.exit("この動画は長すぎます。数分以内の動画で試してください。")
    if sw/sh > 1.2:   # 横長は切らずに上へ置く(切ると文字が読めなくなる)
        vh = int(W*sh/sw)
        vf = f"scale={W}:{vh},pad={W}:{H}:0:{int(H*0.16)}:black,setsar=1"
    else:
        vf = f"scale={W}:{H}:force_original_aspect_ratio=increase,crop={W}:{H},setsar=1"
    sel = "+".join(f"between(t,{a:.3f},{b:.3f})" for a,b in keeps)
    try:
        subprocess.run([FFMPEG,"-y","-v","error","-i",str(src.resolve()),
            "-map","0:v:0","-map","0:a:0",
            "-vf", f"select='{sel}',setpts=N/FRAME_RATE/TB,{vf},ass={ass.name}",
            "-af", f"aselect='{sel}',asetpts=N/SR/TB",
            "-c:v","libx264","-preset","medium","-crf","20","-pix_fmt","yuv420p",
            "-c:a","aac","-b:a","192k", out.name], check=True, cwd=out.parent)
    except subprocess.CalledProcessError:
        sys.exit("動画の書き出しに失敗しました。画面の最後の5行を私(Claude)に伝えてください。")
    finally:
        ass.unlink(missing_ok=True)
    print(f"\n完成: {out}  ({acc:.1f}秒 / 元より{(words[-1]['e']-words[0]['s'])-acc:+.1f}秒)")


if __name__ == "__main__":
    main()
```

## 3. 動作確認をする

`shortvideo/input` に動画が入っていれば1本処理してください。

入っていなければ、次のように私に伝えて待ってください。

> 準備ができました。スマホで撮った縦向きの動画を1本、`shortvideo/input` に入れて「やって」と言ってください。

実行の前に、必ずこう伝えること:
「『1/4 文字起こし中』のまま数分止まって見えることがありますが、正常です。5分は待ってください」

私が動画を入れたら、`py make_short.py input/(ファイル名) output/short.mp4` を実行し、
`output` に動画ができたことをファイルの存在と再生時間で確認してください。

途中で赤いエラーが出たら、原因を自分で調べて直してよいのは**このフォルダの中だけ**。
直せないときは「画面の最後の5行をそのまま見せてください」と私に頼むこと。

## 4. 使い方を私に説明する

最後に、次の3点だけを日本語で簡潔に伝えてください。

1. 動画を `input` に入れる
2. 実行するコマンド1行
3. 完成品が `output` に出ること

長い解説は不要です。3行で終わらせてください。

※ Claude Code デスクトップ版で、新しいチャットに貼り付けてください。あとは Claude が環境構築から使い方の説明までやります。
※ 貼り付けると準備が始まります。途中で Python や ffmpeg の導入について確認を求められたら「はい」と答えてください(会社のパソコンで管理者権限が無い場合は、そこで止まることがあります)。
※ 初回のみ、文字起こしモデル(約500MB)の取得に数分かかります。
※ 動作確認は Windows で行っています。Mac でも動く作りにしていますが、うまくいかない場合はご連絡ください。

これで何ができるか

うまく撮るための5つ

Lv.2 でできること

長い録画から、伸びる区間だけを自動で抜き出す(1本の講義から複数本)/ 冒頭フックを14の型から選んでタイトル生成/強調テロップの自動配置/ 話の間を詰めるジャンプカット/固有名詞の自動修正/出力の自動検証。

10分の録画に対して、AIは16本を提案してきました。私は3本に絞りました。
何を見て13本を捨てたのか——その判断基準とスキル一式は、OUTSTANDING®経営塾でお渡ししています。