@hyperframes/studio-server 0.7.15
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/LICENSE +190 -0
- package/dist/helpers/draftMarkers.d.ts +12 -0
- package/dist/helpers/draftMarkers.js +14 -0
- package/dist/helpers/draftMarkers.js.map +1 -0
- package/dist/helpers/finiteMutation.d.ts +11 -0
- package/dist/helpers/finiteMutation.js +29 -0
- package/dist/helpers/finiteMutation.js.map +1 -0
- package/dist/helpers/manualEditsRenderScript.d.ts +14 -0
- package/dist/helpers/manualEditsRenderScript.js +583 -0
- package/dist/helpers/manualEditsRenderScript.js.map +1 -0
- package/dist/helpers/screenshotClip.d.ts +9 -0
- package/dist/helpers/screenshotClip.js +26 -0
- package/dist/helpers/screenshotClip.js.map +1 -0
- package/dist/helpers/studioMotionRenderScript.d.ts +10 -0
- package/dist/helpers/studioMotionRenderScript.js +191 -0
- package/dist/helpers/studioMotionRenderScript.js.map +1 -0
- package/dist/index.d.ts +167 -0
- package/dist/index.js +3869 -0
- package/dist/index.js.map +1 -0
- package/package.json +67 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3869 @@
|
|
|
1
|
+
// src/createStudioApi.ts
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
|
|
4
|
+
// src/routes/projects.ts
|
|
5
|
+
import { readFile } from "fs/promises";
|
|
6
|
+
import { join as join2 } from "path";
|
|
7
|
+
|
|
8
|
+
// src/helpers/safePath.ts
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import { readdirSync } from "fs";
|
|
11
|
+
import { isSafePath, resolveWithinProject } from "@hyperframes/core";
|
|
12
|
+
var IGNORE_DIRS = /* @__PURE__ */ new Set([".thumbnails", "node_modules", ".git"]);
|
|
13
|
+
function shouldIgnoreDir(rel) {
|
|
14
|
+
return rel === ".hyperframes/backup";
|
|
15
|
+
}
|
|
16
|
+
function isInHiddenOrVendorDir(relPath) {
|
|
17
|
+
const segments = relPath.split("/");
|
|
18
|
+
return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
|
|
19
|
+
}
|
|
20
|
+
function walkDir(dir, prefix = "") {
|
|
21
|
+
const files = [];
|
|
22
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
24
|
+
if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
|
|
25
|
+
if (entry.isDirectory()) {
|
|
26
|
+
files.push(...walkDir(join(dir, entry.name), rel));
|
|
27
|
+
} else {
|
|
28
|
+
files.push(rel);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return files;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// src/routes/projects.ts
|
|
35
|
+
var COMPOSITION_ID_RE = /data-composition-id\s*=/;
|
|
36
|
+
async function filterCompositionFiles(projectDir, files) {
|
|
37
|
+
const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
|
|
38
|
+
const checks = await Promise.all(
|
|
39
|
+
htmlFiles.map(async (f) => {
|
|
40
|
+
try {
|
|
41
|
+
const content = await readFile(join2(projectDir, f), "utf-8");
|
|
42
|
+
return COMPOSITION_ID_RE.test(content);
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
return htmlFiles.filter((_, i) => checks[i]);
|
|
49
|
+
}
|
|
50
|
+
function registerProjectRoutes(api, adapter) {
|
|
51
|
+
api.get("/projects", async (c) => {
|
|
52
|
+
const projects = await adapter.listProjects();
|
|
53
|
+
return c.json({ projects });
|
|
54
|
+
});
|
|
55
|
+
api.get("/resolve-session/:sessionId", async (c) => {
|
|
56
|
+
if (!adapter.resolveSession) {
|
|
57
|
+
return c.json({ error: "not available" }, 404);
|
|
58
|
+
}
|
|
59
|
+
const { sessionId } = c.req.param();
|
|
60
|
+
const result = await adapter.resolveSession(sessionId);
|
|
61
|
+
if (!result) return c.json({ error: "Session not found" }, 404);
|
|
62
|
+
return c.json(result);
|
|
63
|
+
});
|
|
64
|
+
api.get("/projects/:id", async (c) => {
|
|
65
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
66
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
67
|
+
const files = walkDir(project.dir);
|
|
68
|
+
const compositions = await filterCompositionFiles(project.dir, files);
|
|
69
|
+
return c.json({ id: project.id, dir: project.dir, title: project.title, files, compositions });
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/routes/storyboard.ts
|
|
74
|
+
import { existsSync, readFileSync } from "fs";
|
|
75
|
+
import {
|
|
76
|
+
parseStoryboard,
|
|
77
|
+
SCRIPT_FILENAME,
|
|
78
|
+
STORYBOARD_FILENAME
|
|
79
|
+
} from "@hyperframes/core/storyboard";
|
|
80
|
+
function resolveFrames(projectDir, frames) {
|
|
81
|
+
return frames.map((frame) => {
|
|
82
|
+
let srcExists = false;
|
|
83
|
+
if (frame.src) {
|
|
84
|
+
const abs = resolveWithinProject(projectDir, frame.src);
|
|
85
|
+
srcExists = abs ? existsSync(abs) : false;
|
|
86
|
+
}
|
|
87
|
+
return { ...frame, srcExists };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function readScript(projectDir) {
|
|
91
|
+
const abs = resolveWithinProject(projectDir, SCRIPT_FILENAME);
|
|
92
|
+
if (abs && existsSync(abs)) {
|
|
93
|
+
try {
|
|
94
|
+
return { exists: true, path: SCRIPT_FILENAME, content: readFileSync(abs, "utf-8") };
|
|
95
|
+
} catch {
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { exists: false, path: SCRIPT_FILENAME, content: "" };
|
|
99
|
+
}
|
|
100
|
+
function registerStoryboardRoutes(api, adapter) {
|
|
101
|
+
api.get("/projects/:id/storyboard", async (c) => {
|
|
102
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
103
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
104
|
+
const abs = resolveWithinProject(project.dir, STORYBOARD_FILENAME);
|
|
105
|
+
if (!abs || !existsSync(abs)) {
|
|
106
|
+
return c.json({
|
|
107
|
+
exists: false,
|
|
108
|
+
path: STORYBOARD_FILENAME,
|
|
109
|
+
globals: { extra: {} },
|
|
110
|
+
frames: [],
|
|
111
|
+
warnings: [],
|
|
112
|
+
script: readScript(project.dir)
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
let source;
|
|
116
|
+
try {
|
|
117
|
+
source = readFileSync(abs, "utf-8");
|
|
118
|
+
} catch {
|
|
119
|
+
return c.json({ error: "failed to read storyboard" }, 500);
|
|
120
|
+
}
|
|
121
|
+
const manifest = parseStoryboard(source);
|
|
122
|
+
return c.json({
|
|
123
|
+
exists: true,
|
|
124
|
+
path: STORYBOARD_FILENAME,
|
|
125
|
+
globals: manifest.globals,
|
|
126
|
+
frames: resolveFrames(project.dir, manifest.frames),
|
|
127
|
+
warnings: manifest.warnings,
|
|
128
|
+
script: readScript(project.dir)
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/routes/files.ts
|
|
134
|
+
import { bodyLimit } from "hono/body-limit";
|
|
135
|
+
import {
|
|
136
|
+
existsSync as existsSync3,
|
|
137
|
+
readFileSync as readFileSync3,
|
|
138
|
+
writeFileSync as writeFileSync4,
|
|
139
|
+
mkdirSync as mkdirSync3,
|
|
140
|
+
unlinkSync as unlinkSync2,
|
|
141
|
+
rmSync as rmSync2,
|
|
142
|
+
statSync,
|
|
143
|
+
renameSync,
|
|
144
|
+
readdirSync as readdirSync3
|
|
145
|
+
} from "fs";
|
|
146
|
+
import { resolve as resolve2, dirname, join as join6 } from "path";
|
|
147
|
+
|
|
148
|
+
// src/helpers/mime.ts
|
|
149
|
+
var MIME_TYPES = {
|
|
150
|
+
".html": "text/html",
|
|
151
|
+
".css": "text/css",
|
|
152
|
+
".js": "text/javascript",
|
|
153
|
+
".mjs": "text/javascript",
|
|
154
|
+
".json": "application/json",
|
|
155
|
+
".svg": "image/svg+xml",
|
|
156
|
+
".png": "image/png",
|
|
157
|
+
".jpg": "image/jpeg",
|
|
158
|
+
".jpeg": "image/jpeg",
|
|
159
|
+
".gif": "image/gif",
|
|
160
|
+
".webp": "image/webp",
|
|
161
|
+
".ico": "image/x-icon",
|
|
162
|
+
".mp4": "video/mp4",
|
|
163
|
+
".mov": "video/quicktime",
|
|
164
|
+
".webm": "video/webm",
|
|
165
|
+
".mp3": "audio/mpeg",
|
|
166
|
+
".wav": "audio/wav",
|
|
167
|
+
".ogg": "audio/ogg",
|
|
168
|
+
".m4a": "audio/mp4",
|
|
169
|
+
".aac": "audio/aac",
|
|
170
|
+
".flac": "audio/flac",
|
|
171
|
+
".opus": "audio/ogg",
|
|
172
|
+
".woff": "font/woff",
|
|
173
|
+
".woff2": "font/woff2",
|
|
174
|
+
".ttf": "font/ttf",
|
|
175
|
+
".otf": "font/otf",
|
|
176
|
+
".txt": "text/plain",
|
|
177
|
+
".md": "text/markdown",
|
|
178
|
+
".cube": "text/plain; charset=utf-8"
|
|
179
|
+
};
|
|
180
|
+
function getMimeType(path) {
|
|
181
|
+
const ext = path.slice(path.lastIndexOf(".")).toLowerCase();
|
|
182
|
+
return MIME_TYPES[ext] || "application/octet-stream";
|
|
183
|
+
}
|
|
184
|
+
function isAudioFile(name) {
|
|
185
|
+
return (getMimeType(name) ?? "").startsWith("audio/");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// src/helpers/waveform.ts
|
|
189
|
+
import { spawn } from "child_process";
|
|
190
|
+
import { existsSync as existsSync2, writeFileSync, mkdirSync } from "fs";
|
|
191
|
+
import { join as join3, resolve } from "path";
|
|
192
|
+
var SAMPLE_RATE = 4e3;
|
|
193
|
+
var PEAK_COUNT = 4e3;
|
|
194
|
+
var WAVEFORM_CACHE_VERSION = "v2";
|
|
195
|
+
function buildWaveformCacheKey(assetPath) {
|
|
196
|
+
return `${WAVEFORM_CACHE_VERSION}_${assetPath.replace(/[/\\]/g, "_")}.json`;
|
|
197
|
+
}
|
|
198
|
+
function computePeaks(floats, count) {
|
|
199
|
+
const step = floats.length / count;
|
|
200
|
+
const peaks = [];
|
|
201
|
+
for (let i = 0; i < count; i++) {
|
|
202
|
+
const start = Math.floor(i * step);
|
|
203
|
+
const end = Math.min(Math.floor((i + 1) * step), floats.length);
|
|
204
|
+
let max = 0;
|
|
205
|
+
for (let j = start; j < end; j++) {
|
|
206
|
+
const abs = Math.abs(floats[j] ?? 0);
|
|
207
|
+
if (abs > max) max = abs;
|
|
208
|
+
}
|
|
209
|
+
peaks.push(max);
|
|
210
|
+
}
|
|
211
|
+
const maxPeak = Math.max(...peaks, 1e-3);
|
|
212
|
+
return peaks.map((p) => p / maxPeak);
|
|
213
|
+
}
|
|
214
|
+
function ffmpegBinary() {
|
|
215
|
+
const configured = process.env.HYPERFRAMES_FFMPEG_PATH?.trim();
|
|
216
|
+
if (configured) return resolve(configured);
|
|
217
|
+
return "ffmpeg";
|
|
218
|
+
}
|
|
219
|
+
function decodeAudioPeaks(audioPath) {
|
|
220
|
+
return new Promise((resolvePromise, reject) => {
|
|
221
|
+
const proc = spawn(
|
|
222
|
+
ffmpegBinary(),
|
|
223
|
+
[
|
|
224
|
+
"-i",
|
|
225
|
+
audioPath,
|
|
226
|
+
"-af",
|
|
227
|
+
"atrim=start_sample=1152",
|
|
228
|
+
"-f",
|
|
229
|
+
"f32le",
|
|
230
|
+
"-ac",
|
|
231
|
+
"1",
|
|
232
|
+
"-ar",
|
|
233
|
+
String(SAMPLE_RATE),
|
|
234
|
+
"-vn",
|
|
235
|
+
"pipe:1"
|
|
236
|
+
],
|
|
237
|
+
{ stdio: ["ignore", "pipe", "ignore"] }
|
|
238
|
+
);
|
|
239
|
+
const chunks = [];
|
|
240
|
+
proc.stdout?.on("data", (chunk) => chunks.push(chunk));
|
|
241
|
+
proc.on("close", (code) => {
|
|
242
|
+
if (code !== 0 && chunks.length === 0) {
|
|
243
|
+
reject(new Error(`ffmpeg exited with code ${code}`));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const buf = Buffer.concat(chunks);
|
|
247
|
+
const numSamples = Math.floor(buf.length / 4);
|
|
248
|
+
if (numSamples === 0) {
|
|
249
|
+
reject(new Error("ffmpeg produced no audio samples"));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + numSamples * 4);
|
|
253
|
+
resolvePromise(computePeaks(new Float32Array(ab), PEAK_COUNT));
|
|
254
|
+
});
|
|
255
|
+
proc.on("error", reject);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
async function generateWaveformCache(projectDir, assetPath) {
|
|
259
|
+
const audioPath = join3(projectDir, assetPath);
|
|
260
|
+
if (!existsSync2(audioPath)) return;
|
|
261
|
+
const cacheDir = join3(projectDir, ".waveform-cache");
|
|
262
|
+
const cachePath = join3(cacheDir, buildWaveformCacheKey(assetPath));
|
|
263
|
+
if (existsSync2(cachePath)) return;
|
|
264
|
+
const peaks = await decodeAudioPeaks(audioPath);
|
|
265
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
266
|
+
writeFileSync(cachePath, JSON.stringify(peaks));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/helpers/mediaValidation.ts
|
|
270
|
+
import { spawnSync } from "child_process";
|
|
271
|
+
import { mkdtempSync, rmSync, writeFileSync as writeFileSync2 } from "fs";
|
|
272
|
+
import { tmpdir } from "os";
|
|
273
|
+
import { basename, join as join4 } from "path";
|
|
274
|
+
var VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
275
|
+
var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
276
|
+
function validateUploadedMedia(filePath, runner = spawnSync) {
|
|
277
|
+
const isVideo = VIDEO_EXT.test(filePath);
|
|
278
|
+
const isAudio = AUDIO_EXT.test(filePath);
|
|
279
|
+
if (!isVideo && !isAudio) {
|
|
280
|
+
return { ok: true };
|
|
281
|
+
}
|
|
282
|
+
const result = runner("ffprobe", [
|
|
283
|
+
"-v",
|
|
284
|
+
"error",
|
|
285
|
+
"-show_entries",
|
|
286
|
+
"stream=codec_type",
|
|
287
|
+
"-of",
|
|
288
|
+
"json",
|
|
289
|
+
filePath
|
|
290
|
+
]);
|
|
291
|
+
if (result.error?.code === "ENOENT") {
|
|
292
|
+
return { ok: true };
|
|
293
|
+
}
|
|
294
|
+
if (result.status !== 0) {
|
|
295
|
+
return { ok: false, reason: "ffprobe failed to read the media file" };
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const parsed = JSON.parse(String(result.stdout || "{}"));
|
|
299
|
+
const streams = parsed.streams ?? [];
|
|
300
|
+
const hasVideo = streams.some((stream) => stream.codec_type === "video");
|
|
301
|
+
const hasAudio = streams.some((stream) => stream.codec_type === "audio");
|
|
302
|
+
if (isVideo && !hasVideo) {
|
|
303
|
+
return { ok: false, reason: "no supported video stream found" };
|
|
304
|
+
}
|
|
305
|
+
if (isAudio && !hasAudio) {
|
|
306
|
+
return { ok: false, reason: "no supported audio stream found" };
|
|
307
|
+
}
|
|
308
|
+
return { ok: true };
|
|
309
|
+
} catch {
|
|
310
|
+
return { ok: false, reason: "ffprobe returned unreadable media metadata" };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
|
|
314
|
+
const tempDir = mkdtempSync(join4(tmpdir(), "hyperframes-upload-"));
|
|
315
|
+
const tempPath = join4(tempDir, basename(fileName));
|
|
316
|
+
try {
|
|
317
|
+
writeFileSync2(tempPath, buffer);
|
|
318
|
+
return validateUploadedMedia(tempPath, runner);
|
|
319
|
+
} finally {
|
|
320
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// src/helpers/backupJournal.ts
|
|
325
|
+
import { mkdirSync as mkdirSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
326
|
+
import { Buffer as Buffer2 } from "buffer";
|
|
327
|
+
import { join as join5, relative } from "path";
|
|
328
|
+
var DEFAULT_KEEP_PER_FILE = 10;
|
|
329
|
+
function backupKeyForPath(path) {
|
|
330
|
+
return Buffer2.from(path, "utf-8").toString("base64url");
|
|
331
|
+
}
|
|
332
|
+
function timestampPrefix() {
|
|
333
|
+
return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
334
|
+
}
|
|
335
|
+
function backupPathForResponse(projectDir, backupPath) {
|
|
336
|
+
if (!backupPath) return null;
|
|
337
|
+
const rel = relative(projectDir, backupPath);
|
|
338
|
+
if (!rel || rel.startsWith("..")) return null;
|
|
339
|
+
return rel.split("\\").join("/");
|
|
340
|
+
}
|
|
341
|
+
function snapshotBeforeWrite(projectDir, absPath, options = {}) {
|
|
342
|
+
if (!isSafePath(projectDir, absPath)) return { backupPath: null };
|
|
343
|
+
try {
|
|
344
|
+
const content = readFileSync2(absPath);
|
|
345
|
+
const relativePath = relative(projectDir, absPath);
|
|
346
|
+
const backupDir = join5(projectDir, ".hyperframes", "backup");
|
|
347
|
+
mkdirSync2(backupDir, { recursive: true });
|
|
348
|
+
const backupKey = backupKeyForPath(relativePath);
|
|
349
|
+
const backupPath = nextBackupPath(backupDir, backupKey);
|
|
350
|
+
writeFileSync3(backupPath, content);
|
|
351
|
+
pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
|
|
352
|
+
return { backupPath };
|
|
353
|
+
} catch (error) {
|
|
354
|
+
if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "EISDIR")) {
|
|
355
|
+
return { backupPath: null };
|
|
356
|
+
}
|
|
357
|
+
return { backupPath: null, error: error instanceof Error ? error.message : String(error) };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function nextBackupPath(backupDir, backupKey) {
|
|
361
|
+
const base = `${timestampPrefix()}-${backupKey}`;
|
|
362
|
+
let candidate = join5(backupDir, base);
|
|
363
|
+
let counter = 2;
|
|
364
|
+
while (true) {
|
|
365
|
+
try {
|
|
366
|
+
readFileSync2(candidate);
|
|
367
|
+
} catch (error) {
|
|
368
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
369
|
+
return candidate;
|
|
370
|
+
}
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
candidate = join5(backupDir, `${base}-${counter}`);
|
|
374
|
+
counter += 1;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function pruneBackups(backupDir, backupKey, keepPerFile) {
|
|
378
|
+
const keep = Math.max(1, Math.floor(keepPerFile));
|
|
379
|
+
const suffix = `-${backupKey}`;
|
|
380
|
+
const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
|
|
381
|
+
const matches = readdirSync2(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
|
|
382
|
+
return b.localeCompare(a);
|
|
383
|
+
});
|
|
384
|
+
for (const file of matches.slice(keep)) {
|
|
385
|
+
try {
|
|
386
|
+
unlinkSync(file);
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// src/helpers/finiteMutation.ts
|
|
393
|
+
function findUnsafeMutationValues(value, path = "body", options = {}) {
|
|
394
|
+
if (value === null) {
|
|
395
|
+
return options.allowNullPath?.(path) ? [] : [{ path, reason: "null" }];
|
|
396
|
+
}
|
|
397
|
+
if (typeof value === "number") {
|
|
398
|
+
return Number.isFinite(value) ? [] : [{ path, reason: "non-finite-number" }];
|
|
399
|
+
}
|
|
400
|
+
if (!value || typeof value !== "object") return [];
|
|
401
|
+
if (Array.isArray(value)) {
|
|
402
|
+
return value.flatMap(
|
|
403
|
+
(item, index) => findUnsafeMutationValues(item, `${path}[${index}]`, options)
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
return Object.entries(value).flatMap(
|
|
407
|
+
([key, item]) => findUnsafeMutationValues(item, `${path}.${key}`, options)
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
var DOM_PATCH_NULL_VALUE_PATH = /^body\.operations\[\d+\]\.value$/;
|
|
411
|
+
function findUnsafeDomPatchValues(value) {
|
|
412
|
+
return findUnsafeMutationValues(value, "body", {
|
|
413
|
+
allowNullPath: (path) => DOM_PATCH_NULL_VALUE_PATH.test(path)
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/routes/files.ts
|
|
418
|
+
import { classifyPropertyGroup } from "@hyperframes/parsers/gsap-constants";
|
|
419
|
+
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
420
|
+
import { unrollComputedTimeline } from "@hyperframes/parsers";
|
|
421
|
+
import {
|
|
422
|
+
updateAnimationInScript,
|
|
423
|
+
addAnimationToScript,
|
|
424
|
+
removeAnimationFromScript,
|
|
425
|
+
addKeyframeToScript,
|
|
426
|
+
removeKeyframeFromScript,
|
|
427
|
+
updateKeyframeInScript,
|
|
428
|
+
convertToKeyframesFromScript,
|
|
429
|
+
removeAllKeyframesFromScript,
|
|
430
|
+
materializeKeyframesFromScript,
|
|
431
|
+
unrollDynamicAnimations,
|
|
432
|
+
setArcPathInScript,
|
|
433
|
+
updateArcSegmentInScript,
|
|
434
|
+
removeArcPathFromScript,
|
|
435
|
+
addAnimationWithKeyframesToScript,
|
|
436
|
+
splitAnimationsInScript,
|
|
437
|
+
splitIntoPropertyGroupsFromScript,
|
|
438
|
+
shiftPositionsInScript,
|
|
439
|
+
scalePositionsInScript
|
|
440
|
+
} from "@hyperframes/parsers/gsap-writer-acorn";
|
|
441
|
+
|
|
442
|
+
// src/helpers/sourceMutation.ts
|
|
443
|
+
import { parseHTML } from "linkedom";
|
|
444
|
+
import postcss from "postcss";
|
|
445
|
+
import selectorParser from "postcss-selector-parser";
|
|
446
|
+
import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety";
|
|
447
|
+
function parseSourceDocument(source) {
|
|
448
|
+
const hasDocumentShell = /<!doctype|<html[\s>]/i.test(source);
|
|
449
|
+
if (hasDocumentShell) {
|
|
450
|
+
return { document: parseHTML(source).document, wrappedFragment: false };
|
|
451
|
+
}
|
|
452
|
+
return {
|
|
453
|
+
document: parseHTML(`<!DOCTYPE html><html><head></head><body>${source}</body></html>`).document,
|
|
454
|
+
wrappedFragment: true
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
function duplicateCssRulesForId(document2, originalId, newId) {
|
|
458
|
+
const idToken = `#${originalId}`;
|
|
459
|
+
const transform = selectorParser((selectors) => {
|
|
460
|
+
selectors.walkIds((node) => {
|
|
461
|
+
if (node.value === originalId) node.value = newId;
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
for (const styleEl of document2.querySelectorAll("style")) {
|
|
465
|
+
const css = styleEl.textContent ?? "";
|
|
466
|
+
let root;
|
|
467
|
+
try {
|
|
468
|
+
root = postcss.parse(css);
|
|
469
|
+
} catch {
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const clones = [];
|
|
473
|
+
root.walkRules((rule) => {
|
|
474
|
+
if (!rule.selector.includes(idToken)) return;
|
|
475
|
+
const newSelector = transform.processSync(rule.selector);
|
|
476
|
+
if (newSelector === rule.selector) return;
|
|
477
|
+
const clone = rule.clone({ selector: newSelector });
|
|
478
|
+
clones.push(clone);
|
|
479
|
+
});
|
|
480
|
+
if (clones.length > 0) {
|
|
481
|
+
for (const c of clones) root.append(c);
|
|
482
|
+
styleEl.textContent = root.toString();
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
function querySelectorAllWithTemplates(root, selector) {
|
|
487
|
+
const matches = Array.from(root.querySelectorAll(selector));
|
|
488
|
+
if (matches.length > 0) return matches;
|
|
489
|
+
const templates = Array.from(root.querySelectorAll("template"));
|
|
490
|
+
for (const tmpl of templates) {
|
|
491
|
+
const inner = tmpl.querySelectorAll(selector);
|
|
492
|
+
if (inner.length > 0) return Array.from(inner);
|
|
493
|
+
}
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
function escapeCssAttrValue(value) {
|
|
497
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
498
|
+
}
|
|
499
|
+
function findByHfId(document2, hfId) {
|
|
500
|
+
try {
|
|
501
|
+
const matches = querySelectorAllWithTemplates(
|
|
502
|
+
document2,
|
|
503
|
+
`[data-hf-id="${escapeCssAttrValue(hfId)}"]`
|
|
504
|
+
);
|
|
505
|
+
if (matches.length > 1) {
|
|
506
|
+
console.warn(
|
|
507
|
+
`sourceMutation: data-hf-id "${hfId}" matched ${matches.length} elements; using the first. ids must be unique per document.`
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
return matches[0] ?? null;
|
|
511
|
+
} catch {
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
function findTargetElement(document2, target) {
|
|
516
|
+
if (target.hfId) {
|
|
517
|
+
const el = findByHfId(document2, target.hfId);
|
|
518
|
+
if (el) return el;
|
|
519
|
+
}
|
|
520
|
+
if (target.id) {
|
|
521
|
+
const byId = document2.getElementById(target.id);
|
|
522
|
+
if (byId) return byId;
|
|
523
|
+
}
|
|
524
|
+
if (!target.selector) return null;
|
|
525
|
+
try {
|
|
526
|
+
const matches = querySelectorAllWithTemplates(document2, target.selector);
|
|
527
|
+
return matches[target.selectorIndex ?? 0] ?? null;
|
|
528
|
+
} catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function removeElementFromHtml(source, target) {
|
|
533
|
+
const { document: document2, wrappedFragment } = parseSourceDocument(source);
|
|
534
|
+
const element = findTargetElement(document2, target);
|
|
535
|
+
if (!element) return source;
|
|
536
|
+
element.remove();
|
|
537
|
+
return wrappedFragment ? document2.body.innerHTML || "" : document2.toString();
|
|
538
|
+
}
|
|
539
|
+
function isHTMLElement(el) {
|
|
540
|
+
const HTMLEl = el.ownerDocument.defaultView?.HTMLElement;
|
|
541
|
+
return HTMLEl ? el instanceof HTMLEl : "style" in el;
|
|
542
|
+
}
|
|
543
|
+
function patchStyleAttrString(style, property, value) {
|
|
544
|
+
const props = /* @__PURE__ */ new Map();
|
|
545
|
+
const order = [];
|
|
546
|
+
let i = 0;
|
|
547
|
+
while (i < style.length) {
|
|
548
|
+
let depth = 0;
|
|
549
|
+
let inSingle = false;
|
|
550
|
+
let inDouble = false;
|
|
551
|
+
const start = i;
|
|
552
|
+
while (i < style.length) {
|
|
553
|
+
const ch = style[i];
|
|
554
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
|
555
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
|
556
|
+
else if (!inSingle && !inDouble) {
|
|
557
|
+
if (ch === "(") depth++;
|
|
558
|
+
else if (ch === ")") depth = Math.max(0, depth - 1);
|
|
559
|
+
else if (ch === ";" && depth === 0) break;
|
|
560
|
+
}
|
|
561
|
+
i++;
|
|
562
|
+
}
|
|
563
|
+
const decl = style.slice(start, i).trim();
|
|
564
|
+
i++;
|
|
565
|
+
if (!decl) continue;
|
|
566
|
+
const colon = decl.indexOf(":");
|
|
567
|
+
if (colon < 0) continue;
|
|
568
|
+
const key = decl.slice(0, colon).trim();
|
|
569
|
+
const val = decl.slice(colon + 1).trim();
|
|
570
|
+
if (!key) continue;
|
|
571
|
+
if (!props.has(key)) order.push(key);
|
|
572
|
+
props.set(key, val);
|
|
573
|
+
}
|
|
574
|
+
if (value === null) {
|
|
575
|
+
props.delete(property);
|
|
576
|
+
const idx = order.indexOf(property);
|
|
577
|
+
if (idx >= 0) order.splice(idx, 1);
|
|
578
|
+
} else {
|
|
579
|
+
if (!props.has(property)) order.push(property);
|
|
580
|
+
props.set(property, value);
|
|
581
|
+
}
|
|
582
|
+
return order.map((k) => `${k}: ${props.get(k) ?? ""}`).filter((d) => d.trim()).join("; ");
|
|
583
|
+
}
|
|
584
|
+
function patchElementInHtml(source, target, operations) {
|
|
585
|
+
const { document: document2, wrappedFragment } = parseSourceDocument(source);
|
|
586
|
+
const el = findTargetElement(document2, target);
|
|
587
|
+
if (!el || !isHTMLElement(el)) return { html: source, matched: false };
|
|
588
|
+
const htmlEl = el;
|
|
589
|
+
for (const op of operations) {
|
|
590
|
+
switch (op.type) {
|
|
591
|
+
case "inline-style":
|
|
592
|
+
{
|
|
593
|
+
const raw = htmlEl.getAttribute("style") ?? "";
|
|
594
|
+
const patched = patchStyleAttrString(raw, op.property, op.value);
|
|
595
|
+
htmlEl.setAttribute("style", patched);
|
|
596
|
+
}
|
|
597
|
+
break;
|
|
598
|
+
case "attribute":
|
|
599
|
+
{
|
|
600
|
+
const fullAttr = op.property.startsWith("data-") ? op.property : `data-${op.property}`;
|
|
601
|
+
if (op.value != null) {
|
|
602
|
+
htmlEl.setAttribute(fullAttr, op.value);
|
|
603
|
+
} else {
|
|
604
|
+
htmlEl.removeAttribute(fullAttr);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
break;
|
|
608
|
+
case "html-attribute":
|
|
609
|
+
if (!isAllowedHtmlAttribute(op.property)) break;
|
|
610
|
+
if (op.value != null) {
|
|
611
|
+
if (!isSafeAttributeValue(op.property, op.value)) break;
|
|
612
|
+
htmlEl.setAttribute(op.property, op.value);
|
|
613
|
+
} else {
|
|
614
|
+
htmlEl.removeAttribute(op.property);
|
|
615
|
+
}
|
|
616
|
+
break;
|
|
617
|
+
case "text-content":
|
|
618
|
+
if (op.value != null) {
|
|
619
|
+
const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null;
|
|
620
|
+
const textTarget = inner && isHTMLElement(inner) ? inner : htmlEl;
|
|
621
|
+
textTarget.textContent = op.value;
|
|
622
|
+
}
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return {
|
|
627
|
+
html: wrappedFragment ? document2.body.innerHTML || "" : document2.toString(),
|
|
628
|
+
matched: true
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
function probeElementInSource(source, target) {
|
|
632
|
+
if (!target.id && !target.hfId && !target.selector) return false;
|
|
633
|
+
const { document: document2 } = parseSourceDocument(source);
|
|
634
|
+
const el = findTargetElement(document2, target);
|
|
635
|
+
return el != null && isHTMLElement(el);
|
|
636
|
+
}
|
|
637
|
+
function resolveElementTiming(el) {
|
|
638
|
+
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
|
|
639
|
+
const usesDataEnd = el.hasAttribute("data-end");
|
|
640
|
+
const duration = usesDataEnd ? parseFloat(el.getAttribute("data-end") ?? "") - start || 0 : parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
|
|
641
|
+
return { start, duration, usesDataEnd };
|
|
642
|
+
}
|
|
643
|
+
function setElementDuration(el, start, duration, usesDataEnd) {
|
|
644
|
+
if (usesDataEnd) {
|
|
645
|
+
const endTime = String(Math.round((start + duration) * 1e3) / 1e3);
|
|
646
|
+
el.setAttribute("data-end", endTime);
|
|
647
|
+
el.removeAttribute("data-duration");
|
|
648
|
+
} else {
|
|
649
|
+
el.setAttribute("data-duration", String(Math.round(duration * 1e3) / 1e3));
|
|
650
|
+
el.removeAttribute("data-end");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function splitElementInHtml(source, target, splitTime, newId, fallbackTiming) {
|
|
654
|
+
const { document: document2, wrappedFragment } = parseSourceDocument(source);
|
|
655
|
+
const el = findTargetElement(document2, target);
|
|
656
|
+
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
|
|
657
|
+
const timing = resolveElementTiming(el);
|
|
658
|
+
const { usesDataEnd } = timing;
|
|
659
|
+
let { start, duration } = timing;
|
|
660
|
+
if (duration <= 0 && fallbackTiming && fallbackTiming.duration > 0) {
|
|
661
|
+
start = fallbackTiming.start;
|
|
662
|
+
duration = fallbackTiming.duration;
|
|
663
|
+
}
|
|
664
|
+
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
|
|
665
|
+
return { html: source, matched: false, newId: null };
|
|
666
|
+
}
|
|
667
|
+
if (document2.getElementById(newId)) {
|
|
668
|
+
let suffix = 2;
|
|
669
|
+
const base = newId;
|
|
670
|
+
while (document2.getElementById(newId)) {
|
|
671
|
+
newId = `${base}-${suffix++}`;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const firstDuration = splitTime - start;
|
|
675
|
+
const secondDuration = duration - firstDuration;
|
|
676
|
+
const clone = el.cloneNode(true);
|
|
677
|
+
clone.setAttribute("id", newId);
|
|
678
|
+
clone.removeAttribute("data-hf-id");
|
|
679
|
+
for (const node of clone.querySelectorAll("[data-hf-id]")) node.removeAttribute("data-hf-id");
|
|
680
|
+
clone.setAttribute("data-start", String(Math.round(splitTime * 1e3) / 1e3));
|
|
681
|
+
setElementDuration(clone, splitTime, secondDuration, usesDataEnd);
|
|
682
|
+
const playbackStartAttr = el.hasAttribute("data-playback-start") ? "data-playback-start" : el.hasAttribute("data-media-start") ? "data-media-start" : null;
|
|
683
|
+
if (playbackStartAttr) {
|
|
684
|
+
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
|
|
685
|
+
const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? "");
|
|
686
|
+
const rate = Number.isFinite(rateRaw) ? rateRaw : 1;
|
|
687
|
+
clone.setAttribute(
|
|
688
|
+
playbackStartAttr,
|
|
689
|
+
String(Math.round((currentTrim + firstDuration * rate) * 1e3) / 1e3)
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
const originalId = el.getAttribute("id");
|
|
693
|
+
if (originalId) {
|
|
694
|
+
duplicateCssRulesForId(document2, originalId, newId);
|
|
695
|
+
}
|
|
696
|
+
el.setAttribute("data-start", String(Math.round(start * 1e3) / 1e3));
|
|
697
|
+
setElementDuration(el, start, firstDuration, usesDataEnd);
|
|
698
|
+
if (el.nextSibling) {
|
|
699
|
+
el.parentElement.insertBefore(clone, el.nextSibling);
|
|
700
|
+
} else {
|
|
701
|
+
el.parentElement.appendChild(clone);
|
|
702
|
+
}
|
|
703
|
+
return {
|
|
704
|
+
html: wrappedFragment ? document2.body.innerHTML || "" : document2.toString(),
|
|
705
|
+
matched: true,
|
|
706
|
+
newId
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// src/routes/files.ts
|
|
711
|
+
import { parseHTML as parseHTML2 } from "linkedom";
|
|
712
|
+
function isAcornGsapWriterEnabled() {
|
|
713
|
+
const val = process.env["STUDIO_SDK_CUTOVER_ENABLED"];
|
|
714
|
+
return val === "true" || val === "1";
|
|
715
|
+
}
|
|
716
|
+
async function loadGsapParser() {
|
|
717
|
+
return import("@hyperframes/parsers/gsap-parser-recast");
|
|
718
|
+
}
|
|
719
|
+
async function resolveProjectPath(c, adapter, pathPrefix, opts) {
|
|
720
|
+
const id = c.req.param("id");
|
|
721
|
+
const project = await adapter.resolveProject(id);
|
|
722
|
+
if (!project) {
|
|
723
|
+
return { error: c.json({ error: "not found" }, 404) };
|
|
724
|
+
}
|
|
725
|
+
const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), ""));
|
|
726
|
+
if (filePath.includes("\0")) {
|
|
727
|
+
return { error: c.json({ error: "forbidden" }, 403) };
|
|
728
|
+
}
|
|
729
|
+
const absPath = resolveWithinProject(project.dir, filePath);
|
|
730
|
+
if (!absPath) {
|
|
731
|
+
return { error: c.json({ error: "forbidden" }, 403) };
|
|
732
|
+
}
|
|
733
|
+
if (opts?.mustExist && !existsSync3(absPath)) {
|
|
734
|
+
return { error: c.json({ error: "not found" }, 404) };
|
|
735
|
+
}
|
|
736
|
+
return { project, filePath, absPath };
|
|
737
|
+
}
|
|
738
|
+
function resolveProjectFile(c, adapter, opts) {
|
|
739
|
+
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts);
|
|
740
|
+
}
|
|
741
|
+
function resolveFileMutationContext(c, adapter, operation) {
|
|
742
|
+
return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
|
|
743
|
+
}
|
|
744
|
+
function writeIfChanged(c, projectDir, filePath, absPath, original, next) {
|
|
745
|
+
if (next === original) {
|
|
746
|
+
return c.json({ ok: true, changed: false, content: original, path: filePath });
|
|
747
|
+
}
|
|
748
|
+
const backup = snapshotBeforeWrite(projectDir, absPath);
|
|
749
|
+
if (backup.error) console.warn(`Failed to create backup for ${filePath}: ${backup.error}`);
|
|
750
|
+
writeFileSync4(absPath, next, "utf-8");
|
|
751
|
+
return c.json({
|
|
752
|
+
ok: true,
|
|
753
|
+
changed: true,
|
|
754
|
+
content: next,
|
|
755
|
+
path: filePath,
|
|
756
|
+
backupPath: backupPathForResponse(projectDir, backup.backupPath)
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
function rejectUnsafeMutationValues(c, unsafeFields) {
|
|
760
|
+
return c.json(
|
|
761
|
+
{
|
|
762
|
+
error: "mutation contains unsafe values",
|
|
763
|
+
fields: unsafeFields.map((field) => field.path),
|
|
764
|
+
unsafeValues: unsafeFields
|
|
765
|
+
},
|
|
766
|
+
400
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
async function parseMutationBody(c) {
|
|
770
|
+
const body = await c.req.json().catch(() => null);
|
|
771
|
+
if (!body?.target) {
|
|
772
|
+
return { error: c.json({ error: "target required" }, 400) };
|
|
773
|
+
}
|
|
774
|
+
return { target: body.target, body };
|
|
775
|
+
}
|
|
776
|
+
function ensureDir(filePath) {
|
|
777
|
+
const dir = dirname(filePath);
|
|
778
|
+
if (!existsSync3(dir)) mkdirSync3(dir, { recursive: true });
|
|
779
|
+
}
|
|
780
|
+
function generateCopyPath(projectDir, originalPath) {
|
|
781
|
+
const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : "";
|
|
782
|
+
const base = ext ? originalPath.slice(0, -ext.length) : originalPath;
|
|
783
|
+
const copyMatch = base.match(/ \(copy(?: (\d+))?\)$/);
|
|
784
|
+
const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
|
|
785
|
+
let num = copyMatch ? copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2 : 1;
|
|
786
|
+
let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
|
|
787
|
+
while (existsSync3(resolve2(projectDir, candidate))) {
|
|
788
|
+
num++;
|
|
789
|
+
candidate = `${cleanBase} (copy ${num})${ext}`;
|
|
790
|
+
}
|
|
791
|
+
return candidate;
|
|
792
|
+
}
|
|
793
|
+
function walkFiles(dir, filter) {
|
|
794
|
+
const results = [];
|
|
795
|
+
for (const entry of readdirSync3(dir, { withFileTypes: true })) {
|
|
796
|
+
const full = join6(dir, entry.name);
|
|
797
|
+
if (entry.isDirectory()) {
|
|
798
|
+
if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders")
|
|
799
|
+
continue;
|
|
800
|
+
results.push(...walkFiles(full, filter));
|
|
801
|
+
} else if (filter(entry.name)) {
|
|
802
|
+
results.push(full);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return results;
|
|
806
|
+
}
|
|
807
|
+
function updateReferences(projectDir, oldPath, newPath) {
|
|
808
|
+
const textFiles = walkFiles(
|
|
809
|
+
projectDir,
|
|
810
|
+
(name) => /\.(html|css|js|jsx|ts|tsx|json|mjs|cjs|md|mdx)$/i.test(name)
|
|
811
|
+
);
|
|
812
|
+
let updatedCount = 0;
|
|
813
|
+
for (const file of textFiles) {
|
|
814
|
+
const content = readFileSync3(file, "utf-8");
|
|
815
|
+
if (!content.includes(oldPath)) continue;
|
|
816
|
+
const updated = content.split(oldPath).join(newPath);
|
|
817
|
+
if (updated !== content) {
|
|
818
|
+
writeFileSync4(file, updated, "utf-8");
|
|
819
|
+
updatedCount++;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return updatedCount;
|
|
823
|
+
}
|
|
824
|
+
function extractGsapScriptBlock(html) {
|
|
825
|
+
const { document: document2 } = parseHTML2(html);
|
|
826
|
+
const scripts = [
|
|
827
|
+
...document2.querySelectorAll("script:not([src])"),
|
|
828
|
+
...Array.from(document2.querySelectorAll("template")).flatMap(
|
|
829
|
+
(tmpl) => Array.from(tmpl.querySelectorAll("script:not([src])"))
|
|
830
|
+
)
|
|
831
|
+
];
|
|
832
|
+
for (const script of scripts) {
|
|
833
|
+
const content = script.textContent || "";
|
|
834
|
+
if (content.includes("gsap.timeline") || content.includes(".set(") || content.includes(".to(")) {
|
|
835
|
+
return {
|
|
836
|
+
scriptText: content,
|
|
837
|
+
document: document2,
|
|
838
|
+
replaceScript(newText) {
|
|
839
|
+
script.textContent = newText;
|
|
840
|
+
return document2.toString();
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
function stripStudioEditsFromTarget(document2, selector) {
|
|
848
|
+
if (!selector) return 0;
|
|
849
|
+
let stripped = 0;
|
|
850
|
+
try {
|
|
851
|
+
for (const el of document2.querySelectorAll(selector)) {
|
|
852
|
+
if (!isHTMLElement(el)) continue;
|
|
853
|
+
const htmlEl = el;
|
|
854
|
+
let touched = false;
|
|
855
|
+
if (el.getAttribute("data-hf-studio-path-offset")) {
|
|
856
|
+
const originalTranslate = el.getAttribute("data-hf-studio-original-inline-translate");
|
|
857
|
+
htmlEl.style.removeProperty("--hf-studio-offset-x");
|
|
858
|
+
htmlEl.style.removeProperty("--hf-studio-offset-y");
|
|
859
|
+
if (originalTranslate) {
|
|
860
|
+
htmlEl.style.setProperty("translate", originalTranslate);
|
|
861
|
+
} else {
|
|
862
|
+
htmlEl.style.removeProperty("translate");
|
|
863
|
+
}
|
|
864
|
+
el.removeAttribute("data-hf-studio-path-offset");
|
|
865
|
+
el.removeAttribute("data-hf-studio-original-translate");
|
|
866
|
+
el.removeAttribute("data-hf-studio-original-inline-translate");
|
|
867
|
+
touched = true;
|
|
868
|
+
}
|
|
869
|
+
if (el.getAttribute("data-hf-studio-rotation")) {
|
|
870
|
+
const originalRotate = el.getAttribute("data-hf-studio-original-inline-rotate");
|
|
871
|
+
const originalOrigin = el.getAttribute("data-hf-studio-original-rotation-transform-origin");
|
|
872
|
+
htmlEl.style.removeProperty("--hf-studio-rotation");
|
|
873
|
+
if (originalRotate) {
|
|
874
|
+
htmlEl.style.setProperty("rotate", originalRotate);
|
|
875
|
+
} else {
|
|
876
|
+
htmlEl.style.removeProperty("rotate");
|
|
877
|
+
}
|
|
878
|
+
if (originalOrigin) {
|
|
879
|
+
htmlEl.style.setProperty("transform-origin", originalOrigin);
|
|
880
|
+
} else {
|
|
881
|
+
htmlEl.style.removeProperty("transform-origin");
|
|
882
|
+
}
|
|
883
|
+
el.removeAttribute("data-hf-studio-rotation");
|
|
884
|
+
el.removeAttribute("data-hf-studio-rotation-draft");
|
|
885
|
+
el.removeAttribute("data-hf-studio-original-rotate");
|
|
886
|
+
el.removeAttribute("data-hf-studio-original-inline-rotate");
|
|
887
|
+
el.removeAttribute("data-hf-studio-original-rotation-transform-origin");
|
|
888
|
+
touched = true;
|
|
889
|
+
}
|
|
890
|
+
if (touched) stripped++;
|
|
891
|
+
}
|
|
892
|
+
} catch {
|
|
893
|
+
}
|
|
894
|
+
return stripped;
|
|
895
|
+
}
|
|
896
|
+
function keyframesWritePosition(keyframes) {
|
|
897
|
+
return keyframes.some(
|
|
898
|
+
(kf) => Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position")
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
function keyframesWriteRotation(keyframes) {
|
|
902
|
+
return keyframes.some(
|
|
903
|
+
(kf) => Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "rotation")
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
function lastKeyframeOpacity(kfs) {
|
|
907
|
+
if (!kfs) return void 0;
|
|
908
|
+
for (let i = kfs.keyframes.length - 1; i >= 0; i--) {
|
|
909
|
+
if ("opacity" in kfs.keyframes[i].properties) return kfs.keyframes[i].properties.opacity;
|
|
910
|
+
}
|
|
911
|
+
return void 0;
|
|
912
|
+
}
|
|
913
|
+
function resolveFinalOpacity(anim) {
|
|
914
|
+
if (anim.method === "from") return null;
|
|
915
|
+
const raw = anim.keyframes ? lastKeyframeOpacity(anim.keyframes) : anim.properties.opacity;
|
|
916
|
+
if (raw == null) return null;
|
|
917
|
+
if (typeof raw === "string" && /^[+\-*]=/.test(raw)) return null;
|
|
918
|
+
const num = Number(raw);
|
|
919
|
+
return Number.isFinite(num) && num !== 0 ? num : null;
|
|
920
|
+
}
|
|
921
|
+
function bakeVisibilityOnDelete(document2, anim) {
|
|
922
|
+
const opacity = resolveFinalOpacity(anim);
|
|
923
|
+
if (opacity === null) return;
|
|
924
|
+
try {
|
|
925
|
+
for (const el of document2.querySelectorAll(anim.targetSelector)) {
|
|
926
|
+
if (isHTMLElement(el)) el.style.setProperty("opacity", String(opacity));
|
|
927
|
+
}
|
|
928
|
+
} catch {
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
var HOLD_SYNC_MUTATION_TYPES = /* @__PURE__ */ new Set([
|
|
932
|
+
"add-keyframe",
|
|
933
|
+
"update-keyframe",
|
|
934
|
+
"remove-keyframe",
|
|
935
|
+
"remove-all-keyframes",
|
|
936
|
+
"add-with-keyframes",
|
|
937
|
+
"replace-with-keyframes",
|
|
938
|
+
"convert-to-keyframes",
|
|
939
|
+
"materialize-keyframes",
|
|
940
|
+
"update-motion-path-point",
|
|
941
|
+
"add-motion-path-point",
|
|
942
|
+
"remove-motion-path-point",
|
|
943
|
+
// Authors a fresh motionPath tween whose parsed first keyframe is (0,0); if it lands
|
|
944
|
+
// at position > 0 the element snaps home at t=0 without a pre-tween hold-`set`.
|
|
945
|
+
"add-motion-path",
|
|
946
|
+
// Can move a tween's `position` (start) across the t=0 boundary, which flips whether a
|
|
947
|
+
// keyframed position tween needs a hold (started at 0 → moved later, or vice versa).
|
|
948
|
+
"update-meta",
|
|
949
|
+
// Time-shift / time-scale tweens, which can move a keyframed position tween's start
|
|
950
|
+
// across t=0, flipping hold need; stale holds are not repositioned by these ops.
|
|
951
|
+
"shift-positions",
|
|
952
|
+
"scale-positions",
|
|
953
|
+
// Retargets keyframed position tweens to a cloned element's selector; the old hold is
|
|
954
|
+
// keyed to the prior selector, so holds must be rebuilt for the new target.
|
|
955
|
+
"split-animations",
|
|
956
|
+
"delete",
|
|
957
|
+
"delete-all-for-selector"
|
|
958
|
+
]);
|
|
959
|
+
async function executeGsapMutation(body, block, respond) {
|
|
960
|
+
if (!isAcornGsapWriterEnabled()) {
|
|
961
|
+
return executeGsapMutationRecast(body, block, respond);
|
|
962
|
+
}
|
|
963
|
+
return executeGsapMutationAcorn(body, block, respond);
|
|
964
|
+
}
|
|
965
|
+
function executeGsapMutationAcorn(body, block, respond) {
|
|
966
|
+
function requireAnimation(scriptText, animationId) {
|
|
967
|
+
const parsed = parseGsapScriptAcorn(scriptText);
|
|
968
|
+
const anim = parsed.animations.find((a) => a.id === animationId);
|
|
969
|
+
if (!anim) return { err: respond({ error: "animation not found" }, 404) };
|
|
970
|
+
return { anim };
|
|
971
|
+
}
|
|
972
|
+
function requireFromToAnimation(scriptText, animationId) {
|
|
973
|
+
const result = requireAnimation(scriptText, animationId);
|
|
974
|
+
if ("err" in result) return result;
|
|
975
|
+
if (result.anim.method !== "fromTo")
|
|
976
|
+
return { err: respond({ error: "animation is not a fromTo" }, 400) };
|
|
977
|
+
return result;
|
|
978
|
+
}
|
|
979
|
+
switch (body.type) {
|
|
980
|
+
case "update-property":
|
|
981
|
+
case "add-property": {
|
|
982
|
+
const r = requireAnimation(block.scriptText, body.animationId);
|
|
983
|
+
if ("err" in r) return r.err;
|
|
984
|
+
const val = body.type === "update-property" ? body.value : body.defaultValue;
|
|
985
|
+
return updateAnimationInScript(block.scriptText, body.animationId, {
|
|
986
|
+
properties: { ...r.anim.properties, [body.property]: val }
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
case "update-properties": {
|
|
990
|
+
const r = requireAnimation(block.scriptText, body.animationId);
|
|
991
|
+
if ("err" in r) return r.err;
|
|
992
|
+
return updateAnimationInScript(block.scriptText, body.animationId, {
|
|
993
|
+
properties: { ...r.anim.properties, ...body.properties }
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
case "update-from-property":
|
|
997
|
+
case "add-from-property": {
|
|
998
|
+
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
|
999
|
+
if ("err" in r) return r.err;
|
|
1000
|
+
const val = body.type === "update-from-property" ? body.value : body.defaultValue;
|
|
1001
|
+
return updateAnimationInScript(block.scriptText, body.animationId, {
|
|
1002
|
+
fromProperties: { ...r.anim.fromProperties ?? {}, [body.property]: val }
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
case "update-meta": {
|
|
1006
|
+
return updateAnimationInScript(block.scriptText, body.animationId, body.updates);
|
|
1007
|
+
}
|
|
1008
|
+
case "add": {
|
|
1009
|
+
if (body.fromProperties && body.method !== "fromTo") {
|
|
1010
|
+
return respond({ error: "fromProperties is only valid for method=fromTo" }, 400);
|
|
1011
|
+
}
|
|
1012
|
+
const result = addAnimationToScript(block.scriptText, {
|
|
1013
|
+
targetSelector: body.targetSelector,
|
|
1014
|
+
method: body.method,
|
|
1015
|
+
position: body.position,
|
|
1016
|
+
duration: body.duration,
|
|
1017
|
+
ease: body.ease,
|
|
1018
|
+
properties: body.properties,
|
|
1019
|
+
fromProperties: body.fromProperties,
|
|
1020
|
+
...body.global ? { global: true } : {}
|
|
1021
|
+
});
|
|
1022
|
+
return result.script;
|
|
1023
|
+
}
|
|
1024
|
+
case "delete": {
|
|
1025
|
+
const delTarget = requireAnimation(block.scriptText, body.animationId);
|
|
1026
|
+
if (!("err" in delTarget) && body.stripStudioEdits) {
|
|
1027
|
+
stripStudioEditsFromTarget(block.document, delTarget.anim.targetSelector);
|
|
1028
|
+
bakeVisibilityOnDelete(block.document, delTarget.anim);
|
|
1029
|
+
}
|
|
1030
|
+
return removeAnimationFromScript(block.scriptText, body.animationId);
|
|
1031
|
+
}
|
|
1032
|
+
case "delete-all-for-selector": {
|
|
1033
|
+
const parsed = parseGsapScriptAcorn(block.scriptText);
|
|
1034
|
+
const matching = parsed.animations.filter((a) => a.targetSelector === body.targetSelector);
|
|
1035
|
+
if (matching.length === 0) return block.scriptText;
|
|
1036
|
+
stripStudioEditsFromTarget(block.document, body.targetSelector);
|
|
1037
|
+
let script = block.scriptText;
|
|
1038
|
+
for (const anim of matching.reverse()) {
|
|
1039
|
+
script = removeAnimationFromScript(script, anim.id);
|
|
1040
|
+
}
|
|
1041
|
+
return script;
|
|
1042
|
+
}
|
|
1043
|
+
case "remove-property": {
|
|
1044
|
+
const r = requireAnimation(block.scriptText, body.animationId);
|
|
1045
|
+
if ("err" in r) return r.err;
|
|
1046
|
+
const filtered = { ...r.anim.properties };
|
|
1047
|
+
delete filtered[body.property];
|
|
1048
|
+
return updateAnimationInScript(block.scriptText, body.animationId, {
|
|
1049
|
+
properties: filtered
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
case "remove-from-property": {
|
|
1053
|
+
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
|
1054
|
+
if ("err" in r) return r.err;
|
|
1055
|
+
const filtered = { ...r.anim.fromProperties ?? {} };
|
|
1056
|
+
delete filtered[body.property];
|
|
1057
|
+
return updateAnimationInScript(block.scriptText, body.animationId, {
|
|
1058
|
+
fromProperties: filtered
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
case "add-keyframe": {
|
|
1062
|
+
return addKeyframeToScript(
|
|
1063
|
+
block.scriptText,
|
|
1064
|
+
body.animationId,
|
|
1065
|
+
body.percentage,
|
|
1066
|
+
body.properties,
|
|
1067
|
+
body.ease,
|
|
1068
|
+
body.backfillDefaults
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
case "remove-keyframe": {
|
|
1072
|
+
return removeKeyframeFromScript(block.scriptText, body.animationId, body.percentage);
|
|
1073
|
+
}
|
|
1074
|
+
case "update-keyframe": {
|
|
1075
|
+
return updateKeyframeInScript(
|
|
1076
|
+
block.scriptText,
|
|
1077
|
+
body.animationId,
|
|
1078
|
+
body.percentage,
|
|
1079
|
+
body.properties,
|
|
1080
|
+
body.ease
|
|
1081
|
+
);
|
|
1082
|
+
}
|
|
1083
|
+
case "convert-to-keyframes": {
|
|
1084
|
+
return convertToKeyframesFromScript(
|
|
1085
|
+
block.scriptText,
|
|
1086
|
+
body.animationId,
|
|
1087
|
+
body.resolvedFromValues,
|
|
1088
|
+
body.duration
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
case "remove-all-keyframes": {
|
|
1092
|
+
const preCollapse = requireAnimation(block.scriptText, body.animationId);
|
|
1093
|
+
if (!("err" in preCollapse)) {
|
|
1094
|
+
bakeVisibilityOnDelete(block.document, preCollapse.anim);
|
|
1095
|
+
}
|
|
1096
|
+
return removeAllKeyframesFromScript(block.scriptText, body.animationId);
|
|
1097
|
+
}
|
|
1098
|
+
case "materialize-keyframes": {
|
|
1099
|
+
if (body.allElements && body.allElements.length > 0) {
|
|
1100
|
+
return unrollDynamicAnimations(block.scriptText, body.animationId, body.allElements);
|
|
1101
|
+
}
|
|
1102
|
+
return materializeKeyframesFromScript(
|
|
1103
|
+
block.scriptText,
|
|
1104
|
+
body.animationId,
|
|
1105
|
+
body.keyframes,
|
|
1106
|
+
body.easeEach,
|
|
1107
|
+
body.resolvedSelector
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
case "set-arc-path": {
|
|
1111
|
+
return setArcPathInScript(block.scriptText, body.animationId, {
|
|
1112
|
+
enabled: body.enabled,
|
|
1113
|
+
autoRotate: body.autoRotate ?? false,
|
|
1114
|
+
segments: body.segments ?? []
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
case "update-arc-segment": {
|
|
1118
|
+
return updateArcSegmentInScript(block.scriptText, body.animationId, body.segmentIndex, {
|
|
1119
|
+
...body.curviness !== void 0 ? { curviness: body.curviness } : {},
|
|
1120
|
+
...body.cp1 ? { cp1: body.cp1 } : {},
|
|
1121
|
+
...body.cp2 ? { cp2: body.cp2 } : {}
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
case "remove-arc-path": {
|
|
1125
|
+
return removeArcPathFromScript(block.scriptText, body.animationId);
|
|
1126
|
+
}
|
|
1127
|
+
case "add-with-keyframes": {
|
|
1128
|
+
const result = addAnimationWithKeyframesToScript(
|
|
1129
|
+
block.scriptText,
|
|
1130
|
+
body.targetSelector,
|
|
1131
|
+
body.position,
|
|
1132
|
+
body.duration,
|
|
1133
|
+
body.keyframes,
|
|
1134
|
+
body.ease,
|
|
1135
|
+
body.easeEach
|
|
1136
|
+
);
|
|
1137
|
+
return result.script;
|
|
1138
|
+
}
|
|
1139
|
+
case "replace-with-keyframes": {
|
|
1140
|
+
const script = removeAnimationFromScript(block.scriptText, body.animationId);
|
|
1141
|
+
const added = addAnimationWithKeyframesToScript(
|
|
1142
|
+
script,
|
|
1143
|
+
body.targetSelector,
|
|
1144
|
+
body.position,
|
|
1145
|
+
body.duration,
|
|
1146
|
+
body.keyframes,
|
|
1147
|
+
body.ease
|
|
1148
|
+
);
|
|
1149
|
+
return added.script;
|
|
1150
|
+
}
|
|
1151
|
+
case "split-animations": {
|
|
1152
|
+
if (typeof body.originalId !== "string" || !body.originalId || typeof body.newId !== "string" || !body.newId || typeof body.splitTime !== "number" || !Number.isFinite(body.splitTime) || typeof body.elementStart !== "number" || !Number.isFinite(body.elementStart) || typeof body.elementDuration !== "number" || !Number.isFinite(body.elementDuration) || body.elementDuration <= 0) {
|
|
1153
|
+
return respond(
|
|
1154
|
+
{
|
|
1155
|
+
error: "split-animations requires originalId, newId (non-empty strings), splitTime, elementStart (finite numbers), and elementDuration (positive number)"
|
|
1156
|
+
},
|
|
1157
|
+
400
|
|
1158
|
+
);
|
|
1159
|
+
}
|
|
1160
|
+
return splitAnimationsInScript(block.scriptText, {
|
|
1161
|
+
originalId: body.originalId,
|
|
1162
|
+
newId: body.newId,
|
|
1163
|
+
splitTime: body.splitTime,
|
|
1164
|
+
elementStart: body.elementStart,
|
|
1165
|
+
elementDuration: body.elementDuration
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
case "split-into-property-groups": {
|
|
1169
|
+
const result = splitIntoPropertyGroupsFromScript(block.scriptText, body.animationId);
|
|
1170
|
+
return result.script;
|
|
1171
|
+
}
|
|
1172
|
+
case "unroll-timeline": {
|
|
1173
|
+
return unrollComputedTimeline(block.scriptText);
|
|
1174
|
+
}
|
|
1175
|
+
case "shift-positions": {
|
|
1176
|
+
const { targetSelector, delta } = body;
|
|
1177
|
+
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
|
|
1178
|
+
return shiftPositionsInScript(block.scriptText, targetSelector, delta);
|
|
1179
|
+
}
|
|
1180
|
+
case "scale-positions": {
|
|
1181
|
+
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
|
|
1182
|
+
if (!targetSelector || !Number.isFinite(oldStart) || !Number.isFinite(oldDuration) || !Number.isFinite(newStart) || !Number.isFinite(newDuration) || oldDuration <= 0 || newDuration <= 0)
|
|
1183
|
+
return block.scriptText;
|
|
1184
|
+
if (oldStart === newStart && oldDuration === newDuration) return block.scriptText;
|
|
1185
|
+
return scalePositionsInScript(
|
|
1186
|
+
block.scriptText,
|
|
1187
|
+
targetSelector,
|
|
1188
|
+
oldStart,
|
|
1189
|
+
oldDuration,
|
|
1190
|
+
newStart,
|
|
1191
|
+
newDuration
|
|
1192
|
+
);
|
|
1193
|
+
}
|
|
1194
|
+
default:
|
|
1195
|
+
return respond({ error: `unknown mutation type: ${body.type}` }, 400);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
async function executeGsapMutationRecast(body, block, respond) {
|
|
1199
|
+
const parser = await loadGsapParser();
|
|
1200
|
+
const {
|
|
1201
|
+
updateAnimationInScript: updateAnimationInScript2,
|
|
1202
|
+
addAnimationToScript: addAnimationToScript2,
|
|
1203
|
+
removeAnimationFromScript: removeAnimationFromScript2,
|
|
1204
|
+
addKeyframeToScript: addKeyframeToScript2,
|
|
1205
|
+
removeKeyframeFromScript: removeKeyframeFromScript2,
|
|
1206
|
+
updateKeyframeInScript: updateKeyframeInScript2,
|
|
1207
|
+
convertToKeyframesInScript,
|
|
1208
|
+
removeAllKeyframesFromScript: removeAllKeyframesFromScript2,
|
|
1209
|
+
materializeKeyframesInScript,
|
|
1210
|
+
unrollDynamicAnimations: unrollDynamicAnimations2,
|
|
1211
|
+
setArcPathInScript: setArcPathInScript2,
|
|
1212
|
+
updateArcSegmentInScript: updateArcSegmentInScript2,
|
|
1213
|
+
updateMotionPathPointInScript,
|
|
1214
|
+
addMotionPathPointInScript,
|
|
1215
|
+
removeMotionPathPointInScript,
|
|
1216
|
+
addMotionPathToScript,
|
|
1217
|
+
removeArcPathFromScript: removeArcPathFromScript2,
|
|
1218
|
+
addAnimationWithKeyframesToScript: addAnimationWithKeyframesToScript2,
|
|
1219
|
+
splitAnimationsInScript: splitAnimationsInScript2,
|
|
1220
|
+
splitIntoPropertyGroups
|
|
1221
|
+
} = parser;
|
|
1222
|
+
function requireAnimation(scriptText, animationId) {
|
|
1223
|
+
const parsed = parseGsapScriptAcorn(scriptText);
|
|
1224
|
+
const anim = parsed.animations.find((a) => a.id === animationId);
|
|
1225
|
+
if (!anim) return { err: respond({ error: "animation not found" }, 404) };
|
|
1226
|
+
return { anim };
|
|
1227
|
+
}
|
|
1228
|
+
function requireFromToAnimation(scriptText, animationId) {
|
|
1229
|
+
const result = requireAnimation(scriptText, animationId);
|
|
1230
|
+
if ("err" in result) return result;
|
|
1231
|
+
if (result.anim.method !== "fromTo")
|
|
1232
|
+
return { err: respond({ error: "animation is not a fromTo" }, 400) };
|
|
1233
|
+
return result;
|
|
1234
|
+
}
|
|
1235
|
+
switch (body.type) {
|
|
1236
|
+
case "update-property":
|
|
1237
|
+
case "add-property": {
|
|
1238
|
+
const r = requireAnimation(block.scriptText, body.animationId);
|
|
1239
|
+
if ("err" in r) return r.err;
|
|
1240
|
+
const val = body.type === "update-property" ? body.value : body.defaultValue;
|
|
1241
|
+
return updateAnimationInScript2(block.scriptText, body.animationId, {
|
|
1242
|
+
properties: { ...r.anim.properties, [body.property]: val }
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
case "update-properties": {
|
|
1246
|
+
const r = requireAnimation(block.scriptText, body.animationId);
|
|
1247
|
+
if ("err" in r) return r.err;
|
|
1248
|
+
return updateAnimationInScript2(block.scriptText, body.animationId, {
|
|
1249
|
+
properties: { ...r.anim.properties, ...body.properties }
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
case "update-from-property":
|
|
1253
|
+
case "add-from-property": {
|
|
1254
|
+
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
|
1255
|
+
if ("err" in r) return r.err;
|
|
1256
|
+
const val = body.type === "update-from-property" ? body.value : body.defaultValue;
|
|
1257
|
+
return updateAnimationInScript2(block.scriptText, body.animationId, {
|
|
1258
|
+
fromProperties: { ...r.anim.fromProperties ?? {}, [body.property]: val }
|
|
1259
|
+
});
|
|
1260
|
+
}
|
|
1261
|
+
case "update-meta": {
|
|
1262
|
+
return updateAnimationInScript2(block.scriptText, body.animationId, body.updates);
|
|
1263
|
+
}
|
|
1264
|
+
case "add": {
|
|
1265
|
+
if (body.fromProperties && body.method !== "fromTo") {
|
|
1266
|
+
return respond({ error: "fromProperties is only valid for method=fromTo" }, 400);
|
|
1267
|
+
}
|
|
1268
|
+
if (Object.keys(body.properties).some((k) => {
|
|
1269
|
+
const group = classifyPropertyGroup(k);
|
|
1270
|
+
return group === "position" || group === "rotation";
|
|
1271
|
+
})) {
|
|
1272
|
+
stripStudioEditsFromTarget(block.document, body.targetSelector);
|
|
1273
|
+
}
|
|
1274
|
+
const result = addAnimationToScript2(block.scriptText, {
|
|
1275
|
+
targetSelector: body.targetSelector,
|
|
1276
|
+
method: body.method,
|
|
1277
|
+
position: body.position,
|
|
1278
|
+
duration: body.duration,
|
|
1279
|
+
ease: body.ease,
|
|
1280
|
+
properties: body.properties,
|
|
1281
|
+
fromProperties: body.fromProperties,
|
|
1282
|
+
...body.global ? { global: true } : {}
|
|
1283
|
+
});
|
|
1284
|
+
return result.script;
|
|
1285
|
+
}
|
|
1286
|
+
case "delete": {
|
|
1287
|
+
const delTarget = requireAnimation(block.scriptText, body.animationId);
|
|
1288
|
+
if (!("err" in delTarget) && body.stripStudioEdits) {
|
|
1289
|
+
stripStudioEditsFromTarget(block.document, delTarget.anim.targetSelector);
|
|
1290
|
+
bakeVisibilityOnDelete(block.document, delTarget.anim);
|
|
1291
|
+
}
|
|
1292
|
+
return removeAnimationFromScript2(block.scriptText, body.animationId);
|
|
1293
|
+
}
|
|
1294
|
+
case "delete-all-for-selector": {
|
|
1295
|
+
const parsed = parseGsapScriptAcorn(block.scriptText);
|
|
1296
|
+
const matching = parsed.animations.filter((a) => a.targetSelector === body.targetSelector);
|
|
1297
|
+
if (matching.length === 0) return block.scriptText;
|
|
1298
|
+
stripStudioEditsFromTarget(block.document, body.targetSelector);
|
|
1299
|
+
let script = block.scriptText;
|
|
1300
|
+
for (const anim of matching.reverse()) {
|
|
1301
|
+
script = removeAnimationFromScript2(script, anim.id);
|
|
1302
|
+
}
|
|
1303
|
+
return script;
|
|
1304
|
+
}
|
|
1305
|
+
case "remove-property": {
|
|
1306
|
+
const r = requireAnimation(block.scriptText, body.animationId);
|
|
1307
|
+
if ("err" in r) return r.err;
|
|
1308
|
+
const filtered = { ...r.anim.properties };
|
|
1309
|
+
delete filtered[body.property];
|
|
1310
|
+
return updateAnimationInScript2(block.scriptText, body.animationId, {
|
|
1311
|
+
properties: filtered
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
case "remove-from-property": {
|
|
1315
|
+
const r = requireFromToAnimation(block.scriptText, body.animationId);
|
|
1316
|
+
if ("err" in r) return r.err;
|
|
1317
|
+
const filtered = { ...r.anim.fromProperties ?? {} };
|
|
1318
|
+
delete filtered[body.property];
|
|
1319
|
+
return updateAnimationInScript2(block.scriptText, body.animationId, {
|
|
1320
|
+
fromProperties: filtered
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
case "add-keyframe": {
|
|
1324
|
+
return addKeyframeToScript2(
|
|
1325
|
+
block.scriptText,
|
|
1326
|
+
body.animationId,
|
|
1327
|
+
body.percentage,
|
|
1328
|
+
body.properties,
|
|
1329
|
+
body.ease,
|
|
1330
|
+
body.backfillDefaults
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
case "remove-keyframe": {
|
|
1334
|
+
return removeKeyframeFromScript2(block.scriptText, body.animationId, body.percentage);
|
|
1335
|
+
}
|
|
1336
|
+
case "update-keyframe": {
|
|
1337
|
+
return updateKeyframeInScript2(
|
|
1338
|
+
block.scriptText,
|
|
1339
|
+
body.animationId,
|
|
1340
|
+
body.percentage,
|
|
1341
|
+
body.properties,
|
|
1342
|
+
body.ease
|
|
1343
|
+
);
|
|
1344
|
+
}
|
|
1345
|
+
case "convert-to-keyframes": {
|
|
1346
|
+
return convertToKeyframesInScript(
|
|
1347
|
+
block.scriptText,
|
|
1348
|
+
body.animationId,
|
|
1349
|
+
body.resolvedFromValues,
|
|
1350
|
+
body.duration
|
|
1351
|
+
);
|
|
1352
|
+
}
|
|
1353
|
+
case "remove-all-keyframes": {
|
|
1354
|
+
const preCollapse = requireAnimation(block.scriptText, body.animationId);
|
|
1355
|
+
if (!("err" in preCollapse)) {
|
|
1356
|
+
bakeVisibilityOnDelete(block.document, preCollapse.anim);
|
|
1357
|
+
}
|
|
1358
|
+
return removeAllKeyframesFromScript2(block.scriptText, body.animationId);
|
|
1359
|
+
}
|
|
1360
|
+
case "materialize-keyframes": {
|
|
1361
|
+
if (body.allElements && body.allElements.length > 0) {
|
|
1362
|
+
return unrollDynamicAnimations2(block.scriptText, body.animationId, body.allElements);
|
|
1363
|
+
}
|
|
1364
|
+
return materializeKeyframesInScript(
|
|
1365
|
+
block.scriptText,
|
|
1366
|
+
body.animationId,
|
|
1367
|
+
body.keyframes,
|
|
1368
|
+
body.easeEach,
|
|
1369
|
+
body.resolvedSelector
|
|
1370
|
+
);
|
|
1371
|
+
}
|
|
1372
|
+
case "set-arc-path": {
|
|
1373
|
+
return setArcPathInScript2(block.scriptText, body.animationId, {
|
|
1374
|
+
enabled: body.enabled,
|
|
1375
|
+
autoRotate: body.autoRotate ?? false,
|
|
1376
|
+
segments: body.segments ?? []
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
case "update-arc-segment": {
|
|
1380
|
+
return updateArcSegmentInScript2(block.scriptText, body.animationId, body.segmentIndex, {
|
|
1381
|
+
...body.curviness !== void 0 ? { curviness: body.curviness } : {},
|
|
1382
|
+
...body.cp1 ? { cp1: body.cp1 } : {},
|
|
1383
|
+
...body.cp2 ? { cp2: body.cp2 } : {}
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
case "update-motion-path-point": {
|
|
1387
|
+
return updateMotionPathPointInScript(block.scriptText, body.animationId, body.pointIndex, {
|
|
1388
|
+
x: body.x,
|
|
1389
|
+
y: body.y
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
case "add-motion-path-point": {
|
|
1393
|
+
return addMotionPathPointInScript(block.scriptText, body.animationId, body.index, {
|
|
1394
|
+
x: body.x,
|
|
1395
|
+
y: body.y
|
|
1396
|
+
});
|
|
1397
|
+
}
|
|
1398
|
+
case "remove-motion-path-point": {
|
|
1399
|
+
return removeMotionPathPointInScript(block.scriptText, body.animationId, body.index);
|
|
1400
|
+
}
|
|
1401
|
+
case "add-motion-path": {
|
|
1402
|
+
const result = addMotionPathToScript(
|
|
1403
|
+
block.scriptText,
|
|
1404
|
+
body.targetSelector,
|
|
1405
|
+
body.position,
|
|
1406
|
+
body.duration,
|
|
1407
|
+
{ x: body.x, y: body.y },
|
|
1408
|
+
body.ease
|
|
1409
|
+
);
|
|
1410
|
+
return result.script;
|
|
1411
|
+
}
|
|
1412
|
+
case "remove-arc-path": {
|
|
1413
|
+
return removeArcPathFromScript2(block.scriptText, body.animationId);
|
|
1414
|
+
}
|
|
1415
|
+
case "add-with-keyframes": {
|
|
1416
|
+
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
|
|
1417
|
+
stripStudioEditsFromTarget(block.document, body.targetSelector);
|
|
1418
|
+
}
|
|
1419
|
+
const result = addAnimationWithKeyframesToScript2(
|
|
1420
|
+
block.scriptText,
|
|
1421
|
+
body.targetSelector,
|
|
1422
|
+
body.position,
|
|
1423
|
+
body.duration,
|
|
1424
|
+
body.keyframes,
|
|
1425
|
+
body.ease
|
|
1426
|
+
);
|
|
1427
|
+
return result.script;
|
|
1428
|
+
}
|
|
1429
|
+
case "replace-with-keyframes": {
|
|
1430
|
+
if (keyframesWritePosition(body.keyframes) || keyframesWriteRotation(body.keyframes)) {
|
|
1431
|
+
stripStudioEditsFromTarget(block.document, body.targetSelector);
|
|
1432
|
+
}
|
|
1433
|
+
const script = removeAnimationFromScript2(block.scriptText, body.animationId);
|
|
1434
|
+
const added = addAnimationWithKeyframesToScript2(
|
|
1435
|
+
script,
|
|
1436
|
+
body.targetSelector,
|
|
1437
|
+
body.position,
|
|
1438
|
+
body.duration,
|
|
1439
|
+
body.keyframes,
|
|
1440
|
+
body.ease
|
|
1441
|
+
);
|
|
1442
|
+
return added.script;
|
|
1443
|
+
}
|
|
1444
|
+
case "split-animations": {
|
|
1445
|
+
if (typeof body.originalId !== "string" || !body.originalId || typeof body.newId !== "string" || !body.newId || typeof body.splitTime !== "number" || !Number.isFinite(body.splitTime) || typeof body.elementStart !== "number" || !Number.isFinite(body.elementStart) || typeof body.elementDuration !== "number" || !Number.isFinite(body.elementDuration) || body.elementDuration <= 0) {
|
|
1446
|
+
return respond(
|
|
1447
|
+
{
|
|
1448
|
+
error: "split-animations requires originalId, newId (non-empty strings), splitTime, elementStart (finite numbers), and elementDuration (positive number)"
|
|
1449
|
+
},
|
|
1450
|
+
400
|
|
1451
|
+
);
|
|
1452
|
+
}
|
|
1453
|
+
return splitAnimationsInScript2(block.scriptText, {
|
|
1454
|
+
originalId: body.originalId,
|
|
1455
|
+
newId: body.newId,
|
|
1456
|
+
splitTime: body.splitTime,
|
|
1457
|
+
elementStart: body.elementStart,
|
|
1458
|
+
elementDuration: body.elementDuration
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
case "split-into-property-groups": {
|
|
1462
|
+
const result = splitIntoPropertyGroups(block.scriptText, body.animationId);
|
|
1463
|
+
return result.script;
|
|
1464
|
+
}
|
|
1465
|
+
case "unroll-timeline": {
|
|
1466
|
+
return unrollComputedTimeline(block.scriptText);
|
|
1467
|
+
}
|
|
1468
|
+
case "shift-positions": {
|
|
1469
|
+
const { targetSelector, delta } = body;
|
|
1470
|
+
if (!targetSelector || !Number.isFinite(delta) || delta === 0) return block.scriptText;
|
|
1471
|
+
const { shiftPositionsInScript: shiftPositionsInScript2 } = parser;
|
|
1472
|
+
return shiftPositionsInScript2(block.scriptText, targetSelector, delta);
|
|
1473
|
+
}
|
|
1474
|
+
case "scale-positions": {
|
|
1475
|
+
const { targetSelector, oldStart, oldDuration, newStart, newDuration } = body;
|
|
1476
|
+
if (!targetSelector || !Number.isFinite(oldStart) || !Number.isFinite(oldDuration) || !Number.isFinite(newStart) || !Number.isFinite(newDuration) || oldDuration <= 0 || newDuration <= 0)
|
|
1477
|
+
return block.scriptText;
|
|
1478
|
+
if (oldStart === newStart && oldDuration === newDuration) return block.scriptText;
|
|
1479
|
+
const { scalePositionsInScript: scalePositionsInScript2 } = parser;
|
|
1480
|
+
return scalePositionsInScript2(
|
|
1481
|
+
block.scriptText,
|
|
1482
|
+
targetSelector,
|
|
1483
|
+
oldStart,
|
|
1484
|
+
oldDuration,
|
|
1485
|
+
newStart,
|
|
1486
|
+
newDuration
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1489
|
+
default:
|
|
1490
|
+
return respond({ error: `unknown mutation type: ${body.type}` }, 400);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
async function processUploadedFiles(formData, targetDir, projectDir) {
|
|
1494
|
+
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
1495
|
+
const uploaded = [];
|
|
1496
|
+
const skipped = [];
|
|
1497
|
+
const invalid = [];
|
|
1498
|
+
const entries = formData.entries();
|
|
1499
|
+
const subDir = targetDir === projectDir ? "" : targetDir.slice(projectDir.length + 1);
|
|
1500
|
+
for (const [, value] of entries) {
|
|
1501
|
+
if (typeof value === "string") continue;
|
|
1502
|
+
const name = value.name.split("/").pop()?.split("\\").pop() ?? "";
|
|
1503
|
+
if (!name || name.includes("\0") || name.includes("..")) continue;
|
|
1504
|
+
if (value.size > MAX_UPLOAD_BYTES) {
|
|
1505
|
+
skipped.push(name);
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
const destPath = resolve2(targetDir, name);
|
|
1509
|
+
if (!isSafePath(projectDir, destPath)) continue;
|
|
1510
|
+
let finalPath = destPath;
|
|
1511
|
+
let finalName = name;
|
|
1512
|
+
if (existsSync3(finalPath)) {
|
|
1513
|
+
const dotIdx = name.indexOf(".", name.startsWith(".") ? 1 : 0);
|
|
1514
|
+
const ext = dotIdx > 0 ? name.slice(dotIdx) : "";
|
|
1515
|
+
const base = dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
|
1516
|
+
let n = 2;
|
|
1517
|
+
const MAX_COPY_INDEX = 1e4;
|
|
1518
|
+
while (n < MAX_COPY_INDEX && existsSync3(resolve2(targetDir, `${base} (${n})${ext}`))) n++;
|
|
1519
|
+
if (n >= MAX_COPY_INDEX) {
|
|
1520
|
+
skipped.push(name);
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
finalName = `${base} (${n})${ext}`;
|
|
1524
|
+
finalPath = resolve2(targetDir, finalName);
|
|
1525
|
+
}
|
|
1526
|
+
const buffer = Buffer.from(await value.arrayBuffer());
|
|
1527
|
+
const validation = validateUploadedMediaBuffer(finalName, buffer);
|
|
1528
|
+
if (!validation.ok) {
|
|
1529
|
+
invalid.push({ name: finalName, reason: validation.reason });
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
writeFileSync4(finalPath, buffer);
|
|
1533
|
+
const relativePath = subDir ? join6(subDir, finalName) : finalName;
|
|
1534
|
+
uploaded.push(relativePath);
|
|
1535
|
+
if (isAudioFile(finalName)) {
|
|
1536
|
+
generateWaveformCache(projectDir, relativePath).catch(() => {
|
|
1537
|
+
});
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
return { uploaded, skipped, invalid };
|
|
1541
|
+
}
|
|
1542
|
+
function registerFileRoutes(api, adapter) {
|
|
1543
|
+
api.get("/projects/:id/files/*", async (c) => {
|
|
1544
|
+
const res = await resolveProjectFile(c, adapter);
|
|
1545
|
+
if ("error" in res) return res.error;
|
|
1546
|
+
if (!existsSync3(res.absPath)) {
|
|
1547
|
+
if (c.req.query("optional") === "1") {
|
|
1548
|
+
return c.json({ filename: res.filePath, content: "" });
|
|
1549
|
+
}
|
|
1550
|
+
return c.json({ error: "not found" }, 404);
|
|
1551
|
+
}
|
|
1552
|
+
const content = readFileSync3(res.absPath, "utf-8");
|
|
1553
|
+
return c.json({ filename: res.filePath, content });
|
|
1554
|
+
});
|
|
1555
|
+
api.put("/projects/:id/files/*", async (c) => {
|
|
1556
|
+
const res = await resolveProjectFile(c, adapter);
|
|
1557
|
+
if ("error" in res) return res.error;
|
|
1558
|
+
ensureDir(res.absPath);
|
|
1559
|
+
const body = await c.req.text();
|
|
1560
|
+
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
|
1561
|
+
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
|
|
1562
|
+
writeFileSync4(res.absPath, body, "utf-8");
|
|
1563
|
+
return c.json({
|
|
1564
|
+
ok: true,
|
|
1565
|
+
path: res.filePath,
|
|
1566
|
+
backupPath: backupPathForResponse(res.project.dir, backup.backupPath)
|
|
1567
|
+
});
|
|
1568
|
+
});
|
|
1569
|
+
api.post("/projects/:id/files/*", async (c) => {
|
|
1570
|
+
const res = await resolveProjectFile(c, adapter);
|
|
1571
|
+
if ("error" in res) return res.error;
|
|
1572
|
+
if (existsSync3(res.absPath)) {
|
|
1573
|
+
return c.json({ error: "already exists" }, 409);
|
|
1574
|
+
}
|
|
1575
|
+
ensureDir(res.absPath);
|
|
1576
|
+
const body = await c.req.text().catch(() => "");
|
|
1577
|
+
writeFileSync4(res.absPath, body, "utf-8");
|
|
1578
|
+
return c.json({ ok: true, path: res.filePath }, 201);
|
|
1579
|
+
});
|
|
1580
|
+
api.delete("/projects/:id/files/*", async (c) => {
|
|
1581
|
+
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
|
1582
|
+
if ("error" in res) return res.error;
|
|
1583
|
+
const stat = statSync(res.absPath);
|
|
1584
|
+
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
|
1585
|
+
if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
|
|
1586
|
+
if (stat.isDirectory()) {
|
|
1587
|
+
rmSync2(res.absPath, { recursive: true });
|
|
1588
|
+
} else {
|
|
1589
|
+
unlinkSync2(res.absPath);
|
|
1590
|
+
}
|
|
1591
|
+
return c.json({
|
|
1592
|
+
ok: true,
|
|
1593
|
+
backupPath: backupPathForResponse(res.project.dir, backup.backupPath)
|
|
1594
|
+
});
|
|
1595
|
+
});
|
|
1596
|
+
api.post("/projects/:id/file-mutations/remove-element/*", async (c) => {
|
|
1597
|
+
const ctx = await resolveFileMutationContext(c, adapter, "remove-element");
|
|
1598
|
+
if ("error" in ctx) return ctx.error;
|
|
1599
|
+
if (!existsSync3(ctx.absPath)) {
|
|
1600
|
+
return c.json({ error: "not found" }, 404);
|
|
1601
|
+
}
|
|
1602
|
+
const parsed = await parseMutationBody(c);
|
|
1603
|
+
if ("error" in parsed) return parsed.error;
|
|
1604
|
+
const originalContent = readFileSync3(ctx.absPath, "utf-8");
|
|
1605
|
+
return writeIfChanged(
|
|
1606
|
+
c,
|
|
1607
|
+
ctx.project.dir,
|
|
1608
|
+
ctx.filePath,
|
|
1609
|
+
ctx.absPath,
|
|
1610
|
+
originalContent,
|
|
1611
|
+
removeElementFromHtml(originalContent, parsed.target)
|
|
1612
|
+
);
|
|
1613
|
+
});
|
|
1614
|
+
api.post("/projects/:id/file-mutations/split-element/*", async (c) => {
|
|
1615
|
+
const ctx = await resolveFileMutationContext(c, adapter, "split-element");
|
|
1616
|
+
if ("error" in ctx) return ctx.error;
|
|
1617
|
+
const parsed = await parseMutationBody(c);
|
|
1618
|
+
if ("error" in parsed) return parsed.error;
|
|
1619
|
+
if (typeof parsed.body.splitTime !== "number" || !parsed.body.newId) {
|
|
1620
|
+
return c.json({ error: "target, splitTime, and newId required" }, 400);
|
|
1621
|
+
}
|
|
1622
|
+
const fallbackTiming = typeof parsed.body.elementStart === "number" && typeof parsed.body.elementDuration === "number" ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } : void 0;
|
|
1623
|
+
let originalContent;
|
|
1624
|
+
try {
|
|
1625
|
+
originalContent = readFileSync3(ctx.absPath, "utf-8");
|
|
1626
|
+
} catch {
|
|
1627
|
+
return c.json({ error: "not found" }, 404);
|
|
1628
|
+
}
|
|
1629
|
+
const result = splitElementInHtml(
|
|
1630
|
+
originalContent,
|
|
1631
|
+
parsed.target,
|
|
1632
|
+
parsed.body.splitTime,
|
|
1633
|
+
parsed.body.newId,
|
|
1634
|
+
fallbackTiming
|
|
1635
|
+
);
|
|
1636
|
+
if (!result.matched) {
|
|
1637
|
+
return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
|
|
1638
|
+
}
|
|
1639
|
+
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
|
1640
|
+
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
|
|
1641
|
+
writeFileSync4(ctx.absPath, result.html, "utf-8");
|
|
1642
|
+
return c.json({
|
|
1643
|
+
ok: true,
|
|
1644
|
+
changed: true,
|
|
1645
|
+
content: result.html,
|
|
1646
|
+
newId: result.newId,
|
|
1647
|
+
path: ctx.filePath,
|
|
1648
|
+
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath)
|
|
1649
|
+
});
|
|
1650
|
+
});
|
|
1651
|
+
api.post("/projects/:id/file-mutations/patch-element/*", async (c) => {
|
|
1652
|
+
const ctx = await resolveFileMutationContext(c, adapter, "patch-element");
|
|
1653
|
+
if ("error" in ctx) return ctx.error;
|
|
1654
|
+
const parsed = await parseMutationBody(c);
|
|
1655
|
+
if ("error" in parsed) return parsed.error;
|
|
1656
|
+
if (!Array.isArray(parsed.body.operations) || parsed.body.operations.length === 0) {
|
|
1657
|
+
return c.json({ error: "target and operations required" }, 400);
|
|
1658
|
+
}
|
|
1659
|
+
const unsafeFields = findUnsafeDomPatchValues(parsed.body);
|
|
1660
|
+
if (unsafeFields.length > 0) {
|
|
1661
|
+
return rejectUnsafeMutationValues(c, unsafeFields);
|
|
1662
|
+
}
|
|
1663
|
+
let originalContent;
|
|
1664
|
+
try {
|
|
1665
|
+
originalContent = readFileSync3(ctx.absPath, "utf-8");
|
|
1666
|
+
} catch {
|
|
1667
|
+
return c.json({ error: "not found" }, 404);
|
|
1668
|
+
}
|
|
1669
|
+
const { html: patched, matched } = patchElementInHtml(
|
|
1670
|
+
originalContent,
|
|
1671
|
+
parsed.target,
|
|
1672
|
+
parsed.body.operations
|
|
1673
|
+
);
|
|
1674
|
+
if (patched === originalContent) {
|
|
1675
|
+
return c.json({
|
|
1676
|
+
ok: true,
|
|
1677
|
+
changed: false,
|
|
1678
|
+
matched,
|
|
1679
|
+
content: originalContent,
|
|
1680
|
+
path: ctx.filePath
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
|
1684
|
+
if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
|
|
1685
|
+
writeFileSync4(ctx.absPath, patched, "utf-8");
|
|
1686
|
+
return c.json({
|
|
1687
|
+
ok: true,
|
|
1688
|
+
changed: true,
|
|
1689
|
+
matched,
|
|
1690
|
+
content: patched,
|
|
1691
|
+
path: ctx.filePath,
|
|
1692
|
+
backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath)
|
|
1693
|
+
});
|
|
1694
|
+
});
|
|
1695
|
+
api.post("/projects/:id/file-mutations/probe-element/*", async (c) => {
|
|
1696
|
+
const ctx = await resolveFileMutationContext(c, adapter, "probe-element");
|
|
1697
|
+
if ("error" in ctx) return ctx.error;
|
|
1698
|
+
const parsed = await parseMutationBody(c);
|
|
1699
|
+
if ("error" in parsed) return parsed.error;
|
|
1700
|
+
let content;
|
|
1701
|
+
try {
|
|
1702
|
+
content = readFileSync3(ctx.absPath, "utf-8");
|
|
1703
|
+
} catch {
|
|
1704
|
+
return c.json({ exists: false });
|
|
1705
|
+
}
|
|
1706
|
+
const exists = probeElementInSource(content, parsed.target);
|
|
1707
|
+
return c.json({ exists });
|
|
1708
|
+
});
|
|
1709
|
+
api.patch("/projects/:id/files/*", async (c) => {
|
|
1710
|
+
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
|
1711
|
+
if ("error" in res) return res.error;
|
|
1712
|
+
const body = await c.req.json();
|
|
1713
|
+
if (!body.newPath || body.newPath.includes("\0")) {
|
|
1714
|
+
return c.json({ error: "newPath required" }, 400);
|
|
1715
|
+
}
|
|
1716
|
+
const newAbs = resolveWithinProject(res.project.dir, body.newPath);
|
|
1717
|
+
if (!newAbs) {
|
|
1718
|
+
return c.json({ error: "forbidden" }, 403);
|
|
1719
|
+
}
|
|
1720
|
+
if (existsSync3(newAbs)) {
|
|
1721
|
+
return c.json({ error: "already exists" }, 409);
|
|
1722
|
+
}
|
|
1723
|
+
ensureDir(newAbs);
|
|
1724
|
+
renameSync(res.absPath, newAbs);
|
|
1725
|
+
const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);
|
|
1726
|
+
return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
|
|
1727
|
+
});
|
|
1728
|
+
api.post("/projects/:id/duplicate-file", async (c) => {
|
|
1729
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
1730
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
1731
|
+
const body = await c.req.json();
|
|
1732
|
+
if (!body.path || body.path.includes("\0")) {
|
|
1733
|
+
return c.json({ error: "path required" }, 400);
|
|
1734
|
+
}
|
|
1735
|
+
const srcAbs = resolveWithinProject(project.dir, body.path);
|
|
1736
|
+
if (!srcAbs || !existsSync3(srcAbs)) {
|
|
1737
|
+
return c.json({ error: "not found" }, 404);
|
|
1738
|
+
}
|
|
1739
|
+
const copyPath = generateCopyPath(project.dir, body.path);
|
|
1740
|
+
const destAbs = resolveWithinProject(project.dir, copyPath);
|
|
1741
|
+
if (!destAbs) {
|
|
1742
|
+
return c.json({ error: "forbidden" }, 403);
|
|
1743
|
+
}
|
|
1744
|
+
ensureDir(destAbs);
|
|
1745
|
+
writeFileSync4(destAbs, readFileSync3(srcAbs));
|
|
1746
|
+
return c.json({ ok: true, path: copyPath }, 201);
|
|
1747
|
+
});
|
|
1748
|
+
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
1749
|
+
api.post(
|
|
1750
|
+
"/projects/:id/upload",
|
|
1751
|
+
bodyLimit({
|
|
1752
|
+
maxSize: MAX_UPLOAD_BYTES,
|
|
1753
|
+
onError: (c) => c.json({ error: "payload too large" }, 413)
|
|
1754
|
+
}),
|
|
1755
|
+
async (c) => {
|
|
1756
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
1757
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
1758
|
+
const subDir = c.req.query("dir") ?? "";
|
|
1759
|
+
const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
|
|
1760
|
+
if (!targetDir) return c.json({ error: "forbidden" }, 403);
|
|
1761
|
+
if (subDir && !existsSync3(targetDir)) mkdirSync3(targetDir, { recursive: true });
|
|
1762
|
+
const formData = await c.req.formData();
|
|
1763
|
+
const result = await processUploadedFiles(formData, targetDir, project.dir);
|
|
1764
|
+
return c.json(
|
|
1765
|
+
{ ok: true, files: result.uploaded, skipped: result.skipped, invalid: result.invalid },
|
|
1766
|
+
201
|
|
1767
|
+
);
|
|
1768
|
+
}
|
|
1769
|
+
);
|
|
1770
|
+
api.get("/projects/:id/gsap-animations/*", async (c) => {
|
|
1771
|
+
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-animations/`, {
|
|
1772
|
+
mustExist: true
|
|
1773
|
+
});
|
|
1774
|
+
if ("error" in res) return res.error;
|
|
1775
|
+
const html = readFileSync3(res.absPath, "utf-8");
|
|
1776
|
+
const block = extractGsapScriptBlock(html);
|
|
1777
|
+
if (!block) {
|
|
1778
|
+
return c.json({
|
|
1779
|
+
animations: [],
|
|
1780
|
+
timelineVar: "tl",
|
|
1781
|
+
preamble: "",
|
|
1782
|
+
postamble: ""
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
const parsed = parseGsapScriptAcorn(block.scriptText);
|
|
1786
|
+
return c.json(parsed);
|
|
1787
|
+
});
|
|
1788
|
+
api.post("/projects/:id/gsap-mutations/*", async (c) => {
|
|
1789
|
+
const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
|
|
1790
|
+
mustExist: true
|
|
1791
|
+
});
|
|
1792
|
+
if ("error" in res) return res.error;
|
|
1793
|
+
const body = await c.req.json().catch(() => null);
|
|
1794
|
+
if (!body || !body.type) {
|
|
1795
|
+
return c.json({ error: "mutation type required" }, 400);
|
|
1796
|
+
}
|
|
1797
|
+
const unsafeFields = findUnsafeMutationValues(body);
|
|
1798
|
+
if (unsafeFields.length > 0) {
|
|
1799
|
+
return rejectUnsafeMutationValues(c, unsafeFields);
|
|
1800
|
+
}
|
|
1801
|
+
let html = readFileSync3(res.absPath, "utf-8");
|
|
1802
|
+
let block = extractGsapScriptBlock(html);
|
|
1803
|
+
if (!block && (body.type === "add" || body.type === "add-with-keyframes")) {
|
|
1804
|
+
const compId = html.match(/data-composition-id="([^"]+)"/)?.[1] ?? "main";
|
|
1805
|
+
const { GSAP_CDN } = await import("@hyperframes/core");
|
|
1806
|
+
const gsapCdn = `<script src="${GSAP_CDN}"></script>`;
|
|
1807
|
+
const bootstrap = [
|
|
1808
|
+
gsapCdn,
|
|
1809
|
+
"<script>",
|
|
1810
|
+
"window.__timelines = window.__timelines || {};",
|
|
1811
|
+
`const tl = gsap.timeline({ paused: true });`,
|
|
1812
|
+
`window.__timelines["${compId}"] = tl;`,
|
|
1813
|
+
"</script>"
|
|
1814
|
+
].join("\n");
|
|
1815
|
+
if (html.includes("</body>")) {
|
|
1816
|
+
html = html.replace("</body>", `${bootstrap}
|
|
1817
|
+
</body>`);
|
|
1818
|
+
} else {
|
|
1819
|
+
html += `
|
|
1820
|
+
${bootstrap}`;
|
|
1821
|
+
}
|
|
1822
|
+
block = extractGsapScriptBlock(html);
|
|
1823
|
+
}
|
|
1824
|
+
if (!block) {
|
|
1825
|
+
return c.json({ error: "no GSAP script found in file" }, 400);
|
|
1826
|
+
}
|
|
1827
|
+
const respond = (data, status) => (
|
|
1828
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- bridge between generic status and Hono's literal union
|
|
1829
|
+
status ? c.json(data, status) : c.json(data)
|
|
1830
|
+
);
|
|
1831
|
+
const result = await executeGsapMutation(body, block, respond);
|
|
1832
|
+
if (result instanceof Response) return result;
|
|
1833
|
+
let newScript = typeof result === "string" ? result : result.script;
|
|
1834
|
+
if (HOLD_SYNC_MUTATION_TYPES.has(body.type)) {
|
|
1835
|
+
const parser = await loadGsapParser();
|
|
1836
|
+
newScript = parser.syncPositionHoldsBeforeKeyframes(newScript);
|
|
1837
|
+
}
|
|
1838
|
+
const changed = newScript !== block.scriptText;
|
|
1839
|
+
const newHtml = changed ? block.replaceScript(newScript) : html;
|
|
1840
|
+
let backupPath = null;
|
|
1841
|
+
if (changed) {
|
|
1842
|
+
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
|
1843
|
+
if (backup.error)
|
|
1844
|
+
console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
|
|
1845
|
+
backupPath = backupPathForResponse(res.project.dir, backup.backupPath);
|
|
1846
|
+
writeFileSync4(res.absPath, newHtml, "utf-8");
|
|
1847
|
+
}
|
|
1848
|
+
const freshParsed = parseGsapScriptAcorn(newScript);
|
|
1849
|
+
const responsePayload = {
|
|
1850
|
+
ok: true,
|
|
1851
|
+
changed,
|
|
1852
|
+
parsed: freshParsed,
|
|
1853
|
+
before: html,
|
|
1854
|
+
after: newHtml,
|
|
1855
|
+
scriptText: newScript,
|
|
1856
|
+
path: res.filePath,
|
|
1857
|
+
backupPath
|
|
1858
|
+
};
|
|
1859
|
+
if (typeof result !== "string" && result.skippedSelectors.length > 0) {
|
|
1860
|
+
responsePayload.skippedSelectors = result.skippedSelectors;
|
|
1861
|
+
}
|
|
1862
|
+
return c.json(responsePayload);
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// src/routes/preview.ts
|
|
1867
|
+
import { existsSync as existsSync5, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
|
|
1868
|
+
import { join as join8 } from "path";
|
|
1869
|
+
import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2 } from "@hyperframes/core/compiler";
|
|
1870
|
+
|
|
1871
|
+
// src/helpers/subComposition.ts
|
|
1872
|
+
import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
|
|
1873
|
+
import { join as join7 } from "path";
|
|
1874
|
+
import { parseHTML as parseHTML3 } from "linkedom";
|
|
1875
|
+
import {
|
|
1876
|
+
rewriteAssetPaths,
|
|
1877
|
+
rewriteCssAssetUrls,
|
|
1878
|
+
rewriteInlineStyleAssetUrls
|
|
1879
|
+
} from "@hyperframes/core";
|
|
1880
|
+
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
|
1881
|
+
function isFullHtmlDocument(html) {
|
|
1882
|
+
return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
|
|
1883
|
+
}
|
|
1884
|
+
function rewriteRelativePaths(root, compPath) {
|
|
1885
|
+
rewriteAssetPaths(
|
|
1886
|
+
root.querySelectorAll("[src], [href]"),
|
|
1887
|
+
compPath,
|
|
1888
|
+
(el, attr) => el.getAttribute(attr),
|
|
1889
|
+
(el, attr, value) => el.setAttribute(attr, value)
|
|
1890
|
+
);
|
|
1891
|
+
rewriteInlineStyleAssetUrls(
|
|
1892
|
+
root.querySelectorAll("[style]"),
|
|
1893
|
+
compPath,
|
|
1894
|
+
(el) => el.getAttribute("style"),
|
|
1895
|
+
(el, value) => el.setAttribute("style", value)
|
|
1896
|
+
);
|
|
1897
|
+
for (const styleEl of root.querySelectorAll("style")) {
|
|
1898
|
+
styleEl.textContent = rewriteCssAssetUrls(styleEl.textContent || "", compPath);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
function escapeLeadingDigitIdent(id) {
|
|
1902
|
+
return `\\${id.charCodeAt(0).toString(16)} ${id.slice(1)}`;
|
|
1903
|
+
}
|
|
1904
|
+
var REGEXP_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
|
1905
|
+
function fixDigitLeadingIdSelectors(root) {
|
|
1906
|
+
const digitIds = /* @__PURE__ */ new Set();
|
|
1907
|
+
for (const el of root.querySelectorAll("[id]")) {
|
|
1908
|
+
const id = el.getAttribute("id");
|
|
1909
|
+
if (id && /^\d/.test(id)) digitIds.add(id);
|
|
1910
|
+
}
|
|
1911
|
+
if (digitIds.size === 0) return;
|
|
1912
|
+
for (const styleEl of root.querySelectorAll("style")) {
|
|
1913
|
+
let css = styleEl.textContent || "";
|
|
1914
|
+
for (const id of digitIds) {
|
|
1915
|
+
const pattern = new RegExp(`#${id.replace(REGEXP_SPECIALS, "\\$&")}(?![\\w-])`, "g");
|
|
1916
|
+
css = css.replace(pattern, `#${escapeLeadingDigitIdent(id)}`);
|
|
1917
|
+
}
|
|
1918
|
+
styleEl.textContent = css;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
function extractFullDocumentParts(rawHtml, compPath) {
|
|
1922
|
+
const { document: doc } = parseHTML3(rawHtml);
|
|
1923
|
+
const rewriteTargets = [doc.head, doc.body].filter(Boolean);
|
|
1924
|
+
for (const target of rewriteTargets) {
|
|
1925
|
+
rewriteRelativePaths(target, compPath);
|
|
1926
|
+
}
|
|
1927
|
+
fixDigitLeadingIdSelectors(doc);
|
|
1928
|
+
const headContent = doc.head?.innerHTML ?? "";
|
|
1929
|
+
const bodyContent = doc.body?.innerHTML ?? "";
|
|
1930
|
+
const htmlEl = doc.documentElement;
|
|
1931
|
+
const htmlAttrs = extractElementAttrs(htmlEl);
|
|
1932
|
+
const bodyAttrs = doc.body ? extractElementAttrs(doc.body) : "";
|
|
1933
|
+
return { headContent, bodyContent, htmlAttrs, bodyAttrs };
|
|
1934
|
+
}
|
|
1935
|
+
function extractTemplateInnerHtml(rawComp) {
|
|
1936
|
+
const { document: doc } = parseHTML3(rawComp);
|
|
1937
|
+
const template = doc.querySelector("template");
|
|
1938
|
+
return template ? template.innerHTML : null;
|
|
1939
|
+
}
|
|
1940
|
+
function extractElementAttrs(el) {
|
|
1941
|
+
const parts = [];
|
|
1942
|
+
for (let i = 0; i < el.attributes.length; i++) {
|
|
1943
|
+
const attr = el.attributes[i];
|
|
1944
|
+
if (attr.value === "") {
|
|
1945
|
+
parts.push(attr.name);
|
|
1946
|
+
} else {
|
|
1947
|
+
parts.push(`${attr.name}="${attr.value}"`);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
return parts.join(" ");
|
|
1951
|
+
}
|
|
1952
|
+
var NON_RENDERED_TAGS = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "LINK", "META", "TEMPLATE", "NOSCRIPT"]);
|
|
1953
|
+
function promoteTemplateCompositionId(rawComp, body) {
|
|
1954
|
+
const templateCompositionId = rawComp.match(
|
|
1955
|
+
/<template[^>]*\sdata-composition-id\s*=\s*["']([^"']+)["']/i
|
|
1956
|
+
)?.[1];
|
|
1957
|
+
if (!templateCompositionId) return;
|
|
1958
|
+
if (body.querySelector("[data-composition-id]")) return;
|
|
1959
|
+
const root = Array.from(body.children).find((el) => !NON_RENDERED_TAGS.has(el.tagName));
|
|
1960
|
+
root?.setAttribute("data-composition-id", templateCompositionId);
|
|
1961
|
+
}
|
|
1962
|
+
function tagRootCompositionFile(bodyHtml, compPath) {
|
|
1963
|
+
const match = bodyHtml.match(/<[a-zA-Z][^>]*\bdata-composition-id=/);
|
|
1964
|
+
if (match?.index == null) return bodyHtml;
|
|
1965
|
+
const tagEnd = bodyHtml.indexOf(">", match.index);
|
|
1966
|
+
if (tagEnd === -1) return bodyHtml;
|
|
1967
|
+
if (bodyHtml.slice(match.index, tagEnd).includes("data-composition-file")) return bodyHtml;
|
|
1968
|
+
return bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd);
|
|
1969
|
+
}
|
|
1970
|
+
function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref) {
|
|
1971
|
+
const compFile = join7(projectDir, compPath);
|
|
1972
|
+
if (!existsSync4(compFile)) return null;
|
|
1973
|
+
const rawComp = readFileSync4(compFile, "utf-8");
|
|
1974
|
+
let compHeadContent = "";
|
|
1975
|
+
let rewrittenContent;
|
|
1976
|
+
let htmlAttrs = "";
|
|
1977
|
+
let bodyAttrs = "";
|
|
1978
|
+
const templateInner = extractTemplateInnerHtml(rawComp);
|
|
1979
|
+
if (templateInner != null) {
|
|
1980
|
+
const { document: contentDoc } = parseHTML3(
|
|
1981
|
+
`<!DOCTYPE html><html><head></head><body>${templateInner}</body></html>`
|
|
1982
|
+
);
|
|
1983
|
+
rewriteRelativePaths(contentDoc, compPath);
|
|
1984
|
+
fixDigitLeadingIdSelectors(contentDoc);
|
|
1985
|
+
promoteTemplateCompositionId(rawComp, contentDoc.body);
|
|
1986
|
+
rewrittenContent = contentDoc.body.innerHTML || templateInner;
|
|
1987
|
+
} else if (isFullHtmlDocument(rawComp)) {
|
|
1988
|
+
const parts = extractFullDocumentParts(rawComp, compPath);
|
|
1989
|
+
compHeadContent = parts.headContent;
|
|
1990
|
+
rewrittenContent = parts.bodyContent;
|
|
1991
|
+
htmlAttrs = parts.htmlAttrs;
|
|
1992
|
+
bodyAttrs = parts.bodyAttrs;
|
|
1993
|
+
} else {
|
|
1994
|
+
const { document: contentDoc } = parseHTML3(
|
|
1995
|
+
`<!DOCTYPE html><html><head></head><body>${rawComp}</body></html>`
|
|
1996
|
+
);
|
|
1997
|
+
rewriteRelativePaths(contentDoc, compPath);
|
|
1998
|
+
fixDigitLeadingIdSelectors(contentDoc);
|
|
1999
|
+
rewrittenContent = contentDoc.body.innerHTML || rawComp;
|
|
2000
|
+
}
|
|
2001
|
+
rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
|
|
2002
|
+
rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath);
|
|
2003
|
+
const indexPath = join7(projectDir, "index.html");
|
|
2004
|
+
let headContent = "";
|
|
2005
|
+
if (existsSync4(indexPath)) {
|
|
2006
|
+
const indexHtml = readFileSync4(indexPath, "utf-8");
|
|
2007
|
+
const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
2008
|
+
headContent = headMatch?.[1] ?? "";
|
|
2009
|
+
}
|
|
2010
|
+
if (baseHref && !headContent.includes("<base")) {
|
|
2011
|
+
headContent = `<base href="${baseHref}">
|
|
2012
|
+
${headContent}`;
|
|
2013
|
+
}
|
|
2014
|
+
if (compHeadContent) headContent += `
|
|
2015
|
+
${compHeadContent}`;
|
|
2016
|
+
headContent = stripEmbeddedRuntimeScripts(headContent);
|
|
2017
|
+
if (!headContent.includes("hyperframe.runtime") && !headContent.includes("hyperframes-preview-runtime")) {
|
|
2018
|
+
headContent += `
|
|
2019
|
+
<script data-hyperframes-preview-runtime="1" src="${runtimeUrl}"></script>`;
|
|
2020
|
+
}
|
|
2021
|
+
if (!headContent.includes("gsap")) {
|
|
2022
|
+
headContent += `
|
|
2023
|
+
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`;
|
|
2024
|
+
}
|
|
2025
|
+
const htmlOpen = htmlAttrs ? `<html ${htmlAttrs}>` : "<html>";
|
|
2026
|
+
const bodyOpen = bodyAttrs ? `<body ${bodyAttrs}>` : "<body>";
|
|
2027
|
+
return `<!DOCTYPE html>
|
|
2028
|
+
${htmlOpen}
|
|
2029
|
+
<head>
|
|
2030
|
+
${headContent}
|
|
2031
|
+
</head>
|
|
2032
|
+
${bodyOpen}
|
|
2033
|
+
<script>window.__timelines=window.__timelines||{};</script>
|
|
2034
|
+
${rewrittenContent}
|
|
2035
|
+
</body>
|
|
2036
|
+
</html>`;
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// src/helpers/projectSignature.ts
|
|
2040
|
+
import { createHash } from "crypto";
|
|
2041
|
+
import { lstatSync, readFileSync as readFileSync5, readdirSync as readdirSync4 } from "fs";
|
|
2042
|
+
import { extname, isAbsolute, relative as relative2, resolve as resolve3 } from "path";
|
|
2043
|
+
var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
2044
|
+
".cjs",
|
|
2045
|
+
".css",
|
|
2046
|
+
".html",
|
|
2047
|
+
".js",
|
|
2048
|
+
".json",
|
|
2049
|
+
".jsx",
|
|
2050
|
+
".mjs",
|
|
2051
|
+
".svg",
|
|
2052
|
+
".ts",
|
|
2053
|
+
".tsx"
|
|
2054
|
+
]);
|
|
2055
|
+
var SIGNATURE_EXCLUDED_DIRS = /* @__PURE__ */ new Set([
|
|
2056
|
+
".cache",
|
|
2057
|
+
".git",
|
|
2058
|
+
".hyperframes",
|
|
2059
|
+
".next",
|
|
2060
|
+
".vite",
|
|
2061
|
+
"build",
|
|
2062
|
+
"coverage",
|
|
2063
|
+
"dist",
|
|
2064
|
+
"node_modules",
|
|
2065
|
+
"outputs",
|
|
2066
|
+
"renders"
|
|
2067
|
+
]);
|
|
2068
|
+
var MAX_SIGNATURE_TEXT_BYTES = 2e6;
|
|
2069
|
+
var STUDIO_SIGNATURE_MANIFEST_PATHS = [
|
|
2070
|
+
".hyperframes/studio-manual-edits.json",
|
|
2071
|
+
".hyperframes/studio-motion.json"
|
|
2072
|
+
];
|
|
2073
|
+
var projectSignatureCache = /* @__PURE__ */ new Map();
|
|
2074
|
+
function isPathWithin(parentDir, childPath) {
|
|
2075
|
+
const childRelativePath = relative2(parentDir, childPath);
|
|
2076
|
+
return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
|
|
2077
|
+
}
|
|
2078
|
+
function isTextContentEligible(file, size) {
|
|
2079
|
+
return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
|
|
2080
|
+
}
|
|
2081
|
+
function collectProjectSignatureFiles(projectDir, dir, files) {
|
|
2082
|
+
let entries;
|
|
2083
|
+
try {
|
|
2084
|
+
entries = readdirSync4(dir).sort();
|
|
2085
|
+
} catch {
|
|
2086
|
+
return;
|
|
2087
|
+
}
|
|
2088
|
+
for (const entry of entries) {
|
|
2089
|
+
if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
|
|
2090
|
+
const file = resolve3(dir, entry);
|
|
2091
|
+
if (!isPathWithin(projectDir, file)) continue;
|
|
2092
|
+
let stat;
|
|
2093
|
+
try {
|
|
2094
|
+
stat = lstatSync(file);
|
|
2095
|
+
} catch {
|
|
2096
|
+
continue;
|
|
2097
|
+
}
|
|
2098
|
+
if (stat.isSymbolicLink()) continue;
|
|
2099
|
+
if (stat.isDirectory()) {
|
|
2100
|
+
collectProjectSignatureFiles(projectDir, file, files);
|
|
2101
|
+
} else if (stat.isFile()) {
|
|
2102
|
+
files.push({
|
|
2103
|
+
file,
|
|
2104
|
+
mtimeMs: stat.mtimeMs,
|
|
2105
|
+
size: stat.size,
|
|
2106
|
+
textContentEligible: isTextContentEligible(file, stat.size)
|
|
2107
|
+
});
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
function collectProjectSignatureManifestFiles(projectDir, files) {
|
|
2112
|
+
const seen = new Set(files.map((entry) => entry.file));
|
|
2113
|
+
for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
|
|
2114
|
+
const file = resolve3(projectDir, manifestPath);
|
|
2115
|
+
if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
|
|
2116
|
+
let stat;
|
|
2117
|
+
try {
|
|
2118
|
+
stat = lstatSync(file);
|
|
2119
|
+
} catch {
|
|
2120
|
+
continue;
|
|
2121
|
+
}
|
|
2122
|
+
if (stat.isSymbolicLink() || !stat.isFile()) continue;
|
|
2123
|
+
files.push({
|
|
2124
|
+
file,
|
|
2125
|
+
mtimeMs: stat.mtimeMs,
|
|
2126
|
+
size: stat.size,
|
|
2127
|
+
textContentEligible: isTextContentEligible(file, stat.size)
|
|
2128
|
+
});
|
|
2129
|
+
seen.add(file);
|
|
2130
|
+
}
|
|
2131
|
+
}
|
|
2132
|
+
function createProjectFingerprint(projectDir, files) {
|
|
2133
|
+
const hash = createHash("sha256");
|
|
2134
|
+
for (const entry of files) {
|
|
2135
|
+
hash.update(relative2(projectDir, entry.file));
|
|
2136
|
+
hash.update("\0");
|
|
2137
|
+
hash.update(String(entry.size));
|
|
2138
|
+
hash.update("\0");
|
|
2139
|
+
hash.update(String(entry.mtimeMs));
|
|
2140
|
+
hash.update("\0");
|
|
2141
|
+
hash.update(entry.textContentEligible ? "text" : "binary");
|
|
2142
|
+
hash.update("\0");
|
|
2143
|
+
}
|
|
2144
|
+
return hash.digest("hex").slice(0, 24);
|
|
2145
|
+
}
|
|
2146
|
+
function createProjectSignature(projectDir) {
|
|
2147
|
+
const normalizedProjectDir = resolve3(projectDir);
|
|
2148
|
+
const files = [];
|
|
2149
|
+
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files);
|
|
2150
|
+
collectProjectSignatureManifestFiles(normalizedProjectDir, files);
|
|
2151
|
+
files.sort((a, b) => a.file.localeCompare(b.file));
|
|
2152
|
+
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
|
|
2153
|
+
const cached = projectSignatureCache.get(normalizedProjectDir);
|
|
2154
|
+
if (cached?.fingerprint === fingerprint) return cached.signature;
|
|
2155
|
+
const hash = createHash("sha256");
|
|
2156
|
+
for (const entry of files) {
|
|
2157
|
+
const relativePath = relative2(normalizedProjectDir, entry.file);
|
|
2158
|
+
hash.update(relativePath);
|
|
2159
|
+
hash.update("\0");
|
|
2160
|
+
hash.update(String(entry.size));
|
|
2161
|
+
hash.update("\0");
|
|
2162
|
+
if (entry.textContentEligible) {
|
|
2163
|
+
try {
|
|
2164
|
+
hash.update(readFileSync5(entry.file));
|
|
2165
|
+
} catch {
|
|
2166
|
+
hash.update(String(entry.mtimeMs));
|
|
2167
|
+
}
|
|
2168
|
+
} else {
|
|
2169
|
+
hash.update(String(entry.mtimeMs));
|
|
2170
|
+
}
|
|
2171
|
+
hash.update("\0");
|
|
2172
|
+
}
|
|
2173
|
+
const signature = hash.digest("hex").slice(0, 24);
|
|
2174
|
+
projectSignatureCache.set(normalizedProjectDir, { fingerprint, signature });
|
|
2175
|
+
return signature;
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// src/helpers/studioMotionRenderScript.ts
|
|
2179
|
+
var STUDIO_MOTION_PATH = ".hyperframes/studio-motion.json";
|
|
2180
|
+
function hasStudioMotionEntries(manifestContent) {
|
|
2181
|
+
try {
|
|
2182
|
+
const parsed = JSON.parse(manifestContent);
|
|
2183
|
+
return Array.isArray(parsed.motions) && parsed.motions.length > 0;
|
|
2184
|
+
} catch {
|
|
2185
|
+
return false;
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
function createStudioMotionRenderBodyScript(manifestContent, options = {}) {
|
|
2189
|
+
if (!manifestContent.trim() || !hasStudioMotionEntries(manifestContent)) return null;
|
|
2190
|
+
return `(${studioMotionRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
|
|
2191
|
+
}
|
|
2192
|
+
function studioMotionRenderRuntime(manifestContent, activeCompositionPath) {
|
|
2193
|
+
const STUDIO_MOTION_TIMELINE_ID = "studio-motion";
|
|
2194
|
+
const STUDIO_MOTION_ATTR = "data-hf-studio-motion";
|
|
2195
|
+
const ORIGINAL_TRANSFORM_ATTR = "data-hf-studio-motion-original-transform";
|
|
2196
|
+
const ORIGINAL_OPACITY_ATTR = "data-hf-studio-motion-original-opacity";
|
|
2197
|
+
const ORIGINAL_VISIBILITY_ATTR = "data-hf-studio-motion-original-visibility";
|
|
2198
|
+
const objectRecord = (value) => value && typeof value === "object" ? value : null;
|
|
2199
|
+
const finiteNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
2200
|
+
const runtimeWindow = window;
|
|
2201
|
+
const parseMotionValues = (value) => {
|
|
2202
|
+
const record = objectRecord(value);
|
|
2203
|
+
if (!record) return null;
|
|
2204
|
+
const parsed = {};
|
|
2205
|
+
for (const key of ["x", "y", "scale", "rotation", "opacity", "autoAlpha"]) {
|
|
2206
|
+
const next = finiteNumber(record[key]);
|
|
2207
|
+
if (next != null) parsed[key] = next;
|
|
2208
|
+
}
|
|
2209
|
+
return Object.keys(parsed).length > 0 ? parsed : null;
|
|
2210
|
+
};
|
|
2211
|
+
const parseCustomEase = (value) => {
|
|
2212
|
+
const record = objectRecord(value);
|
|
2213
|
+
if (!record) return null;
|
|
2214
|
+
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
2215
|
+
const data = typeof record.data === "string" ? record.data.trim() : "";
|
|
2216
|
+
if (!id || !data) return null;
|
|
2217
|
+
return { id, data };
|
|
2218
|
+
};
|
|
2219
|
+
const parsedManifest = (() => {
|
|
2220
|
+
try {
|
|
2221
|
+
return objectRecord(JSON.parse(manifestContent));
|
|
2222
|
+
} catch {
|
|
2223
|
+
return null;
|
|
2224
|
+
}
|
|
2225
|
+
})();
|
|
2226
|
+
const manifestMotions = Array.isArray(parsedManifest?.motions) ? parsedManifest.motions : [];
|
|
2227
|
+
const sourceFileForElement = (element) => {
|
|
2228
|
+
let current = element;
|
|
2229
|
+
while (current) {
|
|
2230
|
+
const sourceFile = current.getAttribute("data-composition-file") ?? current.getAttribute("data-composition-src");
|
|
2231
|
+
if (sourceFile) return sourceFile;
|
|
2232
|
+
current = current.parentElement;
|
|
2233
|
+
}
|
|
2234
|
+
return activeCompositionPath ?? "index.html";
|
|
2235
|
+
};
|
|
2236
|
+
const elementMatchesSourceFile = (element, sourceFile) => sourceFileForElement(element) === sourceFile;
|
|
2237
|
+
const isHTMLElement2 = (element) => element instanceof HTMLElement;
|
|
2238
|
+
const querySelectorCandidates = (selector) => {
|
|
2239
|
+
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
|
|
2240
|
+
if (className) {
|
|
2241
|
+
return Array.from(document.getElementsByTagName("*")).filter(
|
|
2242
|
+
(element) => isHTMLElement2(element) && element.classList.contains(className)
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
|
|
2246
|
+
return Array.from(document.getElementsByTagName(selector)).filter(isHTMLElement2);
|
|
2247
|
+
}
|
|
2248
|
+
return Array.from(document.querySelectorAll(selector)).filter(isHTMLElement2);
|
|
2249
|
+
};
|
|
2250
|
+
const resolveTarget = (targetRecord) => {
|
|
2251
|
+
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
|
|
2252
|
+
if (!sourceFile) return null;
|
|
2253
|
+
const id = typeof targetRecord.id === "string" ? targetRecord.id : "";
|
|
2254
|
+
if (id) {
|
|
2255
|
+
const byId = document.getElementById(id);
|
|
2256
|
+
if (isHTMLElement2(byId) && elementMatchesSourceFile(byId, sourceFile)) return byId;
|
|
2257
|
+
}
|
|
2258
|
+
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : "";
|
|
2259
|
+
if (!selector) return null;
|
|
2260
|
+
try {
|
|
2261
|
+
const selectorIndex = Math.max(0, Math.floor(finiteNumber(targetRecord.selectorIndex) ?? 0));
|
|
2262
|
+
return querySelectorCandidates(selector).filter(
|
|
2263
|
+
(element) => elementMatchesSourceFile(element, sourceFile)
|
|
2264
|
+
)[selectorIndex] ?? null;
|
|
2265
|
+
} catch {
|
|
2266
|
+
return null;
|
|
2267
|
+
}
|
|
2268
|
+
};
|
|
2269
|
+
const restoreElement = (element) => {
|
|
2270
|
+
runtimeWindow.gsap?.set?.(element, { clearProps: "transform,opacity,visibility" });
|
|
2271
|
+
element.style.transform = element.getAttribute(ORIGINAL_TRANSFORM_ATTR) ?? "";
|
|
2272
|
+
element.style.opacity = element.getAttribute(ORIGINAL_OPACITY_ATTR) ?? "";
|
|
2273
|
+
element.style.visibility = element.getAttribute(ORIGINAL_VISIBILITY_ATTR) ?? "";
|
|
2274
|
+
element.removeAttribute(STUDIO_MOTION_ATTR);
|
|
2275
|
+
element.removeAttribute(ORIGINAL_TRANSFORM_ATTR);
|
|
2276
|
+
element.removeAttribute(ORIGINAL_OPACITY_ATTR);
|
|
2277
|
+
element.removeAttribute(ORIGINAL_VISIBILITY_ATTR);
|
|
2278
|
+
};
|
|
2279
|
+
const restoreStudioMotionElements = () => {
|
|
2280
|
+
for (const element of Array.from(document.querySelectorAll(`[${STUDIO_MOTION_ATTR}]`))) {
|
|
2281
|
+
if (isHTMLElement2(element)) restoreElement(element);
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
const readCurrentTime = () => {
|
|
2285
|
+
try {
|
|
2286
|
+
const playerTime = runtimeWindow.__player?.getTime?.();
|
|
2287
|
+
if (typeof playerTime === "number" && Number.isFinite(playerTime)) {
|
|
2288
|
+
return Math.max(0, playerTime);
|
|
2289
|
+
}
|
|
2290
|
+
} catch {
|
|
2291
|
+
}
|
|
2292
|
+
try {
|
|
2293
|
+
const timelineTime = runtimeWindow.__timeline?.time?.();
|
|
2294
|
+
if (typeof timelineTime === "number" && Number.isFinite(timelineTime)) {
|
|
2295
|
+
return Math.max(0, timelineTime);
|
|
2296
|
+
}
|
|
2297
|
+
} catch {
|
|
2298
|
+
}
|
|
2299
|
+
return 0;
|
|
2300
|
+
};
|
|
2301
|
+
const resolveEase = (motion) => {
|
|
2302
|
+
const fallback = typeof motion.ease === "string" && motion.ease.trim() ? motion.ease.trim() : "none";
|
|
2303
|
+
const customEase = parseCustomEase(motion.customEase);
|
|
2304
|
+
const customEasePlugin = runtimeWindow.CustomEase;
|
|
2305
|
+
if (!customEase || typeof customEasePlugin?.create !== "function") return fallback;
|
|
2306
|
+
try {
|
|
2307
|
+
runtimeWindow.gsap?.registerPlugin?.(customEasePlugin);
|
|
2308
|
+
customEasePlugin.create(customEase.id, customEase.data);
|
|
2309
|
+
return customEase.id;
|
|
2310
|
+
} catch {
|
|
2311
|
+
return fallback;
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2314
|
+
const applyManifest = () => {
|
|
2315
|
+
runtimeWindow.__timelines = runtimeWindow.__timelines ?? {};
|
|
2316
|
+
runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID]?.kill?.();
|
|
2317
|
+
delete runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID];
|
|
2318
|
+
restoreStudioMotionElements();
|
|
2319
|
+
const gsap = runtimeWindow.gsap;
|
|
2320
|
+
if (!gsap?.timeline || manifestMotions.length === 0) return 0;
|
|
2321
|
+
const timeline = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
|
|
2322
|
+
let applied = 0;
|
|
2323
|
+
for (const motionValue of manifestMotions) {
|
|
2324
|
+
const motion = objectRecord(motionValue);
|
|
2325
|
+
if (!motion || motion.kind !== "gsap-motion") continue;
|
|
2326
|
+
const targetRecord = objectRecord(motion.target);
|
|
2327
|
+
if (!targetRecord) continue;
|
|
2328
|
+
const target = resolveTarget(targetRecord);
|
|
2329
|
+
if (!target || typeof timeline.fromTo !== "function") continue;
|
|
2330
|
+
const start = finiteNumber(motion.start);
|
|
2331
|
+
const duration = finiteNumber(motion.duration);
|
|
2332
|
+
if (start == null || duration == null || start < 0 || duration <= 0) continue;
|
|
2333
|
+
const from = parseMotionValues(motion.from);
|
|
2334
|
+
const to = parseMotionValues(motion.to);
|
|
2335
|
+
if (!from || !to) continue;
|
|
2336
|
+
if (!target.hasAttribute(STUDIO_MOTION_ATTR)) {
|
|
2337
|
+
target.setAttribute(ORIGINAL_TRANSFORM_ATTR, target.style.transform);
|
|
2338
|
+
target.setAttribute(ORIGINAL_OPACITY_ATTR, target.style.opacity);
|
|
2339
|
+
target.setAttribute(ORIGINAL_VISIBILITY_ATTR, target.style.visibility);
|
|
2340
|
+
}
|
|
2341
|
+
target.setAttribute(STUDIO_MOTION_ATTR, "true");
|
|
2342
|
+
timeline.fromTo(
|
|
2343
|
+
target,
|
|
2344
|
+
from,
|
|
2345
|
+
{ ...to, duration, ease: resolveEase(motion), overwrite: "auto", immediateRender: false },
|
|
2346
|
+
start
|
|
2347
|
+
);
|
|
2348
|
+
applied += 1;
|
|
2349
|
+
}
|
|
2350
|
+
if (applied === 0) {
|
|
2351
|
+
timeline.kill?.();
|
|
2352
|
+
return 0;
|
|
2353
|
+
}
|
|
2354
|
+
runtimeWindow.__timelines[STUDIO_MOTION_TIMELINE_ID] = timeline;
|
|
2355
|
+
timeline.pause?.();
|
|
2356
|
+
const currentTime = readCurrentTime();
|
|
2357
|
+
if (typeof timeline.totalTime === "function") timeline.totalTime(currentTime, false);
|
|
2358
|
+
else timeline.time?.(currentTime);
|
|
2359
|
+
return applied;
|
|
2360
|
+
};
|
|
2361
|
+
runtimeWindow.__hfStudioMotionApply = applyManifest;
|
|
2362
|
+
applyManifest();
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// src/routes/preview.ts
|
|
2366
|
+
import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
|
|
2367
|
+
|
|
2368
|
+
// src/helpers/hfIdPersist.ts
|
|
2369
|
+
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
2370
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
|
|
2371
|
+
function persistHfIdsIfNeeded(filePath, html) {
|
|
2372
|
+
const normalized = ensureHfIds(html);
|
|
2373
|
+
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
|
|
2374
|
+
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
|
|
2375
|
+
if (idsAfter > idsBefore) {
|
|
2376
|
+
try {
|
|
2377
|
+
const current = readFileSync6(filePath, "utf-8");
|
|
2378
|
+
if (current === html) {
|
|
2379
|
+
writeFileSync5(filePath, normalized, "utf-8");
|
|
2380
|
+
}
|
|
2381
|
+
} catch (err) {
|
|
2382
|
+
console.warn("[hyperframes] persistHfIdsIfNeeded: failed to write ids to disk:", err);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
return normalized;
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
// src/routes/preview.ts
|
|
2389
|
+
var PROJECT_SIGNATURE_META = "hyperframes-project-signature";
|
|
2390
|
+
var GSAP_CDN_VERSION = "3.15.0";
|
|
2391
|
+
var GSAP_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/gsap.min.js"></script>`;
|
|
2392
|
+
var GSAP_CUSTOM_EASE_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/CustomEase.min.js"></script>`;
|
|
2393
|
+
var GSAP_MOTION_PATH_CDN_SCRIPT = `<script src="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/MotionPathPlugin.min.js"></script>`;
|
|
2394
|
+
function resolveProjectSignature(adapter, projectDir) {
|
|
2395
|
+
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
|
|
2396
|
+
}
|
|
2397
|
+
function injectProjectSignature(html, signature) {
|
|
2398
|
+
const tag = `<meta name="${PROJECT_SIGNATURE_META}" content="${signature}">`;
|
|
2399
|
+
if (html.includes(`name="${PROJECT_SIGNATURE_META}"`)) {
|
|
2400
|
+
return html.replace(
|
|
2401
|
+
new RegExp(`<meta\\s+name=["']${PROJECT_SIGNATURE_META}["'][^>]*>`, "i"),
|
|
2402
|
+
tag
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
if (html.includes("</head>")) return html.replace("</head>", `${tag}
|
|
2406
|
+
</head>`);
|
|
2407
|
+
return `${tag}
|
|
2408
|
+
${html}`;
|
|
2409
|
+
}
|
|
2410
|
+
function readStudioMotionManifestContent(projectDir) {
|
|
2411
|
+
const manifestPath = join8(projectDir, STUDIO_MOTION_PATH);
|
|
2412
|
+
if (!existsSync5(manifestPath)) return "";
|
|
2413
|
+
try {
|
|
2414
|
+
return readFileSync7(manifestPath, "utf-8");
|
|
2415
|
+
} catch {
|
|
2416
|
+
return "";
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
function parseStudioMotionManifestContent(content) {
|
|
2420
|
+
try {
|
|
2421
|
+
const parsed = JSON.parse(content);
|
|
2422
|
+
const motions = Array.isArray(parsed.motions) ? parsed.motions : [];
|
|
2423
|
+
return {
|
|
2424
|
+
hasMotion: motions.length > 0,
|
|
2425
|
+
hasCustomEase: motions.some((motion) => Boolean(motion?.customEase))
|
|
2426
|
+
};
|
|
2427
|
+
} catch {
|
|
2428
|
+
return { hasMotion: false, hasCustomEase: false };
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
function injectScriptTagIntoHead(html, scriptTag) {
|
|
2432
|
+
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}
|
|
2433
|
+
</head>`);
|
|
2434
|
+
return `${scriptTag}
|
|
2435
|
+
${html}`;
|
|
2436
|
+
}
|
|
2437
|
+
function htmlHasGsap(html) {
|
|
2438
|
+
const outsideTemplates = html.replace(/<template\b[^>]*>[\s\S]*?<\/template>/gi, "");
|
|
2439
|
+
return /<script\b[^>]*src=["'][^"']*gsap/i.test(outsideTemplates) || /\/\*\s*inlined:.*gsap/i.test(outsideTemplates) || /\b(GreenSock|_gsScope)\b/.test(outsideTemplates) || /\bgsap\.(config|defaults|registerPlugin|version)\b/.test(outsideTemplates);
|
|
2440
|
+
}
|
|
2441
|
+
function htmlHasCustomEase(html) {
|
|
2442
|
+
return /<script\b[^>]*src=["'][^"']*CustomEase/i.test(html) || /\bwindow\.CustomEase\b/.test(html) || /\bCustomEase\s*=\s*/.test(html);
|
|
2443
|
+
}
|
|
2444
|
+
function htmlUsesMotionPath(html) {
|
|
2445
|
+
return /motionPath\s*[:{]/.test(html);
|
|
2446
|
+
}
|
|
2447
|
+
function htmlHasMotionPathPlugin(html) {
|
|
2448
|
+
return /<script\b[^>]*src=["'][^"']*MotionPathPlugin/i.test(html) || /\bwindow\.MotionPathPlugin\b/.test(html) || /\bMotionPathPlugin\s*=\s*/.test(html);
|
|
2449
|
+
}
|
|
2450
|
+
function injectMotionPathPluginIfNeeded(html) {
|
|
2451
|
+
if (!htmlUsesMotionPath(html) || htmlHasMotionPathPlugin(html)) return html;
|
|
2452
|
+
const gsapScript = /<script\b[^>]*\bsrc=["'][^"']*\/gsap(\.min)?\.js["'][^>]*>\s*<\/script>/i;
|
|
2453
|
+
const match = html.match(gsapScript);
|
|
2454
|
+
if (match) {
|
|
2455
|
+
const version = match[0].match(/gsap@([\d.]+)/)?.[1] ?? GSAP_CDN_VERSION;
|
|
2456
|
+
const pluginTag = `<script src="https://cdn.jsdelivr.net/npm/gsap@${version}/dist/MotionPathPlugin.min.js"></script>`;
|
|
2457
|
+
const end = html.indexOf(match[0]) + match[0].length;
|
|
2458
|
+
return html.slice(0, end) + "\n" + pluginTag + html.slice(end);
|
|
2459
|
+
}
|
|
2460
|
+
return injectScriptTagIntoHead(html, GSAP_MOTION_PATH_CDN_SCRIPT);
|
|
2461
|
+
}
|
|
2462
|
+
function injectStudioMotionDependencies(html, manifestContent) {
|
|
2463
|
+
const manifest = parseStudioMotionManifestContent(manifestContent);
|
|
2464
|
+
if (!manifest.hasMotion) return html;
|
|
2465
|
+
let next = html;
|
|
2466
|
+
if (!htmlHasGsap(next)) next = injectScriptTagIntoHead(next, GSAP_CDN_SCRIPT);
|
|
2467
|
+
if (manifest.hasCustomEase && !htmlHasCustomEase(next)) {
|
|
2468
|
+
next = injectScriptTagIntoHead(next, GSAP_CUSTOM_EASE_CDN_SCRIPT);
|
|
2469
|
+
}
|
|
2470
|
+
return next;
|
|
2471
|
+
}
|
|
2472
|
+
function injectStudioMotionScript(html, projectDir, activeCompositionPath) {
|
|
2473
|
+
const manifestContent = readStudioMotionManifestContent(projectDir);
|
|
2474
|
+
const script = createStudioMotionRenderBodyScript(manifestContent, {
|
|
2475
|
+
activeCompositionPath
|
|
2476
|
+
});
|
|
2477
|
+
if (!script) return html;
|
|
2478
|
+
return injectScriptsIntoHtml(
|
|
2479
|
+
injectStudioMotionDependencies(html, manifestContent),
|
|
2480
|
+
[],
|
|
2481
|
+
[script],
|
|
2482
|
+
false
|
|
2483
|
+
);
|
|
2484
|
+
}
|
|
2485
|
+
var GSAP_CDN_FALLBACK_SCRIPT = `<script data-hf-gsap-fallback>
|
|
2486
|
+
(function(){
|
|
2487
|
+
var cdnBase="https://cdn.jsdelivr.net/npm/gsap@${GSAP_CDN_VERSION}/dist/";
|
|
2488
|
+
var loaded={};
|
|
2489
|
+
function loadFallback(file){
|
|
2490
|
+
if(loaded[file])return loaded[file];
|
|
2491
|
+
return loaded[file]=new Promise(function(ok,fail){
|
|
2492
|
+
var s=document.createElement("script");
|
|
2493
|
+
s.src=cdnBase+file;s.onload=ok;s.onerror=fail;
|
|
2494
|
+
document.head.appendChild(s);
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
document.addEventListener("error",function(e){
|
|
2498
|
+
var t=e.target;
|
|
2499
|
+
if(!t||t.tagName!=="SCRIPT"||!t.src)return;
|
|
2500
|
+
var m=t.src.match(/gsap[^/]*\\/dist\\/(.+\\.js)/);
|
|
2501
|
+
if(m)loadFallback(m[1]);
|
|
2502
|
+
},true);
|
|
2503
|
+
})();
|
|
2504
|
+
</script>`;
|
|
2505
|
+
function injectGsapCdnFallback(html) {
|
|
2506
|
+
if (html.includes("data-hf-gsap-fallback")) return html;
|
|
2507
|
+
if (html.includes("<head>")) return html.replace("<head>", "<head>" + GSAP_CDN_FALLBACK_SCRIPT);
|
|
2508
|
+
return GSAP_CDN_FALLBACK_SCRIPT + html;
|
|
2509
|
+
}
|
|
2510
|
+
function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath) {
|
|
2511
|
+
return injectStudioMotionScript(
|
|
2512
|
+
injectMotionPathPluginIfNeeded(
|
|
2513
|
+
injectGsapCdnFallback(
|
|
2514
|
+
injectProjectSignature(html, resolveProjectSignature(adapter, projectDir))
|
|
2515
|
+
)
|
|
2516
|
+
),
|
|
2517
|
+
projectDir,
|
|
2518
|
+
activeCompositionPath
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
async function transformPreviewHtml(html, adapter, project, activeCompositionPath) {
|
|
2522
|
+
if (!adapter.transformPreviewHtml) return html;
|
|
2523
|
+
try {
|
|
2524
|
+
return await adapter.transformPreviewHtml({
|
|
2525
|
+
html,
|
|
2526
|
+
project,
|
|
2527
|
+
activeCompositionPath
|
|
2528
|
+
});
|
|
2529
|
+
} catch (err) {
|
|
2530
|
+
console.warn("[Studio] preview transform failed, using original HTML:", err);
|
|
2531
|
+
return html;
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
function resolveProjectMainHtml(projectDir, projectId) {
|
|
2535
|
+
const indexPath = join8(projectDir, "index.html");
|
|
2536
|
+
if (existsSync5(indexPath)) {
|
|
2537
|
+
return { html: readFileSync7(indexPath, "utf-8"), compositionPath: "index.html" };
|
|
2538
|
+
}
|
|
2539
|
+
const blockHtmlPath = join8(projectDir, `${projectId}.html`);
|
|
2540
|
+
if (existsSync5(blockHtmlPath)) {
|
|
2541
|
+
return { html: readFileSync7(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` };
|
|
2542
|
+
}
|
|
2543
|
+
return null;
|
|
2544
|
+
}
|
|
2545
|
+
function registerPreviewRoutes(api, adapter) {
|
|
2546
|
+
const previewCacheHeaders = (etag) => ({
|
|
2547
|
+
"Cache-Control": "private, no-cache",
|
|
2548
|
+
ETag: etag
|
|
2549
|
+
});
|
|
2550
|
+
api.get("/projects/:id/preview", async (c) => {
|
|
2551
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
2552
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
2553
|
+
const signature = resolveProjectSignature(adapter, project.dir);
|
|
2554
|
+
const etag = `"preview:${signature}"`;
|
|
2555
|
+
const ifNoneMatch = c.req.header("If-None-Match");
|
|
2556
|
+
if (ifNoneMatch === etag) {
|
|
2557
|
+
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
|
|
2558
|
+
}
|
|
2559
|
+
const diskMain = resolveProjectMainHtml(project.dir, project.id);
|
|
2560
|
+
const normalizedDisk = diskMain ? persistHfIdsIfNeeded(join8(project.dir, diskMain.compositionPath), diskMain.html) : null;
|
|
2561
|
+
try {
|
|
2562
|
+
let bundled = await adapter.bundle(project.dir);
|
|
2563
|
+
let mainCompositionPath = "index.html";
|
|
2564
|
+
if (!bundled) {
|
|
2565
|
+
if (!diskMain) return c.text("not found", 404);
|
|
2566
|
+
bundled = stripEmbeddedRuntimeScripts2(normalizedDisk ?? diskMain.html);
|
|
2567
|
+
mainCompositionPath = diskMain.compositionPath;
|
|
2568
|
+
}
|
|
2569
|
+
if (!bundled.includes("hyperframe.runtime") && !bundled.includes("hyperframes-preview-runtime")) {
|
|
2570
|
+
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
|
|
2571
|
+
bundled = bundled.includes("</body>") ? bundled.replace("</body>", `${runtimeTag}
|
|
2572
|
+
</body>`) : bundled + `
|
|
2573
|
+
${runtimeTag}`;
|
|
2574
|
+
}
|
|
2575
|
+
const baseHref = `/api/projects/${project.id}/preview/`;
|
|
2576
|
+
if (!bundled.includes("<base")) {
|
|
2577
|
+
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
|
|
2578
|
+
}
|
|
2579
|
+
bundled = injectStudioPreviewAugmentations(
|
|
2580
|
+
ensureHfIds2(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
|
|
2581
|
+
adapter,
|
|
2582
|
+
project.dir,
|
|
2583
|
+
mainCompositionPath
|
|
2584
|
+
);
|
|
2585
|
+
return c.html(bundled, 200, previewCacheHeaders(etag));
|
|
2586
|
+
} catch {
|
|
2587
|
+
const fallback = resolveProjectMainHtml(project.dir, project.id);
|
|
2588
|
+
if (fallback) {
|
|
2589
|
+
const fallbackHtml = persistHfIdsIfNeeded(
|
|
2590
|
+
join8(project.dir, fallback.compositionPath),
|
|
2591
|
+
fallback.html
|
|
2592
|
+
);
|
|
2593
|
+
return c.html(
|
|
2594
|
+
injectStudioPreviewAugmentations(
|
|
2595
|
+
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
|
|
2596
|
+
adapter,
|
|
2597
|
+
project.dir,
|
|
2598
|
+
fallback.compositionPath
|
|
2599
|
+
),
|
|
2600
|
+
200,
|
|
2601
|
+
previewCacheHeaders(etag)
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
return c.text("not found", 404);
|
|
2605
|
+
}
|
|
2606
|
+
});
|
|
2607
|
+
api.get("/projects/:id/preview/comp/*", async (c) => {
|
|
2608
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
2609
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
2610
|
+
const signature = resolveProjectSignature(adapter, project.dir);
|
|
2611
|
+
const compPath = decodeURIComponent(
|
|
2612
|
+
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? ""
|
|
2613
|
+
);
|
|
2614
|
+
const compFile = resolveWithinProject(project.dir, compPath);
|
|
2615
|
+
if (!compFile || !existsSync5(compFile) || !statSync2(compFile).isFile()) {
|
|
2616
|
+
return c.text("not found", 404);
|
|
2617
|
+
}
|
|
2618
|
+
const etag = `"comp:${compPath}:${signature}"`;
|
|
2619
|
+
const ifNoneMatch = c.req.header("If-None-Match");
|
|
2620
|
+
if (ifNoneMatch === etag) {
|
|
2621
|
+
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
|
|
2622
|
+
}
|
|
2623
|
+
const baseHref = `/api/projects/${project.id}/preview/`;
|
|
2624
|
+
let html = buildSubCompositionHtml(project.dir, compPath, adapter.runtimeUrl, baseHref);
|
|
2625
|
+
if (!html) return c.text("not found", 404);
|
|
2626
|
+
html = ensureHfIds2(await transformPreviewHtml(html, adapter, project, compPath));
|
|
2627
|
+
return c.html(
|
|
2628
|
+
injectStudioPreviewAugmentations(html, adapter, project.dir, compPath),
|
|
2629
|
+
200,
|
|
2630
|
+
previewCacheHeaders(etag)
|
|
2631
|
+
);
|
|
2632
|
+
});
|
|
2633
|
+
api.get("/projects/:id/preview/*", async (c) => {
|
|
2634
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
2635
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
2636
|
+
const subPath = decodeURIComponent(
|
|
2637
|
+
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? ""
|
|
2638
|
+
);
|
|
2639
|
+
const file = resolveWithinProject(project.dir, subPath);
|
|
2640
|
+
if (!file) {
|
|
2641
|
+
return c.text("not found", 404);
|
|
2642
|
+
}
|
|
2643
|
+
const stat = existsSync5(file) ? statSync2(file) : null;
|
|
2644
|
+
if (!stat?.isFile()) {
|
|
2645
|
+
return c.text("not found", 404);
|
|
2646
|
+
}
|
|
2647
|
+
const contentType = getMimeType(subPath);
|
|
2648
|
+
const isText = /\.(html|css|js|json|svg|txt|md|cube)$/i.test(subPath);
|
|
2649
|
+
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`;
|
|
2650
|
+
const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, max-age=3600, must-revalidate", ETag: etag };
|
|
2651
|
+
if (!isText) {
|
|
2652
|
+
const ifNoneMatch = c.req.header("If-None-Match");
|
|
2653
|
+
if (ifNoneMatch === etag) {
|
|
2654
|
+
return new Response(null, { status: 304, headers: cacheHeaders });
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
const buffer = isText ? Buffer.from(readFileSync7(file, "utf-8"), "utf-8") : readFileSync7(file);
|
|
2658
|
+
const totalSize = buffer.length;
|
|
2659
|
+
const rangeHeader = c.req.header("Range");
|
|
2660
|
+
if (rangeHeader) {
|
|
2661
|
+
const match = /bytes=(\d+)-(\d*)/.exec(rangeHeader);
|
|
2662
|
+
if (match) {
|
|
2663
|
+
const start = parseInt(match[1], 10);
|
|
2664
|
+
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
|
|
2665
|
+
const safeEnd = Math.min(end, totalSize - 1);
|
|
2666
|
+
const chunkSize = safeEnd - start + 1;
|
|
2667
|
+
return new Response(new Uint8Array(buffer.slice(start, safeEnd + 1)), {
|
|
2668
|
+
status: 206,
|
|
2669
|
+
headers: {
|
|
2670
|
+
...cacheHeaders,
|
|
2671
|
+
"Content-Type": contentType,
|
|
2672
|
+
"Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`,
|
|
2673
|
+
"Accept-Ranges": "bytes",
|
|
2674
|
+
"Content-Length": String(chunkSize)
|
|
2675
|
+
}
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2679
|
+
return new Response(new Uint8Array(buffer), {
|
|
2680
|
+
headers: {
|
|
2681
|
+
...cacheHeaders,
|
|
2682
|
+
"Content-Type": contentType,
|
|
2683
|
+
"Accept-Ranges": "bytes",
|
|
2684
|
+
"Content-Length": String(totalSize)
|
|
2685
|
+
}
|
|
2686
|
+
});
|
|
2687
|
+
});
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
// src/routes/lint.ts
|
|
2691
|
+
import { readFileSync as readFileSync8 } from "fs";
|
|
2692
|
+
import { join as join9 } from "path";
|
|
2693
|
+
function registerLintRoutes(api, adapter) {
|
|
2694
|
+
api.get("/projects/:id/lint", async (c) => {
|
|
2695
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
2696
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
2697
|
+
try {
|
|
2698
|
+
const htmlFiles = walkDir(project.dir).filter(
|
|
2699
|
+
(f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f)
|
|
2700
|
+
);
|
|
2701
|
+
const allFindings = [];
|
|
2702
|
+
for (const file of htmlFiles) {
|
|
2703
|
+
const content = readFileSync8(join9(project.dir, file), "utf-8");
|
|
2704
|
+
const result = await adapter.lint(content, { filePath: file });
|
|
2705
|
+
if (result?.findings) {
|
|
2706
|
+
for (const f of result.findings) {
|
|
2707
|
+
allFindings.push({ ...f, file });
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
return c.json({ findings: allFindings });
|
|
2712
|
+
} catch (err) {
|
|
2713
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
2714
|
+
return c.json({ error: `Lint failed: ${msg}` }, 500);
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
// src/routes/render.ts
|
|
2720
|
+
import { streamSSE } from "hono/streaming";
|
|
2721
|
+
import { existsSync as existsSync6, readFileSync as readFileSync9, mkdirSync as mkdirSync4, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync3 } from "fs";
|
|
2722
|
+
import { join as join10 } from "path";
|
|
2723
|
+
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
|
|
2724
|
+
import { parseFps } from "@hyperframes/core";
|
|
2725
|
+
var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
|
|
2726
|
+
function registerRenderRoutes(api, adapter) {
|
|
2727
|
+
const renderJobs = /* @__PURE__ */ new Map();
|
|
2728
|
+
const TTL_MS = 3e5;
|
|
2729
|
+
const CLEANUP_INTERVAL_MS = 6e4;
|
|
2730
|
+
let cleanupTimer = null;
|
|
2731
|
+
const cleanupEnabled = () => typeof process !== "undefined" && process.env.NODE_ENV !== "production" && !process.argv.includes("build");
|
|
2732
|
+
const cleanupFinishedJobs = () => {
|
|
2733
|
+
const now = Date.now();
|
|
2734
|
+
for (const [key, job] of renderJobs) {
|
|
2735
|
+
if ((job.status === "complete" || job.status === "failed") && now - job.createdAt > TTL_MS) {
|
|
2736
|
+
renderJobs.delete(key);
|
|
2737
|
+
}
|
|
2738
|
+
}
|
|
2739
|
+
if (renderJobs.size === 0 && cleanupTimer) {
|
|
2740
|
+
clearInterval(cleanupTimer);
|
|
2741
|
+
cleanupTimer = null;
|
|
2742
|
+
}
|
|
2743
|
+
};
|
|
2744
|
+
const ensureCleanupTimer = () => {
|
|
2745
|
+
if (cleanupTimer || !cleanupEnabled()) return;
|
|
2746
|
+
cleanupTimer = setInterval(cleanupFinishedJobs, CLEANUP_INTERVAL_MS);
|
|
2747
|
+
if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
|
|
2748
|
+
cleanupTimer.unref();
|
|
2749
|
+
}
|
|
2750
|
+
};
|
|
2751
|
+
ensureCleanupTimer();
|
|
2752
|
+
api.post("/projects/:id/render", async (c) => {
|
|
2753
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
2754
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
2755
|
+
const body = await c.req.json().catch(() => ({}));
|
|
2756
|
+
const VALID_FORMATS = /* @__PURE__ */ new Set(["mp4", "webm", "mov"]);
|
|
2757
|
+
const FORMAT_EXT = { mp4: ".mp4", webm: ".webm", mov: ".mov" };
|
|
2758
|
+
const format = VALID_FORMATS.has(body.format ?? "") ? body.format : "mp4";
|
|
2759
|
+
const fpsParse = body.fps === void 0 ? null : parseFps(body.fps);
|
|
2760
|
+
const fps = fpsParse && fpsParse.ok ? fpsParse.value : { num: 30, den: 1 };
|
|
2761
|
+
const quality = ["draft", "standard", "high"].includes(body.quality ?? "") ? body.quality : "standard";
|
|
2762
|
+
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
|
|
2763
|
+
let composition;
|
|
2764
|
+
if (typeof body.composition === "string" && body.composition.length > 0) {
|
|
2765
|
+
if (!resolveWithinProject(project.dir, body.composition)) {
|
|
2766
|
+
return c.json({ error: "composition path must be within the project directory" }, 400);
|
|
2767
|
+
}
|
|
2768
|
+
composition = body.composition;
|
|
2769
|
+
}
|
|
2770
|
+
const now = /* @__PURE__ */ new Date();
|
|
2771
|
+
const datePart = now.toISOString().slice(0, 10);
|
|
2772
|
+
const timePart = now.toTimeString().slice(0, 8).replace(/:/g, "-");
|
|
2773
|
+
const jobId = `${project.id}_${datePart}_${timePart}`;
|
|
2774
|
+
const rendersDir = adapter.rendersDir(project);
|
|
2775
|
+
if (!existsSync6(rendersDir)) mkdirSync4(rendersDir, { recursive: true });
|
|
2776
|
+
const ext = FORMAT_EXT[format] ?? ".mp4";
|
|
2777
|
+
const outputPath = join10(rendersDir, `${jobId}${ext}`);
|
|
2778
|
+
const jobState = adapter.startRender({
|
|
2779
|
+
project,
|
|
2780
|
+
outputPath,
|
|
2781
|
+
format,
|
|
2782
|
+
fps,
|
|
2783
|
+
quality,
|
|
2784
|
+
jobId,
|
|
2785
|
+
outputResolution,
|
|
2786
|
+
composition,
|
|
2787
|
+
distinctId: typeof body.telemetryDistinctId === "string" ? body.telemetryDistinctId : void 0
|
|
2788
|
+
});
|
|
2789
|
+
jobState.createdAt = Date.now();
|
|
2790
|
+
renderJobs.set(jobId, jobState);
|
|
2791
|
+
ensureCleanupTimer();
|
|
2792
|
+
return c.json({ jobId, status: "rendering" });
|
|
2793
|
+
});
|
|
2794
|
+
api.get("/render/:jobId/progress", (c) => {
|
|
2795
|
+
const { jobId } = c.req.param();
|
|
2796
|
+
const job = renderJobs.get(jobId);
|
|
2797
|
+
if (!job) return c.json({ error: "not found" }, 404);
|
|
2798
|
+
return streamSSE(c, async (stream) => {
|
|
2799
|
+
while (true) {
|
|
2800
|
+
const current = renderJobs.get(jobId);
|
|
2801
|
+
if (!current) break;
|
|
2802
|
+
await stream.writeSSE({
|
|
2803
|
+
event: "progress",
|
|
2804
|
+
data: JSON.stringify({
|
|
2805
|
+
progress: current.progress,
|
|
2806
|
+
status: current.status,
|
|
2807
|
+
stage: current.stage,
|
|
2808
|
+
error: current.error
|
|
2809
|
+
})
|
|
2810
|
+
});
|
|
2811
|
+
if (current.status === "complete" || current.status === "failed") break;
|
|
2812
|
+
await stream.sleep(500);
|
|
2813
|
+
}
|
|
2814
|
+
});
|
|
2815
|
+
});
|
|
2816
|
+
const RENDER_MIME = {
|
|
2817
|
+
".mp4": "video/mp4",
|
|
2818
|
+
".webm": "video/webm",
|
|
2819
|
+
".mov": "video/quicktime"
|
|
2820
|
+
};
|
|
2821
|
+
const RENDER_EXTENSIONS = Object.keys(RENDER_MIME);
|
|
2822
|
+
function renderContentType(filePath) {
|
|
2823
|
+
const ext = RENDER_EXTENSIONS.find((e) => filePath.endsWith(e));
|
|
2824
|
+
return (ext && RENDER_MIME[ext]) ?? "video/mp4";
|
|
2825
|
+
}
|
|
2826
|
+
api.get("/render/:jobId/view", (c) => {
|
|
2827
|
+
const { jobId } = c.req.param();
|
|
2828
|
+
const job = renderJobs.get(jobId);
|
|
2829
|
+
if (!job?.outputPath || !existsSync6(job.outputPath)) {
|
|
2830
|
+
return c.json({ error: "not found" }, 404);
|
|
2831
|
+
}
|
|
2832
|
+
const contentType = renderContentType(job.outputPath);
|
|
2833
|
+
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
|
|
2834
|
+
const content = readFileSync9(job.outputPath);
|
|
2835
|
+
return new Response(content, {
|
|
2836
|
+
headers: {
|
|
2837
|
+
"Content-Type": contentType,
|
|
2838
|
+
"Content-Disposition": `inline; filename="${filename}"`,
|
|
2839
|
+
"Accept-Ranges": "bytes",
|
|
2840
|
+
"Content-Length": String(content.length)
|
|
2841
|
+
}
|
|
2842
|
+
});
|
|
2843
|
+
});
|
|
2844
|
+
api.get("/render/:jobId/download", (c) => {
|
|
2845
|
+
const { jobId } = c.req.param();
|
|
2846
|
+
const job = renderJobs.get(jobId);
|
|
2847
|
+
if (!job?.outputPath || !existsSync6(job.outputPath)) {
|
|
2848
|
+
return c.json({ error: "not found" }, 404);
|
|
2849
|
+
}
|
|
2850
|
+
const contentType = renderContentType(job.outputPath);
|
|
2851
|
+
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
|
|
2852
|
+
const content = readFileSync9(job.outputPath);
|
|
2853
|
+
return new Response(content, {
|
|
2854
|
+
headers: {
|
|
2855
|
+
"Content-Type": contentType,
|
|
2856
|
+
"Content-Disposition": `attachment; filename="${filename}"`
|
|
2857
|
+
}
|
|
2858
|
+
});
|
|
2859
|
+
});
|
|
2860
|
+
api.delete("/render/:jobId", (c) => {
|
|
2861
|
+
const { jobId } = c.req.param();
|
|
2862
|
+
for (const [, state] of renderJobs) {
|
|
2863
|
+
if (state.id === jobId && state.outputPath) {
|
|
2864
|
+
const dir = state.outputPath.replace(/\/[^/]+$/, "");
|
|
2865
|
+
for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
|
|
2866
|
+
const fp = join10(dir, `${jobId}${ext}`);
|
|
2867
|
+
if (existsSync6(fp)) unlinkSync3(fp);
|
|
2868
|
+
}
|
|
2869
|
+
break;
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
renderJobs.delete(jobId);
|
|
2873
|
+
return c.json({ deleted: true });
|
|
2874
|
+
});
|
|
2875
|
+
api.get("/projects/:id/renders/file/*", async (c) => {
|
|
2876
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
2877
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
2878
|
+
const filename = c.req.path.split("/renders/file/")[1];
|
|
2879
|
+
if (!filename) return c.json({ error: "missing filename" }, 400);
|
|
2880
|
+
const rendersDir = adapter.rendersDir(project);
|
|
2881
|
+
const fp = resolveWithinProject(rendersDir, filename);
|
|
2882
|
+
if (!fp) return c.json({ error: "forbidden" }, 403);
|
|
2883
|
+
if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
|
|
2884
|
+
const contentType = renderContentType(fp);
|
|
2885
|
+
const content = readFileSync9(fp);
|
|
2886
|
+
return new Response(content, {
|
|
2887
|
+
headers: {
|
|
2888
|
+
"Content-Type": contentType,
|
|
2889
|
+
"Content-Disposition": `inline; filename="${filename}"`,
|
|
2890
|
+
"Accept-Ranges": "bytes",
|
|
2891
|
+
"Content-Length": String(content.length)
|
|
2892
|
+
}
|
|
2893
|
+
});
|
|
2894
|
+
});
|
|
2895
|
+
api.get("/projects/:id/renders", async (c) => {
|
|
2896
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
2897
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
2898
|
+
const rendersDir = adapter.rendersDir(project);
|
|
2899
|
+
if (!existsSync6(rendersDir)) return c.json({ renders: [] });
|
|
2900
|
+
const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
|
|
2901
|
+
const fp = join10(rendersDir, f);
|
|
2902
|
+
const stat = statSync3(fp);
|
|
2903
|
+
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
|
|
2904
|
+
const metaPath = join10(rendersDir, `${rid}.meta.json`);
|
|
2905
|
+
let status = "complete";
|
|
2906
|
+
let durationMs;
|
|
2907
|
+
if (existsSync6(metaPath)) {
|
|
2908
|
+
try {
|
|
2909
|
+
const meta = JSON.parse(readFileSync9(metaPath, "utf-8"));
|
|
2910
|
+
if (meta.status === "failed") status = "failed";
|
|
2911
|
+
if (meta.durationMs) durationMs = meta.durationMs;
|
|
2912
|
+
} catch {
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
return {
|
|
2916
|
+
id: rid,
|
|
2917
|
+
filename: f,
|
|
2918
|
+
size: stat.size,
|
|
2919
|
+
createdAt: stat.mtimeMs,
|
|
2920
|
+
status,
|
|
2921
|
+
durationMs
|
|
2922
|
+
};
|
|
2923
|
+
}).sort((a, b) => b.createdAt - a.createdAt);
|
|
2924
|
+
for (const file of files) {
|
|
2925
|
+
if (!renderJobs.has(file.id)) {
|
|
2926
|
+
renderJobs.set(file.id, {
|
|
2927
|
+
id: file.id,
|
|
2928
|
+
status: file.status,
|
|
2929
|
+
progress: 100,
|
|
2930
|
+
outputPath: join10(rendersDir, file.filename),
|
|
2931
|
+
createdAt: file.createdAt
|
|
2932
|
+
});
|
|
2933
|
+
}
|
|
2934
|
+
}
|
|
2935
|
+
return c.json({ renders: files });
|
|
2936
|
+
});
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
// src/routes/thumbnail.ts
|
|
2940
|
+
import { existsSync as existsSync7, readFileSync as readFileSync10, writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, statSync as statSync4 } from "fs";
|
|
2941
|
+
import { join as join11 } from "path";
|
|
2942
|
+
import { createHash as createHash2 } from "crypto";
|
|
2943
|
+
|
|
2944
|
+
// src/helpers/manualEditsRenderScript.ts
|
|
2945
|
+
var STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
|
|
2946
|
+
function createStudioManualEditsRenderBodyScript(manifestContent, options = {}) {
|
|
2947
|
+
if (!manifestContent.trim()) return null;
|
|
2948
|
+
return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`;
|
|
2949
|
+
}
|
|
2950
|
+
function createStudioPositionSeekReapplyScript() {
|
|
2951
|
+
return `(${studioPositionSeekReapplyRuntime.toString()})();`;
|
|
2952
|
+
}
|
|
2953
|
+
function studioPositionSeekReapplyRuntime() {
|
|
2954
|
+
const OFFSET_X_PROP = "--hf-studio-offset-x";
|
|
2955
|
+
const OFFSET_Y_PROP = "--hf-studio-offset-y";
|
|
2956
|
+
const WIDTH_PROP = "--hf-studio-width";
|
|
2957
|
+
const HEIGHT_PROP = "--hf-studio-height";
|
|
2958
|
+
const ROTATION_PROP = "--hf-studio-rotation";
|
|
2959
|
+
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
|
|
2960
|
+
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
|
|
2961
|
+
const ROTATION_ATTR = "data-hf-studio-rotation";
|
|
2962
|
+
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
|
|
2963
|
+
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
|
|
2964
|
+
const MOTION_ATTR = "data-hf-studio-motion";
|
|
2965
|
+
const MOTION_TL_KEY = "studio-motion";
|
|
2966
|
+
const WRAPPED_PROP = "__hfStudioPositionSeekReapplyWrapped";
|
|
2967
|
+
if (!document.querySelector("[" + PATH_OFFSET_ATTR + '="true"]') && !document.querySelector("[" + BOX_SIZE_ATTR + '="true"]') && !document.querySelector("[" + ROTATION_ATTR + '="true"]') && !document.querySelector("[" + MOTION_ATTR + "]"))
|
|
2968
|
+
return;
|
|
2969
|
+
const splitTopLevelWhitespace = (value) => {
|
|
2970
|
+
const parts = [];
|
|
2971
|
+
let depth = 0;
|
|
2972
|
+
let current = "";
|
|
2973
|
+
for (const char of value.trim()) {
|
|
2974
|
+
if (char === "(") depth += 1;
|
|
2975
|
+
if (char === ")") depth = Math.max(0, depth - 1);
|
|
2976
|
+
if (/\s/.test(char) && depth === 0) {
|
|
2977
|
+
if (current) parts.push(current);
|
|
2978
|
+
current = "";
|
|
2979
|
+
} else {
|
|
2980
|
+
current += char;
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
if (current) parts.push(current);
|
|
2984
|
+
return parts;
|
|
2985
|
+
};
|
|
2986
|
+
const composeTranslate = (element, x, y) => {
|
|
2987
|
+
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
|
|
2988
|
+
if (!original || original === "none") return x + " " + y;
|
|
2989
|
+
const parts = splitTopLevelWhitespace(original);
|
|
2990
|
+
if (parts.length === 1) return "calc(" + parts[0] + " + " + x + ") " + y;
|
|
2991
|
+
if (parts.length >= 2) {
|
|
2992
|
+
const z = parts.length >= 3 ? " " + parts[2] : "";
|
|
2993
|
+
return "calc(" + parts[0] + " + " + x + ") calc(" + parts[1] + " + " + y + ")" + z;
|
|
2994
|
+
}
|
|
2995
|
+
return x + " " + y;
|
|
2996
|
+
};
|
|
2997
|
+
const isSimpleRotateAngle = (value) => /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
|
|
2998
|
+
const composeRotation = (element, rotationValue) => {
|
|
2999
|
+
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
|
|
3000
|
+
if (!original || original === "none" || !isSimpleRotateAngle(original)) return rotationValue;
|
|
3001
|
+
return "calc(" + original + " + " + rotationValue + ")";
|
|
3002
|
+
};
|
|
3003
|
+
let lastSeekTime = 0;
|
|
3004
|
+
let cachedMotionKey = "";
|
|
3005
|
+
const finiteNum = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
3006
|
+
const computeMotionKey = (motionEls) => {
|
|
3007
|
+
let key = "";
|
|
3008
|
+
for (let i = 0; i < motionEls.length; i++) {
|
|
3009
|
+
const json = motionEls[i].getAttribute?.(MOTION_ATTR);
|
|
3010
|
+
if (json) key += (key ? "\n" : "") + json;
|
|
3011
|
+
}
|
|
3012
|
+
return key;
|
|
3013
|
+
};
|
|
3014
|
+
const reapplyMotionTimeline = () => {
|
|
3015
|
+
const motionEls = document.querySelectorAll("[" + MOTION_ATTR + "]");
|
|
3016
|
+
if (motionEls.length === 0) {
|
|
3017
|
+
cachedMotionKey = "";
|
|
3018
|
+
return;
|
|
3019
|
+
}
|
|
3020
|
+
const win = window;
|
|
3021
|
+
const gsap = win.gsap;
|
|
3022
|
+
if (!gsap || typeof gsap.timeline !== "function") return;
|
|
3023
|
+
win.__timelines = win.__timelines || {};
|
|
3024
|
+
const motionKey = computeMotionKey(motionEls);
|
|
3025
|
+
const existing = win.__timelines[MOTION_TL_KEY];
|
|
3026
|
+
if (motionKey && motionKey === cachedMotionKey && existing && typeof existing.totalTime === "function") {
|
|
3027
|
+
existing.totalTime(lastSeekTime, false);
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
if (existing && typeof existing.kill === "function") existing.kill();
|
|
3031
|
+
const tl = gsap.timeline({ paused: true, defaults: { overwrite: "auto" } });
|
|
3032
|
+
const fromTo = tl.fromTo;
|
|
3033
|
+
if (typeof fromTo !== "function") return;
|
|
3034
|
+
let applied = 0;
|
|
3035
|
+
for (let i = 0; i < motionEls.length; i++) {
|
|
3036
|
+
const el = motionEls[i];
|
|
3037
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
3038
|
+
const json = el.getAttribute(MOTION_ATTR);
|
|
3039
|
+
if (!json) continue;
|
|
3040
|
+
try {
|
|
3041
|
+
const m = JSON.parse(json);
|
|
3042
|
+
const start = finiteNum(m.start);
|
|
3043
|
+
const duration = finiteNum(m.duration);
|
|
3044
|
+
if (start == null || duration == null || duration <= 0) continue;
|
|
3045
|
+
const ease = typeof m.ease === "string" ? m.ease : "none";
|
|
3046
|
+
const from = m.from && typeof m.from === "object" ? m.from : {};
|
|
3047
|
+
const to = m.to && typeof m.to === "object" ? m.to : {};
|
|
3048
|
+
const customEase = m.customEase;
|
|
3049
|
+
let resolvedEase = ease;
|
|
3050
|
+
if (customEase?.id && customEase?.data && win.CustomEase?.create) {
|
|
3051
|
+
try {
|
|
3052
|
+
gsap.registerPlugin?.(win.CustomEase);
|
|
3053
|
+
win.CustomEase.create(customEase.id, customEase.data);
|
|
3054
|
+
resolvedEase = customEase.id;
|
|
3055
|
+
} catch {
|
|
3056
|
+
}
|
|
3057
|
+
}
|
|
3058
|
+
fromTo.call(
|
|
3059
|
+
tl,
|
|
3060
|
+
el,
|
|
3061
|
+
{ ...from },
|
|
3062
|
+
{ ...to, duration, ease: resolvedEase, overwrite: "auto", immediateRender: false },
|
|
3063
|
+
start
|
|
3064
|
+
);
|
|
3065
|
+
applied += 1;
|
|
3066
|
+
} catch {
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
if (applied === 0) {
|
|
3070
|
+
cachedMotionKey = "";
|
|
3071
|
+
if (typeof tl.kill === "function")
|
|
3072
|
+
tl.kill();
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
cachedMotionKey = motionKey;
|
|
3076
|
+
win.__timelines[MOTION_TL_KEY] = tl;
|
|
3077
|
+
if (typeof tl.pause === "function") tl.pause();
|
|
3078
|
+
if (typeof tl.totalTime === "function")
|
|
3079
|
+
tl.totalTime(lastSeekTime, false);
|
|
3080
|
+
};
|
|
3081
|
+
const stripGsapTranslateFromTransform = (el) => {
|
|
3082
|
+
const transform = el.style.getPropertyValue("transform");
|
|
3083
|
+
if (!transform || transform === "none") return;
|
|
3084
|
+
const win = el.ownerDocument.defaultView;
|
|
3085
|
+
const MatrixCtor = win?.DOMMatrix;
|
|
3086
|
+
if (!MatrixCtor) return;
|
|
3087
|
+
try {
|
|
3088
|
+
const m = new MatrixCtor(transform);
|
|
3089
|
+
if (m.m41 === 0 && m.m42 === 0) return;
|
|
3090
|
+
m.m41 = 0;
|
|
3091
|
+
m.m42 = 0;
|
|
3092
|
+
if (m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1) {
|
|
3093
|
+
el.style.removeProperty("transform");
|
|
3094
|
+
} else {
|
|
3095
|
+
el.style.setProperty("transform", m.toString());
|
|
3096
|
+
}
|
|
3097
|
+
} catch {
|
|
3098
|
+
}
|
|
3099
|
+
};
|
|
3100
|
+
const reapplyAll = () => {
|
|
3101
|
+
const offsetEls = document.querySelectorAll("[" + PATH_OFFSET_ATTR + '="true"]');
|
|
3102
|
+
for (let i = 0; i < offsetEls.length; i++) {
|
|
3103
|
+
const el = offsetEls[i];
|
|
3104
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
3105
|
+
const x = el.style.getPropertyValue(OFFSET_X_PROP);
|
|
3106
|
+
const y = el.style.getPropertyValue(OFFSET_Y_PROP);
|
|
3107
|
+
if (x || y) {
|
|
3108
|
+
el.style.setProperty(
|
|
3109
|
+
"translate",
|
|
3110
|
+
composeTranslate(
|
|
3111
|
+
el,
|
|
3112
|
+
"var(" + OFFSET_X_PROP + ", 0px)",
|
|
3113
|
+
"var(" + OFFSET_Y_PROP + ", 0px)"
|
|
3114
|
+
)
|
|
3115
|
+
);
|
|
3116
|
+
stripGsapTranslateFromTransform(el);
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
const boxSizeEls = document.querySelectorAll("[" + BOX_SIZE_ATTR + '="true"]');
|
|
3120
|
+
for (let i = 0; i < boxSizeEls.length; i++) {
|
|
3121
|
+
const el = boxSizeEls[i];
|
|
3122
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
3123
|
+
const w = el.style.getPropertyValue(WIDTH_PROP);
|
|
3124
|
+
const h = el.style.getPropertyValue(HEIGHT_PROP);
|
|
3125
|
+
if (w) el.style.setProperty("width", w);
|
|
3126
|
+
if (h) el.style.setProperty("height", h);
|
|
3127
|
+
}
|
|
3128
|
+
const rotEls = document.querySelectorAll("[" + ROTATION_ATTR + '="true"]');
|
|
3129
|
+
for (let i = 0; i < rotEls.length; i++) {
|
|
3130
|
+
const el = rotEls[i];
|
|
3131
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
3132
|
+
const rot = el.style.getPropertyValue(ROTATION_PROP);
|
|
3133
|
+
if (rot) {
|
|
3134
|
+
el.style.setProperty("rotate", composeRotation(el, "var(" + ROTATION_PROP + ", 0deg)"));
|
|
3135
|
+
stripGsapTranslateFromTransform(el);
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
reapplyMotionTimeline();
|
|
3139
|
+
};
|
|
3140
|
+
const runtimeWindow = window;
|
|
3141
|
+
const isWrapped = (fn) => Boolean(fn[WRAPPED_PROP]);
|
|
3142
|
+
const markWrapped = (fn) => {
|
|
3143
|
+
try {
|
|
3144
|
+
Object.defineProperty(fn, WRAPPED_PROP, {
|
|
3145
|
+
configurable: false,
|
|
3146
|
+
enumerable: false,
|
|
3147
|
+
value: true
|
|
3148
|
+
});
|
|
3149
|
+
} catch {
|
|
3150
|
+
try {
|
|
3151
|
+
fn[WRAPPED_PROP] = true;
|
|
3152
|
+
} catch {
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3155
|
+
};
|
|
3156
|
+
const wrapFn = (get, set) => {
|
|
3157
|
+
const fn = get();
|
|
3158
|
+
if (typeof fn !== "function") return false;
|
|
3159
|
+
const seek = fn;
|
|
3160
|
+
if (isWrapped(seek)) {
|
|
3161
|
+
reapplyAll();
|
|
3162
|
+
return true;
|
|
3163
|
+
}
|
|
3164
|
+
const wrapped = function(time) {
|
|
3165
|
+
lastSeekTime = typeof time === "number" && Number.isFinite(time) ? Math.max(0, time) : 0;
|
|
3166
|
+
const result = seek.call(this, time);
|
|
3167
|
+
reapplyAll();
|
|
3168
|
+
return result;
|
|
3169
|
+
};
|
|
3170
|
+
markWrapped(wrapped);
|
|
3171
|
+
set(wrapped);
|
|
3172
|
+
reapplyAll();
|
|
3173
|
+
return true;
|
|
3174
|
+
};
|
|
3175
|
+
const wrapSeekFunctions = () => {
|
|
3176
|
+
const a = wrapFn(
|
|
3177
|
+
() => runtimeWindow.__hf?.["seek"],
|
|
3178
|
+
(fn) => {
|
|
3179
|
+
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
|
|
3180
|
+
}
|
|
3181
|
+
);
|
|
3182
|
+
const b = wrapFn(
|
|
3183
|
+
() => runtimeWindow.__player?.["renderSeek"],
|
|
3184
|
+
(fn) => {
|
|
3185
|
+
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
|
|
3186
|
+
}
|
|
3187
|
+
);
|
|
3188
|
+
return a || b;
|
|
3189
|
+
};
|
|
3190
|
+
const installSeekTrap = (obj, key, getter, setter) => {
|
|
3191
|
+
if (!obj) return;
|
|
3192
|
+
try {
|
|
3193
|
+
let current = obj[key];
|
|
3194
|
+
Object.defineProperty(obj, key, {
|
|
3195
|
+
configurable: true,
|
|
3196
|
+
enumerable: true,
|
|
3197
|
+
get() {
|
|
3198
|
+
return current;
|
|
3199
|
+
},
|
|
3200
|
+
set(value) {
|
|
3201
|
+
current = value;
|
|
3202
|
+
if (typeof value === "function" && !isWrapped(value)) {
|
|
3203
|
+
wrapFn(getter, setter);
|
|
3204
|
+
}
|
|
3205
|
+
}
|
|
3206
|
+
});
|
|
3207
|
+
} catch {
|
|
3208
|
+
}
|
|
3209
|
+
};
|
|
3210
|
+
if (document.readyState === "loading") {
|
|
3211
|
+
document.addEventListener("DOMContentLoaded", () => reapplyAll(), { once: true });
|
|
3212
|
+
} else {
|
|
3213
|
+
reapplyAll();
|
|
3214
|
+
}
|
|
3215
|
+
wrapSeekFunctions();
|
|
3216
|
+
installSeekTrap(
|
|
3217
|
+
runtimeWindow.__hf,
|
|
3218
|
+
"seek",
|
|
3219
|
+
() => runtimeWindow.__hf?.["seek"],
|
|
3220
|
+
(fn) => {
|
|
3221
|
+
if (runtimeWindow.__hf) runtimeWindow.__hf["seek"] = fn;
|
|
3222
|
+
}
|
|
3223
|
+
);
|
|
3224
|
+
installSeekTrap(
|
|
3225
|
+
runtimeWindow.__player,
|
|
3226
|
+
"renderSeek",
|
|
3227
|
+
() => runtimeWindow.__player?.["renderSeek"],
|
|
3228
|
+
(fn) => {
|
|
3229
|
+
if (runtimeWindow.__player) runtimeWindow.__player["renderSeek"] = fn;
|
|
3230
|
+
}
|
|
3231
|
+
);
|
|
3232
|
+
let remaining = 120;
|
|
3233
|
+
const interval = setInterval(() => {
|
|
3234
|
+
wrapSeekFunctions();
|
|
3235
|
+
remaining -= 1;
|
|
3236
|
+
if (remaining <= 0) clearInterval(interval);
|
|
3237
|
+
}, 50);
|
|
3238
|
+
}
|
|
3239
|
+
function studioManualEditsRenderRuntime(manifestContent, activeCompositionPath) {
|
|
3240
|
+
const OFFSET_X_PROP = "--hf-studio-offset-x";
|
|
3241
|
+
const OFFSET_Y_PROP = "--hf-studio-offset-y";
|
|
3242
|
+
const WIDTH_PROP = "--hf-studio-width";
|
|
3243
|
+
const HEIGHT_PROP = "--hf-studio-height";
|
|
3244
|
+
const ROTATION_PROP = "--hf-studio-rotation";
|
|
3245
|
+
const PATH_OFFSET_ATTR = "data-hf-studio-path-offset";
|
|
3246
|
+
const BOX_SIZE_ATTR = "data-hf-studio-box-size";
|
|
3247
|
+
const ROTATION_ATTR = "data-hf-studio-rotation";
|
|
3248
|
+
const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate";
|
|
3249
|
+
const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate";
|
|
3250
|
+
const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped";
|
|
3251
|
+
const ROTATION_TRANSFORM_ORIGIN = "center center";
|
|
3252
|
+
const finiteNumber = (value) => typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
3253
|
+
const objectRecord = (value) => value && typeof value === "object" ? value : null;
|
|
3254
|
+
const runtimeWindow = window;
|
|
3255
|
+
const parsedManifest = (() => {
|
|
3256
|
+
try {
|
|
3257
|
+
return objectRecord(JSON.parse(manifestContent));
|
|
3258
|
+
} catch {
|
|
3259
|
+
return null;
|
|
3260
|
+
}
|
|
3261
|
+
})();
|
|
3262
|
+
const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : [];
|
|
3263
|
+
if (manifestEdits.length === 0) return;
|
|
3264
|
+
const sourceFileForElement = (element) => {
|
|
3265
|
+
let current = element;
|
|
3266
|
+
while (current) {
|
|
3267
|
+
const sourceFile = current.getAttribute("data-composition-file") ?? current.getAttribute("data-composition-src");
|
|
3268
|
+
if (sourceFile) return sourceFile;
|
|
3269
|
+
current = current.parentElement;
|
|
3270
|
+
}
|
|
3271
|
+
return activeCompositionPath ?? "index.html";
|
|
3272
|
+
};
|
|
3273
|
+
const elementMatchesSourceFile = (element, sourceFile) => sourceFileForElement(element) === sourceFile;
|
|
3274
|
+
const styleUsesStudioOffset = (value) => value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP);
|
|
3275
|
+
const styleUsesStudioRotation = (value) => value.includes(ROTATION_PROP);
|
|
3276
|
+
const splitTopLevelWhitespace = (value) => {
|
|
3277
|
+
const parts = [];
|
|
3278
|
+
let depth = 0;
|
|
3279
|
+
let current = "";
|
|
3280
|
+
for (const char of value.trim()) {
|
|
3281
|
+
if (char === "(") depth += 1;
|
|
3282
|
+
if (char === ")") depth = Math.max(0, depth - 1);
|
|
3283
|
+
if (/\s/.test(char) && depth === 0) {
|
|
3284
|
+
if (current) parts.push(current);
|
|
3285
|
+
current = "";
|
|
3286
|
+
} else {
|
|
3287
|
+
current += char;
|
|
3288
|
+
}
|
|
3289
|
+
}
|
|
3290
|
+
if (current) parts.push(current);
|
|
3291
|
+
return parts;
|
|
3292
|
+
};
|
|
3293
|
+
const composeTranslate = (element, x, y) => {
|
|
3294
|
+
const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim();
|
|
3295
|
+
if (!original || original === "none") return `${x} ${y}`;
|
|
3296
|
+
const parts = splitTopLevelWhitespace(original);
|
|
3297
|
+
if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`;
|
|
3298
|
+
if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`;
|
|
3299
|
+
if (parts.length === 3) {
|
|
3300
|
+
return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`;
|
|
3301
|
+
}
|
|
3302
|
+
return `${x} ${y}`;
|
|
3303
|
+
};
|
|
3304
|
+
const readStyleOrComputed = (element, property) => {
|
|
3305
|
+
try {
|
|
3306
|
+
return element.style.getPropertyValue(property) || getComputedStyle(element).getPropertyValue(property);
|
|
3307
|
+
} catch {
|
|
3308
|
+
return element.style.getPropertyValue(property);
|
|
3309
|
+
}
|
|
3310
|
+
};
|
|
3311
|
+
const readTransformLonghandBase = (element, property) => {
|
|
3312
|
+
const value = readStyleOrComputed(element, property).trim();
|
|
3313
|
+
return value === "none" ? "" : value;
|
|
3314
|
+
};
|
|
3315
|
+
const preparePathOffsetBase = (element) => {
|
|
3316
|
+
const currentTranslate = readTransformLonghandBase(element, "translate");
|
|
3317
|
+
const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR);
|
|
3318
|
+
const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate);
|
|
3319
|
+
if (!hasMarker) {
|
|
3320
|
+
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : "");
|
|
3321
|
+
} else if (wasResetByAnimation) {
|
|
3322
|
+
element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate);
|
|
3323
|
+
}
|
|
3324
|
+
};
|
|
3325
|
+
const prepareRotationBase = (element) => {
|
|
3326
|
+
const currentRotate = readTransformLonghandBase(element, "rotate");
|
|
3327
|
+
const hasMarker = element.hasAttribute(ROTATION_ATTR);
|
|
3328
|
+
const wasResetByAnimation = !styleUsesStudioRotation(currentRotate);
|
|
3329
|
+
if (!hasMarker) {
|
|
3330
|
+
element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : "");
|
|
3331
|
+
} else if (wasResetByAnimation) {
|
|
3332
|
+
element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate);
|
|
3333
|
+
}
|
|
3334
|
+
};
|
|
3335
|
+
const querySelectorCandidates = (selector) => {
|
|
3336
|
+
const isCandidate = (element) => element instanceof HTMLElement;
|
|
3337
|
+
const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1];
|
|
3338
|
+
if (className) {
|
|
3339
|
+
return Array.from(document.getElementsByTagName("*")).filter(
|
|
3340
|
+
(element) => isCandidate(element) && element.classList.contains(className)
|
|
3341
|
+
);
|
|
3342
|
+
}
|
|
3343
|
+
if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) {
|
|
3344
|
+
return Array.from(document.getElementsByTagName(selector)).filter(isCandidate);
|
|
3345
|
+
}
|
|
3346
|
+
return Array.from(document.querySelectorAll(selector)).filter(isCandidate);
|
|
3347
|
+
};
|
|
3348
|
+
const resolveTarget = (edit) => {
|
|
3349
|
+
const targetRecord = objectRecord(edit.target);
|
|
3350
|
+
if (!targetRecord) return null;
|
|
3351
|
+
const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : "";
|
|
3352
|
+
if (!sourceFile) return null;
|
|
3353
|
+
const id = typeof targetRecord.id === "string" ? targetRecord.id : "";
|
|
3354
|
+
if (id) {
|
|
3355
|
+
const byId = document.getElementById(id);
|
|
3356
|
+
if (byId instanceof HTMLElement && elementMatchesSourceFile(byId, sourceFile)) return byId;
|
|
3357
|
+
const matchesById = [
|
|
3358
|
+
document.documentElement,
|
|
3359
|
+
...Array.from(document.getElementsByTagName("*"))
|
|
3360
|
+
].filter(
|
|
3361
|
+
(element) => element instanceof HTMLElement && element.id === id && elementMatchesSourceFile(element, sourceFile)
|
|
3362
|
+
);
|
|
3363
|
+
if (matchesById[0]) return matchesById[0];
|
|
3364
|
+
}
|
|
3365
|
+
const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : "";
|
|
3366
|
+
if (!selector) return null;
|
|
3367
|
+
try {
|
|
3368
|
+
const matches = querySelectorCandidates(selector).filter(
|
|
3369
|
+
(element) => elementMatchesSourceFile(element, sourceFile)
|
|
3370
|
+
);
|
|
3371
|
+
const selectorIndex = finiteNumber(targetRecord.selectorIndex) ?? 0;
|
|
3372
|
+
return matches[Math.max(0, Math.floor(selectorIndex))] ?? null;
|
|
3373
|
+
} catch {
|
|
3374
|
+
return null;
|
|
3375
|
+
}
|
|
3376
|
+
};
|
|
3377
|
+
const roundRotationAngle = (angle) => Math.round(angle * 10) / 10;
|
|
3378
|
+
const isSimpleRotateAngle = (value) => /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim());
|
|
3379
|
+
const composeRotation = (element, rotationValue) => {
|
|
3380
|
+
const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim();
|
|
3381
|
+
if (!original || original === "none" || !isSimpleRotateAngle(original)) {
|
|
3382
|
+
return rotationValue;
|
|
3383
|
+
}
|
|
3384
|
+
return `calc(${original} + ${rotationValue})`;
|
|
3385
|
+
};
|
|
3386
|
+
const applyPathOffset = (element, edit) => {
|
|
3387
|
+
const x = finiteNumber(edit.x);
|
|
3388
|
+
const y = finiteNumber(edit.y);
|
|
3389
|
+
if (x == null || y == null) return;
|
|
3390
|
+
preparePathOffsetBase(element);
|
|
3391
|
+
element.setAttribute(PATH_OFFSET_ATTR, "true");
|
|
3392
|
+
element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`);
|
|
3393
|
+
element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`);
|
|
3394
|
+
element.style.setProperty(
|
|
3395
|
+
"translate",
|
|
3396
|
+
composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`)
|
|
3397
|
+
);
|
|
3398
|
+
};
|
|
3399
|
+
const readParentFlexBasisPixels = (element, size) => {
|
|
3400
|
+
const parent = element.parentElement;
|
|
3401
|
+
if (!parent) return null;
|
|
3402
|
+
const styles = getComputedStyle(parent);
|
|
3403
|
+
if (styles.display !== "flex" && styles.display !== "inline-flex") return null;
|
|
3404
|
+
return Math.round(
|
|
3405
|
+
Math.max(1, styles.flexDirection.startsWith("column") ? size.height : size.width)
|
|
3406
|
+
);
|
|
3407
|
+
};
|
|
3408
|
+
const applyBoxSize = (element, edit) => {
|
|
3409
|
+
const width = finiteNumber(edit.width);
|
|
3410
|
+
const height = finiteNumber(edit.height);
|
|
3411
|
+
if (width == null || height == null || width <= 0 || height <= 0) return;
|
|
3412
|
+
const rounded = {
|
|
3413
|
+
width: Math.round(Math.max(1, width)),
|
|
3414
|
+
height: Math.round(Math.max(1, height))
|
|
3415
|
+
};
|
|
3416
|
+
element.setAttribute(BOX_SIZE_ATTR, "true");
|
|
3417
|
+
element.style.setProperty(WIDTH_PROP, `${rounded.width}px`);
|
|
3418
|
+
element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`);
|
|
3419
|
+
element.style.setProperty("box-sizing", "border-box");
|
|
3420
|
+
element.style.setProperty("width", `${rounded.width}px`);
|
|
3421
|
+
element.style.setProperty("height", `${rounded.height}px`);
|
|
3422
|
+
element.style.setProperty("min-width", "0px");
|
|
3423
|
+
element.style.setProperty("min-height", "0px");
|
|
3424
|
+
element.style.setProperty("max-width", "none");
|
|
3425
|
+
element.style.setProperty("max-height", "none");
|
|
3426
|
+
const flexBasis = readParentFlexBasisPixels(element, rounded);
|
|
3427
|
+
if (flexBasis != null) {
|
|
3428
|
+
element.style.setProperty("flex-basis", `${flexBasis}px`);
|
|
3429
|
+
element.style.setProperty("flex-grow", "0");
|
|
3430
|
+
element.style.setProperty("flex-shrink", "0");
|
|
3431
|
+
}
|
|
3432
|
+
if (getComputedStyle(element).display === "inline") {
|
|
3433
|
+
element.style.setProperty("display", "inline-block");
|
|
3434
|
+
}
|
|
3435
|
+
};
|
|
3436
|
+
const applyRotation = (element, edit) => {
|
|
3437
|
+
const angle = finiteNumber(edit.angle);
|
|
3438
|
+
if (angle == null) return;
|
|
3439
|
+
prepareRotationBase(element);
|
|
3440
|
+
element.setAttribute(ROTATION_ATTR, "true");
|
|
3441
|
+
element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`);
|
|
3442
|
+
element.style.setProperty("transform-origin", ROTATION_TRANSFORM_ORIGIN);
|
|
3443
|
+
element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`));
|
|
3444
|
+
};
|
|
3445
|
+
const applyManifest = () => {
|
|
3446
|
+
let applied = 0;
|
|
3447
|
+
for (const edit of manifestEdits) {
|
|
3448
|
+
const editRecord = objectRecord(edit);
|
|
3449
|
+
if (!editRecord) continue;
|
|
3450
|
+
const element = resolveTarget(editRecord);
|
|
3451
|
+
if (!element) continue;
|
|
3452
|
+
if (editRecord.kind === "path-offset") applyPathOffset(element, editRecord);
|
|
3453
|
+
if (editRecord.kind === "box-size") applyBoxSize(element, editRecord);
|
|
3454
|
+
if (editRecord.kind === "rotation") applyRotation(element, editRecord);
|
|
3455
|
+
applied += 1;
|
|
3456
|
+
}
|
|
3457
|
+
return applied;
|
|
3458
|
+
};
|
|
3459
|
+
runtimeWindow.__hfStudioManualEditsApply = applyManifest;
|
|
3460
|
+
const markWrapped = (fn) => {
|
|
3461
|
+
try {
|
|
3462
|
+
Object.defineProperty(fn, WRAPPED_SEEK_PROP, {
|
|
3463
|
+
configurable: false,
|
|
3464
|
+
enumerable: false,
|
|
3465
|
+
value: true
|
|
3466
|
+
});
|
|
3467
|
+
} catch {
|
|
3468
|
+
try {
|
|
3469
|
+
fn[WRAPPED_SEEK_PROP] = true;
|
|
3470
|
+
} catch {
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
};
|
|
3474
|
+
const isWrapped = (fn) => Boolean(fn[WRAPPED_SEEK_PROP]);
|
|
3475
|
+
const wrapFunction = (get, set) => {
|
|
3476
|
+
const fn = get();
|
|
3477
|
+
if (!fn) return false;
|
|
3478
|
+
const seek = fn;
|
|
3479
|
+
if (isWrapped(seek)) {
|
|
3480
|
+
applyManifest();
|
|
3481
|
+
return true;
|
|
3482
|
+
}
|
|
3483
|
+
const wrappedSeek = function(time) {
|
|
3484
|
+
const result = seek.call(this, time);
|
|
3485
|
+
applyManifest();
|
|
3486
|
+
return result;
|
|
3487
|
+
};
|
|
3488
|
+
markWrapped(wrappedSeek);
|
|
3489
|
+
set(wrappedSeek);
|
|
3490
|
+
applyManifest();
|
|
3491
|
+
return true;
|
|
3492
|
+
};
|
|
3493
|
+
const wrapSeekFunctions = () => {
|
|
3494
|
+
const wrappedHfSeek = wrapFunction(
|
|
3495
|
+
() => runtimeWindow.__hf?.seek,
|
|
3496
|
+
(fn) => {
|
|
3497
|
+
if (runtimeWindow.__hf) runtimeWindow.__hf.seek = fn;
|
|
3498
|
+
}
|
|
3499
|
+
);
|
|
3500
|
+
const wrappedPlayerRenderSeek = wrapFunction(
|
|
3501
|
+
() => runtimeWindow.__player?.renderSeek,
|
|
3502
|
+
(fn) => {
|
|
3503
|
+
if (runtimeWindow.__player) runtimeWindow.__player.renderSeek = fn;
|
|
3504
|
+
}
|
|
3505
|
+
);
|
|
3506
|
+
return wrappedHfSeek || wrappedPlayerRenderSeek;
|
|
3507
|
+
};
|
|
3508
|
+
if (document.readyState === "loading") {
|
|
3509
|
+
document.addEventListener("DOMContentLoaded", () => applyManifest(), { once: true });
|
|
3510
|
+
} else {
|
|
3511
|
+
applyManifest();
|
|
3512
|
+
}
|
|
3513
|
+
wrapSeekFunctions();
|
|
3514
|
+
let remainingSeekWrapAttempts = 120;
|
|
3515
|
+
const seekWrapInterval = setInterval(() => {
|
|
3516
|
+
wrapSeekFunctions();
|
|
3517
|
+
remainingSeekWrapAttempts -= 1;
|
|
3518
|
+
if (remainingSeekWrapAttempts <= 0) clearInterval(seekWrapInterval);
|
|
3519
|
+
}, 50);
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
// src/routes/thumbnail.ts
|
|
3523
|
+
var THUMBNAIL_CACHE_VERSION = "v4";
|
|
3524
|
+
function registerThumbnailRoutes(api, adapter) {
|
|
3525
|
+
api.get("/projects/:id/thumbnail/*", async (c) => {
|
|
3526
|
+
if (!adapter.generateThumbnail) {
|
|
3527
|
+
return c.json({ error: "Thumbnails not available" }, 501);
|
|
3528
|
+
}
|
|
3529
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3530
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
3531
|
+
let compPath = decodeURIComponent(
|
|
3532
|
+
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? ""
|
|
3533
|
+
);
|
|
3534
|
+
if (compPath && !compPath.includes(".")) compPath += ".html";
|
|
3535
|
+
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
|
|
3536
|
+
const rawSeekTime = url.searchParams.get("t");
|
|
3537
|
+
const parsedSeekTime = rawSeekTime == null ? Number.NaN : parseFloat(rawSeekTime);
|
|
3538
|
+
const seekTime = Number.isFinite(parsedSeekTime) ? parsedSeekTime : 0.5;
|
|
3539
|
+
const vpWidth = parseInt(url.searchParams.get("w") || "0") || 0;
|
|
3540
|
+
const vpHeight = parseInt(url.searchParams.get("h") || "0") || 0;
|
|
3541
|
+
const selector = url.searchParams.get("selector") || void 0;
|
|
3542
|
+
const format = url.searchParams.get("format") === "png" ? "png" : "jpeg";
|
|
3543
|
+
const contentType = format === "png" ? "image/png" : "image/jpeg";
|
|
3544
|
+
const rawSelectorIndex = Number.parseInt(url.searchParams.get("selectorIndex") || "0", 10);
|
|
3545
|
+
const selectorIndex = Number.isFinite(rawSelectorIndex) && rawSelectorIndex > 0 ? rawSelectorIndex : void 0;
|
|
3546
|
+
const urlVersion = url.searchParams.get("v") || "";
|
|
3547
|
+
let compW = vpWidth || 1920;
|
|
3548
|
+
let compH = vpHeight || 1080;
|
|
3549
|
+
let sourceMtime = 0;
|
|
3550
|
+
if (!vpWidth) {
|
|
3551
|
+
const htmlFile = join11(project.dir, compPath);
|
|
3552
|
+
if (existsSync7(htmlFile)) {
|
|
3553
|
+
sourceMtime = Math.round(statSync4(htmlFile).mtimeMs);
|
|
3554
|
+
const html = readFileSync10(htmlFile, "utf-8");
|
|
3555
|
+
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
|
3556
|
+
const hMatch = html.match(/data-height=["'](\d+)["']/);
|
|
3557
|
+
if (wMatch?.[1]) compW = parseInt(wMatch[1]);
|
|
3558
|
+
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
const manualEditsFile = join11(project.dir, STUDIO_MANUAL_EDITS_PATH);
|
|
3562
|
+
let manualEditsKey = "";
|
|
3563
|
+
if (existsSync7(manualEditsFile)) {
|
|
3564
|
+
const manualEditsContent = readFileSync10(manualEditsFile, "utf-8");
|
|
3565
|
+
manualEditsKey = `_${createHash2("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
|
|
3566
|
+
sourceMtime = Math.max(sourceMtime, Math.round(statSync4(manualEditsFile).mtimeMs));
|
|
3567
|
+
}
|
|
3568
|
+
const motionFile = join11(project.dir, STUDIO_MOTION_PATH);
|
|
3569
|
+
let motionKey = "";
|
|
3570
|
+
if (existsSync7(motionFile)) {
|
|
3571
|
+
const motionContent = readFileSync10(motionFile, "utf-8");
|
|
3572
|
+
motionKey = `_${createHash2("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
|
|
3573
|
+
sourceMtime = Math.max(sourceMtime, Math.round(statSync4(motionFile).mtimeMs));
|
|
3574
|
+
}
|
|
3575
|
+
const previewUrl = compPath === "index.html" ? `http://${c.req.header("host")}/api/projects/${project.id}/preview` : `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
|
|
3576
|
+
const cacheDir = join11(project.dir, ".thumbnails");
|
|
3577
|
+
const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
|
|
3578
|
+
const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
|
|
3579
|
+
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}_${format}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
|
|
3580
|
+
const cachePath = join11(cacheDir, cacheKey);
|
|
3581
|
+
if (existsSync7(cachePath)) {
|
|
3582
|
+
return new Response(new Uint8Array(readFileSync10(cachePath)), {
|
|
3583
|
+
headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=60" }
|
|
3584
|
+
});
|
|
3585
|
+
}
|
|
3586
|
+
try {
|
|
3587
|
+
const buffer = await adapter.generateThumbnail({
|
|
3588
|
+
project,
|
|
3589
|
+
compPath,
|
|
3590
|
+
seekTime,
|
|
3591
|
+
width: compW,
|
|
3592
|
+
height: compH,
|
|
3593
|
+
previewUrl,
|
|
3594
|
+
selector,
|
|
3595
|
+
format,
|
|
3596
|
+
selectorIndex
|
|
3597
|
+
});
|
|
3598
|
+
if (!buffer) {
|
|
3599
|
+
return c.json(
|
|
3600
|
+
{ error: "Thumbnail generation failed \u2014 Chrome browser may not be available" },
|
|
3601
|
+
500
|
|
3602
|
+
);
|
|
3603
|
+
}
|
|
3604
|
+
if (!existsSync7(cacheDir)) mkdirSync5(cacheDir, { recursive: true });
|
|
3605
|
+
writeFileSync6(cachePath, buffer);
|
|
3606
|
+
return new Response(new Uint8Array(buffer), {
|
|
3607
|
+
headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=60" }
|
|
3608
|
+
});
|
|
3609
|
+
} catch (err) {
|
|
3610
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3611
|
+
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
|
|
3612
|
+
}
|
|
3613
|
+
});
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
// src/routes/waveform.ts
|
|
3617
|
+
import { existsSync as existsSync8, readFileSync as readFileSync11, writeFileSync as writeFileSync7, mkdirSync as mkdirSync6 } from "fs";
|
|
3618
|
+
import { join as join12 } from "path";
|
|
3619
|
+
function registerWaveformRoutes(api, adapter) {
|
|
3620
|
+
api.get("/projects/:id/waveform/*", async (c) => {
|
|
3621
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3622
|
+
if (!project) return c.json({ error: "not found" }, 404);
|
|
3623
|
+
const assetPath = decodeURIComponent(
|
|
3624
|
+
c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? ""
|
|
3625
|
+
);
|
|
3626
|
+
const audioPath = join12(project.dir, assetPath);
|
|
3627
|
+
if (!existsSync8(audioPath)) return c.json({ error: "file not found" }, 404);
|
|
3628
|
+
const cacheDir = join12(project.dir, ".waveform-cache");
|
|
3629
|
+
const cachePath = join12(cacheDir, buildWaveformCacheKey(assetPath));
|
|
3630
|
+
if (existsSync8(cachePath)) {
|
|
3631
|
+
try {
|
|
3632
|
+
const peaks2 = JSON.parse(readFileSync11(cachePath, "utf-8"));
|
|
3633
|
+
return c.json({ peaks: peaks2 });
|
|
3634
|
+
} catch {
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
let peaks;
|
|
3638
|
+
try {
|
|
3639
|
+
peaks = await decodeAudioPeaks(audioPath);
|
|
3640
|
+
} catch {
|
|
3641
|
+
return c.json({ error: "failed to decode audio" }, 500);
|
|
3642
|
+
}
|
|
3643
|
+
try {
|
|
3644
|
+
mkdirSync6(cacheDir, { recursive: true });
|
|
3645
|
+
writeFileSync7(cachePath, JSON.stringify(peaks));
|
|
3646
|
+
} catch {
|
|
3647
|
+
}
|
|
3648
|
+
return c.json({ peaks });
|
|
3649
|
+
});
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
// src/routes/fonts.ts
|
|
3653
|
+
import { closeSync, constants, fstatSync, openSync, readSync } from "fs";
|
|
3654
|
+
import {
|
|
3655
|
+
collectFontFileEntries,
|
|
3656
|
+
fontDirectories,
|
|
3657
|
+
getSystemProfilerFamilies,
|
|
3658
|
+
locateSystemFont,
|
|
3659
|
+
SYSTEM_FONT_SIZE_LIMIT
|
|
3660
|
+
} from "@hyperframes/core/fonts/system-locator";
|
|
3661
|
+
var MAX_FONT_RESULTS = 2e3;
|
|
3662
|
+
var GOOGLE_FONTS_METADATA_URL = "https://fonts.google.com/metadata/fonts";
|
|
3663
|
+
var GOOGLE_FONTS_FETCH_TIMEOUT_MS = 3e3;
|
|
3664
|
+
var cachedFonts = null;
|
|
3665
|
+
var cachedGoogleFonts = null;
|
|
3666
|
+
var GOOGLE_FONT_FALLBACKS = [
|
|
3667
|
+
"Inter",
|
|
3668
|
+
"Roboto",
|
|
3669
|
+
"Open Sans",
|
|
3670
|
+
"Montserrat",
|
|
3671
|
+
"Poppins",
|
|
3672
|
+
"Lato",
|
|
3673
|
+
"Oswald",
|
|
3674
|
+
"Raleway",
|
|
3675
|
+
"Nunito",
|
|
3676
|
+
"Playfair Display",
|
|
3677
|
+
"Merriweather",
|
|
3678
|
+
"Source Sans 3",
|
|
3679
|
+
"Source Serif 4",
|
|
3680
|
+
"Source Code Pro",
|
|
3681
|
+
"DM Sans",
|
|
3682
|
+
"Space Grotesk",
|
|
3683
|
+
"Space Mono",
|
|
3684
|
+
"Bebas Neue",
|
|
3685
|
+
"Outfit",
|
|
3686
|
+
"JetBrains Mono"
|
|
3687
|
+
];
|
|
3688
|
+
function isRecord(value) {
|
|
3689
|
+
return typeof value === "object" && value !== null;
|
|
3690
|
+
}
|
|
3691
|
+
function collectFontsFromDir(dir) {
|
|
3692
|
+
return collectFontFileEntries(dir).map((e) => e.family);
|
|
3693
|
+
}
|
|
3694
|
+
function listInstalledFontFamilies() {
|
|
3695
|
+
if (cachedFonts) return cachedFonts;
|
|
3696
|
+
const families = /* @__PURE__ */ new Set();
|
|
3697
|
+
for (const family of getSystemProfilerFamilies()) {
|
|
3698
|
+
families.add(family);
|
|
3699
|
+
if (families.size >= MAX_FONT_RESULTS) break;
|
|
3700
|
+
}
|
|
3701
|
+
for (const dir of fontDirectories()) {
|
|
3702
|
+
for (const family of collectFontsFromDir(dir)) {
|
|
3703
|
+
families.add(family);
|
|
3704
|
+
if (families.size >= MAX_FONT_RESULTS) break;
|
|
3705
|
+
}
|
|
3706
|
+
if (families.size >= MAX_FONT_RESULTS) break;
|
|
3707
|
+
}
|
|
3708
|
+
cachedFonts = Array.from(families).sort((a, b) => a.localeCompare(b));
|
|
3709
|
+
return cachedFonts;
|
|
3710
|
+
}
|
|
3711
|
+
function parseGoogleFontMetadata(value) {
|
|
3712
|
+
if (!isRecord(value) || !Array.isArray(value.familyMetadataList)) return [];
|
|
3713
|
+
const families = [];
|
|
3714
|
+
for (const entry of value.familyMetadataList) {
|
|
3715
|
+
if (!isRecord(entry) || typeof entry.family !== "string") continue;
|
|
3716
|
+
families.push(entry.family);
|
|
3717
|
+
}
|
|
3718
|
+
return families;
|
|
3719
|
+
}
|
|
3720
|
+
function stripGoogleJsonGuard(raw) {
|
|
3721
|
+
const prefix = ")]}'";
|
|
3722
|
+
if (!raw.startsWith(prefix)) return raw;
|
|
3723
|
+
let index = prefix.length;
|
|
3724
|
+
while (index < raw.length && (raw[index] === " " || raw[index] === "\n" || raw[index] === "\r" || raw[index] === " " || raw[index] === "\f")) {
|
|
3725
|
+
index += 1;
|
|
3726
|
+
}
|
|
3727
|
+
return raw.slice(index);
|
|
3728
|
+
}
|
|
3729
|
+
async function listGoogleFontFamilies() {
|
|
3730
|
+
if (cachedGoogleFonts) return cachedGoogleFonts;
|
|
3731
|
+
const controller = new AbortController();
|
|
3732
|
+
const timer = setTimeout(() => controller.abort(), GOOGLE_FONTS_FETCH_TIMEOUT_MS);
|
|
3733
|
+
try {
|
|
3734
|
+
const response = await fetch(GOOGLE_FONTS_METADATA_URL, { signal: controller.signal });
|
|
3735
|
+
if (!response.ok) {
|
|
3736
|
+
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
|
|
3737
|
+
return cachedGoogleFonts;
|
|
3738
|
+
}
|
|
3739
|
+
const raw = await response.text();
|
|
3740
|
+
const jsonText = stripGoogleJsonGuard(raw);
|
|
3741
|
+
const families = parseGoogleFontMetadata(JSON.parse(jsonText));
|
|
3742
|
+
cachedGoogleFonts = families.length > 0 ? families : GOOGLE_FONT_FALLBACKS;
|
|
3743
|
+
} catch {
|
|
3744
|
+
cachedGoogleFonts = GOOGLE_FONT_FALLBACKS;
|
|
3745
|
+
} finally {
|
|
3746
|
+
clearTimeout(timer);
|
|
3747
|
+
}
|
|
3748
|
+
return cachedGoogleFonts;
|
|
3749
|
+
}
|
|
3750
|
+
function registerFontRoutes(api) {
|
|
3751
|
+
api.get("/fonts", (c) => c.json({ fonts: listInstalledFontFamilies() }));
|
|
3752
|
+
api.get("/fonts/google", async (c) => c.json({ fonts: await listGoogleFontFamilies() }));
|
|
3753
|
+
api.get("/fonts/file", (c) => {
|
|
3754
|
+
const family = c.req.query("family");
|
|
3755
|
+
if (!family) return c.json({ error: "family parameter required" }, 400);
|
|
3756
|
+
const located = locateSystemFont(family);
|
|
3757
|
+
if (!located) return c.json({ error: "font not found" }, 404);
|
|
3758
|
+
let fd;
|
|
3759
|
+
try {
|
|
3760
|
+
fd = openSync(located.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
3761
|
+
} catch {
|
|
3762
|
+
return c.json({ error: "font file not accessible" }, 404);
|
|
3763
|
+
}
|
|
3764
|
+
try {
|
|
3765
|
+
const stat = fstatSync(fd);
|
|
3766
|
+
if (stat.size > SYSTEM_FONT_SIZE_LIMIT) {
|
|
3767
|
+
return c.json({ error: "font file too large" }, 413);
|
|
3768
|
+
}
|
|
3769
|
+
const buffer = Buffer.alloc(stat.size);
|
|
3770
|
+
readSync(fd, buffer, 0, stat.size, 0);
|
|
3771
|
+
const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
|
|
3772
|
+
const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
|
|
3773
|
+
return new Response(buffer, {
|
|
3774
|
+
headers: {
|
|
3775
|
+
"Content-Type": mimeType,
|
|
3776
|
+
"Content-Disposition": `attachment; filename="${fileName}"`
|
|
3777
|
+
}
|
|
3778
|
+
});
|
|
3779
|
+
} catch {
|
|
3780
|
+
return c.json({ error: "failed to read font file" }, 500);
|
|
3781
|
+
} finally {
|
|
3782
|
+
closeSync(fd);
|
|
3783
|
+
}
|
|
3784
|
+
});
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
// src/routes/registry.ts
|
|
3788
|
+
function registerRegistryRoutes(api, adapter) {
|
|
3789
|
+
api.get("/registry/blocks", async (c) => {
|
|
3790
|
+
if (!adapter.listRegistryCatalog) {
|
|
3791
|
+
return c.json({ error: "Registry not available" }, 501);
|
|
3792
|
+
}
|
|
3793
|
+
const items = await adapter.listRegistryCatalog();
|
|
3794
|
+
return c.json(items);
|
|
3795
|
+
});
|
|
3796
|
+
api.post("/projects/:id/registry/install", async (c) => {
|
|
3797
|
+
if (!adapter.installRegistryBlock) {
|
|
3798
|
+
return c.json({ error: "Registry install not available" }, 501);
|
|
3799
|
+
}
|
|
3800
|
+
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3801
|
+
if (!project) return c.json({ error: "Project not found" }, 404);
|
|
3802
|
+
const body = await c.req.json().catch(() => null);
|
|
3803
|
+
if (!body?.blockName) {
|
|
3804
|
+
return c.json({ error: "blockName is required" }, 400);
|
|
3805
|
+
}
|
|
3806
|
+
try {
|
|
3807
|
+
const result = await adapter.installRegistryBlock({ project, blockName: body.blockName });
|
|
3808
|
+
return c.json(result);
|
|
3809
|
+
} catch (err) {
|
|
3810
|
+
const message = err instanceof Error ? err.message : "Install failed";
|
|
3811
|
+
return c.json({ error: message }, 500);
|
|
3812
|
+
}
|
|
3813
|
+
});
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
// src/createStudioApi.ts
|
|
3817
|
+
function createStudioApi(adapter) {
|
|
3818
|
+
const api = new Hono();
|
|
3819
|
+
registerProjectRoutes(api, adapter);
|
|
3820
|
+
registerStoryboardRoutes(api, adapter);
|
|
3821
|
+
registerFileRoutes(api, adapter);
|
|
3822
|
+
registerPreviewRoutes(api, adapter);
|
|
3823
|
+
registerLintRoutes(api, adapter);
|
|
3824
|
+
registerRenderRoutes(api, adapter);
|
|
3825
|
+
registerThumbnailRoutes(api, adapter);
|
|
3826
|
+
registerWaveformRoutes(api, adapter);
|
|
3827
|
+
registerFontRoutes(api);
|
|
3828
|
+
registerRegistryRoutes(api, adapter);
|
|
3829
|
+
return api;
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
// src/helpers/screenshotClip.ts
|
|
3833
|
+
function getElementScreenshotClip(selector, selectorIndex) {
|
|
3834
|
+
const matches = Array.from(document.querySelectorAll(selector)).filter(
|
|
3835
|
+
(el2) => el2 instanceof HTMLElement
|
|
3836
|
+
);
|
|
3837
|
+
const safeIndex = Math.max(0, Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)));
|
|
3838
|
+
const el = matches[safeIndex] ?? null;
|
|
3839
|
+
if (!(el instanceof HTMLElement)) return void 0;
|
|
3840
|
+
const rect = el.getBoundingClientRect();
|
|
3841
|
+
if (rect.width < 4 || rect.height < 4) return void 0;
|
|
3842
|
+
const pad = 8;
|
|
3843
|
+
const x = Math.max(0, rect.left - pad);
|
|
3844
|
+
const y = Math.max(0, rect.top - pad);
|
|
3845
|
+
const maxWidth = window.innerWidth - x;
|
|
3846
|
+
const maxHeight = window.innerHeight - y;
|
|
3847
|
+
return {
|
|
3848
|
+
x,
|
|
3849
|
+
y,
|
|
3850
|
+
width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)),
|
|
3851
|
+
height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight))
|
|
3852
|
+
};
|
|
3853
|
+
}
|
|
3854
|
+
export {
|
|
3855
|
+
MIME_TYPES,
|
|
3856
|
+
STUDIO_MANUAL_EDITS_PATH,
|
|
3857
|
+
STUDIO_MOTION_PATH,
|
|
3858
|
+
buildSubCompositionHtml,
|
|
3859
|
+
createProjectSignature,
|
|
3860
|
+
createStudioApi,
|
|
3861
|
+
createStudioManualEditsRenderBodyScript,
|
|
3862
|
+
createStudioMotionRenderBodyScript,
|
|
3863
|
+
createStudioPositionSeekReapplyScript,
|
|
3864
|
+
getElementScreenshotClip,
|
|
3865
|
+
getMimeType,
|
|
3866
|
+
isSafePath,
|
|
3867
|
+
walkDir
|
|
3868
|
+
};
|
|
3869
|
+
//# sourceMappingURL=index.js.map
|