@hyperframes/studio 0.6.0-alpha.8 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/assets/{hyperframes-player-DjsVzYFP.js → hyperframes-player-DOFETgjy.js} +1 -1
  2. package/dist/assets/index-D1JDq7Gg.css +1 -0
  3. package/dist/assets/index-DUqUmaoH.js +117 -0
  4. package/dist/favicon.svg +14 -0
  5. package/dist/index.html +3 -2
  6. package/package.json +9 -9
  7. package/src/App.tsx +428 -4299
  8. package/src/components/AskAgentModal.tsx +120 -0
  9. package/src/components/StudioHeader.tsx +133 -0
  10. package/src/components/StudioLeftSidebar.tsx +125 -0
  11. package/src/components/StudioPreviewArea.tsx +163 -0
  12. package/src/components/StudioRightPanel.tsx +198 -0
  13. package/src/components/TimelineToolbar.tsx +89 -0
  14. package/src/components/editor/DomEditOverlay.tsx +15 -1
  15. package/src/components/editor/PropertyPanel.test.ts +0 -49
  16. package/src/components/editor/PropertyPanel.tsx +132 -2763
  17. package/src/components/editor/domEditing.ts +38 -5
  18. package/src/components/editor/manualEditingAvailability.test.ts +2 -2
  19. package/src/components/editor/manualEditingAvailability.ts +1 -1
  20. package/src/components/editor/manualEdits.ts +32 -0
  21. package/src/components/editor/propertyPanelColor.tsx +371 -0
  22. package/src/components/editor/propertyPanelFill.tsx +421 -0
  23. package/src/components/editor/propertyPanelFont.tsx +455 -0
  24. package/src/components/editor/propertyPanelHelpers.ts +401 -0
  25. package/src/components/editor/propertyPanelPrimitives.tsx +357 -0
  26. package/src/components/editor/propertyPanelSections.tsx +453 -0
  27. package/src/components/editor/propertyPanelStyleSections.tsx +411 -0
  28. package/src/components/nle/NLELayout.tsx +8 -11
  29. package/src/components/nle/NLEPreview.tsx +3 -0
  30. package/src/components/renders/RenderQueue.tsx +102 -31
  31. package/src/components/renders/useRenderQueue.ts +8 -2
  32. package/src/components/sidebar/LeftSidebar.tsx +186 -186
  33. package/src/contexts/DomEditContext.tsx +137 -0
  34. package/src/contexts/FileManagerContext.tsx +110 -0
  35. package/src/contexts/PanelLayoutContext.tsx +68 -0
  36. package/src/contexts/StudioContext.tsx +135 -0
  37. package/src/hooks/useAppHotkeys.ts +326 -0
  38. package/src/hooks/useAskAgentModal.ts +162 -0
  39. package/src/hooks/useCaptionDetection.ts +132 -0
  40. package/src/hooks/useCompositionDimensions.ts +25 -0
  41. package/src/hooks/useConsoleErrorCapture.ts +60 -0
  42. package/src/hooks/useDomEditCommits.ts +437 -0
  43. package/src/hooks/useDomEditSession.ts +342 -0
  44. package/src/hooks/useDomEditTextCommits.ts +330 -0
  45. package/src/hooks/useDomSelection.ts +398 -0
  46. package/src/hooks/useFileManager.ts +431 -0
  47. package/src/hooks/useFrameCapture.ts +77 -0
  48. package/src/hooks/useLintModal.ts +35 -0
  49. package/src/hooks/useManifestPersistence.ts +492 -0
  50. package/src/hooks/usePanelLayout.ts +68 -0
  51. package/src/hooks/usePreviewInteraction.ts +153 -0
  52. package/src/hooks/useRenderClipContent.ts +124 -0
  53. package/src/hooks/useTimelineEditing.ts +472 -0
  54. package/src/player/components/Player.tsx +35 -4
  55. package/src/player/components/Timeline.test.ts +0 -8
  56. package/src/player/components/Timeline.tsx +10 -103
  57. package/src/player/components/TimelineClip.tsx +9 -244
  58. package/src/player/hooks/useTimelinePlayer.ts +140 -103
  59. package/src/utils/domEditHelpers.ts +50 -0
  60. package/src/utils/studioFontHelpers.ts +83 -0
  61. package/src/utils/studioHelpers.ts +214 -0
  62. package/src/utils/studioPreviewHelpers.ts +185 -0
  63. package/src/utils/timelineDiscovery.ts +1 -1
  64. package/dist/assets/index-14zH9lqh.css +0 -1
  65. package/dist/assets/index-ClYcrksa.js +0 -108
  66. package/src/player/components/TimelineClip.test.ts +0 -92
