@hyperframes/studio 0.5.0-alpha.1 → 0.5.0-alpha.10
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/index-DKaNgV2Z.css +1 -0
- package/dist/assets/index-peNJzL-4.js +105 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +132 -41
- package/src/captions/components/CaptionOverlay.tsx +2 -1
- package/src/captions/keyboard.test.ts +38 -0
- package/src/captions/keyboard.ts +8 -0
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.tsx +41 -6
- package/src/components/editor/PropertyPanel.tsx +7 -3
- package/src/components/editor/domEditing.test.ts +110 -0
- package/src/components/editor/domEditing.ts +33 -4
- package/src/components/nle/NLEPreview.tsx +5 -1
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/player/components/AudioWaveform.tsx +44 -29
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +42 -10
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/PlayerControls.tsx +120 -11
- package/src/player/components/Timeline.test.ts +84 -0
- package/src/player/components/Timeline.tsx +198 -27
- package/src/player/components/timelineEditing.test.ts +2 -2
- package/src/player/components/timelineEditing.ts +1 -1
- package/src/player/components/timelineZoom.test.ts +21 -0
- package/src/player/components/timelineZoom.ts +11 -0
- package/src/player/hooks/useTimelinePlayer.test.ts +138 -0
- package/src/player/hooks/useTimelinePlayer.ts +354 -43
- package/src/player/lib/time.test.ts +19 -1
- package/src/player/lib/time.ts +20 -0
- package/src/player/store/playerStore.test.ts +11 -1
- package/src/player/store/playerStore.ts +5 -1
- package/src/styles/studio.css +9 -0
- package/src/utils/clipboard.test.ts +88 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/timelineAssetDrop.test.ts +64 -4
- package/src/utils/timelineAssetDrop.ts +27 -5
- package/dist/assets/index-Bi30tos-.js +0 -105
- package/dist/assets/index-Dm9VsShj.css +0 -1
|
@@ -108,6 +108,61 @@ describe("resolveDomEditCapabilities", () => {
|
|
|
108
108
|
});
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it("treats identity transforms left behind by animation libraries as movable", () => {
|
|
112
|
+
expect(
|
|
113
|
+
resolveDomEditCapabilities({
|
|
114
|
+
selector: "#card",
|
|
115
|
+
inlineStyles: {
|
|
116
|
+
left: "120px",
|
|
117
|
+
top: "80px",
|
|
118
|
+
width: "240px",
|
|
119
|
+
height: "140px",
|
|
120
|
+
},
|
|
121
|
+
computedStyles: {
|
|
122
|
+
position: "absolute",
|
|
123
|
+
left: "120px",
|
|
124
|
+
top: "80px",
|
|
125
|
+
width: "240px",
|
|
126
|
+
height: "140px",
|
|
127
|
+
transform: "matrix(1, 0, 0, 1, 0, 0)",
|
|
128
|
+
},
|
|
129
|
+
isCompositionHost: false,
|
|
130
|
+
isMasterView: false,
|
|
131
|
+
}),
|
|
132
|
+
).toMatchObject({
|
|
133
|
+
canMove: true,
|
|
134
|
+
canResize: true,
|
|
135
|
+
canDetachFromLayout: false,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("treats identity matrix3d transforms as movable", () => {
|
|
140
|
+
expect(
|
|
141
|
+
resolveDomEditCapabilities({
|
|
142
|
+
selector: "#card",
|
|
143
|
+
inlineStyles: {
|
|
144
|
+
left: "120px",
|
|
145
|
+
top: "80px",
|
|
146
|
+
width: "240px",
|
|
147
|
+
height: "140px",
|
|
148
|
+
},
|
|
149
|
+
computedStyles: {
|
|
150
|
+
position: "absolute",
|
|
151
|
+
left: "120px",
|
|
152
|
+
top: "80px",
|
|
153
|
+
width: "240px",
|
|
154
|
+
height: "140px",
|
|
155
|
+
transform: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
|
|
156
|
+
},
|
|
157
|
+
isCompositionHost: false,
|
|
158
|
+
isMasterView: false,
|
|
159
|
+
}),
|
|
160
|
+
).toMatchObject({
|
|
161
|
+
canMove: true,
|
|
162
|
+
canResize: true,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
111
166
|
it("allows imported absolute media to resize from computed px geometry", () => {
|
|
112
167
|
expect(
|
|
113
168
|
resolveDomEditCapabilities({
|
|
@@ -228,6 +283,24 @@ describe("resolveDomEditSelection", () => {
|
|
|
228
283
|
expect(selection?.selector).toBe("#card");
|
|
229
284
|
});
|
|
230
285
|
|
|
286
|
+
it("can resolve the exact child when clip-ancestor preference is disabled", () => {
|
|
287
|
+
const document = createDocument(`
|
|
288
|
+
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
|
289
|
+
<p id="copy">Hello</p>
|
|
290
|
+
</section>
|
|
291
|
+
`);
|
|
292
|
+
|
|
293
|
+
const child = document.getElementById("copy") as HTMLElement;
|
|
294
|
+
const selection = resolveDomEditSelection(child, {
|
|
295
|
+
activeCompositionPath: null,
|
|
296
|
+
isMasterView: false,
|
|
297
|
+
preferClipAncestor: false,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(selection?.id).toBe("copy");
|
|
301
|
+
expect(selection?.selector).toBe("#copy");
|
|
302
|
+
});
|
|
303
|
+
|
|
231
304
|
it("collects simple child text blocks as separate editable fields", () => {
|
|
232
305
|
const document = createDocument(`
|
|
233
306
|
<section id="card" class="clip" style="left: 10px; top: 20px; width: 200px; height: 100px; position: absolute;">
|
|
@@ -394,6 +467,43 @@ describe("patch builders and prompt builder", () => {
|
|
|
394
467
|
expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
|
|
395
468
|
});
|
|
396
469
|
|
|
470
|
+
it("uses an absolute source path in copied agent prompts when provided", () => {
|
|
471
|
+
const selection = {
|
|
472
|
+
element: {} as HTMLElement,
|
|
473
|
+
id: "editable-card",
|
|
474
|
+
selector: "#editable-card",
|
|
475
|
+
selectorIndex: undefined,
|
|
476
|
+
sourceFile: "index.html",
|
|
477
|
+
compositionPath: "index.html",
|
|
478
|
+
compositionSrc: undefined,
|
|
479
|
+
isCompositionHost: false,
|
|
480
|
+
label: "Drag me first",
|
|
481
|
+
tagName: "div",
|
|
482
|
+
boundingBox: { x: 108, y: 112, width: 380, height: 196 },
|
|
483
|
+
textContent: "Drag me first",
|
|
484
|
+
dataAttributes: {},
|
|
485
|
+
inlineStyles: {},
|
|
486
|
+
computedStyles: {},
|
|
487
|
+
textFields: [],
|
|
488
|
+
capabilities: {
|
|
489
|
+
canSelect: true,
|
|
490
|
+
canEditStyles: true,
|
|
491
|
+
canMove: true,
|
|
492
|
+
canResize: true,
|
|
493
|
+
canDetachFromLayout: false,
|
|
494
|
+
},
|
|
495
|
+
} satisfies DomEditSelection;
|
|
496
|
+
|
|
497
|
+
const prompt = buildElementAgentPrompt({
|
|
498
|
+
selection,
|
|
499
|
+
currentTime: 1.25,
|
|
500
|
+
sourceFilePath: "/tmp/hf-studio-project/index.html",
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html");
|
|
504
|
+
expect(prompt).not.toContain("Source file: index.html");
|
|
505
|
+
});
|
|
506
|
+
|
|
397
507
|
it("serializes child text fields back into HTML", () => {
|
|
398
508
|
expect(
|
|
399
509
|
serializeDomEditTextFields([
|
|
@@ -93,6 +93,32 @@ function parsePx(value: string | undefined): number | null {
|
|
|
93
93
|
return Number.isFinite(parsed) ? parsed : null;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
function isIdentityTransform(value: string | undefined): boolean {
|
|
97
|
+
const transform = (value ?? "none").trim();
|
|
98
|
+
if (!transform || transform === "none") return true;
|
|
99
|
+
|
|
100
|
+
const matrix = transform.match(/^matrix\(([^)]+)\)$/i);
|
|
101
|
+
if (matrix) {
|
|
102
|
+
const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
103
|
+
if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false;
|
|
104
|
+
return (
|
|
105
|
+
Math.abs(values[0] - 1) < 0.0001 &&
|
|
106
|
+
Math.abs(values[1]) < 0.0001 &&
|
|
107
|
+
Math.abs(values[2]) < 0.0001 &&
|
|
108
|
+
Math.abs(values[3] - 1) < 0.0001 &&
|
|
109
|
+
Math.abs(values[4]) < 0.0001 &&
|
|
110
|
+
Math.abs(values[5]) < 0.0001
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i);
|
|
115
|
+
if (!matrix3d) return false;
|
|
116
|
+
const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim()));
|
|
117
|
+
if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false;
|
|
118
|
+
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
|
119
|
+
return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001);
|
|
120
|
+
}
|
|
121
|
+
|
|
96
122
|
function isClipClassName(className: string | undefined): boolean {
|
|
97
123
|
return Boolean(className?.split(/\s+/).includes("clip"));
|
|
98
124
|
}
|
|
@@ -426,13 +452,13 @@ export function resolveDomEditCapabilities(args: {
|
|
|
426
452
|
const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top);
|
|
427
453
|
const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width);
|
|
428
454
|
const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height);
|
|
429
|
-
const
|
|
455
|
+
const hasTransformDrivenGeometry = !isIdentityTransform(args.computedStyles.transform);
|
|
430
456
|
|
|
431
457
|
const canMove =
|
|
432
458
|
(position === "absolute" || position === "fixed") &&
|
|
433
459
|
left != null &&
|
|
434
460
|
top != null &&
|
|
435
|
-
|
|
461
|
+
!hasTransformDrivenGeometry;
|
|
436
462
|
|
|
437
463
|
const canResize = canMove && (width != null || height != null);
|
|
438
464
|
const isBlockishLayer =
|
|
@@ -442,7 +468,7 @@ export function resolveDomEditCapabilities(args: {
|
|
|
442
468
|
isBlockishDisplay(args.computedStyles.display);
|
|
443
469
|
const canDetachFromLayout =
|
|
444
470
|
!canMove &&
|
|
445
|
-
|
|
471
|
+
!hasTransformDrivenGeometry &&
|
|
446
472
|
isBlockishLayer &&
|
|
447
473
|
(!isInlineTextTag(args.tagName) || isClipClassName(args.className));
|
|
448
474
|
const reasonIfDisabled = !canMove
|
|
@@ -671,12 +697,15 @@ export function buildElementAgentPrompt({
|
|
|
671
697
|
currentTime,
|
|
672
698
|
tagSnippet,
|
|
673
699
|
userInstruction,
|
|
700
|
+
sourceFilePath,
|
|
674
701
|
}: {
|
|
675
702
|
selection: DomEditSelection;
|
|
676
703
|
currentTime: number;
|
|
677
704
|
tagSnippet?: string;
|
|
678
705
|
userInstruction?: string;
|
|
706
|
+
sourceFilePath?: string;
|
|
679
707
|
}): string {
|
|
708
|
+
const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile;
|
|
680
709
|
const lines = [
|
|
681
710
|
"## HyperFrames element edit request v1",
|
|
682
711
|
"Schema version: 1",
|
|
@@ -685,7 +714,7 @@ export function buildElementAgentPrompt({
|
|
|
685
714
|
"",
|
|
686
715
|
`Composition: ${selection.compositionPath}`,
|
|
687
716
|
`Playback time: ${formatTime(currentTime)}`,
|
|
688
|
-
`Source file: ${
|
|
717
|
+
`Source file: ${displayedSourceFile}`,
|
|
689
718
|
`DOM id: ${selection.id ?? "(none)"}`,
|
|
690
719
|
`Selector: ${selection.selector ?? "(none)"}`,
|
|
691
720
|
`Selector index: ${selection.selectorIndex ?? 0}`,
|
|
@@ -67,7 +67,11 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
67
67
|
|
|
68
68
|
return (
|
|
69
69
|
<div className="flex flex-col h-full min-h-0">
|
|
70
|
-
<div
|
|
70
|
+
<div
|
|
71
|
+
className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
|
|
72
|
+
tabIndex={0}
|
|
73
|
+
aria-label="Composition preview"
|
|
74
|
+
>
|
|
71
75
|
{retiringKey && (
|
|
72
76
|
<Player
|
|
73
77
|
key={retiringKey}
|
|
@@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react";
|
|
|
2
2
|
import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
|
|
3
3
|
import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes";
|
|
4
4
|
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
5
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
5
6
|
|
|
6
7
|
interface AssetsTabProps {
|
|
7
8
|
projectId: string;
|
|
@@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({
|
|
|
298
299
|
);
|
|
299
300
|
|
|
300
301
|
const handleCopyPath = useCallback(async (path: string) => {
|
|
301
|
-
|
|
302
|
-
|
|
302
|
+
const copied = await copyTextToClipboard(path);
|
|
303
|
+
if (copied) {
|
|
303
304
|
setCopiedPath(path);
|
|
304
305
|
setTimeout(() => setCopiedPath(null), 1500);
|
|
305
|
-
} catch {
|
|
306
|
-
// ignore
|
|
307
306
|
}
|
|
308
307
|
}, []);
|
|
309
308
|
|
|
@@ -2,6 +2,7 @@ import { memo, useRef, useState, useCallback, useEffect } from "react";
|
|
|
2
2
|
|
|
3
3
|
interface AudioWaveformProps {
|
|
4
4
|
audioUrl: string;
|
|
5
|
+
waveformUrl?: string;
|
|
5
6
|
label: string;
|
|
6
7
|
labelColor: string;
|
|
7
8
|
}
|
|
@@ -49,6 +50,7 @@ function fakePeaks(url: string, count: number): number[] {
|
|
|
49
50
|
|
|
50
51
|
// Module-level cache so decoded audio persists across re-renders and re-mounts
|
|
51
52
|
const peaksCache = new Map<string, number[]>();
|
|
53
|
+
const decodeInFlight = new Map<string, Promise<number[]>>();
|
|
52
54
|
|
|
53
55
|
/**
|
|
54
56
|
* Audio waveform rendered from real PCM data via Web Audio API.
|
|
@@ -57,43 +59,56 @@ const peaksCache = new Map<string, number[]>();
|
|
|
57
59
|
*/
|
|
58
60
|
export const AudioWaveform = memo(function AudioWaveform({
|
|
59
61
|
audioUrl,
|
|
62
|
+
waveformUrl,
|
|
60
63
|
label,
|
|
61
64
|
labelColor,
|
|
62
65
|
}: AudioWaveformProps) {
|
|
63
66
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
64
67
|
const barsRef = useRef<HTMLDivElement | null>(null);
|
|
65
68
|
const roRef = useRef<ResizeObserver | null>(null);
|
|
66
|
-
const
|
|
69
|
+
const cacheKey = waveformUrl ?? audioUrl;
|
|
70
|
+
const [peaks, setPeaks] = useState<number[] | null>(peaksCache.get(cacheKey) ?? null);
|
|
67
71
|
|
|
68
|
-
// Fetch + decode audio once
|
|
69
72
|
useEffect(() => {
|
|
70
|
-
if (peaks || !
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
73
|
+
if (peaks || !cacheKey) return;
|
|
74
|
+
|
|
75
|
+
let cancelled = false;
|
|
76
|
+
|
|
77
|
+
let promise = decodeInFlight.get(cacheKey);
|
|
78
|
+
if (!promise) {
|
|
79
|
+
promise = (
|
|
80
|
+
waveformUrl
|
|
81
|
+
? fetch(waveformUrl)
|
|
82
|
+
.then((r) => r.json())
|
|
83
|
+
.then((d: { peaks?: number[] }) => {
|
|
84
|
+
if (!Array.isArray(d.peaks)) throw new Error("bad response");
|
|
85
|
+
return d.peaks;
|
|
86
|
+
})
|
|
87
|
+
: fetch(audioUrl)
|
|
88
|
+
.then((r) => r.arrayBuffer())
|
|
89
|
+
.then((buf) => {
|
|
90
|
+
const ctx = new AudioContext();
|
|
91
|
+
return ctx.decodeAudioData(buf).finally(() => ctx.close());
|
|
92
|
+
})
|
|
93
|
+
.then((decoded) => extractPeaks(decoded.getChannelData(0), 4000))
|
|
94
|
+
)
|
|
95
|
+
.catch(() => fakePeaks(cacheKey, 4000))
|
|
96
|
+
.then((p) => {
|
|
97
|
+
peaksCache.set(cacheKey, p);
|
|
98
|
+
return p;
|
|
99
|
+
})
|
|
100
|
+
.finally(() => decodeInFlight.delete(cacheKey));
|
|
101
|
+
|
|
102
|
+
decodeInFlight.set(cacheKey, promise);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
promise.then((p) => {
|
|
106
|
+
if (!cancelled) setPeaks(p);
|
|
107
|
+
});
|
|
108
|
+
return () => {
|
|
109
|
+
cancelled = true;
|
|
110
|
+
};
|
|
111
|
+
}, [audioUrl, waveformUrl, cacheKey, peaks]);
|
|
97
112
|
|
|
98
113
|
// Draw bars into the container using innerHTML (fast, zoom-resilient)
|
|
99
114
|
const draw = useCallback(() => {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCompositionThumbnailUrl } from "./CompositionThumbnail";
|
|
3
|
+
|
|
4
|
+
describe("buildCompositionThumbnailUrl", () => {
|
|
5
|
+
it("includes selector and occurrence index for precise element thumbnails", () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildCompositionThumbnailUrl({
|
|
8
|
+
previewUrl: "/api/projects/demo/preview",
|
|
9
|
+
seekTime: 1,
|
|
10
|
+
duration: 2,
|
|
11
|
+
selector: ".card",
|
|
12
|
+
selectorIndex: 2,
|
|
13
|
+
origin: "http://localhost:3000",
|
|
14
|
+
}),
|
|
15
|
+
).toBe(
|
|
16
|
+
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v2&selector=.card&selectorIndex=2",
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -7,6 +7,7 @@ interface CompositionThumbnailProps {
|
|
|
7
7
|
labelColor: string;
|
|
8
8
|
accentColor?: string;
|
|
9
9
|
selector?: string;
|
|
10
|
+
selectorIndex?: number;
|
|
10
11
|
seekTime?: number;
|
|
11
12
|
duration?: number;
|
|
12
13
|
width?: number;
|
|
@@ -16,12 +17,44 @@ interface CompositionThumbnailProps {
|
|
|
16
17
|
const CLIP_HEIGHT = 66;
|
|
17
18
|
const THUMBNAIL_URL_VERSION = "v2";
|
|
18
19
|
|
|
20
|
+
export function buildCompositionThumbnailUrl({
|
|
21
|
+
previewUrl,
|
|
22
|
+
seekTime = 2,
|
|
23
|
+
duration = 5,
|
|
24
|
+
selector,
|
|
25
|
+
selectorIndex,
|
|
26
|
+
origin,
|
|
27
|
+
}: {
|
|
28
|
+
previewUrl: string;
|
|
29
|
+
seekTime?: number;
|
|
30
|
+
duration?: number;
|
|
31
|
+
selector?: string;
|
|
32
|
+
selectorIndex?: number;
|
|
33
|
+
origin: string;
|
|
34
|
+
}): string {
|
|
35
|
+
const thumbnailBase = previewUrl
|
|
36
|
+
.replace("/preview/comp/", "/thumbnail/")
|
|
37
|
+
.replace(/\/preview$/, "/thumbnail/index.html");
|
|
38
|
+
const midTime = seekTime + duration / 2;
|
|
39
|
+
const thumbnailUrl = new URL(thumbnailBase, origin);
|
|
40
|
+
thumbnailUrl.searchParams.set("t", midTime.toFixed(2));
|
|
41
|
+
thumbnailUrl.searchParams.set("v", THUMBNAIL_URL_VERSION);
|
|
42
|
+
if (selector) {
|
|
43
|
+
thumbnailUrl.searchParams.set("selector", selector);
|
|
44
|
+
if (selectorIndex != null && selectorIndex > 0) {
|
|
45
|
+
thumbnailUrl.searchParams.set("selectorIndex", String(selectorIndex));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return thumbnailUrl.toString();
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
20
52
|
previewUrl,
|
|
21
53
|
label,
|
|
22
54
|
labelColor,
|
|
23
55
|
accentColor = "#6B7280",
|
|
24
56
|
selector,
|
|
57
|
+
selectorIndex,
|
|
25
58
|
seekTime = 2,
|
|
26
59
|
duration = 5,
|
|
27
60
|
}: CompositionThumbnailProps) {
|
|
@@ -48,15 +81,14 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
48
81
|
roRef.current?.disconnect();
|
|
49
82
|
});
|
|
50
83
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const url = thumbnailUrl.toString();
|
|
84
|
+
const url = buildCompositionThumbnailUrl({
|
|
85
|
+
previewUrl,
|
|
86
|
+
seekTime,
|
|
87
|
+
duration,
|
|
88
|
+
selector,
|
|
89
|
+
selectorIndex,
|
|
90
|
+
origin: window.location.origin,
|
|
91
|
+
});
|
|
60
92
|
const frameW = Math.max(48, Math.round(CLIP_HEIGHT * aspect));
|
|
61
93
|
const frameCount = containerWidth > 0 ? Math.max(1, Math.ceil(containerWidth / frameW)) : 1;
|
|
62
94
|
|
|
@@ -66,7 +98,7 @@ export const CompositionThumbnail = memo(function CompositionThumbnail({
|
|
|
66
98
|
src={url}
|
|
67
99
|
alt=""
|
|
68
100
|
draggable={false}
|
|
69
|
-
loading="
|
|
101
|
+
loading="eager"
|
|
70
102
|
onLoad={(e) => {
|
|
71
103
|
const img = e.currentTarget;
|
|
72
104
|
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
|
|
@@ -3,6 +3,7 @@ import { useMountEffect } from "../../hooks/useMountEffect";
|
|
|
3
3
|
import { usePlayerStore } from "../store/playerStore";
|
|
4
4
|
import { formatTime } from "../lib/time";
|
|
5
5
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
|
6
|
+
import { copyTextToClipboard } from "../../utils/clipboard";
|
|
6
7
|
|
|
7
8
|
interface EditPopoverProps {
|
|
8
9
|
rangeStart: number;
|
|
@@ -62,16 +63,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
62
63
|
}, [start, end, elementsInRange, prompt]);
|
|
63
64
|
|
|
64
65
|
const handleCopy = useCallback(async () => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} catch {
|
|
68
|
-
const ta = document.createElement("textarea");
|
|
69
|
-
ta.value = buildClipboardText();
|
|
70
|
-
document.body.appendChild(ta);
|
|
71
|
-
ta.select();
|
|
72
|
-
document.execCommand("copy");
|
|
73
|
-
document.body.removeChild(ta);
|
|
74
|
-
}
|
|
66
|
+
const copied = await copyTextToClipboard(buildClipboardText());
|
|
67
|
+
if (!copied) return;
|
|
75
68
|
setCopiedAgentPrompt(true);
|
|
76
69
|
setTimeout(() => {
|
|
77
70
|
setCopiedAgentPrompt(false);
|
|
@@ -82,16 +75,8 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|
|
82
75
|
const handleCopyPrompt = useCallback(async () => {
|
|
83
76
|
const promptText = buildPromptCopyText(prompt);
|
|
84
77
|
if (!promptText) return;
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
const ta = document.createElement("textarea");
|
|
89
|
-
ta.value = promptText;
|
|
90
|
-
document.body.appendChild(ta);
|
|
91
|
-
ta.select();
|
|
92
|
-
document.execCommand("copy");
|
|
93
|
-
document.body.removeChild(ta);
|
|
94
|
-
}
|
|
78
|
+
const copied = await copyTextToClipboard(promptText);
|
|
79
|
+
if (!copied) return;
|
|
95
80
|
setCopiedPromptOnly(true);
|
|
96
81
|
setTimeout(() => {
|
|
97
82
|
setCopiedPromptOnly(false);
|