@hyperframes/studio 0.5.0-alpha.14 → 0.5.0-alpha.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{hyperframes-player-vibA20NC.js → hyperframes-player-Cd8vYWxP.js} +2 -2
- package/dist/assets/index-DFLVGWTx.js +106 -0
- package/dist/assets/index-mXJ-UH9F.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +785 -377
- package/src/captions/generator.test.ts +19 -0
- package/src/captions/generator.ts +9 -2
- package/src/captions/hooks/useCaptionSync.ts +6 -1
- package/src/captions/parser.test.ts +14 -0
- package/src/captions/parser.ts +1 -0
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +970 -115
- package/src/components/editor/PropertyPanel.tsx +91 -83
- package/src/components/editor/domEditing.test.ts +161 -29
- package/src/components/editor/domEditing.ts +84 -113
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1397 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/renders/RenderQueue.tsx +10 -3
- package/src/hooks/usePersistentEditHistory.test.ts +1 -0
- package/src/hooks/usePersistentEditHistory.ts +3 -2
- package/src/player/components/CompositionThumbnail.test.ts +1 -1
- package/src/player/components/CompositionThumbnail.tsx +1 -1
- package/src/player/components/Player.tsx +54 -9
- package/src/player/hooks/useTimelinePlayer.test.ts +1 -0
- package/src/utils/clipboard.test.ts +1 -0
- package/src/utils/frameCapture.ts +3 -1
- package/src/utils/projectRouting.test.ts +87 -0
- package/src/utils/projectRouting.ts +27 -0
- package/dist/assets/index-JhhmFie-.js +0 -105
- package/dist/assets/index-KioPDrX6.css +0 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { Window } from "happy-dom";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
applyManualOffsetDragMatrix,
|
|
5
|
+
invertManualOffsetDragMatrix,
|
|
6
|
+
measureManualOffsetDragScreenToOffsetMatrix,
|
|
7
|
+
resolveManualOffsetForPointerDelta,
|
|
8
|
+
type ManualOffsetDragMatrix,
|
|
9
|
+
} from "./manualOffsetDrag";
|
|
10
|
+
import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP } from "./manualEdits";
|
|
11
|
+
|
|
12
|
+
function expectMatrixClose(actual: ManualOffsetDragMatrix, expected: ManualOffsetDragMatrix): void {
|
|
13
|
+
expect(actual.a).toBeCloseTo(expected.a, 6);
|
|
14
|
+
expect(actual.b).toBeCloseTo(expected.b, 6);
|
|
15
|
+
expect(actual.c).toBeCloseTo(expected.c, 6);
|
|
16
|
+
expect(actual.d).toBeCloseTo(expected.d, 6);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("manual offset drag matrix helpers", () => {
|
|
20
|
+
it("inverts identity movement", () => {
|
|
21
|
+
const inverse = invertManualOffsetDragMatrix({ a: 1, b: 0, c: 0, d: 1 });
|
|
22
|
+
if (!inverse) throw new Error("identity matrix should be invertible");
|
|
23
|
+
|
|
24
|
+
expectMatrixClose(inverse, { a: 1, b: 0, c: 0, d: 1 });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("maps screen movement through a rotated coordinate system", () => {
|
|
28
|
+
const screenToOffset = invertManualOffsetDragMatrix({ a: 0, b: 1, c: -1, d: 0 });
|
|
29
|
+
if (!screenToOffset) throw new Error("rotation matrix should be invertible");
|
|
30
|
+
|
|
31
|
+
const offsetDelta = applyManualOffsetDragMatrix(screenToOffset, { x: 0, y: 10 });
|
|
32
|
+
|
|
33
|
+
expect(offsetDelta.x).toBeCloseTo(10, 6);
|
|
34
|
+
expect(offsetDelta.y).toBeCloseTo(0, 6);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("rejects singular movement matrices", () => {
|
|
38
|
+
expect(invertManualOffsetDragMatrix({ a: 1, b: 1, c: 2, d: 2 })).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("resolves final offsets from the measured inverse matrix", () => {
|
|
42
|
+
const offsetToScreen = { a: 2, b: 3, c: -1, d: 4 };
|
|
43
|
+
const screenToOffset = invertManualOffsetDragMatrix(offsetToScreen);
|
|
44
|
+
if (!screenToOffset) throw new Error("fixture matrix should be invertible");
|
|
45
|
+
|
|
46
|
+
const nextOffset = resolveManualOffsetForPointerDelta({
|
|
47
|
+
initialOffset: { x: 5, y: -2 },
|
|
48
|
+
screenToOffset,
|
|
49
|
+
dx: 7,
|
|
50
|
+
dy: 11,
|
|
51
|
+
});
|
|
52
|
+
const screenDelta = applyManualOffsetDragMatrix(offsetToScreen, {
|
|
53
|
+
x: nextOffset.x - 5,
|
|
54
|
+
y: nextOffset.y + 2,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(screenDelta.x).toBeCloseTo(7, 6);
|
|
58
|
+
expect(screenDelta.y).toBeCloseTo(11, 6);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("measureManualOffsetDragScreenToOffsetMatrix", () => {
|
|
63
|
+
it("measures the element center response and restores probe styles", () => {
|
|
64
|
+
const window = new Window();
|
|
65
|
+
const element = window.document.createElement("div");
|
|
66
|
+
window.document.body.append(element);
|
|
67
|
+
|
|
68
|
+
element.getBoundingClientRect = () => {
|
|
69
|
+
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
|
|
70
|
+
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
|
|
71
|
+
return new window.DOMRect(10 + 2 * offsetX - offsetY, 20 + 3 * offsetX + 4 * offsetY, 12, 8);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
|
|
75
|
+
if (!measured.ok) throw new Error(measured.reason);
|
|
76
|
+
|
|
77
|
+
const expected = invertManualOffsetDragMatrix({ a: 2, b: 3, c: -1, d: 4 });
|
|
78
|
+
if (!expected) throw new Error("fixture matrix should be invertible");
|
|
79
|
+
|
|
80
|
+
expectMatrixClose(measured.matrix, expected);
|
|
81
|
+
expect(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)).toBe("");
|
|
82
|
+
expect(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)).toBe("");
|
|
83
|
+
expect(element.style.getPropertyValue("translate")).toBe("");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("measures movement in parent viewport pixels when the element is inside a scaled iframe", () => {
|
|
87
|
+
const window = new Window();
|
|
88
|
+
const iframe = window.document.createElement("iframe");
|
|
89
|
+
window.document.body.append(iframe);
|
|
90
|
+
const iframeWindow = iframe.contentWindow;
|
|
91
|
+
const iframeDocument = iframe.contentDocument;
|
|
92
|
+
if (!iframeWindow || !iframeDocument) throw new Error("iframe fixture failed to initialize");
|
|
93
|
+
|
|
94
|
+
Object.defineProperty(iframeWindow, "frameElement", {
|
|
95
|
+
configurable: true,
|
|
96
|
+
value: iframe,
|
|
97
|
+
});
|
|
98
|
+
Object.defineProperty(iframeWindow, "innerWidth", {
|
|
99
|
+
configurable: true,
|
|
100
|
+
value: 200,
|
|
101
|
+
});
|
|
102
|
+
Object.defineProperty(iframeWindow, "innerHeight", {
|
|
103
|
+
configurable: true,
|
|
104
|
+
value: 100,
|
|
105
|
+
});
|
|
106
|
+
iframe.getBoundingClientRect = () => new window.DOMRect(50, 40, 100, 50);
|
|
107
|
+
|
|
108
|
+
const element = iframeDocument.createElement("div");
|
|
109
|
+
iframeDocument.body.append(element);
|
|
110
|
+
element.getBoundingClientRect = () => {
|
|
111
|
+
const offsetX = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_X_PROP)) || 0;
|
|
112
|
+
const offsetY = Number.parseFloat(element.style.getPropertyValue(STUDIO_OFFSET_Y_PROP)) || 0;
|
|
113
|
+
return new iframeWindow.DOMRect(20 + offsetX, 30 + offsetY, 40, 20);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
|
|
117
|
+
if (!measured.ok) throw new Error(measured.reason);
|
|
118
|
+
|
|
119
|
+
expectMatrixClose(measured.matrix, { a: 2, b: -0, c: -0, d: 2 });
|
|
120
|
+
|
|
121
|
+
const nextOffset = resolveManualOffsetForPointerDelta({
|
|
122
|
+
initialOffset: { x: 0, y: 0 },
|
|
123
|
+
screenToOffset: measured.matrix,
|
|
124
|
+
dx: 50,
|
|
125
|
+
dy: 25,
|
|
126
|
+
});
|
|
127
|
+
expect(nextOffset).toEqual({ x: 100, y: 50 });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("rejects elements whose movement response cannot be measured", () => {
|
|
131
|
+
const window = new Window();
|
|
132
|
+
const element = window.document.createElement("div");
|
|
133
|
+
window.document.body.append(element);
|
|
134
|
+
element.getBoundingClientRect = () => new window.DOMRect(10, 20, 12, 8);
|
|
135
|
+
|
|
136
|
+
const measured = measureManualOffsetDragScreenToOffsetMatrix(element, { x: 0, y: 0 });
|
|
137
|
+
|
|
138
|
+
expect(measured.ok).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import type { DomEditSelection } from "./domEditing";
|
|
2
|
+
import {
|
|
3
|
+
applyStudioPathOffset,
|
|
4
|
+
applyStudioPathOffsetDraft,
|
|
5
|
+
beginStudioManualEditGesture,
|
|
6
|
+
captureStudioPathOffset,
|
|
7
|
+
endStudioManualEditGesture,
|
|
8
|
+
readStudioPathOffset,
|
|
9
|
+
restoreStudioPathOffset,
|
|
10
|
+
type StudioPathOffsetSnapshot,
|
|
11
|
+
} from "./manualEdits";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_OFFSET_PROBE_PX = 100;
|
|
14
|
+
const MIN_PROBE_VECTOR_LENGTH_PX = 0.01;
|
|
15
|
+
const MIN_MATRIX_DETERMINANT = 0.000001;
|
|
16
|
+
|
|
17
|
+
export interface ManualOffsetDragMatrix {
|
|
18
|
+
a: number;
|
|
19
|
+
b: number;
|
|
20
|
+
c: number;
|
|
21
|
+
d: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ManualOffsetDragRect {
|
|
25
|
+
left: number;
|
|
26
|
+
top: number;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
editScaleX: number;
|
|
30
|
+
editScaleY: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ManualOffsetDragMember {
|
|
34
|
+
key: string;
|
|
35
|
+
selection: DomEditSelection;
|
|
36
|
+
element: HTMLElement;
|
|
37
|
+
initialOffset: { x: number; y: number };
|
|
38
|
+
initialPathOffset: StudioPathOffsetSnapshot;
|
|
39
|
+
gestureToken: string;
|
|
40
|
+
screenToOffset: ManualOffsetDragMatrix;
|
|
41
|
+
originRect: ManualOffsetDragRect;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ManualOffsetDragMemberResult =
|
|
45
|
+
| { ok: true; member: ManualOffsetDragMember }
|
|
46
|
+
| { ok: false; reason: string; selection: DomEditSelection };
|
|
47
|
+
|
|
48
|
+
type Point = { x: number; y: number };
|
|
49
|
+
|
|
50
|
+
function finitePoint(point: Point): boolean {
|
|
51
|
+
return Number.isFinite(point.x) && Number.isFinite(point.y);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function vectorLength(point: Point): number {
|
|
55
|
+
return Math.hypot(point.x, point.y);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function finiteRect(rect: DOMRect): boolean {
|
|
59
|
+
return (
|
|
60
|
+
Number.isFinite(rect.left) &&
|
|
61
|
+
Number.isFinite(rect.top) &&
|
|
62
|
+
Number.isFinite(rect.width) &&
|
|
63
|
+
Number.isFinite(rect.height)
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readViewportSize(win: Window): { width: number; height: number } {
|
|
68
|
+
const docEl = win.document.documentElement;
|
|
69
|
+
const width = win.innerWidth || docEl.clientWidth || 1;
|
|
70
|
+
const height = win.innerHeight || docEl.clientHeight || 1;
|
|
71
|
+
return {
|
|
72
|
+
width: width > 0 ? width : 1,
|
|
73
|
+
height: height > 0 ? height : 1,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getFrameElement(win: Window): HTMLElement | null {
|
|
78
|
+
try {
|
|
79
|
+
const frameElement = win.frameElement;
|
|
80
|
+
if (!frameElement) return null;
|
|
81
|
+
const ownerWin = frameElement.ownerDocument.defaultView;
|
|
82
|
+
const htmlElement = ownerWin?.HTMLElement;
|
|
83
|
+
return htmlElement && frameElement instanceof htmlElement ? frameElement : null;
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getRectCenter(element: HTMLElement): Point | null {
|
|
90
|
+
const rect = element.getBoundingClientRect();
|
|
91
|
+
if (!finiteRect(rect) || (rect.width <= 0 && rect.height <= 0)) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let point = {
|
|
96
|
+
x: rect.left + rect.width / 2,
|
|
97
|
+
y: rect.top + rect.height / 2,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
let win: Window | null = element.ownerDocument.defaultView;
|
|
101
|
+
while (win) {
|
|
102
|
+
const frameElement = getFrameElement(win);
|
|
103
|
+
if (!frameElement) break;
|
|
104
|
+
|
|
105
|
+
const frameRect = frameElement.getBoundingClientRect();
|
|
106
|
+
if (!finiteRect(frameRect) || frameRect.width <= 0 || frameRect.height <= 0) return null;
|
|
107
|
+
|
|
108
|
+
const viewport = readViewportSize(win);
|
|
109
|
+
point = {
|
|
110
|
+
x: frameRect.left + point.x * (frameRect.width / viewport.width),
|
|
111
|
+
y: frameRect.top + point.y * (frameRect.height / viewport.height),
|
|
112
|
+
};
|
|
113
|
+
win = frameElement.ownerDocument.defaultView;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return point;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function invertManualOffsetDragMatrix(
|
|
120
|
+
matrix: ManualOffsetDragMatrix,
|
|
121
|
+
): ManualOffsetDragMatrix | null {
|
|
122
|
+
const determinant = matrix.a * matrix.d - matrix.b * matrix.c;
|
|
123
|
+
if (!Number.isFinite(determinant) || Math.abs(determinant) < MIN_MATRIX_DETERMINANT) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
a: matrix.d / determinant,
|
|
129
|
+
b: -matrix.b / determinant,
|
|
130
|
+
c: -matrix.c / determinant,
|
|
131
|
+
d: matrix.a / determinant,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function applyManualOffsetDragMatrix(matrix: ManualOffsetDragMatrix, point: Point): Point {
|
|
136
|
+
return {
|
|
137
|
+
x: matrix.a * point.x + matrix.c * point.y,
|
|
138
|
+
y: matrix.b * point.x + matrix.d * point.y,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function measureManualOffsetDragScreenToOffsetMatrix(
|
|
143
|
+
element: HTMLElement,
|
|
144
|
+
initialOffset: { x: number; y: number },
|
|
145
|
+
options: { probeSize?: number } = {},
|
|
146
|
+
): { ok: true; matrix: ManualOffsetDragMatrix } | { ok: false; reason: string } {
|
|
147
|
+
const probeSize = options.probeSize ?? DEFAULT_OFFSET_PROBE_PX;
|
|
148
|
+
if (!Number.isFinite(probeSize) || probeSize <= 0) {
|
|
149
|
+
return { ok: false, reason: "Invalid movement probe size." };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const snapshot = captureStudioPathOffset(element);
|
|
153
|
+
try {
|
|
154
|
+
applyStudioPathOffsetDraft(element, initialOffset);
|
|
155
|
+
const origin = getRectCenter(element);
|
|
156
|
+
if (!origin) {
|
|
157
|
+
return { ok: false, reason: "Element has no measurable box." };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
applyStudioPathOffsetDraft(element, {
|
|
161
|
+
x: initialOffset.x + probeSize,
|
|
162
|
+
y: initialOffset.y,
|
|
163
|
+
});
|
|
164
|
+
const probeX = getRectCenter(element);
|
|
165
|
+
if (!probeX) {
|
|
166
|
+
return { ok: false, reason: "Element X movement could not be measured." };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
applyStudioPathOffsetDraft(element, {
|
|
170
|
+
x: initialOffset.x,
|
|
171
|
+
y: initialOffset.y + probeSize,
|
|
172
|
+
});
|
|
173
|
+
const probeY = getRectCenter(element);
|
|
174
|
+
if (!probeY) {
|
|
175
|
+
return { ok: false, reason: "Element Y movement could not be measured." };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const xColumn = {
|
|
179
|
+
x: (probeX.x - origin.x) / probeSize,
|
|
180
|
+
y: (probeX.y - origin.y) / probeSize,
|
|
181
|
+
};
|
|
182
|
+
const yColumn = {
|
|
183
|
+
x: (probeY.x - origin.x) / probeSize,
|
|
184
|
+
y: (probeY.y - origin.y) / probeSize,
|
|
185
|
+
};
|
|
186
|
+
if (
|
|
187
|
+
!finitePoint(xColumn) ||
|
|
188
|
+
!finitePoint(yColumn) ||
|
|
189
|
+
vectorLength(xColumn) < MIN_PROBE_VECTOR_LENGTH_PX ||
|
|
190
|
+
vectorLength(yColumn) < MIN_PROBE_VECTOR_LENGTH_PX
|
|
191
|
+
) {
|
|
192
|
+
return { ok: false, reason: "Element movement response is too small to measure." };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const offsetToScreen = {
|
|
196
|
+
a: xColumn.x,
|
|
197
|
+
b: xColumn.y,
|
|
198
|
+
c: yColumn.x,
|
|
199
|
+
d: yColumn.y,
|
|
200
|
+
};
|
|
201
|
+
const screenToOffset = invertManualOffsetDragMatrix(offsetToScreen);
|
|
202
|
+
if (!screenToOffset) {
|
|
203
|
+
return { ok: false, reason: "Element movement response is not invertible." };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { ok: true, matrix: screenToOffset };
|
|
207
|
+
} finally {
|
|
208
|
+
restoreStudioPathOffset(element, snapshot);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function resolveManualOffsetForPointerDelta(input: {
|
|
213
|
+
initialOffset: { x: number; y: number };
|
|
214
|
+
screenToOffset: ManualOffsetDragMatrix;
|
|
215
|
+
dx: number;
|
|
216
|
+
dy: number;
|
|
217
|
+
}): { x: number; y: number } {
|
|
218
|
+
const offsetDelta = applyManualOffsetDragMatrix(input.screenToOffset, {
|
|
219
|
+
x: input.dx,
|
|
220
|
+
y: input.dy,
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
x: input.initialOffset.x + offsetDelta.x,
|
|
224
|
+
y: input.initialOffset.y + offsetDelta.y,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function createManualOffsetDragMember(input: {
|
|
229
|
+
key: string;
|
|
230
|
+
selection: DomEditSelection;
|
|
231
|
+
element: HTMLElement;
|
|
232
|
+
rect: ManualOffsetDragRect;
|
|
233
|
+
}): ManualOffsetDragMemberResult {
|
|
234
|
+
const initialOffset = readStudioPathOffset(input.element);
|
|
235
|
+
const initialPathOffset = captureStudioPathOffset(input.element);
|
|
236
|
+
const gestureToken = beginStudioManualEditGesture(input.element);
|
|
237
|
+
const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset);
|
|
238
|
+
if (!measured.ok) {
|
|
239
|
+
restoreStudioPathOffset(input.element, initialPathOffset);
|
|
240
|
+
endStudioManualEditGesture(input.element, gestureToken);
|
|
241
|
+
return { ok: false, reason: measured.reason, selection: input.selection };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
ok: true,
|
|
246
|
+
member: {
|
|
247
|
+
key: input.key,
|
|
248
|
+
selection: input.selection,
|
|
249
|
+
element: input.element,
|
|
250
|
+
initialOffset,
|
|
251
|
+
initialPathOffset,
|
|
252
|
+
gestureToken,
|
|
253
|
+
screenToOffset: measured.matrix,
|
|
254
|
+
originRect: input.rect,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function resolveManualOffsetDragMemberOffset(
|
|
260
|
+
member: ManualOffsetDragMember,
|
|
261
|
+
dx: number,
|
|
262
|
+
dy: number,
|
|
263
|
+
): { x: number; y: number } {
|
|
264
|
+
return resolveManualOffsetForPointerDelta({
|
|
265
|
+
initialOffset: member.initialOffset,
|
|
266
|
+
screenToOffset: member.screenToOffset,
|
|
267
|
+
dx,
|
|
268
|
+
dy,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function applyManualOffsetDragDraft(
|
|
273
|
+
member: ManualOffsetDragMember,
|
|
274
|
+
dx: number,
|
|
275
|
+
dy: number,
|
|
276
|
+
): { x: number; y: number } {
|
|
277
|
+
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
|
|
278
|
+
applyStudioPathOffsetDraft(member.element, offset);
|
|
279
|
+
return offset;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function applyManualOffsetDragCommit(
|
|
283
|
+
member: ManualOffsetDragMember,
|
|
284
|
+
dx: number,
|
|
285
|
+
dy: number,
|
|
286
|
+
): { x: number; y: number } {
|
|
287
|
+
const offset = resolveManualOffsetDragMemberOffset(member, dx, dy);
|
|
288
|
+
applyStudioPathOffset(member.element, offset);
|
|
289
|
+
return offset;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void {
|
|
293
|
+
restoreStudioPathOffset(member.element, member.initialPathOffset);
|
|
294
|
+
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
298
|
+
for (const member of members) {
|
|
299
|
+
restoreManualOffsetDragMember(member);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void {
|
|
304
|
+
for (const member of members) {
|
|
305
|
+
endStudioManualEditGesture(member.element, member.gestureToken);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -2,12 +2,17 @@ import { memo, useState, useRef, useEffect } from "react";
|
|
|
2
2
|
import { RenderQueueItem } from "./RenderQueueItem";
|
|
3
3
|
import type { RenderJob } from "./useRenderQueue";
|
|
4
4
|
|
|
5
|
+
type StartRenderHandler = (
|
|
6
|
+
format: "mp4" | "webm" | "mov",
|
|
7
|
+
quality: "draft" | "standard" | "high",
|
|
8
|
+
) => void | Promise<void>;
|
|
9
|
+
|
|
5
10
|
interface RenderQueueProps {
|
|
6
11
|
jobs: RenderJob[];
|
|
7
12
|
projectId: string;
|
|
8
13
|
onDelete: (jobId: string) => void;
|
|
9
14
|
onClearCompleted: () => void;
|
|
10
|
-
onStartRender:
|
|
15
|
+
onStartRender: StartRenderHandler;
|
|
11
16
|
isRendering: boolean;
|
|
12
17
|
}
|
|
13
18
|
|
|
@@ -91,7 +96,7 @@ function FormatExportButton({
|
|
|
91
96
|
onStartRender,
|
|
92
97
|
isRendering,
|
|
93
98
|
}: {
|
|
94
|
-
onStartRender:
|
|
99
|
+
onStartRender: StartRenderHandler;
|
|
95
100
|
isRendering: boolean;
|
|
96
101
|
}) {
|
|
97
102
|
const [format, setFormat] = useState<"mp4" | "webm" | "mov">("mp4");
|
|
@@ -129,7 +134,9 @@ function FormatExportButton({
|
|
|
129
134
|
<option value="webm">WebM</option>
|
|
130
135
|
</select>
|
|
131
136
|
<button
|
|
132
|
-
onClick={() =>
|
|
137
|
+
onClick={() => {
|
|
138
|
+
void onStartRender(format, quality);
|
|
139
|
+
}}
|
|
133
140
|
disabled={isRendering}
|
|
134
141
|
className="flex items-center gap-1 px-2 py-0.5 text-[10px] font-semibold rounded-r bg-studio-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
|
|
135
142
|
>
|
|
@@ -60,6 +60,7 @@ describe("createPersistentEditHistoryController", () => {
|
|
|
60
60
|
},
|
|
61
61
|
});
|
|
62
62
|
expect(result.ok).toBe(true);
|
|
63
|
+
expect(result.paths).toEqual(["index.html"]);
|
|
63
64
|
|
|
64
65
|
expect(controller.snapshot().canUndo).toBe(false);
|
|
65
66
|
expect(controller.snapshot().canRedo).toBe(true);
|
|
@@ -39,6 +39,7 @@ interface ApplyResult {
|
|
|
39
39
|
ok: boolean;
|
|
40
40
|
reason?: "empty" | "content-mismatch";
|
|
41
41
|
label?: string;
|
|
42
|
+
paths?: string[];
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
interface PersistentEditHistoryStoreOptions {
|
|
@@ -196,7 +197,7 @@ export function createPersistentEditHistoryStore({
|
|
|
196
197
|
});
|
|
197
198
|
return {
|
|
198
199
|
state: result.state,
|
|
199
|
-
result: { ok: true, label: result.entry.label },
|
|
200
|
+
result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
|
|
200
201
|
};
|
|
201
202
|
});
|
|
202
203
|
},
|
|
@@ -227,7 +228,7 @@ export function createPersistentEditHistoryStore({
|
|
|
227
228
|
});
|
|
228
229
|
return {
|
|
229
230
|
state: result.state,
|
|
230
|
-
result: { ok: true, label: result.entry.label },
|
|
231
|
+
result: { ok: true, label: result.entry.label, paths: Object.keys(result.entry.files) },
|
|
231
232
|
};
|
|
232
233
|
});
|
|
233
234
|
},
|
|
@@ -13,7 +13,7 @@ describe("buildCompositionThumbnailUrl", () => {
|
|
|
13
13
|
origin: "http://localhost:3000",
|
|
14
14
|
}),
|
|
15
15
|
).toBe(
|
|
16
|
-
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=
|
|
16
|
+
"http://localhost:3000/api/projects/demo/thumbnail/index.html?t=2.00&v=v3&selector=.card&selectorIndex=2",
|
|
17
17
|
);
|
|
18
18
|
});
|
|
19
19
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { forwardRef, useRef, useState } from "react";
|
|
2
|
+
import { isLottieAnimationLoaded } from "@hyperframes/core/runtime/lottie-readiness";
|
|
2
3
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
|
4
|
+
// NOTE: importing "@hyperframes/player" registers a class extending HTMLElement
|
|
5
|
+
// at module load, which throws under SSR. Defer the import to the mount effect
|
|
6
|
+
// so it only runs in the browser.
|
|
3
7
|
|
|
4
8
|
interface PlayerProps {
|
|
5
9
|
projectId?: string;
|
|
@@ -24,14 +28,14 @@ function enableInteractiveIframe(player: HyperframesPlayerElement): void {
|
|
|
24
28
|
iframe?.style.setProperty("pointer-events", "auto");
|
|
25
29
|
}
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
// Assets are considered ready when every `<video>`/`<audio>` has enough data
|
|
32
|
+
// to play through without buffering, and every registered Lottie animation has
|
|
33
|
+
// finished loading.
|
|
34
|
+
//
|
|
35
|
+
// Returns whichever value was returned last on cross-origin / transient DOM
|
|
36
|
+
// races so a brief access failure (e.g. an iframe that just swapped src)
|
|
37
|
+
// doesn't flicker the overlay state — we keep showing whatever was most
|
|
38
|
+
// recently true.
|
|
35
39
|
function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): boolean {
|
|
36
40
|
try {
|
|
37
41
|
const win = iframe.contentWindow as unknown as (Window & { __hfLottie?: unknown[] }) | null;
|
|
@@ -47,7 +51,7 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
|
|
47
51
|
const lotties = win.__hfLottie;
|
|
48
52
|
if (lotties?.length) {
|
|
49
53
|
for (const anim of lotties) {
|
|
50
|
-
if (!
|
|
54
|
+
if (!isLottieAnimationLoaded(anim)) return true;
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
|
|
@@ -57,9 +61,18 @@ function hasUnloadedAssets(iframe: HTMLIFrameElement, lastResult: boolean): bool
|
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Renders a composition preview using the <hyperframes-player> web component.
|
|
66
|
+
*
|
|
67
|
+
* The web component handles iframe scaling, dimension detection, and
|
|
68
|
+
* ResizeObserver internally. This wrapper bridges its inner iframe to the
|
|
69
|
+
* forwarded ref so useTimelinePlayer can access it for clip manifest parsing,
|
|
70
|
+
* timeline probing, and DOM inspection.
|
|
71
|
+
*/
|
|
60
72
|
export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
61
73
|
({ projectId, directUrl, onLoad, portrait, style }, ref) => {
|
|
62
74
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const loadCountRef = useRef(0);
|
|
63
76
|
const assetPollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
64
77
|
const [assetsLoading, setAssetsLoading] = useState(false);
|
|
65
78
|
|
|
@@ -70,9 +83,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
70
83
|
let canceled = false;
|
|
71
84
|
let cleanup: (() => void) | undefined;
|
|
72
85
|
|
|
86
|
+
// Dynamic import registers the custom element in the browser only.
|
|
73
87
|
import("@hyperframes/player").then(() => {
|
|
74
88
|
if (canceled) return;
|
|
75
89
|
|
|
90
|
+
// Create the web component imperatively to avoid JSX custom-element typing.
|
|
76
91
|
const player = document.createElement("hyperframes-player") as HyperframesPlayerElement;
|
|
77
92
|
const src = directUrl || `/api/projects/${projectId}/preview`;
|
|
78
93
|
player.setAttribute("src", src);
|
|
@@ -84,6 +99,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
84
99
|
container.appendChild(player);
|
|
85
100
|
enableInteractiveIframe(player);
|
|
86
101
|
|
|
102
|
+
// Bridge the inner iframe to the forwarded ref for useTimelinePlayer.
|
|
87
103
|
const iframe = player.iframeElement;
|
|
88
104
|
if (typeof ref === "function") {
|
|
89
105
|
ref(iframe);
|
|
@@ -91,12 +107,35 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
91
107
|
(ref as React.MutableRefObject<HTMLIFrameElement | null>).current = iframe;
|
|
92
108
|
}
|
|
93
109
|
|
|
110
|
+
// Prevent the web component's built-in click-to-toggle behavior.
|
|
111
|
+
// The studio manages playback exclusively via useTimelinePlayer.
|
|
94
112
|
const preventToggle = (e: Event) => e.stopImmediatePropagation();
|
|
95
113
|
player.addEventListener("click", preventToggle, { capture: true });
|
|
96
114
|
|
|
115
|
+
// Forward the iframe's native load event to the studio's onIframeLoad.
|
|
97
116
|
const handleLoad = () => {
|
|
117
|
+
loadCountRef.current++;
|
|
118
|
+
// Reveal animation on reload (hot-reload, composition switch)
|
|
119
|
+
if (loadCountRef.current > 1) {
|
|
120
|
+
container.classList.remove("preview-revealing");
|
|
121
|
+
void container.offsetWidth;
|
|
122
|
+
container.classList.add("preview-revealing");
|
|
123
|
+
const onEnd = () => container.classList.remove("preview-revealing");
|
|
124
|
+
container.addEventListener("animationend", onEnd, { once: true });
|
|
125
|
+
}
|
|
98
126
|
onLoad();
|
|
99
127
|
|
|
128
|
+
// Show a loading overlay until every `<video>`/`<audio>` and Lottie
|
|
129
|
+
// asset is ready. Without this users can click play before audio has
|
|
130
|
+
// buffered — the runtime is resilient (queued play() resolves once
|
|
131
|
+
// data arrives), but the overlay communicates why the first frame
|
|
132
|
+
// or first audio beat may lag.
|
|
133
|
+
//
|
|
134
|
+
// Poll with a 10 s safety cap (100 ticks × 100 ms). If the cap
|
|
135
|
+
// trips we hide the overlay so the UI doesn't appear stuck forever,
|
|
136
|
+
// but we log a debug warning so the case is diagnosable — a long
|
|
137
|
+
// cold video or a broken asset can legitimately exceed 10 s on a
|
|
138
|
+
// slow network.
|
|
100
139
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
101
140
|
let lastUnloaded = hasUnloadedAssets(iframe, false);
|
|
102
141
|
if (lastUnloaded) {
|
|
@@ -109,6 +148,11 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
109
148
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
110
149
|
assetPollRef.current = null;
|
|
111
150
|
setAssetsLoading(false);
|
|
151
|
+
if (lastUnloaded) {
|
|
152
|
+
console.debug(
|
|
153
|
+
"[Player] Asset-loading overlay timed out after 10s; hiding anyway. Check network or asset integrity.",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
112
156
|
}
|
|
113
157
|
}, 100);
|
|
114
158
|
} else {
|
|
@@ -123,6 +167,7 @@ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(
|
|
|
123
167
|
if (assetPollRef.current) clearInterval(assetPollRef.current);
|
|
124
168
|
assetPollRef.current = null;
|
|
125
169
|
container.removeChild(player);
|
|
170
|
+
// Clear the forwarded ref
|
|
126
171
|
if (typeof ref === "function") {
|
|
127
172
|
ref(null);
|
|
128
173
|
} else if (ref) {
|
|
@@ -4,6 +4,7 @@ import { copyTextToClipboard } from "./clipboard";
|
|
|
4
4
|
|
|
5
5
|
function installDocument(execCommand: (command: string) => boolean): void {
|
|
6
6
|
const window = new Window();
|
|
7
|
+
Object.assign(window, { SyntaxError });
|
|
7
8
|
Object.defineProperty(window.document, "execCommand", {
|
|
8
9
|
configurable: true,
|
|
9
10
|
value: execCommand,
|