@hyperframes/studio-server 0.7.37 → 0.7.39

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
@@ -543,8 +543,8 @@ function querySelectorAllWithTemplates(root, selector) {
543
543
  if (matches.length > 0) return matches;
544
544
  const templates = Array.from(root.querySelectorAll("template"));
545
545
  for (const tmpl of templates) {
546
- const inner = tmpl.querySelectorAll(selector);
547
- if (inner.length > 0) return Array.from(inner);
546
+ const inner = querySelectorAllWithTemplates(tmpl, selector);
547
+ if (inner.length > 0) return inner;
548
548
  }
549
549
  return [];
550
550
  }
@@ -2330,9 +2330,14 @@ function extractElementAttrs(el) {
2330
2330
  }
2331
2331
  var NON_RENDERED_TAGS = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]);
2332
2332
  function promoteTemplateCompositionId(rawComp, body) {
2333
- const templateCompositionId = rawComp.match(
2334
- /<template[^>]*\sdata-composition-id\s*=\s*["']([^"']+)["']/i
2335
- )?.[1];
2333
+ let templateCompositionId;
2334
+ for (const tag of rawComp.matchAll(/<template\b[^>]*/gi)) {
2335
+ const id = /\bdata-composition-id\s*=\s*["']([^"']+)["']/i.exec(tag[0] ?? "")?.[1];
2336
+ if (id) {
2337
+ templateCompositionId = id;
2338
+ break;
2339
+ }
2340
+ }
2336
2341
  if (!templateCompositionId) return;
2337
2342
  if (body.querySelector("[data-composition-id]")) return;
2338
2343
  const root = Array.from(body.children).find((el) => !NON_RENDERED_TAGS.has(el.tagName));
@@ -2346,10 +2351,10 @@ function tagRootCompositionFile(bodyHtml, compPath) {
2346
2351
  if (bodyHtml.slice(match.index, tagEnd).includes("data-composition-file")) return bodyHtml;
2347
2352
  return bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd);
2348
2353
  }
