@hyperframes/studio-server 0.8.79 → 0.8.80

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/dist/index.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  splitElementInHtml,
14
14
  unwrapElementsFromHtml,
15
15
  wrapElementsInHtml
16
- } from "./chunk-QYDRVMJ6.js";
16
+ } from "./chunk-P5EJPIH3.js";
17
17
  import {
18
18
  getElementScreenshotClip
19
19
  } from "./chunk-W2SBTCO2.js";
@@ -22,12 +22,12 @@ import {
22
22
  isAutoProxyEnabled,
23
23
  proxyEtagSalt,
24
24
  resolvePreviewMediaCodecProbeCache
25
- } from "./chunk-X3T65KC2.js";
25
+ } from "./chunk-YGANSFUU.js";
26
26
  import {
27
27
  ProxyCapacityError,
28
28
  ProxyTranscodeError,
29
29
  resolveProxy
30
- } from "./chunk-45POWYFS.js";
30
+ } from "./chunk-56JREE33.js";
31
31
  import {
32
32
  PROXY_VARIANT_CONFIG,
33
33
  decideMediaProxyEligibility,
@@ -36,7 +36,7 @@ import {
36
36
  probeMediaMetadata,
37
37
  recordProxyRequest,
38
38
  resolveProxyVariantRequest
39
- } from "./chunk-4MNTZVVZ.js";
39
+ } from "./chunk-KOB22VPG.js";
40
40
  import {
41
41
  STUDIO_MANUAL_EDITS_PATH,
42
42
  createStudioManualEditsRenderBodyScript,
@@ -289,22 +289,22 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
289
289
  if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
290
290
  const file = resolve(dir, entry);
291
291
  if (!isPathWithin(projectDir, file)) continue;
292
- let stat2;
292
+ let stat3;
293
293
  try {
294
- stat2 = lstatSync(file);
294
+ stat3 = lstatSync(file);
295
295
  } catch {
296
296
  continue;
297
297
  }
298
- if (stat2.isSymbolicLink()) continue;
299
- if (stat2.isDirectory()) {
298
+ if (stat3.isSymbolicLink()) continue;
299
+ if (stat3.isDirectory()) {
300
300
  collectProjectSignatureFiles(projectDir, file, files);
301
- } else if (stat2.isFile()) {
301
+ } else if (stat3.isFile()) {
302
302
  files.push({
303
303
  file,
304
- mtimeMs: stat2.mtimeMs,
305
- ctimeMs: stat2.ctimeMs,
306
- size: stat2.size,
307
- textContentEligible: isTextContentEligible(file, stat2.size)
304
+ mtimeMs: stat3.mtimeMs,
305
+ ctimeMs: stat3.ctimeMs,
306
+ size: stat3.size,
307
+ textContentEligible: isTextContentEligible(file, stat3.size)
308
308
  });
309
309
  }
310
310
  }
@@ -314,19 +314,19 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
314
314
  for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
315
315
  const file = resolve(projectDir, manifestPath);
316
316
  if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
317
- let stat2;
317
+ let stat3;
318
318
  try {
319
- stat2 = lstatSync(file);
319
+ stat3 = lstatSync(file);
320
320
  } catch {
321
321
  continue;
322
322
  }
323
- if (stat2.isSymbolicLink() || !stat2.isFile()) continue;
323
+ if (stat3.isSymbolicLink() || !stat3.isFile()) continue;
324
324
  files.push({
325
325
  file,
326
- mtimeMs: stat2.mtimeMs,
327
- ctimeMs: stat2.ctimeMs,
328
- size: stat2.size,
329
- textContentEligible: isTextContentEligible(file, stat2.size)
326
+ mtimeMs: stat3.mtimeMs,
327
+ ctimeMs: stat3.ctimeMs,
328
+ size: stat3.size,
329
+ textContentEligible: isTextContentEligible(file, stat3.size)
330
330
  });
331
331
  seen.add(file);
332
332
  }
