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 に貼り付けるテキスト
あなたはこれから、私のパソコンに「縦型ショート動画メーカー Lv.1」をセットアップします。
以下を上から順に、あなた自身が実行してください。私に作業を指示しないでください。

## 0. 前提の確認

まず次を確認し、足りないものは自動で導入してください(管理者権限が必要な操作だけ、私に確認してください)。

- `py --version` または `python --version` が通ること(Python 3.10以上)
- `ffmpeg -version` が通ること
  - 通らなければ Windows は `winget install Gyan.FFmpeg`、macOS は `brew install ffmpeg` で導入
- `py -c "import faster_whisper"` が通ること
  - 通らなければ `py -m pip install faster-whisper` で導入

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

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

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

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

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

W, H = 1080, 1920
MAX_CHARS = 10          # 1行の最大文字数
ROWS_Y = [1300, 1520, 1740]   # 字幕の縦位置(3行まで積む)
MIN_SCREEN = 1.1        # 1画面の最低表示秒(これ未満は読めない)
HEAD_PAD, TAIL_PAD = 0.15, 0.35
PARTICLES = ("は","が","を","に","で","と","も","へ","の","から","まで")
SAFE = ("もう","もし","もの","という","ところ","とき","では","でも","には","はい")

STYLE = """[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, Yu Gothic UI Semibold, 190, &H00FFFFFF, &H000000FF, &H00102040, &H1A000000, 1, 0, 0, 0, 100, 100, 4, 0, 1, 9, 4, 5, 40, 40, 0, 1
"""
LINE = ("Dialogue: 0,{s},{e},Sub,,0,0,0,,{{\\an5\\pos(540,{y})\\fad(90,60)"
        "\\fscx130\\fscy130\\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 probe_wh(src: Path):
    """回転メタデータ込みの表示サイズ。iPhone縦動画は 1920x1080+rotation で入る。"""
    st = json.loads(subprocess.run(
        ["ffprobe","-v","error","-select_streams","v:0","-show_streams","-of","json",str(src)],
        capture_output=True, text=True).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 = Path(tempfile.gettempdir()) / "_ms.wav"
    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)
    words = [{"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()]
    wav.unlink(missing_ok=True)
    return words


def group(words):
    """語を「読める単位」にまとめる。助詞で行を始めない。"""
    rows, cur, st = [], "", None
    for i, w in enumerate(words):
        gap = w["s"] - words[i-1]["e"] if i else 0
        nxt = cur + w["w"]
        if cur and (gap >= 0.35 or len(nxt) > MAX_CHARS):
            rows.append({"t": cur, "s": st, "e": words[i-1]["e"]})
            cur, st = "", None
            nxt = w["w"]
        if st is None:
            st = w["s"]
        cur = nxt
    if cur:
        rows.append({"t": cur, "s": st, "e": words[-1]["e"]})
    # 行頭が助詞なら前の行にくっつける(「AI」「は聞く相手」を防ぐ)
    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"]) <= MAX_CHARS + 4:
            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():
    src, out = Path(sys.argv[1]), Path(sys.argv[2])
    out.parent.mkdir(parents=True, exist_ok=True)
    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"])
    ass = out.with_suffix(".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], t=r["t"]))
    ass.write_text("\n".join(body)+"\n", encoding="utf-8")

    print("4/4 書き出し中...")
    sw, sh = probe_wh(src)
    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)
    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)
    print(f"\n完成: {out}  ({acc:.1f}秒 / 元より{(words[-1]['e']-words[0]['s'])-acc:+.1f}秒)")


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

## 3. 動作確認をする

`shortvideo/input` に動画が入っていれば1本処理してください。
入っていなければ「input フォルダに縦向きの動画を入れてください」と私に伝えてください。

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

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

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

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

※ Claude Code デスクトップ版で、新しいチャットに貼り付けてください。あとは Claude が環境構築から使い方の説明までやります。

これで何ができるか

うまく撮るための5つ

Lv.2 でできること

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

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