@hyperframes/studio-server 0.7.94 → 0.7.96

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.
@@ -1,4 +1,4 @@
1
- export { P as PreviewApiAdapter, i as injectMediaCodecMap, e as injectMediaCodecMapIntoHtml, f as isAutoProxyEnabled, p as proxyEtagSalt, r as resolvePreviewMediaCodecProbeCache } from '../mediaProxyPreview-pJzYIpV9.js';
1
+ export { P as PreviewApiAdapter, i as injectMediaCodecMap, e as injectMediaCodecMapIntoHtml, f as isAutoProxyEnabled, p as proxyEtagSalt, r as resolvePreviewMediaCodecProbeCache } from '../mediaProxyPreview-DVTxBkRO.js';
2
2
  import './mediaCodecMap.js';
3
3
  import '@hyperframes/core';
4
4
  import '@hyperframes/parsers';
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Hono } from 'hono';
2
- import { S as StudioApiAdapter, M as MediaProcessingJobState } from './mediaProxyPreview-pJzYIpV9.js';
3
- export { L as LintResult, P as PreviewApiAdapter, R as RenderJobState, a as ResolvedProject, b as StudioSelectionResponse, c as StudioSelectionSnapshot, d as StudioSelectionTextField } from './mediaProxyPreview-pJzYIpV9.js';
2
+ import { S as StudioApiAdapter, M as MediaProcessingJobState } from './mediaProxyPreview-DVTxBkRO.js';
3
+ export { L as LintResult, P as PreviewApiAdapter, R as RenderJobState, a as ResolvedProject, b as StudioSelectionResponse, c as StudioSelectionSnapshot, d as StudioSelectionTextField } from './mediaProxyPreview-DVTxBkRO.js';
4
4
  export { ScreenshotClip, getElementScreenshotClip } from './helpers/screenshotClip.js';
5
5
  export { STUDIO_MANUAL_EDITS_PATH, StudioManualEditsRenderScriptOptions, createStudioManualEditsRenderBodyScript, createStudioPositionSeekReapplyScript } from './helpers/manualEditsRenderScript.js';
6
6
  export { STUDIO_MOTION_PATH, StudioMotionRenderScriptOptions, createStudioMotionRenderBodyScript } from './helpers/studioMotionRenderScript.js';
@@ -57,6 +57,15 @@ declare function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | nu
57
57
  */
58
58
  declare function buildSubCompositionHtml(projectDir: string, compPath: string, runtimeUrl: string, baseHref?: string, rawOverride?: string): string | null;
59
59
 
60
+ interface ThumbnailOutputDimensions {
61
+ width: number;
62
+ height: number;
63
+ outputWidth: number;
64
+ outputHeight: number;
65
+ }
66
+ /** Sole adapter rule for capturing authored layout at bounded physical dimensions. */
67
+ declare function thumbnailDeviceScaleFactor({ width, height, outputWidth, outputHeight, }: ThumbnailOutputDimensions): number;
68
+
60
69
  type BackgroundRemovalJobOptions = Parameters<NonNullable<StudioApiAdapter["startBackgroundRemoval"]>>[0];
