Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"node": ">=22.18"
},
"dependencies": {
"@fontsource/noto-sans-jp": "5.2.8",
"ffmpeg-static": "5.2.0",
"gsap": "3.13.0",
"playwright-core": "1.56.1"
},
"scripts": {
Expand Down
19 changes: 1 addition & 18 deletions quality-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,5 @@
"version": 2,
"policy": "Every current quality violation is a repayment target. The baseline exists to prevent increases and track reductions.",
"thresholds": {},
"issues": [
{
"id": "eslint|complexity|skills/motion-render/scripts/cli.ts|157|1",
"tool": "eslint",
"category": "complexity",
"path": "skills/motion-render/scripts/cli.ts",
"line": 157,
"column": 1,
"detail": "Async function 'main' has a complexity of 22. Maximum allowed is 20.",
"metric": {
"name": "complexity",
"value": 22,
"limit": 20,
"direction": "lowerIsBetter"
},
"stableKey": "eslint|complexity|skills/motion-render/scripts/cli.ts|function:main"
}
]
"issues": []
}
38 changes: 30 additions & 8 deletions skills/motion-render/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
---
name: motion-render
description: テーマを渡されたら、それを説明するアニメーション動画を Claude Motion で作り、ナレーション(Gemini TTS)とテーマに合う BGM(Lyria)、アニメーションに合わせた効果音(ElevenLabs)を付けて MP4 にする。「〇〇を説明する動画を作って」「〇〇の紹介動画」「/motion-render 〇〇」、既存の Motion の動画を MP4 にしたいときに使う。
description: テーマを渡されたら、それを説明するアニメーション動画を Claude Motion(使えなければ HTML)で作り、ナレーション(Gemini TTS)とテーマに合う BGM(Lyria)、アニメーションに合わせた効果音(ElevenLabs)を付けて MP4 にする。「〇〇を説明する動画を作って」「〇〇の紹介動画」「/motion-render 〇〇」、既存の Motion の動画を MP4 にしたいときに使う。
argument-hint: "<動画のテーマ。長さ・出力先などの指定があれば一緒に>"
---

# Motion Render

`/motion-render <テーマ>` の一言で、テーマを説明するアニメーション動画を作り、ナレーションと BGM を付けて MP4 にする。
映像は Claude Motion で作る。Motion が使えないアカウントでは、映像を 1 枚の HTML として書く(以下「html の映像」)。

**ナレーションと BGM は必ず付ける。** 音のない動画は作らない。既存の Motion の動画を MP4 にするときも、
場面に合わせてナレーションを書き、BGM を作る。効果音は `ELEVENLABS_API_KEY` があるときだけ付ける(下の「効果音」)。
Expand Down Expand Up @@ -59,7 +60,7 @@ Motion の Artifact 型は作成時に Style・Colors・Length を質問する
script.json # 原稿(Claude が書く)
timing.json # 場面の秒数(narrate が書く)
audio/ # <場面id>.wav、bgm.mp3、mix.m4a、sfx/(効果音と試聴用の preview.m4a)
film/ # Motion から取得した映像データ(prepare が展開する)
film/ # 映像。Motion から取得したもの(prepare が展開する)か、html の映像(kit が用意し、Claude が index.html を書く)
```

`script.json` の形:
Expand Down Expand Up @@ -107,9 +108,14 @@ motion-render narrate <work>

`timing.json` に各場面の開始と秒数が出る。Motion の場面の `dur` は、この値をそのまま使う。

### 3. Motion の動画を作る
### 3. 映像を作る

1. `Artifact` の `quickstart`(`intent: "other"`)か `list`(`scope: "types"`)で Motion 型の `type_url` を見つけ、`title` を付けて作る
`Artifact` の `quickstart`(`intent: "other"`)か `list`(`scope: "types"`)で Motion 型を探す。
見つかれば 3A、無ければ(Artifact ツールが無い場合も)3B に進む。どちらを使ったかを報告に書く。

#### 3A. Motion で作る

1. Motion 型の `type_url` に `title` を付けて作る
2. 返ってきた Motion 型の指示に従って場面を作る。ただし:
- 質問はしない(上の「決め方」)
- `scenes.json` の場面の `id`・`dur` は `timing.json` と同じにする
Expand All @@ -119,13 +125,25 @@ motion-render narrate <work>
4. 効果音を付けるときは、場面ごとに「何秒目に何が出るか」を決めた値で `script.json` の `sfx` を書き、
`motion-render sfx <work>` で作る(下の「効果音」)

### 4. 映像データを取得して確認する
#### 3B. html の映像を作る

1. `motion-render kit <work>` で `<work>/film/` にプレーヤー・フォント・GSAP・場面の秒数を置く
2. [references/html-film.md](references/html-film.md) を読み、その決まりに従って `<work>/film/index.html` を書く。
場面は `<section class="scene" id="<場面id>">`、動きは `film.build` の中で `film.at(場面id, 秒)` に置く
3. 並行して BGM を作ってよい: `motion-render bgm <work>`
4. 効果音を付けるときは、`script.json` の `sfx` の `at` に、その動きに渡した `film.at` の秒を書き、
`motion-render sfx <work>` で作る。タイトルの場面に `🎧:11.ai` を小さく入れる

### 4. 映像を確認する

html の映像(3B)は手順 4 の 1〜3 を飛ばし、先に `motion-render check <work>/film` を `layout: ok` になるまで直す
(出力の読み方は references/html-film.md の「確認」)。そのあと 4・5 で静止画を見る。

1. `Artifact` の `read` で `paths: ["index.html", "artifact-type/app.js", "artifact-type/app.css"]`、`out_dir: <work>/film`
2. `ArtifactData` の `list`(`collection: "modules"`)と `get`(`film`/`script`)を `out_dir: <work>/store` で保存
3. `motion-render prepare <work>/store <work>/film`
4. `motion-render stills <work>/film <work>/stills <各場面の中ほどの秒をカンマ区切り>` で全場面を見る。
見出しの不自然な改行、はみ出し、重なりがあれば Motion 側を直し、手順 4 をやり直す
見出しの不自然な改行、はみ出し、重なりがあれば Motion 側(html の映像なら index.html)を直し、手順 4 をやり直す
5. 効果音があれば、各効果音の秒(場面の開始 + `at`)の静止画も書き出し、鳴らす対象がその時点で画面に出ているかを見る。
ずれていれば `at` を直して `motion-render sfx` をやり直す(作り直されるのは変えたものだけ)

Expand All @@ -136,12 +154,13 @@ motion-render build <work> [<出力.mp4>]
```

