cozyclay 1.2.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +193 -0
- package/LICENSE +67 -80
- package/LICENSES/GPL-3.0-or-later.txt +674 -0
- package/LICENSING.md +32 -0
- package/README.md +48 -6
- package/THIRD_PARTY_NOTICES.md +47 -1
- package/bin/cozyclay.mjs +120 -50
- package/bin/mcp-runtime.mjs +115 -0
- package/bin/open-browser.mjs +10 -0
- package/bin/update-check.mjs +158 -0
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +5 -5
- package/dist/ardy/assembled/run-jump-15s.npz +0 -0
- package/dist/assets/app-CqOk49Bk.css +1 -0
- package/dist/assets/app-zu633VCI.js +4811 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/index.html +62 -17
- package/dist/sitemap.xml +7 -1
- package/mcp/LIVE-PROTOCOL.md +144 -0
- package/mcp/README.md +160 -0
- package/mcp/ardy-prompts.mjs +188 -0
- package/mcp/live-hub.mjs +319 -0
- package/mcp/package.json +27 -0
- package/mcp/runtime/package-lock.json +1211 -0
- package/mcp/runtime/package.json +16 -0
- package/mcp/server.mjs +2008 -0
- package/package.json +142 -90
- package/src/App.jsx +5475 -1028
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/motion-edit.js +172 -0
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/pose-pin.js +97 -0
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/root-drop.js +180 -0
- package/src/ardy/timeline-coordinates.js +13 -0
- package/src/ardy/timeline-resize.js +3 -1
- package/src/ardy/timeline.jsx +471 -63
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/ardy/waypoints.js +4 -1
- package/src/asset-pane.jsx +354 -0
- package/src/asset-shelf.js +82 -0
- package/src/camera-block.js +42 -0
- package/src/camera-follow.js +149 -27
- package/src/camera-move.js +45 -19
- package/src/camera-rail-schedule.js +6 -0
- package/src/cuts.js +67 -54
- package/src/dualview.jsx +58 -12
- package/src/generation/generation-request.js +17 -0
- package/src/generation/use-generation.js +3 -14
- package/src/hierarchy-model.js +100 -16
- package/src/hierarchy-panel.jsx +139 -6
- package/src/live-control.js +142 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/mp4-muxer.js +52 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-gizmo.jsx +248 -25
- package/src/offscreen-export.js +166 -0
- package/src/otio.js +217 -0
- package/src/planview.jsx +57 -38
- package/src/pose-extract/detector.js +80 -0
- package/src/pose-extract/image-frame.js +40 -0
- package/src/pose-extract/index.js +4 -0
- package/src/pose-extract/take.js +114 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/poses.js +19 -265
- package/src/posestudio.jsx +441 -17
- package/src/project-browser.jsx +135 -0
- package/src/project-poses.js +9 -0
- package/src/project.js +373 -0
- package/src/props.jsx +69 -3
- package/src/room.jsx +89 -37
- package/src/sample-at.js +136 -0
- package/src/scene-asset-cache.js +166 -0
- package/src/scene-assets.js +378 -0
- package/src/scene-objects.js +308 -7
- package/src/scenes.js +235 -26
- package/src/shot-authoring.js +120 -41
- package/src/shot.js +53 -13
- package/src/source-offer.jsx +12 -0
- package/src/stable-items.js +83 -0
- package/src/styles.css +2283 -349
- package/src/ui.jsx +32 -4
- package/src/usd-camera.js +145 -0
- package/tools/ardy/BRIDGE.md +11 -7
- package/tools/ardy/README.md +24 -5
- package/tools/ardy/artifacts.mjs +34 -0
- package/tools/ardy/bridge.mjs +89 -12
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +123 -11
- package/tools/ardy/cclay_sequence_generate.py +49 -0
- package/tools/ardy/extract.mjs +372 -0
- package/tools/ardy/footage.mjs +467 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/local.mjs +4 -1
- package/tools/ardy/runners/remote.mjs +8 -2
- package/tools/ardy/setup-text-encoder.py +14 -4
- package/tools/dev-full.mjs +63 -5
- package/tools/generation/bridge.mjs +14 -1
- package/tools/process-supervisor.mjs +97 -1
- package/tools/run-tests.mjs +169 -0
- package/dist/assets/app-Cgpk2hwX.js +0 -4803
- package/dist/assets/app-DgZvaAE1.css +0 -1
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "a newer version exists" line, and the command that acts on it.
|
|
3
|
+
*
|
|
4
|
+
* Two rules keep this from becoming the thing people disable: it never blocks
|
|
5
|
+
* the launch (fire it, print later, drop it on any error) and it never talks
|
|
6
|
+
* unless there is actually something newer. No dependencies, same as the
|
|
7
|
+
* launcher it serves.
|
|
8
|
+
*/
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
const REGISTRY_URL = "https://registry.npmjs.org/cozyclay/latest";
|
|
14
|
+
const FETCH_TIMEOUT_MS = 2000;
|
|
15
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
|
|
17
|
+
// Numeric dotted compare, prereleases lose to their own release. Enough to
|
|
18
|
+
// answer "is the registry ahead of us"; anything more is a semver dependency.
|
|
19
|
+
export function compareVersions(a, b) {
|
|
20
|
+
const parse = (v) => {
|
|
21
|
+
const s = String(v).trim();
|
|
22
|
+
const i = s.indexOf("-");
|
|
23
|
+
// split("-", 2) would *drop* everything after the second hyphen, so
|
|
24
|
+
// "1.4.0-next-rc" would compare as "next". Keep the whole tag.
|
|
25
|
+
const core = i === -1 ? s : s.slice(0, i);
|
|
26
|
+
const pre = i === -1 ? "" : s.slice(i + 1);
|
|
27
|
+
return { parts: core.split(".").map((n) => Number.parseInt(n, 10) || 0), pre };
|
|
28
|
+
};
|
|
29
|
+
const left = parse(a);
|
|
30
|
+
const right = parse(b);
|
|
31
|
+
for (let i = 0; i < Math.max(left.parts.length, right.parts.length); i += 1) {
|
|
32
|
+
const diff = (left.parts[i] ?? 0) - (right.parts[i] ?? 0);
|
|
33
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
34
|
+
}
|
|
35
|
+
if (left.pre === right.pre) return 0;
|
|
36
|
+
if (!left.pre) return 1;
|
|
37
|
+
if (!right.pre) return -1;
|
|
38
|
+
// Prerelease tags are dot-separated identifiers; numeric ones order
|
|
39
|
+
// numerically, or beta.10 would look older than beta.2.
|
|
40
|
+
const lp = left.pre.split(".");
|
|
41
|
+
const rp = right.pre.split(".");
|
|
42
|
+
for (let i = 0; i < Math.max(lp.length, rp.length); i += 1) {
|
|
43
|
+
const x = lp[i];
|
|
44
|
+
const y = rp[i];
|
|
45
|
+
if (x === undefined) return -1;
|
|
46
|
+
if (y === undefined) return 1;
|
|
47
|
+
if (/^\d+$/.test(x) && /^\d+$/.test(y)) {
|
|
48
|
+
const diff = Number(x) - Number(y);
|
|
49
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
50
|
+
} else if (x !== y) return x < y ? -1 : 1;
|
|
51
|
+
}
|
|
52
|
+
return 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function readCache(cacheFile) {
|
|
56
|
+
try {
|
|
57
|
+
return JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
58
|
+
} catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function writeCache(cacheFile, cacheDir, latest) {
|
|
64
|
+
try {
|
|
65
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
66
|
+
writeFileSync(cacheFile, JSON.stringify({ latest, checkedAt: Date.now() }, null, "\t"));
|
|
67
|
+
} catch {
|
|
68
|
+
/* a read-only home is not a reason to fail a local dev server */
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Returns null on any failure rather than throwing: a network error must not
|
|
73
|
+
// unwind past the cache fallback in checkForUpdate.
|
|
74
|
+
async function fetchLatest() {
|
|
75
|
+
let body;
|
|
76
|
+
try {
|
|
77
|
+
const res = await fetch(process.env.COZYCLAY_REGISTRY_URL || REGISTRY_URL, {
|
|
78
|
+
headers: { accept: "application/json" },
|
|
79
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) return null;
|
|
82
|
+
body = await res.json();
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (typeof body?.version !== "string") return null;
|
|
87
|
+
// COZYCLAY_REGISTRY_URL can point anywhere, and a junk string parses as
|
|
88
|
+
// zeros further down, which reads as a downgrade. Only trust x.y.z.
|
|
89
|
+
const version = body.version.trim().replace(/^v/, "");
|
|
90
|
+
return /^\d+\.\d+\.\d+/.test(version) ? version : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolves to the newer version string, or null. Never rejects: offline,
|
|
95
|
+
* proxied, rate-limited and "npm is down" all mean "say nothing".
|
|
96
|
+
*/
|
|
97
|
+
export async function checkForUpdate(currentVersion, stateDir) {
|
|
98
|
+
try {
|
|
99
|
+
const cacheFile = join(stateDir, "update-check.json");
|
|
100
|
+
const cached = readCache(cacheFile);
|
|
101
|
+
let latest = cached?.latest ?? null;
|
|
102
|
+
const age = Date.now() - cached?.checkedAt;
|
|
103
|
+
// A future checkedAt (clock skew, edited cache) would otherwise look
|
|
104
|
+
// fresh forever, and the check would never run again.
|
|
105
|
+
const fresh = Number.isFinite(age) && age >= 0 && age <= CACHE_TTL_MS;
|
|
106
|
+
if (!fresh) {
|
|
107
|
+
const fetched = await fetchLatest();
|
|
108
|
+
// A dead registry must not erase a known-good cached version, or the
|
|
109
|
+
// notice silently stops working offline once the TTL lapses.
|
|
110
|
+
if (fetched) {
|
|
111
|
+
latest = fetched;
|
|
112
|
+
writeCache(cacheFile, stateDir, fetched);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!latest) return null;
|
|
116
|
+
return compareVersions(latest, currentVersion) > 0 ? latest : null;
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// npx/bunx runs from a temp dir, so there is no local install to upgrade in
|
|
123
|
+
// place; `npm install -g` is the one command that is right either way.
|
|
124
|
+
export function runUpdate() {
|
|
125
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
126
|
+
const child = spawn(npm, ["install", "-g", "cozyclay@latest"], { stdio: "inherit", shell: process.platform === "win32" });
|
|
127
|
+
child.on("error", (err) => {
|
|
128
|
+
console.error(`cozyclay: could not run npm: ${err.message}`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
});
|
|
131
|
+
child.on("exit", (code, signal) => {
|
|
132
|
+
if (signal || code !== 0) {
|
|
133
|
+
process.exit(code ?? 1);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Report what is on disk now, not what the registry says: those differ
|
|
137
|
+
// the moment a publish lands mid-update.
|
|
138
|
+
const installed = spawn(npm, ["ls", "-g", "--depth=0", "--json", "cozyclay"], {
|
|
139
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
140
|
+
shell: process.platform === "win32",
|
|
141
|
+
});
|
|
142
|
+
let out = "";
|
|
143
|
+
installed.stdout.on("data", (chunk) => {
|
|
144
|
+
out += chunk;
|
|
145
|
+
});
|
|
146
|
+
installed.on("error", () => process.exit(0));
|
|
147
|
+
installed.on("exit", () => {
|
|
148
|
+
let version = "";
|
|
149
|
+
try {
|
|
150
|
+
version = JSON.parse(out)?.dependencies?.cozyclay?.version ?? "";
|
|
151
|
+
} catch {
|
|
152
|
+
/* an unparseable `npm ls` only costs us the version in the message */
|
|
153
|
+
}
|
|
154
|
+
console.log(version ? `cozyclay ${version} installed.` : "cozyclay updated.");
|
|
155
|
+
process.exit(0);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<meta name="description" content="Camera control for AI video: why prompting an angle keeps drifting, what Seedance, Veo and Kling actually accept, and how blocking the shot in 3D first gives you the same camera on every take." />
|
|
7
|
+
<meta name="theme-color" content="#232323" />
|
|
8
|
+
<link rel="canonical" href="https://cozyclay.org/ai-camera-control/" />
|
|
9
|
+
<link rel="icon" href="/favicon.ico" sizes="any" />
|
|
10
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
|
|
11
|
+
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
|
12
|
+
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
|
13
|
+
|
|
14
|
+
<meta property="og:type" content="article" />
|
|
15
|
+
<meta property="og:site_name" content="CozyClay" />
|
|
16
|
+
<meta property="og:title" content="Camera control for AI video: block the shot instead of describing it" />
|
|
17
|
+
<meta property="og:description" content="Why camera prompts drift between takes, and how blocking the angle, lens and dolly in 3D first gets you the same shot every time." />
|
|
18
|
+
<meta property="og:url" content="https://cozyclay.org/ai-camera-control/" />
|
|
19
|
+
<meta property="og:image" content="https://cozyclay.org/media/cozyclay-demo-poster.jpg" />
|
|
20
|
+
<meta property="og:image:width" content="1920" />
|
|
21
|
+
<meta property="og:image:height" content="1080" />
|
|
22
|
+
<meta name="twitter:card" content="summary_large_image" />
|
|
23
|
+
<meta name="twitter:title" content="Camera control for AI video: block the shot instead of describing it" />
|
|
24
|
+
<meta name="twitter:description" content="Why camera prompts drift between takes, and how blocking the angle, lens and dolly in 3D first gets you the same shot every time." />
|
|
25
|
+
<meta name="twitter:image" content="https://cozyclay.org/media/cozyclay-demo-poster.jpg" />
|
|
26
|
+
|
|
27
|
+
<title>Camera control for AI video — block the shot, don't describe it</title>
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
<script type="application/ld+json">
|
|
31
|
+
{
|
|
32
|
+
"@context": "https://schema.org",
|
|
33
|
+
"@type": "Article",
|
|
34
|
+
"headline": "Camera control for AI video: block the shot instead of describing it",
|
|
35
|
+
"description": "Why camera prompts drift between takes, and how blocking the angle, lens and dolly in 3D first gets you the same shot every time.",
|
|
36
|
+
"image": "https://cozyclay.org/media/cozyclay-demo-poster.jpg",
|
|
37
|
+
"mainEntityOfPage": "https://cozyclay.org/ai-camera-control/",
|
|
38
|
+
"author": {
|
|
39
|
+
"@type": "Organization",
|
|
40
|
+
"name": "CozyClay",
|
|
41
|
+
"url": "https://cozyclay.org/"
|
|
42
|
+
},
|
|
43
|
+
"publisher": {
|
|
44
|
+
"@type": "Organization",
|
|
45
|
+
"name": "CozyClay",
|
|
46
|
+
"logo": {
|
|
47
|
+
"@type": "ImageObject",
|
|
48
|
+
"url": "https://cozyclay.org/media/cozyclay-logo.png"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
<script type="application/ld+json">
|
|
54
|
+
{
|
|
55
|
+
"@context": "https://schema.org",
|
|
56
|
+
"@type": "FAQPage",
|
|
57
|
+
"mainEntity": [
|
|
58
|
+
{
|
|
59
|
+
"@type": "Question",
|
|
60
|
+
"name": "Why does my camera angle change between takes?",
|
|
61
|
+
"acceptedAnswer": {
|
|
62
|
+
"@type": "Answer",
|
|
63
|
+
"text": "Because the prompt never fixed it. Words like low angle or close up carry no height, distance or focal length, so the model fills those in and fills them in differently on each run. Pinning composition needs something with numbers behind it - a starting frame, or a camera that exists in a scene."
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
"@type": "Question",
|
|
68
|
+
"name": "Do camera movement prompts work at all?",
|
|
69
|
+
"acceptedAnswer": {
|
|
70
|
+
"@type": "Answer",
|
|
71
|
+
"text": "They work as a nudge. Slow dolly in reliably gets you a push rather than a pan. What it will not do is get you the same push twice, or a push that matches the one in the shot you are cutting to."
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"@type": "Question",
|
|
76
|
+
"name": "What is the highest-leverage thing I can control in AI video?",
|
|
77
|
+
"acceptedAnswer": {
|
|
78
|
+
"@type": "Answer",
|
|
79
|
+
"text": "The first frame. Composition, lens compression and eyeline are all decided the moment that image exists, and the generation inherits them instead of guessing. This is what blocking a shot in 3D gives you."
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"@type": "Question",
|
|
84
|
+
"name": "Do I need Blender or Unreal to block a shot?",
|
|
85
|
+
"acceptedAnswer": {
|
|
86
|
+
"@type": "Answer",
|
|
87
|
+
"text": "No. Both will do it, and both ask you to learn a full DCC to move one camera. CozyClay does the blocking part in a browser tab with Unity-style controls."
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "6a4384d1375d4ffc8a30a6346a8ad1a0"}'></script>
|
|
95
|
+
<style>
|
|
96
|
+
:root {
|
|
97
|
+
--bg: #1b1b1b;
|
|
98
|
+
--panel: #232323;
|
|
99
|
+
--line: #343434;
|
|
100
|
+
--ink: #ece7de;
|
|
101
|
+
--muted: #a9a29a;
|
|
102
|
+
--clay: #f2eee6;
|
|
103
|
+
--accent: #d9a066;
|
|
104
|
+
}
|
|
105
|
+
* { box-sizing: border-box; }
|
|
106
|
+
html { scroll-behavior: smooth; }
|
|
107
|
+
body {
|
|
108
|
+
margin: 0;
|
|
109
|
+
background: var(--bg);
|
|
110
|
+
color: var(--ink);
|
|
111
|
+
font: 16px/1.65 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
112
|
+
-webkit-font-smoothing: antialiased;
|
|
113
|
+
}
|
|
114
|
+
a { color: var(--accent); }
|
|
115
|
+
.wrap { max-width: 980px; margin: 0 auto; padding: 0 20px; }
|
|
116
|
+
header.site {
|
|
117
|
+
border-bottom: 1px solid var(--line);
|
|
118
|
+
position: sticky; top: 0; background: rgba(27,27,27,.9);
|
|
119
|
+
backdrop-filter: blur(8px); z-index: 10;
|
|
120
|
+
}
|
|
121
|
+
header.site .wrap {
|
|
122
|
+
display: flex; align-items: center; gap: 16px;
|
|
123
|
+
min-height: 60px; flex-wrap: wrap;
|
|
124
|
+
}
|
|
125
|
+
.brand { font-weight: 700; letter-spacing: -.01em; color: var(--clay); text-decoration: none; font-size: 18px; }
|
|
126
|
+
header.site nav { margin-left: auto; display: flex; gap: 18px; flex-wrap: wrap; }
|
|
127
|
+
header.site nav a { color: var(--muted); text-decoration: none; font-size: 14px; }
|
|
128
|
+
header.site nav a:hover { color: var(--ink); }
|
|
129
|
+
|
|
130
|
+
.hero { padding: 64px 0 40px; }
|
|
131
|
+
h1 { font-size: clamp(30px, 5.2vw, 46px); line-height: 1.12; margin: 0 0 18px; letter-spacing: -.02em; color: var(--clay); }
|
|
132
|
+
.lede { font-size: clamp(17px, 2.2vw, 20px); color: var(--muted); margin: 0 0 28px; max-width: 44em; }
|
|
133
|
+
.cta { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
|
|
134
|
+
.btn {
|
|
135
|
+
display: inline-block; padding: 12px 20px; border-radius: 8px;
|
|
136
|
+
text-decoration: none; font-weight: 600; font-size: 15px; border: 1px solid var(--line);
|
|
137
|
+
}
|
|
138
|
+
.btn.primary { background: var(--clay); color: #1b1b1b; border-color: var(--clay); }
|
|
139
|
+
.btn.ghost { color: var(--ink); }
|
|
140
|
+
.note { color: var(--muted); font-size: 14px; margin: 0; }
|
|
141
|
+
|
|
142
|
+
figure { margin: 40px 0; }
|
|
143
|
+
figure img { width: 100%; height: auto; display: block; border: 1px solid var(--line); border-radius: 10px; }
|
|
144
|
+
figcaption { color: var(--muted); font-size: 13px; margin-top: 10px; }
|
|
145
|
+
|
|
146
|
+
/* The mark is blue on white and the eyes are knocked out of the fill, so
|
|
147
|
+
it sits on a light plate rather than straight on the dark page. */
|
|
148
|
+
.mark { margin: 0 0 26px; max-width: 190px; }
|
|
149
|
+
.mark img {
|
|
150
|
+
border: 0;
|
|
151
|
+
border-radius: 14px;
|
|
152
|
+
background: var(--clay);
|
|
153
|
+
padding: 14px 12px;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.reel {
|
|
157
|
+
margin: 0 0 30px;
|
|
158
|
+
border: 1px solid var(--line);
|
|
159
|
+
border-radius: 12px;
|
|
160
|
+
overflow: hidden;
|
|
161
|
+
background: #000;
|
|
162
|
+
box-shadow: 0 24px 60px rgba(0, 0, 0, .45);
|
|
163
|
+
}
|
|
164
|
+
.reel video { display: block; width: 100%; height: auto; }
|
|
165
|
+
.reel-note { color: var(--muted); font-size: 13px; margin: 12px 0 0; }
|
|
166
|
+
|
|
167
|
+
section { padding: 40px 0; border-top: 1px solid var(--line); }
|
|
168
|
+
h2 { font-size: clamp(22px, 3.2vw, 28px); margin: 0 0 8px; color: var(--clay); letter-spacing: -.01em; }
|
|
169
|
+
h3 { font-size: 17px; margin: 0 0 6px; color: var(--clay); }
|
|
170
|
+
.sub { color: var(--muted); margin: 0 0 26px; max-width: 48em; }
|
|
171
|
+
|
|
172
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; }
|
|
173
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 20px; }
|
|
174
|
+
.card p { margin: 0; color: var(--muted); font-size: 15px; }
|
|
175
|
+
|
|
176
|
+
pre {
|
|
177
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
178
|
+
padding: 16px 18px; overflow-x: auto; font-size: 14px; line-height: 1.7;
|
|
179
|
+
}
|
|
180
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
181
|
+
|
|
182
|
+
table { width: 100%; border-collapse: collapse; font-size: 15px; }
|
|
183
|
+
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--line); }
|
|
184
|
+
th { color: var(--clay); font-weight: 600; white-space: nowrap; }
|
|
185
|
+
td { color: var(--muted); }
|
|
186
|
+
.scroller { overflow-x: auto; }
|
|
187
|
+
|
|
188
|
+
details { border-bottom: 1px solid var(--line); padding: 16px 0; }
|
|
189
|
+
details summary { cursor: pointer; font-weight: 600; color: var(--clay); font-size: 16px; }
|
|
190
|
+
details p { color: var(--muted); margin: 12px 0 0; }
|
|
191
|
+
|
|
192
|
+
footer.site { border-top: 1px solid var(--line); padding: 32px 0 56px; color: var(--muted); font-size: 14px; }
|
|
193
|
+
footer.site a { color: var(--muted); }
|
|
194
|
+
|
|
195
|
+
@media (max-width: 620px) {
|
|
196
|
+
.hero { padding: 40px 0 28px; }
|
|
197
|
+
.btn { width: 100%; text-align: center; }
|
|
198
|
+
header.site nav { gap: 14px; width: 100%; margin-left: 0; padding-bottom: 10px; }
|
|
199
|
+
}
|
|
200
|
+
</style>
|
|
201
|
+
</head>
|
|
202
|
+
<body>
|
|
203
|
+
<header class="site">
|
|
204
|
+
<div class="wrap">
|
|
205
|
+
<a class="brand" href="/">CozyClay</a>
|
|
206
|
+
<nav>
|
|
207
|
+
<a href="/#what">What it does</a>
|
|
208
|
+
<a href="/#start">Quick start</a>
|
|
209
|
+
<a href="/ai-camera-control/">AI camera control</a>
|
|
210
|
+
<a href="https://github.com/HaD0Yun/CozyClay">GitHub</a>
|
|
211
|
+
</nav>
|
|
212
|
+
</div>
|
|
213
|
+
</header>
|
|
214
|
+
|
|
215
|
+
<main class="wrap">
|
|
216
|
+
<div class="hero">
|
|
217
|
+
<h1>Camera control for AI video</h1>
|
|
218
|
+
<p class="lede">
|
|
219
|
+
Video models will happily give you a low angle. They will not give you
|
|
220
|
+
<em>the same</em> low angle twice. Here is why the prompt keeps drifting, and what
|
|
221
|
+
to do about it.
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<section>
|
|
226
|
+
<h2>The part nobody warns you about</h2>
|
|
227
|
+
<p class="sub">
|
|
228
|
+
A prompt describes a camera in words, and words have no units. "Low angle, close on
|
|
229
|
+
her face, slow push in" is four decisions with no numbers attached: how low, how
|
|
230
|
+
close, what focal length, how far the push travels. The model picks values you never
|
|
231
|
+
specified, and it picks different ones on the next run.
|
|
232
|
+
</p>
|
|
233
|
+
<p class="sub">
|
|
234
|
+
That is fine for a single hero shot. It falls apart the moment you need two shots to
|
|
235
|
+
belong to the same scene. Cut between two takes that each invented their own lens and
|
|
236
|
+
eyeline and the geometry does not agree — the room changes shape across the cut, and
|
|
237
|
+
the audience feels it even when they cannot name it.
|
|
238
|
+
</p>
|
|
239
|
+
<p class="sub">
|
|
240
|
+
The usual response is to write a longer prompt. More adjectives, a reference image,
|
|
241
|
+
a list of shot-type vocabulary. It narrows the spread. It does not close it, because
|
|
242
|
+
the thing being specified was never language in the first place.
|
|
243
|
+
</p>
|
|
244
|
+
</section>
|
|
245
|
+
|
|
246
|
+
<section>
|
|
247
|
+
<h2>What the models actually take</h2>
|
|
248
|
+
<p class="sub">
|
|
249
|
+
The current generation of video models accepts camera intent through some mix of
|
|
250
|
+
three channels, and it is worth knowing which one you are using:
|
|
251
|
+
</p>
|
|
252
|
+
<div class="grid">
|
|
253
|
+
<div class="card">
|
|
254
|
+
<h3>Words</h3>
|
|
255
|
+
<p>
|
|
256
|
+
Shot-type vocabulary in the prompt — wide, medium, close, dolly, crane, 35mm.
|
|
257
|
+
Universally supported, and the loosest. The model interprets; you do not specify.
|
|
258
|
+
</p>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="card">
|
|
261
|
+
<h3>A first frame</h3>
|
|
262
|
+
<p>
|
|
263
|
+
An image the generation starts from. This pins composition hard, because framing,
|
|
264
|
+
lens compression and eyeline are already baked into the pixels. It is the highest
|
|
265
|
+
leverage input most people underuse.
|
|
266
|
+
</p>
|
|
267
|
+
</div>
|
|
268
|
+
<div class="card">
|
|
269
|
+
<h3>A path or reference clip</h3>
|
|
270
|
+
<p>
|
|
271
|
+
Some models take an explicit camera path, or a video whose motion is transferred
|
|
272
|
+
onto a new scene. Strongest control, narrowest support, and it needs the move to
|
|
273
|
+
exist somewhere before you can hand it over.
|
|
274
|
+
</p>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</section>
|
|
278
|
+
|
|
279
|
+
<section>
|
|
280
|
+
<h2>Block the shot, then generate</h2>
|
|
281
|
+
<p class="sub">
|
|
282
|
+
Film production solved this before any of these models existed, and the answer was
|
|
283
|
+
previs: build the scene rough in 3D, put a camera in it, and settle the shot while
|
|
284
|
+
it is still cheap to change. The blocking is the specification. Everything downstream
|
|
285
|
+
inherits it.
|
|
286
|
+
</p>
|
|
287
|
+
<p class="sub">
|
|
288
|
+
The same move works here. Put the geometry somewhere real first — a boxed-out set, a
|
|
289
|
+
character on a path, a camera with an actual position and an actual focal length —
|
|
290
|
+
and the questions the prompt was leaving open are already answered. You are no longer
|
|
291
|
+
asking for a low angle. You have one, and you can hand over the frame it produces.
|
|
292
|
+
</p>
|
|
293
|
+
<p class="sub">
|
|
294
|
+
Three things change once the camera lives in a scene instead of a sentence:
|
|
295
|
+
</p>
|
|
296
|
+
<div class="grid">
|
|
297
|
+
<div class="card">
|
|
298
|
+
<h3>Shots agree with each other</h3>
|
|
299
|
+
<p>
|
|
300
|
+
Two angles on one blocked set share a floor plan, so a cut between them holds.
|
|
301
|
+
Two prompts do not share anything.
|
|
302
|
+
</p>
|
|
303
|
+
</div>
|
|
304
|
+
<div class="card">
|
|
305
|
+
<h3>The move is reproducible</h3>
|
|
306
|
+
<p>
|
|
307
|
+
A dolly you drew is the same dolly tomorrow. Regenerate, change the subject, keep
|
|
308
|
+
the camera. Nothing about that survives a reroll of a text prompt.
|
|
309
|
+
</p>
|
|
310
|
+
</div>
|
|
311
|
+
<div class="card">
|
|
312
|
+
<h3>You fail cheaply</h3>
|
|
313
|
+
<p>
|
|
314
|
+
Finding out the angle was wrong costs a drag in a viewport, not a paid generation
|
|
315
|
+
and the wait attached to it.
|
|
316
|
+
</p>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</section>
|
|
320
|
+
|
|
321
|
+
<section>
|
|
322
|
+
<h2>Doing it in the browser</h2>
|
|
323
|
+
<p class="sub">
|
|
324
|
+
CozyClay is the previs half of that loop, built to be opened rather than installed.
|
|
325
|
+
Block a set out of primitives, drop a character and draw the path they walk, place
|
|
326
|
+
the camera and set its lens, cut between shots on a timeline, and scrub until the
|
|
327
|
+
timing is right. Then take those frames and those camera moves to whichever model you
|
|
328
|
+
are generating with.
|
|
329
|
+
</p>
|
|
330
|
+
|
|
331
|
+
<div class="reel">
|
|
332
|
+
<video
|
|
333
|
+
src="/media/cozyclay-demo.mp4"
|
|
334
|
+
poster="/media/cozyclay-demo-poster.jpg"
|
|
335
|
+
width="1920" height="1080"
|
|
336
|
+
muted loop playsinline controls preload="none"
|
|
337
|
+
></video>
|
|
338
|
+
</div>
|
|
339
|
+
<p class="reel-note">
|
|
340
|
+
Left of the wipe is the generated result. Right of it is the blocking that produced
|
|
341
|
+
the camera.
|
|
342
|
+
</p>
|
|
343
|
+
|
|
344
|
+
<div class="cta">
|
|
345
|
+
<a class="btn primary" href="/">What CozyClay does</a>
|
|
346
|
+
<a class="btn ghost" href="https://github.com/HaD0Yun/CozyClay">View on GitHub</a>
|
|
347
|
+
</div>
|
|
348
|
+
<p class="note">
|
|
349
|
+
Free and open source. Runs locally with <code>npx cozyclay</code>.
|
|
350
|
+
</p>
|
|
351
|
+
</section>
|
|
352
|
+
|
|
353
|
+
<section id="faq">
|
|
354
|
+
<h2>FAQ</h2>
|
|
355
|
+
<details open>
|
|
356
|
+
<summary>Why does my camera angle change between takes?</summary>
|
|
357
|
+
<p>
|
|
358
|
+
Because the prompt never fixed it. Words like "low angle" or "close up" carry no
|
|
359
|
+
height, distance or focal length, so the model fills those in and fills them in
|
|
360
|
+
differently on each run. Pinning composition needs something with numbers behind it
|
|
361
|
+
— a starting frame, or a camera that exists in a scene.
|
|
362
|
+
</p>
|
|
363
|
+
</details>
|
|
364
|
+
<details>
|
|
365
|
+
<summary>Do camera movement prompts work at all?</summary>
|
|
366
|
+
<p>
|
|
367
|
+
They work as a nudge. "Slow dolly in" reliably gets you a push rather than a pan.
|
|
368
|
+
What it will not do is get you the same push twice, or a push that matches the one
|
|
369
|
+
in the shot you are cutting to. Use the vocabulary, but do not expect it to carry
|
|
370
|
+
continuity.
|
|
371
|
+
</p>
|
|
372
|
+
</details>
|
|
373
|
+
<details>
|
|
374
|
+
<summary>What is the highest-leverage thing I can control?</summary>
|
|
375
|
+
<p>
|
|
376
|
+
The first frame. Composition, lens compression and eyeline are all decided the
|
|
377
|
+
moment that image exists, and the generation inherits them instead of guessing.
|
|
378
|
+
This is exactly what blocking a shot in 3D gives you.
|
|
379
|
+
</p>
|
|
380
|
+
</details>
|
|
381
|
+
<details>
|
|
382
|
+
<summary>Do I need Blender or Unreal to block a shot?</summary>
|
|
383
|
+
<p>
|
|
384
|
+
No. Both will do it, and both ask you to learn a full DCC to move one camera.
|
|
385
|
+
CozyClay does the blocking part in a browser tab with Unity-style controls —
|
|
386
|
+
see <a href="/">what it does</a>.
|
|
387
|
+
</p>
|
|
388
|
+
</details>
|
|
389
|
+
</section>
|
|
390
|
+
</main>
|
|
391
|
+
|
|
392
|
+
<footer class="site">
|
|
393
|
+
<div class="wrap">
|
|
394
|
+
<p>
|
|
395
|
+
<a href="/">CozyClay</a> ·
|
|
396
|
+
<a href="https://github.com/HaD0Yun/CozyClay">GitHub</a> ·
|
|
397
|
+
<a href="https://www.npmjs.com/package/cozyclay">npm</a>
|
|
398
|
+
</p>
|
|
399
|
+
<p>
|
|
400
|
+
CozyClay is not affiliated with or endorsed by NVIDIA, Unity Technologies, ByteDance,
|
|
401
|
+
Google or Kuaishou. Product names are used only to describe interoperability.
|
|
402
|
+
</p>
|
|
403
|
+
</div>
|
|
404
|
+
</footer>
|
|
405
|
+
</body>
|
|
406
|
+
</html>
|
package/dist/app/index.html
CHANGED
|
@@ -21,22 +21,22 @@
|
|
|
21
21
|
|
|
22
22
|
<meta property="og:type" content="website" />
|
|
23
23
|
<meta property="og:site_name" content="CozyClay" />
|
|
24
|
-
<meta property="og:title" content="CozyClay Studio — 3D staging and
|
|
24
|
+
<meta property="og:title" content="CozyClay Studio — 3D staging and previs in the browser" />
|
|
25
25
|
<meta property="og:description" content="Block a scene, pose characters, author camera moves, and preview generated motion. No install, no Blender, no Unity." />
|
|
26
26
|
<meta property="og:url" content="https://cozyclay.org/app/" />
|
|
27
27
|
<meta property="og:image" content="https://cozyclay.org/media/cozyclay-demo-poster.jpg" />
|
|
28
28
|
<meta property="og:image:width" content="1920" />
|
|
29
29
|
<meta property="og:image:height" content="1080" />
|
|
30
30
|
<meta name="twitter:card" content="summary_large_image" />
|
|
31
|
-
<meta name="twitter:title" content="CozyClay Studio — 3D staging and
|
|
31
|
+
<meta name="twitter:title" content="CozyClay Studio — 3D staging and previs in the browser" />
|
|
32
32
|
<meta name="twitter:description" content="Block a scene, pose characters, author camera moves, and preview generated motion. No install, no Blender, no Unity." />
|
|
33
33
|
<meta name="twitter:image" content="https://cozyclay.org/media/cozyclay-demo-poster.jpg" />
|
|
34
34
|
|
|
35
|
-
<title>CozyClay Studio — 3D staging and
|
|
35
|
+
<title>CozyClay Studio — 3D staging and previs in your browser</title>
|
|
36
36
|
|
|
37
37
|
<script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "6a4384d1375d4ffc8a30a6346a8ad1a0"}'></script>
|
|
38
|
-
<script type="module" crossorigin src="/assets/app-
|
|
39
|
-
<link rel="stylesheet" crossorigin href="/assets/app-
|
|
38
|
+
<script type="module" crossorigin src="/assets/app-zu633VCI.js"></script>
|
|
39
|
+
<link rel="stylesheet" crossorigin href="/assets/app-CqOk49Bk.css">
|
|
40
40
|
</head>
|
|
41
41
|
<body>
|
|
42
42
|
<div id="root">
|
|
Binary file
|