@hyperframes/studio 0.6.25 → 0.6.27
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-Cqq4uPvL.css → index-DVpLGNHi.css} +1 -1
- package/dist/assets/{index-BA9LlfxA.js → index-DYjmgXgg.js} +35 -36
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/components/editor/TimelineLayerPanel.tsx +0 -98
- package/src/components/editor/domEditing.test.ts +23 -0
- package/src/components/editor/domEditingElement.ts +19 -38
- package/src/components/editor/manualEditingAvailability.test.ts +1 -23
- package/src/components/editor/manualEditingAvailability.ts +3 -15
- package/src/components/editor/manualEditsSnapshot.ts +0 -34
- package/src/hooks/useDomEditSession.ts +0 -5
- package/src/hooks/usePreviewInteraction.ts +2 -40
- package/src/hooks/useTimelineEditing.ts +7 -73
- package/src/icons/SystemIcons.tsx +0 -75
- package/src/player/components/timelineEditing.test.ts +0 -24
- package/src/player/components/timelineEditing.ts +0 -6
- package/src/player/components/useTimelineRangeSelection.ts +0 -16
- package/src/player/hooks/useTimelineSyncCallbacks.ts +0 -6
- package/src/utils/blockInstaller.ts +2 -6
- package/src/utils/sourcePatcher.test.ts +22 -0
- package/src/utils/sourcePatcher.ts +3 -3
- package/src/utils/studioHelpers.ts +3 -9
- package/src/utils/studioPreviewHelpers.ts +3 -69
- package/src/utils/timelineDiscovery.ts +0 -16
- package/src/utils/timelineZIndexInjection.test.ts +155 -0
|
@@ -1,54 +1,18 @@
|
|
|
1
1
|
import {
|
|
2
|
-
WarningCircle,
|
|
3
|
-
Warning,
|
|
4
|
-
ArrowLeft as PhArrowLeft,
|
|
5
2
|
Check as PhCheck,
|
|
6
|
-
CheckCircle as PhCheckCircle,
|
|
7
|
-
Circle as PhCircle,
|
|
8
3
|
Clock as PhClock,
|
|
9
|
-
Code as PhCode,
|
|
10
|
-
DownloadSimple,
|
|
11
|
-
Pencil as PhPencil,
|
|
12
|
-
ArrowSquareOut,
|
|
13
4
|
Eye as PhEye,
|
|
14
|
-
EyeClosed,
|
|
15
|
-
File as PhFile,
|
|
16
|
-
FileCode as PhFileCode,
|
|
17
|
-
FileText as PhFileText,
|
|
18
5
|
FilmStrip,
|
|
19
|
-
Heart as PhHeart,
|
|
20
|
-
Image as PhImage,
|
|
21
|
-
Info as PhInfo,
|
|
22
6
|
Stack,
|
|
23
|
-
SpinnerGap,
|
|
24
|
-
ArrowsOut,
|
|
25
|
-
CornersOut,
|
|
26
|
-
ChatCircle,
|
|
27
7
|
ChatCenteredText,
|
|
28
|
-
Cursor,
|
|
29
8
|
ArrowsOutCardinal,
|
|
30
9
|
MusicNote,
|
|
31
10
|
Palette as PhPalette,
|
|
32
|
-
Paperclip as PhPaperclip,
|
|
33
|
-
Pause as PhPause,
|
|
34
|
-
Play as PhPlay,
|
|
35
11
|
Plus as PhPlus,
|
|
36
|
-
MagnifyingGlass,
|
|
37
|
-
PaperPlaneRight,
|
|
38
|
-
SkipBack as PhSkipBack,
|
|
39
|
-
SkipForward as PhSkipForward,
|
|
40
12
|
Square as PhSquare,
|
|
41
|
-
Trash,
|
|
42
13
|
TextT,
|
|
43
|
-
UploadSimple,
|
|
44
|
-
User as PhUser,
|
|
45
|
-
UsersThree,
|
|
46
|
-
VideoCamera,
|
|
47
14
|
X as PhX,
|
|
48
15
|
Lightning,
|
|
49
|
-
MagnifyingGlassPlus,
|
|
50
|
-
MagnifyingGlassMinus,
|
|
51
|
-
Terminal as PhTerminal,
|
|
52
16
|
CaretDown,
|
|
53
17
|
CaretRight,
|
|
54
18
|
ClipboardText,
|
|
@@ -69,60 +33,21 @@ const makeIcon = (Icon: PhosphorIcon) => {
|
|
|
69
33
|
};
|
|
70
34
|
|
|
71
35
|
// Lucide name → Phosphor equivalent
|
|
72
|
-
export const AlertCircle = makeIcon(WarningCircle);
|
|
73
|
-
export const AlertTriangle = makeIcon(Warning);
|
|
74
|
-
export const ArrowLeft = makeIcon(PhArrowLeft);
|
|
75
36
|
export const Check = makeIcon(PhCheck);
|
|
76
|
-
export const CheckCircle = makeIcon(PhCheckCircle);
|
|
77
|
-
/** CheckCircle2 in lucide is visually identical to CheckCircle */
|
|
78
|
-
export const CheckCircle2 = makeIcon(PhCheckCircle);
|
|
79
|
-
export const Circle = makeIcon(PhCircle);
|
|
80
37
|
export const Clock = makeIcon(PhClock);
|
|
81
|
-
export const Code = makeIcon(PhCode);
|
|
82
|
-
export const Download = makeIcon(DownloadSimple);
|
|
83
|
-
export const Edit2 = makeIcon(PhPencil);
|
|
84
|
-
export const ExternalLink = makeIcon(ArrowSquareOut);
|
|
85
38
|
export const Eye = makeIcon(PhEye);
|
|
86
|
-
export const EyeOff = makeIcon(EyeClosed);
|
|
87
|
-
export const File = makeIcon(PhFile);
|
|
88
|
-
export const FileCode = makeIcon(PhFileCode);
|
|
89
|
-
export const FileText = makeIcon(PhFileText);
|
|
90
39
|
export const Film = makeIcon(FilmStrip);
|
|
91
|
-
export const Heart = makeIcon(PhHeart);
|
|
92
|
-
export const Image = makeIcon(PhImage);
|
|
93
|
-
export const Info = makeIcon(PhInfo);
|
|
94
40
|
export const Layers = makeIcon(Stack);
|
|
95
|
-
export const Loader2 = makeIcon(SpinnerGap);
|
|
96
|
-
export const Maximize = makeIcon(ArrowsOut);
|
|
97
|
-
export const Maximize2 = makeIcon(CornersOut);
|
|
98
|
-
export const MessageCircle = makeIcon(ChatCircle);
|
|
99
41
|
export const MessageSquare = makeIcon(ChatCenteredText);
|
|
100
|
-
export const MousePointer = makeIcon(Cursor);
|
|
101
42
|
export const Move = makeIcon(ArrowsOutCardinal);
|
|
102
43
|
export const Music = makeIcon(MusicNote);
|
|
103
44
|
export const Palette = makeIcon(PhPalette);
|
|
104
|
-
export const Paperclip = makeIcon(PhPaperclip);
|
|
105
|
-
export const Pause = makeIcon(PhPause);
|
|
106
|
-
export const Pencil = makeIcon(PhPencil);
|
|
107
|
-
export const Play = makeIcon(PhPlay);
|
|
108
45
|
export const Plus = makeIcon(PhPlus);
|
|
109
|
-
export const Search = makeIcon(MagnifyingGlass);
|
|
110
|
-
export const Send = makeIcon(PaperPlaneRight);
|
|
111
|
-
export const SkipBack = makeIcon(PhSkipBack);
|
|
112
|
-
export const SkipForward = makeIcon(PhSkipForward);
|
|
113
46
|
export const Square = makeIcon(PhSquare);
|
|
114
|
-
export const Trash2 = makeIcon(Trash);
|
|
115
47
|
export const Type = makeIcon(TextT);
|
|
116
|
-
export const Upload = makeIcon(UploadSimple);
|
|
117
|
-
export const User = makeIcon(PhUser);
|
|
118
|
-
export const Users = makeIcon(UsersThree);
|
|
119
|
-
export const Video = makeIcon(VideoCamera);
|
|
120
48
|
export const X = makeIcon(PhX);
|
|
121
49
|
export const Zap = makeIcon(Lightning);
|
|
122
|
-
export const ZoomIn = makeIcon(MagnifyingGlassPlus);
|
|
123
|
-
export const ZoomOut = makeIcon(MagnifyingGlassMinus);
|
|
124
50
|
// Extra icons used in this project (not in lucide's default mapping above)
|
|
125
|
-
export const Terminal = makeIcon(PhTerminal);
|
|
126
51
|
export const ChevronDown = makeIcon(CaretDown);
|
|
127
52
|
export const ChevronRight = makeIcon(CaretRight);
|
|
128
53
|
export const ClipboardList = makeIcon(ClipboardText);
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
buildPromptCopyText,
|
|
5
5
|
buildTimelineElementAgentPrompt,
|
|
6
6
|
buildTimelineAgentPrompt,
|
|
7
|
-
buildTrackZIndexMap,
|
|
8
7
|
canOffsetTrimClipStart,
|
|
9
8
|
getTimelineEditCapabilities,
|
|
10
9
|
hasPatchableTimelineTarget,
|
|
@@ -159,29 +158,6 @@ describe("resolveTimelineMove", () => {
|
|
|
159
158
|
});
|
|
160
159
|
});
|
|
161
160
|
|
|
162
|
-
describe("buildTrackZIndexMap", () => {
|
|
163
|
-
it("maps visually higher tracks onto higher z-index values", () => {
|
|
164
|
-
expect(buildTrackZIndexMap([-2, -1, 0, 3])).toEqual(
|
|
165
|
-
new Map([
|
|
166
|
-
[-2, 4],
|
|
167
|
-
[-1, 3],
|
|
168
|
-
[0, 2],
|
|
169
|
-
[3, 1],
|
|
170
|
-
]),
|
|
171
|
-
);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it("deduplicates tracks before assigning z-index values", () => {
|
|
175
|
-
expect(buildTrackZIndexMap([-1, 0, -1, 3, 3])).toEqual(
|
|
176
|
-
new Map([
|
|
177
|
-
[-1, 3],
|
|
178
|
-
[0, 2],
|
|
179
|
-
[3, 1],
|
|
180
|
-
]),
|
|
181
|
-
);
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
|
|
185
161
|
describe("canOffsetTrimClipStart", () => {
|
|
186
162
|
it("allows front trim for clips that carry playback offset metadata", () => {
|
|
187
163
|
expect(
|
|
@@ -114,12 +114,6 @@ export function resolveTimelineMove(
|
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
export function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
|
|
118
|
-
const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
|
|
119
|
-
const maxZIndex = uniqueTracks.length;
|
|
120
|
-
return new Map(uniqueTracks.map((track, index) => [track, maxZIndex - index]));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
117
|
export function resolveTimelineResize(
|
|
124
118
|
input: TimelineResizeInput,
|
|
125
119
|
edge: "start" | "end",
|
|
@@ -144,19 +144,3 @@ export function useTimelineRangeSelection({
|
|
|
144
144
|
handlePointerUp,
|
|
145
145
|
};
|
|
146
146
|
}
|
|
147
|
-
|
|
148
|
-
/* ── Seek + scroll utilities (used in Timeline only) ──────────────── */
|
|
149
|
-
export function seekTimeFromScrollX(
|
|
150
|
-
scrollEl: HTMLDivElement,
|
|
151
|
-
clientX: number,
|
|
152
|
-
effectiveDuration: number,
|
|
153
|
-
pps: number,
|
|
154
|
-
onSeek?: (time: number) => void,
|
|
155
|
-
): void {
|
|
156
|
-
const rect = scrollEl.getBoundingClientRect();
|
|
157
|
-
const x = clientX - rect.left + scrollEl.scrollLeft - GUTTER;
|
|
158
|
-
if (x < 0) return;
|
|
159
|
-
const time = Math.max(0, Math.min(effectiveDuration, x / pps));
|
|
160
|
-
liveTime.notify(time);
|
|
161
|
-
onSeek?.(time);
|
|
162
|
-
}
|
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
findTimelineDomNodeForClip,
|
|
19
19
|
createImplicitTimelineLayersFromDOM,
|
|
20
20
|
buildStandaloneRootTimelineElement,
|
|
21
|
-
mergeTimelineElementsPreservingDowngrades,
|
|
22
21
|
getTimelineElementSelector,
|
|
23
22
|
} from "../lib/timelineDOM";
|
|
24
23
|
import {
|
|
@@ -26,7 +25,6 @@ import {
|
|
|
26
25
|
autoHealMissingCompositionIds,
|
|
27
26
|
buildMissingCompositionElements,
|
|
28
27
|
} from "../lib/timelineIframeHelpers";
|
|
29
|
-
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
|
|
30
28
|
|
|
31
29
|
interface UseTimelineSyncCallbacksParams {
|
|
32
30
|
iframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
@@ -288,7 +286,3 @@ export function useTimelineSyncCallbacks({
|
|
|
288
286
|
onIframeLoad,
|
|
289
287
|
};
|
|
290
288
|
}
|
|
291
|
-
|
|
292
|
-
// Re-export the merge helper so the hook can use it via this module (avoids
|
|
293
|
-
// adding another import line to the already-large useTimelinePlayer.ts).
|
|
294
|
-
export { mergeTimelineElementsPreservingDowngrades, getTimelineElementIdentity };
|
|
@@ -5,10 +5,7 @@ import {
|
|
|
5
5
|
resolveTimelineAssetInitialGeometry,
|
|
6
6
|
} from "./timelineAssetDrop";
|
|
7
7
|
import { collectHtmlIds } from "./studioHelpers";
|
|
8
|
-
import {
|
|
9
|
-
buildTrackZIndexMap,
|
|
10
|
-
formatTimelineAttributeNumber,
|
|
11
|
-
} from "../player/components/timelineEditing";
|
|
8
|
+
import { formatTimelineAttributeNumber } from "../player/components/timelineEditing";
|
|
12
9
|
import { saveProjectFilesWithHistory } from "./studioFileHistory";
|
|
13
10
|
import type { EditHistoryKind } from "./editHistory";
|
|
14
11
|
|
|
@@ -127,8 +124,7 @@ export async function addBlockToProject(
|
|
|
127
124
|
? Math.max(...relevantElements.map((te) => te.track)) + 1
|
|
128
125
|
: 1);
|
|
129
126
|
|
|
130
|
-
const
|
|
131
|
-
const zIndex = trackZIndices.get(track) ?? 1;
|
|
127
|
+
const zIndex = Math.max(1, relevantElements.length + 1);
|
|
132
128
|
|
|
133
129
|
const width = isBlock
|
|
134
130
|
? (block as { dimensions: { width: number } }).dimensions.width
|
|
@@ -35,6 +35,28 @@ describe("applyPatchByTarget", () => {
|
|
|
35
35
|
);
|
|
36
36
|
});
|
|
37
37
|
|
|
38
|
+
it("adds inline style to a self-closing void element without malforming it", () => {
|
|
39
|
+
const html = `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" />`;
|
|
40
|
+
const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
|
|
41
|
+
|
|
42
|
+
const result = applyPatch(html, "gif-img", op);
|
|
43
|
+
expect(result).toBe(
|
|
44
|
+
`<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" style="z-index: 3" />`,
|
|
45
|
+
);
|
|
46
|
+
expect(result).not.toContain("/ style");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("adds inline style to a self-closing void element matched by selector", () => {
|
|
50
|
+
const html = `<img class="clip hero" data-start="0" src="bg.png" alt="" />`;
|
|
51
|
+
const op: PatchOperation = { type: "inline-style", property: "opacity", value: "0.5" };
|
|
52
|
+
|
|
53
|
+
const result = applyPatchByTarget(html, { selector: ".hero" }, op);
|
|
54
|
+
expect(result).toBe(
|
|
55
|
+
`<img class="clip hero" data-start="0" src="bg.png" alt="" style="opacity: 0.5" />`,
|
|
56
|
+
);
|
|
57
|
+
expect(result).not.toContain("/ style");
|
|
58
|
+
});
|
|
59
|
+
|
|
38
60
|
it("patches inline move styles by target", () => {
|
|
39
61
|
const html = `<div id="card" style="position: absolute; left: 108px; top: 112px"></div>`;
|
|
40
62
|
|
|
@@ -203,9 +203,9 @@ function patchInlineStyleInTag(
|
|
|
203
203
|
} else {
|
|
204
204
|
// No existing style attribute
|
|
205
205
|
if (value === null) return html; // nothing to remove
|
|
206
|
-
|
|
207
|
-
const
|
|
208
|
-
|
|
206
|
+
const selfClosing = /\s*\/$/.test(tag);
|
|
207
|
+
const base = selfClosing ? tag.replace(/\s*\/$/, "") : tag;
|
|
208
|
+
const newTag = `${base} style="${prop}: ${escapeStyleAttributeValue(value, '"')}"${selfClosing ? " /" : ""}`;
|
|
209
209
|
return html.replace(tag, newTag);
|
|
210
210
|
}
|
|
211
211
|
}
|
|
@@ -23,13 +23,7 @@ export function getTimelineElementLabel(element: TimelineElement): string {
|
|
|
23
23
|
return element.label || element.id || element.tag;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
return window.confirm(
|
|
28
|
-
`Delete ${kind} "${label}"?\n\nThis removes it from the project source. You can use Undo to restore it.`,
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function normalizeProjectAssetPath(value: string): string {
|
|
26
|
+
function normalizeProjectAssetPath(value: string): string {
|
|
33
27
|
const trimmed = value.trim();
|
|
34
28
|
const maybeUrl = /^[a-z]+:\/\//i.test(trimmed) ? new URL(trimmed).pathname : trimmed;
|
|
35
29
|
return decodeURIComponent(maybeUrl)
|
|
@@ -51,7 +45,7 @@ export function toRelativeProjectAssetPath(sourceFile: string, assetPath: string
|
|
|
51
45
|
return [...fromParts.map(() => ".."), ...targetParts].join("/") || assetPath;
|
|
52
46
|
}
|
|
53
47
|
|
|
54
|
-
|
|
48
|
+
function isAbsoluteFilePath(value: string): boolean {
|
|
55
49
|
return /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
56
50
|
}
|
|
57
51
|
|
|
@@ -181,7 +175,7 @@ export function collectHtmlIds(source: string): string[] {
|
|
|
181
175
|
return Array.from(source.matchAll(/\bid="([^"]+)"/g), (match) => match[1] ?? "");
|
|
182
176
|
}
|
|
183
177
|
|
|
184
|
-
|
|
178
|
+
const DEFAULT_TIMELINE_ASSET_DURATION: Record<TimelineAssetKind, number> = {
|
|
185
179
|
image: 3,
|
|
186
180
|
video: 5,
|
|
187
181
|
audio: 5,
|
|
@@ -1,24 +1,18 @@
|
|
|
1
|
-
import type { DomEditViewport
|
|
1
|
+
import type { DomEditViewport } from "../components/editor/domEditing";
|
|
2
2
|
import { resolveVisualDomEditSelectionTarget } from "../components/editor/domEditing";
|
|
3
3
|
import {
|
|
4
4
|
getDomLayerPatchTarget,
|
|
5
5
|
isElementComputedVisible,
|
|
6
6
|
} from "../components/editor/domEditingElement";
|
|
7
|
-
import { usePlayerStore, liveTime } from "../player";
|
|
8
7
|
import { getEventTargetElement } from "./studioHelpers";
|
|
9
8
|
|
|
10
|
-
|
|
9
|
+
interface PreviewLocalPointer {
|
|
11
10
|
x: number;
|
|
12
11
|
y: number;
|
|
13
12
|
viewport: DomEditViewport;
|
|
14
13
|
}
|
|
15
14
|
|
|
16
|
-
|
|
17
|
-
getTime: () => number;
|
|
18
|
-
renderSeek: (timeSeconds: number) => void;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function resolvePreviewLocalPointer(
|
|
15
|
+
function resolvePreviewLocalPointer(
|
|
22
16
|
iframe: HTMLIFrameElement,
|
|
23
17
|
doc: Document,
|
|
24
18
|
win: Window,
|
|
@@ -42,24 +36,6 @@ export function resolvePreviewLocalPointer(
|
|
|
42
36
|
};
|
|
43
37
|
}
|
|
44
38
|
|
|
45
|
-
export function getPreviewLocalPointer(
|
|
46
|
-
iframe: HTMLIFrameElement,
|
|
47
|
-
clientX: number,
|
|
48
|
-
clientY: number,
|
|
49
|
-
): PreviewLocalPointer | null {
|
|
50
|
-
let doc: Document | null = null;
|
|
51
|
-
let win: Window | null = null;
|
|
52
|
-
try {
|
|
53
|
-
doc = iframe.contentDocument;
|
|
54
|
-
win = iframe.contentWindow;
|
|
55
|
-
} catch {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
if (!doc || !win) return null;
|
|
59
|
-
|
|
60
|
-
return resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
39
|
const POINTER_EVENTS_OVERRIDE_ID = "__hf_studio_pointer_events_override__";
|
|
64
40
|
|
|
65
41
|
function forcePointerEventsAuto(doc: Document): HTMLStyleElement | null {
|
|
@@ -122,21 +98,6 @@ export function getPreviewTargetFromPointer(
|
|
|
122
98
|
}
|
|
123
99
|
}
|
|
124
100
|
|
|
125
|
-
export function buildRasterClickSelectionContext(
|
|
126
|
-
selection: DomEditSelection,
|
|
127
|
-
localPointer: PreviewLocalPointer,
|
|
128
|
-
): string {
|
|
129
|
-
return [
|
|
130
|
-
"The user clicked a large raster/background element in the Studio preview.",
|
|
131
|
-
`Preview click: x=${Math.round(localPointer.x)}px, y=${Math.round(localPointer.y)}px in a ${Math.round(
|
|
132
|
-
localPointer.viewport.width,
|
|
133
|
-
)}x${Math.round(localPointer.viewport.height)} composition.`,
|
|
134
|
-
`Selected target: <${selection.tagName}> ${selection.selector ?? selection.id ?? selection.label}.`,
|
|
135
|
-
"Visible copy or artwork at that point may be baked into the selected image/background rather than a selectable DOM text layer.",
|
|
136
|
-
"If the request mentions text seen at the click location, inspect or replace the image asset, or recreate that visible copy as editable DOM.",
|
|
137
|
-
].join("\n");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
101
|
function objectLike(value: unknown): object | null {
|
|
141
102
|
return value && (typeof value === "object" || typeof value === "function") ? value : null;
|
|
142
103
|
}
|
|
@@ -162,33 +123,6 @@ function readPlaybackTime(target: object | null, key: string): number | null {
|
|
|
162
123
|
}
|
|
163
124
|
}
|
|
164
125
|
|
|
165
|
-
export function getPreviewPlayer(win: Window | null | undefined): PreviewPlayerCompat | null {
|
|
166
|
-
const player = objectLike(win ? Reflect.get(win, "__player") : null);
|
|
167
|
-
if (!player) return null;
|
|
168
|
-
const getTime = Reflect.get(player, "getTime");
|
|
169
|
-
const renderSeek = Reflect.get(player, "renderSeek");
|
|
170
|
-
if (typeof getTime !== "function" || typeof renderSeek !== "function") return null;
|
|
171
|
-
return {
|
|
172
|
-
getTime: () => {
|
|
173
|
-
const value = getTime.call(player);
|
|
174
|
-
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
175
|
-
},
|
|
176
|
-
renderSeek: (timeSeconds: number) => {
|
|
177
|
-
renderSeek.call(player, timeSeconds);
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
export function seekStudioPreview(iframe: HTMLIFrameElement | null, timeSeconds: number): boolean {
|
|
183
|
-
const player = getPreviewPlayer(iframe?.contentWindow);
|
|
184
|
-
if (!player) return false;
|
|
185
|
-
const nextTime = Math.max(0, timeSeconds);
|
|
186
|
-
player.renderSeek(nextTime);
|
|
187
|
-
usePlayerStore.getState().setCurrentTime(nextTime);
|
|
188
|
-
liveTime.notify(nextTime);
|
|
189
|
-
return true;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
126
|
export function pauseStudioPreviewPlayback(iframe: HTMLIFrameElement | null): number | null {
|
|
193
127
|
const win = iframe?.contentWindow;
|
|
194
128
|
if (!win) return null;
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
export const TIMELINE_TOGGLE_SHORTCUT_LABEL = "Shift+T";
|
|
2
|
-
const TIMELINE_EDITOR_HINT_STORAGE_KEY = "hf-studio-timeline-editor-hint-dismissed";
|
|
3
|
-
|
|
4
2
|
type TimelineToggleHotkeyEvent = Pick<
|
|
5
3
|
KeyboardEvent,
|
|
6
4
|
"key" | "shiftKey" | "metaKey" | "ctrlKey" | "altKey" | "target"
|
|
@@ -41,17 +39,3 @@ export function shouldHandleTimelineToggleHotkey(event: TimelineToggleHotkeyEven
|
|
|
41
39
|
export function getTimelineToggleTitle(timelineVisible: boolean): string {
|
|
42
40
|
return `${timelineVisible ? "Hide" : "Show"} timeline editor (${TIMELINE_TOGGLE_SHORTCUT_LABEL})`;
|
|
43
41
|
}
|
|
44
|
-
|
|
45
|
-
export function getTimelineEditorHintDismissed(): boolean {
|
|
46
|
-
if (typeof window === "undefined") return false;
|
|
47
|
-
return window.localStorage.getItem(TIMELINE_EDITOR_HINT_STORAGE_KEY) === "1";
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function setTimelineEditorHintDismissed(dismissed: boolean): void {
|
|
51
|
-
if (typeof window === "undefined") return;
|
|
52
|
-
if (dismissed) {
|
|
53
|
-
window.localStorage.setItem(TIMELINE_EDITOR_HINT_STORAGE_KEY, "1");
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
56
|
-
window.localStorage.removeItem(TIMELINE_EDITOR_HINT_STORAGE_KEY);
|
|
57
|
-
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { applyPatchByTarget, applyPatch } from "./sourcePatcher";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Reproduction tests for https://github.com/heygen-com/hyperframes/issues/958
|
|
6
|
+
*
|
|
7
|
+
* The bug: dragging a clip in the Studio timeline rewrites index.html with
|
|
8
|
+
* inline style="z-index: N" on EVERY clip, overriding the author's CSS z-index.
|
|
9
|
+
* Additionally, void elements (img, audio) get malformed self-closing tags.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const ISSUE_HTML = `<style>
|
|
13
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
14
|
+
html, body { margin: 0; width: 1920px; height: 1080px; overflow: hidden; background: #000; }
|
|
15
|
+
#bg-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: 0; }
|
|
16
|
+
#title { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
|
17
|
+
font: 700 120px sans-serif; color: #fff; z-index: 20; }
|
|
18
|
+
</style>
|
|
19
|
+
|
|
20
|
+
<div id="root" data-composition-id="main" data-start="0" data-duration="10"
|
|
21
|
+
data-width="1920" data-height="1080">
|
|
22
|
+
<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>
|
|
23
|
+
<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>
|
|
24
|
+
</div>`;
|
|
25
|
+
|
|
26
|
+
const VOID_ELEMENT_HTML = `<div id="root" data-composition-id="main" data-start="0" data-duration="14"
|
|
27
|
+
data-width="1920" data-height="1080">
|
|
28
|
+
<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>
|
|
29
|
+
<img id="gif-img" class="clip" data-start="1" data-duration="13" data-track-index="2" src="assets/earth.gif" alt="rotating earth gif" />
|
|
30
|
+
<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>
|
|
31
|
+
</div>`;
|
|
32
|
+
|
|
33
|
+
describe("issue #958 — timeline drag must not inject inline z-index", () => {
|
|
34
|
+
it("reproduces the old bug: z-index injection on all clips overrides CSS layering", () => {
|
|
35
|
+
// Simulate the OLD behavior: buildTrackZIndexMap + loop over all clips
|
|
36
|
+
function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
|
|
37
|
+
const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
|
|
38
|
+
const maxZIndex = uniqueTracks.length;
|
|
39
|
+
return new Map(uniqueTracks.map((track, index) => [track, maxZIndex - index]));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const elements = [
|
|
43
|
+
{ id: "bg-video", track: 0 },
|
|
44
|
+
{ id: "title", track: 1 },
|
|
45
|
+
];
|
|
46
|
+
const trackZIndices = buildTrackZIndexMap(elements.map((e) => e.track));
|
|
47
|
+
|
|
48
|
+
// Apply the old z-index injection loop
|
|
49
|
+
let broken = ISSUE_HTML;
|
|
50
|
+
for (const el of elements) {
|
|
51
|
+
const nextZIndex = trackZIndices.get(el.track);
|
|
52
|
+
if (nextZIndex == null) continue;
|
|
53
|
+
broken = applyPatch(broken, el.id, {
|
|
54
|
+
type: "inline-style",
|
|
55
|
+
property: "z-index",
|
|
56
|
+
value: String(nextZIndex),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Verify the bug: bg-video gets z-index: 2, title gets z-index: 1
|
|
61
|
+
// This INVERTS the intended layering (CSS had bg-video: 0, title: 20)
|
|
62
|
+
expect(broken).toContain('id="bg-video"');
|
|
63
|
+
expect(broken).toContain('style="z-index: 2"');
|
|
64
|
+
expect(broken).toContain('id="title"');
|
|
65
|
+
expect(broken).toContain('style="z-index: 1"');
|
|
66
|
+
|
|
67
|
+
// The title (z-index: 1) is now BEHIND the video (z-index: 2)
|
|
68
|
+
// — the opposite of the author's intent (CSS z-index: 20 vs 0)
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("verifies the fix: moving a clip only patches data-start and data-track-index", () => {
|
|
72
|
+
// Simulate the NEW behavior: only patch the moved clip's timing attributes
|
|
73
|
+
const movedElement = { id: "bg-video" };
|
|
74
|
+
const updates = { start: "2.5", track: "0" };
|
|
75
|
+
|
|
76
|
+
let fixed = applyPatchByTarget(
|
|
77
|
+
ISSUE_HTML,
|
|
78
|
+
{ id: movedElement.id },
|
|
79
|
+
{ type: "attribute", property: "start", value: updates.start },
|
|
80
|
+
);
|
|
81
|
+
fixed = applyPatchByTarget(
|
|
82
|
+
fixed,
|
|
83
|
+
{ id: movedElement.id },
|
|
84
|
+
{ type: "attribute", property: "track-index", value: updates.track },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// The moved clip's timing changed
|
|
88
|
+
expect(fixed).toContain('id="bg-video" data-start="2.5" data-track-index="0"');
|
|
89
|
+
|
|
90
|
+
// No inline z-index was injected on ANY element
|
|
91
|
+
expect(fixed).not.toContain('style="z-index');
|
|
92
|
+
|
|
93
|
+
// The title clip is completely untouched
|
|
94
|
+
expect(fixed).toContain(
|
|
95
|
+
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// CSS z-index declarations in <style> are preserved
|
|
99
|
+
expect(fixed).toContain("z-index: 0;");
|
|
100
|
+
expect(fixed).toContain("z-index: 20;");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("verifies the fix: deleting a clip does not inject z-index on remaining clips", () => {
|
|
104
|
+
// After the element removal API call returns the content without bg-video,
|
|
105
|
+
// the old code would loop over remaining clips and inject z-index.
|
|
106
|
+
// The new code just uses the removal result as-is.
|
|
107
|
+
const afterRemoval = ISSUE_HTML.replace(/<video id="bg-video"[^>]*><\/video>\n /, "");
|
|
108
|
+
|
|
109
|
+
// No z-index injection step — the result is used directly
|
|
110
|
+
expect(afterRemoval).not.toContain('style="z-index');
|
|
111
|
+
expect(afterRemoval).toContain(
|
|
112
|
+
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("issue #958 — void element inline style injection", () => {
|
|
118
|
+
it("reproduces the old bug: self-closing tags get malformed when style is injected", () => {
|
|
119
|
+
// The old code did: tag.replace(/>$/, "") + ` style="z-index: 7"`
|
|
120
|
+
// But `tag` from the regex capture never includes `>`, and for self-closing
|
|
121
|
+
// elements it ends with `/`. The replace was a no-op, producing:
|
|
122
|
+
// <img ... / style="z-index: 7">
|
|
123
|
+
const img = `<img id="gif-img" class="clip" data-start="1" src="earth.gif" alt="earth" />`;
|
|
124
|
+
const result = applyPatch(img, "gif-img", {
|
|
125
|
+
type: "inline-style",
|
|
126
|
+
property: "z-index",
|
|
127
|
+
value: "7",
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// With the fix, the style is inserted before the self-closing slash
|
|
131
|
+
expect(result).not.toContain("/ style");
|
|
132
|
+
expect(result).toContain('style="z-index: 7" />');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("verifies inline style on void elements in a full composition", () => {
|
|
136
|
+
// Even though we no longer inject z-index during move/delete,
|
|
137
|
+
// the patchInlineStyleInTag fix is still important for other inline style
|
|
138
|
+
// patches (e.g., position, opacity) that the Studio applies to void elements.
|
|
139
|
+
const result = applyPatch(VOID_ELEMENT_HTML, "gif-img", {
|
|
140
|
+
type: "inline-style",
|
|
141
|
+
property: "opacity",
|
|
142
|
+
value: "0.8",
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
expect(result).toContain('style="opacity: 0.8" />');
|
|
146
|
+
expect(result).not.toContain("/ style");
|
|
147
|
+
// Other elements untouched
|
|
148
|
+
expect(result).toContain(
|
|
149
|
+
'<video id="bg-video" data-start="0" data-track-index="0" src="some-bg.mp4" muted playsinline></video>',
|
|
150
|
+
);
|
|
151
|
+
expect(result).toContain(
|
|
152
|
+
'<div id="title" class="clip" data-start="0" data-duration="10" data-track-index="1">TITLE</div>',
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|