2349
- function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref) {
2354
+ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
2350
2355
  const compFile = join7(projectDir, compPath);
2351
2356
  if (!existsSync4(compFile)) return null;
2352
- const rawComp = readFileSync4(compFile, "utf-8");
2357
+ const rawComp = rawOverride ?? readFileSync4(compFile, "utf-8");
2353
2358
  let compHeadContent = "";
2354
2359
  let rewrittenContent;
2355
2360
  let htmlAttrs = "";
@@ -2746,7 +2751,16 @@ import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
2746
2751
 
2747
2752
  // src/helpers/hfIdPersist.ts
2748
2753
  import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
2749
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
2754
+ import {
2755
+ closeSync,
2756
+ constants,
2757
+ fstatSync,
2758
+ ftruncateSync,
2759
+ openSync,
2760
+ readFileSync as readFileSync6,
2761
+ writeFileSync as writeFileSync5,
2762
+ writeSync
2763
+ } from "fs";
2750
2764
  function persistHfIdsIfNeeded(filePath, html) {
2751
2765
  const normalized = ensureHfIds(html);
2752
2766
  const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
@@ -2763,6 +2777,40 @@ function persistHfIdsIfNeeded(filePath, html) {
2763
2777
  }
2764
2778
  return normalized;
2765
2779
  }
2780
+ function openNoFollow(filePath, flags) {
2781
+ const noFollow = constants.O_NOFOLLOW ?? 0;
2782
+ try {
2783
+ return openSync(filePath, flags | noFollow);
2784
+ } catch {
2785
+ return null;
2786
+ }
2787
+ }
2788
+ function stampFileHfIds(filePath) {
2789
+ let fd = openNoFollow(filePath, constants.O_RDWR);
2790
+ let writable = true;
2791
+ if (fd === null) {
2792
+ fd = openNoFollow(filePath, constants.O_RDONLY);
2793
+ writable = false;
2794
+ }
2795
+ if (fd === null) return null;
2796
+ try {
2797
+ if (!fstatSync(fd).isFile()) return null;
2798
+ const html = readFileSync6(fd, "utf-8");
2799
+ const normalized = ensureHfIds(html);
2800
+ const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
2801
+ const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
2802
+ if (writable && idsAfter > idsBefore) {
2803
+ ftruncateSync(fd, 0);
2804
+ writeSync(fd, normalized, 0, "utf-8");
2805
+ }
2806
+ return normalized;
2807
+ } catch (err) {
2808
+ console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
2809
+ return null;
2810
+ } finally {
2811
+ closeSync(fd);
2812
+ }
2813
+ }
2766
2814
 
2767
2815
  // src/routes/preview.ts
2768
2816
  var PROJECT_SIGNATURE_META = "hyperframes-project-signature";
@@ -2983,6 +3031,10 @@ ${runtimeTag}`;
2983
3031
  return c.text("not found", 404);
2984
3032
  }
2985
3033
  });
3034
+ function pinSubCompHfIds(compFile, compPath) {
3035
+ if (!/\.html?$/i.test(compPath)) return void 0;
3036
+ return stampFileHfIds(compFile);
3037
+ }
2986
3038
  api.get("/projects/:id/preview/comp/*", async (c) => {
2987
3039
  const project = await adapter.resolveProject(c.req.param("id"));
2988
3040
  if (!project) return c.json({ error: "not found" }, 404);
@@ -2994,13 +3046,21 @@ ${runtimeTag}`;
2994
3046
  if (!compFile || !existsSync5(compFile) || !statSync2(compFile).isFile()) {
2995
3047
  return c.text("not found", 404);
2996
3048
  }
2997
- const etag = `"comp:${compPath}:${signature}"`;
3049
+ const etag = `"comp:v2:${compPath}:${signature}"`;
2998
3050
  const ifNoneMatch = c.req.header("If-None-Match");
2999
3051
  if (ifNoneMatch === etag) {
3000
3052
  return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
3001
3053
  }
3054
+ const stamped = pinSubCompHfIds(compFile, compPath);
3055
+ if (stamped === null) return c.text("not found", 404);
3002
3056
  const baseHref = `/api/projects/${project.id}/preview/`;
3003
- let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
3057
+ let html = buildSubCompositionHtml(
3058
+ project.dir,
3059
+ compPath,
3060
+ adapter.runtimeUrl,
3061
+ baseHref,
3062
+ stamped
3063
+ );
3004
3064
  if (!html) return c.text("not found", 404);
3005
3065
  html = ensureHfIds2(await transformPreviewHtml(html, adapter, project, compPath));
3006
3066
  return c.html(
@@ -3111,7 +3171,7 @@ function registerRenderRoutes(api, adapter) {
3111
3171
  const cleanupFinishedJobs = () => {
3112
3172
  const now = Date.now();
3113
3173
  for (const [key, job] of renderJobs) {
3114
- if ((job.status === "complete" || job.status === "failed") && now - job.createdAt > TTL_MS) {
3174
+ if (job.status !== "rendering" && now - job.createdAt > TTL_MS) {
3115
3175
  renderJobs.delete(key);
3116
3176
  }
3117
3177
  }
@@ -3187,11 +3247,21 @@ function registerRenderRoutes(api, adapter) {
3187
3247
  error: current.error
3188
3248
  })
3189
3249
  });
3190
- if (current.status === "complete" || current.status === "failed") break;
3250
+ if (current.status !== "rendering") break;
3191
3251
  await stream.sleep(500);
3192
3252
  }
3193
3253
  });
3194
3254
  });
