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

AIとCapCutでつくる15秒縦型動画──企画から書き出し検証まで、迷わない実務設計

    主資料:ユーザー提供PDF「X AI Video Production Guide」(全11ページ)。本文中の資料p.XはPDF実ページです。PDF内の数値・式・仕様は一次資料と照合し、未確認の推薦閾値や再圧縮条件は採用していません。

    短い縦型動画は、短いから簡単なのではありません。伝えられる時間が少ないぶん、何を見せ、何を捨て、どこで理解してもらうかを先に決める必要があります。AIで映像素材をつくり、CapCutで整えれば制作速度は上げられます。しかし、道具をつないだだけでは、視聴者に伝わる一本にはなりません。企画、素材生成、編集、確認を一つの工程として設計することが大切です。

    この記事では、架空の業務改善サービスを題材にします。相談を受け、現状を整理し、改善案を提示する流れを15秒で説明する動画です。実在企業の成果や架空の数値は置かず、初めて担当する人でも判断を再現できるように、作業の順番と確認方法を具体化します。資料が扱うフック、縦型構図、AI生成、背景ぼかし、人物の背後に文字を置く表現、キーフレーム、書き出し、効果測定という論点を、一本の制作工程に並べ直します。

    1.最初に決めるのは演出ではなく、動画の役割

    制作を始める前に、動画の目的を一文で書きます。今回なら「複雑に見える業務改善の流れを、相談前の人が15秒で理解できる状態にする」です。「認知を取る」「バズらせる」のような広い言葉だけでは、映像の良し悪しを判断できません。誰が、見終わったあとに、何を理解し、どんな次の行動を取れるかまで落とし込みます。

    次に、制作KPIと配信KPIを分けます。制作KPIは、15秒以内に三つの工程が読める、無音でも要点が分かる、ロゴやCTAが欠けない、といった納品前に確認できる項目です。配信KPIは、視聴開始、一定地点までの視聴、プロフィール遷移、問い合わせなど、公開後に観測する項目です。プラットフォームの推薦ロジックを断定するのではなく、自分たちが取得できる指標で比較します。資料は短尺動画で冒頭の注意獲得と視聴維持を重視していますが(資料p.2、p.6)、特定の数値を万能な合格線として扱うのは避けます。

    工程は「目的→台本→絵コンテ→生成→編集→検査→配信→学習」の八つに分けます。ここで重要なのは、AI生成を起点にしないことです。先に生成すると、魅力的だが役割のない素材が増え、後から説明を合わせる作業になります。台本と絵コンテを固定してから必要なショットだけをつくるほうが、修正理由を説明しやすくなります。

    画像

    図解01は、目的から計測までを一周させる制作改善ループです。コード01は、目的、対象者、KPI、工程、権利確認を含む制作ブリーフをJSONとして保存します。

    制作ブリーフをJSON化

    前提: Python 3(標準ライブラリのみ)。実行: python -X utf8 01_production_brief.py --output brief.json。入力: CLI引数。出力: KPI、工程、権利確認を含むJSON。既存出力は--forceなしでは上書きしません。

    import argparse
    import json
    from pathlib import Path
    
    
    def main():
        parser = argparse.ArgumentParser(description="縦型動画の制作ブリーフをJSONで作成")
        parser.add_argument("--output", default="production-brief.json")
        parser.add_argument("--title", default="15秒で伝える新サービス紹介")
        parser.add_argument("--audience", default="サービスを初めて知る人")
        parser.add_argument("--goal", default="プロフィール訪問")
        parser.add_argument("--kpi", default="視聴完了率")
        parser.add_argument("--force", action="store_true", help="既存の出力を上書き")
        args = parser.parse_args()
    
        output = Path(args.output)
        if output.exists() and not args.force:
            raise SystemExit(f"出力済みです: {output}(上書きは --force)")
        brief = {
            "title": args.title,
            "audience": args.audience,
            "goal": args.goal,
            "primary_kpi": args.kpi,
            "duration_seconds": 15,
            "aspect_ratio": "9:16",
            "pipeline": ["企画", "絵コンテ", "素材生成・撮影", "編集", "QC", "投稿", "振り返り"],
            "approval_checks": ["人物・ロゴの利用許諾", "音源ライセンス", "事実と表記", "納品仕様"],
            "note": "数値目標は案件の実績を見て別途設定する",
        }
        output.write_text(json.dumps(brief, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(f"created: {output.resolve()}")
    
    
    if __name__ == "__main__":
        main()

    2.15秒を「秒数」ではなく「理解の段階」で割る

    15秒の台本は、文章を短くする作業ではなく、理解の順番を設計する作業です。今回の例では、0〜3秒をhookとして注意を向けてもらい、3〜6秒のproblemで困りごとを具体化し、6〜12秒のsolutionで整理の方法と得られる見通しを示し、12〜15秒のactionで次の行動を伝えます。この時間配分は公式仕様ではなく、検討を始めるための一例です。素材や話速に合わせて変えて構いません。

    冒頭は大きな主張より、対象者が自分事だと分かる状況を置きます。「業務が複雑すぎる?」だけでは抽象的です。机の上に付箋と表計算の印刷物が散らばり、担当者が同じ情報を転記している。その映像に「同じ数字、何回入力していますか」と重ねると、課題の輪郭が見えます。驚かせるための違和感ではなく、現場で起きる摩擦を一瞬で示すわけです。

    絵コンテには、秒数、画面、テロップ、音声、効果音、素材の入手方法、確認事項を並べます。ここで「AI生成」とだけ書かず、「縦構図の机上ショット」「人物の手元は画面中央」「後で文字を置く左上を空ける」まで指定します。資料はフックから本題、CTAへ進む構成を扱っています(資料p.2)。本稿ではさらに、各カットが次のカットへ渡す情報を明記し、唐突な切り替えを防ぎます。

    画像

    図解02は、15秒を「0〜3秒 hook/3〜6秒 problem/6〜12秒 solution/12〜15秒 action」の四段階に分けた絵コンテです。コード02は、同じ四区分を基準にカット表の開始・終了時刻を読み、空白、重複、15秒超過を検出する検査に使います。

    15秒絵コンテの尺を検証

    前提: Python 3。実行: python -X utf8 02_validate_timeline.py。入力: 任意のショット配列JSON。出力: 合計尺、先頭0秒、隙間・重複、妥当性。デモは0〜3秒hook、3〜6秒problem、6〜12秒solution、12〜15秒actionです。空配列、非有限または負の目標値・許容差を拒否します。

    import argparse
    import json
    import math
    from pathlib import Path
    
    
    DEMO = [
        {"name": "hook", "start": 0.0, "end": 3.0},
        {"name": "problem", "start": 3.0, "end": 6.0},
        {"name": "solution", "start": 6.0, "end": 12.0},
        {"name": "action", "start": 12.0, "end": 15.0},
    ]
    
    
    def validate(shots, target, tolerance):
        errors = []
        ordered = sorted(shots, key=lambda x: x["start"])
        if not ordered:
            return ["ショットが空です"], 0
        if abs(ordered[0]["start"]) > tolerance:
            errors.append(f"先頭が0秒ではありません: {ordered[0]['start']:.3f} 秒")
        for index, shot in enumerate(ordered):
            if not all(math.isfinite(float(shot[key])) for key in ("start", "end")):
                errors.append(f"有限でない時刻: {shot['name']}")
                continue
            if shot["start"] < 0 or shot["end"] <= shot["start"]:
                errors.append(f"不正な区間: {shot['name']}")
            if index and abs(shot["start"] - ordered[index - 1]["end"]) > tolerance:
                errors.append(f"隙間または重複: {ordered[index - 1]['name']} → {shot['name']}")
        end = max((shot["end"] for shot in ordered), default=0)
        if abs(end - target) > tolerance:
            errors.append(f"終了時刻 {end:.3f} 秒(目標 {target:.3f} 秒)")
        return errors, end
    
    
    def main():
        parser = argparse.ArgumentParser(description="絵コンテの時間軸を検証")
        parser.add_argument("--input", help="shot配列を持つJSON。省略時はデモ")
        parser.add_argument("--target", type=float, default=15.0)
        parser.add_argument("--tolerance", type=float, default=0.001)
        args = parser.parse_args()
        if not math.isfinite(args.target) or args.target < 0:
            raise SystemExit("targetは有限の0以上にしてください")
        if not math.isfinite(args.tolerance) or args.tolerance < 0:
            raise SystemExit("toleranceは有限の0以上にしてください")
        shots = json.loads(Path(args.input).read_text(encoding="utf-8")) if args.input else DEMO
        errors, end = validate(shots, args.target, args.tolerance)
        print(json.dumps({"valid": not errors, "end_seconds": end, "errors": errors}, ensure_ascii=False, indent=2))
        raise SystemExit(1 if errors else 0)
    
    
    if __name__ == "__main__":
        main()

    3.縦型レイアウトは「置ける場所」から逆算する

    縦型画面は面積が狭いうえ、投稿先のUIが上下左右に重なります。だから、画面の端まで情報を詰めず、重要な文字と顔を中央寄りに置く設計が必要です。ただし、安全領域の具体的な幅は投稿先、端末、表示状態で変わります。上下左右の余白を何ピクセルと固定して公式仕様のように扱わず、想定する投稿画面でプレビューして決めます。

    作業用には三つの層を考えます。第一は絶対に欠けてはいけない「重要情報領域」、第二は背景や補助図形を置く「演出領域」、第三はUIと重なる可能性を許容する「余白領域」です。今回なら、人物の顔、三工程の見出し、CTAは重要情報領域へ置きます。ぼかした背景や光の粒は演出領域まで広げてもよいでしょう。細かな注釈を画面下端へ置くと読めないため、必要なら別カットに分けます。

    文字サイズは、編集画面の拡大表示で判断しません。完成予定のサイズで再生し、少し離れて読めるかを確認します。長文を小さくするより、語句を削るほうが効果的です。たとえば「現状業務の複雑なプロセスを可視化します」を「流れを見える化」に縮め、詳しい意味は音声へ任せます。色はブランドカラーを使いつつ、背景との明暗差を優先します。

    資料は縦型フレーム内での情報配置と、無音視聴を考慮したテロップの重要性に触れています(資料p.2〜3)。実務では、静止画としての美しさだけでなく、カットが変わる途中でも文字が読めるかを見ます。冒頭、中間、末尾のスクリーンショットを取り、UIを重ねた状態で確認すると、見落としを減らせます。

    画像

    図解03は、縦画面を重要情報・演出・余白の三領域に分け、人物とCTAの配置例を示します。数値はあくまで作業例と明示します。コード03は、指定した動画サイズに対して仮の安全領域を描画した確認用画像を生成します。

    縦型セーフゾーンSVGを生成

    前提: Python 3。実行: python -X utf8 03_make_safe_zone_svg.py --output guide.svg。入力: キャンバス寸法と例示マージン。出力: 編集ソフトへ重ねられるSVG。マージンはX公式値ではありません。

    import argparse
    from pathlib import Path
    
    
    def main():
        parser = argparse.ArgumentParser(description="縦型動画用セーフゾーンSVGを生成")
        parser.add_argument("--output", default="vertical-safe-zone.svg")
        parser.add_argument("--width", type=int, default=1080)
        parser.add_argument("--height", type=int, default=1920)
        parser.add_argument("--top", type=int, default=180, help="例示マージン(px)")
        parser.add_argument("--right", type=int, default=180, help="例示マージン(px)")
        parser.add_argument("--bottom", type=int, default=320, help="例示マージン(px)")
        parser.add_argument("--left", type=int, default=100, help="例示マージン(px)")
        args = parser.parse_args()
        if min(args.width, args.height, args.top, args.right, args.bottom, args.left) < 0:
            raise SystemExit("寸法は0以上にしてください")
        safe_w = args.width - args.left - args.right
        safe_h = args.height - args.top - args.bottom
        if safe_w <= 0 or safe_h <= 0:
            raise SystemExit("マージンがキャンバスを超えています")
        svg = f'''<svg xmlns="http://www.w3.org/2000/svg" width="{args.width}" height="{args.height}" viewBox="0 0 {args.width} {args.height}">
      <rect width="100%" height="100%" fill="#161922"/>
      <rect x="{args.left}" y="{args.top}" width="{safe_w}" height="{safe_h}" fill="#42d392" fill-opacity=".14" stroke="#42d392" stroke-width="6" stroke-dasharray="20 12"/>
      <text x="{args.width / 2}" y="{args.top + 70}" text-anchor="middle" fill="white" font-family="sans-serif" font-size="40">SAFE ZONE(例)</text>
      <text x="{args.width / 2}" y="{args.height - 80}" text-anchor="middle" fill="#aab2c8" font-family="sans-serif" font-size="28">投稿画面で実機確認してください</text>
    </svg>'''
        output = Path(args.output)
        if output.exists():
            raise SystemExit(f"出力済みです: {output}")
        output.write_text(svg, encoding="utf-8")
        print(f"created: {output.resolve()} ({safe_w}x{safe_h} safe area)")
    
    
    if __name__ == "__main__":
        main()

    4.AI生成は、モデル名よりショットの制約を書く

    AI動画のプロンプトで最初に書くべきなのは、形容詞の山ではありません。何を映すか、誰が何をするか、カメラはどう動くか、何秒の素材として使うか、画面のどこを空けるかです。資料ではKling、Wan、Seedance、Grok Imagineなどの比較が示され(資料p.4)、プロンプトを構成する要素が整理されています(資料p.5)。ここでは最新機能や優劣を断定せず、どの生成手段でも使えるショット設計に絞ります。

    今回の冒頭素材なら、「縦型。小規模なオフィスの机。複数の付箋と帳票を前に、担当者が同じ数字を二つの表へ転記している。手元中心。カメラは固定。左上はテロップ用に空ける。照明は自然。過度な表情変化なし」と書きます。さらに避けたい要素として、「指や文字の崩れ、急なカメラ移動、物体の増減、ロゴ、判読可能な個人情報」を添えます。

    また、実在人物や顧客資料を外部の生成サービスへ入れる場合は、利用条件、権利、機密、社内承認を先に確認します。今回の例は架空データと一般的なオフィス表現だけを使います。素材生成の速さより、公開してよい入力かどうかの判断を優先します。

    画像

    図解04は、被写体、動作、場所、カメラ、光、制約で組む生成プロンプト設計です。コード04は、ショット定義JSONからモデル非依存のプロンプト文を組み立て、必須項目の欠落を検出します。

    ショット制約から生成プロンプトを構成

    前提: Python 3。実行: python -X utf8 04_compose_generation_prompt.py。入力: 任意のショットJSON。出力: 被写体・動作・カメラ・照明・尺・制約をまとめたプロンプト。デモのカメラ指定は「胸元から上、カメラ固定」に一本化しています。

    import argparse
    import json
    from pathlib import Path
    
    
    DEMO = {
        "subject": "白い机で試作品を手に取る人物",
        "action": "試作品をカメラへゆっくり近づける",
        "camera": "胸元から上、カメラ固定",
        "lighting": "柔らかい窓光、自然な肌色",
        "duration_seconds": 5,
        "constraints": ["縦9:16", "文字を生成しない", "指や製品形状を崩さない"],
    }
    
    
    def compose(data):
        required = ["subject", "action", "camera", "lighting", "duration_seconds"]
        missing = [key for key in required if key not in data]
        if missing:
            raise ValueError("不足項目: " + ", ".join(missing))
        constraints = "、".join(data.get("constraints", [])) or "なし"
        return (
            f"被写体: {data['subject']}\n動作: {data['action']}\n"
            f"カメラ: {data['camera']}\n照明: {data['lighting']}\n"
            f"尺: {data['duration_seconds']}秒\n制約: {constraints}\n"
            "一貫した被写体と自然な時間変化を保ち、1カットの動画として生成する。"
        )
    
    
    def main():
        parser = argparse.ArgumentParser(description="ショット設計JSONから生成用プロンプトを組み立てる")
        parser.add_argument("--input", help="ショットJSON。省略時はデモ")
        parser.add_argument("--output", help="指定時のみテキスト保存")
        args = parser.parse_args()
        data = json.loads(Path(args.input).read_text(encoding="utf-8")) if args.input else DEMO
        prompt = compose(data)
        if args.output:
            output = Path(args.output)
            if output.exists():
                raise SystemExit(f"出力済みです: {output}")
            output.write_text(prompt + "\n", encoding="utf-8")
            print(f"created: {output.resolve()}")
        else:
            print(prompt)
    
    
    if __name__ == "__main__":
        main()

    5.CapCutの背景ぼかしは、二層構造で考える

    資料p.2〜3の背景ぼかしは、同じ元映像を二つに分け、下の層をぼかした背景、上の層を背景削除した人物として重ねる表現です。人物は鮮明なまま、周囲だけを柔らかくして視線を被写体へ集めます。CapCutの機能名や配置、利用可否は版や環境で異なるため、ここでは層の構造を中心に説明します。

    タイムラインの下段に元映像を置いて背景全体をぼかし、上段には同じ元映像を複製して人物の背景を削除した前景を置きます。二層を重ねると、人物の輪郭と動きは保ちながら背景だけがぼけます。背景が鮮明すぎれば人物と競合し、ぼかしが強すぎれば色の塊がちらついて見えます。数値を固定せず、人物が自然に分離して見える最小限から調整します。

    同じ素材を二層にしたら、開始位置と終了位置をそろえます。片方だけをトリミングすると、背景と前景の動きがずれます。速度変更、静止、逆再生を使う場合も両層へ同じ処理が必要です。音声は一方だけを有効にし、二重再生を防ぎます。これらは見た目より先に確認する項目です。

    背景削除は万能ではありません。髪、指、透明物、動きぼけ、背景と似た色では前景の輪郭が欠けたりちらついたりします。激しいカメラ移動や点滅がある素材では、ぼかした背景の動きにも視線を奪われます。その場合はぼかしを弱めるか、単色や静かな図形背景へ替えます。

    横長素材の余白を埋めたい場合は、応用として下層だけを画面いっぱいに拡大してぼかし、上層に元の比率の映像を置けます。ただし、これは人物の背景削除とは別の使い方です。今回のサービス説明では人物が話すカットに前景分離の二層ぼかしを使い、中盤の工程説明は単色背景にします。

    参考:CapCut公式:背景ぼかし

    画像

    図解05は、同じ素材を人物前景とぼかし背景へ分ける二層合成の構造です。コード05は、各層の開始・終了フレームを記したCSVを読み、ぼかし背景と人物前景の同期ずれを検出します。

    背景ぼかしと前景の同期を検査

    前提: Python 3。実行: python -X utf8 05_check_layer_sync.py。入力: レイヤー名と開始・終了フレームのCSV。出力: 同期判定と不一致レイヤー。

    import argparse
    import csv
    import io
    import json
    from pathlib import Path
    
    
    DEMO = "layer,start_frame,end_frame\nbackground_blur,0,449\nsubject_cutout,0,449\n"
    
    
    def read_rows(path):
        text = Path(path).read_text(encoding="utf-8-sig") if path else DEMO
        return list(csv.DictReader(io.StringIO(text)))
    
    
    def main():
        parser = argparse.ArgumentParser(description="背景ぼかし層と前景層の同期を検査")
        parser.add_argument("--input", help="layer,start_frame,end_frame のCSV")
        parser.add_argument("--tolerance", type=int, default=0, help="許容フレーム差")
        args = parser.parse_args()
        if args.tolerance < 0:
            raise SystemExit("許容差は0以上にしてください")
        rows = read_rows(args.input)
        if len(rows) < 2:
            raise SystemExit("比較には2レイヤー以上必要です")
        parsed = []
        for row in rows:
            start, end = int(row["start_frame"]), int(row["end_frame"])
            if start < 0 or end < start:
                raise SystemExit(f"不正なフレーム範囲: {row['layer']}")
            parsed.append({"layer": row["layer"], "start": start, "end": end})
        base = parsed[0]
        mismatches = [
            item["layer"] for item in parsed[1:]
            if abs(item["start"] - base["start"]) > args.tolerance
            or abs(item["end"] - base["end"]) > args.tolerance
        ]
        print(json.dumps({"synced": not mismatches, "reference": base["layer"], "mismatches": mismatches}, ensure_ascii=False, indent=2))
        raise SystemExit(1 if mismatches else 0)
    
    
    if __name__ == "__main__":
        main()

    6.文字を人物の背後へ置くなら、三層を正しく重ねる

    人物の背後に文字があるように見せる表現は、画面に奥行きをつくれます。構造は単純で、下から「元映像」「文字」「切り抜いた人物」の三層です。資料でも、人物を分離してテキストを挟む方法が扱われています(資料p.3〜4)。難しいのはボタン操作より、三層の時間と位置を一致させることです。

    まず元映像を複製し、上のクリップから人物だけを切り抜きます。元映像と切り抜き人物の間に文字を置きます。人物の輪郭が安定していれば、文字の一部が体で隠れ、背後にあるように見えます。文字全体を隠すと読めないため、人物と交差するのは一部にします。今回なら「流れを」の文字を人物の左肩に少しかけ、「見える化」は肩の外へ出します。

    切り抜きには限界があります。髪、指、半透明の物、動きぼけ、背景と似た色は境界が崩れやすい場所です。輪郭がちらつくと、文字より切り抜きの粗さへ目が向きます。境界補正が使える場合も、強くかければ自然になるとは限りません。完成サイズで再生し、静止画ではなく動きの中で確認します。

    三層の同期も重要です。下の元映像だけ位置を変えると、上の人物と背景がずれます。切り抜き処理後に元素材を差し替えると、フレーム単位で対応しなくなる場合があります。先にカット点と速度を確定し、その後に複製・切り抜きを行うと安全です。修正時は三層をまとめて移動できる状態にしておきます。

    参考:CapCut公式:人物の背後へのテキスト配置

    画像

    図解06は、背景、文字、人物の重なり順でつくる奥行き表現です。コード06は、素材参照名、文字、表示区間から三層の設計書JSONを生成します。CapCutへ直接読み込む形式ではありません。

    人物背後テキストのレイヤー設計書を生成

    前提: Python 3。実行: python -X utf8 06_make_layer_manifest.py --output layers.json。入力: 素材参照名、文字、有限な時間範囲。出力: ローカルJSON。CapCutへ直接読み込むAPIや形式ではありません。

    import argparse
    import json
    import math
    from pathlib import Path
    
    
    def main():
        parser = argparse.ArgumentParser(description="文字を人物の背後へ置くための編集設計書を生成")
        parser.add_argument("--output", default="text-behind-layer-plan.json")
        parser.add_argument("--media", default="source.mp4", help="参照名。メディアは変更しません")
        parser.add_argument("--text", default="新しい視点")
        parser.add_argument("--start", type=float, default=1.0)
        parser.add_argument("--end", type=float, default=4.0)
        args = parser.parse_args()
        if not math.isfinite(args.start) or not math.isfinite(args.end):
            raise SystemExit("startとendは有限値にしてください")
        if args.start < 0 or args.end <= args.start:
            raise SystemExit("start >= 0 かつ end > start にしてください")
        output = Path(args.output)
        if output.exists():
            raise SystemExit(f"出力済みです: {output}")
        plan = {
            "format": "local-editing-plan-v1",
            "warning": "CapCutへ直接読み込む形式ではありません。手作業の配置確認用です。",
            "source_media_reference": args.media,
            "time_range_seconds": {"start": args.start, "end": args.end},
            "layers_bottom_to_top": [
                {"track": 1, "role": "background", "content": args.media},
                {"track": 2, "role": "title", "content": args.text},
                {"track": 3, "role": "subject_cutout", "content": args.media},
            ],
            "checks": ["上下クリップの同期", "輪郭の欠け", "文字の可読性", "第三者素材の権利"],
        }
        output.write_text(json.dumps(plan, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
        print(f"created: {output.resolve()}")
    
    
    if __name__ == "__main__":
        main()

    7.キーフレーム、マスク、追従は「基準点」を共有する

    キーフレームは、ある時点の位置、大きさ、回転、透明度などを記録し、その間を変化させる仕組みです。マスクは見せる範囲を限定し、追従は対象の動きに合わせて位置を更新します。資料はキーフレームやマスク追従を編集表現の要素として挙げています(資料p.3)。三つを同時に使うと便利ですが、基準がずれると修正が難しくなります。

    マスクを人物や物体に追従させる場合、最初に追う基準点を決めます。顔の中心、肩、商品の角など、映像内で見失いにくい点です。対象が画面外へ出る、手前の物で隠れる、急に向きが変わる場面では、自動追従が外れることがあります。処理後は、開始と終了だけでなく、途中を数か所確認します。ズレが見つかったら、その前後に補助キーフレームを置きます。

    確認はフレーム単位と通常速度の両方で行います。フレーム送りでは境界のズレを見つけやすく、通常速度ではちらつきや不自然な加速が分かります。さらに音声を有効にして、動きの着地と効果音や話の区切りが合っているかを見ます。映像だけ整えても、音と数フレームずれると雑な印象になります。

    失敗を減らすには、編集順を固定します。まず素材の採用範囲と速度を決め、次に層を同期し、その後にマスクや切り抜き、最後にキーフレームと音を合わせます。上流を変えるたびに下流の追従情報が影響を受けるためです。修正履歴には「どの素材の何秒地点を変えたか」を残し、原因不明のズレを減らします。

    参考:CapCut公式:キーフレーム

    画像

    図解07は、キーフレームの点と区間でマスク追従を検査する流れです。コード07は、正規化座標のキーフレームCSVを読み、フレームごとの矩形マスク座標を線形補間します。

    マスクの中間フレームを線形補間

    前提: Python 3。実行: python -X utf8 07_interpolate_mask_keyframes.py --output masks.csv。入力: 正規化座標のキーフレームCSV。出力: フレームごとの矩形マスクCSV。負のフレーム、幅・高さ0、画面外座標を拒否します。

    import argparse
    import csv
    import io
    from pathlib import Path
    
    
    DEMO = "frame,x,y,width,height\n0,0.30,0.20,0.25,0.30\n10,0.40,0.25,0.25,0.30\n"
    FIELDS = ["frame", "x", "y", "width", "height"]
    
    
    def load(path):
        text = Path(path).read_text(encoding="utf-8-sig") if path else DEMO
        rows = [{"frame": int(r["frame"]), **{k: float(r[k]) for k in FIELDS[1:]}} for r in csv.DictReader(io.StringIO(text))]
        if len(rows) < 2 or any(row["frame"] < 0 for row in rows) or any(rows[i]["frame"] >= rows[i + 1]["frame"] for i in range(len(rows) - 1)):
            raise ValueError("フレーム昇順のキーフレームを2件以上指定してください")
        for row in rows:
            if row["width"] <= 0 or row["height"] <= 0:
                raise ValueError(f"widthとheightは0より大きくしてください: frame {row['frame']}")
            if any(not 0 <= row[k] <= 1 for k in FIELDS[1:]) or row["x"] + row["width"] > 1 or row["y"] + row["height"] > 1:
                raise ValueError(f"0〜1の画面範囲を超えています: frame {row['frame']}")
        return rows
    
    
    def interpolate(a, b, frame):
        ratio = (frame - a["frame"]) / (b["frame"] - a["frame"])
        return {"frame": frame, **{k: a[k] + (b[k] - a[k]) * ratio for k in FIELDS[1:]}}
    
    
    def main():
        parser = argparse.ArgumentParser(description="矩形マスクのキーフレーム間を線形補間")
        parser.add_argument("--input", help="frame,x,y,width,height CSV")
        parser.add_argument("--output", default="mask-frames.csv")
        args = parser.parse_args()
        try:
            keys = load(args.input)
        except (ValueError, KeyError) as error:
            raise SystemExit(str(error)) from error
        output = Path(args.output)
        if output.exists():
            raise SystemExit(f"出力済みです: {output}")
        frames = []
        for index in range(len(keys) - 1):
            frames.extend(interpolate(keys[index], keys[index + 1], f) for f in range(keys[index]["frame"], keys[index + 1]["frame"]))
        frames.append(keys[-1])
        with output.open("w", encoding="utf-8", newline="") as handle:
            writer = csv.DictWriter(handle, fieldnames=FIELDS)
            writer.writeheader(); writer.writerows(frames)
        print(f"created: {output.resolve()} ({len(frames)} frames)")
    
    
    if __name__ == "__main__":
        main()

    8.書き出しは「完了」ではなく、検査の入口

    編集画面で問題がなくても、書き出したファイルで色、音、文字、長さが変わることがあります。資料は投稿時の画質劣化や書き出し条件に触れていますが(資料p.1〜2)、未確認の圧縮条件を絶対的な境界として扱いません。投稿先の現行ヘルプと実機表示を確認し、元ファイルの品質を記録します。

    検査は四段階で行います。第一は機械的確認です。コンテナ、映像コーデック、音声コーデック、画面寸法、縦横比、フレームレート、長さ、音声の有無をffprobeなどで読みます。本稿ではMP4、H.264映像、AAC音声を制作例として使いますが、Xの公式仕様はWeb、アプリ、Media Studioで確認先と適用範囲が異なります。Media Studio FAQの仕様を通常投稿へ流用せず、実際の投稿経路ごとに現行ヘルプを確認します。1080×1920も制作マスターの一例とし、投稿先の仕様に合わせて720×1280などの別版を用意します。第二は視覚確認です。冒頭、各カットの切り替わり、末尾を再生し、黒画面、文字切れ、輪郭のちらつき、意図しないロゴや生成文字がないかを見ます。

    第三は音の確認です。イヤホンと端末スピーカーの両方で、ナレーションが聞き取れるか、効果音が声を隠していないか、無音区間にノイズがないかを確かめます。ピーク値だけで聞きやすさは決まりません。話者の声質やBGMの帯域も影響するため、数値検査と試聴を組み合わせます。

    第四は投稿画面の確認です。下書きまたは限定的な確認環境が使えるなら、UIが重なった状態で文字とCTAを見ます。端末ごとの差があるので、少なくとも制作端末だけで結論を出さず、別サイズの画面でも確認します。ここで問題があれば、元編集へ戻って直し、同じ検査を繰り返します。再書き出ししたファイルには版番号を付け、古い版の誤投稿を防ぎます。

    参考:ffprobe公式ドキュメント

    画像

    図解08は、書き出し設定と生成後ファイルを分けて確認するQCゲートです。コード08は、ffprobeで制作マスターまたは投稿用軽量版を解析し、内部プロファイルとの差を表示します。変換コマンドは提示だけで自動実行しません。今回の制作環境にはFFmpeg/ffprobeがないためツール検出までを確認し、実動画のQCは未検証です。

    ffprobeで制作マスターと投稿用軽量版を内部QC

    前提: Python 3、実ファイル解析にはFFmpeg/ffprobe。実行: python -X utf8 08_video_qc.py video.mp4 --profile master または --profile post-720。入力: 動画。出力: 尺、コンテナ、映像・音声ストリームと個別チェック。masterは1080×1920、post-720は720×1280の制作例で、X公式合格や投稿成功を保証する総合判定はしません。変換コマンドは引用符付きfilter、yuv420p、+faststart、上書き防止-nを表示するだけで、自動実行しません。

    import argparse
    import json
    import shutil
    import subprocess
    
    
    def tool_status():
        return {name: shutil.which(name) for name in ("ffprobe", "ffmpeg")}
    
    
    def probe(path):
        command = [
            "ffprobe", "-v", "error", "-show_entries",
            "format=duration,format_name:stream=index,codec_type,codec_name,width,height,r_frame_rate",
            "-of", "json", path,
        ]
        return json.loads(subprocess.run(command, check=True, capture_output=True, text=True, encoding="utf-8").stdout)
    
    
    PROFILES = {
        "master": {"width": 1080, "height": 1920, "fps": "30/1", "label": "制作マスターの内部QC例"},
        "post-720": {"width": 720, "height": 1280, "fps": "30/1", "label": "投稿用軽量版の内部QC例"},
    }
    
    
    def summarize(data, profile_name):
        video = next((s for s in data.get("streams", []) if s.get("codec_type") == "video"), None)
        audio = next((s for s in data.get("streams", []) if s.get("codec_type") == "audio"), None)
        profile = PROFILES[profile_name]
        duration = float(data.get("format", {}).get("duration", 0))
        format_names = data.get("format", {}).get("format_name", "").split(",")
        return {
            "qc_scope": profile["label"],
            "duration_seconds": duration,
            "container_reported": format_names,
            "video": video,
            "audio": audio,
            "checks": {
                "video_size": bool(video and video.get("width") == profile["width"] and video.get("height") == profile["height"]),
                "video_fps": bool(video and video.get("r_frame_rate") == profile["fps"]),
                "video_codec_h264": bool(video and video.get("codec_name") == "h264"),
                "audio_codec_aac": bool(audio and audio.get("codec_name") == "aac"),
                "duration_15s_example": abs(duration - 15.0) <= 0.05,
                "container_includes_mp4": "mp4" in format_names,
            },
            "caution": "内部制作基準との比較です。X公式仕様への適合や投稿成功を保証しません。",
        }
    
    
    def main():
        parser = argparse.ArgumentParser(description="ffprobeで動画情報を読み取り、内部制作プロファイルと比較")
        parser.add_argument("input", nargs="?", help="検査する動画。省略時はツール確認だけ")
        parser.add_argument("--profile", choices=PROFILES, default="master")
        parser.add_argument("--print-convert-command", action="store_true")
        args = parser.parse_args()
        tools = tool_status()
        if not args.input:
            print(json.dumps({"tools": tools, "profiles": PROFILES, "note": "内部QC例であり、X公式合格・投稿保証ではありません"}, ensure_ascii=False, indent=2)); return
        if not tools["ffprobe"]:
            raise SystemExit("ffprobeが見つかりません。FFmpegをインストールしてPATHを通してください")
        print(json.dumps(summarize(probe(args.input), args.profile), ensure_ascii=False, indent=2))
        if args.print_convert_command:
            size = "1080:1920" if args.profile == "master" else "720:1280"
            print(f'ffmpeg -n -i INPUT -vf "scale={size}:force_original_aspect_ratio=decrease,pad={size}:-1:-1" -r 30 -c:v libx264 -pix_fmt yuv420p -c:a aac -movflags +faststart OUTPUT.mp4')
            print("INPUTとOUTPUT.mp4を実際のパスへ置換してください。-nは既存出力を上書きしません。")
    
    
    if __name__ == "__main__":
        main()

    9.効果比較は、アルゴリズムの推測より仮説の切り分け

    比較するときは、一度に変える要素を絞ります。Aは冒頭を「同じ数字、何回入力していますか」、Bは「その転記、減らせるかもしれません」とし、そのほかの映像、尺、CTA、投稿条件を可能な範囲でそろえます。複数要素を同時に変えると、何が結果へ影響したか分かりません。投稿時刻や既存フォロワーの反応など、統制できない条件も記録します。

    見る指標は目的に合わせ、似た名称の数値を混同しないようにします。平均視聴時間(AVD)は「総視聴時間÷再生開始数」で秒数を求め、平均視聴割合は「AVD÷動画尺×100」、完了率は「完了数÷再生開始数×100」で求めます。資料p.6のように総視聴時間を動画尺だけで割っても平均視聴割合にはならず、再生開始数が必要です。説明が目的なら、完了視聴だけでなく、リンク先でサービスの流れが理解されたか、問い合わせ時の質問が具体化したかも参考になります。ただし、少数の反応を一般化しません。表示回数が大きく異なる投稿同士は、単純な件数比較だけでは判断しにくいため、率と母数を併記します。

    資料は視聴維持と推薦の関係を強く説明しています(資料p.6)。Xの公式ヘルプには60秒以下の動画が自動ループする旨がありますが、これは再生挙動の説明であり、推薦上の優遇を証明するものではありません。特定の維持率やループ回数を超えれば推薦が急増する、といった未検証の境界は本稿では採用しません。配信システムは外部から全体を観測できず、条件も変化します。手元のデータから言える範囲と推測を分けることが、長期的には制作判断を強くします。

    改善会議では「伸びなかった」で終わらせず、①何を変えたか、②何が観測されたか、③他に考えられる要因、④次の一手を一行ずつ書きます。今回なら「冒頭の問いだけ変更」「3秒地点の残存率に差」「投稿条件と母数が異なる」「次回は同じ曜日帯で再比較」と整理します。これなら、数字が期待どおりでなくても学習が残ります。

    参考:X公式:動画の共有と視聴

    画像

    図解09は、分母と計算式を保持して施策差を比較する計測ファネルです。コード09は、表示・再生・完了・反応数のCSVから各率を同じ分母定義で計算します。分母が0ならnullとし、因果を自動判定しません。

    比較指標を同じ分母で計算

    前提: Python 3。実行: python -X utf8 09_compare_metrics.py。入力: 表示・再生開始イベント・完了・反応数のCSV。出力: 各率と分母定義。viewsはこのCSVで定義した再生開始イベント数で、公開view数と同一とは限りません。分母0はnullにし、完了数が開始数を超える矛盾を拒否します。差からアルゴリズム上の因果は断定しません。

    import argparse
    import csv
    import io
    import json
    from pathlib import Path
    
    
    DEMO = "variant,impressions,views,completed,engagements\nA,1000,420,180,35\nB,800,400,220,42\n"
    
    
    def divide(numerator, denominator):
        return None if denominator == 0 else numerator / denominator
    
    
    def main():
        parser = argparse.ArgumentParser(description="動画案ごとの指標を同じ分母で比較")
        parser.add_argument("--input", help="variant,impressions,views,completed,engagements CSV")
        args = parser.parse_args()
        text = Path(args.input).read_text(encoding="utf-8-sig") if args.input else DEMO
        results = []
        for row in csv.DictReader(io.StringIO(text)):
            values = {key: int(row[key]) for key in ("impressions", "views", "completed", "engagements")}
            if any(value < 0 for value in values.values()):
                raise SystemExit(f"負数は扱えません: {row['variant']}")
            if values["completed"] > values["views"]:
                raise SystemExit(f"完了数が再生開始イベント数を超えています: {row['variant']}")
            results.append({
                "variant": row["variant"],
                "view_rate": divide(values["views"], values["impressions"]),
                "completion_rate": divide(values["completed"], values["views"]),
                "engagement_rate": divide(values["engagements"], values["impressions"]),
                "raw": values,
            })
        print(json.dumps({"definitions": {
            "views": "このCSVで定義・収集した再生開始イベント数。プラットフォームの公開view数と同一とは限らない",
            "view_rate": "再生開始イベント数 / impressions",
            "completion_rate": "completed / views",
            "engagement_rate": "engagements / impressions",
            "zero_denominator": "null",
        }, "results": results, "caution": "差は因果関係や推薦アルゴリズムを保証しません"}, ensure_ascii=False, indent=2))
    
    
    if __name__ == "__main__":
        main()

    10.続けられる制作は、時間・費用・権利を見える化する

    短尺動画を継続するには、一本を作る技術だけでなく、繰り返せる運用が必要です。台本が固まる前に生成を始める、修正回数を決めない、素材の出所を残さない、といった状態では、一本目が完成しても二本目で疲弊します。資料も制作の効率化とスコープ管理に触れています(資料p.6〜9)。ここでは、価格相場を断定せず、自社で記録できる原価へ分解します。

    費用は、ツール利用、素材、音源、外注、確認の工数などに分けます。モデルやサービスの料金は変わるため、記事内に固定価格を置かず、制作時点の契約画面で確認します。無料枠で試作できても、商用利用条件、透かし、生成物の扱い、保存期間が同じとは限りません。案件開始時に確認し、後から公開できない事態を避けます。

    権利と承認では、人物の同意、顧客情報、ロゴ、音源、フォント、生成素材の利用条件をチェックします。AIで作ったから自動的に安全、という扱いはしません。実在人物に似た表現や第三者のブランドが紛れた場合は、採用を止めて確認します。公開前には、台本、画面、音声、CTA、掲載先、公開期間、二次利用の範囲を承認対象としてそろえます。

    複数媒体へ展開するときも、同じファイルをそのまま配るだけでは終わりません。資料p.7〜8のマルチ展開は、投稿画面のUIと重なる位置、利用できる音源の条件、冒頭の入り方、字幕の量を媒体ごとに確認する実務として捉えます。年代構成や音声の初期状態を一律に決めつけず、現行の公式情報と実際のプレビューで調整します。

    [[DIAGRAM_10]]

    図解10は、企画・制作・確認を週次で回し、変動工数を記録する運用表です。コード10は、工数、時給、ツール費、予備率から労務費・経費・予備費・合計を例示試算します。

    制作時間と経費を例示試算

    前提: Python 3。実行: python -X utf8 10_estimate_production_cost.py。入力: 有限かつ非負の工数、時給、ツール費、予備率。出力: 労務費・経費・予備費・合計。入力値は例で、法務・税務・会計助言ではありません。

    import argparse
    import json
    import math
    
    
    def nonnegative(value):
        number = float(value)
        if not math.isfinite(number) or number < 0:
            raise argparse.ArgumentTypeError("有限の0以上を指定してください")
        return number
    
    
    def main():
        parser = argparse.ArgumentParser(description="動画制作費の例示試算")
        parser.add_argument("--planning-hours", type=nonnegative, default=1.5)
        parser.add_argument("--production-hours", type=nonnegative, default=3.0)
        parser.add_argument("--review-hours", type=nonnegative, default=1.0)
        parser.add_argument("--hourly-rate", type=nonnegative, default=5000)
        parser.add_argument("--tool-expenses", type=nonnegative, default=1200)
        parser.add_argument("--other-expenses", type=nonnegative, default=0)
        parser.add_argument("--contingency-percent", type=nonnegative, default=10)
        args = parser.parse_args()
        hours = args.planning_hours + args.production_hours + args.review_hours
        labor = hours * args.hourly_rate
        direct = args.tool_expenses + args.other_expenses
        subtotal = labor + direct
        contingency = subtotal * args.contingency_percent / 100
        if not all(math.isfinite(value) for value in (hours, labor, direct, subtotal, contingency, subtotal + contingency)):
            raise SystemExit("計算結果が有限範囲を超えました。入力値を小さくしてください")
        result = {
            "currency": "JPY",
            "inputs_are_illustrative": True,
            "hours": hours,
            "labor": round(labor),
            "direct_expenses": round(direct),
            "contingency": round(contingency),
            "estimated_total": round(subtotal + contingency),
            "note": "見積判断の補助例です。税務・会計・法務上の助言ではありません。権利許諾費は案件ごとに確認してください。",
        }
        print(json.dumps(result, ensure_ascii=False, indent=2))
    
    
    if __name__ == "__main__":
        main()

    まとめ──短い動画ほど、判断の順番が品質をつくる

    AIとCapCutは、素材づくりと編集の選択肢を広げます。一方で、目的が曖昧なまま生成を重ねたり、演出を先に決めたりすると、短い動画ほど情報が混線します。最初に役割と完成条件を決め、15秒を理解の段階へ分け、縦画面で読める配置をつくる。その後で、必要なショットだけを生成し、層・時間・基準点をそろえて編集します。

    完成後は、書き出しファイルを機械、映像、音、投稿画面の四方向から確認します。公開後は、推薦ロジックを言い当てようとせず、変更した要素と観測結果を対応させます。うまくいかなかった一本にも、次の仮説が残る運用にすることが大切です。

    今回の15秒サービス説明なら、派手な演出より、「困りごと→整理→見通し→次の行動」が迷わず伝わることを優先します。短尺動画の価値は、情報量の多さではありません。視聴者が次の一歩を選べるところまで、理解の道筋を短く整えることにあります。

    関連記事

    再生成7回が、1回になりました。──動画生成AIに足りなかったのは、腕ではなく「台本」でした

    URL:https://note.com/comix_ceo162230/n/n3f709405fa8e

    カメラを買った社長より、プロンプトを書いた社長の方が、動画が増えた

    URL:https://note.com/comix_ceo162230/n/ne02dc809aaf9

    指示が毎回長くなるのは、1つのAIに全部頼んでいたからでした。──「つくる・回す・決める」で担当を分ける

    URL:https://note.com/comix_ceo162230/n/nd92303ad250d

    出典・確認資料

    主資料:X AI Video Production Guide(ユーザー提供PDF、全11ページ)

    X:How to share and watch videos on X — 通常の動画共有・視聴と短い動画の自動ループ。MP4/H.264/AACの根拠には使用せず、自動ループは推薦優遇の証明ではない

    X:Media Studio FAQs — Media Studio向けのMP4/MOV・H.264・AAC。Web・アプリ・APIの通常投稿仕様へ流用しない

    CapCut:背景ぼかし — 背景ぼかしと効果調整の手順。画面構成や機能の利用可否は版・環境で異なる

    CapCut:人物の背後へのテキスト配置 — 元映像、文字、背景を削除した人物を重ねるレイヤー構造

    CapCut:キーフレーム — キーフレームを使い、時間に沿って位置や大きさなどを変化させる方法

    X:公開推薦システムのREADME — 推薦処理に複数の段階とシグナルがあることを示す公開資料。現在の本番環境における固定的な推薦閾値の証明には使わない

    FFmpeg:ffprobe Documentation — 動画ファイルのコンテナ、映像・音声ストリーム、尺などをローカルで確認するための公式資料

    株式会社コミクス 代表取締役 

    鈴木章裕

    https://suzuki-commits.github.io/suzuki-akihiro-profile/

     
     
     
    AI活用のご相談→ https://www.comix.co.jp/contact/ 株式会社コミクス代表取締役。生成AI活用支援実績304社(2026年9月末現在)。営業・資料作成の効率化、AIエージェント導入を支援。何から始めるか迷っている段階でもご相談ください。

    あなたへのおすすめ