ナレーションを各場面の頭に置き、BGM を一定の音量で重ね、効果音をそれぞれの秒に置いて MP4 にする。
ナレーションか BGM、または `script.json` にある効果音が無いと止まる。
ナレーションか BGM、または `script.json` にある効果音が無いと止まる。html の映像は、`check` と同じ問題が
残っていても止まる。

### 6. 確認して報告する

- `ffprobe` で、長さと映像・音声の両トラックがあることを見る
- 報告: 出力のパス、長さ、場面の流れ、BGM の雰囲気、Motion の URL。耳で聞いていないことは聞いていないと書く
- 報告: 出力のパス、長さ、場面の流れ、BGM の雰囲気、Motion の URL(html の映像なら `<work>/film/index.html` のパス)。耳で聞いていないことは聞いていないと書く
- 効果音を付けたときは、試聴用の `audio/sfx/preview.m4a` のパスと、その中の順番(`motion-render sfx` の出力)も報告し、
合わない音があれば教えてほしいと頼む。Claude には音の良し悪しが判定できないため、ここは人が聞いて決める

Expand Down Expand Up @@ -201,5 +220,8 @@ URL を渡された場合も音を付ける。映像データを取得し(手
- `scripts/sfx.ts`: ElevenLabs による効果音の生成(`sfx`)、音量の測定とそろえ方、試聴用ファイル
- `scripts/work.ts`: `script.json` の検証、`timing.json` の計算、ナレーションを動画の場面の開始時刻に置く処理
- `scripts/render.ts`: プレーヤーを開いてコマを返す。描画中は外部への通信を遮断し、Chromium の sandbox を有効にする
- `scripts/kit.ts`・`kit/`: html の映像のプレーヤー(`player.js`)と土台の CSS(`base.css`)を、フォントと GSAP と一緒に置く(`kit`)
- `scripts/layout.ts`・`scripts/snapshot.js`: html の映像の配置の確認(`check`。`stills`・`build` も実行する)
- `references/html-film.md`: html の映像の書き方と配置の決まり
- `scripts/json.ts`: ファイル・API・プレーヤーから来る JSON を、形を確かめながら読む
- `scripts/store.ts`: Motion の store(ArtifactData の保存先)を映像データのフォルダに展開する(`prepare`)
59 changes: 59 additions & 0 deletions skills/motion-render/kit/base.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/* Base of an html film: a 1920x1080 frame, Japanese typesetting, and fixed layers. */
:root {
--safe-x: 144px; /* 7.5% action-safe at 1920 */
--safe-y: 81px;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
}
body {
font-family: "Noto Sans JP", sans-serif;
font-feature-settings: "palt";
-webkit-font-smoothing: antialiased;
}
:lang(ja) {
word-break: auto-phrase;
line-break: strict;
}
#root {
position: relative;
width: 1920px;
height: 1080px;
overflow: hidden;
}
section.scene {
position: absolute;
inset: 0;
}
.num {
font-variant-numeric: tabular-nums;
}