61
70
  type BackgroundRemovalProgressEvent = {
62
71
  kind: "info";
@@ -88,4 +97,4 @@ type BackgroundRemovalRender = (options: {
88
97
  }>;
89
98
  declare function createBackgroundRemovalJob(opts: BackgroundRemovalJobOptions, render: BackgroundRemovalRender): MediaProcessingJobState;
90
99
 
91
- export { type BackgroundRemovalRender, type FileWriteReceipt, MIME_TYPES, MediaProcessingJobState, StudioApiAdapter, buildSubCompositionHtml, consumeFileWriteReceipt, createBackgroundRemovalJob, createProjectSignature, createStudioApi, fileContentVersion, getMimeType, walkDir };
100
+ export { type BackgroundRemovalRender, type FileWriteReceipt, MIME_TYPES, MediaProcessingJobState, StudioApiAdapter, type ThumbnailOutputDimensions, buildSubCompositionHtml, consumeFileWriteReceipt, createBackgroundRemovalJob, createProjectSignature, createStudioApi, fileContentVersion, getMimeType, thumbnailDeviceScaleFactor, walkDir };
package/dist/index.js CHANGED
@@ -3918,10 +3918,170 @@ function registerRenderRoutes(api, adapter) {
3918
3918
  }
3919
3919
 
3920
3920
  // src/routes/thumbnail.ts
3921
- import { existsSync as existsSync8, readFileSync as readFileSync11, writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, statSync as statSync4 } from "fs";
3921
+ import {
3922
+ existsSync as existsSync8,
3923
+ mkdirSync as mkdirSync5,
3924
+ readFileSync as readFileSync11,
3925
+ readdirSync as readdirSync6,
3926
+ renameSync as renameSync2,
3927
+ rmSync as rmSync3,
3928
+ statSync as statSync4,
3929
+ unlinkSync as unlinkSync4,
3930
+ writeFileSync as writeFileSync6
3931
+ } from "fs";
3922
3932
  import { join as join11 } from "path";
3923
- import { createHash as createHash4 } from "crypto";
3933
+ import { createHash as createHash4, randomUUID as randomUUID3 } from "crypto";
3934
+
3935
+ // src/routes/thumbnailGenerationCoordinator.ts
3936
+ var ThumbnailGenerationCoordinator = class {
3937
+ constructor(concurrency = 1) {
3938
+ this.concurrency = concurrency;
3939
+ if (!Number.isInteger(concurrency) || concurrency < 1) {
3940
+ throw new RangeError("Thumbnail concurrency must be a positive integer");
3941
+ }
3942
+ }
3943
+ concurrency;
3944
+ entries = /* @__PURE__ */ new Map();
3945
+ queue = [];
3946
+ activeEntries = /* @__PURE__ */ new Set();
3947
+ active = 0;
3948
+ acquire(key, signal, work) {
3949
+ if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
3950
+ let entry = this.entries.get(key);
3951
+ if (!entry) {
3952
+ let resolve5;
3953
+ let reject;
3954
+ const promise = new Promise((resolvePromise, rejectPromise) => {
3955
+ resolve5 = resolvePromise;
3956
+ reject = rejectPromise;
3957
+ });
3958
+ entry = {
3959
+ key,
3960
+ controller: new AbortController(),
3961
+ leases: 0,
3962
+ state: "queued",
3963
+ work,
3964
+ promise,
3965
+ resolve: resolve5,
3966
+ reject
3967
+ };
3968
+ this.entries.set(key, entry);
3969
+ this.queue.push(entry);
3970
+ }
3971
+ entry.leases++;
3972
+ this.pump();
3973
+ return this.lease(entry, signal);
3974
+ }
3975
+ protectedKeys() {
3976
+ return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
3977
+ }
3978
+ lease(entry, signal) {
3979
+ return new Promise((resolve5, reject) => {
3980
+ let released = false;
3981
+ const release = () => {
3982
+ if (released) return;
3983
+ released = true;
3984
+ signal.removeEventListener("abort", onAbort);
3985
+ entry.leases--;
3986
+ if (entry.leases > 0 || !this.entries.has(entry.key)) return;
3987
+ entry.controller.abort();
3988
+ if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key);
3989
+ if (entry.state === "queued") {
3990
+ const index = this.queue.indexOf(entry);
3991
+ if (index >= 0) this.queue.splice(index, 1);
3992
+ entry.reject(new DOMException("Aborted", "AbortError"));
3993
+ }
3994
+ };
3995
+ const onAbort = () => {
3996
+ release();
3997
+ reject(new DOMException("Aborted", "AbortError"));
3998
+ };
3999
+ signal.addEventListener("abort", onAbort, { once: true });
4000
+ entry.promise.then(
4001
+ (value) => {
4002
+ release();
4003
+ resolve5(value);
4004
+ },
4005
+ (reason) => {
4006
+ release();
4007
+ reject(reason);
4008
+ }
4009
+ );
4010
+ });
4011
+ }
4012
+ pump() {
4013
+ while (this.active < this.concurrency) {
4014
+ const entry = this.queue.shift();
4015
+ if (!entry) return;
4016
+ if (entry.leases === 0) continue;
4017
+ entry.state = "active";
4018
+ this.activeEntries.add(entry);
4019
+ this.active++;
4020
+ void this.run(entry);
4021
+ }
4022
+ }
4023
+ async run(entry) {
4024
+ try {
4025
+ entry.resolve(await entry.work(entry.controller.signal));
4026
+ } catch (error) {
4027
+ entry.reject(error);
4028
+ } finally {
4029
+ this.active--;
4030
+ this.activeEntries.delete(entry);
4031
+ if (this.entries.get(entry.key) === entry) this.entries.delete(entry.key);
4032
+ this.pump();
4033
+ }
4034
+ }
4035
+ };
4036
+ var thumbnailGenerationCoordinator = new ThumbnailGenerationCoordinator(1);
4037
+
4038
+ // src/routes/thumbnail.ts
3924
4039
  var THUMBNAIL_CACHE_VERSION = "v4";
4040
+ var THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
4041
+ var THUMBNAIL_MAX_OUTPUT_HEIGHT = 135;
4042
+ var THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024;
4043
+ var THUMBNAIL_CACHE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
4044
+ var prunedCacheDirs = /* @__PURE__ */ new Set();
4045
+ function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4046
+ if (!existsSync8(cacheDir)) return;
4047
+ const files = readdirSync6(cacheDir, { withFileTypes: true }).flatMap((entry) => {
4048
+ if (!entry.isFile()) return [];
4049
+ const path = join11(cacheDir, entry.name);
4050
+ try {
4051
+ const stats = statSync4(path);
4052
+ return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
4053
+ } catch {
4054
+ return [];
4055
+ }
4056
+ });
4057
+ const retained = [];
4058
+ for (const file of files) {
4059
+ if (!protectedPaths.has(file.path) && now - file.mtimeMs > THUMBNAIL_CACHE_MAX_AGE_MS) {
4060
+ rmSync3(file.path, { force: true });
4061
+ } else {
4062
+ retained.push(file);
4063
+ }
4064
+ }
4065
+ let bytes = retained.reduce((total, file) => total + file.bytes, 0);
4066
+ for (const file of retained.sort((left, right) => left.mtimeMs - right.mtimeMs)) {
4067
+ if (bytes <= THUMBNAIL_CACHE_MAX_BYTES) break;
4068
+ if (protectedPaths.has(file.path)) continue;
4069
+ try {
4070
+ unlinkSync4(file.path);
4071
+ bytes -= file.bytes;
4072
+ } catch {
4073
+ }
4074
+ }
4075
+ }
4076
+ function writeThumbnailAtomically(path, buffer) {
4077
+ const temporaryPath = `${path}.${process.pid}.${randomUUID3()}.tmp`;
4078
+ try {
4079
+ writeFileSync6(temporaryPath, buffer, { flag: "wx" });
4080
+ renameSync2(temporaryPath, path);
4081
+ } finally {
4082
+ rmSync3(temporaryPath, { force: true });
4083
+ }
4084
+ }
3925
4085
  function registerThumbnailRoutes(api, adapter) {
3926
4086
  api.get("/projects/:id/thumbnail/*", async (c) => {
3927
4087
  if (!adapter.generateThumbnail) {
@@ -3942,6 +4102,8 @@ function registerThumbnailRoutes(api, adapter) {
3942
4102
  const selector = url.searchParams.get("selector") || void 0;
3943
4103
  const format = url.searchParams.get("format") === "png" ? "png" : "jpeg";
3944
4104
  const contentType = format === "png" ? "image/png" : "image/jpeg";
4105
+ const requestedOutput = url.searchParams.get("output");
4106
+ const outputMode = requestedOutput === "source" || requestedOutput !== "preview" && format === "png" ? "source" : "preview";
3945
4107
  const rawSelectorIndex = Number.parseInt(url.searchParams.get("selectorIndex") || "0", 10);
3946
4108
  const selectorIndex = Number.isFinite(rawSelectorIndex) && rawSelectorIndex > 0 ? rawSelectorIndex : void 0;
3947
4109
  const urlVersion = url.searchParams.get("v") || "";
@@ -3979,37 +4141,62 @@ function registerThumbnailRoutes(api, adapter) {
3979
4141
  const cacheDir = join11(project.dir, ".thumbnails");
3980
4142
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
3981
4143
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
3982
- const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
4144
+ const outputScale = outputMode === "source" ? 1 : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
4145
+ const outputWidth = Math.max(1, Math.round(compW * outputScale));
4146
+ const outputHeight = Math.max(1, Math.round(compH * outputScale));
4147
+ const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
3983
4148
  const cachePath = join11(cacheDir, cacheKey);
4149
+ if (!prunedCacheDirs.has(cacheDir)) {
4150
+ prunedCacheDirs.add(cacheDir);
4151
+ pruneThumbnailCache(
4152
+ cacheDir,
4153
+ /* @__PURE__ */ new Set([...thumbnailGenerationCoordinator.protectedKeys(), cachePath])
4154
+ );
4155
+ }
3984
4156
  if (existsSync8(cachePath)) {
3985
4157
  return new Response(new Uint8Array(readFileSync11(cachePath)), {
3986
4158
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
3987
4159
  });
3988
4160
  }
3989
4161
  try {
3990
- const buffer = await adapter.generateThumbnail({
3991
- project,
3992
- compPath,
3993
- seekTime,
3994
- width: compW,
3995
- height: compH,
3996
- previewUrl,
3997
- selector,
3998
- format,
3999
- selectorIndex
4000
- });
4162
+ const buffer = await thumbnailGenerationCoordinator.acquire(
4163
+ cachePath,
4164
+ c.req.raw.signal,
4165
+ async (signal) => {
4166
+ const generated = await adapter.generateThumbnail({
4167
+ project,
4168
+ compPath,
4169
+ seekTime,
4170
+ width: compW,
4171
+ height: compH,
4172
+ outputWidth,
4173
+ outputHeight,
4174
+ previewUrl,
4175
+ selector,
4176
+ format,
4177
+ selectorIndex,
4178
+ signal
4179
+ });
4180
+ if (!generated) return null;
4181
+ if (!existsSync8(cacheDir)) mkdirSync5(cacheDir, { recursive: true });
4182
+ writeThumbnailAtomically(cachePath, generated);
4183
+ return generated;
4184
+ }
4185
+ );
4001
4186
  if (!buffer) {
4002
4187
  return c.json(
4003
4188
  { error: "Thumbnail generation failed \u2014 Chrome browser may not be available" },
4004
4189
  500
4005
4190
  );
4006
4191
  }
4007
- if (!existsSync8(cacheDir)) mkdirSync5(cacheDir, { recursive: true });
4008
- writeFileSync6(cachePath, buffer);
4192
+ pruneThumbnailCache(cacheDir, thumbnailGenerationCoordinator.protectedKeys());
4009
4193
  return new Response(new Uint8Array(buffer), {
4010
4194
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
4011
4195
  });
4012
4196
  } catch (err) {
4197
+ if (err instanceof DOMException && err.name === "AbortError") {
4198
+ return new Response(null, { status: 499 });
4199
+ }
4013
4200
  const msg = err instanceof Error ? err.message : String(err);
4014
4201
  return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
4015
4202
  }
@@ -4562,6 +4749,20 @@ function createStudioApi(adapter) {
4562
4749
  return api;
4563
4750
  }
4564
4751
 
4752
+ // src/helpers/thumbnailOutput.ts
4753
+ function thumbnailDeviceScaleFactor({
4754
+ width,
4755
+ height,
4756
+ outputWidth,
4757
+ outputHeight
4758
+ }) {
4759
+ const dimensions = [width, height, outputWidth, outputHeight];
4760
+ if (dimensions.some((value) => !Number.isFinite(value) || value <= 0)) {
4761
+ throw new RangeError("Thumbnail dimensions must be positive finite numbers");
4762
+ }
4763
+ return Math.min(1, outputWidth / width, outputHeight / height);
4764
+ }
4765
+
4565
4766
  // src/helpers/backgroundRemovalJob.ts
4566
4767
  function createBackgroundRemovalJob(opts, render) {
4567
4768
  const state = {
@@ -4631,6 +4832,7 @@ export {
4631
4832
  getElementScreenshotClip,
4632
4833
  getMimeType,
4633
4834
  isSafePath,
4835
+ thumbnailDeviceScaleFactor,
4634
4836
  walkDir
4635
4837
  };
4636
4838
  //# sourceMappingURL=index.js.map