@@ -761,9 +761,9 @@ function hashVersion(hex) {
761
761
  function hashOfVersion(version) {
762
762
  return /^"sha256:([0-9a-f]{64})"$/.exec(version)?.[1];
763
763
  }
764
- function settledFileTag(stat2, now = Date.now()) {
765
- if (now - stat2.ctimeMs < 3e3) return null;
766
- return [stat2.ino, stat2.ctimeMs, stat2.size].map((n) => n.toString(36)).join("-");
764
+ function settledFileTag(stat3, now = Date.now()) {
765
+ if (now - stat3.ctimeMs < 3e3) return null;
766
+ return [stat3.ino, stat3.ctimeMs, stat3.size].map((n) => n.toString(36)).join("-");
767
767
  }
768
768
  function createWriteToken(requestToken) {
769
769
  const token = requestToken?.trim();
@@ -2776,10 +2776,10 @@ function registerFileRoutes(api, adapter) {
2776
2776
  api.delete("/projects/:id/files/*", async (c) => {
2777
2777
  const res = await resolveProjectFile(c, adapter, { mustExist: true });
2778
2778
  if ("error" in res) return res.error;
2779
- const stat2 = statSync3(res.absPath);
2779
+ const stat3 = statSync3(res.absPath);
2780
2780
  const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2781
2781
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2782
- if (stat2.isDirectory()) {
2782
+ if (stat3.isDirectory()) {
2783
2783
  rmSync3(res.absPath, { recursive: true });
2784
2784
  } else {
2785
2785
  unlinkSync2(res.absPath);
@@ -3429,7 +3429,7 @@ function registerFileRoutes(api, adapter) {
3429
3429
  // src/routes/preview.ts
3430
3430
  import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
3431
3431
  import { Readable } from "stream";
3432
- import { join as join9, resolve as resolve4 } from "path";
3432
+ import { join as join10, resolve as resolve5 } from "path";
3433
3433
  import { createHash as createHash4 } from "crypto";
3434
3434
  import {
3435
3435
  addScenePartsManifest,
@@ -3622,6 +3622,83 @@ function hasBaseElement(html) {
3622
3622
  // src/routes/preview.ts
3623
3623
  import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
3624
3624
 
3625
+ // src/helpers/previewReads.ts
3626
+ import { readlinkSync as readlinkSync2 } from "fs";
3627
+ import { dirname as dirname6, join as join9, parse, relative as relative5, resolve as resolve4 } from "path";
3628
+ var ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
3629
+ var REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
3630
+ var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve4(path).toLowerCase() : (path) => resolve4(path);
3631
+ var readsByProject = /* @__PURE__ */ new Map();
3632
+ var builtProjects = /* @__PURE__ */ new Set();
3633
+ var projectKey = (projectDir) => pathKey(realFilePath(resolve4(projectDir)));
3634
+ var inRealProject = (projectDir, path) => join9(realFilePath(resolve4(projectDir)), relative5(resolve4(projectDir), resolve4(projectDir, path)));
3635
+ var readLink = (path) => {
3636
+ try {
3637
+ return readlinkSync2(path);
3638
+ } catch {
3639
+ return null;
3640
+ }
3641
+ };
3642
+ function linksOnTheWay(path) {
3643
+ const passed = [];
3644
+ let at = parse(path).root;
3645
+ let rest = path.slice(at.length).split(/[\\/]+/);
3646
+ for (let hops = 0; rest.length > 0; ) {
3647
+ const part = rest.shift();
3648
+ if (!part || part === ".") continue;
3649
+ if (part === "..") {
3650
+ at = dirname6(at);
3651
+ continue;
3652
+ }
3653
+ const next = join9(at, part);
3654
+ const target = hops < 40 ? readLink(next) : null;
3655
+ if (target === null) {
3656
+ at = next;
3657
+ continue;
3658
+ }
3659
+ hops++;
3660
+ passed.push(next);
3661
+ const root = parse(target).root;
3662
+ if (root) at = root;
3663
+ rest = [...target.slice(root.length).split(/[\\/]+/), ...rest];
3664
+ }
3665
+ passed.push(at);
3666
+ return passed;
3667
+ }
3668
+ function recordPreviewRead(projectDir, filePath) {
3669
+ const key = projectKey(projectDir);
3670
+ let reads = readsByProject.get(key);
3671
+ if (!reads) readsByProject.set(key, reads = /* @__PURE__ */ new Set());
3672
+ const read = inRealProject(projectDir, filePath);
3673
+ for (const found of [read, ...linksOnTheWay(read)]) {
3674
+ for (let path = pathKey(found); !reads.has(path); path = dirname6(path)) reads.add(path);
3675
+ }
3676
+ }
3677
+ function recordPreviewReferences(projectDir, html) {
3678
+ const named = /* @__PURE__ */ new Set();
3679
+ for (const match of html.matchAll(REFERENCE)) {
3680
+ const url = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "").trim();
3681
+ if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) continue;
3682
+ const path = url.split(/[?#]/)[0] ?? "";
3683
+ let decoded = path;
3684
+ try {
3685
+ decoded = decodeURIComponent(path);
3686
+ } catch {
3687
+ }
3688
+ if (decoded) named.add(decoded);
3689
+ }
3690
+ for (const path of named) recordPreviewRead(projectDir, path);
3691
+ }
3692
+ function recordPreviewBuilt(projectDir) {
3693
+ for (const path of ALWAYS_AFFECTS) recordPreviewRead(projectDir, path);
3694
+ builtProjects.add(projectKey(projectDir));
3695
+ }
3696
+ function affectsPreview(projectDir, changedPath) {
3697
+ const key = projectKey(projectDir);
3698
+ if (!builtProjects.has(key)) return true;
3699
+ return readsByProject.get(key)?.has(pathKey(inRealProject(projectDir, changedPath))) ?? false;
3700
+ }
3701
+
3625
3702
  // src/helpers/variablesPayload.ts
3626
3703
  var VARIABLES_PAYLOAD_ERROR = "variables must be a JSON object of {variableId: value}";
3627
3704
  function isVariablesPayload(value) {
@@ -3663,7 +3740,7 @@ function injectProjectSignature(html, signature) {
3663
3740
  ${html}`;
3664
3741
  }
3665
3742
  function readStudioMotionManifestContent(projectDir) {
3666
- const manifestPath = join9(projectDir, STUDIO_MOTION_PATH);
3743
+ const manifestPath = join10(projectDir, STUDIO_MOTION_PATH);
3667
3744
  if (!existsSync5(manifestPath)) return "";
3668
3745
  try {
3669
3746
  return readFileSync8(manifestPath, "utf-8");
@@ -3779,9 +3856,9 @@ function variablesEtagSalt(raw) {
3779
3856
  return `:vars:${createHash4("sha1").update(raw).digest("hex").slice(0, 12)}`;
3780
3857
  }
3781
3858
  function previewVariablesFromRequest(rawVariables) {
3782
- const parse = parsePreviewVariablesParam(rawVariables);
3783
- if (!parse.ok) return { error: parse.error };
3784
- return { raw: rawVariables, values: parse.values };
3859
+ const parse2 = parsePreviewVariablesParam(rawVariables);
3860
+ if (!parse2.ok) return { error: parse2.error };
3861
+ return { raw: rawVariables, values: parse2.values };
3785
3862
  }
3786
3863
  function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath) {
3787
3864
  return injectStudioMotionScript(
@@ -3808,14 +3885,16 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
3808
3885
  }
3809
3886
  }
3810
3887
  function resolveProjectMainHtml(projectDir, projectId) {
3811
- const indexPath = join9(projectDir, "index.html");
3888
+ const indexPath = join10(projectDir, "index.html");
3889
+ const blockHtmlPath = join10(projectDir, `${projectId}.html`);
3890
+ recordPreviewRead(projectDir, indexPath);
3891
+ recordPreviewRead(projectDir, blockHtmlPath);
3812
3892
  if (existsSync5(indexPath)) {
3813
3893
  return {
3814
3894
  html: readFileSync8(indexPath, "utf-8"),
3815
3895
  compositionPath: "index.html"
3816
3896
  };
3817
3897
  }
3818
- const blockHtmlPath = join9(projectDir, `${projectId}.html`);
3819
3898
  if (existsSync5(blockHtmlPath)) {
3820
3899
  return {
3821
3900
  html: readFileSync8(blockHtmlPath, "utf-8"),
@@ -3847,13 +3926,18 @@ function registerPreviewRoutes(api, adapter) {
3847
3926
  const diskMain = resolveProjectMainHtml(project.dir, project.id);
3848
3927
  const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
3849
3928
  try {
3850
- let bundled = await adapter.bundle(project.dir, { stampHfIds: true });
3929
+ let bundled = await adapter.bundle(project.dir, {
3930
+ stampHfIds: true,
3931
+ onRead: (filePath) => recordPreviewRead(project.dir, filePath)
3932
+ });
3851
3933
  let mainCompositionPath = "index.html";
3852
3934
  if (!bundled) {
3853
3935
  if (!diskMain) return null;
3854
3936
  bundled = stripEmbeddedRuntimeScripts2(normalizedDisk ?? diskMain.html);
3855
3937
  mainCompositionPath = diskMain.compositionPath;
3856
3938
  }
3939
+ recordPreviewReferences(project.dir, bundled);
3940
+ recordPreviewBuilt(project.dir);
3857
3941
  if (!bundled.includes("hyperframe.runtime") && !bundled.includes("hyperframes-preview-runtime")) {
3858
3942
  const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
3859
3943
  bundled = insertBeforeCloseTag2(bundled, "body", `${runtimeTag}
@@ -3957,6 +4041,8 @@ ${etag}`;
3957
4041
  const previewVariables = vars.values;
3958
4042
  const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
3959
4043
  const compFile = resolveWithinProject2(project.dir, compPath);
4044
+ if (compFile) recordPreviewRead(project.dir, compFile);
4045
+ recordPreviewRead(project.dir, "index.html");
3960
4046
  if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
3961
4047
  return c.text("not found", 404);
3962
4048
  }
@@ -3980,6 +4066,7 @@ ${etag}`;
3980
4066
  stamped
3981
4067
  );
3982
4068
  if (!html) return c.text("not found", 404);
4069
+ recordPreviewReferences(project.dir, html);
3983
4070
  html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
3984
4071
  html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
3985
4072
  if (previewVariables) html = injectPreviewVariables(html, previewVariables);
@@ -3990,13 +4077,14 @@ ${etag}`;
3990
4077
  const project = await adapter.resolveProject(c.req.param("id"));
3991
4078
  if (!project) return c.json({ error: "not found" }, 404);
3992
4079
  const subPath = requestSubPath(c.req.url, "projects/:id/preview");
3993
- const candidate = resolve4(project.dir, subPath);
4080
+ const candidate = resolve5(project.dir, subPath);
3994
4081
  const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
3995
4082
  if (!file) {
3996
4083
  return c.text("not found", 404);
3997
4084
  }
3998
- const stat2 = existsSync5(file) ? statSync4(file) : null;
3999
- if (!stat2?.isFile()) {
4085
+ recordPreviewRead(project.dir, file);
4086
+ const stat3 = existsSync5(file) ? statSync4(file) : null;
4087
+ if (!stat3?.isFile()) {
4000
4088
  return c.text("not found", 404);
4001
4089
  }
4002
4090
  const contentType = getMimeType(subPath);
@@ -4007,7 +4095,7 @@ ${etag}`;
4007
4095
  if (!isProxyVariantRequest(proxyParam) || !contentType.startsWith("video/") || !isAutoProxyEnabled(adapter)) {
4008
4096
  return c.text("not found", 404);
4009
4097
  }
4010
- const facts = await probeAssetCodec(file);
4098
+ const facts = await probeAssetCodec(file, void 0, mediaCodecProbeCache);
4011
4099
  const eligibility = decideMediaProxyEligibility(facts);
4012
4100
  if (!eligibility.eligible) {
4013
4101
  return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);
@@ -4018,7 +4106,7 @@ ${etag}`;
4018
4106
  return c.text("media proxy variant does not match asset", 422);
4019
4107
  }
4020
4108
  }
4021
- const tag = settledFileTag(stat2);
4109
+ const tag = settledFileTag(stat3);
4022
4110
  const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
4023
4111
  const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
4024
4112
  if (!isText && etag) {
@@ -4087,7 +4175,7 @@ ${etag}`;
4087
4175
 
4088
4176
  // src/routes/lint.ts
4089
4177
  import { readFileSync as readFileSync9 } from "fs";
4090
- import { join as join10 } from "path";
4178
+ import { join as join11 } from "path";
4091
4179
  async function collectProjectFindings(adapter, projectDir) {
4092
4180
  const findings = [];
4093
4181
  const coveredFiles = /* @__PURE__ */ new Set();
@@ -4105,7 +4193,7 @@ async function lintUncoveredHtml(adapter, projectDir, htmlFiles, coveredFiles) {
4105
4193
  const findings = [];
4106
4194
  for (const file of htmlFiles) {
4107
4195
  if (coveredFiles.has(file)) continue;
4108
- const content = readFileSync9(join10(projectDir, file), "utf-8");
4196
+ const content = readFileSync9(join11(projectDir, file), "utf-8");
4109
4197
  const result = await adapter.lint(content, { filePath: file, isSubComposition: true });
4110
4198
  for (const finding of result?.findings ?? []) {
4111
4199
  findings.push({ ...finding, file });
@@ -4139,7 +4227,7 @@ function registerLintRoutes(api, adapter) {
4139
4227
  // src/routes/render.ts
4140
4228
  import { streamSSE } from "hono/streaming";
4141
4229
  import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
4142
- import { join as join11 } from "path";
4230
+ import { join as join12 } from "path";
4143
4231
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
4144
4232
  import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
4145
4233
  var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
@@ -4207,7 +4295,7 @@ function registerRenderRoutes(api, adapter) {
4207
4295
  const rendersDir = adapter.rendersDir(project);
4208
4296
  if (!existsSync6(rendersDir)) mkdirSync5(rendersDir, { recursive: true });
4209
4297
  const ext = FORMAT_EXT[format] ?? ".mp4";
4210
- const outputPath = join11(rendersDir, `${jobId}${ext}`);
4298
+ const outputPath = join12(rendersDir, `${jobId}${ext}`);
4211
4299
  const jobState = adapter.startRender({
4212
4300
  project,
4213
4301
  outputPath,
@@ -4308,7 +4396,7 @@ function registerRenderRoutes(api, adapter) {
4308
4396
  if (state.id === jobId && state.outputPath) {
4309
4397
  const dir = state.outputPath.replace(/\/[^/]+$/, "");
4310
4398
  for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
4311
- const fp = join11(dir, `${jobId}${ext}`);
4399
+ const fp = join12(dir, `${jobId}${ext}`);
4312
4400
  if (existsSync6(fp)) unlinkSync3(fp);
4313
4401
  }
4314
4402
  break;
@@ -4343,10 +4431,10 @@ function registerRenderRoutes(api, adapter) {
4343
4431
  const rendersDir = adapter.rendersDir(project);
4344
4432
  if (!existsSync6(rendersDir)) return c.json({ renders: [] });
4345
4433
  const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4346
- const fp = join11(rendersDir, f);
4347
- const stat2 = statSync5(fp);
4434
+ const fp = join12(rendersDir, f);
4435
+ const stat3 = statSync5(fp);
4348
4436
  const rid = f.replace(/\.(mp4|webm|mov)$/, "");
4349
- const metaPath = join11(rendersDir, `${rid}.meta.json`);
4437
+ const metaPath = join12(rendersDir, `${rid}.meta.json`);
4350
4438
  let status = "complete";
4351
4439
  let durationMs;
4352
4440
  if (existsSync6(metaPath)) {
@@ -4360,8 +4448,8 @@ function registerRenderRoutes(api, adapter) {
4360
4448
  return {
4361
4449
  id: rid,
4362
4450
  filename: f,
4363
- size: stat2.size,
4364
- createdAt: stat2.mtimeMs,
4451
+ size: stat3.size,
4452
+ createdAt: stat3.mtimeMs,
4365
4453
  status,
4366
4454
  durationMs
4367
4455
  };
@@ -4372,7 +4460,7 @@ function registerRenderRoutes(api, adapter) {
4372
4460
  id: file.id,
4373
4461
  status: file.status,
4374
4462
  progress: 100,
4375
- outputPath: join11(rendersDir, file.filename),
4463
+ outputPath: join12(rendersDir, file.filename),
4376
4464
  createdAt: file.createdAt
4377
4465
  });
4378
4466
  }
@@ -4381,80 +4469,11 @@ function registerRenderRoutes(api, adapter) {
4381
4469
  });
4382
4470
  }
4383
4471
 
4384
- // src/routes/thumbnail.ts
4385
- import {
4386
- closeSync as closeSync3,
4387
- existsSync as existsSync7,
4388
- fstatSync as fstatSync3,
4389
- openSync as openSync3,
4390
- mkdirSync as mkdirSync6,
4391
- readFileSync as readFileSync12,
4392
- readdirSync as readdirSync6,
4393
- renameSync as renameSync3,
4394
- rmSync as rmSync4,
4395
- statSync as statSync6,
4396
- unlinkSync as unlinkSync4,
4397
- writeFileSync as writeFileSync6
4398
- } from "fs";
4399
- import { join as join12 } from "path";
4400
- import { createHash as createHash5, randomUUID as randomUUID4 } from "crypto";
4401
-
4402
- // src/helpers/compositionInputs.ts
4403
- import { readFileSync as readFileSync11 } from "fs";
4404
- var ROOT_COMPOSITION = "index.html";
4405
- var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
4406
- function projectReader(projectDir) {
4407
- const sources = /* @__PURE__ */ new Map();
4408
- return (compPath) => {
4409
- if (!sources.has(compPath)) {
4410
- const file = resolveWithinProject2(projectDir, compPath);
4411
- let source = null;
4412
- try {
4413
- source = file ? readFileSync11(file, "utf-8") : null;
4414
- } catch {
4415
- }
4416
- sources.set(compPath, source);
4417
- }
4418
- return sources.get(compPath) ?? null;
4419
- };
4420
- }
4421
- function normalizeSource(src) {
4422
- if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
4423
- const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
4424
- return path || null;
4425
- }
4426
- function closureOf(read, compPath) {
4427
- const closure = /* @__PURE__ */ new Set();
4428
- const visit = (path) => {
4429
- if (closure.has(path)) return;
4430
- closure.add(path);
4431
- for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
4432
- const mounted = normalizeSource(match[2] ?? "");
4433
- if (mounted) visit(mounted);
4434
- }
4435
- };
4436
- visit(compPath);
4437
- return closure;
4438
- }
4439
- var inputSignatures = /* @__PURE__ */ new Map();
4440
- function compositionInputSignature(projectDir, compPath, projectSignature) {
4441
- const key = `${projectDir}\0${compPath}`;
4442
- const known = inputSignatures.get(key);
4443
- if (known?.projectSignature === projectSignature) return known.inputSignature;
4444
- const read = projectReader(projectDir);
4445
- const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
4446
- const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
4447
- const inputSignature = createProjectSignature(projectDir, new Set(siblings));
4448
- inputSignatures.set(key, { projectSignature, inputSignature });
4449
- return inputSignature;
4450
- }
4451
- function compositionsAffectedBy(projectDir, changedPath) {
4452
- const changed = changedPath.replace(/\\/g, "/");
4453
- const read = projectReader(projectDir);
4454
- const mounted = closureOf(read, ROOT_COMPOSITION);
4455
- if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
4456
- return [...mounted].filter((path) => closureOf(read, path).has(changed));
4457
- }
4472
+ // src/routes/imageThumbnail.ts
4473
+ import { HTTPException } from "hono/http-exception";
4474
+ import { open, stat } from "fs/promises";
4475
+ import { createHash as createHash5 } from "crypto";
4476
+ import sharp from "sharp";
4458
4477
 
4459
4478
  // src/routes/thumbnailGenerationCoordinator.ts
4460
4479
  var ThumbnailGenerationCoordinator = class {
@@ -4473,10 +4492,10 @@ var ThumbnailGenerationCoordinator = class {
4473
4492
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4474
4493
  let entry = this.entries.get(key);
4475
4494
  if (!entry) {
4476
- let resolve8;
4495
+ let resolve9;
4477
4496
  let reject;
4478
4497
  const promise = new Promise((resolvePromise, rejectPromise) => {
4479
- resolve8 = resolvePromise;
4498
+ resolve9 = resolvePromise;
4480
4499
  reject = rejectPromise;
4481
4500
  });
4482
4501
  entry = {
@@ -4486,7 +4505,7 @@ var ThumbnailGenerationCoordinator = class {
4486
4505
  state: "queued",
4487
4506
  work,
4488
4507
  promise,
4489
- resolve: resolve8,
4508
+ resolve: resolve9,
4490
4509
  reject
4491
4510
  };
4492
4511
  this.entries.set(key, entry);
@@ -4500,7 +4519,7 @@ var ThumbnailGenerationCoordinator = class {
4500
4519
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4501
4520
  }
4502
4521
  lease(entry, signal) {
4503
- return new Promise((resolve8, reject) => {
4522
+ return new Promise((resolve9, reject) => {
4504
4523
  let released = false;
4505
4524
  const release = () => {
4506
4525
  if (released) return;
@@ -4524,7 +4543,7 @@ var ThumbnailGenerationCoordinator = class {
4524
4543
  entry.promise.then(
4525
4544
  (value) => {
4526
4545
  release();
4527
- resolve8(value);
4546
+ resolve9(value);
4528
4547
  },
4529
4548
  (reason) => {
4530
4549
  release();
@@ -4559,6 +4578,171 @@ var ThumbnailGenerationCoordinator = class {
4559
4578
  };
4560
4579
  var thumbnailGenerationCoordinator = new ThumbnailGenerationCoordinator(1);
4561
4580
 
4581
+ // src/routes/imageThumbnail.ts
4582
+ var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
4583
+ var MAX_CACHE_BYTES = 8 * 1024 * 1024;
4584
+ async function generateThumbnail(path, signal) {
4585
+ const file = await open(path, "r");
4586
+ try {
4587
+ const info = await file.stat();
4588
+ if (!info.isFile() || info.size > MAX_SOURCE_BYTES) throw new Error("Unsupported image");
4589
+ const source = Buffer.alloc(info.size);
4590
+ let offset = 0;
4591
+ while (offset < source.length) {
4592
+ signal.throwIfAborted();
4593
+ const { bytesRead } = await file.read(source, offset, source.length - offset, offset);
4594
+ if (!bytesRead) break;
4595
+ offset += bytesRead;
4596
+ }
4597
+ if (source[0] !== 255 || source[1] !== 216) throw new Error("Unsupported image");
4598
+ const image = sharp(source.subarray(0, offset), { limitInputPixels: 4e7 });
4599
+ if ((await image.metadata()).format !== "jpeg") throw new Error("Unsupported image");
4600
+ signal.throwIfAborted();
4601
+ const result = await image.rotate().resize(240, 135, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 80 }).toBuffer();
4602
+ signal.throwIfAborted();
4603
+ return result;
4604
+ } finally {
4605
+ await file.close();
4606
+ }
4607
+ }
4608
+ async function resolveSource(adapter, id, url) {
4609
+ const project = await adapter.resolveProject(id);
4610
+ if (!project) throw new HTTPException(404);
4611
+ let relative7;
4612
+ try {
4613
+ relative7 = requestSubPath(url, "projects/:id/image-thumbnail");
4614
+ } catch {
4615
+ throw new HTTPException(400);
4616
+ }
4617
+ if (!/\.jpe?g$/i.test(relative7)) throw new HTTPException(415);
4618
+ const path = pinWithinProject(project.dir, relative7);
4619
+ if (!path) throw new HTTPException(404);
4620
+ const info = await stat(path).catch(() => {
4621
+ throw new HTTPException(404);
4622
+ });
4623
+ if (!info.isFile()) throw new HTTPException(404);
4624
+ if (info.size > MAX_SOURCE_BYTES) throw new HTTPException(413);
4625
+ return { path, info };
4626
+ }
4627
+ function registerImageThumbnailRoutes(api, adapter) {
4628
+ const coordinator = new ThumbnailGenerationCoordinator(2);
4629
+ const cache = /* @__PURE__ */ new Map();
4630
+ let cacheBytes = 0;
4631
+ const remember = (key, image) => {
4632
+ cacheBytes -= cache.get(key)?.byteLength ?? 0;
4633
+ cache.delete(key);
4634
+ cache.set(key, image);
4635
+ cacheBytes += image.byteLength;
4636
+ while (cacheBytes > MAX_CACHE_BYTES || cache.size > 128) {
4637
+ const oldest = cache.keys().next().value;
4638
+ if (oldest === void 0) break;
4639
+ cacheBytes -= cache.get(oldest)?.byteLength ?? 0;
4640
+ cache.delete(oldest);
4641
+ }
4642
+ };
4643
+ api.get("/projects/:id/image-thumbnail/*", async (c) => {
4644
+ try {
4645
+ const { path, info } = await resolveSource(adapter, c.req.param("id"), c.req.url);
4646
+ const key = createHash5("sha256").update(JSON.stringify([path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])).digest("hex");
4647
+ const etag = `"${key}"`;
4648
+ c.header("ETag", etag);
4649
+ c.header("Cache-Control", "private, no-cache");
4650
+ c.header("X-Content-Type-Options", "nosniff");
4651
+ if (c.req.header("If-None-Match") === etag) return c.body(null, 304);
4652
+ let result = cache.get(key);
4653
+ if (result) remember(key, result);
4654
+ else {
4655
+ if (coordinator.protectedKeys().size >= 32) return c.text("thumbnail queue full", 429);
4656
+ result = await coordinator.acquire(key, c.req.raw.signal, async (signal) => {
4657
+ const image = await generateThumbnail(path, signal);
4658
+ remember(key, image);
4659
+ return image;
4660
+ }) ?? void 0;
4661
+ }
4662
+ if (!result) return c.text("image unavailable", 422);
4663
+ c.header("Content-Type", "image/jpeg");
4664
+ return c.body(new Uint8Array(result));
4665
+ } catch (error) {
4666
+ if (error instanceof HTTPException) return error.getResponse();
4667
+ return c.text("image unavailable", 422);
4668
+ }
4669
+ });
4670
+ }
4671
+
4672
+ // src/routes/thumbnail.ts
4673
+ import {
4674
+ closeSync as closeSync3,
4675
+ fstatSync as fstatSync3,
4676
+ openSync as openSync3,
4677
+ mkdirSync as mkdirSync6,
4678
+ readFileSync as readFileSync12,
4679
+ readdirSync as readdirSync6,
4680
+ renameSync as renameSync3,
4681
+ rmSync as rmSync4,
4682
+ statSync as statSync6,
4683
+ unlinkSync as unlinkSync4,
4684
+ writeFileSync as writeFileSync6
4685
+ } from "fs";
4686
+ import { join as join13 } from "path";
4687
+ import { createHash as createHash6, randomUUID as randomUUID4 } from "crypto";
4688
+
4689
+ // src/helpers/compositionInputs.ts
4690
+ import { readFileSync as readFileSync11 } from "fs";
4691
+ var ROOT_COMPOSITION = "index.html";
4692
+ var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
4693
+ function projectReader(projectDir) {
4694
+ const sources = /* @__PURE__ */ new Map();
4695
+ return (compPath) => {
4696
+ if (!sources.has(compPath)) {
4697
+ const file = resolveWithinProject2(projectDir, compPath);
4698
+ let source = null;
4699
+ try {
4700
+ source = file ? readFileSync11(file, "utf-8") : null;
4701
+ } catch {
4702
+ }
4703
+ sources.set(compPath, source);
4704
+ }
4705
+ return sources.get(compPath) ?? null;
4706
+ };
4707
+ }
4708
+ function normalizeSource(src) {
4709
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
4710
+ const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
4711
+ return path || null;
4712
+ }
4713
+ function closureOf(read, compPath) {
4714
+ const closure = /* @__PURE__ */ new Set();
4715
+ const visit = (path) => {
4716
+ if (closure.has(path)) return;
4717
+ closure.add(path);
4718
+ for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
4719
+ const mounted = normalizeSource(match[2] ?? "");
4720
+ if (mounted) visit(mounted);
4721
+ }
4722
+ };
4723
+ visit(compPath);
4724
+ return closure;
4725
+ }
4726
+ var inputSignatures = /* @__PURE__ */ new Map();
4727
+ function compositionInputSignature(projectDir, compPath, projectSignature) {
4728
+ const key = `${projectDir}\0${compPath}`;
4729
+ const known = inputSignatures.get(key);
4730
+ if (known?.projectSignature === projectSignature) return known.inputSignature;
4731
+ const read = projectReader(projectDir);
4732
+ const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
4733
+ const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
4734
+ const inputSignature = createProjectSignature(projectDir, new Set(siblings));
4735
+ inputSignatures.set(key, { projectSignature, inputSignature });
4736
+ return inputSignature;
4737
+ }
4738
+ function compositionsAffectedBy(projectDir, changedPath) {
4739
+ const changed = changedPath.replace(/\\/g, "/");
4740
+ const read = projectReader(projectDir);
4741
+ const mounted = closureOf(read, ROOT_COMPOSITION);
4742
+ if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
4743
+ return [...mounted].filter((path) => closureOf(read, path).has(changed));
4744
+ }
4745
+
4562
4746
  // src/routes/thumbnail.ts
4563
4747
  var THUMBNAIL_CACHE_VERSION = "v4";
4564
4748
  var THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
@@ -4567,10 +4751,16 @@ var THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024;
4567
4751
  var THUMBNAIL_CACHE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
4568
4752
  var prunedCacheDirs = /* @__PURE__ */ new Set();
4569
4753
  function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4570
- if (!existsSync7(cacheDir)) return;
4571
- const files = readdirSync6(cacheDir, { withFileTypes: true }).flatMap((entry) => {
4754
+ let entries;
4755
+ try {
4756
+ entries = readdirSync6(cacheDir, { withFileTypes: true });
4757
+ } catch (err) {
4758
+ if (err.code === "ENOENT") return;
4759
+ throw err;
4760
+ }
4761
+ const files = entries.flatMap((entry) => {
4572
4762
  if (!entry.isFile()) return [];
4573
- const path = join12(cacheDir, entry.name);
4763
+ const path = join13(cacheDir, entry.name);
4574
4764
  try {
4575
4765
  const stats = statSync6(path);
4576
4766
  return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
@@ -4606,22 +4796,30 @@ function writeThumbnailAtomically(path, buffer) {
4606
4796
  rmSync4(temporaryPath, { force: true });
4607
4797
  }
4608
4798
  }
4609
- function readCompositionSource(file) {
4799
+ function readFileOnce(file) {
4610
4800
  let fd;
4611
4801
  try {
4612
4802
  fd = openSync3(file, "r");
4613
4803
  } catch (err) {
4614
- if (err.code === "ENOENT") return "missing";
4804
+ const code = err.code;
4805
+ if (code === "ENOENT") return "missing";
4806
+ if (code === "ENOTDIR") return "not-a-file";
4615
4807
  throw err;
4616
4808
  }
4617
4809
  try {
4618
- const stat2 = fstatSync3(fd);
4619
- if (!stat2.isFile()) return "not-a-file";
4620
- return { html: readFileSync12(fd, "utf-8"), mtimeMs: stat2.mtimeMs };
4810
+ const stat3 = fstatSync3(fd);
4811
+ if (!stat3.isFile()) return "not-a-file";
4812
+ return { data: readFileSync12(fd), mtimeMs: stat3.mtimeMs };
4621
4813
  } finally {
4622
4814
  closeSync3(fd);
4623
4815
  }
4624
4816
  }
4817
+ function manifestKey(file) {
4818
+ const manifest = readFileOnce(file);
4819
+ if (typeof manifest === "string") return { key: "", mtimeMs: -Infinity };
4820
+ const hash = createHash6("sha1").update(manifest.data).digest("hex").slice(0, 16);
4821
+ return { key: `_${hash}`, mtimeMs: Math.round(manifest.mtimeMs) };
4822
+ }
4625
4823
  function registerThumbnailRoutes(api, adapter) {
4626
4824
  api.get("/projects/:id/thumbnail/*", async (c) => {
4627
4825
  if (!adapter.generateThumbnail) {
@@ -4633,7 +4831,7 @@ function registerThumbnailRoutes(api, adapter) {
4633
4831
  let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
4634
4832
  if (compPath && !compPath.includes(".")) compPath += ".html";
4635
4833
  const htmlFile = resolveWithinProject2(project.dir, compPath);
4636
- const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
4834
+ const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file";
4637
4835
  if (source === "not-a-file") return c.json({ error: "not found" }, 404);
4638
4836
  const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
4639
4837
  const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
@@ -4655,8 +4853,8 @@ function registerThumbnailRoutes(api, adapter) {
4655
4853
  let sourceMtime = 0;
4656
4854
  let sourceKey = "";
4657
4855
  if (source !== "missing") {
4658
- const { html } = source;
4659
- sourceKey = `_${createHash5("sha1").update(html).digest("hex").slice(0, 16)}`;
4856
+ const html = source.data.toString("utf-8");
4857
+ sourceKey = `_${createHash6("sha1").update(html).digest("hex").slice(0, 16)}`;
4660
4858
  sourceMtime = Math.round(source.mtimeMs);
4661
4859
  if (!vpWidth) {
4662
4860
  const wMatch = html.match(/data-width=["'](\d+)["']/);
@@ -4665,31 +4863,20 @@ function registerThumbnailRoutes(api, adapter) {
4665
4863
  if (hMatch?.[1]) compH = parseInt(hMatch[1]);
4666
4864
  }
4667
4865
  }
4668
- const manualEditsFile = join12(project.dir, STUDIO_MANUAL_EDITS_PATH);
4669
- let manualEditsKey = "";
4670
- if (existsSync7(manualEditsFile)) {
4671
- const manualEditsContent = readFileSync12(manualEditsFile, "utf-8");
4672
- manualEditsKey = `_${createHash5("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4673
- sourceMtime = Math.max(sourceMtime, Math.round(statSync6(manualEditsFile).mtimeMs));
4674
- }
4675
- const motionFile = join12(project.dir, STUDIO_MOTION_PATH);
4676
- let motionKey = "";
4677
- if (existsSync7(motionFile)) {
4678
- const motionContent = readFileSync12(motionFile, "utf-8");
4679
- motionKey = `_${createHash5("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4680
- sourceMtime = Math.max(sourceMtime, Math.round(statSync6(motionFile).mtimeMs));
4681
- }
4866
+ const manualEdits = manifestKey(join13(project.dir, STUDIO_MANUAL_EDITS_PATH));
4867
+ const motion = manifestKey(join13(project.dir, STUDIO_MOTION_PATH));
4868
+ sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
4682
4869
  const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
4683
4870
  const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4684
- const cacheDir = join12(project.dir, ".thumbnails");
4871
+ const cacheDir = join13(project.dir, ".thumbnails");
4685
4872
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
4686
4873
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
4687
- const inputSignatureKey = `_${createHash5("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4874
+ const inputSignatureKey = `_${createHash6("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4688
4875
  const outputScale = outputMode === "source" ? 1 : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
4689
4876
  const outputWidth = Math.max(1, Math.round(compW * outputScale));
4690
4877
  const outputHeight = Math.max(1, Math.round(compH * outputScale));
4691
- const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
4692
- const cachePath = join12(cacheDir, cacheKey);
4878
+ const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEdits.key}${motion.key}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
4879
+ const cachePath = join13(cacheDir, cacheKey);
4693
4880
  if (!prunedCacheDirs.has(cacheDir)) {
4694
4881
  prunedCacheDirs.add(cacheDir);
4695
4882
  pruneThumbnailCache(
@@ -4697,8 +4884,9 @@ function registerThumbnailRoutes(api, adapter) {
4697
4884
  /* @__PURE__ */ new Set([...thumbnailGenerationCoordinator.protectedKeys(), cachePath])
4698
4885
  );
4699
4886
  }
4700
- if (existsSync7(cachePath)) {
4701
- return new Response(new Uint8Array(readFileSync12(cachePath)), {
4887
+ const cached = readFileOnce(cachePath);
4888
+ if (typeof cached === "object") {
4889
+ return new Response(new Uint8Array(cached.data), {
4702
4890
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
4703
4891
  });
4704
4892
  }
@@ -4728,7 +4916,7 @@ function registerThumbnailRoutes(api, adapter) {
4728
4916
  if (afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
4729
4917
  return generated;
4730
4918
  }
4731
- if (!existsSync7(cacheDir)) mkdirSync6(cacheDir, { recursive: true });
4919
+ mkdirSync6(cacheDir, { recursive: true });
4732
4920
  writeThumbnailAtomically(cachePath, generated);
4733
4921
  return generated;
4734
4922
  }
@@ -4754,24 +4942,24 @@ function registerThumbnailRoutes(api, adapter) {
4754
4942
  }
4755
4943
 
4756
4944
  // src/routes/waveform.ts
4757
- import { existsSync as existsSync8, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4758
- import { join as join13, resolve as resolve5 } from "path";
4945
+ import { existsSync as existsSync7, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4946
+ import { join as join14, resolve as resolve6 } from "path";
4759
4947
  import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
4760
4948
  function registerWaveformRoutes(api, adapter) {
4761
4949
  api.get("/projects/:id/waveform/*", async (c) => {
4762
4950
  const project = await adapter.resolveProject(c.req.param("id"));
4763
4951
  if (!project) return c.json({ error: "not found" }, 404);
4764
4952
  const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
4765
- const audioPath = resolve5(project.dir, assetPath);
4953
+ const audioPath = resolve6(project.dir, assetPath);
4766
4954
  if (!isWithinProjectRoot2(project.dir, audioPath)) {
4767
4955
  return c.json({ error: "file not found" }, 404);
4768
4956
  }
4769
4957
  const stats = statSync7(audioPath, { throwIfNoEntry: false });
4770
4958
  if (!stats) return c.json({ error: "file not found" }, 404);
4771
- const cacheDir = join13(project.dir, ".waveform-cache");
4772
- const cachePath = join13(cacheDir, buildWaveformCacheKey(assetPath, stats));
4959
+ const cacheDir = join14(project.dir, ".waveform-cache");
4960
+ const cachePath = join14(cacheDir, buildWaveformCacheKey(assetPath, stats));
4773
4961
  try {
4774
- if (isWaveformCacheDirectory(cacheDir) && existsSync8(cachePath)) {
4962
+ if (isWaveformCacheDirectory(cacheDir) && existsSync7(cachePath)) {
4775
4963
  const peaks2 = JSON.parse(readFileSync13(cachePath, "utf-8"));
4776
4964
  return c.json({ peaks: peaks2 });
4777
4965
  }
@@ -4904,12 +5092,12 @@ function registerFontRoutes(api) {
4904
5092
  return c.json({ error: "font file not accessible" }, 404);
4905
5093
  }
4906
5094
  try {
4907
- const stat2 = fstatSync4(fd);
4908
- if (stat2.size > SYSTEM_FONT_SIZE_LIMIT) {
5095
+ const stat3 = fstatSync4(fd);
5096
+ if (stat3.size > SYSTEM_FONT_SIZE_LIMIT) {
4909
5097
  return c.json({ error: "font file too large" }, 413);
4910
5098
  }
4911
- const buffer = Buffer.alloc(stat2.size);
4912
- readSync(fd, buffer, 0, stat2.size, 0);
5099
+ const buffer = Buffer.alloc(stat3.size);
5100
+ readSync(fd, buffer, 0, stat3.size, 0);
4913
5101
  const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
4914
5102
  const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
4915
5103
  return new Response(buffer, {
@@ -4937,7 +5125,7 @@ function registerRegistryRoutes(api, adapter) {
4937
5125
  });
4938
5126
  api.post("/projects/:id/registry/install", async (c) => {
4939
5127
  if (!adapter.installRegistryBlock) {
4940
- return c.json({ error: "Registry install not available" }, 501);
5128
+ return c.json({ error: "Installing catalog items needs hyperframes preview" }, 501);
4941
5129
  }
4942
5130
  const project = await adapter.resolveProject(c.req.param("id"));
4943
5131
  if (!project) return c.json({ error: "Project not found" }, 404);
@@ -5055,8 +5243,8 @@ function registerSelectionRoutes(api, adapter) {
5055
5243
 
5056
5244
  // src/routes/media.ts
5057
5245
  import { streamSSE as streamSSE2 } from "hono/streaming";
5058
- import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
5059
- import { basename as basename3, dirname as dirname6, extname as extname2, join as join14 } from "path";
5246
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5247
+ import { basename as basename3, dirname as dirname7, extname as extname2, join as join15 } from "path";
5060
5248
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
5061
5249
  ".mp4",
5062
5250
  ".mov",
@@ -5093,7 +5281,7 @@ function uniqueAssetPath(projectDir, assetPath) {
5093
5281
  const ext = extname2(assetPath);
5094
5282
  const withoutExt = assetPath.slice(0, -ext.length);
5095
5283
  let candidate = assetPath;
5096
- for (let index = 2; existsSync9(join14(projectDir, candidate)); index++) {
5284
+ for (let index = 2; existsSync8(join15(projectDir, candidate)); index++) {
5097
5285
  candidate = `${withoutExt}-${index}${ext}`;
5098
5286
  }
5099
5287
  return candidate;
@@ -5144,7 +5332,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5144
5332
  }
5145
5333
  const filePath = resolveWithinProject2(project.dir, assetPath);
5146
5334
  if (!filePath) return c.json({ error: "forbidden" }, 403);
5147
- if (!existsSync9(filePath)) return c.json({ error: "media not found" }, 404);
5335
+ if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
5148
5336
  return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
5149
5337
  });
5150
5338
  api.post(
@@ -5166,7 +5354,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
5166
5354
  }
5167
5355
  const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
5168
5356
  if (!inputPath) return c.json({ error: "forbidden" }, 403);
5169
- if (!existsSync9(inputPath)) return c.json({ error: "input media not found" }, 404);
5357
+ if (!existsSync8(inputPath)) return c.json({ error: "input media not found" }, 404);
5170
5358
  const inputIsVideo = isVideoPath(inputAssetPath);
5171
5359
  const inputIsImage = isImagePath(inputAssetPath);
5172
5360
  if (!inputIsVideo && !inputIsImage) {
@@ -5200,8 +5388,8 @@ function registerMediaRoutes(api, adapter, options = {}) {
5200
5388
  return c.json({ error: "forbidden" }, 403);
5201
5389
  }
5202
5390
  }
5203
- mkdirSync7(dirname6(outputPath), { recursive: true });
5204
- if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
5391
+ mkdirSync7(dirname7(outputPath), { recursive: true });
5392
+ if (backgroundOutputPath) mkdirSync7(dirname7(backgroundOutputPath), { recursive: true });
5205
5393
  const jobId = makeJobId(project.id, mediaJobs);
5206
5394
  const state = adapter.startBackgroundRemoval({
5207
5395
  project,
@@ -5258,12 +5446,12 @@ function registerMediaRoutes(api, adapter, options = {}) {
5258
5446
  }
5259
5447
 
5260
5448
  // src/routes/globalAssets.ts
5261
- import { existsSync as existsSync10, readFileSync as readFileSync14 } from "fs";
5449
+ import { existsSync as existsSync9, readFileSync as readFileSync14 } from "fs";
5262
5450
  import { homedir } from "os";
5263
- import { join as join15 } from "path";
5451
+ import { join as join16 } from "path";
5264
5452
  function readGlobalAssets(home = homedir()) {
5265
- const manifestPath = join15(home, ".media", "manifest.jsonl");
5266
- if (!existsSync10(manifestPath)) return [];
5453
+ const manifestPath = join16(home, ".media", "manifest.jsonl");
5454
+ if (!existsSync9(manifestPath)) return [];
5267
5455
  const out = [];
5268
5456
  for (const line of readFileSync14(manifestPath, "utf8").split("\n")) {
5269
5457
  if (!line.trim()) continue;
@@ -5286,7 +5474,7 @@ function registerGlobalAssetRoutes(api) {
5286
5474
  import { randomUUID as randomUUID8 } from "crypto";
5287
5475
  import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
5288
5476
  import {
5289
- existsSync as existsSync12,
5477
+ existsSync as existsSync11,
5290
5478
  lstatSync as lstatSync4,
5291
5479
  statSync as statSync9,
5292
5480
  mkdirSync as mkdirSync11,
@@ -5296,21 +5484,21 @@ import {
5296
5484
  rmSync as rmSync6
5297
5485
  } from "fs";
5298
5486
  import { homedir as homedir2 } from "os";
5299
- import { dirname as dirname9, isAbsolute as isAbsolute3, join as join19, relative as relative5, resolve as resolve7, sep as sep4 } from "path";
5487
+ import { dirname as dirname10, isAbsolute as isAbsolute3, join as join20, relative as relative6, resolve as resolve8, sep as sep4 } from "path";
5300
5488
 
5301
5489
  // src/history/blobStore.ts
5302
- import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
5490
+ import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
5303
5491
  import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
5304
- import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
5305
- import { dirname as dirname7, join as join16 } from "path";
5492
+ import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat as stat2 } from "fs/promises";
5493
+ import { dirname as dirname8, join as join17 } from "path";
5306
5494
  var BLOB_HASH = /^[0-9a-f]{64}$/;
5307
5495
  async function hashFile(path) {
5308
- const hash = createHash6("sha256");
5496
+ const hash = createHash7("sha256");
5309
5497
  for await (const chunk of createReadStream2(path)) hash.update(chunk);
5310
5498
  return hash.digest("hex");
5311
5499
  }
5312
5500
  async function cloneOrCopy(from, to, beforeReplace) {
5313
- await mkdir(dirname7(to), { recursive: true });
5501
+ await mkdir(dirname8(to), { recursive: true });
5314
5502
  const temp = `${to}.${randomUUID5()}.tmp`;
5315
5503
  try {
5316
5504
  await copyFile(from, temp, constants3.COPYFILE_FICLONE);
@@ -5325,16 +5513,16 @@ async function openBlobStore(dir) {
5325
5513
  const sizes = /* @__PURE__ */ new Map();
5326
5514
  await mkdir(dir, { recursive: true });
5327
5515
  for (const shard of await readdir(dir))
5328
- for (const hash of await readdir(join16(dir, shard)).catch(() => []))
5329
- sizes.set(hash, (await stat(join16(dir, shard, hash))).size);
5516
+ for (const hash of await readdir(join17(dir, shard)).catch(() => []))
5517
+ sizes.set(hash, (await stat2(join17(dir, shard, hash))).size);
5330
5518
  const pathOf = (hash) => {
5331
5519
  if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
5332
- return join16(dir, hash.slice(0, 2), hash);
5520
+ return join17(dir, hash.slice(0, 2), hash);
5333
5521
  };
5334
5522
  let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
5335
5523
  return {
5336
5524
  async put(absPath) {
5337
- const temp = join16(dir, `incoming-${randomUUID5()}`);
5525
+ const temp = join17(dir, `incoming-${randomUUID5()}`);
5338
5526
  await mkdir(dir, { recursive: true });
5339
5527
  await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
5340
5528
  const hash = await hashFile(temp);
@@ -5342,8 +5530,8 @@ async function openBlobStore(dir) {
5342
5530
  await rm(temp, { force: true });
5343
5531
  return hash;
5344
5532
  }
5345
- await mkdir(dirname7(pathOf(hash)), { recursive: true });
5346
- const size = (await stat(temp)).size;
5533
+ await mkdir(dirname8(pathOf(hash)), { recursive: true });
5534
+ const size = (await stat2(temp)).size;
5347
5535
  await rename(temp, pathOf(hash));
5348
5536
  sizes.set(hash, size);
5349
5537
  total += size;
@@ -5367,13 +5555,13 @@ async function openBlobStore(dir) {
5367
5555
 
5368
5556
  // src/history/historyId.ts
5369
5557
  import { randomUUID as randomUUID6 } from "crypto";
5370
- import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5371
- import { join as join17, resolve as resolve6 } from "path";
5372
- var ID_PATH = join17(".hyperframes", "history-id");
5558
+ import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5559
+ import { join as join18, resolve as resolve7 } from "path";
5560
+ var ID_PATH = join18(".hyperframes", "history-id");
5373
5561
  var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5374
5562
  function readId(projectDir) {
5375
5563
  try {
5376
- const id = readFileSync15(join17(projectDir, ID_PATH), "utf-8").trim();
5564
+ const id = readFileSync15(join18(projectDir, ID_PATH), "utf-8").trim();
5377
5565
  return ID_SHAPE.test(id) ? id : null;
5378
5566
  } catch {
5379
5567
  return null;
@@ -5382,35 +5570,35 @@ function readId(projectDir) {
5382
5570
  var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
5383
5571
  function isRecordedFolder(historyDir, folder) {
5384
5572
  try {
5385
- const was = JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
5573
+ const was = JSON.parse(readFileSync15(join18(historyDir, "project.json"), "utf-8"));
5386
5574
  return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
5387
5575
  } catch {
5388
5576
  return false;
5389
5577
  }
5390
5578
  }
5391
5579
  function projectHistoryId(projectDir, historyRoot) {
5392
- const dir = resolve6(projectDir);
5580
+ const dir = resolve7(projectDir);
5393
5581
  const folder = statSync8(dir);
5394
5582
  let id = readId(dir);
5395
- if (!id || existsSync11(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
5583
+ if (!id || existsSync10(join18(historyRoot, id)) && !isRecordedFolder(join18(historyRoot, id), folder)) {
5396
5584
  id = randomUUID6();
5397
- mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5398
- writeFileSync7(join17(dir, ID_PATH), `${id}
5585
+ mkdirSync8(join18(dir, ".hyperframes"), { recursive: true });
5586
+ writeFileSync7(join18(dir, ID_PATH), `${id}
5399
5587
  `);
5400
5588
  }
5401
- recordProject(join17(historyRoot, id), dir, folder);
5589
+ recordProject(join18(historyRoot, id), dir, folder);
5402
5590
  return id;
5403
5591
  }
5404
5592
  function recordProject(historyDir, dir, folder) {
5405
5593
  const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
5406
5594
  mkdirSync8(historyDir, { recursive: true });
5407
- replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
5595
+ replaceFileAtomically(join18(historyDir, "project.json"), JSON.stringify(record), 420);
5408
5596
  }
5409
5597
 
5410
5598
  // src/history/ownerLock.ts
5411
5599
  import { randomUUID as randomUUID7 } from "crypto";
5412
5600
  import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
5413
- import { join as join18 } from "path";
5601
+ import { join as join19 } from "path";
5414
5602
  var HistoryBusyError = class extends Error {
5415
5603
  constructor(pid) {
5416
5604
  super(`This project's history is open in another process (pid ${pid}).`);
@@ -5469,7 +5657,7 @@ function evictDeadOwner(file) {
5469
5657
  }
5470
5658
  }
5471
5659
  async function takeHistoryOwnership(home, waitMs) {
5472
- const file = join18(home, "owner.pid");
5660
+ const file = join19(home, "owner.pid");
5473
5661
  const deadline = Date.now() + waitMs;
5474
5662
  mkdirSync9(home, { recursive: true });
5475
5663
  for (; ; ) {
@@ -5490,7 +5678,7 @@ async function takeHistoryOwnership(home, waitMs) {
5490
5678
 
5491
5679
  // src/history/historyLog.ts
5492
5680
  import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
5493
- import { dirname as dirname8 } from "path";
5681
+ import { dirname as dirname9 } from "path";
5494
5682
  var START = "start";
5495
5683
  function parseRecord(line) {
5496
5684
  try {
@@ -5542,7 +5730,7 @@ function writeLog(file, log) {
5542
5730
  ...log.entries.map((entry) => ({ type: "entry", entry })),
5543
5731
  ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5544
5732
  ];
5545
- mkdirSync10(dirname8(file), { recursive: true });
5733
+ mkdirSync10(dirname9(file), { recursive: true });
5546
5734
  replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5547
5735
  `).join(""), 420);
5548
5736
  }
@@ -5615,7 +5803,7 @@ function referencedHashes(log, current) {
5615
5803
  }
5616
5804
 
5617
5805
  // src/history/projectHistory.ts
5618
- var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
5806
+ var DEFAULT_HISTORY_ROOT = join20(homedir2(), ".cache", "hyperframes", "history");
5619
5807
  var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5620
5808
  var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
5621
5809
  var OUTSIDE = { kind: "outside", name: "Outside" };
@@ -5641,20 +5829,20 @@ async function removeEmptyFolders(dir) {
5641
5829
  throw error;
5642
5830
  }
5643
5831
  for (const entry of entries)
5644
- if (entry.isDirectory()) await removeEmptyFolders(join19(dir, entry.name));
5832
+ if (entry.isDirectory()) await removeEmptyFolders(join20(dir, entry.name));
5645
5833
  await rmdir(dir);
5646
5834
  }
5647
5835
  function fileLeftIn(dir, at, deleted) {
5648
5836
  for (const entry of readdirSync7(dir, { withFileTypes: true })) {
5649
5837
  const path = `${at}/${entry.name}`;
5650
- const left = entry.isDirectory() ? fileLeftIn(join19(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
5838
+ const left = entry.isDirectory() ? fileLeftIn(join20(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
5651
5839
  if (left) return left;
5652
5840
  }
5653
5841
  return void 0;
5654
5842
  }
5655
5843
  function ignoresCase(dir) {
5656
- const self = statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false });
5657
- const other = statSync9(join19(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
5844
+ const self = statSync9(join20(dir, ID_PATH), { throwIfNoEntry: false });
5845
+ const other = statSync9(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
5658
5846
  return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
5659
5847
  }
5660
5848
  var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
@@ -5670,8 +5858,8 @@ var Engine = class {
5670
5858
  this.options = options;
5671
5859
  this.projectId = projectId;
5672
5860
  this.blobs = blobs;
5673
- this.dir = resolve7(options.projectDir);
5674
- this.home = join19(options.historyRoot, projectId);
5861
+ this.dir = resolve8(options.projectDir);
5862
+ this.home = join20(options.historyRoot, projectId);
5675
5863
  this.foldsCase = ignoresCase(this.dir);
5676
5864
  const folder = statSync9(this.dir, { throwIfNoEntry: false });
5677
5865
  if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
@@ -5704,7 +5892,7 @@ var Engine = class {
5704
5892
  return this.foldsCase ? path.toLowerCase() : path;
5705
5893
  }
5706
5894
  get logFile() {
5707
- return join19(this.home, "log.jsonl");
5895
+ return join20(this.home, "log.jsonl");
5708
5896
  }
5709
5897
  now() {
5710
5898
  return (this.options.now ?? Date.now)();
@@ -5732,7 +5920,7 @@ var Engine = class {
5732
5920
  persistLog(record) {
5733
5921
  if (record) saveRecord(this.logFile, this.log, record);
5734
5922
  else writeLog(this.logFile, this.log);
5735
- if (!existsSync12(join19(this.home, "project.json")))
5923
+ if (!existsSync11(join20(this.home, "project.json")))
5736
5924
  recordProject(this.home, this.dir, this.folder);
5737
5925
  }
5738
5926
  replaced() {
@@ -5782,7 +5970,7 @@ var Engine = class {
5782
5970
  }
5783
5971
  readStatCache() {
5784
5972
  try {
5785
- const files = JSON.parse(readFileSync18(join19(this.home, "stat.json"), "utf-8"));
5973
+ const files = JSON.parse(readFileSync18(join20(this.home, "stat.json"), "utf-8"));
5786
5974
  return new Map(Object.entries(files));
5787
5975
  } catch {
5788
5976
  return /* @__PURE__ */ new Map();
@@ -5791,7 +5979,7 @@ var Engine = class {
5791
5979
  saveStatCache() {
5792
5980
  const files = JSON.stringify(Object.fromEntries(this.tracked));
5793
5981
  mkdirSync11(this.home, { recursive: true });
5794
- replaceFileAtomically(join19(this.home, "stat.json"), files, 420);
5982
+ replaceFileAtomically(join20(this.home, "stat.json"), files, 420);
5795
5983
  }
5796
5984
  /**
5797
5985
  * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
@@ -5831,23 +6019,23 @@ var Engine = class {
5831
6019
  /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
5832
6020
  async storeIfPresent(path) {
5833
6021
  try {
5834
- return await this.blobs.put(join19(this.dir, path));
6022
+ return await this.blobs.put(join20(this.dir, path));
5835
6023
  } catch (error) {
5836
6024
  if (error.code === "ENOENT") return null;
5837
6025
  throw error;
5838
6026
  }
5839
6027
  }
5840
- async observe(path, stat2, at) {
6028
+ async observe(path, stat3, at) {
5841
6029
  const known = this.tracked.get(path) ?? { hash: null, stat: null };
5842
- if (stat2 && known.stat === stat2) return false;
6030
+ if (stat3 && known.stat === stat3) return false;
5843
6031
  const hash = await this.storeIfPresent(path);
5844
6032
  if (hash === null || this.whereFolder() !== "here") return false;
5845
- this.tracked.set(path, { hash, stat: stat2 });
6033
+ this.tracked.set(path, { hash, stat: stat3 });
5846
6034
  if (known.hash !== hash) await this.record(path, known.hash, hash, at);
5847
- return known.hash !== hash || known.stat !== stat2;
6035
+ return known.hash !== hash || known.stat !== stat3;
5848
6036
  }
5849
6037
  async record(path, before, after, at) {
5850
- const endedBeforeThisWrite = this.windows.filter((open) => !takesWrite(open, at));
6038
+ const endedBeforeThisWrite = this.windows.filter((open2) => !takesWrite(open2, at));
5851
6039
  if (endedBeforeThisWrite.length) await this.commitOutside();
5852
6040
  for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
5853
6041
  const window = this.windows.at(-1);
@@ -5880,7 +6068,7 @@ var Engine = class {
5880
6068
  if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
5881
6069
  }
5882
6070
  pendingElsewhere() {
5883
- return Boolean(this.outside?.changes.size) || this.windows.some((open) => open.changes.size > 0);
6071
+ return Boolean(this.outside?.changes.size) || this.windows.some((open2) => open2.changes.size > 0);
5884
6072
  }
5885
6073
  /** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
5886
6074
  heldFor(key, taken) {
@@ -5894,7 +6082,7 @@ var Engine = class {
5894
6082
  return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
5895
6083
  }
5896
6084
  logPath(path) {
5897
- return relative5(this.dir, resolve7(this.dir, path)).split(sep4).join("/");
6085
+ return relative6(this.dir, resolve8(this.dir, path)).split(sep4).join("/");
5898
6086
  }
5899
6087
  /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
5900
6088
  async takeClaimed(who, paths, overwrote) {
@@ -5905,7 +6093,7 @@ var Engine = class {
5905
6093
  hashOfVersion(version)
5906
6094
  ])
5907
6095
  );
5908
- const others = this.windows.filter((open) => !sameWho(open.who, who));
6096
+ const others = this.windows.filter((open2) => !sameWho(open2.who, who));
5909
6097
  const groups = this.outside ? [this.outside, ...others] : others;
5910
6098
  const hits = groups.flatMap(
5911
6099
  (group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
@@ -5918,7 +6106,7 @@ var Engine = class {
5918
6106
  }
5919
6107
  hits.forEach(
5920
6108
  ({ change }, i) => forgetOverwrittenBytes(
5921
- join19(this.dir, change.path),
6109
+ join20(this.dir, change.path),
5922
6110
  new Set([...walked[i]].map(hashVersion))
5923
6111
  )
5924
6112
  );
@@ -5926,7 +6114,7 @@ var Engine = class {
5926
6114
  }
5927
6115
  /** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
5928
6116
  async overwrittenBy(change, told, seen) {
5929
- const absPath = join19(this.dir, change.path);
6117
+ const absPath = join20(this.dir, change.path);
5930
6118
  let bytes;
5931
6119
  for (let hash = change.after; hash; ) {
5932
6120
  if (seen.has(hash)) return void 0;
@@ -5938,7 +6126,7 @@ var Engine = class {
5938
6126
  if (hash === change.before || hash === told) return hash;
5939
6127
  }
5940
6128
  if (bytes === void 0) return void 0;
5941
- const staged = join19(this.home, `overwrote-${randomUUID8()}`);
6129
+ const staged = join20(this.home, `overwrote-${randomUUID8()}`);
5942
6130
  await writeFile(staged, bytes);
5943
6131
  try {
5944
6132
  return await this.blobs.put(staged);
@@ -6051,7 +6239,7 @@ var Engine = class {
6051
6239
  }
6052
6240
  /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
6053
6241
  noteChange(path) {
6054
- if (this.notedTimer || !affectsProjectSignature(this.dir, resolve7(this.dir, path))) return;
6242
+ if (this.notedTimer || !affectsProjectSignature(this.dir, resolve8(this.dir, path))) return;
6055
6243
  this.notedTimer = setTimeout(() => {
6056
6244
  this.notedTimer = null;
6057
6245
  this.background(() => this.sweep());
@@ -6093,7 +6281,7 @@ var Engine = class {
6093
6281
  if (!this.windows.includes(window)) return window.entry ?? null;
6094
6282
  clearTimeout(window.idleTimer);
6095
6283
  await this.commitClaim();
6096
- this.windows = this.windows.filter((open) => open !== window);
6284
+ this.windows = this.windows.filter((open2) => open2 !== window);
6097
6285
  window.entry = await this.commit(window) ?? window.entry ?? null;
6098
6286
  return window.entry;
6099
6287
  }
@@ -6109,9 +6297,9 @@ var Engine = class {
6109
6297
  const parts = path.split("/");
6110
6298
  for (let depth = 1; depth <= parts.length; depth += 1) {
6111
6299
  const at = parts.slice(0, depth).join("/");
6112
- const stat2 = lstatSync4(join19(this.dir, at), { throwIfNoEntry: false });
6113
- if (!stat2) return void 0;
6114
- if (at === path || !stat2.isDirectory()) return { at, stat: stat2 };
6300
+ const stat3 = lstatSync4(join20(this.dir, at), { throwIfNoEntry: false });
6301
+ if (!stat3) return void 0;
6302
+ if (at === path || !stat3.isDirectory()) return { at, stat: stat3 };
6115
6303
  }
6116
6304
  return void 0;
6117
6305
  }
@@ -6121,8 +6309,8 @@ var Engine = class {
6121
6309
  if (!standing) return void 0;
6122
6310
  if (standing.stat.isSymbolicLink()) return standing.at;
6123
6311
  if (standing.at === path && !standing.stat.isDirectory()) return void 0;
6124
- const real = realpathSync3.native(join19(this.dir, standing.at));
6125
- const at = relative5(realpathSync3.native(this.dir), real).split(sep4).join("/");
6312
+ const real = realpathSync3.native(join20(this.dir, standing.at));
6313
+ const at = relative6(realpathSync3.native(this.dir), real).split(sep4).join("/");
6126
6314
  if (standing.at !== path) return deleted.has(at) ? void 0 : at;
6127
6315
  return fileLeftIn(real, at, deleted);
6128
6316
  }
@@ -6139,14 +6327,14 @@ var Engine = class {
6139
6327
  for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
6140
6328
  await this.sweep();
6141
6329
  } finally {
6142
- this.windows = this.windows.filter((open) => open !== group);
6330
+ this.windows = this.windows.filter((open2) => open2 !== group);
6143
6331
  }
6144
6332
  return this.commit(group, extra);
6145
6333
  }
6146
6334
  /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
6147
6335
  async writeProjectFile(path, hash) {
6148
6336
  this.assertWritable();
6149
- const absPath = join19(this.dir, path);
6337
+ const absPath = join20(this.dir, path);
6150
6338
  const { writeToken } = this;
6151
6339
  if (writeToken) {
6152
6340
  const version = hash === null ? DELETED_VERSION : hashVersion(hash);
@@ -6270,18 +6458,18 @@ var Engine = class {
6270
6458
  },
6271
6459
  checkout: (entryId, side, emptyDir) => this.queue(async () => {
6272
6460
  this.entry(entryId);
6273
- if (!existsSync12(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
6274
- const dest = resolve7(emptyDir);
6461
+ if (!existsSync11(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
6462
+ const dest = resolve8(emptyDir);
6275
6463
  if (isWithin(this.dir, dest))
6276
6464
  throw new Error(`Checkout writes outside the project only: ${dest}`);
6277
6465
  if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
6278
6466
  throw new Error(`Checkout writes into an empty folder only: ${dest}`);
6279
6467
  const files = manifestAround(this.log, entryId, side);
6280
6468
  try {
6281
- for (const [path, hash] of files) await this.blobs.writeTo(hash, join19(dest, path));
6469
+ for (const [path, hash] of files) await this.blobs.writeTo(hash, join20(dest, path));
6282
6470
  } catch (error) {
6283
6471
  for (const name of await readdir2(dest).catch(() => []))
6284
- await rm2(join19(dest, name), { recursive: true, force: true });
6472
+ await rm2(join20(dest, name), { recursive: true, force: true });
6285
6473
  throw error;
6286
6474
  }
6287
6475
  }),
@@ -6316,9 +6504,9 @@ var Engine = class {
6316
6504
  }
6317
6505
  };
6318
6506
  function isWithin(dir, path) {
6319
- let probe = resolve7(path);
6320
- while (!existsSync12(probe) && dirname9(probe) !== probe) probe = dirname9(probe);
6321
- const rel = relative5(realpathSync3.native(dir), realpathSync3.native(probe));
6507
+ let probe = resolve8(path);
6508
+ while (!existsSync11(probe) && dirname10(probe) !== probe) probe = dirname10(probe);
6509
+ const rel = relative6(realpathSync3.native(dir), realpathSync3.native(probe));
6322
6510
  return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute3(rel);
6323
6511
  }
6324
6512
  function missingIsEmpty(error) {
@@ -6332,15 +6520,15 @@ var HistoryClosedError = class extends Error {
6332
6520
  }
6333
6521
  };
6334
6522
  async function openProjectHistory(options) {
6335
- if (!existsSync12(options.projectDir))
6523
+ if (!existsSync11(options.projectDir))
6336
6524
  throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
6337
6525
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
6338
6526
  const release = await takeHistoryOwnership(
6339
- join19(options.historyRoot, projectId),
6527
+ join20(options.historyRoot, projectId),
6340
6528
  options.ownerWaitMs ?? 5e3
6341
6529
  );
6342
6530
  try {
6343
- const blobs = await openBlobStore(join19(options.historyRoot, projectId, "blobs"));
6531
+ const blobs = await openBlobStore(join20(options.historyRoot, projectId, "blobs"));
6344
6532
  const engine = new Engine(options, projectId, blobs);
6345
6533
  await engine.queue(() => engine.start());
6346
6534
  const api = engine.api();
@@ -6499,6 +6687,7 @@ function createStudioApi(adapter) {
6499
6687
  registerLintRoutes(api, adapter);
6500
6688
  registerRenderRoutes(api, adapter);
6501
6689
  registerThumbnailRoutes(api, adapter);
6690
+ registerImageThumbnailRoutes(api, adapter);
6502
6691
  registerSelectionRoutes(api, adapter);
6503
6692
  registerMediaRoutes(api, adapter);
6504
6693
  registerWaveformRoutes(api, adapter);
@@ -6510,14 +6699,14 @@ function createStudioApi(adapter) {
6510
6699
  }
6511
6700
 
6512
6701
  // src/history/historyCache.ts
6513
- function historyCache(open) {
6702
+ function historyCache(open2) {
6514
6703
  const histories = /* @__PURE__ */ new Map();
6515
6704
  const get = async (projectDir) => {
6516
6705
  let cached = histories.get(projectDir);
6517
- if (!cached) histories.set(projectDir, cached = open(projectDir));
6706
+ if (!cached) histories.set(projectDir, cached = open2(projectDir));
6518
6707
  const opened = await cached;
6519
6708
  if (!opened?.replacedAtPath()) return opened;
6520
- const reopen = () => open(projectDir);
6709
+ const reopen = () => open2(projectDir);
6521
6710
  if (histories.get(projectDir) === cached)
6522
6711
  histories.set(projectDir, opened.close().then(reopen, reopen));
6523
6712
  return get(projectDir);
@@ -6534,6 +6723,7 @@ function historyCache(open) {
6534
6723
  }
6535
6724
 
6536
6725
  // src/helpers/duplicateElement.ts
6726
+ import { sameInstant } from "@hyperframes/core/clip-facts";
6537
6727
  import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
6538
6728
  function duplicateElementInHtml(source, target, newId, at) {
6539
6729
  const { document, wrappedFragment } = parseSourceDocument(source);
@@ -6568,9 +6758,10 @@ function rippleElements(document, element, at, duration, track) {
6568
6758
  continue;
6569
6759
  }
6570
6760
  const start = numericAttribute(candidate, "data-start");
6571
- if (start !== null && start >= at) {
6572
- candidate.setAttribute("data-start", String(start + duration));
6573
- }
6761
+ if (start === null) continue;
6762
+ const metInsertionPoint = sameInstant(start, at);
6763
+ if (start < at && !metInsertionPoint) continue;
6764
+ candidate.setAttribute("data-start", String((metInsertionPoint ? at : start) + duration));
6574
6765
  }
6575
6766
  }
6576
6767
  function stripStableIds(element) {
@@ -6585,11 +6776,11 @@ function numericAttribute(element, name) {
6585
6776
  }
6586
6777
 
6587
6778
  // src/helpers/previewDocumentStore.ts
6588
- import { createHash as createHash7 } from "crypto";
6779
+ import { createHash as createHash8 } from "crypto";
6589
6780
  import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
6590
- import { join as join20 } from "path";
6781
+ import { join as join21 } from "path";
6591
6782
  function createPreviewDocumentStore(dir, salt) {
6592
- const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
6783
+ const fileFor = (key) => join21(dir, `${createHash8("sha256").update(`${salt}
6593
6784
  ${key}`).digest("hex").slice(0, 32)}.html`);
6594
6785
  return {
6595
6786
  read(key) {
@@ -6605,7 +6796,7 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
6605
6796
  const file = fileFor(key);
6606
6797
  replaceFileAtomically(file, html, 420);
6607
6798
  for (const name of readdirSync8(dir)) {
6608
- if (join20(dir, name) !== file) rmSync7(join20(dir, name), { force: true });
6799
+ if (join21(dir, name) !== file) rmSync7(join21(dir, name), { force: true });
6609
6800
  }
6610
6801
  } catch {
6611
6802
  }
@@ -6692,6 +6883,7 @@ export {
6692
6883
  STUDIO_MANUAL_EDITS_PATH,
6693
6884
  STUDIO_MOTION_PATH,
6694
6885
  UNDO_MODES,
6886
+ affectsPreview,
6695
6887
  affectsProjectSignature,
6696
6888
  applyFileMutations,
6697
6889
  buildSubCompositionHtml,