3255
+ api.post("/render/:jobId/cancel", (c) => {
3256
+ const { jobId } = c.req.param();
3257
+ const job = renderJobs.get(jobId);
3258
+ if (!job) return c.json({ error: "not found" }, 404);
3259
+ if (job.status === "rendering") {
3260
+ job.status = "cancelled";
3261
+ job.cancel?.();
3262
+ }
3263
+ return c.json({ status: job.status });
3264
+ });
3195
3265
  const RENDER_MIME = {
3196
3266
  ".mp4": "video/mp4",
3197
3267
  ".webm": "video/webm",
@@ -4031,7 +4101,7 @@ function registerWaveformRoutes(api, adapter) {
4031
4101
  }
4032
4102
 
4033
4103
  // src/routes/fonts.ts
4034
- import { closeSync, constants, fstatSync, openSync, readSync } from "fs";
4104
+ import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, openSync as openSync2, readSync } from "fs";
4035
4105
  import {
4036
4106
  collectFontFileEntries,
4037
4107
  fontDirectories,
@@ -4138,12 +4208,12 @@ function registerFontRoutes(api) {
4138
4208
  if (!located) return c.json({ error: "font not found" }, 404);
4139
4209
  let fd;
4140
4210
  try {
4141
- fd = openSync(located.path, constants.O_RDONLY | constants.O_NOFOLLOW);
4211
+ fd = openSync2(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
4142
4212
  } catch {
4143
4213
  return c.json({ error: "font file not accessible" }, 404);
4144
4214
  }
4145
4215
  try {
4146
- const stat = fstatSync(fd);
4216
+ const stat = fstatSync2(fd);
4147
4217
  if (stat.size > SYSTEM_FONT_SIZE_LIMIT) {
4148
4218
  return c.json({ error: "font file too large" }, 413);
4149
4219
  }
@@ -4160,7 +4230,7 @@ function registerFontRoutes(api) {
4160
4230
  } catch {
4161
4231
  return c.json({ error: "failed to read font file" }, 500);
4162
4232
  } finally {
4163
- closeSync(fd);
4233
+ closeSync2(fd);
4164
4234
  }
4165
4235
  });
4166
4236
  }
@@ -4292,6 +4362,308 @@ function registerSelectionRoutes(api, adapter) {
4292
4362
  });
4293
4363
  }
4294
4364
 
