cozyclay 1.5.0 → 1.6.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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0
4
+
5
+ The official npm package now records anonymous product usage so downloads can
6
+ be distinguished from real launches and returning use.
7
+
8
+ - `npx cozyclay`, `bunx cozyclay`, and global `cclay` launches get a
9
+ signed-package-verified anonymous installation ID.
10
+ - The existing scene, edit, motion, export, and activation funnel is available
11
+ for npm sessions with `distribution: npm` and `app_version` properties.
12
+ - Source checkouts, forks, CI, tests, malformed or unsigned packages, and
13
+ opted-out launches remain untracked.
14
+ - `cclay telemetry status|on|off` controls the package-wide setting; the
15
+ in-app toggle stays synchronized with it.
16
+ - URL/referrer/campaign-derived values are scrubbed before any event leaves the
17
+ browser, and corrupted telemetry state fails closed.
18
+
3
19
  ## 1.5.0
4
20
 
5
21
  The release where the camera can climb a shaped path, recordings leave the studio as
package/README.md CHANGED
@@ -55,7 +55,7 @@ https://github.com/user-attachments/assets/1d0113e5-6922-443d-affc-1bdabc666247
55
55
  - Node.js 22 or newer
56
56
  - npm, or bun
57
57
  - A Chromium-based browser
58
- - *Optional:* an SSH-accessible NVIDIA machine running ARDY, for motion generation
58
+ - *Optional:* an SSH-accessible NVIDIA machine running ARDY, for motion generation — first-time setup downloads a ~16.4 GB text-encoder stack onto that machine; see [`tools/ardy/README.md`](tools/ardy/README.md)
59
59
 
60
60
  ## Quick start
61
61
 