@@ -0,0 +1,421 @@
1
+ import { useMemo, useRef, useState } from "react";
2
+ import { Plus, RotateCcw, X } from "../../icons/SystemIcons";
3
+ import {
4
+ buildDefaultGradientModel,
5
+ insertGradientStop,
6
+ parseGradient,
7
+ serializeGradient,
8
+ type GradientModel,
9
+ } from "./gradientValue";
10
+ import { IMAGE_EXT } from "../../utils/mediaTypes";
11
+ import { FIELD, LABEL, RESPONSIVE_GRID } from "./propertyPanelHelpers";
12
+ import {
13
+ DetailField,
14
+ SelectField,
15
+ SegmentedControl,
16
+ SliderControl,
17
+ } from "./propertyPanelPrimitives";
18
+ import { ColorField } from "./propertyPanelColor";
19
+
20
+ /* ------------------------------------------------------------------ */
21
+ /* Asset path helpers */
22
+ /* ------------------------------------------------------------------ */
23
+
24
+ function normalizeProjectPath(value: string): string {
25
+ const trimmed = value.trim();
26
+ const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
27
+ return decodeURIComponent(maybeUrl)
28
+ .replace(/\\/g, "/")
29
+ .replace(/^\.?\//, "");
30
+ }
31
+
32
+ function toRelativeProjectAssetPath(sourceFile: string, assetPath: string): string {
33
+ const fromParts = normalizeProjectPath(sourceFile).split("/").filter(Boolean);
34
+ const targetParts = normalizeProjectPath(assetPath).split("/").filter(Boolean);
35
+ fromParts.pop();
36
+ while (fromParts.length > 0 && targetParts.length > 0 && fromParts[0] === targetParts[0]) {
37
+ fromParts.shift();
38
+ targetParts.shift();
39
+ }
40
+ return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
41
+ }
42
+
43
+ function toProjectRootAssetPath(assetPath: string): string {
44
+ return normalizeProjectPath(assetPath);
45
+ }
46
+
47
+ function resolveSelectedAsset(
48
+ imageUrl: string,
49
+ sourceFile: string,
50
+ assets: string[],
51
+ ): string | null {
52
+ const normalizedUrl = normalizeProjectPath(imageUrl);
53
+ if (!normalizedUrl) return null;
54
+ for (const asset of assets) {
55
+ const normalizedAsset = normalizeProjectPath(asset);
56
+ const relativeAsset = toRelativeProjectAssetPath(sourceFile, asset);
57
+ if (
58
+ normalizedUrl === normalizedAsset ||
59
+ normalizedUrl === relativeAsset ||
60
+ normalizedUrl.endsWith(`/${normalizedAsset}`) ||
61
+ normalizedUrl.endsWith(`/${relativeAsset}`)
62
+ ) {
63
+ return asset;
64
+ }
65
+ }
66
+ return null;
67
+ }
68
+
69
+ /* ------------------------------------------------------------------ */
70
+ /* ImageFillField */
71
+ /* ------------------------------------------------------------------ */
72
+
73
+ export function ImageFillField({
74
+ projectId,
75
+ sourceFile,
76
+ value,
77
+ assets,
78
+ disabled,
79
+ onCommit,
80
+ onImportAssets,
81
+ }: {
82
+ projectId: string;
83
+ sourceFile: string;
84
+ value: string;
85
+ assets: string[];
86
+ disabled?: boolean;
87
+ onCommit: (nextValue: string) => void;
88
+ onImportAssets?: (files: FileList) => Promise<string[]>;
89
+ }) {
90
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
91
+ const [uploading, setUploading] = useState(false);
92
+ const imageAssets = useMemo(() => assets.filter((a) => IMAGE_EXT.test(a)), [assets]);
93
+ const selectedAsset = useMemo(
94
+ () => resolveSelectedAsset(value, sourceFile, imageAssets),
95
+ [imageAssets, sourceFile, value],
96
+ );
97
+ const externalUrlValue = selectedAsset ? "" : value;
98
+
99
+ const handleUpload = async (files: FileList | null) => {
100
+ if (!files?.length || !onImportAssets) return;
101
+ setUploading(true);
102
+ try {
103
+ const uploaded = await onImportAssets(files);
104
+ const nextImage = uploaded.find((a) => IMAGE_EXT.test(a));
105
+ if (nextImage) onCommit(`url("${toProjectRootAssetPath(nextImage)}")`);
106
+ } finally {
107
+ setUploading(false);
108
+ }
109
+ };
110
+
111
+ return (
112
+ <div className="space-y-4">
113
+ <div className="grid min-w-0 gap-1.5">
114
+ <div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
115
+ <span className={LABEL}>Project asset</span>
116
+ <button
117
+ type="button"
118
+ disabled={disabled || uploading}
119
+ onClick={() => fileInputRef.current?.click()}
120
+ className={`inline-flex h-7 max-w-full items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors ${
121
+ disabled || uploading
122
+ ? "cursor-not-allowed text-neutral-600"
123
+ : "cursor-pointer hover:border-neutral-600 hover:text-white"
124
+ }`}
125
+ >
126
+ <Plus size={12} className="flex-shrink-0" />
127
+ <span className="truncate">{uploading ? "Uploading…" : "Upload image"}</span>
128
+ </button>
129
+ <input
130
+ ref={fileInputRef}
131
+ type="file"
132
+ accept="image/*"
133
+ aria-label="Upload image asset"
134
+ disabled={disabled || uploading}
135
+ className="hidden"
136
+ onChange={async (event) => {
137
+ await handleUpload(event.target.files);
138
+ event.target.value = "";
139
+ }}
140
+ />
141
+ </div>
142
+ {imageAssets.length > 0 ? (
143
+ <div className="space-y-3">
144
+ {selectedAsset && (
145
+ <div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-900/80">
146
+ <img
147
+ src={`/api/projects/${projectId}/preview/${selectedAsset}`}
148
+ alt={selectedAsset.split("/").pop() ?? selectedAsset}
149
+ className="h-28 w-full object-contain bg-neutral-950/80"
150
+ />
151
+ </div>
152
+ )}
153
+ <div className={FIELD}>
154
+ <select
155
+ value={selectedAsset ?? ""}
156
+ disabled={disabled}
157
+ onChange={(e) => {
158
+ const next = e.target.value;
159
+ if (!next) {
160
+ onCommit("none");
161
+ return;
162
+ }
163
+ onCommit(`url("${toProjectRootAssetPath(next)}")`);
164
+ }}
165
+ className="min-w-0 w-full appearance-none bg-transparent text-[11px] font-medium text-neutral-100 outline-none disabled:cursor-not-allowed disabled:text-neutral-600"
166
+ >
167
+ <option value="">None</option>
168
+ {imageAssets.map((asset) => (
169
+ <option key={asset} value={asset}>
170
+ {asset}
171
+ </option>
172
+ ))}
173
+ </select>
174
+ </div>
175
+ </div>
176
+ ) : (
177
+ <div className="rounded-xl border border-dashed border-neutral-800 bg-neutral-900/50 px-3 py-3 text-[11px] leading-5 text-neutral-500">
178
+ No image assets yet. Upload one here and Studio will also add it to the Assets tab.
179
+ </div>
180
+ )}
181
+ </div>
182
+
183
+ <DetailField
184
+ label="External URL"
185
+ value={externalUrlValue}
186
+ disabled={disabled}
187
+ onCommit={(next) => onCommit(next.trim() ? `url("${next.trim()}")` : "none")}
188
+ />
189
+ </div>
190
+ );
191
+ }
192
+
193
+ /* ------------------------------------------------------------------ */
194
+ /* GradientField */
195
+ /* ------------------------------------------------------------------ */
196
+
197
+ export function GradientField({
198
+ value,
199
+ fallbackColor,
200
+ disabled,
201
+ onCommit,
202
+ }: {
203
+ value: string;
204
+ fallbackColor: string | undefined;
205
+ disabled?: boolean;
206
+ onCommit: (nextValue: string) => void;
207
+ }) {
208
+ const previewRef = useRef<HTMLDivElement | null>(null);
209
+ const parsed = parseGradient(value) ?? buildDefaultGradientModel(fallbackColor);
210
+
211
+ const commit = (next: GradientModel) => onCommit(serializeGradient(next));
212
+ const patch = (partial: Partial<GradientModel>) => commit({ ...parsed, ...partial });
213
+
214
+ const updateStop = (index: number, partial: Partial<GradientModel["stops"][number]>) => {
215
+ const stops = parsed.stops.map((stop, i) => (i === index ? { ...stop, ...partial } : stop));
216
+ commit({ ...parsed, stops });
217
+ };
218
+
219
+ const addStop = (position?: number) => {
220
+ const nextGradient =
221
+ position != null
222
+ ? insertGradientStop(parsed, position)
223
+ : insertGradientStop(
224
+ parsed,
225
+ parsed.stops.at(-1)?.position != null
226
+ ? Math.min(100, (parsed.stops.at(-1)?.position ?? 90) + 10)
227
+ : 100,
228
+ );
229
+ commit(nextGradient);
230
+ };
231
+
232
+ const removeStop = (index: number) => {
233
+ if (parsed.stops.length <= 2) return;
234
+ commit({ ...parsed, stops: parsed.stops.filter((_, i) => i !== index) });
235
+ };
236
+
237
+ const previewStyle = { backgroundImage: serializeGradient(parsed) };
238
+
239
+ return (
240
+ <div className="space-y-4">
241
+ <div className={`${FIELD} space-y-3 p-3`}>
242
+ <div
243
+ ref={previewRef}
244
+ className="relative h-11 overflow-hidden rounded-lg border border-neutral-700"
245
+ style={previewStyle}
246
+ onClick={(event) => {
247
+ if (disabled) return;
248
+ const rect = previewRef.current?.getBoundingClientRect();
249
+ if (!rect || rect.width <= 0) return;
250
+ addStop(((event.clientX - rect.left) / rect.width) * 100);
251
+ }}
252
+ >
253
+ {parsed.stops.map((stop, index) => (
254
+ <div
255
+ key={`stop-preview-${index}`}
256
+ className="absolute top-1/2 h-4 w-4 -translate-y-1/2 rounded-full border-2 border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,0.35)]"
257
+ style={{
258
+ left: `calc(${stop.position}% - 8px)`,
259
+ backgroundColor: stop.color,
260
+ }}
261
+ />
262
+ ))}
263
+ </div>
264
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
265
+ <SegmentedControl
266
+ disabled={disabled}
267
+ value={parsed.kind}
268
+ onChange={(next) => patch({ kind: next as GradientModel["kind"] })}
269
+ options={[
270
+ { label: "Linear", value: "linear" },
271
+ { label: "Radial", value: "radial" },
272
+ { label: "Conic", value: "conic" },
273
+ ]}
274
+ />
275
+ <label className="flex items-center gap-2 text-[11px] font-medium text-neutral-400">
276
+ <input
277
+ type="checkbox"
278
+ checked={parsed.repeating}
279
+ disabled={disabled}
280
+ onChange={(e) => patch({ repeating: e.target.checked })}
281
+ className="h-4 w-4 rounded border-neutral-700 bg-neutral-950 text-[#3ce6ac] focus:ring-[#3ce6ac]"
282
+ />
283
+ Repeat
284
+ </label>
285
+ <button
286
+ type="button"
287
+ disabled={disabled}
288
+ onClick={() =>
289
+ commit({
290
+ ...parsed,
291
+ stops: [...parsed.stops].reverse().map((stop) => ({
292
+ ...stop,
293
+ position: 100 - stop.position,
294
+ })),
295
+ })
296
+ }
297
+ className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
298
+ >
299
+ <RotateCcw size={12} />
300
+ Reverse
301
+ </button>
302
+ </div>
303
+ </div>
304
+
305
+ {(parsed.kind === "linear" || parsed.kind === "conic") && (
306
+ <div className="grid gap-1.5">
307
+ <span className={LABEL}>{parsed.kind === "linear" ? "Angle" : "Start angle"}</span>
308
+ <SliderControl
309
+ value={parsed.angle}
310
+ min={0}
311
+ max={360}
312
+ step={1}
313
+ disabled={disabled}
314
+ displayValue={`${Math.round(parsed.angle)}°`}
315
+ formatDisplayValue={(next) => `${Math.round(next)}°`}
316
+ onCommit={(next) => patch({ angle: next })}
317
+ />
318
+ </div>
319
+ )}
320
+
321
+ {parsed.kind === "radial" && (
322
+ <div className={RESPONSIVE_GRID}>
323
+ <SelectField
324
+ label="Shape"
325
+ value={parsed.shape}
326
+ disabled={disabled}
327
+ onChange={(next) => patch({ shape: next as GradientModel["shape"] })}
328
+ options={["ellipse", "circle"]}
329
+ />
330
+ <SelectField
331
+ label="Size"
332
+ value={parsed.radialSize}
333
+ disabled={disabled}
334
+ onChange={(next) => patch({ radialSize: next as GradientModel["radialSize"] })}
335
+ options={["closest-side", "closest-corner", "farthest-side", "farthest-corner"]}
336
+ />
337
+ </div>
338
+ )}
339
+
340
+ {(parsed.kind === "radial" || parsed.kind === "conic") && (
341
+ <div className={RESPONSIVE_GRID}>
342
+ <div className="grid min-w-0 gap-1.5">
343
+ <span className={LABEL}>Center X</span>
344
+ <SliderControl
345
+ value={parsed.centerX}
346
+ min={0}
347
+ max={100}
348
+ step={1}
349
+ disabled={disabled}
350
+ displayValue={`${Math.round(parsed.centerX)}%`}
351
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
352
+ onCommit={(next) => patch({ centerX: next })}
353
+ />
354
+ </div>
355
+ <div className="grid min-w-0 gap-1.5">
356
+ <span className={LABEL}>Center Y</span>
357
+ <SliderControl
358
+ value={parsed.centerY}
359
+ min={0}
360
+ max={100}
361
+ step={1}
362
+ disabled={disabled}
363
+ displayValue={`${Math.round(parsed.centerY)}%`}
364
+ formatDisplayValue={(next) => `${Math.round(next)}%`}
365
+ onCommit={(next) => patch({ centerY: next })}
366
+ />
367
+ </div>
368
+ </div>
369
+ )}
370
+
371
+ <div className="space-y-3">
372
+ <div className="flex items-center justify-between">
373
+ <span className={LABEL}>Stops</span>
374
+ <button
375
+ type="button"
376
+ disabled={disabled || parsed.stops.length >= 6}
377
+ onClick={() => addStop()}
378
+ className="inline-flex h-7 items-center gap-1.5 rounded-lg border border-neutral-700 bg-neutral-950 px-2.5 text-[11px] font-medium text-neutral-300 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-600"
379
+ >
380
+ <Plus size={12} />
381
+ Add stop
382
+ </button>
383
+ </div>
384
+ <div className="space-y-3">
385
+ {parsed.stops.map((stop, index) => (
386
+ <div
387
+ key={`stop-editor-${index}`}
388
+ className="grid min-w-0 grid-cols-[minmax(0,1fr)_68px_28px] gap-2"
389
+ >
390
+ <ColorField
391
+ label={`Stop ${index + 1}`}
392
+ value={stop.color}
393
+ disabled={disabled}
394
+ onCommit={(next) => updateStop(index, { color: next })}
395
+ />
396
+ <DetailField
397
+ label="Pos"
398
+ value={`${Math.round(stop.position)}%`}
399
+ disabled={disabled}
400
+ onCommit={(next) =>
401
+ updateStop(index, {
402
+ position: Number.parseFloat(next.replace("%", "")) || 0,
403
+ })
404
+ }
405
+ />
406
+ <button
407
+ type="button"
408
+ disabled={disabled || parsed.stops.length <= 2}
409
+ onClick={() => removeStop(index)}
410
+ className="mt-[22px] flex h-10 items-center justify-center rounded-lg border border-neutral-700 bg-neutral-950 text-neutral-400 transition-colors hover:border-neutral-600 hover:text-white disabled:cursor-not-allowed disabled:text-neutral-700"
411
+ aria-label={`Remove stop ${index + 1}`}
412
+ >
413
+ <X size={12} />
414
+ </button>
415
+ </div>
416
+ ))}
417
+ </div>
418
+ </div>
419
+ </div>
420
+ );
421
+ }