4365
+ // src/routes/media.ts
4366
+ import { streamSSE as streamSSE2 } from "hono/streaming";
4367
+ import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
4368
+ import { basename as basename2, dirname as dirname2, extname as extname3, join as join13 } from "path";
4369
+
4370
+ // src/helpers/mediaMetadata.ts
4371
+ import { spawnSync as spawnSync2 } from "child_process";
4372
+ import { extname as extname2 } from "path";
4373
+ var VIDEO_EXT2 = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"]);
4374
+ var IMAGE_EXT = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp", ".avif"]);
4375
+ var AUDIO_EXT2 = /* @__PURE__ */ new Set([".mp3", ".wav", ".ogg", ".m4a", ".aac"]);
4376
+ function lower(value) {
4377
+ return value?.toLowerCase() ?? "";
4378
+ }
4379
+ function inferKindFromPath(path) {
4380
+ const ext = extname2(path).toLowerCase();
4381
+ if (VIDEO_EXT2.has(ext)) return "video";
4382
+ if (IMAGE_EXT.has(ext)) return "image";
4383
+ if (AUDIO_EXT2.has(ext)) return "audio";
4384
+ return "unknown";
4385
+ }
4386
+ function colorLabel(input) {
4387
+ if (input.isHdr) {
4388
+ if (input.hdrTransfer === "pq") return "HDR PQ";
4389
+ if (input.hdrTransfer === "hlg") return "HDR HLG";
4390
+ return "HDR";
4391
+ }
4392
+ if (input.colorPrimaries.includes("bt709") || input.colorSpace.includes("bt709") || input.colorTransfer.includes("bt709")) {
4393
+ return "SDR Rec.709";
4394
+ }
4395
+ return "SDR/unknown";
4396
+ }
4397
+ function classifyMediaColor(stream) {
4398
+ const colorPrimaries = lower(stream?.color_primaries);
4399
+ const colorSpace = lower(stream?.color_space);
4400
+ const colorTransfer = lower(stream?.color_transfer);
4401
+ const isHdr = colorPrimaries.includes("bt2020") || colorSpace.includes("bt2020") || colorTransfer === "smpte2084" || colorTransfer === "arib-std-b67";
4402
+ const hdrTransfer = isHdr ? colorTransfer === "smpte2084" ? "pq" : colorTransfer === "arib-std-b67" ? "hlg" : "unknown" : null;
4403
+ return {
4404
+ dynamicRange: stream ? isHdr ? "hdr" : "sdr" : "unknown",
4405
+ hdrTransfer,
4406
+ label: stream ? colorLabel({ isHdr, hdrTransfer, colorPrimaries, colorSpace, colorTransfer }) : "Unknown",
4407
+ isHdr,
4408
+ codecName: stream?.codec_name,
4409
+ profile: stream?.profile,
4410
+ pixelFormat: stream?.pix_fmt,
4411
+ colorSpace: stream?.color_space,
4412
+ colorTransfer: stream?.color_transfer,
4413
+ colorPrimaries: stream?.color_primaries,
4414
+ bitsPerRawSample: stream?.bits_per_raw_sample
4415
+ };
4416
+ }
4417
+ function probeMediaMetadata(filePath, runner = spawnSync2) {
4418
+ const kind = inferKindFromPath(filePath);
4419
+ if (kind === "audio" || kind === "unknown") {
4420
+ return { kind, color: classifyMediaColor(null) };
4421
+ }
4422
+ const result = runner(
4423
+ "ffprobe",
4424
+ [
4425
+ "-v",
4426
+ "error",
4427
+ "-show_entries",
4428
+ "stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample",
4429
+ "-of",
4430
+ "json",
4431
+ filePath
4432
+ ],
4433
+ { timeout: 15e3, maxBuffer: 1024 * 1024 }
4434
+ );
4435
+ if (result.error?.code === "ENOENT") {
4436
+ return { kind, color: classifyMediaColor(null), probeError: "ffprobe unavailable" };
4437
+ }
4438
+ if (result.status !== 0) {
4439
+ return { kind, color: classifyMediaColor(null), probeError: "ffprobe failed" };
4440
+ }
4441
+ try {
4442
+ const parsed = JSON.parse(String(result.stdout || "{}"));
4443
+ const stream = parsed.streams?.find(
4444
+ (item) => kind === "image" ? item.codec_type === "video" : item.codec_type === kind
4445
+ );
4446
+ return { kind, color: classifyMediaColor(stream) };
4447
+ } catch {
4448
+ return { kind, color: classifyMediaColor(null), probeError: "ffprobe returned invalid json" };
4449
+ }
4450
+ }
4451
+
4452
+ // src/routes/media.ts
4453
+ var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([".mp4", ".mov", ".webm", ".mkv", ".avi"]);
4454
+ var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".webp"]);
4455
+ var VIDEO_OUTPUT_EXTENSIONS = /* @__PURE__ */ new Set([".webm", ".mov"]);
4456
+ var QUALITIES = /* @__PURE__ */ new Set(["fast", "balanced", "best"]);
4457
+ var DEVICES = /* @__PURE__ */ new Set(["auto", "cpu", "coreml", "cuda"]);
4458
+ function isVideoPath(path) {
4459
+ return VIDEO_EXTENSIONS.has(extname3(path).toLowerCase());
4460
+ }
4461
+ function isImagePath(path) {
4462
+ return IMAGE_EXTENSIONS.has(extname3(path).toLowerCase());
4463
+ }
4464
+ function normalizeProjectAssetPath(path) {
4465
+ return path.trim().replace(/^[.]\//, "").replace(/[?#].*$/, "");
4466
+ }
4467
+ function containsNullByte(path) {
4468
+ return path.includes("\0");
4469
+ }
4470
+ function slugFileBase(path) {
4471
+ const name = basename2(path, extname3(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
4472
+ return name || "media";
4473
+ }
4474
+ function uniqueAssetPath(projectDir, assetPath) {
4475
+ const ext = extname3(assetPath);
4476
+ const withoutExt = assetPath.slice(0, -ext.length);
4477
+ let candidate = assetPath;
4478
+ for (let index = 2; existsSync9(join13(projectDir, candidate)); index++) {
4479
+ candidate = `${withoutExt}-${index}${ext}`;
4480
+ }
4481
+ return candidate;
4482
+ }
4483
+ function defaultOutputPath(projectDir, inputPath) {
4484
+ const ext = isImagePath(inputPath) ? ".png" : ".webm";
4485
+ return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-cutout${ext}`);
4486
+ }
4487
+ function defaultPlatePath(projectDir, inputPath) {
4488
+ return uniqueAssetPath(projectDir, `assets/cutouts/${slugFileBase(inputPath)}-plate.webm`);
4489
+ }
4490
+ function makeJobId(projectId, mediaJobs) {
4491
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
4492
+ const safeProject = projectId.replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "");
4493
+ const base = `${safeProject || "project"}_remove-bg_${stamp}`;
4494
+ if (!mediaJobs.has(base)) return base;
4495
+ for (let index = 2; ; index++) {
4496
+ const candidate = `${base}-${index}`;
4497
+ if (!mediaJobs.has(candidate)) return candidate;
4498
+ }
4499
+ }
4500
+ function normalizeQuality(value) {
4501
+ return QUALITIES.has(value ?? "") ? value : "balanced";
4502
+ }
4503
+ function normalizeDevice(value) {
4504
+ return DEVICES.has(value ?? "") ? value : "auto";
4505
+ }
4506
+ function registerMediaRoutes(api, adapter, options = {}) {
4507
+ const mediaJobs = /* @__PURE__ */ new Map();
4508
+ const TTL_MS = 3e5;
4509
+ const readMediaMetadata = options.probeMediaMetadata ?? probeMediaMetadata;
4510
+ function cleanupFinishedJobs() {
4511
+ const now = Date.now();
4512
+ for (const [id, job] of mediaJobs) {
4513
+ if ((job.status === "complete" || job.status === "failed") && now - job.createdAt > TTL_MS) {
4514
+ mediaJobs.delete(id);
4515
+ }
4516
+ }
4517
+ }
4518
+ api.get("/projects/:id/media/metadata", async (c) => {
4519
+ const project = await adapter.resolveProject(c.req.param("id"));
4520
+ if (!project) return c.json({ error: "not found" }, 404);
4521
+ const assetPath = normalizeProjectAssetPath(c.req.query("path") ?? "");
4522
+ if (!assetPath) return c.json({ error: "path required" }, 400);
4523
+ if (containsNullByte(assetPath)) return c.json({ error: "forbidden" }, 403);
4524
+ if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
4525
+ return c.json({ error: "media metadata requires a project-local asset" }, 400);
4526
+ }
4527
+ const filePath = resolveWithinProject(project.dir, assetPath);
4528
+ if (!filePath) return c.json({ error: "forbidden" }, 403);
4529
+ if (!existsSync9(filePath)) return c.json({ error: "media not found" }, 404);
4530
+ return c.json({ path: assetPath, metadata: readMediaMetadata(filePath) });
4531
+ });
4532
+ api.post(
4533
+ "/projects/:id/media/remove-background",
4534
+ // fallow-ignore-next-line complexity
4535
+ async (c) => {
4536
+ cleanupFinishedJobs();
4537
+ if (!adapter.startBackgroundRemoval) {
4538
+ return c.json({ error: "background removal is not available in this Studio server" }, 501);
4539
+ }
4540
+ const project = await adapter.resolveProject(c.req.param("id"));
4541
+ if (!project) return c.json({ error: "not found" }, 404);
4542
+ const body = await c.req.json().catch(() => ({}));
4543
+ const inputAssetPath = body.inputPath ? normalizeProjectAssetPath(body.inputPath) : "";
4544
+ if (!inputAssetPath) return c.json({ error: "inputPath required" }, 400);
4545
+ if (containsNullByte(inputAssetPath)) return c.json({ error: "forbidden" }, 403);
4546
+ if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
4547
+ return c.json({ error: "background removal requires a project-local media asset" }, 400);
4548
+ }
4549
+ const inputPath = resolveWithinProject(project.dir, inputAssetPath);
4550
+ if (!inputPath) return c.json({ error: "forbidden" }, 403);
4551
+ if (!existsSync9(inputPath)) return c.json({ error: "input media not found" }, 404);
4552
+ const inputIsVideo = isVideoPath(inputAssetPath);
4553
+ const inputIsImage = isImagePath(inputAssetPath);
4554
+ if (!inputIsVideo && !inputIsImage) {
4555
+ return c.json({ error: "background removal supports video or image assets only" }, 400);
4556
+ }
4557
+ const requestedOutput = body.outputPath ? normalizeProjectAssetPath(body.outputPath) : "";
4558
+ if (requestedOutput && containsNullByte(requestedOutput)) {
4559
+ return c.json({ error: "forbidden" }, 403);
4560
+ }
4561
+ if (requestedOutput && !resolveWithinProject(project.dir, requestedOutput)) {
4562
+ return c.json({ error: "forbidden" }, 403);
4563
+ }
4564
+ const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
4565
+ const outputPath = resolveWithinProject(project.dir, outputAssetPath);
4566
+ if (!outputPath) return c.json({ error: "forbidden" }, 403);
4567
+ if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname3(outputAssetPath).toLowerCase())) {
4568
+ return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
4569
+ }
4570
+ if (inputIsImage && extname3(outputAssetPath).toLowerCase() !== ".png") {
4571
+ return c.json({ error: "image background removal output must be .png" }, 400);
4572
+ }
4573
+ let backgroundOutputAssetPath;
4574
+ let backgroundOutputPath;
4575
+ if (body.createBackgroundPlate) {
4576
+ if (!inputIsVideo) {
4577
+ return c.json({ error: "background plates are only supported for video inputs" }, 400);
4578
+ }
4579
+ backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
4580
+ backgroundOutputPath = resolveWithinProject(project.dir, backgroundOutputAssetPath) ?? void 0;
4581
+ if (!backgroundOutputPath) {
4582
+ return c.json({ error: "forbidden" }, 403);
4583
+ }
4584
+ }
4585
+ mkdirSync7(dirname2(outputPath), { recursive: true });
4586
+ if (backgroundOutputPath) mkdirSync7(dirname2(backgroundOutputPath), { recursive: true });
4587
+ const jobId = makeJobId(project.id, mediaJobs);
4588
+ const state = adapter.startBackgroundRemoval({
4589
+ project,
4590
+ inputPath,
4591
+ inputAssetPath,
4592
+ outputPath,
4593
+ outputAssetPath,
4594
+ backgroundOutputPath,
4595
+ backgroundOutputAssetPath,
4596
+ quality: normalizeQuality(body.quality),
4597
+ device: normalizeDevice(body.device),
4598
+ jobId
4599
+ });
4600
+ state.createdAt = Date.now();
4601
+ mediaJobs.set(jobId, state);
4602
+ return c.json({
4603
+ jobId,
4604
+ status: state.status,
4605
+ outputPath: outputAssetPath,
4606
+ backgroundOutputPath: backgroundOutputAssetPath
4607
+ });
4608
+ }
4609
+ );
4610
+ api.get("/media-jobs/:jobId/progress", (c) => {
4611
+ cleanupFinishedJobs();
4612
+ const { jobId } = c.req.param();
4613
+ const job = mediaJobs.get(jobId);
4614
+ if (!job) return c.json({ error: "not found" }, 404);
4615
+ return streamSSE2(c, async (stream) => {
4616
+ while (true) {
4617
+ const current = mediaJobs.get(jobId);
4618
+ if (!current) break;
4619
+ await stream.writeSSE({
4620
+ event: "progress",
4621
+ data: JSON.stringify({
4622
+ id: current.id,
4623
+ status: current.status,
4624
+ progress: current.progress,
4625
+ stage: current.stage,
4626
+ outputPath: current.outputAssetPath,
4627
+ backgroundOutputPath: current.backgroundOutputAssetPath,
4628
+ error: current.error,
4629
+ provider: current.provider,
4630
+ framesProcessed: current.framesProcessed,
4631
+ durationSeconds: current.durationSeconds,
4632
+ avgMsPerFrame: current.avgMsPerFrame
4633
+ })
4634
+ });
4635
+ if (current.status === "complete" || current.status === "failed") break;
4636
+ await stream.sleep(500);
4637
+ }
4638
+ });
4639
+ });
4640
+ }
4641
+
4642
+ // src/routes/globalAssets.ts
4643
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
4644
+ import { homedir } from "os";
4645
+ import { join as join14 } from "path";
4646
+ function readGlobalAssets(home = homedir()) {
4647
+ const manifestPath = join14(home, ".media", "manifest.jsonl");
4648
+ if (!existsSync10(manifestPath)) return [];
4649
+ const out = [];
4650
+ for (const line of readFileSync12(manifestPath, "utf8").split("\n")) {
4651
+ if (!line.trim()) continue;
4652
+ try {
4653
+ const rec = JSON.parse(line);
4654
+ if (rec && rec.reusable) out.push(rec);
4655
+ } catch {
4656
+ }
4657
+ }
4658
+ return out;
4659
+ }
4660
+ function toPublicAsset(r) {
4661
+ return { id: r.id, type: r.type, description: r.description, sha: r.sha, entity: r.entity };
4662
+ }
4663
+ function registerGlobalAssetRoutes(api) {
4664
+ api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
4665
+ }
4666
+
4295
4667
  // src/createStudioApi.ts
4296
4668
  function createStudioApi(adapter) {
4297
4669
  const api = new Hono();
@@ -4303,9 +4675,11 @@ function createStudioApi(adapter) {
4303
4675
  registerRenderRoutes(api, adapter);
4304
4676
  registerThumbnailRoutes(api, adapter);
4305
4677
  registerSelectionRoutes(api, adapter);
4678
+ registerMediaRoutes(api, adapter);
4306
4679
  registerWaveformRoutes(api, adapter);
4307
4680
  registerFontRoutes(api);
4308
4681
  registerRegistryRoutes(api, adapter);
4682
+ registerGlobalAssetRoutes(api);
4309
4683
  return api;
4310
4684
  }
4311
4685
 
@@ -4331,11 +4705,66 @@ function getElementScreenshotClip(selector, selectorIndex) {
4331
4705
  height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight))
4332
4706
  };
4333
4707
  }
4708
+
4709
+ // src/helpers/backgroundRemovalJob.ts
4710
+ function createBackgroundRemovalJob(opts, render) {
4711
+ const state = {
4712
+ id: opts.jobId,
4713
+ status: "processing",
4714
+ progress: 0,
4715
+ stage: "Preparing background removal",
4716
+ inputAssetPath: opts.inputAssetPath,
4717
+ outputAssetPath: opts.outputAssetPath,
4718
+ outputPath: opts.outputPath,
4719
+ ...opts.backgroundOutputPath ? { backgroundOutputPath: opts.backgroundOutputPath } : {},
4720
+ ...opts.backgroundOutputAssetPath ? { backgroundOutputAssetPath: opts.backgroundOutputAssetPath } : {}
4721
+ };
4722
+ void (async () => {
4723
+ try {
4724
+ const result = await render({
4725
+ inputPath: opts.inputPath,
4726
+ outputPath: opts.outputPath,
4727
+ backgroundOutputPath: opts.backgroundOutputPath,
4728
+ device: opts.device,
4729
+ quality: opts.quality,
4730
+ onProgress: (event) => updateBackgroundRemovalProgress(state, event)
4731
+ });
4732
+ state.status = "complete";
4733
+ state.progress = 100;
4734
+ state.stage = "Complete";
4735
+ state.provider = result.provider;
4736
+ state.framesProcessed = result.framesProcessed;
4737
+ state.durationSeconds = result.durationSeconds;
4738
+ state.avgMsPerFrame = result.avgMsPerFrame;
4739
+ } catch (err) {
4740
+ state.status = "failed";
4741
+ state.error = err instanceof Error ? err.message : String(err);
4742
+ state.stage = "Failed";
4743
+ }
4744
+ })();
4745
+ return state;
4746
+ }
4747
+ function updateBackgroundRemovalProgress(state, event) {
4748
+ if (event.kind === "info") {
4749
+ state.stage = event.message;
4750
+ return;
4751
+ }
4752
+ if (event.kind === "metadata") {
4753
+ state.stage = `Source ${event.width}\xD7${event.height}`;
4754
+ state.progress = 2;
4755
+ return;
4756
+ }
4757
+ state.progress = event.total ? Math.min(99, Math.floor(event.index / event.total * 100)) : 0;
4758
+ state.stage = event.total ? `Removing background ${event.index}/${event.total}` : `Removing background frame ${event.index}`;
4759
+ state.framesProcessed = event.index;
4760
+ state.avgMsPerFrame = event.avgMsPerFrame;
4761
+ }
4334
4762
  export {
4335
4763
  MIME_TYPES,
4336
4764
  STUDIO_MANUAL_EDITS_PATH,
4337
4765
  STUDIO_MOTION_PATH,
4338
4766
  buildSubCompositionHtml,
4767
+ createBackgroundRemovalJob,
4339
4768
  createProjectSignature,
4340
4769
  createStudioApi,
4341
4770
  createStudioManualEditsRenderBodyScript,