@@ -179,6 +179,52 @@ Found something broken, or want a feature? [Open an issue](https://github.com/No
179
179
 
180
180
  All runtime libraries intentionally live in `devDependencies` because the published npm package ships the prebuilt `dist/`, so `npx cozyclay` must not install the studio's dependency tree.
181
181
 
182
+ ## Analytics & privacy
183
+
184
+ The hosted site at [cozyclay.org](https://cozyclay.org/) collects anonymous usage analytics via [PostHog](https://posthog.com/) (US Cloud). There are no cookies and no session recording, and Do-Not-Track is respected. A random pseudonymous identifier is kept in your browser's localStorage so that returning visits and retention can be counted; it is never linked to an account or project content and is removed by clearing site data or using the opt-out toggle.
185
+
186
+ Events collected:
187
+
188
+ | Event | Purpose |
189
+ | --- | --- |
190
+ | `install:first_launch` | First run of the official npm package |
191
+ | `app:session_started` | Start of an official npm package session |
192
+ | `$pageview` | Funnel and drop-off analysis |
193
+ | `scene:created` | Funnel and drop-off analysis |
194
+ | `scene:loaded` | Funnel and drop-off analysis |
195
+ | `craft:first_action` | Funnel and drop-off analysis |
196
+ | `motion:job_started` | Motion reliability |
197
+ | `motion:job_succeeded` | Motion reliability |
198
+ | `motion:job_failed` | Motion reliability |
199
+ | `export:blocking_frame_succeeded` | Funnel and drop-off analysis |
200
+ | `activation:completed` | Funnel and drop-off analysis |
201
+
202
+ Geo data comes from ingest-time GeoIP country lookup only — no precise location is collected. Prompt text, asset names, file names, project content, local paths, and any user-entered text are never collected.
203
+
204
+ The official npm package also measures anonymous first launches, sessions, and
205
+ the same in-app funnel on its `127.0.0.1` studio. It stores one random
206
+ installation identifier in `~/.config/cozyclay/state.json` so returning use can
207
+ be counted across ports and browser storage resets. Source checkouts, forks,
208
+ development servers, CI, and tests do not send analytics. Official npm
209
+ artifacts carry a signature checked by the launcher, so copying or repackaging
210
+ the source does not enable telemetry.
211
+
212
+ The npm package prints this disclosure once on first launch. Control it at any
213
+ time:
214
+
215
+ ```bash
216
+ cclay telemetry status
217
+ cclay telemetry off
218
+ cclay telemetry on
219
+ ```
220
+
221
+ `COZYCLAY_TELEMETRY=0` and `DO_NOT_TRACK=1` disable collection for a launch.
222
+ The in-app topbar toggle changes the same npm-package setting and removes its
223
+ anonymous installation identifier. Hosted-site visitors can opt out with that
224
+ toggle, browser Do-Not-Track, or a content blocker.
225
+
226
+ PostHog's free plan retains events for 1 year.
227
+
182
228
  ## License & credits
183
229
 
184
230
  GNU Affero General Public License v3.0 or later — see [`LICENSE`](LICENSE) and the transition details in [`LICENSING.md`](LICENSING.md). Modified network services must offer their users the corresponding source. Third-party projects retain their own licenses and copyright; see [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md).
@@ -101,6 +101,14 @@ as third-party content that this licence does not cover.
101
101
  - `x-bot-tpose.fbx`, `y-bot-tpose.fbx` — Adobe Mixamo
102
102
  - Terms: https://www.adobe.com/legal/terms.html
103
103
 
104
+ ## posthog-js
105
+
106
+ The hosted site at cozyclay.org uses [posthog-js](https://posthog.com/docs/libraries/js) for anonymous usage analytics.
107
+
108
+ - Copyright PostHog Inc.
109
+ - License: Apache-2.0 AND MIT
110
+ - Source: https://github.com/PostHog/posthog-js
111
+
104
112
  ## CozyClay license scope
105
113
 
106
114
  The CozyClay combined work in this repository is distributed under AGPL-3.0-or-later, subject to the transition details in `LICENSING.md`. That license does not replace or relicense Three.js, ARDY, ARDY model checkpoints, the bundled fonts, the character rigs, or any other third-party component.
package/bin/cozyclay.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { spawn } from "node:child_process";
18
18
  import { startBridge, terminateOwned } from "../tools/process-supervisor.mjs";
19
- import { createReadStream, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
19
+ import { createReadStream, existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
20
20
  import { createServer, request as httpRequest } from "node:http";
21
21
  import { homedir } from "node:os";
22
22
  import { extname, join, normalize, resolve } from "node:path";
@@ -25,10 +25,31 @@ import { fileURLToPath } from "node:url";
25
25
  import { runMcp } from "./mcp-runtime.mjs";
26
26
  import { openBrowser } from "./open-browser.mjs";
27
27
  import { checkForUpdate, runUpdate } from "./update-check.mjs";
28
+ import { verifyPackageMarker } from "./package-signature.mjs";
29
+ import {
30
+ markTelemetryNoticeShown,
31
+ markTelemetryFirstLaunch,
32
+ effectiveTelemetryEnabled,
33
+ readTelemetryState,
34
+ setTelemetryEnabled,
35
+ takeRuntimeTelemetryConfig,
36
+ TELEMETRY_NOTICE_VERSION,
37
+ } from "./telemetry-state.mjs";
28
38
 
29
39
  const PKG_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
40
+ let packageMetadata = {};
41
+ try {
42
+ packageMetadata = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
43
+ } catch {
44
+ // An unreadable package is handled by the existing missing-build checks.
45
+ }
30
46
  const DIST = join(PKG_ROOT, "dist");
31
47
  const BRIDGE = join(PKG_ROOT, "tools", "ardy", "bridge.mjs");
48
+ const OFFICIAL_PACKAGE = (
49
+ packageMetadata.name === "cozyclay"
50
+ && !existsSync(join(PKG_ROOT, ".git"))
51
+ && verifyPackageMarker(join(DIST, "cozyclay-package.json"), PKG_ROOT, packageMetadata)
52
+ );
32
53
  let bridgePort = 5181;
33
54
 
34
55
  const TYPES = {
@@ -99,6 +120,9 @@ const HELP = `cozyclay - browser-based 3D staging studio
99
120
  npx cozyclay --no-star never ask about starring the repo
100
121
  npx cozyclay --no-update-check
101
122
  do not look for a newer release
123
+ cclay telemetry status show anonymous telemetry status
124
+ cclay telemetry off disable anonymous telemetry
125
+ cclay telemetry on enable anonymous telemetry
102
126
 
103
127
  cclay is the same command, shorter: a global install gives you both.
104
128
 
@@ -148,7 +172,9 @@ function readState() {
148
172
  function writeState(patch) {
149
173
  try {
150
174
  mkdirSync(STATE_DIR, { recursive: true });
151
- writeFileSync(STATE_FILE, JSON.stringify({ ...readState(), ...patch }, null, "\t"));
175
+ const temporary = `${STATE_FILE}.${process.pid}.tmp`;
176
+ writeFileSync(temporary, JSON.stringify({ ...readState(), ...patch }, null, "\t"), { mode: 0o600 });
177
+ renameSync(temporary, STATE_FILE);
152
178
  } catch {
153
179
  /* a read-only home is not a reason to fail a local dev server */
154
180
  }
@@ -200,11 +226,7 @@ async function maybeAskForStar(startedAt, opts) {
200
226
  // Read on every launch now, so it must not be able to take the launcher down:
201
227
  // the version is cosmetic here, and "0.0.0" just suppresses a bogus notice.
202
228
  function readVersion() {
203
- try {
204
- return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? "0.0.0";
205
- } catch {
206
- return "0.0.0";
207
- }
229
+ return typeof packageMetadata.version === "string" ? packageMetadata.version : "0.0.0";
208
230
  }
209
231
 
210
232
  const argv = process.argv.slice(2);
@@ -218,6 +240,23 @@ if (argv[0] === "mcp") {
218
240
  process.exit(1);
219
241
  }
220
242
  runUpdate();
243
+ } else if (argv[0] === "telemetry") {
244
+ const action = argv[1] ?? "status";
245
+ if (argv.length > 2 || !["status", "on", "off"].includes(action)) {
246
+ console.error("cozyclay: telemetry accepts status, on, or off");
247
+ process.exit(1);
248
+ }
249
+ if (action === "on") setTelemetryEnabled(STATE_FILE, true);
250
+ if (action === "off") setTelemetryEnabled(STATE_FILE, false);
251
+ const state = readTelemetryState(STATE_FILE);
252
+ const effective = OFFICIAL_PACKAGE && effectiveTelemetryEnabled(state);
253
+ const reason = !OFFICIAL_PACKAGE
254
+ ? " (source checkout)"
255
+ : state.telemetryEnabled && !effective
256
+ ? " (environment override)"
257
+ : "";
258
+ console.log(`Telemetry: ${effective ? "on" : "off"}${reason}`);
259
+ if (action !== "status") console.log("Reload any open CozyClay studio tab to apply this setting.");
221
260
  } else {
222
261
 
223
262
  const opts = parseArgs(argv);
@@ -230,6 +269,21 @@ if (opts.version) {
230
269
  console.log(version);
231
270
  process.exit(0);
232
271
  }
272
+ let runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
273
+ appVersion: version,
274
+ officialPackage: OFFICIAL_PACKAGE,
275
+ });
276
+ const telemetryState = readTelemetryState(STATE_FILE);
277
+ if (
278
+ runtimeTelemetry.telemetryEnabled
279
+ && telemetryState.noticeVersion < TELEMETRY_NOTICE_VERSION
280
+ ) {
281
+ console.log(
282
+ "Anonymous usage metrics are on (no prompts, project content, filenames, or account information).\n" +
283
+ "Disable anytime: cclay telemetry off",
284
+ );
285
+ markTelemetryNoticeShown(STATE_FILE);
286
+ }
233
287
  // Fired before anything blocking and never awaited on the launch path: the
234
288
  // notice is worth a line of output, never a second of startup.
235
289
  const updatePending = opts.updateCheck && !process.env.CI && !process.env.COZYCLAY_NO_UPDATE_CHECK ? checkForUpdate(version, STATE_DIR) : null;
@@ -296,6 +350,46 @@ if (opts.ardy && ardyHost && existsSync(BRIDGE)) {
296
350
 
297
351
  server = createServer((req, res) => {
298
352
  const url = new URL(req.url ?? "/", "http://localhost");
353
+ if (url.pathname === "/__cozyclay/telemetry") {
354
+ if (req.method === "POST") {
355
+ const origin = req.headers.origin;
356
+ const expectedOrigin = `http://127.0.0.1:${opts.port}`;
357
+ if (origin !== expectedOrigin) {
358
+ res.writeHead(403, { "content-type": "application/json; charset=utf-8" });
359
+ res.end(JSON.stringify({ error: "forbidden origin" }));
360
+ return;
361
+ }
362
+ let body = "";
363
+ req.setEncoding("utf8");
364
+ req.on("data", (chunk) => {
365
+ body += chunk;
366
+ if (body.length > 1024) req.destroy();
367
+ });
368
+ req.on("end", () => {
369
+ try {
370
+ const value = JSON.parse(body);
371
+ if (typeof value.enabled !== "boolean") throw new TypeError("enabled must be boolean");
372
+ setTelemetryEnabled(STATE_FILE, value.enabled);
373
+ runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
374
+ appVersion: version,
375
+ officialPackage: OFFICIAL_PACKAGE,
376
+ });
377
+ res.writeHead(200, {
378
+ "content-type": "application/json; charset=utf-8",
379
+ "cache-control": "no-store",
380
+ });
381
+ res.end(JSON.stringify(runtimeTelemetry));
382
+ } catch {
383
+ res.writeHead(400, { "content-type": "application/json; charset=utf-8" });
384
+ res.end(JSON.stringify({ error: "invalid telemetry setting" }));
385
+ }
386
+ });
387
+ return;
388
+ }
389
+ res.writeHead(405, { allow: "POST", "content-type": "application/json; charset=utf-8" });
390
+ res.end(JSON.stringify({ error: "method not allowed" }));
391
+ return;
392
+ }
299
393
  // Only the routes the bridge actually owns: /ardy/ is ALSO a public asset
300
394
  // directory (cskel27-rest.json), and those files live in dist/, not behind
301
395
  // the sidecar. Same rule as the Vite dev proxy bypass.
@@ -303,7 +397,14 @@ server = createServer((req, res) => {
303
397
  proxyToBridge(req, res);
304
398
  return;
305
399
  }
306
- const rel = decodeURIComponent(url.pathname);
400
+ let rel;
401
+ try {
402
+ rel = decodeURIComponent(url.pathname);
403
+ } catch {
404
+ res.writeHead(400, { "content-type": "text/plain; charset=utf-8" });
405
+ res.end("invalid URL encoding");
406
+ return;
407
+ }
307
408
  // normalize + prefix check: a request must not escape dist/.
308
409
  let target = join(DIST, normalize(rel));
309
410
  if (!target.startsWith(DIST)) {
@@ -322,6 +423,25 @@ server = createServer((req, res) => {
322
423
  res.end("not found");
323
424
  return;
324
425
  }
426
+ if (target === join(DIST, "app", "index.html")) {
427
+ runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
428
+ appVersion: version,
429
+ officialPackage: OFFICIAL_PACKAGE,
430
+ });
431
+ const runtime = runtimeTelemetry;
432
+ if (runtime.firstLaunch) {
433
+ markTelemetryFirstLaunch(STATE_FILE);
434
+ }
435
+ const script = `<script>window.__COZYCLAY_RUNTIME__ = ${JSON.stringify(runtime).replaceAll("<", "\\u003c")};</script>`;
436
+ const html = readFileSync(target, "utf8").replace("</head>", `${script}\n</head>`);
437
+ res.writeHead(200, {
438
+ "content-type": "text/html; charset=utf-8",
439
+ "content-length": Buffer.byteLength(html),
440
+ "cache-control": "no-store",
441
+ });
442
+ res.end(html);
443
+ return;
444
+ }
325
445
  serveFile(res, target);
326
446
  });
327
447
 
@@ -340,6 +460,7 @@ server.listen({ port: opts.port, host: "127.0.0.1", ipv6Only: false }, () => {
340
460
  // marketing page.
341
461
  const url = `http://127.0.0.1:${opts.port}/app/`;
342
462
  console.log(`CozyClay is running at ${url}`);
463
+ console.log("Use a Chromium-based browser — Safari and Firefox are not supported.");
343
464
  if (!opts.ardy) console.log("Motion generation: off (--no-ardy).");
344
465
  else if (bridge) console.log(`Motion generation: sidecar running against ${ardyHost}.`);
345
466
  else
@@ -0,0 +1,95 @@
1
+ import { createHash, createPublicKey, verify } from "node:crypto";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { join, relative, resolve, sep } from "node:path";
4
+
5
+ export const PACKAGE_SIGNATURE_PUBLIC_KEY = "MCowBQYDK2VwAyEAiDLutMZ8CX/NzpuQF/juhT8v9QwPBUvIOHFRqEZoG3U=";
6
+
7
+ const CONTENT_ROOTS = [
8
+ "bin",
9
+ "dist",
10
+ "src",
11
+ "tools",
12
+ "mcp/runtime",
13
+ "mcp/server.mjs",
14
+ "mcp/live-hub.mjs",
15
+ "mcp/ardy-prompts.mjs",
16
+ "mcp/package.json",
17
+ "mcp/README.md",
18
+ "mcp/LIVE-PROTOCOL.md",
19
+ "package.json",
20
+ "README.md",
21
+ "CHANGELOG.md",
22
+ "LICENSE",
23
+ "LICENSES",
24
+ "LICENSING.md",
25
+ "THIRD_PARTY_NOTICES.md",
26
+ ];
27
+
28
+ function ignored(relativePath) {
29
+ return relativePath === "dist/cozyclay-package.json"
30
+ || (/^dist\/media\/[^/]+\.mp4$/i.test(relativePath))
31
+ || relativePath.startsWith("tools/ardy/out/")
32
+ || /^tools\/qa-[^/]+\.mjs$/i.test(relativePath);
33
+ }
34
+
35
+ function collectFiles(path, packageRoot, output) {
36
+ let entries;
37
+ try {
38
+ entries = readdirSync(path, { withFileTypes: true });
39
+ } catch (error) {
40
+ if (error?.code === "ENOTDIR") {
41
+ const relativePath = relative(packageRoot, path).split(sep).join("/");
42
+ if (!ignored(relativePath)) output.push({ path, relativePath });
43
+ return;
44
+ }
45
+ if (error?.code === "ENOENT") return;
46
+ throw error;
47
+ }
48
+ for (const entry of entries) {
49
+ const child = join(path, entry.name);
50
+ const relativePath = relative(packageRoot, child).split(sep).join("/");
51
+ if (ignored(relativePath)) continue;
52
+ if (entry.isDirectory()) collectFiles(child, packageRoot, output);
53
+ if (entry.isFile()) output.push({ path: child, relativePath });
54
+ }
55
+ }
56
+
57
+ export function computePackageDigest(packageRoot) {
58
+ const absoluteRoot = resolve(packageRoot);
59
+ const files = [];
60
+ for (const entry of CONTENT_ROOTS) collectFiles(join(absoluteRoot, entry), absoluteRoot, files);
61
+ files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
62
+ const hash = createHash("sha256");
63
+ for (const file of files) {
64
+ hash.update(file.relativePath);
65
+ hash.update("\0");
66
+ hash.update(readFileSync(file.path));
67
+ hash.update("\0");
68
+ }
69
+ return hash.digest("hex");
70
+ }
71
+
72
+ export function verifyPackageMarker(
73
+ markerFile,
74
+ packageRoot,
75
+ packageMetadata,
76
+ publicKeyBase64 = PACKAGE_SIGNATURE_PUBLIC_KEY,
77
+ ) {
78
+ try {
79
+ const marker = JSON.parse(readFileSync(markerFile, "utf8"));
80
+ const payload = JSON.parse(marker.payload);
81
+ if (payload.distribution !== "npm") return false;
82
+ if (payload.package !== "cozyclay") return false;
83
+ if (payload.version !== packageMetadata.version) return false;
84
+ if (payload.repository !== "NomaDamas/CozyClay") return false;
85
+ if (payload.content_sha256 !== computePackageDigest(packageRoot)) return false;
86
+ const publicKey = createPublicKey({
87
+ key: Buffer.from(publicKeyBase64, "base64"),
88
+ format: "der",
89
+ type: "spki",
90
+ });
91
+ return verify(null, Buffer.from(marker.payload), publicKey, Buffer.from(marker.signature, "base64"));
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
@@ -0,0 +1,111 @@
1
+ import { randomUUID as nodeRandomUUID } from "node:crypto";
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ export const TELEMETRY_NOTICE_VERSION = 1;
6
+ export const POSTHOG_PROJECT_TOKEN = "phc_CpizzZ8VhSorSS8yEeQhdpUcvB2erp5xkCbnFD8HTJ5m";
7
+ export const POSTHOG_API_HOST = "https://t.cozyclay.org";
8
+
9
+ const DEFAULT_STATE = Object.freeze({
10
+ installationId: null,
11
+ telemetryEnabled: true,
12
+ firstLaunchedAt: null,
13
+ noticeVersion: 0,
14
+ });
15
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
16
+
17
+ function rawState(stateFile) {
18
+ try {
19
+ const parsed = JSON.parse(readFileSync(stateFile, "utf8"));
20
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
21
+ } catch (error) {
22
+ return error?.code === "ENOENT" ? {} : { telemetryEnabled: false };
23
+ }
24
+ }
25
+
26
+ function normalizedState(value) {
27
+ return {
28
+ installationId: typeof value.installationId === "string" && UUID_PATTERN.test(value.installationId)
29
+ ? value.installationId
30
+ : null,
31
+ telemetryEnabled: value.telemetryEnabled !== false,
32
+ firstLaunchedAt: typeof value.telemetryFirstLaunchedAt === "string"
33
+ ? value.telemetryFirstLaunchedAt
34
+ : null,
35
+ noticeVersion: Number.isInteger(value.telemetryNoticeVersion)
36
+ ? value.telemetryNoticeVersion
37
+ : 0,
38
+ };
39
+ }
40
+
41
+ function writeState(stateFile, patch) {
42
+ const next = { ...rawState(stateFile), ...patch };
43
+ mkdirSync(dirname(stateFile), { recursive: true });
44
+ const temporary = `${stateFile}.${process.pid}.tmp`;
45
+ writeFileSync(temporary, JSON.stringify(next, null, "\t"), { mode: 0o600 });
46
+ renameSync(temporary, stateFile);
47
+ }
48
+
49
+ function envDisablesTelemetry(env) {
50
+ if (env.CI && !/^(0|false|no|off)$/i.test(env.CI)) return true;
51
+ if (/^(1|true|yes|on)$/i.test(env.DO_NOT_TRACK ?? "")) return true;
52
+ return /^(0|false|no|off)$/i.test(env.COZYCLAY_TELEMETRY ?? "");
53
+ }
54
+
55
+ export function readTelemetryState(stateFile) {
56
+ return normalizedState(rawState(stateFile));
57
+ }
58
+
59
+ export function effectiveTelemetryEnabled(state, env = process.env) {
60
+ return state.telemetryEnabled && !envDisablesTelemetry(env);
61
+ }
62
+
63
+ export function setTelemetryEnabled(stateFile, enabled) {
64
+ writeState(stateFile, {
65
+ telemetryEnabled: enabled === true,
66
+ ...(enabled === true ? {} : { installationId: null }),
67
+ });
68
+ return readTelemetryState(stateFile);
69
+ }
70
+
71
+ export function markTelemetryNoticeShown(stateFile) {
72
+ writeState(stateFile, { telemetryNoticeVersion: TELEMETRY_NOTICE_VERSION });
73
+ }
74
+
75
+ export function markTelemetryFirstLaunch(stateFile, now = () => new Date().toISOString()) {
76
+ if (readTelemetryState(stateFile).firstLaunchedAt) return;
77
+ writeState(stateFile, { telemetryFirstLaunchedAt: now() });
78
+ }
79
+
80
+ export function takeRuntimeTelemetryConfig(
81
+ stateFile,
82
+ {
83
+ appVersion,
84
+ officialPackage = true,
85
+ env = process.env,
86
+ now = () => new Date().toISOString(),
87
+ randomUUID = nodeRandomUUID,
88
+ } = {},
89
+ ) {
90
+ const existing = readTelemetryState(stateFile);
91
+ const telemetryEnabled = officialPackage && effectiveTelemetryEnabled(existing, env);
92
+ let installationId = existing.installationId;
93
+ const firstLaunch = telemetryEnabled && !existing.firstLaunchedAt;
94
+ const patch = {};
95
+
96
+ if (telemetryEnabled && !installationId) {
97
+ installationId = randomUUID();
98
+ patch.installationId = installationId;
99
+ }
100
+ if (Object.keys(patch).length > 0) writeState(stateFile, patch);
101
+
102
+ return {
103
+ distribution: "npm",
104
+ telemetryEnabled,
105
+ installationId: telemetryEnabled ? installationId : null,
106
+ appVersion,
107
+ apiKey: POSTHOG_PROJECT_TOKEN,
108
+ apiHost: POSTHOG_API_HOST,
109
+ firstLaunch,
110
+ };
111
+ }
@@ -6,9 +6,8 @@
6
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
7
  <meta name="theme-color" content="#232323" />
8
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
9
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
10
+ <link rel="icon" href="/favicon.ico" sizes="48x48 96x96 192x192" />
12
11
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
13
12
 
14
13
  <meta property="og:type" content="article" />
@@ -207,7 +206,7 @@
207
206
  <a href="/#what">What it does</a>
208
207
  <a href="/#start">Quick start</a>
209
208
  <a href="/ai-camera-control/">AI camera control</a>
210
- <a href="https://github.com/HaD0Yun/CozyClay">GitHub</a>
209
+ <a href="https://github.com/NomaDamas/CozyClay">GitHub</a>
211
210
  </nav>
212
211
  </div>
213
212
  </header>
@@ -343,7 +342,7 @@
343
342
 
344
343
  <div class="cta">
345
344
  <a class="btn primary" href="/">What CozyClay does</a>
346
- <a class="btn ghost" href="https://github.com/HaD0Yun/CozyClay">View on GitHub</a>
345
+ <a class="btn ghost" href="https://github.com/NomaDamas/CozyClay">View on GitHub</a>
347
346
  </div>
348
347
  <p class="note">
349
348
  Free and open source. Runs locally with <code>npx cozyclay</code>.
@@ -393,7 +392,7 @@
393
392
  <div class="wrap">
394
393
  <p>
395
394
  <a href="/">CozyClay</a> ·
396
- <a href="https://github.com/HaD0Yun/CozyClay">GitHub</a> ·
395
+ <a href="https://github.com/NomaDamas/CozyClay">GitHub</a> ·
397
396
  <a href="https://www.npmjs.com/package/cozyclay">npm</a>
398
397
  </p>
399
398
  <p>
@@ -14,9 +14,8 @@
14
14
  <meta name="robots" content="noindex, follow" />
15
15
  <link rel="canonical" href="https://cozyclay.org/app/" />
16
16
  <link rel="manifest" href="/manifest.webmanifest" />
17
- <link rel="icon" href="/favicon.ico" sizes="any" />
18
- <link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
19
17
  <link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
18
+ <link rel="icon" href="/favicon.ico" sizes="48x48 96x96 192x192" />
20
19
  <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
21
20
 
22
21
  <meta property="og:type" content="website" />
@@ -35,7 +34,7 @@
35
34
  <title>CozyClay Studio — 3D staging and previs in your browser</title>
36
35
 
37
36
  <script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "6a4384d1375d4ffc8a30a6346a8ad1a0"}'></script>
38
- <script type="module" crossorigin src="/assets/app-zu633VCI.js"></script>
37
+ <script type="module" crossorigin src="/assets/app-G55dPEDB.js"></script>
39
38
  <link rel="stylesheet" crossorigin href="/assets/app-CqOk49Bk.css">
40
39
  </head>
41
40
  <body>