/* Stacking by role, lowest first. Give every positioned element one of these instead of a
z-index of its own, so what is drawn on top never depends on the order in the HTML. */
.layer-bg {
z-index: 0;
}
.layer-card {
z-index: 10;
}
.layer-line {
z-index: 20;
}
.layer-chip {
z-index: 30;
}
.layer-mark {
z-index: 40;
}
.layer-caption {
z-index: 50;
}
125 changes: 125 additions & 0 deletions skills/motion-render/kit/player.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// The player of an html film. Scene times come from kit/timing.js (written by "motion-render kit"
// from timing.json), so the page never hard-codes them. The page builds one paused GSAP timeline in
// film.build(); render.ts seeks it through window.__film.
(function () {
var T = window.__timing;
if (!T || !window.gsap)
throw new Error("load kit/gsap.min.js and kit/timing.js before kit/player.js");
var FAMILY = '"Noto Sans JP"';
var scenes = {};
T.scenes.forEach(function (s) {
scenes[s.id] = s;
});
var timeline = gsap.timeline({ paused: true });
var builders = [];

function el(x) {
var e = typeof x === "string" ? document.querySelector(x) : x;
if (!e) throw new Error("no element " + x);
return e;
}
// An element's laid-out box in frame px, ignoring transforms (GSAP's x/y/scale).
function layoutBox(e) {
var x = 0,
y = 0;
for (var p = e; p && p.id !== "root"; p = p.offsetParent) {
x += p.offsetLeft;
y += p.offsetTop;
}
return { x: x, y: y, w: e.offsetWidth, h: e.offsetHeight };
}

window.film = {
scenes: scenes,
total: T.total,
timeline: timeline,
// Seconds into the film of `dt` seconds into scene `id`.
at: function (id, dt) {
if (!scenes[id]) throw new Error('scene "' + id + '" is not in timing.json');
return scenes[id].start + (dt || 0);
},
// `dt` seconds before scene `id` ends.
end: function (id, dt) {
return window.film.at(id, scenes[id] ? scenes[id].dur - (dt || 0) : 0);
},
// Runs fn(timeline) once the fonts are in, so text has its real size when the page measures it.
build: function (fn) {
builders.push(fn);
},
// GSAP x/y that put the center of `what` on the center of `target`, moved by (dx, dy).
// Use it to send a chip to a card instead of working out pixel offsets by hand.
spot: function (what, target, dx, dy) {
var a = layoutBox(el(what)),
b = layoutBox(el(target));
return {
x: b.x + b.w / 2 - (a.x + a.w / 2) + (dx || 0),
y: b.y + b.h / 2 - (a.y + a.h / 2) + (dy || 0),
};
},
};

function sections() {
return Array.prototype.slice.call(document.querySelectorAll("section.scene"));
}
// A scene's section is shown over [start, start + dur); everything outside sections stays.
function show(t) {
sections().forEach(function (el) {
var s = scenes[el.id];
el.style.visibility = t >= s.start && t < s.start + s.dur ? "" : "hidden";
});
}
// Fonts load per unicode-range subset on first use, so a scene's first frame could fall back.
// Load every glyph the film shows before the first frame, and fail if any is missing.
async function loadFonts() {
var text = document.body.textContent.replace(/\s+/g, "") + "0123456789";
var weights = ["400", "700"];
try {
await Promise.all(
weights.map(function (w) {
return document.fonts.load(w + " 48px " + FAMILY, text);
})
);
} catch (e) {
throw new Error(
"font " + FAMILY + " failed to load (" + e.message + '). Run "motion-render kit" again.'
);
}
await document.fonts.ready;
weights.forEach(function (w) {
if (!document.fonts.check(w + " 48px " + FAMILY, text))
throw new Error(
"font " + FAMILY + " " + w + ' did not load. Run "motion-render kit" again.'
);
});
}

window.__film = {
load: async function () {
var unknown = sections()
.map(function (el) {
return el.id;
})
.filter(function (id) {
return !scenes[id];
});
if (unknown.length)
throw new Error("section.scene ids not in timing.json: " + unknown.join(", "));
await loadFonts();
builders.forEach(function (fn) {
fn(timeline);
});
window.__film.seek(0);
return {
duration: T.total,
parts: T.scenes.map(function (s) {
return { key: s.id, a0: s.start, dur: s.dur };
}),
};
},
seek: function (t) {
show(t);
// suppressEvents=false so onUpdate callbacks (count-ups) run on every seek.
timeline.seek(t, false);
},
};
})();
17 changes: 17 additions & 0 deletions skills/motion-render/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading