@hyperframes/studio 0.4.12 → 0.4.13-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/hyperframes-player-BOs_kypk.js +198 -0
- package/dist/assets/index-BKkR67xb.css +1 -0
- package/dist/assets/index-rN5doSq1.js +93 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +289 -11
- package/src/components/nle/NLELayout.tsx +24 -7
- package/src/components/nle/NLEPreview.test.ts +32 -0
- package/src/components/nle/NLEPreview.tsx +12 -1
- package/src/player/components/CompositionThumbnail.tsx +94 -17
- package/src/player/components/EditModal.tsx +48 -29
- package/src/player/components/Player.tsx +5 -2
- package/src/player/components/PlayerControls.test.ts +20 -0
- package/src/player/components/PlayerControls.tsx +12 -1
- package/src/player/components/Timeline.test.ts +44 -1
- package/src/player/components/Timeline.tsx +686 -169
- package/src/player/components/TimelineClip.tsx +112 -16
- package/src/player/components/timelineEditing.test.ts +310 -0
- package/src/player/components/timelineEditing.ts +213 -0
- package/src/player/components/timelineTheme.test.ts +56 -0
- package/src/player/components/timelineTheme.ts +141 -0
- package/src/player/components/timelineZoom.test.ts +62 -0
- package/src/player/components/timelineZoom.ts +38 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +96 -0
- package/src/player/hooks/useTimelinePlayer.ts +313 -59
- package/src/player/store/playerStore.test.ts +30 -12
- package/src/player/store/playerStore.ts +23 -9
- package/src/types/hyperframes-player.d.ts +1 -0
- package/src/utils/sourcePatcher.test.ts +84 -0
- package/src/utils/sourcePatcher.ts +143 -0
- package/dist/assets/hyperframes-player-5iD9BZnx.js +0 -198
- package/dist/assets/index-CVDXfFQ6.js +0 -93
- package/dist/assets/index-jmDaI2F7.css +0 -1
package/dist/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
6
|
<title>HyperFrames Studio</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-rN5doSq1.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BKkR67xb.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
11
11
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/studio",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.13-alpha.2",
|
|
4
4
|
"description": "",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
"@phosphor-icons/react": "^2.1.10",
|
|
33
33
|
"codemirror": "^6.0.1",
|
|
34
34
|
"motion": "^12.38.0",
|
|
35
|
-
"@hyperframes/
|
|
36
|
-
"@hyperframes/
|
|
35
|
+
"@hyperframes/player": "0.4.13-alpha.2",
|
|
36
|
+
"@hyperframes/core": "0.4.13-alpha.2"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@types/react": "^19.0.0",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"vite": "^6.4.2",
|
|
48
48
|
"vitest": "^3.2.4",
|
|
49
49
|
"zustand": "^5.0.0",
|
|
50
|
-
"@hyperframes/producer": "0.4.
|
|
50
|
+
"@hyperframes/producer": "0.4.13-alpha.2"
|
|
51
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"react": "^18.0.0 || ^19.0.0",
|
package/src/App.tsx
CHANGED
|
@@ -5,7 +5,7 @@ import { SourceEditor } from "./components/editor/SourceEditor";
|
|
|
5
5
|
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
|
6
6
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
|
7
7
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
|
8
|
-
import { CompositionThumbnail, VideoThumbnail } from "./player";
|
|
8
|
+
import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
|
|
9
9
|
import { AudioWaveform } from "./player/components/AudioWaveform";
|
|
10
10
|
import type { TimelineElement } from "./player";
|
|
11
11
|
import { LintModal } from "./components/LintModal";
|
|
@@ -18,6 +18,15 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|
|
18
18
|
import { useCaptionStore } from "./captions/store";
|
|
19
19
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
|
20
20
|
import { parseCaptionComposition } from "./captions/parser";
|
|
21
|
+
import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
|
|
22
|
+
import {
|
|
23
|
+
buildTrackZIndexMap,
|
|
24
|
+
formatTimelineAttributeNumber,
|
|
25
|
+
} from "./player/components/timelineEditing";
|
|
26
|
+
import {
|
|
27
|
+
getNextTimelineZoomPercent,
|
|
28
|
+
getTimelineZoomPercent,
|
|
29
|
+
} from "./player/components/timelineZoom";
|
|
21
30
|
|
|
22
31
|
interface EditingFile {
|
|
23
32
|
path: string;
|
|
@@ -186,7 +195,7 @@ export function StudioApp() {
|
|
|
186
195
|
}, [captionHasSelection, captionEditMode]);
|
|
187
196
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
|
188
197
|
const [uploadToast, setUploadToast] = useState<string | null>(null);
|
|
189
|
-
const [timelineVisible, setTimelineVisible] = useState(
|
|
198
|
+
const [timelineVisible, setTimelineVisible] = useState(true);
|
|
190
199
|
const dragCounterRef = useRef(0);
|
|
191
200
|
const panelDragRef = useRef<{
|
|
192
201
|
side: "left" | "right";
|
|
@@ -198,6 +207,23 @@ export function StudioApp() {
|
|
|
198
207
|
const activePreviewUrl = activeCompPath
|
|
199
208
|
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
|
200
209
|
: null;
|
|
210
|
+
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
|
211
|
+
const manualZoomPercent = usePlayerStore((s) => s.manualZoomPercent);
|
|
212
|
+
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
|
213
|
+
const setManualZoomPercent = usePlayerStore((s) => s.setManualZoomPercent);
|
|
214
|
+
const timelineElements = usePlayerStore((s) => s.elements);
|
|
215
|
+
const timelineDuration = usePlayerStore((s) => s.duration);
|
|
216
|
+
const effectiveTimelineDuration = useMemo(() => {
|
|
217
|
+
const maxEnd =
|
|
218
|
+
timelineElements.length > 0
|
|
219
|
+
? Math.max(...timelineElements.map((element) => element.start + element.duration))
|
|
220
|
+
: 0;
|
|
221
|
+
return Math.max(timelineDuration, maxEnd);
|
|
222
|
+
}, [timelineDuration, timelineElements]);
|
|
223
|
+
const displayedTimelineZoomPercent = useMemo(
|
|
224
|
+
() => getTimelineZoomPercent(zoomMode, manualZoomPercent),
|
|
225
|
+
[zoomMode, manualZoomPercent],
|
|
226
|
+
);
|
|
201
227
|
|
|
202
228
|
const renderClipContent = useCallback(
|
|
203
229
|
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
|
|
@@ -222,6 +248,8 @@ export function StudioApp() {
|
|
|
222
248
|
previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
|
|
223
249
|
label={el.id || el.tag}
|
|
224
250
|
labelColor={style.label}
|
|
251
|
+
accentColor={style.clip}
|
|
252
|
+
selector={el.selector}
|
|
225
253
|
seekTime={0}
|
|
226
254
|
duration={el.duration}
|
|
227
255
|
/>
|
|
@@ -236,12 +264,20 @@ export function StudioApp() {
|
|
|
236
264
|
previewUrl={activePreviewUrl}
|
|
237
265
|
label={el.id || el.tag}
|
|
238
266
|
labelColor={style.label}
|
|
267
|
+
accentColor={style.clip}
|
|
268
|
+
selector={el.selector}
|
|
239
269
|
seekTime={el.start}
|
|
240
270
|
duration={el.duration}
|
|
241
271
|
/>
|
|
242
272
|
);
|
|
243
273
|
}
|
|
244
274
|
|
|
275
|
+
const htmlPreviewEligible =
|
|
276
|
+
el.duration > 0 &&
|
|
277
|
+
effectiveTimelineDuration > 0 &&
|
|
278
|
+
el.duration < effectiveTimelineDuration * 0.92 &&
|
|
279
|
+
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
|
|
280
|
+
|
|
245
281
|
// Audio clips — waveform visualization
|
|
246
282
|
if (el.tag === "audio") {
|
|
247
283
|
const audioUrl = el.src
|
|
@@ -268,14 +304,14 @@ export function StudioApp() {
|
|
|
268
304
|
);
|
|
269
305
|
}
|
|
270
306
|
|
|
271
|
-
|
|
272
|
-
if (el.tag === "div" && el.duration > 0) {
|
|
273
|
-
const previewUrl = `/api/projects/${pid}/preview`;
|
|
307
|
+
if (htmlPreviewEligible) {
|
|
274
308
|
return (
|
|
275
309
|
<CompositionThumbnail
|
|
276
|
-
previewUrl={
|
|
310
|
+
previewUrl={`/api/projects/${pid}/preview`}
|
|
277
311
|
label={el.id || el.tag}
|
|
278
312
|
labelColor={style.label}
|
|
313
|
+
accentColor={style.clip}
|
|
314
|
+
selector={el.selector}
|
|
279
315
|
seekTime={el.start}
|
|
280
316
|
duration={el.duration}
|
|
281
317
|
/>
|
|
@@ -284,7 +320,53 @@ export function StudioApp() {
|
|
|
284
320
|
|
|
285
321
|
return null;
|
|
286
322
|
},
|
|
287
|
-
[compIdToSrc, activePreviewUrl],
|
|
323
|
+
[compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
|
|
324
|
+
);
|
|
325
|
+
const timelineToolbar = (
|
|
326
|
+
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/40 bg-neutral-950/96">
|
|
327
|
+
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
|
328
|
+
Timeline
|
|
329
|
+
</div>
|
|
330
|
+
<div className="flex items-center gap-1">
|
|
331
|
+
<button
|
|
332
|
+
type="button"
|
|
333
|
+
onClick={() => setZoomMode("fit")}
|
|
334
|
+
className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
|
|
335
|
+
zoomMode === "fit"
|
|
336
|
+
? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
|
|
337
|
+
: "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
|
|
338
|
+
}`}
|
|
339
|
+
title="Fit timeline to width"
|
|
340
|
+
>
|
|
341
|
+
Fit
|
|
342
|
+
</button>
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
onClick={() => {
|
|
346
|
+
setZoomMode("manual");
|
|
347
|
+
setManualZoomPercent(getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent));
|
|
348
|
+
}}
|
|
349
|
+
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
|
350
|
+
title="Zoom out"
|
|
351
|
+
>
|
|
352
|
+
-
|
|
353
|
+
</button>
|
|
354
|
+
<div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
|
|
355
|
+
{`${displayedTimelineZoomPercent}%`}
|
|
356
|
+
</div>
|
|
357
|
+
<button
|
|
358
|
+
type="button"
|
|
359
|
+
onClick={() => {
|
|
360
|
+
setZoomMode("manual");
|
|
361
|
+
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
|
|
362
|
+
}}
|
|
363
|
+
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
|
364
|
+
title="Zoom in"
|
|
365
|
+
>
|
|
366
|
+
+
|
|
367
|
+
</button>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
288
370
|
);
|
|
289
371
|
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
|
290
372
|
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
|
@@ -378,6 +460,195 @@ export function StudioApp() {
|
|
|
378
460
|
}, 600);
|
|
379
461
|
}, []);
|
|
380
462
|
|
|
463
|
+
const handleTimelineElementMove = useCallback(
|
|
464
|
+
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
465
|
+
const pid = projectIdRef.current;
|
|
466
|
+
if (!pid) throw new Error("No active project");
|
|
467
|
+
|
|
468
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
469
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
470
|
+
if (!response.ok) {
|
|
471
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const data = (await response.json()) as { content?: string };
|
|
475
|
+
const originalContent = data.content;
|
|
476
|
+
if (typeof originalContent !== "string") {
|
|
477
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const patchTarget = element.domId
|
|
481
|
+
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
|
482
|
+
: element.selector
|
|
483
|
+
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
|
484
|
+
: null;
|
|
485
|
+
if (!patchTarget) {
|
|
486
|
+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const resolvedTargetPath = targetPath || "index.html";
|
|
490
|
+
const relevantElements = timelineElements
|
|
491
|
+
.map((timelineElement) =>
|
|
492
|
+
(timelineElement.key ?? timelineElement.id) === (element.key ?? element.id)
|
|
493
|
+
? { ...timelineElement, start: updates.start, track: updates.track }
|
|
494
|
+
: timelineElement,
|
|
495
|
+
)
|
|
496
|
+
.filter(
|
|
497
|
+
(timelineElement) =>
|
|
498
|
+
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
|
499
|
+
);
|
|
500
|
+
const trackZIndices = buildTrackZIndexMap(
|
|
501
|
+
relevantElements.map((timelineElement) => timelineElement.track),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
|
|
505
|
+
type: "attribute",
|
|
506
|
+
property: "start",
|
|
507
|
+
value: formatTimelineAttributeNumber(updates.start),
|
|
508
|
+
});
|
|
509
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
510
|
+
type: "attribute",
|
|
511
|
+
property: "track-index",
|
|
512
|
+
value: String(updates.track),
|
|
513
|
+
});
|
|
514
|
+
for (const timelineElement of relevantElements) {
|
|
515
|
+
const elementTarget = timelineElement.domId
|
|
516
|
+
? {
|
|
517
|
+
id: timelineElement.domId,
|
|
518
|
+
selector: timelineElement.selector,
|
|
519
|
+
selectorIndex: timelineElement.selectorIndex,
|
|
520
|
+
}
|
|
521
|
+
: timelineElement.selector
|
|
522
|
+
? {
|
|
523
|
+
selector: timelineElement.selector,
|
|
524
|
+
selectorIndex: timelineElement.selectorIndex,
|
|
525
|
+
}
|
|
526
|
+
: null;
|
|
527
|
+
if (!elementTarget) continue;
|
|
528
|
+
const nextZIndex = trackZIndices.get(timelineElement.track);
|
|
529
|
+
if (nextZIndex == null) continue;
|
|
530
|
+
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
|
531
|
+
type: "inline-style",
|
|
532
|
+
property: "z-index",
|
|
533
|
+
value: String(nextZIndex),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (patchedContent === originalContent) {
|
|
538
|
+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const saveResponse = await fetch(
|
|
542
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
543
|
+
{
|
|
544
|
+
method: "PUT",
|
|
545
|
+
headers: { "Content-Type": "text/plain" },
|
|
546
|
+
body: patchedContent,
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
if (!saveResponse.ok) {
|
|
550
|
+
throw new Error(`Failed to save ${targetPath}`);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (editingPathRef.current === targetPath) {
|
|
554
|
+
setEditingFile({ path: targetPath, content: patchedContent });
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
setRefreshKey((k) => k + 1);
|
|
558
|
+
},
|
|
559
|
+
[activeCompPath, timelineElements],
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
const handleTimelineElementResize = useCallback(
|
|
563
|
+
async (
|
|
564
|
+
element: TimelineElement,
|
|
565
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
566
|
+
) => {
|
|
567
|
+
const pid = projectIdRef.current;
|
|
568
|
+
if (!pid) throw new Error("No active project");
|
|
569
|
+
|
|
570
|
+
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
|
571
|
+
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
|
572
|
+
if (!response.ok) {
|
|
573
|
+
throw new Error(`Failed to read ${targetPath}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const data = (await response.json()) as { content?: string };
|
|
577
|
+
const originalContent = data.content;
|
|
578
|
+
if (typeof originalContent !== "string") {
|
|
579
|
+
throw new Error(`Missing file contents for ${targetPath}`);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const patchTarget = element.domId
|
|
583
|
+
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
|
584
|
+
: element.selector
|
|
585
|
+
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
|
586
|
+
: null;
|
|
587
|
+
if (!patchTarget) {
|
|
588
|
+
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const playbackStartAttrName =
|
|
592
|
+
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
593
|
+
const currentPlaybackStartValue =
|
|
594
|
+
readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
|
|
595
|
+
readAttributeByTarget(originalContent, patchTarget, "media-start");
|
|
596
|
+
const currentPlaybackStart =
|
|
597
|
+
currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
|
|
598
|
+
const trimDelta = updates.start - element.start;
|
|
599
|
+
const fallbackPlaybackStart =
|
|
600
|
+
updates.playbackStart == null &&
|
|
601
|
+
trimDelta !== 0 &&
|
|
602
|
+
Number.isFinite(currentPlaybackStart) &&
|
|
603
|
+
currentPlaybackStart != null
|
|
604
|
+
? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
|
|
605
|
+
: undefined;
|
|
606
|
+
const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
|
|
607
|
+
|
|
608
|
+
let patchedContent = originalContent;
|
|
609
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
610
|
+
type: "attribute",
|
|
611
|
+
property: "start",
|
|
612
|
+
value: formatTimelineAttributeNumber(updates.start),
|
|
613
|
+
});
|
|
614
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
615
|
+
type: "attribute",
|
|
616
|
+
property: "duration",
|
|
617
|
+
value: formatTimelineAttributeNumber(updates.duration),
|
|
618
|
+
});
|
|
619
|
+
if (nextPlaybackStart != null) {
|
|
620
|
+
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
621
|
+
type: "attribute",
|
|
622
|
+
property: playbackStartAttrName,
|
|
623
|
+
value: formatTimelineAttributeNumber(nextPlaybackStart),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
if (patchedContent === originalContent) {
|
|
628
|
+
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const saveResponse = await fetch(
|
|
632
|
+
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
|
633
|
+
{
|
|
634
|
+
method: "PUT",
|
|
635
|
+
headers: { "Content-Type": "text/plain" },
|
|
636
|
+
body: patchedContent,
|
|
637
|
+
},
|
|
638
|
+
);
|
|
639
|
+
if (!saveResponse.ok) {
|
|
640
|
+
throw new Error(`Failed to save ${targetPath}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (editingPathRef.current === targetPath) {
|
|
644
|
+
setEditingFile({ path: targetPath, content: patchedContent });
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
setRefreshKey((k) => k + 1);
|
|
648
|
+
},
|
|
649
|
+
[activeCompPath],
|
|
650
|
+
);
|
|
651
|
+
|
|
381
652
|
// ── File Management Handlers ──
|
|
382
653
|
|
|
383
654
|
const refreshFileTree = useCallback(async () => {
|
|
@@ -780,12 +1051,14 @@ export function StudioApp() {
|
|
|
780
1051
|
{/* Left resize handle */}
|
|
781
1052
|
{!leftCollapsed && (
|
|
782
1053
|
<div
|
|
783
|
-
className="w-
|
|
1054
|
+
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
|
784
1055
|
style={{ touchAction: "none" }}
|
|
785
1056
|
onPointerDown={(e) => handlePanelResizeStart("left", e)}
|
|
786
1057
|
onPointerMove={handlePanelResizeMove}
|
|
787
1058
|
onPointerUp={handlePanelResizeEnd}
|
|
788
|
-
|
|
1059
|
+
>
|
|
1060
|
+
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
1061
|
+
</div>
|
|
789
1062
|
)}
|
|
790
1063
|
|
|
791
1064
|
{/* Center: Preview */}
|
|
@@ -794,7 +1067,10 @@ export function StudioApp() {
|
|
|
794
1067
|
projectId={projectId}
|
|
795
1068
|
refreshKey={refreshKey}
|
|
796
1069
|
activeCompositionPath={activeCompPath}
|
|
1070
|
+
timelineToolbar={timelineToolbar}
|
|
797
1071
|
renderClipContent={renderClipContent}
|
|
1072
|
+
onMoveElement={handleTimelineElementMove}
|
|
1073
|
+
onResizeElement={handleTimelineElementResize}
|
|
798
1074
|
onCompIdToSrcChange={setCompIdToSrc}
|
|
799
1075
|
onCompositionChange={(compPath) => {
|
|
800
1076
|
// Sync activeCompPath when user drills down via timeline double-click
|
|
@@ -875,12 +1151,14 @@ export function StudioApp() {
|
|
|
875
1151
|
{!rightCollapsed && (
|
|
876
1152
|
<>
|
|
877
1153
|
<div
|
|
878
|
-
className="w-
|
|
1154
|
+
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
|
879
1155
|
style={{ touchAction: "none" }}
|
|
880
1156
|
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
|
881
1157
|
onPointerMove={handlePanelResizeMove}
|
|
882
1158
|
onPointerUp={handlePanelResizeEnd}
|
|
883
|
-
|
|
1159
|
+
>
|
|
1160
|
+
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
|
1161
|
+
</div>
|
|
884
1162
|
<div
|
|
885
1163
|
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
|
|
886
1164
|
style={{ width: rightWidth }}
|
|
@@ -27,6 +27,15 @@ interface NLELayoutProps {
|
|
|
27
27
|
element: TimelineElement,
|
|
28
28
|
style: { clip: string; label: string },
|
|
29
29
|
) => ReactNode;
|
|
30
|
+
/** Persist timeline move actions back into source HTML */
|
|
31
|
+
onMoveElement?: (
|
|
32
|
+
element: TimelineElement,
|
|
33
|
+
updates: Pick<TimelineElement, "start" | "track">,
|
|
34
|
+
) => Promise<void> | void;
|
|
35
|
+
onResizeElement?: (
|
|
36
|
+
element: TimelineElement,
|
|
37
|
+
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
38
|
+
) => Promise<void> | void;
|
|
30
39
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
|
31
40
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
|
32
41
|
/** Whether the timeline panel is visible (default: true) */
|
|
@@ -50,6 +59,8 @@ export const NLELayout = memo(function NLELayout({
|
|
|
50
59
|
onIframeRef,
|
|
51
60
|
onCompositionChange,
|
|
52
61
|
renderClipContent,
|
|
62
|
+
onMoveElement,
|
|
63
|
+
onResizeElement,
|
|
53
64
|
onCompIdToSrcChange,
|
|
54
65
|
timelineVisible,
|
|
55
66
|
onToggleTimeline,
|
|
@@ -59,6 +70,7 @@ export const NLELayout = memo(function NLELayout({
|
|
|
59
70
|
togglePlay,
|
|
60
71
|
seek,
|
|
61
72
|
onIframeLoad: baseOnIframeLoad,
|
|
73
|
+
refreshPlayer,
|
|
62
74
|
saveSeekPosition,
|
|
63
75
|
} = useTimelinePlayer();
|
|
64
76
|
|
|
@@ -72,12 +84,13 @@ export const NLELayout = memo(function NLELayout({
|
|
|
72
84
|
usePlayerStore.getState().reset();
|
|
73
85
|
}
|
|
74
86
|
|
|
75
|
-
//
|
|
87
|
+
// Refresh the existing iframe in place when source files change.
|
|
76
88
|
const prevRefreshKeyRef = useRef(refreshKey);
|
|
77
|
-
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (refreshKey === prevRefreshKeyRef.current) return;
|
|
78
91
|
prevRefreshKeyRef.current = refreshKey;
|
|
79
|
-
|
|
80
|
-
}
|
|
92
|
+
refreshPlayer();
|
|
93
|
+
}, [refreshKey, refreshPlayer]);
|
|
81
94
|
|
|
82
95
|
// Wrap onIframeLoad to also notify parent of iframe ref
|
|
83
96
|
const onIframeLoad = useCallback(() => {
|
|
@@ -351,18 +364,20 @@ export const NLELayout = memo(function NLELayout({
|
|
|
351
364
|
<>
|
|
352
365
|
{/* Resize divider */}
|
|
353
366
|
<div
|
|
354
|
-
className="h-
|
|
367
|
+
className="group h-2 flex-shrink-0 cursor-row-resize flex items-center justify-center z-10"
|
|
355
368
|
style={{ touchAction: "none" }}
|
|
356
369
|
onPointerDown={handleDividerPointerDown}
|
|
357
370
|
onPointerMove={handleDividerPointerMove}
|
|
358
371
|
onPointerUp={handleDividerPointerUp}
|
|
359
|
-
|
|
372
|
+
>
|
|
373
|
+
<div className="h-px w-full bg-white/10 transition-colors group-hover:bg-white/16 group-active:bg-white/22" />
|
|
374
|
+
</div>
|
|
360
375
|
|
|
361
376
|
{/* Timeline section — fixed height, resizable */}
|
|
362
377
|
<div className="flex flex-col flex-shrink-0" style={{ height: timelineH }}>
|
|
363
378
|
{/* Timeline tracks */}
|
|
364
379
|
<div
|
|
365
|
-
className="flex-1 min-h-0 overflow-
|
|
380
|
+
className="flex-1 min-h-0 overflow-hidden bg-neutral-950"
|
|
366
381
|
onDoubleClick={(e) => {
|
|
367
382
|
if ((e.target as HTMLElement).closest("[data-clip]")) return;
|
|
368
383
|
if (compositionStack.length > 1) {
|
|
@@ -375,6 +390,8 @@ export const NLELayout = memo(function NLELayout({
|
|
|
375
390
|
onSeek={seek}
|
|
376
391
|
onDrillDown={handleDrillDown}
|
|
377
392
|
renderClipContent={renderClipContent}
|
|
393
|
+
onMoveElement={onMoveElement}
|
|
394
|
+
onResizeElement={onResizeElement}
|
|
378
395
|
/>
|
|
379
396
|
</div>
|
|
380
397
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getPreviewPlayerKey } from "./NLEPreview";
|
|
3
|
+
|
|
4
|
+
describe("getPreviewPlayerKey", () => {
|
|
5
|
+
it("keeps the same player identity when only refreshKey changes", () => {
|
|
6
|
+
expect(
|
|
7
|
+
getPreviewPlayerKey({
|
|
8
|
+
projectId: "timeline-edit-playground",
|
|
9
|
+
refreshKey: 1,
|
|
10
|
+
}),
|
|
11
|
+
).toBe(
|
|
12
|
+
getPreviewPlayerKey({
|
|
13
|
+
projectId: "timeline-edit-playground",
|
|
14
|
+
refreshKey: 2,
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("switches identity when drilling into a different directUrl", () => {
|
|
20
|
+
expect(
|
|
21
|
+
getPreviewPlayerKey({
|
|
22
|
+
projectId: "timeline-edit-playground",
|
|
23
|
+
directUrl: "/api/projects/timeline-edit-playground/preview",
|
|
24
|
+
}),
|
|
25
|
+
).not.toBe(
|
|
26
|
+
getPreviewPlayerKey({
|
|
27
|
+
projectId: "timeline-edit-playground",
|
|
28
|
+
directUrl: "/api/projects/timeline-edit-playground/preview/comp/compositions/intro.html",
|
|
29
|
+
}),
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -10,6 +10,17 @@ interface NLEPreviewProps {
|
|
|
10
10
|
refreshKey?: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
export function getPreviewPlayerKey({
|
|
14
|
+
projectId,
|
|
15
|
+
directUrl,
|
|
16
|
+
}: {
|
|
17
|
+
projectId: string;
|
|
18
|
+
directUrl?: string;
|
|
19
|
+
refreshKey?: number;
|
|
20
|
+
}): string {
|
|
21
|
+
return directUrl ?? projectId;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export const NLEPreview = memo(function NLEPreview({
|
|
14
25
|
projectId,
|
|
15
26
|
iframeRef,
|
|
@@ -18,7 +29,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
18
29
|
directUrl,
|
|
19
30
|
refreshKey,
|
|
20
31
|
}: NLEPreviewProps) {
|
|
21
|
-
const playerKey =
|
|
32
|
+
const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey });
|
|
22
33
|
|
|
23
34
|
return (
|
|
24
35
|
<div className="flex flex-col h-full min-h-0">
|